The §Typecheck enumeration in 0012-tail-calls.md presented the
tail-position rule as exhaustive ("The flag is `false` for: ...") but
omitted `Term::If` entirely — even though `verify_tail_positions`
propagates `is_tail` into both `If` branches (ailang-check/src/lib.rs),
and 0012's own motivating example (`if n == 0 then () else loop(n-1)`)
relies on the `else` branch being a tail position.
Restate the rule by principle (a sub-term is in tail position iff it is
the value of the whole expression) and enumerate the actual checker's
arms: `If` branches, `Match` arms, `Seq` right, `Let`/`LetRec`
in-clause, `Lam` body, and the identity/`Loop` propagators; every
argument position (incl. `If` condition, `Recur`/`New` args) is
non-tail. Matches the present `verify_tail_positions` walker.
3.2 KiB
Tail calls
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 }andTerm::Do { op, args, tail: bool }(see Data model) gain atailflag, serde-defaulting tofalseso existing fixtures load withtail: falseand 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 anis_tail_context: boolthreaded down. The flag istrueat the start and propagates to every sub-term that is the value of the whole expression: the body of everyTerm::Matcharm, both branches ofTerm::If, the right operand ofTerm::Seq, the body ofTerm::Letand the in-clause ofTerm::LetRec, and the body ofTerm::Lam(each Lam opens its own tail scope); the identity nodesTerm::Clone/Term::ReuseAsand aTerm::Loopbody propagate the enclosing flag unchanged. The flag isfalsefor every argument position: args of anyApp/Do/Ctor/Recur/New, the scrutinee ofMatch, the condition ofIf, the left operand ofSeq, and the value of aLet/LetRecbinding. When the walker visits anApp { tail: true }orDo { tail: true }, the flag must betrueat that visit; otherwise emit diagnostictail-call-not-in-tail-position. - Codegen. Emit
musttail call(LLVM IR) for marked calls instead of plaincall. 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 ofapp/dowithtail: trueset on the resulting term. EBNF gains 2 lines; total production count goes from ~28 to ~30, still inside the constraint-1 budget (see authoring surface).
What this does NOT promise. Per the tail-call survey of existing fixtures,
many existing recursive calls are not in tail position
because they are arguments to constructor calls (e.g.
Cons (f h) (map f t)). The current pipeline 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 ships both forms where
relevant.
Migration of existing fixtures is 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.
Ratified by: crates/ailang-check/src/lib.rs.