Iter 7: first-class function references (no capture)

Top-level fn names are now usable as values, fn-typed parameters can
be called as `f(args)`. KISS slice of "closures + HOFs + TIR": no
TIR, no heap, no ABI shift — that bundle stays in Iter 8 where
closure-with-capture and lambdas land together.

Codegen:
- Type::Fn lowers to LLVM `ptr`; sig travels via a per-fn-body
  `ssa_fn_sigs` sidetable on `Emitter`.
- Term::Var falls through to `resolve_top_level_fn` when the name is
  not a local; emits `@ail_<m>_<def>` and registers the sig.
- Term::App splits: static callee (builtin / current-module / qualified)
  keeps the existing direct path; otherwise lower the callee, look up
  the sig, emit indirect `call <ret> (<param-tys>) %fn(args...)`.
- emit_fn registers fn-typed params in the sidetable on entry.
- If branches propagate a fn-pointer sig to their phi when both arms
  match.

Typechecker: unchanged. It already accepted `synth(callee)` against
Type::Fn; the only blocker was codegen rejecting non-Var callees.

Tests: 48 green (was 47). New `examples/hof.ail.json` and e2e
`higher_order_apply_inc` exercise `apply(inc, 41) == 42` end-to-end.

DESIGN.md: "What is not (yet) supported" rewritten — closures-with-
capture and lambdas remain pending, first-class fn-refs added as
positive bullet. Smoke-test list extended with `hof.ail.json`.

JOURNAL.md: Iter 7 entry with rationale, scope choice, architecture
self-check, Iter 8 plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 12:44:10 +02:00
parent 1a448309fa
commit c6c0a10788
5 changed files with 358 additions and 23 deletions
+8
View File
@@ -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.
+169 -18
View File
@@ -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<String, FnSig>,
}
#[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_<name>
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::<Vec<_>>()
.join(", ");
let dst = self.fresh_ssa();
// LLVM indirect-call form: `call <ret>(<param-tys>) <ptr>(<args>)`.
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_<m>_<def>`)
/// + 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<String> {
// 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<FnSig> {
if let Type::Fn { params, ret, .. } = t {
let p: Result<Vec<String>> = 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"),
+15 -5
View File
@@ -229,11 +229,13 @@ ail build <module> — 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).
+89
View File
@@ -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_<m>_<def>`, type `ptr`).
- Fn-typed parameters can be called as `f(args)` — the body emits an
indirect `call <ret> (<param-tys>) %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<String, FnSig>`,
keyed by SSA value (or `@global`). It's reset per function body.
3. At `emit_fn` entry, every fn-typed parameter registers
`(%arg_<name>, 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.
+77
View File
@@ -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 } }
]
}
]
}
}
]
}