diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index a12c665..1f1c282 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -64,6 +64,14 @@ fn list_sum_via_match() { assert_eq!(stdout.trim(), "42"); } +/// Iter 7: first-class function references — `apply(inc, 41)` must +/// produce 42, exercising fn-typed parameters and indirect call. +#[test] +fn higher_order_apply_inc() { + let stdout = build_and_run("hof.ail.json"); + assert_eq!(stdout.trim(), "42"); +} + /// Guards `ail diff`: a modified body changes the hash of `sum`, while /// `main` stays unchanged. Expects exit code 1, `changed` contains exactly /// `sum`, `unchanged` contains `main`, `added`/`removed` empty. diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index e9d70d4..68949b5 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -222,6 +222,12 @@ struct Emitter<'a> { /// Current basic block label. Set by `start_block` and is /// the single source of truth for `phi` operands. current_block: String, + /// Iter 7: 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, } #[derive(Debug, Clone)] @@ -293,6 +299,7 @@ impl<'a> Emitter<'a> { types, ctor_index, current_block: String::new(), + ssa_fn_sigs: BTreeMap::new(), } } @@ -378,6 +385,9 @@ impl<'a> Emitter<'a> { 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(); let mut sig = format!( "define {ret} @ail_{module}_{name}(", @@ -385,17 +395,25 @@ impl<'a> Emitter<'a> { module = self.module_name, name = f.name ); - for (i, (pname, pty)) in f.params.iter().zip(llvm_param_tys.iter()).enumerate() { + 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_ - sig.push_str(&format!("{} %arg_{}", pty, pname)); - self.locals.push(( - pname.clone(), - format!("%arg_{}", pname), - pty.clone(), - )); + let pssa = format!("%arg_{}", pname); + sig.push_str(&format!("{pty} {pssa}")); + self.locals.push((pname.clone(), pssa.clone(), pty.clone())); + // Iter 7: 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); + } } sig.push_str(") {\n"); self.body.push_str(&sig); @@ -432,10 +450,16 @@ impl<'a> Emitter<'a> { }), Term::Var { name } => { if let Some((_, ssa, ty)) = self.locals.iter().rev().find(|(n, _, _)| n == name) { - Ok((ssa.clone(), ty.clone())) - } else { - Err(CodegenError::UnknownVar(name.clone())) + return Ok((ssa.clone(), ty.clone())); } + // Iter 7: 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())); + } + Err(CodegenError::UnknownVar(name.clone())) } Term::Let { name, value, body } => { let (val_ssa, val_ty) = self.lower_term(value)?; @@ -487,18 +511,50 @@ impl<'a> Emitter<'a> { ev = else_v, elbl = else_block_end, )); + // Iter 7: 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 } => { - let name = match callee.as_ref() { - Term::Var { name } => name.clone(), - _ => { - return Err(CodegenError::Internal( - "MVP: callee must be a variable".into(), - )); + // 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); } - }; - self.lower_app(&name, args) + } + 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) } Term::Do { op, args } => self.lower_effect_op(op, args), Term::Ctor { type_name, ctor, args } => self.lower_ctor(type_name, ctor, args), @@ -838,6 +894,82 @@ impl<'a> Emitter<'a> { Ok((dst, sig.ret.clone())) } + /// Iter 7: indirect call through a fn-pointer SSA value. Sig must + /// already be known (sidetable lookup happened in the caller). + fn emit_indirect_call( + &mut self, + callee_ssa: &str, + sig: &FnSig, + args: &[Term], + ) -> 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)); + } + let arglist = compiled + .iter() + .map(|(v, t)| format!("{t} {v}")) + .collect::>() + .join(", "); + let dst = self.fresh_ssa(); + // LLVM indirect-call form: `call () ()`. + let param_tys = sig.params.join(", "); + self.body.push_str(&format!( + " {dst} = call {ret} ({ptys}) {callee_ssa}({arglist})\n", + ret = sig.ret, + ptys = param_tys, + )); + Ok((dst, sig.ret.clone())) + } + + /// Iter 7: 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, and qualified `prefix.def`. + /// Locals shadow this — the caller checks for that first. + fn is_static_callee(&self, name: &str) -> bool { + if builtin_binop(name).is_some() || name == "not" { + return true; + } + if name.matches('.').count() == 1 { + return true; + } + self.module_user_fns + .get(self.module_name) + .is_some_and(|m| m.contains_key(name)) + } + + /// Iter 7: resolve `name` to a top-level fn-pointer (`@ail__`) + /// + its sig, or return None. Mirrors the dispatch in `lower_app` for + /// fn refs only — operators / `not` are not first-class values, so + /// `is_static_callee` excludes them here. + fn resolve_top_level_fn(&self, name: &str) -> Option<(String, FnSig)> { + if name.matches('.').count() == 1 { + let (prefix, suffix) = name.split_once('.')?; + let target = self.import_map.get(prefix)?; + let sig = self.module_user_fns.get(target)?.get(suffix)?.clone(); + return Some((format!("@ail_{target}_{suffix}"), sig)); + } + let sig = self + .module_user_fns + .get(self.module_name)? + .get(name)? + .clone(); + Some((format!("@ail_{module}_{name}", module = self.module_name), sig)) + } + fn lower_effect_op(&mut self, op: &str, args: &[Term]) -> Result<(String, String)> { match op { "io/print_int" => { @@ -950,12 +1082,31 @@ fn llvm_type(t: &Type) -> Result { // intentional — otherwise `ptr` would mask a wrong value. _ => Ok("ptr".into()), }, + // Function values (Iter 7): all fn-pointers are opaque `ptr` + // at the LLVM level. The actual signature travels via the + // emitter's `ssa_fn_sigs` sidetable. + Type::Fn { .. } => Ok("ptr".into()), other => Err(CodegenError::UnsupportedType( ailang_core::pretty::type_to_string(other), )), } } +/// Builds an `FnSig` (LLVM types only) from an AILang `Type::Fn`. +/// Returns `None` for non-function types or if any param/ret type fails +/// to lower (e.g. a residual `Type::Var` or `Forall` that the typechecker +/// would reject before us). +fn fn_sig_from_type(t: &Type) -> Option { + if let Type::Fn { params, ret, .. } = t { + let p: Result> = params.iter().map(llvm_type).collect(); + let r = llvm_type(ret); + if let (Ok(p), Ok(r)) = (p, r) { + return Some(FnSig { params: p, ret: r }); + } + } + None +} + fn builtin_binop(name: &str) -> Option<(&'static str, &'static str)> { Some(match name { "+" => ("add", "i64"), diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f529db0..a455db0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -229,11 +229,13 @@ ail build — full pipeline → binary ## What is not (yet) supported -Snapshot of the boundary at the end of Iter 6. Items move out of this list +Snapshot of the boundary at the end of Iter 7. Items move out of this list as iterations land; the JOURNAL records the exact iteration. -- No closures / higher-order functions. Will require a typed IR stage (TIR) - before lowering. +- No closures with capture / no anonymous lambdas. Both will require a + typed IR stage (TIR) and closure conversion in lowering. First-class + function references (top-level fns as values, fn-typed parameters, + indirect calls) **are** supported as of Iter 7 — see below. - No effect handlers — only the built-in IO and Diverge ops. - No refinements / SMT escalation. - No cross-module ADTs. ADTs are local to a module; ctor names must be @@ -253,6 +255,14 @@ What **is** supported (and used as the smoke test for the pipeline): pattern are restricted to `Var` / `Wild`. - **Imports + qualified cross-module references** via dotted names (Iter 5). +- **First-class function references** (Iter 7). A top-level fn name (or + qualified `prefix.def`) used as a `Term::Var` is a fn-pointer value of + AILang type `Type::Fn { ... }` and LLVM type `ptr`. Fn-typed parameters + may be called directly as `f(args)`. No capture, no lambdas — the only + way to construct a fn-value is to refer to a top-level def. -Pipeline regression smoke test: `examples/sum.ail.json` produces a -binary that prints 55. +Pipeline regression smoke tests: + +- `examples/sum.ail.json` → prints 55 (recursion, arithmetic). +- `examples/list.ail.json` → prints 42 (ADTs + match). +- `examples/hof.ail.json` → prints 42 (first-class fn-refs, indirect call). diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 91c822a..c6090a3 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -430,3 +430,92 @@ stage, closure conversion in lowering, and a heap-aware ABI. The multi-diag refactor in Iter 6 was scoped intentionally minimal — when TIR lands, intra-def diagnostics become structurally cheap and Task #20 gets revisited. + +## 2026-05-07 — Iter 7 done: first-class function references (no capture) + +Iter 6 outlined Iter 7 as "closures + HOFs + TIR". KISS course-correct +on inspection: that bundle had three independent things in it, and the +HOF use-cases (passing functions around, calling through fn-typed +parameters) need none of TIR or capture. Splitting paid off — what +landed here is ~120 LOC of codegen, no TIR, no heap, no ABI churn. +Closures with capture stay queued for Iter 8 (where TIR is the +correct precondition). + +**What works now:** +- Top-level fn name (or qualified `prefix.def`) used as a value yields + an LLVM fn-pointer (`@ail__`, type `ptr`). +- Fn-typed parameters can be called as `f(args)` — the body emits an + indirect `call () %f(...)`. +- Pass through `let`: `let g = inc in g(x)` works (the local just + aliases the global SSA, the sidetable lookup still hits). +- Pass to another fn: `apply(inc, 41) == 42` — see + `examples/hof.ail.json`, exercised end-to-end. + +**What does not (yet) work — by design:** +- No anonymous lambdas. The only fn-value source is a top-level def + reference. +- No capture. A fn-value is always a constant pointer to a top-level + def; there is no environment to allocate. +- Both deferred to Iter 8 where they share the TIR + closure-conversion + preconditions. + +**Implementation, in order of where the rubber meets the road:** + +1. `llvm_type` learned `Type::Fn { .. } -> "ptr"`. The actual signature + travels separately. New helper `fn_sig_from_type` lifts an AILang + fn-type into an `FnSig` (LLVM types only). +2. `Emitter` got a sidetable: `ssa_fn_sigs: BTreeMap`, + keyed by SSA value (or `@global`). It's reset per function body. +3. At `emit_fn` entry, every fn-typed parameter registers + `(%arg_, sig)` in the sidetable. +4. `lower_term(Term::Var)` now falls through to a top-level fn lookup + (`resolve_top_level_fn`) when the name isn't a local. The returned + SSA is the global symbol; the sidetable gets the sig. +5. `lower_term(Term::App)` dispatches: + - if callee is a `Var` AND not shadowed AND statically known + (`is_static_callee` covers builtin operators, qualified + `prefix.def`, current-module fns), keep the existing direct + `lower_app` path — no extra indirection in the IR; + - otherwise lower the callee, expect type `ptr`, look up the sig in + the sidetable, emit `emit_indirect_call`. +6. `Term::If` propagates the sig to its phi SSA when both branches are + fn-pointers with matching sigs (cheap two-line copy; no separate + test, falls out of the `apply`-on-conditional pattern). + +**Why no typechecker change?** The typechecker already accepted +fn-typed locals (`Term::Var` against `env.globals`, App via `synth(callee)` +unifying with `Type::Fn`). The only blocker was `MVP: callee must be a +variable` in codegen. + +**Tests:** 48 green (previously 47). + +- `crates/ail/tests/e2e.rs::higher_order_apply_inc` builds and runs + `examples/hof.ail.json`, asserts the binary prints `42`. +- Existing tests unchanged (incl. snapshot tests around the IR + emission for `sum`, `list`, `max3`). + +**Architecture self-check:** + +- *Would I use this language now?* Yes for `apply`-style and "pass a + predicate" patterns. Still no for capturing closures (`let n = 3 in + map(\x -> x + n, xs)`-equivalent), but the ergonomic gap shrank. +- *Consistency:* DESIGN.md "What is not (yet) supported" rewritten in + the same edit; first-class fn-refs now have a positive bullet, the + closures bullet is precise about what it means (no capture, no + lambdas). +- *Visualisation:* `ail describe`/`manifest` already render fn-typed + params correctly via the existing `pretty::type_to_string` + (`((Int) -> Int, Int) -> Int`). No tooling change required. +- *KISS:* every alternative I considered (full `LocalType` enum, + swapping `(String, String)` returns to a typed wrapper, lifting + lambdas to defs as syntactic sugar) was strictly more code than the + sidetable approach, with no expressivity gain. + +**Plan iteration 8:** + +Closures with capture, anonymous lambdas, the typed IR (TIR) layer, +closure conversion in lowering. Now that we have indirect calls +working, the main delta is: a fn-value also needs an environment +pointer, the sidetable becomes per-value (heap-allocated), and the +calling convention shifts to `(env_ptr, args...)`. Touches every +existing call path — that's why it gets its own iteration. diff --git a/examples/hof.ail.json b/examples/hof.ail.json new file mode 100644 index 0000000..f8172f7 --- /dev/null +++ b/examples/hof.ail.json @@ -0,0 +1,77 @@ +{ + "schema": "ailang/v0", + "name": "hof", + "imports": [], + "defs": [ + { + "kind": "fn", + "name": "inc", + "type": { + "k": "fn", + "params": [{ "k": "con", "name": "Int" }], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + }, + "params": ["x"], + "doc": "increment by one", + "body": { + "t": "app", + "fn": { "t": "var", "name": "+" }, + "args": [ + { "t": "var", "name": "x" }, + { "t": "lit", "lit": { "kind": "int", "value": 1 } } + ] + } + }, + { + "kind": "fn", + "name": "apply", + "type": { + "k": "fn", + "params": [ + { + "k": "fn", + "params": [{ "k": "con", "name": "Int" }], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + }, + { "k": "con", "name": "Int" } + ], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + }, + "params": ["f", "x"], + "doc": "apply a fn-of-Int to an Int", + "body": { + "t": "app", + "fn": { "t": "var", "name": "f" }, + "args": [{ "t": "var", "name": "x" }] + } + }, + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "do", + "op": "io/print_int", + "args": [ + { + "t": "app", + "fn": { "t": "var", "name": "apply" }, + "args": [ + { "t": "var", "name": "inc" }, + { "t": "lit", "lit": { "kind": "int", "value": 41 } } + ] + } + ] + } + } + ] +}