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, .. } = ¶m.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(¶ms, &[]);
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(¶ms, &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(¶ms, &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(®, &variant_path("Empty")).map(|i| i.enum_name.as_str()),
7279 Some("Shape")
7280 );
7281 // An unknown name does not.
7282 assert!(registered_variant(®, &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(®, &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(®, &trait_path, &impl_methods).is_empty());
7474
7475 // The required method is never synthesized even when not overridden.
7476 let inherited = inherited_default_methods(®, &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}