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
+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"),