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:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user