Skip to main content

bock_codegen/
generator.rs

1//! Code generator trait and output types.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use bock_air::{AIRNode, AirArg, EnumVariantPayload, NodeKind};
7use bock_types::AIRModule;
8
9use crate::error::CodegenError;
10use crate::profile::TargetProfile;
11
12// ─── GeneratedCode ───────────────────────────────────────────────────────────
13
14/// Output from code generation — consistent across all targets.
15#[derive(Debug, Clone)]
16pub struct GeneratedCode {
17    /// Generated output files (path + content + per-file source map).
18    pub files: Vec<OutputFile>,
19}
20
21/// A single generated output file.
22#[derive(Debug, Clone)]
23pub struct OutputFile {
24    /// Relative path for the output file.
25    pub path: PathBuf,
26    /// Generated source code content.
27    pub content: String,
28    /// Source map for this file's content (optional). Each generated file
29    /// owns its own map — multi-file builds produce one map per file.
30    pub source_map: Option<SourceMap>,
31}
32
33/// Derive the output path for a generated file from its source `.bock` path.
34///
35/// Per spec §20.6.1, a source file at `src/<path>.bock` produces output at
36/// `<path>.<ext>` (relative to the target build directory). Sources outside
37/// `src/` keep their full path. The returned `PathBuf` is always relative —
38/// callers prepend `build/<target>/`.
39///
40/// - `src/main.bock` → `main.<ext>`
41/// - `src/utils/parse.bock` → `utils/parse.<ext>`
42/// - `main.bock` → `main.<ext>` (no `src/` prefix to strip)
43///
44/// Leading `./` and any other curdir components are normalized away before
45/// stripping, so the source path can be supplied either bare or with a
46/// `./` prefix as produced by directory traversal.
47#[must_use]
48pub fn derive_output_path(source_path: &Path, target: &TargetProfile) -> PathBuf {
49    use std::path::Component;
50
51    let mut comps: Vec<&std::ffi::OsStr> = source_path
52        .components()
53        .filter_map(|c| match c {
54            Component::Normal(s) => Some(s),
55            _ => None,
56        })
57        .collect();
58
59    if comps.first().and_then(|c| c.to_str()) == Some("src") {
60        comps.remove(0);
61    }
62
63    let stripped: PathBuf = comps.iter().collect();
64    stripped.with_extension(&target.conventions.file_extension)
65}
66
67/// Maps AIR source spans to generated code spans.
68///
69/// Populated by JS/TS code generators with pointwise mappings from generated
70/// `(line, col)` back to source `(line, col)`. For other targets, only the
71/// legacy `entries` list (AIR node id → target byte range) is populated.
72#[derive(Debug, Clone, Default)]
73pub struct SourceMap {
74    /// Legacy entries keyed by AIR node id (present for all targets).
75    pub entries: Vec<SourceMapEntry>,
76    /// Pointwise position mappings from generated code to source.
77    pub mappings: Vec<SourceMapping>,
78    /// File name (no directory) this map refers to. Populated by
79    /// `generate_project` from the source-mirrored output path.
80    pub generated_file: String,
81    /// Source files referenced by `mappings`, in file-id order.
82    /// Each entry is `(path, optional_inline_content)`.
83    pub sources: Vec<SourceInfo>,
84}
85
86/// A single source-map entry linking an AIR span to a target span.
87#[derive(Debug, Clone)]
88pub struct SourceMapEntry {
89    /// AIR node id.
90    pub air_node_id: u32,
91    /// Index into `GeneratedCode::files`.
92    pub file_index: usize,
93    /// Byte offset in the generated file.
94    pub target_start: usize,
95    /// Byte length in the generated file.
96    pub target_len: usize,
97}
98
99/// A single pointwise mapping from a position in generated code to a position
100/// in the originating Bock source.
101#[derive(Debug, Clone)]
102pub struct SourceMapping {
103    /// 1-indexed line in the generated file.
104    pub gen_line: u32,
105    /// 1-indexed column (character count) in the generated file.
106    pub gen_col: u32,
107    /// 1-indexed source line. `0` means unresolved — call
108    /// [`SourceMap::resolve_positions`] with source content to fill this in.
109    pub src_line: u32,
110    /// 1-indexed source column. `0` when unresolved.
111    pub src_col: u32,
112    /// Byte offset into the source file; used to (re)compute line/col.
113    pub src_offset: u32,
114    /// File-registry id of the source file (index into `SourceMap::sources`).
115    pub src_file_id: u32,
116}
117
118/// Metadata for a source file referenced by a [`SourceMap`].
119#[derive(Debug, Clone)]
120pub struct SourceInfo {
121    /// File path (relative or absolute), as it should appear in the emitted
122    /// source-map JSON.
123    pub path: String,
124    /// Optional inline content — when present, embedded into the `.map` file
125    /// via `sourcesContent`.
126    pub content: Option<String>,
127}
128
129impl SourceMap {
130    /// Fills in `src_line` and `src_col` on every mapping by looking up
131    /// `src_offset` inside `sources_content`, which is indexed by
132    /// `src_file_id`. Mappings whose `src_file_id` is out of range are left
133    /// unresolved.
134    pub fn resolve_positions(&mut self, sources_content: &[&str]) {
135        for m in &mut self.mappings {
136            let Some(src) = sources_content.get(m.src_file_id as usize) else {
137                continue;
138            };
139            let (line, col) = byte_to_line_col(src, m.src_offset as usize);
140            m.src_line = line;
141            m.src_col = col;
142        }
143    }
144
145    /// Emits a Source Map v3 JSON document referring to this map's
146    /// `generated_file` and `sources`. Only mappings whose `src_line` is
147    /// non-zero are included.
148    #[must_use]
149    pub fn to_source_map_v3_json(&self) -> String {
150        let mut out = String::new();
151        out.push_str("{\"version\":3,\"file\":\"");
152        out.push_str(&escape_json(&self.generated_file));
153        out.push_str("\",\"sourceRoot\":\"\",\"sources\":[");
154        for (i, s) in self.sources.iter().enumerate() {
155            if i > 0 {
156                out.push(',');
157            }
158            out.push('"');
159            out.push_str(&escape_json(&s.path));
160            out.push('"');
161        }
162        out.push_str("],\"sourcesContent\":[");
163        for (i, s) in self.sources.iter().enumerate() {
164            if i > 0 {
165                out.push(',');
166            }
167            match &s.content {
168                Some(c) => {
169                    out.push('"');
170                    out.push_str(&escape_json(c));
171                    out.push('"');
172                }
173                None => out.push_str("null"),
174            }
175        }
176        out.push_str("],\"names\":[],\"mappings\":\"");
177        out.push_str(&encode_vlq_mappings(&self.mappings));
178        out.push_str("\"}");
179        out
180    }
181}
182
183/// Convert a UTF-8 byte offset into a 1-indexed (line, column) pair. Column
184/// counts Unicode scalar values, not bytes — matching `bock-source`.
185fn byte_to_line_col(src: &str, offset: usize) -> (u32, u32) {
186    let offset = offset.min(src.len());
187    let before = &src[..offset];
188    let line = before.bytes().filter(|b| *b == b'\n').count() as u32 + 1;
189    let line_start = before.rfind('\n').map_or(0, |i| i + 1);
190    let col = src[line_start..offset].chars().count() as u32 + 1;
191    (line, col)
192}
193
194/// Minimal JSON string escaper for the small subset of characters that
195/// appear in paths and source files.
196fn escape_json(s: &str) -> String {
197    let mut out = String::with_capacity(s.len());
198    for c in s.chars() {
199        match c {
200            '"' => out.push_str("\\\""),
201            '\\' => out.push_str("\\\\"),
202            '\n' => out.push_str("\\n"),
203            '\r' => out.push_str("\\r"),
204            '\t' => out.push_str("\\t"),
205            '\u{08}' => out.push_str("\\b"),
206            '\u{0C}' => out.push_str("\\f"),
207            c if (c as u32) < 0x20 => {
208                out.push_str(&format!("\\u{:04x}", c as u32));
209            }
210            c => out.push(c),
211        }
212    }
213    out
214}
215
216/// Encode mappings as a Source Map v3 "mappings" string (semicolons between
217/// generated lines, commas between segments, VLQ-encoded deltas).
218fn encode_vlq_mappings(mappings: &[SourceMapping]) -> String {
219    let mut resolved: Vec<&SourceMapping> = mappings.iter().filter(|m| m.src_line > 0).collect();
220    resolved.sort_by_key(|m| (m.gen_line, m.gen_col));
221
222    let mut out = String::new();
223    let mut prev_gen_line: u32 = 1;
224    let mut prev_gen_col: i64 = 0;
225    let mut prev_src_file: i64 = 0;
226    let mut prev_src_line: i64 = 0;
227    let mut prev_src_col: i64 = 0;
228
229    let mut first_on_line = true;
230    for m in resolved {
231        while prev_gen_line < m.gen_line {
232            out.push(';');
233            prev_gen_line += 1;
234            prev_gen_col = 0;
235            first_on_line = true;
236        }
237        if !first_on_line {
238            out.push(',');
239        }
240        let gen_col = (m.gen_col as i64) - 1;
241        let src_file = m.src_file_id as i64;
242        let src_line = (m.src_line as i64) - 1;
243        let src_col = (m.src_col as i64) - 1;
244
245        vlq_encode(&mut out, gen_col - prev_gen_col);
246        vlq_encode(&mut out, src_file - prev_src_file);
247        vlq_encode(&mut out, src_line - prev_src_line);
248        vlq_encode(&mut out, src_col - prev_src_col);
249
250        prev_gen_col = gen_col;
251        prev_src_file = src_file;
252        prev_src_line = src_line;
253        prev_src_col = src_col;
254        first_on_line = false;
255    }
256    out
257}
258
259/// Base-64 VLQ encode a single signed integer onto `out`.
260fn vlq_encode(out: &mut String, value: i64) {
261    const BASE64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
262    let mut v: u64 = if value < 0 {
263        ((-value as u64) << 1) | 1
264    } else {
265        (value as u64) << 1
266    };
267    loop {
268        let mut digit = (v & 0x1F) as u8;
269        v >>= 5;
270        if v != 0 {
271            digit |= 0x20;
272        }
273        out.push(BASE64[digit as usize] as char);
274        if v == 0 {
275            break;
276        }
277    }
278}
279
280// ─── CodeGenerator trait ─────────────────────────────────────────────────────
281
282/// The trait all per-target code generators implement.
283///
284/// Each target (JS, TS, Python, Rust, Go) provides a struct that implements
285/// this trait. The `generate_module` method transforms a fully-lowered AIR
286/// module into target-specific source code.
287pub trait CodeGenerator {
288    /// Returns the target profile for this generator.
289    fn target(&self) -> &TargetProfile;
290
291    /// Returns `true` when the given AIR node should go through Tier 1
292    /// AI synthesis (§17.2, Q3 amended).
293    ///
294    /// The default implementation consults [`TargetProfile::ai_hints`]
295    /// via [`crate::ai_synthesis::needs_ai_synthesis`]. Backends that
296    /// want per-node overrides (e.g., only non-trivial `match`
297    /// expressions) can override this method.
298    fn needs_ai_synthesis(&self, node: &bock_air::AIRNode) -> bool {
299        crate::ai_synthesis::needs_ai_synthesis(self.target(), node)
300    }
301
302    /// Generates target code from a fully-lowered AIR module.
303    ///
304    /// # Errors
305    ///
306    /// Returns `CodegenError` if the module contains constructs that cannot
307    /// be represented in the target language.
308    fn generate_module(&self, module: &AIRModule) -> Result<GeneratedCode, CodegenError>;
309
310    /// Returns the source-code snippet that invokes the user's `main` function
311    /// as the entry point for this target, or `None` if the target has a
312    /// native entry-point convention (Rust `fn main`, Go `func main`) that
313    /// runs without a synthetic call.
314    ///
315    /// `main_is_async` is `true` when the user's `main` function is declared
316    /// `async fn`; targets with native async runtimes (JS, TS, Python) wrap
317    /// the call in an event-loop driver in that case.
318    ///
319    /// Targets that need a trailing invocation (JS, TS, Python) override this
320    /// to return e.g. `"main();\n"`. The default `generate_project` appends
321    /// the snippet when any module declares a top-level `main` function.
322    fn entry_invocation(&self, main_is_async: bool) -> Option<String> {
323        let _ = main_is_async;
324        None
325    }
326
327    /// Generates target code from multiple AIR modules with their source paths.
328    ///
329    /// Per spec §20.6.1 (DQ19 resolved), each reached module is emitted to its
330    /// own target file and cross-module references are wired with the target's
331    /// **native** import mechanism (ESM `import`, Python package imports, Rust
332    /// `mod`/`use`, Go package files). Every v1 backend (JS, TS, Python, Rust,
333    /// Go) overrides this with its per-module native-import emitter, so this is
334    /// a **required** method — there is no default. (The single-module
335    /// [`Self::generate_module`] is the self-contained, runtime-inlining emit
336    /// used by per-backend unit tests, not a multi-module fallback.)
337    fn generate_project(
338        &self,
339        modules: &[(&AIRModule, &Path)],
340    ) -> Result<GeneratedCode, CodegenError>;
341
342    /// Transpile the project's `@test` functions into the target's idiomatic test
343    /// framework (project mode, §20.6.2).
344    ///
345    /// `framework` selects the deep-config test-framework variant (`"vitest"` /
346    /// `"jest"` for js/ts; `"pytest"` / `"unittest"` for python; ignored for
347    /// rust/go, whose frameworks are universal — `cargo test` / `go test`).
348    /// Returns the test files to write into the scaffolded project plus an
349    /// optional snippet to append to the entry file (Rust wires its inline
350    /// `#[cfg(test)] mod` from `src/main.rs`). When the project has no `@test`
351    /// functions the returned [`TestArtifacts`] is empty.
352    ///
353    /// The default implementation returns no test artifacts; every v1 backend
354    /// overrides it.
355    ///
356    /// # Errors
357    ///
358    /// Returns `CodegenError` if a test body contains a construct that cannot be
359    /// represented in the target language.
360    fn generate_tests(
361        &self,
362        modules: &[(&AIRModule, &Path)],
363        framework: &str,
364    ) -> Result<TestArtifacts, CodegenError> {
365        let _ = (modules, framework);
366        Ok(TestArtifacts::default())
367    }
368}
369
370/// The output of [`CodeGenerator::generate_tests`]: the transpiled test files
371/// plus an optional snippet appended to the entry file.
372///
373/// Most targets place their tests in standalone files (`*.test.js`,
374/// `test_*.py`, `*_test.go`). Rust uses an inline `#[cfg(test)] mod`, so its
375/// `bock_tests.rs` must be wired into `src/main.rs` via a `mod bock_tests;`
376/// declaration — carried in [`Self::entry_append`].
377#[derive(Debug, Clone, Default)]
378pub struct TestArtifacts {
379    /// Standalone test files, paths relative to the target build root.
380    pub files: Vec<OutputFile>,
381    /// A snippet to append verbatim to the entry file's content (Rust's
382    /// `mod bock_tests;`), or `None` when no entry wiring is required. The path
383    /// of the entry file is target-specific; the build driver knows it.
384    pub entry_append: Option<String>,
385}
386
387/// Restrict `modules` to those **reachable** from the entry module via real
388/// `use` edges, returned in a *deterministic* dependency-before-dependent order
389/// (a post-order DFS of the `use` graph with `use` targets visited in declared
390/// module-path order). The result is independent of the input slice's order, so
391/// the emitted per-module tree is byte-stable across the per-process topo-sort
392/// shuffling described below.
393///
394/// `bock build` prepends the entire embedded `core.*` stdlib and makes every
395/// user module implicitly depend on all of it (the §18.2 prelude, so core
396/// symbols resolve without an explicit `use`). That implicit dependency is
397/// correct for *name resolution* but wrong for *output*: emitting a core
398/// module a program never references both bloats the output and — until the
399/// stdlib is codegen-clean on every target — drags its latent codegen defects
400/// into the build. The emitted tree must therefore include only modules the
401/// entry program actually reaches through a real `use`.
402///
403/// Reachability is the transitive closure of each module's `ImportDecl` paths
404/// (the explicit `use`s) matched against other modules' declared `module`
405/// path — never the synthetic prelude edges, which are not represented as
406/// `ImportDecl`s in the AIR. A program with no `use` (e.g. `hello_world`) thus
407/// emits its entry module alone.
408///
409/// The entry module is the one declaring `main`; absent that (a library), the
410/// last module in dependency order. The returned vec borrows from `modules`.
411#[must_use]
412pub fn reachable_modules<'a>(
413    modules: &'a [(&'a AIRModule, &'a Path)],
414) -> Vec<(&'a AIRModule, &'a Path)> {
415    // Map declared module-path string → index, for resolving `use` targets.
416    let path_of = |m: &AIRModule| -> Option<String> {
417        if let NodeKind::Module { path: Some(p), .. } = &m.kind {
418            Some(
419                p.segments
420                    .iter()
421                    .map(|s| s.name.as_str())
422                    .collect::<Vec<_>>()
423                    .join("."),
424            )
425        } else {
426            None
427        }
428    };
429    let mut by_path: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
430    for (i, (m, _)) in modules.iter().enumerate() {
431        if let Some(p) = path_of(m) {
432            by_path.entry(p).or_insert(i);
433        }
434    }
435
436    // The explicit `use` targets of one module, as path strings.
437    let use_targets = |m: &AIRModule| -> Vec<String> {
438        let NodeKind::Module { imports, .. } = &m.kind else {
439            return vec![];
440        };
441        imports
442            .iter()
443            .filter_map(|imp| {
444                if let NodeKind::ImportDecl { path, .. } = &imp.kind {
445                    Some(
446                        path.segments
447                            .iter()
448                            .map(|s| s.name.as_str())
449                            .collect::<Vec<_>>()
450                            .join("."),
451                    )
452                } else {
453                    None
454                }
455            })
456            .collect()
457    };
458
459    // Entry = the module declaring `main`, else the last (top of dep order).
460    let Some(entry_idx) = modules
461        .iter()
462        .position(|(m, _)| module_declares_main_fn(m))
463        .or_else(|| modules.len().checked_sub(1))
464    else {
465        return vec![];
466    };
467
468    // A *deterministic* post-order DFS of the explicit-`use` graph from the
469    // entry module: this both prunes to reachable modules and orders them
470    // dependencies-before-dependents, with a canonical, input-order-independent
471    // result.
472    //
473    // Determinism matters because `bock build` runs in a fresh process per
474    // invocation, and the upstream module list (`air_modules`) is produced by a
475    // topological sort whose internal `HashMap`/`HashSet` iteration is seeded
476    // randomly per process — so the *same* program's `modules` slice can arrive
477    // in different (all valid) topological orders on different runs. Relying on
478    // that input order made the emitted module order (entry selection + file
479    // emission order) vary run-to-run, which surfaced as a rare, random `bock
480    // build` failure once several independent embedded `core.*` modules were
481    // reachable. Visiting each module's `use` targets in a fixed order (declared
482    // module path, then index) pins the output.
483    let mut visited = vec![false; modules.len()];
484    let mut order: Vec<usize> = Vec::new();
485    // Iterative post-order DFS (recursion-free to avoid deep-graph stack use):
486    // `Enter(i)` schedules children then a matching `Exit(i)`; `Exit(i)` appends
487    // `i` after all its dependencies, giving dependency-before-dependent order.
488    enum Step {
489        Enter(usize),
490        Exit(usize),
491    }
492    let mut stack = vec![Step::Enter(entry_idx)];
493    while let Some(step) = stack.pop() {
494        match step {
495            Step::Enter(idx) => {
496                if visited[idx] {
497                    continue;
498                }
499                visited[idx] = true;
500                stack.push(Step::Exit(idx));
501                // Resolve this module's `use` targets to indices and visit them
502                // in a deterministic order: by declared module path (stable
503                // across runs), then by index as a final tiebreak.
504                let mut child_indices: Vec<usize> = use_targets(modules[idx].0)
505                    .iter()
506                    .filter_map(|target| by_path.get(target).copied())
507                    .collect();
508                child_indices.sort_by(|&a, &b| {
509                    path_of(modules[a].0)
510                        .cmp(&path_of(modules[b].0))
511                        .then(a.cmp(&b))
512                });
513                child_indices.dedup();
514                // Push in reverse so the smallest-keyed child is processed first
515                // (the stack pops LIFO), keeping the emitted order ascending.
516                for child in child_indices.into_iter().rev() {
517                    if !visited[child] {
518                        stack.push(Step::Enter(child));
519                    }
520                }
521            }
522            Step::Exit(idx) => order.push(idx),
523        }
524    }
525
526    order.into_iter().map(|i| modules[i]).collect()
527}
528
529/// Returns true if the given AIR module declares a top-level function named
530/// `main`. Used by the build pipeline to decide whether to append an
531/// entry-point invocation to the generated output of targets without a
532/// native main convention.
533#[must_use]
534pub fn module_declares_main_fn(module: &AIRModule) -> bool {
535    let NodeKind::Module { items, .. } = &module.kind else {
536        return false;
537    };
538    items.iter().any(|item| {
539        matches!(
540            &item.kind,
541            NodeKind::FnDecl { name, .. } if name.name == "main"
542        )
543    })
544}
545
546/// The declared module-path of an AIR module as a dotted string
547/// (e.g. `core.option`), or `None` if the module declares no `module <path>`.
548///
549/// Used by the per-module (native-import) emission path to map a module's
550/// *declared* path — not its on-disk source path — onto the target's import
551/// path. For Python this drives both the emitted file location
552/// (`core.option` → `core/option.py`) and the import statement
553/// (`from core.option import …`), so the two agree and a multi-file program
554/// resolves its imports when run from the build root.
555#[must_use]
556pub fn module_path_string(module: &AIRModule) -> Option<String> {
557    if let NodeKind::Module { path: Some(p), .. } = &module.kind {
558        Some(
559            p.segments
560                .iter()
561                .map(|s| s.name.as_str())
562                .collect::<Vec<_>>()
563                .join("."),
564        )
565    } else {
566        None
567    }
568}
569
570/// Returns true if the given AIR module declares a top-level `async fn main`.
571/// Used by `generate_project` to select an async-aware entry invocation.
572#[must_use]
573pub fn module_main_fn_is_async(module: &AIRModule) -> bool {
574    let NodeKind::Module { items, .. } = &module.kind else {
575        return false;
576    };
577    items.iter().any(|item| {
578        matches!(
579            &item.kind,
580            NodeKind::FnDecl { name, is_async: true, .. } if name.name == "main"
581        )
582    })
583}
584
585// ─── Transpiled-test extraction + assertion classification (§20.6.2) ─────────
586//
587// Project mode (§20.6.2) transpiles each Bock `@test` function into the target's
588// idiomatic test framework. These shared helpers identify the `@test` functions
589// and classify the `expect(actual).<assertion>(expected)` chains in their bodies
590// so each per-target test emitter can lower them to its framework's idiom
591// without re-implementing the AIR pattern-matching five times.
592
593/// Returns `true` if `node` is a function declaration carrying the `@test`
594/// annotation. Matches the discovery rule used by `bock test`
595/// (`bock-cli::test::discover_test_functions`): an `@test`-annotated `FnDecl`.
596#[must_use]
597pub fn fn_is_test(node: &AIRNode) -> bool {
598    matches!(
599        &node.kind,
600        NodeKind::FnDecl { annotations, .. }
601            if annotations.iter().any(|a| a.name.name == "test")
602    )
603}
604
605/// Collect every `@test`-annotated top-level function across the given modules,
606/// paired with the module's declared path (dotted, or `""` if anonymous).
607///
608/// The result preserves module order and within-module declaration order, so the
609/// emitted test files are deterministic. Each entry borrows the `FnDecl` node.
610#[must_use]
611pub fn collect_test_fns<'a>(
612    modules: &'a [(&'a AIRModule, &'a Path)],
613) -> Vec<(&'a AIRNode, String)> {
614    let mut tests = Vec::new();
615    for (module, _) in modules {
616        let module_path = module_path_string(module).unwrap_or_default();
617        if let NodeKind::Module { items, .. } = &module.kind {
618            for item in items {
619                if fn_is_test(item) {
620                    tests.push((item, module_path.clone()));
621                }
622            }
623        }
624    }
625    tests
626}
627
628/// A recognized Bock test assertion (`expect(actual).<method>(...)`).
629///
630/// Each variant carries the framework-agnostic *intent*; the per-target emitter
631/// maps it to the idiom (Vitest/Jest `expect().toBe(...)`, pytest `assert`, Rust
632/// `assert_eq!`, Go `t.Errorf`, etc.). The assertion methods mirror the
633/// interpreter's `register_test_builtins` set (`bock-interp::builtins`).
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub enum TestAssertion {
636    /// `expect(actual).to_equal(expected)`
637    Equal,
638    /// `expect(actual).to_be_true()`
639    BeTrue,
640    /// `expect(actual).to_be_false()`
641    BeFalse,
642    /// `expect(actual).to_be_some()`
643    BeSome,
644    /// `expect(actual).to_be_none()`
645    BeNone,
646    /// `expect(actual).to_be_ok()`
647    BeOk,
648    /// `expect(actual).to_be_err()`
649    BeErr,
650}
651
652impl TestAssertion {
653    fn from_method(name: &str) -> Option<Self> {
654        match name {
655            "to_equal" => Some(Self::Equal),
656            "to_be_true" => Some(Self::BeTrue),
657            "to_be_false" => Some(Self::BeFalse),
658            "to_be_some" => Some(Self::BeSome),
659            "to_be_none" => Some(Self::BeNone),
660            "to_be_ok" => Some(Self::BeOk),
661            "to_be_err" => Some(Self::BeErr),
662            _ => None,
663        }
664    }
665}
666
667/// If `stmt` is an `expect(actual).<assertion>(expected?)` chain, classify it.
668///
669/// Returns `(assertion, actual_expr, expected_expr_opt)` where `actual_expr` is
670/// the argument to `expect(...)` and `expected_expr_opt` is the explicit
671/// argument to the assertion method (present for [`TestAssertion::Equal`],
672/// absent for the nullary predicates). Returns `None` for any statement that is
673/// not an `expect(...)`-rooted assertion chain — the emitter falls back to its
674/// normal statement lowering for those.
675///
676/// A Bock method call `recv.m(args)` is lowered (by `bock-air::lower`) to
677/// `Call { callee: FieldAccess(recv, m), args: [self=recv, ...args] }` — the
678/// receiver is *prepended* as the implicit `self` argument. So an assertion
679/// `expect(actual).to_equal(expected)` is:
680/// ```text
681/// Call {
682///   callee: FieldAccess(object: Call{expect, [actual]}, field: to_equal),
683///   args: [ self = Call{expect, [actual]}, expected ],
684/// }
685/// ```
686/// The explicit `expected` is therefore `args[1]` (`args[0]` is the self copy).
687#[must_use]
688pub fn classify_assertion(stmt: &AIRNode) -> Option<(TestAssertion, &AIRNode, Option<&AIRNode>)> {
689    let NodeKind::Call { callee, args, .. } = &stmt.kind else {
690        return None;
691    };
692    let NodeKind::FieldAccess { object, field } = &callee.kind else {
693        return None;
694    };
695    let assertion = TestAssertion::from_method(&field.name)?;
696    // The receiver object must be `expect(actual)`.
697    let NodeKind::Call {
698        callee: expect_callee,
699        args: expect_args,
700        ..
701    } = &object.kind
702    else {
703        return None;
704    };
705    let NodeKind::Identifier { name } = &expect_callee.kind else {
706        return None;
707    };
708    if name.name != "expect" {
709        return None;
710    }
711    let actual = expect_args.first().map(|a| &a.value)?;
712    // `args[0]` is the desugared `self` (a copy of the `expect(...)` receiver);
713    // the explicit assertion argument, if any, is `args[1]`.
714    let expected = args.get(1).map(|a| &a.value);
715    Some((assertion, actual, expected))
716}
717
718// ─── ESM per-module emission helpers (js/ts) ────────────────────────────────
719//
720// The JS and TS backends emit a per-module **native ES-module import tree**
721// (spec §20.6.1; DQ19 resolved): each reachable module → its own `.js`/`.ts`
722// file, cross-module references resolved with real `import { x } from "./…"`.
723// These helpers are shared by both backends because the analysis is purely
724// over the AIR (declared symbols, references, declared module paths) and is
725// identical for the two targets. Python (`py.rs`) has its own equivalents
726// because its import surface (package paths, no relative specifier, a shared
727// `*`-runtime) differs enough to not share cleanly.
728
729/// Runtime-prelude *value* names that the JS/TS backends lower **inline** to
730/// tagged objects (`{ _tag: "Some", _0: v }`, `{ _tag: "Less" }`, …) — NOT from
731/// a cross-module `core.*` import. The implicit-import pass and the
732/// public-symbol map must never route these through a `core.option` /
733/// `core.compare` import: the declaring module does not actually export them
734/// (they are compiler built-ins), so a real import would be an unresolved
735/// reference.
736///
737/// `Ordering` is intentionally **absent**: unlike `Optional`/`Result`, the
738/// comparison `Ordering` enum is genuinely *declared* (and exported) by the
739/// `core.compare` stdlib module, so a cross-module use of the `Ordering` **type**
740/// resolves through a real import; only its *variant values* (`Less`/`Equal`/
741/// `Greater`) lower inline and so stay excluded here.
742pub const ESM_RUNTIME_PRELUDE_NAMES: &[&str] = &[
743    "Optional", "Some", "None", "Result", "Ok", "Err", "Less", "Equal", "Greater",
744];
745
746/// The declaration kind of a public symbol exposed by the per-module ESM
747/// analysis. Each backend maps this to the right cross-module import form,
748/// because the JS and TS emitted shapes differ (a trait is a JS `const` mixin
749/// value but a TS `interface` type; an enum *type* name has no JS binding but is
750/// a TS type; a type alias is erased in JS but a TS type).
751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
752pub enum EsmDeclKind {
753    /// A top-level function (camelCased on emit; a value in both targets).
754    Function,
755    /// A `const` (a value in both targets).
756    Const,
757    /// A record (JS/TS `class` — a value; in TS also a type).
758    Record,
759    /// A `class` (a value; in TS also a type).
760    Class,
761    /// An enum's **type** name (`Ordering`) — no JS binding; a TS type.
762    EnumType,
763    /// An enum **variant** value (`Color_Red`) — a value in both targets.
764    EnumVariant,
765    /// A trait — a JS `const` mixin value; a TS `interface` type.
766    Trait,
767    /// An effect — a JS/TS `class`/`interface`; treated as a value.
768    Effect,
769    /// A type alias — erased in JS; a TS type.
770    TypeAlias,
771}
772
773impl EsmDeclKind {
774    /// Whether a symbol of this kind has a runtime binding in **JS** (so a JS
775    /// cross-module reference imports it as a value). Type-only kinds (an enum
776    /// type name, a TS-only type alias) have no JS binding.
777    #[must_use]
778    pub fn is_js_value(self) -> bool {
779        matches!(
780            self,
781            EsmDeclKind::Function
782                | EsmDeclKind::Const
783                | EsmDeclKind::Record
784                | EsmDeclKind::Class
785                | EsmDeclKind::EnumVariant
786                | EsmDeclKind::Trait
787                | EsmDeclKind::Effect
788        )
789    }
790
791    /// Whether a symbol of this kind is imported with TS `import type` (a
792    /// pure-type kind: an enum type name, a trait interface, or a type alias).
793    /// Value-and-type kinds (records, classes) and pure values import normally.
794    #[must_use]
795    pub fn is_ts_type_only(self) -> bool {
796        matches!(
797            self,
798            EsmDeclKind::EnumType | EsmDeclKind::Trait | EsmDeclKind::TypeAlias
799        )
800    }
801}
802
803/// One public symbol exposed by the per-module ESM analysis. Carries the
804/// declaring module-path and the declaration kind. The kind drives both the
805/// emitted-name transform (only a function is camelCased: `get_or` → `getOr`)
806/// and the import form each backend selects (value vs `import type` vs skip in
807/// JS) — see [`EsmDeclKind`].
808#[derive(Debug, Clone)]
809pub struct EsmSymbol {
810    /// Dotted declared module-path that declares this symbol (e.g. `core.iter`).
811    pub module_path: String,
812    /// The declaration kind.
813    pub kind: EsmDeclKind,
814    /// For an [`EsmDeclKind::EnumVariant`], the variant's **bare source name**
815    /// (`Electronics`) — distinct from the map key, which is the *emitted*
816    /// value-name (`Category_Electronics`). `None` for every other kind.
817    ///
818    /// A glob-imported (`use models.*`) variant is referenced in AIR by its bare
819    /// source name, not its emitted `Enum_Variant` name, so the implicit-import
820    /// reference scan must also try the bare spelling — see
821    /// [`implicit_esm_imports_for`]. Keeping the import's emitted *name* as the
822    /// map key (and only matching on the bare name) means the backends still
823    /// import the identifier they actually emit (`Category_Electronics`).
824    pub variant_bare_name: Option<String>,
825}
826
827impl EsmSymbol {
828    /// True if the symbol is a function (camelCased on emit).
829    #[must_use]
830    pub fn is_fn(&self) -> bool {
831        matches!(self.kind, EsmDeclKind::Function)
832    }
833}
834
835/// Build a map from every **public top-level symbol name** (the raw Bock name)
836/// declared across `modules` to its [`EsmSymbol`] (declaring module-path +
837/// whether it is a function). Covers functions, records, enums (and each
838/// variant's emitted `Enum_Variant` factory/const name), traits, classes,
839/// effects, type aliases, and consts.
840///
841/// The per-module ESM emission path needs this for **implicit imports**: a
842/// prelude trait used as a base in an `impl` (`impl Iterable for Bag`, with
843/// `Iterable` auto-imported per §18.2) is referenced without an explicit `use`.
844/// Emitting one file per module means the consuming file must
845/// `import` `Iterable` from `core/iter.js` even though it never appears in an
846/// explicit `use`. This map lets the backend add exactly those imports for
847/// names a module references but neither declares locally nor imports
848/// explicitly. The key is the **raw** Bock name so the reference scan in
849/// [`implicit_esm_imports_for`] matches the AIR debug rendering.
850///
851/// Runtime-prelude names ([`ESM_RUNTIME_PRELUDE_NAMES`]) are excluded — they
852/// lower inline. The first declarer wins for a name declared in several modules
853/// (deterministic via the dependency order `modules` arrives in).
854#[must_use]
855pub fn collect_public_symbols_for_esm(
856    modules: &[(&AIRModule, &Path)],
857) -> HashMap<String, EsmSymbol> {
858    let mut map: HashMap<String, EsmSymbol> = HashMap::new();
859    for (module, _) in modules {
860        let Some(module_path) = module_path_string(module) else {
861            continue;
862        };
863        let NodeKind::Module { items, .. } = &module.kind else {
864            continue;
865        };
866        for item in items {
867            let mut record = |name: &str, kind: EsmDeclKind, variant_bare_name: Option<String>| {
868                if !ESM_RUNTIME_PRELUDE_NAMES.contains(&name) {
869                    map.entry(name.to_string()).or_insert_with(|| EsmSymbol {
870                        module_path: module_path.clone(),
871                        kind,
872                        variant_bare_name,
873                    });
874                }
875            };
876            match &item.kind {
877                NodeKind::FnDecl {
878                    visibility, name, ..
879                } => {
880                    if matches!(visibility, bock_ast::Visibility::Public) {
881                        record(&name.name, EsmDeclKind::Function, None);
882                    }
883                }
884                NodeKind::RecordDecl {
885                    visibility, name, ..
886                } => {
887                    if matches!(visibility, bock_ast::Visibility::Public) {
888                        record(&name.name, EsmDeclKind::Record, None);
889                    }
890                }
891                NodeKind::ClassDecl {
892                    visibility, name, ..
893                } => {
894                    if matches!(visibility, bock_ast::Visibility::Public) {
895                        record(&name.name, EsmDeclKind::Class, None);
896                    }
897                }
898                NodeKind::TraitDecl {
899                    visibility, name, ..
900                } => {
901                    if matches!(visibility, bock_ast::Visibility::Public) {
902                        record(&name.name, EsmDeclKind::Trait, None);
903                    }
904                }
905                NodeKind::EffectDecl {
906                    visibility, name, ..
907                } => {
908                    if matches!(visibility, bock_ast::Visibility::Public) {
909                        record(&name.name, EsmDeclKind::Effect, None);
910                    }
911                }
912                NodeKind::TypeAlias {
913                    visibility, name, ..
914                } => {
915                    if matches!(visibility, bock_ast::Visibility::Public) {
916                        record(&name.name, EsmDeclKind::TypeAlias, None);
917                    }
918                }
919                NodeKind::ConstDecl {
920                    visibility, name, ..
921                } => {
922                    if matches!(visibility, bock_ast::Visibility::Public) {
923                        record(&name.name, EsmDeclKind::Const, None);
924                    }
925                }
926                NodeKind::EnumDecl {
927                    visibility,
928                    name,
929                    variants,
930                    ..
931                } => {
932                    if matches!(visibility, bock_ast::Visibility::Public) {
933                        record(&name.name, EsmDeclKind::EnumType, None);
934                        for v in variants {
935                            if let NodeKind::EnumVariant { name: vname, .. } = &v.kind {
936                                // Key on the emitted value-name (`Category_Electronics`)
937                                // so the import binds the identifier the backends emit,
938                                // but carry the bare source name (`Electronics`) so the
939                                // reference scan can match a glob-imported use site.
940                                record(
941                                    &format!("{}_{}", name.name, vname.name),
942                                    EsmDeclKind::EnumVariant,
943                                    Some(vname.name.clone()),
944                                );
945                            }
946                        }
947                    }
948                }
949                _ => {}
950            }
951        }
952    }
953    map
954}
955
956/// One **exportable, runtime-valued** public declaration of a module: the raw
957/// emitted name plus whether it is a function (camelCased on emit). Returned by
958/// [`exportable_value_names`].
959#[derive(Debug, Clone)]
960pub struct EsmExport {
961    /// The symbol's name as the declaration/enum-variant emits it before any
962    /// camelCase transform (`get_or`, `Color_Red`, `MAX`).
963    pub name: String,
964    /// True if the symbol is a function (the backend camelCases it on emit).
965    pub is_fn: bool,
966}
967
968/// The set of **JS/TS-emitted, exportable** public top-level value declarations
969/// a module declares — the names the per-module path lists in a trailing
970/// `export { … }` (or, for functions, that the backend exports inline). Covers
971/// functions, records, enums (+ each `Enum_Variant`), traits, classes, effects,
972/// and consts. **Type aliases are excluded**: they are erased in JS (a comment,
973/// no runtime binding) and emitted as an `export type` alias inline in TS, so
974/// they need no trailing re-export. Runtime-prelude names are excluded (lowered
975/// inline). Each entry carries the function flag so the backend camelCases
976/// function names to match their inline `export function` form.
977///
978/// Used by the **JS** backend's trailing-export pass (TS exports every kind
979/// except enum variants inline — see [`enum_variant_value_names`]).
980#[must_use]
981pub fn exportable_value_names(module: &AIRModule) -> Vec<EsmExport> {
982    let mut names: Vec<EsmExport> = Vec::new();
983    let mut push = |name: String, is_fn: bool| {
984        if !ESM_RUNTIME_PRELUDE_NAMES.contains(&name.as_str()) {
985            names.push(EsmExport { name, is_fn });
986        }
987    };
988    let NodeKind::Module { items, .. } = &module.kind else {
989        return names;
990    };
991    for item in items {
992        match &item.kind {
993            NodeKind::FnDecl {
994                visibility, name, ..
995            } => {
996                if matches!(visibility, bock_ast::Visibility::Public) {
997                    push(name.name.clone(), true);
998                }
999            }
1000            NodeKind::RecordDecl {
1001                visibility, name, ..
1002            }
1003            | NodeKind::TraitDecl {
1004                visibility, name, ..
1005            }
1006            | NodeKind::ClassDecl {
1007                visibility, name, ..
1008            }
1009            | NodeKind::EffectDecl {
1010                visibility, name, ..
1011            }
1012            | NodeKind::ConstDecl {
1013                visibility, name, ..
1014            } => {
1015                if matches!(visibility, bock_ast::Visibility::Public) {
1016                    push(name.name.clone(), false);
1017                }
1018            }
1019            NodeKind::EnumDecl {
1020                visibility,
1021                name,
1022                variants,
1023                ..
1024            } => {
1025                if matches!(visibility, bock_ast::Visibility::Public) {
1026                    for v in variants {
1027                        if let NodeKind::EnumVariant { name: vname, .. } = &v.kind {
1028                            push(format!("{}_{}", name.name, vname.name), false);
1029                        }
1030                    }
1031                }
1032            }
1033            _ => {}
1034        }
1035    }
1036    names
1037}
1038
1039/// The public **enum-variant value names** (`Color_Red`, …) declared in
1040/// `module` — the names a TS per-module file must re-export in a trailing
1041/// `export { … }`.
1042///
1043/// In the TS backend every public top-level declaration exports inline
1044/// (`export class`, `export type`, `export function`, `export const`) **except**
1045/// an enum's per-variant interface / const / factory, which the variant emitter
1046/// writes without an `export`. The per-module tree needs those exported so a
1047/// consuming file can import them, so this enumerates exactly the variant value
1048/// names for the trailing re-export. Variants of a runtime-
1049/// prelude enum (`Optional` / `Result` / `Ordering`) are excluded — they lower
1050/// inline.
1051#[must_use]
1052pub fn enum_variant_value_names(module: &AIRModule) -> Vec<String> {
1053    let mut names: Vec<String> = Vec::new();
1054    let NodeKind::Module { items, .. } = &module.kind else {
1055        return names;
1056    };
1057    for item in items {
1058        if let NodeKind::EnumDecl {
1059            visibility,
1060            name,
1061            variants,
1062            ..
1063        } = &item.kind
1064        {
1065            if matches!(visibility, bock_ast::Visibility::Public)
1066                && !ESM_RUNTIME_PRELUDE_NAMES.contains(&name.name.as_str())
1067            {
1068                for v in variants {
1069                    if let NodeKind::EnumVariant { name: vname, .. } = &v.kind {
1070                        names.push(format!("{}_{}", name.name, vname.name));
1071                    }
1072                }
1073            }
1074        }
1075    }
1076    names
1077}
1078
1079/// Top-level symbol names declared **locally** in `module` (item names plus
1080/// each enum variant's emitted `Enum_Variant` name) — the names a per-module
1081/// implicit import must never shadow with a cross-module import.
1082#[must_use]
1083pub fn locally_declared_names(module: &AIRModule) -> std::collections::HashSet<String> {
1084    let mut names = std::collections::HashSet::new();
1085    let NodeKind::Module { items, .. } = &module.kind else {
1086        return names;
1087    };
1088    for item in items {
1089        match &item.kind {
1090            NodeKind::FnDecl { name, .. }
1091            | NodeKind::RecordDecl { name, .. }
1092            | NodeKind::TraitDecl { name, .. }
1093            | NodeKind::ClassDecl { name, .. }
1094            | NodeKind::EffectDecl { name, .. }
1095            | NodeKind::TypeAlias { name, .. }
1096            | NodeKind::ConstDecl { name, .. } => {
1097                names.insert(name.name.clone());
1098            }
1099            NodeKind::EnumDecl { name, variants, .. } => {
1100                names.insert(name.name.clone());
1101                for v in variants {
1102                    if let NodeKind::EnumVariant { name: vname, .. } = &v.kind {
1103                        names.insert(format!("{}_{}", name.name, vname.name));
1104                    }
1105                }
1106            }
1107            _ => {}
1108        }
1109    }
1110    names
1111}
1112
1113/// Names brought into scope by `module`'s explicit `use` declarations (the
1114/// imported leaf names and their aliases) — already emitted as real imports, so
1115/// the implicit-import pass must skip them.
1116#[must_use]
1117pub fn explicitly_imported_names(module: &AIRModule) -> std::collections::HashSet<String> {
1118    let mut names = std::collections::HashSet::new();
1119    let NodeKind::Module { imports, .. } = &module.kind else {
1120        return names;
1121    };
1122    for import in imports {
1123        if let NodeKind::ImportDecl {
1124            items: bock_ast::ImportItems::Named(named),
1125            ..
1126        } = &import.kind
1127        {
1128            for n in named {
1129                names.insert(n.name.name.clone());
1130                if let Some(alias) = &n.alias {
1131                    names.insert(alias.name.clone());
1132                }
1133            }
1134        }
1135    }
1136    names
1137}
1138
1139/// One implicit cross-module import computed by [`implicit_esm_imports_for`]:
1140/// the declaring module-path, the raw symbol name, and the declaration kind (so
1141/// the backend can camelCase a function, route a type to `import type`, or skip
1142/// a JS type-only name).
1143#[derive(Debug, Clone)]
1144pub struct ImplicitEsmImport {
1145    /// Dotted declared module-path that declares the symbol.
1146    pub module_path: String,
1147    /// The symbol's raw Bock name.
1148    pub name: String,
1149    /// The declaration kind.
1150    pub kind: EsmDeclKind,
1151}
1152
1153impl ImplicitEsmImport {
1154    /// True if the symbol is a function (camelCased on emit).
1155    #[must_use]
1156    pub fn is_fn(&self) -> bool {
1157        matches!(self.kind, EsmDeclKind::Function)
1158    }
1159}
1160
1161/// Compute the implicit cross-module imports for `module`: public symbols
1162/// declared in *other* reachable modules that `module` references but neither
1163/// declares locally nor imports explicitly.
1164///
1165/// "References" is a conservative structural scan of the module's debug
1166/// rendering for the symbol name as a quoted identifier token. It can only
1167/// *over*-import a name the program does not really use (a harmless dead
1168/// import), never *under*-import — so it cannot reintroduce the unresolved
1169/// reference it exists to fix.
1170///
1171/// An **enum variant** needs a second probe. Its map key is the *emitted*
1172/// value-name (`Category_Electronics`), but a glob-imported (`use models.*`)
1173/// variant is referenced in AIR by its *bare* source name
1174/// (`Identifier { name: "Electronics" }`) — the `Enum_Variant` joining happens
1175/// only at emit time. So for a variant we also scan for the bare source name
1176/// ([`EsmSymbol::variant_bare_name`]) and, on a match, import the symbol under
1177/// its emitted key (the identifier the backends actually emit and need bound).
1178/// Without this the per-module JS/TS file omits the variant import and
1179/// `ReferenceError`s / TS2304s at every bare-variant use site.
1180#[must_use]
1181pub fn implicit_esm_imports_for(
1182    module: &AIRModule,
1183    public_symbols: &HashMap<String, EsmSymbol>,
1184    own_path: &str,
1185) -> Vec<ImplicitEsmImport> {
1186    let local = locally_declared_names(module);
1187    let explicit = explicitly_imported_names(module);
1188    let rendered = format!("{module:?}");
1189    let mut out: Vec<ImplicitEsmImport> = Vec::new();
1190    for (name, sym) in public_symbols {
1191        if sym.module_path == own_path || local.contains(name) || explicit.contains(name) {
1192            continue;
1193        }
1194        // A reference is the emitted name as a quoted token, or — for an enum
1195        // variant — its bare source name (the spelling a glob-imported use site
1196        // carries). Either way the symbol is imported under its emitted key.
1197        let referenced = rendered.contains(&format!("\"{name}\""))
1198            || sym
1199                .variant_bare_name
1200                .as_ref()
1201                .is_some_and(|bare| rendered.contains(&format!("\"{bare}\"")));
1202        if referenced {
1203            out.push(ImplicitEsmImport {
1204                module_path: sym.module_path.clone(),
1205                name: name.clone(),
1206                kind: sym.kind,
1207            });
1208        }
1209    }
1210    out
1211}
1212
1213/// Collect the names of every **record** declared across `modules` (the names
1214/// the JS/TS backends emit as classes and construct with `new Name(...)`).
1215///
1216/// The per-module path emits each module in its own context, so a cross-module
1217/// record construction (`handling (Log with ConsoleLog {})` where `ConsoleLog`
1218/// is `use`d from another module) would not find the record in the local
1219/// `record_names` set and would mis-lower to a bare object literal `{}` instead
1220/// of `new ConsoleLog()` (dropping its prototype methods). Pre-seeding
1221/// `record_names` from the whole reachable set gives every per-module emit
1222/// context cross-module record visibility. Mirrors
1223/// [`collect_enum_variants`] / [`collect_trait_decls`].
1224#[must_use]
1225pub fn collect_record_names(modules: &[(&AIRModule, &Path)]) -> std::collections::HashSet<String> {
1226    let mut names = std::collections::HashSet::new();
1227    for (module, _) in modules {
1228        let NodeKind::Module { items, .. } = &module.kind else {
1229            continue;
1230        };
1231        for item in items {
1232            if let NodeKind::RecordDecl { name, .. } = &item.kind {
1233                names.insert(name.name.clone());
1234            }
1235        }
1236    }
1237    names
1238}
1239
1240/// Collect every **class** declared across `modules`, mapping each class name to
1241/// its **field names in declaration order**.
1242///
1243/// A Bock `class` and a `record` both lower to a JS/TS `class`, but with
1244/// *different constructor shapes*: a `record T { a, b }` emits a destructured
1245/// `constructor({ a, b })` (so a `T { a: x, b: y }` literal lowers to `new T({ a:
1246/// x, b: y })`), whereas a `class T { a, b }` emits a **positional**
1247/// `constructor(a, b)` (so a `T { a: x, b: y }` literal must lower to `new T(x,
1248/// y)` — arguments in *field-declaration order*, regardless of the literal's
1249/// field spelling order).
1250///
1251/// The js/ts `RecordConstruct` emitters consult this map (kept **separate** from
1252/// [`collect_record_names`]) to pick the class's positional shape and to order
1253/// the supplied field values by the declared field order. It is js/ts-only: the
1254/// shared `record_names` set must stay records-only because py/go/rust derive
1255/// other behavior from it, and a Bock class's positional construction is a
1256/// js/ts emission concern. Without it a class literal falls through to the
1257/// record/object path and emits a bare object literal whose prototype methods
1258/// are unreachable (`btn.render is not a function`). Mirrors
1259/// [`collect_record_names`] / [`collect_enum_variants`].
1260#[must_use]
1261pub fn collect_class_fields(
1262    modules: &[(&AIRModule, &Path)],
1263) -> std::collections::HashMap<String, Vec<String>> {
1264    let mut classes = std::collections::HashMap::new();
1265    for (module, _) in modules {
1266        let NodeKind::Module { items, .. } = &module.kind else {
1267            continue;
1268        };
1269        for item in items {
1270            if let NodeKind::ClassDecl { name, fields, .. } = &item.kind {
1271                let field_order = fields.iter().map(|f| f.name.name.clone()).collect();
1272                classes.insert(name.name.clone(), field_order);
1273            }
1274        }
1275    }
1276    classes
1277}
1278
1279/// Pre-scan every reached module and collect the **declared names of all
1280/// module-scope `const`s**.
1281///
1282/// A const's identifier must be spelled identically at its declaration and at
1283/// every use site, across all backends. Each backend's value-identifier
1284/// transform (`to_camel_case` on JS/TS, `to_snake_case` on Python, `to_pascal_case`
1285/// on Go) mangles a `SCREAMING_SNAKE` const name *differently* at the use site
1286/// than the declaration emits it (def `FIZZ_NUM` vs use `fizzNUM` on JS/TS; def
1287/// `fizz_num` vs use `FIZZ_NUM` on Python; def `FIZZNUM` vs use `fizzNUM` on Go),
1288/// producing a "not defined"/`NameError` at the target. The backends consult this
1289/// registry at both the `ConstDecl` and `Identifier` arms to emit the const's
1290/// **verbatim declared name** in both places — `SCREAMING_SNAKE` is a valid
1291/// identifier in every target. A *pre-scan* (rather than recording consts as
1292/// their decls are emitted) is required because a use site may precede its
1293/// const's declaration in source order, and because a `use`d const can live in a
1294/// different module than its use. Mirrors [`collect_record_names`] /
1295/// [`collect_enum_variants`].
1296#[must_use]
1297pub fn collect_const_names(modules: &[(&AIRModule, &Path)]) -> std::collections::HashSet<String> {
1298    let mut names = std::collections::HashSet::new();
1299    for (module, _) in modules {
1300        let NodeKind::Module { items, .. } = &module.kind else {
1301            continue;
1302        };
1303        for item in items {
1304            if let NodeKind::ConstDecl { name, .. } = &item.kind {
1305                names.insert(name.name.clone());
1306            }
1307        }
1308    }
1309    names
1310}
1311
1312/// Compute the relative ES-module import specifier from the file that hosts
1313/// module `from_path` to the file that hosts module `to_path`, both keyed on
1314/// their **declared** dotted module-paths and laid out at the mirrored path
1315/// (`core.option` → `core/option.<ext>`). The entry module is always at the
1316/// build root as `main.<ext>` regardless of its declared path, so callers pass
1317/// the **empty string** as `from_path` for the entry file.
1318///
1319/// Returns a specifier that always begins with `./` or `../` and ends with the
1320/// target file extension (e.g. `./core/option.js`, `../helper.ts`) — ESM
1321/// requires a relative specifier to be explicitly relative and, for Node, to
1322/// carry the file extension.
1323#[must_use]
1324pub fn esm_relative_specifier(from_path: &str, to_path: &str, ext: &str) -> String {
1325    // The directory components of the *source* file (everything but the final
1326    // segment, which is the file stem). The entry file lives at the root.
1327    let from_dirs: Vec<&str> = if from_path.is_empty() {
1328        Vec::new()
1329    } else {
1330        let segs: Vec<&str> = from_path.split('.').collect();
1331        segs[..segs.len().saturating_sub(1)].to_vec()
1332    };
1333    let to_segs: Vec<&str> = to_path.split('.').filter(|s| !s.is_empty()).collect();
1334
1335    // Longest common directory prefix.
1336    let mut common = 0usize;
1337    while common < from_dirs.len()
1338        && common + 1 < to_segs.len()
1339        && from_dirs[common] == to_segs[common]
1340    {
1341        common += 1;
1342    }
1343
1344    let ups = from_dirs.len() - common;
1345    let mut spec = String::new();
1346    if ups == 0 {
1347        spec.push_str("./");
1348    } else {
1349        for _ in 0..ups {
1350            spec.push_str("../");
1351        }
1352    }
1353    let down: Vec<&str> = to_segs[common..].to_vec();
1354    spec.push_str(&down.join("/"));
1355    spec.push('.');
1356    spec.push_str(ext);
1357    spec
1358}
1359
1360// ─── Shared js/ts transpiled-test builder (§20.6.2) ──────────────────────────
1361
1362/// Lowers a single AIR expression to its target string, for the shared js/ts
1363/// test-file builder. Implemented by a thin adapter over each backend's private
1364/// emit context so [`js_ts_generate_tests`] can reuse the exact expression
1365/// lowering the runtime tree uses (function casing, enum/Optional reps, …).
1366pub trait JsTsExprEmitter {
1367    /// Render `node` as a target expression string.
1368    ///
1369    /// # Errors
1370    ///
1371    /// Propagates the backend's [`CodegenError`].
1372    fn expr_to_string(&mut self, node: &AIRNode) -> Result<String, CodegenError>;
1373}
1374
1375/// camelCase a Bock identifier the way the js/ts backends do for value names
1376/// (so an imported function name matches its `export function` form). Mirrors
1377/// `bock-codegen::js::to_camel_case` for the common `snake_case`/`PascalCase`
1378/// inputs `@test` bodies reference.
1379fn js_camel_case(s: &str) -> String {
1380    let mut out = String::with_capacity(s.len());
1381    let mut upper_next = false;
1382    for (i, c) in s.chars().enumerate() {
1383        if c == '_' {
1384            upper_next = true;
1385        } else if i == 0 {
1386            out.push(c.to_ascii_lowercase());
1387        } else if upper_next {
1388            out.push(c.to_ascii_uppercase());
1389            upper_next = false;
1390        } else {
1391            out.push(c);
1392        }
1393    }
1394    out
1395}
1396
1397/// Build the Vitest/Jest test file for the project's `@test` functions (S7),
1398/// shared by the JS and TS backends (identical apart from the file extension and
1399/// the concrete emit context, both injected by the caller).
1400///
1401/// - `framework`: `"jest"` → Jest globals; anything else → Vitest import.
1402/// - `file_ext`: the test file's own extension (`"js"` / `"ts"`).
1403/// - `import_ext`: the extension to use in *import specifiers*. For JS this is
1404///   `"js"`; for TS it is **also** `"js"` — TS/ESM specifiers reference the
1405///   emitted `.js`, which `tsc`'s `.js`→`.ts` resolution follows (and a strict
1406///   `tsc --noEmit` rejects a `.ts` specifier without `allowImportingTsExtensions`).
1407/// - `output_path`: maps a module to its emitted file path (entry → `main.<ext>`).
1408/// - `make_emitter`: builds the per-program expression emitter (with the same
1409///   cross-module registries the runtime tree uses).
1410///
1411/// Imports each module's public functions by name and lowers
1412/// `expect(actual).<assertion>(expected)` chains to the framework's matcher API.
1413/// Returns a single `bock.test.<file_ext>` file (no entry wiring).
1414///
1415/// # Errors
1416///
1417/// Propagates [`CodegenError`] from expression lowering.
1418pub fn js_ts_generate_tests<'a, F, M>(
1419    modules: &'a [(&'a AIRModule, &'a Path)],
1420    framework: &str,
1421    file_ext: &str,
1422    import_ext: &str,
1423    output_path: F,
1424    make_emitter: M,
1425) -> Result<TestArtifacts, CodegenError>
1426where
1427    F: Fn(&'a AIRModule, &'a Path, bool) -> PathBuf,
1428    M: for<'b> FnOnce(&'b [(&'b AIRModule, &'b Path)]) -> Box<dyn JsTsExprEmitter>,
1429{
1430    let reachable = reachable_modules(modules);
1431    let tests = collect_test_fns(&reachable);
1432    if tests.is_empty() {
1433        return Ok(TestArtifacts::default());
1434    }
1435    let entry_idx = reachable
1436        .iter()
1437        .position(|(m, _)| module_declares_main_fn(m))
1438        .unwrap_or(reachable.len().saturating_sub(1));
1439
1440    // Build the per-module import lines: each reachable module's public function
1441    // names, imported by their camelCased (emitted) name from the module's file.
1442    let mut import_lines: Vec<String> = Vec::new();
1443    for (i, (module, source_path)) in reachable.iter().enumerate() {
1444        // Public functions, imported by their camelCased (emitted) name.
1445        let mut import_names: Vec<String> = exportable_value_names(module)
1446            .into_iter()
1447            .filter(|e| e.is_fn)
1448            .map(|e| js_camel_case(&e.name))
1449            .collect();
1450        // Enum-variant constructors a `@test` body may reference *bare* as a
1451        // call argument (e.g. `apply_casing("x", Upper)` → emits `Casing_Upper`,
1452        // the frozen `{enum}_{variant}` const). The runtime tree exports these
1453        // (js trailing `export { … }`, ts `enum_variant_value_names`), but unlike
1454        // a function name they are emitted *verbatim* (no camelCase), so import
1455        // them under their exact value-name. Over-importing an unreferenced
1456        // variant is a harmless dead import; under-importing a referenced one is
1457        // a `ReferenceError` at test runtime — so mirror the non-test path and
1458        // include every public variant value-name.
1459        import_names.extend(enum_variant_value_names(module));
1460        if import_names.is_empty() {
1461            continue;
1462        }
1463        let spec = if i == entry_idx {
1464            // The entry file is `main.<file_ext>`; the specifier references the
1465            // emitted/served module by its import extension (`main.js`).
1466            let stem = output_path(module, source_path, true)
1467                .with_extension(import_ext)
1468                .display()
1469                .to_string();
1470            format!("./{stem}")
1471        } else {
1472            let to_path = module_path_string(module).unwrap_or_default();
1473            esm_relative_specifier("", &to_path, import_ext)
1474        };
1475        // Normalize Windows-style separators a Display might emit.
1476        let spec = spec.replace('\\', "/");
1477        import_lines.push(format!(
1478            "import {{ {} }} from \"{spec}\";",
1479            import_names.join(", ")
1480        ));
1481    }
1482    import_lines.sort_unstable();
1483    import_lines.dedup();
1484
1485    let is_jest = framework == "jest";
1486    let mut out = String::new();
1487    if is_jest {
1488        out.push_str("// Jest provides describe/it/expect as globals.\n");
1489    } else {
1490        out.push_str("import { describe, it, expect } from \"vitest\";\n");
1491    }
1492    for line in &import_lines {
1493        out.push_str(line);
1494        out.push('\n');
1495    }
1496    out.push('\n');
1497    out.push_str("describe(\"bock tests\", () => {\n");
1498
1499    let mut emitter = make_emitter(&reachable);
1500    for (test_fn, _module_path) in &tests {
1501        let NodeKind::FnDecl { name, body, .. } = &test_fn.kind else {
1502            continue;
1503        };
1504        out.push_str(&format!("  it(\"{}\", () => {{\n", name.name));
1505        emit_js_test_body(body, emitter.as_mut(), &mut out)?;
1506        out.push_str("  });\n");
1507    }
1508    out.push_str("});\n");
1509
1510    Ok(TestArtifacts {
1511        files: vec![OutputFile {
1512            path: PathBuf::from(format!("bock.test.{file_ext}")),
1513            content: out,
1514            source_map: None,
1515        }],
1516        entry_append: None,
1517    })
1518}
1519
1520/// Whether a JS/TS `actual` expression must be parenthesized before a `._tag`
1521/// member access, i.e. whether its emitted form binds looser than member access.
1522///
1523/// Atoms and postfix forms — identifiers, literals, calls, method calls, field
1524/// accesses, and index reads — bind at least as tightly as member access, so a
1525/// following `._tag` needs no wrapping (and Prettier would strip any redundant
1526/// parens). Everything else (binary/conditional/await/etc.) is wrapped so the
1527/// generated test file is both correct and `prettier --check`-clean.
1528fn js_actual_needs_member_parens(actual: &AIRNode) -> bool {
1529    !matches!(
1530        &actual.kind,
1531        NodeKind::Identifier { .. }
1532            | NodeKind::Literal { .. }
1533            | NodeKind::Call { .. }
1534            | NodeKind::MethodCall { .. }
1535            | NodeKind::FieldAccess { .. }
1536            | NodeKind::Index { .. }
1537    )
1538}
1539
1540/// Emit the statements of a js/ts `@test` body into `out`, lowering `expect(...)`
1541/// assertion chains to the Vitest/Jest matcher API and dropping any non-assertion
1542/// statement that the matcher set does not cover into a `let` (handled by the
1543/// expression emitter). Each line is indented four spaces (inside `it(... => {`).
1544fn emit_js_test_body(
1545    body: &AIRNode,
1546    emitter: &mut dyn JsTsExprEmitter,
1547    out: &mut String,
1548) -> Result<(), CodegenError> {
1549    let stmts: Vec<&AIRNode> = match &body.kind {
1550        NodeKind::Block { stmts, tail } => stmts.iter().chain(tail.as_deref()).collect(),
1551        _ => vec![body],
1552    };
1553    for stmt in stmts {
1554        if let Some((assertion, actual, expected)) = classify_assertion(stmt) {
1555            let a = emitter.expr_to_string(actual)?;
1556            // For tag-discriminating predicates we read `<actual>._tag`. Wrap the
1557            // actual in parens only when its expression form would otherwise bind
1558            // looser than the member access (so the emitted `.test` file stays
1559            // `prettier --check`-clean: prettier strips redundant parens around a
1560            // call/identifier/member/index, §20.6.2 codegen-formatter agreement).
1561            let tagged = |a: &str| -> String {
1562                if js_actual_needs_member_parens(actual) {
1563                    format!("({a})._tag")
1564                } else {
1565                    format!("{a}._tag")
1566                }
1567            };
1568            let line = match assertion {
1569                TestAssertion::Equal => {
1570                    let e = match expected {
1571                        Some(e) => emitter.expr_to_string(e)?,
1572                        None => "undefined".to_string(),
1573                    };
1574                    format!("expect({a}).toEqual({e});")
1575                }
1576                TestAssertion::BeTrue => format!("expect({a}).toBe(true);"),
1577                TestAssertion::BeFalse => format!("expect({a}).toBe(false);"),
1578                TestAssertion::BeSome => format!("expect({}).toBe(\"Some\");", tagged(&a)),
1579                TestAssertion::BeNone => format!("expect({}).toBe(\"None\");", tagged(&a)),
1580                TestAssertion::BeOk => format!("expect({}).toBe(\"Ok\");", tagged(&a)),
1581                TestAssertion::BeErr => format!("expect({}).toBe(\"Err\");", tagged(&a)),
1582            };
1583            out.push_str(&format!("    {line}\n"));
1584        } else if let NodeKind::LetBinding { pattern, value, .. } = &stmt.kind {
1585            // A `let` in a test body (e.g. building the value under assertion):
1586            // lower it to a `const` so the following assertions can reference it.
1587            let name = match &pattern.kind {
1588                NodeKind::BindPat { name, .. } => js_camel_case(&name.name),
1589                _ => continue,
1590            };
1591            let v = emitter.expr_to_string(value)?;
1592            out.push_str(&format!("    const {name} = {v};\n"));
1593        } else {
1594            // Any other statement is emitted as an expression statement.
1595            let s = emitter.expr_to_string(stmt)?;
1596            out.push_str(&format!("    {s};\n"));
1597        }
1598    }
1599    Ok(())
1600}
1601
1602// ─── Native-module emission helpers (rust/go) ───────────────────────────────
1603//
1604// The Rust and Go backends emit a per-module **native module tree** (spec
1605// §20.6.1; DQ19 resolved): each reachable module → its own target file,
1606// cross-module references resolved with the target's native module system
1607// (Rust `use crate::<m>::<x>;`; Go same-package symbol visibility). These
1608// helpers are shared because the analysis — which public symbols a module
1609// declares, and which symbols declared elsewhere a module references but never
1610// `use`s explicitly — is purely over the AIR and identical for both targets.
1611//
1612// Unlike the ESM helpers, these carry no per-symbol declaration *kind*: Rust
1613// and Go re-export every public top-level declaration uniformly (Rust via a
1614// crate-path `use`; Go via the shared package scope), so a flat name→module
1615// map suffices.
1616
1617/// Build a map from every **public top-level symbol name** declared across
1618/// `modules` to the dotted declared module-path that declares it (e.g.
1619/// `Iterable` → `core.iter`). Covers functions, records, enums (the **type**
1620/// name), traits, classes, effects, type aliases, and consts.
1621///
1622/// The per-module native-module path needs this for **implicit imports**: a
1623/// §18.2-prelude trait used as an `impl` base (`impl Iterable for Bag`, with
1624/// `Iterable` auto-imported per §18.2) is referenced without an explicit `use`.
1625/// Emitting one file per module means the consuming `main.rs` must
1626/// `use crate::core::iter::Iterable;` even though `Iterable` never appears in
1627/// an explicit `use`. (Go keeps one package across files, so a same-package
1628/// symbol is visible without an import; Go uses this map only to know which
1629/// names are cross-module, not to emit anything.) The map lets the backend add
1630/// exactly those Rust `use`s for names a module references but neither declares
1631/// locally nor imports explicitly.
1632///
1633/// Enum **variants** are intentionally *not* recorded as separate symbols:
1634/// Rust accesses a variant through its type (`Ordering::Less`), so importing
1635/// the enum type suffices, and a synthetic `Ordering_Less` is not a real Rust
1636/// item to `use`. Go (same-package) needs no imports at all.
1637///
1638/// The first declarer wins for a name declared in several modules (the
1639/// dependency order `modules` arrives in is deterministic — see
1640/// [`reachable_modules`]).
1641#[must_use]
1642pub fn collect_public_symbol_modules(modules: &[(&AIRModule, &Path)]) -> HashMap<String, String> {
1643    let mut map: HashMap<String, String> = HashMap::new();
1644    for (module, _) in modules {
1645        let Some(module_path) = module_path_string(module) else {
1646            continue;
1647        };
1648        let NodeKind::Module { items, .. } = &module.kind else {
1649            continue;
1650        };
1651        for item in items {
1652            let mut record = |name: &str| {
1653                map.entry(name.to_string())
1654                    .or_insert_with(|| module_path.clone());
1655            };
1656            match &item.kind {
1657                NodeKind::FnDecl {
1658                    visibility, name, ..
1659                }
1660                | NodeKind::RecordDecl {
1661                    visibility, name, ..
1662                }
1663                | NodeKind::TraitDecl {
1664                    visibility, name, ..
1665                }
1666                | NodeKind::ClassDecl {
1667                    visibility, name, ..
1668                }
1669                | NodeKind::EffectDecl {
1670                    visibility, name, ..
1671                }
1672                | NodeKind::TypeAlias {
1673                    visibility, name, ..
1674                }
1675                | NodeKind::ConstDecl {
1676                    visibility, name, ..
1677                }
1678                | NodeKind::EnumDecl {
1679                    visibility, name, ..
1680                } => {
1681                    if matches!(visibility, bock_ast::Visibility::Public) {
1682                        record(&name.name);
1683                    }
1684                }
1685                _ => {}
1686            }
1687        }
1688    }
1689    map
1690}
1691
1692/// Compute the implicit cross-module imports for `module`: public symbols
1693/// declared in *other* reachable modules that `module` references but neither
1694/// declares locally nor imports explicitly. Returns `(module_path, name)`
1695/// pairs.
1696///
1697/// "References" is a conservative structural scan of the module's debug
1698/// rendering for the symbol name as a quoted identifier token (mirroring the
1699/// Python / ESM equivalents). It can only *over*-import a name the program does
1700/// not really use — harmless on Rust (a dead `use` is `allow`-ed by the
1701/// crate-level `#![allow(unused_imports)]`) — never *under*-import, so it
1702/// cannot reintroduce the unresolved reference it exists to fix.
1703#[must_use]
1704pub fn implicit_imports_for(
1705    module: &AIRModule,
1706    public_symbols: &HashMap<String, String>,
1707    own_path: &str,
1708) -> Vec<(String, String)> {
1709    let local = locally_declared_names(module);
1710    let explicit = explicitly_imported_names(module);
1711    let rendered = format!("{module:?}");
1712    let mut out: Vec<(String, String)> = Vec::new();
1713    for (name, declaring_module) in public_symbols {
1714        if declaring_module == own_path || local.contains(name) || explicit.contains(name) {
1715            continue;
1716        }
1717        if rendered.contains(&format!("\"{name}\"")) {
1718            out.push((declaring_module.clone(), name.clone()));
1719        }
1720    }
1721    out
1722}
1723
1724/// Map a module's *declared* dotted path (`core.option`) to its **relative
1725/// output path** in a per-module tree, with the target's file extension
1726/// (`core/option.<ext>`). The entry module is laid out separately (always
1727/// `main.<ext>` at a stable location), so callers pass non-entry modules here;
1728/// a module with no declared path falls back to its source-mirrored path.
1729#[must_use]
1730pub fn module_tree_relpath(
1731    module: &AIRModule,
1732    source_path: &Path,
1733    target: &TargetProfile,
1734) -> PathBuf {
1735    match module_path_string(module) {
1736        Some(path) if !path.is_empty() => {
1737            let rel: PathBuf = path.split('.').collect();
1738            rel.with_extension(&target.conventions.file_extension)
1739        }
1740        _ => derive_output_path(source_path, target),
1741    }
1742}
1743
1744// ─── Statement-aware match helpers ──────────────────────────────────────────
1745//
1746// Some Bock `match` arms have *statement* bodies — `break`, `continue`,
1747// `return`, or an assignment. These have no value, so an arm carrying one
1748// cannot be lowered to an expression form (a ternary, an IIFE, or a value
1749// `match` arm). Backends that emit `match` as an expression must instead emit
1750// such a match in **statement position** (a `switch` / if-chain that yields no
1751// value). The predicates below let every backend agree on what counts as a
1752// statement arm without duplicating the classification.
1753
1754/// Returns true if `node` is a statement-like AIR node — one that performs
1755/// control flow or mutation and yields no usable value in expression position.
1756///
1757/// These are exactly the node kinds a target's expression form (ternary, IIFE,
1758/// value-`match` arm) cannot host: `break`, `continue`, `return`, assignment,
1759/// and an `if` that yields no value.
1760///
1761/// An `if` is statement-like — and so must be emitted in statement position
1762/// rather than lowered to a ternary / IIFE — exactly when it produces no value:
1763///
1764/// - it has **no `else` branch** (a value-less `if` cannot be an expression), or
1765/// - it has an `else` branch but **both branches are statement bodies** (e.g.
1766///   `if (c) { return a } else { return b }`), so neither yields a value.
1767///
1768/// A value `if/else` (e.g. `let x = if (c) { 1 } else { 2 }`) always has an
1769/// `else` whose branches end in an *expression* tail, so
1770/// [`arm_body_is_statement`] returns `false` for them and the `if` stays an
1771/// expression. `if let … = expr` returning a value is likewise unaffected: with
1772/// an expression-tail `else` it is not classified here.
1773#[must_use]
1774pub fn node_is_statement(node: &AIRNode) -> bool {
1775    if let NodeKind::If {
1776        then_block,
1777        else_block,
1778        ..
1779    } = &node.kind
1780    {
1781        return match else_block {
1782            // No `else`: the `if` yields no value, so it is a statement.
1783            None => true,
1784            // With an `else`, the `if` is a statement only when *both* branches
1785            // are statement bodies (neither yields a usable value). A value
1786            // `if/else` has expression-tail branches and falls through to
1787            // `false`, keeping it an expression.
1788            Some(else_b) => arm_body_is_statement(then_block) && arm_body_is_statement(else_b),
1789        };
1790    }
1791    matches!(
1792        node.kind,
1793        NodeKind::Break { .. }
1794            | NodeKind::Continue
1795            | NodeKind::Return { .. }
1796            | NodeKind::Assign { .. }
1797    )
1798}
1799
1800/// Returns true if a `match`-arm body is a statement body — either the body is
1801/// itself a statement node, or it is a `{ ... }` block whose tail is a
1802/// statement node (or which has no tail at all, e.g. a block ending in a
1803/// statement with no value).
1804#[must_use]
1805pub fn arm_body_is_statement(body: &AIRNode) -> bool {
1806    if node_is_statement(body) {
1807        return true;
1808    }
1809    if let NodeKind::Block { tail, .. } = &body.kind {
1810        return match tail {
1811            Some(t) => node_is_statement(t),
1812            // A block with no tail expression yields no value.
1813            None => true,
1814        };
1815    }
1816    false
1817}
1818
1819/// Returns true if any arm of a `match` carries a statement body (see
1820/// [`arm_body_is_statement`]). When true, backends without a statement-admitting
1821/// expression form (Go, Python, JS, TS) must emit the `match` in statement
1822/// position rather than as an expression.
1823#[must_use]
1824pub fn match_has_statement_arm(arms: &[AIRNode]) -> bool {
1825    arms.iter().any(
1826        |arm| matches!(&arm.kind, NodeKind::MatchArm { body, .. } if arm_body_is_statement(body)),
1827    )
1828}
1829
1830/// Returns true if a `match`'s arms require the *if/else-if-chain* lowering
1831/// (JS, TS, Go) rather than the value/tag `switch` fast-path.
1832///
1833/// The value/tag `switch` those backends emit can express only a flat dispatch
1834/// on a single discriminant (a literal value, or an ADT `._tag`). It
1835/// **structurally cannot** express:
1836///
1837/// - **guards** — a failed guard must fall through to the *next arm*, but a
1838///   `break` inside a `switch` exits the whole `switch`;
1839/// - **or-patterns** (`1 | 2 | 3 => …`) — one arm, several discriminants;
1840/// - **tuple patterns** (`(a, b) => …`) — no single discriminant;
1841/// - **nested constructor / record patterns** (`Some(Ok(v)) => …`) — the inner
1842///   pattern must itself be tested and its bindings extracted recursively.
1843///
1844/// When any arm needs one of these, the backend lowers the *whole* match to an
1845/// `if (<test> && <guard?>) { <binds>; <body> } else if …` chain (see each
1846/// backend's `emit_match_ifchain`). Otherwise the existing `switch` fast-path is
1847/// kept, so the proven Optional / Result / user-enum / value lowerings do not
1848/// regress.
1849///
1850/// A constructor / record field counts as "nested" only when its sub-pattern is
1851/// itself refutable or structured — another constructor, record, tuple,
1852/// or-pattern, or literal. A bare bind (`Some(x)`) or wildcard (`Some(_)`) field
1853/// is *not* nested: the flat `switch` already extracts those correctly.
1854#[must_use]
1855pub fn match_needs_ifchain(arms: &[AIRNode]) -> bool {
1856    arms.iter().any(|arm| {
1857        let NodeKind::MatchArm { pattern, guard, .. } = &arm.kind else {
1858            return false;
1859        };
1860        guard.is_some() || pattern_needs_ifchain(pattern)
1861    })
1862}
1863
1864/// True if `pat` (a pattern node) can only be lowered via the if/else-if chain
1865/// — i.e. it is an or-pattern, a tuple pattern, a **list** pattern (`[]`,
1866/// `[x]`, `[first, ..rest]`), a **range** pattern (`1..10`, `1..=10`), or a
1867/// constructor/record pattern carrying a nested structured sub-pattern. See
1868/// [`match_needs_ifchain`].
1869///
1870/// List and range patterns join the always-if-chain set because neither has a
1871/// single `switch` discriminant: a list match needs a length test plus
1872/// positional element / `..rest` binds, and a range match is a relational
1873/// `lo <= x < hi` test. Routing them uniformly through the if-chain on every
1874/// backend that consults this recogniser (ts, go) lets one shared `pattern_test`
1875/// / `pattern_binds` per backend handle them, instead of each backend needing a
1876/// bespoke detour (cf. js's former local `match_has_unswitchable_pattern`).
1877fn pattern_needs_ifchain(pat: &AIRNode) -> bool {
1878    match &pat.kind {
1879        NodeKind::OrPat { .. }
1880        | NodeKind::TuplePat { .. }
1881        | NodeKind::ListPat { .. }
1882        | NodeKind::RangePat { .. } => true,
1883        NodeKind::ConstructorPat { fields, .. } => fields.iter().any(field_is_structured),
1884        NodeKind::RecordPat { fields, .. } => fields
1885            .iter()
1886            .filter_map(|f| f.pattern.as_deref())
1887            .any(field_is_structured),
1888        _ => false,
1889    }
1890}
1891
1892/// True if a constructor / record *field* sub-pattern is structured (anything
1893/// other than a bare bind or wildcard), so the enclosing match must take the
1894/// if-chain path to test and bind it recursively.
1895fn field_is_structured(pat: &AIRNode) -> bool {
1896    !matches!(&pat.kind, NodeKind::WildcardPat | NodeKind::BindPat { .. })
1897}
1898
1899// ─── Shared temp-hoist desugar for value-position diverging control flow ───────
1900//
1901// A control-flow expression used in *value position* (a `let` initialiser, a
1902// `return` value, a call argument, an assignment RHS) whose arms **diverge** —
1903// one arm yields a value while another exits via `return`/`break`/`continue`/a
1904// diverging intrinsic (`todo()`/`unreachable()`) — has no clean per-backend
1905// expression form. A ternary / value-IIFE cannot host a `return` arm (the IIFE
1906// would capture it), and every backend's value emitter previously fell through
1907// to `/* unsupported */` (rust/js/go) or `# unsupported` (py) for the diverging
1908// tail. The chat-protocol example is the canonical case:
1909//
1910//     let msg_type = if (raw.starts_with("TEXT|")) { Text }
1911//                    else { … else { return Err("unknown") } }
1912//
1913// [`hoist_value_cf`] rewrites each such value position into a self-contained
1914// block that every backend already emits correctly through its existing
1915// statement / `let` / assignment / IIFE machinery:
1916//
1917//     {
1918//       let mut __bock_cf_N            // declared, no initialiser (DeclOnly)
1919//       <CF in statement position>    // value tails → `__bock_cf_N = v`,
1920//                                      // diverging tails kept verbatim
1921//       __bock_cf_N                    // block tail: read the temp
1922//     }
1923//
1924// The control-flow node is kept intact (only relocated to statement position
1925// with assignment tails), so a backend's structural type inference — e.g. Go's
1926// `infer_branchy_expr_type` — still fires on it to type the `var` declaration.
1927
1928/// Metadata key marking a synthesised temp [`NodeKind::LetBinding`] as
1929/// *declare-only*: it introduces the binding with no initialiser. The shared
1930/// [`hoist_value_cf`] desugar emits these; every backend's `let` emitter checks
1931/// this key and emits the bare declaration (`let x;` / `var x T` / Rust deferred
1932/// `let mut x;`) rather than a `= <value>` initialiser. The carried
1933/// [`bock_air::stubs::Value::Bool`] is always `true`.
1934pub const DECL_ONLY_META: &str = "bock_decl_only";
1935
1936/// Internal metadata key marking a synthesised `{ temp = v; break }` block (from
1937/// a value-`loop` `break <v>` rewrite) as *splice-flattenable*: the enclosing
1938/// statement list inlines its statements rather than nesting it, so no `{ … }`
1939/// block remains in statement position (which a backend would treat as a
1940/// value-IIFE). Never emitted — consumed entirely within [`hoist_value_cf`].
1941const SPLICE_BLOCK_META: &str = "bock_splice_block";
1942
1943/// True when a value-position node is a control-flow construct whose branches
1944/// **diverge** — at least one branch yields a value AND at least one branch
1945/// exits via `return`/`break`/`continue`/a diverging intrinsic. These are the
1946/// nodes [`hoist_value_cf`] rewrites; a construct where *every* branch yields a
1947/// value already lowers fine via the existing expression paths and is left
1948/// untouched (so value `if`/`match`/`loop` codegen does not regress).
1949#[must_use]
1950pub fn value_cf_diverges(node: &AIRNode) -> bool {
1951    match &node.kind {
1952        // A `loop` delivers a value only through a `break <v>`. Hoist it only
1953        // when it carries at least one value-bearing `break` — a value-less loop
1954        // (whose result is unit / discarded) has a clean statement form already
1955        // and must NOT be hoisted (that would leave the temp uninitialised).
1956        NodeKind::Loop { body } => loop_has_value_break(body),
1957        NodeKind::If {
1958            then_block,
1959            else_block,
1960            let_pattern: None,
1961            ..
1962        } => {
1963            let branches = [Some(then_block.as_ref()), else_block.as_deref()];
1964            let any_diverges = branches
1965                .iter()
1966                .flatten()
1967                .any(|b| branch_diverges_or_nested(b));
1968            let any_value = branches.iter().flatten().any(|b| branch_yields_value(b));
1969            any_diverges && any_value
1970        }
1971        NodeKind::Match { arms, .. } => {
1972            let bodies: Vec<&AIRNode> = arms
1973                .iter()
1974                .filter_map(|a| match &a.kind {
1975                    NodeKind::MatchArm { body, .. } => Some(body.as_ref()),
1976                    _ => None,
1977                })
1978                .collect();
1979            let any_diverges = bodies.iter().any(|b| branch_diverges_or_nested(b));
1980            let any_value = bodies.iter().any(|b| branch_yields_value(b));
1981            any_diverges && any_value
1982        }
1983        NodeKind::Block { tail, .. } => tail.as_deref().is_some_and(value_cf_diverges),
1984        _ => false,
1985    }
1986}
1987
1988/// True when a branch / arm body used in value position diverges at its tail
1989/// (a `return`/`break`/`continue`/diverging-intrinsic), or is itself a nested
1990/// diverging value-CF (an `if`/`match`/`loop` chain whose own branches diverge).
1991fn branch_diverges_or_nested(node: &AIRNode) -> bool {
1992    branch_tail_diverges(node) || value_cf_diverges(node)
1993}
1994
1995/// True when the *value tail* of `node` diverges — it produces no usable value
1996/// on any path. That is: a `return`/`break`/`continue` node, a diverging
1997/// intrinsic call (`todo()`/`unreachable()`), the `Unreachable` node, a block
1998/// whose tail/last-statement diverges, **or** an `if`/`match` *every* one of
1999/// whose branches diverges (e.g. `match s { Ok => return …; Err => return … }` —
2000/// no arm yields a value, so the construct yields none and must not be treated
2001/// as a value-bearing arm of an enclosing hoist).
2002fn branch_tail_diverges(node: &AIRNode) -> bool {
2003    match &node.kind {
2004        NodeKind::Return { .. } | NodeKind::Break { .. } | NodeKind::Continue => true,
2005        NodeKind::Unreachable => true,
2006        NodeKind::Call { .. } => call_is_diverging_intrinsic(node),
2007        NodeKind::Block { stmts, tail } => match tail {
2008            Some(t) => branch_tail_diverges(t),
2009            None => stmts.last().is_some_and(branch_tail_diverges),
2010        },
2011        // An `if` with no `else` can fall through (yields a value path), so it
2012        // does not fully diverge; with an `else`, it diverges iff both branches
2013        // do.
2014        NodeKind::If {
2015            then_block,
2016            else_block: Some(else_b),
2017            ..
2018        } => branch_tail_diverges(then_block) && branch_tail_diverges(else_b),
2019        NodeKind::Match { arms, .. } => {
2020            let bodies: Vec<&AIRNode> = arms
2021                .iter()
2022                .filter_map(|a| match &a.kind {
2023                    NodeKind::MatchArm { body, .. } => Some(body.as_ref()),
2024                    _ => None,
2025                })
2026                .collect();
2027            !bodies.is_empty() && bodies.iter().all(|b| branch_tail_diverges(b))
2028        }
2029        _ => false,
2030    }
2031}
2032
2033/// True when a branch / arm body used in value position yields a usable value —
2034/// its tail is neither a diverging statement nor (recursively) a diverging
2035/// nested CF on *every* path. A branch that is itself a diverging value-CF still
2036/// yields a value (its value arm does), so this returns `true` for it.
2037fn branch_yields_value(node: &AIRNode) -> bool {
2038    if value_cf_diverges(node) {
2039        return true;
2040    }
2041    !branch_tail_diverges(node)
2042}
2043
2044/// True when a `loop` body contains a value-carrying `break <v>` (so the loop
2045/// produces a value and, in value position, needs the temp-hoist). Does not
2046/// descend into nested loops — their `break`s target themselves — or into
2047/// functions/lambdas.
2048fn loop_has_value_break(body: &AIRNode) -> bool {
2049    match &body.kind {
2050        NodeKind::Break { value } => value.is_some(),
2051        NodeKind::Loop { .. }
2052        | NodeKind::While { .. }
2053        | NodeKind::For { .. }
2054        | NodeKind::FnDecl { .. }
2055        | NodeKind::Lambda { .. } => false,
2056        NodeKind::Block { stmts, tail } => {
2057            stmts.iter().any(loop_has_value_break)
2058                || tail.as_deref().is_some_and(loop_has_value_break)
2059        }
2060        NodeKind::If {
2061            then_block,
2062            else_block,
2063            ..
2064        } => {
2065            loop_has_value_break(then_block)
2066                || else_block.as_deref().is_some_and(loop_has_value_break)
2067        }
2068        NodeKind::Match { arms, .. } => arms.iter().any(
2069            |a| matches!(&a.kind, NodeKind::MatchArm { body, .. } if loop_has_value_break(body)),
2070        ),
2071        NodeKind::Guard { else_block, .. } => loop_has_value_break(else_block),
2072        _ => false,
2073    }
2074}
2075
2076/// True when `node` is a call to a diverging intrinsic (`todo()` /
2077/// `unreachable()`), matched by callee identifier name. Mirrors the per-backend
2078/// `call_is_diverging` recognisers so the shared desugar agrees with them.
2079fn call_is_diverging_intrinsic(node: &AIRNode) -> bool {
2080    let NodeKind::Call { callee, .. } = &node.kind else {
2081        return false;
2082    };
2083    matches!(
2084        &callee.kind,
2085        NodeKind::Identifier { name } if name.name == "todo" || name.name == "unreachable"
2086    )
2087}
2088
2089/// Run the shared temp-hoist desugar over a fully-lowered, type-checked AIR
2090/// module, returning the rewritten module. Idempotent on trees with no
2091/// value-position diverging control flow (returns an equivalent tree).
2092///
2093/// This is a **codegen pre-pass**: it runs after type-checking and the
2094/// ownership/effect/capability analyses (so they never see the synthesised
2095/// declare-only bindings) and before every backend emits, making the rewrite
2096/// shared once across all five targets. It deliberately lives here rather than
2097/// in `bock-air`'s S-AIR lowering because the synthesised temp's type is only
2098/// derivable at codegen (e.g. Go infers it structurally from the relocated
2099/// control-flow node), and to keep the interpreter and semantic analyses out of
2100/// the blast radius.
2101#[must_use]
2102/// Codegen pre-pass: rewrite every derived-blanket `recv.into()` call in
2103/// `module` into the resolvable associated call `Target.from(recv)`.
2104///
2105/// A derived blanket `Into[Target] for Source` is the bodyless reverse impl the
2106/// compiler synthesizes from a user `impl From[Source] for Target`. It is
2107/// *unexecutable* if emitted as an ordinary method call — the AIR lowers
2108/// `recv.into()` to `Call(FieldAccess(recv, "into"), [recv])`, which dispatches
2109/// to a non-existent `into` method on every compiled target (JS `recv.into is
2110/// not a function`, etc.). The executable form is `Target.from(recv)` — the
2111/// `from` associated function each backend emits for the `From` impl.
2112///
2113/// Run **after** type-checking (in each backend's `generate_*`), so a `.into()`
2114/// that reaches this pass has already resolved to a valid `Into` target: an
2115/// unrelated-target `.into()` was rejected at check time (`E4012`) and never
2116/// arrives here. The pass fires only when the module declares exactly one
2117/// distinct `From` target, making the rewrite's target unambiguous (the
2118/// documented v1 single-conversion scope). With zero or several `From` impls it
2119/// is a no-op, leaving the call to its existing lowering. The rewritten `Call`
2120/// is stamped [`bock_air::lower::ASSOC_CALL_META_KEY`] so the backends emit the
2121/// static / free-function `from` call.
2122pub fn lower_blanket_into(module: AIRNode) -> AIRNode {
2123    let targets = collect_from_targets(&module);
2124    // Unambiguous only with exactly one distinct `From` target.
2125    let [target] = targets.as_slice() else {
2126        return module;
2127    };
2128    let mut rewriter = BlanketIntoRewriter {
2129        next_id: max_node_id(&module) + 1,
2130        target: target.clone(),
2131    };
2132    rewriter.rewrite(module)
2133}
2134
2135/// The base name of every `impl From[Source] for Target`'s target type, deduped.
2136fn collect_from_targets(module: &AIRNode) -> Vec<String> {
2137    let NodeKind::Module { items, .. } = &module.kind else {
2138        return Vec::new();
2139    };
2140    let mut targets: Vec<String> = items
2141        .iter()
2142        .filter_map(|item| {
2143            let NodeKind::ImplBlock {
2144                trait_path: Some(tp),
2145                target,
2146                ..
2147            } = &item.kind
2148            else {
2149                return None;
2150            };
2151            if tp.segments.last().map(|s| s.name.as_str()) != Some("From") {
2152                return None;
2153            }
2154            type_node_base_name(target)
2155        })
2156        .collect();
2157    targets.sort();
2158    targets.dedup();
2159    targets
2160}
2161
2162/// The base name of a `TypeNamed` AIR node (`Foot` from `Foot` / `Foot[T]`).
2163fn type_node_base_name(ty: &AIRNode) -> Option<String> {
2164    if let NodeKind::TypeNamed { path, .. } = &ty.kind {
2165        path.segments.last().map(|s| s.name.clone())
2166    } else {
2167        None
2168    }
2169}
2170
2171struct BlanketIntoRewriter {
2172    next_id: bock_air::NodeId,
2173    target: String,
2174}
2175
2176impl BlanketIntoRewriter {
2177    fn fresh_id(&mut self) -> bock_air::NodeId {
2178        let id = self.next_id;
2179        self.next_id += 1;
2180        id
2181    }
2182
2183    fn rewrite(&mut self, mut node: AIRNode) -> AIRNode {
2184        // Rewrite a desugared `recv.into()` call: `Call(FieldAccess(recv,
2185        // "into"), [recv])` → `Call(FieldAccess(Identifier(Target), "from"),
2186        // [recv])` stamped as an associated call. `desugared_self_call` confirms
2187        // the receiver is re-passed as the lone `self` arg (so this is the
2188        // blanket `.into()`, never an associated `Type.into()` or a 1-arg method
2189        // named `into`).
2190        if let NodeKind::Call { callee, args, .. } = &node.kind {
2191            if let Some((recv, method, rest)) = desugared_self_call(callee, args) {
2192                if method.name == "into" && rest.is_empty() {
2193                    let span = node.span;
2194                    let recv = recv.clone();
2195                    let target_id =
2196                        AIRNode::new(self.fresh_id(), span, ident_node(&self.target, span));
2197                    let field = AIRNode::new(
2198                        self.fresh_id(),
2199                        span,
2200                        NodeKind::FieldAccess {
2201                            object: Box::new(target_id),
2202                            field: bock_ast::Ident {
2203                                name: "from".to_string(),
2204                                span,
2205                            },
2206                        },
2207                    );
2208                    let mut call = AIRNode::new(
2209                        self.fresh_id(),
2210                        span,
2211                        NodeKind::Call {
2212                            callee: Box::new(field),
2213                            args: vec![AirArg {
2214                                label: None,
2215                                value: self.rewrite(recv),
2216                            }],
2217                            type_args: vec![],
2218                        },
2219                    );
2220                    call.metadata.insert(
2221                        bock_air::lower::ASSOC_CALL_META_KEY.to_string(),
2222                        bock_air::Value::Bool(true),
2223                    );
2224                    return call;
2225                }
2226            }
2227        }
2228        node.kind = self.rewrite_kind(node.kind);
2229        node
2230    }
2231
2232    fn rewrite_box(&mut self, node: Box<AIRNode>) -> Box<AIRNode> {
2233        Box::new(self.rewrite(*node))
2234    }
2235
2236    fn rewrite_vec(&mut self, nodes: Vec<AIRNode>) -> Vec<AIRNode> {
2237        nodes.into_iter().map(|n| self.rewrite(n)).collect()
2238    }
2239
2240    fn rewrite_args(&mut self, args: Vec<AirArg>) -> Vec<AirArg> {
2241        args.into_iter()
2242            .map(|a| AirArg {
2243                label: a.label,
2244                value: self.rewrite(a.value),
2245            })
2246            .collect()
2247    }
2248
2249    /// Recurse into every child that can contain an expression. Mirrors the
2250    /// structure [`ValueCfHoister`] walks; any arm not listed has no nested
2251    /// expression a `.into()` could hide in.
2252    fn rewrite_kind(&mut self, kind: NodeKind) -> NodeKind {
2253        match kind {
2254            NodeKind::Module {
2255                path,
2256                annotations,
2257                imports,
2258                items,
2259            } => NodeKind::Module {
2260                path,
2261                annotations,
2262                imports,
2263                items: self.rewrite_vec(items),
2264            },
2265            NodeKind::FnDecl {
2266                annotations,
2267                visibility,
2268                is_async,
2269                name,
2270                generic_params,
2271                params,
2272                return_type,
2273                effect_clause,
2274                where_clause,
2275                body,
2276            } => NodeKind::FnDecl {
2277                annotations,
2278                visibility,
2279                is_async,
2280                name,
2281                generic_params,
2282                params,
2283                return_type,
2284                effect_clause,
2285                where_clause,
2286                body: self.rewrite_box(body),
2287            },
2288            NodeKind::ImplBlock {
2289                annotations,
2290                generic_params,
2291                trait_path,
2292                trait_args,
2293                target,
2294                where_clause,
2295                methods,
2296            } => NodeKind::ImplBlock {
2297                annotations,
2298                generic_params,
2299                trait_path,
2300                trait_args,
2301                target,
2302                where_clause,
2303                methods: self.rewrite_vec(methods),
2304            },
2305            NodeKind::ClassDecl {
2306                annotations,
2307                visibility,
2308                name,
2309                generic_params,
2310                base,
2311                traits,
2312                fields,
2313                methods,
2314            } => NodeKind::ClassDecl {
2315                annotations,
2316                visibility,
2317                name,
2318                generic_params,
2319                base,
2320                traits,
2321                fields,
2322                methods: self.rewrite_vec(methods),
2323            },
2324            NodeKind::Block { stmts, tail } => NodeKind::Block {
2325                stmts: self.rewrite_vec(stmts),
2326                tail: tail.map(|t| self.rewrite_box(t)),
2327            },
2328            NodeKind::LetBinding {
2329                pattern,
2330                ty,
2331                value,
2332                is_mut,
2333            } => NodeKind::LetBinding {
2334                pattern,
2335                ty,
2336                value: self.rewrite_box(value),
2337                is_mut,
2338            },
2339            NodeKind::Assign { target, op, value } => NodeKind::Assign {
2340                target: self.rewrite_box(target),
2341                op,
2342                value: self.rewrite_box(value),
2343            },
2344            NodeKind::Call {
2345                callee,
2346                args,
2347                type_args,
2348            } => NodeKind::Call {
2349                callee: self.rewrite_box(callee),
2350                args: self.rewrite_args(args),
2351                type_args,
2352            },
2353            NodeKind::MethodCall {
2354                receiver,
2355                method,
2356                args,
2357                type_args,
2358            } => NodeKind::MethodCall {
2359                receiver: self.rewrite_box(receiver),
2360                method,
2361                args: self.rewrite_args(args),
2362                type_args,
2363            },
2364            NodeKind::FieldAccess { object, field } => NodeKind::FieldAccess {
2365                object: self.rewrite_box(object),
2366                field,
2367            },
2368            NodeKind::Index { object, index } => NodeKind::Index {
2369                object: self.rewrite_box(object),
2370                index: self.rewrite_box(index),
2371            },
2372            NodeKind::BinaryOp { op, left, right } => NodeKind::BinaryOp {
2373                op,
2374                left: self.rewrite_box(left),
2375                right: self.rewrite_box(right),
2376            },
2377            NodeKind::UnaryOp { op, operand } => NodeKind::UnaryOp {
2378                op,
2379                operand: self.rewrite_box(operand),
2380            },
2381            NodeKind::Propagate { expr } => NodeKind::Propagate {
2382                expr: self.rewrite_box(expr),
2383            },
2384            NodeKind::Await { expr } => NodeKind::Await {
2385                expr: self.rewrite_box(expr),
2386            },
2387            NodeKind::Move { expr } => NodeKind::Move {
2388                expr: self.rewrite_box(expr),
2389            },
2390            NodeKind::Borrow { expr } => NodeKind::Borrow {
2391                expr: self.rewrite_box(expr),
2392            },
2393            NodeKind::MutableBorrow { expr } => NodeKind::MutableBorrow {
2394                expr: self.rewrite_box(expr),
2395            },
2396            NodeKind::Return { value } => NodeKind::Return {
2397                value: value.map(|v| self.rewrite_box(v)),
2398            },
2399            NodeKind::Lambda { params, body } => NodeKind::Lambda {
2400                params,
2401                body: self.rewrite_box(body),
2402            },
2403            NodeKind::If {
2404                let_pattern,
2405                condition,
2406                then_block,
2407                else_block,
2408            } => NodeKind::If {
2409                let_pattern,
2410                condition: self.rewrite_box(condition),
2411                then_block: self.rewrite_box(then_block),
2412                else_block: else_block.map(|e| self.rewrite_box(e)),
2413            },
2414            NodeKind::Match { scrutinee, arms } => NodeKind::Match {
2415                scrutinee: self.rewrite_box(scrutinee),
2416                arms: self.rewrite_vec(arms),
2417            },
2418            NodeKind::MatchArm {
2419                pattern,
2420                guard,
2421                body,
2422            } => NodeKind::MatchArm {
2423                pattern,
2424                guard: guard.map(|g| self.rewrite_box(g)),
2425                body: self.rewrite_box(body),
2426            },
2427            NodeKind::Guard {
2428                let_pattern,
2429                condition,
2430                else_block,
2431            } => NodeKind::Guard {
2432                let_pattern,
2433                condition: self.rewrite_box(condition),
2434                else_block: self.rewrite_box(else_block),
2435            },
2436            NodeKind::While { condition, body } => NodeKind::While {
2437                condition: self.rewrite_box(condition),
2438                body: self.rewrite_box(body),
2439            },
2440            NodeKind::Loop { body } => NodeKind::Loop {
2441                body: self.rewrite_box(body),
2442            },
2443            NodeKind::For {
2444                pattern,
2445                iterable,
2446                body,
2447            } => NodeKind::For {
2448                pattern,
2449                iterable: self.rewrite_box(iterable),
2450                body: self.rewrite_box(body),
2451            },
2452            NodeKind::ListLiteral { elems } => NodeKind::ListLiteral {
2453                elems: self.rewrite_vec(elems),
2454            },
2455            NodeKind::SetLiteral { elems } => NodeKind::SetLiteral {
2456                elems: self.rewrite_vec(elems),
2457            },
2458            NodeKind::TupleLiteral { elems } => NodeKind::TupleLiteral {
2459                elems: self.rewrite_vec(elems),
2460            },
2461            NodeKind::Pipe { left, right } => NodeKind::Pipe {
2462                left: self.rewrite_box(left),
2463                right: self.rewrite_box(right),
2464            },
2465            NodeKind::Compose { left, right } => NodeKind::Compose {
2466                left: self.rewrite_box(left),
2467                right: self.rewrite_box(right),
2468            },
2469            NodeKind::Range { lo, hi, inclusive } => NodeKind::Range {
2470                lo: self.rewrite_box(lo),
2471                hi: self.rewrite_box(hi),
2472                inclusive,
2473            },
2474            NodeKind::RecordConstruct {
2475                path,
2476                fields,
2477                spread,
2478            } => NodeKind::RecordConstruct {
2479                path,
2480                fields: fields
2481                    .into_iter()
2482                    .map(|f| bock_air::AirRecordField {
2483                        name: f.name,
2484                        value: f.value.map(|v| self.rewrite_box(v)),
2485                    })
2486                    .collect(),
2487                spread: spread.map(|s| self.rewrite_box(s)),
2488            },
2489            NodeKind::MapLiteral { entries } => NodeKind::MapLiteral {
2490                entries: entries
2491                    .into_iter()
2492                    .map(|e| bock_air::AirMapEntry {
2493                        key: self.rewrite(e.key),
2494                        value: self.rewrite(e.value),
2495                    })
2496                    .collect(),
2497            },
2498            NodeKind::Interpolation { parts } => NodeKind::Interpolation {
2499                parts: parts
2500                    .into_iter()
2501                    .map(|p| match p {
2502                        bock_air::AirInterpolationPart::Expr(e) => {
2503                            bock_air::AirInterpolationPart::Expr(self.rewrite_box(e))
2504                        }
2505                        lit @ bock_air::AirInterpolationPart::Literal(_) => lit,
2506                    })
2507                    .collect(),
2508            },
2509            NodeKind::ResultConstruct { variant, value } => NodeKind::ResultConstruct {
2510                variant,
2511                value: value.map(|v| self.rewrite_box(v)),
2512            },
2513            NodeKind::Break { value } => NodeKind::Break {
2514                value: value.map(|v| self.rewrite_box(v)),
2515            },
2516            NodeKind::EffectOp {
2517                effect,
2518                operation,
2519                args,
2520            } => NodeKind::EffectOp {
2521                effect,
2522                operation,
2523                args: self.rewrite_args(args),
2524            },
2525            NodeKind::HandlingBlock { handlers, body } => NodeKind::HandlingBlock {
2526                handlers: handlers
2527                    .into_iter()
2528                    .map(|h| bock_air::AirHandlerPair {
2529                        effect: h.effect,
2530                        handler: self.rewrite_box(h.handler),
2531                    })
2532                    .collect(),
2533                body: self.rewrite_box(body),
2534            },
2535            // No nested expression position a `.into()` could occupy.
2536            other => other,
2537        }
2538    }
2539}
2540
2541/// Build an [`bock_air::NodeKind::Identifier`] holding `name` at `span`.
2542fn ident_node(name: &str, span: bock_errors::Span) -> NodeKind {
2543    NodeKind::Identifier {
2544        name: bock_ast::Ident {
2545            name: name.to_string(),
2546            span,
2547        },
2548    }
2549}
2550
2551pub fn hoist_value_cf(module: AIRNode) -> AIRNode {
2552    let mut hoister = ValueCfHoister {
2553        next_id: max_node_id(&module) + 1,
2554        counter: 0,
2555        prelude: Vec::new(),
2556    };
2557    hoister.rewrite(module)
2558}
2559
2560/// Largest [`bock_air::NodeId`] anywhere in `node`. The pre-pass mints fresh
2561/// ids above this so synthesised nodes never collide with existing ones.
2562fn max_node_id(node: &AIRNode) -> bock_air::NodeId {
2563    struct MaxId(bock_air::NodeId);
2564    impl bock_air::visitor::Visitor for MaxId {
2565        fn visit_node(&mut self, node: &AIRNode) {
2566            self.0 = self.0.max(node.id);
2567            bock_air::visitor::walk_node(self, node);
2568        }
2569    }
2570    let mut m = MaxId(0);
2571    use bock_air::visitor::Visitor;
2572    m.visit_node(node);
2573    m.0
2574}
2575
2576struct ValueCfHoister {
2577    next_id: bock_air::NodeId,
2578    counter: u32,
2579    /// Statements to splice **before** the statement currently being rewritten.
2580    /// A value-position diverging CF pushes its `[temp decl, CF-as-stmt]` here
2581    /// and yields a temp-read; the enclosing block drains this per statement so
2582    /// the prelude lands in the right scope (never an IIFE — see [`hoist`]).
2583    prelude: Vec<AIRNode>,
2584}
2585
2586impl ValueCfHoister {
2587    fn fresh_id(&mut self) -> bock_air::NodeId {
2588        let id = self.next_id;
2589        self.next_id += 1;
2590        id
2591    }
2592
2593    fn fresh_temp_name(&mut self) -> String {
2594        let n = self.counter;
2595        self.counter += 1;
2596        format!("__bock_cf_{n}")
2597    }
2598
2599    fn node(&mut self, span: bock_errors::Span, kind: NodeKind) -> AIRNode {
2600        AIRNode::new(self.fresh_id(), span, kind)
2601    }
2602
2603    /// Recursively rewrite a node, hoisting any value-position diverging CF it
2604    /// contains. Walks the whole tree so nested value positions are covered.
2605    fn rewrite(&mut self, mut node: AIRNode) -> AIRNode {
2606        node.kind = self.rewrite_kind(node.kind, node.span);
2607        node
2608    }
2609
2610    fn rewrite_box(&mut self, node: Box<AIRNode>) -> Box<AIRNode> {
2611        Box::new(self.rewrite(*node))
2612    }
2613
2614    /// Rewrite a node used in **value position**: if it is a diverging value-CF,
2615    /// hoist it into prelude statements (a declare-only temp + the CF in
2616    /// statement form) and return a read of the temp; otherwise recurse.
2617    fn rewrite_value(&mut self, node: AIRNode) -> AIRNode {
2618        if value_cf_diverges(&node) {
2619            self.hoist(node)
2620        } else {
2621            self.rewrite(node)
2622        }
2623    }
2624
2625    fn rewrite_value_box(&mut self, node: Box<AIRNode>) -> Box<AIRNode> {
2626        Box::new(self.rewrite_value(*node))
2627    }
2628
2629    /// Hoist a diverging value-CF `cf`: push `let mut __bock_cf_N` and the CF in
2630    /// statement form (value tails → `__bock_cf_N = v`, diverging tails kept)
2631    /// onto the prelude buffer, and return a read of `__bock_cf_N`. The prelude
2632    /// is later spliced into the enclosing statement list by [`rewrite_stmts`],
2633    /// so the diverging arms stay in the *enclosing* function/loop scope rather
2634    /// than being captured by an IIFE.
2635    fn hoist(&mut self, cf: AIRNode) -> AIRNode {
2636        let span = cf.span;
2637        let temp = self.fresh_temp_name();
2638
2639        // `let mut __bock_cf_N` — declare-only (no initialiser). The value slot
2640        // carries a placeholder `Unreachable` that backends never emit because
2641        // the DECL_ONLY_META marker routes them to the bare declaration.
2642        let decl_pat = self.node(
2643            span,
2644            NodeKind::BindPat {
2645                name: bock_ast::Ident {
2646                    name: temp.clone(),
2647                    span,
2648                },
2649                is_mut: true,
2650            },
2651        );
2652        let placeholder = self.node(span, NodeKind::Unreachable);
2653        let mut decl = self.node(
2654            span,
2655            NodeKind::LetBinding {
2656                is_mut: true,
2657                pattern: Box::new(decl_pat),
2658                ty: None,
2659                value: Box::new(placeholder),
2660            },
2661        );
2662        decl.metadata.insert(
2663            DECL_ONLY_META.to_string(),
2664            bock_air::stubs::Value::Bool(true),
2665        );
2666
2667        // The CF relocated to statement position, value tails → `temp = v`.
2668        let stmt_cf = self.rewrite_to_assign(cf, &temp);
2669
2670        self.prelude.push(decl);
2671        self.prelude.push(stmt_cf);
2672
2673        self.node(
2674            span,
2675            NodeKind::Identifier {
2676                name: bock_ast::Ident {
2677                    name: temp.clone(),
2678                    span,
2679                },
2680            },
2681        )
2682    }
2683
2684    /// Rewrite a list of block statements, splicing each statement's hoist
2685    /// prelude (if any) immediately before it. Saves/restores the prelude buffer
2686    /// so a hoist inside one statement never leaks into a sibling.
2687    fn rewrite_stmts(&mut self, stmts: Vec<AIRNode>) -> Vec<AIRNode> {
2688        let mut out = Vec::with_capacity(stmts.len());
2689        for stmt in stmts {
2690            let saved = std::mem::take(&mut self.prelude);
2691            let rewritten = self.rewrite(stmt);
2692            let prelude = std::mem::replace(&mut self.prelude, saved);
2693            out.extend(prelude);
2694            out.push(rewritten);
2695        }
2696        out
2697    }
2698
2699    /// Rewrite a function/lambda body whose **block tail is a value position**
2700    /// (the function's implicit return value). Unlike a bare statement block
2701    /// (see the `Block` arm of [`Self::rewrite_kind`]), the tail here is hoisted
2702    /// when it is a diverging value-CF — a function ending in `if c { v } else {
2703    /// return }` returns the `if`'s value, so it must become a temp. The hoist
2704    /// prelude is spliced into the body's statement list before the temp-read
2705    /// tail. Non-block bodies (a bare-expression lambda) are value-hoisted whole.
2706    fn rewrite_body(&mut self, body: Box<AIRNode>) -> Box<AIRNode> {
2707        let body = *body;
2708        let NodeKind::Block { stmts, tail } = body.kind else {
2709            return Box::new(self.rewrite_value(body));
2710        };
2711        let mut out_stmts = self.rewrite_stmts(stmts);
2712        let new_tail = match tail {
2713            Some(t) => {
2714                let saved = std::mem::take(&mut self.prelude);
2715                let rewritten = self.rewrite_value(*t);
2716                let prelude = std::mem::replace(&mut self.prelude, saved);
2717                out_stmts.extend(prelude);
2718                Some(Box::new(rewritten))
2719            }
2720            None => None,
2721        };
2722        Box::new(AIRNode::new(
2723            body.id,
2724            body.span,
2725            NodeKind::Block {
2726                stmts: out_stmts,
2727                tail: new_tail,
2728            },
2729        ))
2730    }
2731
2732    /// Rewrite a (now statement-position) control-flow node so each value-
2733    /// yielding tail becomes `temp = <value>` and each diverging tail is kept.
2734    /// Recurses through nested `if`/`match`/`block`; a `loop`'s value arrives via
2735    /// `break <v>`, rewritten to `temp = <v>; break`.
2736    fn rewrite_to_assign(&mut self, node: AIRNode, temp: &str) -> AIRNode {
2737        let span = node.span;
2738        match node.kind {
2739            NodeKind::Block { stmts, tail } => {
2740                let mut stmts = self.rewrite_stmts(stmts);
2741                // The tail becomes `temp = <value>`; any prelude its value hoists
2742                // must land before that assignment, inside this block.
2743                if let Some(t) = tail {
2744                    let saved = std::mem::take(&mut self.prelude);
2745                    let assigned = self.rewrite_to_assign(*t, temp);
2746                    let prelude = std::mem::replace(&mut self.prelude, saved);
2747                    stmts.extend(prelude);
2748                    stmts.push(assigned);
2749                }
2750                AIRNode::new(node.id, span, NodeKind::Block { stmts, tail: None })
2751            }
2752            NodeKind::If {
2753                let_pattern,
2754                condition,
2755                then_block,
2756                else_block,
2757            } => {
2758                let condition = self.rewrite_box(condition);
2759                let then_block = Box::new(self.rewrite_to_assign(*then_block, temp));
2760                let else_block = else_block.map(|e| Box::new(self.rewrite_to_assign(*e, temp)));
2761                AIRNode::new(
2762                    node.id,
2763                    span,
2764                    NodeKind::If {
2765                        let_pattern,
2766                        condition,
2767                        then_block,
2768                        else_block,
2769                    },
2770                )
2771            }
2772            NodeKind::Match { scrutinee, arms } => {
2773                let scrutinee = self.rewrite_box(scrutinee);
2774                let arms = arms
2775                    .into_iter()
2776                    .map(|arm| match arm.kind {
2777                        NodeKind::MatchArm {
2778                            pattern,
2779                            guard,
2780                            body,
2781                        } => {
2782                            let body = Box::new(self.rewrite_to_assign(*body, temp));
2783                            AIRNode::new(
2784                                arm.id,
2785                                arm.span,
2786                                NodeKind::MatchArm {
2787                                    pattern,
2788                                    guard,
2789                                    body,
2790                                },
2791                            )
2792                        }
2793                        other => AIRNode::new(arm.id, arm.span, other),
2794                    })
2795                    .collect();
2796                AIRNode::new(node.id, span, NodeKind::Match { scrutinee, arms })
2797            }
2798            NodeKind::Loop { body } => {
2799                // The loop value arrives via `break <v>`; rewrite those to
2800                // `temp = <v>; break`. Nested loops own their own `break`s, so
2801                // the rewrite does not cross into them.
2802                let body = Box::new(self.rewrite_breaks_to_assign(*body, temp));
2803                AIRNode::new(node.id, span, NodeKind::Loop { body })
2804            }
2805            // A diverging tail (`return`/`break`/`continue`/diverging call): keep
2806            // verbatim (rewriting its sub-expressions for any nested hoists).
2807            _ if branch_tail_diverges(&AIRNode::new(node.id, span, node.kind.clone())) => {
2808                AIRNode::new(node.id, span, self.rewrite_kind(node.kind, span))
2809            }
2810            // A plain value tail: `temp = <value>`. A bare-expression arm body
2811            // (not a block) whose value itself hoists must keep that prelude with
2812            // the assignment, so wrap them in a block when a prelude was produced.
2813            _ => {
2814                let saved = std::mem::take(&mut self.prelude);
2815                let value = self.rewrite_value(AIRNode::new(node.id, span, node.kind));
2816                let prelude = std::mem::replace(&mut self.prelude, saved);
2817                let assign = self.assign_temp(temp, value, span);
2818                if prelude.is_empty() {
2819                    assign
2820                } else {
2821                    let mut stmts = prelude;
2822                    stmts.push(assign);
2823                    self.node(span, NodeKind::Block { stmts, tail: None })
2824                }
2825            }
2826        }
2827    }
2828
2829    /// Within a value-`loop` body, rewrite `break <v>` → `{ temp = v; break }`.
2830    /// Does not descend into nested loops (their `break`s target themselves) or
2831    /// into functions/lambdas.
2832    fn rewrite_breaks_to_assign(&mut self, node: AIRNode, temp: &str) -> AIRNode {
2833        let span = node.span;
2834        match node.kind {
2835            NodeKind::Break { value: Some(v) } => {
2836                // `break <v>` → a flattenable splice block `{ temp = v; break }`.
2837                // The enclosing Block arm splices its statements inline so no
2838                // nested `{ … }` (which a backend treats as a value-IIFE) remains.
2839                let value = self.rewrite_value(*v);
2840                let assign = self.assign_temp(temp, value, span);
2841                let brk = self.node(span, NodeKind::Break { value: None });
2842                let mut blk = self.node(
2843                    span,
2844                    NodeKind::Block {
2845                        stmts: vec![assign, brk],
2846                        tail: None,
2847                    },
2848                );
2849                blk.metadata.insert(
2850                    SPLICE_BLOCK_META.to_string(),
2851                    bock_air::stubs::Value::Bool(true),
2852                );
2853                blk
2854            }
2855            NodeKind::Loop { .. }
2856            | NodeKind::While { .. }
2857            | NodeKind::For { .. }
2858            | NodeKind::FnDecl { .. }
2859            | NodeKind::Lambda { .. } => self.rewrite(AIRNode::new(node.id, span, node.kind)),
2860            NodeKind::Block { stmts, tail } => {
2861                let mut out: Vec<AIRNode> = Vec::with_capacity(stmts.len());
2862                for s in stmts {
2863                    let r = self.rewrite_breaks_to_assign(s, temp);
2864                    Self::splice_or_push(&mut out, r);
2865                }
2866                // A loop-body block's tail that contains a `break` is a diverging
2867                // statement (not a value), so the rewritten tail moves into the
2868                // statement list — keeping it out of value position (an IIFE).
2869                let new_tail = tail.and_then(|t| {
2870                    let rewritten = self.rewrite_breaks_to_assign(*t, temp);
2871                    Self::splice_or_push(&mut out, rewritten);
2872                    None
2873                });
2874                AIRNode::new(
2875                    node.id,
2876                    span,
2877                    NodeKind::Block {
2878                        stmts: out,
2879                        tail: new_tail,
2880                    },
2881                )
2882            }
2883            NodeKind::If {
2884                let_pattern,
2885                condition,
2886                then_block,
2887                else_block,
2888            } => {
2889                let condition = self.rewrite_box(condition);
2890                let then_block = Box::new(self.rewrite_breaks_to_assign(*then_block, temp));
2891                let else_block =
2892                    else_block.map(|e| Box::new(self.rewrite_breaks_to_assign(*e, temp)));
2893                AIRNode::new(
2894                    node.id,
2895                    span,
2896                    NodeKind::If {
2897                        let_pattern,
2898                        condition,
2899                        then_block,
2900                        else_block,
2901                    },
2902                )
2903            }
2904            NodeKind::Match { scrutinee, arms } => {
2905                let scrutinee = self.rewrite_box(scrutinee);
2906                let arms = arms
2907                    .into_iter()
2908                    .map(|arm| match arm.kind {
2909                        NodeKind::MatchArm {
2910                            pattern,
2911                            guard,
2912                            body,
2913                        } => {
2914                            let body = Box::new(self.rewrite_breaks_to_assign(*body, temp));
2915                            AIRNode::new(
2916                                arm.id,
2917                                arm.span,
2918                                NodeKind::MatchArm {
2919                                    pattern,
2920                                    guard,
2921                                    body,
2922                                },
2923                            )
2924                        }
2925                        other => AIRNode::new(arm.id, arm.span, other),
2926                    })
2927                    .collect();
2928                AIRNode::new(node.id, span, NodeKind::Match { scrutinee, arms })
2929            }
2930            other => self.rewrite(AIRNode::new(node.id, span, other)),
2931        }
2932    }
2933
2934    /// Push `node` onto `out`, flattening a splice-flattenable block (from a
2935    /// `break <v>` rewrite) so its `{ temp = v; break }` statements land inline.
2936    fn splice_or_push(out: &mut Vec<AIRNode>, node: AIRNode) {
2937        if node.metadata.contains_key(SPLICE_BLOCK_META) {
2938            if let NodeKind::Block { stmts, tail } = node.kind {
2939                out.extend(stmts);
2940                if let Some(t) = tail {
2941                    out.push(*t);
2942                }
2943                return;
2944            }
2945        }
2946        out.push(node);
2947    }
2948
2949    /// `temp = <value>` as an `Assign` node.
2950    fn assign_temp(&mut self, temp: &str, value: AIRNode, span: bock_errors::Span) -> AIRNode {
2951        let target = self.node(
2952            span,
2953            NodeKind::Identifier {
2954                name: bock_ast::Ident {
2955                    name: temp.to_string(),
2956                    span,
2957                },
2958            },
2959        );
2960        self.node(
2961            span,
2962            NodeKind::Assign {
2963                op: bock_ast::AssignOp::Assign,
2964                target: Box::new(target),
2965                value: Box::new(value),
2966            },
2967        )
2968    }
2969
2970    /// Rewrite the children of a node kind, hoisting value-position children.
2971    fn rewrite_kind(&mut self, kind: NodeKind, _span: bock_errors::Span) -> NodeKind {
2972        match kind {
2973            NodeKind::Module {
2974                path,
2975                annotations,
2976                imports,
2977                items,
2978            } => NodeKind::Module {
2979                path,
2980                annotations,
2981                imports: imports.into_iter().map(|n| self.rewrite(n)).collect(),
2982                items: items.into_iter().map(|n| self.rewrite(n)).collect(),
2983            },
2984            NodeKind::FnDecl {
2985                annotations,
2986                visibility,
2987                is_async,
2988                name,
2989                generic_params,
2990                params,
2991                return_type,
2992                effect_clause,
2993                where_clause,
2994                body,
2995            } => NodeKind::FnDecl {
2996                annotations,
2997                visibility,
2998                is_async,
2999                name,
3000                generic_params,
3001                params: params.into_iter().map(|p| self.rewrite(p)).collect(),
3002                return_type,
3003                effect_clause,
3004                where_clause,
3005                body: self.rewrite_body(body),
3006            },
3007            NodeKind::ClassDecl {
3008                annotations,
3009                visibility,
3010                name,
3011                generic_params,
3012                base,
3013                traits,
3014                fields,
3015                methods,
3016            } => NodeKind::ClassDecl {
3017                annotations,
3018                visibility,
3019                name,
3020                generic_params,
3021                base,
3022                traits,
3023                fields,
3024                methods: methods.into_iter().map(|m| self.rewrite(m)).collect(),
3025            },
3026            NodeKind::TraitDecl {
3027                annotations,
3028                visibility,
3029                is_platform,
3030                name,
3031                generic_params,
3032                associated_types,
3033                methods,
3034            } => NodeKind::TraitDecl {
3035                annotations,
3036                visibility,
3037                is_platform,
3038                name,
3039                generic_params,
3040                associated_types,
3041                methods: methods.into_iter().map(|m| self.rewrite(m)).collect(),
3042            },
3043            NodeKind::ImplBlock {
3044                annotations,
3045                generic_params,
3046                trait_path,
3047                trait_args,
3048                target,
3049                where_clause,
3050                methods,
3051            } => NodeKind::ImplBlock {
3052                annotations,
3053                generic_params,
3054                trait_path,
3055                trait_args,
3056                target,
3057                where_clause,
3058                methods: methods.into_iter().map(|m| self.rewrite(m)).collect(),
3059            },
3060            NodeKind::EffectDecl {
3061                annotations,
3062                visibility,
3063                name,
3064                generic_params,
3065                components,
3066                operations,
3067            } => NodeKind::EffectDecl {
3068                annotations,
3069                visibility,
3070                name,
3071                generic_params,
3072                components,
3073                operations: operations.into_iter().map(|o| self.rewrite(o)).collect(),
3074            },
3075            NodeKind::ConstDecl {
3076                annotations,
3077                visibility,
3078                name,
3079                ty,
3080                value,
3081            } => NodeKind::ConstDecl {
3082                annotations,
3083                visibility,
3084                name,
3085                ty,
3086                value: self.rewrite_value_box(value),
3087            },
3088            NodeKind::PropertyTest {
3089                name,
3090                bindings,
3091                body,
3092            } => NodeKind::PropertyTest {
3093                name,
3094                bindings,
3095                body: self.rewrite_box(body),
3096            },
3097            NodeKind::LetBinding {
3098                is_mut,
3099                pattern,
3100                ty,
3101                value,
3102            } => NodeKind::LetBinding {
3103                is_mut,
3104                pattern,
3105                ty,
3106                value: self.rewrite_value_box(value),
3107            },
3108            NodeKind::Assign { op, target, value } => NodeKind::Assign {
3109                op,
3110                target,
3111                value: self.rewrite_value_box(value),
3112            },
3113            NodeKind::Return { value } => NodeKind::Return {
3114                value: value.map(|v| self.rewrite_value_box(v)),
3115            },
3116            NodeKind::Break { value } => NodeKind::Break {
3117                value: value.map(|v| self.rewrite_value_box(v)),
3118            },
3119            NodeKind::Call {
3120                callee,
3121                args,
3122                type_args,
3123            } => NodeKind::Call {
3124                callee: self.rewrite_box(callee),
3125                args: args.into_iter().map(|a| self.rewrite_arg(a)).collect(),
3126                type_args,
3127            },
3128            NodeKind::MethodCall {
3129                receiver,
3130                method,
3131                type_args,
3132                args,
3133            } => NodeKind::MethodCall {
3134                receiver: self.rewrite_box(receiver),
3135                method,
3136                type_args,
3137                args: args.into_iter().map(|a| self.rewrite_arg(a)).collect(),
3138            },
3139            NodeKind::Block { stmts, tail } => {
3140                // A block's tail is hoisted only when the block *itself* is in a
3141                // value position — which the enclosing value consumer detects via
3142                // `value_cf_diverges` (it recurses into the block tail) and then
3143                // routes through `hoist`/`rewrite_to_assign`. Here (a bare /
3144                // statement-position block) the tail is just recursed into, never
3145                // hoisted: a `match`/`if` whose *result is discarded* (e.g. a
3146                // statement-position `match s { … => return …, _ => {} }`) must
3147                // not be turned into a temp it never assigns.
3148                let out_stmts = self.rewrite_stmts(stmts);
3149                let tail = tail.map(|t| self.rewrite_box(t));
3150                NodeKind::Block {
3151                    stmts: out_stmts,
3152                    tail,
3153                }
3154            }
3155            NodeKind::If {
3156                let_pattern,
3157                condition,
3158                then_block,
3159                else_block,
3160            } => NodeKind::If {
3161                let_pattern,
3162                condition: self.rewrite_box(condition),
3163                then_block: self.rewrite_box(then_block),
3164                else_block: else_block.map(|e| self.rewrite_box(e)),
3165            },
3166            NodeKind::Match { scrutinee, arms } => NodeKind::Match {
3167                scrutinee: self.rewrite_box(scrutinee),
3168                arms: arms.into_iter().map(|a| self.rewrite(a)).collect(),
3169            },
3170            NodeKind::MatchArm {
3171                pattern,
3172                guard,
3173                body,
3174            } => NodeKind::MatchArm {
3175                pattern,
3176                guard,
3177                body: self.rewrite_box(body),
3178            },
3179            NodeKind::For {
3180                pattern,
3181                iterable,
3182                body,
3183            } => NodeKind::For {
3184                pattern,
3185                iterable: self.rewrite_box(iterable),
3186                body: self.rewrite_box(body),
3187            },
3188            NodeKind::While { condition, body } => NodeKind::While {
3189                condition: self.rewrite_box(condition),
3190                body: self.rewrite_box(body),
3191            },
3192            NodeKind::Loop { body } => NodeKind::Loop {
3193                body: self.rewrite_box(body),
3194            },
3195            NodeKind::Guard {
3196                let_pattern,
3197                condition,
3198                else_block,
3199            } => NodeKind::Guard {
3200                let_pattern,
3201                condition: self.rewrite_box(condition),
3202                else_block: self.rewrite_box(else_block),
3203            },
3204            NodeKind::HandlingBlock { handlers, body } => NodeKind::HandlingBlock {
3205                handlers,
3206                body: self.rewrite_box(body),
3207            },
3208            NodeKind::Lambda { params, body } => NodeKind::Lambda {
3209                params,
3210                body: self.rewrite_body(body),
3211            },
3212            NodeKind::BinaryOp { op, left, right } => NodeKind::BinaryOp {
3213                op,
3214                left: self.rewrite_box(left),
3215                right: self.rewrite_box(right),
3216            },
3217            NodeKind::UnaryOp { op, operand } => NodeKind::UnaryOp {
3218                op,
3219                operand: self.rewrite_box(operand),
3220            },
3221            NodeKind::FieldAccess { object, field } => NodeKind::FieldAccess {
3222                object: self.rewrite_box(object),
3223                field,
3224            },
3225            NodeKind::Index { object, index } => NodeKind::Index {
3226                object: self.rewrite_box(object),
3227                index: self.rewrite_box(index),
3228            },
3229            NodeKind::Propagate { expr } => NodeKind::Propagate {
3230                expr: self.rewrite_box(expr),
3231            },
3232            NodeKind::Await { expr } => NodeKind::Await {
3233                expr: self.rewrite_box(expr),
3234            },
3235            NodeKind::Move { expr } => NodeKind::Move {
3236                expr: self.rewrite_box(expr),
3237            },
3238            NodeKind::Borrow { expr } => NodeKind::Borrow {
3239                expr: self.rewrite_box(expr),
3240            },
3241            NodeKind::MutableBorrow { expr } => NodeKind::MutableBorrow {
3242                expr: self.rewrite_box(expr),
3243            },
3244            // Leaf nodes and node kinds with no value-position children: kept
3245            // verbatim. (Type expressions, literals, identifiers, patterns,
3246            // collection literals — collection element/record-field hoisting is
3247            // out of scope; the diverging-CF shapes never appear there in the
3248            // exercised examples, and hoisting them would change evaluation
3249            // order.)
3250            other => other,
3251        }
3252    }
3253
3254    fn rewrite_arg(&mut self, arg: AirArg) -> AirArg {
3255        AirArg {
3256            label: arg.label,
3257            value: self.rewrite_value(arg.value),
3258        }
3259    }
3260}
3261
3262/// Decide whether a loop must be given a target label so that a `break`/
3263/// `continue` inside a statement-arm `match` reaches the loop rather than the
3264/// `switch` the `match` lowers to.
3265///
3266/// In Go and JS/TS, `break` inside a `switch` exits the switch. When a
3267/// statement-arm `match` (lowered to a `switch`) contains a `break` (or, in
3268/// Go-style lowering, a `continue`) intended for an enclosing loop, the loop
3269/// needs a label and the jump must be labelled. This returns true when the
3270/// loop body contains — without crossing into a nested loop or function — a
3271/// `match` with a statement arm that performs a `break`/`continue`.
3272#[must_use]
3273pub fn loop_needs_break_label(body: &AIRNode) -> bool {
3274    fn arm_has_jump(node: &AIRNode) -> bool {
3275        match &node.kind {
3276            NodeKind::Break { .. } | NodeKind::Continue => true,
3277            NodeKind::For { .. }
3278            | NodeKind::While { .. }
3279            | NodeKind::Loop { .. }
3280            | NodeKind::FnDecl { .. }
3281            | NodeKind::Lambda { .. } => false,
3282            NodeKind::Block { stmts, tail } => {
3283                stmts.iter().any(arm_has_jump) || tail.as_deref().is_some_and(arm_has_jump)
3284            }
3285            NodeKind::If {
3286                then_block,
3287                else_block,
3288                ..
3289            } => arm_has_jump(then_block) || else_block.as_deref().is_some_and(arm_has_jump),
3290            NodeKind::Match { arms, .. } => arms
3291                .iter()
3292                .any(|a| matches!(&a.kind, NodeKind::MatchArm { body, .. } if arm_has_jump(body))),
3293            NodeKind::Guard { else_block, .. } => arm_has_jump(else_block),
3294            _ => false,
3295        }
3296    }
3297    fn find(node: &AIRNode) -> bool {
3298        match &node.kind {
3299            NodeKind::For { .. }
3300            | NodeKind::While { .. }
3301            | NodeKind::Loop { .. }
3302            | NodeKind::FnDecl { .. }
3303            | NodeKind::Lambda { .. } => false,
3304            NodeKind::Match { arms, .. } => match_has_statement_arm(arms)
3305                && arms.iter().any(
3306                    |a| matches!(&a.kind, NodeKind::MatchArm { body, .. } if arm_has_jump(body)),
3307                ),
3308            NodeKind::Block { stmts, tail } => {
3309                stmts.iter().any(find) || tail.as_deref().is_some_and(find)
3310            }
3311            NodeKind::If {
3312                then_block,
3313                else_block,
3314                ..
3315            } => find(then_block) || else_block.as_deref().is_some_and(find),
3316            NodeKind::Guard { else_block, .. } => find(else_block),
3317            _ => false,
3318        }
3319    }
3320    find(body)
3321}
3322
3323/// If `param` is a method parameter that binds the receiver `self`, return
3324/// `Some(is_mut)` carrying its mutability; otherwise `None`.
3325///
3326/// The AIR keeps `self` as an ordinary leading `Param` whose pattern is a
3327/// `BindPat { name: "self" }`. Backends with native receivers (Rust `&self` /
3328/// `&mut self`, Go `func (self *T)`, Python `self`) consume this param to emit
3329/// the receiver and must not also emit it as a normal positional parameter.
3330#[must_use]
3331pub fn param_binds_self(param: &AIRNode) -> Option<bool> {
3332    let NodeKind::Param { pattern, .. } = &param.kind else {
3333        return None;
3334    };
3335    if let NodeKind::BindPat { name, is_mut } = &pattern.kind {
3336        if name.name == "self" {
3337            return Some(*is_mut);
3338        }
3339    }
3340    None
3341}
3342
3343/// Recognise a *desugared instance method call*.
3344///
3345/// The AIR lowerer rewrites `recv.method(args)` into
3346/// `Call { callee: FieldAccess(recv, method), args: [recv, ...args] }`, cloning
3347/// the receiver into both the field-access object and the leading argument
3348/// (so they share a [`NodeId`](bock_air::NodeId)). This helper detects that
3349/// shape — callee is a `FieldAccess` whose object is identical to the first
3350/// argument — and returns the receiver, the method name, and the remaining
3351/// (non-self) arguments. Targets with native method receivers (Rust, Go,
3352/// Python) use this to emit `recv.method(rest)` instead of double-passing the
3353/// receiver. Associated calls (`Type.method(...)`) prepend no self and are not
3354/// matched here.
3355#[must_use]
3356pub fn desugared_self_call<'a>(
3357    callee: &'a AIRNode,
3358    args: &'a [AirArg],
3359) -> Option<(&'a AIRNode, &'a bock_ast::Ident, &'a [AirArg])> {
3360    let NodeKind::FieldAccess { object, field } = &callee.kind else {
3361        return None;
3362    };
3363    let first = args.first()?;
3364    // The lowerer clones the receiver into both positions, so the self arg
3365    // and the field-access object are the *same* node (same NodeId). A genuine
3366    // `(p.f)(p)` field-closure call would have two distinct receiver nodes.
3367    if first.value.id == object.id {
3368        Some((object.as_ref(), field, &args[1..]))
3369    } else {
3370        None
3371    }
3372}
3373
3374/// The read-only / non-mutating `List` built-in methods this codegen lowers
3375/// natively per target (see [`desugared_list_method`]). The in-place mutators
3376/// are excluded: `push`/`append` lower via [`desugared_list_mutating_method`]
3377/// (DQ18) and `pop`/`remove_at`/`insert`/`reverse`/`set` via
3378/// [`desugared_list_inplace_mutator`] (DQ30).
3379pub const READ_ONLY_LIST_METHODS: &[&str] = &[
3380    "len", "length", "count", "is_empty", "get", "contains", "first", "last", "concat", "index_of",
3381    "join",
3382];
3383
3384/// The in-place `List` mutators (DQ18) lowered natively per target via
3385/// [`desugared_list_mutating_method`]. These resolve in the checker to a `Void`
3386/// return and require a `mut` receiver (enforced by the ownership pass), so each
3387/// backend emits them in *statement position* as a value-less mutation:
3388///
3389/// - rust / js / ts: `recv.push(x)`
3390/// - python: `recv.append(x)`
3391/// - go: `recv = append(recv, x)` (slice growth is reassignment in Go; the `mut`
3392///   receiver guarantees `recv` is a valid lvalue — a `let mut` binding or a
3393///   mutable field place)
3394///
3395/// `append` is Bock's spelling alias for `push`; both lower identically.
3396pub const MUTATING_LIST_METHODS: &[&str] = &["push", "append"];
3397
3398/// The raw `recv_kind` annotation tag the checker stamped on a desugared method
3399/// call node, if any.
3400///
3401/// Returns the verbatim tag string (`"List"`, `"User:Counter"`,
3402/// `"Primitive:Int"`, …) without stripping any prefix, or `None` when the node
3403/// carries no `recv_kind` stamp. This is the unprefixed sibling of
3404/// [`primitive_recv_kind`] / [`trait_bound_recv_kind`], used where a recogniser
3405/// needs to *distinguish* its own receiver category from any other stamped one
3406/// (e.g. the built-in `List` recogniser ruling out a same-named user-record
3407/// method).
3408#[must_use]
3409pub fn raw_recv_kind(node: &AIRNode) -> Option<&str> {
3410    let bock_air::Value::String(tag) =
3411        node.metadata.get(bock_types::checker::RECV_KIND_META_KEY)?
3412    else {
3413        return None;
3414    };
3415    Some(tag.as_str())
3416}
3417
3418/// True when `node` is a `Call` the lowerer classified as an
3419/// **associated-function call** (`Type.method(args)` — no `self` prepended), via
3420/// the [`bock_air::lower::ASSOC_CALL_META_KEY`] stamp.
3421///
3422/// Backends use this to emit a static / free-function call keyed by the type
3423/// name (`Type.method(args)`) instead of the value-receiver method form their
3424/// generic fall-through would produce — which camel-cases the type name into a
3425/// non-existent value (`typeValue.method(...)`). The companion to
3426/// [`assoc_fn_def`], which recognises the matching *definition* shape.
3427#[must_use]
3428pub fn is_associated_call(node: &AIRNode) -> bool {
3429    matches!(
3430        node.metadata.get(bock_air::lower::ASSOC_CALL_META_KEY),
3431        Some(bock_air::Value::Bool(true))
3432    )
3433}
3434
3435/// The set of primitive type names that can appear as the *callee* of a
3436/// canonical primitive associated conversion (`Prim.from(x)` / `Prim.try_from`).
3437///
3438/// Restricted to the conversion *targets* the canonical matrix defines
3439/// (`register_canonical_conversions`): `Int`/`Float` (numeric widening +
3440/// `TryFrom[String]`), `String` (`From[Char]`). Sized primitives (`Int64`, …)
3441/// are conversion targets too, but are not in the resolver's type-name vocab,
3442/// so they never reach codegen as an associated-call callee; `Int`/`Float`/
3443/// `String` are the v1-reachable callees.
3444pub const PRIMITIVE_CONVERSION_TARGETS: &[&str] = &["Int", "Float", "String"];
3445
3446/// Q-prim-assoc: when `node` is a **primitive** associated-conversion call
3447/// (`Float.from(x)` / `Int.try_from(s)` / `String.from(c)`), returns
3448/// `(target_prim_name, method, arg)`, where `method` is `"from"` or
3449/// `"try_from"` and `arg` is the single source-value argument.
3450///
3451/// Such a call is stamped [`is_associated_call`] and has the callee shape
3452/// `FieldAccess(Identifier(Prim), method)` where `Prim` is a
3453/// [`PRIMITIVE_CONVERSION_TARGETS`] name. Backends emit each target's native
3454/// conversion rather than the generic associated-call form (`Float.from(x)`),
3455/// which references a non-existent member on the host primitive (`float.from` is
3456/// a syntax error in Python; `Float`/`Float_from` are undefined in JS/Go/Rust).
3457/// Returns `None` for any other call, including user-type associated calls
3458/// (`Fahrenheit.from(c)`) and non-conversion methods, which keep their existing
3459/// lowering.
3460#[must_use]
3461pub fn primitive_conversion_call<'a>(
3462    node: &'a AIRNode,
3463    callee: &'a AIRNode,
3464    args: &'a [AirArg],
3465) -> Option<(&'a str, &'a str, &'a AIRNode)> {
3466    if !is_associated_call(node) {
3467        return None;
3468    }
3469    let NodeKind::FieldAccess { object, field } = &callee.kind else {
3470        return None;
3471    };
3472    let NodeKind::Identifier { name } = &object.kind else {
3473        return None;
3474    };
3475    let target = PRIMITIVE_CONVERSION_TARGETS
3476        .iter()
3477        .copied()
3478        .find(|&p| p == name.name)?;
3479    let method = match field.name.as_str() {
3480        m @ ("from" | "try_from") => m,
3481        _ => return None,
3482    };
3483    let arg = args.first().map(|a| &a.value)?;
3484    if args.len() != 1 {
3485        return None;
3486    }
3487    Some((target, method, arg))
3488}
3489
3490/// True when an impl/trait `method` (an [`bock_air::NodeKind::FnDecl`]) is an
3491/// **associated function** — it does not bind a leading `self` receiver, so it
3492/// is reached as `Type.method(...)` rather than `value.method(...)`.
3493///
3494/// Such a method must be emitted as a static / free function (no synthesized
3495/// receiver, no spurious `self` parameter) on every backend; otherwise the
3496/// generic impl-method path attaches it as an instance method and the
3497/// associated call cannot resolve it. The companion to [`is_associated_call`],
3498/// which recognises the matching *call* shape.
3499#[must_use]
3500pub fn assoc_fn_def(method: &AIRNode) -> bool {
3501    let NodeKind::FnDecl { params, .. } = &method.kind else {
3502        return false;
3503    };
3504    match params.first() {
3505        Some(first) => param_binds_self(first).is_none(),
3506        // A zero-parameter impl method (`fn origin() -> T`) binds no `self`.
3507        None => true,
3508    }
3509}
3510
3511/// True when an impl/trait `method` should be emitted as an associated function
3512/// — [`assoc_fn_def`] holds **and** the method is not an effect operation.
3513///
3514/// An **effect** operation (`effect Log { fn log(message: String) }`) also lacks
3515/// a `self` receiver, but a handler's `impl Log for ConsoleLog { fn log(...) }`
3516/// is an *instance* method: it is dispatched as `handler.log(...)` and must
3517/// satisfy the effect's interface, so it cannot be a static / free function.
3518/// `effect_ops` maps each known effect operation name to its effect (seeded
3519/// from every `EffectDecl` before emission), so a method whose name is a key is
3520/// an effect op and is kept as an instance method.
3521#[must_use]
3522pub fn is_associated_impl_method(method: &AIRNode, effect_ops: &HashMap<String, String>) -> bool {
3523    if !assoc_fn_def(method) {
3524        return false;
3525    }
3526    if let NodeKind::FnDecl { name, .. } = &method.kind {
3527        if effect_ops.contains_key(&name.name) {
3528            return false;
3529        }
3530    }
3531    true
3532}
3533
3534/// True when a `BinaryOp { op: Add, left, right }` is **list concatenation** and
3535/// must be lowered to the target's concat idiom rather than a native `+`.
3536///
3537/// Two independent signals, either of which suffices:
3538///
3539/// 1. The checker's [`bock_types::checker::LIST_CONCAT_META_KEY`] stamp — set on
3540///    a `+` whose operands resolved to `List[T]`. This is the precise signal for
3541///    every `+` the checker's body pass reaches.
3542/// 2. A *syntactic* fallback: one operand is a list literal (`xs + [todo]` /
3543///    `[head] + tail`). A list literal can only be `+`-combined with another
3544///    list (numeric/string `+` never has a `[...]` operand), so this is
3545///    unambiguous — and it covers `+` sites the checker's body pass does not
3546///    currently visit (e.g. inside `impl` method bodies, which are not yet
3547///    type-checked), where the stamp is absent.
3548///
3549/// Each backend calls this from its `NodeKind::BinaryOp { op: Add, .. }` arm. See
3550/// the metadata key's docs for the per-target lowering rationale.
3551#[must_use]
3552pub fn is_list_concat(node: &AIRNode, left: &AIRNode, right: &AIRNode) -> bool {
3553    let stamped = matches!(
3554        node.metadata.get(bock_types::checker::LIST_CONCAT_META_KEY),
3555        Some(bock_air::Value::Bool(true))
3556    );
3557    let has_list_literal = matches!(left.kind, NodeKind::ListLiteral { .. })
3558        || matches!(right.kind, NodeKind::ListLiteral { .. });
3559    stamped || has_list_literal
3560}
3561
3562/// True when a `BinaryOp { op: Div | Rem, .. }` is **integer** division /
3563/// remainder and must be lowered to DQ23's cross-target integer semantics (§3.6)
3564/// rather than the target's native `/` / `%`.
3565///
3566/// The signal is the checker's [`bock_types::checker::INT_ARITH_META_KEY`] stamp,
3567/// set on a `/` or `%` whose two operands both resolved to an integer primitive.
3568/// A purely syntactic codegen check cannot see that bare identifiers (`a / b`)
3569/// are integer-typed, so — unlike [`is_list_concat`], which has a list-literal
3570/// fallback — there is no syntactic fallback here: the stamp is the sole signal.
3571///
3572/// Each backend that diverges from the contract on its native operator (JS/TS:
3573/// float `/`, no zero-abort; Python: floor `//` and floor-`%`) calls this from
3574/// its `NodeKind::BinaryOp { op: Div | Rem, .. }` arm. Rust and Go already match
3575/// the contract with native `/` / `%`, so they ignore it. See the metadata key's
3576/// docs for the per-target lowering rationale.
3577#[must_use]
3578pub fn is_int_arith(node: &AIRNode) -> bool {
3579    matches!(
3580        node.metadata.get(bock_types::checker::INT_ARITH_META_KEY),
3581        Some(bock_air::Value::Bool(true))
3582    )
3583}
3584
3585/// True when a `BinaryOp { op: Lt | Le | Gt | Ge, .. }` is an **ordering
3586/// comparison on a user `Comparable` type** and must be lowered through the
3587/// type's `compare(self, other)` method rather than the target's native
3588/// `<` / `<=` / `>` / `>=`.
3589///
3590/// The signal is the checker's [`bock_types::checker::USER_COMPARE_META_KEY`]
3591/// stamp, set on an ordering operator whose operands resolved to a `Named`
3592/// (record / class) type implementing `Comparable`. A purely syntactic codegen
3593/// check cannot see that bare identifiers (`a < b`) are a user `Comparable` type,
3594/// so — like [`is_int_arith`] — the stamp is the sole signal: there is no
3595/// syntactic fallback.
3596///
3597/// Every backend consults this from its `NodeKind::BinaryOp` arm: native `<` on
3598/// two user values is broken on all five targets (Python `TypeError`, Rust/Go
3599/// non-comparable structs, JS `NaN`-coercion). Mapping the operator onto the
3600/// `compare` result (`<` ⇒ `== Less`, `>` ⇒ `== Greater`, `<=` ⇒ `!= Greater`,
3601/// `>=` ⇒ `!= Less`) reuses the per-target `Ordering` representation the stdlib
3602/// already emits. See the metadata key's docs for the rationale.
3603#[must_use]
3604pub fn is_user_compare(node: &AIRNode) -> bool {
3605    matches!(
3606        node.metadata
3607            .get(bock_types::checker::USER_COMPARE_META_KEY),
3608        Some(bock_air::Value::Bool(true))
3609    )
3610}
3611
3612/// The DQ29 equality lane a `BinaryOp { op: Eq | Ne, .. }` node was stamped
3613/// with by the checker ([`bock_types::checker::USER_EQ_META_KEY`]), or `None`
3614/// for an unstamped (native-equality) comparison.
3615///
3616/// Lanes: `"impl"` (explicit `impl Equatable` — dispatch through the type's
3617/// `eq`), `"structural"` (record/enum/tuple shape — JS/TS need the `__bockEq`
3618/// helper; natively-structural targets keep `==`), `"deep"` (involves a
3619/// collection — JS/TS *and* Go route through their deep-equality helpers), and
3620/// `"generic"` (bounded type var — JS/TS route through `__bockEq`). See the
3621/// metadata key's docs for the per-target rationale. Like [`is_user_compare`],
3622/// the stamp is the sole signal: codegen has no type information of its own.
3623#[must_use]
3624pub fn user_eq_kind(node: &AIRNode) -> Option<&str> {
3625    match node.metadata.get(bock_types::checker::USER_EQ_META_KEY) {
3626        Some(bock_air::Value::String(kind)) => Some(kind.as_str()),
3627        _ => None,
3628    }
3629}
3630
3631/// True when a `RecordDecl` / `EnumDecl` node carries the checker's
3632/// [`bock_types::checker::DERIVE_EQ_META_KEY`] stamp — the type conforms to
3633/// `Equatable` structurally (DQ29) and declares no explicit impl, so the Rust
3634/// backend adds `PartialEq` to its `#[derive(..)]` list.
3635#[must_use]
3636pub fn derives_structural_eq(node: &AIRNode) -> bool {
3637    matches!(
3638        node.metadata.get(bock_types::checker::DERIVE_EQ_META_KEY),
3639        Some(bock_air::Value::Bool(true))
3640    )
3641}
3642
3643/// Map an ordering [`BinOp`](bock_ast::BinOp) (`<` / `<=` / `>` / `>=`) onto the `Ordering`
3644/// variant name and whether the comparison is an *equality* (`true`) or
3645/// *inequality* (`false`) against it, for lowering a user-`Comparable`
3646/// comparison through `compare`:
3647///
3648/// | op   | variant     | equality |
3649/// |------|-------------|----------|
3650/// | `<`  | `"Less"`    | `true`   |
3651/// | `>`  | `"Greater"` | `true`   |
3652/// | `<=` | `"Greater"` | `false`  |
3653/// | `>=` | `"Less"`    | `false`  |
3654///
3655/// `a < b` ⇒ `compare == Less`, `a <= b` ⇒ `compare != Greater`, etc. Returns
3656/// `None` for any non-ordering operator (the caller only invokes it after
3657/// [`is_user_compare`], which already restricts to the four ordering ops).
3658#[must_use]
3659pub fn user_compare_variant(op: bock_ast::BinOp) -> Option<(&'static str, bool)> {
3660    use bock_ast::BinOp;
3661    match op {
3662        BinOp::Lt => Some(("Less", true)),
3663        BinOp::Gt => Some(("Greater", true)),
3664        BinOp::Le => Some(("Greater", false)),
3665        BinOp::Ge => Some(("Less", false)),
3666        _ => None,
3667    }
3668}
3669
3670/// True when an expression node is a `Bool` value that must stringify to the
3671/// canonical lowercase `"true"` / `"false"` (§3.5) — the checker stamped it with
3672/// [`bock_types::checker::BOOL_STRINGIFY_META_KEY`] because it appears as an
3673/// `${expr}` interpolation part of `Bool` type.
3674///
3675/// Only the Python backend consults this (its `f"{b}"` prints `True`/`False`);
3676/// JS/TS template literals and Rust/Go formatting already print lowercase.
3677#[must_use]
3678pub fn is_bool_stringify(node: &AIRNode) -> bool {
3679    matches!(
3680        node.metadata
3681            .get(bock_types::checker::BOOL_STRINGIFY_META_KEY),
3682        Some(bock_air::Value::Bool(true))
3683    )
3684}
3685
3686/// Recognise a *desugared `List` built-in method call*.
3687///
3688/// Building on the same desugared shape [`desugared_self_call`] detects
3689/// (`Call { callee: FieldAccess(recv, method), args: [recv, ...rest] }`), this
3690/// helper additionally requires that `method` is one of the read-only `List`
3691/// built-ins ([`READ_ONLY_LIST_METHODS`]). It is the shared recogniser each
3692/// backend wires into its `Call` arm *before* the generic
3693/// [`desugared_self_call`] / fall-through, so `nums.len()`, `nums.get(i)`,
3694/// `nums.contains(x)`, etc. are lowered to the target's idiomatic form (e.g.
3695/// `(nums).length`, a tagged-`Optional` bounds check, …) rather than emitted
3696/// verbatim as `nums.len(nums)` — which would fail at the target's
3697/// runtime/compile step.
3698///
3699/// `call_node` is the full `Call` AIR node (it holds the `recv_kind`
3700/// annotation); `callee`/`args` are its fields, passed separately so a backend
3701/// can call this from inside its `NodeKind::Call { callee, args, .. }` arm.
3702///
3703/// Unlike the `Optional`/`Result`/`Map`/`Set` recognisers — which fire *only*
3704/// on their exact `recv_kind` stamp — this one accepts both a `recv_kind =
3705/// "List"` stamp *and an absent stamp* (the checker leaves the receiver
3706/// untagged when its type is an unresolved inference variable, and several
3707/// existing list fixtures rely on that fall-through). It does, however, *reject*
3708/// a call carrying any *other* stamp: that rules out a same-named method on a
3709/// user record (`recv_kind = "User:Counter"`), a primitive, or another
3710/// container, so a user-defined `len()`/`is_empty()`/`contains(...)` falls
3711/// through to the user-method path instead of being shadowed by the built-in
3712/// `List` lowering.
3713///
3714/// Returns the receiver, the (validated) method name, and the remaining
3715/// (non-self) arguments. The element type of the list is intentionally *not*
3716/// inspected here: the checker has already type-checked the call, and each
3717/// backend's lowering is element-type-agnostic for these methods.
3718#[must_use]
3719pub fn desugared_list_method<'a>(
3720    call_node: &'a AIRNode,
3721    callee: &'a AIRNode,
3722    args: &'a [AirArg],
3723) -> Option<(&'a AIRNode, &'a str, &'a [AirArg])> {
3724    // A stamp other than "List" means the receiver is a user type / primitive /
3725    // other container; the built-in List lowering must not shadow it. An absent
3726    // stamp keeps the historical name-only behaviour (unresolved receiver type).
3727    if !matches!(raw_recv_kind(call_node), None | Some("List")) {
3728        return None;
3729    }
3730    let (recv, field, rest) = desugared_self_call(callee, args)?;
3731    let method = field.name.as_str();
3732    if READ_ONLY_LIST_METHODS.contains(&method) {
3733        Some((recv, method, rest))
3734    } else {
3735        None
3736    }
3737}
3738
3739/// Recognise a *desugared in-place `List` mutator call* (`push`/`append`, DQ18).
3740///
3741/// The mutating sibling of [`desugared_list_method`]: same desugared shape
3742/// (`Call { callee: FieldAccess(recv, method), args: [recv, x] }`), same
3743/// `recv_kind`-gating (a `recv_kind = "List"` stamp *or* an absent stamp; any
3744/// other stamp — a user record, a `Map`/`Set`, a primitive — is rejected so a
3745/// same-named user method is not shadowed), but the method must be one of
3746/// [`MUTATING_LIST_METHODS`]. Returns the receiver, the validated method name,
3747/// and the remaining (non-self) arguments (the single pushed element).
3748///
3749/// The checker types these calls as `Void` and the ownership pass guarantees the
3750/// receiver is a `mut` lvalue, so each backend wires this into its `Call` arm
3751/// (alongside [`desugared_list_method`]) and lowers it to the target's in-place
3752/// idiom in statement position.
3753#[must_use]
3754pub fn desugared_list_mutating_method<'a>(
3755    call_node: &'a AIRNode,
3756    callee: &'a AIRNode,
3757    args: &'a [AirArg],
3758) -> Option<(&'a AIRNode, &'a str, &'a [AirArg])> {
3759    if !matches!(raw_recv_kind(call_node), None | Some("List")) {
3760        return None;
3761    }
3762    let (recv, field, rest) = desugared_self_call(callee, args)?;
3763    let method = field.name.as_str();
3764    if MUTATING_LIST_METHODS.contains(&method) {
3765        Some((recv, method, rest))
3766    } else {
3767        None
3768    }
3769}
3770
3771/// The DQ30 in-place `List` mutators, lowered natively per target via
3772/// [`desugared_list_inplace_mutator`]. All are `mut self`
3773/// (`E5004`-enforced like DQ18's `push`/`append`); the per-method contracts:
3774///
3775/// - `pop() -> Optional[T]` — removes/returns the **last** element; `None` on
3776///   empty (emptiness is a normal state, never an abort);
3777/// - `remove_at(index) -> T` — removes/returns the element at `index`;
3778///   out-of-bounds (including negative) **aborts** (§10.5 Panic);
3779/// - `insert(index, value) -> Void` — inserts before `index`; valid range
3780///   `0..=len` (`len` is the append position); out-of-bounds aborts —
3781///   explicitly NOT Python's native clamp;
3782/// - `reverse() -> Void` — reverses in place;
3783/// - `set(index, value) -> Void` — overwrites the element at `index`;
3784///   out-of-bounds aborts (JS's native silent array extension and Python's
3785///   negative indexing are both excluded by explicit bounds checks).
3786///
3787/// The synthesized abort checks (js/ts/python/go) throw/raise/panic with the
3788/// normalized message `List.<op>: index <i> out of bounds (len <n>)`; the Rust
3789/// backend keeps `Vec`'s native panics (which carry the index and length), per
3790/// the DQ23 native-abort convention.
3791pub const INPLACE_LIST_MUTATORS: &[&str] = &["pop", "remove_at", "insert", "reverse", "set"];
3792
3793/// Recognise a *desugared DQ30 in-place `List` mutator call*
3794/// (`pop`/`remove_at`/`insert`/`reverse`/`set`).
3795///
3796/// The DQ30 sibling of [`desugared_list_mutating_method`]: same desugared shape
3797/// (`Call { callee: FieldAccess(recv, method), args: [recv, ...rest] }`), but
3798/// the method must be one of [`INPLACE_LIST_MUTATORS`] — and `set` additionally
3799/// requires the **explicit** `recv_kind = "List"` stamp (never the absent-stamp
3800/// fall-through), because `set(k, v)` is also a live `Map` method and an
3801/// unstamped receiver must not be claimed by the `List` lowering. The other
3802/// four names are `List`-only today, so they keep the DQ18 `None | "List"`
3803/// gating (the checker leaves an unresolved-inference receiver unstamped).
3804///
3805/// Returns the receiver, the validated method name, and the remaining
3806/// (non-self) arguments. The ownership pass guarantees the receiver is a `mut`
3807/// lvalue (E5004), so each backend may mutate the receiver *place* in its
3808/// lowering (JS/TS/Python mutate the reference; Go reassigns through a
3809/// pointer where the length changes; Rust borrows `&mut` natively).
3810#[must_use]
3811pub fn desugared_list_inplace_mutator<'a>(
3812    call_node: &'a AIRNode,
3813    callee: &'a AIRNode,
3814    args: &'a [AirArg],
3815) -> Option<(&'a AIRNode, &'a str, &'a [AirArg])> {
3816    let (recv, field, rest) = desugared_self_call(callee, args)?;
3817    let method = field.name.as_str();
3818    if !INPLACE_LIST_MUTATORS.contains(&method) {
3819        return None;
3820    }
3821    let gate_ok = if method == "set" {
3822        matches!(raw_recv_kind(call_node), Some("List"))
3823    } else {
3824        matches!(raw_recv_kind(call_node), None | Some("List"))
3825    };
3826    if gate_ok {
3827        Some((recv, method, rest))
3828    } else {
3829        None
3830    }
3831}
3832
3833/// The *functional* `List` built-in methods that take a closure argument and
3834/// must be lowered to each target's native iteration idiom (see
3835/// [`desugared_list_functional_method`]).
3836///
3837/// These resolve in the checker to a concrete return type with a fully typed
3838/// closure parameter (see `resolve_builtin_method_fn_type` for `List`), but the
3839/// receiver type `List[T]` has no `.map`/`.filter`/`.reduce`/… method in *any*
3840/// target — JS/TS arrays have `.map`/`.filter`/`.reduce` but not the desugared
3841/// `recv.map(recv, cb)` shape; Python lists, Rust `Vec`, and Go slices have no
3842/// such methods at all. Without a dedicated lowering these fall through to the
3843/// generic desugared-self-call path, which emits `recv.map(recv, cb)` —
3844/// array-not-a-callback on TS, "x.map is not a function" on JS, `'list' object
3845/// has no attribute 'map'` on Python, `no method 'map' for Vec` on Rust, and a
3846/// keyword/selector parse error on Go (`map` is reserved). This is the surface
3847/// counterpart to the `core.iter` *free functions* (`map`/`filter`/`fold`/…
3848/// over `ListIterator[T]`), which already lower correctly.
3849///
3850/// The set mirrors the closure-taking `List` methods the checker resolves:
3851/// `map`/`filter`/`reduce`/`fold`/`for_each`/`find`/`any`/`all`/`flat_map`. The
3852/// no-closure functional combinators (`take`/`skip`/`reverse`/`sort`/`dedup`/
3853/// `enumerate`/`zip`/`flatten`/`to_set`/`push`/`pop`/…) are intentionally NOT in
3854/// this set: they are either non-closure transforms or mutating methods left to
3855/// their existing paths (DQ18).
3856pub const FUNCTIONAL_LIST_METHODS: &[&str] = &[
3857    "map", "filter", "reduce", "fold", "for_each", "find", "any", "all", "flat_map",
3858];
3859
3860/// Recognise a *desugared `List` functional (closure-taking) method call*.
3861///
3862/// The functional sibling of [`desugared_list_method`]: same desugared shape
3863/// (`Call { callee: FieldAccess(recv, method), args: [recv, closure, …] }`),
3864/// same `recv_kind`-gating (accepts a `recv_kind = "List"` stamp *or* an absent
3865/// stamp, rejects any other stamp so a same-named user-record method or a
3866/// `Map`/`Set` method is not shadowed — those run *before* this recogniser via
3867/// their own `recv_kind`), but requires the method to be one of
3868/// [`FUNCTIONAL_LIST_METHODS`]. Returns the receiver, the validated method name,
3869/// and the remaining (non-self) arguments (the closure plus, for `fold`, the
3870/// initial accumulator).
3871///
3872/// Each backend wires this into its `Call` arm alongside
3873/// [`desugared_list_method`] and lowers it to the target's native iteration
3874/// idiom with the closure passed *once* and correctly (no duplicated receiver).
3875#[must_use]
3876pub fn desugared_list_functional_method<'a>(
3877    call_node: &'a AIRNode,
3878    callee: &'a AIRNode,
3879    args: &'a [AirArg],
3880) -> Option<(&'a AIRNode, &'a str, &'a [AirArg])> {
3881    if !matches!(raw_recv_kind(call_node), None | Some("List")) {
3882        return None;
3883    }
3884    let (recv, field, rest) = desugared_self_call(callee, args)?;
3885    let method = field.name.as_str();
3886    if FUNCTIONAL_LIST_METHODS.contains(&method) {
3887        Some((recv, method, rest))
3888    } else {
3889        None
3890    }
3891}
3892
3893// ─── Primitive-bridge method dispatch ────────────────────────────────────────
3894//
3895// The §18.2 core traits (Comparable/Equatable/Displayable) cover primitives via
3896// compiler-registered canonical conformances (the Q-bridge), so `(1).compare(2)`
3897// type-checks to `Ordering` and `a.eq(b)` to `Bool`. But codegen sees only the
3898// desugared `Call(FieldAccess(1, "compare"), [1, 2])` — and `i64`/`number`/`int`
3899// have no `.compare`/`.eq` method, so the generic desugared-self-call lowering
3900// emits `1.compare(1, 2)` (JS) / `1_i64.compare(2_i64)` (Rust), which fail on
3901// every target. This module recognises such calls — using the checker's
3902// `recv_kind` annotation (`bock_types::checker::RECV_KIND_META_KEY`) to confirm
3903// the receiver is a primitive — so each backend lowers them to the target's
3904// intrinsic comparison/equality/stringification.
3905
3906/// The three variants of the prelude `Ordering` enum (`core.compare`), in the
3907/// order the comparison ladder produces them.
3908///
3909/// `Ordering` is the return type of `Comparable.compare`, so the primitive
3910/// bridge constructs one of these per comparison. When the `core.compare` enum
3911/// declaration is not among the reached modules, each backend lowers
3912/// `Ordering`/`Less`/`Equal`/`Greater` to a self-contained representation
3913/// (Rust's native `std::cmp::Ordering`, a tagged object in JS/TS, a runtime
3914/// singleton in Python/Go) — the same treatment the built-in `Optional`/
3915/// `Result` receive.
3916pub const ORDERING_VARIANTS: &[&str] = &["Less", "Equal", "Greater"];
3917
3918/// Returns the variant name if `name` is one of the prelude `Ordering` variants
3919/// (`Less`/`Equal`/`Greater`), else `None`. The returned `&'static str` is the
3920/// canonical spelling, suitable for emitting into target source.
3921#[must_use]
3922pub fn ordering_variant(name: &str) -> Option<&'static str> {
3923    ORDERING_VARIANTS.iter().copied().find(|&v| v == name)
3924}
3925
3926/// The primitive trait-bridge methods this codegen lowers to a target intrinsic.
3927///
3928/// `compare`/`eq` are the canonical `Comparable`/`Equatable` methods; `to_string`
3929/// and `display` are the `Displayable` stringification methods. All resolve in
3930/// the checker to a known return type (`Ordering`, `Bool`, `String`) and must be
3931/// lowered to the target's intrinsic because the primitive has no such method in
3932/// the target language.
3933pub const PRIMITIVE_BRIDGE_METHODS: &[&str] = &["compare", "eq", "to_string", "display"];
3934
3935/// The receiver-kind annotation value, parsed into the primitive type name.
3936///
3937/// Returns the primitive type's name (e.g. `"Int"`, `"Float"`, `"String"`) when
3938/// the node carries a `recv_kind = "Primitive:<Ty>"` metadata stamp, else
3939/// `None`. This is the codegen-side reader of the checker→codegen annotation.
3940#[must_use]
3941pub fn primitive_recv_kind(node: &AIRNode) -> Option<&str> {
3942    let bock_air::Value::String(tag) =
3943        node.metadata.get(bock_types::checker::RECV_KIND_META_KEY)?
3944    else {
3945        return None;
3946    };
3947    tag.strip_prefix("Primitive:")
3948}
3949
3950/// Recognise a *desugared primitive trait-bridge method call*.
3951///
3952/// Building on [`desugared_self_call`], this additionally requires that (a) the
3953/// `call_node` carries the checker's `recv_kind = "Primitive:<Ty>"` annotation
3954/// and (b) the method is one of [`PRIMITIVE_BRIDGE_METHODS`]. Returns the
3955/// receiver node, the method name, the remaining (non-self) arguments, and the
3956/// primitive type name — everything a backend needs to lower the call to its
3957/// intrinsic (`x.cmp(&y)` / `x == y` / `x.to_string()` in Rust, the ternary
3958/// `Ordering` construction in JS/TS/Python/Go, …).
3959///
3960/// `call_node` is the full `Call` AIR node (it holds the annotation); `callee`
3961/// and `args` are its `callee`/`args` fields, passed separately so a backend can
3962/// call this from inside its `NodeKind::Call { callee, args, .. }` match arm.
3963#[must_use]
3964pub fn primitive_bridge_call<'a>(
3965    call_node: &'a AIRNode,
3966    callee: &'a AIRNode,
3967    args: &'a [AirArg],
3968) -> Option<(&'a AIRNode, &'a str, &'a [AirArg], &'a str)> {
3969    let prim = primitive_recv_kind(call_node)?;
3970    let (recv, field, rest) = desugared_self_call(callee, args)?;
3971    let method = field.name.as_str();
3972    if PRIMITIVE_BRIDGE_METHODS.contains(&method) {
3973        Some((recv, method, rest, prim))
3974    } else {
3975        None
3976    }
3977}
3978
3979/// The receiver-kind annotation value, parsed into the bounding *trait* name.
3980///
3981/// Returns the trait name (e.g. `"Equatable"`, `"Comparable"`) when the node
3982/// carries a `recv_kind = "TraitBound:<Trait>"` metadata stamp, else `None`. The
3983/// checker stamps this on a method call whose receiver is a bounded type variable
3984/// (`a.eq(b)` inside `eq_check[T: Equatable]`), recording that the method
3985/// dispatches through that trait bound rather than a concrete type. The codegen
3986/// analogue of [`primitive_recv_kind`].
3987#[must_use]
3988pub fn trait_bound_recv_kind(node: &AIRNode) -> Option<&str> {
3989    let bock_air::Value::String(tag) =
3990        node.metadata.get(bock_types::checker::RECV_KIND_META_KEY)?
3991    else {
3992        return None;
3993    };
3994    tag.strip_prefix("TraitBound:")
3995}
3996
3997/// Recognise a *desugared sealed-core-trait bridge method call* on a bounded
3998/// generic type variable.
3999///
4000/// The generic analogue of [`primitive_bridge_call`]: building on
4001/// [`desugared_self_call`], this additionally requires that (a) the `call_node`
4002/// carries the checker's `recv_kind = "TraitBound:<Trait>"` annotation, (b) the
4003/// trait is one of the compiler-provided sealed core traits
4004/// ([`bock_types::traits::SEALED_CORE_TRAITS`]) and is NOT declared in
4005/// `trait_decls` (i.e. it is the primitive conformance, not a user trait that
4006/// happens to share the name), and (c) the method is one of
4007/// [`PRIMITIVE_BRIDGE_METHODS`].
4008///
4009/// When all three hold the method dispatches through a sealed core trait whose
4010/// primitive instantiations (`Int`/`String`/`Bool`) have no `.eq`/`.compare`
4011/// method in any target, so each backend must lower it to the target intrinsic —
4012/// exactly as the `Primitive:<Ty>` bridge does, but driven by the generic bound
4013/// rather than a concrete receiver type. Returns the receiver node, the method
4014/// name, the remaining (non-self) arguments, and the trait name.
4015///
4016/// A `TraitBound:<Trait>` whose trait IS user-declared is left to the normal
4017/// trait-dispatch lowering (the user `impl` provides the method); a non-sealed
4018/// trait bound is likewise untouched.
4019#[must_use]
4020pub fn trait_bound_bridge_call<'a>(
4021    call_node: &'a AIRNode,
4022    callee: &'a AIRNode,
4023    args: &'a [AirArg],
4024    trait_decls: &TraitDeclRegistry,
4025) -> Option<(&'a AIRNode, &'a str, &'a [AirArg], &'a str)> {
4026    let tr = trait_bound_recv_kind(call_node)?;
4027    if !is_unimplemented_sealed_core_trait(tr, trait_decls) {
4028        return None;
4029    }
4030    let (recv, field, rest) = desugared_self_call(callee, args)?;
4031    let method = field.name.as_str();
4032    if PRIMITIVE_BRIDGE_METHODS.contains(&method) {
4033        Some((recv, method, rest, tr))
4034    } else {
4035        None
4036    }
4037}
4038
4039/// True when `trait_name` is a compiler-provided sealed core trait
4040/// ([`bock_types::traits::SEALED_CORE_TRAITS`]) that is NOT declared as a user
4041/// trait in `trait_decls`. Such a bound is the primitive conformance and must be
4042/// lowered to the target's built-in (native `==`/comparison/stringification and
4043/// the built-in ordered/equality constraint) rather than referenced as a real
4044/// trait/interface, which does not exist in any target. Shared by the
4045/// generic-bound renderers and the method-call bridge so the two stay in lockstep.
4046#[must_use]
4047pub fn is_unimplemented_sealed_core_trait(
4048    trait_name: &str,
4049    trait_decls: &TraitDeclRegistry,
4050) -> bool {
4051    bock_types::traits::SEALED_CORE_TRAITS.contains(&trait_name)
4052        && !trait_decls.contains_key(trait_name)
4053}
4054
4055/// Fold a function/impl `where`-clause's trait bounds onto the matching generic
4056/// params, returning an owned param list with the constraints attached inline.
4057///
4058/// A generic-param bound may be written two ways: inline (`fn f[T: Show]`),
4059/// where it already lives on `GenericParam.bounds`, or via a `where`-clause
4060/// (`fn f[T]() where (T: Show)`), where it lives in a separate `where_clause`
4061/// keyed by the type-var name. The generic-param renderers
4062/// (`generic_params_to_ts`, the Go type-param constraint emitter) only read
4063/// `GenericParam.bounds`, so a `where`-clause bound would otherwise be dropped
4064/// at codegen — emitting `<T>` instead of `<T extends Show>` / `[T any]` instead
4065/// of `[T Show]`, which fails the target compiler when the body calls a trait
4066/// method on `T`. This applies to a *locally* defined `where`-bounded fn and,
4067/// because an imported generic fn is emitted in its own module file carrying its
4068/// reconstructed `where`-clause (PR #286), to cross-module dispatch too.
4069///
4070/// Each constraint's bounds are appended to the param whose name matches
4071/// `constraint.param`; an inline bound already present is preserved (no dedup is
4072/// needed — a target's bound list tolerates repeats, and source cannot legally
4073/// state the same bound both inline and in `where`). Params with no matching
4074/// constraint are returned unchanged.
4075#[must_use]
4076pub fn merge_where_bounds_into_generics(
4077    generic_params: &[bock_ast::GenericParam],
4078    where_clause: &[bock_ast::TypeConstraint],
4079) -> Vec<bock_ast::GenericParam> {
4080    if where_clause.is_empty() {
4081        return generic_params.to_vec();
4082    }
4083    generic_params
4084        .iter()
4085        .map(|p| {
4086            let mut p = p.clone();
4087            for constraint in where_clause {
4088                if constraint.param.name == p.name.name {
4089                    p.bounds.extend(constraint.bounds.iter().cloned());
4090                }
4091            }
4092            p
4093        })
4094        .collect()
4095}
4096
4097// ─── Optional / Result built-in method dispatch ──────────────────────────────
4098//
4099// `Optional[T]` and `Result[T, E]` expose a small set of built-in methods
4100// (`is_some`/`unwrap_or`/`map`, `is_ok`/`unwrap`/…) that the checker resolves to
4101// a concrete return type. But codegen sees only the desugared
4102// `Call(FieldAccess(recv, m), [recv, …])` — and the *same* method names overlap
4103// across the two types (`unwrap`/`unwrap_or`/`map` are on both, and on `List`).
4104// Without disambiguation a backend either double-passes the receiver
4105// (`o.unwrap_or(o, 0)`, a runtime error in JS) or calls a method the tagged
4106// representation does not have (`o.is_some` on a TS `{_tag:"None"}` union). The
4107// checker's `recv_kind` annotation (`RECV_KIND_META_KEY`, value `"Optional"` /
4108// `"Result"`) records the resolved receiver category on the call node, so each
4109// backend reads it here to pick the right lowering on the tagged value.
4110
4111/// The built-in `Optional[T]` methods this codegen lowers on the tagged value.
4112///
4113/// `is_some`/`is_none` test the tag; `unwrap`/`unwrap_or` extract the payload (or
4114/// a default); `map`/`flat_map` transform it. The set mirrors the checker's
4115/// `Optional` method resolution (`checker.rs`), so every method that type-checks
4116/// has a lowering.
4117pub const OPTIONAL_METHODS: &[&str] = &[
4118    "is_some",
4119    "is_none",
4120    "unwrap",
4121    "unwrap_or",
4122    "map",
4123    "flat_map",
4124];
4125
4126/// The built-in `Result[T, E]` methods this codegen lowers on the tagged value.
4127///
4128/// `is_ok`/`is_err` test the tag; `unwrap`/`unwrap_or` extract the `Ok` payload
4129/// (or a default); `map`/`map_err` transform the `Ok`/`Err` payload. Mirrors the
4130/// checker's `Result` method resolution (`checker.rs`).
4131pub const RESULT_METHODS: &[&str] = &["is_ok", "is_err", "unwrap", "unwrap_or", "map", "map_err"];
4132
4133/// The receiver-kind annotation value, when it is one of the built-in container
4134/// categories `Optional`, `Result`, `Map`, or `Set`.
4135///
4136/// Returns the tag (`"Optional"` / `"Result"` / `"Map"` / `"Set"`) when the
4137/// node carries a `recv_kind` stamp with that exact value, else `None`. This is
4138/// the codegen-side reader of the checker→codegen annotation, the
4139/// disambiguation crux for the overloaded method names that appear on several
4140/// built-in containers (`unwrap`/`unwrap_or`/`map` on `Optional`/`Result`;
4141/// `filter`/`map`/`len`/`contains`/`to_list` across `List`/`Map`/`Set`).
4142#[must_use]
4143pub fn container_recv_kind(node: &AIRNode) -> Option<&str> {
4144    let bock_air::Value::String(tag) =
4145        node.metadata.get(bock_types::checker::RECV_KIND_META_KEY)?
4146    else {
4147        return None;
4148    };
4149    match tag.as_str() {
4150        "Optional" => Some("Optional"),
4151        "Result" => Some("Result"),
4152        "Map" => Some("Map"),
4153        "Set" => Some("Set"),
4154        _ => None,
4155    }
4156}
4157
4158/// Recognise a *desugared `Optional[T]` built-in method call*.
4159///
4160/// Building on [`desugared_self_call`], this additionally requires that (a) the
4161/// `call_node` carries the checker's `recv_kind = "Optional"` annotation and (b)
4162/// the method is one of [`OPTIONAL_METHODS`]. Returns the receiver node, the
4163/// method name, and the remaining (non-self) arguments — everything a backend
4164/// needs to lower the call on the tagged Optional value
4165/// (`(o._tag === "Some" ? o._0 : d)` in JS/TS, `o._0 if isinstance(o,_BockSome)
4166/// else d` in Python, an `__bockOption`-tag test in Go, the native method in
4167/// Rust).
4168///
4169/// `call_node` is the full `Call` AIR node (it holds the annotation); `callee`
4170/// and `args` are its `callee`/`args` fields, passed separately so a backend can
4171/// call this from inside its `NodeKind::Call { callee, args, .. }` arm.
4172#[must_use]
4173pub fn desugared_optional_method<'a>(
4174    call_node: &'a AIRNode,
4175    callee: &'a AIRNode,
4176    args: &'a [AirArg],
4177) -> Option<(&'a AIRNode, &'a str, &'a [AirArg])> {
4178    if container_recv_kind(call_node) != Some("Optional") {
4179        return None;
4180    }
4181    let (recv, field, rest) = desugared_self_call(callee, args)?;
4182    let method = field.name.as_str();
4183    if OPTIONAL_METHODS.contains(&method) {
4184        Some((recv, method, rest))
4185    } else {
4186        None
4187    }
4188}
4189
4190/// Recognise a *desugared `Result[T, E]` built-in method call*.
4191///
4192/// The `Result` counterpart of [`desugared_optional_method`]: requires the
4193/// `recv_kind = "Result"` annotation and a method in [`RESULT_METHODS`]. Returns
4194/// the receiver node, the method name, and the remaining (non-self) arguments.
4195/// The `recv_kind` disambiguation is what lets a backend distinguish
4196/// `r.unwrap_or(d)` on a `Result` (test `_tag === "Ok"`) from the same call on an
4197/// `Optional` (test `_tag === "Some"`).
4198#[must_use]
4199pub fn desugared_result_method<'a>(
4200    call_node: &'a AIRNode,
4201    callee: &'a AIRNode,
4202    args: &'a [AirArg],
4203) -> Option<(&'a AIRNode, &'a str, &'a [AirArg])> {
4204    if container_recv_kind(call_node) != Some("Result") {
4205        return None;
4206    }
4207    let (recv, field, rest) = desugared_self_call(callee, args)?;
4208    let method = field.name.as_str();
4209    if RESULT_METHODS.contains(&method) {
4210        Some((recv, method, rest))
4211    } else {
4212        None
4213    }
4214}
4215
4216// ─── Map / Set built-in method dispatch ──────────────────────────────────────
4217//
4218// `Map[K, V]` and `Set[E]` expose built-in methods (`get`/`set`/`keys`/…,
4219// `add`/`contains`/`union`/…) that the checker resolves to a concrete return
4220// type. Codegen sees only the desugared `Call(FieldAccess(recv, m), [recv, …])`,
4221// and several method names overlap with `List` (`len`/`length`/`count`,
4222// `filter`, `map`, `to_list`, plus `contains` on `Set`/`List`): without
4223// disambiguation a `Map`/`Set` receiver's `get`/`len`/`contains_key` is routed
4224// through the `List` path (`(m).length`, an index-bounds `Optional`), and the
4225// `Map`/`Set`-only methods (`set`/`add`/`keys`/`values`) fall through to the
4226// generic desugared-self-call, emitting `m.set(m, k, v)` — undefined on every
4227// target. The checker's `recv_kind` annotation (`RECV_KIND_META_KEY`, value
4228// `"Map"` / `"Set"`) records the resolved receiver category on the call node,
4229// so each backend reads it here to pick the right lowering and — crucially —
4230// runs the recognisers *before* `desugared_list_method` so the overlapping
4231// names dispatch by receiver kind, not by method name alone.
4232
4233/// The built-in `Map[K, V]` methods this codegen lowers natively per target.
4234///
4235/// Mirrors the checker's `Map` method resolution (`checker.rs`): `get` returns
4236/// `Optional[V]`; `set`/`delete`/`merge`/`filter` return the (receiver) map;
4237/// `keys`/`values`/`entries`/`to_list` return a `List`; `len`/`length`/`count`
4238/// an `Int`; `contains_key`/`is_empty` a `Bool`; `for_each` `Void`. Membership
4239/// is spelled `contains_key` (the checker's name); a bare `contains` on a `Map`
4240/// does not resolve to a built-in (see the PR's Q-map-contains-name note).
4241pub const MAP_METHODS: &[&str] = &[
4242    "get",
4243    "set",
4244    "delete",
4245    "merge",
4246    "filter",
4247    "keys",
4248    "values",
4249    "entries",
4250    "to_list",
4251    "len",
4252    "length",
4253    "count",
4254    "contains_key",
4255    "is_empty",
4256    "for_each",
4257];
4258
4259/// The built-in `Set[E]` methods this codegen lowers natively per target.
4260///
4261/// Mirrors the checker's `Set` method resolution (`checker.rs`): `add`/`remove`/
4262/// `union`/`intersection`/`difference`/`filter`/`map` return the (receiver) set;
4263/// `contains`/`is_subset`/`is_superset`/`is_empty` a `Bool`; `len`/`length`/
4264/// `count` an `Int`; `to_list` a `List`; `for_each` `Void`. `contains` here is
4265/// the *set-membership* test — distinct from `List.contains`, disambiguated by
4266/// the `recv_kind = "Set"` annotation.
4267pub const SET_METHODS: &[&str] = &[
4268    "add",
4269    "remove",
4270    "union",
4271    "intersection",
4272    "difference",
4273    "filter",
4274    "map",
4275    "contains",
4276    "is_subset",
4277    "is_superset",
4278    "len",
4279    "length",
4280    "count",
4281    "is_empty",
4282    "to_list",
4283    "for_each",
4284];
4285
4286/// Recognise a *desugared `Map[K, V]` built-in method call*.
4287///
4288/// Building on [`desugared_self_call`], this additionally requires that (a) the
4289/// `call_node` carries the checker's `recv_kind = "Map"` annotation and (b) the
4290/// method is one of [`MAP_METHODS`]. Returns the receiver node, the method name,
4291/// and the remaining (non-self) arguments — everything a backend needs to lower
4292/// the call on the native map representation (`new Map`/`dict`/`HashMap`/
4293/// `map[K]V`). Each backend wires this into its `Call` arm *before*
4294/// [`desugared_list_method`] so a `Map` receiver's `get`/`len`/`contains_key`
4295/// no longer hits the `List` path.
4296///
4297/// `call_node` is the full `Call` AIR node (it holds the annotation); `callee`
4298/// and `args` are its `callee`/`args` fields, passed separately so a backend can
4299/// call this from inside its `NodeKind::Call { callee, args, .. }` arm.
4300#[must_use]
4301pub fn desugared_map_method<'a>(
4302    call_node: &'a AIRNode,
4303    callee: &'a AIRNode,
4304    args: &'a [AirArg],
4305) -> Option<(&'a AIRNode, &'a str, &'a [AirArg])> {
4306    if container_recv_kind(call_node) != Some("Map") {
4307        return None;
4308    }
4309    let (recv, field, rest) = desugared_self_call(callee, args)?;
4310    let method = field.name.as_str();
4311    if MAP_METHODS.contains(&method) {
4312        Some((recv, method, rest))
4313    } else {
4314        None
4315    }
4316}
4317
4318/// Recognise a *desugared `Set[E]` built-in method call*.
4319///
4320/// The `Set` counterpart of [`desugared_map_method`]: requires the
4321/// `recv_kind = "Set"` annotation and a method in [`SET_METHODS`]. Returns the
4322/// receiver node, the method name, and the remaining (non-self) arguments. The
4323/// `recv_kind` disambiguation is what lets a backend distinguish `s.contains(x)`
4324/// on a `Set` (native membership) from the same call on a `List` (a linear
4325/// scan), and `s.len()`/`s.filter(..)`/`s.map(..)` from their `List` forms.
4326#[must_use]
4327pub fn desugared_set_method<'a>(
4328    call_node: &'a AIRNode,
4329    callee: &'a AIRNode,
4330    args: &'a [AirArg],
4331) -> Option<(&'a AIRNode, &'a str, &'a [AirArg])> {
4332    if container_recv_kind(call_node) != Some("Set") {
4333        return None;
4334    }
4335    let (recv, field, rest) = desugared_self_call(callee, args)?;
4336    let method = field.name.as_str();
4337    if SET_METHODS.contains(&method) {
4338        Some((recv, method, rest))
4339    } else {
4340        None
4341    }
4342}
4343
4344// ─── String built-in method dispatch ─────────────────────────────────────────
4345//
4346// `String` exposes a set of built-in methods (`len`/`to_upper`/`trim`/
4347// `contains`/`split`/…) that the checker resolves to a concrete return type
4348// (`checker.rs`, the `Type::Primitive(PrimitiveType::String)` method table). But
4349// codegen sees only the desugared `Call(FieldAccess(recv, m), [recv, …])`, and
4350// several of these method names overlap with `List` (`len`/`length`/`count`,
4351// `is_empty`, `contains`, `index_of`): without disambiguation a String
4352// receiver's `contains`/`len` is routed through the `List` path (e.g. Go's
4353// `[]interface{}` linear scan), which fails to compile against a `string`. The
4354// remaining String-only methods (`to_upper`/`trim`/`replace`/…) fall through to
4355// the generic desugared-self-call and emit `s.to_upper(s)` — undefined on every
4356// target. The checker's `recv_kind` annotation (`RECV_KIND_META_KEY`, value
4357// `"Primitive:String"`) records the resolved receiver category on the call node,
4358// so each backend reads it here to pick the native string lowering and —
4359// crucially — runs this recogniser *before* `desugared_list_method` so the
4360// overlapping names dispatch by receiver kind, not by method name alone.
4361
4362/// The built-in `String` methods this codegen lowers to each target's native
4363/// string ops.
4364///
4365/// Mirrors the checker's `String` method resolution
4366/// (`checker.rs`, `Type::Primitive(PrimitiveType::String)`): `len`/`byte_len`
4367/// return `Int` (scalar count vs byte count, per spec §18.3); `is_empty`/
4368/// `contains`/`starts_with`/`ends_with` a `Bool`; `to_upper`/`to_lower`/`trim`/
4369/// `replace` a `String`; `split` a `List[String]`. The set is intentionally the
4370/// *minimum-useful* subset that lowers cleanly to a native op on all five
4371/// targets — methods needing nontrivial index/Unicode semantics (`char_at`,
4372/// `slice`, `chars`, …) are deferred and fall through to the generic path.
4373pub const STRING_METHODS: &[&str] = &[
4374    "len",
4375    "length",
4376    "count",
4377    "byte_len",
4378    "is_empty",
4379    "to_upper",
4380    "to_lower",
4381    "trim",
4382    "contains",
4383    "starts_with",
4384    "ends_with",
4385    "replace",
4386    "split",
4387];
4388
4389/// Recognise a *desugared `String` built-in method call*.
4390///
4391/// Building on [`desugared_self_call`], this additionally requires that (a) the
4392/// `call_node` carries the checker's `recv_kind = "Primitive:String"` annotation
4393/// and (b) the method is one of [`STRING_METHODS`]. Returns the receiver node,
4394/// the method name, and the remaining (non-self) arguments — everything a
4395/// backend needs to lower the call to the target's native string op
4396/// (`s.toUpperCase()` / `s.upper()` / `s.to_uppercase()` / `strings.ToUpper(s)`,
4397/// `[...s].length` / `len(s)` / `s.chars().count()` / `utf8.RuneCountInString(s)`,
4398/// …). Each backend wires this into its `Call` arm *before*
4399/// [`desugared_list_method`] so a String receiver's `len`/`contains` no longer
4400/// hits the `List` path (the Go `[]interface{}` scan).
4401///
4402/// `call_node` is the full `Call` AIR node (it holds the annotation); `callee`
4403/// and `args` are its `callee`/`args` fields, passed separately so a backend can
4404/// call this from inside its `NodeKind::Call { callee, args, .. }` arm.
4405#[must_use]
4406pub fn desugared_string_method<'a>(
4407    call_node: &'a AIRNode,
4408    callee: &'a AIRNode,
4409    args: &'a [AirArg],
4410) -> Option<(&'a AIRNode, &'a str, &'a [AirArg])> {
4411    if primitive_recv_kind(call_node) != Some("String") {
4412        return None;
4413    }
4414    let (recv, field, rest) = desugared_self_call(callee, args)?;
4415    let method = field.name.as_str();
4416    if STRING_METHODS.contains(&method) {
4417        Some((recv, method, rest))
4418    } else {
4419        None
4420    }
4421}
4422
4423// ─── Reserved-keyword escaping ───────────────────────────────────────────────
4424//
4425// A Bock value identifier (a parameter, local `let`, or free-function name) is a
4426// plain word the user chose; nothing stops it colliding with a *target*
4427// language's reserved word. Before this layer codegen emitted such an identifier
4428// verbatim, producing source the target rejects at compile/parse time —
4429// `function getOr(o, default)` (JS/TS/Go reserve `default`), `def: int = …`
4430// (Python reserves `def`), and so on. Because each backend funnels its
4431// value-binding names through a single case-conversion (`to_camel_case` for
4432// JS/TS/Go, `to_snake_case` for Python/Rust), one post-conversion escape step
4433// per target closes the whole class: a converted name that equals a target
4434// keyword is suffixed with `_` (`default` → `default_`, `def` → `def_`),
4435// applied *consistently* at the declaration site, every reference site, and —
4436// for Go — the type-inference scope-map keys, so they always agree.
4437//
4438// Scope: only *value* identifiers are escaped. Member/field/method names (a
4439// `obj.default` access, a host-method call) are NOT — `default` is a perfectly
4440// legal member name on every target, and escaping it would break the access.
4441// Type names are not escaped either (no v1 keyword collides with a Bock type
4442// name, and they live in a different namespace). The suffix-`_` mangle is
4443// stable and idempotent (`escape` of an already-escaped name is itself), and
4444// the chosen suffix never itself reintroduces a keyword.
4445
4446/// The codegen target whose reserved-word set an identifier is being escaped
4447/// against. Mirrors the five v1 backends.
4448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4449pub enum KeywordTarget {
4450    /// JavaScript (`js`).
4451    Js,
4452    /// TypeScript (`ts`) — a superset of the JS reserved set.
4453    Ts,
4454    /// Python (`python`).
4455    Python,
4456    /// Rust (`rust`).
4457    Rust,
4458    /// Go (`go`).
4459    Go,
4460}
4461
4462/// JavaScript reserved words and future-reserved words (ES2015+), plus the
4463/// literal keywords. A value binding named any of these must be escaped.
4464const JS_KEYWORDS: &[&str] = &[
4465    "break",
4466    "case",
4467    "catch",
4468    "class",
4469    "const",
4470    "continue",
4471    "debugger",
4472    "default",
4473    "delete",
4474    "do",
4475    "else",
4476    "export",
4477    "extends",
4478    "false",
4479    "finally",
4480    "for",
4481    "function",
4482    "if",
4483    "import",
4484    "in",
4485    "instanceof",
4486    "new",
4487    "null",
4488    "return",
4489    "super",
4490    "switch",
4491    "this",
4492    "throw",
4493    "true",
4494    "try",
4495    "typeof",
4496    "var",
4497    "void",
4498    "while",
4499    "with",
4500    "yield",
4501    "enum",
4502    "await",
4503    "implements",
4504    "interface",
4505    "let",
4506    "package",
4507    "private",
4508    "protected",
4509    "public",
4510    "static",
4511];
4512
4513/// TypeScript reserves everything JS does plus a handful of type-level words
4514/// that are also illegal as plain bindings in value positions the backend emits.
4515const TS_EXTRA_KEYWORDS: &[&str] = &[
4516    "abstract",
4517    "as",
4518    "any",
4519    "boolean",
4520    "constructor",
4521    "declare",
4522    "get",
4523    "infer",
4524    "is",
4525    "keyof",
4526    "module",
4527    "namespace",
4528    "never",
4529    "readonly",
4530    "require",
4531    "number",
4532    "object",
4533    "set",
4534    "string",
4535    "symbol",
4536    "type",
4537    "undefined",
4538    "unique",
4539    "unknown",
4540    "from",
4541    "of",
4542    "async",
4543];
4544
4545/// Python 3 keywords (`keyword.kwlist`) plus the soft keywords that are unsafe
4546/// as bindings in the positions the backend emits.
4547const PYTHON_KEYWORDS: &[&str] = &[
4548    "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue",
4549    "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import",
4550    "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while",
4551    "with", "yield", "match", "case",
4552];
4553
4554/// Rust strict and reserved keywords (2018/2021 editions). Rust *could* use the
4555/// raw-identifier form (`r#match`) for most of these, but a uniform `_` suffix
4556/// keeps the escape identical across all targets and avoids the handful of words
4557/// (`crate`/`self`/`super`/`Self`) that cannot be raw identifiers at all.
4558const RUST_KEYWORDS: &[&str] = &[
4559    "as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false", "fn",
4560    "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
4561    "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe",
4562    "use", "where", "while", "async", "await", "abstract", "become", "box", "do", "final", "macro",
4563    "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "union",
4564];
4565
4566/// Go keywords (the Go spec's 25 reserved words).
4567const GO_KEYWORDS: &[&str] = &[
4568    "break",
4569    "case",
4570    "chan",
4571    "const",
4572    "continue",
4573    "default",
4574    "defer",
4575    "else",
4576    "fallthrough",
4577    "for",
4578    "func",
4579    "go",
4580    "goto",
4581    "if",
4582    "import",
4583    "interface",
4584    "map",
4585    "package",
4586    "range",
4587    "return",
4588    "select",
4589    "struct",
4590    "switch",
4591    "type",
4592    "var",
4593];
4594
4595/// True when `name` is a reserved word in the given target's keyword set.
4596#[must_use]
4597pub fn is_target_keyword(name: &str, target: KeywordTarget) -> bool {
4598    match target {
4599        KeywordTarget::Js => JS_KEYWORDS.contains(&name),
4600        KeywordTarget::Ts => JS_KEYWORDS.contains(&name) || TS_EXTRA_KEYWORDS.contains(&name),
4601        KeywordTarget::Python => PYTHON_KEYWORDS.contains(&name),
4602        KeywordTarget::Rust => RUST_KEYWORDS.contains(&name),
4603        KeywordTarget::Go => GO_KEYWORDS.contains(&name),
4604    }
4605}
4606
4607/// Escape `name` (an already case-converted *value* identifier) against the
4608/// target's reserved-word set: a name that collides with a keyword gets a
4609/// trailing `_`, otherwise it is returned unchanged.
4610///
4611/// Idempotent — the suffixed form is never itself a keyword, so re-escaping is a
4612/// no-op. Apply this at every site that emits or keys on a Bock value binding
4613/// (declaration, reference, and the Go scope-inference maps) so the escaped name
4614/// is used uniformly. Do **not** apply it to member/field/method names or to
4615/// type names (see the section comment).
4616#[must_use]
4617pub fn escape_target_keyword(name: &str, target: KeywordTarget) -> String {
4618    if is_target_keyword(name, target) {
4619        format!("{name}_")
4620    } else {
4621        name.to_string()
4622    }
4623}
4624
4625// ─── Enum-variant registry ──────────────────────────────────────────────────
4626//
4627// User-defined enum *declarations* already lower correctly per target (JS
4628// tagged factories, Rust real `enum`, Go sealed interface + variant structs).
4629// What every backend lacked was a way to recognise, at a *use* site, that a
4630// bare `Red` / `Circle { .. }` / `Rect(..)` is an enum variant rather than a
4631// variable, a record, or a free function — and which enum it belongs to. The
4632// AIR carries no back-pointer from a variant name to its enum at a construction
4633// or pattern site (`ConstructorPat`/`RecordPat`/`RecordConstruct` paths hold
4634// only the variant name, never the enum). This registry closes that gap: a
4635// single pre-scan over every reached module maps each variant name to its enum
4636// and payload shape, which each backend consults to qualify constructions
4637// (`Color_Red`, `Shape::Circle`, `ShapeCircle{..}`) and to dispatch matches.
4638//
4639// The built-in `Optional`/`Result` constructors (`Some`/`None`/`Ok`/`Err`) are
4640// pre-seeded so one mechanism describes both user and built-in ADTs (B1). The
4641// pre-seeded entries are a *fallback*: each backend keeps its existing bespoke
4642// Optional/Result lowering and consults the registry only afterwards, so the
4643// proven Optional/Result paths are never regressed.
4644
4645/// The payload shape of an enum variant, as needed to lower a construction or
4646/// a match arm in any target.
4647#[derive(Debug, Clone, PartialEq, Eq)]
4648pub enum VariantPayloadKind {
4649    /// A unit variant (`Red`): no payload.
4650    Unit,
4651    /// A tuple variant (`Rect(Float, Float)`): positional fields, by arity.
4652    Tuple(usize),
4653    /// A struct variant (`Circle { radius: Float }`): named fields, in
4654    /// declaration order.
4655    Struct(Vec<String>),
4656}
4657
4658/// What the registry knows about one enum variant: the enum it belongs to and
4659/// its payload shape.
4660#[derive(Debug, Clone, PartialEq, Eq)]
4661pub struct EnumVariantInfo {
4662    /// The declared name of the owning enum (e.g. `Shape`).
4663    pub enum_name: String,
4664    /// The variant's payload shape.
4665    pub payload: VariantPayloadKind,
4666}
4667
4668/// Maps an enum-variant name to its [`EnumVariantInfo`]. Variant names are
4669/// globally unique within a v1 Bock program (no per-enum namespacing at use
4670/// sites — `Red`, not `Color.Red`), so a flat map keyed by the bare variant
4671/// name resolves every construction and pattern site.
4672pub type EnumVariantRegistry = HashMap<String, EnumVariantInfo>;
4673
4674/// Pre-scan every reached module and build the [`EnumVariantRegistry`].
4675///
4676/// Walks each module's top-level `EnumDecl`s (the only place enum variants are
4677/// declared) and records every variant. A *pre-scan* — rather than recording
4678/// variants as their decls are emitted — is required because a use site may
4679/// precede its enum's declaration in source order (forward reference), and
4680/// because a `use`d enum's decl can live in a different module than its
4681/// construction site (cross-module `use`). This mirrors the Go backend's
4682/// existing `collect_methods` / `collect_optional_returns` pre-scans.
4683///
4684/// The built-in `Optional`/`Result` constructors are pre-seeded (B1) so the
4685/// same registry describes built-in ADTs; backends treat these as a fallback
4686/// behind their bespoke Optional/Result lowering (see the module comment).
4687#[must_use]
4688pub fn collect_enum_variants(modules: &[(&AIRModule, &Path)]) -> EnumVariantRegistry {
4689    let mut registry = EnumVariantRegistry::new();
4690    seed_builtin_variants(&mut registry);
4691    for (module, _) in modules {
4692        let NodeKind::Module { items, .. } = &module.kind else {
4693            continue;
4694        };
4695        for item in items {
4696            collect_enum_variants_from_item(item, &mut registry);
4697        }
4698    }
4699    registry
4700}
4701
4702/// Pre-seed the built-in `Optional` (`Some`/`None`) and `Result` (`Ok`/`Err`)
4703/// constructors. `Some`/`Ok`/`Err` carry a single positional payload; `None` is
4704/// a unit variant.
4705fn seed_builtin_variants(registry: &mut EnumVariantRegistry) {
4706    registry.insert(
4707        "Some".to_string(),
4708        EnumVariantInfo {
4709            enum_name: "Optional".to_string(),
4710            payload: VariantPayloadKind::Tuple(1),
4711        },
4712    );
4713    registry.insert(
4714        "None".to_string(),
4715        EnumVariantInfo {
4716            enum_name: "Optional".to_string(),
4717            payload: VariantPayloadKind::Unit,
4718        },
4719    );
4720    registry.insert(
4721        "Ok".to_string(),
4722        EnumVariantInfo {
4723            enum_name: "Result".to_string(),
4724            payload: VariantPayloadKind::Tuple(1),
4725        },
4726    );
4727    registry.insert(
4728        "Err".to_string(),
4729        EnumVariantInfo {
4730            enum_name: "Result".to_string(),
4731            payload: VariantPayloadKind::Tuple(1),
4732        },
4733    );
4734}
4735
4736/// Record every variant of a single `EnumDecl` item into `registry`. Non-enum
4737/// items are ignored.
4738fn collect_enum_variants_from_item(item: &AIRNode, registry: &mut EnumVariantRegistry) {
4739    let NodeKind::EnumDecl { name, variants, .. } = &item.kind else {
4740        return;
4741    };
4742    let enum_name = name.name.clone();
4743    for variant in variants {
4744        let NodeKind::EnumVariant {
4745            name: vname,
4746            payload,
4747        } = &variant.kind
4748        else {
4749            continue;
4750        };
4751        let payload = match payload {
4752            EnumVariantPayload::Unit => VariantPayloadKind::Unit,
4753            EnumVariantPayload::Tuple(elems) => VariantPayloadKind::Tuple(elems.len()),
4754            EnumVariantPayload::Struct(fields) => {
4755                VariantPayloadKind::Struct(fields.iter().map(|f| f.name.name.clone()).collect())
4756            }
4757        };
4758        registry.insert(
4759            vname.name.clone(),
4760            EnumVariantInfo {
4761                enum_name: enum_name.clone(),
4762                payload,
4763            },
4764        );
4765    }
4766}
4767
4768/// Look up the last segment of a `TypePath` (the variant name) in the registry.
4769/// Returns `None` when the path is empty or the name is not a known variant.
4770#[must_use]
4771pub fn registered_variant<'a>(
4772    registry: &'a EnumVariantRegistry,
4773    path: &bock_ast::TypePath,
4774) -> Option<&'a EnumVariantInfo> {
4775    let last = path.segments.last()?;
4776    registry.get(&last.name)
4777}
4778
4779/// Maps a generic type's declared name to its generic parameters. Built by a
4780/// pre-scan of every `RecordDecl`/`EnumDecl`/`ClassDecl` across the reached
4781/// modules.
4782///
4783/// Backends with native generic-receiver / `impl` syntax (Rust `impl<T> T<T>`,
4784/// Go `func (self *T[T])`, TS declaration-merged `interface T<T>`) need a
4785/// generic type's parameters at its method-emission site even though the AIR
4786/// `impl Box { ... }` block carries no generic params of its own — the `T` is
4787/// declared on the *record*, not the impl. This registry recovers those params
4788/// at the impl site. A *pre-scan* (rather than recording params as decls are
4789/// emitted) is required because an `impl` may precede its type's declaration in
4790/// source order, and because a `use`d type's decl can live in a different
4791/// module than its `impl` (cross-module `use`). Mirrors
4792/// [`collect_enum_variants`].
4793pub type GenericDeclRegistry = HashMap<String, Vec<bock_ast::GenericParam>>;
4794
4795/// Pre-scan every reached module and build the [`GenericDeclRegistry`].
4796/// Records the generic parameters declared on each top-level `RecordDecl`,
4797/// `EnumDecl`, and `ClassDecl`. Non-generic decls are recorded with an empty
4798/// parameter list (their presence still lets a backend distinguish a known
4799/// concrete type from an unknown one).
4800#[must_use]
4801pub fn collect_generic_decls(modules: &[(&AIRModule, &Path)]) -> GenericDeclRegistry {
4802    let mut registry = GenericDeclRegistry::new();
4803    for (module, _) in modules {
4804        let NodeKind::Module { items, .. } = &module.kind else {
4805            continue;
4806        };
4807        for item in items {
4808            collect_generic_decls_from_item(item, &mut registry);
4809        }
4810    }
4811    registry
4812}
4813
4814/// Record one decl's name → generic params into `registry`. Ignores items that
4815/// are not record/enum/class declarations.
4816fn collect_generic_decls_from_item(item: &AIRNode, registry: &mut GenericDeclRegistry) {
4817    let (name, generic_params) = match &item.kind {
4818        NodeKind::RecordDecl {
4819            name,
4820            generic_params,
4821            ..
4822        }
4823        | NodeKind::EnumDecl {
4824            name,
4825            generic_params,
4826            ..
4827        }
4828        | NodeKind::ClassDecl {
4829            name,
4830            generic_params,
4831            ..
4832        } => (name, generic_params),
4833        _ => return,
4834    };
4835    registry.insert(name.name.clone(), generic_params.clone());
4836}
4837
4838/// Pre-scan every module's top-level type declarations and collect the names of
4839/// those declared `public` (records, enums, traits, classes). A backend that
4840/// emits a declaration-merging companion (TS's `interface Target` that mirrors
4841/// an `impl`'s prototype methods) needs this: TS requires every declaration in
4842/// a merged declaration to agree on export-ness, so the companion `interface`
4843/// must be `export`ed exactly when the target type is. Mirrors
4844/// [`collect_generic_decls`].
4845#[must_use]
4846pub fn collect_exported_type_names(
4847    modules: &[(&AIRModule, &Path)],
4848) -> std::collections::HashSet<String> {
4849    let mut names = std::collections::HashSet::new();
4850    for (module, _) in modules {
4851        let NodeKind::Module { items, .. } = &module.kind else {
4852            continue;
4853        };
4854        for item in items {
4855            let (visibility, name) = match &item.kind {
4856                NodeKind::RecordDecl {
4857                    visibility, name, ..
4858                }
4859                | NodeKind::EnumDecl {
4860                    visibility, name, ..
4861                }
4862                | NodeKind::TraitDecl {
4863                    visibility, name, ..
4864                }
4865                | NodeKind::ClassDecl {
4866                    visibility, name, ..
4867                } => (visibility, name),
4868                _ => continue,
4869            };
4870            if matches!(visibility, bock_ast::Visibility::Public) {
4871                names.insert(name.name.clone());
4872            }
4873        }
4874    }
4875    names
4876}
4877
4878// ─── Trait-declaration registry (default methods) ────────────────────────────
4879
4880/// What the registry knows about one trait declaration: its declared generic
4881/// parameters and its methods (each an `AIRNode::FnDecl`), partitioned by the
4882/// caller via [`is_default_method`].
4883#[derive(Debug, Clone)]
4884pub struct TraitDeclInfo {
4885    /// Generic parameters declared on the trait (`trait Comparable[T]`).
4886    pub generic_params: Vec<bock_ast::GenericParam>,
4887    /// Every method declared in the trait body, in source order. A method whose
4888    /// AIR body block is non-empty is a *default* method (see
4889    /// [`is_default_method`]); an empty body marks a *required* method.
4890    pub methods: Vec<AIRNode>,
4891}
4892
4893/// Maps a trait's declared name to its [`TraitDeclInfo`]. Trait names are
4894/// globally unique within a Bock program, so a flat map keyed by the bare name
4895/// resolves every `impl Trait for Type` block to its trait.
4896pub type TraitDeclRegistry = HashMap<String, TraitDeclInfo>;
4897
4898/// True when `fn_decl` is a trait **default** method — one that carries a body.
4899///
4900/// A required (signature-only) trait method has no body in source
4901/// (`fn compare(self, other: Self) -> Ordering`); the AIR lowerer represents
4902/// that absence as an *empty* `Block` (`stmts` empty, no tail expression). A
4903/// default method (`fn not_eq(self, other: Self) -> Bool { ... }`) lowers to a
4904/// non-empty block. We therefore detect "has a default body" structurally as
4905/// "the body block is non-empty".
4906///
4907/// HEURISTIC NOTE: this is the empty-block heuristic. It is exact for code
4908/// produced by the current AIR lowerer (`bock-air::lower::lower_fn` synthesizes
4909/// `Block { stmts: vec![], tail: None }` for a bodyless method and only for a
4910/// bodyless method). A user *default* method whose body is literally `{}` (an
4911/// empty block) would be misclassified as required — but an empty-bodied method
4912/// returning a non-`Void` type does not type-check, and a `Void` default with an
4913/// empty body is behaviorally identical to no default, so the misclassification
4914/// is harmless. A robust, unambiguous fix would be an explicit `has_body` flag
4915/// on the AIR `FnDecl` (carried from the AST's `Option<Block>`); that is a
4916/// possible follow-up (a `bock-air` change, out of scope here).
4917#[must_use]
4918pub fn is_default_method(fn_decl: &AIRNode) -> bool {
4919    let NodeKind::FnDecl { body, .. } = &fn_decl.kind else {
4920        return false;
4921    };
4922    match &body.kind {
4923        NodeKind::Block { stmts, tail } => !stmts.is_empty() || tail.is_some(),
4924        // A non-Block body is unusual but, if present, counts as a real body.
4925        _ => true,
4926    }
4927}
4928
4929/// The method name of a `FnDecl` node, or `None` for a non-`FnDecl`.
4930#[must_use]
4931pub fn fn_decl_name(fn_decl: &AIRNode) -> Option<&str> {
4932    if let NodeKind::FnDecl { name, .. } = &fn_decl.kind {
4933        Some(name.name.as_str())
4934    } else {
4935        None
4936    }
4937}
4938
4939/// True if a type-expression node mentions `Self` anywhere (directly or nested
4940/// inside an optional / tuple / function / generic-arg position).
4941#[must_use]
4942fn type_node_mentions_self(node: &AIRNode) -> bool {
4943    match &node.kind {
4944        NodeKind::TypeSelf => true,
4945        NodeKind::TypeOptional { inner } => type_node_mentions_self(inner),
4946        NodeKind::TypeTuple { elems } => elems.iter().any(type_node_mentions_self),
4947        NodeKind::TypeFunction { params, ret, .. } => {
4948            params.iter().any(type_node_mentions_self) || type_node_mentions_self(ret)
4949        }
4950        NodeKind::TypeNamed { args, .. } => args.iter().any(type_node_mentions_self),
4951        _ => false,
4952    }
4953}
4954
4955/// True if any of `trait_info`'s methods reference `Self` in a (non-receiver)
4956/// parameter type or in the return type.
4957///
4958/// A trait with a `Self`-typed operand — `fn compare(self, other: Self) ->
4959/// Ordering` — cannot be encoded as a plain Go interface used as a generic
4960/// bound: the interface method would have to be `Compare(Self)`, but a Go
4961/// interface cannot name the implementing type. The Go backend instead encodes
4962/// such a trait as an *F-bounded* generic interface (`type Comparable[T any]
4963/// interface { Compare(T) Ordering }`), satisfied by `func (Key) Compare(Key)`,
4964/// and lowers a bound `[T: Comparable]` to `[T Comparable[T]]`. This predicate
4965/// selects the traits that need that treatment; a trait with no `Self` operand
4966/// (only `self`) stays a plain interface.
4967#[must_use]
4968pub fn trait_uses_self_operand(trait_info: &TraitDeclInfo) -> bool {
4969    trait_info.methods.iter().any(|m| {
4970        let NodeKind::FnDecl {
4971            params,
4972            return_type,
4973            ..
4974        } = &m.kind
4975        else {
4976            return false;
4977        };
4978        // Skip the leading `self` receiver; inspect the remaining param types
4979        // and the return type for a `Self` mention.
4980        let param_self = params
4981            .iter()
4982            .skip(1)
4983            .filter_map(|p| {
4984                if let NodeKind::Param { ty: Some(t), .. } = &p.kind {
4985                    Some(t.as_ref())
4986                } else {
4987                    None
4988                }
4989            })
4990            .any(type_node_mentions_self);
4991        let ret_self = return_type.as_deref().is_some_and(type_node_mentions_self);
4992        param_self || ret_self
4993    })
4994}
4995
4996/// Pre-scan every reached module and build the [`TraitDeclRegistry`].
4997///
4998/// Walks each module's top-level `TraitDecl`s and records the trait's generic
4999/// params and the full method list. Backends use this at each `impl Trait for
5000/// Type` site to recover the trait's *default* methods (those carrying a body)
5001/// so they can be synthesized onto the implementing type — the trait interface
5002/// alone carries only signatures, so a type that relies on an inherited default
5003/// would otherwise have no such method at runtime (js/ts/go). A *pre-scan*
5004/// (rather than recording traits as their decls are emitted) is required because
5005/// an `impl` may precede its trait's declaration in source order, and because a
5006/// `use`d trait's decl can live in a different module than its `impl`
5007/// (cross-module `use`). Mirrors [`collect_generic_decls`].
5008#[must_use]
5009pub fn collect_trait_decls(modules: &[(&AIRModule, &Path)]) -> TraitDeclRegistry {
5010    let mut registry = TraitDeclRegistry::new();
5011    for (module, _) in modules {
5012        let NodeKind::Module { items, .. } = &module.kind else {
5013            continue;
5014        };
5015        for item in items {
5016            if let NodeKind::TraitDecl {
5017                name,
5018                generic_params,
5019                methods,
5020                ..
5021            } = &item.kind
5022            {
5023                registry.insert(
5024                    name.name.clone(),
5025                    TraitDeclInfo {
5026                        generic_params: generic_params.clone(),
5027                        methods: methods.clone(),
5028                    },
5029                );
5030            }
5031        }
5032    }
5033    registry
5034}
5035
5036/// Resolve, for an `impl Trait for Type` block, the trait default methods that
5037/// the impl does **not** override and that must therefore be synthesized onto
5038/// the target. `trait_path` is the `ImplBlock`'s `trait_path`; `impl_methods`
5039/// its own methods. Returns *cloned* default-method `FnDecl` nodes, in
5040/// trait-declaration order. Empty when the impl has no trait, the trait is
5041/// unknown, or every default is overridden. Returns owned clones (rather than
5042/// registry borrows) so a backend can iterate them while mutating its own
5043/// emission buffer, without holding a borrow of the registry across the
5044/// `&mut self` writes.
5045#[must_use]
5046pub fn inherited_default_methods(
5047    registry: &TraitDeclRegistry,
5048    trait_path: &bock_ast::TypePath,
5049    impl_methods: &[AIRNode],
5050) -> Vec<AIRNode> {
5051    let Some(trait_name) = trait_path.segments.last().map(|s| s.name.as_str()) else {
5052        return Vec::new();
5053    };
5054    let Some(info) = registry.get(trait_name) else {
5055        return Vec::new();
5056    };
5057    let overridden: std::collections::HashSet<&str> =
5058        impl_methods.iter().filter_map(fn_decl_name).collect();
5059    info.methods
5060        .iter()
5061        .filter(|m| is_default_method(m))
5062        .filter(|m| fn_decl_name(m).is_some_and(|n| !overridden.contains(n)))
5063        .cloned()
5064        .collect()
5065}
5066
5067/// True when an `impl` block (an [`bock_air::NodeKind::ImplBlock`]) declares at
5068/// least one **instance** method — one that binds `self`, **or** an effect
5069/// operation (which is dispatched on a handler instance despite taking no
5070/// `self`; see [`is_associated_impl_method`]).
5071///
5072/// An impl whose methods are *all* associated functions (e.g. `impl From[A] for
5073/// B` with only `from(value)`) contributes no instance contract: implementing it
5074/// adds only static members. Backends that model trait conformance through
5075/// instance inheritance / structural interfaces (Python base class, TS
5076/// `interface … extends Trait`) must wire the trait in only when this returns
5077/// `true`; otherwise the base/`extends` reference points at a trait with no
5078/// instance members — often a prelude trait not even emitted into the consuming
5079/// module, so the reference would be undefined.
5080#[must_use]
5081pub fn impl_has_instance_method(
5082    impl_block: &AIRNode,
5083    effect_ops: &HashMap<String, String>,
5084) -> bool {
5085    let NodeKind::ImplBlock { methods, .. } = &impl_block.kind else {
5086        return false;
5087    };
5088    methods
5089        .iter()
5090        .any(|m| !is_associated_impl_method(m, effect_ops))
5091}
5092
5093// ─── Field/method name-collision disambiguation ───────────────────────────────
5094//
5095// Several stdlib (and user) types declare a *field* and a *method* that share a
5096// Bock name — the canonical case is `core.error`'s `SimpleError`, which has a
5097// `message: String` field *and* a `message()` method (the `Error` trait method).
5098// In Bock these are distinct (`self.message` vs `self.message()`), but most
5099// target object models collapse a field and a same-named method onto one member
5100// slot, which breaks at codegen:
5101//
5102//   - Go: `go build` rejects a struct with a field and method of the same name.
5103//   - TS: `class { message: string }` + `interface { message(): string }` is a
5104//     "Duplicate identifier".
5105//   - JS: the instance field `this.message` shadows the prototype method, so
5106//     `obj.message()` is "not a function".
5107//   - Python: the dataclass field overwrites the method attribute on the class.
5108//   - Rust: a field and an inherent method *may* share a name, so it is a no-op.
5109//
5110// The shared remedy: when a type has a method whose *emitted* name equals one of
5111// its *emitted* field names, the **method** is renamed (the field keeps its
5112// name) by appending a disambiguating suffix, and the rename is applied
5113// identically at the trait-interface declaration, the receiver/impl method, and
5114// every call site so they always agree. The two helpers below let every backend
5115// (go/ts/js/py) share this policy — collecting field names in the backend's own
5116// casing and routing both declarations and call sites through one rename — so
5117// any future field/method pair is handled uniformly without per-collision code.
5118
5119/// Collect every record/class field name in the module, mapped through the
5120/// backend's `cased` name function (`to_pascal_case` for Go, `to_camel_case`
5121/// for js/ts, identity/snake for Python). Backends use the returned set with
5122/// [`disambiguate_method_name`] to detect a method whose emitted name collides
5123/// with a field's emitted name.
5124///
5125/// The set is intentionally a *union* across all records/classes in the module
5126/// (not per-type): it mirrors the Go backend's original behavior and is a safe
5127/// over-approximation — at worst it renames a method on a type that happens to
5128/// share a name with an *unrelated* type's field, which is harmless because the
5129/// rename is applied consistently at the method's declaration and all its call
5130/// sites. Keeping it module-global keeps the lookup a single `HashSet` shared by
5131/// declaration and call-site emission, which run at different points.
5132#[must_use]
5133pub fn collect_record_field_names<F>(
5134    module: &AIRNode,
5135    cased: F,
5136) -> std::collections::HashSet<String>
5137where
5138    F: Fn(&str) -> String,
5139{
5140    let mut names = std::collections::HashSet::new();
5141    if let NodeKind::Module { items, .. } = &module.kind {
5142        for item in items {
5143            if let NodeKind::RecordDecl { fields, .. } | NodeKind::ClassDecl { fields, .. } =
5144                &item.kind
5145            {
5146                for f in fields {
5147                    names.insert(cased(&f.name.name));
5148                }
5149            }
5150        }
5151    }
5152    names
5153}
5154
5155/// Disambiguate a method's emitted name against the type's field names.
5156///
5157/// `cased_name` is the method name already mapped through the backend's casing
5158/// rule (so the comparison is apples-to-apples with the `field_names` produced
5159/// by [`collect_record_field_names`] using the *same* casing). When the cased
5160/// method name is also a field name, the method is renamed by appending
5161/// `suffix` directly to the cased name — the suffix is the backend's
5162/// already-cased disambiguator (`"Method"` for Go's Pascal and js/ts's camel,
5163/// `"_method"` for Python's snake), so `message`/`Message` become
5164/// `messageMethod`/`MessageMethod`/`message_method`. The cased prefix is left
5165/// untouched (no re-casing), so camelCase names with internal capitals survive
5166/// intact. Non-colliding names pass through unchanged.
5167///
5168/// Backends call this identically at the method declaration and at every call
5169/// site, so the renamed method always resolves.
5170#[must_use]
5171pub fn disambiguate_method_name(
5172    cased_name: String,
5173    field_names: &std::collections::HashSet<String>,
5174    suffix: &str,
5175) -> String {
5176    if field_names.contains(&cased_name) {
5177        format!("{cased_name}{suffix}")
5178    } else {
5179        cased_name
5180    }
5181}
5182
5183// ─── Tests ───────────────────────────────────────────────────────────────────
5184
5185#[cfg(test)]
5186mod tests {
5187    use super::*;
5188
5189    #[test]
5190    fn disambiguate_method_name_suffixes_only_on_collision() {
5191        let mut fields = std::collections::HashSet::new();
5192        fields.insert("message".to_string());
5193        fields.insert("Message".to_string());
5194        // Camel/snake non-colliding name passes through unchanged.
5195        assert_eq!(
5196            disambiguate_method_name("render".to_string(), &fields, "Method"),
5197            "render"
5198        );
5199        // Colliding camel name gets the camel suffix.
5200        assert_eq!(
5201            disambiguate_method_name("message".to_string(), &fields, "Method"),
5202            "messageMethod"
5203        );
5204        // Colliding Pascal name (Go) gets the Pascal suffix.
5205        assert_eq!(
5206            disambiguate_method_name("Message".to_string(), &fields, "Method"),
5207            "MessageMethod"
5208        );
5209        // Colliding snake name (Python) gets the snake suffix.
5210        assert_eq!(
5211            disambiguate_method_name("message".to_string(), &fields, "_method"),
5212            "message_method"
5213        );
5214    }
5215
5216    #[test]
5217    fn merge_where_bounds_folds_constraints_onto_matching_param() {
5218        use bock_ast::{GenericParam, Ident, TypeConstraint, TypePath};
5219        use bock_errors::{FileId, Span};
5220
5221        fn span() -> Span {
5222            Span {
5223                file: FileId(0),
5224                start: 0,
5225                end: 0,
5226            }
5227        }
5228        fn ident(name: &str) -> Ident {
5229            Ident {
5230                span: span(),
5231                name: name.to_string(),
5232            }
5233        }
5234        fn type_path(name: &str) -> TypePath {
5235            TypePath {
5236                segments: vec![ident(name)],
5237                span: span(),
5238            }
5239        }
5240        fn param(name: &str, bounds: Vec<TypePath>) -> GenericParam {
5241            GenericParam {
5242                id: 0,
5243                span: span(),
5244                name: ident(name),
5245                bounds,
5246            }
5247        }
5248        fn constraint(param: &str, bounds: Vec<TypePath>) -> TypeConstraint {
5249            TypeConstraint {
5250                id: 0,
5251                span: span(),
5252                param: ident(param),
5253                bounds,
5254            }
5255        }
5256
5257        // No where-clause: params pass through unchanged.
5258        let params = vec![param("T", vec![])];
5259        let merged = merge_where_bounds_into_generics(&params, &[]);
5260        assert_eq!(merged, params);
5261
5262        // `where (T: Ranked)` folds onto T; the unconstrained U is untouched.
5263        let params = vec![param("T", vec![]), param("U", vec![])];
5264        let wc = vec![constraint("T", vec![type_path("Ranked")])];
5265        let merged = merge_where_bounds_into_generics(&params, &wc);
5266        assert_eq!(
5267            merged[0]
5268                .bounds
5269                .iter()
5270                .map(|b| &b.segments[0].name)
5271                .collect::<Vec<_>>(),
5272            vec!["Ranked"]
5273        );
5274        assert!(merged[1].bounds.is_empty());
5275
5276        // An inline bound is preserved and the where-clause bound is appended.
5277        let params = vec![param("T", vec![type_path("Show")])];
5278        let wc = vec![constraint("T", vec![type_path("Ranked")])];
5279        let merged = merge_where_bounds_into_generics(&params, &wc);
5280        assert_eq!(
5281            merged[0]
5282                .bounds
5283                .iter()
5284                .map(|b| &b.segments[0].name)
5285                .collect::<Vec<_>>(),
5286            vec!["Show", "Ranked"]
5287        );
5288    }
5289
5290    #[test]
5291    fn collect_record_field_names_unions_records_and_classes() {
5292        use bock_ast::{Ident, RecordDeclField, TypeExpr, TypePath, Visibility};
5293        use bock_errors::{FileId, Span};
5294
5295        fn span() -> Span {
5296            Span {
5297                file: FileId(0),
5298                start: 0,
5299                end: 0,
5300            }
5301        }
5302        fn ident(name: &str) -> Ident {
5303            Ident {
5304                name: name.to_string(),
5305                span: span(),
5306            }
5307        }
5308        fn ty() -> TypeExpr {
5309            TypeExpr::Named {
5310                id: 0,
5311                span: span(),
5312                path: TypePath {
5313                    segments: vec![ident("String")],
5314                    span: span(),
5315                },
5316                args: vec![],
5317            }
5318        }
5319        fn field(name: &str) -> RecordDeclField {
5320            RecordDeclField {
5321                id: 0,
5322                span: span(),
5323                name: ident(name),
5324                ty: ty(),
5325                default: None,
5326            }
5327        }
5328
5329        let record = AIRNode::new(
5330            1,
5331            span(),
5332            NodeKind::RecordDecl {
5333                annotations: vec![],
5334                visibility: Visibility::Public,
5335                name: ident("SimpleError"),
5336                generic_params: vec![],
5337                fields: vec![field("message")],
5338            },
5339        );
5340        let class = AIRNode::new(
5341            2,
5342            span(),
5343            NodeKind::ClassDecl {
5344                annotations: vec![],
5345                visibility: Visibility::Public,
5346                name: ident("Handler"),
5347                generic_params: vec![],
5348                base: None,
5349                traits: vec![],
5350                fields: vec![field("state")],
5351                methods: vec![],
5352            },
5353        );
5354        let module = AIRNode::new(
5355            0,
5356            span(),
5357            NodeKind::Module {
5358                path: None,
5359                annotations: vec![],
5360                imports: vec![],
5361                items: vec![record, class],
5362            },
5363        );
5364
5365        // Identity casing → raw field names unioned from a record and a class.
5366        let names = collect_record_field_names(&module, |n| n.to_string());
5367        assert!(names.contains("message"));
5368        assert!(names.contains("state"));
5369        assert_eq!(names.len(), 2);
5370    }
5371
5372    #[test]
5373    fn output_file_stores_path_and_content() {
5374        let f = OutputFile {
5375            path: PathBuf::from("main.js"),
5376            content: "console.log('hello');".into(),
5377            source_map: None,
5378        };
5379        assert_eq!(f.path, PathBuf::from("main.js"));
5380        assert!(f.content.contains("console.log"));
5381    }
5382
5383    #[test]
5384    fn generated_code_with_no_source_map() {
5385        let code = GeneratedCode {
5386            files: vec![OutputFile {
5387                path: PathBuf::from("out.py"),
5388                content: "print('hello')".into(),
5389                source_map: None,
5390            }],
5391        };
5392        assert_eq!(code.files.len(), 1);
5393        assert!(code.files[0].source_map.is_none());
5394    }
5395
5396    #[test]
5397    fn derive_output_path_strips_src_prefix() {
5398        let js = TargetProfile::javascript();
5399        assert_eq!(
5400            derive_output_path(Path::new("src/main.bock"), &js),
5401            PathBuf::from("main.js")
5402        );
5403        assert_eq!(
5404            derive_output_path(Path::new("src/utils/parse.bock"), &js),
5405            PathBuf::from("utils/parse.js")
5406        );
5407        assert_eq!(
5408            derive_output_path(Path::new("src/api/v1/handler.bock"), &js),
5409            PathBuf::from("api/v1/handler.js")
5410        );
5411    }
5412
5413    #[test]
5414    fn derive_output_path_preserves_paths_without_src_prefix() {
5415        let py = TargetProfile::python();
5416        assert_eq!(
5417            derive_output_path(Path::new("main.bock"), &py),
5418            PathBuf::from("main.py")
5419        );
5420        assert_eq!(
5421            derive_output_path(Path::new("lib/foo.bock"), &py),
5422            PathBuf::from("lib/foo.py")
5423        );
5424    }
5425
5426    #[test]
5427    fn derive_output_path_normalizes_leading_curdir() {
5428        let js = TargetProfile::javascript();
5429        assert_eq!(
5430            derive_output_path(Path::new("./src/main.bock"), &js),
5431            PathBuf::from("main.js")
5432        );
5433        assert_eq!(
5434            derive_output_path(Path::new("./main.bock"), &js),
5435            PathBuf::from("main.js")
5436        );
5437        assert_eq!(
5438            derive_output_path(Path::new("./src/utils/parse.bock"), &js),
5439            PathBuf::from("utils/parse.js")
5440        );
5441    }
5442
5443    #[test]
5444    fn derive_output_path_uses_target_extension() {
5445        let path = Path::new("src/main.bock");
5446        assert_eq!(
5447            derive_output_path(path, &TargetProfile::javascript()),
5448            PathBuf::from("main.js")
5449        );
5450        assert_eq!(
5451            derive_output_path(path, &TargetProfile::typescript()),
5452            PathBuf::from("main.ts")
5453        );
5454        assert_eq!(
5455            derive_output_path(path, &TargetProfile::python()),
5456            PathBuf::from("main.py")
5457        );
5458        assert_eq!(
5459            derive_output_path(path, &TargetProfile::rust()),
5460            PathBuf::from("main.rs")
5461        );
5462        assert_eq!(
5463            derive_output_path(path, &TargetProfile::go()),
5464            PathBuf::from("main.go")
5465        );
5466    }
5467
5468    #[test]
5469    fn esm_relative_specifier_from_entry_root() {
5470        // Entry (`main.<ext>` at the build root) → a `core.option` sibling.
5471        assert_eq!(
5472            esm_relative_specifier("", "core.option", "js"),
5473            "./core/option.js"
5474        );
5475        // Entry → a root-level sibling module.
5476        assert_eq!(esm_relative_specifier("", "helper", "js"), "./helper.js");
5477    }
5478
5479    #[test]
5480    fn esm_relative_specifier_between_nested_modules() {
5481        // `helper` (root) → `core.option` (nested): one level down.
5482        assert_eq!(
5483            esm_relative_specifier("helper", "core.option", "ts"),
5484            "./core/option.ts"
5485        );
5486        // `core.option` → `core.compare`: same dir, no `../`.
5487        assert_eq!(
5488            esm_relative_specifier("core.option", "core.compare", "js"),
5489            "./compare.js"
5490        );
5491        // `a.b.deep` → `helper` (root): climb out of `a/b/`.
5492        assert_eq!(
5493            esm_relative_specifier("a.b.deep", "helper", "js"),
5494            "../../helper.js"
5495        );
5496        // `a.b.deep` → `a.c.thing`: climb to the common `a/` then descend.
5497        assert_eq!(
5498            esm_relative_specifier("a.b.deep", "a.c.thing", "js"),
5499            "../c/thing.js"
5500        );
5501    }
5502
5503    #[test]
5504    fn source_map_default_is_empty() {
5505        let sm = SourceMap::default();
5506        assert!(sm.entries.is_empty());
5507        assert!(sm.mappings.is_empty());
5508        assert!(sm.sources.is_empty());
5509    }
5510
5511    #[test]
5512    fn byte_to_line_col_basic() {
5513        let s = "abc\ndef\nghi";
5514        assert_eq!(byte_to_line_col(s, 0), (1, 1));
5515        assert_eq!(byte_to_line_col(s, 3), (1, 4));
5516        assert_eq!(byte_to_line_col(s, 4), (2, 1));
5517        assert_eq!(byte_to_line_col(s, 8), (3, 1));
5518    }
5519
5520    #[test]
5521    fn resolve_positions_fills_line_col() {
5522        let mut sm = SourceMap {
5523            mappings: vec![SourceMapping {
5524                gen_line: 1,
5525                gen_col: 1,
5526                src_line: 0,
5527                src_col: 0,
5528                src_offset: 4,
5529                src_file_id: 0,
5530            }],
5531            ..Default::default()
5532        };
5533        sm.resolve_positions(&["abc\ndef"]);
5534        assert_eq!(sm.mappings[0].src_line, 2);
5535        assert_eq!(sm.mappings[0].src_col, 1);
5536    }
5537
5538    #[test]
5539    fn vlq_encodes_known_values() {
5540        // Source Map v3 VLQ reference values.
5541        let mut s = String::new();
5542        vlq_encode(&mut s, 0);
5543        assert_eq!(s, "A");
5544        s.clear();
5545        vlq_encode(&mut s, 1);
5546        assert_eq!(s, "C");
5547        s.clear();
5548        vlq_encode(&mut s, -1);
5549        assert_eq!(s, "D");
5550        s.clear();
5551        vlq_encode(&mut s, 16);
5552        assert_eq!(s, "gB");
5553    }
5554
5555    #[test]
5556    fn source_map_v3_json_contains_required_fields() {
5557        let mut sm = SourceMap {
5558            generated_file: "output.js".into(),
5559            ..Default::default()
5560        };
5561        sm.sources.push(SourceInfo {
5562            path: "main.bock".into(),
5563            content: Some("let x = 1\n".into()),
5564        });
5565        sm.mappings.push(SourceMapping {
5566            gen_line: 1,
5567            gen_col: 1,
5568            src_line: 1,
5569            src_col: 1,
5570            src_offset: 0,
5571            src_file_id: 0,
5572        });
5573        let json = sm.to_source_map_v3_json();
5574        assert!(json.contains("\"version\":3"));
5575        assert!(json.contains("\"file\":\"output.js\""));
5576        assert!(json.contains("\"sources\":[\"main.bock\"]"));
5577        assert!(json.contains("\"mappings\":"));
5578    }
5579
5580    // ── module_declares_main_fn ─────────────────────────────────────────────
5581
5582    use bock_air::AIRNode;
5583    use bock_ast::{Ident, Visibility};
5584    use bock_errors::{FileId, Span};
5585
5586    fn dummy_span() -> Span {
5587        Span {
5588            file: FileId(0),
5589            start: 0,
5590            end: 0,
5591        }
5592    }
5593
5594    fn ident(name: &str) -> Ident {
5595        Ident {
5596            name: name.to_string(),
5597            span: dummy_span(),
5598        }
5599    }
5600
5601    fn fn_decl(name: &str) -> AIRNode {
5602        let body = AIRNode::new(
5603            1,
5604            dummy_span(),
5605            NodeKind::Block {
5606                stmts: vec![],
5607                tail: None,
5608            },
5609        );
5610        AIRNode::new(
5611            0,
5612            dummy_span(),
5613            NodeKind::FnDecl {
5614                annotations: vec![],
5615                visibility: Visibility::Public,
5616                is_async: false,
5617                name: ident(name),
5618                generic_params: vec![],
5619                params: vec![],
5620                return_type: None,
5621                effect_clause: vec![],
5622                where_clause: vec![],
5623                body: Box::new(body),
5624            },
5625        )
5626    }
5627
5628    fn module_with(items: Vec<AIRNode>) -> AIRNode {
5629        AIRNode::new(
5630            0,
5631            dummy_span(),
5632            NodeKind::Module {
5633                path: None,
5634                annotations: vec![],
5635                imports: vec![],
5636                items,
5637            },
5638        )
5639    }
5640
5641    /// A `module <path.segments>` whose `imports` are `use <dep>` of each name
5642    /// in `uses`, carrying the given top-level `items`.
5643    fn module_named(path: &str, uses: &[&str], items: Vec<AIRNode>) -> AIRNode {
5644        use bock_ast::ModulePath;
5645        let module_path = ModulePath {
5646            segments: path.split('.').map(ident).collect(),
5647            span: dummy_span(),
5648        };
5649        let imports = uses
5650            .iter()
5651            .enumerate()
5652            .map(|(i, dep)| {
5653                AIRNode::new(
5654                    100 + i as u32,
5655                    dummy_span(),
5656                    NodeKind::ImportDecl {
5657                        path: bock_ast::ModulePath {
5658                            segments: dep.split('.').map(ident).collect(),
5659                            span: dummy_span(),
5660                        },
5661                        items: bock_ast::ImportItems::Glob,
5662                    },
5663                )
5664            })
5665            .collect();
5666        AIRNode::new(
5667            0,
5668            dummy_span(),
5669            NodeKind::Module {
5670                path: Some(module_path),
5671                annotations: vec![],
5672                imports,
5673                items,
5674            },
5675        )
5676    }
5677
5678    // ── Transpiled-test extraction + assertion classification (S7) ───────────
5679
5680    /// A `@test`-annotated `fn` named `name` with the given body block.
5681    fn test_fn_decl(name: &str, body: AIRNode) -> AIRNode {
5682        use bock_ast::{Annotation, Visibility};
5683        let annotation = Annotation {
5684            id: 0,
5685            name: ident("test"),
5686            args: vec![],
5687            span: dummy_span(),
5688        };
5689        AIRNode::new(
5690            0,
5691            dummy_span(),
5692            NodeKind::FnDecl {
5693                annotations: vec![annotation],
5694                visibility: Visibility::Private,
5695                is_async: false,
5696                name: ident(name),
5697                generic_params: vec![],
5698                params: vec![],
5699                return_type: None,
5700                effect_clause: vec![],
5701                where_clause: vec![],
5702                body: Box::new(body),
5703            },
5704        )
5705    }
5706
5707    fn identifier(id: u32, name: &str) -> AIRNode {
5708        AIRNode::new(id, dummy_span(), NodeKind::Identifier { name: ident(name) })
5709    }
5710
5711    fn call(id: u32, callee: AIRNode, args: Vec<AIRNode>) -> AIRNode {
5712        AIRNode::new(
5713            id,
5714            dummy_span(),
5715            NodeKind::Call {
5716                callee: Box::new(callee),
5717                args: args
5718                    .into_iter()
5719                    .map(|value| AirArg { label: None, value })
5720                    .collect(),
5721                type_args: vec![],
5722            },
5723        )
5724    }
5725
5726    /// Build the lowered AIR for `expect(actual).<method>(expected?)`: a `Call`
5727    /// whose callee is `FieldAccess(expect(actual), method)` and whose first arg
5728    /// is the desugared `self` (a copy of the `expect(...)` receiver). This is the
5729    /// exact shape `bock-air::lower` produces for a method call.
5730    fn assertion(method: &str, actual: AIRNode, expected: Option<AIRNode>) -> AIRNode {
5731        let expect_call = call(10, identifier(11, "expect"), vec![actual]);
5732        let field = AIRNode::new(
5733            12,
5734            dummy_span(),
5735            NodeKind::FieldAccess {
5736                object: Box::new(expect_call.clone()),
5737                field: ident(method),
5738            },
5739        );
5740        let mut args = vec![expect_call];
5741        if let Some(e) = expected {
5742            args.push(e);
5743        }
5744        call(13, field, args)
5745    }
5746
5747    #[test]
5748    fn fn_is_test_detects_test_annotation() {
5749        let body = AIRNode::new(
5750            1,
5751            dummy_span(),
5752            NodeKind::Block {
5753                stmts: vec![],
5754                tail: None,
5755            },
5756        );
5757        assert!(fn_is_test(&test_fn_decl("t", body)));
5758        assert!(!fn_is_test(&fn_decl("not_a_test")));
5759    }
5760
5761    #[test]
5762    fn collect_test_fns_finds_annotated_functions() {
5763        let body = AIRNode::new(
5764            1,
5765            dummy_span(),
5766            NodeKind::Block {
5767                stmts: vec![],
5768                tail: None,
5769            },
5770        );
5771        let m = module_named(
5772            "main",
5773            &[],
5774            vec![
5775                fn_decl("main"),
5776                test_fn_decl("test_a", body.clone()),
5777                fn_decl("helper"),
5778                test_fn_decl("test_b", body),
5779            ],
5780        );
5781        let p = std::path::Path::new("x.bock");
5782        let modules = [(&m, p)];
5783        let tests = collect_test_fns(&modules);
5784        assert_eq!(tests.len(), 2);
5785        let names: Vec<&str> = tests
5786            .iter()
5787            .map(|(n, _)| match &n.kind {
5788                NodeKind::FnDecl { name, .. } => name.name.as_str(),
5789                _ => "?",
5790            })
5791            .collect();
5792        assert_eq!(names, vec!["test_a", "test_b"]);
5793        assert_eq!(tests[0].1, "main");
5794    }
5795
5796    #[test]
5797    fn classify_assertion_recognizes_equal() {
5798        let stmt = assertion("to_equal", identifier(1, "x"), Some(identifier(2, "y")));
5799        let (kind, actual, expected) = classify_assertion(&stmt).expect("should classify");
5800        assert_eq!(kind, TestAssertion::Equal);
5801        assert!(matches!(&actual.kind, NodeKind::Identifier { name } if name.name == "x"));
5802        let expected = expected.expect("equal has an expected operand");
5803        assert!(matches!(&expected.kind, NodeKind::Identifier { name } if name.name == "y"));
5804    }
5805
5806    #[test]
5807    fn classify_assertion_recognizes_nullary_predicates() {
5808        for (method, expected_kind) in [
5809            ("to_be_true", TestAssertion::BeTrue),
5810            ("to_be_false", TestAssertion::BeFalse),
5811            ("to_be_some", TestAssertion::BeSome),
5812            ("to_be_none", TestAssertion::BeNone),
5813            ("to_be_ok", TestAssertion::BeOk),
5814            ("to_be_err", TestAssertion::BeErr),
5815        ] {
5816            let stmt = assertion(method, identifier(1, "v"), None);
5817            let (kind, _actual, expected) =
5818                classify_assertion(&stmt).unwrap_or_else(|| panic!("classify {method}"));
5819            assert_eq!(kind, expected_kind, "method {method}");
5820            assert!(expected.is_none(), "{method} takes no expected operand");
5821        }
5822    }
5823
5824    #[test]
5825    fn classify_assertion_rejects_non_assertions() {
5826        // A plain function call is not an assertion.
5827        let plain = call(1, identifier(2, "do_thing"), vec![]);
5828        assert!(classify_assertion(&plain).is_none());
5829        // A method call whose receiver is not `expect(...)`.
5830        let other = {
5831            let recv = call(3, identifier(4, "build"), vec![]);
5832            let field = AIRNode::new(
5833                5,
5834                dummy_span(),
5835                NodeKind::FieldAccess {
5836                    object: Box::new(recv.clone()),
5837                    field: ident("to_equal"),
5838                },
5839            );
5840            call(6, field, vec![recv, identifier(7, "z")])
5841        };
5842        assert!(classify_assertion(&other).is_none());
5843        // An unknown assertion method on `expect(...)`.
5844        let unknown = assertion("to_be_weird", identifier(8, "v"), None);
5845        assert!(classify_assertion(&unknown).is_none());
5846    }
5847
5848    #[test]
5849    fn reachable_modules_prunes_unused_prelude_modules() {
5850        // Mirrors a `bock build`: the embedded `core.*` stdlib is prepended in
5851        // dependency order, then the user `main`. `main` uses NOTHING, so only
5852        // `main` should be emitted — never the prelude-only stdlib.
5853        let core_a = module_named("core.compare", &[], vec![]);
5854        let core_b = module_named("core.convert", &["core.compare"], vec![]);
5855        let main_m = module_named("main", &[], vec![fn_decl("main")]);
5856        let p = std::path::Path::new("x.bock");
5857        let modules = [(&core_a, p), (&core_b, p), (&main_m, p)];
5858        let got = reachable_modules(&modules);
5859        assert_eq!(got.len(), 1, "only the entry module should be reachable");
5860        assert!(module_declares_main_fn(got[0].0));
5861    }
5862
5863    #[test]
5864    fn reachable_modules_includes_transitive_use_targets() {
5865        // `main` uses `util`, `util` uses `helper`; an unrelated `unused`
5866        // module is excluded. The emitted tree must include the transitive
5867        // `use` closure (main, util, helper) but drop `unused`.
5868        let helper = module_named("helper", &[], vec![fn_decl("h")]);
5869        let util = module_named("util", &["helper"], vec![fn_decl("u")]);
5870        let unused = module_named("unused", &[], vec![fn_decl("x")]);
5871        let main_m = module_named("main", &["util"], vec![fn_decl("main")]);
5872        let p = std::path::Path::new("x.bock");
5873        let modules = [(&helper, p), (&util, p), (&unused, p), (&main_m, p)];
5874        let got = reachable_modules(&modules);
5875        let paths: Vec<String> = got
5876            .iter()
5877            .map(|(m, _)| {
5878                let NodeKind::Module { path: Some(pp), .. } = &m.kind else {
5879                    return String::new();
5880                };
5881                pp.segments
5882                    .iter()
5883                    .map(|s| s.name.as_str())
5884                    .collect::<Vec<_>>()
5885                    .join(".")
5886            })
5887            .collect();
5888        assert!(paths.contains(&"main".to_string()));
5889        assert!(paths.contains(&"util".to_string()));
5890        assert!(paths.contains(&"helper".to_string()));
5891        assert!(!paths.contains(&"unused".to_string()), "got: {paths:?}");
5892        // Dependency order is preserved (helper before util before main).
5893        let pos = |name: &str| paths.iter().position(|x| x == name).unwrap();
5894        assert!(pos("helper") < pos("util"));
5895        assert!(pos("util") < pos("main"));
5896    }
5897
5898    #[test]
5899    fn reachable_modules_order_is_input_order_independent() {
5900        // The emitted module order must be deterministic regardless of the order
5901        // the `modules` slice arrives in — the upstream topological sort iterates a
5902        // `HashMap`/`HashSet` with a per-process random seed, so independent
5903        // modules can be presented in any (valid) order. `main` uses three
5904        // mutually-independent cores plus a transitive chain; whatever the input
5905        // permutation, the reachable order must be byte-identical (and still
5906        // dependency-before-dependent). This is the guard for the random
5907        // `bock build` failure once several embedded `core.*` were reachable.
5908        let leaf = module_named("z.leaf", &[], vec![fn_decl("l")]);
5909        let a = module_named("core.a", &["z.leaf"], vec![fn_decl("a")]);
5910        let b = module_named("core.b", &[], vec![fn_decl("b")]);
5911        let c = module_named("core.c", &[], vec![fn_decl("c")]);
5912        let main_m = module_named(
5913            "main",
5914            &["core.a", "core.b", "core.c"],
5915            vec![fn_decl("main")],
5916        );
5917        let p = std::path::Path::new("x.bock");
5918
5919        let names = |got: &[(&AIRModule, &std::path::Path)]| -> Vec<String> {
5920            got.iter()
5921                .filter_map(|(m, _)| {
5922                    if let NodeKind::Module { path: Some(pp), .. } = &m.kind {
5923                        Some(
5924                            pp.segments
5925                                .iter()
5926                                .map(|s| s.name.as_str())
5927                                .collect::<Vec<_>>()
5928                                .join("."),
5929                        )
5930                    } else {
5931                        None
5932                    }
5933                })
5934                .collect()
5935        };
5936
5937        // Several distinct input permutations of the same module set.
5938        let perm1 = [(&leaf, p), (&a, p), (&b, p), (&c, p), (&main_m, p)];
5939        let perm2 = [(&c, p), (&main_m, p), (&b, p), (&leaf, p), (&a, p)];
5940        let perm3 = [(&main_m, p), (&c, p), (&b, p), (&a, p), (&leaf, p)];
5941        let o1 = names(&reachable_modules(&perm1));
5942        let o2 = names(&reachable_modules(&perm2));
5943        let o3 = names(&reachable_modules(&perm3));
5944        assert_eq!(o1, o2, "module order must not depend on input order");
5945        assert_eq!(o1, o3, "module order must not depend on input order");
5946        // All five reachable, dependency-before-dependent, ties canonical.
5947        assert_eq!(o1.len(), 5, "got: {o1:?}");
5948        let pos = |name: &str| o1.iter().position(|x| x == name).unwrap();
5949        assert!(pos("z.leaf") < pos("core.a"), "got: {o1:?}");
5950        assert!(pos("core.a") < pos("main"), "got: {o1:?}");
5951        assert!(pos("core.b") < pos("main"), "got: {o1:?}");
5952        assert!(pos("core.c") < pos("main"), "got: {o1:?}");
5953        // `main` (the dependent) is emitted last.
5954        assert_eq!(o1.last().map(String::as_str), Some("main"), "got: {o1:?}");
5955    }
5956
5957    #[test]
5958    fn module_declares_main_detects_top_level_main() {
5959        let m = module_with(vec![fn_decl("helper"), fn_decl("main")]);
5960        assert!(module_declares_main_fn(&m));
5961    }
5962
5963    #[test]
5964    fn module_declares_main_returns_false_when_absent() {
5965        let m = module_with(vec![fn_decl("helper"), fn_decl("other")]);
5966        assert!(!module_declares_main_fn(&m));
5967    }
5968
5969    #[test]
5970    fn module_declares_main_returns_false_for_empty_module() {
5971        let m = module_with(vec![]);
5972        assert!(!module_declares_main_fn(&m));
5973    }
5974
5975    // ── Statement / match / desugar helpers ─────────────────────────────────
5976
5977    fn n(id: u32, kind: NodeKind) -> AIRNode {
5978        AIRNode::new(id, dummy_span(), kind)
5979    }
5980
5981    fn match_arm(id: u32, body: AIRNode) -> AIRNode {
5982        n(
5983            id,
5984            NodeKind::MatchArm {
5985                pattern: Box::new(n(id + 1, NodeKind::WildcardPat)),
5986                guard: None,
5987                body: Box::new(body),
5988            },
5989        )
5990    }
5991
5992    #[test]
5993    fn node_is_statement_classifies_control_flow() {
5994        assert!(node_is_statement(&n(1, NodeKind::Break { value: None })));
5995        assert!(node_is_statement(&n(1, NodeKind::Continue)));
5996        assert!(node_is_statement(&n(1, NodeKind::Return { value: None })));
5997        assert!(!node_is_statement(&n(
5998            1,
5999            NodeKind::Literal {
6000                lit: bock_ast::Literal::Int("1".into())
6001            }
6002        )));
6003    }
6004
6005    /// A `{ tail }` block carrying a single tail node.
6006    fn block_with_tail(id: u32, tail: AIRNode) -> AIRNode {
6007        n(
6008            id,
6009            NodeKind::Block {
6010                stmts: vec![],
6011                tail: Some(Box::new(tail)),
6012            },
6013        )
6014    }
6015
6016    /// A bare `1` literal node (an expression with a usable value).
6017    fn int_lit(id: u32) -> AIRNode {
6018        n(
6019            id,
6020            NodeKind::Literal {
6021                lit: bock_ast::Literal::Int("1".into()),
6022            },
6023        )
6024    }
6025
6026    /// An `if` node: `if <cond> <then_block> [else <else_block>]`. Condition is
6027    /// a placeholder; only the branch shapes matter for classification.
6028    fn if_node(id: u32, then_block: AIRNode, else_block: Option<AIRNode>) -> AIRNode {
6029        n(
6030            id,
6031            NodeKind::If {
6032                let_pattern: None,
6033                condition: Box::new(n(id + 100, NodeKind::Placeholder)),
6034                then_block: Box::new(then_block),
6035                else_block: else_block.map(Box::new),
6036            },
6037        )
6038    }
6039
6040    #[test]
6041    fn node_is_statement_classifies_no_else_if_as_statement() {
6042        // `if (c) { return }` — no else, yields no value → statement (DV15).
6043        let no_else = if_node(
6044            1,
6045            block_with_tail(2, n(3, NodeKind::Return { value: None })),
6046            None,
6047        );
6048        assert!(node_is_statement(&no_else));
6049
6050        // `if (c) { break }` and `if (c) { continue }` likewise.
6051        let no_else_break = if_node(
6052            10,
6053            block_with_tail(11, n(12, NodeKind::Break { value: None })),
6054            None,
6055        );
6056        assert!(node_is_statement(&no_else_break));
6057    }
6058
6059    #[test]
6060    fn node_is_statement_classifies_all_statement_if_else_as_statement() {
6061        // `if (c) { return a } else { return b }` — both branches statements,
6062        // neither yields a value → statement.
6063        let stmt_both = if_node(
6064            1,
6065            block_with_tail(2, n(3, NodeKind::Return { value: None })),
6066            Some(block_with_tail(4, n(5, NodeKind::Break { value: None }))),
6067        );
6068        assert!(node_is_statement(&stmt_both));
6069    }
6070
6071    #[test]
6072    fn node_is_statement_leaves_value_if_else_an_expression() {
6073        // `let x = if (c) { 1 } else { 2 }` — both branches end in an
6074        // expression tail, so the `if` yields a value and must stay an
6075        // expression. Misclassifying it as a statement would break value `if`.
6076        let value_if = if_node(
6077            1,
6078            block_with_tail(2, int_lit(3)),
6079            Some(block_with_tail(4, int_lit(5))),
6080        );
6081        assert!(!node_is_statement(&value_if));
6082        assert!(!arm_body_is_statement(&value_if));
6083    }
6084
6085    #[test]
6086    fn node_is_statement_leaves_mixed_if_else_an_expression() {
6087        // One statement branch, one value branch → the `if` can yield a value
6088        // on the value branch, so it is not a pure statement. Stays expression.
6089        let mixed = if_node(
6090            1,
6091            block_with_tail(2, n(3, NodeKind::Return { value: None })),
6092            Some(block_with_tail(4, int_lit(5))),
6093        );
6094        assert!(!node_is_statement(&mixed));
6095    }
6096
6097    #[test]
6098    fn node_is_statement_handles_else_if_chains() {
6099        // `if (a) { return } else if (b) { break }` — the `else` is itself a
6100        // no-else statement `if`, so the whole chain is a statement.
6101        let inner = if_node(
6102            20,
6103            block_with_tail(21, n(22, NodeKind::Break { value: None })),
6104            None,
6105        );
6106        let chain = if_node(
6107            1,
6108            block_with_tail(2, n(3, NodeKind::Return { value: None })),
6109            Some(inner),
6110        );
6111        assert!(node_is_statement(&chain));
6112
6113        // `if (a) { return } else if (b) { 1 } else { 2 }` — the trailing
6114        // else-if yields a value, so the chain stays an expression.
6115        let value_inner = if_node(
6116            30,
6117            block_with_tail(31, int_lit(32)),
6118            Some(block_with_tail(33, int_lit(34))),
6119        );
6120        let mixed_chain = if_node(
6121            40,
6122            block_with_tail(41, n(42, NodeKind::Return { value: None })),
6123            Some(value_inner),
6124        );
6125        assert!(!node_is_statement(&mixed_chain));
6126    }
6127
6128    #[test]
6129    fn arm_body_is_statement_for_block_with_statement_tail() {
6130        let block_tail_break = n(
6131            1,
6132            NodeKind::Block {
6133                stmts: vec![],
6134                tail: Some(Box::new(n(2, NodeKind::Break { value: None }))),
6135            },
6136        );
6137        assert!(arm_body_is_statement(&block_tail_break));
6138        // A block with no tail yields no value → statement.
6139        let empty = n(
6140            3,
6141            NodeKind::Block {
6142                stmts: vec![],
6143                tail: None,
6144            },
6145        );
6146        assert!(arm_body_is_statement(&empty));
6147    }
6148
6149    #[test]
6150    fn match_has_statement_arm_detects_break() {
6151        let arms = vec![
6152            match_arm(10, n(12, NodeKind::Break { value: None })),
6153            match_arm(
6154                20,
6155                n(
6156                    22,
6157                    NodeKind::Literal {
6158                        lit: bock_ast::Literal::Int("0".into()),
6159                    },
6160                ),
6161            ),
6162        ];
6163        assert!(match_has_statement_arm(&arms));
6164
6165        let value_arms = vec![match_arm(
6166            30,
6167            n(
6168                32,
6169                NodeKind::Literal {
6170                    lit: bock_ast::Literal::Int("0".into()),
6171                },
6172            ),
6173        )];
6174        assert!(!match_has_statement_arm(&value_arms));
6175    }
6176
6177    /// A single-segment type path (`Some`, `Ok`, …) for constructor patterns.
6178    fn ctor_path(name: &str) -> bock_ast::TypePath {
6179        bock_ast::TypePath {
6180            segments: vec![ident(name)],
6181            span: dummy_span(),
6182        }
6183    }
6184
6185    /// A `match` arm with an explicit pattern and optional guard.
6186    fn arm_with(id: u32, pattern: AIRNode, guard: Option<AIRNode>) -> AIRNode {
6187        n(
6188            id,
6189            NodeKind::MatchArm {
6190                pattern: Box::new(pattern),
6191                guard: guard.map(Box::new),
6192                body: Box::new(int_lit(id + 100)),
6193            },
6194        )
6195    }
6196
6197    #[test]
6198    fn match_needs_ifchain_keeps_switch_fast_path_for_simple_matches() {
6199        // A bind-only / wildcard match (`x => …`, `_ => …`) stays on the switch.
6200        let bind_arms = vec![
6201            arm_with(
6202                1,
6203                n(
6204                    2,
6205                    NodeKind::BindPat {
6206                        name: ident("x"),
6207                        is_mut: false,
6208                    },
6209                ),
6210                None,
6211            ),
6212            arm_with(3, n(4, NodeKind::WildcardPat), None),
6213        ];
6214        assert!(!match_needs_ifchain(&bind_arms));
6215
6216        // A flat `Some(x)` / `Ok(v)` constructor match (bare-bind fields) stays
6217        // on the switch — the proven Optional/Result lowering must not regress.
6218        let flat_ctor = vec![arm_with(
6219            10,
6220            n(
6221                11,
6222                NodeKind::ConstructorPat {
6223                    path: ctor_path("Some"),
6224                    fields: vec![n(
6225                        12,
6226                        NodeKind::BindPat {
6227                            name: ident("x"),
6228                            is_mut: false,
6229                        },
6230                    )],
6231                },
6232            ),
6233            None,
6234        )];
6235        assert!(!match_needs_ifchain(&flat_ctor));
6236    }
6237
6238    #[test]
6239    fn match_needs_ifchain_detects_guard() {
6240        let arms = vec![arm_with(1, n(2, NodeKind::WildcardPat), Some(int_lit(3)))];
6241        assert!(match_needs_ifchain(&arms));
6242    }
6243
6244    #[test]
6245    fn match_needs_ifchain_detects_or_and_tuple() {
6246        let or_arm = vec![arm_with(
6247            1,
6248            n(
6249                2,
6250                NodeKind::OrPat {
6251                    alternatives: vec![int_lit(3), int_lit(4)],
6252                },
6253            ),
6254            None,
6255        )];
6256        assert!(match_needs_ifchain(&or_arm));
6257
6258        let tuple_arm = vec![arm_with(
6259            10,
6260            n(
6261                11,
6262                NodeKind::TuplePat {
6263                    elems: vec![
6264                        n(
6265                            12,
6266                            NodeKind::BindPat {
6267                                name: ident("a"),
6268                                is_mut: false,
6269                            },
6270                        ),
6271                        n(
6272                            13,
6273                            NodeKind::BindPat {
6274                                name: ident("b"),
6275                                is_mut: false,
6276                            },
6277                        ),
6278                    ],
6279                },
6280            ),
6281            None,
6282        )];
6283        assert!(match_needs_ifchain(&tuple_arm));
6284    }
6285
6286    #[test]
6287    fn match_needs_ifchain_detects_nested_constructor() {
6288        // `Some(Ok(v))`: the inner field is itself a constructor → nested.
6289        let nested = vec![arm_with(
6290            1,
6291            n(
6292                2,
6293                NodeKind::ConstructorPat {
6294                    path: ctor_path("Some"),
6295                    fields: vec![n(
6296                        3,
6297                        NodeKind::ConstructorPat {
6298                            path: ctor_path("Ok"),
6299                            fields: vec![n(
6300                                4,
6301                                NodeKind::BindPat {
6302                                    name: ident("v"),
6303                                    is_mut: false,
6304                                },
6305                            )],
6306                        },
6307                    )],
6308                },
6309            ),
6310            None,
6311        )];
6312        assert!(match_needs_ifchain(&nested));
6313    }
6314
6315    #[test]
6316    fn match_needs_ifchain_detects_list_pattern() {
6317        // `[]`, `[only]`, `[first, ..rest]`: a list pattern has no single
6318        // `switch` discriminant — every backend that consults the recogniser
6319        // (ts, go) must route these to the if-chain so elements / `..rest` bind.
6320        let empty = vec![arm_with(
6321            1,
6322            n(
6323                2,
6324                NodeKind::ListPat {
6325                    elems: vec![],
6326                    rest: None,
6327                },
6328            ),
6329            None,
6330        )];
6331        assert!(match_needs_ifchain(&empty));
6332
6333        let head_rest = vec![arm_with(
6334            10,
6335            n(
6336                11,
6337                NodeKind::ListPat {
6338                    elems: vec![n(
6339                        12,
6340                        NodeKind::BindPat {
6341                            name: ident("first"),
6342                            is_mut: false,
6343                        },
6344                    )],
6345                    rest: Some(Box::new(n(
6346                        13,
6347                        NodeKind::BindPat {
6348                            name: ident("rest"),
6349                            is_mut: false,
6350                        },
6351                    ))),
6352                },
6353            ),
6354            None,
6355        )];
6356        assert!(match_needs_ifchain(&head_rest));
6357    }
6358
6359    #[test]
6360    fn match_needs_ifchain_detects_range_pattern() {
6361        // `1..10` / `1..=10`: a range pattern is a relational test, not a single
6362        // discriminant, so it cannot ride the `switch` fast-path.
6363        let range = vec![arm_with(
6364            1,
6365            n(
6366                2,
6367                NodeKind::RangePat {
6368                    lo: Box::new(int_lit(3)),
6369                    hi: Box::new(int_lit(4)),
6370                    inclusive: false,
6371                },
6372            ),
6373            None,
6374        )];
6375        assert!(match_needs_ifchain(&range));
6376    }
6377
6378    #[test]
6379    fn desugared_self_call_matches_shared_receiver_id() {
6380        // Receiver node with id 5 cloned into both the FieldAccess object and
6381        // the leading arg — the lowerer\'s desugared-method marker.
6382        let recv = n(5, NodeKind::Identifier { name: ident("p") });
6383        let callee = n(
6384            6,
6385            NodeKind::FieldAccess {
6386                object: Box::new(recv.clone()),
6387                field: ident("m"),
6388            },
6389        );
6390        let args = vec![
6391            AirArg {
6392                label: None,
6393                value: recv,
6394            },
6395            AirArg {
6396                label: None,
6397                value: n(7, NodeKind::Identifier { name: ident("x") }),
6398            },
6399        ];
6400        let got = desugared_self_call(&callee, &args).expect("should match");
6401        assert_eq!(got.1.name, "m");
6402        assert_eq!(got.2.len(), 1); // one non-self arg
6403
6404        // A genuine field-closure call `(p.f)(p)` has *distinct* receiver
6405        // nodes (different ids), so it is not treated as a method call.
6406        let p1 = n(8, NodeKind::Identifier { name: ident("p") });
6407        let p2 = n(9, NodeKind::Identifier { name: ident("p") });
6408        let callee2 = n(
6409            10,
6410            NodeKind::FieldAccess {
6411                object: Box::new(p1),
6412                field: ident("f"),
6413            },
6414        );
6415        let args2 = vec![AirArg {
6416            label: None,
6417            value: p2,
6418        }];
6419        assert!(desugared_self_call(&callee2, &args2).is_none());
6420    }
6421
6422    /// Build a desugared method call `recv.method(extra)` in the AIR shape the
6423    /// lowerer produces (receiver cloned into both the FieldAccess object and
6424    /// the leading self arg, sharing a NodeId).
6425    ///
6426    /// Returns the (callee, args) pair and the full wrapping `Call` node — the
6427    /// latter is what carries the checker's `recv_kind` annotation, so a
6428    /// recogniser that gates on the stamp reads it from there.
6429    fn desugared_call(method: &str, extra: Vec<AIRNode>) -> (AIRNode, Vec<AirArg>, AIRNode) {
6430        let recv = n(
6431            5,
6432            NodeKind::Identifier {
6433                name: ident("nums"),
6434            },
6435        );
6436        let callee = n(
6437            6,
6438            NodeKind::FieldAccess {
6439                object: Box::new(recv.clone()),
6440                field: ident(method),
6441            },
6442        );
6443        let mut args = vec![AirArg {
6444            label: None,
6445            value: recv,
6446        }];
6447        args.extend(extra.into_iter().map(|value| AirArg { label: None, value }));
6448        let call_node = n(
6449            8,
6450            NodeKind::Call {
6451                callee: Box::new(callee.clone()),
6452                args: args.clone(),
6453                type_args: vec![],
6454            },
6455        );
6456        (callee, args, call_node)
6457    }
6458
6459    #[test]
6460    fn desugared_list_method_matches_read_only_builtins() {
6461        // Every read-only built-in is recognised, returning the receiver, the
6462        // method name, and the non-self args.
6463        for &m in READ_ONLY_LIST_METHODS {
6464            let extra = match m {
6465                "get" | "contains" | "index_of" | "concat" | "join" => {
6466                    vec![n(7, NodeKind::Identifier { name: ident("x") })]
6467                }
6468                _ => vec![],
6469            };
6470            let n_extra = extra.len();
6471            let (callee, args, call_node) = desugared_call(m, extra);
6472            let (recv, got_method, rest) =
6473                desugared_list_method(&call_node, &callee, &args).expect("should match");
6474            assert_eq!(got_method, m);
6475            assert!(matches!(&recv.kind, NodeKind::Identifier { name } if name.name == "nums"));
6476            assert_eq!(rest.len(), n_extra);
6477        }
6478    }
6479
6480    #[test]
6481    fn desugared_list_method_rejects_mutating_and_unknown_methods() {
6482        // Mutating built-ins (DQ18/DQ30 — recognised by their own dedicated
6483        // recognisers) and arbitrary method names are NOT recognised — they
6484        // fall through to each backend's generic path.
6485        for &m in &["push", "pop", "insert", "remove", "clear", "frobnicate"] {
6486            let (callee, args, call_node) = desugared_call(m, vec![]);
6487            assert!(
6488                desugared_list_method(&call_node, &callee, &args).is_none(),
6489                "{m} should not be recognised as a read-only List method"
6490            );
6491        }
6492    }
6493
6494    #[test]
6495    fn desugared_list_inplace_mutator_matches_dq30_methods() {
6496        // Every DQ30 in-place mutator is recognised (with the `set` exception
6497        // tested separately), returning receiver, method, and non-self args.
6498        for &m in INPLACE_LIST_MUTATORS {
6499            let extra = match m {
6500                "pop" | "reverse" => vec![],
6501                "remove_at" => vec![n(7, NodeKind::Identifier { name: ident("i") })],
6502                _ => vec![
6503                    n(7, NodeKind::Identifier { name: ident("i") }),
6504                    n(9, NodeKind::Identifier { name: ident("x") }),
6505                ],
6506            };
6507            let n_extra = extra.len();
6508            let (callee, args, mut call_node) = desugared_call(m, extra);
6509            call_node.metadata.insert(
6510                bock_types::checker::RECV_KIND_META_KEY.to_string(),
6511                bock_air::Value::String("List".to_string()),
6512            );
6513            let (recv, got_method, rest) =
6514                desugared_list_inplace_mutator(&call_node, &callee, &args).expect("should match");
6515            assert_eq!(got_method, m);
6516            assert!(matches!(&recv.kind, NodeKind::Identifier { name } if name.name == "nums"));
6517            assert_eq!(rest.len(), n_extra);
6518        }
6519    }
6520
6521    #[test]
6522    fn desugared_list_inplace_mutator_set_requires_explicit_list_stamp() {
6523        // `set(k, v)` is also a live `Map` method, so the List lowering must
6524        // only claim it under an explicit `recv_kind = "List"` stamp: an
6525        // unstamped or Map-stamped `set` falls through to the Map path.
6526        let extra = vec![
6527            n(7, NodeKind::Identifier { name: ident("i") }),
6528            n(9, NodeKind::Identifier { name: ident("x") }),
6529        ];
6530        let (callee, args, call_node) = desugared_call("set", extra);
6531        assert!(
6532            desugared_list_inplace_mutator(&call_node, &callee, &args).is_none(),
6533            "unstamped `set` must not be claimed by the List mutator lowering"
6534        );
6535        let mut map_stamped = call_node.clone();
6536        map_stamped.metadata.insert(
6537            bock_types::checker::RECV_KIND_META_KEY.to_string(),
6538            bock_air::Value::String("Map".to_string()),
6539        );
6540        assert!(
6541            desugared_list_inplace_mutator(&map_stamped, &callee, &args).is_none(),
6542            "Map-stamped `set` must not be claimed by the List mutator lowering"
6543        );
6544        // `pop` (List-only name) keeps the DQ18 absent-stamp fall-through.
6545        let (callee_p, args_p, call_p) = desugared_call("pop", vec![]);
6546        assert!(
6547            desugared_list_inplace_mutator(&call_p, &callee_p, &args_p).is_some(),
6548            "unstamped `pop` keeps the absent-stamp fall-through"
6549        );
6550    }
6551
6552    #[test]
6553    fn desugared_list_inplace_mutator_rejects_user_stamp() {
6554        // A user record's same-named method (`recv_kind = "User:<name>"`) is
6555        // never claimed by the built-in mutator lowering.
6556        for &m in INPLACE_LIST_MUTATORS {
6557            let (callee, args, mut call_node) = desugared_call(m, vec![]);
6558            call_node.metadata.insert(
6559                bock_types::checker::RECV_KIND_META_KEY.to_string(),
6560                bock_air::Value::String("User:Counter".to_string()),
6561            );
6562            assert!(
6563                desugared_list_inplace_mutator(&call_node, &callee, &args).is_none(),
6564                "{m} on a user record must not route to the List mutator lowering"
6565            );
6566        }
6567    }
6568
6569    #[test]
6570    fn desugared_list_method_accepts_explicit_list_stamp() {
6571        // A `recv_kind = "List"` stamp (or no stamp) is accepted: the built-in
6572        // List lowering fires on a genuine list receiver.
6573        let (callee, args, mut call_node) = desugared_call("len", vec![]);
6574        call_node.metadata.insert(
6575            bock_types::checker::RECV_KIND_META_KEY.to_string(),
6576            bock_air::Value::String("List".to_string()),
6577        );
6578        assert!(
6579            desugared_list_method(&call_node, &callee, &args).is_some(),
6580            "a `recv_kind = \"List\"` len() must be recognised as the built-in"
6581        );
6582    }
6583
6584    #[test]
6585    fn desugared_list_method_rejects_same_named_user_record_method() {
6586        // A user record with its own `len()`/`is_empty()`/`contains()` is stamped
6587        // `recv_kind = "User:<name>"`; the built-in List lowering must NOT shadow
6588        // it (Q-r2-codegen-residue item c). The call falls through to the
6589        // user-method path instead.
6590        for &m in &["len", "is_empty", "contains", "count", "first"] {
6591            let extra = if m == "contains" {
6592                vec![n(7, NodeKind::Identifier { name: ident("x") })]
6593            } else {
6594                vec![]
6595            };
6596            let (callee, args, mut call_node) = desugared_call(m, extra);
6597            call_node.metadata.insert(
6598                bock_types::checker::RECV_KIND_META_KEY.to_string(),
6599                bock_air::Value::String("User:Counter".to_string()),
6600            );
6601            assert!(
6602                desugared_list_method(&call_node, &callee, &args).is_none(),
6603                "{m} on a user record (recv_kind=User:Counter) must not route to the List built-in"
6604            );
6605        }
6606    }
6607
6608    #[test]
6609    fn desugared_list_functional_method_matches_closure_combinators() {
6610        // Every functional (closure-taking) built-in is recognised, returning the
6611        // receiver, the method name, and the non-self args (the closure, plus the
6612        // seed for `fold`). The closure arg is modelled as a bare identifier here;
6613        // the recogniser is closure-shape-agnostic.
6614        for &m in FUNCTIONAL_LIST_METHODS {
6615            let extra = if m == "fold" {
6616                vec![
6617                    n(
6618                        7,
6619                        NodeKind::Identifier {
6620                            name: ident("init"),
6621                        },
6622                    ),
6623                    n(9, NodeKind::Identifier { name: ident("cb") }),
6624                ]
6625            } else {
6626                vec![n(7, NodeKind::Identifier { name: ident("cb") })]
6627            };
6628            let n_extra = extra.len();
6629            let (callee, args, call_node) = desugared_call(m, extra);
6630            let (recv, got_method, rest) =
6631                desugared_list_functional_method(&call_node, &callee, &args).expect("should match");
6632            assert_eq!(got_method, m);
6633            assert!(matches!(&recv.kind, NodeKind::Identifier { name } if name.name == "nums"));
6634            assert_eq!(rest.len(), n_extra);
6635        }
6636    }
6637
6638    #[test]
6639    fn desugared_list_functional_method_rejects_read_only_and_other_stamps() {
6640        // The read-only built-ins are NOT functional combinators (they route
6641        // through `desugared_list_method` instead).
6642        for &m in &["len", "get", "concat", "join", "frobnicate"] {
6643            let (callee, args, call_node) = desugared_call(m, vec![]);
6644            assert!(
6645                desugared_list_functional_method(&call_node, &callee, &args).is_none(),
6646                "{m} must not be recognised as a functional List method"
6647            );
6648        }
6649        // A non-`List` `recv_kind` (a user record / Map / Set sharing the method
6650        // name) is rejected so the built-in does not shadow it.
6651        let extra = vec![n(7, NodeKind::Identifier { name: ident("cb") })];
6652        let (callee, args, mut call_node) = desugared_call("map", extra);
6653        call_node.metadata.insert(
6654            bock_types::checker::RECV_KIND_META_KEY.to_string(),
6655            bock_air::Value::String("Set".to_string()),
6656        );
6657        assert!(
6658            desugared_list_functional_method(&call_node, &callee, &args).is_none(),
6659            "map on a Set receiver must not route to the List functional built-in"
6660        );
6661    }
6662
6663    #[test]
6664    fn is_list_concat_reads_the_checker_stamp() {
6665        let lhs = n(20, NodeKind::Identifier { name: ident("a") });
6666        let rhs = n(21, NodeKind::Identifier { name: ident("b") });
6667
6668        // Two plain (non-list-literal) operands with no stamp → not list concat.
6669        let plain = n(1, NodeKind::Identifier { name: ident("x") });
6670        assert!(
6671            !is_list_concat(&plain, &lhs, &rhs),
6672            "an unstamped node with non-literal operands is not list concat"
6673        );
6674
6675        // The `Bool(true)` checker stamp marks list concat.
6676        let mut stamped = n(2, NodeKind::Identifier { name: ident("x") });
6677        stamped.metadata.insert(
6678            bock_types::checker::LIST_CONCAT_META_KEY.to_string(),
6679            bock_air::Value::Bool(true),
6680        );
6681        assert!(
6682            is_list_concat(&stamped, &lhs, &rhs),
6683            "the `Bool(true)` stamp marks list concat"
6684        );
6685
6686        // The syntactic fallback fires when an operand is a list literal, even
6687        // without the stamp (covers `+` sites the checker body pass misses).
6688        let list_lit = n(22, NodeKind::ListLiteral { elems: vec![] });
6689        assert!(
6690            is_list_concat(&plain, &lhs, &list_lit),
6691            "a list-literal operand marks list concat syntactically"
6692        );
6693    }
6694
6695    // ── Primitive-bridge / Ordering ──────────────────────────────────────────
6696
6697    #[test]
6698    fn ordering_variant_recognises_only_the_three_variants() {
6699        assert_eq!(ordering_variant("Less"), Some("Less"));
6700        assert_eq!(ordering_variant("Equal"), Some("Equal"));
6701        assert_eq!(ordering_variant("Greater"), Some("Greater"));
6702        assert_eq!(ordering_variant("Some"), None);
6703        assert_eq!(ordering_variant("less"), None);
6704        assert_eq!(ordering_variant("Ordering"), None);
6705    }
6706
6707    /// Build a desugared `recv.method(extra)` call node carrying the checker's
6708    /// `recv_kind` annotation `tag`, as the consumer sees it post-checking.
6709    fn annotated_call(
6710        method: &str,
6711        tag: &str,
6712        extra: Vec<AIRNode>,
6713    ) -> (AIRNode, AIRNode, Vec<AirArg>) {
6714        let (callee, args, _) = desugared_call(method, extra);
6715        let mut call = n(
6716            100,
6717            NodeKind::Call {
6718                callee: Box::new(callee.clone()),
6719                args: args.clone(),
6720                type_args: vec![],
6721            },
6722        );
6723        call.metadata.insert(
6724            bock_types::checker::RECV_KIND_META_KEY.to_string(),
6725            bock_air::Value::String(tag.to_string()),
6726        );
6727        (call, callee, args)
6728    }
6729
6730    #[test]
6731    fn primitive_recv_kind_reads_the_annotation() {
6732        let (call, _, _) = annotated_call("compare", "Primitive:Int", vec![]);
6733        assert_eq!(primitive_recv_kind(&call), Some("Int"));
6734
6735        let (call, _, _) = annotated_call("unwrap_or", "Optional", vec![]);
6736        assert_eq!(primitive_recv_kind(&call), None);
6737
6738        // No annotation → None.
6739        let (callee, args, _) = desugared_call("compare", vec![]);
6740        let bare = n(
6741            101,
6742            NodeKind::Call {
6743                callee: Box::new(callee),
6744                args,
6745                type_args: vec![],
6746            },
6747        );
6748        assert_eq!(primitive_recv_kind(&bare), None);
6749    }
6750
6751    #[test]
6752    fn primitive_bridge_call_matches_bridge_methods_on_primitive() {
6753        for &m in PRIMITIVE_BRIDGE_METHODS {
6754            let extra = if matches!(m, "compare" | "eq") {
6755                vec![n(7, NodeKind::Identifier { name: ident("x") })]
6756            } else {
6757                vec![]
6758            };
6759            let n_extra = extra.len();
6760            let (call, callee, args) = annotated_call(m, "Primitive:Int", extra);
6761            let (recv, method, rest, prim) =
6762                primitive_bridge_call(&call, &callee, &args).expect("should match");
6763            assert_eq!(method, m);
6764            assert_eq!(prim, "Int");
6765            assert_eq!(rest.len(), n_extra);
6766            assert!(matches!(&recv.kind, NodeKind::Identifier { name } if name.name == "nums"));
6767        }
6768    }
6769
6770    #[test]
6771    fn primitive_bridge_call_rejects_non_primitive_and_unknown_methods() {
6772        // Right method, but the receiver is not a primitive → not a bridge call.
6773        let (call, callee, args) = annotated_call("compare", "User:Point", vec![]);
6774        assert!(primitive_bridge_call(&call, &callee, &args).is_none());
6775
6776        // Primitive receiver, but a method the bridge does not cover.
6777        let (call, callee, args) = annotated_call("frobnicate", "Primitive:Int", vec![]);
6778        assert!(primitive_bridge_call(&call, &callee, &args).is_none());
6779
6780        // Primitive receiver + bridge method, but no annotation → not matched.
6781        let (callee, args, _) = desugared_call("compare", vec![]);
6782        let bare = n(
6783            102,
6784            NodeKind::Call {
6785                callee: Box::new(callee.clone()),
6786                args: args.clone(),
6787                type_args: vec![],
6788            },
6789        );
6790        assert!(primitive_bridge_call(&bare, &callee, &args).is_none());
6791    }
6792
6793    #[test]
6794    fn container_recv_kind_reads_optional_and_result() {
6795        let (call, _, _) = annotated_call("unwrap_or", "Optional", vec![]);
6796        assert_eq!(container_recv_kind(&call), Some("Optional"));
6797        let (call, _, _) = annotated_call("unwrap_or", "Result", vec![]);
6798        assert_eq!(container_recv_kind(&call), Some("Result"));
6799        // Non-container tags are not matched.
6800        let (call, _, _) = annotated_call("unwrap_or", "List", vec![]);
6801        assert_eq!(container_recv_kind(&call), None);
6802        let (call, _, _) = annotated_call("compare", "Primitive:Int", vec![]);
6803        assert_eq!(container_recv_kind(&call), None);
6804    }
6805
6806    #[test]
6807    fn desugared_optional_method_matches_optional_methods() {
6808        for &m in OPTIONAL_METHODS {
6809            let extra = if matches!(m, "unwrap_or" | "map" | "flat_map") {
6810                vec![n(7, NodeKind::Identifier { name: ident("x") })]
6811            } else {
6812                vec![]
6813            };
6814            let n_extra = extra.len();
6815            let (call, callee, args) = annotated_call(m, "Optional", extra);
6816            let (recv, got, rest) =
6817                desugared_optional_method(&call, &callee, &args).expect("should match");
6818            assert_eq!(got, m);
6819            assert_eq!(rest.len(), n_extra);
6820            assert!(matches!(&recv.kind, NodeKind::Identifier { name } if name.name == "nums"));
6821            // A `Result`-tagged call must NOT match the Optional recogniser.
6822            let (call_r, callee_r, args_r) = annotated_call(m, "Result", vec![]);
6823            assert!(desugared_optional_method(&call_r, &callee_r, &args_r).is_none());
6824        }
6825    }
6826
6827    #[test]
6828    fn desugared_result_method_matches_result_methods() {
6829        for &m in RESULT_METHODS {
6830            let extra = if matches!(m, "unwrap_or" | "map" | "map_err") {
6831                vec![n(7, NodeKind::Identifier { name: ident("x") })]
6832            } else {
6833                vec![]
6834            };
6835            let n_extra = extra.len();
6836            let (call, callee, args) = annotated_call(m, "Result", extra);
6837            let (recv, got, rest) =
6838                desugared_result_method(&call, &callee, &args).expect("should match");
6839            assert_eq!(got, m);
6840            assert_eq!(rest.len(), n_extra);
6841            assert!(matches!(&recv.kind, NodeKind::Identifier { name } if name.name == "nums"));
6842            // An `Optional`-tagged call must NOT match the Result recogniser.
6843            let (call_o, callee_o, args_o) = annotated_call(m, "Optional", vec![]);
6844            assert!(desugared_result_method(&call_o, &callee_o, &args_o).is_none());
6845        }
6846    }
6847
6848    #[test]
6849    fn container_methods_require_the_annotation() {
6850        // The right method name + receiver shape, but no `recv_kind` annotation
6851        // → not matched (the disambiguation crux).
6852        let (callee, args, _) = desugared_call("unwrap_or", vec![]);
6853        let bare = n(
6854            103,
6855            NodeKind::Call {
6856                callee: Box::new(callee.clone()),
6857                args: args.clone(),
6858                type_args: vec![],
6859            },
6860        );
6861        assert!(desugared_optional_method(&bare, &callee, &args).is_none());
6862        assert!(desugared_result_method(&bare, &callee, &args).is_none());
6863        // Annotated container, but a method outside the recognised set.
6864        let (call, callee, args) = annotated_call("frobnicate", "Optional", vec![]);
6865        assert!(desugared_optional_method(&call, &callee, &args).is_none());
6866    }
6867
6868    #[test]
6869    fn container_recv_kind_reads_map_and_set() {
6870        let (call, _, _) = annotated_call("get", "Map", vec![]);
6871        assert_eq!(container_recv_kind(&call), Some("Map"));
6872        let (call, _, _) = annotated_call("add", "Set", vec![]);
6873        assert_eq!(container_recv_kind(&call), Some("Set"));
6874    }
6875
6876    #[test]
6877    fn desugared_map_method_matches_map_methods() {
6878        for &m in MAP_METHODS {
6879            // `set` takes two args; the others either take one or none — arity is
6880            // not validated by the recogniser, so pass none and assert it matches.
6881            let (call, callee, args) = annotated_call(m, "Map", vec![]);
6882            let (recv, got, _rest) =
6883                desugared_map_method(&call, &callee, &args).expect("should match");
6884            assert_eq!(got, m);
6885            assert!(matches!(&recv.kind, NodeKind::Identifier { name } if name.name == "nums"));
6886            // A `Set`-tagged call must NOT match the Map recogniser (overlapping
6887            // names `filter`/`len`/`length`/`count`/`is_empty`/`to_list`/`for_each`).
6888            let (call_s, callee_s, args_s) = annotated_call(m, "Set", vec![]);
6889            if SET_METHODS.contains(&m) {
6890                assert!(desugared_map_method(&call_s, &callee_s, &args_s).is_none());
6891            }
6892        }
6893        // The Map-only membership spelling is `contains_key`, not `contains`
6894        // (the checker resolves a bare `contains` on a Map to a fresh var).
6895        let (call, callee, args) = annotated_call("contains", "Map", vec![]);
6896        assert!(desugared_map_method(&call, &callee, &args).is_none());
6897    }
6898
6899    #[test]
6900    fn desugared_set_method_matches_set_methods() {
6901        for &m in SET_METHODS {
6902            let (call, callee, args) = annotated_call(m, "Set", vec![]);
6903            let (recv, got, _rest) =
6904                desugared_set_method(&call, &callee, &args).expect("should match");
6905            assert_eq!(got, m);
6906            assert!(matches!(&recv.kind, NodeKind::Identifier { name } if name.name == "nums"));
6907            // A `Map`-tagged call must NOT match the Set recogniser.
6908            let (call_m, callee_m, args_m) = annotated_call(m, "Map", vec![]);
6909            if MAP_METHODS.contains(&m) {
6910                assert!(desugared_set_method(&call_m, &callee_m, &args_m).is_none());
6911            }
6912        }
6913    }
6914
6915    #[test]
6916    fn desugared_string_method_matches_string_methods_on_primitive_string() {
6917        for &m in STRING_METHODS {
6918            // `replace` takes two extra args, the rest take zero or one; the
6919            // recogniser is arity-agnostic, so a single placeholder suffices.
6920            let extra = vec![n(7, NodeKind::Identifier { name: ident("x") })];
6921            let (call, callee, args) = annotated_call(m, "Primitive:String", extra);
6922            let (recv, got, rest) =
6923                desugared_string_method(&call, &callee, &args).expect("should match");
6924            assert_eq!(got, m);
6925            assert_eq!(rest.len(), 1);
6926            assert!(matches!(&recv.kind, NodeKind::Identifier { name } if name.name == "nums"));
6927            // A non-String primitive receiver must NOT match (e.g. `Int`).
6928            let (call_i, callee_i, args_i) = annotated_call(m, "Primitive:Int", vec![]);
6929            assert!(desugared_string_method(&call_i, &callee_i, &args_i).is_none());
6930        }
6931    }
6932
6933    #[test]
6934    fn desugared_string_method_rejects_unknown_methods_and_missing_annotation() {
6935        // A String receiver, but a method the recogniser does not cover.
6936        let (call, callee, args) = annotated_call("frobnicate", "Primitive:String", vec![]);
6937        assert!(desugared_string_method(&call, &callee, &args).is_none());
6938
6939        // The right method name + receiver shape, but no `recv_kind` annotation
6940        // → not matched, so a bare `xs.contains(x)` (a `List`) still falls
6941        // through to the List recogniser rather than the String one.
6942        let (callee, args, _) = desugared_call(
6943            "contains",
6944            vec![n(7, NodeKind::Identifier { name: ident("x") })],
6945        );
6946        let bare = n(
6947            105,
6948            NodeKind::Call {
6949                callee: Box::new(callee.clone()),
6950                args: args.clone(),
6951                type_args: vec![],
6952            },
6953        );
6954        assert!(desugared_string_method(&bare, &callee, &args).is_none());
6955    }
6956
6957    #[test]
6958    fn map_set_methods_require_the_annotation() {
6959        // The right method name + receiver shape, but no `recv_kind` annotation
6960        // → not matched. A bare `m.get(k)` without the annotation must fall
6961        // through to the List recogniser, not the Map one.
6962        let (callee, args, _) = desugared_call("get", vec![]);
6963        let bare = n(
6964            104,
6965            NodeKind::Call {
6966                callee: Box::new(callee.clone()),
6967                args: args.clone(),
6968                type_args: vec![],
6969            },
6970        );
6971        assert!(desugared_map_method(&bare, &callee, &args).is_none());
6972        assert!(desugared_set_method(&bare, &callee, &args).is_none());
6973    }
6974
6975    #[test]
6976    fn param_binds_self_detects_self_param() {
6977        let self_p = n(
6978            1,
6979            NodeKind::Param {
6980                pattern: Box::new(n(
6981                    2,
6982                    NodeKind::BindPat {
6983                        name: ident("self"),
6984                        is_mut: false,
6985                    },
6986                )),
6987                ty: None,
6988                default: None,
6989            },
6990        );
6991        assert_eq!(param_binds_self(&self_p), Some(false));
6992
6993        let other = n(
6994            3,
6995            NodeKind::Param {
6996                pattern: Box::new(n(
6997                    4,
6998                    NodeKind::BindPat {
6999                        name: ident("x"),
7000                        is_mut: false,
7001                    },
7002                )),
7003                ty: None,
7004                default: None,
7005            },
7006        );
7007        assert_eq!(param_binds_self(&other), None);
7008    }
7009
7010    #[test]
7011    fn loop_needs_break_label_when_match_arm_breaks() {
7012        // loop body: { match _ { _ => break } }
7013        let match_node = n(
7014            1,
7015            NodeKind::Match {
7016                scrutinee: Box::new(n(2, NodeKind::Identifier { name: ident("i") })),
7017                arms: vec![match_arm(3, n(5, NodeKind::Break { value: None }))],
7018            },
7019        );
7020        let body = n(
7021            6,
7022            NodeKind::Block {
7023                stmts: vec![match_node],
7024                tail: None,
7025            },
7026        );
7027        assert!(loop_needs_break_label(&body));
7028
7029        // A match whose arms only return values needs no label.
7030        let value_match = n(
7031            10,
7032            NodeKind::Match {
7033                scrutinee: Box::new(n(11, NodeKind::Identifier { name: ident("i") })),
7034                arms: vec![match_arm(
7035                    12,
7036                    n(
7037                        14,
7038                        NodeKind::Literal {
7039                            lit: bock_ast::Literal::Int("0".into()),
7040                        },
7041                    ),
7042                )],
7043            },
7044        );
7045        let body2 = n(
7046            15,
7047            NodeKind::Block {
7048                stmts: vec![value_match],
7049                tail: None,
7050            },
7051        );
7052        assert!(!loop_needs_break_label(&body2));
7053    }
7054
7055    // ── Enum-variant registry ───────────────────────────────────────────────
7056
7057    /// Build an `EnumVariant` AIR node with the given payload.
7058    fn enum_variant(name: &str, payload: EnumVariantPayload) -> AIRNode {
7059        n(
7060            0,
7061            NodeKind::EnumVariant {
7062                name: ident(name),
7063                payload,
7064            },
7065        )
7066    }
7067
7068    /// Build a `struct`-variant field-decl with the given name (type is a
7069    /// placeholder — `collect_enum_variants` only reads the field name).
7070    fn record_field(name: &str) -> bock_ast::RecordDeclField {
7071        bock_ast::RecordDeclField {
7072            id: 0,
7073            span: dummy_span(),
7074            name: ident(name),
7075            ty: bock_ast::TypeExpr::Named {
7076                id: 0,
7077                span: dummy_span(),
7078                path: bock_ast::TypePath {
7079                    segments: vec![ident("Int")],
7080                    span: dummy_span(),
7081                },
7082                args: vec![],
7083            },
7084            default: None,
7085        }
7086    }
7087
7088    /// Build an `EnumDecl` AIR node named `name` with the given variants.
7089    fn enum_decl(name: &str, variants: Vec<AIRNode>) -> AIRNode {
7090        n(
7091            0,
7092            NodeKind::EnumDecl {
7093                annotations: vec![],
7094                visibility: bock_ast::Visibility::Public,
7095                name: ident(name),
7096                generic_params: vec![],
7097                variants,
7098            },
7099        )
7100    }
7101
7102    /// A `TypePath` of a single segment (a bare variant name at a use site).
7103    fn variant_path(name: &str) -> bock_ast::TypePath {
7104        bock_ast::TypePath {
7105            segments: vec![ident(name)],
7106            span: dummy_span(),
7107        }
7108    }
7109
7110    #[test]
7111    fn collect_enum_variants_records_all_payload_kinds() {
7112        // enum Shape { Circle { radius } | Rect(_, _) | Empty }
7113        let shape = enum_decl(
7114            "Shape",
7115            vec![
7116                enum_variant(
7117                    "Circle",
7118                    EnumVariantPayload::Struct(vec![record_field("radius")]),
7119                ),
7120                enum_variant(
7121                    "Rect",
7122                    EnumVariantPayload::Tuple(vec![
7123                        n(1, NodeKind::Placeholder),
7124                        n(2, NodeKind::Placeholder),
7125                    ]),
7126                ),
7127                enum_variant("Empty", EnumVariantPayload::Unit),
7128            ],
7129        );
7130        let m = module_named("main", &[], vec![shape]);
7131        let p = std::path::Path::new("x.bock");
7132        let reg = collect_enum_variants(&[(&m, p)]);
7133
7134        let circle = reg.get("Circle").expect("Circle registered");
7135        assert_eq!(circle.enum_name, "Shape");
7136        assert_eq!(
7137            circle.payload,
7138            VariantPayloadKind::Struct(vec!["radius".to_string()])
7139        );
7140
7141        let rect = reg.get("Rect").expect("Rect registered");
7142        assert_eq!(rect.enum_name, "Shape");
7143        assert_eq!(rect.payload, VariantPayloadKind::Tuple(2));
7144
7145        let empty = reg.get("Empty").expect("Empty registered");
7146        assert_eq!(empty.enum_name, "Shape");
7147        assert_eq!(empty.payload, VariantPayloadKind::Unit);
7148    }
7149
7150    #[test]
7151    fn collect_enum_variants_pre_seeds_optional_and_result() {
7152        // An empty module set still carries the built-in Optional/Result entries
7153        // so one mechanism describes both user and built-in ADTs (B1).
7154        let reg = collect_enum_variants(&[]);
7155        assert_eq!(
7156            reg.get("Some").map(|i| i.enum_name.as_str()),
7157            Some("Optional")
7158        );
7159        assert_eq!(
7160            reg.get("Some").map(|i| &i.payload),
7161            Some(&VariantPayloadKind::Tuple(1))
7162        );
7163        assert_eq!(
7164            reg.get("None").map(|i| &i.payload),
7165            Some(&VariantPayloadKind::Unit)
7166        );
7167        assert_eq!(reg.get("Ok").map(|i| i.enum_name.as_str()), Some("Result"));
7168        assert_eq!(reg.get("Err").map(|i| i.enum_name.as_str()), Some("Result"));
7169    }
7170
7171    #[test]
7172    fn collect_enum_variants_spans_multiple_modules() {
7173        // A `use`d enum in another reached module is still registered (the
7174        // pre-scan walks every module, so a forward / cross-module reference
7175        // resolves).
7176        let color = enum_decl("Color", vec![enum_variant("Red", EnumVariantPayload::Unit)]);
7177        let lib = module_named("lib", &[], vec![color]);
7178        let main_m = module_named("main", &["lib"], vec![fn_decl("main")]);
7179        let p = std::path::Path::new("x.bock");
7180        let reg = collect_enum_variants(&[(&lib, p), (&main_m, p)]);
7181        assert_eq!(reg.get("Red").map(|i| i.enum_name.as_str()), Some("Color"));
7182    }
7183
7184    /// A `fn <name>() { <stmts> }` declaration carrying the given body
7185    /// statements — used to plant a bare-variant reference in a use site.
7186    fn fn_decl_with_body(name: &str, stmts: Vec<AIRNode>) -> AIRNode {
7187        let body = AIRNode::new(900, dummy_span(), NodeKind::Block { stmts, tail: None });
7188        AIRNode::new(
7189            0,
7190            dummy_span(),
7191            NodeKind::FnDecl {
7192                annotations: vec![],
7193                visibility: Visibility::Public,
7194                is_async: false,
7195                name: ident(name),
7196                generic_params: vec![],
7197                params: vec![],
7198                return_type: None,
7199                effect_clause: vec![],
7200                where_clause: vec![],
7201                body: Box::new(body),
7202            },
7203        )
7204    }
7205
7206    #[test]
7207    fn implicit_esm_imports_glob_imported_enum_variant_by_bare_name() {
7208        // `module models` declares `public enum Category { Electronics Clothing }`.
7209        // `module main` does `use models.*` (a glob import — `module_named` emits
7210        // ImportItems::Glob) and references the bare variant `Electronics`. The
7211        // shared collector must produce an implicit import keyed on the *emitted*
7212        // value-name `Category_Electronics`, even though the AIR only ever spells
7213        // the bare source name. Without it `main.js`/`main.ts` omit the import and
7214        // ReferenceError/TS2304 at the use site (inventory-system regression).
7215        let category = enum_decl(
7216            "Category",
7217            vec![
7218                enum_variant("Electronics", EnumVariantPayload::Unit),
7219                enum_variant("Clothing", EnumVariantPayload::Unit),
7220            ],
7221        );
7222        let models = module_named("models", &[], vec![category]);
7223        // `fn use_it() { let _ = Electronics }` — bare-variant reference.
7224        let use_electronics = AIRNode::new(
7225            901,
7226            dummy_span(),
7227            NodeKind::LetBinding {
7228                is_mut: false,
7229                pattern: Box::new(AIRNode::new(
7230                    902,
7231                    dummy_span(),
7232                    NodeKind::BindPat {
7233                        name: ident("_"),
7234                        is_mut: false,
7235                    },
7236                )),
7237                ty: None,
7238                value: Box::new(identifier(903, "Electronics")),
7239            },
7240        );
7241        let main_m = module_named(
7242            "main",
7243            &["models"],
7244            vec![fn_decl_with_body("use_it", vec![use_electronics])],
7245        );
7246        let p = std::path::Path::new("x.bock");
7247
7248        let public_symbols = collect_public_symbols_for_esm(&[(&models, p), (&main_m, p)]);
7249        let imports = implicit_esm_imports_for(&main_m, &public_symbols, "main");
7250
7251        let variant_import = imports
7252            .iter()
7253            .find(|i| i.name == "Category_Electronics")
7254            .expect(
7255                "glob-imported bare variant `Electronics` must import as `Category_Electronics`",
7256            );
7257        assert_eq!(variant_import.module_path, "models");
7258        assert_eq!(variant_import.kind, EsmDeclKind::EnumVariant);
7259        // The unreferenced sibling variant must NOT be over-imported (the scan is
7260        // by bare name and `Clothing` never appears at a use site).
7261        assert!(
7262            !imports.iter().any(|i| i.name == "Category_Clothing"),
7263            "unreferenced variant `Clothing` must not be imported; got: {imports:?}"
7264        );
7265    }
7266
7267    #[test]
7268    fn registered_variant_resolves_last_path_segment() {
7269        let shape = enum_decl(
7270            "Shape",
7271            vec![enum_variant("Empty", EnumVariantPayload::Unit)],
7272        );
7273        let m = module_named("main", &[], vec![shape]);
7274        let p = std::path::Path::new("x.bock");
7275        let reg = collect_enum_variants(&[(&m, p)]);
7276        // A bare variant path resolves.
7277        assert_eq!(
7278            registered_variant(&reg, &variant_path("Empty")).map(|i| i.enum_name.as_str()),
7279            Some("Shape")
7280        );
7281        // An unknown name does not.
7282        assert!(registered_variant(&reg, &variant_path("Nope")).is_none());
7283    }
7284
7285    // ── Generic-decl registry ───────────────────────────────────────────────
7286
7287    /// `record Box[T] { value: T }`.
7288    fn generic_record_decl(name: &str, params: &[&str]) -> AIRNode {
7289        n(
7290            0,
7291            NodeKind::RecordDecl {
7292                annotations: vec![],
7293                visibility: bock_ast::Visibility::Public,
7294                name: ident(name),
7295                generic_params: params
7296                    .iter()
7297                    .map(|p| bock_ast::GenericParam {
7298                        id: 0,
7299                        span: dummy_span(),
7300                        name: ident(p),
7301                        bounds: vec![],
7302                    })
7303                    .collect(),
7304                fields: vec![record_field("value")],
7305            },
7306        )
7307    }
7308
7309    #[test]
7310    fn collect_generic_decls_records_params_and_spans_modules() {
7311        let boxed = generic_record_decl("Box", &["T"]);
7312        let pair = generic_record_decl("Pair", &["A", "B"]);
7313        let plain = generic_record_decl("Plain", &[]);
7314        let lib = module_named("lib", &[], vec![pair]);
7315        let main_m = module_named("main", &["lib"], vec![boxed, plain]);
7316        let p = std::path::Path::new("x.bock");
7317        let reg = collect_generic_decls(&[(&lib, p), (&main_m, p)]);
7318
7319        // Single-param generic.
7320        let box_params = reg.get("Box").expect("Box registered");
7321        assert_eq!(box_params.len(), 1);
7322        assert_eq!(box_params[0].name.name, "T");
7323
7324        // Two-param generic, declaration order preserved, across module boundary.
7325        let pair_params = reg.get("Pair").expect("Pair registered");
7326        assert_eq!(pair_params.len(), 2);
7327        assert_eq!(pair_params[0].name.name, "A");
7328        assert_eq!(pair_params[1].name.name, "B");
7329
7330        // Non-generic decl is present with an empty param list.
7331        assert_eq!(reg.get("Plain").map(Vec::len), Some(0));
7332        // Unknown type is absent.
7333        assert!(!reg.contains_key("Nope"));
7334    }
7335
7336    // ── Trait-declaration registry ─────────────────────────────────────────
7337
7338    /// A trait method `FnDecl`. `default_body` controls the body block: when
7339    /// true a non-empty block (a default method), else an empty block (a
7340    /// required method). `self_operand` adds a second `other: Self` param.
7341    fn trait_method(name: &str, default_body: bool, self_operand: bool) -> AIRNode {
7342        let tail = if default_body {
7343            Some(Box::new(n(
7344                50,
7345                NodeKind::Literal {
7346                    lit: bock_ast::Literal::Unit,
7347                },
7348            )))
7349        } else {
7350            None
7351        };
7352        let body = n(
7353            40,
7354            NodeKind::Block {
7355                stmts: vec![],
7356                tail,
7357            },
7358        );
7359        let mut params = vec![n(
7360            41,
7361            NodeKind::Param {
7362                pattern: Box::new(n(
7363                    42,
7364                    NodeKind::BindPat {
7365                        name: ident("self"),
7366                        is_mut: false,
7367                    },
7368                )),
7369                ty: None,
7370                default: None,
7371            },
7372        )];
7373        if self_operand {
7374            params.push(n(
7375                43,
7376                NodeKind::Param {
7377                    pattern: Box::new(n(
7378                        44,
7379                        NodeKind::BindPat {
7380                            name: ident("other"),
7381                            is_mut: false,
7382                        },
7383                    )),
7384                    ty: Some(Box::new(n(45, NodeKind::TypeSelf))),
7385                    default: None,
7386                },
7387            ));
7388        }
7389        n(
7390            10,
7391            NodeKind::FnDecl {
7392                annotations: vec![],
7393                visibility: Visibility::Private,
7394                is_async: false,
7395                name: ident(name),
7396                generic_params: vec![],
7397                params,
7398                return_type: None,
7399                effect_clause: vec![],
7400                where_clause: vec![],
7401                body: Box::new(body),
7402            },
7403        )
7404    }
7405
7406    fn trait_decl(name: &str, methods: Vec<AIRNode>) -> AIRNode {
7407        n(
7408            5,
7409            NodeKind::TraitDecl {
7410                annotations: vec![],
7411                visibility: Visibility::Public,
7412                is_platform: false,
7413                name: ident(name),
7414                generic_params: vec![],
7415                associated_types: vec![],
7416                methods,
7417            },
7418        )
7419    }
7420
7421    #[test]
7422    fn is_default_method_uses_empty_block_heuristic() {
7423        // A non-empty body block (tail expr) → default method.
7424        assert!(is_default_method(&trait_method("dflt", true, false)));
7425        // An empty body block → required method.
7426        assert!(!is_default_method(&trait_method("req", false, false)));
7427    }
7428
7429    #[test]
7430    fn collect_trait_decls_records_methods_and_spans_modules() {
7431        let eq = trait_decl(
7432            "Eq",
7433            vec![
7434                trait_method("equals", false, true),
7435                trait_method("not_equals", true, true),
7436            ],
7437        );
7438        let other = trait_decl("Show", vec![trait_method("show", false, false)]);
7439        let lib = module_named("lib", &[], vec![other]);
7440        let main_m = module_named("main", &["lib"], vec![eq]);
7441        let p = std::path::Path::new("x.bock");
7442        let reg = collect_trait_decls(&[(&lib, p), (&main_m, p)]);
7443
7444        let eq_info = reg.get("Eq").expect("Eq registered");
7445        assert_eq!(eq_info.methods.len(), 2);
7446        // `Show` from the other module is also registered.
7447        assert!(reg.contains_key("Show"));
7448    }
7449
7450    #[test]
7451    fn inherited_default_methods_excludes_overridden_and_required() {
7452        // trait Eq { equals (required); not_equals (default) }
7453        let eq = trait_decl(
7454            "Eq",
7455            vec![
7456                trait_method("equals", false, true),
7457                trait_method("not_equals", true, true),
7458            ],
7459        );
7460        let m = module_named("main", &[], vec![eq]);
7461        let p = std::path::Path::new("x.bock");
7462        let reg = collect_trait_decls(&[(&m, p)]);
7463        let trait_path = variant_path("Eq");
7464
7465        // An impl overriding only `equals` inherits the `not_equals` default.
7466        let impl_methods = vec![fn_decl("equals")];
7467        let inherited = inherited_default_methods(&reg, &trait_path, &impl_methods);
7468        assert_eq!(inherited.len(), 1);
7469        assert_eq!(fn_decl_name(&inherited[0]), Some("not_equals"));
7470
7471        // An impl overriding the default too inherits nothing.
7472        let impl_methods = vec![fn_decl("equals"), fn_decl("not_equals")];
7473        assert!(inherited_default_methods(&reg, &trait_path, &impl_methods).is_empty());
7474
7475        // The required method is never synthesized even when not overridden.
7476        let inherited = inherited_default_methods(&reg, &trait_path, &[]);
7477        assert_eq!(inherited.len(), 1);
7478        assert_eq!(fn_decl_name(&inherited[0]), Some("not_equals"));
7479    }
7480
7481    #[test]
7482    fn trait_uses_self_operand_detects_self_typed_params() {
7483        // `equals(self, other: Self)` references `Self` in a non-receiver param.
7484        let with_self = TraitDeclInfo {
7485            generic_params: vec![],
7486            methods: vec![trait_method("equals", false, true)],
7487        };
7488        assert!(trait_uses_self_operand(&with_self));
7489
7490        // `show(self)` has only the receiver — no `Self` operand.
7491        let without_self = TraitDeclInfo {
7492            generic_params: vec![],
7493            methods: vec![trait_method("show", false, false)],
7494        };
7495        assert!(!trait_uses_self_operand(&without_self));
7496    }
7497
7498    #[test]
7499    fn collect_exported_type_names_records_only_public_types() {
7500        let pub_rec = generic_record_decl("Key", &[]); // public by helper
7501        let priv_rec = n(
7502            70,
7503            NodeKind::RecordDecl {
7504                annotations: vec![],
7505                visibility: Visibility::Private,
7506                name: ident("Hidden"),
7507                generic_params: vec![],
7508                fields: vec![],
7509            },
7510        );
7511        let m = module_named("main", &[], vec![pub_rec, priv_rec]);
7512        let p = std::path::Path::new("x.bock");
7513        let names = collect_exported_type_names(&[(&m, p)]);
7514        assert!(names.contains("Key"));
7515        assert!(!names.contains("Hidden"));
7516    }
7517
7518    // ── Value-position diverging-CF temp-hoist desugar ───────────────────────
7519
7520    /// A `return <int>` statement node.
7521    fn return_int(id: u32) -> AIRNode {
7522        n(
7523            id,
7524            NodeKind::Return {
7525                value: Some(Box::new(int_lit(id + 1))),
7526            },
7527        )
7528    }
7529
7530    #[test]
7531    fn value_cf_diverges_detects_if_with_return_branch() {
7532        // `if (c) { 1 } else { return 0 }` — one value arm, one diverging arm.
7533        let node = if_node(
7534            1,
7535            block_with_tail(2, int_lit(3)),
7536            Some(block_with_tail(4, return_int(5))),
7537        );
7538        assert!(value_cf_diverges(&node));
7539    }
7540
7541    #[test]
7542    fn value_cf_diverges_skips_plain_value_if() {
7543        // `if (c) { 1 } else { 2 }` — both arms yield a value; not diverging.
7544        let node = if_node(
7545            1,
7546            block_with_tail(2, int_lit(3)),
7547            Some(block_with_tail(4, int_lit(5))),
7548        );
7549        assert!(!value_cf_diverges(&node));
7550    }
7551
7552    #[test]
7553    fn value_cf_diverges_detects_nested_else_if_chain() {
7554        // `if (a) { 1 } else { if (b) { 2 } else { return 0 } }` — chat-protocol
7555        // shape: the diverging `return` is buried in a nested else-if.
7556        let inner = if_node(
7557            10,
7558            block_with_tail(11, int_lit(12)),
7559            Some(block_with_tail(13, return_int(14))),
7560        );
7561        let outer = if_node(1, block_with_tail(2, int_lit(3)), Some(inner));
7562        assert!(value_cf_diverges(&outer));
7563    }
7564
7565    #[test]
7566    fn value_cf_diverges_hoists_value_loop_only() {
7567        // A `loop` that yields a value via `break <v>` needs statement-form
7568        // delivery in value position.
7569        let value_loop = n(
7570            1,
7571            NodeKind::Loop {
7572                body: Box::new(block_with_tail(
7573                    2,
7574                    n(
7575                        3,
7576                        NodeKind::Break {
7577                            value: Some(Box::new(int_lit(4))),
7578                        },
7579                    ),
7580                )),
7581            },
7582        );
7583        assert!(value_cf_diverges(&value_loop));
7584
7585        // A value-less `loop` (bare `break`, result discarded) has a clean
7586        // statement form already and must NOT be hoisted (else the temp would be
7587        // left uninitialised).
7588        let unit_loop = n(
7589            10,
7590            NodeKind::Loop {
7591                body: Box::new(block_with_tail(11, n(12, NodeKind::Break { value: None }))),
7592            },
7593        );
7594        assert!(!value_cf_diverges(&unit_loop));
7595    }
7596
7597    #[test]
7598    fn value_cf_diverges_detects_match_with_return_arm() {
7599        // `match s { _ => 1, _ => return }` — one value arm, one diverging.
7600        let arms = vec![
7601            match_arm(10, int_lit(12)),
7602            match_arm(20, n(22, NodeKind::Return { value: None })),
7603        ];
7604        let m = n(
7605            1,
7606            NodeKind::Match {
7607                scrutinee: Box::new(n(2, NodeKind::Placeholder)),
7608                arms,
7609            },
7610        );
7611        assert!(value_cf_diverges(&m));
7612    }
7613
7614    /// Extract the single `FnDecl` body block from a hoisted module wrapper.
7615    fn hoisted_let_block(value: AIRNode) -> AIRNode {
7616        // fn f() { let x = <value> }
7617        let let_pat = n(
7618            900,
7619            NodeKind::BindPat {
7620                name: ident("x"),
7621                is_mut: false,
7622            },
7623        );
7624        let let_binding = n(
7625            901,
7626            NodeKind::LetBinding {
7627                is_mut: false,
7628                pattern: Box::new(let_pat),
7629                ty: None,
7630                value: Box::new(value),
7631            },
7632        );
7633        let body = n(
7634            902,
7635            NodeKind::Block {
7636                stmts: vec![let_binding],
7637                tail: None,
7638            },
7639        );
7640        let fn_decl = n(
7641            903,
7642            NodeKind::FnDecl {
7643                annotations: vec![],
7644                visibility: Visibility::Private,
7645                is_async: false,
7646                name: ident("f"),
7647                generic_params: vec![],
7648                params: vec![],
7649                return_type: None,
7650                effect_clause: vec![],
7651                where_clause: vec![],
7652                body: Box::new(body),
7653            },
7654        );
7655        module_named("main", &[], vec![fn_decl])
7656    }
7657
7658    /// Return the statement list of the hoisted module's `fn f` body block.
7659    fn fn_body_stmts(module: &AIRNode) -> &[AIRNode] {
7660        let NodeKind::Module { items, .. } = &module.kind else {
7661            panic!("module");
7662        };
7663        let NodeKind::FnDecl { body, .. } = &items[0].kind else {
7664            panic!("fn");
7665        };
7666        let NodeKind::Block { stmts, .. } = &body.kind else {
7667            panic!("body block");
7668        };
7669        stmts
7670    }
7671
7672    /// The `let x = …` binding among a statement list.
7673    fn find_let_x(stmts: &[AIRNode]) -> &AIRNode {
7674        stmts
7675            .iter()
7676            .find(|s| {
7677                matches!(&s.kind, NodeKind::LetBinding { pattern, .. }
7678                    if matches!(&pattern.kind, NodeKind::BindPat { name, .. } if name.name == "x"))
7679            })
7680            .expect("let x binding")
7681    }
7682
7683    #[test]
7684    fn hoist_rewrites_diverging_let_into_prelude_and_temp_read() {
7685        // `let x = if (c) { 1 } else { return 0 }` splices, in the enclosing
7686        // block, before the `let`:
7687        //   let mut __bock_cf_0
7688        //   if (c) { __bock_cf_0 = 1 } else { return 0 }
7689        //   let x = __bock_cf_0
7690        let value = if_node(
7691            1,
7692            block_with_tail(2, int_lit(3)),
7693            Some(block_with_tail(4, return_int(5))),
7694        );
7695        let module = hoist_value_cf(hoisted_let_block(value));
7696        let stmts = fn_body_stmts(&module);
7697        assert_eq!(stmts.len(), 3, "decl + CF-stmt + let; got {}", stmts.len());
7698        // stmts[0]: declare-only temp.
7699        assert!(
7700            matches!(&stmts[0].kind, NodeKind::LetBinding { is_mut: true, .. }),
7701            "first stmt must be the mut temp decl, got {:?}",
7702            stmts[0].kind
7703        );
7704        assert_eq!(
7705            stmts[0].metadata.get(DECL_ONLY_META),
7706            Some(&bock_air::stubs::Value::Bool(true)),
7707            "temp decl must carry the declare-only marker"
7708        );
7709        // stmts[1]: relocated `if`, value arm → Assign, diverging arm kept.
7710        let NodeKind::If {
7711            then_block,
7712            else_block,
7713            ..
7714        } = &stmts[1].kind
7715        else {
7716            panic!("expected relocated If, got {:?}", stmts[1].kind);
7717        };
7718        // The value arm's block now ends in an `Assign` statement (the tail was
7719        // moved into `stmts` as `temp = 1`).
7720        let NodeKind::Block {
7721            stmts: then_stmts,
7722            tail: then_tail,
7723        } = &then_block.kind
7724        else {
7725            panic!("then block");
7726        };
7727        let then_last = then_tail
7728            .as_deref()
7729            .or_else(|| then_stmts.last())
7730            .map(|t| &t.kind);
7731        assert!(
7732            matches!(then_last, Some(NodeKind::Assign { .. })),
7733            "value arm must end in an Assign, got {then_last:?}"
7734        );
7735        // The diverging arm keeps its `return` (as tail or last statement).
7736        let NodeKind::Block {
7737            stmts: else_stmts,
7738            tail: else_tail,
7739        } = &else_block.as_ref().unwrap().kind
7740        else {
7741            panic!("else block");
7742        };
7743        let else_last = else_tail
7744            .as_deref()
7745            .or_else(|| else_stmts.last())
7746            .map(|t| &t.kind);
7747        assert!(
7748            matches!(else_last, Some(NodeKind::Return { .. })),
7749            "diverging arm must keep its return, got {else_last:?}"
7750        );
7751        // stmts[2]: `let x = __bock_cf_0` (a temp read, not a Block/IIFE).
7752        let NodeKind::LetBinding { value, .. } = &find_let_x(stmts).kind else {
7753            panic!("let x");
7754        };
7755        assert!(
7756            matches!(&value.kind, NodeKind::Identifier { name } if name.name.starts_with("__bock_cf_")),
7757            "let value must read the temp identifier, got {:?}",
7758            value.kind
7759        );
7760    }
7761
7762    #[test]
7763    fn hoist_leaves_plain_value_let_untouched() {
7764        // `let x = if (c) { 1 } else { 2 }` must NOT be hoisted (no divergence).
7765        let value = if_node(
7766            1,
7767            block_with_tail(2, int_lit(3)),
7768            Some(block_with_tail(4, int_lit(5))),
7769        );
7770        let module = hoist_value_cf(hoisted_let_block(value));
7771        let stmts = fn_body_stmts(&module);
7772        assert_eq!(stmts.len(), 1, "no prelude for a plain value if");
7773        let NodeKind::LetBinding { value, .. } = &stmts[0].kind else {
7774            panic!("let");
7775        };
7776        assert!(
7777            matches!(&value.kind, NodeKind::If { .. }),
7778            "plain value if must stay the let's If value, got {:?}",
7779            value.kind
7780        );
7781    }
7782
7783    #[test]
7784    fn hoist_rewrites_loop_break_value() {
7785        // `let x = loop { break 1 }` splices a value-loop whose `break 1` becomes
7786        // `{ __bock_cf_0 = 1; break }`, then `let x = __bock_cf_0`.
7787        let loop_value = n(
7788            1,
7789            NodeKind::Loop {
7790                body: Box::new(n(
7791                    2,
7792                    NodeKind::Block {
7793                        stmts: vec![n(
7794                            3,
7795                            NodeKind::Break {
7796                                value: Some(Box::new(int_lit(4))),
7797                            },
7798                        )],
7799                        tail: None,
7800                    },
7801                )),
7802            },
7803        );
7804        let module = hoist_value_cf(hoisted_let_block(loop_value));
7805        let stmts = fn_body_stmts(&module);
7806        assert_eq!(stmts.len(), 3);
7807        assert!(matches!(&stmts[1].kind, NodeKind::Loop { .. }));
7808        // The loop now contains a bare `break` (value hoisted into an Assign).
7809        let mut found_bare_break = false;
7810        struct BreakFinder<'a>(&'a mut bool);
7811        impl bock_air::visitor::Visitor for BreakFinder<'_> {
7812            fn visit_node(&mut self, node: &AIRNode) {
7813                if matches!(&node.kind, NodeKind::Break { value: None }) {
7814                    *self.0 = true;
7815                }
7816                bock_air::visitor::walk_node(self, node);
7817            }
7818        }
7819        use bock_air::visitor::Visitor;
7820        BreakFinder(&mut found_bare_break).visit_node(&stmts[1]);
7821        assert!(
7822            found_bare_break,
7823            "break value must be hoisted into an Assign, leaving a bare break"
7824        );
7825    }
7826}