Skip to main content

bock_codegen/
js.rs

1//! JavaScript code generator — rule-based (Tier 2) transpilation from AIR to JS.
2//!
3//! Handles all capability gaps:
4//! - Algebraic types → tagged objects: `{ _tag: "Variant", ...fields }`
5//! - Pattern matching → `switch` on `_tag` + destructuring
6//! - Effects → destructured parameter object
7//! - Ownership → erased (JS is GC)
8//! - Async → `async`/`await` (native)
9//! - Generics → erased (JS is dynamically typed)
10
11use std::collections::{HashMap, HashSet};
12use std::fmt::Write;
13use std::path::PathBuf;
14
15use bock_air::{AIRNode, AirInterpolationPart, EnumVariantPayload, NodeKind, ResultVariant};
16use bock_ast::{AssignOp, BinOp, Literal, UnaryOp, Visibility};
17use bock_errors::Span;
18use bock_types::AIRModule;
19
20use crate::error::CodegenError;
21use crate::generator::{CodeGenerator, GeneratedCode, OutputFile, SourceMap, SourceMapping};
22use crate::profile::TargetProfile;
23
24/// Runtime helpers injected at the top of any module that references
25/// `Channel.new`, `spawn`, or calls `.send` / `.recv` on a channel. Kept in
26/// an IIFE so the symbols are globally reachable without name mangling.
27const CONCURRENCY_RUNTIME_JS: &str = "\
28// ── Bock concurrency runtime ──
29const __bockChannelNew = () => {
30  const queue = [];
31  const waiters = [];
32  const ch = {
33    send(v) {
34      if (waiters.length > 0) { waiters.shift()(v); } else { queue.push(v); }
35    },
36    recv() {
37      return new Promise((resolve) => {
38        if (queue.length > 0) { resolve(queue.shift()); }
39        else { waiters.push(resolve); }
40      });
41    },
42    close() {}
43  };
44  return [ch, ch];
45};
46const __bockSpawn = (x) => x;
47";
48
49/// Runtime helpers for Bock range expressions (`0..n` / `0..=n`), injected at
50/// the top of any module that uses a `Range`. JS has no native range value, so
51/// `for i in 0..n` lowers to `for (const i of range(0, n))`; these define
52/// `range`/`rangeInclusive` as eager `Array` builders matching Bock's
53/// half-open (`range`) and inclusive (`rangeInclusive`) bound semantics — the
54/// same semantics Python's `range(lo, hi)` / `range(lo, hi + 1)` and Rust's
55/// `lo..hi` / `lo..=hi` produce. Emitted once into the shared `_bock_runtime.js`
56/// (per-module path) or inlined at most once (single-module path), gated on a
57/// ctx flag (mirrors the concurrency runtime).
58const RANGE_RUNTIME_JS: &str = "\
59// ── Bock range runtime ──
60const range = (lo, hi) => { const r = []; for (let i = lo; i < hi; i++) r.push(i); return r; };
61const rangeInclusive = (lo, hi) => { const r = []; for (let i = lo; i <= hi; i++) r.push(i); return r; };
62";
63
64/// True if the module references a `Range` node anywhere (so the range runtime
65/// prelude must be emitted). A cheap structural scan over the debug rendering,
66/// mirroring [`EmitCtx::module_uses_concurrency`]. `RangePat` (a match-arm range
67/// pattern) does not contain the `Range {` substring, so it is not matched —
68/// the helpers are only needed for range *values*.
69fn js_module_uses_range(items: &[AIRNode]) -> bool {
70    items.iter().any(|n| format!("{n:?}").contains("Range {"))
71}
72
73/// Runtime helper for DQ29 structural equality (`==`/`!=` on records, enums,
74/// tuples, `List`/`Map`/`Set`, `Optional`/`Result`, and bounded generics): JS
75/// `===` on two objects is reference identity, so every stamped equality (see
76/// [`crate::generator::user_eq_kind`], lanes `"structural"`/`"deep"`/
77/// `"deep_custom"`/`"generic"`) lowers to `__bockEq(a, b)` instead.
78///
79/// The `"deep_custom"` lane (DQ31: a container whose element tree carries a
80/// custom `impl Equatable`) needs no special handling here — `__bockEq` already
81/// dispatches through an element's `eq` method when present (the `a.eq` check
82/// below), so the custom element equality is honored inside the collection,
83/// including `Map` key-matching and `Set` membership.
84///
85/// Semantics:
86/// - non-objects fall through to `===` — which keeps the IEEE `NaN !== NaN`
87///   Float behavior (the DQ10 caveat) and native string/number/boolean
88///   equality;
89/// - a value carrying an `eq` method (an explicit `impl Equatable` attached to
90///   the prototype) dispatches through it, so custom equality is honored even
91///   for elements *inside* collections;
92/// - arrays (Bock `List` and tuples) compare element-wise;
93/// - `Map`/`Set` compare by content, ORDER-INDEPENDENTLY, with a fast path via
94///   native key lookup (primitive keys) and a deep-equality scan fallback;
95/// - everything else (records, tagged enum/Optional/Result objects) compares
96///   by own enumerable properties, which includes the `_tag` discriminant.
97///
98/// Emitted once into the shared `_bock_runtime.js` (per-module path) or
99/// inlined at most once (single-module path), gated on a ctx flag (mirrors the
100/// range runtime).
101const EQ_RUNTIME_JS: &str = "\
102// ── Bock structural equality runtime ──
103const __bockEq = (a, b) => {
104  if (a === b) return true;
105  if (typeof a !== \"object\" || typeof b !== \"object\" || a === null || b === null) {
106    return a === b;
107  }
108  if (typeof a.eq === \"function\" && typeof b.eq === \"function\") return a.eq(a, b);
109  if (Array.isArray(a)) {
110    if (!Array.isArray(b) || a.length !== b.length) return false;
111    for (let i = 0; i < a.length; i++) { if (!__bockEq(a[i], b[i])) return false; }
112    return true;
113  }
114  if (a instanceof Map) {
115    if (!(b instanceof Map) || a.size !== b.size) return false;
116    for (const [k, v] of a) {
117      if (b.has(k)) { if (!__bockEq(b.get(k), v)) return false; continue; }
118      let found = false;
119      for (const [bk, bv] of b) { if (__bockEq(k, bk) && __bockEq(v, bv)) { found = true; break; } }
120      if (!found) return false;
121    }
122    return true;
123  }
124  if (a instanceof Set) {
125    if (!(b instanceof Set) || a.size !== b.size) return false;
126    for (const x of a) {
127      if (b.has(x)) continue;
128      let found = false;
129      for (const y of b) { if (__bockEq(x, y)) { found = true; break; } }
130      if (!found) return false;
131    }
132    return true;
133  }
134  const ka = Object.keys(a);
135  const kb = Object.keys(b);
136  if (ka.length !== kb.length) return false;
137  for (const k of ka) { if (!__bockEq(a[k], b[k])) return false; }
138  return true;
139};
140";
141
142/// True if the module contains an equality that must lower through
143/// [`EQ_RUNTIME_JS`]'s `__bockEq`: a `BinaryOp` the checker stamped with a
144/// non-`"impl"` [`bock_types::checker::USER_EQ_META_KEY`] lane, or an
145/// `a.eq(b)` bridge call on an `Equatable`-bounded generic receiver (whose
146/// instantiation may be a record). Cheap debug-rendering scan, mirroring
147/// [`js_module_uses_range`]. The `"impl"` lane dispatches through the type's
148/// own `eq` method and needs no helper.
149fn js_module_uses_eq(items: &[AIRNode]) -> bool {
150    items.iter().any(|n| {
151        let dbg = format!("{n:?}");
152        dbg.contains("\"user_eq\": String(\"structural\")")
153            || dbg.contains("\"user_eq\": String(\"deep\")")
154            || dbg.contains("\"user_eq\": String(\"deep_custom\")")
155            || dbg.contains("\"user_eq\": String(\"generic\")")
156            || dbg.contains("TraitBound:Equatable")
157    })
158}
159
160/// The structural comparison runtime: lower a bounded `T: Comparable`'s
161/// `a.compare(b)` through this so it dispatches to the instantiated type's own
162/// `compare` method when present (a record/enum carrying an `impl Comparable`),
163/// falling back to the native `<`/`===` ternary for a primitive instantiation
164/// (where `compare` is the intrinsic ordering). Without it the bounded bridge
165/// emitted the native ternary unconditionally, so `(a) < (b)` on two RECORDS is
166/// always `false` (object `<` coerces to `NaN`) and `(a) === (b)` is reference
167/// identity — every comparison wrongly returned `Greater`, silently mis-ordering
168/// `max`/`min`/sort over a user `Comparable` type. The emitted `compare` method
169/// takes `(self, other)` (the desugared self-call duplicates the receiver), so
170/// the helper calls `a.compare(a, b)`. Mirrors [`EQ_RUNTIME_JS`]'s `__bockEq`.
171/// (Q-bounded-comparable-codegen.)
172const COMPARE_RUNTIME_JS: &str = "\
173// ── Bock structural comparison runtime ──
174const __bockCompare = (a, b) => {
175  if (a !== null && typeof a === \"object\" && typeof a.compare === \"function\") {
176    return a.compare(a, b);
177  }
178  return (a < b ? { _tag: \"Less\" } : (a === b ? { _tag: \"Equal\" } : { _tag: \"Greater\" }));
179};
180";
181
182/// True if the module contains a bounded `T: Comparable` `compare` bridge call
183/// (whose instantiation may be a record), so [`COMPARE_RUNTIME_JS`]'s
184/// `__bockCompare` must be emitted. Mirrors [`js_module_uses_eq`]. The
185/// `TraitBound:Comparable` recv-kind tag is the checker's signal that a
186/// `.compare(..)` dispatches through the generic bound rather than a concrete
187/// type.
188fn js_module_uses_compare(items: &[AIRNode]) -> bool {
189    items.iter().any(|n| {
190        let dbg = format!("{n:?}");
191        dbg.contains("TraitBound:Comparable")
192    })
193}
194
195/// The display-string runtime: lower a `${expr}` interpolation part through this
196/// so a user value with a `Displayable` impl (its emitted `to_string` method) is
197/// rendered via that method, not the structural `[object Object]`. For a
198/// primitive / array / plain object the helper falls back to native `String(x)`,
199/// matching what a bare `${x}` template substitution produced before. The user
200/// `to_string` is emitted as `T.prototype.to_string = function(self) { … }`
201/// (snake_case, distinct from JS's built-in `toString`), so the helper detects
202/// it by `typeof x.to_string === "function"` and calls `x.to_string(x)` (the
203/// receiver is passed both as the JS `this` and as the explicit `self` arg).
204/// (Q-displayable-interpolation-dispatch.)
205const STR_RUNTIME_JS: &str = "// ── Bock display-string runtime ──
206const __bockStr = (x) => {
207  if (x !== null && typeof x === \"object\" && typeof x.to_string === \"function\") {
208    return x.to_string(x);
209  }
210  return String(x);
211};
212";
213
214/// True if the module contains a string interpolation (`${expr}`), so
215/// [`STR_RUNTIME_JS`]'s `__bockStr` must be emitted. Mirrors
216/// [`js_module_uses_eq`]. Conservative: any module with an `Interpolation` node
217/// gets the helper; a module that only interpolates primitives still links a
218/// tiny dead helper (`#![allow(unused)]`-style — JS tree-shaking / harmless).
219fn js_module_uses_str(items: &[AIRNode]) -> bool {
220    items.iter().any(|n| {
221        let dbg = format!("{n:?}");
222        dbg.contains("Interpolation")
223    })
224}
225
226/// The shared per-module runtime module name (without extension). In the
227/// per-module (native-import) emission path the concurrency and range runtime
228/// helpers live in one file — `_bock_runtime.js` at the build root — and every
229/// emitted module imports the named helpers it references (`__bockChannelNew`,
230/// `range`, …). A single shared definition avoids redeclaring `const
231/// __bockChannelNew` / `const range` across files (a duplicate top-level
232/// `const` is a redeclaration error in an ES module).
233const RUNTIME_MODULE_JS: &str = "_bock_runtime";
234
235/// JavaScript code generator implementing the `CodeGenerator` trait.
236#[derive(Debug)]
237pub struct JsGenerator {
238    profile: TargetProfile,
239}
240
241impl JsGenerator {
242    /// Creates a new JavaScript code generator.
243    #[must_use]
244    pub fn new() -> Self {
245        Self {
246            profile: TargetProfile::javascript(),
247        }
248    }
249}
250
251impl Default for JsGenerator {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257impl CodeGenerator for JsGenerator {
258    fn target(&self) -> &TargetProfile {
259        &self.profile
260    }
261
262    fn generate_module(&self, module: &AIRModule) -> Result<GeneratedCode, CodegenError> {
263        // Shared pre-pass: hoist value-position diverging control flow into
264        // declare-then-assign temp blocks so the diverging arms emit as
265        // statements rather than `/* unsupported */`.
266        let module =
267            &crate::generator::hoist_value_cf(crate::generator::lower_blanket_into(module.clone()));
268        let mut ctx = EmitCtx::new();
269        ctx.enum_variants =
270            crate::generator::collect_enum_variants(&[(module, std::path::Path::new(""))]);
271        ctx.trait_decls =
272            crate::generator::collect_trait_decls(&[(module, std::path::Path::new(""))]);
273        ctx.class_fields =
274            crate::generator::collect_class_fields(&[(module, std::path::Path::new(""))]);
275        ctx.const_names =
276            crate::generator::collect_const_names(&[(module, std::path::Path::new(""))]);
277        ctx.emit_node(module)?;
278        let (content, mappings) = ctx.finish();
279        let source_map = SourceMap {
280            generated_file: String::new(),
281            mappings,
282            ..Default::default()
283        };
284        Ok(GeneratedCode {
285            files: vec![OutputFile {
286                path: PathBuf::new(),
287                content,
288                source_map: Some(source_map),
289            }],
290        })
291    }
292
293    fn entry_invocation(&self, main_is_async: bool) -> Option<String> {
294        if main_is_async {
295            // Wrap in an async IIFE so top-level await isn't required — keeps
296            // the generated script runnable as both an ES module and a script.
297            Some("(async () => { await main(); })();\n".to_string())
298        } else {
299            Some("main();\n".to_string())
300        }
301    }
302
303    /// Emit a per-module **native ES-module import tree** (spec §20.6.1; DQ19
304    /// resolved): each module the entry program reaches through a real `use` is
305    /// emitted to its **own** `.js` file, and cross-module references resolve
306    /// through real ESM `import { … } from "./…"`. This is the sole `bock build`
307    /// output path.
308    ///
309    /// Output-path mapping is keyed on each module's *declared* path, not its
310    /// on-disk source path, so the file layout and the import specifier agree:
311    /// `module core.option` ⇒ `core/option.js` and `import … from
312    /// "./core/option.js"`. The **entry** module (the one declaring `main`, else
313    /// the last in dependency order) is always emitted as `main.js` so the run
314    /// model is stable.
315    ///
316    /// To run under `node main.js`, the emitted tree is ESM (relative specifiers
317    /// carry `.js`, declarations use `export`); the minimal `package.json`
318    /// `{"type":"module"}` run affordance — which makes Node treat the `.js`
319    /// files as ES modules — is emitted by the **scaffolder** in project mode
320    /// (S6a / DV18), not by codegen, so `--source-only` output is bare source.
321    /// The concurrency and range runtime
322    /// helpers are emitted **once** into a shared `_bock_runtime.js`
323    /// (see `RUNTIME_MODULE_JS`); every module that references one imports the
324    /// helpers it needs.
325    fn generate_project(
326        &self,
327        modules: &[(&AIRModule, &std::path::Path)],
328    ) -> Result<GeneratedCode, CodegenError> {
329        // Shared pre-pass: hoist value-position diverging control flow (see
330        // `hoist_value_cf`) on every module before any registry collection or
331        // emission, so all targets emit valid statement-form code.
332        let hoisted: Vec<(AIRModule, &std::path::Path)> = modules
333            .iter()
334            .map(|(m, p)| {
335                (
336                    crate::generator::hoist_value_cf(crate::generator::lower_blanket_into(
337                        (*m).clone(),
338                    )),
339                    *p,
340                )
341            })
342            .collect();
343        let modules: Vec<(&AIRModule, &std::path::Path)> =
344            hoisted.iter().map(|(m, p)| (m, *p)).collect();
345        let modules = modules.as_slice();
346        // Emit only modules the entry program actually `use`s (plus the entry
347        // itself), dependency-ordered — never the prelude-only stdlib.
348        let reachable = crate::generator::reachable_modules(modules);
349        let modules = reachable.as_slice();
350        if modules.is_empty() {
351            return Ok(GeneratedCode { files: vec![] });
352        }
353
354        // The entry module names `main.js`; every other module is placed at the
355        // path mirrored from its declared module-path.
356        let entry_idx = modules
357            .iter()
358            .position(|(m, _)| crate::generator::module_declares_main_fn(m))
359            .unwrap_or(modules.len() - 1);
360
361        // Registries collected across the whole reachable set so a reference in
362        // one file to a type declared in another lowers identically to bundling.
363        let enum_variants = crate::generator::collect_enum_variants(modules);
364        let trait_decls = crate::generator::collect_trait_decls(modules);
365        let record_names = crate::generator::collect_record_names(modules);
366        let class_fields = crate::generator::collect_class_fields(modules);
367        let const_names = crate::generator::collect_const_names(modules);
368        let public_symbols = crate::generator::collect_public_symbols_for_esm(modules);
369        // Program-wide field/method name-collision set (camelCased). Built across
370        // *all* reachable modules so a call site in `main.js` to a renamed method
371        // declared in `core/error.js` agrees with that declaration — the method
372        // and its cross-module call sites must rename identically.
373        let mut field_method_collisions = HashSet::new();
374        for (module, _) in modules {
375            field_method_collisions.extend(crate::generator::collect_record_field_names(
376                module,
377                to_camel_case,
378            ));
379        }
380
381        let main_is_async = modules
382            .iter()
383            .any(|(m, _)| crate::generator::module_main_fn_is_async(m));
384        let invocation = self.entry_invocation(main_is_async);
385
386        let mut files: Vec<OutputFile> = Vec::with_capacity(modules.len() + 2);
387        let mut runtime_concurrency = false;
388        let mut runtime_range = false;
389        let mut runtime_eq = false;
390        let mut runtime_compare = false;
391        let mut runtime_str = false;
392
393        for (i, (module, source_path)) in modules.iter().enumerate() {
394            let own_path = crate::generator::module_path_string(module).unwrap_or_default();
395            let mut ctx = EmitCtx::new();
396            ctx.per_module = true;
397            ctx.enum_variants = enum_variants.clone();
398            ctx.trait_decls = trait_decls.clone();
399            // Record names need the whole reachable set so a cross-module record
400            // construction (`use`d from another module) lowers to `new Name(...)`
401            // rather than a bare object literal that drops its prototype methods.
402            ctx.record_names = record_names.clone();
403            // Class field-orders need the whole reachable set too: a cross-module
404            // `class` construction must lower to its positional `new Name(...)`.
405            ctx.class_fields = class_fields.clone();
406            ctx.const_names = const_names.clone();
407            // Program-wide collision set so renamed methods and their
408            // cross-module call sites agree (see above).
409            ctx.field_method_collisions = field_method_collisions.clone();
410            // Effect-op resolution needs the whole reachable set: a bare op in
411            // one module may belong to an effect declared in another.
412            ctx.seed_effect_registries(modules);
413            // The entry file is always `main.js` at the root, so it imports
414            // siblings as if it lived at the root regardless of its declared
415            // path — pass the empty self-path for the relative-specifier base.
416            ctx.self_module_path = if i == entry_idx {
417                String::new()
418            } else {
419                own_path.clone()
420            };
421            ctx.implicit_imports =
422                crate::generator::implicit_esm_imports_for(module, &public_symbols, &own_path);
423            ctx.public_symbols = public_symbols.clone();
424            ctx.export_names = crate::generator::exportable_value_names(module);
425            ctx.emit_node(module)?;
426            runtime_concurrency |= ctx.needs_runtime_concurrency;
427            runtime_range |= ctx.needs_runtime_range;
428            runtime_eq |= ctx.needs_runtime_eq;
429            runtime_compare |= ctx.needs_runtime_compare;
430            runtime_str |= ctx.needs_runtime_str;
431            let (mut content, mappings) = ctx.finish();
432
433            // The entry file gets the `main()` invocation appended exactly once.
434            if i == entry_idx && crate::generator::module_declares_main_fn(module) {
435                if let Some(invoc) = invocation.as_ref() {
436                    if !content.is_empty() && !content.ends_with('\n') {
437                        content.push('\n');
438                    }
439                    content.push_str(invoc);
440                }
441            }
442
443            let out_path = self.module_output_path(module, source_path, i == entry_idx);
444            let generated_file = out_path
445                .file_name()
446                .and_then(|s| s.to_str())
447                .unwrap_or("")
448                .to_string();
449            files.push(OutputFile {
450                path: out_path,
451                content,
452                source_map: Some(SourceMap {
453                    generated_file,
454                    mappings,
455                    ..Default::default()
456                }),
457            });
458        }
459
460        // Shared runtime module with exactly the helpers referenced, each
461        // `export`ed so consuming modules can `import { … }` them.
462        if runtime_concurrency || runtime_range || runtime_eq || runtime_compare || runtime_str {
463            let mut content = String::new();
464            if runtime_concurrency {
465                content.push_str(&export_runtime_consts(CONCURRENCY_RUNTIME_JS));
466                content.push('\n');
467            }
468            if runtime_range {
469                content.push_str(&export_runtime_consts(RANGE_RUNTIME_JS));
470                content.push('\n');
471            }
472            if runtime_eq {
473                content.push_str(&export_runtime_consts(EQ_RUNTIME_JS));
474                content.push('\n');
475            }
476            if runtime_compare {
477                content.push_str(&export_runtime_consts(COMPARE_RUNTIME_JS));
478                content.push('\n');
479            }
480            if runtime_str {
481                content.push_str(&export_runtime_consts(STR_RUNTIME_JS));
482                content.push('\n');
483            }
484            files.push(OutputFile {
485                path: PathBuf::from(format!("{RUNTIME_MODULE_JS}.js")),
486                content,
487                source_map: Some(SourceMap {
488                    generated_file: format!("{RUNTIME_MODULE_JS}.js"),
489                    ..Default::default()
490                }),
491            });
492        }
493
494        // Run-affordance emission moved to the project-mode scaffolder (S6a /
495        // DV18): codegen emits only the per-module `.js` *source* tree in all
496        // modes; the `package.json` `{"type":"module"}` run affordance is
497        // emitted by `JsScaffolder` in project mode only (never under
498        // `--source-only`). See `scaffold.rs`.
499
500        Ok(GeneratedCode { files })
501    }
502
503    /// Transpile `@test` functions into a Vitest/Jest `bock.test.js` file (S7).
504    ///
505    /// `framework` selects the idiom: `"jest"` uses Jest's global `describe`/
506    /// `it`/`expect`; anything else uses Vitest (`import { describe, it, expect }
507    /// from "vitest"`). Both share the `expect(actual).toEqual/toBe(...)` API, so
508    /// the per-assertion lowering is identical; only the framework import differs.
509    /// The functions under test are imported by name from their emitted modules.
510    fn generate_tests(
511        &self,
512        modules: &[(&AIRModule, &std::path::Path)],
513        framework: &str,
514    ) -> Result<crate::generator::TestArtifacts, CodegenError> {
515        crate::generator::js_ts_generate_tests(
516            modules,
517            framework,
518            &self.target().conventions.file_extension,
519            // JS imports the emitted `.js` siblings directly.
520            "js",
521            |module, source_path, is_entry| self.module_output_path(module, source_path, is_entry),
522            |ctx_modules| {
523                let enum_variants = crate::generator::collect_enum_variants(ctx_modules);
524                let trait_decls = crate::generator::collect_trait_decls(ctx_modules);
525                let record_names = crate::generator::collect_record_names(ctx_modules);
526                let class_fields = crate::generator::collect_class_fields(ctx_modules);
527                let const_names = crate::generator::collect_const_names(ctx_modules);
528                let mut field_method_collisions = HashSet::new();
529                for (module, _) in ctx_modules {
530                    field_method_collisions.extend(crate::generator::collect_record_field_names(
531                        module,
532                        to_camel_case,
533                    ));
534                }
535                let mut ctx = EmitCtx::new();
536                ctx.per_module = true;
537                ctx.enum_variants = enum_variants;
538                ctx.trait_decls = trait_decls;
539                ctx.record_names = record_names;
540                ctx.class_fields = class_fields;
541                ctx.const_names = const_names;
542                ctx.field_method_collisions = field_method_collisions;
543                ctx.seed_effect_registries(ctx_modules);
544                Box::new(JsTestEmitter { ctx })
545            },
546        )
547    }
548}
549
550/// Adapter wrapping a JS [`EmitCtx`] so the shared js/ts test-file builder
551/// ([`crate::generator::js_ts_generate_tests`]) can drive expression lowering
552/// without depending on the concrete (private) emit-context type.
553struct JsTestEmitter {
554    ctx: EmitCtx,
555}
556
557impl crate::generator::JsTsExprEmitter for JsTestEmitter {
558    fn expr_to_string(&mut self, node: &AIRNode) -> Result<String, CodegenError> {
559        self.ctx.expr_to_string(node)
560    }
561}
562
563impl JsGenerator {
564    /// Output path for one module in the per-module native-import tree.
565    ///
566    /// The entry module is always `main.js` (mirrored from its source path) so
567    /// the run model `node main.js` is stable. Every other module is placed at
568    /// the path mirrored from its **declared** module-path so the file location
569    /// and the relative import specifier agree:
570    /// `module core.option` ⇒ `core/option.js`. A module without a declared path
571    /// falls back to its source-mirrored path.
572    fn module_output_path(
573        &self,
574        module: &AIRModule,
575        source_path: &std::path::Path,
576        is_entry: bool,
577    ) -> PathBuf {
578        if is_entry {
579            return crate::generator::derive_output_path(source_path, self.target());
580        }
581        match crate::generator::module_path_string(module) {
582            Some(path) if !path.is_empty() => {
583                let rel: PathBuf = path.split('.').collect();
584                rel.with_extension(&self.target().conventions.file_extension)
585            }
586            _ => crate::generator::derive_output_path(source_path, self.target()),
587        }
588    }
589}
590
591/// Rewrite a runtime-prelude string (a sequence of top-level `const NAME = …`
592/// definitions, see [`CONCURRENCY_RUNTIME_JS`] / [`RANGE_RUNTIME_JS`]) so each
593/// top-level `const` becomes an `export const`. Used to build the shared
594/// `_bock_runtime.js`, whose helpers consuming modules import by name. Only
595/// lines that *begin* a top-level `const` (column 0) are rewritten; nested
596/// `const`s inside a helper body are indented and so left untouched.
597fn export_runtime_consts(runtime: &str) -> String {
598    runtime
599        .lines()
600        .map(|line| {
601            if let Some(rest) = line.strip_prefix("const ") {
602                format!("export const {rest}")
603            } else {
604                line.to_string()
605            }
606        })
607        .collect::<Vec<_>>()
608        .join("\n")
609}
610
611// ─── Emission context ────────────────────────────────────────────────────────
612
613/// Internal state for JS emission.
614struct EmitCtx {
615    buf: String,
616    indent: usize,
617    /// Maps effect operation name → effect type name (e.g., "log" → "Logger").
618    effect_ops: HashMap<String, String>,
619    /// Maps effect type name → current handler variable name in scope.
620    current_handler_vars: HashMap<String, String>,
621    /// Maps function name → effect type names from its `with` clause.
622    fn_effects: HashMap<String, Vec<String>>,
623    /// Maps composite effect name → component effect names.
624    composite_effects: HashMap<String, Vec<String>>,
625    /// Names of records declared in this module (emitted as classes).
626    record_names: HashSet<String>,
627    /// Names of `class` declarations mapped to their **field names in
628    /// declaration order**, pre-scanned across the reachable program. A Bock
629    /// `class` emits a *positional* `constructor(a, b)` (unlike a record's
630    /// destructured `constructor({ a, b })`), so a `class` literal `T { a: x, b:
631    /// y }` must lower to `new T(x, y)` with arguments ordered by the declared
632    /// field order — not the bare object literal the record path emits (whose
633    /// prototype methods would be unreachable). See
634    /// [`crate::generator::collect_class_fields`].
635    class_fields: HashMap<String, Vec<String>>,
636    /// Declared names of module-scope `const`s, pre-scanned across the reachable
637    /// program. A const identifier is emitted verbatim at both its declaration
638    /// and every use so the two agree (the `to_camel_case` transform would
639    /// otherwise mangle a `SCREAMING_SNAKE` use site, e.g. `FIZZ_NUM` → `fizzNUM`,
640    /// against the verbatim-emitted definition). See [`crate::generator::collect_const_names`].
641    const_names: HashSet<String>,
642    /// 1-indexed current line in `buf`, maintained incrementally.
643    cur_line: u32,
644    /// 1-indexed current column (char count) in `buf`, maintained incrementally.
645    cur_col: u32,
646    /// Byte offset in `buf` up to which (cur_line, cur_col) is accurate.
647    scan_pos: usize,
648    /// Last (gen_line, gen_col) we recorded — avoids recording duplicates
649    /// when multiple nested nodes share the same output position.
650    last_marked: Option<(u32, u32)>,
651    /// Collected source-map entries (populated via [`Self::mark_span`]).
652    mappings: Vec<SourceMapping>,
653    /// Loop-label stack — see [`crate::generator::loop_needs_break_label`]. In
654    /// JS, `break` inside a `switch` exits the switch, so a statement-arm
655    /// `match` (lowered to a `switch`) that wants to `break`/`continue` an
656    /// enclosing loop must use a labelled jump. `Some` once a label is
657    /// allocated for a loop; only allocated labels are emitted.
658    loop_labels: Vec<Option<String>>,
659    /// Depth of statement-arm `switch` emission; when > 0, `break`/`continue`
660    /// target the innermost labelled loop rather than the switch.
661    switch_label_depth: usize,
662    /// Monotonic counter for unique loop-label names.
663    loop_label_counter: usize,
664    /// Monotonic counter for unique `match` scrutinee temporaries. A non-trivial
665    /// scrutinee (a call, etc.) is hoisted into `const __matchN = <scrutinee>;`
666    /// once, so it is evaluated a single time. Re-emitting the scrutinee inline
667    /// in every arm (the prior behavior) double-evaluated it — a real bug for a
668    /// scrutinee with side effects, e.g. a stateful iterator's `match next(it)`.
669    match_temp_counter: usize,
670    /// Set once the concurrency runtime prelude has been emitted in the
671    /// single-module self-contained path ([`JsGenerator::generate_module`]), so
672    /// a module that references the runtime more than once still inlines it at
673    /// most once. (The per-module project path emits the runtime once into the
674    /// shared `_bock_runtime.js` instead.)
675    concurrency_runtime_emitted: bool,
676    /// Set once the range runtime prelude ([`RANGE_RUNTIME_JS`]) has been
677    /// emitted in the single-module self-contained path, so the
678    /// `range`/`rangeInclusive` helpers inline at most once (a duplicate `const
679    /// range` would be a redeclaration error). Deduped exactly as
680    /// [`Self::concurrency_runtime_emitted`].
681    range_runtime_emitted: bool,
682    /// Set once the structural-equality runtime ([`EQ_RUNTIME_JS`]) has been
683    /// emitted in the single-module self-contained path. Deduped exactly as
684    /// [`Self::range_runtime_emitted`].
685    eq_runtime_emitted: bool,
686    /// Set once the structural-comparison runtime ([`COMPARE_RUNTIME_JS`]) has
687    /// been emitted in the single-module self-contained path. Deduped exactly as
688    /// [`Self::eq_runtime_emitted`]. (Q-bounded-comparable-codegen.)
689    compare_runtime_emitted: bool,
690    /// Set once the display-string runtime ([`STR_RUNTIME_JS`]) has been emitted
691    /// in the single-module self-contained path. Deduped exactly as
692    /// [`Self::eq_runtime_emitted`]. (Q-displayable-interpolation-dispatch.)
693    str_runtime_emitted: bool,
694    /// User-enum-variant registry (DV14). Maps a variant name to its enum so a
695    /// unit-variant reference lowers to the frozen `{enum}_{variant}` const, a
696    /// struct/tuple construction lowers to the `{enum}_{variant}(..)` factory,
697    /// and a `match` recognises struct-payload (`RecordPat`) arms as ADT
698    /// variants. Pre-scanned across the reached modules. The built-in
699    /// Optional/Result pre-seeds are filtered out where bespoke lowering applies.
700    enum_variants: crate::generator::EnumVariantRegistry,
701    /// Trait-declaration registry. Used at each `impl Trait for Type` site to
702    /// recover the trait's *default* methods (those carrying a body) so they can
703    /// be attached to the target's prototype alongside the impl's own methods —
704    /// a type relying on an inherited default would otherwise have no such
705    /// method. Pre-scanned across the reached modules (mirrors
706    /// [`Self::enum_variants`]).
707    trait_decls: crate::generator::TraitDeclRegistry,
708    /// True in the **per-module native-import** emission path
709    /// ([`JsGenerator::generate_project`], the sole real-build path). When set,
710    /// the `Module` arm emits real ESM `import { … } from "./…"` for
711    /// cross-module references, records which shared-runtime helpers the module
712    /// needs (instead of inlining them), and a trailing `export { … }`
713    /// re-exports the module's public non-function declarations (functions
714    /// export inline). When clear, the module is emitted as a single
715    /// self-contained file with its runtime preludes inlined — the
716    /// [`JsGenerator::generate_module`] path used by unit tests.
717    per_module: bool,
718    /// In the per-module path, records that this module references the
719    /// concurrency runtime (`Channel`/`spawn`) — so `generate_project` emits the
720    /// concurrency helpers into the shared `_bock_runtime.js` and this module
721    /// imports the names it needs from it (rather than inlining the prelude,
722    /// which would redeclare `const __bockChannelNew` across files).
723    needs_runtime_concurrency: bool,
724    /// As [`Self::needs_runtime_concurrency`], for the range runtime
725    /// (`range`/`rangeInclusive`).
726    needs_runtime_range: bool,
727    /// As [`Self::needs_runtime_concurrency`], for the DQ29 structural-equality
728    /// runtime (`__bockEq`).
729    needs_runtime_eq: bool,
730    /// As [`Self::needs_runtime_concurrency`], for the bounded-`Comparable`
731    /// structural-comparison runtime (`__bockCompare`). Set when this module
732    /// lowers a `T: Comparable` `compare` bridge call.
733    /// (Q-bounded-comparable-codegen.)
734    needs_runtime_compare: bool,
735    /// As [`Self::needs_runtime_concurrency`], for the display-string runtime
736    /// (`__bockStr`). Set when this module emits a `${expr}` interpolation.
737    /// (Q-displayable-interpolation-dispatch.)
738    needs_runtime_str: bool,
739    /// Implicit cross-module imports for the per-module path — names this module
740    /// references but neither declares locally nor imports via an explicit `use`
741    /// (e.g. a §18.2-prelude trait used as a base in an `impl`). Computed in
742    /// `generate_project`; emitted as ESM imports by the `Module` arm.
743    implicit_imports: Vec<crate::generator::ImplicitEsmImport>,
744    /// Map of every reachable public symbol → its declaring module + kind, used
745    /// to spell an explicit `use`d name the way its declaration emits it (a
746    /// function is camelCased; other kinds keep their raw name).
747    public_symbols: HashMap<String, crate::generator::EsmSymbol>,
748    /// The declared dotted module-path of the file currently being emitted
749    /// (`core.option`), or empty for the entry file (always `main.js` at the
750    /// build root). Drives the relative-specifier computation for every emitted
751    /// `import`.
752    self_module_path: String,
753    /// Public, exportable value declarations this module declares — listed in
754    /// the trailing `export { … }` of the per-module file. Functions are skipped
755    /// there (they export inline via `emit_fn_decl`); every other kind (records,
756    /// enums + variants, traits, classes, effects, consts) is re-exported.
757    /// Computed in `generate_project`.
758    export_names: Vec<crate::generator::EsmExport>,
759    /// Camel-cased record/class field names in the module being emitted, used to
760    /// disambiguate a method whose camelCased name collides with a field name
761    /// (`core.error`'s `message` field + `message()` method). A JS instance
762    /// field shadows a same-named prototype method, so the *method* is renamed
763    /// (`messageMethod`) at its prototype attachment, its class-body emission,
764    /// and every call site via [`Self::js_method_name`]; the field keeps its
765    /// name. Populated at the start of the `Module` arm (shared collector with
766    /// go/ts/py).
767    field_method_collisions: HashSet<String>,
768    /// Per-JS-lexical-block stack of simple `let`/`const` binding state. Bock
769    /// permits re-binding (`let x = …; let x = …`) which shadows the prior
770    /// binding in the same scope; JS `const`/`let` forbid re-declaration in one
771    /// block scope. Each frame records the JS idents already declared in the
772    /// block and, of those, which need `let` (because they are re-bound or
773    /// assigned later). The first declaration of a re-bound name uses `let`;
774    /// every subsequent binding of the same name emits a plain assignment
775    /// (`x = …`) rather than a redeclaration. See [`LetScope`].
776    let_scopes: Vec<LetScope>,
777    /// Monotonic counter for unique `?`-propagation temporaries (`__try0`,
778    /// `__try1`, …). See [`EmitCtx::hoist_propagates`].
779    propagate_temp_counter: usize,
780    /// Maps a `Propagate` node (keyed by its `&AIRNode` address) to the JS temp
781    /// that [`EmitCtx::hoist_propagates`] bound its unwrapped payload into. The
782    /// `Propagate` arm of [`EmitCtx::emit_expr`] reads this to emit `<temp>._0`
783    /// in place of the wrapped value. The address is stable across the
784    /// pre-statement hoist walk and the subsequent expression emission because
785    /// both traverse the *same* borrowed AIR node tree.
786    propagate_temps: HashMap<usize, String>,
787    /// When set, a block-body tail expression is **discarded** — emitted as a
788    /// bare expression statement (`<value>;`) rather than `return <value>;`. A
789    /// loop body (`for`/`while`/`loop`) and a statement-position `if`/`match`
790    /// arm are statement context: their tail is not the enclosing function's
791    /// value, so `return`ing it would abort the function on the first iteration
792    /// (e.g. `for (…) { return console.log(i); }` exits `main` after one line).
793    /// Set for the duration of a loop body via [`EmitCtx::emit_loop_body`] and
794    /// for a block's non-tail statements in [`EmitCtx::emit_block_body_inner`];
795    /// cleared (saved/restored) when entering a genuine value context — a
796    /// lambda body, a value-position block/`match` IIFE — so their tail still
797    /// `return`s the body's value. See the TS backend's `ValueSink::Discard`
798    /// (#240) for the mirror of this design.
799    discard_tail: bool,
800}
801
802/// One JS lexical block's `let`/`const` binding state — see
803/// [`EmitCtx::let_scopes`].
804#[derive(Default)]
805struct LetScope {
806    /// Simple JS idents already emitted as a declaration in this block.
807    declared: HashSet<String>,
808    /// Of the block's simple bindings, those that are re-bound or assigned and
809    /// so must be declared with `let` (not `const`) at their first declaration.
810    needs_let: HashSet<String>,
811}
812
813impl EmitCtx {
814    fn new() -> Self {
815        Self {
816            buf: String::with_capacity(4096),
817            indent: 0,
818            effect_ops: HashMap::new(),
819            current_handler_vars: HashMap::new(),
820            fn_effects: HashMap::new(),
821            composite_effects: HashMap::new(),
822            record_names: HashSet::new(),
823            class_fields: HashMap::new(),
824            const_names: HashSet::new(),
825            cur_line: 1,
826            cur_col: 1,
827            scan_pos: 0,
828            last_marked: None,
829            mappings: Vec::new(),
830            loop_labels: Vec::new(),
831            switch_label_depth: 0,
832            loop_label_counter: 0,
833            match_temp_counter: 0,
834            concurrency_runtime_emitted: false,
835            range_runtime_emitted: false,
836            eq_runtime_emitted: false,
837            compare_runtime_emitted: false,
838            str_runtime_emitted: false,
839            enum_variants: crate::generator::EnumVariantRegistry::new(),
840            trait_decls: crate::generator::TraitDeclRegistry::new(),
841            per_module: false,
842            needs_runtime_concurrency: false,
843            needs_runtime_range: false,
844            needs_runtime_eq: false,
845            needs_runtime_compare: false,
846            needs_runtime_str: false,
847            implicit_imports: Vec::new(),
848            public_symbols: HashMap::new(),
849            self_module_path: String::new(),
850            export_names: Vec::new(),
851            field_method_collisions: HashSet::new(),
852            let_scopes: Vec::new(),
853            propagate_temp_counter: 0,
854            propagate_temps: HashMap::new(),
855            discard_tail: false,
856        }
857    }
858
859    /// Disambiguate an *already-rendered* JS method member name against the
860    /// program's field names: when the rendered name collides with a field name,
861    /// append a `Method` suffix (`message` → `messageMethod`), else return it
862    /// unchanged.
863    ///
864    /// JS renders method names two ways — the prototype attachment and
865    /// `FieldAccess`-in-call-position use the raw Bock name, while
866    /// `emit_class_method` and the `MethodCall` arm use `to_camel_case` — so this
867    /// helper takes whatever each site already produced and only adds the suffix,
868    /// preserving each site's existing casing (a switch to camelCase would change
869    /// snake_case method names, breaking call sites that still spell them raw).
870    /// For the field/method collisions this targets (single-word field names like
871    /// `message`, where raw == camelCase) the renderings agree. Shared policy
872    /// with go/ts/py (see [`crate::generator::disambiguate_method_name`]).
873    fn js_method_name(&self, rendered: &str) -> String {
874        crate::generator::disambiguate_method_name(
875            rendered.to_string(),
876            &self.field_method_collisions,
877            "Method",
878        )
879    }
880
881    /// Returns the variant info for `path` if its last segment is a registered
882    /// user enum variant. The built-in `Optional`/`Result` pre-seeds
883    /// (`Some`/`None`/`Ok`/`Err`) are excluded — those are lowered by the
884    /// bespoke tagged-object paths (`try_emit_prelude_ctor`, the `None`
885    /// identifier special case, `ResultConstruct`), which must not change.
886    fn user_variant_for_path(
887        &self,
888        path: &bock_ast::TypePath,
889    ) -> Option<&crate::generator::EnumVariantInfo> {
890        let info = crate::generator::registered_variant(&self.enum_variants, path)?;
891        if matches!(info.enum_name.as_str(), "Optional" | "Result") {
892            return None;
893        }
894        Some(info)
895    }
896
897    /// As [`Self::user_variant_for_path`] but keyed by a bare identifier name.
898    fn user_variant_for_name(&self, name: &str) -> Option<&crate::generator::EnumVariantInfo> {
899        let info = self.enum_variants.get(name)?;
900        if matches!(info.enum_name.as_str(), "Optional" | "Result") {
901            return None;
902        }
903        Some(info)
904    }
905
906    fn finish(self) -> (String, Vec<SourceMapping>) {
907        (self.buf, self.mappings)
908    }
909
910    /// Pre-seed the effect registries (`effect_ops`, `composite_effects`) from
911    /// every module's top-level `EffectDecl`s. In the per-module path each
912    /// module is emitted by its own context, so a bare op `log(...)` used in
913    /// `main` whose effect `Log` is declared in another module would not be
914    /// recognised as an effect op (and not rewritten to `logger.log(...)`)
915    /// without pre-seeding from the whole reachable set. Mirrors how
916    /// `enum_variants` / `trait_decls` are collected across the reached modules,
917    /// and the Python backend's `PyEmitCtx::seed_effect_registries`.
918    fn seed_effect_registries(&mut self, modules: &[(&AIRModule, &std::path::Path)]) {
919        for (module, _) in modules {
920            let NodeKind::Module { items, .. } = &module.kind else {
921                continue;
922            };
923            for item in items {
924                let NodeKind::EffectDecl {
925                    name,
926                    components,
927                    operations,
928                    ..
929                } = &item.kind
930                else {
931                    continue;
932                };
933                if !components.is_empty() {
934                    let comp_names: Vec<String> = components
935                        .iter()
936                        .map(|tp| {
937                            tp.segments
938                                .last()
939                                .map_or("effect".to_string(), |s| s.name.clone())
940                        })
941                        .collect();
942                    self.composite_effects.insert(name.name.clone(), comp_names);
943                    continue;
944                }
945                for op in operations {
946                    if let NodeKind::FnDecl { name: op_name, .. } = &op.kind {
947                        self.effect_ops
948                            .insert(op_name.name.clone(), name.name.clone());
949                    }
950                }
951            }
952        }
953    }
954
955    /// Emit the per-module ESM `import` statements at the top of the file: the
956    /// shared-runtime import (concurrency / range helpers), the explicit
957    /// cross-module `use` imports, and the implicit prelude imports (e.g. a base
958    /// trait used in an `impl`). Grouped one `import { … } from "./…"` per
959    /// source module, with the relative specifier computed from this file's
960    /// declared module-path. Called once at the top of the `Module` arm in the
961    /// per-module path.
962    fn emit_esm_imports(&mut self, imports: &[AIRNode]) -> Result<(), CodegenError> {
963        // The JS backend always emits `.js` files; the relative specifier
964        // carries that extension (ESM + Node require an explicit extension).
965        let ext = "js";
966
967        // Shared runtime: `import { … } from "./…/_bock_runtime.js"`.
968        if self.needs_runtime_concurrency
969            || self.needs_runtime_range
970            || self.needs_runtime_eq
971            || self.needs_runtime_compare
972            || self.needs_runtime_str
973        {
974            let mut names: Vec<&str> = Vec::new();
975            if self.needs_runtime_concurrency {
976                names.extend(["__bockChannelNew", "__bockSpawn"]);
977            }
978            if self.needs_runtime_range {
979                names.extend(["range", "rangeInclusive"]);
980            }
981            if self.needs_runtime_eq {
982                names.push("__bockEq");
983            }
984            if self.needs_runtime_compare {
985                names.push("__bockCompare");
986            }
987            if self.needs_runtime_str {
988                names.push("__bockStr");
989            }
990            let spec = crate::generator::esm_relative_specifier(
991                &self.self_module_path,
992                RUNTIME_MODULE_JS,
993                ext,
994            );
995            self.writeln(&format!(
996                "import {{ {} }} from \"{spec}\";",
997                names.join(", ")
998            ));
999        }
1000
1001        // Explicit `use` imports → real ESM imports. A `use`d *function* is
1002        // imported under its camelCased name (matching its `export function`
1003        // form); other kinds keep their raw name. An explicit rename (`as`)
1004        // applies the same transform to the alias.
1005        for import in imports {
1006            if let NodeKind::ImportDecl { path, items } = &import.kind {
1007                let target_path = path
1008                    .segments
1009                    .iter()
1010                    .map(|s| s.name.as_str())
1011                    .collect::<Vec<_>>()
1012                    .join(".");
1013                if target_path.is_empty() {
1014                    continue;
1015                }
1016                let spec = crate::generator::esm_relative_specifier(
1017                    &self.self_module_path,
1018                    &target_path,
1019                    ext,
1020                );
1021                match items {
1022                    bock_ast::ImportItems::Named(named) => {
1023                        let rendered: Vec<String> = named
1024                            .iter()
1025                            // A `use`d runtime-prelude *value* name (`Optional`,
1026                            // `Some`, …) lowers inline and is never a real export;
1027                            // a type-only symbol (an enum *type* name, a type
1028                            // alias) has no JS binding. Neither may appear in a
1029                            // real JS import.
1030                            .filter(|n| {
1031                                if crate::generator::ESM_RUNTIME_PRELUDE_NAMES
1032                                    .contains(&n.name.name.as_str())
1033                                {
1034                                    return false;
1035                                }
1036                                match self.public_symbols.get(&n.name.name) {
1037                                    Some(s) => s.kind.is_js_value(),
1038                                    // Unknown symbol: keep it (conservative — a
1039                                    // genuinely-missing import surfaces loudly).
1040                                    None => true,
1041                                }
1042                            })
1043                            .map(|n| {
1044                                let is_fn = self
1045                                    .public_symbols
1046                                    .get(&n.name.name)
1047                                    .map(|s| s.is_fn())
1048                                    .unwrap_or(false);
1049                                let src = self.esm_emit_name(&n.name.name, is_fn);
1050                                match &n.alias {
1051                                    Some(alias) => {
1052                                        let dst = self.esm_emit_name(&alias.name, is_fn);
1053                                        if dst == src {
1054                                            src
1055                                        } else {
1056                                            format!("{src} as {dst}")
1057                                        }
1058                                    }
1059                                    None => src,
1060                                }
1061                            })
1062                            .collect();
1063                        if !rendered.is_empty() {
1064                            self.writeln(&format!(
1065                                "import {{ {} }} from \"{spec}\";",
1066                                rendered.join(", ")
1067                            ));
1068                        }
1069                    }
1070                    // `use Foo` / `use Foo.*` — Bock brings the module's exported
1071                    // names into scope unqualified. ESM has no namespace-flatten
1072                    // import; the consuming references are resolved as implicit
1073                    // imports (below) by name, so a module/glob import needs no
1074                    // statement here.
1075                    bock_ast::ImportItems::Module | bock_ast::ImportItems::Glob => {}
1076                }
1077            }
1078        }
1079
1080        // Implicit imports: prelude-visible names referenced but not explicitly
1081        // `use`d (e.g. a base trait), grouped per declaring module, deterministic.
1082        // Type-only kinds (an enum *type* name, a type alias) have no JS binding,
1083        // so they are skipped here — a JS reference to them is erased.
1084        let mut by_module: std::collections::BTreeMap<String, Vec<String>> =
1085            std::collections::BTreeMap::new();
1086        for imp in &self.implicit_imports {
1087            if !imp.kind.is_js_value() {
1088                continue;
1089            }
1090            by_module
1091                .entry(imp.module_path.clone())
1092                .or_default()
1093                .push(esm_emit_name_static(&imp.name, imp.is_fn()));
1094        }
1095        for (module_path, mut names) in by_module {
1096            names.sort_unstable();
1097            names.dedup();
1098            let spec =
1099                crate::generator::esm_relative_specifier(&self.self_module_path, &module_path, ext);
1100            self.writeln(&format!(
1101                "import {{ {} }} from \"{spec}\";",
1102                names.join(", ")
1103            ));
1104        }
1105        Ok(())
1106    }
1107
1108    /// Spell `name` the way the JS backend emits its declaration / call sites: a
1109    /// function is camelCased (and keyword-escaped) via [`js_value_ident`]; any
1110    /// other declaration kind keeps its raw name. Used so an `import`/`export`
1111    /// statement binds exactly the identifier the code references.
1112    fn esm_emit_name(&self, name: &str, is_fn: bool) -> String {
1113        esm_emit_name_static(name, is_fn)
1114    }
1115
1116    /// Emit the trailing `export { … }` for this module's public **non-function**
1117    /// declarations (records, enums + variants, traits, classes, effects,
1118    /// consts). Functions export inline via [`Self::emit_fn_decl`] and so are
1119    /// skipped here; type aliases are erased in JS. Emits nothing when there is
1120    /// nothing to re-export.
1121    fn emit_trailing_exports(&mut self) {
1122        let mut names: Vec<String> = self
1123            .export_names
1124            .iter()
1125            .filter(|e| !e.is_fn)
1126            .map(|e| esm_emit_name_static(&e.name, e.is_fn))
1127            .collect();
1128        if names.is_empty() {
1129            return;
1130        }
1131        names.sort_unstable();
1132        names.dedup();
1133        if !self.buf.is_empty() && !self.buf.ends_with('\n') {
1134            self.buf.push('\n');
1135        }
1136        self.writeln(&format!("export {{ {} }};", names.join(", ")));
1137    }
1138
1139    /// Bring `cur_line` / `cur_col` up to date with everything appended to
1140    /// `buf` since the last sync.
1141    fn sync_pos(&mut self) {
1142        if self.scan_pos >= self.buf.len() {
1143            return;
1144        }
1145        let slice = &self.buf[self.scan_pos..];
1146        for ch in slice.chars() {
1147            if ch == '\n' {
1148                self.cur_line += 1;
1149                self.cur_col = 1;
1150            } else {
1151                self.cur_col += 1;
1152            }
1153        }
1154        self.scan_pos = self.buf.len();
1155    }
1156
1157    /// Record a mapping from the current generated position to the start of
1158    /// `span`. Dedupes consecutive recordings at the same output position.
1159    fn mark_span(&mut self, span: Span) {
1160        if span.start == 0 && span.end == 0 {
1161            return;
1162        }
1163        self.sync_pos();
1164        let key = (self.cur_line, self.cur_col);
1165        if self.last_marked == Some(key) {
1166            return;
1167        }
1168        self.last_marked = Some(key);
1169        self.mappings.push(SourceMapping {
1170            gen_line: self.cur_line,
1171            gen_col: self.cur_col,
1172            src_line: 0,
1173            src_col: 0,
1174            src_offset: span.start as u32,
1175            src_file_id: span.file.0,
1176        });
1177    }
1178
1179    fn indent_str(&self) -> String {
1180        "  ".repeat(self.indent)
1181    }
1182
1183    fn write_indent(&mut self) {
1184        let indent = self.indent_str();
1185        self.buf.push_str(&indent);
1186    }
1187
1188    fn writeln(&mut self, s: &str) {
1189        self.write_indent();
1190        self.buf.push_str(s);
1191        self.buf.push('\n');
1192    }
1193
1194    // ── Prelude function mapping ──────────────────────────────────────────
1195
1196    /// Emit an expression into a temporary buffer and return the string.
1197    fn expr_to_string(&mut self, node: &AIRNode) -> Result<String, CodegenError> {
1198        let start = self.buf.len();
1199        // Snapshot source-map state so mappings recorded during the scratch
1200        // emission (which will be truncated and possibly re-emitted elsewhere)
1201        // don't leak into the final output.
1202        let saved_line = self.cur_line;
1203        let saved_col = self.cur_col;
1204        let saved_scan = self.scan_pos;
1205        let saved_marked = self.last_marked;
1206        let mappings_len = self.mappings.len();
1207        self.emit_expr(node)?;
1208        let s = self.buf[start..].to_string();
1209        self.buf.truncate(start);
1210        self.cur_line = saved_line;
1211        self.cur_col = saved_col;
1212        self.scan_pos = saved_scan;
1213        self.last_marked = saved_marked;
1214        self.mappings.truncate(mappings_len);
1215        Ok(s)
1216    }
1217
1218    /// Map Bock prelude functions to JS equivalents.
1219    /// Returns `Some(code)` if the call is a prelude function, `None` otherwise.
1220    fn map_prelude_call(
1221        &mut self,
1222        callee: &AIRNode,
1223        args: &[bock_air::AirArg],
1224    ) -> Result<Option<String>, CodegenError> {
1225        let name = match &callee.kind {
1226            NodeKind::Identifier { name } => name.name.as_str(),
1227            _ => return Ok(None),
1228        };
1229        let arg_strs: Vec<String> = args
1230            .iter()
1231            .map(|a| self.expr_to_string(&a.value))
1232            .collect::<Result<_, _>>()?;
1233        let code = match name {
1234            "println" => {
1235                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
1236                format!("console.log({a})")
1237            }
1238            "print" => {
1239                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
1240                format!("process.stdout.write(String({a}))")
1241            }
1242            "debug" => {
1243                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
1244                format!("console.debug({a})")
1245            }
1246            "assert" => {
1247                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
1248                format!("if (!{a}) throw new Error(\"assertion failed\")")
1249            }
1250            "todo" => "throw new Error(\"not implemented\")".to_string(),
1251            "unreachable" => "throw new Error(\"unreachable\")".to_string(),
1252            "sleep" => {
1253                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
1254                // Route through an installed `Clock` handler if one is in scope;
1255                // otherwise fall through to the host primitive (default).
1256                if let Some(handler) = self.clock_handler_var() {
1257                    format!("{handler}.sleep({a})")
1258                } else {
1259                    // Duration is ns → setTimeout takes ms.
1260                    format!("new Promise((__r) => setTimeout(__r, Math.floor(({a}) / 1e6)))")
1261                }
1262            }
1263            _ => return Ok(None),
1264        };
1265        Ok(Some(code))
1266    }
1267
1268    /// Decide whether to inject the concurrency runtime prelude. For
1269    /// simplicity we scan the serialized AIR for `Channel` / `spawn`
1270    /// references — a false positive just adds a few dozen bytes of dead
1271    /// helper code, which JS runtimes elide at GC.
1272    fn module_uses_concurrency(&self, items: &[AIRNode]) -> bool {
1273        items.iter().any(Self::node_uses_concurrency)
1274    }
1275
1276    fn node_uses_concurrency(node: &AIRNode) -> bool {
1277        let serialized = format!("{node:?}");
1278        serialized.contains("\"Channel\"") || serialized.contains("\"spawn\"")
1279    }
1280
1281    /// Recognise `Channel.new()`, `spawn(...)`, and `ch.send(...)` /
1282    /// `ch.recv()` as desugared method calls. Emits the runtime-helper
1283    /// form when matched.
1284    fn try_emit_concurrency_call(
1285        &mut self,
1286        callee: &AIRNode,
1287        args: &[bock_air::AirArg],
1288    ) -> Result<bool, CodegenError> {
1289        // Global spawn(...)
1290        if let NodeKind::Identifier { name } = &callee.kind {
1291            if name.name == "spawn" {
1292                self.buf.push_str("__bockSpawn(");
1293                for (i, arg) in args.iter().enumerate() {
1294                    if i > 0 {
1295                        self.buf.push_str(", ");
1296                    }
1297                    self.emit_expr(&arg.value)?;
1298                }
1299                self.buf.push(')');
1300                return Ok(true);
1301            }
1302        }
1303        let NodeKind::FieldAccess { object, field } = &callee.kind else {
1304            return Ok(false);
1305        };
1306        // Associated call: Channel.new()
1307        if let NodeKind::Identifier { name: type_name } = &object.kind {
1308            if type_name.name == "Channel" && field.name == "new" {
1309                self.buf.push_str("__bockChannelNew()");
1310                return Ok(true);
1311            }
1312        }
1313        // Desugared method call on a channel: the AIR lowerer re-inserts
1314        // the receiver as the first arg (`tx.send(v)` → `send(tx, v)`);
1315        // strip it before emitting `tx.send(v)`.
1316        if matches!(field.name.as_str(), "send" | "recv" | "close") {
1317            self.emit_expr(object)?;
1318            let _ = write!(self.buf, ".{}", field.name);
1319            self.buf.push('(');
1320            for (i, arg) in args.iter().skip(1).enumerate() {
1321                if i > 0 {
1322                    self.buf.push_str(", ");
1323                }
1324                self.emit_expr(&arg.value)?;
1325            }
1326            self.buf.push(')');
1327            return Ok(true);
1328        }
1329        Ok(false)
1330    }
1331
1332    /// Recognise `Duration.xxx(...)` / `Instant.xxx(...)` associated-function
1333    /// calls and emit inline arithmetic. Durations are plain Numbers
1334    /// (nanoseconds); Instants are Numbers representing ns since `performance.timeOrigin`.
1335    ///
1336    /// Returns `Ok(true)` if the call was emitted.
1337    fn try_emit_time_assoc_call(
1338        &mut self,
1339        callee: &AIRNode,
1340        args: &[bock_air::AirArg],
1341    ) -> Result<bool, CodegenError> {
1342        let NodeKind::FieldAccess { object, field } = &callee.kind else {
1343            return Ok(false);
1344        };
1345        let NodeKind::Identifier { name: type_name } = &object.kind else {
1346            return Ok(false);
1347        };
1348        let arg_strs: Vec<String> = args
1349            .iter()
1350            .map(|a| self.expr_to_string(&a.value))
1351            .collect::<Result<_, _>>()?;
1352        let arg0 = || arg_strs.first().cloned().unwrap_or_default();
1353        let code = match (type_name.name.as_str(), field.name.as_str()) {
1354            ("Duration", "zero") => "0".to_string(),
1355            ("Duration", "nanos") => arg0(),
1356            ("Duration", "micros") => format!("(({}) * 1000)", arg0()),
1357            ("Duration", "millis") => format!("(({}) * 1000000)", arg0()),
1358            ("Duration", "seconds") => format!("(({}) * 1000000000)", arg0()),
1359            ("Duration", "minutes") => format!("(({}) * 60000000000)", arg0()),
1360            ("Duration", "hours") => format!("(({}) * 3600000000000)", arg0()),
1361            ("Instant", "now") => {
1362                // Route through an installed `Clock` handler's `now_monotonic`
1363                // op if one is in scope; otherwise emit the host primitive.
1364                if let Some(handler) = self.clock_handler_var() {
1365                    format!("{handler}.now_monotonic()")
1366                } else {
1367                    "(performance.now() * 1000000)".to_string()
1368                }
1369            }
1370            _ => return Ok(false),
1371        };
1372        self.buf.push_str(&code);
1373        Ok(true)
1374    }
1375
1376    /// Recognise desugared method calls `Call(FieldAccess(recv, m), [recv, ...args])`
1377    /// on Duration/Instant values and emit inline arithmetic. Returns true if
1378    /// the call was emitted.
1379    ///
1380    /// `node` is the full `Call` AIR node, consulted only to *exclude* primitive
1381    /// receivers: [`is_time_method_name`] alone is ambiguous (`abs` is both
1382    /// `Duration.abs` and `Int.abs`/`Float.abs`), so when the checker has stamped
1383    /// `recv_kind = "Primitive:<Ty>"` this is a numeric method, not a time method —
1384    /// bail so [`Self::try_emit_numeric_method`] handles it.
1385    fn try_emit_time_desugared_method(
1386        &mut self,
1387        node: &AIRNode,
1388        callee: &AIRNode,
1389        args: &[bock_air::AirArg],
1390    ) -> Result<bool, CodegenError> {
1391        if crate::generator::primitive_recv_kind(node).is_some() {
1392            return Ok(false);
1393        }
1394        let NodeKind::FieldAccess { object, field } = &callee.kind else {
1395            return Ok(false);
1396        };
1397        // Skip associated-fn form: `Type.method(...)`.
1398        if let NodeKind::Identifier { name } = &object.kind {
1399            if matches!(name.name.as_str(), "Duration" | "Instant") {
1400                return Ok(false);
1401            }
1402        }
1403        if !is_time_method_name(&field.name) {
1404            return Ok(false);
1405        }
1406        let remaining: Vec<bock_air::AirArg> = args.iter().skip(1).cloned().collect();
1407        self.try_emit_time_method(object, &field.name, &remaining)
1408    }
1409
1410    /// Recognise instance methods on Duration/Instant values and emit inline
1411    /// arithmetic. Returns `Ok(true)` if the call was emitted.
1412    fn try_emit_time_method(
1413        &mut self,
1414        receiver: &AIRNode,
1415        method: &str,
1416        args: &[bock_air::AirArg],
1417    ) -> Result<bool, CodegenError> {
1418        let recv_str = self.expr_to_string(receiver)?;
1419        let arg_strs: Vec<String> = args
1420            .iter()
1421            .map(|a| self.expr_to_string(&a.value))
1422            .collect::<Result<_, _>>()?;
1423        let code = match method {
1424            "as_nanos" => format!("({recv_str})"),
1425            "as_millis" => format!("Math.floor(({recv_str}) / 1000000)"),
1426            "as_seconds" => format!("Math.floor(({recv_str}) / 1000000000)"),
1427            "is_zero" => format!("(({recv_str}) === 0)"),
1428            "is_negative" => format!("(({recv_str}) < 0)"),
1429            "abs" => format!("Math.abs({recv_str})"),
1430            "elapsed" => {
1431                // `instant.elapsed()` is derived: `now - instant`. Route the
1432                // "now" read through an installed `Clock` handler if in scope;
1433                // otherwise read the host monotonic clock (default).
1434                if let Some(handler) = self.clock_handler_var() {
1435                    format!("({handler}.now_monotonic() - ({recv_str}))")
1436                } else {
1437                    format!("((performance.now() * 1000000) - ({recv_str}))")
1438                }
1439            }
1440            "duration_since" => {
1441                let other = arg_strs.first().cloned().unwrap_or_default();
1442                format!("(({recv_str}) - ({other}))")
1443            }
1444            _ => return Ok(false),
1445        };
1446        self.buf.push_str(&code);
1447        Ok(true)
1448    }
1449
1450    /// Emit Some/Ok/Err calls as tagged-object constructions, matching
1451    /// the representation user-defined enum variants use. Returns true if
1452    /// the call was handled.
1453    fn try_emit_prelude_ctor(
1454        &mut self,
1455        callee: &AIRNode,
1456        args: &[bock_air::AirArg],
1457    ) -> Result<bool, CodegenError> {
1458        let name = match &callee.kind {
1459            NodeKind::Identifier { name } => name.name.as_str(),
1460            _ => return Ok(false),
1461        };
1462        if !matches!(name, "Some" | "Ok" | "Err") {
1463            return Ok(false);
1464        }
1465        let _ = write!(self.buf, "{{ _tag: \"{name}\"");
1466        if let Some(arg) = args.first() {
1467            self.buf.push_str(", _0: ");
1468            self.emit_expr(&arg.value)?;
1469        }
1470        self.buf.push_str(" }");
1471        Ok(true)
1472    }
1473
1474    /// Q-prim-assoc: lower a primitive associated-conversion call
1475    /// (`Float.from(x)` / `Int.try_from(s)` / `String.from(c)`) to JS's native
1476    /// conversion. `from` is an infallible value coercion; `try_from` parses a
1477    /// `String` and returns the Bock `Result` tagged-object shape
1478    /// (`{ _tag: "Ok"/"Err", _0: … }`), the `Err` payload a `ConvertError`
1479    /// (in scope via the `Result[T, ConvertError]` return-type import). Returns
1480    /// `true` when handled.
1481    fn try_emit_primitive_conversion(
1482        &mut self,
1483        node: &AIRNode,
1484        callee: &AIRNode,
1485        args: &[bock_air::AirArg],
1486    ) -> Result<bool, CodegenError> {
1487        let Some((target, method, arg)) =
1488            crate::generator::primitive_conversion_call(node, callee, args)
1489        else {
1490            return Ok(false);
1491        };
1492        let arg_str = self.expr_to_string(arg)?;
1493        let code = match (target, method) {
1494            // `from`: infallible coercion. Int/sized-int -> Float and
1495            // sized-int -> Int are identity on a JS `number`; Char -> String is
1496            // already a single-char string, kept parenthesised for safety.
1497            ("Float" | "Int" | "String", "from") => format!("({arg_str})"),
1498            // `Int.try_from(s)`: strict integer parse. Reject anything that is
1499            // not an optionally-signed run of digits (JS `parseInt` is lenient).
1500            ("Int", "try_from") => format!(
1501                "((__s) => /^[+-]?[0-9]+$/.test(__s.trim()) \
1502                 ? {{ _tag: \"Ok\", _0: Number.parseInt(__s.trim(), 10) }} \
1503                 : {{ _tag: \"Err\", _0: new ConvertError({{ message: \
1504                 `cannot parse '${{__s}}' as Int` }}) }})({arg_str})"
1505            ),
1506            // `Float.try_from(s)`: parse a float; reject empty / non-numeric
1507            // (`Number("")` is 0, so guard the empty/whitespace case explicitly).
1508            ("Float", "try_from") => format!(
1509                "((__s) => {{ const __t = __s.trim(); const __n = Number(__t); \
1510                 return (__t.length > 0 && !Number.isNaN(__n)) \
1511                 ? {{ _tag: \"Ok\", _0: __n }} \
1512                 : {{ _tag: \"Err\", _0: new ConvertError({{ message: \
1513                 `cannot parse '${{__s}}' as Float` }}) }}; }})({arg_str})"
1514            ),
1515            _ => return Ok(false),
1516        };
1517        self.buf.push_str(&code);
1518        Ok(true)
1519    }
1520
1521    /// Emit a built-in `Optional`/`Result` method call to its JS form.
1522    ///
1523    /// Recognised via the checker's `recv_kind` annotation
1524    /// ([`crate::generator::desugared_optional_method`] /
1525    /// [`crate::generator::desugared_result_method`]) so the overloaded names
1526    /// (`unwrap`/`unwrap_or`/`map`) dispatch to the right tag test. Both types use
1527    /// the inline tagged-object representation (`{ _tag: "Some"/"Ok", _0: v }` /
1528    /// `{ _tag: "None"/"Err", _0: e }`), so the lowering is a ternary on `._tag`.
1529    /// The receiver is wrapped in an IIFE (`((__o) => …)(recv)`) so it is
1530    /// evaluated exactly once even when read several times (`map`, the default
1531    /// branch). Returns `true` if the call was handled.
1532    fn try_emit_container_method(
1533        &mut self,
1534        node: &AIRNode,
1535        callee: &AIRNode,
1536        args: &[bock_air::AirArg],
1537    ) -> Result<bool, CodegenError> {
1538        if let Some((recv, method, rest)) =
1539            crate::generator::desugared_optional_method(node, callee, args)
1540        {
1541            self.emit_tagged_container_method(recv, method, rest, "Some")?;
1542            return Ok(true);
1543        }
1544        if let Some((recv, method, rest)) =
1545            crate::generator::desugared_result_method(node, callee, args)
1546        {
1547            self.emit_tagged_container_method(recv, method, rest, "Ok")?;
1548            return Ok(true);
1549        }
1550        Ok(false)
1551    }
1552
1553    /// Lower a tagged-container method on `recv` to JS. `present_tag` is the
1554    /// "payload-carrying" tag (`"Some"` for `Optional`, `"Ok"` for `Result`); the
1555    /// predicate methods (`is_some`/`is_ok` vs `is_none`/`is_err`) and the
1556    /// payload extraction (`unwrap`/`unwrap_or`/`map`) are expressed against it.
1557    fn emit_tagged_container_method(
1558        &mut self,
1559        recv: &AIRNode,
1560        method: &str,
1561        rest: &[bock_air::AirArg],
1562        present_tag: &str,
1563    ) -> Result<(), CodegenError> {
1564        // `is_some`/`is_ok` and `is_none`/`is_err` are pure tag tests; emit
1565        // inline without an IIFE (the receiver is read once).
1566        match method {
1567            "is_some" | "is_ok" => {
1568                self.buf.push('(');
1569                self.emit_expr(recv)?;
1570                let _ = write!(self.buf, "._tag === \"{present_tag}\")");
1571                return Ok(());
1572            }
1573            "is_none" | "is_err" => {
1574                self.buf.push('(');
1575                self.emit_expr(recv)?;
1576                let _ = write!(self.buf, "._tag !== \"{present_tag}\")");
1577                return Ok(());
1578            }
1579            _ => {}
1580        }
1581        // The remaining methods read the receiver more than once, so bind it in
1582        // an IIFE.
1583        self.buf.push_str("((__c) => ");
1584        match method {
1585            "unwrap" => {
1586                let _ = write!(
1587                    self.buf,
1588                    "__c._tag === \"{present_tag}\" ? __c._0 : undefined"
1589                );
1590            }
1591            "unwrap_or" => {
1592                let _ = write!(self.buf, "__c._tag === \"{present_tag}\" ? __c._0 : ");
1593                if let Some(d) = rest.first() {
1594                    self.emit_expr(&d.value)?;
1595                } else {
1596                    self.buf.push_str("undefined");
1597                }
1598            }
1599            "map" => {
1600                let _ = write!(
1601                    self.buf,
1602                    "__c._tag === \"{present_tag}\" ? {{ _tag: \"{present_tag}\", _0: ("
1603                );
1604                if let Some(f) = rest.first() {
1605                    self.emit_expr(&f.value)?;
1606                } else {
1607                    self.buf.push_str("(x) => x");
1608                }
1609                self.buf.push_str(")(__c._0) } : __c");
1610            }
1611            "flat_map" => {
1612                let _ = write!(self.buf, "__c._tag === \"{present_tag}\" ? (");
1613                if let Some(f) = rest.first() {
1614                    self.emit_expr(&f.value)?;
1615                } else {
1616                    self.buf.push_str("(x) => x");
1617                }
1618                self.buf.push_str(")(__c._0) : __c");
1619            }
1620            "map_err" => {
1621                // Transform only the `Err` payload; an `Ok` passes through.
1622                let _ = write!(
1623                    self.buf,
1624                    "__c._tag === \"{present_tag}\" ? __c : {{ _tag: \"Err\", _0: ("
1625                );
1626                if let Some(f) = rest.first() {
1627                    self.emit_expr(&f.value)?;
1628                } else {
1629                    self.buf.push_str("(x) => x");
1630                }
1631                self.buf.push_str(")(__c._0) }");
1632            }
1633            _ => {
1634                // Unreachable: the recogniser only admits the methods above.
1635                self.buf.push_str("undefined");
1636            }
1637        }
1638        self.buf.push_str(")(");
1639        self.emit_expr(recv)?;
1640        self.buf.push(')');
1641        Ok(())
1642    }
1643
1644    /// Emit a read-only `List` built-in method call to its JS form.
1645    ///
1646    /// Recognised via [`crate::generator::desugared_list_method`] in the `Call`
1647    /// arm. `Optional`-returning methods (`get`/`first`/`last`/`index_of`) emit
1648    /// the same tagged-object representation user enum variants use
1649    /// (`{ _tag: "Some", _0: v }` / `{ _tag: "None" }`). Methods that need the
1650    /// receiver more than once (`get`/`first`/`last`/`index_of`) wrap it in an
1651    /// IIFE so the receiver expression is evaluated exactly once.
1652    fn try_emit_list_method(
1653        &mut self,
1654        node: &AIRNode,
1655        callee: &AIRNode,
1656        args: &[bock_air::AirArg],
1657    ) -> Result<bool, CodegenError> {
1658        let Some((recv, method, rest)) =
1659            crate::generator::desugared_list_method(node, callee, args)
1660        else {
1661            return Ok(false);
1662        };
1663        match method {
1664            "len" | "length" | "count" => {
1665                self.buf.push('(');
1666                self.emit_expr(recv)?;
1667                self.buf.push_str(").length");
1668            }
1669            "is_empty" => {
1670                self.buf.push_str("((");
1671                self.emit_expr(recv)?;
1672                self.buf.push_str(").length === 0)");
1673            }
1674            "get" => {
1675                let Some(idx) = rest.first() else {
1676                    return Ok(false);
1677                };
1678                self.buf
1679                    .push_str("((__r, __i) => (__i >= 0 && __i < __r.length) ? ");
1680                self.buf
1681                    .push_str("{ _tag: \"Some\", _0: __r[__i] } : { _tag: \"None\" })(");
1682                self.emit_expr(recv)?;
1683                self.buf.push_str(", ");
1684                self.emit_expr(&idx.value)?;
1685                self.buf.push(')');
1686            }
1687            "first" => {
1688                self.buf.push_str("((__r) => __r.length > 0 ? ");
1689                self.buf
1690                    .push_str("{ _tag: \"Some\", _0: __r[0] } : { _tag: \"None\" })(");
1691                self.emit_expr(recv)?;
1692                self.buf.push(')');
1693            }
1694            "last" => {
1695                self.buf.push_str("((__r) => __r.length > 0 ? ");
1696                self.buf
1697                    .push_str("{ _tag: \"Some\", _0: __r[__r.length - 1] } : { _tag: \"None\" })(");
1698                self.emit_expr(recv)?;
1699                self.buf.push(')');
1700            }
1701            "contains" => {
1702                let Some(x) = rest.first() else {
1703                    return Ok(false);
1704                };
1705                self.buf.push('(');
1706                self.emit_expr(recv)?;
1707                self.buf.push_str(").includes(");
1708                self.emit_expr(&x.value)?;
1709                self.buf.push(')');
1710            }
1711            "index_of" => {
1712                let Some(x) = rest.first() else {
1713                    return Ok(false);
1714                };
1715                self.buf
1716                    .push_str("((__r, __x) => { const __i = __r.indexOf(__x); ");
1717                self.buf.push_str(
1718                    "return __i >= 0 ? { _tag: \"Some\", _0: __i } : { _tag: \"None\" }; })(",
1719                );
1720                self.emit_expr(recv)?;
1721                self.buf.push_str(", ");
1722                self.emit_expr(&x.value)?;
1723                self.buf.push(')');
1724            }
1725            "concat" => {
1726                let Some(o) = rest.first() else {
1727                    return Ok(false);
1728                };
1729                self.buf.push('(');
1730                self.emit_expr(recv)?;
1731                self.buf.push_str(").concat(");
1732                self.emit_expr(&o.value)?;
1733                self.buf.push(')');
1734            }
1735            "join" => {
1736                let Some(sep) = rest.first() else {
1737                    return Ok(false);
1738                };
1739                self.buf.push('(');
1740                self.emit_expr(recv)?;
1741                self.buf.push_str(").join(");
1742                self.emit_expr(&sep.value)?;
1743                self.buf.push(')');
1744            }
1745            _ => return Ok(false),
1746        }
1747        Ok(true)
1748    }
1749
1750    /// Emit an in-place `List` mutator (`push`/`append`, DQ18) to its JS form.
1751    ///
1752    /// Recognised via [`crate::generator::desugared_list_mutating_method`] in the
1753    /// `Call` arm. JS arrays carry a native `push`, so `recv.push(x)` lowers
1754    /// directly. The checker types these as `Void`, so they appear in statement
1755    /// position; the receiver is a `mut` lvalue (ownership-enforced), evaluated
1756    /// once.
1757    fn try_emit_list_mutating_method(
1758        &mut self,
1759        node: &AIRNode,
1760        callee: &AIRNode,
1761        args: &[bock_air::AirArg],
1762    ) -> Result<bool, CodegenError> {
1763        let Some((recv, _method, rest)) =
1764            crate::generator::desugared_list_mutating_method(node, callee, args)
1765        else {
1766            return Ok(false);
1767        };
1768        let Some(x) = rest.first() else {
1769            return Ok(false);
1770        };
1771        self.buf.push('(');
1772        self.emit_expr(recv)?;
1773        self.buf.push_str(").push(");
1774        self.emit_expr(&x.value)?;
1775        self.buf.push(')');
1776        Ok(true)
1777    }
1778
1779    /// Emit a DQ30 in-place `List` mutator
1780    /// (`pop`/`remove_at`/`insert`/`reverse`/`set`) to its JS form.
1781    ///
1782    /// Recognised via [`crate::generator::desugared_list_inplace_mutator`]. JS
1783    /// arrays are reference values, so an IIFE parameter (`__r`) aliases the
1784    /// receiver and native mutations through it are visible to the caller —
1785    /// the same single-evaluation trick the read-only Optional-returning
1786    /// methods use:
1787    ///
1788    /// - `pop` → length-check + native `arr.pop()`, wrapped into the tagged
1789    ///   Optional rep (`{ _tag: "Some", _0: v }` / `{ _tag: "None" }`);
1790    /// - `remove_at(i)` → bounds-check + `arr.splice(i, 1)[0]`;
1791    /// - `insert(i, x)` → bounds-check (`0..=len`) + `arr.splice(i, 0, x)`;
1792    /// - `reverse` → native in-place `arr.reverse()`;
1793    /// - `set(i, x)` → bounds-check + `arr[i] = x` — the check is load-bearing:
1794    ///   native JS index-assign past the end silently *extends* the array.
1795    ///
1796    /// The bounds checks throw with the normalized abort message
1797    /// `List.<op>: index <i> out of bounds (len <n>)`, following the DQ23
1798    /// integer-division zero-check convention (`throw new Error(...)`).
1799    fn try_emit_list_inplace_mutator(
1800        &mut self,
1801        node: &AIRNode,
1802        callee: &AIRNode,
1803        args: &[bock_air::AirArg],
1804    ) -> Result<bool, CodegenError> {
1805        let Some((recv, method, rest)) =
1806            crate::generator::desugared_list_inplace_mutator(node, callee, args)
1807        else {
1808            return Ok(false);
1809        };
1810        match method {
1811            "pop" => {
1812                self.buf.push_str(
1813                    "((__r) => __r.length > 0 ? { _tag: \"Some\", _0: __r.pop() } : \
1814                     { _tag: \"None\" })(",
1815                );
1816                self.emit_expr(recv)?;
1817                self.buf.push(')');
1818            }
1819            "remove_at" => {
1820                let Some(idx) = rest.first() else {
1821                    return Ok(false);
1822                };
1823                self.buf.push_str(
1824                    "((__r, __i) => { if (__i < 0 || __i >= __r.length) { throw new Error(\
1825                     \"List.remove_at: index \" + __i + \" out of bounds (len \" + __r.length + \")\"); } \
1826                     return __r.splice(__i, 1)[0]; })(",
1827                );
1828                self.emit_expr(recv)?;
1829                self.buf.push_str(", ");
1830                self.emit_expr(&idx.value)?;
1831                self.buf.push(')');
1832            }
1833            "insert" => {
1834                let (Some(idx), Some(x)) = (rest.first(), rest.get(1)) else {
1835                    return Ok(false);
1836                };
1837                self.buf.push_str(
1838                    "((__r, __i, __x) => { if (__i < 0 || __i > __r.length) { throw new Error(\
1839                     \"List.insert: index \" + __i + \" out of bounds (len \" + __r.length + \")\"); } \
1840                     __r.splice(__i, 0, __x); })(",
1841                );
1842                self.emit_expr(recv)?;
1843                self.buf.push_str(", ");
1844                self.emit_expr(&idx.value)?;
1845                self.buf.push_str(", ");
1846                self.emit_expr(&x.value)?;
1847                self.buf.push(')');
1848            }
1849            "reverse" => {
1850                self.buf.push('(');
1851                self.emit_expr(recv)?;
1852                self.buf.push_str(").reverse()");
1853            }
1854            "set" => {
1855                let (Some(idx), Some(x)) = (rest.first(), rest.get(1)) else {
1856                    return Ok(false);
1857                };
1858                self.buf.push_str(
1859                    "((__r, __i, __x) => { if (__i < 0 || __i >= __r.length) { throw new Error(\
1860                     \"List.set: index \" + __i + \" out of bounds (len \" + __r.length + \")\"); } \
1861                     __r[__i] = __x; })(",
1862                );
1863                self.emit_expr(recv)?;
1864                self.buf.push_str(", ");
1865                self.emit_expr(&idx.value)?;
1866                self.buf.push_str(", ");
1867                self.emit_expr(&x.value)?;
1868                self.buf.push(')');
1869            }
1870            _ => return Ok(false),
1871        }
1872        Ok(true)
1873    }
1874
1875    /// Emit a functional (closure-taking) `List` built-in method call to its JS
1876    /// form.
1877    ///
1878    /// Recognised via [`crate::generator::desugared_list_functional_method`] in
1879    /// the `Call` arm. JS arrays carry native `map`/`filter`/`reduce`/`forEach`/
1880    /// `some`/`every`/`flatMap`, so the closure is passed *once* (no duplicated
1881    /// receiver — the desugared `recv.map(recv, cb)` shape that the generic
1882    /// fall-through would otherwise emit is what broke). `fold(init, cb)` maps to
1883    /// `reduce(cb, init)`; `find` wraps the native `.find` result (element or
1884    /// `undefined`) into the tagged `Optional` representation user enum variants
1885    /// use.
1886    fn try_emit_list_functional_method(
1887        &mut self,
1888        node: &AIRNode,
1889        callee: &AIRNode,
1890        args: &[bock_air::AirArg],
1891    ) -> Result<bool, CodegenError> {
1892        let Some((recv, method, rest)) =
1893            crate::generator::desugared_list_functional_method(node, callee, args)
1894        else {
1895            return Ok(false);
1896        };
1897        match method {
1898            "map" | "filter" | "for_each" | "any" | "all" | "flat_map" => {
1899                let Some(cb) = rest.first() else {
1900                    return Ok(false);
1901                };
1902                let native = match method {
1903                    "map" => "map",
1904                    "filter" => "filter",
1905                    "for_each" => "forEach",
1906                    "any" => "some",
1907                    "all" => "every",
1908                    "flat_map" => "flatMap",
1909                    _ => unreachable!(),
1910                };
1911                self.buf.push('(');
1912                self.emit_expr(recv)?;
1913                let _ = write!(self.buf, ").{native}(");
1914                self.emit_expr(&cb.value)?;
1915                self.buf.push(')');
1916            }
1917            "reduce" => {
1918                // Bock `reduce((a, b) => ...)` has no seed: the first element is
1919                // the initial accumulator, matching JS `.reduce(cb)`.
1920                let Some(cb) = rest.first() else {
1921                    return Ok(false);
1922                };
1923                self.buf.push('(');
1924                self.emit_expr(recv)?;
1925                self.buf.push_str(").reduce(");
1926                self.emit_expr(&cb.value)?;
1927                self.buf.push(')');
1928            }
1929            "fold" => {
1930                // Bock `fold(init, (acc, x) => ...)` → JS `reduce(cb, init)`.
1931                let (Some(init), Some(cb)) = (rest.first(), rest.get(1)) else {
1932                    return Ok(false);
1933                };
1934                self.buf.push('(');
1935                self.emit_expr(recv)?;
1936                self.buf.push_str(").reduce(");
1937                self.emit_expr(&cb.value)?;
1938                self.buf.push_str(", ");
1939                self.emit_expr(&init.value)?;
1940                self.buf.push(')');
1941            }
1942            "find" => {
1943                // Native `.find` yields the element or `undefined`; wrap into the
1944                // tagged `Optional` representation.
1945                let Some(cb) = rest.first() else {
1946                    return Ok(false);
1947                };
1948                self.buf.push_str("((__r) => { const __m = __r.find(");
1949                self.emit_expr(&cb.value)?;
1950                self.buf.push_str(
1951                    "); return __m === undefined ? { _tag: \"None\" } : { _tag: \"Some\", _0: __m }; })(",
1952                );
1953                self.emit_expr(recv)?;
1954                self.buf.push(')');
1955            }
1956            _ => return Ok(false),
1957        }
1958        Ok(true)
1959    }
1960
1961    /// Emit a built-in `Map[K, V]` method call to its JS form (native `Map`).
1962    ///
1963    /// Recognised via [`crate::generator::desugared_map_method`] (gated on the
1964    /// checker's `recv_kind = "Map"` annotation) and wired into the `Call` arm
1965    /// *before* [`Self::try_emit_list_method`], so a `Map` receiver's
1966    /// `get`/`contains_key`/`len` dispatch here rather than through the `List`
1967    /// path. `get` returns the same tagged-`Optional` representation the rest of
1968    /// codegen uses (`{ _tag: "Some", _0: v }` / `{ _tag: "None" }`). Mutating
1969    /// methods (`set`/`delete`/`merge`) mutate in place and return the receiver,
1970    /// matching the checker's `-> Map[K, V]` return type (full value-vs-`mut
1971    /// self` semantics is DQ18 → P4). Returns `true` if the call was handled.
1972    fn try_emit_map_method(
1973        &mut self,
1974        node: &AIRNode,
1975        callee: &AIRNode,
1976        args: &[bock_air::AirArg],
1977    ) -> Result<bool, CodegenError> {
1978        let Some((recv, method, rest)) = crate::generator::desugared_map_method(node, callee, args)
1979        else {
1980            return Ok(false);
1981        };
1982        match method {
1983            "len" | "length" | "count" => {
1984                self.buf.push('(');
1985                self.emit_expr(recv)?;
1986                self.buf.push_str(").size");
1987            }
1988            "is_empty" => {
1989                self.buf.push_str("((");
1990                self.emit_expr(recv)?;
1991                self.buf.push_str(").size === 0)");
1992            }
1993            "contains_key" => {
1994                let Some(k) = rest.first() else {
1995                    return Ok(false);
1996                };
1997                self.buf.push('(');
1998                self.emit_expr(recv)?;
1999                self.buf.push_str(").has(");
2000                self.emit_expr(&k.value)?;
2001                self.buf.push(')');
2002            }
2003            "get" => {
2004                let Some(k) = rest.first() else {
2005                    return Ok(false);
2006                };
2007                self.buf.push_str(
2008                    "((__m, __k) => __m.has(__k) ? { _tag: \"Some\", _0: __m.get(__k) } : \
2009                     { _tag: \"None\" })(",
2010                );
2011                self.emit_expr(recv)?;
2012                self.buf.push_str(", ");
2013                self.emit_expr(&k.value)?;
2014                self.buf.push(')');
2015            }
2016            "set" => {
2017                let (Some(k), Some(v)) = (rest.first(), rest.get(1)) else {
2018                    return Ok(false);
2019                };
2020                self.buf
2021                    .push_str("((__m, __k, __v) => { __m.set(__k, __v); return __m; })(");
2022                self.emit_expr(recv)?;
2023                self.buf.push_str(", ");
2024                self.emit_expr(&k.value)?;
2025                self.buf.push_str(", ");
2026                self.emit_expr(&v.value)?;
2027                self.buf.push(')');
2028            }
2029            "delete" => {
2030                let Some(k) = rest.first() else {
2031                    return Ok(false);
2032                };
2033                self.buf
2034                    .push_str("((__m, __k) => { __m.delete(__k); return __m; })(");
2035                self.emit_expr(recv)?;
2036                self.buf.push_str(", ");
2037                self.emit_expr(&k.value)?;
2038                self.buf.push(')');
2039            }
2040            "merge" => {
2041                let Some(o) = rest.first() else {
2042                    return Ok(false);
2043                };
2044                self.buf.push_str(
2045                    "((__m, __o) => { for (const [__k, __v] of __o) __m.set(__k, __v); \
2046                     return __m; })(",
2047                );
2048                self.emit_expr(recv)?;
2049                self.buf.push_str(", ");
2050                self.emit_expr(&o.value)?;
2051                self.buf.push(')');
2052            }
2053            "filter" => {
2054                let Some(f) = rest.first() else {
2055                    return Ok(false);
2056                };
2057                self.buf.push_str(
2058                    "((__m, __f) => { const __r = new Map(); \
2059                     for (const [__k, __v] of __m) if (__f(__k, __v)) __r.set(__k, __v); \
2060                     return __r; })(",
2061                );
2062                self.emit_expr(recv)?;
2063                self.buf.push_str(", ");
2064                self.emit_expr(&f.value)?;
2065                self.buf.push(')');
2066            }
2067            "keys" => {
2068                self.buf.push_str("[...(");
2069                self.emit_expr(recv)?;
2070                self.buf.push_str(").keys()]");
2071            }
2072            "values" => {
2073                self.buf.push_str("[...(");
2074                self.emit_expr(recv)?;
2075                self.buf.push_str(").values()]");
2076            }
2077            "entries" | "to_list" => {
2078                self.buf.push_str("[...(");
2079                self.emit_expr(recv)?;
2080                self.buf.push_str(").entries()]");
2081            }
2082            "for_each" => {
2083                let Some(f) = rest.first() else {
2084                    return Ok(false);
2085                };
2086                self.buf
2087                    .push_str("((__m, __f) => { for (const [__k, __v] of __m) __f(__k, __v); })(");
2088                self.emit_expr(recv)?;
2089                self.buf.push_str(", ");
2090                self.emit_expr(&f.value)?;
2091                self.buf.push(')');
2092            }
2093            _ => return Ok(false),
2094        }
2095        Ok(true)
2096    }
2097
2098    /// Emit a built-in `Set[E]` method call to its JS form (native `Set`).
2099    ///
2100    /// Recognised via [`crate::generator::desugared_set_method`] (gated on
2101    /// `recv_kind = "Set"`) and wired *before* [`Self::try_emit_list_method`],
2102    /// so a `Set` receiver's `contains`/`len`/`filter`/`map` no longer route
2103    /// through the `List` path. Mutating methods (`add`/`remove`) mutate in
2104    /// place and return the receiver. Returns `true` if handled.
2105    fn try_emit_set_method(
2106        &mut self,
2107        node: &AIRNode,
2108        callee: &AIRNode,
2109        args: &[bock_air::AirArg],
2110    ) -> Result<bool, CodegenError> {
2111        let Some((recv, method, rest)) = crate::generator::desugared_set_method(node, callee, args)
2112        else {
2113            return Ok(false);
2114        };
2115        match method {
2116            "len" | "length" | "count" => {
2117                self.buf.push('(');
2118                self.emit_expr(recv)?;
2119                self.buf.push_str(").size");
2120            }
2121            "is_empty" => {
2122                self.buf.push_str("((");
2123                self.emit_expr(recv)?;
2124                self.buf.push_str(").size === 0)");
2125            }
2126            "contains" => {
2127                let Some(x) = rest.first() else {
2128                    return Ok(false);
2129                };
2130                self.buf.push('(');
2131                self.emit_expr(recv)?;
2132                self.buf.push_str(").has(");
2133                self.emit_expr(&x.value)?;
2134                self.buf.push(')');
2135            }
2136            "add" => {
2137                let Some(x) = rest.first() else {
2138                    return Ok(false);
2139                };
2140                self.buf
2141                    .push_str("((__s, __x) => { __s.add(__x); return __s; })(");
2142                self.emit_expr(recv)?;
2143                self.buf.push_str(", ");
2144                self.emit_expr(&x.value)?;
2145                self.buf.push(')');
2146            }
2147            "remove" => {
2148                let Some(x) = rest.first() else {
2149                    return Ok(false);
2150                };
2151                self.buf
2152                    .push_str("((__s, __x) => { __s.delete(__x); return __s; })(");
2153                self.emit_expr(recv)?;
2154                self.buf.push_str(", ");
2155                self.emit_expr(&x.value)?;
2156                self.buf.push(')');
2157            }
2158            "union" => {
2159                let Some(o) = rest.first() else {
2160                    return Ok(false);
2161                };
2162                self.buf
2163                    .push_str("((__a, __b) => new Set([...__a, ...__b]))(");
2164                self.emit_expr(recv)?;
2165                self.buf.push_str(", ");
2166                self.emit_expr(&o.value)?;
2167                self.buf.push(')');
2168            }
2169            "intersection" => {
2170                let Some(o) = rest.first() else {
2171                    return Ok(false);
2172                };
2173                self.buf
2174                    .push_str("((__a, __b) => new Set([...__a].filter((__x) => __b.has(__x))))(");
2175                self.emit_expr(recv)?;
2176                self.buf.push_str(", ");
2177                self.emit_expr(&o.value)?;
2178                self.buf.push(')');
2179            }
2180            "difference" => {
2181                let Some(o) = rest.first() else {
2182                    return Ok(false);
2183                };
2184                self.buf
2185                    .push_str("((__a, __b) => new Set([...__a].filter((__x) => !__b.has(__x))))(");
2186                self.emit_expr(recv)?;
2187                self.buf.push_str(", ");
2188                self.emit_expr(&o.value)?;
2189                self.buf.push(')');
2190            }
2191            "is_subset" => {
2192                let Some(o) = rest.first() else {
2193                    return Ok(false);
2194                };
2195                self.buf
2196                    .push_str("((__a, __b) => [...__a].every((__x) => __b.has(__x)))(");
2197                self.emit_expr(recv)?;
2198                self.buf.push_str(", ");
2199                self.emit_expr(&o.value)?;
2200                self.buf.push(')');
2201            }
2202            "is_superset" => {
2203                let Some(o) = rest.first() else {
2204                    return Ok(false);
2205                };
2206                self.buf
2207                    .push_str("((__a, __b) => [...__b].every((__x) => __a.has(__x)))(");
2208                self.emit_expr(recv)?;
2209                self.buf.push_str(", ");
2210                self.emit_expr(&o.value)?;
2211                self.buf.push(')');
2212            }
2213            "filter" => {
2214                let Some(f) = rest.first() else {
2215                    return Ok(false);
2216                };
2217                self.buf
2218                    .push_str("((__s, __f) => new Set([...__s].filter(__f)))(");
2219                self.emit_expr(recv)?;
2220                self.buf.push_str(", ");
2221                self.emit_expr(&f.value)?;
2222                self.buf.push(')');
2223            }
2224            "map" => {
2225                let Some(f) = rest.first() else {
2226                    return Ok(false);
2227                };
2228                self.buf
2229                    .push_str("((__s, __f) => new Set([...__s].map(__f)))(");
2230                self.emit_expr(recv)?;
2231                self.buf.push_str(", ");
2232                self.emit_expr(&f.value)?;
2233                self.buf.push(')');
2234            }
2235            "to_list" => {
2236                self.buf.push_str("[...(");
2237                self.emit_expr(recv)?;
2238                self.buf.push_str(")]");
2239            }
2240            "for_each" => {
2241                let Some(f) = rest.first() else {
2242                    return Ok(false);
2243                };
2244                self.buf
2245                    .push_str("((__s, __f) => { for (const __x of __s) __f(__x); })(");
2246                self.emit_expr(recv)?;
2247                self.buf.push_str(", ");
2248                self.emit_expr(&f.value)?;
2249                self.buf.push(')');
2250            }
2251            _ => return Ok(false),
2252        }
2253        Ok(true)
2254    }
2255
2256    /// Lower a primitive trait-bridge method call (`compare`/`eq`/`to_string`/
2257    /// `display` on a primitive receiver) to its JS form.
2258    ///
2259    /// `(1).compare(2)` resolves in the checker to `Ordering`, but a JS `number`
2260    /// has no `.compare`; this lowers it to a ternary that produces the same
2261    /// tagged-object `Ordering` value the construction/match sides use
2262    /// (`{ _tag: "Less" }` / `…"Equal"` / `…"Greater"`). `eq` becomes `===`;
2263    /// `to_string`/`display` become `String(x)`.
2264    /// Lower a desugared `String` built-in method call (`recv_kind =
2265    /// "Primitive:String"`) to its native JavaScript string op. Wired into the
2266    /// `Call` arm *before* `try_emit_list_method` so a String receiver's
2267    /// `len`/`contains`/`is_empty` dispatch here, not through the List path.
2268    ///
2269    /// `len` is the Unicode SCALAR count (`[...s].length`, which iterates by code
2270    /// point) per spec §18.3 — not `s.length` (UTF-16 code units). `byte_len` is
2271    /// the UTF-8 byte count via `TextEncoder`. `replace` replaces ALL occurrences
2272    /// (`replaceAll`). `split` returns a JS array, which is the List runtime rep.
2273    ///
2274    /// Gated on `recv_kind = "Primitive:String"` directly (not the cross-backend
2275    /// [`crate::generator::desugared_string_method`] subset) so JS can lower the
2276    /// wider resolved String surface — `slice`/`substring`/`char_at`/`index_of`/
2277    /// `repeat`/`reverse`/`trim_start`/`trim_end` — to native ops, matching the
2278    /// Rust backend without widening the shared `STRING_METHODS` const (which
2279    /// would force every backend to handle the extra names). `slice`/`reverse`
2280    /// iterate by code point (`[...s]`) to honour the scalar-index semantics.
2281    /// `char_at`/`index_of` return the inline tagged `Optional`
2282    /// (`{ _tag: "Some", _0: v }` / `{ _tag: "None" }`).
2283    fn try_emit_string_method(
2284        &mut self,
2285        node: &AIRNode,
2286        callee: &AIRNode,
2287        args: &[bock_air::AirArg],
2288    ) -> Result<bool, CodegenError> {
2289        if crate::generator::primitive_recv_kind(node) != Some("String") {
2290            return Ok(false);
2291        }
2292        let Some((recv, field, rest)) = crate::generator::desugared_self_call(callee, args) else {
2293            return Ok(false);
2294        };
2295        let method = field.name.as_str();
2296        let recv_str = self.expr_to_string(recv)?;
2297        let arg0 = |this: &mut Self| -> Result<Option<String>, CodegenError> {
2298            rest.first()
2299                .map(|a| this.expr_to_string(&a.value))
2300                .transpose()
2301        };
2302        let code = match method {
2303            "len" | "length" | "count" => format!("[...({recv_str})].length"),
2304            "byte_len" => format!("new TextEncoder().encode({recv_str}).length"),
2305            "is_empty" => format!("(({recv_str}).length === 0)"),
2306            "to_upper" => format!("({recv_str}).toUpperCase()"),
2307            "to_lower" => format!("({recv_str}).toLowerCase()"),
2308            "trim" => format!("({recv_str}).trim()"),
2309            "trim_start" => format!("({recv_str}).trimStart()"),
2310            "trim_end" => format!("({recv_str}).trimEnd()"),
2311            "reverse" => format!("[...({recv_str})].reverse().join('')"),
2312            "to_string" | "display" => format!("String({recv_str})"),
2313            "repeat" => {
2314                let Some(n) = arg0(self)? else {
2315                    return Ok(false);
2316                };
2317                format!("({recv_str}).repeat({n})")
2318            }
2319            "contains" => {
2320                let Some(p) = arg0(self)? else {
2321                    return Ok(false);
2322                };
2323                format!("({recv_str}).includes({p})")
2324            }
2325            "starts_with" => {
2326                let Some(p) = arg0(self)? else {
2327                    return Ok(false);
2328                };
2329                format!("({recv_str}).startsWith({p})")
2330            }
2331            "ends_with" => {
2332                let Some(p) = arg0(self)? else {
2333                    return Ok(false);
2334                };
2335                format!("({recv_str}).endsWith({p})")
2336            }
2337            "replace" => {
2338                let Some(from) = arg0(self)? else {
2339                    return Ok(false);
2340                };
2341                let Some(to) = rest
2342                    .get(1)
2343                    .map(|a| self.expr_to_string(&a.value))
2344                    .transpose()?
2345                else {
2346                    return Ok(false);
2347                };
2348                format!("({recv_str}).replaceAll({from}, {to})")
2349            }
2350            "split" => {
2351                let Some(sep) = arg0(self)? else {
2352                    return Ok(false);
2353                };
2354                format!("({recv_str}).split({sep})")
2355            }
2356            // `slice`/`substring(start, end)` are scalar-index half-open
2357            // substrings (spec §18.3 — indices count Unicode scalars, not UTF-16
2358            // code units). Iterate by code point via the spread so multibyte input
2359            // is handled correctly, then `slice`/`join` the resulting array.
2360            "slice" | "substring" => {
2361                let Some(start) = arg0(self)? else {
2362                    return Ok(false);
2363                };
2364                let Some(end) = rest
2365                    .get(1)
2366                    .map(|a| self.expr_to_string(&a.value))
2367                    .transpose()?
2368                else {
2369                    return Ok(false);
2370                };
2371                format!("[...({recv_str})].slice({start}, {end}).join('')")
2372            }
2373            // `char_at(i)` returns `Optional[Char]` — `None` when out of range.
2374            "char_at" => {
2375                let Some(i) = arg0(self)? else {
2376                    return Ok(false);
2377                };
2378                format!(
2379                    "((__s, __i) => __i >= 0 && __i < __s.length ? {{ _tag: \"Some\", _0: __s[__i] }} : {{ _tag: \"None\" }})([...({recv_str})], {i})"
2380                )
2381            }
2382            // `index_of(needle)` returns `Optional[Int]` — the scalar index of the
2383            // first match, or `None`. JS `indexOf` is a UTF-16 code-unit offset, so
2384            // convert it to a scalar index via the code-point prefix length.
2385            "index_of" => {
2386                let Some(p) = arg0(self)? else {
2387                    return Ok(false);
2388                };
2389                format!(
2390                    "((__s, __p) => {{ const __b = __s.indexOf(__p); return __b >= 0 ? {{ _tag: \"Some\", _0: [...__s.slice(0, __b)].length }} : {{ _tag: \"None\" }}; }})({recv_str}, {p})"
2391                )
2392            }
2393            _ => return Ok(false),
2394        };
2395        self.buf.push_str(&code);
2396        Ok(true)
2397    }
2398
2399    /// Lower a desugared numeric/`Char`/`Bool` primitive method (`recv_kind =
2400    /// "Primitive:Int" | "Primitive:Float" | "Primitive:Char" | "Primitive:Bool"`)
2401    /// to its native JavaScript form. Covers the conversion and math methods the
2402    /// checker resolves on the scalar primitives — `to_float`/`to_int`/`abs`/`min`/
2403    /// `max`/`clamp`/`floor`/`ceil`/`round`/`sqrt`/… — none of which exist as
2404    /// methods on a JS `number`/`boolean`/string-char. Wired into the `Call` arm
2405    /// alongside [`Self::try_emit_string_method`], before the generic
2406    /// desugared-self-call fall-through (which would emit `n.to_float(n)`).
2407    /// `compare`/`eq`/`to_string`/`display`/`hash_code` stay on the primitive
2408    /// *bridge* path. `Char` is a single-code-point JS string.
2409    fn try_emit_numeric_method(
2410        &mut self,
2411        node: &AIRNode,
2412        callee: &AIRNode,
2413        args: &[bock_air::AirArg],
2414    ) -> Result<bool, CodegenError> {
2415        let prim = match crate::generator::primitive_recv_kind(node) {
2416            Some(p @ ("Int" | "Float" | "Char" | "Bool")) => p,
2417            _ => return Ok(false),
2418        };
2419        let Some((recv, field, rest)) = crate::generator::desugared_self_call(callee, args) else {
2420            return Ok(false);
2421        };
2422        let method = field.name.as_str();
2423        let recv_str = self.expr_to_string(recv)?;
2424        let arg = |this: &mut Self, i: usize| -> Result<Option<String>, CodegenError> {
2425            rest.get(i)
2426                .map(|a| this.expr_to_string(&a.value))
2427                .transpose()
2428        };
2429        let code = match (prim, method) {
2430            // Conversions. `to_float`/`to_int` are runtime no-ops on a JS `number`,
2431            // but `to_int` truncates toward zero (Bock `Float.to_int`).
2432            ("Int", "to_float") => format!("({recv_str})"),
2433            ("Float", "to_int") => format!("Math.trunc({recv_str})"),
2434            ("Char", "to_int") => format!("(({recv_str}).codePointAt(0))"),
2435            ("Bool", "to_int") => format!("(({recv_str}) ? 1 : 0)"),
2436            // Int math.
2437            ("Int", "abs") => format!("Math.abs({recv_str})"),
2438            ("Int" | "Float", "min") => {
2439                let Some(o) = arg(self, 0)? else {
2440                    return Ok(false);
2441                };
2442                format!("Math.min({recv_str}, {o})")
2443            }
2444            ("Int" | "Float", "max") => {
2445                let Some(o) = arg(self, 0)? else {
2446                    return Ok(false);
2447                };
2448                format!("Math.max({recv_str}, {o})")
2449            }
2450            ("Int" | "Float", "clamp") => {
2451                let (Some(lo), Some(hi)) = (arg(self, 0)?, arg(self, 1)?) else {
2452                    return Ok(false);
2453                };
2454                format!("Math.min(Math.max({recv_str}, {lo}), {hi})")
2455            }
2456            ("Int", "shift_left") => {
2457                let Some(o) = arg(self, 0)? else {
2458                    return Ok(false);
2459                };
2460                format!("(({recv_str}) << ({o}))")
2461            }
2462            ("Int", "shift_right") => {
2463                let Some(o) = arg(self, 0)? else {
2464                    return Ok(false);
2465                };
2466                format!("(({recv_str}) >> ({o}))")
2467            }
2468            // Float math.
2469            ("Float", "abs") => format!("Math.abs({recv_str})"),
2470            ("Float", "floor") => format!("Math.floor({recv_str})"),
2471            ("Float", "ceil") => format!("Math.ceil({recv_str})"),
2472            ("Float", "round") => format!("Math.round({recv_str})"),
2473            ("Float", "sqrt") => format!("Math.sqrt({recv_str})"),
2474            ("Float", "is_nan") => format!("Number.isNaN({recv_str})"),
2475            ("Float", "is_infinite") => format!("(!Number.isFinite({recv_str}))"),
2476            // Bool.
2477            ("Bool", "negate") => format!("(!({recv_str}))"),
2478            // Char (a one-code-point JS string).
2479            ("Char", "to_upper") => format!("({recv_str}).toUpperCase()"),
2480            ("Char", "to_lower") => format!("({recv_str}).toLowerCase()"),
2481            ("Char", "is_alpha") => format!("(/\\p{{L}}/u.test({recv_str}))"),
2482            ("Char", "is_digit") => format!("(/[0-9]/.test({recv_str}))"),
2483            ("Char", "is_whitespace") => format!("(/\\s/.test({recv_str}))"),
2484            _ => return Ok(false),
2485        };
2486        self.buf.push_str(&code);
2487        Ok(true)
2488    }
2489
2490    fn try_emit_primitive_bridge(
2491        &mut self,
2492        node: &AIRNode,
2493        callee: &AIRNode,
2494        args: &[bock_air::AirArg],
2495    ) -> Result<bool, CodegenError> {
2496        let Some((recv, method, rest, _prim)) =
2497            crate::generator::primitive_bridge_call(node, callee, args)
2498        else {
2499            return Ok(false);
2500        };
2501        self.emit_bridge_method(recv, method, rest)
2502    }
2503
2504    /// Lower a sealed-core-trait bridge method on a *bounded generic type
2505    /// variable* (`a.eq(b)` / `a.compare(b)` inside `eq_check[T: Equatable]`) to
2506    /// its JS form (GAP-C). JS generics are erased, so only the method call needs
2507    /// lowering — `a.eq(b)` becomes `a === b`, etc. — and the body is identical to
2508    /// the `Primitive:<Ty>` bridge. Fires only when the bound trait is sealed-core
2509    /// and NOT a user-declared trait (a user trait's `impl` provides the method).
2510    fn try_emit_trait_bound_bridge(
2511        &mut self,
2512        node: &AIRNode,
2513        callee: &AIRNode,
2514        args: &[bock_air::AirArg],
2515    ) -> Result<bool, CodegenError> {
2516        let Some((recv, method, rest, _tr)) =
2517            crate::generator::trait_bound_bridge_call(node, callee, args, &self.trait_decls)
2518        else {
2519            return Ok(false);
2520        };
2521        // DQ29: unlike the `Primitive:<Ty>` bridge (whose receiver is a known
2522        // scalar, where `===` is correct), a bounded `T: Equatable` receiver
2523        // may be instantiated with a RECORD — JS `===` would be reference
2524        // identity — so `a.eq(b)` lowers through the `__bockEq` structural
2525        // helper, which falls back to `===` for primitives.
2526        if method == "eq" {
2527            let Some(other) = rest.first() else {
2528                return Ok(false);
2529            };
2530            let recv_str = self.expr_to_string(recv)?;
2531            let other = self.expr_to_string(&other.value)?;
2532            let _ = write!(self.buf, "__bockEq({recv_str}, {other})");
2533            return Ok(true);
2534        }
2535        // Q-bounded-comparable-codegen: a bounded `T: Comparable` `compare`
2536        // receiver may be instantiated with a RECORD whose ordering lives in its
2537        // own `compare` method — the native `<`/`===` ternary the
2538        // `emit_bridge_method` `compare` arm emits is correct ONLY for a
2539        // primitive instantiation. Route through `__bockCompare`, which calls the
2540        // value's `compare` method when present and falls back to the native
2541        // ternary for primitives.
2542        if method == "compare" {
2543            let Some(other) = rest.first() else {
2544                return Ok(false);
2545            };
2546            let recv_str = self.expr_to_string(recv)?;
2547            let other = self.expr_to_string(&other.value)?;
2548            self.needs_runtime_compare = true;
2549            let _ = write!(self.buf, "__bockCompare({recv_str}, {other})");
2550            return Ok(true);
2551        }
2552        self.emit_bridge_method(recv, method, rest)
2553    }
2554
2555    /// Shared body of the primitive / trait-bound bridges: emit the native JS form
2556    /// of `compare` (the `Ordering` ternary), `eq` (`===`), or `to_string`/
2557    /// `display` (`String(..)`).
2558    fn emit_bridge_method(
2559        &mut self,
2560        recv: &AIRNode,
2561        method: &str,
2562        rest: &[bock_air::AirArg],
2563    ) -> Result<bool, CodegenError> {
2564        let recv_str = self.expr_to_string(recv)?;
2565        match method {
2566            "compare" => {
2567                let Some(other) = rest.first() else {
2568                    return Ok(false);
2569                };
2570                let other = self.expr_to_string(&other.value)?;
2571                let _ = write!(
2572                    self.buf,
2573                    "(({recv_str}) < ({other}) ? {{ _tag: \"Less\" }} : \
2574                     (({recv_str}) === ({other}) ? {{ _tag: \"Equal\" }} : {{ _tag: \"Greater\" }}))"
2575                );
2576            }
2577            "eq" => {
2578                let Some(other) = rest.first() else {
2579                    return Ok(false);
2580                };
2581                let other = self.expr_to_string(&other.value)?;
2582                let _ = write!(self.buf, "(({recv_str}) === ({other}))");
2583            }
2584            "to_string" | "display" => {
2585                let _ = write!(self.buf, "String({recv_str})");
2586            }
2587            _ => return Ok(false),
2588        }
2589        Ok(true)
2590    }
2591
2592    // ── Top-level dispatch ──────────────────────────────────────────────────
2593
2594    fn emit_node(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
2595        self.mark_span(node.span);
2596        match &node.kind {
2597            NodeKind::Module { items, imports, .. } => {
2598                // Field/method name-collision set (camelCased, to match the
2599                // method-name casing). A method whose camelCased name equals a
2600                // field name is renamed via `js_method_name`. In the per-module
2601                // path this is pre-seeded program-wide by `generate_project` so a
2602                // call site in one file agrees with the renamed method declared
2603                // in another; we *extend* here so the single-module
2604                // `generate_module` path (no pre-seed) is also covered.
2605                self.field_method_collisions
2606                    .extend(crate::generator::collect_record_field_names(
2607                        node,
2608                        to_camel_case,
2609                    ));
2610                if self.per_module {
2611                    // Per-module native-import path (the real build): each module
2612                    // is emitted to its own `.js` file and the runtime helpers
2613                    // live in the shared `_bock_runtime.js`. Record which runtime
2614                    // helpers this module references; `generate_project` emits
2615                    // them once into the shared module, and `emit_esm_imports`
2616                    // imports the referenced names here.
2617                    if self.module_uses_concurrency(items) {
2618                        self.needs_runtime_concurrency = true;
2619                    }
2620                    if js_module_uses_range(items) {
2621                        self.needs_runtime_range = true;
2622                    }
2623                    if js_module_uses_eq(items) {
2624                        self.needs_runtime_eq = true;
2625                    }
2626                    if js_module_uses_compare(items) {
2627                        self.needs_runtime_compare = true;
2628                    }
2629                    if js_module_uses_str(items) {
2630                        self.needs_runtime_str = true;
2631                    }
2632                    // Real ESM imports (runtime, explicit `use`, implicit prelude)
2633                    // at the top of the file, before any declaration.
2634                    self.emit_esm_imports(imports)?;
2635                } else {
2636                    // Single-module self-contained emit (`generate_module`, used
2637                    // by unit tests): the module's runtime preludes are inlined
2638                    // into this one file and `ImportDecl`s are dropped. The
2639                    // concurrency / range runtimes are inlined at most once,
2640                    // gated on a ctx flag.
2641                    if !self.concurrency_runtime_emitted && self.module_uses_concurrency(items) {
2642                        self.buf.push_str(CONCURRENCY_RUNTIME_JS);
2643                        self.buf.push('\n');
2644                        self.concurrency_runtime_emitted = true;
2645                    }
2646                    if !self.range_runtime_emitted && js_module_uses_range(items) {
2647                        self.buf.push_str(RANGE_RUNTIME_JS);
2648                        self.buf.push('\n');
2649                        self.range_runtime_emitted = true;
2650                    }
2651                    if !self.eq_runtime_emitted && js_module_uses_eq(items) {
2652                        self.buf.push_str(EQ_RUNTIME_JS);
2653                        self.buf.push('\n');
2654                        self.eq_runtime_emitted = true;
2655                    }
2656                    if !self.compare_runtime_emitted && js_module_uses_compare(items) {
2657                        self.buf.push_str(COMPARE_RUNTIME_JS);
2658                        self.buf.push('\n');
2659                        self.compare_runtime_emitted = true;
2660                    }
2661                    if !self.str_runtime_emitted && js_module_uses_str(items) {
2662                        self.buf.push_str(STR_RUNTIME_JS);
2663                        self.buf.push('\n');
2664                        self.str_runtime_emitted = true;
2665                    }
2666                }
2667                // `@test` functions are transpiled separately into Vitest/Jest
2668                // test files (project mode, §20.6.2 — see `generate_tests`), never
2669                // into the runtime module tree: their `expect(...)` assertion DSL
2670                // has no runtime definition in the emitted source.
2671                let mut first = true;
2672                for item in items.iter() {
2673                    if crate::generator::fn_is_test(item) {
2674                        continue;
2675                    }
2676                    if !first {
2677                        self.buf.push('\n');
2678                    }
2679                    first = false;
2680                    self.emit_node(item)?;
2681                }
2682                // Per-module path: re-export the public non-function declarations
2683                // (functions export inline). Emitted once after all items.
2684                if self.per_module {
2685                    self.emit_trailing_exports();
2686                }
2687                Ok(())
2688            }
2689            NodeKind::ImportDecl { .. } => {
2690                // Bock `use` is resolved by the real ESM imports emitted up front
2691                // by `emit_esm_imports` from the `Module` arm (per-module path),
2692                // or dropped entirely in the single-module self-contained path.
2693                // Either way, the per-item visit here is a no-op.
2694                Ok(())
2695            }
2696            NodeKind::FnDecl {
2697                visibility,
2698                is_async,
2699                name,
2700                params,
2701                effect_clause,
2702                body,
2703                ..
2704            } => self.emit_fn_decl(
2705                *visibility,
2706                *is_async,
2707                &name.name,
2708                params,
2709                effect_clause,
2710                body,
2711                false,
2712            ),
2713            NodeKind::RecordDecl { name, fields, .. } => {
2714                // Record → class (supports prototype-based `impl` method attachment).
2715                self.record_names.insert(name.name.clone());
2716                if fields.is_empty() {
2717                    self.writeln(&format!("class {} {{}}", name.name));
2718                } else {
2719                    let field_names: Vec<&str> =
2720                        fields.iter().map(|f| f.name.name.as_str()).collect();
2721                    self.writeln(&format!("class {} {{", name.name));
2722                    self.indent += 1;
2723                    self.writeln(&format!("constructor({{ {} }}) {{", field_names.join(", "),));
2724                    self.indent += 1;
2725                    for f in &field_names {
2726                        self.writeln(&format!("this.{f} = {f};"));
2727                    }
2728                    self.indent -= 1;
2729                    self.writeln("}");
2730                    self.indent -= 1;
2731                    self.writeln("}");
2732                }
2733                Ok(())
2734            }
2735            NodeKind::EnumDecl { name, variants, .. } => {
2736                // ADTs → tagged object factory functions.
2737                for variant in variants {
2738                    self.emit_enum_variant(&name.name, variant)?;
2739                }
2740                Ok(())
2741            }
2742            NodeKind::ClassDecl {
2743                name,
2744                fields,
2745                methods,
2746                ..
2747            } => {
2748                // Register the class's positional field order so a `class`
2749                // literal lowers to `new Name(...)` (see `class_fields`). A
2750                // pre-pass already seeds this across the reachable set; re-record
2751                // here so the single-module emit path is correct even when the
2752                // pre-pass is not run.
2753                self.class_fields.insert(
2754                    name.name.clone(),
2755                    fields.iter().map(|f| f.name.name.clone()).collect(),
2756                );
2757                self.writeln(&format!("class {} {{", name.name));
2758                self.indent += 1;
2759                // Constructor
2760                let field_names: Vec<&str> = fields.iter().map(|f| f.name.name.as_str()).collect();
2761                self.writeln(&format!("constructor({}) {{", field_names.join(", ")));
2762                self.indent += 1;
2763                for f in &field_names {
2764                    self.writeln(&format!("this.{f} = {f};"));
2765                }
2766                self.indent -= 1;
2767                self.writeln("}");
2768                // Methods
2769                for method in methods {
2770                    self.buf.push('\n');
2771                    self.emit_class_method(method)?;
2772                }
2773                self.indent -= 1;
2774                self.writeln("}");
2775                Ok(())
2776            }
2777            NodeKind::TraitDecl { name, methods, .. } => {
2778                // Traits → comment + method stubs as a "mixin" object.
2779                self.writeln(&format!("// trait {}", name.name));
2780                self.writeln(&format!("const {} = {{", name.name));
2781                self.indent += 1;
2782                for (i, method) in methods.iter().enumerate() {
2783                    if i > 0 {
2784                        self.buf.push('\n');
2785                    }
2786                    if let NodeKind::FnDecl {
2787                        name, params, body, ..
2788                    } = &method.kind
2789                    {
2790                        let param_names = self.collect_param_names(params);
2791                        self.writeln(&format!("{}({}) {{", name.name, param_names.join(", ")));
2792                        self.indent += 1;
2793                        self.emit_block_body(body)?;
2794                        self.indent -= 1;
2795                        self.writeln("},");
2796                    }
2797                }
2798                self.indent -= 1;
2799                self.writeln("};");
2800                Ok(())
2801            }
2802            NodeKind::ImplBlock {
2803                trait_path,
2804                target,
2805                methods,
2806                ..
2807            } => {
2808                // impl → comment + attach methods to prototype.
2809                let target_name = self.type_expr_to_string(target);
2810                if let Some(tp) = trait_path {
2811                    let trait_name = tp
2812                        .segments
2813                        .iter()
2814                        .map(|s| s.name.as_str())
2815                        .collect::<Vec<_>>()
2816                        .join(".");
2817                    self.writeln(&format!("// impl {trait_name} for {target_name}"));
2818                } else {
2819                    self.writeln(&format!("// impl {target_name}"));
2820                }
2821                // Trait default methods (codegen-completeness P2): synthesize
2822                // every default method the impl does not override onto the
2823                // target's prototype, alongside the impl's own methods. JS is
2824                // untyped, so a default method emits identically to an impl
2825                // method; a default body that calls another trait method via
2826                // `self.other(self, ...)` resolves through the same prototype.
2827                let default_methods: Vec<AIRNode> = trait_path
2828                    .as_ref()
2829                    .map(|tp| {
2830                        crate::generator::inherited_default_methods(&self.trait_decls, tp, methods)
2831                    })
2832                    .unwrap_or_default();
2833                for method in methods.iter().chain(default_methods.iter()) {
2834                    if let NodeKind::FnDecl {
2835                        is_async,
2836                        name,
2837                        params,
2838                        effect_clause,
2839                        body,
2840                        ..
2841                    } = &method.kind
2842                    {
2843                        let async_kw = if *is_async { "async " } else { "" };
2844                        let param_names = self.collect_param_names(params);
2845                        let effects_param = self.effects_param(effect_clause);
2846                        let mut all_params = param_names;
2847                        if let Some(ep) = effects_param {
2848                            all_params.push(ep);
2849                        }
2850                        // An associated function (no `self` receiver, e.g. a
2851                        // `From` impl's `from`) is a *static* method on the
2852                        // class object, reached as `Type.method(...)`. An
2853                        // instance method attaches to the prototype. Emitting an
2854                        // associated fn on the prototype would make
2855                        // `Type.method` undefined at the call site.
2856                        let attach = if crate::generator::is_associated_impl_method(
2857                            method,
2858                            &self.effect_ops,
2859                        ) {
2860                            ""
2861                        } else {
2862                            ".prototype"
2863                        };
2864                        self.writeln(&format!(
2865                            "{target_name}{attach}.{} = {async_kw}function({}) {{",
2866                            self.js_method_name(&name.name),
2867                            all_params.join(", "),
2868                        ));
2869                        self.indent += 1;
2870                        let old_handler_vars = self.current_handler_vars.clone();
2871                        let expanded = self.expand_effect_names(effect_clause);
2872                        for ename in &expanded {
2873                            self.current_handler_vars
2874                                .insert(ename.clone(), to_camel_case(ename));
2875                        }
2876                        self.emit_block_body(body)?;
2877                        self.current_handler_vars = old_handler_vars;
2878                        self.indent -= 1;
2879                        self.writeln("};");
2880                    }
2881                }
2882                Ok(())
2883            }
2884            NodeKind::EffectDecl {
2885                name,
2886                components,
2887                operations,
2888                ..
2889            } => {
2890                // Composite effect: register expansion and emit comment.
2891                if !components.is_empty() {
2892                    let comp_names: Vec<String> = components
2893                        .iter()
2894                        .map(|tp| {
2895                            tp.segments
2896                                .last()
2897                                .map_or("effect".to_string(), |s| s.name.clone())
2898                        })
2899                        .collect();
2900                    self.writeln(&format!(
2901                        "// composite effect {} = {}",
2902                        name.name,
2903                        comp_names.join(" + ")
2904                    ));
2905                    // A composite effect is a compile-time grouping with no
2906                    // runtime representation (functions expand it to its
2907                    // component handler params). But a *public* composite
2908                    // effect appears in this module's `export { … }` list, so
2909                    // it still needs a concrete binding to export — otherwise
2910                    // the ESM export references an undefined name. Emit a frozen
2911                    // marker object recording the component names.
2912                    let marker = comp_names
2913                        .iter()
2914                        .map(|c| format!("\"{c}\""))
2915                        .collect::<Vec<_>>()
2916                        .join(", ");
2917                    self.writeln(&format!(
2918                        "const {} = Object.freeze({{ __composite: [{}] }});",
2919                        name.name, marker
2920                    ));
2921                    self.composite_effects.insert(name.name.clone(), comp_names);
2922                    return Ok(());
2923                }
2924                // Record effect operations for Call → handler.op rewriting.
2925                for op in operations {
2926                    if let NodeKind::FnDecl { name: op_name, .. } = &op.kind {
2927                        self.effect_ops
2928                            .insert(op_name.name.clone(), name.name.clone());
2929                    }
2930                }
2931                // Effects → abstract class with methods that throw.
2932                self.writeln(&format!("class {} {{", name.name));
2933                self.indent += 1;
2934                for op in operations {
2935                    if let NodeKind::FnDecl { name, params, .. } = &op.kind {
2936                        let param_names = self.collect_param_names(params);
2937                        self.writeln(&format!(
2938                            "{}({}) {{",
2939                            to_camel_case(&name.name),
2940                            param_names.join(", "),
2941                        ));
2942                        self.indent += 1;
2943                        self.writeln("throw new Error(\"not implemented\");");
2944                        self.indent -= 1;
2945                        self.writeln("}");
2946                    }
2947                }
2948                self.indent -= 1;
2949                self.writeln("}");
2950                Ok(())
2951            }
2952            NodeKind::TypeAlias { name, .. } => {
2953                // Type aliases are erased in JS.
2954                self.writeln(&format!("// type {} = ...", name.name));
2955                Ok(())
2956            }
2957            NodeKind::ConstDecl { name, value, .. } => {
2958                let ind = self.indent_str();
2959                let _ = write!(self.buf, "{ind}const {} = ", name.name);
2960                self.emit_expr(value)?;
2961                self.buf.push_str(";\n");
2962                Ok(())
2963            }
2964            NodeKind::ModuleHandle { effect, handler } => {
2965                let effect_name = effect.segments.last().map_or("effect", |s| s.name.as_str());
2966                let var_name = format!("__{}", to_camel_case(effect_name));
2967                let ind = self.indent_str();
2968                let _ = write!(self.buf, "{ind}const {var_name} = ");
2969                self.emit_expr(handler)?;
2970                self.buf.push_str(";\n");
2971                // Register as ambient handler so same-module calls pick it up.
2972                self.current_handler_vars
2973                    .insert(effect_name.to_string(), var_name);
2974                Ok(())
2975            }
2976            NodeKind::PropertyTest { name, body, .. } => {
2977                self.writeln(&format!("// property test: {name}"));
2978                self.writeln("// (property tests are not emitted in JS output)");
2979                let _ = body;
2980                Ok(())
2981            }
2982            // Statement / expression nodes at top level:
2983            NodeKind::LetBinding { .. }
2984            | NodeKind::If { .. }
2985            | NodeKind::For { .. }
2986            | NodeKind::While { .. }
2987            | NodeKind::Loop { .. }
2988            | NodeKind::Return { .. }
2989            | NodeKind::Break { .. }
2990            | NodeKind::Continue
2991            | NodeKind::Guard { .. }
2992            | NodeKind::Match { .. }
2993            | NodeKind::Block { .. }
2994            | NodeKind::HandlingBlock { .. }
2995            | NodeKind::Assign { .. } => self.emit_stmt(node),
2996            // Expression nodes that appear as statements:
2997            _ => {
2998                // A `?` in this statement-position expression (e.g. a bare
2999                // `save(x)?`) hoists to a pre-statement temp + early-return.
3000                let only_propagate = self.hoist_propagates(node)?
3001                    && matches!(&node.kind, NodeKind::Propagate { .. });
3002                // A bare `expr?` statement's whole value is the hoisted temp's
3003                // payload; once hoisted (and the success path falls through),
3004                // there is nothing left to emit as its own statement.
3005                if only_propagate {
3006                    return Ok(());
3007                }
3008                self.write_indent();
3009                self.emit_expr(node)?;
3010                self.buf.push_str(";\n");
3011                Ok(())
3012            }
3013        }
3014    }
3015
3016    // ── Function declarations ───────────────────────────────────────────────
3017
3018    #[allow(clippy::too_many_arguments)]
3019    fn emit_fn_decl(
3020        &mut self,
3021        visibility: Visibility,
3022        is_async: bool,
3023        name: &str,
3024        params: &[AIRNode],
3025        effect_clause: &[bock_ast::TypePath],
3026        body: &AIRNode,
3027        _is_method: bool,
3028    ) -> Result<(), CodegenError> {
3029        let export = if matches!(visibility, Visibility::Public) {
3030            "export "
3031        } else {
3032            ""
3033        };
3034        let async_kw = if is_async { "async " } else { "" };
3035        let param_names = self.collect_param_names(params);
3036        let effects_param = self.effects_param(effect_clause);
3037        let mut all_params = param_names;
3038        if let Some(ep) = effects_param {
3039            all_params.push(ep);
3040        }
3041        if !effect_clause.is_empty() {
3042            let effect_names = self.expand_effect_names(effect_clause);
3043            self.fn_effects.insert(name.to_string(), effect_names);
3044        }
3045        let js_name = js_value_ident(name);
3046        self.writeln(&format!(
3047            "{export}{async_kw}function {js_name}({}) {{",
3048            all_params.join(", "),
3049        ));
3050        self.indent += 1;
3051        let old_handler_vars = self.current_handler_vars.clone();
3052        let expanded = self.expand_effect_names(effect_clause);
3053        for ename in &expanded {
3054            self.current_handler_vars
3055                .insert(ename.clone(), to_camel_case(ename));
3056        }
3057        self.emit_fn_body_seeded(params, body)?;
3058        self.current_handler_vars = old_handler_vars;
3059        self.indent -= 1;
3060        self.writeln("}");
3061        Ok(())
3062    }
3063
3064    fn emit_class_method(&mut self, method: &AIRNode) -> Result<(), CodegenError> {
3065        if let NodeKind::FnDecl {
3066            is_async,
3067            name,
3068            params,
3069            effect_clause,
3070            body,
3071            ..
3072        } = &method.kind
3073        {
3074            let async_kw = if *is_async { "async " } else { "" };
3075            let param_names = self.collect_param_names(params);
3076            let effects_param = self.effects_param(effect_clause);
3077            let mut all_params = param_names;
3078            if let Some(ep) = effects_param {
3079                all_params.push(ep);
3080            }
3081            let method_name = self.js_method_name(&to_camel_case(&name.name));
3082            self.writeln(&format!(
3083                "{async_kw}{method_name}({}) {{",
3084                all_params.join(", "),
3085            ));
3086            self.indent += 1;
3087            let old_handler_vars = self.current_handler_vars.clone();
3088            let expanded = self.expand_effect_names(effect_clause);
3089            for ename in &expanded {
3090                self.current_handler_vars
3091                    .insert(ename.clone(), to_camel_case(ename));
3092            }
3093            self.emit_fn_body_seeded(params, body)?;
3094            self.current_handler_vars = old_handler_vars;
3095            self.indent -= 1;
3096            self.writeln("}");
3097        }
3098        Ok(())
3099    }
3100
3101    fn collect_param_names(&self, params: &[AIRNode]) -> Vec<String> {
3102        params
3103            .iter()
3104            .filter_map(|p| {
3105                if let NodeKind::Param {
3106                    pattern, default, ..
3107                } = &p.kind
3108                {
3109                    let name = self.pattern_to_binding_name(pattern);
3110                    if let Some(def) = default {
3111                        let mut ctx = EmitCtx::new();
3112                        ctx.indent = self.indent;
3113                        ctx.enum_variants = self.enum_variants.clone();
3114                        if ctx.emit_expr_to_string(def).is_ok() {
3115                            let (def_str, _) = ctx.finish();
3116                            return Some(format!("{name} = {def_str}"));
3117                        }
3118                    }
3119                    Some(name)
3120                } else {
3121                    None
3122                }
3123            })
3124            .collect()
3125    }
3126
3127    fn emit_expr_to_string(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
3128        self.emit_expr(node)
3129    }
3130
3131    /// Expand effect names, replacing composite effects with their components.
3132    fn expand_effect_names(&self, effects: &[bock_ast::TypePath]) -> Vec<String> {
3133        let mut result = Vec::new();
3134        for tp in effects {
3135            let name = tp
3136                .segments
3137                .last()
3138                .map_or("effect".to_string(), |s| s.name.clone());
3139            if let Some(components) = self.composite_effects.get(&name) {
3140                result.extend(components.iter().cloned());
3141            } else {
3142                result.push(name);
3143            }
3144        }
3145        result
3146    }
3147
3148    /// The in-scope `Clock` effect handler variable, if one is installed.
3149    ///
3150    /// When `Some`, the `Clock` time operations (`Instant.now`, `sleep`,
3151    /// `elapsed`) are routed through the handler instead of inlining the host
3152    /// primitive (Q-clock-handler-routing, §18.3.1/§18.4); when `None`, no
3153    /// handler is in scope and the default host primitive is emitted.
3154    fn clock_handler_var(&self) -> Option<&str> {
3155        self.current_handler_vars.get("Clock").map(String::as_str)
3156    }
3157
3158    /// Effects → destructured parameter object: `{ log, clock }`.
3159    fn effects_param(&self, effects: &[bock_ast::TypePath]) -> Option<String> {
3160        if effects.is_empty() {
3161            return None;
3162        }
3163        let expanded = self.expand_effect_names(effects);
3164        if expanded.is_empty() {
3165            return None;
3166        }
3167        let names: Vec<String> = expanded.iter().map(|n| to_camel_case(n)).collect();
3168        Some(format!("{{ {} }}", names.join(", ")))
3169    }
3170
3171    /// Build a `{ effect: handler_var, ... }` argument for calling an effectful function.
3172    /// Returns `None` if the callee has no registered effects or no handlers are in scope.
3173    fn build_effects_call_arg_js(&self, fn_name: &str) -> Option<String> {
3174        let effects = self.fn_effects.get(fn_name)?;
3175        let entries: Vec<String> = effects
3176            .iter()
3177            .filter_map(|e| {
3178                let handler_var = self.current_handler_vars.get(e)?;
3179                let param_name = to_camel_case(e);
3180                Some(format!("{param_name}: {handler_var}"))
3181            })
3182            .collect();
3183        if entries.is_empty() {
3184            return None;
3185        }
3186        Some(format!("{{ {} }}", entries.join(", ")))
3187    }
3188
3189    // ── Enum variant factories ──────────────────────────────────────────────
3190
3191    fn emit_enum_variant(
3192        &mut self,
3193        enum_name: &str,
3194        variant: &AIRNode,
3195    ) -> Result<(), CodegenError> {
3196        if let NodeKind::EnumVariant { name, payload } = &variant.kind {
3197            let vname = &name.name;
3198            match payload {
3199                EnumVariantPayload::Unit => {
3200                    self.writeln(&format!(
3201                        "const {enum_name}_{vname} = Object.freeze({{ _tag: \"{vname}\" }});"
3202                    ));
3203                }
3204                EnumVariantPayload::Struct(fields) => {
3205                    let field_names: Vec<&str> =
3206                        fields.iter().map(|f| f.name.name.as_str()).collect();
3207                    self.writeln(&format!(
3208                        "function {enum_name}_{vname}({}) {{",
3209                        field_names.join(", ")
3210                    ));
3211                    self.indent += 1;
3212                    self.writeln(&format!(
3213                        "return {{ _tag: \"{vname}\", {} }};",
3214                        field_names.join(", ")
3215                    ));
3216                    self.indent -= 1;
3217                    self.writeln("}");
3218                }
3219                EnumVariantPayload::Tuple(elems) => {
3220                    let param_names: Vec<String> =
3221                        (0..elems.len()).map(|i| format!("_{i}")).collect();
3222                    self.writeln(&format!(
3223                        "function {enum_name}_{vname}({}) {{",
3224                        param_names.join(", ")
3225                    ));
3226                    self.indent += 1;
3227                    self.writeln(&format!(
3228                        "return {{ _tag: \"{vname}\", {} }};",
3229                        param_names
3230                            .iter()
3231                            .enumerate()
3232                            .map(|(i, p)| format!("_{i}: {p}"))
3233                            .collect::<Vec<_>>()
3234                            .join(", ")
3235                    ));
3236                    self.indent -= 1;
3237                    self.writeln("}");
3238                }
3239            }
3240        }
3241        Ok(())
3242    }
3243
3244    // ── Statements ──────────────────────────────────────────────────────────
3245
3246    fn emit_stmt(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
3247        self.mark_span(node.span);
3248        // Hoist any `?` in this statement's value into pre-statement temps +
3249        // early-returns (see `hoist_propagates`). The value-carrying statement
3250        // arms (`let`/assign/return/expr-stmt) all funnel through here.
3251        self.hoist_propagates(node)?;
3252        match &node.kind {
3253            NodeKind::LetBinding {
3254                is_mut,
3255                pattern,
3256                value,
3257                ..
3258            } => {
3259                // Declare-only temp from the shared value-CF hoist: emit a bare
3260                // `let name;` (no initialiser); the relocated control flow that
3261                // follows assigns it on every non-diverging path.
3262                if node.metadata.contains_key(crate::generator::DECL_ONLY_META) {
3263                    if let NodeKind::BindPat { name, .. } = &pattern.kind {
3264                        let ind = self.indent_str();
3265                        let js_name = js_value_ident(&name.name);
3266                        self.mark_simple_let_declared(&js_name);
3267                        let _ = writeln!(self.buf, "{ind}let {js_name};");
3268                        return Ok(());
3269                    }
3270                }
3271                let ind = self.indent_str();
3272                // A simple `let name = …` is subject to JS redeclaration rules.
3273                // Bock allows re-binding the same name in one scope (shadowing);
3274                // JS does not, so the second-and-later binding of a simple name
3275                // becomes a plain assignment, and the first declaration uses
3276                // `let` (not `const`) when the name is later re-bound/assigned.
3277                if let NodeKind::BindPat { name, .. } = &pattern.kind {
3278                    let js_name = js_value_ident(&name.name);
3279                    if self.simple_let_redeclared(&js_name) {
3280                        let _ = write!(self.buf, "{ind}{js_name} = ");
3281                        self.emit_expr(value)?;
3282                        self.buf.push_str(";\n");
3283                        return Ok(());
3284                    }
3285                    let needs_let = *is_mut || self.simple_let_needs_let(&js_name);
3286                    let kw = if needs_let { "let" } else { "const" };
3287                    self.mark_simple_let_declared(&js_name);
3288                    let _ = write!(self.buf, "{ind}{kw} {js_name} = ");
3289                    self.emit_expr(value)?;
3290                    self.buf.push_str(";\n");
3291                    return Ok(());
3292                }
3293                let kw = if *is_mut { "let" } else { "const" };
3294                let binding = self.pattern_to_js_destructure(pattern);
3295                let _ = write!(self.buf, "{ind}{kw} {binding} = ");
3296                self.emit_expr(value)?;
3297                self.buf.push_str(";\n");
3298                Ok(())
3299            }
3300            NodeKind::If {
3301                let_pattern,
3302                condition,
3303                then_block,
3304                else_block,
3305            } => {
3306                if let Some(pat) = let_pattern {
3307                    // if-let → check + destructure
3308                    let ind = self.indent_str();
3309                    let _ = write!(self.buf, "{ind}if (");
3310                    self.emit_expr(condition)?;
3311                    self.buf.push_str(" != null) {\n");
3312                    self.indent += 1;
3313                    let binding = self.pattern_to_js_destructure(pat);
3314                    self.writeln(&format!("const {binding} = "));
3315                    // Fix: remove trailing newline, add the condition expr
3316                    // Actually, for if-let, condition is the value being matched.
3317                    // We'll just emit the block body.
3318                    self.emit_block_body(then_block)?;
3319                    self.indent -= 1;
3320                } else {
3321                    let ind = self.indent_str();
3322                    let _ = write!(self.buf, "{ind}if (");
3323                    self.emit_expr(condition)?;
3324                    self.buf.push_str(") {\n");
3325                    self.indent += 1;
3326                    self.emit_block_body(then_block)?;
3327                    self.indent -= 1;
3328                }
3329                if let Some(else_b) = else_block {
3330                    if matches!(else_b.kind, NodeKind::If { .. }) {
3331                        let ind = self.indent_str();
3332                        let _ = write!(self.buf, "{ind}}} else ");
3333                        // Emit the else-if inline (no indent push for the `if` keyword).
3334                        self.emit_stmt(else_b)?;
3335                        return Ok(());
3336                    }
3337                    self.writeln("} else {");
3338                    self.indent += 1;
3339                    self.emit_block_body(else_b)?;
3340                    self.indent -= 1;
3341                }
3342                self.writeln("}");
3343                Ok(())
3344            }
3345            NodeKind::For {
3346                pattern,
3347                iterable,
3348                body,
3349            } => {
3350                let binding = self.pattern_to_js_destructure(pattern);
3351                self.emit_loop_label_prefix(body);
3352                let ind = self.indent_str();
3353                let _ = write!(self.buf, "{ind}for (const {binding} of ");
3354                self.emit_expr(iterable)?;
3355                self.buf.push_str(") {\n");
3356                self.indent += 1;
3357                self.emit_loop_body(body)?;
3358                self.indent -= 1;
3359                self.writeln("}");
3360                self.loop_labels.pop();
3361                Ok(())
3362            }
3363            NodeKind::While { condition, body } => {
3364                self.emit_loop_label_prefix(body);
3365                let ind = self.indent_str();
3366                let _ = write!(self.buf, "{ind}while (");
3367                self.emit_expr(condition)?;
3368                self.buf.push_str(") {\n");
3369                self.indent += 1;
3370                self.emit_loop_body(body)?;
3371                self.indent -= 1;
3372                self.writeln("}");
3373                self.loop_labels.pop();
3374                Ok(())
3375            }
3376            NodeKind::Loop { body } => {
3377                self.emit_loop_label_prefix(body);
3378                self.writeln("while (true) {");
3379                self.indent += 1;
3380                self.emit_loop_body(body)?;
3381                self.indent -= 1;
3382                self.writeln("}");
3383                self.loop_labels.pop();
3384                Ok(())
3385            }
3386            NodeKind::Return { value } => {
3387                if let Some(val) = value {
3388                    let ind = self.indent_str();
3389                    let _ = write!(self.buf, "{ind}return ");
3390                    self.emit_expr(val)?;
3391                    self.buf.push_str(";\n");
3392                } else {
3393                    self.writeln("return;");
3394                }
3395                Ok(())
3396            }
3397            NodeKind::Break { value } => {
3398                if let Some(val) = value {
3399                    // JS break doesn't support values; emit as comment + break.
3400                    let ind = self.indent_str();
3401                    let _ = write!(self.buf, "{ind}/* break value: ");
3402                    self.emit_expr(val)?;
3403                    self.buf.push_str(" */\n");
3404                }
3405                // Inside a statement-arm `switch`, a bare `break` exits the
3406                // switch; target the enclosing loop label instead.
3407                if self.switch_label_depth > 0 {
3408                    if let Some(label) = self.innermost_loop_label() {
3409                        self.writeln(&format!("break {label};"));
3410                        return Ok(());
3411                    }
3412                }
3413                self.writeln("break;");
3414                Ok(())
3415            }
3416            NodeKind::Continue => {
3417                if self.switch_label_depth > 0 {
3418                    if let Some(label) = self.innermost_loop_label() {
3419                        self.writeln(&format!("continue {label};"));
3420                        return Ok(());
3421                    }
3422                }
3423                self.writeln("continue;");
3424                Ok(())
3425            }
3426            NodeKind::Guard {
3427                let_pattern,
3428                condition,
3429                else_block,
3430            } => {
3431                if let Some(pat) = let_pattern {
3432                    // `guard (let pat = expr) else { … }`: evaluate `expr` once,
3433                    // run the else (which must diverge) when `pat` does not
3434                    // match, then bind `pat`'s names into the *enclosing* scope
3435                    // so they are in scope for the statements after the guard.
3436                    self.match_temp_counter += 1;
3437                    let tmp = format!("__guard{}", self.match_temp_counter);
3438                    let ind = self.indent_str();
3439                    let _ = write!(self.buf, "{ind}const {tmp} = ");
3440                    self.emit_expr(condition)?;
3441                    self.buf.push_str(";\n");
3442                    let test = self.pattern_test_js(pat, &tmp);
3443                    // A bare bind / wildcard pattern always matches → no `if`.
3444                    if !test.is_empty() {
3445                        let ind = self.indent_str();
3446                        let _ = writeln!(self.buf, "{ind}if (!({test})) {{");
3447                        self.indent += 1;
3448                        self.emit_block_body(else_block)?;
3449                        self.indent -= 1;
3450                        self.writeln("}");
3451                    }
3452                    // Bindings land in the enclosing scope (no nested block).
3453                    self.pattern_binds_js(pat, &tmp)?;
3454                } else {
3455                    let ind = self.indent_str();
3456                    let _ = write!(self.buf, "{ind}if (!(");
3457                    self.emit_expr(condition)?;
3458                    self.buf.push_str(")) {\n");
3459                    self.indent += 1;
3460                    self.emit_block_body(else_block)?;
3461                    self.indent -= 1;
3462                    self.writeln("}");
3463                }
3464                Ok(())
3465            }
3466            NodeKind::Match { scrutinee, arms } => self.emit_match(scrutinee, arms),
3467            NodeKind::Block { stmts, tail } => {
3468                // A statement-position block is its own JS `{}` lexical scope, so
3469                // it gets its own `let` scope frame (a name re-bound inside is
3470                // independent of the enclosing block's bindings).
3471                self.writeln("{");
3472                self.indent += 1;
3473                self.enter_let_scope(node);
3474                for s in stmts {
3475                    self.emit_node(s)?;
3476                }
3477                if let Some(t) = tail {
3478                    self.write_indent();
3479                    self.emit_expr(t)?;
3480                    self.buf.push_str(";\n");
3481                }
3482                self.leave_let_scope();
3483                self.indent -= 1;
3484                self.writeln("}");
3485                Ok(())
3486            }
3487            NodeKind::HandlingBlock { handlers, body } => {
3488                // handling block → scoped handler instantiation. The emitted
3489                // `{ … }` is its own JS lexical block, so it gets a fresh `let`
3490                // scope frame: a name first bound in one `handling` block and
3491                // re-bound in a *sibling* `handling` block is two independent
3492                // declarations (each block-scoped), not a redeclaration. Without
3493                // a fresh frame the redeclaration tracker would carry the prior
3494                // block's `declared` set into this one and rewrite the second
3495                // `let x = …` into a bare `x = …`, referencing a name that went
3496                // out of scope when the first block closed (ReferenceError under
3497                // strict mode; a leaked global in sloppy mode).
3498                self.writeln("{");
3499                self.indent += 1;
3500                self.enter_let_scope(body);
3501                let old_handler_vars = self.current_handler_vars.clone();
3502                for h in handlers {
3503                    let effect_name = h
3504                        .effect
3505                        .segments
3506                        .last()
3507                        .map_or("effect", |s| s.name.as_str());
3508                    let var_name = format!("__{}", to_camel_case(effect_name));
3509                    let ind = self.indent_str();
3510                    let _ = write!(self.buf, "{ind}const {var_name} = ");
3511                    self.emit_expr(&h.handler)?;
3512                    self.buf.push_str(";\n");
3513                    self.current_handler_vars
3514                        .insert(effect_name.to_string(), var_name);
3515                }
3516                if let NodeKind::Block { stmts, tail } = &body.kind {
3517                    for s in stmts {
3518                        self.emit_node(s)?;
3519                    }
3520                    if let Some(t) = tail {
3521                        self.write_indent();
3522                        self.emit_expr(t)?;
3523                        self.buf.push_str(";\n");
3524                    }
3525                } else {
3526                    self.emit_stmt(body)?;
3527                }
3528                self.current_handler_vars = old_handler_vars;
3529                self.leave_let_scope();
3530                self.indent -= 1;
3531                self.writeln("}");
3532                Ok(())
3533            }
3534            NodeKind::Assign { op, target, value } => {
3535                let ind = self.indent_str();
3536                let _ = write!(self.buf, "{ind}");
3537                self.emit_expr(target)?;
3538                let op_str = match op {
3539                    AssignOp::Assign => "=",
3540                    AssignOp::AddAssign => "+=",
3541                    AssignOp::SubAssign => "-=",
3542                    AssignOp::MulAssign => "*=",
3543                    AssignOp::DivAssign => "/=",
3544                    AssignOp::RemAssign => "%=",
3545                };
3546                let _ = write!(self.buf, " {op_str} ");
3547                self.emit_expr(value)?;
3548                self.buf.push_str(";\n");
3549                Ok(())
3550            }
3551            _ => {
3552                // Fallback: emit as expression statement.
3553                self.write_indent();
3554                self.emit_expr(node)?;
3555                self.buf.push_str(";\n");
3556                Ok(())
3557            }
3558        }
3559    }
3560
3561    // ── Expressions ─────────────────────────────────────────────────────────
3562
3563    fn emit_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
3564        self.mark_span(node.span);
3565        match &node.kind {
3566            NodeKind::Literal { lit } => {
3567                match lit {
3568                    Literal::Int(s) => self.buf.push_str(s),
3569                    Literal::Float(s) => self.buf.push_str(s),
3570                    Literal::Bool(b) => self.buf.push_str(if *b { "true" } else { "false" }),
3571                    Literal::Char(s) => {
3572                        self.buf.push('\'');
3573                        self.buf.push_str(s);
3574                        self.buf.push('\'');
3575                    }
3576                    Literal::String(s) => {
3577                        self.buf.push('"');
3578                        self.buf.push_str(&escape_js_string(s));
3579                        self.buf.push('"');
3580                    }
3581                    Literal::Unit => self.buf.push_str("undefined"),
3582                }
3583                Ok(())
3584            }
3585            NodeKind::Identifier { name } => {
3586                if name.name == "None" {
3587                    self.buf.push_str("{ _tag: \"None\" }");
3588                } else if let Some(variant) = crate::generator::ordering_variant(&name.name) {
3589                    // Prelude `Ordering` variant → an inline tagged object, the
3590                    // same self-contained representation the primitive-bridge
3591                    // `compare` and the `_tag`-switch match use (when the
3592                    // `core.compare` enum decl is not among the reached modules).
3593                    let _ = write!(self.buf, "{{ _tag: \"{variant}\" }}");
3594                } else if let Some(enum_name) = self
3595                    .user_variant_for_name(&name.name)
3596                    .map(|i| i.enum_name.clone())
3597                {
3598                    // A bare unit-variant reference (`Red`) → the frozen
3599                    // `{enum}_{variant}` const emitted by `emit_enum_variant`.
3600                    let _ = write!(self.buf, "{enum_name}_{}", name.name);
3601                } else if self.const_names.contains(&name.name) {
3602                    // A module-scope `const` is emitted verbatim at its
3603                    // declaration; spell its use site identically so the two agree
3604                    // (the `to_camel_case` transform would mangle a SCREAMING_SNAKE
3605                    // name, e.g. `FIZZ_NUM` → `fizzNUM`).
3606                    self.buf.push_str(&name.name);
3607                } else {
3608                    self.buf.push_str(&js_value_ident(&name.name));
3609                }
3610                Ok(())
3611            }
3612            NodeKind::BinaryOp { op, left, right } => {
3613                // `+` on two `List[T]` operands is concatenation: spread both into
3614                // a fresh array (`[...a, ...b]`). JS's native `+` would coerce the
3615                // arrays to strings and concatenate *those*, a silent bug.
3616                if matches!(op, BinOp::Add) && crate::generator::is_list_concat(node, left, right) {
3617                    self.buf.push_str("[...");
3618                    self.emit_expr(left)?;
3619                    self.buf.push_str(", ...");
3620                    self.emit_expr(right)?;
3621                    self.buf.push(']');
3622                    return Ok(());
3623                }
3624                // Integer `/` and `%` (DQ23, §3.6): JS `/` is float division and
3625                // `Math.trunc(a / 0)` yields `Infinity` rather than aborting, so
3626                // lower to a self-contained IIFE that (a) aborts on a zero divisor
3627                // and (b) truncates toward zero. JS `%` already takes the sign of
3628                // the dividend, so the remainder needs only the zero-abort. Passing
3629                // both operands as IIFE arguments evaluates each exactly once.
3630                if matches!(op, BinOp::Div | BinOp::Rem) && crate::generator::is_int_arith(node) {
3631                    let body = if matches!(op, BinOp::Div) {
3632                        "Math.trunc(__a / __b)"
3633                    } else {
3634                        "__a % __b"
3635                    };
3636                    self.buf.push_str("((__a, __b) => { if (__b === 0) { throw new Error(\"integer division or modulo by zero\"); } return ");
3637                    self.buf.push_str(body);
3638                    self.buf.push_str("; })(");
3639                    self.emit_expr(left)?;
3640                    self.buf.push_str(", ");
3641                    self.emit_expr(right)?;
3642                    self.buf.push(')');
3643                    return Ok(());
3644                }
3645                // Ordering operators on a user `Comparable` type lower through
3646                // `compare` (native `<` on two objects coerces to `NaN`). The
3647                // tagged `Ordering` is read off `._tag`, matching how a
3648                // hand-written `a.compare(b)` lowers — the receiver is passed both
3649                // as the JS method receiver and as the explicit `self` argument.
3650                if crate::generator::is_user_compare(node) {
3651                    if let Some((tag, is_eq)) = crate::generator::user_compare_variant(*op) {
3652                        let recv = self.expr_to_string(left)?;
3653                        let other = self.expr_to_string(right)?;
3654                        let eq = if is_eq { "===" } else { "!==" };
3655                        let _ = write!(
3656                            self.buf,
3657                            "(({recv}).compare({recv}, {other})._tag {eq} \"{tag}\")"
3658                        );
3659                        return Ok(());
3660                    }
3661                }
3662                // DQ29 (§18.5 structural Equatable): a stamped `==`/`!=`
3663                // cannot use native `===` (reference identity on objects).
3664                // The `"impl"` lane dispatches through the explicit
3665                // `impl Equatable`'s `eq` — receiver passed as both the JS
3666                // method receiver and the explicit `self` argument, matching
3667                // how a hand-written `a.eq(b)` lowers (Q-js-user-equality-
3668                // reference, #339). The structural lanes lower through the
3669                // `__bockEq` runtime helper.
3670                if matches!(op, BinOp::Eq | BinOp::Ne) {
3671                    if let Some(kind) = crate::generator::user_eq_kind(node) {
3672                        let recv = self.expr_to_string(left)?;
3673                        let other = self.expr_to_string(right)?;
3674                        let neg = if *op == BinOp::Ne { "!" } else { "" };
3675                        if kind == "impl" {
3676                            let _ = write!(self.buf, "{neg}(({recv}).eq({recv}, {other}))");
3677                        } else {
3678                            let _ = write!(self.buf, "{neg}__bockEq({recv}, {other})");
3679                        }
3680                        return Ok(());
3681                    }
3682                }
3683                self.buf.push('(');
3684                self.emit_expr(left)?;
3685                let op_str = match op {
3686                    BinOp::Add => " + ",
3687                    BinOp::Sub => " - ",
3688                    BinOp::Mul => " * ",
3689                    BinOp::Div => " / ",
3690                    BinOp::Rem => " % ",
3691                    BinOp::Pow => " ** ",
3692                    BinOp::Eq => " === ",
3693                    BinOp::Ne => " !== ",
3694                    BinOp::Lt => " < ",
3695                    BinOp::Le => " <= ",
3696                    BinOp::Gt => " > ",
3697                    BinOp::Ge => " >= ",
3698                    BinOp::And => " && ",
3699                    BinOp::Or => " || ",
3700                    BinOp::BitAnd => " & ",
3701                    BinOp::BitOr => " | ",
3702                    BinOp::BitXor => " ^ ",
3703                    BinOp::Compose => " /* >> */ ",
3704                    BinOp::Is => " instanceof ",
3705                };
3706                self.buf.push_str(op_str);
3707                self.emit_expr(right)?;
3708                self.buf.push(')');
3709                Ok(())
3710            }
3711            NodeKind::UnaryOp { op, operand } => {
3712                let op_str = match op {
3713                    UnaryOp::Neg => "-",
3714                    UnaryOp::Not => "!",
3715                    UnaryOp::BitNot => "~",
3716                };
3717                self.buf.push_str(op_str);
3718                self.emit_expr(operand)?;
3719                Ok(())
3720            }
3721            NodeKind::Call { callee, args, .. } => {
3722                if let Some(code) = self.map_prelude_call(callee, args)? {
3723                    self.buf.push_str(&code);
3724                    return Ok(());
3725                }
3726                if self.try_emit_prelude_ctor(callee, args)? {
3727                    return Ok(());
3728                }
3729                if self.try_emit_time_assoc_call(callee, args)? {
3730                    return Ok(());
3731                }
3732                if self.try_emit_time_desugared_method(node, callee, args)? {
3733                    return Ok(());
3734                }
3735                if self.try_emit_concurrency_call(callee, args)? {
3736                    return Ok(());
3737                }
3738                // Map/Set method dispatch runs *before* the List recogniser so
3739                // the overlapping method names (`len`/`contains`/`filter`/`map`/
3740                // `to_list`) and the Map/Set-only `get`/`set`/`add`/`keys`/… are
3741                // routed by the checker's `recv_kind`, not by name alone.
3742                if self.try_emit_map_method(node, callee, args)? {
3743                    return Ok(());
3744                }
3745                if self.try_emit_set_method(node, callee, args)? {
3746                    return Ok(());
3747                }
3748                // String method dispatch runs *before* the List recogniser so the
3749                // overlapping `len`/`contains`/`is_empty` names route by the
3750                // checker's `recv_kind = "Primitive:String"`, not by name alone.
3751                if self.try_emit_string_method(node, callee, args)? {
3752                    return Ok(());
3753                }
3754                // Numeric/Char/Bool primitive methods (`to_float`/`abs`/`sqrt`/…)
3755                // likewise route by the checker's `recv_kind = "Primitive:Int|…"`
3756                // before the generic fall-through, which would emit `n.to_float(n)`.
3757                if self.try_emit_numeric_method(node, callee, args)? {
3758                    return Ok(());
3759                }
3760                if self.try_emit_list_mutating_method(node, callee, args)? {
3761                    return Ok(());
3762                }
3763                if self.try_emit_list_inplace_mutator(node, callee, args)? {
3764                    return Ok(());
3765                }
3766                if self.try_emit_list_method(node, callee, args)? {
3767                    return Ok(());
3768                }
3769                if self.try_emit_list_functional_method(node, callee, args)? {
3770                    return Ok(());
3771                }
3772                if self.try_emit_primitive_bridge(node, callee, args)? {
3773                    return Ok(());
3774                }
3775                if self.try_emit_trait_bound_bridge(node, callee, args)? {
3776                    return Ok(());
3777                }
3778                if self.try_emit_container_method(node, callee, args)? {
3779                    return Ok(());
3780                }
3781                // Rewrite bare effect operation calls: log(...) → handler.log(...)
3782                if let NodeKind::Identifier { name } = &callee.kind {
3783                    if let Some(effect_name) = self.effect_ops.get(&name.name).cloned() {
3784                        if let Some(handler_var) =
3785                            self.current_handler_vars.get(&effect_name).cloned()
3786                        {
3787                            let _ = write!(self.buf, "{}.{}", handler_var, name.name);
3788                            self.buf.push('(');
3789                            for (i, arg) in args.iter().enumerate() {
3790                                if i > 0 {
3791                                    self.buf.push_str(", ");
3792                                }
3793                                self.emit_expr(&arg.value)?;
3794                            }
3795                            self.buf.push(')');
3796                            return Ok(());
3797                        }
3798                    }
3799                }
3800                // Q-prim-assoc: a primitive associated-conversion call
3801                // (`Float.from(x)` / `Int.try_from(s)` / `String.from(c)`)
3802                // lowers to JS's native conversion, NOT the static-member form
3803                // below (`Float.from` is undefined on the host `number`).
3804                if self.try_emit_primitive_conversion(node, callee, args)? {
3805                    return Ok(());
3806                }
3807                // An associated-function call (`Type.method(args)` — stamped by
3808                // the lowerer, no `self` prepended) is a *static* method on the
3809                // class object. Emit `Type.method(args)` with the type name
3810                // preserved (JS class names are PascalCase, matching the Bock
3811                // type name); the generic fall-through would camel-case the
3812                // receiver identifier into a non-existent value (`typeValue`).
3813                if crate::generator::is_associated_call(node) {
3814                    if let NodeKind::FieldAccess { object, field } = &callee.kind {
3815                        if let NodeKind::Identifier { name: type_name } = &object.kind {
3816                            let _ = write!(
3817                                self.buf,
3818                                "{}.{}",
3819                                type_name.name,
3820                                self.js_method_name(&field.name)
3821                            );
3822                            self.buf.push('(');
3823                            for (i, arg) in args.iter().enumerate() {
3824                                if i > 0 {
3825                                    self.buf.push_str(", ");
3826                                }
3827                                self.emit_expr(&arg.value)?;
3828                            }
3829                            self.buf.push(')');
3830                            return Ok(());
3831                        }
3832                    }
3833                }
3834                // A trait/record method call lowers to `Call(FieldAccess(recv,
3835                // method), [recv, ...])` (the receiver is re-passed as `self`,
3836                // sharing the receiver's NodeId — see `desugared_self_call`).
3837                // When the method name collides with a field name, the *method*
3838                // was renamed at its declaration (`<name>Method`); rename the
3839                // call's member access to match so it resolves. A genuine field
3840                // *read* (bare `FieldAccess`, not in call position) and a
3841                // field-closure call `(p.f)(x)` (distinct receiver nodes) keep
3842                // the field name. Shared policy with go/ts/py. Unlike Python's
3843                // implicit `self`, JS prototype functions take an explicit `self`
3844                // param, so all `args` (the re-passed receiver included) are kept.
3845                if let NodeKind::FieldAccess { object, field } = &callee.kind {
3846                    if crate::generator::desugared_self_call(callee, args).is_some() {
3847                        // The generic fall-through emits the field name raw, so
3848                        // disambiguate against the raw name (matches the raw
3849                        // prototype attachment).
3850                        let renamed = self.js_method_name(&field.name);
3851                        if renamed != field.name {
3852                            self.emit_expr(object)?;
3853                            let _ = write!(self.buf, ".{renamed}");
3854                            self.buf.push('(');
3855                            for (i, arg) in args.iter().enumerate() {
3856                                if i > 0 {
3857                                    self.buf.push_str(", ");
3858                                }
3859                                self.emit_expr(&arg.value)?;
3860                            }
3861                            self.buf.push(')');
3862                            return Ok(());
3863                        }
3864                    }
3865                }
3866                // Pass handler args to effectful function calls.
3867                let effects_arg = if let NodeKind::Identifier { name } = &callee.kind {
3868                    self.build_effects_call_arg_js(&name.name)
3869                } else {
3870                    None
3871                };
3872                self.emit_callee(callee)?;
3873                self.buf.push('(');
3874                for (i, arg) in args.iter().enumerate() {
3875                    if i > 0 {
3876                        self.buf.push_str(", ");
3877                    }
3878                    self.emit_expr(&arg.value)?;
3879                }
3880                if let Some(ea) = effects_arg {
3881                    if !args.is_empty() {
3882                        self.buf.push_str(", ");
3883                    }
3884                    self.buf.push_str(&ea);
3885                }
3886                self.buf.push(')');
3887                Ok(())
3888            }
3889            NodeKind::MethodCall {
3890                receiver,
3891                method,
3892                args,
3893                ..
3894            } => {
3895                if self.try_emit_time_method(receiver, &method.name, args)? {
3896                    return Ok(());
3897                }
3898                self.emit_expr(receiver)?;
3899                let _ = write!(
3900                    self.buf,
3901                    ".{}",
3902                    self.js_method_name(&to_camel_case(&method.name))
3903                );
3904                self.buf.push('(');
3905                for (i, arg) in args.iter().enumerate() {
3906                    if i > 0 {
3907                        self.buf.push_str(", ");
3908                    }
3909                    self.emit_expr(&arg.value)?;
3910                }
3911                self.buf.push(')');
3912                Ok(())
3913            }
3914            NodeKind::FieldAccess { object, field } => {
3915                self.emit_expr(object)?;
3916                let _ = write!(self.buf, ".{}", field.name);
3917                Ok(())
3918            }
3919            NodeKind::Index { object, index } => {
3920                self.emit_expr(object)?;
3921                self.buf.push('[');
3922                self.emit_expr(index)?;
3923                self.buf.push(']');
3924                Ok(())
3925            }
3926            NodeKind::Lambda { params, body } => {
3927                let param_names = self.collect_param_names(params);
3928                let _ = write!(self.buf, "({}) => ", param_names.join(", "));
3929                // If body is a block, emit with braces; otherwise inline.
3930                if matches!(body.kind, NodeKind::Block { .. }) {
3931                    self.buf.push_str("{\n");
3932                    self.indent += 1;
3933                    // A lambda body is a fresh function-body tail context: its
3934                    // tail is the lambda's return value, so clear any active
3935                    // `discard_tail` (e.g. from an enclosing loop body) for the
3936                    // duration of the body.
3937                    let prev = std::mem::replace(&mut self.discard_tail, false);
3938                    let r = self.emit_block_body(body);
3939                    self.discard_tail = prev;
3940                    r?;
3941                    self.indent -= 1;
3942                    self.write_indent();
3943                    self.buf.push('}');
3944                } else {
3945                    self.emit_expr(body)?;
3946                }
3947                Ok(())
3948            }
3949            NodeKind::Pipe { left, right } => {
3950                // Pipe `a |> f` → `f(a)`.
3951                // If right is a Call with a Placeholder, substitute left for it.
3952                self.emit_pipe(left, right)
3953            }
3954            NodeKind::Compose { left, right } => {
3955                // `f >> g` → `((x) => g(f(x)))`. A composed callee (`left`/`right`)
3956                // that is itself a `Compose`/`Lambda` must be parenthesized: a bare
3957                // arrow `(x) => …` followed by `(x)` parses as `(x) => (…(x))`,
3958                // binding the call to the arrow's body rather than invoking the
3959                // arrow. `emit_callee` wraps those forms. In practice the AIR lowers
3960                // `>>` to a `Lambda` before codegen (so chained `>>` reaches the
3961                // `Call` arm, not here), making this a defensive fall-through.
3962                let _ = write!(self.buf, "((x) => ");
3963                self.emit_callee(right)?;
3964                self.buf.push('(');
3965                self.emit_callee(left)?;
3966                self.buf.push_str("(x)))");
3967                Ok(())
3968            }
3969            NodeKind::Await { expr } => {
3970                self.buf.push_str("(await ");
3971                self.emit_expr(expr)?;
3972                self.buf.push(')');
3973                Ok(())
3974            }
3975            NodeKind::Propagate { expr } => {
3976                // `expr?` is desugared by the pre-statement `hoist_propagates`
3977                // pass into `const __tryN = <expr>; if (__tryN is failure) return
3978                // __tryN;`, which records `__tryN` for this node. Here the
3979                // operator evaluates to the unwrapped payload `__tryN._0`. If no
3980                // temp was recorded (a `?` in an un-hoisted position, e.g. inside
3981                // a short-circuited `&&` operand), fall back to evaluating the
3982                // inner expression — preserving the prior pass-through behavior
3983                // rather than emitting an undefined temp reference.
3984                if let Some(tmp) = self.propagate_temps.get(&(node as *const AIRNode as usize)) {
3985                    let _ = write!(self.buf, "{tmp}._0");
3986                    Ok(())
3987                } else {
3988                    self.emit_expr(expr)
3989                }
3990            }
3991            NodeKind::Range { lo, hi, inclusive } => {
3992                // No native range in JS; emit a helper call.
3993                if *inclusive {
3994                    self.buf.push_str("rangeInclusive(");
3995                } else {
3996                    self.buf.push_str("range(");
3997                }
3998                self.emit_expr(lo)?;
3999                self.buf.push_str(", ");
4000                self.emit_expr(hi)?;
4001                self.buf.push(')');
4002                Ok(())
4003            }
4004            NodeKind::RecordConstruct {
4005                path,
4006                fields,
4007                spread,
4008            } => {
4009                // A struct-variant construction (`Circle { radius: 2.0 }`) →
4010                // the `{enum}_{variant}(field, ..)` factory function, passing
4011                // field values in declaration order. Only fires for registered
4012                // user variants; plain records keep their object/class form.
4013                let struct_variant = if spread.is_none() {
4014                    self.user_variant_for_path(path).and_then(|info| {
4015                        if let crate::generator::VariantPayloadKind::Struct(field_order) =
4016                            &info.payload
4017                        {
4018                            Some((info.enum_name.clone(), field_order.clone()))
4019                        } else {
4020                            None
4021                        }
4022                    })
4023                } else {
4024                    None
4025                };
4026                if let Some((enum_name, field_order)) = struct_variant {
4027                    let variant = path.segments.last().map_or("", |s| s.name.as_str());
4028                    let _ = write!(self.buf, "{enum_name}_{variant}(");
4029                    for (i, fname) in field_order.iter().enumerate() {
4030                        if i > 0 {
4031                            self.buf.push_str(", ");
4032                        }
4033                        // Emit the value supplied for this field, in the
4034                        // factory's parameter order (the decl order).
4035                        let supplied = fields.iter().find(|f| &f.name.name == fname);
4036                        match supplied.and_then(|f| f.value.as_ref()) {
4037                            Some(val) => self.emit_expr(val)?,
4038                            // Shorthand `{ radius }` ≡ `{ radius: radius }` — the
4039                            // RHS is a value reference, so escape like any ident.
4040                            None => self.buf.push_str(&js_value_ident(fname)),
4041                        }
4042                    }
4043                    self.buf.push(')');
4044                    return Ok(());
4045                }
4046                let type_name = path.segments.last().map(|s| s.name.as_str()).unwrap_or("");
4047                // A Bock `class` lowers to a *positional* `constructor(a, b)`
4048                // (unlike a record's destructured `constructor({ a, b })`), so a
4049                // class literal must construct as `new T(a_value, b_value)` with
4050                // values ordered by the *declared* field order — not the literal's
4051                // field order, and not a bare object literal (whose prototype
4052                // methods would be unreachable). Falls through to the
4053                // record/object path only when this is not a known class.
4054                if let Some(field_order) = self.class_fields.get(type_name).cloned() {
4055                    let _ = write!(self.buf, "new {type_name}(");
4056                    for (i, fname) in field_order.iter().enumerate() {
4057                        if i > 0 {
4058                            self.buf.push_str(", ");
4059                        }
4060                        let supplied = fields.iter().find(|f| &f.name.name == fname);
4061                        match supplied.and_then(|f| f.value.as_ref()) {
4062                            Some(val) => self.emit_expr(val)?,
4063                            // A field present in the literal as shorthand
4064                            // (`T { label }` ≡ `T { label: label }`) — the RHS is
4065                            // a value reference; otherwise (field omitted, only
4066                            // possible with a `..base` spread) read it off `base`.
4067                            None if supplied.is_some() => {
4068                                self.buf.push_str(&js_value_ident(fname));
4069                            }
4070                            None => match spread {
4071                                Some(sp) => {
4072                                    self.emit_expr(sp)?;
4073                                    let _ = write!(self.buf, ".{}", js_value_ident(fname));
4074                                }
4075                                None => self.buf.push_str("undefined"),
4076                            },
4077                        }
4078                    }
4079                    self.buf.push(')');
4080                    return Ok(());
4081                }
4082                let is_class = self.record_names.contains(type_name);
4083                if is_class {
4084                    let _ = write!(self.buf, "new {type_name}(");
4085                    if fields.is_empty() && spread.is_none() {
4086                        self.buf.push(')');
4087                        return Ok(());
4088                    }
4089                }
4090                if let Some(sp) = spread {
4091                    self.buf.push_str("{ ...");
4092                    self.emit_expr(sp)?;
4093                    if !fields.is_empty() {
4094                        self.buf.push_str(", ");
4095                    }
4096                } else {
4097                    self.buf.push_str("{ ");
4098                }
4099                for (i, f) in fields.iter().enumerate() {
4100                    if i > 0 {
4101                        self.buf.push_str(", ");
4102                    }
4103                    if let Some(val) = &f.value {
4104                        let _ = write!(self.buf, "{}: ", f.name.name);
4105                        self.emit_expr(val)?;
4106                    } else {
4107                        // Shorthand: { name }
4108                        self.buf.push_str(&f.name.name);
4109                    }
4110                }
4111                self.buf.push_str(" }");
4112                if is_class {
4113                    self.buf.push(')');
4114                }
4115                Ok(())
4116            }
4117            NodeKind::ListLiteral { elems } => {
4118                self.buf.push('[');
4119                for (i, e) in elems.iter().enumerate() {
4120                    if i > 0 {
4121                        self.buf.push_str(", ");
4122                    }
4123                    self.emit_expr(e)?;
4124                }
4125                self.buf.push(']');
4126                Ok(())
4127            }
4128            NodeKind::MapLiteral { entries } => {
4129                self.buf.push_str("new Map([");
4130                for (i, entry) in entries.iter().enumerate() {
4131                    if i > 0 {
4132                        self.buf.push_str(", ");
4133                    }
4134                    self.buf.push('[');
4135                    self.emit_expr(&entry.key)?;
4136                    self.buf.push_str(", ");
4137                    self.emit_expr(&entry.value)?;
4138                    self.buf.push(']');
4139                }
4140                self.buf.push_str("])");
4141                Ok(())
4142            }
4143            NodeKind::SetLiteral { elems } => {
4144                self.buf.push_str("new Set([");
4145                for (i, e) in elems.iter().enumerate() {
4146                    if i > 0 {
4147                        self.buf.push_str(", ");
4148                    }
4149                    self.emit_expr(e)?;
4150                }
4151                self.buf.push_str("])");
4152                Ok(())
4153            }
4154            NodeKind::TupleLiteral { elems } => {
4155                // JS doesn't have tuples; emit as an array.
4156                self.buf.push('[');
4157                for (i, e) in elems.iter().enumerate() {
4158                    if i > 0 {
4159                        self.buf.push_str(", ");
4160                    }
4161                    self.emit_expr(e)?;
4162                }
4163                self.buf.push(']');
4164                Ok(())
4165            }
4166            NodeKind::Interpolation { parts } => {
4167                self.buf.push('`');
4168                for part in parts {
4169                    match part {
4170                        AirInterpolationPart::Literal(s) => {
4171                            self.buf.push_str(&escape_template_literal(s));
4172                        }
4173                        AirInterpolationPart::Expr(expr) => {
4174                            // Q-displayable-interpolation-dispatch: render the
4175                            // part through `__bockStr` so a user value with a
4176                            // `Displayable` impl (its `to_string` method) is shown
4177                            // via that method, not `[object Object]`. Primitives /
4178                            // arrays / plain objects fall back to native
4179                            // `String(x)`, matching the prior bare `${x}`.
4180                            self.needs_runtime_str = true;
4181                            self.buf.push_str("${__bockStr(");
4182                            self.emit_expr(expr)?;
4183                            self.buf.push_str(")}");
4184                        }
4185                    }
4186                }
4187                self.buf.push('`');
4188                Ok(())
4189            }
4190            NodeKind::Placeholder => {
4191                self.buf.push('_');
4192                Ok(())
4193            }
4194            NodeKind::Unreachable => {
4195                self.buf
4196                    .push_str("(() => { throw new Error(\"unreachable\"); })()");
4197                Ok(())
4198            }
4199            NodeKind::ResultConstruct { variant, value } => {
4200                // Use the `_0` payload key — the same shape the surface
4201                // `Ok(..)`/`Err(..)` construction (`try_emit_prelude_ctor`) emits
4202                // and the `Result` match reads — so construction and match agree
4203                // (the old `value`/`error` keys were never read by the match).
4204                let tag = match variant {
4205                    ResultVariant::Ok => "Ok",
4206                    ResultVariant::Err => "Err",
4207                };
4208                let _ = write!(self.buf, "{{ _tag: \"{tag}\", _0: ");
4209                if let Some(v) = value {
4210                    self.emit_expr(v)?;
4211                } else {
4212                    self.buf.push_str("undefined");
4213                }
4214                self.buf.push_str(" }");
4215                Ok(())
4216            }
4217            NodeKind::Assign { op, target, value } => {
4218                self.emit_expr(target)?;
4219                let op_str = match op {
4220                    AssignOp::Assign => " = ",
4221                    AssignOp::AddAssign => " += ",
4222                    AssignOp::SubAssign => " -= ",
4223                    AssignOp::MulAssign => " *= ",
4224                    AssignOp::DivAssign => " /= ",
4225                    AssignOp::RemAssign => " %= ",
4226                };
4227                self.buf.push_str(op_str);
4228                self.emit_expr(value)?;
4229                Ok(())
4230            }
4231            NodeKind::If {
4232                condition,
4233                then_block,
4234                else_block,
4235                ..
4236            } => {
4237                // Ternary for expression-position if.
4238                self.buf.push('(');
4239                self.emit_expr(condition)?;
4240                self.buf.push_str(" ? ");
4241                self.emit_block_as_expr(then_block)?;
4242                self.buf.push_str(" : ");
4243                if let Some(eb) = else_block {
4244                    self.emit_block_as_expr(eb)?;
4245                } else {
4246                    self.buf.push_str("undefined");
4247                }
4248                self.buf.push(')');
4249                Ok(())
4250            }
4251            NodeKind::Block { stmts, tail } => {
4252                // Blocks in expression position → IIFE. The IIFE body is its own
4253                // JS lexical scope, so it gets its own `let` scope frame — a name
4254                // re-bound across two *sibling* IIFEs (e.g. two arms of an
4255                // expression-position `match`) is two independent declarations,
4256                // not a redeclaration.
4257                self.buf.push_str("(() => {\n");
4258                self.indent += 1;
4259                self.enter_let_scope(node);
4260                for s in stmts {
4261                    self.emit_node(s)?;
4262                }
4263                if let Some(t) = tail {
4264                    let ind = self.indent_str();
4265                    let _ = write!(self.buf, "{ind}return ");
4266                    self.emit_expr(t)?;
4267                    self.buf.push_str(";\n");
4268                }
4269                self.leave_let_scope();
4270                self.indent -= 1;
4271                self.write_indent();
4272                self.buf.push_str("})()");
4273                Ok(())
4274            }
4275            NodeKind::Match { scrutinee, arms } => {
4276                // Match in expression position → IIFE with switch. The IIFE
4277                // arrow returns the matched arm's value, so the arm bodies must
4278                // `return` their tail — clear any active `discard_tail` (e.g. a
4279                // statement-position context such as an enclosing loop body or a
4280                // non-tail statement) for the IIFE body, restored after.
4281                self.buf.push_str("(() => {\n");
4282                self.indent += 1;
4283                let prev = std::mem::replace(&mut self.discard_tail, false);
4284                let r = self.emit_match(scrutinee, arms);
4285                self.discard_tail = prev;
4286                r?;
4287                self.indent -= 1;
4288                self.write_indent();
4289                self.buf.push_str("})()");
4290                Ok(())
4291            }
4292            // Ownership nodes: erase in JS.
4293            NodeKind::Move { expr }
4294            | NodeKind::Borrow { expr }
4295            | NodeKind::MutableBorrow { expr } => self.emit_expr(expr),
4296            // Effect operation invocation.
4297            NodeKind::EffectOp {
4298                effect,
4299                operation,
4300                args,
4301            } => {
4302                let effect_name = effect.segments.last().map_or("effect", |s| s.name.as_str());
4303                let _ = write!(
4304                    self.buf,
4305                    "{}.{}",
4306                    to_camel_case(effect_name),
4307                    operation.name
4308                );
4309                self.buf.push('(');
4310                for (i, arg) in args.iter().enumerate() {
4311                    if i > 0 {
4312                        self.buf.push_str(", ");
4313                    }
4314                    self.emit_expr(&arg.value)?;
4315                }
4316                self.buf.push(')');
4317                Ok(())
4318            }
4319            // Type expressions: erased in JS.
4320            NodeKind::TypeNamed { .. }
4321            | NodeKind::TypeTuple { .. }
4322            | NodeKind::TypeFunction { .. }
4323            | NodeKind::TypeOptional { .. }
4324            | NodeKind::TypeSelf => {
4325                self.buf.push_str("/* type */");
4326                Ok(())
4327            }
4328            // EffectRef in expression position:
4329            NodeKind::EffectRef { path } => {
4330                let name = path
4331                    .segments
4332                    .iter()
4333                    .map(|s| s.name.as_str())
4334                    .collect::<Vec<_>>()
4335                    .join(".");
4336                self.buf.push_str(&name);
4337                Ok(())
4338            }
4339            // Error node:
4340            NodeKind::Error => {
4341                self.buf.push_str("/* error */");
4342                Ok(())
4343            }
4344            // Fallback for any other node kinds appearing in expression position.
4345            _ => {
4346                self.buf.push_str("/* unsupported */");
4347                Ok(())
4348            }
4349        }
4350    }
4351
4352    // ── Match → switch ──────────────────────────────────────────────────────
4353
4354    /// Emit a JS label before a loop iff a contained statement-arm `match`
4355    /// needs to `break`/`continue` the loop (JS `break` otherwise exits the
4356    /// inner `switch`). Pair with `self.loop_labels.pop()` after the loop body.
4357    fn emit_loop_label_prefix(&mut self, body: &AIRNode) {
4358        if crate::generator::loop_needs_break_label(body) {
4359            self.loop_label_counter += 1;
4360            let label = format!("__bockLoop{}", self.loop_label_counter);
4361            self.writeln(&format!("{label}:"));
4362            self.loop_labels.push(Some(label));
4363        } else {
4364            self.loop_labels.push(None);
4365        }
4366    }
4367
4368    /// Label of the innermost loop, if one was allocated.
4369    fn innermost_loop_label(&self) -> Option<&str> {
4370        self.loop_labels.last().and_then(|l| l.as_deref())
4371    }
4372
4373    fn emit_match(&mut self, scrutinee: &AIRNode, arms: &[AIRNode]) -> Result<(), CodegenError> {
4374        // Guards, or-patterns, tuple patterns, and nested constructor/record
4375        // patterns cannot be expressed by the flat `switch` below (a failed
4376        // guard's `break` exits the switch instead of falling through; an
4377        // or-pattern collapses to a single `default:`; a tuple has no single
4378        // discriminant; a nested sub-pattern's bindings are lost). Lower those
4379        // to an if/else-if chain instead. Additive: the proven Optional /
4380        // Result / user-enum / value `switch` fast-path is kept for everything
4381        // else (see `match_needs_ifchain`).
4382        if crate::generator::match_needs_ifchain(arms) || match_has_unswitchable_pattern(arms) {
4383            // List patterns (`[]`, `[first, ..rest]`) and range patterns
4384            // (`1..10`) have no single switch discriminant — every arm would
4385            // collapse to a `default:`, emitting more than one `default` clause
4386            // (a JS `SyntaxError`). The shared `match_needs_ifchain`
4387            // (generator.rs) does not yet recognise these, so the js emitter
4388            // routes them to the if/else-if chain itself. See OPEN in the PR
4389            // body for the shared-side gap.
4390            return self.emit_match_ifchain(scrutinee, arms);
4391        }
4392
4393        // A tag-based (ADT) match dispatches on `._tag`. This is true when any
4394        // arm is a constructor pattern (`Some(x)`, `Rect(w, h)`) *or* a record
4395        // pattern whose path is a registered enum variant (`Circle { radius }`).
4396        // The latter is the case the prior `ConstructorPat`-only check missed:
4397        // a struct-payload variant lowers to a `RecordPat`, so an all-struct
4398        // match never set `is_adt` and every arm fell to `default:` (DV14).
4399        let is_adt = arms.iter().any(|arm| {
4400            let NodeKind::MatchArm { pattern, .. } = &arm.kind else {
4401                return false;
4402            };
4403            match &pattern.kind {
4404                NodeKind::ConstructorPat { .. } => true,
4405                NodeKind::RecordPat { path, .. } => self.user_variant_for_path(path).is_some(),
4406                _ => false,
4407            }
4408        });
4409
4410        // Hoist a non-trivial scrutinee into a single `const __matchN = …;` so it
4411        // is evaluated once (re-emitting it in every arm double-evaluated it — a
4412        // real bug for a scrutinee with side effects). A bare identifier is
4413        // already a stable reference, so leave it inline.
4414        let temp = if matches!(scrutinee.kind, NodeKind::Identifier { .. }) {
4415            None
4416        } else {
4417            self.match_temp_counter += 1;
4418            let name = format!("__match{}", self.match_temp_counter);
4419            let ind = self.indent_str();
4420            let _ = write!(self.buf, "{ind}const {name} = ");
4421            self.emit_expr(scrutinee)?;
4422            self.buf.push_str(";\n");
4423            Some(name)
4424        };
4425
4426        let ind = self.indent_str();
4427        let _ = write!(self.buf, "{ind}switch (");
4428        self.emit_scrutinee_ref(scrutinee, temp.as_deref())?;
4429        if is_adt {
4430            self.buf.push_str("._tag) {\n");
4431        } else {
4432            self.buf.push_str(") {\n");
4433        }
4434        self.indent += 1;
4435        self.switch_label_depth += 1;
4436        for arm in arms {
4437            self.emit_match_arm(arm, is_adt, scrutinee, temp.as_deref())?;
4438        }
4439        self.switch_label_depth -= 1;
4440        self.indent -= 1;
4441        self.writeln("}");
4442        Ok(())
4443    }
4444
4445    /// Emit a reference to the match scrutinee: the hoisted temp name when one
4446    /// was introduced, else the scrutinee expression inline (a bare identifier).
4447    fn emit_scrutinee_ref(
4448        &mut self,
4449        scrutinee: &AIRNode,
4450        temp: Option<&str>,
4451    ) -> Result<(), CodegenError> {
4452        match temp {
4453            Some(name) => {
4454                self.buf.push_str(name);
4455                Ok(())
4456            }
4457            None => self.emit_expr(scrutinee),
4458        }
4459    }
4460
4461    fn emit_match_arm(
4462        &mut self,
4463        arm: &AIRNode,
4464        is_adt: bool,
4465        scrutinee: &AIRNode,
4466        temp: Option<&str>,
4467    ) -> Result<(), CodegenError> {
4468        if let NodeKind::MatchArm {
4469            pattern,
4470            guard,
4471            body,
4472        } = &arm.kind
4473        {
4474            match &pattern.kind {
4475                NodeKind::WildcardPat => {
4476                    self.writeln("default: {");
4477                }
4478                NodeKind::BindPat { name, is_mut } if !is_adt => {
4479                    // Bind pattern as default with variable binding. A `mut x`
4480                    // arm binding may be reassigned in the body (`x = x + 1`),
4481                    // so it must be declared `let`, not `const`.
4482                    self.writeln("default: {");
4483                    self.indent += 1;
4484                    let kw = if *is_mut { "let" } else { "const" };
4485                    let ind = self.indent_str();
4486                    let _ = write!(self.buf, "{ind}{kw} {} = ", js_value_ident(&name.name));
4487                    self.emit_scrutinee_ref(scrutinee, temp)?;
4488                    self.buf.push_str(";\n");
4489                    self.indent -= 1;
4490                }
4491                NodeKind::LiteralPat { lit } => {
4492                    let ind = self.indent_str();
4493                    let _ = write!(self.buf, "{ind}case ");
4494                    match lit {
4495                        Literal::Int(s) => self.buf.push_str(s),
4496                        Literal::Float(s) => self.buf.push_str(s),
4497                        Literal::Bool(b) => self.buf.push_str(if *b { "true" } else { "false" }),
4498                        Literal::Char(s) => {
4499                            self.buf.push('\'');
4500                            self.buf.push_str(s);
4501                            self.buf.push('\'');
4502                        }
4503                        Literal::String(s) => {
4504                            self.buf.push('"');
4505                            self.buf.push_str(&escape_js_string(s));
4506                            self.buf.push('"');
4507                        }
4508                        Literal::Unit => self.buf.push_str("undefined"),
4509                    }
4510                    self.buf.push_str(": {\n");
4511                }
4512                NodeKind::ConstructorPat { path, fields } => {
4513                    let variant_name = path.segments.last().map_or("_", |s| s.name.as_str());
4514                    self.writeln(&format!("case \"{variant_name}\": {{"));
4515                    // Destructure fields from the scrutinee.
4516                    if !fields.is_empty() {
4517                        self.indent += 1;
4518                        for (i, field) in fields.iter().enumerate() {
4519                            let binding = self.pattern_to_binding_name(field);
4520                            let ind = self.indent_str();
4521                            let _ = write!(self.buf, "{ind}const {binding} = ");
4522                            self.emit_scrutinee_ref(scrutinee, temp)?;
4523                            let _ = writeln!(self.buf, "._{i};");
4524                        }
4525                        self.indent -= 1;
4526                    }
4527                }
4528                NodeKind::RecordPat { path, fields, .. } => {
4529                    let variant_name = path.segments.last().map_or("_", |s| s.name.as_str());
4530                    if is_adt {
4531                        self.writeln(&format!("case \"{variant_name}\": {{"));
4532                    } else {
4533                        self.writeln("default: {");
4534                    }
4535                    if !fields.is_empty() {
4536                        self.indent += 1;
4537                        for f in fields {
4538                            let field_name = &f.name.name;
4539                            if let Some(pat) = &f.pattern {
4540                                let binding = self.pattern_to_binding_name(pat);
4541                                let ind = self.indent_str();
4542                                let _ = write!(self.buf, "{ind}const {binding} = ");
4543                                self.emit_scrutinee_ref(scrutinee, temp)?;
4544                                let _ = writeln!(self.buf, ".{field_name};");
4545                            } else {
4546                                let ind = self.indent_str();
4547                                let _ = write!(self.buf, "{ind}const {field_name} = ");
4548                                self.emit_scrutinee_ref(scrutinee, temp)?;
4549                                let _ = writeln!(self.buf, ".{field_name};");
4550                            }
4551                        }
4552                        self.indent -= 1;
4553                    }
4554                }
4555                _ => {
4556                    // Fallback: emit as default case.
4557                    self.writeln("default: {");
4558                }
4559            }
4560
4561            self.indent += 1;
4562            if let Some(g) = guard {
4563                let ind = self.indent_str();
4564                let _ = write!(self.buf, "{ind}if (!(");
4565                self.emit_expr(g)?;
4566                self.buf.push_str(")) break;\n");
4567            }
4568            self.emit_block_body(body)?;
4569            self.writeln("break;");
4570            self.indent -= 1;
4571            self.writeln("}");
4572        }
4573        Ok(())
4574    }
4575
4576    // ── Match → if/else-if chain (guards, or-/tuple/nested patterns) ──────────
4577
4578    /// Lower a `match` whose arms cannot be expressed by a flat `switch` (see
4579    /// [`crate::generator::match_needs_ifchain`]) to an `if (<test>) { <binds>;
4580    /// <body> } else if …` chain.
4581    ///
4582    /// The scrutinee is evaluated once into `__matchN` (a non-identifier
4583    /// scrutinee would otherwise be re-evaluated in every arm's test). Each arm
4584    /// contributes one `if`/`else if`; a catch-all pattern (wildcard or bare
4585    /// bind) with no guard becomes a plain `else`. Failed guards therefore fall
4586    /// through to the next `else if`, which is the semantics a `switch` could
4587    /// not express. Bock matches are exhaustive, so a chain with no `else` is
4588    /// safe.
4589    fn emit_match_ifchain(
4590        &mut self,
4591        scrutinee: &AIRNode,
4592        arms: &[AIRNode],
4593    ) -> Result<(), CodegenError> {
4594        // Single-evaluation: a bare identifier is already stable, so reuse it as
4595        // the access root; anything else is hoisted into `__matchN`.
4596        let root: String = if let NodeKind::Identifier { name } = &scrutinee.kind {
4597            name.name.clone()
4598        } else {
4599            self.match_temp_counter += 1;
4600            let name = format!("__match{}", self.match_temp_counter);
4601            let ind = self.indent_str();
4602            let _ = write!(self.buf, "{ind}const {name} = ");
4603            self.emit_expr(scrutinee)?;
4604            self.buf.push_str(";\n");
4605            name
4606        };
4607
4608        let mut first = true;
4609        let mut closed = false; // an unconditional `else` (or bare block) ended the chain
4610        let arm_count = arms.len();
4611        for (idx, arm) in arms.iter().enumerate() {
4612            let NodeKind::MatchArm {
4613                pattern,
4614                guard,
4615                body,
4616            } = &arm.kind
4617            else {
4618                continue;
4619            };
4620            let test = self.pattern_test_js(pattern, &root);
4621            let is_catch_all = matches!(
4622                pattern.kind,
4623                NodeKind::WildcardPat | NodeKind::BindPat { .. }
4624            );
4625            let is_last = idx + 1 == arm_count;
4626            // An unconditional `else` is emitted when the arm cannot fail to
4627            // match at its position: a catch-all (wildcard / bare bind) with no
4628            // guard, or the final arm with no guard (Bock matches are
4629            // exhaustive, so the last unguarded arm is guaranteed reached). This
4630            // also closes the chain so a value-returning function typechecks.
4631            let unconditional = guard.is_none() && (is_catch_all || is_last);
4632            let ind = self.indent_str();
4633            if unconditional {
4634                if first {
4635                    // No preceding `if`: emit a bare block so bindings/body run.
4636                    let _ = writeln!(self.buf, "{ind}{{");
4637                } else {
4638                    let _ = writeln!(self.buf, "{ind}else {{");
4639                }
4640                closed = true;
4641            } else {
4642                let mut cond = if test.is_empty() {
4643                    "true".to_string()
4644                } else {
4645                    test
4646                };
4647                if let Some(g) = guard {
4648                    // The guard may reference the arm's pattern bindings (`x if
4649                    // (x > 0)`). Those bindings are introduced *inside* the arm
4650                    // body, so they are not in scope in the surrounding `else
4651                    // if` condition. Evaluate the guard in an arrow-IIFE that
4652                    // first re-introduces the bindings, so a failed guard still
4653                    // falls through to the next `else if` (the fall-through a
4654                    // `switch` could not express).
4655                    let g_str = self.expr_to_string(g)?;
4656                    let binds = self.pattern_binds_to_string_js(pattern, &root);
4657                    let guard_test = if binds.is_empty() {
4658                        format!("({g_str})")
4659                    } else {
4660                        format!("(() => {{ {binds}return ({g_str}); }})()")
4661                    };
4662                    if cond == "true" {
4663                        cond = guard_test;
4664                    } else {
4665                        cond = format!("{cond} && {guard_test}");
4666                    }
4667                }
4668                if first {
4669                    let _ = writeln!(self.buf, "{ind}if ({cond}) {{");
4670                } else {
4671                    let _ = writeln!(self.buf, "{ind}else if ({cond}) {{");
4672                }
4673            }
4674            first = false;
4675            self.indent += 1;
4676            self.pattern_binds_js(pattern, &root)?;
4677            self.emit_block_body(body)?;
4678            self.indent -= 1;
4679            self.writeln("}");
4680        }
4681        // If every arm was conditional (all guarded, or no catch-all), close the
4682        // chain with a throw so a value-returning function still typechecks and
4683        // a genuinely unmatched scrutinee fails loudly rather than silently.
4684        if !closed && !first {
4685            self.writeln("else { throw new Error(\"non-exhaustive match\"); }");
4686        }
4687        Ok(())
4688    }
4689
4690    /// Build the boolean test that selects `pat` against the JS expression
4691    /// `access`. Returns the empty string for a pattern that always matches (a
4692    /// wildcard or bare bind), so the caller can render it as an `else`.
4693    fn pattern_test_js(&self, pat: &AIRNode, access: &str) -> String {
4694        match &pat.kind {
4695            NodeKind::WildcardPat | NodeKind::BindPat { .. } => String::new(),
4696            NodeKind::LiteralPat { lit } => {
4697                format!("{access} === {}", js_literal(lit))
4698            }
4699            NodeKind::ConstructorPat { path, fields } => {
4700                let variant = path.segments.last().map_or("_", |s| s.name.as_str());
4701                let mut tests = vec![format!("{access}._tag === \"{variant}\"")];
4702                for (i, field) in fields.iter().enumerate() {
4703                    let sub = self.pattern_test_js(field, &format!("{access}._{i}"));
4704                    if !sub.is_empty() {
4705                        tests.push(sub);
4706                    }
4707                }
4708                tests.join(" && ")
4709            }
4710            NodeKind::RecordPat { path, fields, .. } => {
4711                let variant = path.segments.last().map_or("_", |s| s.name.as_str());
4712                // A registered enum variant dispatches on `._tag`; a plain record
4713                // (not an enum variant) has no tag, so only its field sub-tests
4714                // apply.
4715                let mut tests = Vec::new();
4716                if self.user_variant_for_path(path).is_some() {
4717                    tests.push(format!("{access}._tag === \"{variant}\""));
4718                }
4719                for f in fields {
4720                    if let Some(p) = &f.pattern {
4721                        let sub = self.pattern_test_js(p, &format!("{access}.{}", f.name.name));
4722                        if !sub.is_empty() {
4723                            tests.push(sub);
4724                        }
4725                    }
4726                }
4727                if tests.is_empty() {
4728                    String::new()
4729                } else {
4730                    tests.join(" && ")
4731                }
4732            }
4733            NodeKind::TuplePat { elems } => {
4734                let mut tests = vec![format!("Array.isArray({access})")];
4735                for (i, e) in elems.iter().enumerate() {
4736                    let sub = self.pattern_test_js(e, &format!("{access}[{i}]"));
4737                    if !sub.is_empty() {
4738                        tests.push(sub);
4739                    }
4740                }
4741                tests.join(" && ")
4742            }
4743            NodeKind::ListPat { elems, rest } => {
4744                // `[a, b]` requires an array of exactly len(elems); `[a, ..rest]`
4745                // requires at least len(elems). Element sub-patterns are tested
4746                // positionally; the rest binds the slice and adds no test.
4747                let n = elems.len();
4748                let len_test = if rest.is_some() {
4749                    format!("{access}.length >= {n}")
4750                } else {
4751                    format!("{access}.length === {n}")
4752                };
4753                let mut tests = vec![format!("Array.isArray({access})"), len_test];
4754                for (i, e) in elems.iter().enumerate() {
4755                    let sub = self.pattern_test_js(e, &format!("{access}[{i}]"));
4756                    if !sub.is_empty() {
4757                        tests.push(sub);
4758                    }
4759                }
4760                tests.join(" && ")
4761            }
4762            NodeKind::RangePat { lo, hi, inclusive } => {
4763                // `lo..hi` → `access >= lo && access < hi`; `lo..=hi` uses `<=`.
4764                let lo_s = range_bound_to_js(lo);
4765                let hi_s = range_bound_to_js(hi);
4766                let upper = if *inclusive { "<=" } else { "<" };
4767                format!("{access} >= {lo_s} && {access} {upper} {hi_s}")
4768            }
4769            NodeKind::OrPat { alternatives } => {
4770                let alts: Vec<String> = alternatives
4771                    .iter()
4772                    .map(|a| {
4773                        let t = self.pattern_test_js(a, access);
4774                        if t.is_empty() {
4775                            "true".to_string()
4776                        } else {
4777                            format!("({t})")
4778                        }
4779                    })
4780                    .collect();
4781                alts.join(" || ")
4782            }
4783            _ => String::new(),
4784        }
4785    }
4786
4787    /// Emit the `const <name> = <access…>;` bindings introduced by `pat`,
4788    /// recursing into nested constructor / record / tuple sub-patterns. An
4789    /// or-pattern binds against its first alternative (all alternatives bind the
4790    /// same names by Bock's rules).
4791    fn pattern_binds_js(&mut self, pat: &AIRNode, access: &str) -> Result<(), CodegenError> {
4792        match &pat.kind {
4793            NodeKind::BindPat { name, .. } => {
4794                let js = js_value_ident(&name.name);
4795                // Skip a self-binding (`const n = n`): when an arm's bind name
4796                // equals the scrutinee access (e.g. `match n { n if … }`), the
4797                // name already refers to the value. Emitting `const n = n` is
4798                // both redundant and a `let`/`const` TDZ self-reference error.
4799                if js != access {
4800                    let ind = self.indent_str();
4801                    let _ = writeln!(self.buf, "{ind}const {js} = {access};");
4802                }
4803            }
4804            NodeKind::ConstructorPat { fields, .. } => {
4805                for (i, field) in fields.iter().enumerate() {
4806                    self.pattern_binds_js(field, &format!("{access}._{i}"))?;
4807                }
4808            }
4809            NodeKind::RecordPat { fields, .. } => {
4810                for f in fields {
4811                    let field_access = format!("{access}.{}", f.name.name);
4812                    match &f.pattern {
4813                        Some(p) => self.pattern_binds_js(p, &field_access)?,
4814                        // Shorthand `{ radius }` binds `radius` to `<access>.radius`.
4815                        None => {
4816                            let ind = self.indent_str();
4817                            let _ =
4818                                writeln!(self.buf, "{ind}const {} = {field_access};", f.name.name);
4819                        }
4820                    }
4821                }
4822            }
4823            NodeKind::TuplePat { elems } => {
4824                for (i, e) in elems.iter().enumerate() {
4825                    self.pattern_binds_js(e, &format!("{access}[{i}]"))?;
4826                }
4827            }
4828            NodeKind::ListPat { elems, rest } => {
4829                for (i, e) in elems.iter().enumerate() {
4830                    self.pattern_binds_js(e, &format!("{access}[{i}]"))?;
4831                }
4832                // `..rest` binds the remaining elements as a slice; a bare `..`
4833                // (RestPat) or absent rest binds nothing.
4834                if let Some(r) = rest {
4835                    if let NodeKind::BindPat { name, .. } = &r.kind {
4836                        let ind = self.indent_str();
4837                        let _ = writeln!(
4838                            self.buf,
4839                            "{ind}const {} = {access}.slice({});",
4840                            js_value_ident(&name.name),
4841                            elems.len()
4842                        );
4843                    }
4844                }
4845            }
4846            NodeKind::OrPat { alternatives } => {
4847                if let Some(first) = alternatives.first() {
4848                    self.pattern_binds_js(first, access)?;
4849                }
4850            }
4851            // Wildcard / literal: nothing to bind.
4852            _ => {}
4853        }
4854        Ok(())
4855    }
4856
4857    /// Collect the bindings introduced by `pat` as a single-line string of
4858    /// `const … = …; ` statements (used to re-introduce them inside the
4859    /// guard-evaluating IIFE — see [`Self::emit_match_ifchain`]).
4860    fn pattern_binds_to_string_js(&self, pat: &AIRNode, access: &str) -> String {
4861        let mut out = String::new();
4862        self.collect_binds_js(pat, access, &mut out);
4863        out
4864    }
4865
4866    fn collect_binds_js(&self, pat: &AIRNode, access: &str, out: &mut String) {
4867        match &pat.kind {
4868            NodeKind::BindPat { name, .. } => {
4869                let js = js_value_ident(&name.name);
4870                // Skip a self-binding (`const n = n`) — redundant and a TDZ
4871                // error inside the guard-evaluating IIFE. See `pattern_binds_js`.
4872                if js != access {
4873                    let _ = write!(out, "const {js} = {access}; ");
4874                }
4875            }
4876            NodeKind::ConstructorPat { fields, .. } => {
4877                for (i, field) in fields.iter().enumerate() {
4878                    self.collect_binds_js(field, &format!("{access}._{i}"), out);
4879                }
4880            }
4881            NodeKind::RecordPat { fields, .. } => {
4882                for f in fields {
4883                    let field_access = format!("{access}.{}", f.name.name);
4884                    match &f.pattern {
4885                        Some(p) => self.collect_binds_js(p, &field_access, out),
4886                        None => {
4887                            let _ = write!(out, "const {} = {field_access}; ", f.name.name);
4888                        }
4889                    }
4890                }
4891            }
4892            NodeKind::TuplePat { elems } => {
4893                for (i, e) in elems.iter().enumerate() {
4894                    self.collect_binds_js(e, &format!("{access}[{i}]"), out);
4895                }
4896            }
4897            NodeKind::ListPat { elems, rest } => {
4898                for (i, e) in elems.iter().enumerate() {
4899                    self.collect_binds_js(e, &format!("{access}[{i}]"), out);
4900                }
4901                if let Some(r) = rest {
4902                    if let NodeKind::BindPat { name, .. } = &r.kind {
4903                        let _ = write!(
4904                            out,
4905                            "const {} = {access}.slice({}); ",
4906                            js_value_ident(&name.name),
4907                            elems.len()
4908                        );
4909                    }
4910                }
4911            }
4912            NodeKind::OrPat { alternatives } => {
4913                if let Some(first) = alternatives.first() {
4914                    self.collect_binds_js(first, access, out);
4915                }
4916            }
4917            _ => {}
4918        }
4919    }
4920
4921    // ── Pipe operator ───────────────────────────────────────────────────────
4922
4923    fn emit_pipe(&mut self, left: &AIRNode, right: &AIRNode) -> Result<(), CodegenError> {
4924        // `left |> right` → `right(left)`
4925        // If right is a Call with Placeholder, substitute left for it.
4926        if let NodeKind::Call { callee, args, .. } = &right.kind {
4927            let has_placeholder = args
4928                .iter()
4929                .any(|a| matches!(a.value.kind, NodeKind::Placeholder));
4930            if has_placeholder {
4931                self.emit_expr(callee)?;
4932                self.buf.push('(');
4933                for (i, arg) in args.iter().enumerate() {
4934                    if i > 0 {
4935                        self.buf.push_str(", ");
4936                    }
4937                    if matches!(arg.value.kind, NodeKind::Placeholder) {
4938                        self.emit_expr(left)?;
4939                    } else {
4940                        self.emit_expr(&arg.value)?;
4941                    }
4942                }
4943                self.buf.push(')');
4944                return Ok(());
4945            }
4946        }
4947        // Simple case: `right(left)`. `right` is a callee, so parenthesize it
4948        // when it is a bare arrow (`Lambda`/`Compose`) — otherwise the trailing
4949        // `(left)` binds to the arrow body instead of invoking it.
4950        self.emit_callee(right)?;
4951        self.buf.push('(');
4952        self.emit_expr(left)?;
4953        self.buf.push(')');
4954        Ok(())
4955    }
4956
4957    /// Emit an expression in **callee** position, parenthesizing it when its
4958    /// surface syntax would otherwise swallow the trailing argument list.
4959    ///
4960    /// The case that matters is a bare arrow callee: `(x) => body` followed by
4961    /// `(arg)` parses in JS as `(x) => (body(arg))` — the call binds to the body,
4962    /// never invoking the arrow. Wrapping it as `((x) => body)(arg)` makes the
4963    /// call apply to the arrow itself. This arises when the AIR compose desugar
4964    /// (`f >> g` → `(__compose_x) => g(f(__compose_x))`) **nests**: a chained
4965    /// `>>` lowers the inner compose to a `Lambda` (or a `Compose` still awaiting
4966    /// lowering), which then appears as the callee `f`/`g` inside the call.
4967    /// Mirrors the python (`emit_callee`) and rust (`emit_callee_rs`) backends.
4968    fn emit_callee(&mut self, callee: &AIRNode) -> Result<(), CodegenError> {
4969        if matches!(
4970            callee.kind,
4971            NodeKind::Lambda { .. } | NodeKind::Compose { .. }
4972        ) {
4973            self.buf.push('(');
4974            self.emit_expr(callee)?;
4975            self.buf.push(')');
4976            Ok(())
4977        } else {
4978            self.emit_expr(callee)
4979        }
4980    }
4981
4982    // ── Helpers ─────────────────────────────────────────────────────────────
4983
4984    /// Returns true if `js_name` has already been declared in the innermost
4985    /// `let` scope, so a further binding of it must be a plain assignment rather
4986    /// than a `const`/`let` re-declaration (which JS rejects).
4987    fn simple_let_redeclared(&self, js_name: &str) -> bool {
4988        self.let_scopes
4989            .last()
4990            .is_some_and(|s| s.declared.contains(js_name))
4991    }
4992
4993    /// Returns true if `js_name` is re-bound or assigned later in its block, so
4994    /// its first declaration must use `let` (not `const`) to allow reassignment.
4995    fn simple_let_needs_let(&self, js_name: &str) -> bool {
4996        self.let_scopes
4997            .last()
4998            .is_some_and(|s| s.needs_let.contains(js_name))
4999    }
5000
5001    /// Record that `js_name` has now been declared in the innermost `let` scope.
5002    fn mark_simple_let_declared(&mut self, js_name: &str) {
5003        if let Some(s) = self.let_scopes.last_mut() {
5004            s.declared.insert(js_name.to_string());
5005        }
5006    }
5007
5008    /// Push a fresh `let` scope for a JS block, pre-scanning `block`'s direct
5009    /// statements to find which simple `let`-bound names are re-bound or
5010    /// assigned within the block (so their first declaration emits `let`). Only
5011    /// the block's own statements are scanned — nested blocks open their own
5012    /// scopes, so a name re-bound only in a nested block does not force `let`
5013    /// here. Returns the depth to which [`Self::leave_let_scope`] should unwind.
5014    fn enter_let_scope(&mut self, block: &AIRNode) {
5015        let mut needs_let = HashSet::new();
5016        if let NodeKind::Block { stmts, tail } = &block.kind {
5017            let mut seen: HashSet<String> = HashSet::new();
5018            let mut visit = |n: &AIRNode, needs_let: &mut HashSet<String>| {
5019                match &n.kind {
5020                    NodeKind::LetBinding { pattern, .. } => {
5021                        if let NodeKind::BindPat { name, .. } = &pattern.kind {
5022                            let js = js_value_ident(&name.name);
5023                            // A re-binding of an already-seen name needs `let`.
5024                            if !seen.insert(js.clone()) {
5025                                needs_let.insert(js);
5026                            }
5027                        }
5028                    }
5029                    NodeKind::Assign { target, .. } => {
5030                        if let NodeKind::Identifier { name } = &target.kind {
5031                            needs_let.insert(js_value_ident(&name.name));
5032                        }
5033                    }
5034                    _ => {}
5035                }
5036            };
5037            for s in stmts {
5038                visit(s, &mut needs_let);
5039            }
5040            if let Some(t) = tail {
5041                visit(t, &mut needs_let);
5042            }
5043        }
5044        self.let_scopes.push(LetScope {
5045            declared: HashSet::new(),
5046            needs_let,
5047        });
5048    }
5049
5050    /// Pop the innermost `let` scope pushed by [`Self::enter_let_scope`].
5051    fn leave_let_scope(&mut self) {
5052        self.let_scopes.pop();
5053    }
5054
5055    fn emit_block_body(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
5056        self.enter_let_scope(node);
5057        let r = self.emit_block_body_inner(node);
5058        self.leave_let_scope();
5059        r
5060    }
5061
5062    /// Emit a **loop body** (`for`/`while`/`loop`). A loop body is statement
5063    /// position: its tail expression is discarded (a Bock loop evaluates to
5064    /// Unit; the body's value is not the function's value). The default
5065    /// [`Self::emit_block_body`] treats a tail as a function-body return, which
5066    /// for a loop body emits `return console.log(i);` — aborting the function on
5067    /// the first iteration (the loop runs once, then the fn exits). Setting
5068    /// [`Self::discard_tail`] for the body's duration routes the tail to a bare
5069    /// expression statement instead. The flag is saved/restored so it never
5070    /// leaks past the loop, and any nested lambda / value-position IIFE clears
5071    /// it (their tail is genuinely returned). A `break v` value still flows
5072    /// through the separate `/* break value */` path, not this discard flag.
5073    fn emit_loop_body(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
5074        let prev = std::mem::replace(&mut self.discard_tail, true);
5075        let r = self.emit_block_body(node);
5076        self.discard_tail = prev;
5077        r
5078    }
5079
5080    /// Lower every `?` (`Propagate`) reachable in `stmt`'s own evaluation into a
5081    /// pre-statement hoist, then record the unwrapped-payload temp so the
5082    /// `Propagate` arm of [`Self::emit_expr`] substitutes `<temp>._0` in place.
5083    ///
5084    /// `expr?` is a value-position operator that, on the failure tag (`Err` /
5085    /// `None`), must **early-return** the wrapped value from the enclosing
5086    /// function — which JS cannot express inside an arbitrary sub-expression (an
5087    /// IIFE's `return` would only exit the IIFE). So for each `?` we emit, before
5088    /// the consuming statement:
5089    /// ```js
5090    /// const __tryN = <inner>;
5091    /// if (__tryN._tag === "Err" || __tryN._tag === "None") return __tryN;
5092    /// ```
5093    /// and the operator itself then evaluates to `__tryN._0`. The failure-tag
5094    /// test (`Err`/`None`) covers both `Result` and `Optional`, which share the
5095    /// `{ _tag, _0 }` tagged-object representation; the `Propagate` node carries
5096    /// no type annotation to distinguish them, so the test keys off the failure
5097    /// tags rather than the success tag.
5098    ///
5099    /// The walk visits in evaluation (innermost-first) order and stops at scope
5100    /// boundaries — `lambda`/`block` bodies and the branch bodies of
5101    /// `if`/`match`/`loop` — because a `?` inside those belongs to that nested
5102    /// statement scope and is hoisted when *it* is emitted. It also does not
5103    /// descend the short-circuited operand of `&&`/`||` (whose evaluation is
5104    /// conditional). Returns whether any `?` was hoisted.
5105    fn hoist_propagates(&mut self, stmt: &AIRNode) -> Result<bool, CodegenError> {
5106        let mut found: Vec<&AIRNode> = Vec::new();
5107        collect_propagates_in_expr(stmt, &mut found);
5108        let any = !found.is_empty();
5109        for prop in found {
5110            if let NodeKind::Propagate { expr } = &prop.kind {
5111                let n = self.propagate_temp_counter;
5112                self.propagate_temp_counter += 1;
5113                let tmp = format!("__try{n}");
5114                let ind = self.indent_str();
5115                let _ = write!(self.buf, "{ind}const {tmp} = ");
5116                // `expr` may itself contain `?` already hoisted above (nested
5117                // `g(f(x)?)?`); those temps are registered, so emit substitutes.
5118                self.emit_expr(expr)?;
5119                self.buf.push_str(";\n");
5120                let _ = writeln!(
5121                    self.buf,
5122                    "{ind}if ({tmp}._tag === \"Err\" || {tmp}._tag === \"None\") return {tmp};"
5123                );
5124                self.propagate_temps
5125                    .insert(prop as *const AIRNode as usize, tmp);
5126            }
5127        }
5128        Ok(any)
5129    }
5130
5131    /// Emit a function/method body whose top-level `let` scope is pre-seeded with
5132    /// the function's `params` as already-declared names. A Bock `let x = …` that
5133    /// shadows a parameter `x` is the same block scope as the JS parameter, so it
5134    /// must lower to a plain assignment (`x = …`) rather than a `let`/`const`
5135    /// redeclaration (which JS rejects). Used by [`Self::emit_fn_decl`] /
5136    /// [`Self::emit_class_method`] in place of [`Self::emit_block_body`].
5137    fn emit_fn_body_seeded(
5138        &mut self,
5139        params: &[AIRNode],
5140        body: &AIRNode,
5141    ) -> Result<(), CodegenError> {
5142        self.enter_let_scope(body);
5143        if let Some(scope) = self.let_scopes.last_mut() {
5144            for p in params {
5145                if let NodeKind::Param { pattern, .. } = &p.kind {
5146                    if let NodeKind::BindPat { name, .. } = &pattern.kind {
5147                        let js = js_value_ident(&name.name);
5148                        scope.needs_let.insert(js.clone());
5149                        scope.declared.insert(js);
5150                    }
5151                }
5152            }
5153        }
5154        let r = self.emit_block_body_inner(body);
5155        self.leave_let_scope();
5156        r
5157    }
5158
5159    fn emit_block_body_inner(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
5160        if let NodeKind::Block { stmts, tail } = &node.kind {
5161            // Every non-tail statement is statement position: its value is
5162            // discarded. A statement-position `if`/`match` whose branch/arm body
5163            // ends in an expression (e.g. `println(...)`) must NOT `return` that
5164            // value — doing so aborts the function before the statements after
5165            // the `if`/`match` run. Activate `discard_tail` for the non-tail
5166            // statements (restored before the tail, which keeps function-body
5167            // return semantics). A nested loop/lambda overrides this within its
5168            // own body, so the discard applies only to the immediate
5169            // statement-position control flow.
5170            let prev_discard = std::mem::replace(&mut self.discard_tail, true);
5171            let mut stmt_res = Ok(());
5172            for s in stmts {
5173                stmt_res = self.emit_node(s);
5174                if stmt_res.is_err() {
5175                    break;
5176                }
5177            }
5178            self.discard_tail = prev_discard;
5179            stmt_res?;
5180            if let Some(t) = tail {
5181                // A statement tail (`break`/`continue`/`return`/assignment) is
5182                // emitted as a statement, never wrapped in `return`.
5183                if crate::generator::node_is_statement(t) {
5184                    self.emit_node(t)?;
5185                    return Ok(());
5186                }
5187                // A loop / while / for / guard / handling-block in tail position
5188                // has no JS expression form (its value, if any, was already
5189                // hoisted into a preceding temp by the shared value-CF pre-pass,
5190                // leaving a value-less construct here). Emit it as a statement —
5191                // `return while (…)` / `return /* unsupported */` is what the
5192                // fall-through `return <expr>` would otherwise produce.
5193                if tail_is_statement_form(t) {
5194                    self.emit_node(t)?;
5195                    return Ok(());
5196                }
5197                // A `match` with statement arms yields no value: emit it as a
5198                // statement `switch`, not as an IIFE.
5199                if let NodeKind::Match { scrutinee, arms } = &t.kind {
5200                    if crate::generator::match_has_statement_arm(arms) {
5201                        self.emit_match(scrutinee, arms)?;
5202                        return Ok(());
5203                    }
5204                }
5205                // A diverging-intrinsic tail (`todo()`/`unreachable()`) lowers to
5206                // a bare `throw` statement; `return throw …` is invalid JS, so
5207                // emit it as a statement.
5208                if js_call_is_diverging(t) {
5209                    self.write_indent();
5210                    self.emit_expr(t)?;
5211                    self.buf.push_str(";\n");
5212                    return Ok(());
5213                }
5214                // A `?` in the tail value (e.g. body tail `find_task(id)?`)
5215                // hoists to a pre-`return` temp + early-return.
5216                self.hoist_propagates(t)?;
5217                self.emit_tail_value(t)?;
5218            }
5219        } else if crate::generator::node_is_statement(node) || tail_is_statement_form(node) {
5220            self.emit_node(node)?;
5221        } else if js_call_is_diverging(node) {
5222            self.write_indent();
5223            self.emit_expr(node)?;
5224            self.buf.push_str(";\n");
5225        } else if let NodeKind::Match { scrutinee, arms } = &node.kind {
5226            if crate::generator::match_has_statement_arm(arms) {
5227                self.emit_match(scrutinee, arms)?;
5228            } else {
5229                self.hoist_propagates(node)?;
5230                self.emit_tail_value(node)?;
5231            }
5232        } else {
5233            // Single expression as body.
5234            self.hoist_propagates(node)?;
5235            self.emit_tail_value(node)?;
5236        }
5237        Ok(())
5238    }
5239
5240    /// Emit a block-body tail *value* expression. In a function-body /
5241    /// value-context block this is `return <value>;`. In a statement-position
5242    /// block ([`Self::discard_tail`] set — a loop body, or a statement-position
5243    /// `if`/`match` branch) the value is discarded, emitted as a bare expression
5244    /// statement `<value>;`; a `return` there would abort the enclosing function
5245    /// on the first loop iteration (the fizzbuzz / chat-protocol silent
5246    /// truncation bug). Callers that need an early-return-on-`?` must call
5247    /// [`Self::hoist_propagates`] first (this just renders the value).
5248    fn emit_tail_value(&mut self, value: &AIRNode) -> Result<(), CodegenError> {
5249        let ind = self.indent_str();
5250        if self.discard_tail {
5251            self.buf.push_str(&ind);
5252            self.emit_expr(value)?;
5253            self.buf.push_str(";\n");
5254        } else {
5255            let _ = write!(self.buf, "{ind}return ");
5256            self.emit_expr(value)?;
5257            self.buf.push_str(";\n");
5258        }
5259        Ok(())
5260    }
5261
5262    fn emit_block_as_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
5263        if let NodeKind::Block { stmts, tail } = &node.kind {
5264            if stmts.is_empty() {
5265                if let Some(t) = tail {
5266                    return self.emit_expr(t);
5267                }
5268            }
5269        }
5270        // Fallback: emit as IIFE.
5271        self.emit_expr(node)
5272    }
5273
5274    fn pattern_to_binding_name(&self, pat: &AIRNode) -> String {
5275        match &pat.kind {
5276            NodeKind::BindPat { name, .. } => js_value_ident(&name.name),
5277            NodeKind::WildcardPat => "_".into(),
5278            NodeKind::TuplePat { elems } => {
5279                format!(
5280                    "[{}]",
5281                    elems
5282                        .iter()
5283                        .map(|e| self.pattern_to_binding_name(e))
5284                        .collect::<Vec<_>>()
5285                        .join(", ")
5286                )
5287            }
5288            NodeKind::RecordPat { fields, .. } => {
5289                format!(
5290                    "{{ {} }}",
5291                    fields
5292                        .iter()
5293                        .map(|f| to_camel_case(&f.name.name).to_string())
5294                        .collect::<Vec<_>>()
5295                        .join(", ")
5296                )
5297            }
5298            _ => "_".into(),
5299        }
5300    }
5301
5302    fn pattern_to_js_destructure(&self, pat: &AIRNode) -> String {
5303        self.pattern_to_binding_name(pat)
5304    }
5305
5306    fn type_expr_to_string(&self, node: &AIRNode) -> String {
5307        match &node.kind {
5308            NodeKind::TypeNamed { path, .. } => path
5309                .segments
5310                .iter()
5311                .map(|s| s.name.as_str())
5312                .collect::<Vec<_>>()
5313                .join("."),
5314            NodeKind::Identifier { name } => name.name.clone(),
5315            _ => "Unknown".into(),
5316        }
5317    }
5318}
5319
5320// ─── Utility functions ───────────────────────────────────────────────────────
5321
5322/// Returns true if `name` is the identifier of a Duration or Instant instance
5323/// method. Used to recognise `d.as_millis()` / `i.elapsed()` calls during codegen.
5324fn is_time_method_name(name: &str) -> bool {
5325    matches!(
5326        name,
5327        "as_nanos"
5328            | "as_millis"
5329            | "as_seconds"
5330            | "is_zero"
5331            | "is_negative"
5332            | "abs"
5333            | "elapsed"
5334            | "duration_since"
5335    )
5336}
5337
5338/// Convert a name to `camelCase` (handles `snake_case`, `PascalCase`, and already `camelCase`).
5339/// Convert a Bock *value* identifier (a param, local binding, or free-function
5340/// name) to its JS form: `camelCase`, then escaped against the JS reserved-word
5341/// set so a binding named e.g. `default` emits `default_` rather than the
5342/// illegal bare keyword. Apply at every value declaration and reference site so
5343/// the escaped name is used uniformly; member/method names use bare
5344/// True when a value-position node lowers to a JS **statement-form** construct
5345/// that has no expression form — a `loop`/`while`/`for`, a `guard`, a nested
5346/// `block`, or a `handling` block. In tail position such a node must be emitted
5347/// as a statement (via [`EmitCtx::emit_node`]), never wrapped in `return <expr>`
5348/// (which would yield invalid JS such as `return while (…)` or, for a value-less
5349/// loop the expression lowering can't represent, `return /* unsupported */`).
5350///
5351/// Value-*bearing* loops/ifs/matches are rewritten upstream by the shared
5352/// value-CF pre-pass ([`crate::generator::hoist_value_cf`]) into a declare-then-
5353/// assign temp, so any such construct still present in tail position is
5354/// value-less and is safe to emit as a bare statement.
5355fn tail_is_statement_form(node: &AIRNode) -> bool {
5356    matches!(
5357        node.kind,
5358        NodeKind::Loop { .. }
5359            | NodeKind::While { .. }
5360            | NodeKind::For { .. }
5361            | NodeKind::Guard { .. }
5362            | NodeKind::HandlingBlock { .. }
5363    )
5364}
5365
5366/// Collect every `?` (`Propagate`) node reachable in `node`'s **own evaluation**
5367/// into `out`, in evaluation (innermost-first) order — see
5368/// [`EmitCtx::hoist_propagates`].
5369///
5370/// The walk follows only sub-expressions that are unconditionally evaluated as
5371/// part of the enclosing statement: a call's callee + args, a method call's
5372/// receiver + args, both operands of a non-short-circuiting binary op, a unary
5373/// operand, field/index access, `await`, a record/list/tuple/set element, a
5374/// pipe/compose operand, an interpolation hole, an assignment RHS, and a
5375/// `let`/`Propagate`/`Return` payload. It deliberately does **not** descend
5376/// into nested scopes (`Block`/`Lambda`) or the conditional/branch sub-trees of
5377/// `If`/`Match`/`Loop`/`While`/`For`/`Guard` (whose own `?`s are hoisted when
5378/// that nested statement is emitted), nor into the short-circuited operand of
5379/// `&&`/`||`.
5380fn collect_propagates_in_expr<'a>(node: &'a AIRNode, out: &mut Vec<&'a AIRNode>) {
5381    match &node.kind {
5382        NodeKind::Propagate { expr } => {
5383            // Inner `?` first (nested `f(x)?` → hoist `f(x)`'s `?` before this).
5384            collect_propagates_in_expr(expr, out);
5385            out.push(node);
5386        }
5387        NodeKind::Call { callee, args, .. } => {
5388            collect_propagates_in_expr(callee, out);
5389            for a in args {
5390                collect_propagates_in_expr(&a.value, out);
5391            }
5392        }
5393        NodeKind::MethodCall { receiver, args, .. } => {
5394            collect_propagates_in_expr(receiver, out);
5395            for a in args {
5396                collect_propagates_in_expr(&a.value, out);
5397            }
5398        }
5399        NodeKind::BinaryOp { op, left, right } => {
5400            collect_propagates_in_expr(left, out);
5401            // `&&` / `||` short-circuit: the right operand is only sometimes
5402            // evaluated, so a `?` there cannot be unconditionally hoisted.
5403            if !matches!(op, BinOp::And | BinOp::Or) {
5404                collect_propagates_in_expr(right, out);
5405            }
5406        }
5407        NodeKind::UnaryOp { operand, .. } => collect_propagates_in_expr(operand, out),
5408        NodeKind::FieldAccess { object, .. } => collect_propagates_in_expr(object, out),
5409        NodeKind::Index { object, index } => {
5410            collect_propagates_in_expr(object, out);
5411            collect_propagates_in_expr(index, out);
5412        }
5413        NodeKind::Await { expr } => collect_propagates_in_expr(expr, out),
5414        NodeKind::Pipe { left, right } | NodeKind::Compose { left, right } => {
5415            collect_propagates_in_expr(left, out);
5416            collect_propagates_in_expr(right, out);
5417        }
5418        NodeKind::Range { lo, hi, .. } => {
5419            collect_propagates_in_expr(lo, out);
5420            collect_propagates_in_expr(hi, out);
5421        }
5422        NodeKind::RecordConstruct { fields, spread, .. } => {
5423            for f in fields {
5424                if let Some(v) = &f.value {
5425                    collect_propagates_in_expr(v, out);
5426                }
5427            }
5428            if let Some(s) = spread {
5429                collect_propagates_in_expr(s, out);
5430            }
5431        }
5432        NodeKind::ListLiteral { elems }
5433        | NodeKind::SetLiteral { elems }
5434        | NodeKind::TupleLiteral { elems } => {
5435            for e in elems {
5436                collect_propagates_in_expr(e, out);
5437            }
5438        }
5439        NodeKind::MapLiteral { entries } => {
5440            for e in entries {
5441                collect_propagates_in_expr(&e.key, out);
5442                collect_propagates_in_expr(&e.value, out);
5443            }
5444        }
5445        NodeKind::Interpolation { parts } => {
5446            for p in parts {
5447                if let AirInterpolationPart::Expr(e) = p {
5448                    collect_propagates_in_expr(e, out);
5449                }
5450            }
5451        }
5452        NodeKind::Assign { value, .. } => collect_propagates_in_expr(value, out),
5453        NodeKind::LetBinding { value, .. } => collect_propagates_in_expr(value, out),
5454        NodeKind::Return { value: Some(v) } => collect_propagates_in_expr(v, out),
5455        // Leaf, nested-scope, and conditional-control-flow nodes are not
5456        // descended: their `?`s (if any) belong to a different statement scope.
5457        _ => {}
5458    }
5459}
5460
5461/// True when `node` is a call to a diverging intrinsic (`todo()` /
5462/// `unreachable()`), which lowers to a bare `throw new Error(...)` *statement* —
5463/// it never produces a value. In value-tail position the throw must be emitted
5464/// as its own statement: `return throw …` is a JS `SyntaxError`.
5465fn js_call_is_diverging(node: &AIRNode) -> bool {
5466    if let NodeKind::Call { callee, .. } = &node.kind {
5467        if let NodeKind::Identifier { name } = &callee.kind {
5468            return matches!(name.name.as_str(), "todo" | "unreachable");
5469        }
5470    }
5471    false
5472}
5473
5474/// [`to_camel_case`] (a keyword is legal as a member name). See
5475/// [`crate::generator::escape_target_keyword`].
5476///
5477/// Beyond the shared reserved-word set, JS forbids `eval` and `arguments` as a
5478/// binding or function name in strict mode (and every emitted ESM module is
5479/// strict). These are not reserved *keywords*, so they are not in the shared
5480/// list; the js emitter escapes them here so e.g. a Bock `fn eval(...)` emits
5481/// `function eval_(...)` rather than the strict-mode `SyntaxError`.
5482fn js_value_ident(name: &str) -> String {
5483    let escaped = crate::generator::escape_target_keyword(
5484        &to_camel_case(name),
5485        crate::generator::KeywordTarget::Js,
5486    );
5487    if matches!(escaped.as_str(), "eval" | "arguments") {
5488        format!("{escaped}_")
5489    } else {
5490        escaped
5491    }
5492}
5493
5494/// Spell `name` the way the JS backend emits the symbol's declaration / call
5495/// sites for an `import`/`export` specifier in the per-module path: a function
5496/// is camelCased and keyword-escaped via [`js_value_ident`]; any other kind
5497/// (records, enum variants, classes, traits, effects, consts) keeps its raw
5498/// name. A free function (not a method) so both `EmitCtx` and `generate_project`
5499/// helpers can call it.
5500/// True if any arm of `arms` matches against a list pattern (`[]`, `[a, ..b]`)
5501/// or a range pattern (`1..10`, `1..=10`). Neither has a single `switch`
5502/// discriminant — every such arm lowers to a `default:`, which is a JS
5503/// `SyntaxError` ("more than one default clause") once there are two of them.
5504/// The js emitter routes these to the if/else-if chain instead.
5505fn match_has_unswitchable_pattern(arms: &[AIRNode]) -> bool {
5506    arms.iter().any(|arm| {
5507        matches!(
5508            &arm.kind,
5509            NodeKind::MatchArm { pattern, .. }
5510                if matches!(pattern.kind, NodeKind::ListPat { .. } | NodeKind::RangePat { .. })
5511        )
5512    })
5513}
5514
5515fn esm_emit_name_static(name: &str, is_fn: bool) -> String {
5516    if is_fn {
5517        js_value_ident(name)
5518    } else {
5519        name.to_string()
5520    }
5521}
5522
5523fn to_camel_case(s: &str) -> String {
5524    if s.is_empty() || s == "_" {
5525        return s.to_string();
5526    }
5527    // If already camelCase (starts lowercase, no underscores), return as-is.
5528    if !s.contains('_') && s.starts_with(|c: char| c.is_lowercase()) {
5529        return s.to_string();
5530    }
5531    // If it's snake_case, convert to camelCase.
5532    if s.contains('_') {
5533        let parts: Vec<&str> = s.split('_').filter(|p| !p.is_empty()).collect();
5534        if parts.is_empty() {
5535            return s.to_string();
5536        }
5537        let mut result = parts[0].to_lowercase();
5538        for part in &parts[1..] {
5539            let mut chars = part.chars();
5540            if let Some(first) = chars.next() {
5541                result.push(
5542                    first
5543                        .to_uppercase()
5544                        .next()
5545                        .expect("uppercase yields at least one char"),
5546                );
5547                result.extend(chars);
5548            }
5549        }
5550        return result;
5551    }
5552    // If PascalCase, lowercase first letter.
5553    let mut chars = s.chars();
5554    let first = chars.next().expect("non-empty string guaranteed by caller");
5555    let mut result = first.to_lowercase().to_string();
5556    result.extend(chars);
5557    result
5558}
5559
5560/// Escape special characters in a JS string literal.
5561fn escape_js_string(s: &str) -> String {
5562    let mut out = String::with_capacity(s.len());
5563    for ch in s.chars() {
5564        match ch {
5565            '"' => out.push_str("\\\""),
5566            '\\' => out.push_str("\\\\"),
5567            '\n' => out.push_str("\\n"),
5568            '\r' => out.push_str("\\r"),
5569            '\t' => out.push_str("\\t"),
5570            _ => out.push(ch),
5571        }
5572    }
5573    out
5574}
5575
5576/// Render a literal as a JS value expression — used by the if-chain match
5577/// lowering to compare a scrutinee against a literal pattern (`<access> === …`).
5578/// Render a `RangePat` bound (`lo`/`hi`) as a JS expression. Range bounds are
5579/// literals (`1..10`) or a const identifier (`MIN..MAX`); anything else falls
5580/// back to the wrapped literal/identifier text, or `0` for an unrecognised node.
5581fn range_bound_to_js(node: &AIRNode) -> String {
5582    match &node.kind {
5583        NodeKind::LiteralPat { lit } => js_literal(lit),
5584        NodeKind::Literal { lit } => js_literal(lit),
5585        NodeKind::Identifier { name } => js_value_ident(&name.name),
5586        _ => "0".to_string(),
5587    }
5588}
5589
5590fn js_literal(lit: &Literal) -> String {
5591    match lit {
5592        Literal::Int(s) | Literal::Float(s) => s.clone(),
5593        Literal::Bool(b) => {
5594            if *b {
5595                "true".to_string()
5596            } else {
5597                "false".to_string()
5598            }
5599        }
5600        Literal::Char(s) => format!("'{s}'"),
5601        Literal::String(s) => format!("\"{}\"", escape_js_string(s)),
5602        Literal::Unit => "undefined".to_string(),
5603    }
5604}
5605
5606/// Escape special characters in a JS template literal.
5607fn escape_template_literal(s: &str) -> String {
5608    let mut out = String::with_capacity(s.len());
5609    for ch in s.chars() {
5610        match ch {
5611            '`' => out.push_str("\\`"),
5612            '\\' => out.push_str("\\\\"),
5613            '$' => out.push_str("\\$"),
5614            _ => out.push(ch),
5615        }
5616    }
5617    out
5618}
5619
5620// ─── Tests ───────────────────────────────────────────────────────────────────
5621
5622#[cfg(test)]
5623mod tests {
5624    use super::*;
5625    use bock_air::{AirArg, AirRecordField};
5626    use bock_ast::{Ident, TypePath};
5627    use bock_errors::{FileId, Span};
5628
5629    fn span() -> Span {
5630        Span {
5631            file: FileId(0),
5632            start: 0,
5633            end: 0,
5634        }
5635    }
5636
5637    fn ident(name: &str) -> Ident {
5638        Ident {
5639            name: name.to_string(),
5640            span: span(),
5641        }
5642    }
5643
5644    fn type_path(segments: &[&str]) -> TypePath {
5645        TypePath {
5646            segments: segments.iter().map(|s| ident(s)).collect(),
5647            span: span(),
5648        }
5649    }
5650
5651    fn node(id: u32, kind: NodeKind) -> AIRNode {
5652        AIRNode::new(id, span(), kind)
5653    }
5654
5655    fn int_lit(id: u32, val: &str) -> AIRNode {
5656        node(
5657            id,
5658            NodeKind::Literal {
5659                lit: Literal::Int(val.into()),
5660            },
5661        )
5662    }
5663
5664    fn str_lit(id: u32, val: &str) -> AIRNode {
5665        node(
5666            id,
5667            NodeKind::Literal {
5668                lit: Literal::String(val.into()),
5669            },
5670        )
5671    }
5672
5673    fn bool_lit(id: u32, val: bool) -> AIRNode {
5674        node(
5675            id,
5676            NodeKind::Literal {
5677                lit: Literal::Bool(val),
5678            },
5679        )
5680    }
5681
5682    fn id_node(id: u32, name: &str) -> AIRNode {
5683        node(id, NodeKind::Identifier { name: ident(name) })
5684    }
5685
5686    fn bind_pat(id: u32, name: &str) -> AIRNode {
5687        node(
5688            id,
5689            NodeKind::BindPat {
5690                name: ident(name),
5691                is_mut: false,
5692            },
5693        )
5694    }
5695
5696    fn param_node(id: u32, name: &str) -> AIRNode {
5697        node(
5698            id,
5699            NodeKind::Param {
5700                pattern: Box::new(bind_pat(id + 100, name)),
5701                ty: None,
5702                default: None,
5703            },
5704        )
5705    }
5706
5707    fn block(id: u32, stmts: Vec<AIRNode>, tail: Option<AIRNode>) -> AIRNode {
5708        node(
5709            id,
5710            NodeKind::Block {
5711                stmts,
5712                tail: tail.map(Box::new),
5713            },
5714        )
5715    }
5716
5717    fn module(imports: Vec<AIRNode>, items: Vec<AIRNode>) -> AIRNode {
5718        node(
5719            0,
5720            NodeKind::Module {
5721                path: None,
5722                annotations: vec![],
5723                imports,
5724                items,
5725            },
5726        )
5727    }
5728
5729    fn gen(module: &AIRNode) -> String {
5730        let gen = JsGenerator::new();
5731        let result = gen.generate_module(module).unwrap();
5732        result.files[0].content.clone()
5733    }
5734
5735    // ── Basic tests ─────────────────────────────────────────────────────────
5736
5737    #[test]
5738    fn implements_code_generator_trait() {
5739        let gen = JsGenerator::new();
5740        assert_eq!(gen.target().id, "js");
5741    }
5742
5743    #[test]
5744    fn empty_module() {
5745        let m = module(vec![], vec![]);
5746        let out = gen(&m);
5747        assert_eq!(out, "");
5748    }
5749
5750    #[test]
5751    fn simple_function() {
5752        let body = block(2, vec![], Some(int_lit(3, "42")));
5753        let f = node(
5754            1,
5755            NodeKind::FnDecl {
5756                annotations: vec![],
5757                visibility: Visibility::Private,
5758                is_async: false,
5759                name: ident("answer"),
5760                generic_params: vec![],
5761                params: vec![],
5762                return_type: None,
5763                effect_clause: vec![],
5764                where_clause: vec![],
5765                body: Box::new(body),
5766            },
5767        );
5768        let out = gen(&module(vec![], vec![f]));
5769        assert!(out.contains("function answer()"));
5770        assert!(out.contains("return 42;"));
5771    }
5772
5773    /// Build a desugared `recv.method(extra)` Call in the AIR shape the lowerer
5774    /// produces (receiver cloned into the FieldAccess object and the leading
5775    /// self arg, sharing a NodeId).
5776    fn list_method_call(method: &str, extra: Vec<AIRNode>) -> AIRNode {
5777        let recv = id_node(5, "nums");
5778        let callee = node(
5779            6,
5780            NodeKind::FieldAccess {
5781                object: Box::new(recv.clone()),
5782                field: ident(method),
5783            },
5784        );
5785        let mut args = vec![AirArg {
5786            label: None,
5787            value: recv,
5788        }];
5789        args.extend(extra.into_iter().map(|value| AirArg { label: None, value }));
5790        node(
5791            7,
5792            NodeKind::Call {
5793                callee: Box::new(callee),
5794                args,
5795                type_args: vec![],
5796            },
5797        )
5798    }
5799
5800    #[test]
5801    fn list_len_emits_length_property() {
5802        let body = block(2, vec![], Some(list_method_call("len", vec![])));
5803        let f = node(
5804            1,
5805            NodeKind::FnDecl {
5806                annotations: vec![],
5807                visibility: Visibility::Private,
5808                is_async: false,
5809                name: ident("f"),
5810                generic_params: vec![],
5811                params: vec![],
5812                return_type: None,
5813                effect_clause: vec![],
5814                where_clause: vec![],
5815                body: Box::new(body),
5816            },
5817        );
5818        let out = gen(&module(vec![], vec![f]));
5819        assert!(out.contains("(nums).length"), "got: {out}");
5820        // Must NOT emit the verbatim double-pass `nums.len(nums)`.
5821        assert!(!out.contains("nums.len("), "got: {out}");
5822    }
5823
5824    #[test]
5825    fn list_get_emits_tagged_optional_with_bounds_check() {
5826        let body = block(
5827            2,
5828            vec![],
5829            Some(list_method_call("get", vec![int_lit(8, "1")])),
5830        );
5831        let f = node(
5832            1,
5833            NodeKind::FnDecl {
5834                annotations: vec![],
5835                visibility: Visibility::Private,
5836                is_async: false,
5837                name: ident("f"),
5838                generic_params: vec![],
5839                params: vec![],
5840                return_type: None,
5841                effect_clause: vec![],
5842                where_clause: vec![],
5843                body: Box::new(body),
5844            },
5845        );
5846        let out = gen(&module(vec![], vec![f]));
5847        assert!(out.contains("_tag: \"Some\""), "got: {out}");
5848        assert!(out.contains("_tag: \"None\""), "got: {out}");
5849        assert!(
5850            out.contains("__i < __r.length"),
5851            "bounds check missing, got: {out}"
5852        );
5853    }
5854
5855    #[test]
5856    fn function_with_params() {
5857        let body = block(
5858            5,
5859            vec![],
5860            Some(node(
5861                6,
5862                NodeKind::BinaryOp {
5863                    op: BinOp::Add,
5864                    left: Box::new(id_node(7, "a")),
5865                    right: Box::new(id_node(8, "b")),
5866                },
5867            )),
5868        );
5869        let f = node(
5870            1,
5871            NodeKind::FnDecl {
5872                annotations: vec![],
5873                visibility: Visibility::Public,
5874                is_async: false,
5875                name: ident("add"),
5876                generic_params: vec![],
5877                params: vec![param_node(2, "a"), param_node(3, "b")],
5878                return_type: None,
5879                effect_clause: vec![],
5880                where_clause: vec![],
5881                body: Box::new(body),
5882            },
5883        );
5884        let out = gen(&module(vec![], vec![f]));
5885        assert!(out.contains("export function add(a, b)"));
5886        assert!(out.contains("(a + b)"));
5887    }
5888
5889    #[test]
5890    fn async_function() {
5891        let body = block(
5892            3,
5893            vec![],
5894            Some(node(
5895                4,
5896                NodeKind::Await {
5897                    expr: Box::new(node(
5898                        5,
5899                        NodeKind::Call {
5900                            callee: Box::new(id_node(6, "fetch")),
5901                            args: vec![AirArg {
5902                                label: None,
5903                                value: str_lit(7, "https://example.com"),
5904                            }],
5905                            type_args: vec![],
5906                        },
5907                    )),
5908                },
5909            )),
5910        );
5911        let f = node(
5912            1,
5913            NodeKind::FnDecl {
5914                annotations: vec![],
5915                visibility: Visibility::Private,
5916                is_async: true,
5917                name: ident("fetchData"),
5918                generic_params: vec![],
5919                params: vec![],
5920                return_type: None,
5921                effect_clause: vec![],
5922                where_clause: vec![],
5923                body: Box::new(body),
5924            },
5925        );
5926        let out = gen(&module(vec![], vec![f]));
5927        assert!(out.contains("async function fetchData()"));
5928        assert!(out.contains("await fetch"));
5929    }
5930
5931    #[test]
5932    fn effects_as_destructured_params() {
5933        let body = block(
5934            3,
5935            vec![node(
5936                4,
5937                NodeKind::LetBinding {
5938                    is_mut: false,
5939                    pattern: Box::new(bind_pat(5, "msg")),
5940                    ty: None,
5941                    value: Box::new(str_lit(6, "hello")),
5942                },
5943            )],
5944            Some(node(
5945                7,
5946                NodeKind::EffectOp {
5947                    effect: type_path(&["Log"]),
5948                    operation: ident("info"),
5949                    args: vec![AirArg {
5950                        label: None,
5951                        value: id_node(8, "msg"),
5952                    }],
5953                },
5954            )),
5955        );
5956        let f = node(
5957            1,
5958            NodeKind::FnDecl {
5959                annotations: vec![],
5960                visibility: Visibility::Private,
5961                is_async: false,
5962                name: ident("process"),
5963                generic_params: vec![],
5964                params: vec![param_node(2, "data")],
5965                return_type: None,
5966                effect_clause: vec![type_path(&["Log"]), type_path(&["Clock"])],
5967                where_clause: vec![],
5968                body: Box::new(body),
5969            },
5970        );
5971        let out = gen(&module(vec![], vec![f]));
5972        assert!(out.contains("function process(data, { log, clock })"));
5973        assert!(out.contains("log.info(msg)"));
5974    }
5975
5976    /// Q-clock-handler-routing: inside a `with Clock` function the §18.3.1 time
5977    /// builtins route through the in-scope `clock` handler — `Instant.now()` →
5978    /// `clock.now_monotonic()`, `sleep(d)` → `clock.sleep(d)`,
5979    /// `start.elapsed()` → `clock.now_monotonic() - start` — NOT the inlined
5980    /// host primitives (`performance.now()` / `setTimeout`).
5981    #[test]
5982    fn clock_time_ops_route_through_handler() {
5983        let out = gen(&module(vec![], vec![clock_timed_fn()]));
5984        assert!(out.contains("clock.now_monotonic()"), "got: {out}");
5985        assert!(out.contains("clock.sleep("), "got: {out}");
5986        assert!(
5987            !out.contains("performance.now()"),
5988            "host clock primitive leaked past the handler: {out}"
5989        );
5990        assert!(
5991            !out.contains("setTimeout"),
5992            "host sleep primitive leaked past the handler: {out}"
5993        );
5994    }
5995
5996    /// Builds `fn timed() with Clock { let start = Instant.now(); sleep(
5997    /// Duration.millis(1)); let d = start.elapsed() }` — the `with Clock` clause
5998    /// puts the `clock` handler in scope so the time builtins route through it.
5999    fn clock_timed_fn() -> AIRNode {
6000        let instant_now = node(
6001            40,
6002            NodeKind::Call {
6003                callee: Box::new(node(
6004                    41,
6005                    NodeKind::FieldAccess {
6006                        object: Box::new(id_node(42, "Instant")),
6007                        field: ident("now"),
6008                    },
6009                )),
6010                args: vec![],
6011                type_args: vec![],
6012            },
6013        );
6014        let duration_millis = node(
6015            50,
6016            NodeKind::Call {
6017                callee: Box::new(node(
6018                    51,
6019                    NodeKind::FieldAccess {
6020                        object: Box::new(id_node(52, "Duration")),
6021                        field: ident("millis"),
6022                    },
6023                )),
6024                args: vec![AirArg {
6025                    label: None,
6026                    value: int_lit(53, "1"),
6027                }],
6028                type_args: vec![],
6029            },
6030        );
6031        let sleep_call = node(
6032            60,
6033            NodeKind::Call {
6034                callee: Box::new(id_node(61, "sleep")),
6035                args: vec![AirArg {
6036                    label: None,
6037                    value: duration_millis,
6038                }],
6039                type_args: vec![],
6040            },
6041        );
6042        let elapsed_call = node(
6043            70,
6044            NodeKind::MethodCall {
6045                receiver: Box::new(id_node(71, "start")),
6046                method: ident("elapsed"),
6047                type_args: vec![],
6048                args: vec![],
6049            },
6050        );
6051        let body = block(
6052            30,
6053            vec![
6054                node(
6055                    31,
6056                    NodeKind::LetBinding {
6057                        is_mut: false,
6058                        pattern: Box::new(bind_pat(32, "start")),
6059                        ty: None,
6060                        value: Box::new(instant_now),
6061                    },
6062                ),
6063                sleep_call,
6064                node(
6065                    33,
6066                    NodeKind::LetBinding {
6067                        is_mut: false,
6068                        pattern: Box::new(bind_pat(34, "d")),
6069                        ty: None,
6070                        value: Box::new(elapsed_call),
6071                    },
6072                ),
6073            ],
6074            None,
6075        );
6076        node(
6077            1,
6078            NodeKind::FnDecl {
6079                annotations: vec![],
6080                visibility: Visibility::Private,
6081                is_async: false,
6082                name: ident("timed"),
6083                generic_params: vec![],
6084                params: vec![],
6085                return_type: None,
6086                effect_clause: vec![type_path(&["Clock"])],
6087                where_clause: vec![],
6088                body: Box::new(body),
6089            },
6090        )
6091    }
6092
6093    #[test]
6094    fn enum_to_tagged_objects() {
6095        let enum_decl = node(
6096            1,
6097            NodeKind::EnumDecl {
6098                annotations: vec![],
6099                visibility: Visibility::Public,
6100                name: ident("Shape"),
6101                generic_params: vec![],
6102                variants: vec![
6103                    node(
6104                        2,
6105                        NodeKind::EnumVariant {
6106                            name: ident("Circle"),
6107                            payload: EnumVariantPayload::Struct(vec![bock_ast::RecordDeclField {
6108                                id: 0,
6109                                span: span(),
6110                                name: ident("radius"),
6111                                ty: bock_ast::TypeExpr::Named {
6112                                    id: 0,
6113                                    span: span(),
6114                                    path: type_path(&["Float"]),
6115                                    args: vec![],
6116                                },
6117                                default: None,
6118                            }]),
6119                        },
6120                    ),
6121                    node(
6122                        3,
6123                        NodeKind::EnumVariant {
6124                            name: ident("None"),
6125                            payload: EnumVariantPayload::Unit,
6126                        },
6127                    ),
6128                ],
6129            },
6130        );
6131        let out = gen(&module(vec![], vec![enum_decl]));
6132        assert!(out.contains("function Shape_Circle(radius)"));
6133        assert!(out.contains("_tag: \"Circle\""));
6134        assert!(out.contains("Shape_None = Object.freeze({ _tag: \"None\" })"));
6135    }
6136
6137    #[test]
6138    fn match_on_tagged_objects() {
6139        let scrutinee = id_node(10, "shape");
6140        let arms = vec![
6141            node(
6142                11,
6143                NodeKind::MatchArm {
6144                    pattern: Box::new(node(
6145                        12,
6146                        NodeKind::ConstructorPat {
6147                            path: type_path(&["Shape", "Circle"]),
6148                            fields: vec![bind_pat(13, "r")],
6149                        },
6150                    )),
6151                    guard: None,
6152                    body: Box::new(block(
6153                        14,
6154                        vec![],
6155                        Some(node(
6156                            15,
6157                            NodeKind::BinaryOp {
6158                                op: BinOp::Mul,
6159                                left: Box::new(id_node(16, "r")),
6160                                right: Box::new(id_node(17, "r")),
6161                            },
6162                        )),
6163                    )),
6164                },
6165            ),
6166            node(
6167                18,
6168                NodeKind::MatchArm {
6169                    pattern: Box::new(node(19, NodeKind::WildcardPat)),
6170                    guard: None,
6171                    body: Box::new(block(20, vec![], Some(int_lit(21, "0")))),
6172                },
6173            ),
6174        ];
6175        let match_stmt = node(
6176            9,
6177            NodeKind::Match {
6178                scrutinee: Box::new(scrutinee),
6179                arms,
6180            },
6181        );
6182        // Wrap match in a function for statement context
6183        let f = node(
6184            1,
6185            NodeKind::FnDecl {
6186                annotations: vec![],
6187                visibility: Visibility::Private,
6188                is_async: false,
6189                name: ident("area"),
6190                generic_params: vec![],
6191                params: vec![param_node(2, "shape")],
6192                return_type: None,
6193                effect_clause: vec![],
6194                where_clause: vec![],
6195                body: Box::new(block(3, vec![match_stmt], None)),
6196            },
6197        );
6198        let out = gen(&module(vec![], vec![f]));
6199        assert!(out.contains("switch (shape._tag)"));
6200        assert!(out.contains("case \"Circle\""));
6201        assert!(out.contains("const r = shape._0;"));
6202        assert!(out.contains("default:"));
6203    }
6204
6205    #[test]
6206    fn ownership_erased() {
6207        let move_expr = node(
6208            1,
6209            NodeKind::Move {
6210                expr: Box::new(id_node(2, "x")),
6211            },
6212        );
6213        let borrow_expr = node(
6214            3,
6215            NodeKind::Borrow {
6216                expr: Box::new(id_node(4, "y")),
6217            },
6218        );
6219        let mut_borrow_expr = node(
6220            5,
6221            NodeKind::MutableBorrow {
6222                expr: Box::new(id_node(6, "z")),
6223            },
6224        );
6225        let body = block(
6226            7,
6227            vec![
6228                node(
6229                    8,
6230                    NodeKind::LetBinding {
6231                        is_mut: false,
6232                        pattern: Box::new(bind_pat(9, "a")),
6233                        ty: None,
6234                        value: Box::new(move_expr),
6235                    },
6236                ),
6237                node(
6238                    10,
6239                    NodeKind::LetBinding {
6240                        is_mut: false,
6241                        pattern: Box::new(bind_pat(11, "b")),
6242                        ty: None,
6243                        value: Box::new(borrow_expr),
6244                    },
6245                ),
6246                node(
6247                    12,
6248                    NodeKind::LetBinding {
6249                        is_mut: false,
6250                        pattern: Box::new(bind_pat(13, "c")),
6251                        ty: None,
6252                        value: Box::new(mut_borrow_expr),
6253                    },
6254                ),
6255            ],
6256            None,
6257        );
6258        let f = node(
6259            0,
6260            NodeKind::FnDecl {
6261                annotations: vec![],
6262                visibility: Visibility::Private,
6263                is_async: false,
6264                name: ident("test"),
6265                generic_params: vec![],
6266                params: vec![],
6267                return_type: None,
6268                effect_clause: vec![],
6269                where_clause: vec![],
6270                body: Box::new(body),
6271            },
6272        );
6273        let out = gen(&module(vec![], vec![f]));
6274        // Ownership annotations should be erased; just the values remain.
6275        assert!(out.contains("const a = x;"));
6276        assert!(out.contains("const b = y;"));
6277        assert!(out.contains("const c = z;"));
6278    }
6279
6280    #[test]
6281    fn let_binding_mut_uses_let() {
6282        let binding = node(
6283            1,
6284            NodeKind::LetBinding {
6285                is_mut: true,
6286                pattern: Box::new(bind_pat(2, "count")),
6287                ty: None,
6288                value: Box::new(int_lit(3, "0")),
6289            },
6290        );
6291        let f = node(
6292            0,
6293            NodeKind::FnDecl {
6294                annotations: vec![],
6295                visibility: Visibility::Private,
6296                is_async: false,
6297                name: ident("test"),
6298                generic_params: vec![],
6299                params: vec![],
6300                return_type: None,
6301                effect_clause: vec![],
6302                where_clause: vec![],
6303                body: Box::new(block(4, vec![binding], None)),
6304            },
6305        );
6306        let out = gen(&module(vec![], vec![f]));
6307        assert!(out.contains("let count = 0;"));
6308    }
6309
6310    #[test]
6311    fn string_interpolation() {
6312        let interp = node(
6313            1,
6314            NodeKind::Interpolation {
6315                parts: vec![
6316                    AirInterpolationPart::Literal("Hello, ".into()),
6317                    AirInterpolationPart::Expr(Box::new(id_node(2, "name"))),
6318                    AirInterpolationPart::Literal("!".into()),
6319                ],
6320            },
6321        );
6322        let binding = node(
6323            3,
6324            NodeKind::LetBinding {
6325                is_mut: false,
6326                pattern: Box::new(bind_pat(4, "msg")),
6327                ty: None,
6328                value: Box::new(interp),
6329            },
6330        );
6331        let f = node(
6332            0,
6333            NodeKind::FnDecl {
6334                annotations: vec![],
6335                visibility: Visibility::Private,
6336                is_async: false,
6337                name: ident("greet"),
6338                generic_params: vec![],
6339                params: vec![param_node(5, "name")],
6340                return_type: None,
6341                effect_clause: vec![],
6342                where_clause: vec![],
6343                body: Box::new(block(6, vec![binding], Some(id_node(7, "msg")))),
6344            },
6345        );
6346        let out = gen(&module(vec![], vec![f]));
6347        // The interpolated part is rendered through `__bockStr` so a user value
6348        // with a `Displayable` impl dispatches through its `to_string`
6349        // (Q-displayable-interpolation-dispatch); a primitive falls back to
6350        // native `String(x)`.
6351        assert!(out.contains("`Hello, ${__bockStr(name)}!`"));
6352    }
6353
6354    #[test]
6355    fn list_map_set_literals() {
6356        let list = node(
6357            1,
6358            NodeKind::ListLiteral {
6359                elems: vec![int_lit(2, "1"), int_lit(3, "2"), int_lit(4, "3")],
6360            },
6361        );
6362        let map = node(
6363            5,
6364            NodeKind::MapLiteral {
6365                entries: vec![bock_air::AirMapEntry {
6366                    key: str_lit(6, "a"),
6367                    value: int_lit(7, "1"),
6368                }],
6369            },
6370        );
6371        let set = node(
6372            8,
6373            NodeKind::SetLiteral {
6374                elems: vec![int_lit(9, "1"), int_lit(10, "2")],
6375            },
6376        );
6377        let body = block(
6378            11,
6379            vec![
6380                node(
6381                    12,
6382                    NodeKind::LetBinding {
6383                        is_mut: false,
6384                        pattern: Box::new(bind_pat(13, "xs")),
6385                        ty: None,
6386                        value: Box::new(list),
6387                    },
6388                ),
6389                node(
6390                    14,
6391                    NodeKind::LetBinding {
6392                        is_mut: false,
6393                        pattern: Box::new(bind_pat(15, "m")),
6394                        ty: None,
6395                        value: Box::new(map),
6396                    },
6397                ),
6398                node(
6399                    16,
6400                    NodeKind::LetBinding {
6401                        is_mut: false,
6402                        pattern: Box::new(bind_pat(17, "s")),
6403                        ty: None,
6404                        value: Box::new(set),
6405                    },
6406                ),
6407            ],
6408            None,
6409        );
6410        let f = node(
6411            0,
6412            NodeKind::FnDecl {
6413                annotations: vec![],
6414                visibility: Visibility::Private,
6415                is_async: false,
6416                name: ident("collections"),
6417                generic_params: vec![],
6418                params: vec![],
6419                return_type: None,
6420                effect_clause: vec![],
6421                where_clause: vec![],
6422                body: Box::new(body),
6423            },
6424        );
6425        let out = gen(&module(vec![], vec![f]));
6426        assert!(out.contains("[1, 2, 3]"));
6427        assert!(out.contains("new Map([[\"a\", 1]])"));
6428        assert!(out.contains("new Set([1, 2])"));
6429    }
6430
6431    #[test]
6432    fn record_construction() {
6433        let rec = node(
6434            1,
6435            NodeKind::RecordConstruct {
6436                path: type_path(&["User"]),
6437                fields: vec![
6438                    AirRecordField {
6439                        name: ident("name"),
6440                        value: Some(Box::new(str_lit(2, "Alice"))),
6441                    },
6442                    AirRecordField {
6443                        name: ident("age"),
6444                        value: Some(Box::new(int_lit(3, "30"))),
6445                    },
6446                ],
6447                spread: None,
6448            },
6449        );
6450        let binding = node(
6451            4,
6452            NodeKind::LetBinding {
6453                is_mut: false,
6454                pattern: Box::new(bind_pat(5, "user")),
6455                ty: None,
6456                value: Box::new(rec),
6457            },
6458        );
6459        let f = node(
6460            0,
6461            NodeKind::FnDecl {
6462                annotations: vec![],
6463                visibility: Visibility::Private,
6464                is_async: false,
6465                name: ident("test"),
6466                generic_params: vec![],
6467                params: vec![],
6468                return_type: None,
6469                effect_clause: vec![],
6470                where_clause: vec![],
6471                body: Box::new(block(6, vec![binding], None)),
6472            },
6473        );
6474        let out = gen(&module(vec![], vec![f]));
6475        assert!(out.contains("{ name: \"Alice\", age: 30 }"));
6476    }
6477
6478    #[test]
6479    fn control_flow() {
6480        let if_stmt = node(
6481            1,
6482            NodeKind::If {
6483                let_pattern: None,
6484                condition: Box::new(bool_lit(2, true)),
6485                then_block: Box::new(block(3, vec![], Some(int_lit(4, "1")))),
6486                else_block: Some(Box::new(block(5, vec![], Some(int_lit(6, "2"))))),
6487            },
6488        );
6489        let for_stmt = node(
6490            7,
6491            NodeKind::For {
6492                pattern: Box::new(bind_pat(8, "x")),
6493                iterable: Box::new(id_node(9, "items")),
6494                body: Box::new(block(10, vec![], None)),
6495            },
6496        );
6497        let while_stmt = node(
6498            11,
6499            NodeKind::While {
6500                condition: Box::new(bool_lit(12, true)),
6501                body: Box::new(block(
6502                    13,
6503                    vec![node(14, NodeKind::Break { value: None })],
6504                    None,
6505                )),
6506            },
6507        );
6508        let body = block(15, vec![if_stmt, for_stmt, while_stmt], None);
6509        let f = node(
6510            0,
6511            NodeKind::FnDecl {
6512                annotations: vec![],
6513                visibility: Visibility::Private,
6514                is_async: false,
6515                name: ident("flow"),
6516                generic_params: vec![],
6517                params: vec![param_node(16, "items")],
6518                return_type: None,
6519                effect_clause: vec![],
6520                where_clause: vec![],
6521                body: Box::new(body),
6522            },
6523        );
6524        let out = gen(&module(vec![], vec![f]));
6525        assert!(out.contains("if (true)"));
6526        assert!(out.contains("} else {"));
6527        assert!(out.contains("for (const x of items)"));
6528        assert!(out.contains("while (true)"));
6529        assert!(out.contains("break;"));
6530    }
6531
6532    #[test]
6533    fn lambda_and_pipe() {
6534        let lambda = node(
6535            1,
6536            NodeKind::Lambda {
6537                params: vec![param_node(2, "x")],
6538                body: Box::new(node(
6539                    3,
6540                    NodeKind::BinaryOp {
6541                        op: BinOp::Mul,
6542                        left: Box::new(id_node(4, "x")),
6543                        right: Box::new(int_lit(5, "2")),
6544                    },
6545                )),
6546            },
6547        );
6548        let pipe = node(
6549            6,
6550            NodeKind::Pipe {
6551                left: Box::new(int_lit(7, "5")),
6552                right: Box::new(id_node(8, "double")),
6553            },
6554        );
6555        let body = block(
6556            9,
6557            vec![
6558                node(
6559                    10,
6560                    NodeKind::LetBinding {
6561                        is_mut: false,
6562                        pattern: Box::new(bind_pat(11, "double")),
6563                        ty: None,
6564                        value: Box::new(lambda),
6565                    },
6566                ),
6567                node(
6568                    12,
6569                    NodeKind::LetBinding {
6570                        is_mut: false,
6571                        pattern: Box::new(bind_pat(13, "result")),
6572                        ty: None,
6573                        value: Box::new(pipe),
6574                    },
6575                ),
6576            ],
6577            None,
6578        );
6579        let f = node(
6580            0,
6581            NodeKind::FnDecl {
6582                annotations: vec![],
6583                visibility: Visibility::Private,
6584                is_async: false,
6585                name: ident("test"),
6586                generic_params: vec![],
6587                params: vec![],
6588                return_type: None,
6589                effect_clause: vec![],
6590                where_clause: vec![],
6591                body: Box::new(body),
6592            },
6593        );
6594        let out = gen(&module(vec![], vec![f]));
6595        assert!(out.contains("(x) => (x * 2)"));
6596        assert!(out.contains("double(5)"));
6597    }
6598
6599    #[test]
6600    fn result_construct() {
6601        let ok = node(
6602            1,
6603            NodeKind::ResultConstruct {
6604                variant: ResultVariant::Ok,
6605                value: Some(Box::new(int_lit(2, "42"))),
6606            },
6607        );
6608        let err = node(
6609            3,
6610            NodeKind::ResultConstruct {
6611                variant: ResultVariant::Err,
6612                value: Some(Box::new(str_lit(4, "failed"))),
6613            },
6614        );
6615        let body = block(
6616            5,
6617            vec![
6618                node(
6619                    6,
6620                    NodeKind::LetBinding {
6621                        is_mut: false,
6622                        pattern: Box::new(bind_pat(7, "good")),
6623                        ty: None,
6624                        value: Box::new(ok),
6625                    },
6626                ),
6627                node(
6628                    8,
6629                    NodeKind::LetBinding {
6630                        is_mut: false,
6631                        pattern: Box::new(bind_pat(9, "bad")),
6632                        ty: None,
6633                        value: Box::new(err),
6634                    },
6635                ),
6636            ],
6637            None,
6638        );
6639        let f = node(
6640            0,
6641            NodeKind::FnDecl {
6642                annotations: vec![],
6643                visibility: Visibility::Private,
6644                is_async: false,
6645                name: ident("test"),
6646                generic_params: vec![],
6647                params: vec![],
6648                return_type: None,
6649                effect_clause: vec![],
6650                where_clause: vec![],
6651                body: Box::new(body),
6652            },
6653        );
6654        let out = gen(&module(vec![], vec![f]));
6655        // Reconciled on the `_0` payload key the `Result` match reads.
6656        assert!(out.contains("{ _tag: \"Ok\", _0: 42 }"), "got: {out}");
6657        assert!(
6658            out.contains("{ _tag: \"Err\", _0: \"failed\" }"),
6659            "got: {out}"
6660        );
6661    }
6662
6663    #[test]
6664    fn class_declaration() {
6665        let method_body = block(10, vec![], Some(id_node(11, "undefined")));
6666        let method = node(
6667            5,
6668            NodeKind::FnDecl {
6669                annotations: vec![],
6670                visibility: Visibility::Public,
6671                is_async: false,
6672                name: ident("greet"),
6673                generic_params: vec![],
6674                params: vec![],
6675                return_type: None,
6676                effect_clause: vec![],
6677                where_clause: vec![],
6678                body: Box::new(method_body),
6679            },
6680        );
6681        let cls = node(
6682            1,
6683            NodeKind::ClassDecl {
6684                annotations: vec![],
6685                visibility: Visibility::Public,
6686                name: ident("Person"),
6687                generic_params: vec![],
6688                base: None,
6689                traits: vec![],
6690                fields: vec![bock_ast::RecordDeclField {
6691                    id: 0,
6692                    span: span(),
6693                    name: ident("name"),
6694                    ty: bock_ast::TypeExpr::Named {
6695                        id: 0,
6696                        span: span(),
6697                        path: type_path(&["String"]),
6698                        args: vec![],
6699                    },
6700                    default: None,
6701                }],
6702                methods: vec![method],
6703            },
6704        );
6705        let out = gen(&module(vec![], vec![cls]));
6706        assert!(out.contains("class Person {"));
6707        assert!(out.contains("constructor(name)"));
6708        assert!(out.contains("this.name = name;"));
6709        assert!(out.contains("greet()"));
6710    }
6711
6712    /// A `class T { a, b }` literal must construct via the class's **positional**
6713    /// constructor — `new T(a_value, b_value)` in *field-declaration order* — not
6714    /// the bare object literal the record path would emit (whose prototype
6715    /// methods would be unreachable). Q-class-codegen.
6716    #[test]
6717    fn class_literal_constructs_positionally() {
6718        fn class_field(name: &str) -> bock_ast::RecordDeclField {
6719            bock_ast::RecordDeclField {
6720                id: 0,
6721                span: span(),
6722                name: ident(name),
6723                ty: bock_ast::TypeExpr::Named {
6724                    id: 0,
6725                    span: span(),
6726                    path: type_path(&["String"]),
6727                    args: vec![],
6728                },
6729                default: None,
6730            }
6731        }
6732        let cls = node(
6733            1,
6734            NodeKind::ClassDecl {
6735                annotations: vec![],
6736                visibility: Visibility::Public,
6737                name: ident("Button"),
6738                generic_params: vec![],
6739                base: None,
6740                traits: vec![],
6741                fields: vec![class_field("label"), class_field("on_click")],
6742                methods: vec![],
6743            },
6744        );
6745        // Construct with the fields supplied OUT of declaration order
6746        // (`on_click` before `label`) — the emitter must still pass them in
6747        // declaration order positionally.
6748        let construct = node(
6749            10,
6750            NodeKind::RecordConstruct {
6751                path: type_path(&["Button"]),
6752                fields: vec![
6753                    AirRecordField {
6754                        name: ident("on_click"),
6755                        value: Some(Box::new(str_lit(11, "click"))),
6756                    },
6757                    AirRecordField {
6758                        name: ident("label"),
6759                        value: Some(Box::new(str_lit(12, "Submit"))),
6760                    },
6761                ],
6762                spread: None,
6763            },
6764        );
6765        let main_fn = node(
6766            20,
6767            NodeKind::FnDecl {
6768                annotations: vec![],
6769                visibility: Visibility::Private,
6770                is_async: false,
6771                name: ident("main"),
6772                generic_params: vec![],
6773                params: vec![],
6774                return_type: None,
6775                effect_clause: vec![],
6776                where_clause: vec![],
6777                body: Box::new(block(
6778                    21,
6779                    vec![node(
6780                        22,
6781                        NodeKind::LetBinding {
6782                            is_mut: false,
6783                            pattern: Box::new(bind_pat(23, "b")),
6784                            ty: None,
6785                            value: Box::new(construct),
6786                        },
6787                    )],
6788                    None,
6789                )),
6790            },
6791        );
6792        let out = gen(&module(vec![], vec![cls, main_fn]));
6793        // Positional construction in declaration order — NOT a bare object literal.
6794        assert!(
6795            out.contains(r#"new Button("Submit", "click")"#),
6796            "expected positional `new Button(...)` in declaration order, got:\n{out}"
6797        );
6798        assert!(
6799            !out.contains("{ label:"),
6800            "class literal must not emit a bare object literal:\n{out}"
6801        );
6802    }
6803
6804    #[test]
6805    fn const_declaration() {
6806        let c = node(
6807            1,
6808            NodeKind::ConstDecl {
6809                annotations: vec![],
6810                visibility: Visibility::Public,
6811                name: ident("PI"),
6812                ty: Box::new(node(
6813                    2,
6814                    NodeKind::TypeNamed {
6815                        path: type_path(&["Float"]),
6816                        args: vec![],
6817                    },
6818                )),
6819                value: Box::new(node(
6820                    3,
6821                    NodeKind::Literal {
6822                        lit: Literal::Float("3.14159".into()),
6823                    },
6824                )),
6825            },
6826        );
6827        let out = gen(&module(vec![], vec![c]));
6828        assert!(out.contains("const PI = 3.14159;"));
6829    }
6830
6831    #[test]
6832    fn record_declaration() {
6833        let rec = node(
6834            1,
6835            NodeKind::RecordDecl {
6836                annotations: vec![],
6837                visibility: Visibility::Public,
6838                name: ident("Point"),
6839                generic_params: vec![],
6840                fields: vec![
6841                    bock_ast::RecordDeclField {
6842                        id: 0,
6843                        span: span(),
6844                        name: ident("x"),
6845                        ty: bock_ast::TypeExpr::Named {
6846                            id: 0,
6847                            span: span(),
6848                            path: type_path(&["Float"]),
6849                            args: vec![],
6850                        },
6851                        default: None,
6852                    },
6853                    bock_ast::RecordDeclField {
6854                        id: 0,
6855                        span: span(),
6856                        name: ident("y"),
6857                        ty: bock_ast::TypeExpr::Named {
6858                            id: 0,
6859                            span: span(),
6860                            path: type_path(&["Float"]),
6861                            args: vec![],
6862                        },
6863                        default: None,
6864                    },
6865                ],
6866            },
6867        );
6868        let out = gen(&module(vec![], vec![rec]));
6869        assert!(out.contains("class Point {"));
6870        assert!(out.contains("constructor({ x, y })"));
6871        assert!(out.contains("this.x = x;"));
6872        assert!(out.contains("this.y = y;"));
6873    }
6874
6875    // ── End-to-end tests (node --check + node execution) ────────────────────
6876
6877    fn has_node() -> bool {
6878        std::process::Command::new("which")
6879            .arg("node")
6880            .output()
6881            .map(|o| o.status.success())
6882            .unwrap_or(false)
6883    }
6884
6885    /// Run generated JS through `node --check` for syntax validation.
6886    fn check_js_syntax(code: &str) -> bool {
6887        use std::io::Write;
6888        let mut child = std::process::Command::new("node")
6889            .arg("--check")
6890            .stdin(std::process::Stdio::piped())
6891            .stdout(std::process::Stdio::null())
6892            .stderr(std::process::Stdio::null())
6893            .spawn()
6894            .expect("failed to spawn node");
6895        child
6896            .stdin
6897            .as_mut()
6898            .unwrap()
6899            .write_all(code.as_bytes())
6900            .unwrap();
6901        child.wait().unwrap().success()
6902    }
6903
6904    /// Run generated JS with `node` and capture stdout.
6905    fn run_js(code: &str) -> String {
6906        let output = std::process::Command::new("node")
6907            .arg("-e")
6908            .arg(code)
6909            .output()
6910            .expect("failed to run node");
6911        String::from_utf8(output.stdout).unwrap().trim().to_string()
6912    }
6913
6914    #[test]
6915    #[ignore]
6916    fn e2e_hello_world() {
6917        if !has_node() {
6918            return;
6919        }
6920        // fn main() { console.log("Hello, World!") }
6921        let body = block(
6922            2,
6923            vec![],
6924            Some(node(
6925                3,
6926                NodeKind::Call {
6927                    callee: Box::new(node(
6928                        4,
6929                        NodeKind::FieldAccess {
6930                            object: Box::new(id_node(5, "console")),
6931                            field: ident("log"),
6932                        },
6933                    )),
6934                    args: vec![AirArg {
6935                        label: None,
6936                        value: str_lit(6, "Hello, World!"),
6937                    }],
6938                    type_args: vec![],
6939                },
6940            )),
6941        );
6942        let f = node(
6943            1,
6944            NodeKind::FnDecl {
6945                annotations: vec![],
6946                visibility: Visibility::Private,
6947                is_async: false,
6948                name: ident("main"),
6949                generic_params: vec![],
6950                params: vec![],
6951                return_type: None,
6952                effect_clause: vec![],
6953                where_clause: vec![],
6954                body: Box::new(body),
6955            },
6956        );
6957        let code = gen(&module(vec![], vec![f]));
6958        let full = format!("{code}\nmain();\n");
6959        assert!(check_js_syntax(&full));
6960        assert_eq!(run_js(&full), "Hello, World!");
6961    }
6962
6963    #[test]
6964    #[ignore]
6965    fn e2e_arithmetic() {
6966        if !has_node() {
6967            return;
6968        }
6969        let body = block(
6970            2,
6971            vec![],
6972            Some(node(
6973                3,
6974                NodeKind::BinaryOp {
6975                    op: BinOp::Add,
6976                    left: Box::new(int_lit(4, "10")),
6977                    right: Box::new(int_lit(5, "32")),
6978                },
6979            )),
6980        );
6981        let f = node(
6982            1,
6983            NodeKind::FnDecl {
6984                annotations: vec![],
6985                visibility: Visibility::Private,
6986                is_async: false,
6987                name: ident("calc"),
6988                generic_params: vec![],
6989                params: vec![],
6990                return_type: None,
6991                effect_clause: vec![],
6992                where_clause: vec![],
6993                body: Box::new(body),
6994            },
6995        );
6996        let code = gen(&module(vec![], vec![f]));
6997        let full = format!("{code}\nconsole.log(calc());\n");
6998        assert!(check_js_syntax(&full));
6999        assert_eq!(run_js(&full), "42");
7000    }
7001
7002    #[test]
7003    #[ignore]
7004    fn e2e_if_else() {
7005        if !has_node() {
7006            return;
7007        }
7008        let if_stmt = node(
7009            3,
7010            NodeKind::If {
7011                let_pattern: None,
7012                condition: Box::new(node(
7013                    4,
7014                    NodeKind::BinaryOp {
7015                        op: BinOp::Gt,
7016                        left: Box::new(id_node(5, "x")),
7017                        right: Box::new(int_lit(6, "0")),
7018                    },
7019                )),
7020                then_block: Box::new(block(7, vec![], Some(str_lit(8, "positive")))),
7021                else_block: Some(Box::new(block(
7022                    9,
7023                    vec![],
7024                    Some(str_lit(10, "non-positive")),
7025                ))),
7026            },
7027        );
7028        let body = block(2, vec![if_stmt], None);
7029        let f = node(
7030            1,
7031            NodeKind::FnDecl {
7032                annotations: vec![],
7033                visibility: Visibility::Private,
7034                is_async: false,
7035                name: ident("classify"),
7036                generic_params: vec![],
7037                params: vec![param_node(11, "x")],
7038                return_type: None,
7039                effect_clause: vec![],
7040                where_clause: vec![],
7041                body: Box::new(body),
7042            },
7043        );
7044        let code = gen(&module(vec![], vec![f]));
7045        let full = format!("{code}\nconsole.log(classify(5));\nconsole.log(classify(-1));\n");
7046        assert!(check_js_syntax(&full));
7047        let output = run_js(&full);
7048        assert!(output.contains("positive"));
7049        assert!(output.contains("non-positive"));
7050    }
7051
7052    #[test]
7053    #[ignore]
7054    fn e2e_for_loop() {
7055        if !has_node() {
7056            return;
7057        }
7058        let body = block(
7059            2,
7060            vec![
7061                node(
7062                    3,
7063                    NodeKind::LetBinding {
7064                        is_mut: true,
7065                        pattern: Box::new(bind_pat(4, "sum")),
7066                        ty: None,
7067                        value: Box::new(int_lit(5, "0")),
7068                    },
7069                ),
7070                node(
7071                    6,
7072                    NodeKind::For {
7073                        pattern: Box::new(bind_pat(7, "x")),
7074                        iterable: Box::new(node(
7075                            8,
7076                            NodeKind::ListLiteral {
7077                                elems: vec![int_lit(9, "1"), int_lit(10, "2"), int_lit(11, "3")],
7078                            },
7079                        )),
7080                        body: Box::new(block(
7081                            12,
7082                            vec![node(
7083                                13,
7084                                NodeKind::Assign {
7085                                    op: AssignOp::AddAssign,
7086                                    target: Box::new(id_node(14, "sum")),
7087                                    value: Box::new(id_node(15, "x")),
7088                                },
7089                            )],
7090                            None,
7091                        )),
7092                    },
7093                ),
7094            ],
7095            Some(id_node(16, "sum")),
7096        );
7097        let f = node(
7098            1,
7099            NodeKind::FnDecl {
7100                annotations: vec![],
7101                visibility: Visibility::Private,
7102                is_async: false,
7103                name: ident("total"),
7104                generic_params: vec![],
7105                params: vec![],
7106                return_type: None,
7107                effect_clause: vec![],
7108                where_clause: vec![],
7109                body: Box::new(body),
7110            },
7111        );
7112        let code = gen(&module(vec![], vec![f]));
7113        let full = format!("{code}\nconsole.log(total());\n");
7114        assert!(check_js_syntax(&full));
7115        assert_eq!(run_js(&full), "6");
7116    }
7117
7118    #[test]
7119    #[ignore]
7120    fn e2e_tagged_objects() {
7121        if !has_node() {
7122            return;
7123        }
7124        // enum Color { Red, Green, Blue }
7125        let enum_decl = node(
7126            1,
7127            NodeKind::EnumDecl {
7128                annotations: vec![],
7129                visibility: Visibility::Public,
7130                name: ident("Color"),
7131                generic_params: vec![],
7132                variants: vec![
7133                    node(
7134                        2,
7135                        NodeKind::EnumVariant {
7136                            name: ident("Red"),
7137                            payload: EnumVariantPayload::Unit,
7138                        },
7139                    ),
7140                    node(
7141                        3,
7142                        NodeKind::EnumVariant {
7143                            name: ident("Green"),
7144                            payload: EnumVariantPayload::Unit,
7145                        },
7146                    ),
7147                    node(
7148                        4,
7149                        NodeKind::EnumVariant {
7150                            name: ident("Blue"),
7151                            payload: EnumVariantPayload::Unit,
7152                        },
7153                    ),
7154                ],
7155            },
7156        );
7157        let code = gen(&module(vec![], vec![enum_decl]));
7158        let full =
7159            format!("{code}\nconsole.log(Color_Red._tag);\nconsole.log(Color_Green._tag);\n");
7160        assert!(check_js_syntax(&full));
7161        let output = run_js(&full);
7162        assert!(output.contains("Red"));
7163        assert!(output.contains("Green"));
7164    }
7165
7166    #[test]
7167    #[ignore]
7168    fn e2e_match_switch() {
7169        if !has_node() {
7170            return;
7171        }
7172        // Match on literal values
7173        let match_node = node(
7174            3,
7175            NodeKind::Match {
7176                scrutinee: Box::new(id_node(4, "n")),
7177                arms: vec![
7178                    node(
7179                        5,
7180                        NodeKind::MatchArm {
7181                            pattern: Box::new(node(
7182                                6,
7183                                NodeKind::LiteralPat {
7184                                    lit: Literal::Int("1".into()),
7185                                },
7186                            )),
7187                            guard: None,
7188                            body: Box::new(block(7, vec![], Some(str_lit(8, "one")))),
7189                        },
7190                    ),
7191                    node(
7192                        9,
7193                        NodeKind::MatchArm {
7194                            pattern: Box::new(node(
7195                                10,
7196                                NodeKind::LiteralPat {
7197                                    lit: Literal::Int("2".into()),
7198                                },
7199                            )),
7200                            guard: None,
7201                            body: Box::new(block(11, vec![], Some(str_lit(12, "two")))),
7202                        },
7203                    ),
7204                    node(
7205                        13,
7206                        NodeKind::MatchArm {
7207                            pattern: Box::new(node(14, NodeKind::WildcardPat)),
7208                            guard: None,
7209                            body: Box::new(block(15, vec![], Some(str_lit(16, "other")))),
7210                        },
7211                    ),
7212                ],
7213            },
7214        );
7215        let f = node(
7216            1,
7217            NodeKind::FnDecl {
7218                annotations: vec![],
7219                visibility: Visibility::Private,
7220                is_async: false,
7221                name: ident("describe"),
7222                generic_params: vec![],
7223                params: vec![param_node(2, "n")],
7224                return_type: None,
7225                effect_clause: vec![],
7226                where_clause: vec![],
7227                body: Box::new(block(17, vec![match_node], None)),
7228            },
7229        );
7230        let code = gen(&module(vec![], vec![f]));
7231        let full = format!("{code}\nconsole.log(describe(1));\nconsole.log(describe(2));\nconsole.log(describe(99));\n");
7232        assert!(check_js_syntax(&full));
7233        let output = run_js(&full);
7234        assert!(output.contains("one"));
7235        assert!(output.contains("two"));
7236        assert!(output.contains("other"));
7237    }
7238
7239    /// A literal + bind value match keeps the `switch` fast-path (no if-chain).
7240    #[test]
7241    fn match_literal_bind_stays_switch() {
7242        let arms = vec![
7243            node(
7244                5,
7245                NodeKind::MatchArm {
7246                    pattern: Box::new(node(
7247                        6,
7248                        NodeKind::LiteralPat {
7249                            lit: Literal::Int("0".into()),
7250                        },
7251                    )),
7252                    guard: None,
7253                    body: Box::new(block(7, vec![], Some(str_lit(8, "zero")))),
7254                },
7255            ),
7256            node(
7257                9,
7258                NodeKind::MatchArm {
7259                    pattern: Box::new(node(
7260                        10,
7261                        NodeKind::BindPat {
7262                            name: ident("x"),
7263                            is_mut: false,
7264                        },
7265                    )),
7266                    guard: None,
7267                    body: Box::new(block(11, vec![], Some(id_node(12, "x")))),
7268                },
7269            ),
7270        ];
7271        let m = node(
7272            3,
7273            NodeKind::Match {
7274                scrutinee: Box::new(id_node(4, "n")),
7275                arms,
7276            },
7277        );
7278        let f = node(
7279            1,
7280            NodeKind::FnDecl {
7281                annotations: vec![],
7282                visibility: Visibility::Private,
7283                is_async: false,
7284                name: ident("label"),
7285                generic_params: vec![],
7286                params: vec![param_node(2, "n")],
7287                return_type: None,
7288                effect_clause: vec![],
7289                where_clause: vec![],
7290                body: Box::new(block(13, vec![m], None)),
7291            },
7292        );
7293        let code = gen(&module(vec![], vec![f]));
7294        assert!(
7295            code.contains("switch (n)"),
7296            "expected switch fast-path, got:\n{code}"
7297        );
7298        assert!(
7299            !code.contains("else if"),
7300            "should not use if-chain, got:\n{code}"
7301        );
7302    }
7303
7304    /// A guarded arm lowers to an if/else-if chain whose failed guard falls
7305    /// through to the next arm (the value-`switch` could not express this).
7306    #[test]
7307    fn match_guard_lowers_to_ifchain() {
7308        let guarded = |id: u32, label: &str| {
7309            node(
7310                id,
7311                NodeKind::MatchArm {
7312                    pattern: Box::new(node(
7313                        id + 1,
7314                        NodeKind::BindPat {
7315                            name: ident("x"),
7316                            is_mut: false,
7317                        },
7318                    )),
7319                    guard: Some(Box::new(node(
7320                        id + 2,
7321                        NodeKind::BinaryOp {
7322                            op: BinOp::Gt,
7323                            left: Box::new(id_node(id + 3, "x")),
7324                            right: Box::new(node(
7325                                id + 4,
7326                                NodeKind::Literal {
7327                                    lit: Literal::Int("0".into()),
7328                                },
7329                            )),
7330                        },
7331                    ))),
7332                    body: Box::new(block(id + 5, vec![], Some(str_lit(id + 6, label)))),
7333                },
7334            )
7335        };
7336        let arms = vec![
7337            guarded(5, "pos"),
7338            node(
7339                20,
7340                NodeKind::MatchArm {
7341                    pattern: Box::new(node(21, NodeKind::WildcardPat)),
7342                    guard: None,
7343                    body: Box::new(block(22, vec![], Some(str_lit(23, "other")))),
7344                },
7345            ),
7346        ];
7347        let m = node(
7348            3,
7349            NodeKind::Match {
7350                scrutinee: Box::new(id_node(4, "n")),
7351                arms,
7352            },
7353        );
7354        let f = node(
7355            1,
7356            NodeKind::FnDecl {
7357                annotations: vec![],
7358                visibility: Visibility::Private,
7359                is_async: false,
7360                name: ident("classify"),
7361                generic_params: vec![],
7362                params: vec![param_node(2, "n")],
7363                return_type: None,
7364                effect_clause: vec![],
7365                where_clause: vec![],
7366                body: Box::new(block(30, vec![m], None)),
7367            },
7368        );
7369        let code = gen(&module(vec![], vec![f]));
7370        assert!(
7371            !code.contains("switch"),
7372            "guard match must not use switch, got:\n{code}"
7373        );
7374        assert!(
7375            code.contains("else"),
7376            "guard match must chain to a fallthrough, got:\n{code}"
7377        );
7378        // The guard binding is re-introduced inside the condition's IIFE.
7379        assert!(
7380            code.contains("const x = n;"),
7381            "guard must bind x, got:\n{code}"
7382        );
7383    }
7384
7385    #[test]
7386    #[ignore]
7387    fn e2e_string_interpolation() {
7388        if !has_node() {
7389            return;
7390        }
7391        let body = block(
7392            2,
7393            vec![],
7394            Some(node(
7395                3,
7396                NodeKind::Interpolation {
7397                    parts: vec![
7398                        AirInterpolationPart::Literal("Hello, ".into()),
7399                        AirInterpolationPart::Expr(Box::new(id_node(4, "name"))),
7400                        AirInterpolationPart::Literal("! You are ".into()),
7401                        AirInterpolationPart::Expr(Box::new(id_node(5, "age"))),
7402                        AirInterpolationPart::Literal(" years old.".into()),
7403                    ],
7404                },
7405            )),
7406        );
7407        let f = node(
7408            1,
7409            NodeKind::FnDecl {
7410                annotations: vec![],
7411                visibility: Visibility::Private,
7412                is_async: false,
7413                name: ident("greet"),
7414                generic_params: vec![],
7415                params: vec![param_node(6, "name"), param_node(7, "age")],
7416                return_type: None,
7417                effect_clause: vec![],
7418                where_clause: vec![],
7419                body: Box::new(body),
7420            },
7421        );
7422        let code = gen(&module(vec![], vec![f]));
7423        let full = format!("{code}\nconsole.log(greet(\"Alice\", 30));\n");
7424        assert!(check_js_syntax(&full));
7425        assert_eq!(run_js(&full), "Hello, Alice! You are 30 years old.");
7426    }
7427
7428    #[test]
7429    #[ignore]
7430    fn e2e_lambda_and_method_call() {
7431        if !has_node() {
7432            return;
7433        }
7434        let body = block(
7435            2,
7436            vec![node(
7437                3,
7438                NodeKind::LetBinding {
7439                    is_mut: false,
7440                    pattern: Box::new(bind_pat(4, "nums")),
7441                    ty: None,
7442                    value: Box::new(node(
7443                        5,
7444                        NodeKind::ListLiteral {
7445                            elems: vec![int_lit(6, "1"), int_lit(7, "2"), int_lit(8, "3")],
7446                        },
7447                    )),
7448                },
7449            )],
7450            Some(node(
7451                9,
7452                NodeKind::MethodCall {
7453                    receiver: Box::new(node(
7454                        10,
7455                        NodeKind::MethodCall {
7456                            receiver: Box::new(id_node(11, "nums")),
7457                            method: ident("map"),
7458                            type_args: vec![],
7459                            args: vec![AirArg {
7460                                label: None,
7461                                value: node(
7462                                    12,
7463                                    NodeKind::Lambda {
7464                                        params: vec![param_node(13, "x")],
7465                                        body: Box::new(node(
7466                                            14,
7467                                            NodeKind::BinaryOp {
7468                                                op: BinOp::Mul,
7469                                                left: Box::new(id_node(15, "x")),
7470                                                right: Box::new(int_lit(16, "2")),
7471                                            },
7472                                        )),
7473                                    },
7474                                ),
7475                            }],
7476                        },
7477                    )),
7478                    method: ident("join"),
7479                    type_args: vec![],
7480                    args: vec![AirArg {
7481                        label: None,
7482                        value: str_lit(17, ", "),
7483                    }],
7484                },
7485            )),
7486        );
7487        let f = node(
7488            1,
7489            NodeKind::FnDecl {
7490                annotations: vec![],
7491                visibility: Visibility::Private,
7492                is_async: false,
7493                name: ident("transform"),
7494                generic_params: vec![],
7495                params: vec![],
7496                return_type: None,
7497                effect_clause: vec![],
7498                where_clause: vec![],
7499                body: Box::new(body),
7500            },
7501        );
7502        let code = gen(&module(vec![], vec![f]));
7503        let full = format!("{code}\nconsole.log(transform());\n");
7504        assert!(check_js_syntax(&full));
7505        assert_eq!(run_js(&full), "2, 4, 6");
7506    }
7507
7508    #[test]
7509    #[ignore]
7510    fn e2e_while_loop() {
7511        if !has_node() {
7512            return;
7513        }
7514        let body = block(
7515            2,
7516            vec![
7517                node(
7518                    3,
7519                    NodeKind::LetBinding {
7520                        is_mut: true,
7521                        pattern: Box::new(bind_pat(4, "i")),
7522                        ty: None,
7523                        value: Box::new(int_lit(5, "0")),
7524                    },
7525                ),
7526                node(
7527                    6,
7528                    NodeKind::LetBinding {
7529                        is_mut: true,
7530                        pattern: Box::new(bind_pat(7, "result")),
7531                        ty: None,
7532                        value: Box::new(int_lit(8, "1")),
7533                    },
7534                ),
7535                node(
7536                    9,
7537                    NodeKind::While {
7538                        condition: Box::new(node(
7539                            10,
7540                            NodeKind::BinaryOp {
7541                                op: BinOp::Lt,
7542                                left: Box::new(id_node(11, "i")),
7543                                right: Box::new(id_node(12, "n")),
7544                            },
7545                        )),
7546                        body: Box::new(block(
7547                            13,
7548                            vec![
7549                                node(
7550                                    14,
7551                                    NodeKind::Assign {
7552                                        op: AssignOp::MulAssign,
7553                                        target: Box::new(id_node(15, "result")),
7554                                        value: Box::new(int_lit(16, "2")),
7555                                    },
7556                                ),
7557                                node(
7558                                    17,
7559                                    NodeKind::Assign {
7560                                        op: AssignOp::AddAssign,
7561                                        target: Box::new(id_node(18, "i")),
7562                                        value: Box::new(int_lit(19, "1")),
7563                                    },
7564                                ),
7565                            ],
7566                            None,
7567                        )),
7568                    },
7569                ),
7570            ],
7571            Some(id_node(20, "result")),
7572        );
7573        let f = node(
7574            1,
7575            NodeKind::FnDecl {
7576                annotations: vec![],
7577                visibility: Visibility::Private,
7578                is_async: false,
7579                name: ident("pow2"),
7580                generic_params: vec![],
7581                params: vec![param_node(21, "n")],
7582                return_type: None,
7583                effect_clause: vec![],
7584                where_clause: vec![],
7585                body: Box::new(body),
7586            },
7587        );
7588        let code = gen(&module(vec![], vec![f]));
7589        let full = format!("{code}\nconsole.log(pow2(10));\n");
7590        assert!(check_js_syntax(&full));
7591        assert_eq!(run_js(&full), "1024");
7592    }
7593
7594    #[test]
7595    #[ignore]
7596    fn e2e_async_await() {
7597        if !has_node() {
7598            return;
7599        }
7600        // async fn delayed() { return await Promise.resolve(42) }
7601        let body = block(
7602            2,
7603            vec![],
7604            Some(node(
7605                3,
7606                NodeKind::Await {
7607                    expr: Box::new(node(
7608                        4,
7609                        NodeKind::Call {
7610                            callee: Box::new(node(
7611                                5,
7612                                NodeKind::FieldAccess {
7613                                    object: Box::new(id_node(6, "Promise")),
7614                                    field: ident("resolve"),
7615                                },
7616                            )),
7617                            args: vec![AirArg {
7618                                label: None,
7619                                value: int_lit(7, "42"),
7620                            }],
7621                            type_args: vec![],
7622                        },
7623                    )),
7624                },
7625            )),
7626        );
7627        let f = node(
7628            1,
7629            NodeKind::FnDecl {
7630                annotations: vec![],
7631                visibility: Visibility::Private,
7632                is_async: true,
7633                name: ident("delayed"),
7634                generic_params: vec![],
7635                params: vec![],
7636                return_type: None,
7637                effect_clause: vec![],
7638                where_clause: vec![],
7639                body: Box::new(body),
7640            },
7641        );
7642        let code = gen(&module(vec![], vec![f]));
7643        let full = format!("{code}\ndelayed().then(v => console.log(v));\n");
7644        assert!(check_js_syntax(&full));
7645        assert_eq!(run_js(&full), "42");
7646    }
7647
7648    #[test]
7649    fn to_camel_case_converts() {
7650        // PascalCase → camelCase
7651        assert_eq!(to_camel_case("Log"), "log");
7652        assert_eq!(to_camel_case("Clock"), "clock");
7653        assert_eq!(to_camel_case("IO"), "iO");
7654        assert_eq!(to_camel_case(""), "");
7655        // snake_case → camelCase
7656        assert_eq!(to_camel_case("create_user"), "createUser");
7657        assert_eq!(to_camel_case("get_all_items"), "getAllItems");
7658        // Already camelCase → unchanged
7659        assert_eq!(to_camel_case("createUser"), "createUser");
7660        assert_eq!(to_camel_case("x"), "x");
7661        // Underscore → unchanged
7662        assert_eq!(to_camel_case("_"), "_");
7663    }
7664
7665    #[test]
7666    fn snake_case_fn_becomes_camel_case() {
7667        let body = block(2, vec![], Some(int_lit(3, "42")));
7668        let f = node(
7669            1,
7670            NodeKind::FnDecl {
7671                annotations: vec![],
7672                visibility: Visibility::Private,
7673                is_async: false,
7674                name: ident("create_user"),
7675                generic_params: vec![],
7676                params: vec![],
7677                return_type: None,
7678                effect_clause: vec![],
7679                where_clause: vec![],
7680                body: Box::new(body),
7681            },
7682        );
7683        let out = gen(&module(vec![], vec![f]));
7684        assert!(
7685            out.contains("function createUser()"),
7686            "expected camelCase function name, got: {out}"
7687        );
7688    }
7689
7690    #[test]
7691    fn escape_js_string_works() {
7692        assert_eq!(escape_js_string("hello"), "hello");
7693        assert_eq!(escape_js_string("he\"llo"), "he\\\"llo");
7694        assert_eq!(escape_js_string("line\nbreak"), "line\\nbreak");
7695    }
7696
7697    #[test]
7698    fn escape_template_literal_works() {
7699        assert_eq!(escape_template_literal("hello"), "hello");
7700        assert_eq!(escape_template_literal("cost: $5"), "cost: \\$5");
7701        assert_eq!(escape_template_literal("back`tick"), "back\\`tick");
7702    }
7703
7704    // ── Prelude function mapping tests ──────────────────────────────────────
7705
7706    /// Helper: generate JS for a module with a `main` function containing a single call.
7707    fn gen_prelude_call(func_name: &str, arg: AIRNode) -> String {
7708        let call = node(
7709            10,
7710            NodeKind::Call {
7711                callee: Box::new(id_node(11, func_name)),
7712                args: vec![AirArg {
7713                    label: None,
7714                    value: arg,
7715                }],
7716                type_args: vec![],
7717            },
7718        );
7719        let body = block(2, vec![call], None);
7720        let f = node(
7721            1,
7722            NodeKind::FnDecl {
7723                name: ident("main"),
7724                params: vec![],
7725                return_type: None,
7726                body: Box::new(body),
7727                generic_params: vec![],
7728                visibility: Visibility::Private,
7729                annotations: vec![],
7730                effect_clause: vec![],
7731                where_clause: vec![],
7732                is_async: false,
7733            },
7734        );
7735        gen(&module(vec![], vec![f]))
7736    }
7737
7738    /// Helper: generate JS for a nullary prelude call (no args).
7739    fn gen_prelude_call_no_args(func_name: &str) -> String {
7740        let call = node(
7741            10,
7742            NodeKind::Call {
7743                callee: Box::new(id_node(11, func_name)),
7744                args: vec![],
7745                type_args: vec![],
7746            },
7747        );
7748        let body = block(2, vec![call], None);
7749        let f = node(
7750            1,
7751            NodeKind::FnDecl {
7752                name: ident("main"),
7753                params: vec![],
7754                return_type: None,
7755                body: Box::new(body),
7756                generic_params: vec![],
7757                visibility: Visibility::Private,
7758                annotations: vec![],
7759                effect_clause: vec![],
7760                where_clause: vec![],
7761                is_async: false,
7762            },
7763        );
7764        gen(&module(vec![], vec![f]))
7765    }
7766
7767    #[test]
7768    fn prelude_println_maps_to_console_log() {
7769        let code = gen_prelude_call("println", str_lit(12, "Hello"));
7770        assert!(
7771            code.contains("console.log("),
7772            "expected console.log, got: {code}"
7773        );
7774        assert!(
7775            !code.contains("println("),
7776            "should not contain bare println, got: {code}"
7777        );
7778    }
7779
7780    #[test]
7781    fn prelude_print_maps_to_process_stdout_write() {
7782        let code = gen_prelude_call("print", str_lit(12, "no newline"));
7783        assert!(
7784            code.contains("process.stdout.write(String("),
7785            "expected process.stdout.write, got: {code}"
7786        );
7787    }
7788
7789    #[test]
7790    fn prelude_debug_maps_to_console_debug() {
7791        let code = gen_prelude_call("debug", str_lit(12, "val"));
7792        assert!(
7793            code.contains("console.debug("),
7794            "expected console.debug, got: {code}"
7795        );
7796    }
7797
7798    #[test]
7799    fn prelude_assert_maps_to_throw() {
7800        let code = gen_prelude_call("assert", bool_lit(12, true));
7801        assert!(
7802            code.contains("if (!true) throw new Error(\"assertion failed\")"),
7803            "expected assert mapping, got: {code}"
7804        );
7805    }
7806
7807    #[test]
7808    fn prelude_todo_maps_to_throw_not_implemented() {
7809        let code = gen_prelude_call_no_args("todo");
7810        assert!(
7811            code.contains("throw new Error(\"not implemented\")"),
7812            "expected todo mapping, got: {code}"
7813        );
7814    }
7815
7816    #[test]
7817    fn prelude_unreachable_maps_to_throw_unreachable() {
7818        let code = gen_prelude_call_no_args("unreachable");
7819        assert!(
7820            code.contains("throw new Error(\"unreachable\")"),
7821            "expected unreachable mapping, got: {code}"
7822        );
7823    }
7824
7825    #[test]
7826    fn non_prelude_call_unaffected() {
7827        let code = gen_prelude_call("my_custom_func", str_lit(12, "arg"));
7828        assert!(
7829            code.contains("myCustomFunc("),
7830            "expected normal call emission, got: {code}"
7831        );
7832    }
7833
7834    // ── Effect declaration tests ────────────────────────────────────────────
7835
7836    #[test]
7837    fn effect_decl_becomes_class() {
7838        let effect = node(
7839            1,
7840            NodeKind::EffectDecl {
7841                annotations: vec![],
7842                visibility: Visibility::Public,
7843                name: ident("Logger"),
7844                generic_params: vec![],
7845                components: vec![],
7846                operations: vec![
7847                    node(
7848                        2,
7849                        NodeKind::FnDecl {
7850                            annotations: vec![],
7851                            visibility: Visibility::Public,
7852                            is_async: false,
7853                            name: ident("log"),
7854                            generic_params: vec![],
7855                            params: vec![param_node(3, "level"), param_node(4, "msg")],
7856                            return_type: None,
7857                            effect_clause: vec![],
7858                            where_clause: vec![],
7859                            body: Box::new(block(5, vec![], None)),
7860                        },
7861                    ),
7862                    node(
7863                        6,
7864                        NodeKind::FnDecl {
7865                            annotations: vec![],
7866                            visibility: Visibility::Public,
7867                            is_async: false,
7868                            name: ident("flush"),
7869                            generic_params: vec![],
7870                            params: vec![],
7871                            return_type: None,
7872                            effect_clause: vec![],
7873                            where_clause: vec![],
7874                            body: Box::new(block(7, vec![], None)),
7875                        },
7876                    ),
7877                ],
7878            },
7879        );
7880        let out = gen(&module(vec![], vec![effect]));
7881        assert!(out.contains("class Logger {"), "got: {out}");
7882        assert!(out.contains("log(level, msg) {"), "got: {out}");
7883        assert!(out.contains("flush() {"), "got: {out}");
7884        assert!(
7885            out.contains("throw new Error(\"not implemented\");"),
7886            "got: {out}"
7887        );
7888    }
7889
7890    #[test]
7891    fn effect_decl_empty_operations() {
7892        let effect = node(
7893            1,
7894            NodeKind::EffectDecl {
7895                annotations: vec![],
7896                visibility: Visibility::Public,
7897                name: ident("Empty"),
7898                generic_params: vec![],
7899                components: vec![],
7900                operations: vec![],
7901            },
7902        );
7903        let out = gen(&module(vec![], vec![effect]));
7904        assert!(out.contains("class Empty {"), "got: {out}");
7905        assert!(out.contains("}"), "got: {out}");
7906    }
7907
7908    #[test]
7909    fn handling_block_passes_handlers_to_effectful_call() {
7910        use bock_air::AirHandlerPair;
7911
7912        let effect_decl = node(
7913            1,
7914            NodeKind::EffectDecl {
7915                annotations: vec![],
7916                visibility: Visibility::Public,
7917                name: ident("Logger"),
7918                generic_params: vec![],
7919                components: vec![],
7920                operations: vec![node(
7921                    2,
7922                    NodeKind::FnDecl {
7923                        annotations: vec![],
7924                        visibility: Visibility::Public,
7925                        is_async: false,
7926                        name: ident("log"),
7927                        generic_params: vec![],
7928                        params: vec![param_node(3, "msg")],
7929                        return_type: None,
7930                        effect_clause: vec![],
7931                        where_clause: vec![],
7932                        body: Box::new(block(4, vec![], None)),
7933                    },
7934                )],
7935            },
7936        );
7937
7938        // fn inner() with Logger
7939        let inner_fn = node(
7940            10,
7941            NodeKind::FnDecl {
7942                annotations: vec![],
7943                visibility: Visibility::Private,
7944                is_async: false,
7945                name: ident("inner"),
7946                generic_params: vec![],
7947                params: vec![],
7948                return_type: None,
7949                effect_clause: vec![type_path(&["Logger"])],
7950                where_clause: vec![],
7951                body: Box::new(block(12, vec![], Some(str_lit(13, "hello")))),
7952            },
7953        );
7954
7955        let call_inner = node(
7956            20,
7957            NodeKind::Call {
7958                callee: Box::new(id_node(21, "inner")),
7959                args: vec![],
7960                type_args: vec![],
7961            },
7962        );
7963        let handling = node(
7964            30,
7965            NodeKind::HandlingBlock {
7966                handlers: vec![AirHandlerPair {
7967                    effect: type_path(&["Logger"]),
7968                    handler: Box::new(node(
7969                        31,
7970                        NodeKind::Call {
7971                            callee: Box::new(id_node(32, "StdoutLogger")),
7972                            args: vec![],
7973                            type_args: vec![],
7974                        },
7975                    )),
7976                }],
7977                body: Box::new(block(33, vec![], Some(call_inner))),
7978            },
7979        );
7980        let main_fn = node(
7981            40,
7982            NodeKind::FnDecl {
7983                annotations: vec![],
7984                visibility: Visibility::Private,
7985                is_async: false,
7986                name: ident("main"),
7987                generic_params: vec![],
7988                params: vec![],
7989                return_type: None,
7990                effect_clause: vec![],
7991                where_clause: vec![],
7992                body: Box::new(block(41, vec![handling], None)),
7993            },
7994        );
7995
7996        let out = gen(&module(vec![], vec![effect_decl, inner_fn, main_fn]));
7997        // JS: inner({ logger: __logger })
7998        assert!(
7999            out.contains("inner({ logger: __logger })"),
8000            "handling block should pass handler to effectful call, got: {out}"
8001        );
8002        assert!(
8003            out.contains("const __logger = stdoutLogger()"),
8004            "handling block should instantiate handler, got: {out}"
8005        );
8006    }
8007
8008    #[test]
8009    fn sibling_handling_blocks_do_not_share_let_scope() {
8010        use bock_air::AirHandlerPair;
8011
8012        // Two *sibling* `handling` blocks, each `let part = …` under the SAME
8013        // name. Each block lowers to its own `{ … }` JS lexical scope, so both
8014        // must declare a fresh `const part` — neither may be rewritten into a
8015        // bare `part = …` assignment against the other (which would reference a
8016        // name that went out of scope when the first block closed). Regression
8017        // for Q-js-handling-let-redeclaration.
8018        let make_handling = |id: u32, val: &str| {
8019            node(
8020                id,
8021                NodeKind::HandlingBlock {
8022                    handlers: vec![AirHandlerPair {
8023                        effect: type_path(&["Logger"]),
8024                        handler: Box::new(node(
8025                            id + 1,
8026                            NodeKind::Call {
8027                                callee: Box::new(id_node(id + 2, "StdoutLogger")),
8028                                args: vec![],
8029                                type_args: vec![],
8030                            },
8031                        )),
8032                    }],
8033                    body: Box::new(block(
8034                        id + 3,
8035                        vec![let_binding(id + 4, "part", false, str_lit(id + 6, val))],
8036                        Some(id_node(id + 7, "part")),
8037                    )),
8038                },
8039            )
8040        };
8041        let main_fn = node(
8042            40,
8043            NodeKind::FnDecl {
8044                annotations: vec![],
8045                visibility: Visibility::Private,
8046                is_async: false,
8047                name: ident("main"),
8048                generic_params: vec![],
8049                params: vec![],
8050                return_type: None,
8051                effect_clause: vec![],
8052                where_clause: vec![],
8053                body: Box::new(block(
8054                    41,
8055                    vec![make_handling(50, "first"), make_handling(70, "second")],
8056                    None,
8057                )),
8058            },
8059        );
8060
8061        let out = gen(&module(vec![], vec![main_fn]));
8062        assert_eq!(
8063            out.matches("const part = ").count(),
8064            2,
8065            "each sibling handling block should declare its own `const part`, got: {out}"
8066        );
8067        assert!(
8068            !out.contains("\n  part = "),
8069            "no sibling handling block may rewrite its `let part` into a bare \
8070             assignment, got: {out}"
8071        );
8072    }
8073
8074    #[test]
8075    fn composite_effect_expands_to_components() {
8076        use bock_air::AirHandlerPair;
8077
8078        // effect Logger { fn log(msg: String) -> Void }
8079        let logger_decl = node(
8080            1,
8081            NodeKind::EffectDecl {
8082                annotations: vec![],
8083                visibility: Visibility::Public,
8084                name: ident("Logger"),
8085                generic_params: vec![],
8086                components: vec![],
8087                operations: vec![node(
8088                    2,
8089                    NodeKind::FnDecl {
8090                        annotations: vec![],
8091                        visibility: Visibility::Public,
8092                        is_async: false,
8093                        name: ident("log"),
8094                        generic_params: vec![],
8095                        params: vec![param_node(3, "msg")],
8096                        return_type: None,
8097                        effect_clause: vec![],
8098                        where_clause: vec![],
8099                        body: Box::new(block(4, vec![], None)),
8100                    },
8101                )],
8102            },
8103        );
8104
8105        // effect Clock { fn now() -> Int }
8106        let clock_decl = node(
8107            5,
8108            NodeKind::EffectDecl {
8109                annotations: vec![],
8110                visibility: Visibility::Public,
8111                name: ident("Clock"),
8112                generic_params: vec![],
8113                components: vec![],
8114                operations: vec![node(
8115                    6,
8116                    NodeKind::FnDecl {
8117                        annotations: vec![],
8118                        visibility: Visibility::Public,
8119                        is_async: false,
8120                        name: ident("now"),
8121                        generic_params: vec![],
8122                        params: vec![],
8123                        return_type: None,
8124                        effect_clause: vec![],
8125                        where_clause: vec![],
8126                        body: Box::new(block(7, vec![], None)),
8127                    },
8128                )],
8129            },
8130        );
8131
8132        // effect ServiceStack = Logger + Clock
8133        let composite_decl = node(
8134            8,
8135            NodeKind::EffectDecl {
8136                annotations: vec![],
8137                visibility: Visibility::Public,
8138                name: ident("ServiceStack"),
8139                generic_params: vec![],
8140                components: vec![type_path(&["Logger"]), type_path(&["Clock"])],
8141                operations: vec![],
8142            },
8143        );
8144
8145        // fn serve(request) with ServiceStack → should expand to Logger + Clock params
8146        let serve_fn = node(
8147            10,
8148            NodeKind::FnDecl {
8149                annotations: vec![],
8150                visibility: Visibility::Private,
8151                is_async: false,
8152                name: ident("serve"),
8153                generic_params: vec![],
8154                params: vec![param_node(11, "request")],
8155                return_type: None,
8156                effect_clause: vec![type_path(&["ServiceStack"])],
8157                where_clause: vec![],
8158                body: Box::new(block(12, vec![], Some(str_lit(13, "ok")))),
8159            },
8160        );
8161
8162        // main with handling block
8163        let call_serve = node(
8164            20,
8165            NodeKind::Call {
8166                callee: Box::new(id_node(21, "serve")),
8167                args: vec![bock_air::AirArg {
8168                    label: None,
8169                    value: str_lit(22, "GET /"),
8170                }],
8171                type_args: vec![],
8172            },
8173        );
8174        let handling = node(
8175            30,
8176            NodeKind::HandlingBlock {
8177                handlers: vec![
8178                    AirHandlerPair {
8179                        effect: type_path(&["Logger"]),
8180                        handler: Box::new(node(
8181                            31,
8182                            NodeKind::Call {
8183                                callee: Box::new(id_node(32, "StdLogger")),
8184                                args: vec![],
8185                                type_args: vec![],
8186                            },
8187                        )),
8188                    },
8189                    AirHandlerPair {
8190                        effect: type_path(&["Clock"]),
8191                        handler: Box::new(node(
8192                            33,
8193                            NodeKind::Call {
8194                                callee: Box::new(id_node(34, "StdClock")),
8195                                args: vec![],
8196                                type_args: vec![],
8197                            },
8198                        )),
8199                    },
8200                ],
8201                body: Box::new(block(35, vec![], Some(call_serve))),
8202            },
8203        );
8204        let main_fn = node(
8205            40,
8206            NodeKind::FnDecl {
8207                annotations: vec![],
8208                visibility: Visibility::Private,
8209                is_async: false,
8210                name: ident("main"),
8211                generic_params: vec![],
8212                params: vec![],
8213                return_type: None,
8214                effect_clause: vec![],
8215                where_clause: vec![],
8216                body: Box::new(block(41, vec![handling], None)),
8217            },
8218        );
8219
8220        let out = gen(&module(
8221            vec![],
8222            vec![logger_decl, clock_decl, composite_decl, serve_fn, main_fn],
8223        ));
8224
8225        // Composite effect should emit a comment, not a class.
8226        assert!(
8227            out.contains("// composite effect ServiceStack = Logger + Clock"),
8228            "composite effect should be a comment, got: {out}"
8229        );
8230        assert!(
8231            !out.contains("class ServiceStack"),
8232            "composite effect should NOT generate a class, got: {out}"
8233        );
8234        // A *public* composite effect is also listed in the per-module
8235        // `export { … }`, so it must have a concrete binding to export (an
8236        // unbound name is an ESM "Export 'X' is not defined" error). It emits a
8237        // frozen marker object recording its component names.
8238        assert!(
8239            out.contains(
8240                "const ServiceStack = Object.freeze({ __composite: [\"Logger\", \"Clock\"] });"
8241            ),
8242            "composite effect should emit an exportable binding, got: {out}"
8243        );
8244
8245        // serve should have expanded handler params for Logger + Clock.
8246        assert!(
8247            out.contains("function serve(request, { logger, clock })"),
8248            "serve should have expanded effect params, got: {out}"
8249        );
8250
8251        // Calling serve from handling block should pass both handlers.
8252        assert!(
8253            out.contains("logger: __logger") && out.contains("clock: __clock"),
8254            "call should pass expanded handler args, got: {out}"
8255        );
8256    }
8257
8258    #[test]
8259    fn record_becomes_class_for_prototype_impls() {
8260        use bock_air::AirHandlerPair;
8261
8262        let rec = node(
8263            1,
8264            NodeKind::RecordDecl {
8265                annotations: vec![],
8266                visibility: Visibility::Public,
8267                name: ident("ConsoleLogger"),
8268                generic_params: vec![],
8269                fields: vec![],
8270            },
8271        );
8272        let out = gen(&module(vec![], vec![rec]));
8273        assert!(
8274            out.contains("class ConsoleLogger {}"),
8275            "empty record should be an empty class, got: {out}"
8276        );
8277        let _ = AirHandlerPair {
8278            // keep import used
8279            effect: type_path(&["X"]),
8280            handler: Box::new(id_node(0, "x")),
8281        };
8282    }
8283
8284    #[test]
8285    fn record_construct_of_declared_record_uses_new() {
8286        let rec = node(
8287            1,
8288            NodeKind::RecordDecl {
8289                annotations: vec![],
8290                visibility: Visibility::Public,
8291                name: ident("ConsoleLogger"),
8292                generic_params: vec![],
8293                fields: vec![],
8294            },
8295        );
8296        let construct = node(
8297            2,
8298            NodeKind::RecordConstruct {
8299                path: type_path(&["ConsoleLogger"]),
8300                fields: vec![],
8301                spread: None,
8302            },
8303        );
8304        let let_stmt = node(
8305            3,
8306            NodeKind::LetBinding {
8307                is_mut: false,
8308                pattern: Box::new(bind_pat(4, "x")),
8309                ty: None,
8310                value: Box::new(construct),
8311            },
8312        );
8313        let f = node(
8314            5,
8315            NodeKind::FnDecl {
8316                annotations: vec![],
8317                visibility: Visibility::Private,
8318                is_async: false,
8319                name: ident("test"),
8320                generic_params: vec![],
8321                params: vec![],
8322                return_type: None,
8323                effect_clause: vec![],
8324                where_clause: vec![],
8325                body: Box::new(block(6, vec![let_stmt], None)),
8326            },
8327        );
8328        let out = gen(&module(vec![], vec![rec, f]));
8329        assert!(
8330            out.contains("new ConsoleLogger()"),
8331            "declared record construct should use `new`, got: {out}"
8332        );
8333    }
8334
8335    #[test]
8336    fn module_handle_registers_handler_for_same_module_calls() {
8337        use bock_air::AirHandlerPair;
8338        let _ = AirHandlerPair {
8339            effect: type_path(&["X"]),
8340            handler: Box::new(id_node(0, "x")),
8341        };
8342
8343        // effect Logger { fn log(msg) }
8344        let effect_decl = node(
8345            1,
8346            NodeKind::EffectDecl {
8347                annotations: vec![],
8348                visibility: Visibility::Public,
8349                name: ident("Logger"),
8350                generic_params: vec![],
8351                components: vec![],
8352                operations: vec![node(
8353                    2,
8354                    NodeKind::FnDecl {
8355                        annotations: vec![],
8356                        visibility: Visibility::Public,
8357                        is_async: false,
8358                        name: ident("log"),
8359                        generic_params: vec![],
8360                        params: vec![param_node(3, "msg")],
8361                        return_type: None,
8362                        effect_clause: vec![],
8363                        where_clause: vec![],
8364                        body: Box::new(block(4, vec![], None)),
8365                    },
8366                )],
8367            },
8368        );
8369
8370        // record StdoutLogger {}
8371        let rec = node(
8372            5,
8373            NodeKind::RecordDecl {
8374                annotations: vec![],
8375                visibility: Visibility::Public,
8376                name: ident("StdoutLogger"),
8377                generic_params: vec![],
8378                fields: vec![],
8379            },
8380        );
8381
8382        // fn greet() with Logger { log("hi") }
8383        let greet = node(
8384            10,
8385            NodeKind::FnDecl {
8386                annotations: vec![],
8387                visibility: Visibility::Public,
8388                is_async: false,
8389                name: ident("greet"),
8390                generic_params: vec![],
8391                params: vec![],
8392                return_type: None,
8393                effect_clause: vec![type_path(&["Logger"])],
8394                where_clause: vec![],
8395                body: Box::new(block(11, vec![], Some(str_lit(12, "hi")))),
8396            },
8397        );
8398
8399        // handle Logger with StdoutLogger {}
8400        let module_handle = node(
8401            20,
8402            NodeKind::ModuleHandle {
8403                effect: type_path(&["Logger"]),
8404                handler: Box::new(node(
8405                    21,
8406                    NodeKind::RecordConstruct {
8407                        path: type_path(&["StdoutLogger"]),
8408                        fields: vec![],
8409                        spread: None,
8410                    },
8411                )),
8412            },
8413        );
8414
8415        // fn main() { greet() }
8416        let call_greet = node(
8417            30,
8418            NodeKind::Call {
8419                callee: Box::new(id_node(31, "greet")),
8420                args: vec![],
8421                type_args: vec![],
8422            },
8423        );
8424        let main_fn = node(
8425            32,
8426            NodeKind::FnDecl {
8427                annotations: vec![],
8428                visibility: Visibility::Private,
8429                is_async: false,
8430                name: ident("main"),
8431                generic_params: vec![],
8432                params: vec![],
8433                return_type: None,
8434                effect_clause: vec![],
8435                where_clause: vec![],
8436                body: Box::new(block(33, vec![], Some(call_greet))),
8437            },
8438        );
8439
8440        let out = gen(&module(
8441            vec![],
8442            vec![effect_decl, rec, greet, module_handle, main_fn],
8443        ));
8444
8445        // Module handle creates __logger with new.
8446        assert!(
8447            out.contains("const __logger = new StdoutLogger()"),
8448            "module handle should use `new` on declared record, got: {out}"
8449        );
8450        // Calls in main() pick up the module-level handler.
8451        assert!(
8452            out.contains("greet({ logger: __logger })"),
8453            "module handle should be threaded into effectful calls, got: {out}"
8454        );
8455    }
8456
8457    // ── Async entry point ───────────────────────────────────────────────────
8458
8459    #[test]
8460    fn entry_invocation_sync_main() {
8461        let inv = JsGenerator::new().entry_invocation(false).unwrap();
8462        assert_eq!(inv, "main();\n");
8463    }
8464
8465    #[test]
8466    fn entry_invocation_async_main() {
8467        let inv = JsGenerator::new().entry_invocation(true).unwrap();
8468        assert!(inv.contains("async () =>"));
8469        assert!(inv.contains("await main()"));
8470    }
8471
8472    #[test]
8473    fn generate_project_async_main_wraps_entry() {
8474        let main_fn = node(
8475            1,
8476            NodeKind::FnDecl {
8477                annotations: vec![],
8478                visibility: Visibility::Private,
8479                is_async: true,
8480                name: ident("main"),
8481                generic_params: vec![],
8482                params: vec![],
8483                return_type: None,
8484                effect_clause: vec![],
8485                where_clause: vec![],
8486                body: Box::new(block(2, vec![], None)),
8487            },
8488        );
8489        let m = module(vec![], vec![main_fn]);
8490        let gen = JsGenerator::new();
8491        let src_path = std::path::Path::new("src/main.bock");
8492        let out = gen.generate_project(&[(&m, src_path)]).unwrap();
8493        let src = &out.files[0].content;
8494        assert_eq!(out.files[0].path, std::path::PathBuf::from("main.js"));
8495        assert!(src.contains("async function main()"), "got: {src}");
8496        assert!(
8497            src.contains("(async () => { await main(); })();"),
8498            "async entry wrapper missing, got: {src}"
8499        );
8500    }
8501
8502    /// A module node with a declared dotted `path` (e.g. `core.option`), used by
8503    /// the per-module emission tests where the file layout and the relative
8504    /// import specifier are keyed on the declared module-path.
8505    fn module_with_path(path: &[&str], imports: Vec<AIRNode>, items: Vec<AIRNode>) -> AIRNode {
8506        node(
8507            0,
8508            NodeKind::Module {
8509                path: Some(bock_ast::ModulePath {
8510                    segments: path.iter().map(|s| ident(s)).collect(),
8511                    span: span(),
8512                }),
8513                annotations: vec![],
8514                imports,
8515                items,
8516            },
8517        )
8518    }
8519
8520    /// An `import <path>.{ name }` AIR node (a single-item `Named` import).
8521    fn import_named(id: u32, path: &[&str], name: &str) -> AIRNode {
8522        node(
8523            id,
8524            NodeKind::ImportDecl {
8525                path: bock_ast::ModulePath {
8526                    segments: path.iter().map(|s| ident(s)).collect(),
8527                    span: span(),
8528                },
8529                items: bock_ast::ImportItems::Named(vec![bock_ast::ImportedName {
8530                    span: span(),
8531                    name: ident(name),
8532                    alias: None,
8533                }]),
8534            },
8535        )
8536    }
8537
8538    /// A bare `fn <name>() -> <tail>` declaration with the given visibility and a
8539    /// single tail expression as its body.
8540    fn fn_decl_tail(id: u32, vis: Visibility, name: &str, tail: AIRNode) -> AIRNode {
8541        node(
8542            id,
8543            NodeKind::FnDecl {
8544                annotations: vec![],
8545                visibility: vis,
8546                is_async: false,
8547                name: ident(name),
8548                generic_params: vec![],
8549                params: vec![],
8550                return_type: None,
8551                effect_clause: vec![],
8552                where_clause: vec![],
8553                body: Box::new(block(id + 1, vec![], Some(tail))),
8554            },
8555        )
8556    }
8557
8558    #[test]
8559    fn per_module_emits_native_esm_import_tree() {
8560        // entry `module main` uses `mathutil.add_one`; `module mathutil` exports a
8561        // `public fn add_one`. Per-module emission must produce `main.js` (with a
8562        // real `import { addOne } from "./mathutil.js"` — note the camelCase) and
8563        // `mathutil.js` — a real import tree, not a single collapsed file. The
8564        // `package.json` run affordance is emitted by the scaffolder (project
8565        // mode), NOT codegen (S6a / DV18).
8566        let call = node(
8567            10,
8568            NodeKind::Call {
8569                callee: Box::new(id_node(11, "add_one")),
8570                args: vec![bock_air::AirArg {
8571                    label: None,
8572                    value: int_lit(12, "6"),
8573                }],
8574                type_args: vec![],
8575            },
8576        );
8577        let main_mod = module_with_path(
8578            &["main"],
8579            vec![import_named(5, &["mathutil"], "add_one")],
8580            vec![fn_decl_tail(1, Visibility::Private, "main", call)],
8581        );
8582        let util_mod = module_with_path(
8583            &["mathutil"],
8584            vec![],
8585            vec![fn_decl_tail(
8586                20,
8587                Visibility::Public,
8588                "add_one",
8589                int_lit(22, "7"),
8590            )],
8591        );
8592
8593        let gen = JsGenerator::new();
8594        let out = gen
8595            .generate_project(&[
8596                (&main_mod, std::path::Path::new("src/main.bock")),
8597                (&util_mod, std::path::Path::new("src/mathutil.bock")),
8598            ])
8599            .unwrap();
8600
8601        let by_name = |p: &str| out.files.iter().find(|f| f.path == std::path::Path::new(p));
8602        let main_file = by_name("main.js").expect("main.js emitted");
8603        let util_file = by_name("mathutil.js").expect("mathutil.js emitted");
8604        // Codegen no longer emits the run affordance (S6a / DV18) — the
8605        // scaffolder owns the `package.json` in project mode.
8606        assert!(
8607            by_name("package.json").is_none(),
8608            "codegen must NOT emit package.json — the scaffolder owns it (S6a)"
8609        );
8610
8611        assert!(
8612            main_file
8613                .content
8614                .contains("import { addOne } from \"./mathutil.js\";"),
8615            "main.js must import the camelCased fn from the sibling; got:\n{}",
8616            main_file.content
8617        );
8618        assert!(
8619            main_file.content.contains("main();"),
8620            "main.js must carry the entry invocation; got:\n{}",
8621            main_file.content
8622        );
8623        assert!(
8624            util_file.content.contains("export function addOne("),
8625            "mathutil.js must export the fn inline; got:\n{}",
8626            util_file.content
8627        );
8628    }
8629
8630    #[test]
8631    fn per_module_reexports_record_and_constructs_cross_module() {
8632        // entry uses `shapes.Point`; `module shapes` declares `public record
8633        // Point`. Per-module emission must re-export `Point` from `shapes.js`
8634        // (records do not export inline in JS) and `main.js` must import it.
8635        let point = node(
8636            30,
8637            NodeKind::RecordDecl {
8638                annotations: vec![],
8639                visibility: Visibility::Public,
8640                name: ident("Point"),
8641                generic_params: vec![],
8642                fields: vec![],
8643            },
8644        );
8645        let shapes_mod = module_with_path(&["shapes"], vec![], vec![point]);
8646        let ctor = node(
8647            10,
8648            NodeKind::RecordConstruct {
8649                path: type_path(&["Point"]),
8650                fields: vec![],
8651                spread: None,
8652            },
8653        );
8654        let main_mod = module_with_path(
8655            &["main"],
8656            vec![import_named(5, &["shapes"], "Point")],
8657            vec![fn_decl_tail(1, Visibility::Private, "main", ctor)],
8658        );
8659
8660        let gen = JsGenerator::new();
8661        let out = gen
8662            .generate_project(&[
8663                (&main_mod, std::path::Path::new("src/main.bock")),
8664                (&shapes_mod, std::path::Path::new("src/shapes.bock")),
8665            ])
8666            .unwrap();
8667        let by_name = |p: &str| out.files.iter().find(|f| f.path == std::path::Path::new(p));
8668        let main_file = by_name("main.js").expect("main.js emitted");
8669        let shapes_file = by_name("shapes.js").expect("shapes.js emitted");
8670        assert!(
8671            shapes_file.content.contains("export { Point };"),
8672            "shapes.js must re-export the record; got:\n{}",
8673            shapes_file.content
8674        );
8675        assert!(
8676            main_file
8677                .content
8678                .contains("import { Point } from \"./shapes.js\";"),
8679            "main.js must import the record; got:\n{}",
8680            main_file.content
8681        );
8682        // Cross-module record construction must lower to `new Point(...)`, not a
8683        // bare object literal (record_names seeded across the reachable set).
8684        assert!(
8685            main_file.content.contains("new Point("),
8686            "cross-module record construction must use `new`; got:\n{}",
8687            main_file.content
8688        );
8689    }
8690
8691    /// A `match` whose scrutinee is a call must be hoisted into a single
8692    /// `const __matchN = …;` so it is evaluated once. Re-emitting the call
8693    /// inline in each arm double-evaluated it — a real bug for a scrutinee with
8694    /// side effects (e.g. a stateful iterator's `match next(it)`).
8695    #[test]
8696    fn match_call_scrutinee_hoisted_to_temp() {
8697        // match f() { Some(x) => x; None => 0 }
8698        let scrutinee = node(
8699            10,
8700            NodeKind::Call {
8701                callee: Box::new(id_node(11, "f")),
8702                args: vec![],
8703                type_args: vec![],
8704            },
8705        );
8706        let some_arm = node(
8707            20,
8708            NodeKind::MatchArm {
8709                pattern: Box::new(node(
8710                    21,
8711                    NodeKind::ConstructorPat {
8712                        path: type_path(&["Some"]),
8713                        fields: vec![bind_pat(22, "x")],
8714                    },
8715                )),
8716                guard: None,
8717                body: Box::new(block(23, vec![], Some(id_node(24, "x")))),
8718            },
8719        );
8720        let none_arm = node(
8721            30,
8722            NodeKind::MatchArm {
8723                pattern: Box::new(node(
8724                    31,
8725                    NodeKind::ConstructorPat {
8726                        path: type_path(&["None"]),
8727                        fields: vec![],
8728                    },
8729                )),
8730                guard: None,
8731                body: Box::new(block(32, vec![], Some(int_lit(33, "0")))),
8732            },
8733        );
8734        let match_stmt = node(
8735            40,
8736            NodeKind::Match {
8737                scrutinee: Box::new(scrutinee),
8738                arms: vec![some_arm, none_arm],
8739            },
8740        );
8741        let f = node(
8742            1,
8743            NodeKind::FnDecl {
8744                annotations: vec![],
8745                visibility: Visibility::Private,
8746                is_async: false,
8747                name: ident("run"),
8748                generic_params: vec![],
8749                params: vec![],
8750                return_type: None,
8751                effect_clause: vec![],
8752                where_clause: vec![],
8753                body: Box::new(block(2, vec![match_stmt], None)),
8754            },
8755        );
8756        let out = gen(&module(vec![], vec![f]));
8757        assert!(
8758            out.contains("const __match1 = f();"),
8759            "call scrutinee should be hoisted to a temp, got: {out}"
8760        );
8761        assert!(
8762            out.contains("switch (__match1._tag)"),
8763            "switch should dispatch on the hoisted temp, got: {out}"
8764        );
8765        assert!(
8766            out.contains("const x = __match1._0;"),
8767            "payload binding should read the hoisted temp, got: {out}"
8768        );
8769        assert!(
8770            !out.contains("f()._tag") && !out.contains("f()._0"),
8771            "call scrutinee must not be re-emitted inline, got: {out}"
8772        );
8773    }
8774
8775    #[test]
8776    fn method_colliding_with_field_is_disambiguated() {
8777        // record SimpleError { message: String }
8778        let record_decl = node(
8779            1,
8780            NodeKind::RecordDecl {
8781                annotations: vec![],
8782                visibility: Visibility::Public,
8783                name: ident("SimpleError"),
8784                generic_params: vec![],
8785                fields: vec![bock_ast::RecordDeclField {
8786                    id: 0,
8787                    span: span(),
8788                    name: ident("message"),
8789                    ty: bock_ast::TypeExpr::Named {
8790                        id: 0,
8791                        span: span(),
8792                        path: type_path(&["String"]),
8793                        args: vec![],
8794                    },
8795                    default: None,
8796                }],
8797            },
8798        );
8799        // impl Error for SimpleError { fn message(self) { self.message } }
8800        let method = node(
8801            10,
8802            NodeKind::FnDecl {
8803                annotations: vec![],
8804                visibility: Visibility::Public,
8805                is_async: false,
8806                name: ident("message"),
8807                generic_params: vec![],
8808                params: vec![param_node(11, "self")],
8809                return_type: None,
8810                effect_clause: vec![],
8811                where_clause: vec![],
8812                body: Box::new(block(
8813                    12,
8814                    vec![],
8815                    Some(node(
8816                        13,
8817                        NodeKind::FieldAccess {
8818                            object: Box::new(id_node(14, "self")),
8819                            field: ident("message"),
8820                        },
8821                    )),
8822                )),
8823            },
8824        );
8825        let impl_block = node(
8826            20,
8827            NodeKind::ImplBlock {
8828                annotations: vec![],
8829                target: Box::new(node(
8830                    21,
8831                    NodeKind::TypeNamed {
8832                        path: type_path(&["SimpleError"]),
8833                        args: vec![],
8834                    },
8835                )),
8836                trait_path: Some(type_path(&["Error"])),
8837                trait_args: vec![],
8838                generic_params: vec![],
8839                where_clause: vec![],
8840                methods: vec![method],
8841            },
8842        );
8843        // fn read(e: SimpleError) { e.message() }  → Call(FieldAccess(e,message),[e])
8844        let read_fn = node(
8845            30,
8846            NodeKind::FnDecl {
8847                annotations: vec![],
8848                visibility: Visibility::Public,
8849                is_async: false,
8850                name: ident("read"),
8851                generic_params: vec![],
8852                params: vec![param_node(31, "e")],
8853                return_type: None,
8854                effect_clause: vec![],
8855                where_clause: vec![],
8856                body: Box::new(block(
8857                    32,
8858                    vec![],
8859                    Some(node(
8860                        33,
8861                        NodeKind::Call {
8862                            callee: Box::new(node(
8863                                34,
8864                                NodeKind::FieldAccess {
8865                                    // The lowerer reuses the *same* receiver node
8866                                    // in both the field-access object and the self
8867                                    // arg; `desugared_self_call` keys on the shared
8868                                    // NodeId, so the test must too.
8869                                    object: Box::new(id_node(35, "e")),
8870                                    field: ident("message"),
8871                                },
8872                            )),
8873                            type_args: vec![],
8874                            args: vec![AirArg {
8875                                label: None,
8876                                value: id_node(35, "e"),
8877                            }],
8878                        },
8879                    )),
8880                )),
8881            },
8882        );
8883        let out = gen(&module(vec![], vec![record_decl, impl_block, read_fn]));
8884        // The field is still set on the instance under its own name.
8885        assert!(
8886            out.contains("this.message = message"),
8887            "field should remain `message`, got: {out}"
8888        );
8889        // The method (prototype) and call site are renamed to `messageMethod`.
8890        assert!(
8891            out.contains("SimpleError.prototype.messageMethod = "),
8892            "prototype method should be `messageMethod`, got: {out}"
8893        );
8894        assert!(
8895            out.contains(".messageMethod(e)"),
8896            "call site should be `.messageMethod(e)`, got: {out}"
8897        );
8898        // The method body still *reads* the field via `self.message`.
8899        assert!(
8900            out.contains("return self.message;"),
8901            "method body should read the field `self.message`, got: {out}"
8902        );
8903    }
8904
8905    // ── js codegen fixes (examples audit) ────────────────────────────────────
8906
8907    /// Helpers for the audit-fix tests below.
8908    fn let_binding(id: u32, name: &str, is_mut: bool, value: AIRNode) -> AIRNode {
8909        node(
8910            id,
8911            NodeKind::LetBinding {
8912                is_mut,
8913                pattern: Box::new(node(
8914                    id + 1,
8915                    NodeKind::BindPat {
8916                        name: ident(name),
8917                        is_mut,
8918                    },
8919                )),
8920                ty: None,
8921                value: Box::new(value),
8922            },
8923        )
8924    }
8925
8926    fn fn_decl(id: u32, name: &str, params: Vec<AIRNode>, body: AIRNode) -> AIRNode {
8927        node(
8928            id,
8929            NodeKind::FnDecl {
8930                annotations: vec![],
8931                visibility: Visibility::Private,
8932                is_async: false,
8933                name: ident(name),
8934                generic_params: vec![],
8935                params,
8936                return_type: None,
8937                effect_clause: vec![],
8938                where_clause: vec![],
8939                body: Box::new(body),
8940            },
8941        )
8942    }
8943
8944    fn match_arm(id: u32, pattern: AIRNode, guard: Option<AIRNode>, body: AIRNode) -> AIRNode {
8945        node(
8946            id,
8947            NodeKind::MatchArm {
8948                pattern: Box::new(pattern),
8949                guard: guard.map(Box::new),
8950                body: Box::new(body),
8951            },
8952        )
8953    }
8954
8955    #[test]
8956    fn rebound_let_lowers_to_assignment_not_redeclaration() {
8957        // fn f() { let acc = 1; let acc = 2; let acc = 3 }
8958        let body = block(
8959            1,
8960            vec![
8961                let_binding(10, "acc", false, int_lit(11, "1")),
8962                let_binding(20, "acc", false, int_lit(21, "2")),
8963                let_binding(30, "acc", false, int_lit(31, "3")),
8964            ],
8965            None,
8966        );
8967        let out = gen(&module(vec![], vec![fn_decl(2, "f", vec![], body)]));
8968        // First binding declares `let` (re-bound later), subsequent ones assign.
8969        assert!(
8970            out.contains("let acc = 1;"),
8971            "first re-bound `let` should declare with `let`, got: {out}"
8972        );
8973        assert_eq!(
8974            out.matches("acc = ").count(),
8975            3,
8976            "all three bindings should reference `acc`, got: {out}"
8977        );
8978        assert!(
8979            !out.contains("const acc"),
8980            "a re-bound binding must not emit `const acc`, got: {out}"
8981        );
8982    }
8983
8984    #[test]
8985    fn let_shadowing_a_param_lowers_to_assignment() {
8986        // fn f(x) { let x = x + 1; x }  — `let x` shadows the param in the same
8987        // JS block scope, so it must become an assignment, not a redeclaration.
8988        let rebind = let_binding(
8989            10,
8990            "x",
8991            false,
8992            node(
8993                12,
8994                NodeKind::BinaryOp {
8995                    op: BinOp::Add,
8996                    left: Box::new(id_node(13, "x")),
8997                    right: Box::new(int_lit(14, "1")),
8998                },
8999            ),
9000        );
9001        let body = block(1, vec![rebind], Some(id_node(15, "x")));
9002        let out = gen(&module(
9003            vec![],
9004            vec![fn_decl(2, "f", vec![param_node(3, "x")], body)],
9005        ));
9006        assert!(
9007            out.contains("x = (x + 1);"),
9008            "let shadowing a param should assign, got: {out}"
9009        );
9010        assert!(
9011            !out.contains("let x = (x + 1)") && !out.contains("const x = (x + 1)"),
9012            "let shadowing a param must not redeclare `x`, got: {out}"
9013        );
9014    }
9015
9016    #[test]
9017    fn sibling_iife_blocks_do_not_share_let_scope() {
9018        // Two arms of an expression-position match each `let x = …`. Lowered to
9019        // sibling IIFEs, each is its own scope, so both may use a fresh `const`.
9020        let arm1 = match_arm(
9021            40,
9022            node(
9023                41,
9024                NodeKind::LiteralPat {
9025                    lit: Literal::Int("0".into()),
9026                },
9027            ),
9028            None,
9029            block(
9030                42,
9031                vec![let_binding(43, "x", false, int_lit(44, "1"))],
9032                Some(id_node(45, "x")),
9033            ),
9034        );
9035        let arm2 = match_arm(
9036            50,
9037            node(51, NodeKind::WildcardPat),
9038            None,
9039            block(
9040                52,
9041                vec![let_binding(53, "x", false, int_lit(54, "2"))],
9042                Some(id_node(55, "x")),
9043            ),
9044        );
9045        let m = node(
9046            60,
9047            NodeKind::Match {
9048                scrutinee: Box::new(id_node(61, "n")),
9049                arms: vec![arm1, arm2],
9050            },
9051        );
9052        let body = block(1, vec![], Some(m));
9053        let out = gen(&module(
9054            vec![],
9055            vec![fn_decl(2, "f", vec![param_node(3, "n")], body)],
9056        ));
9057        // Both arm bodies independently declare `const x` (separate IIFE scopes);
9058        // neither is rewritten into an assignment against the other.
9059        assert_eq!(
9060            out.matches("const x = ").count(),
9061            2,
9062            "sibling IIFE arms should each declare their own `const x`, got: {out}"
9063        );
9064    }
9065
9066    #[test]
9067    fn list_pattern_match_routes_to_ifchain() {
9068        // match xs { [] => 0; [first, ..rest] => first }
9069        let empty_arm = match_arm(
9070            10,
9071            node(
9072                11,
9073                NodeKind::ListPat {
9074                    elems: vec![],
9075                    rest: None,
9076                },
9077            ),
9078            None,
9079            block(12, vec![], Some(int_lit(13, "0"))),
9080        );
9081        let cons_arm = match_arm(
9082            20,
9083            node(
9084                21,
9085                NodeKind::ListPat {
9086                    elems: vec![bind_pat(22, "first")],
9087                    rest: Some(Box::new(bind_pat(23, "rest"))),
9088                },
9089            ),
9090            None,
9091            block(24, vec![], Some(id_node(25, "first"))),
9092        );
9093        let m = node(
9094            30,
9095            NodeKind::Match {
9096                scrutinee: Box::new(id_node(31, "xs")),
9097                arms: vec![empty_arm, cons_arm],
9098            },
9099        );
9100        let body = block(1, vec![], Some(m));
9101        let out = gen(&module(
9102            vec![],
9103            vec![fn_decl(2, "f", vec![param_node(3, "xs")], body)],
9104        ));
9105        // List-pattern matches must use the if/else-if chain (never a `switch`
9106        // with multiple `default`s), with array length tests and a `..rest`
9107        // slice binding.
9108        assert!(
9109            !out.contains("switch"),
9110            "list-pattern match must not lower to a switch, got: {out}"
9111        );
9112        assert!(
9113            out.contains("xs.length === 0"),
9114            "empty-list arm should test `length === 0`, got: {out}"
9115        );
9116        // The cons arm is the final unguarded arm, so it is the chain's `else`
9117        // (Bock matches are exhaustive) — no explicit length test, but it binds
9118        // the head element and the `..rest` slice.
9119        assert!(
9120            out.contains("const first = xs[0];"),
9121            "cons arm should bind the head element, got: {out}"
9122        );
9123        assert!(
9124            out.contains("const rest = xs.slice(1);"),
9125            "`..rest` should bind the trailing slice, got: {out}"
9126        );
9127    }
9128
9129    #[test]
9130    fn range_pattern_match_routes_to_ifchain() {
9131        // match n { 1..10 => "lo"; _ => "hi" }
9132        let range_arm = match_arm(
9133            10,
9134            node(
9135                11,
9136                NodeKind::RangePat {
9137                    lo: Box::new(int_lit(12, "1")),
9138                    hi: Box::new(int_lit(13, "10")),
9139                    inclusive: false,
9140                },
9141            ),
9142            None,
9143            block(14, vec![], Some(str_lit(15, "lo"))),
9144        );
9145        let wild_arm = match_arm(
9146            20,
9147            node(21, NodeKind::WildcardPat),
9148            None,
9149            block(22, vec![], Some(str_lit(23, "hi"))),
9150        );
9151        let m = node(
9152            30,
9153            NodeKind::Match {
9154                scrutinee: Box::new(id_node(31, "n")),
9155                arms: vec![range_arm, wild_arm],
9156            },
9157        );
9158        let body = block(1, vec![], Some(m));
9159        let out = gen(&module(
9160            vec![],
9161            vec![fn_decl(2, "f", vec![param_node(3, "n")], body)],
9162        ));
9163        assert!(
9164            !out.contains("switch"),
9165            "range-pattern match must not lower to a switch, got: {out}"
9166        );
9167        assert!(
9168            out.contains("n >= 1 && n < 10"),
9169            "exclusive range should test `>= lo && < hi`, got: {out}"
9170        );
9171    }
9172
9173    #[test]
9174    fn mut_bind_arm_emits_let_not_const() {
9175        // match n { mut x => { x = x + 1; x } }
9176        let mut_pat = node(
9177            11,
9178            NodeKind::BindPat {
9179                name: ident("x"),
9180                is_mut: true,
9181            },
9182        );
9183        let assign = node(
9184            12,
9185            NodeKind::Assign {
9186                op: AssignOp::Assign,
9187                target: Box::new(id_node(13, "x")),
9188                value: Box::new(node(
9189                    14,
9190                    NodeKind::BinaryOp {
9191                        op: BinOp::Add,
9192                        left: Box::new(id_node(15, "x")),
9193                        right: Box::new(int_lit(16, "1")),
9194                    },
9195                )),
9196            },
9197        );
9198        let arm = match_arm(
9199            10,
9200            mut_pat,
9201            None,
9202            block(17, vec![assign], Some(id_node(18, "x"))),
9203        );
9204        let m = node(
9205            30,
9206            NodeKind::Match {
9207                scrutinee: Box::new(id_node(31, "n")),
9208                arms: vec![arm],
9209            },
9210        );
9211        let body = block(1, vec![], Some(m));
9212        let out = gen(&module(
9213            vec![],
9214            vec![fn_decl(2, "f", vec![param_node(3, "n")], body)],
9215        ));
9216        assert!(
9217            out.contains("let x = n;"),
9218            "a `mut` arm binding should declare with `let`, got: {out}"
9219        );
9220        assert!(
9221            !out.contains("const x = n;"),
9222            "a `mut` arm binding must not be `const`, got: {out}"
9223        );
9224    }
9225
9226    #[test]
9227    fn guard_let_binds_pattern_into_enclosing_scope() {
9228        // fn f(s) { guard (let Ok(v) = parse(s)) else { return }; v }
9229        let guard = node(
9230            10,
9231            NodeKind::Guard {
9232                let_pattern: Some(Box::new(node(
9233                    11,
9234                    NodeKind::ConstructorPat {
9235                        path: type_path(&["Ok"]),
9236                        fields: vec![bind_pat(12, "v")],
9237                    },
9238                ))),
9239                condition: Box::new(node(
9240                    13,
9241                    NodeKind::Call {
9242                        callee: Box::new(id_node(14, "parse")),
9243                        type_args: vec![],
9244                        args: vec![AirArg {
9245                            label: None,
9246                            value: id_node(15, "s"),
9247                        }],
9248                    },
9249                )),
9250                else_block: Box::new(block(
9251                    16,
9252                    vec![node(17, NodeKind::Return { value: None })],
9253                    None,
9254                )),
9255            },
9256        );
9257        let body = block(1, vec![guard], Some(id_node(18, "v")));
9258        let out = gen(&module(
9259            vec![],
9260            vec![fn_decl(2, "f", vec![param_node(3, "s")], body)],
9261        ));
9262        // The pattern is tested and `v` is bound for use *after* the guard.
9263        assert!(
9264            out.contains("._tag === \"Ok\""),
9265            "guard should test the constructor tag, got: {out}"
9266        );
9267        assert!(
9268            out.contains("const v = "),
9269            "guard should bind `v` into the enclosing scope, got: {out}"
9270        );
9271    }
9272
9273    #[test]
9274    fn eval_identifier_is_escaped_for_strict_mode() {
9275        // fn eval() { 0 }
9276        let body = block(1, vec![], Some(int_lit(11, "0")));
9277        let out = gen(&module(vec![], vec![fn_decl(2, "eval", vec![], body)]));
9278        assert!(
9279            out.contains("function eval_("),
9280            "a fn named `eval` must be escaped to `eval_` (strict mode), got: {out}"
9281        );
9282        assert!(
9283            !out.contains("function eval("),
9284            "bare `function eval(` is a strict-mode SyntaxError, got: {out}"
9285        );
9286    }
9287
9288    /// Build `fn f() { let x = if (c) { 1 } else { return 0 }  x }` — a value-
9289    /// position `if` whose else branch diverges via `return`. The shared
9290    /// value-CF hoist must lower it to a declare-then-assign temp, never an IIFE
9291    /// (which would capture the `return`) or `/* unsupported */`.
9292    fn diverging_value_if_fn() -> AIRNode {
9293        let then_b = block(2, vec![], Some(int_lit(3, "1")));
9294        let ret = node(
9295            5,
9296            NodeKind::Return {
9297                value: Some(Box::new(int_lit(6, "0"))),
9298            },
9299        );
9300        let else_b = block(4, vec![], Some(ret));
9301        let if_node = node(
9302            1,
9303            NodeKind::If {
9304                let_pattern: None,
9305                condition: Box::new(id_node(7, "c")),
9306                then_block: Box::new(then_b),
9307                else_block: Some(Box::new(else_b)),
9308            },
9309        );
9310        let let_x = node(
9311            10,
9312            NodeKind::LetBinding {
9313                is_mut: false,
9314                pattern: Box::new(bind_pat(11, "x")),
9315                ty: None,
9316                value: Box::new(if_node),
9317            },
9318        );
9319        let body = block(20, vec![let_x], Some(id_node(21, "x")));
9320        let f = node(
9321            30,
9322            NodeKind::FnDecl {
9323                annotations: vec![],
9324                visibility: Visibility::Private,
9325                is_async: false,
9326                name: ident("f"),
9327                generic_params: vec![],
9328                params: vec![],
9329                return_type: None,
9330                effect_clause: vec![],
9331                where_clause: vec![],
9332                body: Box::new(body),
9333            },
9334        );
9335        module(vec![], vec![f])
9336    }
9337
9338    #[test]
9339    fn diverging_value_if_hoists_to_stmt_form_no_iife() {
9340        let out = gen(&diverging_value_if_fn());
9341        assert!(
9342            !out.contains("/* unsupported */"),
9343            "diverging value-if must not emit `/* unsupported */`, got: {out}"
9344        );
9345        // The value arm assigns the hoisted temp; the diverging arm keeps return.
9346        assert!(
9347            out.contains("bockCf0 = MessageType") || out.contains("bockCf0 = 1"),
9348            "value arm must assign the temp, got: {out}"
9349        );
9350        assert!(
9351            out.contains("return 0"),
9352            "diverging arm must keep its return (not wrapped in an IIFE), got: {out}"
9353        );
9354    }
9355
9356    // ── `?` propagation (Q-propagate-operator-noop) ──────────────────────────
9357
9358    fn call_node(id: u32, callee: &str, args: Vec<AIRNode>) -> AIRNode {
9359        node(
9360            id,
9361            NodeKind::Call {
9362                callee: Box::new(id_node(id + 1, callee)),
9363                type_args: vec![],
9364                args: args
9365                    .into_iter()
9366                    .map(|value| AirArg { label: None, value })
9367                    .collect(),
9368            },
9369        )
9370    }
9371
9372    fn propagate(id: u32, expr: AIRNode) -> AIRNode {
9373        node(
9374            id,
9375            NodeKind::Propagate {
9376                expr: Box::new(expr),
9377            },
9378        )
9379    }
9380
9381    fn ctor_call(id: u32, name: &str, arg: AIRNode) -> AIRNode {
9382        node(
9383            id,
9384            NodeKind::Call {
9385                callee: Box::new(id_node(id + 1, name)),
9386                type_args: vec![],
9387                args: vec![AirArg {
9388                    label: None,
9389                    value: arg,
9390                }],
9391            },
9392        )
9393    }
9394
9395    #[test]
9396    fn propagate_in_let_rhs_unwraps_and_early_returns() {
9397        // fn f(x) { let v = g(x)?  Ok(v) }
9398        let let_v = node(
9399            10,
9400            NodeKind::LetBinding {
9401                is_mut: false,
9402                pattern: Box::new(bind_pat(11, "v")),
9403                ty: None,
9404                value: Box::new(propagate(12, call_node(13, "g", vec![id_node(15, "x")]))),
9405            },
9406        );
9407        let tail = ctor_call(20, "Ok", id_node(22, "v"));
9408        let body = block(1, vec![let_v], Some(tail));
9409        let out = gen(&module(
9410            vec![],
9411            vec![fn_decl(2, "f", vec![param_node(3, "x")], body)],
9412        ));
9413        // The `?` must hoist into a temp, early-return on the failure tag, and
9414        // bind the unwrapped payload — never pass the wrapped value through.
9415        assert!(
9416            out.contains("g(x)"),
9417            "the propagated expr must be evaluated, got: {out}"
9418        );
9419        assert!(
9420            out.contains("_tag === \"Err\"") || out.contains("_tag === \"None\""),
9421            "`?` must early-return on the failure tag, got: {out}"
9422        );
9423        assert!(
9424            out.contains("return __try"),
9425            "`?` must early-return the wrapped failure value, got: {out}"
9426        );
9427        assert!(
9428            out.contains("const v = __try0._0") || out.contains("const v = __try1._0"),
9429            "`?` must bind the unwrapped payload (`._0`), got: {out}"
9430        );
9431    }
9432
9433    #[test]
9434    fn propagate_in_statement_position_early_returns() {
9435        // fn f(x) { save(x)?  Ok(0) }
9436        let stmt = propagate(10, call_node(11, "save", vec![id_node(13, "x")]));
9437        let tail = ctor_call(20, "Ok", int_lit(22, "0"));
9438        let body = block(1, vec![stmt], Some(tail));
9439        let out = gen(&module(
9440            vec![],
9441            vec![fn_decl(2, "f", vec![param_node(3, "x")], body)],
9442        ));
9443        assert!(
9444            out.contains("save(x)"),
9445            "the propagated call must be evaluated, got: {out}"
9446        );
9447        assert!(
9448            out.contains("_tag === \"Err\"") || out.contains("_tag === \"None\""),
9449            "statement-position `?` must early-return on the failure tag, got: {out}"
9450        );
9451        assert!(
9452            !out.contains("/* unsupported */"),
9453            "statement-position `?` must not emit `/* unsupported */`, got: {out}"
9454        );
9455    }
9456
9457    // ── Tail-position value-less control flow (guessing-game `loop`) ──────────
9458
9459    #[test]
9460    fn tail_position_loop_emits_as_statement_not_unsupported() {
9461        // fn f() { loop { break } }  — a value-less loop in tail position must
9462        // lower to a `while (true) { … }` statement, never `return /* … */`.
9463        let brk = node(40, NodeKind::Break { value: None });
9464        let loop_body = block(41, vec![brk], None);
9465        let lp = node(
9466            42,
9467            NodeKind::Loop {
9468                body: Box::new(loop_body),
9469            },
9470        );
9471        let body = block(1, vec![], Some(lp));
9472        let out = gen(&module(vec![], vec![fn_decl(2, "f", vec![], body)]));
9473        assert!(
9474            !out.contains("/* unsupported */"),
9475            "a tail-position value-less loop must not emit `/* unsupported */`, got: {out}"
9476        );
9477        assert!(
9478            out.contains("while (true)"),
9479            "a tail-position loop must lower to `while (true)`, got: {out}"
9480        );
9481        assert!(
9482            !out.contains("return while"),
9483            "a loop must not be wrapped in `return`, got: {out}"
9484        );
9485    }
9486
9487    #[test]
9488    fn tail_position_guard_emits_as_statement() {
9489        // fn f(s) { guard (let Ok(v) = parse(s)) else { return } }
9490        // (guard as the block tail) must emit as a statement, not `return …`.
9491        let guard = node(
9492            10,
9493            NodeKind::Guard {
9494                let_pattern: Some(Box::new(node(
9495                    11,
9496                    NodeKind::ConstructorPat {
9497                        path: type_path(&["Ok"]),
9498                        fields: vec![bind_pat(12, "v")],
9499                    },
9500                ))),
9501                condition: Box::new(call_node(13, "parse", vec![id_node(16, "s")])),
9502                else_block: Box::new(block(
9503                    17,
9504                    vec![node(18, NodeKind::Return { value: None })],
9505                    None,
9506                )),
9507            },
9508        );
9509        let body = block(1, vec![], Some(guard));
9510        let out = gen(&module(
9511            vec![],
9512            vec![fn_decl(2, "f", vec![param_node(3, "s")], body)],
9513        ));
9514        assert!(
9515            !out.contains("/* unsupported */"),
9516            "a tail-position guard must not emit `/* unsupported */`, got: {out}"
9517        );
9518        assert!(
9519            out.contains("._tag === \"Ok\""),
9520            "the guard's pattern test must survive, got: {out}"
9521        );
9522    }
9523
9524    #[test]
9525    fn diverging_intrinsic_tail_is_a_bare_statement_not_return_throw() {
9526        // fn f() -> Int { todo() }  — `todo()` lowers to a bare `throw`; emitting
9527        // `return throw …` is a JS SyntaxError, so the tail must be a statement.
9528        let body = block(1, vec![], Some(call_node(10, "todo", vec![])));
9529        let out = gen(&module(vec![], vec![fn_decl(2, "f", vec![], body)]));
9530        assert!(
9531            out.contains("throw new Error(\"not implemented\")"),
9532            "`todo()` must lower to a throw, got: {out}"
9533        );
9534        assert!(
9535            !out.contains("return throw"),
9536            "`return throw …` is invalid JS; the throw must be a bare statement, got: {out}"
9537        );
9538    }
9539
9540    // ── Loop / statement-position tails must be discarded, not `return`ed ─────
9541    //
9542    // A loop (`for`/`while`/`loop`) body's final expression — and a
9543    // statement-position `if`/`match` arm's tail — is discarded in Bock (these
9544    // are statements, not the function's value). The JS backend's
9545    // `emit_block_body_inner` had wrapped every block tail in `return`, so
9546    // e.g. `for i in … { println(i) }` emitted `for (…) { return
9547    // console.log(i); }` — the `return` aborts the function on iteration 1
9548    // (fizzbuzz printed one line, then `main` returned; it exited 0, so the
9549    // exit-code-only exec audit hid the truncation). These pin the discard
9550    // behaviour for each statement context, and guard that a genuine
9551    // value-returning tail (function body, lambda, value-position `match` IIFE)
9552    // still `return`s.
9553
9554    /// `1..=hi` inclusive range over a `count` literal.
9555    fn incl_range(id: u32, lo: &str, hi: &str) -> AIRNode {
9556        node(
9557            id,
9558            NodeKind::Range {
9559                lo: Box::new(int_lit(id + 1, lo)),
9560                hi: Box::new(int_lit(id + 2, hi)),
9561                inclusive: true,
9562            },
9563        )
9564    }
9565
9566    #[test]
9567    fn for_loop_body_tail_call_is_discarded_not_returned() {
9568        // fn main() { for i in 1..=3 { println(i) } }
9569        let loop_body = block(
9570            30,
9571            vec![],
9572            Some(call_node(31, "println", vec![id_node(33, "i")])),
9573        );
9574        let for_loop = node(
9575            10,
9576            NodeKind::For {
9577                pattern: Box::new(bind_pat(11, "i")),
9578                iterable: Box::new(incl_range(20, "1", "3")),
9579                body: Box::new(loop_body),
9580            },
9581        );
9582        let out = gen(&module(
9583            vec![],
9584            vec![fn_decl(1, "main", vec![], block(2, vec![for_loop], None))],
9585        ));
9586        assert!(
9587            !out.contains("return console.log"),
9588            "a for-loop body's tail call must be a discarded statement, not a \
9589             `return` (which aborts the loop after one iteration); got:\n{out}"
9590        );
9591        assert!(
9592            out.contains("console.log(i);"),
9593            "the loop body should still emit the call as a statement; got:\n{out}"
9594        );
9595    }
9596
9597    #[test]
9598    fn while_loop_body_tail_call_is_discarded_not_returned() {
9599        // fn main() { while (true) { println("x") } }
9600        let loop_body = block(
9601            30,
9602            vec![],
9603            Some(call_node(31, "println", vec![str_lit(33, "x")])),
9604        );
9605        let while_loop = node(
9606            10,
9607            NodeKind::While {
9608                condition: Box::new(bool_lit(20, true)),
9609                body: Box::new(loop_body),
9610            },
9611        );
9612        let out = gen(&module(
9613            vec![],
9614            vec![fn_decl(1, "main", vec![], block(2, vec![while_loop], None))],
9615        ));
9616        assert!(
9617            !out.contains("return console.log"),
9618            "a while-loop body's tail call must be a discarded statement, not a \
9619             `return`; got:\n{out}"
9620        );
9621        assert!(
9622            out.contains("console.log(\"x\");"),
9623            "the loop body should still emit the call as a statement; got:\n{out}"
9624        );
9625    }
9626
9627    #[test]
9628    fn infinite_loop_body_tail_call_is_discarded_not_returned() {
9629        // fn main() { loop { println("x") } }
9630        let loop_body = block(
9631            30,
9632            vec![],
9633            Some(call_node(31, "println", vec![str_lit(33, "x")])),
9634        );
9635        let inf_loop = node(
9636            10,
9637            NodeKind::Loop {
9638                body: Box::new(loop_body),
9639            },
9640        );
9641        let out = gen(&module(
9642            vec![],
9643            vec![fn_decl(1, "main", vec![], block(2, vec![inf_loop], None))],
9644        ));
9645        assert!(
9646            !out.contains("return console.log"),
9647            "a `loop` body's tail call must be a discarded statement, not a \
9648             `return`; got:\n{out}"
9649        );
9650    }
9651
9652    #[test]
9653    fn statement_match_arm_tail_call_is_discarded_not_returned() {
9654        // fn run(r) { match r { Ok(v) => println(v); Err(e) => println(e) }; println("done") }
9655        // The trailing statement makes the `match` non-tail (statement position),
9656        // so its arm tails are discarded, not returned — a `return` inside the
9657        // `switch` would skip the `println("done")` after the match.
9658        let ok_arm = match_arm(
9659            20,
9660            node(
9661                21,
9662                NodeKind::ConstructorPat {
9663                    path: type_path(&["Ok"]),
9664                    fields: vec![bind_pat(22, "v")],
9665                },
9666            ),
9667            None,
9668            block(
9669                23,
9670                vec![],
9671                Some(call_node(24, "println", vec![id_node(26, "v")])),
9672            ),
9673        );
9674        let err_arm = match_arm(
9675            30,
9676            node(
9677                31,
9678                NodeKind::ConstructorPat {
9679                    path: type_path(&["Err"]),
9680                    fields: vec![bind_pat(32, "e")],
9681                },
9682            ),
9683            None,
9684            block(
9685                33,
9686                vec![],
9687                Some(call_node(34, "println", vec![id_node(36, "e")])),
9688            ),
9689        );
9690        let match_stmt = node(
9691            40,
9692            NodeKind::Match {
9693                scrutinee: Box::new(id_node(41, "r")),
9694                arms: vec![ok_arm, err_arm],
9695            },
9696        );
9697        let trailer = call_node(50, "println", vec![str_lit(52, "done")]);
9698        let body = block(3, vec![match_stmt], Some(trailer));
9699        let out = gen(&module(
9700            vec![],
9701            vec![fn_decl(1, "run", vec![param_node(2, "r")], body)],
9702        ));
9703        assert!(
9704            out.contains("console.log(v);") && out.contains("console.log(e);"),
9705            "statement-position match arms should emit their tail call as a \
9706             discarded statement; got:\n{out}"
9707        );
9708        assert!(
9709            !out.contains("return console.log(v);") && !out.contains("return console.log(e);"),
9710            "a statement-position match arm's tail call must be a discarded \
9711             statement, not a `return` (which would skip the code after the \
9712             match); got:\n{out}"
9713        );
9714    }
9715
9716    #[test]
9717    fn loop_in_function_body_does_not_discard_the_function_tail() {
9718        // fn total() { let mut sum = 0; for i in 1..=3 { sum = sum + i }; sum }
9719        // The loop body discards its (absent) tail, but the function's own tail
9720        // `sum` must still `return` — the discard must not leak past the loop.
9721        let assign = node(
9722            40,
9723            NodeKind::Assign {
9724                op: AssignOp::Assign,
9725                target: Box::new(id_node(41, "sum")),
9726                value: Box::new(node(
9727                    42,
9728                    NodeKind::BinaryOp {
9729                        op: BinOp::Add,
9730                        left: Box::new(id_node(43, "sum")),
9731                        right: Box::new(id_node(44, "i")),
9732                    },
9733                )),
9734            },
9735        );
9736        let for_loop = node(
9737            10,
9738            NodeKind::For {
9739                pattern: Box::new(bind_pat(11, "i")),
9740                iterable: Box::new(incl_range(20, "1", "3")),
9741                body: Box::new(block(30, vec![assign], None)),
9742            },
9743        );
9744        let body = block(
9745            2,
9746            vec![let_binding(5, "sum", true, int_lit(6, "0")), for_loop],
9747            Some(id_node(7, "sum")),
9748        );
9749        let out = gen(&module(vec![], vec![fn_decl(1, "total", vec![], body)]));
9750        assert!(
9751            out.contains("return sum;"),
9752            "the function-body tail after a loop must still `return`; got:\n{out}"
9753        );
9754    }
9755
9756    #[test]
9757    fn value_position_match_in_loop_body_still_returns_arm_values() {
9758        // fn main() { for i in 1..=3 { let s = match i { 1 => "one"; _ => "many" } } }
9759        // The `match` is in value position (a `let` initialiser), so its IIFE
9760        // arms must `return` their value even though the enclosing loop body is a
9761        // discard context — the discard must not leak into the value IIFE.
9762        let arm1 = match_arm(
9763            60,
9764            node(
9765                61,
9766                NodeKind::LiteralPat {
9767                    lit: Literal::Int("1".into()),
9768                },
9769            ),
9770            None,
9771            str_lit(62, "one"),
9772        );
9773        let arm_def = match_arm(
9774            70,
9775            node(71, NodeKind::WildcardPat),
9776            None,
9777            str_lit(72, "many"),
9778        );
9779        let match_expr = node(
9780            50,
9781            NodeKind::Match {
9782                scrutinee: Box::new(id_node(51, "i")),
9783                arms: vec![arm1, arm_def],
9784            },
9785        );
9786        let let_s = let_binding(40, "s", false, match_expr);
9787        let for_loop = node(
9788            10,
9789            NodeKind::For {
9790                pattern: Box::new(bind_pat(11, "i")),
9791                iterable: Box::new(incl_range(20, "1", "3")),
9792                body: Box::new(block(30, vec![let_s], None)),
9793            },
9794        );
9795        let out = gen(&module(
9796            vec![],
9797            vec![fn_decl(1, "main", vec![], block(2, vec![for_loop], None))],
9798        ));
9799        assert!(
9800            out.contains("return \"one\";") && out.contains("return \"many\";"),
9801            "a value-position `match` IIFE inside a loop body must still `return` \
9802             its arm values; got:\n{out}"
9803        );
9804    }
9805
9806    #[test]
9807    fn lambda_in_loop_body_still_returns_its_tail() {
9808        // fn main() { for i in 1..=3 { let f = (x) => { x } } }
9809        // The lambda body's tail is the lambda's return value; the enclosing
9810        // loop's discard context must not turn it into a bare statement.
9811        let lambda = node(
9812            50,
9813            NodeKind::Lambda {
9814                params: vec![param_node(51, "x")],
9815                body: Box::new(block(52, vec![], Some(id_node(53, "x")))),
9816            },
9817        );
9818        let let_f = let_binding(40, "f", false, lambda);
9819        let for_loop = node(
9820            10,
9821            NodeKind::For {
9822                pattern: Box::new(bind_pat(11, "i")),
9823                iterable: Box::new(incl_range(20, "1", "3")),
9824                body: Box::new(block(30, vec![let_f], None)),
9825            },
9826        );
9827        let out = gen(&module(
9828            vec![],
9829            vec![fn_decl(1, "main", vec![], block(2, vec![for_loop], None))],
9830        ));
9831        assert!(
9832            out.contains("return x;"),
9833            "a lambda body's tail inside a loop must still `return`; got:\n{out}"
9834        );
9835    }
9836
9837    #[test]
9838    fn function_body_tail_call_still_returns() {
9839        // Regression guard: a plain value-returning function-body tail still
9840        // emits `return` — the discard only applies in statement position.
9841        // fn greet() { println("hi") }   (last expr is the body's value)
9842        let body = block(
9843            2,
9844            vec![],
9845            Some(call_node(10, "println", vec![str_lit(12, "hi")])),
9846        );
9847        let out = gen(&module(vec![], vec![fn_decl(1, "greet", vec![], body)]));
9848        assert!(
9849            out.contains("return console.log(\"hi\");"),
9850            "a function-body tail call must still `return`; got:\n{out}"
9851        );
9852    }
9853
9854    /// End-to-end: a `for` loop printing N lines must print all N lines, not 1.
9855    /// This is the fizzbuzz silent-truncation bug — the loop body's tail
9856    /// `println` previously `return`ed from `main` after the first iteration.
9857    #[test]
9858    fn e2e_for_loop_prints_all_iterations_not_just_first() {
9859        if !has_node() {
9860            return;
9861        }
9862        // fn main() { for i in 1..=5 { println(i) } }
9863        let loop_body = block(
9864            30,
9865            vec![],
9866            Some(call_node(31, "println", vec![id_node(33, "i")])),
9867        );
9868        let for_loop = node(
9869            10,
9870            NodeKind::For {
9871                pattern: Box::new(bind_pat(11, "i")),
9872                iterable: Box::new(incl_range(20, "1", "5")),
9873                body: Box::new(loop_body),
9874            },
9875        );
9876        let code = gen(&module(
9877            vec![],
9878            vec![fn_decl(1, "main", vec![], block(2, vec![for_loop], None))],
9879        ));
9880        let full = format!("{code}\nmain();\n");
9881        assert!(check_js_syntax(&full), "emitted JS must be valid:\n{full}");
9882        let out = run_js(&full);
9883        let lines: Vec<&str> = out.lines().collect();
9884        assert_eq!(
9885            lines.len(),
9886            5,
9887            "the loop must print all 5 iterations (a `return` in the body would \
9888             stop after the first); got {} line(s):\n{out}\n--- source ---\n{full}",
9889            lines.len()
9890        );
9891        assert_eq!(lines, vec!["1", "2", "3", "4", "5"], "got:\n{out}");
9892    }
9893}