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());
|
out.insert(name.clone());
|
||||||
}
|
}
|
||||||
Term::App { callee, args } => {
|
Term::App { callee, args, .. } => {
|
||||||
walk_term(callee, out, builtins, scope);
|
walk_term(callee, out, builtins, scope);
|
||||||
for a in args {
|
for a in args {
|
||||||
walk_term(a, out, builtins, scope);
|
walk_term(a, out, builtins, scope);
|
||||||
@@ -878,7 +878,7 @@ fn walk_term(
|
|||||||
scope.remove(name);
|
scope.remove(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Term::Do { op, args } => {
|
Term::Do { op, args, .. } => {
|
||||||
// Mark effect ops as `effect:io/print_int` so they can be
|
// Mark effect ops as `effect:io/print_int` so they can be
|
||||||
// separated from normal function calls.
|
// separated from normal function calls.
|
||||||
out.insert(format!("effect:{op}"));
|
out.insert(format!("effect:{op}"));
|
||||||
|
|||||||
@@ -107,6 +107,62 @@ fn list_map_poly_inc_then_prints() {
|
|||||||
assert_eq!(lines, vec!["2", "3", "4"]);
|
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.
|
/// Iter 11 dogfood: insertion sort over an 11-element IntList.
|
||||||
/// Exercises `<=`, `if`, mutual-leaf recursion (`insert` and `sort`),
|
/// Exercises `<=`, `if`, mutual-leaf recursion (`insert` and `sort`),
|
||||||
/// nested ctor construction, and the Iter 10 seq operator inside
|
/// 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-cycle` — workspace loader (Iter 5b, in the CLI path)
|
||||||
//! - `module-name-mismatch` — 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)
|
//! - `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;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
|||||||
@@ -395,6 +395,12 @@ pub enum CheckError {
|
|||||||
/// for qualified cross-module references. Code: `invalid-def-name`.
|
/// for qualified cross-module references. Code: `invalid-def-name`.
|
||||||
#[error("invalid def name `{name}`: contains `.` (reserved for qualified refs)")]
|
#[error("invalid def name `{name}`: contains `.` (reserved for qualified refs)")]
|
||||||
InvalidDefName { name: String },
|
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>;
|
type Result<T> = std::result::Result<T, CheckError>;
|
||||||
@@ -430,6 +436,7 @@ impl CheckError {
|
|||||||
CheckError::UnknownModule { .. } => "unknown-module",
|
CheckError::UnknownModule { .. } => "unknown-module",
|
||||||
CheckError::UnknownImport { .. } => "unknown-import",
|
CheckError::UnknownImport { .. } => "unknown-import",
|
||||||
CheckError::InvalidDefName { .. } => "invalid-def-name",
|
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)?;
|
let body_ty = synth(&f.body, &env, &mut locals, &mut effects, &f.name, &mut subst, &mut counter)?;
|
||||||
unify(&ret_ty, &body_ty, &mut subst)?;
|
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();
|
let declared: BTreeSet<String> = declared_effs.into_iter().collect();
|
||||||
for e in &effects {
|
for e in &effects {
|
||||||
if !declared.contains(e) {
|
if !declared.contains(e) {
|
||||||
@@ -903,6 +915,77 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
fn check_const(c: &ConstDef, env: &Env) -> Result<()> {
|
||||||
// Const types are never polymorphic — a Forall here is rejected
|
// Const types are never polymorphic — a Forall here is rejected
|
||||||
// outright. Any other type passes through to `synth` as before.
|
// outright. Any other type passes through to `synth` as before.
|
||||||
@@ -976,7 +1059,7 @@ fn synth(
|
|||||||
};
|
};
|
||||||
Ok(maybe_instantiate(raw, counter))
|
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 = synth(callee, env, locals, effects, in_def, subst, counter)?;
|
||||||
let cty = subst.apply(&cty);
|
let cty = subst.apply(&cty);
|
||||||
let (params, ret, fx) = match &cty {
|
let (params, ret, fx) = match &cty {
|
||||||
@@ -1035,7 +1118,7 @@ fn synth(
|
|||||||
}
|
}
|
||||||
Ok(r)
|
Ok(r)
|
||||||
}
|
}
|
||||||
Term::Do { op, args } => {
|
Term::Do { op, args, .. } => {
|
||||||
let sig = env
|
let sig = env
|
||||||
.effect_ops
|
.effect_ops
|
||||||
.get(op)
|
.get(op)
|
||||||
@@ -1425,6 +1508,7 @@ mod tests {
|
|||||||
Term::Var { name: "a".into() },
|
Term::Var { name: "a".into() },
|
||||||
Term::Var { name: "b".into() },
|
Term::Var { name: "b".into() },
|
||||||
],
|
],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
)],
|
)],
|
||||||
};
|
};
|
||||||
@@ -1474,6 +1558,7 @@ mod tests {
|
|||||||
args: vec![Term::Lit {
|
args: vec![Term::Lit {
|
||||||
lit: Literal::Int { value: 1 },
|
lit: Literal::Int { value: 1 },
|
||||||
}],
|
}],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
)],
|
)],
|
||||||
};
|
};
|
||||||
@@ -1711,6 +1796,7 @@ mod tests {
|
|||||||
Term::App {
|
Term::App {
|
||||||
callee: Box::new(Term::Var { name: "id".into() }),
|
callee: Box::new(Term::Var { name: "id".into() }),
|
||||||
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
|
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// `use_bool` returns id(true) :: Bool.
|
// `use_bool` returns id(true) :: Bool.
|
||||||
@@ -1725,6 +1811,7 @@ mod tests {
|
|||||||
Term::App {
|
Term::App {
|
||||||
callee: Box::new(Term::Var { name: "id".into() }),
|
callee: Box::new(Term::Var { name: "id".into() }),
|
||||||
args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
|
args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let m = Module {
|
let m = Module {
|
||||||
@@ -1766,6 +1853,7 @@ mod tests {
|
|||||||
Term::App {
|
Term::App {
|
||||||
callee: Box::new(Term::Var { name: "id".into() }),
|
callee: Box::new(Term::Var { name: "id".into() }),
|
||||||
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
|
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let m = Module {
|
let m = Module {
|
||||||
@@ -1805,6 +1893,7 @@ mod tests {
|
|||||||
Term::App {
|
Term::App {
|
||||||
callee: Box::new(Term::Var { name: "f".into() }),
|
callee: Box::new(Term::Var { name: "f".into() }),
|
||||||
args: vec![Term::Var { name: "x".into() }],
|
args: vec![Term::Var { name: "x".into() }],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// A monomorphic helper to be passed to apply.
|
// A monomorphic helper to be passed to apply.
|
||||||
@@ -1822,6 +1911,7 @@ mod tests {
|
|||||||
Term::Var { name: "n".into() },
|
Term::Var { name: "n".into() },
|
||||||
Term::Lit { lit: Literal::Int { value: 1 } },
|
Term::Lit { lit: Literal::Int { value: 1 } },
|
||||||
],
|
],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let use_apply = fn_def(
|
let use_apply = fn_def(
|
||||||
@@ -1838,6 +1928,7 @@ mod tests {
|
|||||||
Term::Var { name: "succ".into() },
|
Term::Var { name: "succ".into() },
|
||||||
Term::Lit { lit: Literal::Int { value: 41 } },
|
Term::Lit { lit: Literal::Int { value: 41 } },
|
||||||
],
|
],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let m = Module {
|
let m = Module {
|
||||||
@@ -1945,6 +2036,7 @@ mod tests {
|
|||||||
ctor: "MkBox".into(),
|
ctor: "MkBox".into(),
|
||||||
args: vec![Term::Lit { lit: Literal::Int { value: 7 } }],
|
args: vec![Term::Lit { lit: Literal::Int { value: 7 } }],
|
||||||
}],
|
}],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let use_bool = fn_def(
|
let use_bool = fn_def(
|
||||||
@@ -1962,6 +2054,7 @@ mod tests {
|
|||||||
ctor: "MkBox".into(),
|
ctor: "MkBox".into(),
|
||||||
args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
|
args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
|
||||||
}],
|
}],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let m = Module {
|
let m = Module {
|
||||||
@@ -2041,4 +2134,131 @@ mod tests {
|
|||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
assert!(msg.contains("type mismatch"), "got: {msg}");
|
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: 2 } },
|
||||||
Term::Lit { lit: Literal::Int { value: 3 } },
|
Term::Lit { lit: Literal::Int { value: 3 } },
|
||||||
],
|
],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
doc: None,
|
doc: None,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -359,6 +359,12 @@ struct Emitter<'a> {
|
|||||||
/// Current basic block label. Set by `start_block` and is
|
/// Current basic block label. Set by `start_block` and is
|
||||||
/// the single source of truth for `phi` operands.
|
/// the single source of truth for `phi` operands.
|
||||||
current_block: String,
|
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
|
/// Iter 7: SSA value (or `@global`) -> its FnSig, for first-class
|
||||||
/// function values. Populated whenever we lower a `Term::Var` to a
|
/// function values. Populated whenever we lower a `Term::Var` to a
|
||||||
/// top-level fn pointer or when a fn-typed parameter is bound at
|
/// top-level fn pointer or when a fn-typed parameter is bound at
|
||||||
@@ -479,6 +485,7 @@ impl<'a> Emitter<'a> {
|
|||||||
types,
|
types,
|
||||||
ctor_index,
|
ctor_index,
|
||||||
current_block: String::new(),
|
current_block: String::new(),
|
||||||
|
block_terminated: false,
|
||||||
ssa_fn_sigs: BTreeMap::new(),
|
ssa_fn_sigs: BTreeMap::new(),
|
||||||
current_def: String::new(),
|
current_def: String::new(),
|
||||||
lam_counter: 0,
|
lam_counter: 0,
|
||||||
@@ -490,6 +497,7 @@ impl<'a> Emitter<'a> {
|
|||||||
self.body.push_str(label);
|
self.body.push_str(label);
|
||||||
self.body.push_str(":\n");
|
self.body.push_str(":\n");
|
||||||
self.current_block = label.to_string();
|
self.current_block = label.to_string();
|
||||||
|
self.block_terminated = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_module(&mut self) -> Result<()> {
|
fn emit_module(&mut self) -> Result<()> {
|
||||||
@@ -694,14 +702,21 @@ impl<'a> Emitter<'a> {
|
|||||||
self.start_block("entry");
|
self.start_block("entry");
|
||||||
|
|
||||||
let (val, val_ty) = self.lower_term(&f.body)?;
|
let (val, val_ty) = self.lower_term(&f.body)?;
|
||||||
if val_ty != llvm_ret {
|
if !self.block_terminated {
|
||||||
return Err(CodegenError::Internal(format!(
|
if val_ty != llvm_ret {
|
||||||
"fn `{}`: body type {val_ty} != return type {llvm_ret}",
|
return Err(CodegenError::Internal(format!(
|
||||||
f.name
|
"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
|
// Iter 8b: flush lambda thunks collected while lowering this fn's
|
||||||
// body. They go after the closing `}` of the parent fn, before
|
// body. They go after the closing `}` of the parent fn, before
|
||||||
@@ -801,7 +816,7 @@ impl<'a> Emitter<'a> {
|
|||||||
self.locals.pop();
|
self.locals.pop();
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
Term::App { callee, args } => {
|
Term::App { callee, args, tail } => {
|
||||||
// Direct call when the callee is a `Var` referring to a
|
// Direct call when the callee is a `Var` referring to a
|
||||||
// statically-known target (builtin, current-module fn,
|
// statically-known target (builtin, current-module fn,
|
||||||
// qualified cross-module fn) AND not shadowed by a local.
|
// 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() {
|
if let Term::Var { name } = callee.as_ref() {
|
||||||
let shadowed = self.locals.iter().any(|(n, _, _, _)| n == name);
|
let shadowed = self.locals.iter().any(|(n, _, _, _)| n == name);
|
||||||
if !shadowed && self.is_static_callee(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)?;
|
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}`"
|
"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::Ctor { type_name, ctor, args } => self.lower_ctor(type_name, ctor, args),
|
||||||
Term::Match { scrutinee, arms } => self.lower_match(scrutinee, arms),
|
Term::Match { scrutinee, arms } => self.lower_match(scrutinee, arms),
|
||||||
Term::Lam { params, param_tys, ret_ty, effects: _, body } => {
|
Term::Lam { params, param_tys, ret_ty, effects: _, body } => {
|
||||||
@@ -840,6 +855,12 @@ impl<'a> Emitter<'a> {
|
|||||||
Term::Seq { lhs, rhs } => {
|
Term::Seq { lhs, rhs } => {
|
||||||
// Iter 10: lower lhs for its effects, discard the SSA;
|
// Iter 10: lower lhs for its effects, discard the SSA;
|
||||||
// lower rhs and return its value as the whole expression.
|
// 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)?;
|
let _ = self.lower_term(lhs)?;
|
||||||
self.lower_term(rhs)
|
self.lower_term(rhs)
|
||||||
}
|
}
|
||||||
@@ -1083,17 +1104,22 @@ impl<'a> Emitter<'a> {
|
|||||||
for _ in 0..pushed {
|
for _ in 0..pushed {
|
||||||
self.locals.pop();
|
self.locals.pop();
|
||||||
}
|
}
|
||||||
phi_inputs.push((val, self.current_block.clone()));
|
// Iter 14e: if the arm body lowered to a `musttail call` +
|
||||||
self.body
|
// `ret`, the block is already terminated. Skip the
|
||||||
.push_str(&format!(" br label %{join_lbl}\n"));
|
// fall-through `br` and exclude this arm from the join phi.
|
||||||
if let Some(rt) = &result_ty {
|
if !self.block_terminated {
|
||||||
if rt != &vty {
|
phi_inputs.push((val, self.current_block.clone()));
|
||||||
return Err(CodegenError::Internal(format!(
|
self.body
|
||||||
"match arm result type {vty} != {rt}"
|
.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 {
|
for _ in 0..pushed {
|
||||||
self.locals.pop();
|
self.locals.pop();
|
||||||
}
|
}
|
||||||
phi_inputs.push((val, self.current_block.clone()));
|
if !self.block_terminated {
|
||||||
self.body
|
phi_inputs.push((val, self.current_block.clone()));
|
||||||
.push_str(&format!(" br label %{join_lbl}\n"));
|
self.body
|
||||||
if let Some(rt) = &result_ty {
|
.push_str(&format!(" br label %{join_lbl}\n"));
|
||||||
if rt != &vty {
|
if let Some(rt) = &result_ty {
|
||||||
return Err(CodegenError::Internal(format!(
|
if rt != &vty {
|
||||||
"match default arm result type {vty} != {rt}"
|
return Err(CodegenError::Internal(format!(
|
||||||
)));
|
"match default arm result type {vty} != {rt}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result_ty = Some(vty);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
result_ty = Some(vty);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Typechecker guarantees exhaustiveness, so unreachable.
|
// Typechecker guarantees exhaustiveness, so unreachable.
|
||||||
@@ -1130,6 +1158,18 @@ impl<'a> Emitter<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// join
|
// 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);
|
self.start_block(&join_lbl);
|
||||||
let phi = self.fresh_ssa();
|
let phi = self.fresh_ssa();
|
||||||
let rt = result_ty.unwrap_or_else(|| "i64".into());
|
let rt = result_ty.unwrap_or_else(|| "i64".into());
|
||||||
@@ -1195,20 +1235,43 @@ impl<'a> Emitter<'a> {
|
|||||||
|
|
||||||
self.start_block(&then_lbl);
|
self.start_block(&then_lbl);
|
||||||
let (then_v, then_ty) = self.lower_term(true_body)?;
|
let (then_v, then_ty) = self.lower_term(true_body)?;
|
||||||
// Nested code in the `then` body may have changed the
|
// Iter 14e: a tail-call in this arm already terminated its
|
||||||
// block label — phi must see the last actual block.
|
// block; skip its branch to join and exclude from phi.
|
||||||
|
let then_terminated = self.block_terminated;
|
||||||
let then_block_end = self.current_block.clone();
|
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);
|
self.start_block(&else_lbl);
|
||||||
let (else_v, else_ty) = self.lower_term(false_body)?;
|
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!(
|
return Err(CodegenError::Internal(format!(
|
||||||
"Bool-match arms type mismatch: {then_ty} vs {else_ty}"
|
"Bool-match arms type mismatch: {then_ty} vs {else_ty}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let else_block_end = self.current_block.clone();
|
if !else_terminated {
|
||||||
self.body.push_str(&format!(" br label %{join_lbl}\n"));
|
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);
|
self.start_block(&join_lbl);
|
||||||
let phi = self.fresh_ssa();
|
let phi = self.fresh_ssa();
|
||||||
@@ -1235,7 +1298,7 @@ impl<'a> Emitter<'a> {
|
|||||||
Ok((phi, then_ty))
|
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.
|
// Built-in arithmetic / comparison.
|
||||||
if let Some((instr, ret_ty)) = builtin_binop(name) {
|
if let Some((instr, ret_ty)) = builtin_binop(name) {
|
||||||
if args.len() != 2 {
|
if args.len() != 2 {
|
||||||
@@ -1249,6 +1312,11 @@ impl<'a> Emitter<'a> {
|
|||||||
self.body.push_str(&format!(
|
self.body.push_str(&format!(
|
||||||
" {dst} = {instr} i64 {a}, {b}\n"
|
" {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()));
|
return Ok((dst, ret_ty.into()));
|
||||||
}
|
}
|
||||||
if name == "not" {
|
if name == "not" {
|
||||||
@@ -1277,7 +1345,7 @@ impl<'a> Emitter<'a> {
|
|||||||
.get(&target_module)
|
.get(&target_module)
|
||||||
.is_some_and(|m| m.contains_key(suffix))
|
.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
|
let target_fns = self
|
||||||
.module_user_fns
|
.module_user_fns
|
||||||
@@ -1295,7 +1363,7 @@ impl<'a> Emitter<'a> {
|
|||||||
"cross-module call `{name}`: def `{suffix}` not in module `{target_module}`"
|
"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?
|
// Polymorphic def in the current module?
|
||||||
@@ -1305,7 +1373,7 @@ impl<'a> Emitter<'a> {
|
|||||||
.is_some_and(|m| m.contains_key(name))
|
.is_some_and(|m| m.contains_key(name))
|
||||||
{
|
{
|
||||||
let owner = self.module_name.to_string();
|
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?
|
// User function in the current module?
|
||||||
@@ -1315,7 +1383,7 @@ impl<'a> Emitter<'a> {
|
|||||||
.and_then(|m| m.get(name))
|
.and_then(|m| m.get(name))
|
||||||
.cloned()
|
.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!(
|
Err(CodegenError::Internal(format!(
|
||||||
@@ -1332,6 +1400,7 @@ impl<'a> Emitter<'a> {
|
|||||||
owner_module: &str,
|
owner_module: &str,
|
||||||
def_name: &str,
|
def_name: &str,
|
||||||
args: &[Term],
|
args: &[Term],
|
||||||
|
tail: bool,
|
||||||
) -> Result<(String, String)> {
|
) -> Result<(String, String)> {
|
||||||
let fdef = self
|
let fdef = self
|
||||||
.module_polymorphic_fns
|
.module_polymorphic_fns
|
||||||
@@ -1408,10 +1477,16 @@ impl<'a> Emitter<'a> {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
let dst = self.fresh_ssa();
|
let dst = self.fresh_ssa();
|
||||||
|
let call_kw = if tail { "musttail call" } else { "call" };
|
||||||
self.body.push_str(&format!(
|
self.body.push_str(&format!(
|
||||||
" {dst} = call {ret} @{mangled}({arglist})\n",
|
" {dst} = {call_kw} {ret} @{mangled}({arglist})\n",
|
||||||
ret = llvm_ret,
|
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))
|
Ok((dst, llvm_ret))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1421,6 +1496,7 @@ impl<'a> Emitter<'a> {
|
|||||||
target_def: &str,
|
target_def: &str,
|
||||||
sig: &FnSig,
|
sig: &FnSig,
|
||||||
args: &[Term],
|
args: &[Term],
|
||||||
|
tail: bool,
|
||||||
) -> Result<(String, String)> {
|
) -> Result<(String, String)> {
|
||||||
let mut compiled_args = Vec::new();
|
let mut compiled_args = Vec::new();
|
||||||
for (a, exp_ty) in args.iter().zip(sig.params.iter()) {
|
for (a, exp_ty) in args.iter().zip(sig.params.iter()) {
|
||||||
@@ -1438,12 +1514,23 @@ impl<'a> Emitter<'a> {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
let dst = self.fresh_ssa();
|
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!(
|
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,
|
ret = sig.ret,
|
||||||
module = target_module,
|
module = target_module,
|
||||||
name = target_def,
|
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()))
|
Ok((dst, sig.ret.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1457,6 +1544,7 @@ impl<'a> Emitter<'a> {
|
|||||||
callee_ssa: &str,
|
callee_ssa: &str,
|
||||||
sig: &FnSig,
|
sig: &FnSig,
|
||||||
args: &[Term],
|
args: &[Term],
|
||||||
|
tail: bool,
|
||||||
) -> Result<(String, String)> {
|
) -> Result<(String, String)> {
|
||||||
if args.len() != sig.params.len() {
|
if args.len() != sig.params.len() {
|
||||||
return Err(CodegenError::Internal(format!(
|
return Err(CodegenError::Internal(format!(
|
||||||
@@ -1505,11 +1593,25 @@ impl<'a> Emitter<'a> {
|
|||||||
param_tys.push_str(pt);
|
param_tys.push_str(pt);
|
||||||
}
|
}
|
||||||
let dst = self.fresh_ssa();
|
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!(
|
self.body.push_str(&format!(
|
||||||
" {dst} = call {ret} ({ptys}) {thunk}({arglist})\n",
|
" {dst} = {call_kw} {ret} ({ptys}) {thunk}({arglist})\n",
|
||||||
ret = sig.ret,
|
ret = sig.ret,
|
||||||
ptys = param_tys,
|
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()))
|
Ok((dst, sig.ret.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1594,6 +1696,8 @@ impl<'a> Emitter<'a> {
|
|||||||
let saved_locals = std::mem::take(&mut self.locals);
|
let saved_locals = std::mem::take(&mut self.locals);
|
||||||
let saved_counter = self.counter;
|
let saved_counter = self.counter;
|
||||||
let saved_block = std::mem::take(&mut self.current_block);
|
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);
|
let saved_sigs = std::mem::take(&mut self.ssa_fn_sigs);
|
||||||
// Lambdas inside lambdas are fine: they get their own counter
|
// Lambdas inside lambdas are fine: they get their own counter
|
||||||
// namespace within the enclosing thunk. They share the
|
// 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)?;
|
let (body_v, body_ty) = self.lower_term(lam_body)?;
|
||||||
if body_ty != llvm_ret {
|
if !self.block_terminated {
|
||||||
return Err(CodegenError::Internal(format!(
|
if body_ty != llvm_ret {
|
||||||
"lambda `{thunk_name}`: body type {body_ty} != return type {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
|
// Park the thunk text in the deferred queue and restore outer
|
||||||
// emitter state.
|
// emitter state.
|
||||||
@@ -1669,6 +1777,7 @@ impl<'a> Emitter<'a> {
|
|||||||
self.locals = saved_locals;
|
self.locals = saved_locals;
|
||||||
self.counter = saved_counter;
|
self.counter = saved_counter;
|
||||||
self.current_block = saved_block;
|
self.current_block = saved_block;
|
||||||
|
self.block_terminated = saved_terminated;
|
||||||
self.ssa_fn_sigs = saved_sigs;
|
self.ssa_fn_sigs = saved_sigs;
|
||||||
|
|
||||||
// 3. Emit allocation + capture filling + closure-pair packing
|
// 3. Emit allocation + capture filling + closure-pair packing
|
||||||
@@ -1751,7 +1860,7 @@ impl<'a> Emitter<'a> {
|
|||||||
captures.push(name.clone());
|
captures.push(name.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Term::App { callee, args } => {
|
Term::App { callee, args, .. } => {
|
||||||
Self::collect_captures(callee, bound, captures, captures_set, builtins, top_level);
|
Self::collect_captures(callee, bound, captures, captures_set, builtins, top_level);
|
||||||
for a in args {
|
for a in args {
|
||||||
Self::collect_captures(a, bound, captures, captures_set, builtins, top_level);
|
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 {
|
match op {
|
||||||
"io/print_int" => {
|
"io/print_int" => {
|
||||||
if args.len() != 1 {
|
if args.len() != 1 {
|
||||||
@@ -1889,8 +2010,12 @@ impl<'a> Emitter<'a> {
|
|||||||
}
|
}
|
||||||
let fmt = self.intern_string("fmt_int", "%lld\n");
|
let fmt = self.intern_string("fmt_int", "%lld\n");
|
||||||
self.body.push_str(&format!(
|
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()))
|
Ok(("0".into(), "i8".into()))
|
||||||
}
|
}
|
||||||
"io/print_str" => {
|
"io/print_str" => {
|
||||||
@@ -1906,7 +2031,11 @@ impl<'a> Emitter<'a> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
self.body
|
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()))
|
Ok(("0".into(), "i8".into()))
|
||||||
}
|
}
|
||||||
"io/print_bool" => {
|
"io/print_bool" => {
|
||||||
@@ -1942,6 +2071,10 @@ impl<'a> Emitter<'a> {
|
|||||||
));
|
));
|
||||||
self.body.push_str(&format!(" br label %{join_lbl}\n"));
|
self.body.push_str(&format!(" br label %{join_lbl}\n"));
|
||||||
self.start_block(&join_lbl);
|
self.start_block(&join_lbl);
|
||||||
|
if tail {
|
||||||
|
self.body.push_str(" ret i8 0\n");
|
||||||
|
self.block_terminated = true;
|
||||||
|
}
|
||||||
Ok(("0".into(), "i8".into()))
|
Ok(("0".into(), "i8".into()))
|
||||||
}
|
}
|
||||||
other => Err(CodegenError::Internal(format!(
|
other => Err(CodegenError::Internal(format!(
|
||||||
@@ -2049,7 +2182,7 @@ impl<'a> Emitter<'a> {
|
|||||||
ret: ret_ty.clone(),
|
ret: ret_ty.clone(),
|
||||||
effects: effects.clone(),
|
effects: effects.clone(),
|
||||||
}),
|
}),
|
||||||
Term::App { callee, args } => {
|
Term::App { callee, args, .. } => {
|
||||||
let cty = self.synth_with_extras(callee, extras)?;
|
let cty = self.synth_with_extras(callee, extras)?;
|
||||||
match cty {
|
match cty {
|
||||||
Type::Fn { ret, .. } => Ok(*ret),
|
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 {
|
fn apply_subst_to_term(t: &Term, subst: &BTreeMap<String, Type>) -> Term {
|
||||||
match t {
|
match t {
|
||||||
Term::Lit { .. } | Term::Var { .. } => t.clone(),
|
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)),
|
callee: Box::new(apply_subst_to_term(callee, subst)),
|
||||||
args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(),
|
args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(),
|
||||||
|
tail: *tail,
|
||||||
},
|
},
|
||||||
Term::Let { name, value, body } => Term::Let {
|
Term::Let { name, value, body } => Term::Let {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
value: Box::new(apply_subst_to_term(value, subst)),
|
value: Box::new(apply_subst_to_term(value, subst)),
|
||||||
body: Box::new(apply_subst_to_term(body, 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(),
|
op: op.clone(),
|
||||||
args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(),
|
args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(),
|
||||||
|
tail: *tail,
|
||||||
},
|
},
|
||||||
Term::Ctor { type_name, ctor, args } => Term::Ctor {
|
Term::Ctor { type_name, ctor, args } => Term::Ctor {
|
||||||
type_name: type_name.clone(),
|
type_name: type_name.clone(),
|
||||||
@@ -2559,6 +2694,7 @@ mod tests {
|
|||||||
Term::Var { name: "a".into() },
|
Term::Var { name: "a".into() },
|
||||||
Term::Var { name: "b".into() },
|
Term::Var { name: "b".into() },
|
||||||
],
|
],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
doc: None,
|
doc: None,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -200,10 +200,18 @@ pub enum Term {
|
|||||||
Var { name: String },
|
Var { name: String },
|
||||||
/// Function application. `callee` is evaluated to a function value;
|
/// Function application. `callee` is evaluated to a function value;
|
||||||
/// `args` are evaluated left-to-right.
|
/// `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 {
|
App {
|
||||||
#[serde(rename = "fn")]
|
#[serde(rename = "fn")]
|
||||||
callee: Box<Term>,
|
callee: Box<Term>,
|
||||||
args: Vec<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-binding: `value` is evaluated and bound to `name` in `body`.
|
||||||
Let {
|
Let {
|
||||||
@@ -213,9 +221,13 @@ pub enum Term {
|
|||||||
},
|
},
|
||||||
/// Effect operation invocation (e.g. `do print "hi"`). The `op` is
|
/// Effect operation invocation (e.g. `do print "hi"`). The `op` is
|
||||||
/// resolved against the effect-handler table at link time.
|
/// resolved against the effect-handler table at link time.
|
||||||
|
///
|
||||||
|
/// Iter 14e: see [`Term::App`] for the `tail` field semantics.
|
||||||
Do {
|
Do {
|
||||||
op: String,
|
op: String,
|
||||||
args: Vec<Term>,
|
args: Vec<Term>,
|
||||||
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
|
tail: bool,
|
||||||
},
|
},
|
||||||
/// Constructor application. `type_name` binds the ADT, `ctor` the
|
/// Constructor application. `type_name` binds the ADT, `ctor` the
|
||||||
/// variant. Example: `Some(42)` ->
|
/// variant. Example: `Some(42)` ->
|
||||||
@@ -402,3 +414,13 @@ impl PartialEq for Type {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl Eq 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: "a".into() },
|
||||||
Term::Var { name: "b".into() },
|
Term::Var { name: "b".into() },
|
||||||
],
|
],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
doc: None,
|
doc: None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ fn term_block(t: &Term, indent: usize) -> String {
|
|||||||
match t {
|
match t {
|
||||||
Term::Lit { lit } => format!("{pad}{}", lit_to_string(lit)),
|
Term::Lit { lit } => format!("{pad}{}", lit_to_string(lit)),
|
||||||
Term::Var { name } => format!("{pad}{name}"),
|
Term::Var { name } => format!("{pad}{name}"),
|
||||||
Term::App { callee, args } => {
|
Term::App { callee, args, .. } => {
|
||||||
let mut s = format!("{pad}(");
|
let mut s = format!("{pad}(");
|
||||||
s.push_str(&term_inline(callee));
|
s.push_str(&term_inline(callee));
|
||||||
for a in args {
|
for a in args {
|
||||||
@@ -193,7 +193,7 @@ fn term_block(t: &Term, indent: usize) -> String {
|
|||||||
s.push(')');
|
s.push(')');
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
Term::Do { op, args } => {
|
Term::Do { op, args, .. } => {
|
||||||
let mut s = format!("{pad}(do {op}");
|
let mut s = format!("{pad}(do {op}");
|
||||||
for a in args {
|
for a in args {
|
||||||
s.push(' ');
|
s.push(' ');
|
||||||
@@ -287,7 +287,7 @@ fn term_inline(t: &Term) -> String {
|
|||||||
match t {
|
match t {
|
||||||
Term::Lit { lit } => lit_to_string(lit),
|
Term::Lit { lit } => lit_to_string(lit),
|
||||||
Term::Var { name } => name.clone(),
|
Term::Var { name } => name.clone(),
|
||||||
Term::App { callee, args } => {
|
Term::App { callee, args, .. } => {
|
||||||
let mut s = String::from("(");
|
let mut s = String::from("(");
|
||||||
s.push_str(&term_inline(callee));
|
s.push_str(&term_inline(callee));
|
||||||
for a in args {
|
for a in args {
|
||||||
@@ -297,7 +297,7 @@ fn term_inline(t: &Term) -> String {
|
|||||||
s.push(')');
|
s.push(')');
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
Term::Do { op, args } => {
|
Term::Do { op, args, .. } => {
|
||||||
let mut s = format!("(do {op}");
|
let mut s = format!("(do {op}");
|
||||||
for a in args {
|
for a in args {
|
||||||
s.push(' ');
|
s.push(' ');
|
||||||
@@ -414,6 +414,7 @@ mod tests {
|
|||||||
Term::Var { name: "a".into() },
|
Term::Var { name: "a".into() },
|
||||||
Term::Var { name: "b".into() },
|
Term::Var { name: "b".into() },
|
||||||
],
|
],
|
||||||
|
tail: false,
|
||||||
},
|
},
|
||||||
doc: None,
|
doc: None,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -34,18 +34,20 @@
|
|||||||
//! effects-clause::= "(" "effects" ident+ ")"
|
//! effects-clause::= "(" "effects" ident+ ")"
|
||||||
//!
|
//!
|
||||||
//! term ::= var-ref | int-lit | str-lit | bool-lit | unit-lit
|
//! term ::= var-ref | int-lit | str-lit | bool-lit | unit-lit
|
||||||
//! | app-term | match-term | ctor-term | do-term | seq-term
|
//! | app-term | tail-app-term | match-term | ctor-term
|
||||||
//! | lam-term | let-term
|
//! | do-term | tail-do-term | seq-term | lam-term | let-term
|
||||||
//! var-ref ::= ident ; reserved: true/false → bool-lit
|
//! var-ref ::= ident ; reserved: true/false → bool-lit
|
||||||
//! int-lit ::= integer ; numeric atom
|
//! int-lit ::= integer ; numeric atom
|
||||||
//! str-lit ::= string ; string atom
|
//! str-lit ::= string ; string atom
|
||||||
//! bool-lit ::= "true" | "false"
|
//! bool-lit ::= "true" | "false"
|
||||||
//! unit-lit ::= "(" "lit-unit" ")"
|
//! unit-lit ::= "(" "lit-unit" ")"
|
||||||
//! app-term ::= "(" "app" term term+ ")"
|
//! app-term ::= "(" "app" term term+ ")"
|
||||||
|
//! tail-app-term ::= "(" "tail-app" term term+ ")" ; Iter 14e
|
||||||
//! ctor-term ::= "(" "term-ctor" ident ident term* ")"
|
//! ctor-term ::= "(" "term-ctor" ident ident term* ")"
|
||||||
//! match-term ::= "(" "match" term case-arm+ ")"
|
//! match-term ::= "(" "match" term case-arm+ ")"
|
||||||
//! case-arm ::= "(" "case" pattern term ")"
|
//! case-arm ::= "(" "case" pattern term ")"
|
||||||
//! do-term ::= "(" "do" ident term* ")"
|
//! do-term ::= "(" "do" ident term* ")"
|
||||||
|
//! tail-do-term ::= "(" "tail-do" ident term* ")" ; Iter 14e
|
||||||
//! seq-term ::= "(" "seq" term term ")"
|
//! seq-term ::= "(" "seq" term term ")"
|
||||||
//! lam-term ::= "(" "lam" "(" "params" typed-param* ")"
|
//! lam-term ::= "(" "lam" "(" "params" typed-param* ")"
|
||||||
//! "(" "ret" type ")"
|
//! "(" "ret" type ")"
|
||||||
@@ -677,9 +679,11 @@ impl<'a> Parser<'a> {
|
|||||||
match head {
|
match head {
|
||||||
"lit-unit" => self.parse_lit_unit(),
|
"lit-unit" => self.parse_lit_unit(),
|
||||||
"app" => self.parse_app(),
|
"app" => self.parse_app(),
|
||||||
|
"tail-app" => self.parse_tail_app(),
|
||||||
"term-ctor" => self.parse_term_ctor(),
|
"term-ctor" => self.parse_term_ctor(),
|
||||||
"match" => self.parse_match(),
|
"match" => self.parse_match(),
|
||||||
"do" => self.parse_do(),
|
"do" => self.parse_do(),
|
||||||
|
"tail-do" => self.parse_tail_do(),
|
||||||
"seq" => self.parse_seq(),
|
"seq" => self.parse_seq(),
|
||||||
"lam" => self.parse_lam(),
|
"lam" => self.parse_lam(),
|
||||||
"let" => self.parse_let(),
|
"let" => self.parse_let(),
|
||||||
@@ -689,8 +693,8 @@ impl<'a> Parser<'a> {
|
|||||||
production: "term",
|
production: "term",
|
||||||
message: format!(
|
message: format!(
|
||||||
"unknown term head `{other}`; expected one of \
|
"unknown term head `{other}`; expected one of \
|
||||||
`app`, `lam`, `let`, `match`, `do`, `seq`, \
|
`app`, `tail-app`, `lam`, `let`, `match`, `do`, \
|
||||||
`term-ctor`, `lit-unit`"
|
`tail-do`, `seq`, `term-ctor`, `lit-unit`"
|
||||||
),
|
),
|
||||||
pos,
|
pos,
|
||||||
})
|
})
|
||||||
@@ -736,12 +740,32 @@ impl<'a> Parser<'a> {
|
|||||||
fn parse_app(&mut self) -> Result<Term, ParseError> {
|
fn parse_app(&mut self) -> Result<Term, ParseError> {
|
||||||
self.expect_lparen("app-term")?;
|
self.expect_lparen("app-term")?;
|
||||||
self.expect_keyword("app")?;
|
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()?;
|
let callee = self.parse_term()?;
|
||||||
// 1+ args
|
// 1+ args
|
||||||
if matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
|
if matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
|
||||||
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
|
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
|
||||||
return Err(ParseError::Production {
|
return Err(ParseError::Production {
|
||||||
production: "app-term",
|
production,
|
||||||
message: "expected at least one argument".into(),
|
message: "expected at least one argument".into(),
|
||||||
pos,
|
pos,
|
||||||
});
|
});
|
||||||
@@ -750,10 +774,11 @@ impl<'a> Parser<'a> {
|
|||||||
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
|
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
|
||||||
args.push(self.parse_term()?);
|
args.push(self.parse_term()?);
|
||||||
}
|
}
|
||||||
self.expect_rparen("app-term")?;
|
self.expect_rparen(production)?;
|
||||||
Ok(Term::App {
|
Ok(Term::App {
|
||||||
callee: Box::new(callee),
|
callee: Box::new(callee),
|
||||||
args,
|
args,
|
||||||
|
tail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,13 +834,30 @@ impl<'a> Parser<'a> {
|
|||||||
fn parse_do(&mut self) -> Result<Term, ParseError> {
|
fn parse_do(&mut self) -> Result<Term, ParseError> {
|
||||||
self.expect_lparen("do-term")?;
|
self.expect_lparen("do-term")?;
|
||||||
self.expect_keyword("do")?;
|
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 op = self.expect_ident("effect op (e.g. `io/print_int`)")?;
|
||||||
let mut args = Vec::new();
|
let mut args = Vec::new();
|
||||||
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
|
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
|
||||||
args.push(self.parse_term()?);
|
args.push(self.parse_term()?);
|
||||||
}
|
}
|
||||||
self.expect_rparen("do-term")?;
|
self.expect_rparen(production)?;
|
||||||
Ok(Term::Do { op, args })
|
Ok(Term::Do { op, args, tail })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_seq(&mut self) -> Result<Term, ParseError> {
|
fn parse_seq(&mut self) -> Result<Term, ParseError> {
|
||||||
|
|||||||
@@ -223,8 +223,12 @@ fn write_term(out: &mut String, t: &Term, level: usize) {
|
|||||||
match t {
|
match t {
|
||||||
Term::Lit { lit } => write_lit(out, lit),
|
Term::Lit { lit } => write_lit(out, lit),
|
||||||
Term::Var { name } => out.push_str(name),
|
Term::Var { name } => out.push_str(name),
|
||||||
Term::App { callee, args } => {
|
Term::App { callee, args, tail } => {
|
||||||
out.push_str("(app ");
|
// 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);
|
write_term(out, callee, level);
|
||||||
for a in args {
|
for a in args {
|
||||||
out.push(' ');
|
out.push(' ');
|
||||||
@@ -241,8 +245,9 @@ fn write_term(out: &mut String, t: &Term, level: usize) {
|
|||||||
write_term(out, body, level);
|
write_term(out, body, level);
|
||||||
out.push(')');
|
out.push(')');
|
||||||
}
|
}
|
||||||
Term::Do { op, args } => {
|
Term::Do { op, args, tail } => {
|
||||||
out.push_str("(do ");
|
// Iter 14e: `tail-do` mirrors `tail-app` for effect ops.
|
||||||
|
out.push_str(if *tail { "(tail-do " } else { "(do " });
|
||||||
out.push_str(op);
|
out.push_str(op);
|
||||||
for a in args {
|
for a in args {
|
||||||
out.push(' ');
|
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`; the three hand-written `.ailx` exhibits parse to canonical
|
||||||
JSON identical to their corresponding `.ail.json` files.
|
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
|
## Decision 7: redundancy removal — `Term::If` is not a primitive
|
||||||
|
|
||||||
`Term::If { cond, then, else_ }` is semantically a subset of
|
`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`,
|
Hash invalidation for the three migrated fixtures (`sum`, `sort`,
|
||||||
`max3`) is intentional; the new hashes become the new identity.
|
`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)
|
## Mangling scheme (Iter 5c)
|
||||||
|
|
||||||
All AILang functions are mangled to `@ail_<module>_<def>` — even in the
|
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
|
which makes 14f (GC) the more important iter for handling
|
||||||
long lists than 14e by itself.
|
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": {
|
"rhs": {
|
||||||
"t": "app",
|
"t": "app",
|
||||||
"fn": { "t": "var", "name": "print_list" },
|
"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)
|
(case (pat-ctor Cons h t)
|
||||||
(seq
|
(seq
|
||||||
(do io/print_int h)
|
(do io/print_int h)
|
||||||
(app print_list t))))))
|
(tail-app print_list t))))))
|
||||||
|
|
||||||
(fn main
|
(fn main
|
||||||
(doc "Map inc over [1,2,3] via polymorphic List, then print: 2,3,4.")
|
(doc "Map inc over [1,2,3] via polymorphic List, then print: 2,3,4.")
|
||||||
|
|||||||
@@ -204,7 +204,8 @@
|
|||||||
"rhs": {
|
"rhs": {
|
||||||
"t": "app",
|
"t": "app",
|
||||||
"fn": { "t": "var", "name": "print_list" },
|
"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