4d45bc6c56
Single-iter milestone closing Gitea #1. Architect surfaced 4 honesty-rule drift items (2 high, 2 medium) — all post-Task-13 documentation drift, no codegen behaviour shift. Bench all three scripts exit 0. Inline tidy applied under the CLAUDE.md trivial- mechanical carve-out (4 documentation/comment edits on architect- pre-named sites; no code surface change). No separate tidy iter. Architect: drift_found → CLEAN-after-tidy. The architect walked design/INDEX.md, the contracts touched by the milestone (float-semantics, prelude-classes, str-abi, scope-boundaries, authoring-surface, typeclasses, method-dispatch), `git log 86dd7d7..HEAD --format=full` for the three milestone commits (speca68d7b6, plan400ad7c, iter5170b6a), and the diff. The 4 drift items + per-item resolution: - [high] `design/contracts/prelude-classes.md` — Task-13 direct-icmp intercept for `lt__Int`/`le__Int`/`gt__Int`/ `ge__Int`/`ne__Int` was invisible. The Ord-class free-helper paragraph described them as routing via `compare` + `match` over Ordering, with no statement that at Int they short- circuit to direct `icmp`. The IR shape is actively pinned by `ord_int_intercept_ir_pin.rs` and load-bearing for `bench_closure_chain` perf. Tidy: new paragraph added naming the Int-direct family explicitly, the bench_closure_chain rationale, and the deliberate Int-only asymmetry (Bool/Str still go through compare→match because no current fixture exercises Ord-helpers there at hot-loop frequency). - [high] `crates/ailang-codegen/src/lib.rs:2777-2789` — rustdoc on `try_emit_primitive_instance_body` was three iterations stale: claimed "currently inhabited arms: `eq__Str` and `compare__Int/Bool/Str`" (= 4 arms, was correct as of iter 23.3), and claimed `eq__Int`/`eq__Bool` "ride the natural `lower_eq` dispatch via their lambda body `(== x y)`". Both claims are factually false post-milestone (the arm set is now 14: 4 Eq + 3 compare + 5 lt/le/gt/ge/ne + 6 float_*; lower_eq is deleted; `==` is no longer a surface name). Tidy: rustdoc rewritten to enumerate the actual 14 arms, explain the alwaysinline-pairing, name the Int-direct optimization asymmetry, and cross-reference prelude-classes.md. - [medium] `crates/ail/tests/prelude_eq_alwaysinline_ir_pin.rs:19-21` — comment asserted the test could not use `eq_demo.ail` because "`eq_demo.ail` calls `(app == …)` directly which today uses the `lower_eq` direct-emit short-circuit and never references the prelude eq__Int symbol". Both factual claims became false the moment Task 4's fixture migration landed (eq_demo.ail now calls `(app eq …)`) and Task 7 deleted lower_eq. The test functions correctly but the rationale- comment lied about why the fixture choice mattered. Tidy: comment rewritten to describe the actual test path — `eq_ord_user_adt.ail` invokes `(app eq …)` on IntBox whose user-instance body internally calls `(app eq ai bi)` on Int, monomorphising `prelude.eq__Int` into the emitted IR where the alwaysinline attribute on the `define` line is the assertion target. - [medium] `design/contracts/typeclasses.md` — implicit-prelude- import was load-bearing at codegen for the new bare-prelude- fn surface (`(app float_eq …)` etc. without explicit `prelude.float_eq` qualifier) but no contract named it. The codegen extension (`lower_app::resolve_callee`, `is_static_callee`, `resolve_top_level_fn` each gained a parallel prelude-fallback during iter Task 2/3) was a silent dependency on a workspace invariant that lived only in typechecker-side comments. Tidy: invariant (4) added as a sibling to the existing three invariants on cross-module typeclass references, naming the source-level codegen fallback as the symmetric partner to invariant (2)'s post- mono fallback; the summary paragraph extended from "three invariants are lockstep partners" to four, with `float_compare_smoke_e2e.rs` named as the invariant-(4) ratifier. The four fixes are pure-docs/comment work (no `.rs` code path changed, no IR shape changed); the workspace passing-test count is unchanged (640 still GREEN, 0 FAILED). No code regression risk; therefore inline-apply rather than dispatching a separate planner+implement cycle was the right cost-benefit call. Bench: 0/0/0. - bench/check.py exit 0 — 36 metrics, 0 regressed (full bench_closure_chain healed by Task 13's direct-icmp intercept arms — bump_s -6.05% rc_s -2.67% bump_rss_kb -0.77% rc_rss_kb +0.46%, all four previously-regressed metrics back inside tolerance). - bench/compile_check.py exit 0 — 24 metrics, 0 regressed. Largest delta build_O0_ms.bench_list_sum_explicit +11.04% (within 20% tolerance — operator-routing adds two extra intercept-arm matches on the compile hot-path, marginal). - bench/cross_lang.py exit 0 — 25 metrics, 0 regressed. No baseline updates needed; no `--update-baseline` invoked. The intercept-arm extension in Task 13 explicitly aimed at restoring the pre-milestone baseline; bench data ratifies that the aim was met. Pipeline this milestone followed: brainstorm (one-question-at-a-time Q&A, three design forks resolved: operator-names die from surface; Float keeps named fns; codegen via always-call + alwaysinline pre-emptive mitigation; grounding- check PASS over 11 load-bearing assumptions, after one BLOCK → spec defect about desugar.rs:2414 mischaracterisation fixed) -> planner (recon DONE_WITH_CONCERNS, 4 substantive spec gaps absorbed inline: 60 fixtures not ≈10, 8 in-source AST-literal sites not 3, 5 contract files not 3, alwaysinline mechanism new to crate; plus 2 self- review items absorbed inline: north-star RED-first via Unit-eq line; lockstep edits at codegen/lib.rs:2529) -> implement (DONE 12/12 from orchestrator; Boss reclassified as PARTIAL via acceptance-#8-fail after independent bench re-run; iter scope extended to Task 13 via SendMessage to same orchestrator-context; final DONE 13/13) -> audit (drift_found, 4 items inline-tidy applied: prelude- classes.md paragraph + codegen rustdoc refresh + test comment rewrite + typeclasses.md invariant-(4) addition; bench 0/0/0; CLEAN-after-tidy) Closing Gitea #1. The `closes #1` trailer lived on the iter commit `5170b6a` and fired on push. Spec: docs/specs/2026-05-20-operator-routing-eq-ord.md Plan: docs/plans/operator-routing-eq-ord.1.md
4647 lines
210 KiB
Rust
4647 lines
210 KiB
Rust
//! LLVM IR text emitter for AILang (MVP).
|
|
//!
|
|
//! Third stage of the compiler pipeline (`core` → `check` → `codegen`
|
|
//! → `ail` CLI). Consumes a fully type-checked [`Module`] (single-file
|
|
//! mode) or [`Workspace`] (multi-module mode) and produces LLVM IR as
|
|
//! a UTF-8 string ready to be written as a `.ll` file and handed to
|
|
//! `clang`. The two entry points are [`emit_ir`] (single module) and
|
|
//! [`lower_workspace`] (multi-module); both share the same mangling
|
|
//! scheme and ABI.
|
|
//!
|
|
//! Strategy: we generate LLVM IR as a string, write it as `.ll`, and
|
|
//! link it with `clang`. No binding to a specific libllvm version.
|
|
//!
|
|
//! Type mapping:
|
|
//! - `Int` -> `i64`
|
|
//! - `Bool` -> `i1`
|
|
//! - `Unit` -> `i8` (value always 0)
|
|
//!
|
|
//! Mangling scheme (Iter 5c):
|
|
//! - **All** AILang functions become `@ail_<module>_<def>`. This holds
|
|
//! even for single-module programs. The old form `@ail_<def>` is gone.
|
|
//! - Global string/constant symbols are mangled per module:
|
|
//! `@.str_<module>_<idx>` and `@ail_<module>_<def>` for constant globals.
|
|
//! - The entry point remains `main` (LLVM/C ABI). [`lower_workspace`]
|
|
//! emits `define i64 @main() { call @ail_<entry-module>_main() ... }`
|
|
//! as a trampoline to the entry module's `main`. If missing, the
|
|
//! build fails with [`CodegenError::MissingEntryMain`].
|
|
//! - `source_filename` appears exactly once at the top, with
|
|
//! `<entry-module>.ail` as value (per workspace).
|
|
//!
|
|
//! **Precondition.** Neither [`emit_ir`] nor [`lower_workspace`] runs
|
|
//! the typechecker. Callers must have run `ailang_check::check_module`
|
|
//! (or `check_workspace`) first; codegen will panic or emit malformed
|
|
//! IR if invariants the checker enforces (resolved metavars, declared
|
|
//! effects, ctor arity) are violated.
|
|
|
|
use ailang_core::ast::*;
|
|
use ailang_core::Workspace;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use ailang_check::uniqueness::{infer_module, UniquenessTable};
|
|
|
|
mod drop;
|
|
mod escape;
|
|
mod lambda;
|
|
mod match_lower;
|
|
mod subst;
|
|
mod synth;
|
|
|
|
use escape::NonEscapeSet;
|
|
use subst::{
|
|
apply_subst_to_type, derive_substitution,
|
|
qualify_local_types_codegen, unify_for_subst,
|
|
};
|
|
use synth::{
|
|
builtin_ail_type, builtin_binop_typed, builtin_effect_op_ret, c_byte_len, default_triple,
|
|
fn_sig_from_type, ll_string_literal, llvm_type,
|
|
};
|
|
|
|
/// Classifies a builtin name as a polymorphic arithmetic op (the
|
|
/// set `+`, `-`, `*`, `/`, `%`). After iter operator-routing-eq-ord.1
|
|
/// the comparator names `==` / `!=` / `<` / `<=` / `>` / `>=` are
|
|
/// no longer surface-level identifiers (comparison goes through the
|
|
/// `eq` / `compare` class methods and the `float_*` named fns), so
|
|
/// only the five arithmetic ops live here. Used by `lower_app` to
|
|
/// decide whether to call `builtin_binop_typed`, and by
|
|
/// `is_static_callee` (in combination with `not`) to recognise
|
|
/// built-in callees during the global-resolution pass.
|
|
fn is_arithmetic_op(name: &str) -> bool {
|
|
matches!(name, "+" | "-" | "*" | "/" | "%")
|
|
}
|
|
|
|
/// Failure modes of [`emit_ir`] / [`lower_workspace`].
|
|
///
|
|
/// Most variants signal a compiler invariant violation rather than a
|
|
/// user-facing diagnostic — by the time a module reaches codegen the
|
|
/// typechecker has already accepted it. The exceptions are
|
|
/// [`CodegenError::MissingEntryMain`] (a workspace-level shape check
|
|
/// that the typechecker doesn't enforce) and the wrapping variants
|
|
/// [`CodegenError::Def`] / [`CodegenError::InModule`] which add path
|
|
/// context to an inner error.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum CodegenError {
|
|
/// Wraps an inner error with the name of the def being lowered.
|
|
/// Attached by the per-def lowering loop in [`lower_workspace`] so
|
|
/// the failing definition is named in the message even when the
|
|
/// underlying error is structural (e.g. an [`CodegenError::Internal`]
|
|
/// from deep inside `lower_term`).
|
|
#[error("def `{0}`: {1}")]
|
|
Def(String, Box<CodegenError>),
|
|
|
|
/// Wraps an inner error with the name of the module being lowered.
|
|
/// Attached by [`lower_workspace`]'s per-module loop so multi-module
|
|
/// builds report which module failed without requiring the caller
|
|
/// to thread a module name through every call site.
|
|
#[error("module `{0}`: {1}")]
|
|
InModule(String, Box<CodegenError>),
|
|
|
|
/// `llvm_type` was asked to lower an AILang [`Type`] it does not
|
|
/// know how to represent. In the MVP this fires for an unresolved
|
|
/// rigid `Type::Var` reaching codegen (a substitution bug: a
|
|
/// parameterised-ADT type argument that monomorphisation should
|
|
/// have substituted to a concrete type before codegen) or for any
|
|
/// non-`Con`/`Fn`/`Var` shape that has not yet been wired through.
|
|
#[error("unsupported type: {0}")]
|
|
UnsupportedType(String),
|
|
|
|
/// A `Term::Var { name }` could not be resolved against the local
|
|
/// SSA stack, the current module's top-level fns, or a qualified
|
|
/// import. A correctly type-checked module never produces this; if
|
|
/// it does, the typechecker and the codegen-side resolver have
|
|
/// drifted out of sync.
|
|
#[error("unknown variable: `{0}`")]
|
|
UnknownVar(String),
|
|
|
|
/// A `Def::Fn` was reached whose `ty` is not a `Type::Fn`. The
|
|
/// typechecker ([`ailang_check::CheckError::FnTypeRequired`]) should
|
|
/// have rejected this case before us; emit_fn re-checks defensively
|
|
/// because a stale typechecker contract would otherwise produce
|
|
/// malformed IR.
|
|
#[error("expected fn type, got {0}")]
|
|
NotFnType(String),
|
|
|
|
/// The entry module of the workspace has no `main : () -> Unit !IO`
|
|
/// def, so [`lower_workspace`] cannot emit the C-ABI trampoline.
|
|
/// This is a workspace-level shape requirement that the typechecker
|
|
/// does not enforce (a library module is well-typed without a main),
|
|
/// so it surfaces here instead.
|
|
#[error("entry module `{0}` has no `main` def")]
|
|
MissingEntryMain(String),
|
|
|
|
/// Catch-all for codegen-side invariant violations: missing
|
|
/// ctor entry, lambda environment shape mismatch, mono-queue
|
|
/// inconsistency, etc. The string carries the precise diagnostic;
|
|
/// a user-facing build never produces this if the workspace
|
|
/// type-checks cleanly.
|
|
#[error("internal: {0}")]
|
|
Internal(String),
|
|
}
|
|
|
|
type Result<T> = std::result::Result<T, CodegenError>;
|
|
|
|
/// Which heap-allocation runtime the emitted IR targets.
|
|
///
|
|
/// `Rc` is the canonical production allocator (reference counting +
|
|
/// uniqueness inference); allocations go through `@ailang_rc_alloc`
|
|
/// from `runtime/rc.c`, which prefixes every payload with an 8-byte
|
|
/// refcount header, and codegen emits `inc`/`dec` calls at the points
|
|
/// dictated by linearity.
|
|
/// `Bump` is a raw-alloc bench-floor: every allocation site lowers to
|
|
/// `@bump_malloc`, supplied by `runtime/bump.c` — a no-free, statically-
|
|
/// sized arena allocator used purely to measure RC overhead against the
|
|
/// structurally cheapest allocator. Bump is bench-only, not a production
|
|
/// target.
|
|
/// The IR is otherwise byte-identical between the two strategies modulo
|
|
/// the allocator symbol name (and the RC-only inc/dec instrumentation).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AllocStrategy {
|
|
Bump,
|
|
Rc,
|
|
}
|
|
|
|
/// Embedding-ABI M1: codegen output shape.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Target {
|
|
/// Default: emit the `@main` trampoline; require an entry `main`
|
|
/// (`MissingEntryMain` if absent). Whole-program executable.
|
|
Executable,
|
|
/// `--emit=staticlib`: suppress the `@main` trampoline AND the
|
|
/// `MissingEntryMain` shape-check (a kernel module is a library);
|
|
/// emit one external C entrypoint per `(export …)` fn forwarding
|
|
/// to the internal `@ail_<module>_<def>`. M2: the entrypoint takes
|
|
/// a mandatory leading `ptr %ctx` (per-thread embedding context)
|
|
/// published via `@__ail_tls_ctx` around the unchanged internal
|
|
/// call. The ctx-threaded C signature is the M2 shape; the
|
|
/// value/record layout is frozen as of M3
|
|
/// (design/contracts/frozen-value-layout.md).
|
|
StaticLib,
|
|
}
|
|
|
|
impl AllocStrategy {
|
|
/// LLVM IR-level name of the allocator fn (without leading `@`).
|
|
fn fn_name(self) -> &'static str {
|
|
match self {
|
|
AllocStrategy::Bump => "bump_malloc",
|
|
AllocStrategy::Rc => "ailang_rc_alloc",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Single-module entry point. Lowers `m` to a `.ll` string with `m`
|
|
/// itself as the entry module. Returns the full LLVM IR text, ready to
|
|
/// be written to disk and handed to `clang`.
|
|
///
|
|
/// Used by tests, by `ail emit-ir`, and by `ail build`/`run` whenever
|
|
/// the input is a single `.ail.json` file rather than a workspace.
|
|
/// Internally builds a trivial [`Workspace`] containing only `m` and
|
|
/// delegates to [`lower_workspace`] — the mangling scheme,
|
|
/// `source_filename`, and the `@main` trampoline are therefore
|
|
/// identical between the two entry points.
|
|
///
|
|
/// **Precondition.** `m` must already type-check. This function does
|
|
/// **not** call `ailang_check`; passing a module with unresolved
|
|
/// metavars, undeclared effects, or arity mismatches will produce
|
|
/// either a [`CodegenError`] or malformed IR. For any input that came
|
|
/// from disk, run `ailang_check::check_module(m)` first and only call
|
|
/// `emit_ir` when the diagnostic list is empty.
|
|
///
|
|
/// Use [`lower_workspace`] instead when the program spans multiple
|
|
/// modules (cross-module calls, transitive imports) — `emit_ir` is the
|
|
/// short-cut for the single-file demo case.
|
|
pub fn emit_ir(m: &Module) -> Result<String> {
|
|
// nested ctor patterns are desugared inside
|
|
// `lower_workspace`, so single-module callers go through the
|
|
// same code path with no extra work here.
|
|
let mut modules = BTreeMap::new();
|
|
modules.insert(m.name.clone(), m.clone());
|
|
let ws = Workspace {
|
|
entry: m.name.clone(),
|
|
modules,
|
|
root_dir: std::path::PathBuf::from("."),
|
|
// emit_ir is the single-module shortcut. The
|
|
// empty registry is fine for 22b.1 because codegen does not
|
|
// yet read it; once 22b.3 monomorphisation runs, the queue
|
|
// is built from class-method-call sites in already-typechecked
|
|
// module bodies, not from the registry.
|
|
registry: ailang_core::workspace::Registry::default(),
|
|
};
|
|
lower_workspace(&ws)
|
|
}
|
|
|
|
/// Variant of [`lower_workspace`] that selects the heap allocator at
|
|
/// codegen time. `AllocStrategy::Rc` is the canonical production path
|
|
/// (matches [`lower_workspace`]); `AllocStrategy::Bump` swaps every
|
|
/// runtime-allocator site for `@bump_malloc` (supplied by
|
|
/// `runtime/bump.c`) so the bench harness can measure RC overhead
|
|
/// against the raw-alloc floor.
|
|
pub fn lower_workspace_with_alloc(ws: &Workspace, alloc: AllocStrategy) -> Result<String> {
|
|
lower_workspace_inner(ws, alloc, Target::Executable)
|
|
}
|
|
|
|
/// Embedding-ABI M1 entry point. Lowers a [`Workspace`] for the
|
|
/// static-library target: no `@main`, no `MissingEntryMain`, one
|
|
/// external `@<sym>` forwarder per `(export …)` fn.
|
|
pub fn lower_workspace_staticlib_with_alloc(
|
|
ws: &Workspace,
|
|
alloc: AllocStrategy,
|
|
) -> Result<String> {
|
|
lower_workspace_inner(ws, alloc, Target::StaticLib)
|
|
}
|
|
|
|
/// Multi-module entry point. Lowers an entire [`Workspace`] (entry
|
|
/// module plus its transitive imports, as produced by
|
|
/// `ailang_core::load_workspace`) to a single `.ll` string and emits
|
|
/// the C-ABI `@main` trampoline pointing at the entry module's `main`.
|
|
/// This is what `ail build` and `ail run` call for any real
|
|
/// multi-module program.
|
|
///
|
|
/// Module order is alphabetic (BTreeMap order = deterministic). Within
|
|
/// a module, def order matches the AST.
|
|
///
|
|
/// Cross-module calls: `Term::Var { name }` with exactly one dot
|
|
/// (`<prefix>.<def>`) is resolved via the calling module's import map
|
|
/// to `@ail_<actual_module>_<def>`. Local var lookups (no dot) stay
|
|
/// stack locals or local top-level defs of the current module.
|
|
///
|
|
/// **Precondition.** Every module in `ws.modules` must already
|
|
/// type-check (`ailang_check::check_workspace(ws)` returns no errors).
|
|
/// `lower_workspace` does **not** invoke the typechecker itself;
|
|
/// running it on an unchecked workspace is a caller bug. Beyond
|
|
/// type-checking, this function additionally requires that the entry
|
|
/// module declares `main : () -> Unit !IO` — otherwise it returns
|
|
/// [`CodegenError::MissingEntryMain`].
|
|
///
|
|
/// Use [`emit_ir`] for the single-file shortcut when there are no
|
|
/// imports.
|
|
pub fn lower_workspace(ws: &Workspace) -> Result<String> {
|
|
lower_workspace_inner(ws, AllocStrategy::Rc, Target::Executable)
|
|
}
|
|
|
|
/// Embedding-ABI M1 single-call entry point symmetric with
|
|
/// [`lower_workspace`]: lowers a [`Workspace`] for the static-library
|
|
/// target with the default `AllocStrategy::Rc`. This is what
|
|
/// `ail emit-ir --emit=staticlib` calls so an author can read a
|
|
/// `main`-free kernel's IR (the external `@<sym>` forwarders, no
|
|
/// `@main`) — the Decision-5 IR-readability affordance for the
|
|
/// artefact M1 introduced. Equivalent to
|
|
/// `lower_workspace_staticlib_with_alloc(ws, AllocStrategy::Rc)`.
|
|
pub fn lower_workspace_staticlib(ws: &Workspace) -> Result<String> {
|
|
lower_workspace_inner(ws, AllocStrategy::Rc, Target::StaticLib)
|
|
}
|
|
|
|
fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy, target: Target) -> Result<String> {
|
|
// desugar every module before any lowering work runs.
|
|
// The pass is idempotent and structurally identical to what
|
|
// `ailang-check` runs at its public entries, so the codegen
|
|
// sees the same flat-pattern AST as the typechecker.
|
|
let ws_owned = Workspace {
|
|
entry: ws.entry.clone(),
|
|
modules: ws
|
|
.modules
|
|
.iter()
|
|
.map(|(k, m)| (k.clone(), ailang_core::desugar::desugar_module(m)))
|
|
.collect(),
|
|
root_dir: ws.root_dir.clone(),
|
|
// pass the registry through unchanged. The desugar
|
|
// pass does not touch class/instance defs (see desugar.rs:
|
|
// 22b.1 passthrough), so the registry built at load time
|
|
// remains valid against the desugared modules.
|
|
registry: ws.registry.clone(),
|
|
};
|
|
let ws = &ws_owned;
|
|
let mut header = String::new();
|
|
let mut body = String::new();
|
|
let mut all_strings: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
|
|
// ^ per module: list of (global-name, content). Order = insertion order.
|
|
// parallel aggregation for language `Str`-literal globals
|
|
// (packed-struct shape). Same map shape; emitted by a parallel loop
|
|
// alongside the existing one.
|
|
let mut all_str_literals: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
|
|
|
|
// Pass 1: per-module top-level symbol tables.
|
|
// - `module_user_fns`: LLVM-typed FnSig for monomorphic fns. Used by
|
|
// the call resolver. Post-iter-23.4 the workspace contains ONLY
|
|
// monomorphic `Def::Fn`s (the typecheck-time mono pass synthesises
|
|
// one per concrete instantiation); any residual `Type::Forall` ty
|
|
// is a stale source def kept for round-trip / `ail diff` purposes
|
|
// and is intentionally not lowered.
|
|
// - `module_def_ail_types`: AILang `Type` for every fn-typed def.
|
|
// Retained for codegen-side type-tracking utilities (e.g.
|
|
// `synth_arg_type` for ctor-arg type inference).
|
|
let mut module_user_fns: BTreeMap<String, BTreeMap<String, FnSig>> = BTreeMap::new();
|
|
let mut module_def_ail_types: BTreeMap<String, BTreeMap<String, Type>> = BTreeMap::new();
|
|
// cross-module ctor table. Maps module name → ctor name →
|
|
// CtorRef (with `type_name` *unqualified*, since the ctor is defined
|
|
// in that module). Cross-module ctor lookups resolve through this
|
|
// table instead of the per-Emitter `ctor_index`.
|
|
let mut module_ctor_index: BTreeMap<String, BTreeMap<String, CtorRef>> = BTreeMap::new();
|
|
// per-module const table. Used to resolve `Term::Var`
|
|
// references to const defs (literal or non-literal) at lowering
|
|
// time. Literal consts emit a global and are loaded; non-literal
|
|
// consts (e.g. ctor expressions) are inlined at every reference
|
|
// site since check_const guarantees their bodies are pure.
|
|
let mut module_consts: BTreeMap<String, BTreeMap<String, ConstDef>> = BTreeMap::new();
|
|
for (mname, m) in &ws.modules {
|
|
let mut user_fns = BTreeMap::new();
|
|
let mut ail_types = BTreeMap::new();
|
|
let mut ctors = BTreeMap::new();
|
|
for def in &m.defs {
|
|
if let Def::Fn(f) = def {
|
|
ail_types.insert(f.name.clone(), f.ty.clone());
|
|
if let Type::Fn { params, ret, .. } = &f.ty {
|
|
let psig: Result<Vec<String>> = params.iter().map(llvm_type).collect();
|
|
let rsig = llvm_type(ret);
|
|
if let (Ok(params), Ok(ret)) = (psig, rsig) {
|
|
user_fns.insert(f.name.clone(), FnSig { params, ret });
|
|
}
|
|
}
|
|
// iter 23.4: `Type::Forall`-quantified defs are
|
|
// intentionally skipped — the mono pass has already
|
|
// produced their monomorphic counterparts.
|
|
}
|
|
if let Def::Type(td) = def {
|
|
for (i, c) in td.ctors.iter().enumerate() {
|
|
let fields: Vec<String> = c
|
|
.fields
|
|
.iter()
|
|
.map(|t| llvm_type(t).unwrap_or_else(|_| "ptr".into()))
|
|
.collect();
|
|
ctors.insert(
|
|
c.name.clone(),
|
|
CtorRef {
|
|
type_name: td.name.clone(),
|
|
tag: i as u32,
|
|
fields,
|
|
ail_fields: c.fields.clone(),
|
|
type_vars: td.vars.clone(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// collect const defs for this module so non-literal
|
|
// consts can be inlined at `Term::Var` reference sites.
|
|
let mut consts: BTreeMap<String, ConstDef> = BTreeMap::new();
|
|
for def in &m.defs {
|
|
if let Def::Const(c) = def {
|
|
consts.insert(c.name.clone(), c.clone());
|
|
}
|
|
}
|
|
module_user_fns.insert(mname.clone(), user_fns);
|
|
module_def_ail_types.insert(mname.clone(), ail_types);
|
|
module_ctor_index.insert(mname.clone(), ctors);
|
|
module_consts.insert(mname.clone(), consts);
|
|
}
|
|
|
|
// Pass 2: lower per module. Globals/strings are accumulated per module,
|
|
// because they are mangled per module.
|
|
for (mname, m) in &ws.modules {
|
|
// Import map for cross-module resolution. Identical to the
|
|
// logic in the typechecker (see `check_in_workspace`): alias or
|
|
// module name as key, actual module name as value.
|
|
let mut import_map: BTreeMap<String, String> = BTreeMap::new();
|
|
for imp in &m.imports {
|
|
let key = imp.alias.clone().unwrap_or_else(|| imp.module.clone());
|
|
import_map.insert(key, imp.module.clone());
|
|
}
|
|
// mirror the typechecker's implicit-prelude import
|
|
// injection (crates/ailang-check/src/lib.rs:1198 and :1300).
|
|
// User modules that call prelude-resident fns synthesised by
|
|
// monomorphisation (e.g. `prelude.eq__Int` from a user calling
|
|
// `eq x y` on Ints) need the codegen-side
|
|
// import_map to resolve the `prelude.` prefix at the call
|
|
// site (`lower_call`'s prefix lookup). Post-canonical-type-form ctor lookup
|
|
// is canonical (bare = local, qualified routes via import_map);
|
|
// this entry serves fn-name resolution only.
|
|
if m.name != "prelude" {
|
|
import_map
|
|
.entry("prelude".to_string())
|
|
.or_insert_with(|| "prelude".to_string());
|
|
}
|
|
|
|
let mut emitter = Emitter::new(
|
|
m,
|
|
mname,
|
|
&module_user_fns,
|
|
&module_def_ail_types,
|
|
&module_ctor_index,
|
|
&module_consts,
|
|
import_map,
|
|
alloc,
|
|
);
|
|
emitter
|
|
.emit_module()
|
|
.map_err(|e| CodegenError::InModule(mname.clone(), Box::new(e)))?;
|
|
header.push_str(&emitter.header);
|
|
body.push_str(&emitter.body);
|
|
// Collect strings in insertion order.
|
|
let mut entries: Vec<(String, String)> = Vec::new();
|
|
for (content, (name, _)) in &emitter.strings {
|
|
entries.push((name.clone(), content.clone()));
|
|
}
|
|
// sort by global name to stay deterministic across runs (intern_string
|
|
// uses a monotonic counter, so alphabetic is enough).
|
|
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
|
all_strings.insert(mname.clone(), entries);
|
|
// parallel collection of `Str`-literal globals.
|
|
let mut lit_entries: Vec<(String, String)> = Vec::new();
|
|
for (content, (name, _)) in &emitter.str_literals {
|
|
lit_entries.push((name.clone(), content.clone()));
|
|
}
|
|
lit_entries.sort_by(|a, b| a.0.cmp(&b.0));
|
|
all_str_literals.insert(mname.clone(), lit_entries);
|
|
}
|
|
|
|
// Trampoline: verify that the entry module has a
|
|
// `main : () -> Unit !IO`. Suppressed for the staticlib target —
|
|
// a kernel module is a library and has no `main`.
|
|
if target == Target::Executable {
|
|
let entry_module = ws
|
|
.modules
|
|
.get(&ws.entry)
|
|
.ok_or_else(|| CodegenError::Internal(format!("entry module `{}` not in workspace", ws.entry)))?;
|
|
let has_main = entry_module
|
|
.defs
|
|
.iter()
|
|
.any(|d| matches!(d, Def::Fn(f) if f.name == "main" && main_is_void(&f.ty)));
|
|
if !has_main {
|
|
return Err(CodegenError::MissingEntryMain(ws.entry.clone()));
|
|
}
|
|
}
|
|
|
|
let mut out = String::new();
|
|
out.push_str("; AILang generated workspace; entry: ");
|
|
out.push_str(&ws.entry);
|
|
out.push('\n');
|
|
out.push_str("source_filename = \"");
|
|
out.push_str(&ws.entry);
|
|
out.push_str(".ail\"\n");
|
|
out.push_str("target triple = \"");
|
|
out.push_str(default_triple());
|
|
out.push_str("\"\n\n");
|
|
|
|
// Globals: per module, alphabetically over module names (BTreeMap order),
|
|
// then insertion order per module.
|
|
let mut emitted_global = false;
|
|
for entries in all_strings.values() {
|
|
for (name, content) in entries {
|
|
let escaped = ll_string_literal(content);
|
|
let len = c_byte_len(content);
|
|
out.push_str(&format!(
|
|
"@{name} = private unnamed_addr constant [{len} x i8] c\"{escaped}\", align 1\n",
|
|
));
|
|
emitted_global = true;
|
|
}
|
|
}
|
|
// packed-struct globals for language `Str` literals.
|
|
// First `i64` is the byte length (excluding the trailing NUL); the
|
|
// `[N+1 x i8]` carries the bytes followed by the terminating NUL.
|
|
// The IR-`Str` pointer that flows through the rest of codegen lands
|
|
// on the `len`-field via constexpr-GEP at the `Literal::Str` arms
|
|
// (see emit_const_def / lower_term). Static-Str pointers are kept
|
|
// out of `ailang_rc_dec` by codegen-level elision (move-tracking
|
|
// from iter 18d.3 + non-escape lowering from iter 18b), so no
|
|
// sentinel rc-header slot is needed at the global.
|
|
for entries in all_str_literals.values() {
|
|
for (name, content) in entries {
|
|
let escaped = ll_string_literal(content);
|
|
let total = c_byte_len(content); // bytes + NUL
|
|
let bytes_len = total - 1; // bytes only
|
|
out.push_str(&format!(
|
|
"@{name} = private unnamed_addr constant <{{ i64, [{total} x i8] }}> <{{ i64 {bytes_len}, [{total} x i8] c\"{escaped}\" }}>, align 8\n",
|
|
));
|
|
emitted_global = true;
|
|
}
|
|
}
|
|
if emitted_global {
|
|
out.push('\n');
|
|
}
|
|
|
|
out.push_str("declare i32 @printf(ptr, ...)\n");
|
|
out.push_str("declare i32 @puts(ptr)\n");
|
|
// The allocator declaration name follows `alloc`. `Rc` declares
|
|
// `@ailang_rc_alloc` (canonical); `Bump` declares `@bump_malloc`
|
|
// (raw-alloc bench-floor), supplied by `runtime/bump.c`.
|
|
out.push_str(&format!("declare ptr @{}(i64)\n", alloc.fn_name()));
|
|
// under `--alloc=rc`, also declare the inc/dec ABI from
|
|
// `runtime/rc.c` so codegen can emit refcount calls at every
|
|
// `Term::Clone` site and at end-of-scope of trackable RC binders.
|
|
// `Bump` keeps its leak-only IR shape — nothing to declare.
|
|
if matches!(alloc, AllocStrategy::Rc) {
|
|
out.push_str("declare void @ailang_rc_inc(ptr)\n");
|
|
out.push_str("declare void @ailang_rc_dec(ptr)\n");
|
|
// drop-worklist ABI for `(drop-iterative)` types.
|
|
// Declared unconditionally under `--alloc=rc` (the linker
|
|
// drops symbols if no emitted fn references them); symmetric
|
|
// with inc/dec above. See `runtime/rc.c` for the strategy.
|
|
out.push_str("declare ptr @ailang_drop_worklist_new()\n");
|
|
out.push_str("declare void @ailang_drop_worklist_push(ptr, ptr)\n");
|
|
out.push_str("declare ptr @ailang_drop_worklist_pop(ptr)\n");
|
|
out.push_str("declare void @ailang_drop_worklist_free(ptr)\n");
|
|
}
|
|
// `==` on `Str` lowers to `@strcmp` followed by
|
|
// `icmp eq i32 0`. NUL-terminated strings make this a one-liner;
|
|
// libc supplies `strcmp` so no extra link flag is needed.
|
|
out.push_str("declare i32 @strcmp(ptr, ptr)\n");
|
|
// `runtime/str.c::ail_str_eq` backs the prelude's
|
|
// `eq__Str` mono symbol (see `try_emit_primitive_instance_body`).
|
|
// Declared unconditionally — the codegen intercept emits a call
|
|
// to it whenever the monomorphiser synthesises `eq__Str` in any
|
|
// module; the .o supplying the symbol is linked unconditionally
|
|
// by `ail build` (see `crates/ail/src/main.rs::build_to`). The
|
|
// `zeroext i1` return matches clang's lowering of C `_Bool`.
|
|
out.push_str("declare zeroext i1 @ail_str_eq(ptr, ptr)\n");
|
|
// `runtime/str.c::ail_str_compare` backs the prelude's
|
|
// `compare__Str` mono symbol (see `try_emit_primitive_instance_body`
|
|
// `compare__Str` arm below). Declared unconditionally on the same
|
|
// rationale as `@ail_str_eq` — the .o is supplied by the
|
|
// unconditionally-linked `runtime/str.c`, and clang -O2 dead-
|
|
// strips the symbol when no caller exists. Returns i32 normalised
|
|
// to {-1, 0, +1} so the branch ladder in the intercept can compare
|
|
// against constant 0 directly.
|
|
out.push_str("declare i32 @ail_str_compare(ptr, ptr)\n");
|
|
// heap-Str formatter externs from `runtime/str.c`.
|
|
// Both return a heap-allocated Str pointer (rc_header at offset
|
|
// -8; consumer ABI shared with static-Str — len at offset 0,
|
|
// bytes at offset 8). Declared unconditionally on the same
|
|
// rationale as `@ail_str_eq` / `@ail_str_compare`: the .o is
|
|
// supplied by the unconditionally-linked `runtime/str.c`, and
|
|
// clang -O2 dead-strips the declarations when no caller exists.
|
|
out.push_str("declare ptr @ailang_int_to_str(i64)\n");
|
|
out.push_str("declare ptr @ailang_float_to_str(double)\n");
|
|
// heap-Str primitive externs for the `show` instances
|
|
// — `ailang_bool_to_str` (Show Bool) and `ailang_str_clone`
|
|
// (Show Str). Same dual-realisation ABI as int_to_str /
|
|
// float_to_str: rc_header at offset -8 on the heap-Str output;
|
|
// consumer ABI shared with static-Str (len at offset 0, bytes
|
|
// at offset 8). Declared unconditionally on the same rationale
|
|
// — runtime/str.c is unconditionally linked and clang -O2 dead-
|
|
// strips when no caller exists.
|
|
out.push_str("declare ptr @ailang_bool_to_str(i1)\n");
|
|
out.push_str("declare ptr @ailang_str_clone(ptr)\n");
|
|
out.push_str("declare ptr @ailang_str_concat(ptr, ptr)\n");
|
|
// Floats iter 4.4: saturating fp-to-int intrinsic for
|
|
// float_to_int_truncate. NaN → 0, +Inf → i64::MAX, -Inf →
|
|
// i64::MIN, finite-out-of-range saturates, finite-in-range
|
|
// truncates toward zero. LLVM 12+, always available with
|
|
// clang 22.
|
|
out.push_str("declare i64 @llvm.fptosi.sat.i64.f64(double)\n\n");
|
|
out.push_str(&header);
|
|
out.push_str(&body);
|
|
|
|
match target {
|
|
Target::Executable => {
|
|
// Trampoline @main → @ail_<entry>_main.
|
|
out.push_str(&format!(
|
|
"\ndefine i32 @main() {{\n call i8 @ail_{}_main()\n ret i32 0\n}}\n",
|
|
ws.entry
|
|
));
|
|
}
|
|
Target::StaticLib => {
|
|
// One external C entrypoint per `(export …)` fn,
|
|
// forwarding to the internal `@ail_<module>_<fn>`.
|
|
// The check-side export gate guarantees every param and
|
|
// the ret are `Int`/`Float` or a single-constructor
|
|
// record of those (M3); map Int→i64, Float→double, a
|
|
// record → ptr. Scalar fns have no captured env, so
|
|
// the internal callee takes the scalars positionally
|
|
// (confirmed against `emit_fn`'s signature emission,
|
|
// `lib.rs:1087`).
|
|
// M2: the per-thread embedding ctx slot. Defined by
|
|
// runtime/rc.c (__thread ailang_ctx_t *__ail_tls_ctx);
|
|
// the forwarder publishes its explicit ctx param here for
|
|
// the synchronous duration of the internal call.
|
|
out.push_str("\n@__ail_tls_ctx = external thread_local global ptr\n");
|
|
for (mname, m) in &ws.modules {
|
|
for d in &m.defs {
|
|
if let Def::Fn(f) = d {
|
|
if let Some(sym) = &f.export {
|
|
let (param_tys, ret_ty) = match fn_scalar_sig(&f.ty) {
|
|
Some(sig) => sig,
|
|
None => return Err(CodegenError::Internal(format!(
|
|
"export `{sym}`: non-scalar signature reached codegen \
|
|
(check-side gate should have rejected it)"))),
|
|
};
|
|
let cret = llvm_scalar(&ret_ty);
|
|
let args: Vec<String> = param_tys.iter().enumerate()
|
|
.map(|(i, t)| format!("{} %a{i}", llvm_scalar(t)))
|
|
.collect();
|
|
let mut sig_params = vec!["ptr %ctx".to_string()];
|
|
sig_params.extend(
|
|
param_tys.iter().enumerate()
|
|
.map(|(i, t)| format!("{} %a{i}", llvm_scalar(t))));
|
|
out.push_str(&format!(
|
|
"\ndefine {cret} @{sym}({}) {{\n \
|
|
%saved = load ptr, ptr @__ail_tls_ctx\n \
|
|
store ptr %ctx, ptr @__ail_tls_ctx\n \
|
|
%r = call {cret} @ail_{mname}_{}({})\n \
|
|
store ptr %saved, ptr @__ail_tls_ctx\n \
|
|
ret {cret} %r\n}}\n",
|
|
sig_params.join(", "),
|
|
f.name,
|
|
args.join(", "),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
/// Scalar-signature view of an export fn's type. `None` if not the
|
|
/// flat `Type::Fn` of scalars the M1 gate guarantees.
|
|
fn fn_scalar_sig(t: &Type) -> Option<(Vec<Type>, Type)> {
|
|
let inner = match t {
|
|
Type::Forall { body, .. } => body.as_ref(),
|
|
other => other,
|
|
};
|
|
match inner {
|
|
// M3: params/ret may include a single-ctor scalar record type;
|
|
// llvm_scalar maps it to `ptr`. fn_scalar_sig is type-shape only.
|
|
Type::Fn { params, ret, .. } =>
|
|
Some((params.clone(), (**ret).clone())),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// `Int` → `i64`, `Float` → `double`, a single-ctor scalar record
|
|
/// (any other named `Type::Con` reaching an `(export)` signature, by
|
|
/// the Task-2 export gate) → `ptr`. The check-side gate rejects every
|
|
/// other shape before codegen.
|
|
fn llvm_scalar(t: &Type) -> &'static str {
|
|
match t {
|
|
Type::Con { name, .. } if name == "Float" => "double",
|
|
Type::Con { name, .. } if name == "Int" => "i64",
|
|
// M3: any other Type::Con reaching an (export) signature is,
|
|
// by the Task-2 export gate, a single-ctor scalar record;
|
|
// it crosses the C ABI as a bare `ptr`
|
|
// (design/contracts/frozen-value-layout.md — payload pointer).
|
|
Type::Con { .. } => "ptr",
|
|
_ => "i64",
|
|
}
|
|
}
|
|
|
|
fn main_is_void(t: &Type) -> bool {
|
|
match t {
|
|
Type::Fn { params, ret, .. } => {
|
|
params.is_empty()
|
|
&& matches!(ret.as_ref(), Type::Con { name, .. } if name == "Unit")
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
struct Emitter<'a> {
|
|
module: &'a Module,
|
|
/// Name of the currently lowered module (for mangling).
|
|
module_name: &'a str,
|
|
header: String,
|
|
body: String,
|
|
/// String constants: content -> (global name (without `@`), llvm type length incl. \0)
|
|
strings: BTreeMap<String, (String, usize)>,
|
|
/// language `Str` literals interned as
|
|
/// packed-struct globals (`<{ i64, [N+1 x i8] }>`) carrying an
|
|
/// explicit `len` field and the bytes + trailing NUL. Parallel to
|
|
/// `strings` (which still serves runtime-internal format strings
|
|
/// like `%lld\n` / `true\n` in the raw `[N x i8]` shape). Same
|
|
/// key shape; the two tables coexist.
|
|
str_literals: BTreeMap<String, (String, usize)>,
|
|
/// Local symbol table per function: (name, ssa, llvm_type, ail_type).
|
|
/// The AILang type is recorded so that the codegen-side type tracker
|
|
/// can derive substitutions at polymorphic call sites without
|
|
/// re-running the typechecker (Iter 12b).
|
|
locals: Vec<(String, String, String, Type)>,
|
|
/// Monotonic counter for SSA values and labels.
|
|
counter: u64,
|
|
/// Monotonic counter for global string names (per module).
|
|
str_counter: u64,
|
|
/// Top-level functions per module of the workspace, for call resolution.
|
|
module_user_fns: &'a BTreeMap<String, BTreeMap<String, FnSig>>,
|
|
/// AILang types of every fn-typed top-level def, per module. Carries
|
|
/// `Forall` for polymorphic defs (used to derive substitutions at
|
|
/// monomorphic call sites). Populated in pass 1 of `lower_workspace`.
|
|
module_def_ail_types: &'a BTreeMap<String, BTreeMap<String, Type>>,
|
|
/// Import map of the current module (alias/module name → actual module name).
|
|
import_map: BTreeMap<String, String>,
|
|
/// ADT table: type_name -> list of ctors in definition order.
|
|
/// Tag of a ctor = index in this list.
|
|
/// Kept around for future tools (pretty-printer for ADT values,
|
|
/// decision-tree optimization).
|
|
#[allow(dead_code)]
|
|
types: BTreeMap<String, Vec<CtorInfo>>,
|
|
/// cross-module ctor index, keyed by module name. Used by
|
|
/// `lookup_ctor_by_type` (for `Term::Ctor.type_name`) and
|
|
/// `lookup_ctor_in_pattern` (for `Pattern::Ctor.ctor`). Built once
|
|
/// per workspace and shared by every Emitter. Replaces the per-
|
|
/// emitter `ctor_index` of pre-15a — that table only knew the
|
|
/// current module's ctors and broke on cross-module references.
|
|
module_ctor_index: &'a BTreeMap<String, BTreeMap<String, CtorRef>>,
|
|
/// per-module const defs, used to resolve `Term::Var`
|
|
/// references (bare or qualified) to a const's body. Literal
|
|
/// consts emit a global and are loaded via `@ail_<m>_<name>`;
|
|
/// non-literal consts are inlined at every reference site (sound
|
|
/// because `check_const` rejects effects, so the body is pure).
|
|
module_consts: &'a BTreeMap<String, BTreeMap<String, ConstDef>>,
|
|
/// Current basic block label. Set by `start_block` and is
|
|
/// the single source of truth for `phi` operands.
|
|
current_block: String,
|
|
/// true while the current block already ends in a
|
|
/// terminator (currently only `ret` after a `musttail call`).
|
|
/// Callers in the term lowering walk consult this to skip
|
|
/// fall-through `br` emission and to omit the value from a
|
|
/// surrounding match-arm phi. Reset by [`Self::start_block`].
|
|
block_terminated: bool,
|
|
/// SSA value (or `@global`) -> its FnSig, for first-class
|
|
/// function values. Populated whenever we lower a `Term::Var` to a
|
|
/// top-level fn pointer or when a fn-typed parameter is bound at
|
|
/// function entry. Used by `Term::App` when the callee is not a
|
|
/// statically-known top-level name.
|
|
ssa_fn_sigs: BTreeMap<String, FnSig>,
|
|
/// name of the currently-emitted def (for lambda thunk
|
|
/// naming `<def>_lam<n>`).
|
|
current_def: String,
|
|
/// per-def counter for lambda thunks. Reset in emit_fn.
|
|
lam_counter: u32,
|
|
/// thunk fn IR text for lambdas encountered during
|
|
/// lowering. Flushed at the end of emit_fn (LLVM IR allows fns in
|
|
/// any order).
|
|
deferred_thunks: Vec<String>,
|
|
/// per-fn escape-analysis result. Set of pointer-as-usize
|
|
/// addresses of `Term::Ctor` and `Term::Lam` nodes that the
|
|
/// analysis proved do not escape the fn frame they are allocated
|
|
/// in. Such allocations lower to `alloca` instead of the runtime
|
|
/// allocator (`@ailang_rc_alloc` or `@bump_malloc`).
|
|
/// Populated by `analyze_fn_body` at the start of `emit_fn` and at
|
|
/// the start of every lambda thunk emission inside `lower_lambda`.
|
|
non_escape: NonEscapeSet,
|
|
/// Bench iter: which allocator the heap-allocation paths target.
|
|
/// Decided at the top-level entry point (`lower_workspace_inner`)
|
|
/// and propagated to every site that emits a `call ptr @<alloc>(...)`.
|
|
alloc: AllocStrategy,
|
|
/// per-binder uniqueness side-table for the current
|
|
/// module, keyed by `(def_name, binder_name)`. Built once per
|
|
/// emitter and consulted by `Term::Let` lowering to decide whether
|
|
/// to emit `call void @ailang_rc_dec(ptr %v)` at scope close. The
|
|
/// table is module-scoped because the inference is whole-fn local;
|
|
/// no cross-module entries appear.
|
|
uniqueness: UniquenessTable,
|
|
/// per-closure-pair drop-function symbol. Keyed by the
|
|
/// closure-pair SSA value (e.g. `%v17`) the most-recent
|
|
/// `lower_lambda` call returned. Consulted by the `Term::Let`
|
|
/// lowering to emit `call void @<drop>(ptr %v17)` instead of the
|
|
/// raw `@ailang_rc_dec` when the binder owns a closure pair.
|
|
/// Empty under non-`Rc` allocators — a closure under
|
|
/// `--alloc=bump` has no drop fn (bump leaks by design).
|
|
closure_drops: BTreeMap<String, String>,
|
|
/// per-fn-body move tracking. Keyed by binder name, maps
|
|
/// to the set of positional ctor-field indices that have been
|
|
/// "moved out" via a pattern destructure. A field is moved when a
|
|
/// `(case (Ctor h t) <body>)` arm binds a non-wildcard, pointer-
|
|
/// typed slot — the load-into-binder is treated as a transfer of
|
|
/// ownership from the source slot to the binder's SSA. The source
|
|
/// slot is NOT mutated; codegen merely remembers, statically, that
|
|
/// the binder's new owner now holds the only live reference along
|
|
/// this path.
|
|
///
|
|
/// Consulted at two call sites that emit dec sequences against the
|
|
/// source binder:
|
|
/// 1. `Term::Let` scope close (`is_rc_heap_allocated` path) —
|
|
/// when the entry is non-empty, codegen inlines a per-field
|
|
/// dec sequence that skips slots in the moved set. When the
|
|
/// entry is empty (the common case), the existing
|
|
/// `drop_<m>_<T>(ptr)` call is emitted unchanged.
|
|
/// 2. `lower_reuse_as_rc`'s reuse arm — moved-out slots are
|
|
/// skipped (they no longer hold a live reference); non-moved
|
|
/// slots are dec'd via `field_drop_call` before the new field
|
|
/// values overwrite them.
|
|
///
|
|
/// Reset to empty at the top of every fn body (`emit_fn` and the
|
|
/// thunk-emission section of `lower_lambda`). Entries for a
|
|
/// particular binder are removed when that binder leaves scope
|
|
/// (on `Term::Let` body close, on match-arm body close).
|
|
moved_slots: BTreeMap<String, BTreeSet<usize>>,
|
|
/// per-fn-body parameter modes, keyed by parameter
|
|
/// name. Set once at the top of `emit_fn` (and at lambda thunk
|
|
/// entry) from the fn type's `param_modes`. Consulted by
|
|
/// `lower_match`'s arm-close pattern-binder dec emission (Iter A) to
|
|
/// decide whether the scrutinee was statically owned: if the
|
|
/// scrutinee resolves to a fn-param whose mode is `Borrow` or
|
|
/// `Implicit`, the pattern-binder dec must NOT fire — the caller
|
|
/// still holds a reference and dec'ing the pattern-binder would
|
|
/// fragment the caller's structure.
|
|
///
|
|
/// Symmetric with the Iter B gate at fn return (`emit_fn`'s Own-
|
|
/// param dec): both sites must check the param-mode signal before
|
|
/// dec'ing, because Implicit and Borrow do not carry the "caller
|
|
/// handed off ownership" signal that makes the dec safe.
|
|
current_param_modes: BTreeMap<String, ParamMode>,
|
|
/// Per-fn map of name → (alloca SSA name, AIL element type) for
|
|
/// alloca-resident loop binders. Populated on entry to a
|
|
/// `Term::Loop` (iter loop-recur.3): the `Term::Loop` arm inserts
|
|
/// each binder here and `recur` stores into the slot. Entries are
|
|
/// removed (or restored to their prior binding) on block exit.
|
|
/// Consulted by the `Term::Var` arm of `lower_term` before
|
|
/// `self.locals` — the single load path for loop binders. Loop
|
|
/// binders are tracked typecheck-side in the ordinary `locals`
|
|
/// plus `loop_stack` (positional); this codegen map is a
|
|
/// representation detail, not a scope-precedence claim.
|
|
binder_allocas: BTreeMap<String, (String, Type)>,
|
|
/// loop-recur iter 3: stack of enclosing `Term::Loop` codegen
|
|
/// frames. Each frame is `(header_block_label, binder_slots)`
|
|
/// where `binder_slots` is `(binder_name, alloca_ssa, llvm_ty)`
|
|
/// in declaration order. Pushed on `Term::Loop` body entry,
|
|
/// popped on exit; `Term::Recur` reads `.last()` for its target
|
|
/// header + the binder allocas to store into. Saved/reset/
|
|
/// restored at the lambda-lowering boundary alongside
|
|
/// `binder_allocas` (a `recur` cannot cross a lambda boundary).
|
|
loop_frames: Vec<(String, Vec<(String, String, String)>)>,
|
|
/// Side buffer for `alloca` instructions emitted during body
|
|
/// lowering but hoisted to the fn's entry block. Flushed once
|
|
/// into `self.body` at `entry_block_end_marker` after
|
|
/// `lower_term` completes. Entry-block placement is what makes
|
|
/// the loop-binder allocas mem2reg-eligible regardless of how
|
|
/// deeply nested the originating `Term::Loop` is.
|
|
pending_entry_allocas: String,
|
|
/// byte position in `self.body` immediately after
|
|
/// the `entry:\n` label, captured during `start_block("entry")`
|
|
/// at fn-body emission. Used to splice `pending_entry_allocas`
|
|
/// into the entry block once body lowering completes.
|
|
entry_block_end_marker: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
#[allow(dead_code)]
|
|
struct CtorInfo {
|
|
name: String,
|
|
fields: Vec<String>, // llvm types
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct CtorRef {
|
|
type_name: String,
|
|
tag: u32,
|
|
/// Precomputed LLVM field types. Valid only for monomorphic ADTs
|
|
/// (`type_vars.is_empty()`). For parameterised ADTs the entries are
|
|
/// meaningless (free `Type::Var` lowers via the `_ => ptr` fallback)
|
|
/// and must be re-derived per use site after substituting through
|
|
/// `ail_fields`.
|
|
fields: Vec<String>,
|
|
/// AILang-level field types (parallel to `fields`). Carries
|
|
/// `Type::Var` references for parameterised ADTs (Iter 13b); these
|
|
/// are substituted at every ctor / match-arm use site.
|
|
ail_fields: Vec<Type>,
|
|
/// type parameters of the owning TypeDef, in declaration
|
|
/// order. Empty for monomorphic ADTs (`type IntList = ...`); non-
|
|
/// empty for parameterised ADTs (`type Box[a] = MkBox(a)` →
|
|
/// `["a"]`). Used as the var-set for `unify_for_subst` when deriving
|
|
/// substitutions at a use site, and to map type-args
|
|
/// (`Type::Con.args[i]`) back to the right var when lowering match
|
|
/// arms against a parameterised scrutinee.
|
|
type_vars: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct FnSig {
|
|
params: Vec<String>, // llvm types
|
|
ret: String, // llvm type
|
|
}
|
|
|
|
impl<'a> Emitter<'a> {
|
|
// The eight parameters are all the workspace-flat tables the
|
|
// emitter needs at construction time; bundling them into a
|
|
// context struct would just rename the boilerplate without
|
|
// reducing it. Keep the explicit-arg shape.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn new(
|
|
module: &'a Module,
|
|
module_name: &'a str,
|
|
module_user_fns: &'a BTreeMap<String, BTreeMap<String, FnSig>>,
|
|
module_def_ail_types: &'a BTreeMap<String, BTreeMap<String, Type>>,
|
|
module_ctor_index: &'a BTreeMap<String, BTreeMap<String, CtorRef>>,
|
|
module_consts: &'a BTreeMap<String, BTreeMap<String, ConstDef>>,
|
|
import_map: BTreeMap<String, String>,
|
|
alloc: AllocStrategy,
|
|
) -> Self {
|
|
let mut types: BTreeMap<String, Vec<CtorInfo>> = BTreeMap::new();
|
|
for def in &module.defs {
|
|
if let Def::Type(td) = def {
|
|
let mut infos = Vec::new();
|
|
for c in td.ctors.iter() {
|
|
// precomputed LLVM field types are only
|
|
// meaningful for monomorphic ADTs. For parameterised
|
|
// ADTs the field types reference free `Type::Var`s
|
|
// and must be derived per use site after
|
|
// substituting; we still populate the slot with
|
|
// `i64`/`ptr` placeholders so the index shape stays
|
|
// uniform, but neither `lower_ctor` nor
|
|
// `lower_match` reads from it when `type_vars` is
|
|
// non-empty.
|
|
let fields: Vec<String> = c
|
|
.fields
|
|
.iter()
|
|
.map(|t| llvm_type(t).unwrap_or_else(|_| "ptr".into()))
|
|
.collect();
|
|
infos.push(CtorInfo {
|
|
name: c.name.clone(),
|
|
fields,
|
|
});
|
|
}
|
|
types.insert(td.name.clone(), infos);
|
|
}
|
|
}
|
|
|
|
// build the per-module uniqueness side-table once
|
|
// per emitter. The inference is pure (no I/O, no global state),
|
|
// so doing it here is cheap and keeps codegen's input self-
|
|
// contained.
|
|
let uniqueness = infer_module(module);
|
|
|
|
Self {
|
|
module,
|
|
module_name,
|
|
header: String::new(),
|
|
body: String::new(),
|
|
strings: BTreeMap::new(),
|
|
str_literals: BTreeMap::new(),
|
|
locals: Vec::new(),
|
|
counter: 0,
|
|
str_counter: 0,
|
|
module_user_fns,
|
|
module_def_ail_types,
|
|
import_map,
|
|
types,
|
|
module_ctor_index,
|
|
module_consts,
|
|
current_block: String::new(),
|
|
block_terminated: false,
|
|
ssa_fn_sigs: BTreeMap::new(),
|
|
current_def: String::new(),
|
|
lam_counter: 0,
|
|
deferred_thunks: Vec::new(),
|
|
non_escape: NonEscapeSet::new(),
|
|
alloc,
|
|
uniqueness,
|
|
closure_drops: BTreeMap::new(),
|
|
moved_slots: BTreeMap::new(),
|
|
current_param_modes: BTreeMap::new(),
|
|
binder_allocas: BTreeMap::new(),
|
|
loop_frames: Vec::new(),
|
|
pending_entry_allocas: String::new(),
|
|
entry_block_end_marker: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn start_block(&mut self, label: &str) {
|
|
self.body.push_str(label);
|
|
self.body.push_str(":\n");
|
|
// capture the entry-block splice point for
|
|
// `pending_entry_allocas`. The marker points to the byte
|
|
// position immediately after the `entry:\n` label — the
|
|
// splice (in `emit_fn`'s tail) inserts the hoisted allocas
|
|
// there, before any subsequent body emission.
|
|
if label == "entry" {
|
|
self.entry_block_end_marker = Some(self.body.len());
|
|
}
|
|
self.current_block = label.to_string();
|
|
self.block_terminated = false;
|
|
}
|
|
|
|
fn emit_module(&mut self) -> Result<()> {
|
|
let defs: Vec<&Def> = self.module.defs.iter().collect();
|
|
for def in defs {
|
|
match def {
|
|
Def::Fn(f) => {
|
|
// Polymorphic defs aren't emitted in their original
|
|
// form — they are specialised on demand at call sites
|
|
// (Iter 12b). Skip them here; the drain pass below
|
|
// emits the specialised versions.
|
|
if matches!(&f.ty, Type::Forall { .. }) {
|
|
continue;
|
|
}
|
|
self.emit_fn(f)
|
|
.map_err(|e| CodegenError::Def(f.name.clone(), Box::new(e)))?;
|
|
}
|
|
Def::Const(c) => {
|
|
self.emit_const(c)
|
|
.map_err(|e| CodegenError::Def(c.name.clone(), Box::new(e)))?;
|
|
}
|
|
Def::Type(_) => {
|
|
// No LLVM definition needed: the ADT exists only as a
|
|
// logical type. Heap boxes are allocated ad hoc via
|
|
// the runtime allocator (Iter 14f's escape-analysis target).
|
|
}
|
|
// class/instance defs do not emit IR yet.
|
|
// 22b.3 monomorphisation will rewrite class-method
|
|
// calls into calls against synthesised monomorphic
|
|
// FnDefs; once that pass runs, class/instance bodies
|
|
// never reach the emit path on their own — they only
|
|
// appear inlined into the synthesised fns.
|
|
Def::Class(_) | Def::Instance(_) => {}
|
|
}
|
|
}
|
|
// iter 23.4: the monomorphisation queue is gone — the
|
|
// typecheck-time mono pass synthesises every specialised
|
|
// `Def::Fn` before codegen runs (see
|
|
// `ailang_check::mono::monomorphise_workspace`). Codegen
|
|
// sees only monomorphic defs; the drain loop and
|
|
// `emit_specialised_fn` have been removed.
|
|
|
|
// per-ADT drop functions. Emitted only under
|
|
// `--alloc=rc`. One `void @drop_<module>_<TypeName>(ptr)` per
|
|
// `Def::Type` in the current module — the call site for
|
|
// recursive ADTs (`drop_<m>_List` calling itself on the tail)
|
|
// requires every ADT to have a uniformly-named drop fn, so we
|
|
// emit even for ADTs with no boxed children (those drop fns
|
|
// just dec the outer box). Under `Gc`/`Bump` no drop fns
|
|
// appear — the IR shape stays byte-identical to pre-18c.4.
|
|
if matches!(self.alloc, AllocStrategy::Rc) {
|
|
for def in &self.module.defs {
|
|
if let Def::Type(td) = def {
|
|
if td.drop_iterative {
|
|
// opt-in iterative-drop body. The
|
|
// recursive cascade overflows the C stack on
|
|
// long chains (a million-cell list ≈ 8MB
|
|
// stack); the iterative variant uses an
|
|
// explicit heap-allocated worklist instead.
|
|
self.emit_iterative_drop_fn_for_type(td);
|
|
} else {
|
|
self.emit_drop_fn_for_type(td);
|
|
}
|
|
// tag-conditional partial-drop
|
|
// helper alongside the recursive/iterative drop fn.
|
|
// Used at carve-out sites where a binder's runtime
|
|
// tag is dynamic and `moved_slots` is a strict
|
|
// subset of its ptr fields. Same shape regardless
|
|
// of `(drop-iterative)` (the helper is single-shot
|
|
// on the binder; field cascades go through their
|
|
// own drop fns which themselves choose recursive
|
|
// vs iterative).
|
|
self.emit_partial_drop_fn_for_type(td);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// emit one specialised version of a polymorphic def.
|
|
/// Substitutes rigid vars in both the type and the body, then
|
|
/// calls `emit_fn` against a synthetic FnDef whose `name` already
|
|
/// contains the descriptor — the existing mangling concatenates
|
|
/// `ail_<module>_<name>` and produces the desired symbol.
|
|
fn emit_const(&mut self, c: &ConstDef) -> Result<()> {
|
|
// non-literal const values (e.g. ctor expressions) are
|
|
// not emitted as globals. They are inlined at every `Term::Var`
|
|
// reference site — sound because `check_const` rejects effectful
|
|
// bodies, so re-evaluating the body at each use is observably
|
|
// equivalent to a single computation. Trade-off: a long
|
|
// recursive const evaluated in many places duplicates work,
|
|
// but the demo-scale workloads shipped in the stdlib
|
|
// examples are small enough that this is a non-issue. A
|
|
// future iter may layer a `@llvm.global_ctors`-style init
|
|
// path on top to share the result across reference sites.
|
|
let lty = llvm_type(&c.ty)?;
|
|
let lit = match &c.value {
|
|
Term::Lit { lit } => lit,
|
|
_ => return Ok(()),
|
|
};
|
|
let (val_ty, val) = match lit {
|
|
Literal::Int { value } => ("i64".to_string(), value.to_string()),
|
|
Literal::Bool { value } => (
|
|
"i1".to_string(),
|
|
if *value { "true".into() } else { "false".into() },
|
|
),
|
|
Literal::Unit => ("i8".to_string(), "0".to_string()),
|
|
Literal::Str { value } => {
|
|
// emit a packed-struct global and return a
|
|
// constexpr-GEP pointer landing on the `len`-field (now
|
|
// the first field of the packed struct, since the
|
|
// hs.1-era sentinel rc-header slot was removed). Every
|
|
// IR-Str pointer in this codegen pipeline has the
|
|
// shape len at offset 0, bytes at offset 8.
|
|
let g = self.intern_str_literal("str", value);
|
|
let total = c_byte_len(value); // bytes + NUL
|
|
(
|
|
"ptr".to_string(),
|
|
format!(
|
|
"getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)",
|
|
),
|
|
)
|
|
}
|
|
Literal::Float { bits } => ("double".to_string(), format!("0x{:016X}", bits)),
|
|
};
|
|
if val_ty != lty {
|
|
return Err(CodegenError::Internal(format!(
|
|
"const type mismatch: {} vs {}",
|
|
lty, val_ty
|
|
)));
|
|
}
|
|
self.header.push_str(&format!(
|
|
"@ail_{module}_{name} = constant {ty} {val}\n",
|
|
module = self.module_name,
|
|
name = c.name,
|
|
ty = lty,
|
|
val = val,
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns `true` when the codegen should emit the LLVM
|
|
/// `alwaysinline` attribute on this fn's `define` line. The
|
|
/// allowlist is the set of primitive-instance bodies emitted by
|
|
/// `try_emit_primitive_instance_body` whose IR is so trivial
|
|
/// (one `icmp` / `fcmp` / `call` plus a `ret`) that `-O0` build
|
|
/// paths pay an unacceptable per-call overhead without inlining,
|
|
/// while `-O2` would inline these unconditionally anyway. The
|
|
/// list mirrors the intercept arm set in
|
|
/// `try_emit_primitive_instance_body` for the equality /
|
|
/// ordering / Float-comparison primitives. Checked against the
|
|
/// unmangled fn name; these symbols only live in the prelude
|
|
/// module so cross-module collisions are not a concern.
|
|
fn intercept_emit_wants_alwaysinline(symbol: &str) -> bool {
|
|
if matches!(
|
|
symbol,
|
|
"eq__Int" | "eq__Bool" | "eq__Str" | "eq__Unit"
|
|
| "compare__Int" | "compare__Bool" | "compare__Str"
|
|
| "float_eq" | "float_ne"
|
|
| "float_lt" | "float_le"
|
|
| "float_gt" | "float_ge"
|
|
) {
|
|
return true;
|
|
}
|
|
// The polymorphic free helpers `ne` / `lt` / `le` / `gt` /
|
|
// `ge` mono into per-type bodies. At `_Int` (iter
|
|
// operator-routing-eq-ord.1 Task 13) they are intercepted as
|
|
// single direct `icmp` instructions in
|
|
// `try_emit_primitive_instance_body` — same shape as
|
|
// `eq__Int`, ie. one `icmp` + one `ret`. At other types
|
|
// (`_Bool`, `_Str`, user ADTs) they keep their polymorphic
|
|
// body — a one-arm-deep `match (compare x y) (case LT ... )
|
|
// (case _ ... )` — which still folds at `-O2` provided the
|
|
// mono symbol carries `alwaysinline`. Both shapes benefit
|
|
// from the attribute; the stem-prefix match covers all T the
|
|
// mono pass produces uniformly so the bench-perf parity with
|
|
// the pre-milestone arithmetic-comparator emission is
|
|
// preserved end-to-end.
|
|
if let Some(stem) = symbol.split("__").next() {
|
|
if matches!(stem, "ne" | "lt" | "le" | "gt" | "ge") {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn emit_fn(&mut self, f: &FnDef) -> Result<()> {
|
|
// also lift `param_modes` out of the fn type. The
|
|
// fn-return Own-param dec emission below consults it to decide
|
|
// which params get a drop call before `ret`. `Implicit`
|
|
// entries (legacy / unannotated) and `Borrow` entries are
|
|
// skipped — only `Own` carries the static "caller handed off
|
|
// ownership" signal.
|
|
let (param_tys, ret_ty, param_modes) = match &f.ty {
|
|
Type::Fn {
|
|
params,
|
|
ret,
|
|
param_modes,
|
|
..
|
|
} => (params.clone(), (**ret).clone(), param_modes.clone()),
|
|
other => {
|
|
return Err(CodegenError::NotFnType(
|
|
ailang_core::pretty::type_to_string(other),
|
|
));
|
|
}
|
|
};
|
|
|
|
let llvm_param_tys: Vec<String> =
|
|
param_tys.iter().map(llvm_type).collect::<Result<_>>()?;
|
|
let llvm_ret = llvm_type(&ret_ty)?;
|
|
|
|
self.locals.clear();
|
|
self.counter = 0;
|
|
// Per-fn body: the sidetable starts empty. Top-level fn references
|
|
// get registered on demand by `lower_term(Term::Var)`.
|
|
self.ssa_fn_sigs.clear();
|
|
// lambda thunks live in the same module body but get
|
|
// collected during lowering and appended after the parent fn.
|
|
self.current_def = f.name.clone();
|
|
self.lam_counter = 0;
|
|
// move tracking is per-fn-body.
|
|
self.moved_slots.clear();
|
|
// Param-mode lookup is per-fn-body. Built from the fn type's
|
|
// `param_modes` (already destructured above) and
|
|
// consulted by `lower_match`'s Iter A gate to skip arm-close
|
|
// pattern-binder dec when the scrutinee is a non-Own param.
|
|
self.current_param_modes.clear();
|
|
// Per-fn-body loop-binder bookkeeping. The map is
|
|
// populated/restored by the `Term::Loop` arm of `lower_term`;
|
|
// the side buffer collects alloca instructions for hoisting;
|
|
// the marker is captured by `start_block("entry")` below.
|
|
self.binder_allocas.clear();
|
|
self.pending_entry_allocas.clear();
|
|
self.entry_block_end_marker = None;
|
|
for (i, pname) in f.params.iter().enumerate() {
|
|
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit);
|
|
self.current_param_modes.insert(pname.clone(), mode);
|
|
}
|
|
// run escape analysis over the fn body. The result
|
|
// is queried at every `Term::Ctor` / `Term::Lam` lowering site
|
|
// to decide between `alloca` (non-escaping) and the runtime
|
|
// allocator (escaping). The analysis is purely additive — a
|
|
// stale or empty result only loses optimisation opportunities,
|
|
// never correctness.
|
|
self.non_escape = escape::analyze_fn_body(&f.body);
|
|
|
|
let mut sig = format!(
|
|
"define {ret} @ail_{module}_{name}(",
|
|
ret = llvm_ret,
|
|
module = self.module_name,
|
|
name = f.name
|
|
);
|
|
for (i, ((pname, pty), pty_ail)) in f
|
|
.params
|
|
.iter()
|
|
.zip(llvm_param_tys.iter())
|
|
.zip(param_tys.iter())
|
|
.enumerate()
|
|
{
|
|
if i > 0 {
|
|
sig.push_str(", ");
|
|
}
|
|
// SSA argument name: %arg_<name>
|
|
let pssa = format!("%arg_{}", pname);
|
|
sig.push_str(&format!("{pty} {pssa}"));
|
|
self.locals.push((
|
|
pname.clone(),
|
|
pssa.clone(),
|
|
pty.clone(),
|
|
pty_ail.clone(),
|
|
));
|
|
// if this param is a function value, record its sig
|
|
// so that `f(args)` inside the body can emit an indirect call.
|
|
if let Some(fs) = fn_sig_from_type(pty_ail) {
|
|
self.ssa_fn_sigs.insert(pssa, fs);
|
|
}
|
|
}
|
|
if Self::intercept_emit_wants_alwaysinline(&f.name) {
|
|
// Append the `alwaysinline` attribute between the close-paren
|
|
// of the parameter list and the `{` opening the body. Symbols
|
|
// matched here are emitted by `try_emit_primitive_instance_body`;
|
|
// forcing inlining keeps the single `icmp` / `fcmp` body folded
|
|
// at every use site under `-O0` (matching the IR shape the
|
|
// deleted `lower_eq` direct-emit path produced).
|
|
sig.push_str(") alwaysinline {\n");
|
|
} else {
|
|
sig.push_str(") {\n");
|
|
}
|
|
self.body.push_str(&sig);
|
|
self.start_block("entry");
|
|
|
|
// primitive-instance body intercept. Returns true if
|
|
// the body was emitted in full (including the closing `}\n\n`).
|
|
// When that fires we skip the normal body-lowering block below
|
|
// but still fall through to deferred-thunk flush + closure-pair
|
|
// emission, so the intercepted fn participates in the same
|
|
// post-body machinery as every other top-level fn (iter 23.2.2
|
|
// fixup: previously this short-circuited past
|
|
// `emit_adapter_and_static_closure`, leaving the fn without a
|
|
// closure-pair symbol — a footgun the moment any caller
|
|
// referenced it by value).
|
|
let body_was_intercepted =
|
|
self.try_emit_primitive_instance_body(&f.name, &llvm_param_tys, &llvm_ret)?;
|
|
|
|
if !body_was_intercepted {
|
|
let (val, val_ty) = self.lower_term(&f.body)?;
|
|
if !self.block_terminated {
|
|
if val_ty != llvm_ret {
|
|
return Err(CodegenError::Internal(format!(
|
|
"fn `{}`: body type {val_ty} != return type {llvm_ret}",
|
|
f.name
|
|
)));
|
|
}
|
|
// fn-return Own-param dec. Symmetric to
|
|
// 18c.3/18c.4's `Term::Let`-scope-close drop and 18d.4's
|
|
// arm-close pattern-binder dec, fired at the lexical
|
|
// close of a fn body. For each parameter with
|
|
// `ParamMode::Own`, emit a drop call iff:
|
|
// - alloc strategy is `Rc`,
|
|
// - the parameter's lowered type is `ptr`,
|
|
// - uniqueness inference recorded `consume_count == 0`
|
|
// for the param in this fn's body (no internal use
|
|
// consumed it; the param's slot owns the only ref the
|
|
// callee received from the caller's hand-off),
|
|
// - the param's SSA is not the body's tail value
|
|
// (returning the param transfers ownership back to
|
|
// the caller's frame; caller dec's, not us),
|
|
// - the current block is still open.
|
|
//
|
|
// `Implicit`-mode params do NOT get this dec: they have
|
|
// no static "caller handed off ownership" signal —
|
|
// emitting a dec here might double-dec a value the caller
|
|
// also dec's. `Borrow`-mode params definitely don't get
|
|
// dec'd (the caller still owns them).
|
|
//
|
|
// Closes the 18c.3/18c.4 carve-out: "fn parameters still
|
|
// don't get dec'd at fn return — the caller-handed-off-
|
|
// ownership signal is the `(own T)` mode, but wiring it
|
|
// through codegen is part of the wider mode-aware story."
|
|
if matches!(self.alloc, AllocStrategy::Rc) {
|
|
for (i, ((pname, plty), pty_ail)) in f
|
|
.params
|
|
.iter()
|
|
.zip(llvm_param_tys.iter())
|
|
.zip(param_tys.iter())
|
|
.enumerate()
|
|
{
|
|
if plty != "ptr" {
|
|
continue;
|
|
}
|
|
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit);
|
|
if !matches!(mode, ParamMode::Own) {
|
|
continue;
|
|
}
|
|
let consume_count = self
|
|
.uniqueness
|
|
.get(&(self.current_def.clone(), pname.clone()))
|
|
.map(|info| info.consume_count)
|
|
.unwrap_or(u32::MAX);
|
|
if consume_count != 0 {
|
|
continue;
|
|
}
|
|
let p_ssa = format!("%arg_{}", pname);
|
|
if val == p_ssa {
|
|
// The param IS the fn's return value —
|
|
// ownership transfers back to the caller.
|
|
continue;
|
|
}
|
|
let moves = self
|
|
.moved_slots
|
|
.get(pname)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
if moves.is_empty() {
|
|
// Route through the per-type drop fn for the
|
|
// param's static type. `field_drop_call`
|
|
// resolves `Type::Con` to `drop_<owner>_<T>`
|
|
// and falls back to `ailang_rc_dec` for
|
|
// closure / Var fields — closure-typed Own
|
|
// params therefore use the same shallow free
|
|
// 18c.4 set up for closure-typed ADT fields,
|
|
// matching the iter brief's "closure-typed
|
|
// Own params follow whichever debt path
|
|
// 18c.4 set up" carve-out.
|
|
let drop_call = self.field_drop_call(pty_ail);
|
|
self.body.push_str(&format!(
|
|
" call void @{drop_call}(ptr {p_ssa})\n"
|
|
));
|
|
} else {
|
|
// dynamic-tag partial-drop
|
|
// via the per-type helper. The param's runtime
|
|
// tag is dynamic but its static type is known
|
|
// (`pty_ail`), so we route through
|
|
// `partial_drop_<owner>_<T>(p, mask)` which
|
|
// dispatches on the runtime tag and dec's only
|
|
// the unmoved fields. The fallback to shallow
|
|
// `ailang_rc_dec` only fires for non-ADT param
|
|
// types (Str, fn-typed, vars) — none of which
|
|
// can populate `moved_slots` in practice, so
|
|
// the fallback is dead under the typechecker.
|
|
let sym = Self::partial_drop_symbol_for_type(
|
|
self, pty_ail,
|
|
);
|
|
let mask = Self::build_moved_mask(&moves);
|
|
if let (Some(sym), Some(mask)) = (sym, mask) {
|
|
self.body.push_str(&format!(
|
|
" call void @{sym}(ptr {p_ssa}, i64 {mask})\n"
|
|
));
|
|
} else {
|
|
self.body.push_str(&format!(
|
|
" call void @ailang_rc_dec(ptr {p_ssa})\n"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.body
|
|
.push_str(&format!(" ret {val_ty} {val}\n}}\n\n"));
|
|
} else {
|
|
// a `tail-app`/`tail-do` at the body root already
|
|
// emitted its own `ret` (after `musttail call`). Just close
|
|
// the function body — no fall-through ret.
|
|
self.body.push_str("}\n\n");
|
|
}
|
|
} // close `if !body_was_intercepted`
|
|
|
|
// splice any deferred entry-block allocas at the
|
|
// captured marker (immediately after the `entry:\n` label).
|
|
// If no mut-vars were emitted for this fn,
|
|
// `pending_entry_allocas` is empty and this is a no-op. The
|
|
// splice runs after body lowering finalises but BEFORE the
|
|
// deferred-thunks flush — thunks are already self-contained
|
|
// strings parked in `deferred_thunks`, with their own splice
|
|
// already performed inside the lambda emission boundary in
|
|
// `lambda.rs`.
|
|
if !self.pending_entry_allocas.is_empty() {
|
|
let marker = self.entry_block_end_marker.ok_or_else(|| {
|
|
CodegenError::Internal(format!(
|
|
"fn `{}`: pending_entry_allocas accumulated but no entry-block \
|
|
marker was captured",
|
|
f.name
|
|
))
|
|
})?;
|
|
let allocas = std::mem::take(&mut self.pending_entry_allocas);
|
|
self.body.insert_str(marker, &allocas);
|
|
}
|
|
|
|
// flush lambda thunks collected while lowering this fn's
|
|
// body. They go after the closing `}` of the parent fn, before
|
|
// the adapter, so the parent fn is contiguous.
|
|
for t in self.deferred_thunks.drain(..) {
|
|
self.body.push_str(&t);
|
|
}
|
|
|
|
// emit closure-pair scaffold (adapter + static closure)
|
|
// for this fn. The adapter takes an extra `ptr %_env` (ignored,
|
|
// null sentinel for top-level fns) and forwards to the real fn.
|
|
// The static closure pair `{ adapter_ptr, null }` is the value
|
|
// produced when this fn is referenced as a `Term::Var` value
|
|
// (closure-pair pointer ABI).
|
|
self.emit_adapter_and_static_closure(&f.name, &llvm_param_tys, &llvm_ret);
|
|
Ok(())
|
|
}
|
|
|
|
/// closure-pair scaffold for a top-level fn. Always emitted
|
|
/// (one wrapper per fn), so cross-module references just use the
|
|
/// `<m>_<f>_clos` symbol without coordination.
|
|
fn emit_adapter_and_static_closure(
|
|
&mut self,
|
|
fn_name: &str,
|
|
param_tys: &[String],
|
|
ret_ty: &str,
|
|
) {
|
|
let m = self.module_name;
|
|
// Adapter: `(ptr %_env, params...) -> ret` calls the real fn,
|
|
// returning whatever it returned.
|
|
let mut adapter = format!(
|
|
"define {ret} @ail_{m}_{fn_name}_adapter(ptr %_env",
|
|
ret = ret_ty,
|
|
);
|
|
for (i, pty) in param_tys.iter().enumerate() {
|
|
adapter.push_str(&format!(", {pty} %a{i}"));
|
|
}
|
|
adapter.push_str(") {\nentry:\n");
|
|
let mut call_args = String::new();
|
|
for (i, pty) in param_tys.iter().enumerate() {
|
|
if i > 0 {
|
|
call_args.push_str(", ");
|
|
}
|
|
call_args.push_str(&format!("{pty} %a{i}"));
|
|
}
|
|
adapter.push_str(&format!(
|
|
" %r = call {ret} @ail_{m}_{fn_name}({call_args})\n",
|
|
ret = ret_ty,
|
|
));
|
|
adapter.push_str(&format!(" ret {ret} %r\n}}\n\n", ret = ret_ty));
|
|
self.body.push_str(&adapter);
|
|
|
|
// Static closure pair: `{ adapter_ptr, null }`. The address of
|
|
// this global IS the fn-value that escapes to other code.
|
|
self.header.push_str(&format!(
|
|
"@ail_{m}_{fn_name}_clos = private unnamed_addr constant {{ ptr, ptr }} {{ ptr @ail_{m}_{fn_name}_adapter, ptr null }}\n"
|
|
));
|
|
}
|
|
|
|
/// Lowers a term to (SSA value string, LLVM type).
|
|
pub(crate) fn lower_term(&mut self, t: &Term) -> Result<(String, String)> {
|
|
match t {
|
|
Term::Lit { lit } => Ok(match lit {
|
|
Literal::Int { value } => (value.to_string(), "i64".into()),
|
|
Literal::Bool { value } => (
|
|
if *value { "true".into() } else { "false".into() },
|
|
"i1".into(),
|
|
),
|
|
Literal::Str { value } => {
|
|
// language `Str` literals materialise as
|
|
// a constexpr-GEP into the packed-struct global,
|
|
// landing on the `len`-field (now the first field,
|
|
// since the hs.1-era sentinel rc-header slot was
|
|
// removed). IR-Str pointer carries len at 0, bytes
|
|
// at +8.
|
|
let g = self.intern_str_literal("str", value);
|
|
let total = c_byte_len(value); // bytes + NUL
|
|
(
|
|
format!(
|
|
"getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)",
|
|
),
|
|
"ptr".into(),
|
|
)
|
|
}
|
|
Literal::Unit => ("0".into(), "i8".into()),
|
|
Literal::Float { bits } => (format!("0x{:016X}", bits), "double".into()),
|
|
}),
|
|
Term::Var { name } => {
|
|
// Floats iter 4.5: bare-value Float constants resolve
|
|
// directly to LLVM hex-float `double` SSA values at
|
|
// the use site — no global declaration, no
|
|
// intern-global path. Parallel to how `__unreachable__`
|
|
// is intercepted, but as a value rather than a
|
|
// terminator (constants are SSA values; the
|
|
// unreachable-instruction path doesn't apply).
|
|
match name.as_str() {
|
|
"nan" => return Ok(("0x7FF8000000000000".into(), "double".into())),
|
|
"inf" => return Ok(("0x7FF0000000000000".into(), "double".into())),
|
|
"neg_inf" => return Ok(("0xFFF0000000000000".into(), "double".into())),
|
|
_ => {}
|
|
}
|
|
// `__unreachable__` is a polymorphic bottom
|
|
// value (`forall a. a`). At codegen we emit LLVM
|
|
// `unreachable` as the block terminator and return a
|
|
// dummy SSA value. The surrounding `if`/`match`/`seq`
|
|
// already inspects `block_terminated` and forwards the
|
|
// sibling branch's type, so the type we report here is
|
|
// not consumed by a phi node — `i8` is a sound
|
|
// placeholder. Subsequent emissions in this block are
|
|
// gated by `block_terminated`.
|
|
if name == "__unreachable__" {
|
|
self.body.push_str(" unreachable\n");
|
|
self.block_terminated = true;
|
|
return Ok(("0".into(), "i8".into()));
|
|
}
|
|
// Loop binders take precedence over let-bound / param
|
|
// resolutions. Shadowing across nested loops is
|
|
// handled by the `Term::Loop` arm's push-and-restore
|
|
// on the BTreeMap entry, so a single `get` suffices
|
|
// here (the entry visible at this point is the
|
|
// innermost binding).
|
|
if let Some((alloca_name, ail_ty)) =
|
|
self.binder_allocas.get(name).cloned()
|
|
{
|
|
let llvm_ty = llvm_type(&ail_ty)?;
|
|
let load_ssa = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {load_ssa} = load {llvm_ty}, ptr {alloca_name}\n"
|
|
));
|
|
return Ok((load_ssa, llvm_ty));
|
|
}
|
|
if let Some((_, ssa, ty, _)) =
|
|
self.locals.iter().rev().find(|(n, _, _, _)| n == name)
|
|
{
|
|
return Ok((ssa.clone(), ty.clone()));
|
|
}
|
|
// bare reference to a top-level fn yields a fn-pointer
|
|
// value of type `ptr`. Cross-module via `prefix.def`, current
|
|
// module via plain `def`. Sidetable carries the sig.
|
|
if let Some((global, sig)) = self.resolve_top_level_fn(name) {
|
|
self.ssa_fn_sigs.entry(global.clone()).or_insert(sig);
|
|
return Ok((global, "ptr".into()));
|
|
}
|
|
// const lookup. Both bare (`xs`) and qualified
|
|
// (`prefix.xs`) forms resolve through `module_consts`.
|
|
// Literal-bodied consts get a load from the global; non-
|
|
// literal bodies (e.g. ctor expressions) are inlined.
|
|
if let Some((owner_module, cdef)) = self.resolve_const(name) {
|
|
let lty = llvm_type(&cdef.ty)?;
|
|
if matches!(&cdef.value, Term::Lit { .. }) {
|
|
let v = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {v} = load {lty}, ptr @ail_{owner_module}_{cname}, align 8\n",
|
|
cname = cdef.name,
|
|
));
|
|
return Ok((v, lty));
|
|
} else {
|
|
// Inline the const body. Switch module context to
|
|
// the owning module while lowering so any nested
|
|
// bare references resolve in the const's home
|
|
// namespace. Simpler approach: call lower_term
|
|
// directly; the current emitter's module context
|
|
// is fine because cross-module ctors are already
|
|
// qualified in the AST after typecheck.
|
|
let value = cdef.value.clone();
|
|
return self.lower_term(&value);
|
|
}
|
|
}
|
|
Err(CodegenError::UnknownVar(name.clone()))
|
|
}
|
|
Term::Let { name, value, body } => {
|
|
// decide whether this let-binder is
|
|
// trackable for `dec` emission BEFORE lowering. The
|
|
// value term must lower through `ailang_rc_alloc` —
|
|
// that means `Term::Ctor` / `Term::Lam` whose escape
|
|
// analysis says "heap" (not `alloca`) under
|
|
// `--alloc=rc`. Other value shapes (calls, vars,
|
|
// literals) lower to SSAs we don't statically own at
|
|
// this scope.
|
|
let trackable = self.is_rc_heap_allocated(value);
|
|
|
|
let val_ail = self.synth_arg_type(value)?;
|
|
let (val_ssa, val_ty) = self.lower_term(value)?;
|
|
self.locals
|
|
.push((name.clone(), val_ssa.clone(), val_ty.clone(), val_ail));
|
|
// let-alias-aware mode propagation.
|
|
// If `value` is a `Term::Var` referencing a name in
|
|
// `current_param_modes`, the let-binder inherits that
|
|
// mode for the duration of the body. Without this,
|
|
// `(let a t (match a ...))` where `t` is an Implicit
|
|
// / Borrow-mode param defeats the
|
|
// `scrutinee_is_owned` gate in `lower_match` (the
|
|
// gate looks up `a` in `current_param_modes`, misses,
|
|
// and defaults to "owned" — Iter A then dec's
|
|
// pattern-binders whose underlying memory belongs to
|
|
// the caller).
|
|
//
|
|
// Restored on let-body-close (push/pop pattern).
|
|
let inherited_mode: Option<ParamMode> = match value.as_ref() {
|
|
Term::Var { name: src } => {
|
|
self.current_param_modes.get(src).copied()
|
|
}
|
|
_ => None,
|
|
};
|
|
let prior_mode = if let Some(m) = inherited_mode {
|
|
let prior = self.current_param_modes.insert(name.clone(), m);
|
|
Some(prior)
|
|
} else {
|
|
None
|
|
};
|
|
let r = self.lower_term(body);
|
|
if let Some(prior) = prior_mode {
|
|
match prior {
|
|
Some(m) => {
|
|
self.current_param_modes.insert(name.clone(), m);
|
|
}
|
|
None => {
|
|
self.current_param_modes.remove(name);
|
|
}
|
|
}
|
|
}
|
|
self.locals.pop();
|
|
// lift the binder's move set out of the
|
|
// side table. The binder is leaving scope here; we
|
|
// remove the entry whether or not we end up using it
|
|
// for the dec emission below. `take` returns a
|
|
// by-value `BTreeSet<usize>` (or empty) so we can
|
|
// both consult and clear in one move.
|
|
let moves_for_binder: BTreeSet<usize> =
|
|
self.moved_slots.remove(name).unwrap_or_default();
|
|
|
|
// emit a drop call at scope close iff:
|
|
// - we're tracking this binder (heap RC alloc above),
|
|
// - the value type is `ptr` (not a primitive),
|
|
// - uniqueness inference recorded `consume_count == 0`
|
|
// for the binder (no callee / outer term has already
|
|
// taken ownership; the binder owns the only ref),
|
|
// - the body's tail value is NOT the binder itself
|
|
// (a binder that flows out as the result transfers
|
|
// ownership to the caller — caller dec's), and
|
|
// - the current block is still open (a tail-call /
|
|
// `unreachable` already exited; nothing to emit).
|
|
//
|
|
// the drop call is no longer a raw
|
|
// `ailang_rc_dec`. For a `Term::Ctor` binder the call
|
|
// routes through `@drop_<owner>_<TypeName>` so any
|
|
// boxed children of the cell cascade through their
|
|
// own drop fns; for a `Term::Lam` binder the call
|
|
// routes through the per-pair drop fn the lambda
|
|
// emission recorded in `closure_drops`. Both shapes
|
|
// call `ailang_rc_dec` on the outer box internally,
|
|
// so the refcount story is unchanged.
|
|
if trackable && val_ty == "ptr" && !self.block_terminated {
|
|
let consume_count = self
|
|
.uniqueness
|
|
.get(&(self.current_def.clone(), name.clone()))
|
|
.map(|info| info.consume_count)
|
|
.unwrap_or(u32::MAX); // Defensive: skip if missing.
|
|
let body_returns_binder = match &r {
|
|
Ok((body_ssa, _)) => body_ssa == &val_ssa,
|
|
Err(_) => true, // Don't emit on error path either.
|
|
};
|
|
if consume_count == 0 && !body_returns_binder {
|
|
// when the binder has moved-out
|
|
// pattern slots, the uniform `drop_<m>_<T>`
|
|
// would re-dec values that have already been
|
|
// transferred to other binders. Inline a
|
|
// per-field dec sequence that skips moved
|
|
// slots; non-moved pointer slots dec via
|
|
// `field_drop_call` (null-guarded drop fns
|
|
// matching the recursive cascade in
|
|
// `emit_drop_fn_for_type`). For the empty-
|
|
// moves common case (every fixture pre-18d.3
|
|
// and most fixtures post-18d.3) we emit the
|
|
// identical `drop_<m>_<T>(ptr)` call as
|
|
// 18c.4, preserving IR shape.
|
|
if !moves_for_binder.is_empty() {
|
|
self.emit_inlined_partial_drop(
|
|
value,
|
|
&val_ssa,
|
|
&moves_for_binder,
|
|
)?;
|
|
} else {
|
|
let drop_sym = self.drop_symbol_for_binder(value, &val_ssa);
|
|
self.body.push_str(&format!(
|
|
" call void @{drop_sym}(ptr {val_ssa})\n"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
r
|
|
}
|
|
Term::If { cond, then, else_ } => {
|
|
let (cond_v, cond_ty) = self.lower_term(cond)?;
|
|
if cond_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"if cond not i1: {cond_ty}"
|
|
)));
|
|
}
|
|
let id = self.fresh_id();
|
|
let then_lbl = format!("then.{id}");
|
|
let else_lbl = format!("else.{id}");
|
|
let join_lbl = format!("join.{id}");
|
|
|
|
self.body.push_str(&format!(
|
|
" br i1 {cond_v}, label %{then_lbl}, label %{else_lbl}\n"
|
|
));
|
|
|
|
self.start_block(&then_lbl);
|
|
let (then_v, then_ty) = self.lower_term(then)?;
|
|
// a tail-call in this branch already terminated
|
|
// its block; skip its branch to join and exclude from phi.
|
|
let then_terminated = self.block_terminated;
|
|
let then_block_end = self.current_block.clone();
|
|
if !then_terminated {
|
|
self.body.push_str(&format!(" br label %{join_lbl}\n"));
|
|
}
|
|
|
|
self.start_block(&else_lbl);
|
|
let (else_v, else_ty) = self.lower_term(else_)?;
|
|
let else_terminated = self.block_terminated;
|
|
let else_block_end = self.current_block.clone();
|
|
if !then_terminated && !else_terminated && then_ty != else_ty {
|
|
return Err(CodegenError::Internal(format!(
|
|
"if branches type mismatch: {then_ty} vs {else_ty}"
|
|
)));
|
|
}
|
|
if !else_terminated {
|
|
self.body.push_str(&format!(" br label %{join_lbl}\n"));
|
|
}
|
|
|
|
// if both branches terminated, the whole `if` is
|
|
// terminated and no join is reachable. Mark and bail.
|
|
if then_terminated && else_terminated {
|
|
self.block_terminated = true;
|
|
return Ok(("0".into(), then_ty));
|
|
}
|
|
// If exactly one branch terminated, the join receives only
|
|
// the other branch's value — no phi node is needed.
|
|
if then_terminated {
|
|
self.start_block(&join_lbl);
|
|
return Ok((else_v, else_ty));
|
|
}
|
|
if else_terminated {
|
|
self.start_block(&join_lbl);
|
|
return Ok((then_v, then_ty));
|
|
}
|
|
|
|
self.start_block(&join_lbl);
|
|
let phi = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {phi} = phi {ty} [ {tv}, %{tlbl} ], [ {ev}, %{elbl} ]\n",
|
|
ty = then_ty,
|
|
tv = then_v,
|
|
tlbl = then_block_end,
|
|
ev = else_v,
|
|
elbl = else_block_end,
|
|
));
|
|
// if both branches yield the same fn-pointer sig,
|
|
// forward it to the phi SSA so subsequent indirect calls
|
|
// can resolve.
|
|
if then_ty == "ptr" {
|
|
if let (Some(ts), Some(es)) =
|
|
(self.ssa_fn_sigs.get(&then_v), self.ssa_fn_sigs.get(&else_v))
|
|
{
|
|
if ts.params == es.params && ts.ret == es.ret {
|
|
let merged = ts.clone();
|
|
self.ssa_fn_sigs.insert(phi.clone(), merged);
|
|
}
|
|
}
|
|
}
|
|
Ok((phi, then_ty))
|
|
}
|
|
Term::App { callee, args, tail } => {
|
|
// Direct call when the callee is a `Var` referring to a
|
|
// statically-known target (builtin, current-module fn,
|
|
// qualified cross-module fn) AND not shadowed by a local.
|
|
// Otherwise we fall through to the indirect-call path,
|
|
// which lowers the callee to a fn-pointer and looks up
|
|
// its sig in the sidetable.
|
|
if let Term::Var { name } = callee.as_ref() {
|
|
let shadowed = self.locals.iter().any(|(n, _, _, _)| n == name);
|
|
if !shadowed && self.is_static_callee(name) {
|
|
return self.lower_app(name, args, *tail);
|
|
}
|
|
}
|
|
let (callee_ssa, callee_ty) = self.lower_term(callee)?;
|
|
if callee_ty != "ptr" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"indirect call: callee type must be ptr, got {callee_ty}"
|
|
)));
|
|
}
|
|
let sig = self
|
|
.ssa_fn_sigs
|
|
.get(&callee_ssa)
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
CodegenError::Internal(format!(
|
|
"indirect call: no FnSig recorded for `{callee_ssa}`"
|
|
))
|
|
})?;
|
|
self.emit_indirect_call(&callee_ssa, &sig, args, *tail)
|
|
}
|
|
Term::Do { op, args, tail } => self.lower_effect_op(op, args, *tail),
|
|
Term::Ctor { type_name, ctor, args } => {
|
|
// pass the term pointer so `lower_ctor` can
|
|
// consult the escape-analysis result for this exact
|
|
// allocation site.
|
|
let term_ptr = (t as *const Term) as usize;
|
|
self.lower_ctor(type_name, ctor, args, term_ptr)
|
|
}
|
|
Term::Match { scrutinee, arms } => self.lower_match(scrutinee, arms),
|
|
Term::Lam { params, param_tys, ret_ty, effects: _, body } => {
|
|
// same as `Ctor` — pass the term pointer for
|
|
// escape-analysis lookup. A non-escaping closure pair
|
|
// (and its env) lower to `alloca`.
|
|
let term_ptr = (t as *const Term) as usize;
|
|
self.lower_lambda(params, param_tys, ret_ty, body, term_ptr)
|
|
}
|
|
Term::Seq { lhs, rhs } => {
|
|
// lower lhs for its effects, discard the SSA;
|
|
// lower rhs and return its value as the whole expression.
|
|
// lhs may not legally be a `tail` call (the
|
|
// typechecker rejects that), so `block_terminated` is
|
|
// false after it. rhs is in the same tail context as the
|
|
// surrounding seq, so a `tail-app` there will set
|
|
// `block_terminated`; the outer match-arm/fn-body
|
|
// handler honours that.
|
|
let _ = self.lower_term(lhs)?;
|
|
self.lower_term(rhs)
|
|
}
|
|
Term::LetRec { .. } => {
|
|
// `Term::LetRec` is eliminated by the
|
|
// desugar pass before codegen runs, so reaching it
|
|
// here is a bug.
|
|
unreachable!("Term::LetRec eliminated by desugar")
|
|
}
|
|
Term::Clone { value } => {
|
|
// lower the inner value, then emit
|
|
// `call void @ailang_rc_inc(ptr %v)` under `--alloc=rc`.
|
|
// Inc is skipped for non-`ptr` values (primitives like
|
|
// `i64` carry no refcount) and for `@`-prefixed SSAs
|
|
// (top-level fn closure-pair globals live in the LLVM
|
|
// data segment, not heap memory — `runtime/rc.c`'s
|
|
// header layout doesn't apply to them). Codegen elision
|
|
// here matches `runtime/rc.c`'s comment about static
|
|
// pointers.
|
|
let (val_ssa, val_ty) = self.lower_term(value)?;
|
|
if matches!(self.alloc, AllocStrategy::Rc)
|
|
&& val_ty == "ptr"
|
|
&& !val_ssa.starts_with('@')
|
|
&& !self.block_terminated
|
|
{
|
|
self.body.push_str(&format!(
|
|
" call void @ailang_rc_inc(ptr {val_ssa})\n"
|
|
));
|
|
}
|
|
Ok((val_ssa, val_ty))
|
|
}
|
|
Term::ReuseAs { source, body } => {
|
|
// under --alloc=rc, lower as a runtime-
|
|
// refcount-1 dispatch — if the source's box is
|
|
// unique we overwrite it in place (skipping the
|
|
// alloc-and-cascade-dec round-trip); otherwise we
|
|
// allocate a fresh box and dec the source. Other
|
|
// allocators keep the 18d.1 identity behaviour.
|
|
if !matches!(self.alloc, AllocStrategy::Rc) {
|
|
return self.lower_term(body);
|
|
}
|
|
// The body must be a Term::Ctor for the in-place
|
|
// rewrite to make sense. 18d.1 typecheck rejects
|
|
// any other shape; lams are accepted by typecheck
|
|
// but not yet supported by reuse codegen — fall
|
|
// back to identity for those (the body still
|
|
// allocates via ailang_rc_alloc, just without the
|
|
// reuse fast path).
|
|
let (body_type_name, body_ctor, body_args) = match body.as_ref() {
|
|
Term::Ctor { type_name, ctor, args } => (type_name, ctor, args),
|
|
_ => return self.lower_term(body),
|
|
};
|
|
self.lower_reuse_as_rc(source, body_type_name, body_ctor, body_args)
|
|
}
|
|
Term::Loop { binders, body } => {
|
|
// loop-recur iter 3: loop binders are loop-carried
|
|
// values lowered as entry-block allocas (the
|
|
// `pending_entry_allocas` mechanism) registered in
|
|
// `binder_allocas` so the existing `Term::Var` load
|
|
// path resolves them with zero new Var code
|
|
// (representation-sharing; Boss calls 1+2). The loop
|
|
// header is a fresh block reached by an
|
|
// unconditional `br` from the pre-header (current)
|
|
// block; `recur` stores new values into the binder
|
|
// allocas and back-edges here. `clang -O2` mem2reg
|
|
// promotes the allocas to the phi nodes the spec's
|
|
// implementation-shape describes. The loop's value
|
|
// is the body's value on the non-`recur` exit,
|
|
// materialised by the existing `if`/`match` join
|
|
// once `recur` sets `block_terminated` (Boss call 3).
|
|
let id = self.fresh_id();
|
|
let header = format!("loop.header.{id}");
|
|
let mut saved: Vec<(String, Option<(String, Type)>)> = Vec::new();
|
|
let mut frame_slots: Vec<(String, String, String)> = Vec::new();
|
|
for b in binders {
|
|
let alloca_name =
|
|
format!("%loopv_{}_{}", b.name, self.fresh_id());
|
|
let llvm_ty = llvm_type(&b.ty)?;
|
|
self.pending_entry_allocas
|
|
.push_str(&format!(" {alloca_name} = alloca {llvm_ty}\n"));
|
|
let (init_ssa, init_ty) = self.lower_term(&b.init)?;
|
|
if init_ty != llvm_ty {
|
|
return Err(CodegenError::Internal(format!(
|
|
"Term::Loop binder `{}`: init LLVM type {init_ty} != declared {llvm_ty}",
|
|
b.name
|
|
)));
|
|
}
|
|
self.body.push_str(&format!(
|
|
" store {llvm_ty} {init_ssa}, ptr {alloca_name}\n"
|
|
));
|
|
let prior = self
|
|
.binder_allocas
|
|
.insert(b.name.clone(), (alloca_name.clone(), b.ty.clone()));
|
|
saved.push((b.name.clone(), prior));
|
|
frame_slots.push((b.name.clone(), alloca_name, llvm_ty));
|
|
}
|
|
self.body.push_str(&format!(" br label %{header}\n"));
|
|
self.loop_frames.push((header.clone(), frame_slots));
|
|
self.start_block(&header);
|
|
let body_result = self.lower_term(body);
|
|
self.loop_frames.pop();
|
|
// Restore prior bindings unconditionally (defensive
|
|
// on the `?` path).
|
|
for (name, prior) in saved.into_iter().rev() {
|
|
match prior {
|
|
Some(p) => {
|
|
self.binder_allocas.insert(name, p);
|
|
}
|
|
None => {
|
|
self.binder_allocas.remove(&name);
|
|
}
|
|
}
|
|
}
|
|
body_result
|
|
}
|
|
Term::Recur { args } => {
|
|
// loop-recur iter 3: re-enter the innermost
|
|
// enclosing loop. Typecheck (iter 2) pinned arity ==
|
|
// binder count and per-arg type == binder type, so a
|
|
// miss/mismatch here is an internal error. ALL args
|
|
// are lowered to SSA values BEFORE any store, so a
|
|
// recur arg that reads a binder sees its OLD value
|
|
// (recur is a simultaneous positional rebind, e.g.
|
|
// `(recur (+ acc i) (+ i 1))`). The back-edge `br` is
|
|
// a block terminator: set `block_terminated` at
|
|
// recur's OWN emit site (the parallel setter — Boss
|
|
// call 4; tail-app's SET sites byte-unchanged) so the
|
|
// enclosing `if`/`match` join excludes this block
|
|
// exactly like a tail-app block. recur never falls
|
|
// through; its value is never used — return the
|
|
// canonical Unit/dead SSA (`("0","i8")`, the
|
|
// both-if-branches-terminated precedent).
|
|
let (header, slots) = self
|
|
.loop_frames
|
|
.last()
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
CodegenError::Internal(
|
|
"Term::Recur reached codegen with no enclosing loop frame \
|
|
— typecheck should have rejected this earlier"
|
|
.into(),
|
|
)
|
|
})?;
|
|
if args.len() != slots.len() {
|
|
return Err(CodegenError::Internal(format!(
|
|
"Term::Recur arg count {} != enclosing loop binder count {} \
|
|
— typecheck should have rejected this earlier",
|
|
args.len(),
|
|
slots.len()
|
|
)));
|
|
}
|
|
let mut lowered: Vec<(String, String)> =
|
|
Vec::with_capacity(args.len());
|
|
for a in args {
|
|
lowered.push(self.lower_term(a)?);
|
|
}
|
|
for ((arg_ssa, arg_ty), (_name, alloca_name, llvm_ty)) in
|
|
lowered.into_iter().zip(slots.iter())
|
|
{
|
|
if &arg_ty != llvm_ty {
|
|
return Err(CodegenError::Internal(format!(
|
|
"Term::Recur arg LLVM type {arg_ty} != binder type {llvm_ty} \
|
|
— typecheck should have rejected this earlier"
|
|
)));
|
|
}
|
|
self.body.push_str(&format!(
|
|
" store {llvm_ty} {arg_ssa}, ptr {alloca_name}\n"
|
|
));
|
|
}
|
|
self.body.push_str(&format!(" br label %{header}\n"));
|
|
self.block_terminated = true;
|
|
Ok(("0".into(), "i8".into()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolves a `Term::Ctor.type_name` (canonical form: bare
|
|
/// = local TypeDef, qualified `<owner>.<type>` = explicit
|
|
/// cross-module) to the codegen-side `CtorRef`. Qualified names
|
|
/// route through `import_map`; bare names hit the current
|
|
/// module's `module_ctor_index` directly. No imports-walk
|
|
/// fallback — the typechecker (post-canonical-type-form) and the workspace
|
|
/// validator (post-canonical-type-form) have already pinned canonical form.
|
|
pub(crate) fn lookup_ctor_by_type(
|
|
&self,
|
|
type_name: &str,
|
|
ctor_name: &str,
|
|
) -> Result<CtorRef> {
|
|
if type_name.matches('.').count() == 1 {
|
|
let (prefix, suffix) = type_name.split_once('.').expect("checked");
|
|
let target_module = self.import_map.get(prefix).cloned().ok_or_else(|| {
|
|
CodegenError::Internal(format!(
|
|
"qualified ctor `{type_name}/{ctor_name}`: prefix `{prefix}` not in import map"
|
|
))
|
|
})?;
|
|
let cref = self
|
|
.module_ctor_index
|
|
.get(&target_module)
|
|
.and_then(|m| m.get(ctor_name))
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
CodegenError::Internal(format!(
|
|
"qualified ctor `{type_name}/{ctor_name}` not in module `{target_module}`"
|
|
))
|
|
})?;
|
|
if cref.type_name != suffix {
|
|
return Err(CodegenError::Internal(format!(
|
|
"ctor `{ctor_name}` belongs to `{}`, not `{type_name}`",
|
|
cref.type_name
|
|
)));
|
|
}
|
|
Ok(cref)
|
|
} else {
|
|
// Bare type_name is canonical-form local. Hit the
|
|
// current module's ctor table directly; non-match is
|
|
// a hard error.
|
|
let cref = self
|
|
.module_ctor_index
|
|
.get(self.module_name)
|
|
.and_then(|m| m.get(ctor_name))
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
CodegenError::Internal(format!(
|
|
"unknown ctor `{ctor_name}` for type `{type_name}` in module `{}`",
|
|
self.module_name
|
|
))
|
|
})?;
|
|
if cref.type_name != type_name {
|
|
return Err(CodegenError::Internal(format!(
|
|
"ctor `{ctor_name}` belongs to local type `{}`, not `{type_name}`; \
|
|
cross-module ctor refs require qualified type_name",
|
|
cref.type_name
|
|
)));
|
|
}
|
|
Ok(cref)
|
|
}
|
|
}
|
|
|
|
/// collects the set of type names declared in `owner_module`.
|
|
/// Used to mirror the typechecker's `qualify_local_types` rewrite
|
|
/// when reading a polymorphic fn's signature pulled across the
|
|
/// import boundary.
|
|
pub(crate) fn collect_owner_local_types(&self, owner_module: &str) -> BTreeSet<String> {
|
|
self.module_ctor_index
|
|
.get(owner_module)
|
|
.map(|m| {
|
|
m.values()
|
|
.map(|c| c.type_name.clone())
|
|
.collect::<BTreeSet<_>>()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// resolves a ctor in pattern position. The current
|
|
/// `module_name`'s ctor table is consulted first; on miss, the
|
|
/// imported modules are scanned (the typechecker has already
|
|
/// vetted unambiguity, so the first hit wins — local always
|
|
/// shadows imported on conflict). Using `module_name` rather than
|
|
/// `self.ctor_index` matters when emitting a specialised fn body
|
|
/// in the owner's module context (see `emit_specialised_fn`).
|
|
pub(crate) fn lookup_ctor_in_pattern(&self, ctor_name: &str) -> Result<CtorRef> {
|
|
if let Some(cref) = self
|
|
.module_ctor_index
|
|
.get(self.module_name)
|
|
.and_then(|m| m.get(ctor_name))
|
|
.cloned()
|
|
{
|
|
return Ok(cref);
|
|
}
|
|
// Walk the *current* module's imports for fallback. When
|
|
// emitting a specialised fn body in another module, the
|
|
// emitter's `import_map` is still the consumer's; we want the
|
|
// owner's. Look up the owner module's import map indirectly
|
|
// through `self.module` whenever it equals `self.module_name`,
|
|
// and fall back to the active `import_map` only when we are
|
|
// genuinely emitting in the consumer module. Since
|
|
// `emit_specialised_fn` swaps only `module_name`, not
|
|
// `import_map`, the fallback below covers both cases by
|
|
// additionally searching every module in `module_ctor_index`
|
|
// — that's cheap (number of modules in a workspace is small)
|
|
// and the typechecker has already pinned uniqueness.
|
|
for (mname, ctors) in self.module_ctor_index.iter() {
|
|
if mname == self.module_name {
|
|
continue;
|
|
}
|
|
if let Some(cref) = ctors.get(ctor_name).cloned() {
|
|
return Ok(cref);
|
|
}
|
|
}
|
|
Err(CodegenError::Internal(format!(
|
|
"unknown ctor in pattern: `{ctor_name}`"
|
|
)))
|
|
}
|
|
|
|
fn lower_app(&mut self, name: &str, args: &[Term], tail: bool) -> Result<(String, String)> {
|
|
// 2026-05-21 operator-routing-eq-ord: the `if name == "=="`
|
|
// short-circuit that routed through `lower_eq` is gone. `==`
|
|
// is no longer a surface identifier; the typechecker
|
|
// intercepts `(app == …)` as `unknown variable` long before
|
|
// codegen. Equality dispatch lives in
|
|
// `try_emit_primitive_instance_body` for the primitive Eq
|
|
// instance bodies (Int/Bool/Str/Unit), called via the
|
|
// class-method `eq` from prelude.Eq.
|
|
|
|
// Arithmetic ops `+`/`-`/`*`/`/`/`%` are still polymorphic
|
|
// over `{Int, Float}`. Resolve the arg type, then dispatch
|
|
// via `builtin_binop_typed`.
|
|
if is_arithmetic_op(name) {
|
|
if args.len() != 2 {
|
|
return Err(CodegenError::Internal(format!(
|
|
"builtin `{name}` expected 2 args"
|
|
)));
|
|
}
|
|
let arg_ty = self.synth_arg_type(&args[0])?;
|
|
let (instr, operand_ll_ty, result_ll_ty) = builtin_binop_typed(name, &arg_ty)
|
|
.ok_or_else(|| CodegenError::Internal(format!(
|
|
"`{name}` not supported for type `{}`",
|
|
ailang_core::pretty::type_to_string(&arg_ty)
|
|
)))?;
|
|
let (a, _) = self.lower_term(&args[0])?;
|
|
let (b, _) = self.lower_term(&args[1])?;
|
|
let dst = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {dst} = {instr} {operand_ll_ty} {a}, {b}\n"
|
|
));
|
|
// Builtins are not function calls in LLVM (they're inline
|
|
// arithmetic); `tail` annotation has nothing to act on.
|
|
// The typechecker accepts the marker but it is a no-op
|
|
// here. (Iter 14e survey: no fixture marks a builtin tail.)
|
|
let _ = tail;
|
|
return Ok((dst, result_ll_ty.into()));
|
|
}
|
|
if name == "not" {
|
|
if args.len() != 1 {
|
|
return Err(CodegenError::Internal("not arity".into()));
|
|
}
|
|
let (a, _) = self.lower_term(&args[0])?;
|
|
let dst = self.fresh_ssa();
|
|
self.body
|
|
.push_str(&format!(" {dst} = xor i1 {a}, true\n"));
|
|
return Ok((dst, "i1".into()));
|
|
}
|
|
|
|
// Floats iter 4.4: polymorphic neg + 3 monomorphic fn builtins.
|
|
if name == "neg" {
|
|
if args.len() != 1 {
|
|
return Err(CodegenError::Internal("neg arity".into()));
|
|
}
|
|
let arg_ty = self.synth_arg_type(&args[0])?;
|
|
let (a, _) = self.lower_term(&args[0])?;
|
|
let dst = self.fresh_ssa();
|
|
match &arg_ty {
|
|
Type::Con { name, .. } if name == "Int" => {
|
|
self.body.push_str(&format!(" {dst} = sub i64 0, {a}\n"));
|
|
return Ok((dst, "i64".into()));
|
|
}
|
|
Type::Con { name, .. } if name == "Float" => {
|
|
// LLVM 8+ `fneg` correctly handles -0.0.
|
|
self.body.push_str(&format!(" {dst} = fneg double {a}\n"));
|
|
return Ok((dst, "double".into()));
|
|
}
|
|
other => return Err(CodegenError::Internal(format!(
|
|
"`neg` not supported for type `{}`",
|
|
ailang_core::pretty::type_to_string(other)
|
|
))),
|
|
}
|
|
}
|
|
if name == "int_to_float" {
|
|
if args.len() != 1 {
|
|
return Err(CodegenError::Internal("int_to_float arity".into()));
|
|
}
|
|
let (a, _) = self.lower_term(&args[0])?;
|
|
let dst = self.fresh_ssa();
|
|
self.body.push_str(&format!(" {dst} = sitofp i64 {a} to double\n"));
|
|
return Ok((dst, "double".into()));
|
|
}
|
|
if name == "float_to_int_truncate" {
|
|
if args.len() != 1 {
|
|
return Err(CodegenError::Internal("float_to_int_truncate arity".into()));
|
|
}
|
|
let (a, _) = self.lower_term(&args[0])?;
|
|
let dst = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {dst} = call i64 @llvm.fptosi.sat.i64.f64(double {a})\n"
|
|
));
|
|
return Ok((dst, "i64".into()));
|
|
}
|
|
if name == "is_nan" {
|
|
if args.len() != 1 {
|
|
return Err(CodegenError::Internal("is_nan arity".into()));
|
|
}
|
|
let (a, _) = self.lower_term(&args[0])?;
|
|
let dst = self.fresh_ssa();
|
|
// `fcmp uno x, x` returns `i1 1` iff `x` is NaN — only
|
|
// NaN compares unordered against itself.
|
|
self.body.push_str(&format!(" {dst} = fcmp uno double {a}, {a}\n"));
|
|
return Ok((dst, "i1".into()));
|
|
}
|
|
if name == "int_to_str" {
|
|
// lowers to the runtime C glue
|
|
// `ailang_int_to_str(i64) -> ptr` defined in
|
|
// `runtime/str.c`. Returned pointer is a heap-Str (see
|
|
// the `float_to_str` arm below for the dual-realisation
|
|
// ABI note).
|
|
if args.len() != 1 {
|
|
return Err(CodegenError::Internal("int_to_str arity".into()));
|
|
}
|
|
let (a, _) = self.lower_term(&args[0])?;
|
|
let dst = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {dst} = call ptr @ailang_int_to_str(i64 {a})\n"
|
|
));
|
|
return Ok((dst, "ptr".to_string()));
|
|
}
|
|
if name == "float_to_str" {
|
|
// lowers to the runtime C glue
|
|
// `ailang_float_to_str(double) -> ptr` defined in
|
|
// `runtime/str.c`. The returned pointer is a heap-Str
|
|
// (rc_header at offset -8; consumer ABI shared with
|
|
// static-Str). The IR-header declare is unconditional;
|
|
// `runtime/rc.c` is unconditionally linked since iter
|
|
// hs.4 so the `ailang_rc_alloc` callee in str.c always
|
|
// resolves.
|
|
if args.len() != 1 {
|
|
return Err(CodegenError::Internal("float_to_str arity".into()));
|
|
}
|
|
let (a, _) = self.lower_term(&args[0])?;
|
|
let dst = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {dst} = call ptr @ailang_float_to_str(double {a})\n"
|
|
));
|
|
return Ok((dst, "ptr".to_string()));
|
|
}
|
|
if name == "bool_to_str" {
|
|
// lowers to the runtime C glue
|
|
// `ailang_bool_to_str(i1) -> ptr` defined in
|
|
// `runtime/str.c`. Returned pointer is a heap-Str
|
|
// (rc_header at offset -8; consumer ABI shared with
|
|
// static-Str). Used by `show__Bool` in milestone 24.
|
|
if args.len() != 1 {
|
|
return Err(CodegenError::Internal("bool_to_str arity".into()));
|
|
}
|
|
let (a, _) = self.lower_term(&args[0])?;
|
|
let dst = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {dst} = call ptr @ailang_bool_to_str(i1 {a})\n"
|
|
));
|
|
return Ok((dst, "ptr".to_string()));
|
|
}
|
|
if name == "str_clone" {
|
|
// lowers to the runtime C glue
|
|
// `ailang_str_clone(ptr) -> ptr` defined in
|
|
// `runtime/str.c`. Reads `len` from offset 0 of the
|
|
// source Str payload and allocates a fresh heap-Str
|
|
// slab; works uniformly on static-Str and heap-Str
|
|
// inputs because the consumer ABI is identical. Used by
|
|
// `show__Str` in milestone 24.
|
|
if args.len() != 1 {
|
|
return Err(CodegenError::Internal("str_clone arity".into()));
|
|
}
|
|
let (a, _) = self.lower_term(&args[0])?;
|
|
let dst = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {dst} = call ptr @ailang_str_clone(ptr {a})\n"
|
|
));
|
|
return Ok((dst, "ptr".to_string()));
|
|
}
|
|
if name == "str_concat" {
|
|
// lowers to the runtime C glue
|
|
// `ailang_str_concat(ptr, ptr) -> ptr` defined in
|
|
// `runtime/str.c`. Reads `len` from offset 0 of each
|
|
// source Str payload and allocates a fresh heap-Str
|
|
// slab sized for the combined bytes; works uniformly
|
|
// on static-Str and heap-Str inputs because the
|
|
// consumer ABI is identical. Common shape in Show
|
|
// bodies: `(app str_concat "label=" (app int_to_str x))`.
|
|
if args.len() != 2 {
|
|
return Err(CodegenError::Internal("str_concat arity".into()));
|
|
}
|
|
let (a, _) = self.lower_term(&args[0])?;
|
|
let (b, _) = self.lower_term(&args[1])?;
|
|
let dst = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {dst} = call ptr @ailang_str_concat(ptr {a}, ptr {b})\n"
|
|
));
|
|
return Ok((dst, "ptr".to_string()));
|
|
}
|
|
|
|
// Cross-module call: exactly one dot in the name → resolve via import map.
|
|
// Logic identical to the typechecker (see `synth` for `Term::Var`).
|
|
if name.matches('.').count() == 1 {
|
|
let (prefix, suffix) = name.split_once('.').expect("checked");
|
|
// fall back to direct module-name lookup when
|
|
// the prefix isn't in the current module's import_map. A
|
|
// post-mono synthesised body may carry cross-module
|
|
// references to modules its source template didn't import
|
|
// (e.g. `prelude.print__<UserType>` synthesised in
|
|
// `prelude` calls `show_user_adt.show__<UserType>` even
|
|
// though prelude does not import user modules). See the
|
|
// matching fallback in `resolve_top_level_fn` for the
|
|
// companion change on the fn-pointer Var-resolution path.
|
|
let target_module = match self.import_map.get(prefix).cloned() {
|
|
Some(m) => m,
|
|
None if self.module_user_fns.contains_key(prefix) => prefix.to_string(),
|
|
None => {
|
|
return Err(CodegenError::Internal(format!(
|
|
"cross-module call `{name}`: prefix `{prefix}` not in import map"
|
|
)))
|
|
}
|
|
};
|
|
// iter 23.4: codegen-time poly-call dispatch removed. Post-mono
|
|
// every poly call site has been rewritten by `rewrite_mono_calls`
|
|
// to a monomorphic symbol, so the lookup-ladder below sees only
|
|
// user fns / consts / builtins. The pre-iter-23.4 path checked
|
|
// `module_polymorphic_fns` and dispatched to `lower_polymorphic_call`;
|
|
// both are gone (and the supporting Emitter fields with them).
|
|
let target_fns = self
|
|
.module_user_fns
|
|
.get(&target_module)
|
|
.ok_or_else(|| {
|
|
CodegenError::Internal(format!(
|
|
"cross-module call `{name}`: target module `{target_module}` not found in workspace"
|
|
))
|
|
})?;
|
|
let sig = target_fns
|
|
.get(suffix)
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
CodegenError::Internal(format!(
|
|
"cross-module call `{name}`: def `{suffix}` not in module `{target_module}`"
|
|
))
|
|
})?;
|
|
return self.emit_call(&target_module, suffix, &sig, args, tail);
|
|
}
|
|
|
|
// iter 23.4: codegen-time poly-call dispatch removed (see above).
|
|
|
|
// User function in the current module?
|
|
if let Some(sig) = self
|
|
.module_user_fns
|
|
.get(self.module_name)
|
|
.and_then(|m| m.get(name))
|
|
.cloned()
|
|
{
|
|
return self.emit_call(self.module_name, name, &sig, args, tail);
|
|
}
|
|
|
|
// operator-routing-eq-ord.1: prelude fallback for monomorphic
|
|
// prelude fns called bare from a non-prelude module (e.g.
|
|
// `(app float_eq …)`). Mirrors the fallback already in
|
|
// `is_static_callee` and `resolve_top_level_fn`.
|
|
if self.module_name != "prelude" {
|
|
if let Some(sig) = self
|
|
.module_user_fns
|
|
.get("prelude")
|
|
.and_then(|m| m.get(name))
|
|
.cloned()
|
|
{
|
|
return self.emit_call("prelude", name, &sig, args, tail);
|
|
}
|
|
}
|
|
|
|
Err(CodegenError::Internal(format!(
|
|
"unknown callee: `{name}`"
|
|
)))
|
|
}
|
|
|
|
fn emit_call(
|
|
&mut self,
|
|
target_module: &str,
|
|
target_def: &str,
|
|
sig: &FnSig,
|
|
args: &[Term],
|
|
tail: bool,
|
|
) -> Result<(String, String)> {
|
|
let mut compiled_args = Vec::new();
|
|
for (a, exp_ty) in args.iter().zip(sig.params.iter()) {
|
|
let (v, vty) = self.lower_term(a)?;
|
|
if &vty != exp_ty {
|
|
return Err(CodegenError::Internal(format!(
|
|
"call `{target_module}.{target_def}` arg type mismatch: expected {exp_ty}, got {vty}"
|
|
)));
|
|
}
|
|
compiled_args.push((v, vty));
|
|
}
|
|
let arglist = compiled_args
|
|
.iter()
|
|
.map(|(v, t)| format!("{t} {v}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let dst = self.fresh_ssa();
|
|
// emit `musttail call ... ret` for `tail: true`. The
|
|
// call SSA flows directly into the `ret`, satisfying LLVM's
|
|
// "must immediately ret" rule. Same calling convention and
|
|
// signature as the surrounding fn (the typechecker enforces
|
|
// type compatibility).
|
|
let call_kw = if tail { "musttail call" } else { "call" };
|
|
self.body.push_str(&format!(
|
|
" {dst} = {call_kw} {ret} @ail_{module}_{name}({arglist})\n",
|
|
ret = sig.ret,
|
|
module = target_module,
|
|
name = target_def,
|
|
));
|
|
if tail {
|
|
self.body
|
|
.push_str(&format!(" ret {ret} {dst}\n", ret = sig.ret));
|
|
self.block_terminated = true;
|
|
}
|
|
Ok((dst, sig.ret.clone()))
|
|
}
|
|
|
|
/// indirect call through a closure-pair pointer. The
|
|
/// callee SSA points at `{ ptr thunk, ptr env }`; we GEP+load both
|
|
/// halves and call `thunk(env, args...)`. The user-visible `sig`
|
|
/// describes only the user-level params/ret — the env_ptr is
|
|
/// inserted by codegen, transparent to the source language.
|
|
fn emit_indirect_call(
|
|
&mut self,
|
|
callee_ssa: &str,
|
|
sig: &FnSig,
|
|
args: &[Term],
|
|
tail: bool,
|
|
) -> Result<(String, String)> {
|
|
if args.len() != sig.params.len() {
|
|
return Err(CodegenError::Internal(format!(
|
|
"indirect call arity mismatch: sig expects {}, got {}",
|
|
sig.params.len(),
|
|
args.len()
|
|
)));
|
|
}
|
|
let mut compiled = Vec::new();
|
|
for (a, exp_ty) in args.iter().zip(sig.params.iter()) {
|
|
let (v, vty) = self.lower_term(a)?;
|
|
if &vty != exp_ty {
|
|
return Err(CodegenError::Internal(format!(
|
|
"indirect call arg type mismatch: expected {exp_ty}, got {vty}"
|
|
)));
|
|
}
|
|
compiled.push((v, vty));
|
|
}
|
|
// Unpack the closure pair: thunk pointer at offset 0, env pointer
|
|
// at offset 8. Use a typed GEP through `{ ptr, ptr }` so the
|
|
// offsets are computed correctly across targets.
|
|
let thunk_p = self.fresh_ssa();
|
|
let thunk = self.fresh_ssa();
|
|
let env_p = self.fresh_ssa();
|
|
let env = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {thunk_p} = getelementptr inbounds {{ ptr, ptr }}, ptr {callee_ssa}, i64 0, i32 0\n"
|
|
));
|
|
self.body
|
|
.push_str(&format!(" {thunk} = load ptr, ptr {thunk_p}\n"));
|
|
self.body.push_str(&format!(
|
|
" {env_p} = getelementptr inbounds {{ ptr, ptr }}, ptr {callee_ssa}, i64 0, i32 1\n"
|
|
));
|
|
self.body
|
|
.push_str(&format!(" {env} = load ptr, ptr {env_p}\n"));
|
|
|
|
// Build the actual call. The thunk's signature is `(ptr, params...)`
|
|
// — env_ptr is the implicit first arg, transparent to the user.
|
|
let mut arglist = format!("ptr {env}");
|
|
for (v, t) in &compiled {
|
|
arglist.push_str(&format!(", {t} {v}"));
|
|
}
|
|
let mut param_tys = String::from("ptr");
|
|
for pt in &sig.params {
|
|
param_tys.push_str(", ");
|
|
param_tys.push_str(pt);
|
|
}
|
|
let dst = self.fresh_ssa();
|
|
// indirect tail calls. Same `musttail`/`ret` shape as
|
|
// emit_call. The thunk's signature uniformly inserts an
|
|
// `env_ptr` first arg, but a `musttail call` to a thunk whose
|
|
// signature exactly matches the parent fn's prototype +
|
|
// env_ptr is malformed (parent has no env_ptr in its prototype).
|
|
// For the MVP no fixture marks an indirect tail call; we honour
|
|
// the flag by emitting `musttail call` (LLVM verifier will
|
|
// catch a real signature mismatch at IR-verification time).
|
|
let call_kw = if tail { "musttail call" } else { "call" };
|
|
self.body.push_str(&format!(
|
|
" {dst} = {call_kw} {ret} ({ptys}) {thunk}({arglist})\n",
|
|
ret = sig.ret,
|
|
ptys = param_tys,
|
|
));
|
|
if tail {
|
|
self.body
|
|
.push_str(&format!(" ret {ret} {dst}\n", ret = sig.ret));
|
|
self.block_terminated = true;
|
|
}
|
|
Ok((dst, sig.ret.clone()))
|
|
}
|
|
|
|
|
|
/// is `name` a callee that can be resolved at compile time
|
|
/// (no fn-pointer needed)? True for builtin operators, the
|
|
/// current-module top-level fns (mono or poly), and qualified
|
|
/// `prefix.def`. Locals shadow this — the caller checks for
|
|
/// that first.
|
|
fn is_static_callee(&self, name: &str) -> bool {
|
|
if is_arithmetic_op(name) || name == "not" {
|
|
return true;
|
|
}
|
|
// Floats iter 4.4: new fn-builtins (`neg`, `int_to_float`,
|
|
// `float_to_int_truncate`, `is_nan`, `float_to_str`) lower
|
|
// inline in `lower_app`, parallel to the operator path. Iter
|
|
// hs.4: `int_to_str` joins the list, lowering to
|
|
// `@ailang_int_to_str` from `runtime/str.c`.
|
|
if matches!(
|
|
name,
|
|
"neg"
|
|
| "int_to_float"
|
|
| "float_to_int_truncate"
|
|
| "is_nan"
|
|
| "float_to_str"
|
|
| "int_to_str"
|
|
| "bool_to_str"
|
|
| "str_clone"
|
|
| "str_concat"
|
|
) {
|
|
return true;
|
|
}
|
|
if name.matches('.').count() == 1 {
|
|
return true;
|
|
}
|
|
// iter 23.4: poly-def arm dropped — post-mono there are no
|
|
// `Type::Forall` defs in `module_user_fns` to begin with, and
|
|
// `module_polymorphic_fns` no longer exists.
|
|
if self
|
|
.module_user_fns
|
|
.get(self.module_name)
|
|
.is_some_and(|m| m.contains_key(name))
|
|
{
|
|
return true;
|
|
}
|
|
// operator-routing-eq-ord.1: the typechecker auto-imports
|
|
// prelude into every non-prelude module so a bare reference
|
|
// like `(app float_eq …)` typechecks against the prelude's
|
|
// monomorphic `float_eq` fn. The codegen needs the matching
|
|
// resolution: if the name is not in the current module but
|
|
// IS in prelude's monomorphic fn table, treat it as a static
|
|
// callee — the call lowers to `@ail_prelude_<name>` directly.
|
|
// Without this fallback, post-mono polymorphic-prelude calls
|
|
// (e.g. `print`) work because the mono pass synthesises a
|
|
// mono arm `print__T` into the user's module, but monomorphic
|
|
// prelude fns (no Forall to instantiate) never get a mirror
|
|
// arm in user modules and would otherwise fail at codegen
|
|
// with `unknown variable`.
|
|
if self.module_name != "prelude"
|
|
&& self
|
|
.module_user_fns
|
|
.get("prelude")
|
|
.is_some_and(|m| m.contains_key(name))
|
|
{
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// resolve `name` to a top-level fn-value, i.e. the address
|
|
/// of its static closure pair `@ail_<m>_<def>_clos`, plus the user-
|
|
/// visible FnSig (params/ret WITHOUT the env_ptr — that's added at
|
|
/// the call site by the closure ABI). Returns None if the name does
|
|
/// not refer to a top-level fn. Operators / `not` are not first-
|
|
/// class values; `is_static_callee` filters them earlier.
|
|
fn resolve_top_level_fn(&self, name: &str) -> Option<(String, FnSig)> {
|
|
if name.matches('.').count() == 1 {
|
|
let (prefix, suffix) = name.split_once('.')?;
|
|
// dotted form resolves through the current
|
|
// module's import_map first (the standard cross-module
|
|
// reference path). If the prefix isn't in import_map,
|
|
// fall back to a direct module-name lookup against
|
|
// `module_user_fns` — a post-mono synthesised body may
|
|
// carry cross-module references to modules its source
|
|
// template didn't import (e.g. `prelude.print__<UserType>`
|
|
// synthesised in `prelude` calls `show_user_adt.show__<UserType>`
|
|
// even though prelude does not import user modules; this is
|
|
// a valid post-mono construct because both ends were
|
|
// independently typechecked under their original module
|
|
// contexts before mono ran).
|
|
let target = match self.import_map.get(prefix) {
|
|
Some(m) => m,
|
|
None => prefix,
|
|
};
|
|
let sig = self.module_user_fns.get(target)?.get(suffix)?.clone();
|
|
return Some((format!("@ail_{target}_{suffix}_clos"), sig));
|
|
}
|
|
if let Some(sig) = self
|
|
.module_user_fns
|
|
.get(self.module_name)
|
|
.and_then(|m| m.get(name))
|
|
.cloned()
|
|
{
|
|
return Some((
|
|
format!("@ail_{module}_{name}_clos", module = self.module_name),
|
|
sig,
|
|
));
|
|
}
|
|
// operator-routing-eq-ord.1: fall back to prelude (mirrors
|
|
// the auto-import the typechecker performs for non-prelude
|
|
// modules). Needed for monomorphic prelude fns (e.g. `float_eq`)
|
|
// which have no mono mirror in the user's module.
|
|
if self.module_name != "prelude" {
|
|
if let Some(sig) = self
|
|
.module_user_fns
|
|
.get("prelude")
|
|
.and_then(|m| m.get(name))
|
|
.cloned()
|
|
{
|
|
return Some((format!("@ail_prelude_{name}_clos"), sig));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// resolve a `Term::Var` reference to a const def. Returns
|
|
/// `(owning_module, ConstDef)` on hit. Both bare current-module
|
|
/// references and qualified `prefix.name` cross-module references
|
|
/// resolve through the same path; the prefix routes through the
|
|
/// emitter's `import_map` to the actual module.
|
|
fn resolve_const(&self, name: &str) -> Option<(String, ConstDef)> {
|
|
if name.matches('.').count() == 1 {
|
|
let (prefix, suffix) = name.split_once('.')?;
|
|
let target = self.import_map.get(prefix)?;
|
|
let cdef = self.module_consts.get(target)?.get(suffix)?.clone();
|
|
return Some((target.clone(), cdef));
|
|
}
|
|
let cdef = self
|
|
.module_consts
|
|
.get(self.module_name)?
|
|
.get(name)?
|
|
.clone();
|
|
Some((self.module_name.to_string(), cdef))
|
|
}
|
|
|
|
fn lower_effect_op(&mut self, op: &str, args: &[Term], tail: bool) -> Result<(String, String)> {
|
|
// `musttail` requires identical caller/callee
|
|
// prototypes (same return type, same param types). The MVP's
|
|
// runtime print helpers (`printf`, `puts`) return `i32`, but the
|
|
// AILang fn enclosing a `tail-do io/print_*` returns `Unit`
|
|
// (`i8`). `musttail` would be rejected by the LLVM verifier.
|
|
// We therefore use the `tail` keyword (LLVM IR optimisation
|
|
// hint, NOT a guarantee) for `tail: true` do-ops. The optimiser
|
|
// is free to TCO it; if it can't, the call falls back to a
|
|
// normal call. The body of the AILang fn afterwards is empty
|
|
// (the op was the last thing), so we close it with `ret i8 0`.
|
|
let _ = tail;
|
|
let call_kw = if tail { "tail call" } else { "call" };
|
|
match op {
|
|
"io/print_str" => {
|
|
if args.len() != 1 {
|
|
return Err(CodegenError::Internal(
|
|
"io/print_str arity".into(),
|
|
));
|
|
}
|
|
let (v, vty) = self.lower_term(&args[0])?;
|
|
if vty != "ptr" {
|
|
return Err(CodegenError::Internal(
|
|
"io/print_str needs ptr".into(),
|
|
));
|
|
}
|
|
// `Str` values now flow as a pointer to the
|
|
// `len`-field of the packed-struct slab; @puts needs
|
|
// the bytes pointer 8 bytes further on.
|
|
let bytes = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {bytes} = getelementptr inbounds i8, ptr {v}, i64 8\n"
|
|
));
|
|
self.body
|
|
.push_str(&format!(" {call_kw} i32 @puts(ptr {bytes})\n"));
|
|
if tail {
|
|
self.body.push_str(" ret i8 0\n");
|
|
self.block_terminated = true;
|
|
}
|
|
Ok(("0".into(), "i8".into()))
|
|
}
|
|
other => Err(CodegenError::Internal(format!(
|
|
"unknown effect op: {other}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// hand-rolled body for monomorphiser-synthesised
|
|
/// primitive instance methods whose natural lambda-lowering would
|
|
/// not produce the spec-mandated IR shape. Returns `Ok(true)` if
|
|
/// the body was emitted (including the closing `}` and a final
|
|
/// `\n\n`); `emit_fn` skips its normal body-lowering branch but
|
|
/// MUST still run its post-body steps (deferred-thunk flush and
|
|
/// `emit_adapter_and_static_closure` — the closure-pair every
|
|
/// top-level fn gets so it is reachable as a `Term::Var` value).
|
|
/// `Ok(false)` lets `emit_fn` continue with normal body lowering.
|
|
///
|
|
/// Currently inhabited arms (post-operator-routing-eq-ord
|
|
/// milestone): the `Eq` instance bodies `eq__Int`, `eq__Bool`,
|
|
/// `eq__Str`, `eq__Unit`; the `Ord` instance bodies `compare__Int`,
|
|
/// `compare__Bool`, `compare__Str`; the Int-direct short-circuits
|
|
/// for the Ord-class free helpers `lt__Int`, `le__Int`, `gt__Int`,
|
|
/// `ge__Int`, `ne__Int`; and the six Float-named comparison fns
|
|
/// `float_eq`, `float_ne`, `float_lt`, `float_le`, `float_gt`,
|
|
/// `float_ge`. Fourteen arms total; every arm pairs with an
|
|
/// `alwaysinline` attribute on the generated `define` line (via
|
|
/// `intercept_emit_wants_alwaysinline`) so the call folds to a
|
|
/// single instruction at every use site under -O0 as well as
|
|
/// -O2. The eq/compare/Float arms exist because there is no
|
|
/// type-polymorphic builtin that would lower to the right
|
|
/// per-type instruction (`lower_eq` was deleted in the
|
|
/// operator-routing-eq-ord milestone — `==` is no longer a
|
|
/// language identifier; the class-method dispatch through
|
|
/// `prelude.Eq` / `prelude.Ord` is the only path). The `lt/le/gt/
|
|
/// ge/ne` Int-direct arms exist as an asymmetric optimization
|
|
/// (Int-only) — without them the source-level body of `lt`/etc.
|
|
/// allocates an `Ordering` ctor per call via the
|
|
/// `compare__Int` → `match` path, which surfaces as a
|
|
/// `bench_closure_chain` regression. See
|
|
/// `design/contracts/prelude-classes.md` for the full surface
|
|
/// and the Int-only asymmetry rationale.
|
|
fn try_emit_primitive_instance_body(
|
|
&mut self,
|
|
fn_name: &str,
|
|
param_tys: &[String],
|
|
ret_ty: &str,
|
|
) -> Result<bool> {
|
|
match fn_name {
|
|
"eq__Str" => {
|
|
if param_tys != ["ptr", "ptr"] || ret_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"eq__Str body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (ptr, ptr) -> i1)"
|
|
)));
|
|
}
|
|
// The two params are the two most recently pushed locals;
|
|
// `emit_fn` populated `self.locals` from `f.params` before
|
|
// dispatching here. Pull their SSA names without assuming
|
|
// a specific surface-level binder name.
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
// IR-Str pointers now land on the
|
|
// `len`-field of the packed-struct slab; @ail_str_eq's
|
|
// strcmp-based body needs the bytes pointer 8 bytes
|
|
// further on.
|
|
let a_bytes = self.fresh_ssa();
|
|
let b_bytes = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {a_bytes} = getelementptr inbounds i8, ptr {a_ssa}, i64 8\n"
|
|
));
|
|
self.body.push_str(&format!(
|
|
" {b_bytes} = getelementptr inbounds i8, ptr {b_ssa}, i64 8\n"
|
|
));
|
|
let dst = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {dst} = call zeroext i1 @ail_str_eq(ptr {a_bytes}, ptr {b_bytes})\n"
|
|
));
|
|
self.body.push_str(&format!(" ret i1 {dst}\n"));
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(true)
|
|
}
|
|
"compare__Int" => {
|
|
if param_tys != ["i64", "i64"] || ret_ty != "ptr" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"compare__Int body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (i64, i64) -> ptr)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
self.emit_compare_ladder(
|
|
&format!("icmp slt i64 {a_ssa}, {b_ssa}"),
|
|
&format!("icmp eq i64 {a_ssa}, {b_ssa}"),
|
|
)?;
|
|
Ok(true)
|
|
}
|
|
"compare__Bool" => {
|
|
if param_tys != ["i1", "i1"] || ret_ty != "ptr" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"compare__Bool body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (i1, i1) -> ptr)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
self.emit_compare_ladder(
|
|
&format!("icmp ult i1 {a_ssa}, {b_ssa}"),
|
|
&format!("icmp eq i1 {a_ssa}, {b_ssa}"),
|
|
)?;
|
|
Ok(true)
|
|
}
|
|
"compare__Str" => {
|
|
if param_tys != ["ptr", "ptr"] || ret_ty != "ptr" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"compare__Str body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (ptr, ptr) -> ptr)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
// IR-Str pointers now land on the
|
|
// `len`-field of the packed-struct slab; @ail_str_compare's
|
|
// strcmp-based body needs the bytes pointer 8 bytes
|
|
// further on.
|
|
let a_bytes = self.fresh_ssa();
|
|
let b_bytes = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {a_bytes} = getelementptr inbounds i8, ptr {a_ssa}, i64 8\n"
|
|
));
|
|
self.body.push_str(&format!(
|
|
" {b_bytes} = getelementptr inbounds i8, ptr {b_ssa}, i64 8\n"
|
|
));
|
|
let cmp_res = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {cmp_res} = call i32 @ail_str_compare(ptr {a_bytes}, ptr {b_bytes})\n"
|
|
));
|
|
self.emit_compare_ladder(
|
|
&format!("icmp slt i32 {cmp_res}, 0"),
|
|
&format!("icmp eq i32 {cmp_res}, 0"),
|
|
)?;
|
|
Ok(true)
|
|
}
|
|
"eq__Int" => {
|
|
if param_tys != ["i64", "i64"] || ret_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"eq__Int body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (i64, i64) -> i1)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
let r = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {r} = icmp eq i64 {a_ssa}, {b_ssa}\n"
|
|
));
|
|
self.body.push_str(&format!(" ret i1 {r}\n"));
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(true)
|
|
}
|
|
"eq__Bool" => {
|
|
if param_tys != ["i1", "i1"] || ret_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"eq__Bool body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (i1, i1) -> i1)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
let r = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {r} = icmp eq i1 {a_ssa}, {b_ssa}\n"
|
|
));
|
|
self.body.push_str(&format!(" ret i1 {r}\n"));
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(true)
|
|
}
|
|
"eq__Unit" => {
|
|
// Unit is single-inhabitant: all values compare equal.
|
|
// The fn signature still carries the operand slots; we
|
|
// discard them and return true unconditionally.
|
|
if ret_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"eq__Unit body intercept: unexpected return type \
|
|
({param_tys:?}) -> {ret_ty} (want -> i1)"
|
|
)));
|
|
}
|
|
self.body.push_str(" ret i1 1\n");
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(true)
|
|
}
|
|
"float_eq" => {
|
|
if param_tys != ["double", "double"] || ret_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"float_eq body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (double, double) -> i1)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
let r = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {r} = fcmp oeq double {a_ssa}, {b_ssa}\n"
|
|
));
|
|
self.body.push_str(&format!(" ret i1 {r}\n"));
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(true)
|
|
}
|
|
"float_ne" => {
|
|
// `fcmp une` ("unordered or not-equal"), not `one`,
|
|
// mirroring float-semantics.md: NaN != NaN must be true.
|
|
if param_tys != ["double", "double"] || ret_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"float_ne body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (double, double) -> i1)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
let r = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {r} = fcmp une double {a_ssa}, {b_ssa}\n"
|
|
));
|
|
self.body.push_str(&format!(" ret i1 {r}\n"));
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(true)
|
|
}
|
|
"float_lt" => {
|
|
if param_tys != ["double", "double"] || ret_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"float_lt body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (double, double) -> i1)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
let r = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {r} = fcmp olt double {a_ssa}, {b_ssa}\n"
|
|
));
|
|
self.body.push_str(&format!(" ret i1 {r}\n"));
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(true)
|
|
}
|
|
"float_le" => {
|
|
if param_tys != ["double", "double"] || ret_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"float_le body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (double, double) -> i1)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
let r = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {r} = fcmp ole double {a_ssa}, {b_ssa}\n"
|
|
));
|
|
self.body.push_str(&format!(" ret i1 {r}\n"));
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(true)
|
|
}
|
|
"float_gt" => {
|
|
if param_tys != ["double", "double"] || ret_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"float_gt body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (double, double) -> i1)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
let r = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {r} = fcmp ogt double {a_ssa}, {b_ssa}\n"
|
|
));
|
|
self.body.push_str(&format!(" ret i1 {r}\n"));
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(true)
|
|
}
|
|
"float_ge" => {
|
|
if param_tys != ["double", "double"] || ret_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"float_ge body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (double, double) -> i1)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
let r = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {r} = fcmp oge double {a_ssa}, {b_ssa}\n"
|
|
));
|
|
self.body.push_str(&format!(" ret i1 {r}\n"));
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(true)
|
|
}
|
|
// Ord/Eq Int direct-icmp arms (iter operator-routing-eq-ord.1
|
|
// Task 13). The polymorphic prelude bodies are
|
|
// `(match (app compare x y) ...)`; without these intercept
|
|
// arms, each `lt`-at-Int call site goes through
|
|
// `compare__Int` → allocate an `Ordering` ctor → match.
|
|
// At `-O2` even with `alwaysinline` on the helpers, clang
|
|
// does not always collapse the Ordering allocation across
|
|
// the helper boundary (observed: +29% bump_s, +47% rc_s
|
|
// bench_closure_chain regression before this arm shipped).
|
|
// Direct `icmp slt i64` at the helper body lets clang fold
|
|
// the comparison to the use site reliably.
|
|
//
|
|
// `ne__Int` uses `icmp ne i64` directly, NOT the
|
|
// `not (eq x y)` indirection — that would re-introduce one
|
|
// call boundary and one `xor i1 %r, true` per comparison
|
|
// that contributes no semantic value beyond the direct
|
|
// `icmp ne`.
|
|
//
|
|
// Bool variants (`lt__Bool`, `le__Bool`, etc.) are not
|
|
// emitted as intercept arms here because no current
|
|
// bench fixture or example reaches them; the polymorphic
|
|
// body via `compare__Bool` remains correct, just slightly
|
|
// slower per call. The next fixture that needs Bool-ordered
|
|
// perf can extend this family symmetrically.
|
|
"lt__Int" => self.emit_direct_int_icmp_intercept("lt__Int", "icmp slt i64", param_tys, ret_ty),
|
|
"le__Int" => self.emit_direct_int_icmp_intercept("le__Int", "icmp sle i64", param_tys, ret_ty),
|
|
"gt__Int" => self.emit_direct_int_icmp_intercept("gt__Int", "icmp sgt i64", param_tys, ret_ty),
|
|
"ge__Int" => self.emit_direct_int_icmp_intercept("ge__Int", "icmp sge i64", param_tys, ret_ty),
|
|
"ne__Int" => self.emit_direct_int_icmp_intercept("ne__Int", "icmp ne i64", param_tys, ret_ty),
|
|
_ => Ok(false),
|
|
}
|
|
}
|
|
|
|
/// Emit a direct `<icmp_op> i64` body for the `lt__Int` /
|
|
/// `le__Int` / `gt__Int` / `ge__Int` / `ne__Int` intercept arms.
|
|
/// All five share the same shape: pull the two operand SSAs from
|
|
/// `self.locals` (populated by `emit_fn` before dispatch), emit
|
|
/// `%r = <icmp_op> %a, %b` and `ret i1 %r`, close the body.
|
|
/// `icmp_op` is the full instruction-and-operand-type prefix
|
|
/// (e.g. `"icmp slt i64"`); the operand SSAs are appended.
|
|
fn emit_direct_int_icmp_intercept(
|
|
&mut self,
|
|
fn_name: &str,
|
|
icmp_op: &str,
|
|
param_tys: &[String],
|
|
ret_ty: &str,
|
|
) -> Result<bool> {
|
|
if param_tys != ["i64", "i64"] || ret_ty != "i1" {
|
|
return Err(CodegenError::Internal(format!(
|
|
"{fn_name} body intercept: unexpected signature \
|
|
({param_tys:?}) -> {ret_ty} (want (i64, i64) -> i1)"
|
|
)));
|
|
}
|
|
let n = self.locals.len();
|
|
let a_ssa = self.locals[n - 2].1.clone();
|
|
let b_ssa = self.locals[n - 1].1.clone();
|
|
let r = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {r} = {icmp_op} {a_ssa}, {b_ssa}\n"
|
|
));
|
|
self.body.push_str(&format!(" ret i1 {r}\n"));
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(true)
|
|
}
|
|
|
|
/// emit one arm of the `compare__T` branch ladder.
|
|
/// Starts a fresh basic block labelled `label`, constructs the
|
|
/// matching `Ordering` ctor (LT / EQ / GT) via `lower_ctor`, and
|
|
/// emits a `ret ptr <ssa>`. Used three times per `compare__T`
|
|
/// arm in `try_emit_primitive_instance_body`. The ctor is a
|
|
/// zero-field allocation; `lower_ctor` handles alloc-strategy
|
|
/// variance (Gc / Bump / Rc) without the intercept duplicating
|
|
/// the per-strategy logic.
|
|
fn emit_ordering_arm(&mut self, label: &str, ctor: &str) -> Result<()> {
|
|
self.start_block(label);
|
|
// term_ptr 0: synthetic call site, not present in the
|
|
// escape-analysis result; falls back to the conservative
|
|
// "escapes → heap allocate" default. Safe for the Ordering
|
|
// return value (the caller owns it after `ret`).
|
|
let (ssa, _llvm_ty) = self.lower_ctor(
|
|
"Ordering",
|
|
ctor,
|
|
&[],
|
|
0,
|
|
)?;
|
|
self.body.push_str(&format!(" ret ptr {ssa}\n"));
|
|
self.block_terminated = true;
|
|
Ok(())
|
|
}
|
|
|
|
/// emit the labelled three-way branch ladder shared
|
|
/// across all three `compare__T` intercept arms. Given two
|
|
/// instruction RHS strings (the LT-test and the EQ-test, e.g.
|
|
/// `"icmp slt i64 %a, %b"`), assigns each to a fresh SSA, wires
|
|
/// them into LT-block / EQ-block / GT-block via
|
|
/// `emit_ordering_arm`, and closes the fn body. The EQ-test
|
|
/// must be emitted INSIDE the `after_lt_label` block (not before
|
|
/// the LT-branch), so the helper takes the RHS as a string and
|
|
/// performs the SSA assignment itself at the correct point. The
|
|
/// two test instructions vary per arm (`icmp slt i64` /
|
|
/// `icmp ult i1` / `icmp slt i32` etc.); any per-arm prep
|
|
/// (e.g. `compare__Str`'s `@ail_str_compare` call) must be
|
|
/// emitted by the caller before invoking this helper.
|
|
fn emit_compare_ladder(
|
|
&mut self,
|
|
lt_test_instr: &str,
|
|
eq_test_instr: &str,
|
|
) -> Result<()> {
|
|
let lt_test_ssa = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {lt_test_ssa} = {lt_test_instr}\n"
|
|
));
|
|
let id = self.fresh_id();
|
|
let lt_label = format!("cmp_lt_{id}");
|
|
let after_lt_label = format!("cmp_after_lt_{id}");
|
|
let eq_label = format!("cmp_eq_{id}");
|
|
let gt_label = format!("cmp_gt_{id}");
|
|
self.body.push_str(&format!(
|
|
" br i1 {lt_test_ssa}, label %{lt_label}, label %{after_lt_label}\n"
|
|
));
|
|
self.emit_ordering_arm(<_label, "LT")?;
|
|
self.start_block(&after_lt_label);
|
|
let eq_test_ssa = self.fresh_ssa();
|
|
self.body.push_str(&format!(
|
|
" {eq_test_ssa} = {eq_test_instr}\n"
|
|
));
|
|
self.body.push_str(&format!(
|
|
" br i1 {eq_test_ssa}, label %{eq_label}, label %{gt_label}\n"
|
|
));
|
|
self.emit_ordering_arm(&eq_label, "EQ")?;
|
|
self.emit_ordering_arm(>_label, "GT")?;
|
|
self.body.push_str("}\n\n");
|
|
self.block_terminated = true;
|
|
Ok(())
|
|
}
|
|
|
|
// 2026-05-21 operator-routing-eq-ord: `lower_eq` is deleted.
|
|
// Equality is no longer a direct-emit codegen path; it flows
|
|
// through the class method `eq` (prelude.Eq) whose primitive
|
|
// instance bodies are emitted by
|
|
// `try_emit_primitive_instance_body` (Eq Int/Bool/Str/Unit
|
|
// arms, each lowering to a single `icmp` / call /
|
|
// constant-`i1`-`1` plus the alwaysinline attribute on the
|
|
// generated fn definition). The deleted fn handled Int / Bool /
|
|
// Str / Unit / Float branches plus the ADT/Fn rejection arms;
|
|
// none of those code paths are now reachable.
|
|
|
|
pub(crate) fn fresh_ssa(&mut self) -> String {
|
|
self.counter += 1;
|
|
format!("%v{}", self.counter)
|
|
}
|
|
pub(crate) fn fresh_id(&mut self) -> u64 {
|
|
self.counter += 1;
|
|
self.counter
|
|
}
|
|
|
|
/// parallel to the legacy
|
|
/// `intern_string` (retired in the per-type-print-op retirement alongside the per-type
|
|
/// print effect-ops it served), but for
|
|
/// language `Str` literals emitted as packed-struct globals
|
|
/// (len + bytes + NUL). Shares the same monotonic `str_counter`
|
|
/// so the produced global names remain alphabetically orderable
|
|
/// alongside format-string globals.
|
|
fn intern_str_literal(&mut self, hint: &str, content: &str) -> String {
|
|
if let Some((name, _)) = self.str_literals.get(content) {
|
|
return name.clone();
|
|
}
|
|
let name = format!(".str_{}_{}_{}", self.module_name, hint, self.str_counter);
|
|
self.str_counter += 1;
|
|
let len = c_byte_len(content);
|
|
self.str_literals
|
|
.insert(content.to_string(), (name.clone(), len));
|
|
name
|
|
}
|
|
|
|
/// lightweight AILang-type computation for an expression
|
|
/// in the current scope. Mirrors what the typechecker already
|
|
/// derived; we replay it here only because the typechecker doesn't
|
|
/// hand its annotations down.
|
|
///
|
|
/// Used at polymorphic call sites to derive the type substitution
|
|
/// from the actual argument types, at let-bindings / match-arm
|
|
/// scrutinees to populate the AILang-type slot of locals. Trusts
|
|
/// the typechecker for well-formedness — failures here are internal
|
|
/// errors (e.g. unbound var that the checker should have rejected).
|
|
///
|
|
/// Limitations: nested polymorphic instantiations (an arg that is
|
|
/// itself a polymorphic call) work via `synth_with_extras`'s
|
|
/// recursion; the substitution is derived on-the-fly and applied
|
|
/// to the return type. The body of a let is walked with the
|
|
/// let-bound name added to a small `extras` shadow stack so we
|
|
/// don't need `&mut self`.
|
|
pub(crate) fn synth_arg_type(&self, t: &Term) -> Result<Type> {
|
|
self.synth_with_extras(t, &[])
|
|
}
|
|
|
|
fn synth_with_extras(&self, t: &Term, extras: &[(String, Type)]) -> Result<Type> {
|
|
match t {
|
|
Term::Lit { lit } => Ok(match lit {
|
|
Literal::Int { .. } => Type::int(),
|
|
Literal::Bool { .. } => Type::bool_(),
|
|
Literal::Str { .. } => Type::str_(),
|
|
Literal::Unit => Type::unit(),
|
|
Literal::Float { .. } => Type::float(),
|
|
}),
|
|
Term::Var { name } => {
|
|
// Lookup precedence: extras (let-bindings introduced
|
|
// during this synth walk) → mut-var allocas → emitter
|
|
// locals → globals → builtins. Mirrors typechecker
|
|
// shadowing and the lower_term-side precedence
|
|
// (mut.3: mut-vars in scope take precedence over
|
|
// outer let-bound / param resolutions).
|
|
for (n, ty) in extras.iter().rev() {
|
|
if n == name {
|
|
return Ok(ty.clone());
|
|
}
|
|
}
|
|
if let Some((_, ail_ty)) = self.binder_allocas.get(name) {
|
|
return Ok(ail_ty.clone());
|
|
}
|
|
if let Some((_, _, _, ail)) =
|
|
self.locals.iter().rev().find(|(n, _, _, _)| n == name)
|
|
{
|
|
return Ok(ail.clone());
|
|
}
|
|
if name.matches('.').count() == 1 {
|
|
let (prefix, suffix) = name.split_once('.').expect("checked");
|
|
// try the current module's import_map
|
|
// first (standard cross-module reference), then
|
|
// fall back to a direct module-name lookup for
|
|
// post-mono synthesised cross-module references
|
|
// (see `resolve_top_level_fn` and the cross-module
|
|
// call arm in `lower_app` for matching fallbacks).
|
|
let target_opt: Option<&str> = self
|
|
.import_map
|
|
.get(prefix)
|
|
.map(|s| s.as_str())
|
|
.or_else(|| {
|
|
if self.module_def_ail_types.contains_key(prefix) {
|
|
Some(prefix)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
if let Some(target) = target_opt {
|
|
if let Some(ty) = self
|
|
.module_def_ail_types
|
|
.get(target)
|
|
.and_then(|m| m.get(suffix))
|
|
{
|
|
// qualify any bare type-cons that
|
|
// refer to types declared in `target` so the
|
|
// returned signature lines up with the
|
|
// qualified ctors / type names produced
|
|
// elsewhere in the consumer module. Mirrors
|
|
// the typechecker's `qualify_local_types`.
|
|
let owner_local_types = self.collect_owner_local_types(target);
|
|
return Ok(qualify_local_types_codegen(
|
|
ty,
|
|
target,
|
|
&owner_local_types,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if let Some(ty) = self
|
|
.module_def_ail_types
|
|
.get(self.module_name)
|
|
.and_then(|m| m.get(name))
|
|
{
|
|
return Ok(ty.clone());
|
|
}
|
|
// const refs participate in arg-type
|
|
// synthesis. Bare or qualified, both forms route
|
|
// through `resolve_const` and yield the const's
|
|
// declared type. Const types are already qualified
|
|
// (the AST writes them in the consumer's namespace
|
|
// via `module.Type`), so no further qualification
|
|
// is needed.
|
|
if let Some((_, cdef)) = self.resolve_const(name) {
|
|
return Ok(cdef.ty);
|
|
}
|
|
if let Some(t) = builtin_ail_type(name) {
|
|
return Ok(t);
|
|
}
|
|
Err(CodegenError::UnknownVar(name.clone()))
|
|
}
|
|
Term::Lam {
|
|
param_tys,
|
|
ret_ty,
|
|
effects,
|
|
..
|
|
} => Ok(Type::Fn {
|
|
params: param_tys.clone(),
|
|
ret: ret_ty.clone(),
|
|
effects: effects.clone(),
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
}),
|
|
Term::App { callee, args, .. } => {
|
|
let cty = self.synth_with_extras(callee, extras)?;
|
|
match cty {
|
|
Type::Fn { ret, .. } => Ok(*ret),
|
|
Type::Forall { vars, constraints: _, body } => {
|
|
let arg_tys: Vec<Type> = args
|
|
.iter()
|
|
.map(|a| self.synth_with_extras(a, extras))
|
|
.collect::<Result<_>>()?;
|
|
let (params, ret) = match body.as_ref() {
|
|
Type::Fn { params, ret, .. } => (params.clone(), (**ret).clone()),
|
|
_ => {
|
|
return Err(CodegenError::Internal(
|
|
"synth_arg_type: forall body is not Fn".into(),
|
|
));
|
|
}
|
|
};
|
|
let subst = derive_substitution(&vars, ¶ms, &arg_tys)?;
|
|
Ok(apply_subst_to_type(&ret, &subst))
|
|
}
|
|
other => Err(CodegenError::Internal(format!(
|
|
"synth_arg_type: callee not a fn type: {}",
|
|
ailang_core::pretty::type_to_string(&other)
|
|
))),
|
|
}
|
|
}
|
|
Term::Let { name, value, body } => {
|
|
let v_ail = self.synth_with_extras(value, extras)?;
|
|
let mut new_extras: Vec<(String, Type)> = extras.to_vec();
|
|
new_extras.push((name.clone(), v_ail));
|
|
self.synth_with_extras(body, &new_extras)
|
|
}
|
|
Term::If { then, .. } => self.synth_with_extras(then, extras),
|
|
Term::Do { op, .. } => builtin_effect_op_ret(op).ok_or_else(|| {
|
|
CodegenError::Internal(format!(
|
|
"synth_arg_type: unknown effect op `{op}`"
|
|
))
|
|
}),
|
|
Term::Ctor { type_name, ctor, args } => {
|
|
// derive concrete type-args of a parameterised
|
|
// ADT instance from the recursively-synthesised arg
|
|
// types. For monomorphic ADTs (`type_vars.is_empty()`)
|
|
// we keep the pre-13b shape `Type::Con { args: vec![] }`
|
|
// — matching what the typechecker produces.
|
|
// a qualified `type_name` resolves through the
|
|
// cross-module ctor index. The result `Type::Con.name`
|
|
// stays qualified to match what the typechecker emits.
|
|
// when the ctor is cross-module, `cref.ail_fields`
|
|
// is written in the owning module's local namespace, so a
|
|
// recursive self-reference like `Cons a (List a)` carries
|
|
// a bare `Con("List", _)` even though every other place
|
|
// sees the qualified `std_list.List<...>`. Apply
|
|
// `qualify_local_types_codegen` before `unify_for_subst`
|
|
// so the unification doesn't fail on name mismatch.
|
|
let cref = self.lookup_ctor_by_type(type_name, ctor)?;
|
|
if cref.type_vars.is_empty() {
|
|
return Ok(Type::Con {
|
|
name: type_name.clone(),
|
|
args: vec![],
|
|
});
|
|
}
|
|
let qualified_ail_fields: Vec<Type> = if type_name.matches('.').count() == 1 {
|
|
let (prefix, _) = type_name.split_once('.').expect("checked");
|
|
if let Some(target) = self.import_map.get(prefix) {
|
|
let owner_local_types = self.collect_owner_local_types(target);
|
|
cref.ail_fields
|
|
.iter()
|
|
.map(|f| qualify_local_types_codegen(f, target, &owner_local_types))
|
|
.collect()
|
|
} else {
|
|
cref.ail_fields.clone()
|
|
}
|
|
} else {
|
|
cref.ail_fields.clone()
|
|
};
|
|
let arg_tys: Vec<Type> = args
|
|
.iter()
|
|
.map(|a| self.synth_with_extras(a, extras))
|
|
.collect::<Result<_>>()?;
|
|
let var_set: BTreeSet<&str> =
|
|
cref.type_vars.iter().map(|s| s.as_str()).collect();
|
|
let mut subst: BTreeMap<String, Type> = BTreeMap::new();
|
|
for (exp, actual) in qualified_ail_fields.iter().zip(arg_tys.iter()) {
|
|
unify_for_subst(exp, actual, &var_set, &mut subst)?;
|
|
}
|
|
// Vars not pinned by ctor args (e.g. `Nil` for `List a`,
|
|
// `None` for `Maybe a`) are filled with a synth-only
|
|
// wildcard `Type::Var { name: "$u" }`. The `$u`-prefix
|
|
// is reserved here (mirrors the checker's `$m` for
|
|
// metavars) and is treated as a match-anything wildcard
|
|
// by `unify_for_subst` on the arg side. This matters
|
|
// when a nullary ctor like `Nil` is nested inside a
|
|
// parent ctor whose other args pin the same type var
|
|
// concretely — e.g. `Cons(Int, Nil) : List<a>` must
|
|
// pin `a = Int` from the head and let the tail's
|
|
// unconstrained `a` defer rather than collide on
|
|
// `Type::unit()` as it would have pre-fix.
|
|
let resolved: Vec<Type> = cref
|
|
.type_vars
|
|
.iter()
|
|
.map(|v| {
|
|
subst
|
|
.get(v)
|
|
.cloned()
|
|
.unwrap_or_else(|| Type::Var { name: "$u".into() })
|
|
})
|
|
.collect();
|
|
Ok(Type::Con {
|
|
name: type_name.clone(),
|
|
args: resolved,
|
|
})
|
|
}
|
|
Term::Match { arms, .. } => {
|
|
if let Some(first) = arms.first() {
|
|
self.synth_with_extras(&first.body, extras)
|
|
} else {
|
|
Err(CodegenError::Internal(
|
|
"synth_arg_type: empty match".into(),
|
|
))
|
|
}
|
|
}
|
|
Term::Seq { rhs, .. } => self.synth_with_extras(rhs, extras),
|
|
Term::LetRec { .. } => {
|
|
// eliminated by desugar before codegen.
|
|
unreachable!("Term::LetRec eliminated by desugar")
|
|
}
|
|
Term::Clone { value } => {
|
|
// clone is identity — same type as inner.
|
|
self.synth_with_extras(value, extras)
|
|
}
|
|
Term::ReuseAs { body, .. } => {
|
|
// identity — the result type is the body's
|
|
// type. The source is dropped at codegen.
|
|
self.synth_with_extras(body, extras)
|
|
}
|
|
// A `Term::Loop`'s static type is the body's type.
|
|
// `Term::Recur` does not fall through; a Unit stub is
|
|
// safe (recur transfers control and never produces a
|
|
// value at its own position).
|
|
Term::Loop { body, .. } => self.synth_with_extras(body, extras),
|
|
Term::Recur { .. } => Ok(Type::unit()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use ailang_core::SCHEMA;
|
|
|
|
#[test]
|
|
fn emits_arith_fn() {
|
|
// Single module becomes a trivial workspace via `emit_ir`; the
|
|
// mangling is `@ail_<module>_<def>` even in the single-file case.
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![
|
|
Def::Fn(FnDef {
|
|
name: "add".into(),
|
|
ty: Type::Fn {
|
|
params: vec![Type::int(), Type::int()],
|
|
ret: Box::new(Type::int()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec!["a".into(), "b".into()],
|
|
body: Term::App {
|
|
callee: Box::new(Term::Var { name: "+".into() }),
|
|
args: vec![
|
|
Term::Var { name: "a".into() },
|
|
Term::Var { name: "b".into() },
|
|
],
|
|
tail: false,
|
|
},
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
}),
|
|
// Entry module needs a `main`, otherwise
|
|
// `lower_workspace` returns `MissingEntryMain`.
|
|
Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Lit { lit: Literal::Unit },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
}),
|
|
],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("define i64 @ail_t_add(i64 %arg_a, i64 %arg_b)"),
|
|
"ir was: {ir}"
|
|
);
|
|
assert!(ir.contains("add i64 %arg_a, %arg_b"));
|
|
assert!(
|
|
ir.contains("call i8 @ail_t_main()"),
|
|
"trampoline call missing: {ir}"
|
|
);
|
|
}
|
|
|
|
// 2026-05-21 operator-routing-eq-ord: the two
|
|
// `eq_on_adt_rejected_at_codegen` / `eq_on_fn_rejected_at_codegen`
|
|
// tests pinned the `lower_eq` direct-emit rejection path for ADT /
|
|
// Fn args. With `lower_eq` deleted in Task 7 and `==` no longer a
|
|
// builtin, the rejection path is gone by structure: `(app eq ADT
|
|
// ADT)` is rejected at typecheck as `NoInstance Eq <ADT>` (the
|
|
// user did not provide an instance), and `(app eq Fn Fn)` is
|
|
// similarly rejected. Coverage of the ADT no-instance case lives
|
|
// in `eq_ord_e2e.rs` (negative path on a missing user instance).
|
|
|
|
#[test]
|
|
fn missing_entry_main_is_error() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "noentry".into(),
|
|
imports: vec![],
|
|
defs: vec![Def::Fn(FnDef {
|
|
name: "helper".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::int()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Lit {
|
|
lit: Literal::Int { value: 1 },
|
|
},
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
})],
|
|
};
|
|
let err = emit_ir(&m).unwrap_err();
|
|
match err {
|
|
CodegenError::MissingEntryMain(name) => assert_eq!(name, "noentry"),
|
|
other => panic!("expected MissingEntryMain, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// under `--alloc=rc`, codegen emits one
|
|
/// `define void @drop_<m>_<T>` per `Def::Type`. For a recursive
|
|
/// ADT — a `IntList` with `Cons(Int, IntList)` — the `Cons` arm
|
|
/// of the drop fn loads the tail field and calls the ADT's own
|
|
/// drop fn on it (the recursion 18e replaces with an iterative
|
|
/// worklist). We assert both shapes here:
|
|
///
|
|
/// 1. `define void @drop_<m>_IntList(ptr %p)` is present.
|
|
/// 2. The fn's body contains a self-recursive call
|
|
/// `call void @drop_<m>_IntList(ptr %v...)` — proof that
|
|
/// the `Cons` arm walked the tail field rather than just
|
|
/// decrementing the outer cell.
|
|
///
|
|
/// The `Nil` arm has no boxed children and is a `br` to the
|
|
/// shared `join` block — implicit in (1).
|
|
///
|
|
/// Negative complement: under `--alloc=bump` no drop fn is
|
|
/// emitted (bump leaks by design); the IR shape stays
|
|
/// byte-identical to the pre-18c.4 pipeline.
|
|
#[test]
|
|
fn rc_alloc_emits_recursive_drop_fn_for_recursive_adt() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "rclist".into(),
|
|
imports: vec![],
|
|
defs: vec![
|
|
Def::Type(TypeDef {
|
|
name: "IntList".into(),
|
|
vars: vec![],
|
|
ctors: vec![
|
|
Ctor {
|
|
name: "Nil".into(),
|
|
fields: vec![],
|
|
},
|
|
Ctor {
|
|
name: "Cons".into(),
|
|
fields: vec![
|
|
Type::int(),
|
|
Type::Con {
|
|
name: "IntList".into(),
|
|
args: vec![],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
doc: None,
|
|
drop_iterative: false,
|
|
}),
|
|
Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Lit { lit: Literal::Unit },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
}),
|
|
],
|
|
};
|
|
let ws = Workspace {
|
|
entry: m.name.clone(),
|
|
modules: {
|
|
let mut x = BTreeMap::new();
|
|
x.insert(m.name.clone(), m.clone());
|
|
x
|
|
},
|
|
root_dir: std::path::PathBuf::from("."),
|
|
registry: ailang_core::workspace::Registry::default(),
|
|
};
|
|
let ir_rc = lower_workspace_with_alloc(&ws, AllocStrategy::Rc).unwrap();
|
|
assert!(
|
|
ir_rc.contains("define void @drop_rclist_IntList(ptr %p)"),
|
|
"rc IR missing per-type drop fn header. IR was:\n{ir_rc}"
|
|
);
|
|
// The Cons arm loads the tail field and recurses through
|
|
// the same drop symbol — proof that the cascade is wired.
|
|
assert!(
|
|
ir_rc.contains("call void @drop_rclist_IntList(ptr %v"),
|
|
"rc IR missing recursive drop call inside drop_rclist_IntList. IR was:\n{ir_rc}"
|
|
);
|
|
// The drop fn finishes by dec'ing the outer box.
|
|
assert!(
|
|
ir_rc.contains("call void @ailang_rc_dec(ptr %p)"),
|
|
"rc IR missing outer-box dec inside drop_rclist_IntList. IR was:\n{ir_rc}"
|
|
);
|
|
|
|
// Negative complement: no drop fns under `--alloc=bump`
|
|
// (only RC emits per-type drop fns; bump leaks).
|
|
let ir_bump = lower_workspace_with_alloc(&ws, AllocStrategy::Bump).unwrap();
|
|
assert!(
|
|
!ir_bump.contains("@drop_rclist_IntList"),
|
|
"bump IR should not declare/define any per-type drop fn. IR was:\n{ir_bump}"
|
|
);
|
|
}
|
|
|
|
/// Floats iter 4.1 RED: a `Literal::Float { bits: 0x3ff8_0000_0000_0000 }`
|
|
/// (= `1.5_f64`) lowers in a `Const` definition as an LLVM hex-float
|
|
/// `double` SSA constant. The exact IR snippet pinned: `@ail_t_k =
|
|
/// constant double 0x3FF8000000000000`.
|
|
#[test]
|
|
fn lowers_float_const_to_hex_double() {
|
|
use ailang_core::ast::*;
|
|
let m = Module {
|
|
schema: ailang_core::SCHEMA.to_string(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![
|
|
Def::Const(ConstDef {
|
|
name: "k".into(),
|
|
ty: Type::float(),
|
|
value: Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } },
|
|
doc: None,
|
|
}),
|
|
Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Lit { lit: Literal::Unit },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
}),
|
|
],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("@ail_t_k = constant double 0x3FF8000000000000"),
|
|
"ir missing the Float literal lowering: {ir}"
|
|
);
|
|
}
|
|
|
|
/// Floats iter 4.2 RED: `(+ 1.5 2.5)` lowers as `fadd double`,
|
|
/// not `add i64`. The Int regression `(+ 1 2)` still lowers as
|
|
/// `add i64`. Both lowerings live in one IR for one workspace
|
|
/// build.
|
|
#[test]
|
|
fn lowers_float_arithmetic_dispatched() {
|
|
use ailang_core::ast::*;
|
|
fn fn_def(name: &str, body: Term, ret_ty: Type) -> Def {
|
|
Def::Fn(FnDef {
|
|
name: name.into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(ret_ty),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body,
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
})
|
|
}
|
|
let plus_int = Term::App {
|
|
callee: Box::new(Term::Var { name: "+".into() }),
|
|
args: vec![
|
|
Term::Lit { lit: Literal::Int { value: 1 } },
|
|
Term::Lit { lit: Literal::Int { value: 2 } },
|
|
],
|
|
tail: false,
|
|
};
|
|
let plus_float = Term::App {
|
|
callee: Box::new(Term::Var { name: "+".into() }),
|
|
args: vec![
|
|
Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } },
|
|
Term::Lit { lit: Literal::Float { bits: 0x4004_0000_0000_0000u64 } },
|
|
],
|
|
tail: false,
|
|
};
|
|
let m = Module {
|
|
schema: ailang_core::SCHEMA.to_string(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![
|
|
fn_def("ai", plus_int, Type::int()),
|
|
fn_def("af", plus_float, Type::float()),
|
|
fn_def("main", Term::Lit { lit: Literal::Unit }, Type::unit()),
|
|
],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("add i64"),
|
|
"Int arithmetic regressed (no `add i64` in IR): {ir}"
|
|
);
|
|
assert!(
|
|
ir.contains("fadd double"),
|
|
"Float arithmetic missing (no `fadd double` in IR): {ir}"
|
|
);
|
|
}
|
|
|
|
// 2026-05-21 operator-routing-eq-ord: the
|
|
// `lowers_float_comparison_dispatched` test pinned the direct-emit
|
|
// `builtin_binop_typed` comparator arms for `==`/`!=`/`<` over
|
|
// Int and Float. Those arms are deleted in Task 7 (comparison is
|
|
// now class-method dispatch for Int/Bool/Str via prelude.Eq/Ord
|
|
// and named-fn dispatch for Float via float_eq / float_lt / etc.);
|
|
// the test's premise is gone. Replacement coverage of the new
|
|
// IR shape lives in the intercept-arm machinery exercised by
|
|
// `prelude_eq_int_carries_alwaysinline_attribute_in_emitted_ir`
|
|
// (Eq Int → icmp eq + alwaysinline) and indirectly via the
|
|
// workspace e2e tests for the Float-named-fn surface.
|
|
|
|
/// Floats iter 4.4 RED: four new fn-builtins lower to the spec'd
|
|
/// LLVM ops. `neg` polymorphic dispatches to `sub i64 0, %x` for
|
|
/// Int and `fneg double %x` for Float (NOT `fsub 0.0, %x`, which
|
|
/// is wrong for `-0.0`). `int_to_float` → `sitofp`. `is_nan` →
|
|
/// `fcmp uno double %x, %x`. `float_to_int_truncate` →
|
|
/// `@llvm.fptosi.sat.i64.f64` intrinsic call.
|
|
#[test]
|
|
fn lowers_float_fn_builtins() {
|
|
use ailang_core::ast::*;
|
|
fn fn_def(name: &str, body: Term, ret_ty: Type) -> Def {
|
|
Def::Fn(FnDef {
|
|
name: name.into(),
|
|
ty: Type::Fn {
|
|
params: vec![], ret: Box::new(ret_ty), effects: vec![],
|
|
param_modes: vec![], ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![], body, suppress: vec![], doc: None,
|
|
export: None,
|
|
})
|
|
}
|
|
fn app1(callee: &str, arg: Term) -> Term {
|
|
Term::App {
|
|
callee: Box::new(Term::Var { name: callee.into() }),
|
|
args: vec![arg], tail: false,
|
|
}
|
|
}
|
|
let neg_int = app1("neg", Term::Lit { lit: Literal::Int { value: 5 } });
|
|
let neg_float = app1("neg", Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } });
|
|
let i2f = app1("int_to_float", Term::Lit { lit: Literal::Int { value: 5 } });
|
|
let f2i = app1("float_to_int_truncate", Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } });
|
|
let isnan = app1("is_nan", Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } });
|
|
let main_def = fn_def("main", Term::Lit { lit: Literal::Unit }, Type::unit());
|
|
let m = Module {
|
|
schema: ailang_core::SCHEMA.to_string(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![
|
|
fn_def("ni", neg_int, Type::int()),
|
|
fn_def("nf", neg_float, Type::float()),
|
|
fn_def("c1", i2f, Type::float()),
|
|
fn_def("c2", f2i, Type::int()),
|
|
fn_def("isn", isnan, Type::bool_()),
|
|
main_def,
|
|
],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(ir.contains("sub i64 0,"), "neg Int missing: {ir}");
|
|
assert!(ir.contains("fneg double"), "neg Float missing (must use fneg, not fsub-from-zero): {ir}");
|
|
assert!(ir.contains("sitofp i64"), "int_to_float missing: {ir}");
|
|
assert!(ir.contains("@llvm.fptosi.sat.i64.f64"), "float_to_int_truncate intrinsic missing: {ir}");
|
|
assert!(ir.contains("fcmp uno double"), "is_nan missing (must be fcmp uno x, x): {ir}");
|
|
}
|
|
|
|
/// Floats iter 4.5 RED: `nan`/`inf`/`neg_inf` resolve as bare
|
|
/// `Term::Var` references and lower to direct hex-float `double`
|
|
/// SSA values at the use site (no global definition emitted —
|
|
/// they are values, not unreachable-style terminators).
|
|
#[test]
|
|
fn lowers_float_constants() {
|
|
use ailang_core::ast::*;
|
|
fn fn_def(name: &str, body: Term) -> Def {
|
|
Def::Fn(FnDef {
|
|
name: name.into(),
|
|
ty: Type::Fn {
|
|
params: vec![], ret: Box::new(Type::float()), effects: vec![],
|
|
param_modes: vec![], ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![], body, suppress: vec![], doc: None,
|
|
export: None,
|
|
})
|
|
}
|
|
let main_def = Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![], ret: Box::new(Type::unit()), effects: vec![],
|
|
param_modes: vec![], ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![], body: Term::Lit { lit: Literal::Unit },
|
|
suppress: vec![], doc: None,
|
|
export: None,
|
|
});
|
|
let m = Module {
|
|
schema: ailang_core::SCHEMA.to_string(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![
|
|
fn_def("k_nan", Term::Var { name: "nan".into() }),
|
|
fn_def("k_inf", Term::Var { name: "inf".into() }),
|
|
fn_def("k_neg_inf", Term::Var { name: "neg_inf".into() }),
|
|
main_def,
|
|
],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(ir.contains("0x7FF8000000000000"), "nan bit pattern missing: {ir}");
|
|
assert!(ir.contains("0x7FF0000000000000"), "+inf bit pattern missing: {ir}");
|
|
assert!(ir.contains("0xFFF0000000000000"), "-inf bit pattern missing: {ir}");
|
|
}
|
|
|
|
/// codegen intercepts a fn named `eq__Str` (the
|
|
/// monomorphiser's synthesised symbol for `Eq Str.eq`) and emits
|
|
/// a two-instruction body that calls @ail_str_eq, regardless of
|
|
/// what the lambda body in the source would lower to. Pinned with
|
|
/// a synthetic FnDef so the test does not depend on prelude
|
|
/// auto-injection.
|
|
#[test]
|
|
fn eq_str_mono_symbol_emits_ail_str_eq_call() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "prelude".into(),
|
|
imports: vec![],
|
|
defs: vec![
|
|
Def::Fn(FnDef {
|
|
name: "eq__Str".into(),
|
|
ty: Type::Fn {
|
|
params: vec![Type::str_(), Type::str_()],
|
|
ret: Box::new(Type::bool_()),
|
|
effects: vec![],
|
|
param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec!["x".into(), "y".into()],
|
|
// Body is a placeholder — the intercept must
|
|
// ignore it. We use Lit::Bool false so even if the
|
|
// intercept misfires the test still compiles.
|
|
body: Term::Lit { lit: Literal::Bool { value: false } },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
}),
|
|
Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Lit { lit: Literal::Unit },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
}),
|
|
],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("declare zeroext i1 @ail_str_eq(ptr, ptr)"),
|
|
"header missing @ail_str_eq declaration; ir was:\n{ir}"
|
|
);
|
|
assert!(
|
|
ir.contains("call zeroext i1 @ail_str_eq("),
|
|
"eq__Str body must call @ail_str_eq; ir was:\n{ir}"
|
|
);
|
|
}
|
|
|
|
/// every top-level fn — including primitive-
|
|
/// instance-bodied ones like `eq__Str` — must flow through
|
|
/// `emit_adapter_and_static_closure` so that referencing the fn
|
|
/// as a `Term::Var` value (dictionary entry, by-value pass to
|
|
/// higher-order code) finds the expected closure-pair symbol.
|
|
/// The intercept path used to short-circuit past adapter emission,
|
|
/// leaving `eq__Str` as the only top-level fn in the module
|
|
/// without an `@ail_<m>_<f>_adapter` / `@ail_<m>_<f>_clos` pair.
|
|
/// This test pins the invariant.
|
|
#[test]
|
|
fn eq_str_mono_symbol_emits_closure_adapter_pair() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "prelude".into(),
|
|
imports: vec![],
|
|
defs: vec![
|
|
Def::Fn(FnDef {
|
|
name: "eq__Str".into(),
|
|
ty: Type::Fn {
|
|
params: vec![Type::str_(), Type::str_()],
|
|
ret: Box::new(Type::bool_()),
|
|
effects: vec![],
|
|
param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec!["x".into(), "y".into()],
|
|
body: Term::Lit { lit: Literal::Bool { value: false } },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
}),
|
|
Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Lit { lit: Literal::Unit },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
}),
|
|
],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("@ail_prelude_eq__Str_adapter"),
|
|
"eq__Str must emit a closure adapter; ir was:\n{ir}"
|
|
);
|
|
assert!(
|
|
ir.contains("@ail_prelude_eq__Str_clos"),
|
|
"eq__Str must emit a static closure pair; ir was:\n{ir}"
|
|
);
|
|
}
|
|
|
|
// `eq_int_call_produces_icmp_i64_mono_fn` +
|
|
// `eq_bool_call_produces_icmp_i1_mono_fn` +
|
|
// `eq_str_call_produces_ail_str_eq_call_mono_fn` relocated to
|
|
// `crates/ailang-codegen/tests/eq_primitives_pin.rs` in iter
|
|
// form-a.1 Task 5 (use `ailang_surface::load_workspace` on the
|
|
// `eq_primitives_smoke.ail` Form-A fixture; the original `.ail.json`
|
|
// fixture is deleted in T8).
|
|
|
|
/// `runtime/str.c::ail_str_compare` backs the prelude's
|
|
/// `compare__Str` mono symbol (see `try_emit_primitive_instance_body`).
|
|
/// The declaration is unconditional in the IR header alongside
|
|
/// `@ail_str_eq`; this test pins the header line so a regression
|
|
/// that drops it would fail at link time only on programs that
|
|
/// actually call `compare` on a Str.
|
|
#[test]
|
|
fn ir_header_declares_ail_str_compare() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Lit { lit: Literal::Unit },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
})],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("declare i32 @ail_str_compare(ptr, ptr)"),
|
|
"header missing @ail_str_compare declaration; ir was:\n{ir}"
|
|
);
|
|
}
|
|
|
|
/// codegen intercepts a fn named `compare__Int` and
|
|
/// emits a three-way branch ladder: `icmp slt i64 a, b` →
|
|
/// LT-block; else `icmp eq i64 a, b` → EQ-block; else GT-block.
|
|
/// Each block constructs the matching Ordering ctor via the
|
|
/// existing `lower_ctor` path. Pinned with a synthetic FnDef so
|
|
/// the test does not depend on prelude auto-injection.
|
|
#[test]
|
|
fn compare_int_mono_symbol_emits_branch_ladder() {
|
|
let m = synth_compare_module("compare__Int", Type::int());
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("icmp slt i64"),
|
|
"compare__Int body must contain `icmp slt i64`; ir:\n{ir}"
|
|
);
|
|
assert!(
|
|
ir.contains("icmp eq i64"),
|
|
"compare__Int body must contain `icmp eq i64`; ir:\n{ir}"
|
|
);
|
|
}
|
|
|
|
/// same shape for `compare__Bool`. The LT-test uses
|
|
/// `icmp ult i1` (unsigned: false=0 ult true=1 gives the natural
|
|
/// Bool ordering false < true); the EQ-test uses `icmp eq i1`.
|
|
#[test]
|
|
fn compare_bool_mono_symbol_emits_branch_ladder() {
|
|
let m = synth_compare_module("compare__Bool", Type::bool_());
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("icmp ult i1"),
|
|
"compare__Bool body must contain `icmp ult i1`; ir:\n{ir}"
|
|
);
|
|
assert!(
|
|
ir.contains("icmp eq i1"),
|
|
"compare__Bool body must contain `icmp eq i1`; ir:\n{ir}"
|
|
);
|
|
}
|
|
|
|
/// `compare__Str` calls `@ail_str_compare` to get the
|
|
/// normalised {-1, 0, +1}, then branches: slt 0 → LT, eq 0 → EQ,
|
|
/// else GT.
|
|
#[test]
|
|
fn compare_str_mono_symbol_emits_ail_str_compare_call() {
|
|
let m = synth_compare_module("compare__Str", Type::str_());
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("call i32 @ail_str_compare("),
|
|
"compare__Str body must call @ail_str_compare; ir:\n{ir}"
|
|
);
|
|
assert!(
|
|
ir.contains("icmp slt i32"),
|
|
"compare__Str body must compare result `slt i32` against 0; ir:\n{ir}"
|
|
);
|
|
assert!(
|
|
ir.contains("icmp eq i32"),
|
|
"compare__Str body must compare result `eq i32` against 0; ir:\n{ir}"
|
|
);
|
|
}
|
|
|
|
/// Test helper: minimal two-module workspace where the "prelude"
|
|
/// module declares `data Ordering = LT | EQ | GT` and an
|
|
/// instance-fn shell (the intercept overrides the body), and the
|
|
/// entry module's `main` is a Unit no-op so `emit_ir` returns a
|
|
/// well-formed program. Used by the three `compare_*` tests above.
|
|
fn synth_compare_module(fn_name: &str, param_ail_ty: Type) -> Module {
|
|
let ordering = Def::Type(TypeDef {
|
|
name: "Ordering".into(),
|
|
vars: vec![],
|
|
ctors: vec![
|
|
Ctor { name: "LT".into(), fields: vec![] },
|
|
Ctor { name: "EQ".into(), fields: vec![] },
|
|
Ctor { name: "GT".into(), fields: vec![] },
|
|
],
|
|
doc: None,
|
|
drop_iterative: false,
|
|
});
|
|
// Ordering is `ptr` at the LLVM level (boxed ADT).
|
|
let compare = Def::Fn(FnDef {
|
|
name: fn_name.into(),
|
|
ty: Type::Fn {
|
|
params: vec![param_ail_ty.clone(), param_ail_ty.clone()],
|
|
ret: Box::new(Type::Con { name: "Ordering".into(), args: vec![] }),
|
|
effects: vec![],
|
|
param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec!["x".into(), "y".into()],
|
|
body: Term::Lit { lit: Literal::Unit }, // placeholder; intercept overrides
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
});
|
|
let main_def = Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Lit { lit: Literal::Unit },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
});
|
|
Module {
|
|
schema: SCHEMA.into(),
|
|
name: "prelude".into(),
|
|
imports: vec![],
|
|
defs: vec![ordering, compare, main_def],
|
|
}
|
|
}
|
|
|
|
/// language `Str` literals emit as packed-struct globals
|
|
/// carrying an explicit `len` field followed by the bytes + trailing
|
|
/// NUL. (The hs.1-era sentinel rc-header slot was removed per the
|
|
/// amended spec; static-Str pointers are kept out of `ailang_rc_dec`
|
|
/// at the codegen level via move-tracking and non-escape lowering,
|
|
/// so no header slot is needed.) This test pins the layout shape
|
|
/// against a tiny single-`Literal::Str` fixture.
|
|
#[test]
|
|
fn static_str_global_uses_packed_struct_with_len() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![
|
|
Def::Const(ConstDef {
|
|
name: "greeting".into(),
|
|
ty: Type::str_(),
|
|
value: Term::Lit { lit: Literal::Str { value: "hello".into() } },
|
|
doc: None,
|
|
}),
|
|
Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Lit { lit: Literal::Unit },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
}),
|
|
],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains(r#"<{ i64, [6 x i8] }> <{ i64 5, [6 x i8] c"hello\00" }>"#),
|
|
"expected packed-struct global with len + bytes + NUL; ir was:\n{ir}"
|
|
);
|
|
}
|
|
|
|
/// a `Literal::Str` at a callsite materialises a constexpr
|
|
/// `getelementptr` landing on the `len`-field of the packed-struct
|
|
/// global (now the *first* field, since the hs.1-era sentinel
|
|
/// rc-header slot was removed per the amended spec). This pins the
|
|
/// IR-Str-pointer convention used uniformly by all consumers.
|
|
#[test]
|
|
fn static_str_callsite_pointer_is_payload_via_constexpr_gep() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec!["IO".into()],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Do {
|
|
op: "io/print_str".into(),
|
|
args: vec![Term::Lit { lit: Literal::Str { value: "hi".into() } }],
|
|
tail: false,
|
|
},
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
})],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains(r#"getelementptr inbounds (<{ i64, [3 x i8] }>, ptr @.str_t_str_0, i32 0, i32 0)"#),
|
|
"expected constexpr-GEP-to-len-field at callsite; ir was:\n{ir}"
|
|
);
|
|
}
|
|
|
|
/// after the layout migration, the `io/print_str`
|
|
/// path must `getelementptr i8` +8 onto the IR-Str pointer before
|
|
/// passing it to `@puts`, so `@puts` receives the bytes pointer
|
|
/// (skipping the `len` field) and produces correct output.
|
|
#[test]
|
|
fn print_str_calls_puts_with_bytes_pointer() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec!["IO".into()],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Do {
|
|
op: "io/print_str".into(),
|
|
args: vec![Term::Lit { lit: Literal::Str { value: "hi".into() } }],
|
|
tail: false,
|
|
},
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
})],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
let body_idx = ir.find("define i8 @ail_t_main").expect("main body");
|
|
let body = &ir[body_idx..];
|
|
let puts_idx = body.find("@puts(").expect("@puts call present");
|
|
let before_puts = &body[..puts_idx];
|
|
assert!(
|
|
before_puts.contains("getelementptr inbounds i8, ptr ") && before_puts.contains(", i64 8"),
|
|
"expected `getelementptr inbounds i8, ptr <v>, i64 8` before @puts call; ir body was:\n{body}"
|
|
);
|
|
}
|
|
|
|
/// `eq__Str`'s body must `getelementptr i8` +8 on
|
|
/// both operand pointers before calling `@ail_str_eq`, since the
|
|
/// IR-Str pointer now lands on the `len`-field, not the bytes.
|
|
#[test]
|
|
fn eq_str_calls_ail_str_eq_with_bytes_pointer() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "prelude".into(),
|
|
imports: vec![],
|
|
defs: vec![
|
|
Def::Fn(FnDef {
|
|
name: "eq__Str".into(),
|
|
ty: Type::Fn {
|
|
params: vec![Type::str_(), Type::str_()],
|
|
ret: Box::new(Type::bool_()),
|
|
effects: vec![],
|
|
param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec!["x".into(), "y".into()],
|
|
body: Term::Lit { lit: Literal::Bool { value: false } },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
}),
|
|
Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Lit { lit: Literal::Unit },
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
}),
|
|
],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
let body_idx = ir.find("define i1 @ail_prelude_eq__Str").expect("eq__Str body");
|
|
let body = &ir[body_idx..];
|
|
let eq_idx = body.find("@ail_str_eq(").expect("@ail_str_eq call present");
|
|
let before_eq = &body[..eq_idx];
|
|
// Two GEPs (one per operand) must precede the call.
|
|
let gep_count = before_eq.matches("getelementptr inbounds i8, ptr ").count();
|
|
assert_eq!(
|
|
gep_count, 2,
|
|
"expected 2 +8 GEPs before @ail_str_eq (one per operand); ir body was:\n{body}"
|
|
);
|
|
assert!(
|
|
before_eq.matches(", i64 8").count() >= 2,
|
|
"expected both GEPs to be `, i64 8`; ir body was:\n{body}"
|
|
);
|
|
}
|
|
|
|
/// `compare__Str`'s body must `getelementptr i8` +8
|
|
/// on both operand pointers before calling `@ail_str_compare`,
|
|
/// symmetric to the `eq__Str` change.
|
|
#[test]
|
|
fn compare_str_calls_ail_str_compare_with_bytes_pointer() {
|
|
let m = synth_compare_module("compare__Str", Type::str_());
|
|
let ir = emit_ir(&m).unwrap();
|
|
let body_idx = ir
|
|
.find("define ptr @ail_prelude_compare__Str")
|
|
.expect("compare__Str body");
|
|
let body = &ir[body_idx..];
|
|
let cmp_idx = body
|
|
.find("@ail_str_compare(")
|
|
.expect("@ail_str_compare call present");
|
|
let before_cmp = &body[..cmp_idx];
|
|
let gep_count = before_cmp.matches("getelementptr inbounds i8, ptr ").count();
|
|
assert_eq!(
|
|
gep_count, 2,
|
|
"expected 2 +8 GEPs before @ail_str_compare; ir body was:\n{body}"
|
|
);
|
|
assert!(
|
|
before_cmp.matches(", i64 8").count() >= 2,
|
|
"expected both GEPs to be `, i64 8`; ir body was:\n{body}"
|
|
);
|
|
}
|
|
|
|
// 2026-05-21 operator-routing-eq-ord: the
|
|
// `lower_eq_str_calls_strcmp_with_bytes_pointer` test pinned the
|
|
// `lower_eq` direct-emit Str path that used an inline `@strcmp`
|
|
// call. With `lower_eq` deleted in Task 7 and `==` no longer a
|
|
// builtin, the only Str-equality path is class-method dispatch
|
|
// via prelude.Eq.eq, which the intercept lowers via the existing
|
|
// `eq__Str` arm (call to `@ail_str_eq`, not `@strcmp`). That
|
|
// path's GEP-+8 / bytes-pointer correctness is pinned by
|
|
// `eq_str_mono_symbol_emits_ail_str_eq_call` below.
|
|
|
|
/// a `Term::App` calling `int_to_str` lowers to
|
|
/// `call ptr @ailang_int_to_str(i64 %a)`. Pins the new builtin's
|
|
/// lowering shape against the runtime C glue introduced in iter
|
|
/// hs.3.
|
|
#[test]
|
|
fn int_to_str_lowers_to_ailang_int_to_str_call() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec!["IO".into()],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Do {
|
|
op: "io/print_str".into(),
|
|
args: vec![Term::App {
|
|
callee: Box::new(Term::Var { name: "int_to_str".into() }),
|
|
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
|
|
tail: false,
|
|
}],
|
|
tail: false,
|
|
},
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
})],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("call ptr @ailang_int_to_str(i64 "),
|
|
"expected lowering of int_to_str to call @ailang_int_to_str; ir was:\n{ir}"
|
|
);
|
|
}
|
|
|
|
/// `float_to_str` no longer raises CodegenError::Internal
|
|
/// — it lowers to `call ptr @ailang_float_to_str(double %a)`,
|
|
/// symmetric to the new `int_to_str` arm.
|
|
#[test]
|
|
fn float_to_str_no_longer_errors_internal() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec!["IO".into()],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Do {
|
|
op: "io/print_str".into(),
|
|
args: vec![Term::App {
|
|
callee: Box::new(Term::Var { name: "float_to_str".into() }),
|
|
args: vec![Term::Lit { lit: Literal::Float { bits: (3.5_f64).to_bits() } }],
|
|
tail: false,
|
|
}],
|
|
tail: false,
|
|
},
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
})],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("call ptr @ailang_float_to_str(double "),
|
|
"expected lowering of float_to_str to call @ailang_float_to_str; ir was:\n{ir}"
|
|
);
|
|
}
|
|
|
|
/// a `Term::App` calling `bool_to_str` lowers to
|
|
/// `call ptr @ailang_bool_to_str(i1 %a)`. Pins the new builtin's
|
|
/// lowering shape against the runtime C glue introduced in this
|
|
/// iter (mirror of hs.4's `int_to_str_lowers_to_ailang_int_to_str_call`).
|
|
#[test]
|
|
fn bool_to_str_emits_call_to_ailang_bool_to_str() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec!["IO".into()],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Do {
|
|
op: "io/print_str".into(),
|
|
args: vec![Term::App {
|
|
callee: Box::new(Term::Var { name: "bool_to_str".into() }),
|
|
args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
|
|
tail: false,
|
|
}],
|
|
tail: false,
|
|
},
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
})],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("call ptr @ailang_bool_to_str(i1 "),
|
|
"expected lowering of bool_to_str to call @ailang_bool_to_str; ir was:\n{ir}"
|
|
);
|
|
}
|
|
|
|
/// a `Term::App` calling `str_clone` lowers to
|
|
/// `call ptr @ailang_str_clone(ptr %a)`. Pins the new builtin's
|
|
/// lowering shape against the runtime C glue introduced in this
|
|
/// iter (mirror of hs.4's `int_to_str_lowers_to_ailang_int_to_str_call`).
|
|
#[test]
|
|
fn str_clone_emits_call_to_ailang_str_clone() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec!["IO".into()],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Do {
|
|
op: "io/print_str".into(),
|
|
args: vec![Term::App {
|
|
callee: Box::new(Term::Var { name: "str_clone".into() }),
|
|
args: vec![Term::Lit { lit: Literal::Str { value: "hi".into() } }],
|
|
tail: false,
|
|
}],
|
|
tail: false,
|
|
},
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
})],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("call ptr @ailang_str_clone(ptr "),
|
|
"expected lowering of str_clone to call @ailang_str_clone; ir was:\n{ir}"
|
|
);
|
|
}
|
|
|
|
/// a `Term::App` calling `str_concat` lowers to
|
|
/// `call ptr @ailang_str_concat(ptr %a, ptr %b)`. Pins the new
|
|
/// builtin's extern declaration AND the lower_app arm together
|
|
/// (mirror of the str_clone IR pin above).
|
|
#[test]
|
|
fn str_concat_emits_call_to_ailang_str_concat() {
|
|
let m = Module {
|
|
schema: SCHEMA.into(),
|
|
name: "t".into(),
|
|
imports: vec![],
|
|
defs: vec![Def::Fn(FnDef {
|
|
name: "main".into(),
|
|
ty: Type::Fn {
|
|
params: vec![],
|
|
ret: Box::new(Type::unit()),
|
|
effects: vec!["IO".into()],
|
|
param_modes: vec![],
|
|
ret_mode: ParamMode::Implicit,
|
|
},
|
|
params: vec![],
|
|
body: Term::Do {
|
|
op: "io/print_str".into(),
|
|
args: vec![Term::App {
|
|
callee: Box::new(Term::Var { name: "str_concat".into() }),
|
|
args: vec![
|
|
Term::Lit { lit: Literal::Str { value: "x".into() } },
|
|
Term::Lit { lit: Literal::Str { value: "y".into() } },
|
|
],
|
|
tail: false,
|
|
}],
|
|
tail: false,
|
|
},
|
|
suppress: vec![],
|
|
doc: None,
|
|
export: None,
|
|
})],
|
|
};
|
|
let ir = emit_ir(&m).unwrap();
|
|
assert!(
|
|
ir.contains("declare ptr @ailang_str_concat(ptr, ptr)"),
|
|
"expected extern declaration; ir was:\n{ir}"
|
|
);
|
|
assert!(
|
|
ir.contains("call ptr @ailang_str_concat(ptr "),
|
|
"expected lowering of str_concat to call @ailang_str_concat; ir was:\n{ir}"
|
|
);
|
|
}
|
|
}
|