From db710b1a73760acbe5de1059e8cbd00d39852092 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 30 May 2026 17:14:07 +0200 Subject: [PATCH] fix(codegen): replay loop binders in synth_with_extras (closes #47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `let`-bound `loop` whose body references one of its own loop binders on the non-recur exit passed `ail check` but failed `ail build` with `unknown variable`. The fieldtest surfaced this with an owned RawBuf loop binder, but the trigger is element-type-independent. Root cause: the Term::Loop arm of synth_with_extras (the type-replay walk the let-lowering runs via synth_arg_type on every let value) descended into the loop body WITHOUT adding the loop binders to `extras`, unlike the Term::Let arm which pushes its binder. A loop binder referenced on the non-recur exit then resolved to UnknownVar during the type replay. Fix: clone `extras`, push each loop binder's (name, ty), and thread the augmented extras into the recursive synth of the body — mirroring the Term::Let arm exactly. General fix; a plain Int let-bound loop that references its binder also builds now. This restores check<->codegen agreement for the natural fill-loop (thread an owned buffer of runtime length through a loop/recur, one RawBuf.set per iteration) — the way to populate a buffer whose length is not a fixed set of literal indices. Surfaced by the raw-buf fieldtest (finding B2, docs/specs/0058). RED-first: loop_let_bound_binder_reference_builds. --- crates/ail/tests/e2e.rs | 16 ++++++++++++++ crates/ailang-codegen/src/lib.rs | 13 ++++++++++- examples/loop_let_bound_binder_ref.ail | 30 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 examples/loop_let_bound_binder_ref.ail diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index e6c57f2..ead9ce7 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -119,6 +119,22 @@ fn loop_recur_infinite_loop_compiles() { ); } +/// RED for fieldtest finding B2 (raw-buf milestone, Gitea #7): +/// a `loop` whose result is `let`-bound and whose body references one +/// of its own binders on the non-recur exit must BUILD, not only +/// `check`. The `Term::Let` codegen lowering replays the value's +/// AILang type via `synth_arg_type`; that replay walk +/// (`synth_with_extras`) must descend through `Term::Loop` with the +/// loop binders in scope — as it already does for `Term::Let` — or the +/// binder reference resolves to `UnknownVar`, breaking check↔codegen +/// agreement. Element-type-independent: the fieldtest hit it with an +/// owned `RawBuf` binder; a plain `Int` binder is the minimal trigger. +#[test] +fn loop_let_bound_binder_reference_builds() { + let stdout = build_and_run("loop_let_bound_binder_ref.ail"); + assert_eq!(stdout.trim(), "3"); +} + /// Guards block tracking in codegen: max3 has nested `if`s, and wrong /// phi block tracking would produce a wrong result here. #[test] diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index a8518a9..70efb73 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -3387,7 +3387,18 @@ impl<'a> Emitter<'a> { // `Term::Recur` does not fall through; a Unit stub is // safe (recur transfers control and never produces a // value at its own position). - Term::Loop { body, .. } => self.synth_with_extras(body, extras), + Term::Loop { binders, body } => { + // The loop's binders are in scope within `body` (and on + // the non-recur exit). Replay them into `extras` exactly + // as the `Term::Let` arm does for its single binder, so a + // `Term::Var` resolving to a loop binder synths instead of + // hitting `UnknownVar`. + let mut new_extras: Vec<(String, Type)> = extras.to_vec(); + for b in binders { + new_extras.push((b.name.clone(), b.ty.clone())); + } + self.synth_with_extras(body, &new_extras) + } Term::Recur { .. } => Ok(Type::unit()), // raw-buf.4: Term::New is desugared to (app T.new …) before // codegen (see lower_term's arm), so it never reaches the diff --git a/examples/loop_let_bound_binder_ref.ail b/examples/loop_let_bound_binder_ref.ail new file mode 100644 index 0000000..e877dfb --- /dev/null +++ b/examples/loop_let_bound_binder_ref.ail @@ -0,0 +1,30 @@ +; RED for fieldtest finding B2 (raw-buf milestone, Gitea #7). +; +; Property: a `loop` whose result is `let`-bound, and whose body +; references one of its own loop binders on the non-recur exit, must +; BUILD — not only `check`. The let-lowering in codegen calls +; `synth_arg_type` on every let value to replay its AILang type; that +; type-replay walk must descend through `Term::Loop` with the loop's +; binders in scope, exactly as it already does for `Term::Let`. +; +; The fieldtest surfaced this with an owned `RawBuf` loop binder, but +; the trigger is element-type-independent: it is the `let`-bound loop +; with a binder reference reachable through the synth body-walk. A +; plain `Int` binder is the minimal autonomous reproduction. +; +; Pre-fix symptom: `ail check` ok, `ail build` -> +; Error: module `loop_let_bound_binder_ref`: def `main`: +; unknown variable: `b` +; +; Expected stdout: 3 (loop counts i 0,1,2 and returns binder b == i). +(module loop_let_bound_binder_ref + (fn main + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body + (let r + (loop (b (con Int) 0) (i (con Int) 0) + (if (app ge i 3) + b + (recur (app + i 1) (app + i 1)))) + (app print r)))))