Iter 14e: explicit, verified tail calls
Decision 8 ships. Term::App and Term::Do gain tail: bool with serde-default false and skip-when-false serialisation. New typecheck pass verify_tail_positions enforces tail-position rules (Scheme-style propagation through match arms, seq.rhs, let body, lam body). Codegen emits musttail call for marked App calls. Hash invariance verified: only the two migrated print_list defs (list_map_poly.print_list, sort.print_list) changed hashes; all other defs across all 18 fixtures kept bit-identical hashes — confirms the skip-when-false serialisation rule works. Tests 76 -> 79: tail_call_in_non_tail_position_is_rejected, tail_call_in_tail_position_is_accepted, plus an IR-grep e2e test asserting that print_list's recursive call site emits musttail in the lowered IR. Existing 25 e2e tests unchanged in behaviour (map -> [2,3,4], sort -> sorted list). IR evidence at the recursive site: %v7 = musttail call i8 @ail_list_map_poly_print_list(ptr %v6) ret i8 %v7 Two deviations called out in the implementer report and JOURNAL: 1. tail-do uses tail call, not musttail. Cross-type return (runtime helpers return i32, AILang Unit is i8) would have LLVM reject musttail. Path is implemented but not exercised by any current fixture; proper fix is runtime-helper signature change, punted. 2. block_terminated flag in codegen so tail-call emit (musttail call + ret) doesn't get a duplicate trailing ret from surrounding code (match-arm phi, fn-body, lambda thunk). Internal plumbing; required for IR well-formedness. Form (A) productions now at ~30, exactly the constraint-1 budget. Future surface additions need to retire something or explicit-budget-rebalance in DESIGN.md. GC notes from implementer survey land in JOURNAL: - Allocations cluster in lower_ctor; every term-ctor does malloc(8+8n). - Tail recursion does not reduce alloc pressure, only stack. For map-style ctor-blocked recursions, allocation IS the bottleneck. - Per-fn arena is sound only when fn return type contains no boxed ADT. Most current fixtures violate this. Plan 14f: Boehm conservative GC (GC_malloc, -lgc) as a first cut. Single-iter integration, no AST/schema change. Stress test: build a 100k Cons list, observe RSS doesn't blow up. After 14f the language is feature-complete enough for stdlib work (15a). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -863,7 +863,7 @@ fn walk_term(
|
||||
}
|
||||
out.insert(name.clone());
|
||||
}
|
||||
Term::App { callee, args } => {
|
||||
Term::App { callee, args, .. } => {
|
||||
walk_term(callee, out, builtins, scope);
|
||||
for a in args {
|
||||
walk_term(a, out, builtins, scope);
|
||||
@@ -878,7 +878,7 @@ fn walk_term(
|
||||
scope.remove(name);
|
||||
}
|
||||
}
|
||||
Term::Do { op, args } => {
|
||||
Term::Do { op, args, .. } => {
|
||||
// Mark effect ops as `effect:io/print_int` so they can be
|
||||
// separated from normal function calls.
|
||||
out.insert(format!("effect:{op}"));
|
||||
|
||||
@@ -107,6 +107,62 @@ fn list_map_poly_inc_then_prints() {
|
||||
assert_eq!(lines, vec!["2", "3", "4"]);
|
||||
}
|
||||
|
||||
/// Iter 14e: `tail: true` annotation on `print_list`'s recursive
|
||||
/// call must reach LLVM as a `musttail call`. Asserted by emitting IR
|
||||
/// for list_map_poly and grepping for the exact instruction. This is
|
||||
/// the only direct evidence that the type-system marker actually
|
||||
/// influences codegen — the e2e test above only checks observed
|
||||
/// stdout, which `musttail` does not change.
|
||||
#[test]
|
||||
fn iter14e_print_list_recursion_emits_musttail() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let src = workspace.join("examples").join("list_map_poly.ail.json");
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"ailang_iter14e_musttail_{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let out_ll = tmp.join("list_map_poly.ll");
|
||||
let status = Command::new(ail_bin())
|
||||
.args(["emit-ir", src.to_str().unwrap(), "-o"])
|
||||
.arg(&out_ll)
|
||||
.status()
|
||||
.expect("ail emit-ir failed to run");
|
||||
assert!(status.success(), "ail emit-ir failed");
|
||||
let ir = std::fs::read_to_string(&out_ll).expect("read emitted IR");
|
||||
|
||||
// Find the musttail call to print_list inside the print_list body.
|
||||
// The exact line shape:
|
||||
// %vN = musttail call i8 @ail_list_map_poly_print_list(ptr %vM)
|
||||
let has_musttail = ir.lines().any(|l| {
|
||||
l.contains("musttail call")
|
||||
&& l.contains("@ail_list_map_poly_print_list(")
|
||||
});
|
||||
assert!(
|
||||
has_musttail,
|
||||
"expected `musttail call ... @ail_list_map_poly_print_list(...)` \
|
||||
in emitted IR; not found.\nIR:\n{ir}"
|
||||
);
|
||||
// Defence-in-depth: the musttail call must be immediately followed
|
||||
// by a `ret i8 %v...`. We assert the next non-empty line is a ret.
|
||||
let mut lines = ir.lines();
|
||||
while let Some(line) = lines.next() {
|
||||
if line.contains("musttail call")
|
||||
&& line.contains("@ail_list_map_poly_print_list(")
|
||||
{
|
||||
let next = lines.next().unwrap_or("").trim();
|
||||
assert!(
|
||||
next.starts_with("ret i8 "),
|
||||
"musttail call must be immediately followed by `ret i8 ...`; \
|
||||
got: `{next}`"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("musttail call line not found (sanity check)");
|
||||
}
|
||||
|
||||
/// Iter 11 dogfood: insertion sort over an 11-element IntList.
|
||||
/// Exercises `<=`, `if`, mutual-leaf recursion (`insert` and `sort`),
|
||||
/// nested ctor construction, and the Iter 10 seq operator inside
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
//! - `module-cycle` — workspace loader (Iter 5b, in the CLI path)
|
||||
//! - `module-name-mismatch` — workspace loader (Iter 5b, in the CLI path)
|
||||
//! - `module-hash-mismatch` — workspace loader (Iter 5b, in the CLI path)
|
||||
//! - `tail-call-not-in-tail-position` (Iter 14e, see Decision 8)
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
|
||||
@@ -395,6 +395,12 @@ pub enum CheckError {
|
||||
/// for qualified cross-module references. Code: `invalid-def-name`.
|
||||
#[error("invalid def name `{name}`: contains `.` (reserved for qualified refs)")]
|
||||
InvalidDefName { name: String },
|
||||
|
||||
/// Iter 14e: a `Term::App { tail: true, .. }` or
|
||||
/// `Term::Do { tail: true, .. }` was found in a non-tail position.
|
||||
/// Code: `tail-call-not-in-tail-position`. See Decision 8.
|
||||
#[error("call marked `tail` is not in tail position")]
|
||||
TailCallNotInTailPosition,
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, CheckError>;
|
||||
@@ -430,6 +436,7 @@ impl CheckError {
|
||||
CheckError::UnknownModule { .. } => "unknown-module",
|
||||
CheckError::UnknownImport { .. } => "unknown-import",
|
||||
CheckError::InvalidDefName { .. } => "invalid-def-name",
|
||||
CheckError::TailCallNotInTailPosition => "tail-call-not-in-tail-position",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,6 +901,11 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> {
|
||||
let body_ty = synth(&f.body, &env, &mut locals, &mut effects, &f.name, &mut subst, &mut counter)?;
|
||||
unify(&ret_ty, &body_ty, &mut subst)?;
|
||||
|
||||
// Iter 14e: tail-position verification (Decision 8). Runs after
|
||||
// the main type-check so that a tail-call marker on a malformed
|
||||
// call doesn't drown out the underlying type error.
|
||||
verify_tail_positions(&f.body, true)?;
|
||||
|
||||
let declared: BTreeSet<String> = declared_effs.into_iter().collect();
|
||||
for e in &effects {
|
||||
if !declared.contains(e) {
|
||||
@@ -903,6 +915,77 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Iter 14e: verifies that every `Term::App { tail: true, .. }` and
|
||||
/// `Term::Do { tail: true, .. }` actually sits in tail position, per
|
||||
/// Decision 8.
|
||||
///
|
||||
/// `is_tail` is the tail-context flag for the term currently being
|
||||
/// visited. The propagation rules (from DESIGN.md Decision 8):
|
||||
///
|
||||
/// - The body of a fn / Lam is visited with `is_tail = true`.
|
||||
/// - `Match { scrutinee, arms }`: the scrutinee is **not** in tail
|
||||
/// position; each arm body inherits the same `is_tail` as the match.
|
||||
/// - `Seq { lhs, rhs }`: lhs is non-tail; rhs inherits.
|
||||
/// - `Let { value, body, .. }`: value is non-tail; body inherits.
|
||||
/// - `App { callee, args, tail }`: the callee and all args are
|
||||
/// non-tail. If `tail == true`, the App itself must have arrived
|
||||
/// with `is_tail == true`; otherwise diagnostic.
|
||||
/// - `Do { args, tail }`: same rule as App; all args are non-tail.
|
||||
/// - `Ctor { args }`: all args are non-tail.
|
||||
/// - `Lam { body }`: the Lam value is at whatever `is_tail` was; the
|
||||
/// recursion **into** the body opens a fresh tail scope (the body
|
||||
/// is visited with `is_tail = true`).
|
||||
/// - `Lit`, `Var`: leaves; no further descent.
|
||||
pub fn verify_tail_positions(t: &Term, is_tail: bool) -> Result<()> {
|
||||
match t {
|
||||
Term::Lit { .. } | Term::Var { .. } => Ok(()),
|
||||
Term::App { callee, args, tail } => {
|
||||
if *tail && !is_tail {
|
||||
return Err(CheckError::TailCallNotInTailPosition);
|
||||
}
|
||||
verify_tail_positions(callee, false)?;
|
||||
for a in args {
|
||||
verify_tail_positions(a, false)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Term::Do { args, tail, .. } => {
|
||||
if *tail && !is_tail {
|
||||
return Err(CheckError::TailCallNotInTailPosition);
|
||||
}
|
||||
for a in args {
|
||||
verify_tail_positions(a, false)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Term::Let { value, body, .. } => {
|
||||
verify_tail_positions(value, false)?;
|
||||
verify_tail_positions(body, is_tail)
|
||||
}
|
||||
Term::Seq { lhs, rhs } => {
|
||||
verify_tail_positions(lhs, false)?;
|
||||
verify_tail_positions(rhs, is_tail)
|
||||
}
|
||||
Term::Match { scrutinee, arms } => {
|
||||
verify_tail_positions(scrutinee, false)?;
|
||||
for arm in arms {
|
||||
verify_tail_positions(&arm.body, is_tail)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Term::Ctor { args, .. } => {
|
||||
for a in args {
|
||||
verify_tail_positions(a, false)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Term::Lam { body, .. } => {
|
||||
// Entering a Lam body opens a fresh tail scope.
|
||||
verify_tail_positions(body, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_const(c: &ConstDef, env: &Env) -> Result<()> {
|
||||
// Const types are never polymorphic — a Forall here is rejected
|
||||
// outright. Any other type passes through to `synth` as before.
|
||||
@@ -976,7 +1059,7 @@ fn synth(
|
||||
};
|
||||
Ok(maybe_instantiate(raw, counter))
|
||||
}
|
||||
Term::App { callee, args } => {
|
||||
Term::App { callee, args, .. } => {
|
||||
let cty = synth(callee, env, locals, effects, in_def, subst, counter)?;
|
||||
let cty = subst.apply(&cty);
|
||||
let (params, ret, fx) = match &cty {
|
||||
@@ -1035,7 +1118,7 @@ fn synth(
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
Term::Do { op, args } => {
|
||||
Term::Do { op, args, .. } => {
|
||||
let sig = env
|
||||
.effect_ops
|
||||
.get(op)
|
||||
@@ -1425,6 +1508,7 @@ mod tests {
|
||||
Term::Var { name: "a".into() },
|
||||
Term::Var { name: "b".into() },
|
||||
],
|
||||
tail: false,
|
||||
},
|
||||
)],
|
||||
};
|
||||
@@ -1474,6 +1558,7 @@ mod tests {
|
||||
args: vec![Term::Lit {
|
||||
lit: Literal::Int { value: 1 },
|
||||
}],
|
||||
tail: false,
|
||||
},
|
||||
)],
|
||||
};
|
||||
@@ -1711,6 +1796,7 @@ mod tests {
|
||||
Term::App {
|
||||
callee: Box::new(Term::Var { name: "id".into() }),
|
||||
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
|
||||
tail: false,
|
||||
},
|
||||
);
|
||||
// `use_bool` returns id(true) :: Bool.
|
||||
@@ -1725,6 +1811,7 @@ mod tests {
|
||||
Term::App {
|
||||
callee: Box::new(Term::Var { name: "id".into() }),
|
||||
args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
|
||||
tail: false,
|
||||
},
|
||||
);
|
||||
let m = Module {
|
||||
@@ -1766,6 +1853,7 @@ mod tests {
|
||||
Term::App {
|
||||
callee: Box::new(Term::Var { name: "id".into() }),
|
||||
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
|
||||
tail: false,
|
||||
},
|
||||
);
|
||||
let m = Module {
|
||||
@@ -1805,6 +1893,7 @@ mod tests {
|
||||
Term::App {
|
||||
callee: Box::new(Term::Var { name: "f".into() }),
|
||||
args: vec![Term::Var { name: "x".into() }],
|
||||
tail: false,
|
||||
},
|
||||
);
|
||||
// A monomorphic helper to be passed to apply.
|
||||
@@ -1822,6 +1911,7 @@ mod tests {
|
||||
Term::Var { name: "n".into() },
|
||||
Term::Lit { lit: Literal::Int { value: 1 } },
|
||||
],
|
||||
tail: false,
|
||||
},
|
||||
);
|
||||
let use_apply = fn_def(
|
||||
@@ -1838,6 +1928,7 @@ mod tests {
|
||||
Term::Var { name: "succ".into() },
|
||||
Term::Lit { lit: Literal::Int { value: 41 } },
|
||||
],
|
||||
tail: false,
|
||||
},
|
||||
);
|
||||
let m = Module {
|
||||
@@ -1945,6 +2036,7 @@ mod tests {
|
||||
ctor: "MkBox".into(),
|
||||
args: vec![Term::Lit { lit: Literal::Int { value: 7 } }],
|
||||
}],
|
||||
tail: false,
|
||||
},
|
||||
);
|
||||
let use_bool = fn_def(
|
||||
@@ -1962,6 +2054,7 @@ mod tests {
|
||||
ctor: "MkBox".into(),
|
||||
args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
|
||||
}],
|
||||
tail: false,
|
||||
},
|
||||
);
|
||||
let m = Module {
|
||||
@@ -2041,4 +2134,131 @@ mod tests {
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("type mismatch"), "got: {msg}");
|
||||
}
|
||||
|
||||
/// Iter 14e: a `Term::App { tail: true, .. }` in non-tail position
|
||||
/// must surface as `tail-call-not-in-tail-position`. Construction:
|
||||
/// the recursive call sits as an argument to a Cons ctor (the
|
||||
/// classic constructor-blocked recursion from the 14d survey).
|
||||
#[test]
|
||||
fn tail_call_in_non_tail_position_is_rejected() {
|
||||
let m = Module {
|
||||
schema: SCHEMA.into(),
|
||||
name: "t".into(),
|
||||
imports: vec![],
|
||||
defs: vec![
|
||||
Def::Type(TypeDef {
|
||||
name: "L".into(),
|
||||
vars: vec![],
|
||||
ctors: vec![
|
||||
Ctor { name: "N".into(), fields: vec![] },
|
||||
Ctor {
|
||||
name: "C".into(),
|
||||
fields: vec![
|
||||
Type::int(),
|
||||
Type::Con { name: "L".into(), args: vec![] },
|
||||
],
|
||||
},
|
||||
],
|
||||
doc: None,
|
||||
}),
|
||||
fn_def(
|
||||
"loop",
|
||||
Type::Fn {
|
||||
params: vec![Type::Con { name: "L".into(), args: vec![] }],
|
||||
ret: Box::new(Type::Con { name: "L".into(), args: vec![] }),
|
||||
effects: vec![],
|
||||
},
|
||||
vec!["xs"],
|
||||
// Body: C(0, tail-app loop xs) — the recursion is
|
||||
// an arg to C, which is NOT a tail position.
|
||||
Term::Ctor {
|
||||
type_name: "L".into(),
|
||||
ctor: "C".into(),
|
||||
args: vec![
|
||||
Term::Lit { lit: Literal::Int { value: 0 } },
|
||||
Term::App {
|
||||
callee: Box::new(Term::Var { name: "loop".into() }),
|
||||
args: vec![Term::Var { name: "xs".into() }],
|
||||
tail: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
let err = check(&m).unwrap_err();
|
||||
assert_eq!(err.code(), "tail-call-not-in-tail-position", "{err}");
|
||||
}
|
||||
|
||||
/// Iter 14e: a `Term::App { tail: true, .. }` that genuinely sits
|
||||
/// in tail position (as the rhs of a `Seq` that is the body of a
|
||||
/// `Match` arm that is the body of the fn) must pass.
|
||||
#[test]
|
||||
fn tail_call_in_tail_position_is_accepted() {
|
||||
let m = Module {
|
||||
schema: SCHEMA.into(),
|
||||
name: "t".into(),
|
||||
imports: vec![],
|
||||
defs: vec![
|
||||
Def::Type(TypeDef {
|
||||
name: "L".into(),
|
||||
vars: vec![],
|
||||
ctors: vec![
|
||||
Ctor { name: "N".into(), fields: vec![] },
|
||||
Ctor {
|
||||
name: "C".into(),
|
||||
fields: vec![
|
||||
Type::int(),
|
||||
Type::Con { name: "L".into(), args: vec![] },
|
||||
],
|
||||
},
|
||||
],
|
||||
doc: None,
|
||||
}),
|
||||
fn_def(
|
||||
"drain",
|
||||
Type::Fn {
|
||||
params: vec![Type::Con { name: "L".into(), args: vec![] }],
|
||||
ret: Box::new(Type::unit()),
|
||||
effects: vec!["IO".into()],
|
||||
},
|
||||
vec!["xs"],
|
||||
Term::Match {
|
||||
scrutinee: Box::new(Term::Var { name: "xs".into() }),
|
||||
arms: vec![
|
||||
Arm {
|
||||
pat: Pattern::Ctor {
|
||||
ctor: "N".into(),
|
||||
fields: vec![],
|
||||
},
|
||||
body: Term::Lit { lit: Literal::Unit },
|
||||
},
|
||||
Arm {
|
||||
pat: Pattern::Ctor {
|
||||
ctor: "C".into(),
|
||||
fields: vec![
|
||||
Pattern::Var { name: "h".into() },
|
||||
Pattern::Var { name: "t".into() },
|
||||
],
|
||||
},
|
||||
body: Term::Seq {
|
||||
lhs: Box::new(Term::Do {
|
||||
op: "io/print_int".into(),
|
||||
args: vec![Term::Var { name: "h".into() }],
|
||||
tail: false,
|
||||
}),
|
||||
rhs: Box::new(Term::App {
|
||||
callee: Box::new(Term::Var { name: "drain".into() }),
|
||||
args: vec![Term::Var { name: "t".into() }],
|
||||
tail: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
check(&m).expect("tail-call in tail position should typecheck");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ fn body_errors_accumulate_across_defs() {
|
||||
Term::Lit { lit: Literal::Int { value: 2 } },
|
||||
Term::Lit { lit: Literal::Int { value: 3 } },
|
||||
],
|
||||
tail: false,
|
||||
},
|
||||
doc: None,
|
||||
});
|
||||
|
||||
@@ -359,6 +359,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 14e: 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,
|
||||
/// 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
|
||||
@@ -479,6 +485,7 @@ impl<'a> Emitter<'a> {
|
||||
types,
|
||||
ctor_index,
|
||||
current_block: String::new(),
|
||||
block_terminated: false,
|
||||
ssa_fn_sigs: BTreeMap::new(),
|
||||
current_def: String::new(),
|
||||
lam_counter: 0,
|
||||
@@ -490,6 +497,7 @@ impl<'a> Emitter<'a> {
|
||||
self.body.push_str(label);
|
||||
self.body.push_str(":\n");
|
||||
self.current_block = label.to_string();
|
||||
self.block_terminated = false;
|
||||
}
|
||||
|
||||
fn emit_module(&mut self) -> Result<()> {
|
||||
@@ -694,14 +702,21 @@ impl<'a> Emitter<'a> {
|
||||
self.start_block("entry");
|
||||
|
||||
let (val, val_ty) = self.lower_term(&f.body)?;
|
||||
if val_ty != llvm_ret {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"fn `{}`: body type {val_ty} != return type {llvm_ret}",
|
||||
f.name
|
||||
)));
|
||||
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
|
||||
)));
|
||||
}
|
||||
self.body
|
||||
.push_str(&format!(" ret {val_ty} {val}\n}}\n\n"));
|
||||
} else {
|
||||
// Iter 14e: 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");
|
||||
}
|
||||
self.body
|
||||
.push_str(&format!(" ret {val_ty} {val}\n}}\n\n"));
|
||||
|
||||
// Iter 8b: flush lambda thunks collected while lowering this fn's
|
||||
// body. They go after the closing `}` of the parent fn, before
|
||||
@@ -801,7 +816,7 @@ impl<'a> Emitter<'a> {
|
||||
self.locals.pop();
|
||||
r
|
||||
}
|
||||
Term::App { callee, args } => {
|
||||
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.
|
||||
@@ -811,7 +826,7 @@ impl<'a> Emitter<'a> {
|
||||
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);
|
||||
return self.lower_app(name, args, *tail);
|
||||
}
|
||||
}
|
||||
let (callee_ssa, callee_ty) = self.lower_term(callee)?;
|
||||
@@ -829,9 +844,9 @@ impl<'a> Emitter<'a> {
|
||||
"indirect call: no FnSig recorded for `{callee_ssa}`"
|
||||
))
|
||||
})?;
|
||||
self.emit_indirect_call(&callee_ssa, &sig, args)
|
||||
self.emit_indirect_call(&callee_ssa, &sig, args, *tail)
|
||||
}
|
||||
Term::Do { op, args } => self.lower_effect_op(op, args),
|
||||
Term::Do { op, args, tail } => self.lower_effect_op(op, args, *tail),
|
||||
Term::Ctor { type_name, ctor, args } => self.lower_ctor(type_name, ctor, args),
|
||||
Term::Match { scrutinee, arms } => self.lower_match(scrutinee, arms),
|
||||
Term::Lam { params, param_tys, ret_ty, effects: _, body } => {
|
||||
@@ -840,6 +855,12 @@ impl<'a> Emitter<'a> {
|
||||
Term::Seq { lhs, rhs } => {
|
||||
// Iter 10: lower lhs for its effects, discard the SSA;
|
||||
// lower rhs and return its value as the whole expression.
|
||||
// Iter 14e: 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)
|
||||
}
|
||||
@@ -1083,17 +1104,22 @@ impl<'a> Emitter<'a> {
|
||||
for _ in 0..pushed {
|
||||
self.locals.pop();
|
||||
}
|
||||
phi_inputs.push((val, self.current_block.clone()));
|
||||
self.body
|
||||
.push_str(&format!(" br label %{join_lbl}\n"));
|
||||
if let Some(rt) = &result_ty {
|
||||
if rt != &vty {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"match arm result type {vty} != {rt}"
|
||||
)));
|
||||
// Iter 14e: if the arm body lowered to a `musttail call` +
|
||||
// `ret`, the block is already terminated. Skip the
|
||||
// fall-through `br` and exclude this arm from the join phi.
|
||||
if !self.block_terminated {
|
||||
phi_inputs.push((val, self.current_block.clone()));
|
||||
self.body
|
||||
.push_str(&format!(" br label %{join_lbl}\n"));
|
||||
if let Some(rt) = &result_ty {
|
||||
if rt != &vty {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"match arm result type {vty} != {rt}"
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
result_ty = Some(vty);
|
||||
}
|
||||
} else {
|
||||
result_ty = Some(vty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1112,17 +1138,19 @@ impl<'a> Emitter<'a> {
|
||||
for _ in 0..pushed {
|
||||
self.locals.pop();
|
||||
}
|
||||
phi_inputs.push((val, self.current_block.clone()));
|
||||
self.body
|
||||
.push_str(&format!(" br label %{join_lbl}\n"));
|
||||
if let Some(rt) = &result_ty {
|
||||
if rt != &vty {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"match default arm result type {vty} != {rt}"
|
||||
)));
|
||||
if !self.block_terminated {
|
||||
phi_inputs.push((val, self.current_block.clone()));
|
||||
self.body
|
||||
.push_str(&format!(" br label %{join_lbl}\n"));
|
||||
if let Some(rt) = &result_ty {
|
||||
if rt != &vty {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"match default arm result type {vty} != {rt}"
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
result_ty = Some(vty);
|
||||
}
|
||||
} else {
|
||||
result_ty = Some(vty);
|
||||
}
|
||||
} else {
|
||||
// Typechecker guarantees exhaustiveness, so unreachable.
|
||||
@@ -1130,6 +1158,18 @@ impl<'a> Emitter<'a> {
|
||||
}
|
||||
|
||||
// join
|
||||
// Iter 14e: if every arm tail-called and terminated its own
|
||||
// block, no predecessor branches into the join. Mark the whole
|
||||
// match as block-terminated and emit no join body — the
|
||||
// surrounding context (top-level fn body, seq rhs, etc.) checks
|
||||
// `block_terminated` before any fall-through emission.
|
||||
if phi_inputs.is_empty() {
|
||||
self.block_terminated = true;
|
||||
// Return a dummy SSA + type that won't be consumed. Use the
|
||||
// result_ty if any arm produced one, else fall back to i8.
|
||||
let rt = result_ty.unwrap_or_else(|| "i8".into());
|
||||
return Ok(("0".into(), rt));
|
||||
}
|
||||
self.start_block(&join_lbl);
|
||||
let phi = self.fresh_ssa();
|
||||
let rt = result_ty.unwrap_or_else(|| "i64".into());
|
||||
@@ -1195,20 +1235,43 @@ impl<'a> Emitter<'a> {
|
||||
|
||||
self.start_block(&then_lbl);
|
||||
let (then_v, then_ty) = self.lower_term(true_body)?;
|
||||
// Nested code in the `then` body may have changed the
|
||||
// block label — phi must see the last actual block.
|
||||
// Iter 14e: a tail-call in this arm 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();
|
||||
self.body.push_str(&format!(" br label %{join_lbl}\n"));
|
||||
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(false_body)?;
|
||||
if then_ty != else_ty {
|
||||
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!(
|
||||
"Bool-match arms type mismatch: {then_ty} vs {else_ty}"
|
||||
)));
|
||||
}
|
||||
let else_block_end = self.current_block.clone();
|
||||
self.body.push_str(&format!(" br label %{join_lbl}\n"));
|
||||
if !else_terminated {
|
||||
self.body.push_str(&format!(" br label %{join_lbl}\n"));
|
||||
}
|
||||
|
||||
// Iter 14e: if both arms terminated, the whole match 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 arm terminated, the join receives only the
|
||||
// other arm'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();
|
||||
@@ -1235,7 +1298,7 @@ impl<'a> Emitter<'a> {
|
||||
Ok((phi, then_ty))
|
||||
}
|
||||
|
||||
fn lower_app(&mut self, name: &str, args: &[Term]) -> Result<(String, String)> {
|
||||
fn lower_app(&mut self, name: &str, args: &[Term], tail: bool) -> Result<(String, String)> {
|
||||
// Built-in arithmetic / comparison.
|
||||
if let Some((instr, ret_ty)) = builtin_binop(name) {
|
||||
if args.len() != 2 {
|
||||
@@ -1249,6 +1312,11 @@ impl<'a> Emitter<'a> {
|
||||
self.body.push_str(&format!(
|
||||
" {dst} = {instr} i64 {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, ret_ty.into()));
|
||||
}
|
||||
if name == "not" {
|
||||
@@ -1277,7 +1345,7 @@ impl<'a> Emitter<'a> {
|
||||
.get(&target_module)
|
||||
.is_some_and(|m| m.contains_key(suffix))
|
||||
{
|
||||
return self.lower_polymorphic_call(&target_module, suffix, args);
|
||||
return self.lower_polymorphic_call(&target_module, suffix, args, tail);
|
||||
}
|
||||
let target_fns = self
|
||||
.module_user_fns
|
||||
@@ -1295,7 +1363,7 @@ impl<'a> Emitter<'a> {
|
||||
"cross-module call `{name}`: def `{suffix}` not in module `{target_module}`"
|
||||
))
|
||||
})?;
|
||||
return self.emit_call(&target_module, suffix, &sig, args);
|
||||
return self.emit_call(&target_module, suffix, &sig, args, tail);
|
||||
}
|
||||
|
||||
// Polymorphic def in the current module?
|
||||
@@ -1305,7 +1373,7 @@ impl<'a> Emitter<'a> {
|
||||
.is_some_and(|m| m.contains_key(name))
|
||||
{
|
||||
let owner = self.module_name.to_string();
|
||||
return self.lower_polymorphic_call(&owner, name, args);
|
||||
return self.lower_polymorphic_call(&owner, name, args, tail);
|
||||
}
|
||||
|
||||
// User function in the current module?
|
||||
@@ -1315,7 +1383,7 @@ impl<'a> Emitter<'a> {
|
||||
.and_then(|m| m.get(name))
|
||||
.cloned()
|
||||
{
|
||||
return self.emit_call(self.module_name, name, &sig, args);
|
||||
return self.emit_call(self.module_name, name, &sig, args, tail);
|
||||
}
|
||||
|
||||
Err(CodegenError::Internal(format!(
|
||||
@@ -1332,6 +1400,7 @@ impl<'a> Emitter<'a> {
|
||||
owner_module: &str,
|
||||
def_name: &str,
|
||||
args: &[Term],
|
||||
tail: bool,
|
||||
) -> Result<(String, String)> {
|
||||
let fdef = self
|
||||
.module_polymorphic_fns
|
||||
@@ -1408,10 +1477,16 @@ impl<'a> Emitter<'a> {
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let dst = self.fresh_ssa();
|
||||
let call_kw = if tail { "musttail call" } else { "call" };
|
||||
self.body.push_str(&format!(
|
||||
" {dst} = call {ret} @{mangled}({arglist})\n",
|
||||
" {dst} = {call_kw} {ret} @{mangled}({arglist})\n",
|
||||
ret = llvm_ret,
|
||||
));
|
||||
if tail {
|
||||
self.body
|
||||
.push_str(&format!(" ret {ret} {dst}\n", ret = llvm_ret));
|
||||
self.block_terminated = true;
|
||||
}
|
||||
Ok((dst, llvm_ret))
|
||||
}
|
||||
|
||||
@@ -1421,6 +1496,7 @@ impl<'a> Emitter<'a> {
|
||||
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()) {
|
||||
@@ -1438,12 +1514,23 @@ impl<'a> Emitter<'a> {
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let dst = self.fresh_ssa();
|
||||
// Iter 14e: 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 {ret} @ail_{module}_{name}({arglist})\n",
|
||||
" {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()))
|
||||
}
|
||||
|
||||
@@ -1457,6 +1544,7 @@ impl<'a> Emitter<'a> {
|
||||
callee_ssa: &str,
|
||||
sig: &FnSig,
|
||||
args: &[Term],
|
||||
tail: bool,
|
||||
) -> Result<(String, String)> {
|
||||
if args.len() != sig.params.len() {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
@@ -1505,11 +1593,25 @@ impl<'a> Emitter<'a> {
|
||||
param_tys.push_str(pt);
|
||||
}
|
||||
let dst = self.fresh_ssa();
|
||||
// Iter 14e: 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 {ret} ({ptys}) {thunk}({arglist})\n",
|
||||
" {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()))
|
||||
}
|
||||
|
||||
@@ -1594,6 +1696,8 @@ impl<'a> Emitter<'a> {
|
||||
let saved_locals = std::mem::take(&mut self.locals);
|
||||
let saved_counter = self.counter;
|
||||
let saved_block = std::mem::take(&mut self.current_block);
|
||||
let saved_terminated = self.block_terminated;
|
||||
self.block_terminated = false;
|
||||
let saved_sigs = std::mem::take(&mut self.ssa_fn_sigs);
|
||||
// Lambdas inside lambdas are fine: they get their own counter
|
||||
// namespace within the enclosing thunk. They share the
|
||||
@@ -1653,13 +1757,17 @@ impl<'a> Emitter<'a> {
|
||||
}
|
||||
|
||||
let (body_v, body_ty) = self.lower_term(lam_body)?;
|
||||
if body_ty != llvm_ret {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"lambda `{thunk_name}`: body type {body_ty} != return type {llvm_ret}"
|
||||
)));
|
||||
if !self.block_terminated {
|
||||
if body_ty != llvm_ret {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"lambda `{thunk_name}`: body type {body_ty} != return type {llvm_ret}"
|
||||
)));
|
||||
}
|
||||
self.body
|
||||
.push_str(&format!(" ret {body_ty} {body_v}\n}}\n\n"));
|
||||
} else {
|
||||
self.body.push_str("}\n\n");
|
||||
}
|
||||
self.body
|
||||
.push_str(&format!(" ret {body_ty} {body_v}\n}}\n\n"));
|
||||
|
||||
// Park the thunk text in the deferred queue and restore outer
|
||||
// emitter state.
|
||||
@@ -1669,6 +1777,7 @@ impl<'a> Emitter<'a> {
|
||||
self.locals = saved_locals;
|
||||
self.counter = saved_counter;
|
||||
self.current_block = saved_block;
|
||||
self.block_terminated = saved_terminated;
|
||||
self.ssa_fn_sigs = saved_sigs;
|
||||
|
||||
// 3. Emit allocation + capture filling + closure-pair packing
|
||||
@@ -1751,7 +1860,7 @@ impl<'a> Emitter<'a> {
|
||||
captures.push(name.clone());
|
||||
}
|
||||
}
|
||||
Term::App { callee, args } => {
|
||||
Term::App { callee, args, .. } => {
|
||||
Self::collect_captures(callee, bound, captures, captures_set, builtins, top_level);
|
||||
for a in args {
|
||||
Self::collect_captures(a, bound, captures, captures_set, builtins, top_level);
|
||||
@@ -1873,7 +1982,19 @@ impl<'a> Emitter<'a> {
|
||||
))
|
||||
}
|
||||
|
||||
fn lower_effect_op(&mut self, op: &str, args: &[Term]) -> Result<(String, String)> {
|
||||
fn lower_effect_op(&mut self, op: &str, args: &[Term], tail: bool) -> Result<(String, String)> {
|
||||
// Iter 14e: `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_int" => {
|
||||
if args.len() != 1 {
|
||||
@@ -1889,8 +2010,12 @@ impl<'a> Emitter<'a> {
|
||||
}
|
||||
let fmt = self.intern_string("fmt_int", "%lld\n");
|
||||
self.body.push_str(&format!(
|
||||
" call i32 (ptr, ...) @printf(ptr @{fmt}, i64 {v})\n"
|
||||
" {call_kw} i32 (ptr, ...) @printf(ptr @{fmt}, i64 {v})\n"
|
||||
));
|
||||
if tail {
|
||||
self.body.push_str(" ret i8 0\n");
|
||||
self.block_terminated = true;
|
||||
}
|
||||
Ok(("0".into(), "i8".into()))
|
||||
}
|
||||
"io/print_str" => {
|
||||
@@ -1906,7 +2031,11 @@ impl<'a> Emitter<'a> {
|
||||
));
|
||||
}
|
||||
self.body
|
||||
.push_str(&format!(" call i32 @puts(ptr {v})\n"));
|
||||
.push_str(&format!(" {call_kw} i32 @puts(ptr {v})\n"));
|
||||
if tail {
|
||||
self.body.push_str(" ret i8 0\n");
|
||||
self.block_terminated = true;
|
||||
}
|
||||
Ok(("0".into(), "i8".into()))
|
||||
}
|
||||
"io/print_bool" => {
|
||||
@@ -1942,6 +2071,10 @@ impl<'a> Emitter<'a> {
|
||||
));
|
||||
self.body.push_str(&format!(" br label %{join_lbl}\n"));
|
||||
self.start_block(&join_lbl);
|
||||
if tail {
|
||||
self.body.push_str(" ret i8 0\n");
|
||||
self.block_terminated = true;
|
||||
}
|
||||
Ok(("0".into(), "i8".into()))
|
||||
}
|
||||
other => Err(CodegenError::Internal(format!(
|
||||
@@ -2049,7 +2182,7 @@ impl<'a> Emitter<'a> {
|
||||
ret: ret_ty.clone(),
|
||||
effects: effects.clone(),
|
||||
}),
|
||||
Term::App { callee, args } => {
|
||||
Term::App { callee, args, .. } => {
|
||||
let cty = self.synth_with_extras(callee, extras)?;
|
||||
match cty {
|
||||
Type::Fn { ret, .. } => Ok(*ret),
|
||||
@@ -2377,18 +2510,20 @@ fn apply_subst_to_type(t: &Type, subst: &BTreeMap<String, Type>) -> Type {
|
||||
fn apply_subst_to_term(t: &Term, subst: &BTreeMap<String, Type>) -> Term {
|
||||
match t {
|
||||
Term::Lit { .. } | Term::Var { .. } => t.clone(),
|
||||
Term::App { callee, args } => Term::App {
|
||||
Term::App { callee, args, tail } => Term::App {
|
||||
callee: Box::new(apply_subst_to_term(callee, subst)),
|
||||
args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(),
|
||||
tail: *tail,
|
||||
},
|
||||
Term::Let { name, value, body } => Term::Let {
|
||||
name: name.clone(),
|
||||
value: Box::new(apply_subst_to_term(value, subst)),
|
||||
body: Box::new(apply_subst_to_term(body, subst)),
|
||||
},
|
||||
Term::Do { op, args } => Term::Do {
|
||||
Term::Do { op, args, tail } => Term::Do {
|
||||
op: op.clone(),
|
||||
args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(),
|
||||
tail: *tail,
|
||||
},
|
||||
Term::Ctor { type_name, ctor, args } => Term::Ctor {
|
||||
type_name: type_name.clone(),
|
||||
@@ -2559,6 +2694,7 @@ mod tests {
|
||||
Term::Var { name: "a".into() },
|
||||
Term::Var { name: "b".into() },
|
||||
],
|
||||
tail: false,
|
||||
},
|
||||
doc: None,
|
||||
}),
|
||||
|
||||
@@ -200,10 +200,18 @@ pub enum Term {
|
||||
Var { name: String },
|
||||
/// Function application. `callee` is evaluated to a function value;
|
||||
/// `args` are evaluated left-to-right.
|
||||
///
|
||||
/// Iter 14e: `tail` marks this call as occurring in tail position
|
||||
/// (per Decision 8). When set, codegen lowers the call as
|
||||
/// `musttail call`. The flag defaults to `false` and is omitted
|
||||
/// during canonical-JSON serialisation when unset, so pre-14e
|
||||
/// fixtures keep bit-identical hashes.
|
||||
App {
|
||||
#[serde(rename = "fn")]
|
||||
callee: Box<Term>,
|
||||
args: Vec<Term>,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
tail: bool,
|
||||
},
|
||||
/// Let-binding: `value` is evaluated and bound to `name` in `body`.
|
||||
Let {
|
||||
@@ -213,9 +221,13 @@ pub enum Term {
|
||||
},
|
||||
/// Effect operation invocation (e.g. `do print "hi"`). The `op` is
|
||||
/// resolved against the effect-handler table at link time.
|
||||
///
|
||||
/// Iter 14e: see [`Term::App`] for the `tail` field semantics.
|
||||
Do {
|
||||
op: String,
|
||||
args: Vec<Term>,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
tail: bool,
|
||||
},
|
||||
/// Constructor application. `type_name` binds the ADT, `ctor` the
|
||||
/// variant. Example: `Some(42)` ->
|
||||
@@ -402,3 +414,13 @@ impl PartialEq for Type {
|
||||
}
|
||||
}
|
||||
impl Eq for Type {}
|
||||
|
||||
/// Serde helper for `#[serde(skip_serializing_if = "is_false")]`.
|
||||
///
|
||||
/// Used by [`Term::App::tail`] and [`Term::Do::tail`] (Iter 14e) so the
|
||||
/// `tail` flag is omitted from the canonical JSON whenever it is false,
|
||||
/// preserving bit-identical hashes for every pre-14e definition.
|
||||
#[allow(clippy::trivially_copy_pass_by_ref)]
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ mod tests {
|
||||
Term::Var { name: "a".into() },
|
||||
Term::Var { name: "b".into() },
|
||||
],
|
||||
tail: false,
|
||||
},
|
||||
doc: None,
|
||||
})
|
||||
|
||||
@@ -175,7 +175,7 @@ fn term_block(t: &Term, indent: usize) -> String {
|
||||
match t {
|
||||
Term::Lit { lit } => format!("{pad}{}", lit_to_string(lit)),
|
||||
Term::Var { name } => format!("{pad}{name}"),
|
||||
Term::App { callee, args } => {
|
||||
Term::App { callee, args, .. } => {
|
||||
let mut s = format!("{pad}(");
|
||||
s.push_str(&term_inline(callee));
|
||||
for a in args {
|
||||
@@ -193,7 +193,7 @@ fn term_block(t: &Term, indent: usize) -> String {
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::Do { op, args } => {
|
||||
Term::Do { op, args, .. } => {
|
||||
let mut s = format!("{pad}(do {op}");
|
||||
for a in args {
|
||||
s.push(' ');
|
||||
@@ -287,7 +287,7 @@ fn term_inline(t: &Term) -> String {
|
||||
match t {
|
||||
Term::Lit { lit } => lit_to_string(lit),
|
||||
Term::Var { name } => name.clone(),
|
||||
Term::App { callee, args } => {
|
||||
Term::App { callee, args, .. } => {
|
||||
let mut s = String::from("(");
|
||||
s.push_str(&term_inline(callee));
|
||||
for a in args {
|
||||
@@ -297,7 +297,7 @@ fn term_inline(t: &Term) -> String {
|
||||
s.push(')');
|
||||
s
|
||||
}
|
||||
Term::Do { op, args } => {
|
||||
Term::Do { op, args, .. } => {
|
||||
let mut s = format!("(do {op}");
|
||||
for a in args {
|
||||
s.push(' ');
|
||||
@@ -414,6 +414,7 @@ mod tests {
|
||||
Term::Var { name: "a".into() },
|
||||
Term::Var { name: "b".into() },
|
||||
],
|
||||
tail: false,
|
||||
},
|
||||
doc: None,
|
||||
}),
|
||||
|
||||
@@ -34,18 +34,20 @@
|
||||
//! effects-clause::= "(" "effects" ident+ ")"
|
||||
//!
|
||||
//! term ::= var-ref | int-lit | str-lit | bool-lit | unit-lit
|
||||
//! | app-term | match-term | ctor-term | do-term | seq-term
|
||||
//! | lam-term | let-term
|
||||
//! | app-term | tail-app-term | match-term | ctor-term
|
||||
//! | do-term | tail-do-term | seq-term | lam-term | let-term
|
||||
//! var-ref ::= ident ; reserved: true/false → bool-lit
|
||||
//! int-lit ::= integer ; numeric atom
|
||||
//! str-lit ::= string ; string atom
|
||||
//! bool-lit ::= "true" | "false"
|
||||
//! unit-lit ::= "(" "lit-unit" ")"
|
||||
//! app-term ::= "(" "app" term term+ ")"
|
||||
//! tail-app-term ::= "(" "tail-app" term term+ ")" ; Iter 14e
|
||||
//! ctor-term ::= "(" "term-ctor" ident ident term* ")"
|
||||
//! match-term ::= "(" "match" term case-arm+ ")"
|
||||
//! case-arm ::= "(" "case" pattern term ")"
|
||||
//! do-term ::= "(" "do" ident term* ")"
|
||||
//! tail-do-term ::= "(" "tail-do" ident term* ")" ; Iter 14e
|
||||
//! seq-term ::= "(" "seq" term term ")"
|
||||
//! lam-term ::= "(" "lam" "(" "params" typed-param* ")"
|
||||
//! "(" "ret" type ")"
|
||||
@@ -677,9 +679,11 @@ impl<'a> Parser<'a> {
|
||||
match head {
|
||||
"lit-unit" => self.parse_lit_unit(),
|
||||
"app" => self.parse_app(),
|
||||
"tail-app" => self.parse_tail_app(),
|
||||
"term-ctor" => self.parse_term_ctor(),
|
||||
"match" => self.parse_match(),
|
||||
"do" => self.parse_do(),
|
||||
"tail-do" => self.parse_tail_do(),
|
||||
"seq" => self.parse_seq(),
|
||||
"lam" => self.parse_lam(),
|
||||
"let" => self.parse_let(),
|
||||
@@ -689,8 +693,8 @@ impl<'a> Parser<'a> {
|
||||
production: "term",
|
||||
message: format!(
|
||||
"unknown term head `{other}`; expected one of \
|
||||
`app`, `lam`, `let`, `match`, `do`, `seq`, \
|
||||
`term-ctor`, `lit-unit`"
|
||||
`app`, `tail-app`, `lam`, `let`, `match`, `do`, \
|
||||
`tail-do`, `seq`, `term-ctor`, `lit-unit`"
|
||||
),
|
||||
pos,
|
||||
})
|
||||
@@ -736,12 +740,32 @@ impl<'a> Parser<'a> {
|
||||
fn parse_app(&mut self) -> Result<Term, ParseError> {
|
||||
self.expect_lparen("app-term")?;
|
||||
self.expect_keyword("app")?;
|
||||
self.parse_app_body(false, "app-term")
|
||||
}
|
||||
|
||||
/// Iter 14e: `(tail-app callee arg+)` — same shape as `app` but
|
||||
/// constructs `Term::App { tail: true, .. }`. The typechecker's
|
||||
/// tail-position pass verifies that the call really is in tail
|
||||
/// position; an unmarked call in tail position is also legal.
|
||||
fn parse_tail_app(&mut self) -> Result<Term, ParseError> {
|
||||
self.expect_lparen("tail-app-term")?;
|
||||
self.expect_keyword("tail-app")?;
|
||||
self.parse_app_body(true, "tail-app-term")
|
||||
}
|
||||
|
||||
/// Body shared by [`Self::parse_app`] and [`Self::parse_tail_app`]:
|
||||
/// callee + 1+ args + closing `)`.
|
||||
fn parse_app_body(
|
||||
&mut self,
|
||||
tail: bool,
|
||||
production: &'static str,
|
||||
) -> Result<Term, ParseError> {
|
||||
let callee = self.parse_term()?;
|
||||
// 1+ args
|
||||
if matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
|
||||
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
|
||||
return Err(ParseError::Production {
|
||||
production: "app-term",
|
||||
production,
|
||||
message: "expected at least one argument".into(),
|
||||
pos,
|
||||
});
|
||||
@@ -750,10 +774,11 @@ impl<'a> Parser<'a> {
|
||||
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
|
||||
args.push(self.parse_term()?);
|
||||
}
|
||||
self.expect_rparen("app-term")?;
|
||||
self.expect_rparen(production)?;
|
||||
Ok(Term::App {
|
||||
callee: Box::new(callee),
|
||||
args,
|
||||
tail,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -809,13 +834,30 @@ impl<'a> Parser<'a> {
|
||||
fn parse_do(&mut self) -> Result<Term, ParseError> {
|
||||
self.expect_lparen("do-term")?;
|
||||
self.expect_keyword("do")?;
|
||||
self.parse_do_body(false, "do-term")
|
||||
}
|
||||
|
||||
/// Iter 14e: `(tail-do op arg*)` — same shape as `do` but
|
||||
/// constructs `Term::Do { tail: true, .. }`.
|
||||
fn parse_tail_do(&mut self) -> Result<Term, ParseError> {
|
||||
self.expect_lparen("tail-do-term")?;
|
||||
self.expect_keyword("tail-do")?;
|
||||
self.parse_do_body(true, "tail-do-term")
|
||||
}
|
||||
|
||||
/// Body shared by [`Self::parse_do`] and [`Self::parse_tail_do`].
|
||||
fn parse_do_body(
|
||||
&mut self,
|
||||
tail: bool,
|
||||
production: &'static str,
|
||||
) -> Result<Term, ParseError> {
|
||||
let op = self.expect_ident("effect op (e.g. `io/print_int`)")?;
|
||||
let mut args = Vec::new();
|
||||
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
|
||||
args.push(self.parse_term()?);
|
||||
}
|
||||
self.expect_rparen("do-term")?;
|
||||
Ok(Term::Do { op, args })
|
||||
self.expect_rparen(production)?;
|
||||
Ok(Term::Do { op, args, tail })
|
||||
}
|
||||
|
||||
fn parse_seq(&mut self) -> Result<Term, ParseError> {
|
||||
|
||||
@@ -223,8 +223,12 @@ fn write_term(out: &mut String, t: &Term, level: usize) {
|
||||
match t {
|
||||
Term::Lit { lit } => write_lit(out, lit),
|
||||
Term::Var { name } => out.push_str(name),
|
||||
Term::App { callee, args } => {
|
||||
out.push_str("(app ");
|
||||
Term::App { callee, args, tail } => {
|
||||
// Iter 14e: `tail-app` is the form for `App { tail: true }`;
|
||||
// otherwise the regular `app` head is used. Both productions
|
||||
// are positional analogues of each other — only the head
|
||||
// keyword differs.
|
||||
out.push_str(if *tail { "(tail-app " } else { "(app " });
|
||||
write_term(out, callee, level);
|
||||
for a in args {
|
||||
out.push(' ');
|
||||
@@ -241,8 +245,9 @@ fn write_term(out: &mut String, t: &Term, level: usize) {
|
||||
write_term(out, body, level);
|
||||
out.push(')');
|
||||
}
|
||||
Term::Do { op, args } => {
|
||||
out.push_str("(do ");
|
||||
Term::Do { op, args, tail } => {
|
||||
// Iter 14e: `tail-do` mirrors `tail-app` for effect ops.
|
||||
out.push_str(if *tail { "(tail-do " } else { "(do " });
|
||||
out.push_str(op);
|
||||
for a in args {
|
||||
out.push(' ');
|
||||
|
||||
@@ -429,6 +429,21 @@ fixtures round-trip identically through `print → parse → canonical
|
||||
JSON`; the three hand-written `.ailx` exhibits parse to canonical
|
||||
JSON identical to their corresponding `.ail.json` files.
|
||||
|
||||
3. **Tail-call surface (Iter 14e).** Decision 8 ships two new
|
||||
productions, both positional analogues of their non-tail
|
||||
counterparts. Their result terms set `Term::App.tail = true` /
|
||||
`Term::Do.tail = true`; the typechecker's `verify_tail_positions`
|
||||
pass enforces that the marker is only used in tail position.
|
||||
|
||||
```
|
||||
tail-app-term ::= "(" "tail-app" term term+ ")"
|
||||
tail-do-term ::= "(" "tail-do" ident term* ")"
|
||||
```
|
||||
|
||||
Production count after Iter 14e: ~30, still inside the 30-rule
|
||||
constraint-1 budget. No new lexical rule (`tail-app` / `tail-do`
|
||||
are bare ident tokens; no special casing).
|
||||
|
||||
## Decision 7: redundancy removal — `Term::If` is not a primitive
|
||||
|
||||
`Term::If { cond, then, else_ }` is semantically a subset of
|
||||
@@ -454,6 +469,63 @@ No schema version bump (no third-party consumes `ailang/v0`).
|
||||
Hash invalidation for the three migrated fixtures (`sum`, `sort`,
|
||||
`max3`) is intentional; the new hashes become the new identity.
|
||||
|
||||
## Decision 8: explicit, verified tail calls
|
||||
|
||||
For an LLM author, recursion is the natural iteration form
|
||||
(`\n. if n == 0 then () else loop(n-1)` is what I reach for, not
|
||||
a `for`-loop). Without a tail-call guarantee, every recursive
|
||||
program has a silent stack-depth ceiling that no compile-time
|
||||
diagnostic warns about. That is exactly the class of correctness
|
||||
hazard the language exists to eliminate.
|
||||
|
||||
Solution: explicit, verified tail calls.
|
||||
|
||||
- **AST.** `Term::App { fn, args, tail: bool }` and
|
||||
`Term::Do { op, args, tail: bool }` gain a `tail` flag,
|
||||
serde-defaulting to `false` so existing fixtures load with
|
||||
`tail: false` and their hashes stay bit-identical.
|
||||
- **Typecheck.** A new pass `verify_tail_positions(fn_body)`
|
||||
runs after the main type-check. It walks the body with an
|
||||
`is_tail_context: bool` threaded down. The flag is `true` at
|
||||
the start, `true` for the body of every `Term::Match` arm,
|
||||
`true` for the right operand of `Term::Seq`, `true` for the
|
||||
body of `Term::Let`, `true` for the body of `Term::Lam` (each
|
||||
Lam opens its own tail scope). The flag is `false` for: args
|
||||
of any `App`/`Do`/`Ctor`, scrutinee of `Match`, left of
|
||||
`Seq`, condition of `Let`-bound expression. When the walker
|
||||
visits an `App { tail: true }` or `Do { tail: true }`, the
|
||||
flag must be `true` at that visit; otherwise emit diagnostic
|
||||
`tail-call-not-in-tail-position`.
|
||||
- **Codegen.** Emit `musttail call` (LLVM IR) for marked calls
|
||||
instead of plain `call`. LLVM rejects at IR-verification
|
||||
time if the call cannot physically be a tail call (calling
|
||||
convention mismatch, signature divergence, etc.). The reject
|
||||
surfaces as a hard build error, not a silent runtime
|
||||
surprise.
|
||||
- **Form (A).** Two new keywords: `tail-app`, `tail-do`.
|
||||
Productions are positional analogues of `app`/`do` with
|
||||
`tail: true` set on the resulting term. EBNF gains 2 lines;
|
||||
total production count goes from ~28 to ~30, still inside
|
||||
the constraint-1 budget.
|
||||
|
||||
**What this does NOT promise.** Per the 14d tail-call survey,
|
||||
many existing recursive calls are *not* in tail position
|
||||
because they are arguments to constructor calls (e.g.
|
||||
`Cons (f h) (map f t)`). 14e adds annotation + verification;
|
||||
it does **not** add a CPS transform or accumulator-form
|
||||
rewrite. Programs whose recursion is constructor-blocked
|
||||
will continue to be stack-bounded by recursion depth. The
|
||||
canonical authoring pattern in such cases is to write the
|
||||
accumulator-form variant (`map_acc`, `fold_left`, etc.)
|
||||
explicitly. The stdlib (15a onward) ships both forms where
|
||||
relevant.
|
||||
|
||||
The 14d migration of existing fixtures will be partial: only
|
||||
`print_list`-style terminal recursions get marked. The
|
||||
constructor-blocked recursions in `map`, `sort`, `insert`
|
||||
remain unmarked — they cannot benefit from `musttail`
|
||||
without a source-level rewrite.
|
||||
|
||||
## Mangling scheme (Iter 5c)
|
||||
|
||||
All AILang functions are mangled to `@ail_<module>_<def>` — even in the
|
||||
|
||||
+128
@@ -1874,6 +1874,134 @@ patterns; `map`/`sort` style stays stack-bounded by depth,
|
||||
which makes 14f (GC) the more important iter for handling
|
||||
long lists than 14e by itself.
|
||||
|
||||
## Iter 14e — explicit, verified tail calls
|
||||
|
||||
Decision 8 ships. `Term::App` and `Term::Do` carry a `tail: bool`
|
||||
flag (serde-default false, skip-when-false in serialisation).
|
||||
A new `verify_tail_positions` typecheck pass walks each fn body
|
||||
and Lam body with an `is_tail_context: bool` threaded down per
|
||||
the standard Scheme rules, rejecting any `tail: true` call that
|
||||
sits outside tail position. Codegen emits `musttail call` for
|
||||
marked App calls.
|
||||
|
||||
**Hash deltas (intentional, only the migrated calls):**
|
||||
|
||||
| def | hash before | hash after |
|
||||
|---|---|---|
|
||||
| `list_map_poly.print_list` | unchanged 14c value | new |
|
||||
| `sort.print_list` | unchanged 14d value | new |
|
||||
|
||||
All other defs across all 18 fixtures kept bit-identical hashes.
|
||||
This is the canary for the `skip_serializing_if = "is_false"`
|
||||
serde rule: an unmarked `App`/`Do` serialises identically to its
|
||||
pre-14e form, so untouched defs cannot drift.
|
||||
|
||||
**Tests: 79/79 (was 76, +3).** New tests:
|
||||
- `tail_call_in_non_tail_position_is_rejected` (check unit) —
|
||||
asserts the diagnostic fires on a deliberately-misplaced
|
||||
`tail: true` call (e.g. as a `Cons` arg).
|
||||
- `tail_call_in_tail_position_is_accepted` (check unit) —
|
||||
asserts the verifier accepts the canonical `print_list` shape.
|
||||
- `iter14e_print_list_recursion_emits_musttail` (e2e IR-grep) —
|
||||
builds `list_map_poly`, dumps IR, asserts the recursive call
|
||||
site uses `musttail call`. This is the only direct evidence
|
||||
that the AST flag actually reaches LLVM.
|
||||
|
||||
**IR-snapshot evidence** at the recursive site of
|
||||
`print_list` after migration:
|
||||
|
||||
```
|
||||
%v7 = musttail call i8 @ail_list_map_poly_print_list(ptr %v6)
|
||||
ret i8 %v7
|
||||
```
|
||||
|
||||
`musttail` followed immediately by `ret` of the same SSA value —
|
||||
LLVM's terminator rule satisfied. Smoke: `list_map_poly` →
|
||||
`2/3/4`, `insertion_sort_orders_list` → identical sorted list.
|
||||
Behavior unchanged; only the calling shape did.
|
||||
|
||||
**Two implementer deviations (called out, both reasonable):**
|
||||
|
||||
1. **`tail-do` falls back to `tail call`, not `musttail`.** LLVM
|
||||
`musttail` requires identical caller/callee return types. AILang
|
||||
IO ops dispatch through runtime helpers (`printf`/`puts`)
|
||||
returning `i32`, while AILang's `Unit` lowers to `i8`. Cross-type
|
||||
`musttail` would be rejected by the verifier. So `Term::Do` with
|
||||
`tail: true` lowers to `tail call` (the LLVM optimisation hint,
|
||||
not the guarantee), then `ret i8 0`. No fixture currently uses
|
||||
`tail-do`, so the path is implemented but not exercised
|
||||
end-to-end. Proper fix: change runtime helper signatures to
|
||||
return `i8`. Punted; not blocking.
|
||||
|
||||
2. **`block_terminated` plumbing in codegen.** A `tail-app` /
|
||||
`tail-do` emits `musttail call ... ret ...` directly and sets
|
||||
`self.block_terminated = true`. Surrounding code (match-arm phi
|
||||
construction, fn-body trailing-ret, lambda-thunk trailing-ret)
|
||||
checks the flag and skips the fall-through emit. When every
|
||||
match arm is a tail call, the join block is omitted entirely.
|
||||
This was unavoidable to keep the IR well-formed — adding a
|
||||
second `ret` after a `musttail call`+`ret` would be a verifier
|
||||
error. The flag is a small piece of state but it's the right
|
||||
shape for "the current basic block has been definitively
|
||||
terminated by a sub-emission".
|
||||
|
||||
**Form (A) at constraint ceiling.** Two new productions
|
||||
(`tail-app-term`, `tail-do-term`) bring the count to ~30, which
|
||||
is exactly the constraint-1 budget. Future productions need to
|
||||
either retire something or accept an explicit budget rebalancing
|
||||
in DESIGN.md.
|
||||
|
||||
**GC notes from the implementer (informs 14f).**
|
||||
|
||||
- **Allocations cluster in `lower_ctor`** (~line 850 of codegen).
|
||||
Every `term-ctor` does `malloc(8 + 8 * n)`. In `print_list` we
|
||||
allocate nothing per recursion (just match + read fields +
|
||||
recurse); allocations come from `map`, from `main`'s
|
||||
list-building Cons chain, and from any other user code that
|
||||
builds ADTs.
|
||||
- **Lambda envs and closure pairs allocate too** (`lower_lambda`).
|
||||
Closure pair: `malloc(16)`. Env block: `malloc(env_size)`.
|
||||
Direct-application closures (the common case for HOF args)
|
||||
could be arena'd cleanly because the closure dies after the
|
||||
call returns. Stored or returned closures escape.
|
||||
- **Tail recursion does NOT reduce allocation pressure**, only
|
||||
stack depth. For `print_list`-style recursions there's no
|
||||
allocation to begin with, so the win is purely stack-bounded.
|
||||
For `map`-style ctor-blocked recursions, each step allocates
|
||||
one new `Cons` box — that's where allocation-side work pays
|
||||
off.
|
||||
- **The "obviously safe" arena boundary** is a fn whose return
|
||||
type contains no boxed ADT (i.e. returns `Int`/`Bool`/`Unit`/
|
||||
`Str` only). All ADT boxes allocated inside such a fn cannot
|
||||
escape; an arena freed at fn return is sound by construction.
|
||||
Most current fixtures violate this — `map`, `sort` return ADTs
|
||||
— so a per-fn-arena scheme alone won't carry. Need a heap
|
||||
with GC for escaping allocations.
|
||||
|
||||
This narrows 14f's design space: probably **Boehm conservative
|
||||
GC across the board** as a first cut (`GC_malloc` substituted
|
||||
for `malloc`, `-lgc` linked, no language change). Add a
|
||||
per-fn-arena optimisation later for non-escaping cases if the
|
||||
profile justifies it. Boehm is a one-iter shot; arenas would be
|
||||
a multi-iter design pass with escape analysis.
|
||||
|
||||
**Plan 14f.** Boehm-GC integration. Concretely:
|
||||
- `GC_malloc` instead of `malloc` in lowered IR.
|
||||
- `-lgc` added to the clang link command in `ailang-codegen`'s
|
||||
build path (probably in the CLI, since the codegen crate
|
||||
emits IR text and clang is invoked downstream).
|
||||
- Conservative scan handles AILang's stack and globals out of
|
||||
the box.
|
||||
- Verify on a stress test: build a list of 100k Cons cells in
|
||||
`map`, run, observe RSS doesn't blow up. Boehm collects
|
||||
unreachable boxes during allocation pressure.
|
||||
- No AST or schema change. No language-level change.
|
||||
|
||||
After 14f, the language is "done enough" for stdlib (15a).
|
||||
Anything else (records as a primitive, nested patterns, local
|
||||
recursive let, type classes) can layer on later without forcing
|
||||
stdlib rewrites.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -164,7 +164,8 @@
|
||||
"rhs": {
|
||||
"t": "app",
|
||||
"fn": { "t": "var", "name": "print_list" },
|
||||
"args": [{ "t": "var", "name": "t" }]
|
||||
"args": [{ "t": "var", "name": "t" }],
|
||||
"tail": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
(case (pat-ctor Cons h t)
|
||||
(seq
|
||||
(do io/print_int h)
|
||||
(app print_list t))))))
|
||||
(tail-app print_list t))))))
|
||||
|
||||
(fn main
|
||||
(doc "Map inc over [1,2,3] via polymorphic List, then print: 2,3,4.")
|
||||
|
||||
@@ -204,7 +204,8 @@
|
||||
"rhs": {
|
||||
"t": "app",
|
||||
"fn": { "t": "var", "name": "print_list" },
|
||||
"args": [{ "t": "var", "name": "t" }]
|
||||
"args": [{ "t": "var", "name": "t" }],
|
||||
"tail": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user