Skip to main content

bock_codegen/
py.rs

1//! Python code generator — rule-based (Tier 2) transpilation from AIR to Python.
2//!
3//! Handles all capability gaps:
4//! - Records → `@dataclass` classes
5//! - Algebraic types → dataclasses with `_tag` discriminant
6//! - Pattern matching → native `match`/`case` (Python 3.10+)
7//! - Effects → keyword arguments
8//! - Ownership → erased (Python is GC)
9//! - Generics → `TypeVar` + `Generic[T]` (so `T` resolves at class-eval time)
10//! - Type hints on all declarations
11
12use std::cell::Cell;
13use std::collections::HashMap;
14use std::fmt::Write;
15use std::path::PathBuf;
16
17use bock_air::{AIRNode, AirInterpolationPart, EnumVariantPayload, NodeKind, ResultVariant};
18use bock_ast::{AssignOp, BinOp, Literal, TypeExpr, UnaryOp, Visibility};
19use bock_types::AIRModule;
20
21use crate::error::CodegenError;
22use crate::generator::{CodeGenerator, GeneratedCode, OutputFile, SourceMap};
23use crate::profile::TargetProfile;
24
25/// Runtime helpers for Bock concurrency in Python. Backed by
26/// `asyncio.Queue`, which supports multiple producers and a single
27/// consumer awaiting `.get()` at a time. Injected at the top of any
28/// module that references `Channel` or `spawn`.
29/// Conservative module scan for `Channel` / `spawn` references.
30fn py_module_uses_concurrency(items: &[AIRNode]) -> bool {
31    items.iter().any(|n| {
32        let s = format!("{n:?}");
33        s.contains("\"Channel\"") || s.contains("\"spawn\"")
34    })
35}
36
37/// True if the module references `Optional`, `Some`, or `None` anywhere — or
38/// calls `pop`, whose DQ30 lowering builds the tagged Optional values
39/// (`_BockSome(...)` / `_bock_none`) — so the Optional runtime prelude must be
40/// emitted. A cheap structural scan over the debug rendering, mirroring
41/// [`py_module_uses_concurrency`] and the Go/TS backends'
42/// `*_module_uses_optional`. Over-matching only emits unused runtime classes,
43/// which is harmless.
44fn py_module_uses_optional(items: &[AIRNode]) -> bool {
45    items.iter().any(|n| {
46        let s = format!("{n:?}");
47        s.contains("\"Optional\"")
48            || s.contains("TypeOptional")
49            || s.contains("\"Some\"")
50            || s.contains("\"None\"")
51            || s.contains("\"pop\"")
52    })
53}
54
55/// True if the module uses a DQ30 in-place `List` mutator whose Python
56/// lowering pre-checks bounds and aborts through the
57/// [`LIST_MUTATOR_RUNTIME_PY`] helper (`remove_at`/`insert`/`set`). Gates
58/// emission of that prelude, mirroring [`py_module_uses_optional`]. `pop`
59/// (no abort path) and `reverse` (native `lst.reverse()`) need no helper, so
60/// they don't gate here. `set` over-matches `Map.set`, which only emits an
61/// unused helper function — harmless.
62fn py_module_uses_list_mutators(items: &[AIRNode]) -> bool {
63    items.iter().any(|n| {
64        let s = format!("{n:?}");
65        s.contains("\"remove_at\"") || s.contains("\"insert\"") || s.contains("\"set\"")
66    })
67}
68
69/// True if the module references `Result`, `Ok`, or `Err` anywhere, so the
70/// `Result` runtime prelude must be emitted. Mirrors [`py_module_uses_optional`].
71fn py_module_uses_result(items: &[AIRNode]) -> bool {
72    items.iter().any(|n| {
73        let s = format!("{n:?}");
74        s.contains("\"Result\"")
75            || s.contains("ResultConstruct")
76            || s.contains("\"Ok\"")
77            || s.contains("\"Err\"")
78    })
79}
80
81/// True if the module uses the `?` propagate operator anywhere, so the
82/// [`PROPAGATE_RUNTIME_PY`] helper (`_bock_try` / `_BockPropagate`) must be
83/// emitted. Mirrors [`py_module_uses_optional`]: a structural scan over the debug
84/// rendering for the `Propagate` AIR node.
85fn py_module_uses_propagate(items: &[AIRNode]) -> bool {
86    items.iter().any(|n| format!("{n:?}").contains("Propagate"))
87}
88
89/// True if the module contains a string interpolation (`${expr}`), so the
90/// [`STR_RUNTIME_PY`] `_bock_str` helper must be emitted. Gates that prelude,
91/// mirroring [`py_module_uses_propagate`]. (Q-displayable-interpolation-dispatch.)
92fn py_module_uses_str(items: &[AIRNode]) -> bool {
93    items
94        .iter()
95        .any(|n| format!("{n:?}").contains("Interpolation"))
96}
97
98/// True if the module uses a `List` functional combinator that lowers to one of
99/// the [`LIST_FUNCTIONAL_RUNTIME_PY`] helpers (`reduce`/`fold`/`find`/`for_each`).
100/// Gates emission of that prelude, mirroring [`py_module_uses_optional`]. `map`/
101/// `filter`/`any`/`all`/`flat_map` lower to Python builtins (`list(map(..))` /
102/// `any(..)` / a comprehension) and need no helper, so they don't gate here.
103fn py_module_uses_list_functional(items: &[AIRNode]) -> bool {
104    items.iter().any(|n| {
105        let s = format!("{n:?}");
106        s.contains("\"reduce\"")
107            || s.contains("\"fold\"")
108            || s.contains("\"find\"")
109            || s.contains("\"for_each\"")
110    })
111}
112
113/// True if the module references the prelude `Ordering` enum, any of its
114/// variants, or a `compare` method call (which the primitive bridge lowers to an
115/// `Ordering` runtime value). Gates emission of [`ORDERING_RUNTIME_PY`], mirroring
116/// [`py_module_uses_optional`].
117fn py_module_uses_ordering(items: &[AIRNode]) -> bool {
118    items.iter().any(|n| {
119        let s = format!("{n:?}");
120        s.contains("\"Ordering\"")
121            || s.contains("\"Less\"")
122            || s.contains("\"Equal\"")
123            || s.contains("\"Greater\"")
124            || s.contains("\"compare\"")
125    })
126}
127
128/// Runtime for Bock `Optional[T]` in Python. The *value* representation mirrors
129/// JS/TS/Go: a tagged value with a `Some` payload or a `None` marker. Python's
130/// `None` is a keyword (and a distinct concept), so Bock's `None` must NOT
131/// collide with it — it lowers to the singleton `_bock_none`, and `Some(x)` to
132/// `_BockSome(x)`. `__match_args__` lets `case _BockSome(v):` bind the payload
133/// positionally; `case _BockNone():` matches the marker. This keeps type and
134/// value in agreement and makes `match o { Some(x) => ...; None => ... }` lower
135/// to valid structural pattern matching (the old codegen emitted bare
136/// `Some`/`None` with no definitions and `case None():`, a `SyntaxError`).
137const OPTIONAL_RUNTIME_PY: &str = "\
138# ── Bock Optional runtime ──
139class _BockSome:
140    __match_args__ = ('_0',)
141    __slots__ = ('_0',)
142    def __init__(self, _0):
143        self._0 = _0
144    def __repr__(self):
145        return f'Some({self._0!r})'
146    def __eq__(self, other):
147        if not isinstance(other, _BockSome):
148            return NotImplemented
149        return self._0 == other._0
150    def __hash__(self):
151        return hash(('Some', self._0))
152
153class _BockNone:
154    __slots__ = ()
155    def __repr__(self):
156        return 'None'
157    def __eq__(self, other):
158        if not isinstance(other, _BockNone):
159            return NotImplemented
160        return True
161    def __hash__(self):
162        return hash('None')
163
164_bock_none = _BockNone()
165";
166
167/// Runtime for Bock `Result[T, E]` in Python. Mirrors `OPTIONAL_RUNTIME_PY`: the
168/// `Ok` payload and the `Err` payload each live in a distinct class with
169/// `__match_args__` so `case _BockOk(v):` / `case _BockErr(e):` bind the payload
170/// positionally — the same shape the surface `Ok(..)`/`Err(..)` construction
171/// emits (`_BockOk(..)` / `_BockErr(..)`). The old codegen emitted bare
172/// `Ok(..)`/`case Ok(_0=n):` against undefined names; this keeps construction and
173/// match in agreement on the same runtime classes.
174const RESULT_RUNTIME_PY: &str = "\
175# ── Bock Result runtime ──
176class _BockOk:
177    __match_args__ = ('_0',)
178    __slots__ = ('_0',)
179    def __init__(self, _0):
180        self._0 = _0
181    def __repr__(self):
182        return f'Ok({self._0!r})'
183    def __eq__(self, other):
184        if not isinstance(other, _BockOk):
185            return NotImplemented
186        return self._0 == other._0
187    def __hash__(self):
188        return hash(('Ok', self._0))
189
190class _BockErr:
191    __match_args__ = ('_0',)
192    __slots__ = ('_0',)
193    def __init__(self, _0):
194        self._0 = _0
195    def __repr__(self):
196        return f'Err({self._0!r})'
197    def __eq__(self, other):
198        if not isinstance(other, _BockErr):
199            return NotImplemented
200        return self._0 == other._0
201    def __hash__(self):
202        return hash(('Err', self._0))
203
204def _bock_parse_int(s, mk_err):
205    try:
206        return _BockOk(int(s.strip()))
207    except (ValueError, TypeError):
208        return _BockErr(mk_err(f\"cannot parse {s!r} as Int\"))
209
210def _bock_parse_float(s, mk_err):
211    try:
212        t = s.strip()
213        if t == '':
214            raise ValueError()
215        return _BockOk(float(t))
216    except (ValueError, TypeError):
217        return _BockErr(mk_err(f\"cannot parse {s!r} as Float\"))
218";
219
220/// Runtime for the `?` propagate operator in Python. `expr?` lowers to
221/// `_bock_try(expr)`: an `Ok`/`Some` value yields its payload; an `Err`/`None`
222/// value raises the `_BockPropagate` sentinel carrying the original tagged value.
223/// The enclosing function (the one containing the `?`) has its body wrapped in
224/// `try: … except _BockPropagate as __p: return __p.value`, so the `Err`/`None`
225/// is re-returned unchanged — Rust-`?` semantics.
226///
227/// The unwrap test is by **class name** (`type(v).__name__`) rather than
228/// `isinstance`, so the helper is self-contained: it does not hard-reference
229/// `_BockOk`/`_BockSome`, which lets it live in `_bock_runtime` (or be inlined)
230/// even when only one of the Optional/Result preludes is present. Anything that
231/// is not a recognised success tag (including the `_BockNone` singleton) is
232/// treated as the failing case and propagated.
233const PROPAGATE_RUNTIME_PY: &str = "\
234# ── Bock `?` propagate runtime ──
235class _BockPropagate(Exception):
236    __slots__ = ('value',)
237    def __init__(self, value):
238        super().__init__()
239        self.value = value
240
241def _bock_try(v):
242    if type(v).__name__ in ('_BockOk', '_BockSome'):
243        return v._0
244    raise _BockPropagate(v)
245";
246
247/// The prelude `Ordering` runtime: the three variants of `core.compare.Ordering`
248/// as singleton instances of distinct classes, matchable by `case` and emitted
249/// for construction. Mirrors `OPTIONAL_RUNTIME_PY` — when the `core.compare`
250/// enum declaration is not among the reached modules, the primitive bridge
251/// (`(x).compare(y)`) and any bare `Less`/`Equal`/`Greater` need this
252/// self-contained representation. Each class is empty (no payload), so
253/// `case _BockOrderingLess():` matches the corresponding singleton.
254const ORDERING_RUNTIME_PY: &str = "\
255# ── Bock Ordering runtime ──
256class _BockOrderingLess:
257    __slots__ = ()
258    def __repr__(self):
259        return 'Less'
260
261class _BockOrderingEqual:
262    __slots__ = ()
263    def __repr__(self):
264        return 'Equal'
265
266class _BockOrderingGreater:
267    __slots__ = ()
268    def __repr__(self):
269        return 'Greater'
270
271_bock_less = _BockOrderingLess()
272_bock_equal = _BockOrderingEqual()
273_bock_greater = _BockOrderingGreater()
274
275def _bock_compare(a, b):
276    m = getattr(a, 'compare', None)
277    if callable(m):
278        return m(b)
279    return _bock_less if a < b else (_bock_equal if a == b else _bock_greater)
280";
281
282/// Runtime helper for the DQ30 in-place `List` mutators whose Python lowering
283/// pre-checks bounds (`remove_at`/`insert`/`set`): Python cannot `raise` in an
284/// expression (the lowerings are single-evaluation `lambda` IIFEs), so the
285/// violated-contract branch calls this raising helper instead. The message is
286/// the normalized cross-target abort form `List.<op>: index <i> out of bounds
287/// (len <n>)` (op, index, and length — the DQ30 contract), raised as an
288/// `IndexError` so the abort kind matches Python's native indexing aborts (the
289/// DQ23 convention of using the target's idiomatic abort channel). The
290/// pre-checks themselves are load-bearing on Python: native `lst.insert`
291/// CLAMPS out-of-range indices and native `lst.pop(i)` / `lst[i] = x` accept
292/// negative indices — all excluded by the `0 <= i` checks at the call sites.
293/// Gated by [`py_module_uses_list_mutators`].
294const LIST_MUTATOR_RUNTIME_PY: &str = "\
295# ── Bock List in-place-mutator runtime ──
296def _bock_list_abort(op, i, n):
297    raise IndexError(f'List.{op}: index {i} out of bounds (len {n})')
298";
299
300/// Runtime for a `${expr}` interpolation part: render a value with a
301/// `Displayable` impl through its `to_string` method rather than the structural
302/// `repr`/`str` (a dataclass `Point(x=3, y=7)`). The user `Displayable.to_string`
303/// is emitted as a `to_string(self)` method (distinct from Python's `__str__`),
304/// so the helper detects it by `callable(getattr(x, 'to_string', None))` and
305/// calls `x.to_string()`. Primitives and other values fall back to `str(x)`.
306/// Gated by [`py_module_uses_str`]. (Q-displayable-interpolation-dispatch.)
307const STR_RUNTIME_PY: &str = "\
308# ── Bock display-string runtime ──
309def _bock_str(x):
310    m = getattr(x, 'to_string', None)
311    if callable(m):
312        return m()
313    return str(x)
314";
315
316/// Runtime helpers for the closure-taking `List` combinators whose Python form
317/// is not a single builtin expression: `reduce`/`fold` (left folds, no
318/// statement-level loop is expressible in a lambda), `find` (returns the tagged
319/// `Optional`), and `for_each` (a side-effecting drive returning `None`). `map`/
320/// `filter`/`any`/`all`/`flat_map` lower inline to Python builtins and need no
321/// helper. `_bock_find` builds the same tagged `Optional` runtime values
322/// (`_BockSome`/`_bock_none`) that `OPTIONAL_RUNTIME_PY` defines, so that prelude
323/// is co-emitted whenever this one is. Gated by [`py_module_uses_list_functional`].
324const LIST_FUNCTIONAL_RUNTIME_PY: &str = "\
325# ── Bock List functional-combinator runtime ──
326def _bock_reduce(xs, f):
327    it = iter(xs)
328    acc = next(it)
329    for x in it:
330        acc = f(acc, x)
331    return acc
332
333def _bock_fold(xs, init, f):
334    acc = init
335    for x in xs:
336        acc = f(acc, x)
337    return acc
338
339def _bock_find(xs, pred):
340    for x in xs:
341        if pred(x):
342            return _BockSome(x)
343    return _bock_none
344
345def _bock_for_each(xs, f):
346    for x in xs:
347        f(x)
348    return None
349";
350
351/// Runtime-prelude names that resolve through the shared `_bock_runtime`
352/// module (or built-in lowering), NOT through a cross-module import — so the
353/// implicit-import pass must never try to import them from a declaring module.
354/// These are the §18.2-prelude container/ordering symbols whose Python form is
355/// the bespoke tagged runtime (`_BockSome`, …), not the `core.*` declaration.
356const RUNTIME_PRELUDE_NAMES: &[&str] = &[
357    "Optional", "Some", "None", "Result", "Ok", "Err", "Ordering", "Less", "Equal", "Greater",
358];
359
360/// Build a map from every **public top-level symbol name** declared across
361/// `modules` to the dotted module-path that declares it (e.g. `Iterable` →
362/// `core.iter`). Covers functions, records, enums (and each variant's emitted
363/// `Enum_Variant` dataclass name), traits, classes, effects, type aliases, and
364/// consts.
365///
366/// The per-module emission path needs this for **implicit imports**: a prelude
367/// trait used as a base class (`impl Iterable for Bag`, with `Iterable`
368/// auto-imported per §18.2) is referenced without an explicit `use`. Emitting
369/// one file per module means `main.py` must `import` `Iterable` from `core.iter`
370/// even though it never appears in an explicit `use`. This map lets
371/// `generate_project` add exactly those imports for names a module references
372/// but neither declares locally nor imports explicitly.
373///
374/// Runtime-prelude names (`RUNTIME_PRELUDE_NAMES`) are excluded — they resolve
375/// through `_bock_runtime`, not a `core.*` import. The first declarer wins for a
376/// name declared in several modules (deterministic via the dependency order
377/// `modules` arrives in).
378fn collect_public_symbol_modules(
379    modules: &[(&AIRModule, &std::path::Path)],
380) -> HashMap<String, String> {
381    let mut map: HashMap<String, String> = HashMap::new();
382    for (module, _) in modules {
383        let Some(module_path) = crate::generator::module_path_string(module) else {
384            continue;
385        };
386        let NodeKind::Module { items, .. } = &module.kind else {
387            continue;
388        };
389        for item in items {
390            let mut record = |name: &str| {
391                if !RUNTIME_PRELUDE_NAMES.contains(&name) {
392                    map.entry(name.to_string())
393                        .or_insert_with(|| module_path.clone());
394                }
395            };
396            match &item.kind {
397                NodeKind::FnDecl {
398                    visibility, name, ..
399                }
400                | NodeKind::RecordDecl {
401                    visibility, name, ..
402                }
403                | NodeKind::TraitDecl {
404                    visibility, name, ..
405                }
406                | NodeKind::ClassDecl {
407                    visibility, name, ..
408                }
409                | NodeKind::EffectDecl {
410                    visibility, name, ..
411                }
412                | NodeKind::TypeAlias {
413                    visibility, name, ..
414                }
415                | NodeKind::ConstDecl {
416                    visibility, name, ..
417                } => {
418                    if matches!(visibility, Visibility::Public) {
419                        record(&name.name);
420                    }
421                }
422                NodeKind::EnumDecl {
423                    visibility,
424                    name,
425                    variants,
426                    ..
427                } => {
428                    if matches!(visibility, Visibility::Public) {
429                        record(&name.name);
430                        for v in variants {
431                            if let NodeKind::EnumVariant { name: vname, .. } = &v.kind {
432                                record(&format!("{}_{}", name.name, vname.name));
433                            }
434                        }
435                    }
436                }
437                _ => {}
438            }
439        }
440    }
441    map
442}
443
444/// Declared module-path of `module`, or empty if it declares none.
445fn module_path_string_of(module: &AIRModule) -> String {
446    crate::generator::module_path_string(module).unwrap_or_default()
447}
448
449/// Top-level symbol names declared **locally** in `module` (item names plus
450/// each enum variant's emitted `Enum_Variant` name) — the names a per-module
451/// implicit import must never shadow with a cross-module import.
452fn locally_declared_names(module: &AIRModule) -> std::collections::HashSet<String> {
453    let mut names = std::collections::HashSet::new();
454    let NodeKind::Module { items, .. } = &module.kind else {
455        return names;
456    };
457    for item in items {
458        match &item.kind {
459            NodeKind::FnDecl { name, .. }
460            | NodeKind::RecordDecl { name, .. }
461            | NodeKind::TraitDecl { name, .. }
462            | NodeKind::ClassDecl { name, .. }
463            | NodeKind::EffectDecl { name, .. }
464            | NodeKind::TypeAlias { name, .. }
465            | NodeKind::ConstDecl { name, .. } => {
466                names.insert(name.name.clone());
467            }
468            NodeKind::EnumDecl { name, variants, .. } => {
469                names.insert(name.name.clone());
470                for v in variants {
471                    if let NodeKind::EnumVariant { name: vname, .. } = &v.kind {
472                        names.insert(format!("{}_{}", name.name, vname.name));
473                    }
474                }
475            }
476            _ => {}
477        }
478    }
479    names
480}
481
482/// Names brought into scope by `module`'s explicit `use` declarations (the
483/// imported leaf names and their aliases) — already emitted as real imports,
484/// so the implicit-import pass must skip them.
485fn explicitly_imported_names(module: &AIRModule) -> std::collections::HashSet<String> {
486    let mut names = std::collections::HashSet::new();
487    let NodeKind::Module { imports, .. } = &module.kind else {
488        return names;
489    };
490    for import in imports {
491        if let NodeKind::ImportDecl {
492            items: bock_ast::ImportItems::Named(named),
493            ..
494        } = &import.kind
495        {
496            for n in named {
497                names.insert(n.name.name.clone());
498                if let Some(alias) = &n.alias {
499                    names.insert(alias.name.clone());
500                }
501            }
502        }
503    }
504    names
505}
506
507/// Tally, per identifier name, how many times that name appears purely as a
508/// **record/enum/class field label** anywhere in `module` — i.e. in a position
509/// that names a *field*, never a cross-module symbol. Covers the four label
510/// positions:
511///
512/// - record / class / enum-struct-variant field **declarations**
513///   (`record R { total_value: Float }`),
514/// - record-construction labels (`R { total_value: v }`),
515/// - record-pattern field labels (`R { total_value }`),
516/// - field **access** (`r.total_value`).
517///
518/// The implicit-import scan ([`implicit_imports_for`]) matches a public symbol
519/// name against the module's debug rendering. A field label produces the same
520/// quoted-identifier token as a genuine reference, so a record field whose name
521/// collides with a sibling module's public function (e.g. `InventorySummary`'s
522/// `total_value` field vs. `service.total_value`) was spuriously "referenced",
523/// pulling in `from service import total_value`. Because `service` already
524/// imports `models`, that creates a Python import **cycle**
525/// (`ImportError: cannot import name … (circular import)`).
526///
527/// Subtracting these label occurrences from the total lets the scan keep its
528/// "over-import is harmless" property for true references while never importing
529/// a name that appears *only* as a field label.
530fn field_label_occurrences(module: &AIRModule) -> HashMap<String, usize> {
531    let mut counts: HashMap<String, usize> = HashMap::new();
532    fn bump(counts: &mut HashMap<String, usize>, name: &str) {
533        *counts.entry(name.to_string()).or_insert(0) += 1;
534    }
535    fn walk_decl_fields(counts: &mut HashMap<String, usize>, fields: &[bock_ast::RecordDeclField]) {
536        for f in fields {
537            bump(counts, &f.name.name);
538        }
539    }
540    fn walk(counts: &mut HashMap<String, usize>, node: &AIRNode) {
541        match &node.kind {
542            NodeKind::RecordDecl { fields, .. } | NodeKind::ClassDecl { fields, .. } => {
543                walk_decl_fields(counts, fields);
544            }
545            NodeKind::EnumVariant {
546                payload: bock_air::EnumVariantPayload::Struct(fields),
547                ..
548            } => {
549                walk_decl_fields(counts, fields);
550            }
551            NodeKind::FieldAccess { field, object } => {
552                bump(counts, &field.name);
553                walk(counts, object);
554            }
555            NodeKind::RecordConstruct { fields, spread, .. } => {
556                for f in fields {
557                    bump(counts, &f.name.name);
558                    if let Some(v) = &f.value {
559                        walk(counts, v);
560                    }
561                }
562                if let Some(s) = spread {
563                    walk(counts, s);
564                }
565            }
566            NodeKind::RecordPat { fields, .. } => {
567                for f in fields {
568                    bump(counts, &f.name.name);
569                    if let Some(p) = &f.pattern {
570                        walk(counts, p);
571                    }
572                }
573            }
574            _ => {}
575        }
576        // Recurse into every child node. The match above handles the
577        // label-bearing kinds; this generic descent reaches the rest. We render
578        // children via the structural API rather than enumerating every
579        // `NodeKind`, so new expression kinds are covered automatically.
580        for child in child_nodes(node) {
581            walk(counts, child);
582        }
583    }
584    walk(&mut counts, module);
585    counts
586}
587
588/// The directly-owned child `AIRNode`s of `node`, for the field-label walk in
589/// [`field_label_occurrences`]. Returns the kind-specific children; the
590/// label-bearing kinds (record/field-access/construct/pattern) are descended by
591/// the caller's `match`, so here they are skipped to avoid double-counting their
592/// label idents.
593fn child_nodes(node: &AIRNode) -> Vec<&AIRNode> {
594    let mut out: Vec<&AIRNode> = Vec::new();
595    macro_rules! p {
596        ($e:expr) => {
597            out.push($e)
598        };
599    }
600    macro_rules! popt {
601        ($e:expr) => {
602            if let Some(n) = $e {
603                out.push(n)
604            }
605        };
606    }
607    macro_rules! pvec {
608        ($e:expr) => {
609            for n in $e {
610                out.push(n)
611            }
612        };
613    }
614    match &node.kind {
615        NodeKind::Module { imports, items, .. } => {
616            pvec!(imports);
617            pvec!(items);
618        }
619        NodeKind::FnDecl {
620            params,
621            return_type,
622            body,
623            ..
624        } => {
625            pvec!(params);
626            popt!(return_type.as_deref());
627            p!(body);
628        }
629        NodeKind::EnumDecl { variants, .. } => {
630            pvec!(variants);
631        }
632        // Tuple-payload element types are descended; struct-payload field labels
633        // are handled by the caller's match (so they are counted, not skipped).
634        NodeKind::EnumVariant {
635            payload: EnumVariantPayload::Tuple(types),
636            ..
637        } => {
638            pvec!(types);
639        }
640        NodeKind::ClassDecl { methods, .. } => {
641            // Field labels handled by caller; descend into methods only.
642            pvec!(methods);
643        }
644        NodeKind::TraitDecl { methods, .. } => {
645            pvec!(methods);
646        }
647        NodeKind::ImplBlock {
648            target, methods, ..
649        } => {
650            p!(target);
651            pvec!(methods);
652        }
653        NodeKind::EffectDecl { operations, .. } => {
654            pvec!(operations);
655        }
656        NodeKind::TypeAlias { ty, .. } => p!(ty),
657        NodeKind::ConstDecl { ty, value, .. } => {
658            p!(ty);
659            p!(value);
660        }
661        NodeKind::ModuleHandle { handler, .. } => p!(handler),
662        NodeKind::PropertyTest { body, .. } => p!(body),
663        NodeKind::Param {
664            pattern,
665            ty,
666            default,
667        } => {
668            p!(pattern);
669            popt!(ty.as_deref());
670            popt!(default.as_deref());
671        }
672        NodeKind::TypeNamed { args, .. } => {
673            pvec!(args);
674        }
675        NodeKind::TypeTuple { elems } => {
676            pvec!(elems);
677        }
678        NodeKind::TypeFunction { params, ret, .. } => {
679            pvec!(params);
680            p!(ret);
681        }
682        NodeKind::TypeOptional { inner } => p!(inner),
683        NodeKind::BinaryOp { left, right, .. } => {
684            p!(left);
685            p!(right);
686        }
687        NodeKind::UnaryOp { operand, .. } => p!(operand),
688        NodeKind::Assign { target, value, .. } => {
689            p!(target);
690            p!(value);
691        }
692        NodeKind::Call {
693            callee,
694            args,
695            type_args,
696        } => {
697            p!(callee);
698            for a in args {
699                out.push(&a.value);
700            }
701            pvec!(type_args);
702        }
703        NodeKind::MethodCall {
704            receiver,
705            type_args,
706            args,
707            ..
708        } => {
709            p!(receiver);
710            pvec!(type_args);
711            for a in args {
712                out.push(&a.value);
713            }
714        }
715        NodeKind::Index { object, index } => {
716            p!(object);
717            p!(index);
718        }
719        NodeKind::Propagate { expr }
720        | NodeKind::Await { expr }
721        | NodeKind::Move { expr }
722        | NodeKind::Borrow { expr }
723        | NodeKind::MutableBorrow { expr } => p!(expr),
724        NodeKind::Lambda { params, body } => {
725            pvec!(params);
726            p!(body);
727        }
728        NodeKind::Pipe { left, right } | NodeKind::Compose { left, right } => {
729            p!(left);
730            p!(right);
731        }
732        NodeKind::Range { lo, hi, .. } | NodeKind::RangePat { lo, hi, .. } => {
733            p!(lo);
734            p!(hi);
735        }
736        NodeKind::ListLiteral { elems }
737        | NodeKind::SetLiteral { elems }
738        | NodeKind::TupleLiteral { elems }
739        | NodeKind::TuplePat { elems } => {
740            pvec!(elems);
741        }
742        NodeKind::MapLiteral { entries } => {
743            for e in entries {
744                out.push(&e.key);
745                out.push(&e.value);
746            }
747        }
748        NodeKind::Interpolation { parts } => {
749            for part in parts {
750                if let bock_air::AirInterpolationPart::Expr(n) = part {
751                    out.push(n.as_ref());
752                }
753            }
754        }
755        NodeKind::ResultConstruct { value, .. }
756        | NodeKind::Return { value }
757        | NodeKind::Break { value } => {
758            popt!(value.as_deref());
759        }
760        NodeKind::If {
761            let_pattern,
762            condition,
763            then_block,
764            else_block,
765        } => {
766            popt!(let_pattern.as_deref());
767            p!(condition);
768            p!(then_block);
769            popt!(else_block.as_deref());
770        }
771        NodeKind::Guard {
772            let_pattern,
773            condition,
774            else_block,
775        } => {
776            popt!(let_pattern.as_deref());
777            p!(condition);
778            p!(else_block);
779        }
780        NodeKind::Match { scrutinee, arms } => {
781            p!(scrutinee);
782            pvec!(arms);
783        }
784        NodeKind::MatchArm {
785            pattern,
786            guard,
787            body,
788        } => {
789            p!(pattern);
790            popt!(guard.as_deref());
791            p!(body);
792        }
793        NodeKind::For {
794            pattern,
795            iterable,
796            body,
797        } => {
798            p!(pattern);
799            p!(iterable);
800            p!(body);
801        }
802        NodeKind::While { condition, body } => {
803            p!(condition);
804            p!(body);
805        }
806        NodeKind::Loop { body } => p!(body),
807        NodeKind::Block { stmts, tail } => {
808            pvec!(stmts);
809            popt!(tail.as_deref());
810        }
811        NodeKind::LetBinding {
812            pattern, ty, value, ..
813        } => {
814            p!(pattern);
815            popt!(ty.as_deref());
816            p!(value);
817        }
818        NodeKind::EffectOp { args, .. } => {
819            for a in args {
820                out.push(&a.value);
821            }
822        }
823        NodeKind::HandlingBlock { handlers, body } => {
824            for h in handlers {
825                out.push(&h.handler);
826            }
827            p!(body);
828        }
829        NodeKind::ConstructorPat { fields, .. } => {
830            pvec!(fields);
831        }
832        NodeKind::ListPat { elems, rest } => {
833            pvec!(elems);
834            popt!(rest.as_deref());
835        }
836        NodeKind::OrPat { alternatives } => {
837            pvec!(alternatives);
838        }
839        NodeKind::GuardPat { pattern, guard } => {
840            p!(pattern);
841            p!(guard);
842        }
843        // Leaf / label-bearing kinds with no extra children to descend (the
844        // latter are handled by the caller's `match`).
845        NodeKind::ImportDecl { .. }
846        | NodeKind::RecordDecl { .. }
847        | NodeKind::FieldAccess { .. }
848        | NodeKind::RecordConstruct { .. }
849        | NodeKind::RecordPat { .. }
850        | NodeKind::TypeSelf
851        | NodeKind::Literal { .. }
852        | NodeKind::Identifier { .. }
853        | NodeKind::Placeholder
854        | NodeKind::Unreachable
855        | NodeKind::Continue
856        | NodeKind::WildcardPat
857        | NodeKind::BindPat { .. }
858        | NodeKind::LiteralPat { .. }
859        | NodeKind::RestPat
860        | NodeKind::Error
861        | NodeKind::EffectRef { .. } => {}
862        // `NodeKind` is `#[non_exhaustive]`. A future kind we have not taught
863        // this walker about contributes no field-label children, so the scan
864        // falls back to its old (harmless over-import) behavior for it — never
865        // under-import.
866        _ => {}
867    }
868    out
869}
870
871/// Count quoted-identifier-token occurrences of `name` in `rendered` — the
872/// number of `"name"` substrings in the AIR debug dump.
873fn quoted_token_count(rendered: &str, name: &str) -> usize {
874    rendered.matches(&format!("\"{name}\"")).count()
875}
876
877/// Whether a value expression in **binding/expression position** must be lowered
878/// to Python *statements* (assigning the binding) rather than emitted as a
879/// Python expression.
880///
881/// Python has no statement-admitting expression form (no value-`loop`, no
882/// IIFE), so these constructs cannot ride inside a `let x = …` expression:
883///
884/// - a `match` whose arms include a statement / diverging body
885///   (`_ => { return … }`, see [`crate::generator::match_has_statement_arm`]);
886/// - a `loop` / `while` (a value-`loop` yields via `break <v>`, which Python's
887///   valueless `break` cannot express);
888/// - an `if` that is itself a statement (both branches statement bodies) —
889///   it produces no expression value;
890/// - a `Block` carrying statements (a tail-only block is fine as an expression).
891///
892/// When true, [`PyEmitCtx::emit_value_binding`] hoists the construct into real
893/// Python statements. Otherwise the existing expression lowering (including the
894/// ternary `match`/`if` paths) is used unchanged.
895fn value_needs_stmt_form(value: &AIRNode) -> bool {
896    match &value.kind {
897        NodeKind::Match { arms, .. } => {
898            crate::generator::match_has_statement_arm(arms)
899                || control_flow_has_raise_branch(value)
900                // A let-EXPRESSION-position match (`let x = match … { … }`) must
901                // route the same payload/field-binding shapes to statement form
902                // as the function-tail/return value path: the `(lambda __v: …)`
903                // value chain cannot bind a record-pattern field or a user-enum
904                // constructor payload, so without this the binding is left free
905                // (`NameError`). Q-py-letexpr-match-namerror.
906                || match_value_needs_stmt_form(arms)
907        }
908        NodeKind::Loop { .. } | NodeKind::While { .. } => true,
909        NodeKind::If { .. } => {
910            crate::generator::node_is_statement(value) || control_flow_has_raise_branch(value)
911        }
912        NodeKind::Block { stmts, .. } => !stmts.is_empty(),
913        _ => false,
914    }
915}
916
917/// Whether `node` lowers to a Python **`raise` statement** — a diverging
918/// expression that yields no value: a `todo()` / `unreachable()` prelude call
919/// (see [`PyEmitCtx::map_prelude_call`]), or the `unreachable` AIR node. Such an
920/// expression is valid as a statement but **not** after `return` / `= `
921/// (`return raise NotImplementedError()` is a `SyntaxError`), so in value/tail
922/// position it must be emitted bare. The fall-through value is supplied by the
923/// surrounding control flow — the function simply never returns past the raise.
924fn is_raise_expr(node: &AIRNode) -> bool {
925    match &node.kind {
926        NodeKind::Unreachable => true,
927        NodeKind::Call { callee, .. } => matches!(
928            &callee.kind,
929            NodeKind::Identifier { name }
930                if matches!(name.name.as_str(), "todo" | "unreachable")
931        ),
932        _ => false,
933    }
934}
935
936/// Whether `node`'s subtree contains a `?` propagate operator that belongs to
937/// *this* function/method — used to decide whether the body must be wrapped in
938/// the `try: … except _BockPropagate: return …` envelope (see
939/// [`PyEmitCtx::emit_fn_body_with_propagate`]). The walk stops at a nested
940/// `FnDecl`/`Lambda`/`ClassDecl` boundary: a `?` inside a nested closure or
941/// method propagates from *that* inner function, so it gets its own wrapper and
942/// must not force one on the enclosing body.
943fn body_contains_propagate(node: &AIRNode) -> bool {
944    if matches!(node.kind, NodeKind::Propagate { .. }) {
945        return true;
946    }
947    // Do not descend into a nested scope: its `?` is the inner function's.
948    if matches!(
949        node.kind,
950        NodeKind::FnDecl { .. } | NodeKind::Lambda { .. } | NodeKind::ClassDecl { .. }
951    ) {
952        return false;
953    }
954    child_nodes(node).iter().any(|c| body_contains_propagate(c))
955}
956
957/// The tail/block value of `node` (for an `if`/`match` arm body), unwrapping a
958/// single-tail `Block`.
959fn unwrap_block_tail(node: &AIRNode) -> &AIRNode {
960    if let NodeKind::Block {
961        stmts,
962        tail: Some(t),
963    } = &node.kind
964    {
965        if stmts.is_empty() {
966            return t;
967        }
968    }
969    node
970}
971
972/// Whether an **expression-position** `if` (or `match`) has a branch/arm body
973/// that lowers to a diverging Python `raise` (`todo()` / `unreachable()`).
974/// Such a construct cannot ride inside a ternary (`return raise … if … else …`
975/// is a `SyntaxError`), so it must be hoisted to a statement-form `if`/`match`
976/// whose non-diverging branches `return` while the diverging branch `raise`s.
977fn control_flow_has_raise_branch(node: &AIRNode) -> bool {
978    match &node.kind {
979        NodeKind::If {
980            then_block,
981            else_block,
982            ..
983        } => {
984            is_raise_expr(unwrap_block_tail(then_block))
985                || control_flow_has_raise_branch(then_block)
986                || else_block.as_ref().is_some_and(|eb| {
987                    is_raise_expr(unwrap_block_tail(eb)) || control_flow_has_raise_branch(eb)
988                })
989        }
990        NodeKind::Match { arms, .. } => arms.iter().any(|arm| {
991            if let NodeKind::MatchArm { body, .. } = &arm.kind {
992                is_raise_expr(unwrap_block_tail(body)) || control_flow_has_raise_branch(body)
993            } else {
994                false
995            }
996        }),
997        _ => false,
998    }
999}
1000
1001/// Whether a **value-position** `if` (one consumed as an expression — a
1002/// function tail, a `return` value) must be lowered to a statement-form
1003/// `if`/`elif`/`else` rather than a Python ternary.
1004///
1005/// The ternary form (`<then> if <cond> else <else>`) emits only each branch's
1006/// *tail* expression: any statements in a branch block — most importantly a
1007/// `let` binding — are silently dropped, so a later reference to that binding
1008/// becomes a `NameError` (the microservice `handle_delete_user` is the canonical
1009/// case: its `if (authorized) { let role = …; if (role == "admin") … }` lost the
1010/// `role` binding inside the ternary). Routing such an `if` to statement form
1011/// (each branch recursing through `emit_block_body`, which emits the `let` then
1012/// `return`s the tail) preserves the bindings. A branch is "droppable" when its
1013/// block carries statements (or nests another droppable `if`/`elif`).
1014fn if_value_needs_stmt_form(node: &AIRNode) -> bool {
1015    let NodeKind::If {
1016        then_block,
1017        else_block,
1018        ..
1019    } = &node.kind
1020    else {
1021        return false;
1022    };
1023    block_has_droppable_stmts(then_block)
1024        || else_block.as_deref().is_some_and(|eb| {
1025            if matches!(eb.kind, NodeKind::If { .. }) {
1026                if_value_needs_stmt_form(eb)
1027            } else {
1028                block_has_droppable_stmts(eb)
1029            }
1030        })
1031}
1032
1033/// True when a block carries leading statements that a value/ternary lowering
1034/// would drop (it emits only the tail). An empty-statement block is safe as a
1035/// ternary branch; a block with a `let` / expression statement is not. See
1036/// [`if_value_needs_stmt_form`].
1037fn block_has_droppable_stmts(node: &AIRNode) -> bool {
1038    matches!(&node.kind, NodeKind::Block { stmts, .. } if !stmts.is_empty())
1039}
1040
1041/// Whether a **value-position** `match` (one consumed as an expression — a
1042/// function tail, a `return` value) must be lowered to a statement-form
1043/// `match`/`case` rather than the `(lambda __v: …)` conditional chain.
1044///
1045/// The conditional chain can correctly express a flat dispatch that binds at
1046/// most a single payload: a literal, range, list, `Some(x)`/`Ok`/`Err`/`None`,
1047/// a whole-scrutinee bind, or a wildcard. It **cannot** test or bind:
1048///
1049/// - guards (it dropped the guard entirely),
1050/// - or / tuple / nested-constructor / range / list patterns (caught by the
1051///   shared [`crate::generator::match_needs_ifchain`]),
1052/// - **record patterns** — even a bare-bind one (`Point { x, .. } => "x=${x}"`),
1053///   whose field binding the chain left free (`(lambda __v: f"x={x}")(p)` →
1054///   `NameError: name 'x'`). The shared recogniser treats a bare-bind record
1055///   field as *not* structured, so it returns false for that shape; this
1056///   py-local predicate adds record patterns on top so the Python backend routes
1057///   them to the statement-form `emit_pattern`, which binds `case Point(x=x):`
1058///   by field name. (Kept py-local rather than widening the shared recogniser,
1059///   which the if-chain backends consult for their own switch fast-path.)
1060fn match_value_needs_stmt_form(arms: &[AIRNode]) -> bool {
1061    crate::generator::match_needs_ifchain(arms)
1062        || arms.iter().any(|arm| {
1063            matches!(
1064                &arm.kind,
1065                NodeKind::MatchArm { pattern, .. }
1066                    if matches!(pattern.kind, NodeKind::RecordPat { .. })
1067            )
1068        })
1069        || arms
1070            .iter()
1071            .any(|arm| matches!(&arm.kind, NodeKind::MatchArm { pattern, .. } if arm_constructor_binds_payload(pattern)))
1072        || match_arm_drops_leading_stmts(arms)
1073}
1074
1075/// Whether `pattern` is a **constructor pattern that binds a payload the
1076/// `(lambda __v: …)` value chain cannot bind** — i.e. a user-enum variant such
1077/// as `Circle(r)` / `Rect(w, h)`. [`PyEmitCtx::emit_arm_value`] only binds the
1078/// payload of the runtime constructors `Some(x)`/`Ok(x)`/`Err(x)` (and the
1079/// payload-less `None`); for any *other* constructor it emits the arm body with
1080/// the field bindings left FREE, so a value-position / let-expression match
1081/// `let label = match s { Circle(r) => r … }` lowered to `(lambda __v: r …)(s)`
1082/// and raised `NameError: name 'r'` at run time (Q-py-letexpr-match-namerror /
1083/// Q-py-valuepos-match-payload-namebind — same root). Routing such a match to
1084/// the statement-form `match`/`case`, whose `emit_pattern` binds
1085/// `case Shape_Circle(_0=r):` by position, makes the binding resolve. The
1086/// chain-supported `Some`/`None`/`Ok`/`Err` shapes are left on the chain.
1087fn arm_constructor_binds_payload(pattern: &AIRNode) -> bool {
1088    let NodeKind::ConstructorPat { path, fields } = &pattern.kind else {
1089        return false;
1090    };
1091    // The runtime Optional/Result constructors are bound by the value chain.
1092    let leaf = path.segments.last().map_or("", |s| s.name.as_str());
1093    if matches!(leaf, "Some" | "None" | "Ok" | "Err") {
1094        return false;
1095    }
1096    // Any field that introduces a binding (a bare `BindPat`, or a nested
1097    // structured sub-pattern that itself binds) cannot be left free in the
1098    // expression chain.
1099    fields
1100        .iter()
1101        .any(|f| !matches!(f.kind, NodeKind::WildcardPat))
1102}
1103
1104/// Whether any **value-position** `match` arm carries a leading statement that
1105/// the `(lambda __v: …)` conditional chain cannot fold into an
1106/// immediately-applied lambda and would therefore *silently drop*.
1107///
1108/// The chain lowers an arm body block with a *value* tail via
1109/// [`PyEmitCtx::try_emit_block_stmts_as_expr`], which folds a leading simple
1110/// immutable `let` (`lambda x: …`) or a bare expression statement
1111/// (`lambda _: …`) into the expression. But a leading construct that has no
1112/// Python *expression* form — a loop (`for`/`while`/`loop`), an assignment, a
1113/// `return`/`break`/`continue`, a mutable or destructuring `let`, or a nested
1114/// block — makes `try_emit_block_stmts_as_expr` bail; the caller then falls
1115/// back to emitting just the block's tail, dropping the leading statement
1116/// (e.g. `Ok(n) => { for i in 0..n { log(i) } "ok" }` lost the whole loop).
1117///
1118/// When this predicate is true the match is routed to the statement-form
1119/// `match`/`case` ([`PyEmitCtx::emit_match`]), whose arm bodies recurse through
1120/// [`PyEmitCtx::emit_block_body`] — emitting each leading statement and then
1121/// `return`ing the tail — so the side effect runs *and* the value is produced.
1122/// The check mirrors `try_emit_block_stmts_as_expr`'s bail conditions exactly,
1123/// so the two stay in agreement (only arms the chain *can* express stay on it).
1124fn match_arm_drops_leading_stmts(arms: &[AIRNode]) -> bool {
1125    arms.iter().any(|arm| {
1126        let NodeKind::MatchArm { body, .. } = &arm.kind else {
1127            return false;
1128        };
1129        // Only a block with both leading statements *and* a value tail rides the
1130        // lambda chain; a statement-tail / tail-less arm is already routed to
1131        // statement form by `match_has_statement_arm`, and a tail-only block has
1132        // nothing to drop.
1133        let NodeKind::Block {
1134            stmts,
1135            tail: Some(_),
1136        } = &body.kind
1137        else {
1138            return false;
1139        };
1140        stmts.iter().any(stmt_not_lambda_expressible)
1141    })
1142}
1143
1144/// Whether a leading block statement has no Python *expression* form and so
1145/// cannot be folded into the `(lambda …: …)` chain by
1146/// [`PyEmitCtx::try_emit_block_stmts_as_expr`]. Mirrors that method's bail set
1147/// exactly (see [`match_arm_drops_leading_stmts`]).
1148fn stmt_not_lambda_expressible(stmt: &AIRNode) -> bool {
1149    match &stmt.kind {
1150        // A mutable or non-simple-bind (tuple/record/destructuring) `let` cannot
1151        // become a `lambda` parameter; a simple immutable `let` can.
1152        NodeKind::LetBinding {
1153            is_mut, pattern, ..
1154        } => *is_mut || PyEmitCtx::simple_bind_name(pattern).is_none(),
1155        // Statements with no expression form.
1156        NodeKind::Assign { .. }
1157        | NodeKind::While { .. }
1158        | NodeKind::For { .. }
1159        | NodeKind::Loop { .. }
1160        | NodeKind::Return { .. }
1161        | NodeKind::Break { .. }
1162        | NodeKind::Continue
1163        | NodeKind::Block { .. } => true,
1164        // A bare expression statement is foldable via `lambda _: …`.
1165        _ => false,
1166    }
1167}
1168
1169/// Compute the implicit cross-module imports for `module`: public symbols
1170/// declared in *other* reachable modules that `module` references but neither
1171/// declares locally nor imports explicitly. Returns `(module_path, name)`
1172/// pairs.
1173///
1174/// "References" is a conservative structural scan of the module's debug
1175/// rendering for the symbol name as an identifier token (mirroring
1176/// [`py_module_uses_optional`] and friends). It can only *over*-import a name
1177/// the program does not really use, which is harmless (a dead import), never
1178/// *under*-import — so it cannot reintroduce the `NameError` it exists to fix.
1179///
1180/// Exception: a name that appears *only* as a record/enum/class **field label**
1181/// (declaration, construction, pattern, or `.field` access — see
1182/// [`field_label_occurrences`]) is **not** a cross-module symbol reference.
1183/// Importing it anyway was the root cause of a Python import cycle when a record
1184/// field collided with a sibling module's public function (e.g.
1185/// `InventorySummary.total_value` vs. `service.total_value`, making `models`
1186/// import `service` which already imports `models`). We subtract the field-label
1187/// occurrences so such names are skipped while genuine references still import.
1188fn implicit_imports_for(
1189    module: &AIRModule,
1190    public_symbols: &HashMap<String, String>,
1191    own_path: &str,
1192) -> Vec<(String, String)> {
1193    let local = locally_declared_names(module);
1194    let explicit = explicitly_imported_names(module);
1195    let rendered = format!("{module:?}");
1196    let field_labels = field_label_occurrences(module);
1197    let mut out: Vec<(String, String)> = Vec::new();
1198    for (name, declaring_module) in public_symbols {
1199        if declaring_module == own_path || local.contains(name) || explicit.contains(name) {
1200            continue;
1201        }
1202        // Identifier-token match: the AIR debug rendering quotes identifier
1203        // names, so `"Iterable"` appears iff the name is referenced. Subtract
1204        // the field-label occurrences: a name reached *only* through field
1205        // labels is not a cross-module reference and must not be imported (it
1206        // would create an import cycle for field/function name collisions).
1207        let total = quoted_token_count(&rendered, name);
1208        let labels = field_labels.get(name).copied().unwrap_or(0);
1209        if total > labels {
1210            out.push((declaring_module.clone(), name.clone()));
1211        }
1212    }
1213    out
1214}
1215
1216/// The shared per-module runtime module name (without extension). In the
1217/// per-module (native-import) emission path the four runtime preludes
1218/// (`Optional`, `Result`, `Ordering`, concurrency) live in one file —
1219/// `_bock_runtime.py` at the build root — and every emitted module imports the
1220/// names it needs from it. A single shared definition keeps the tagged runtime
1221/// classes *identical objects* across files, so an `isinstance(x, _BockSome)`
1222/// in `main.py` still matches a `_BockSome` built in `core/option.py` (separate
1223/// per-file class definitions would not be `isinstance`-compatible).
1224const RUNTIME_MODULE_PY: &str = "_bock_runtime";
1225
1226/// The Ordering-runtime *singleton* name for an `Ordering` variant
1227/// (`Less`→`_bock_less`, …). Used at construction sites.
1228fn ordering_singleton_py(variant: &str) -> &'static str {
1229    match variant {
1230        "Less" => "_bock_less",
1231        "Equal" => "_bock_equal",
1232        _ => "_bock_greater",
1233    }
1234}
1235
1236/// The Ordering-runtime *class* name for an `Ordering` variant
1237/// (`Less`→`_BockOrderingLess`, …). Used as a `case` pattern.
1238fn ordering_class_py(variant: &str) -> &'static str {
1239    match variant {
1240        "Less" => "_BockOrderingLess",
1241        "Equal" => "_BockOrderingEqual",
1242        _ => "_BockOrderingGreater",
1243    }
1244}
1245
1246const CONCURRENCY_RUNTIME_PY: &str = "\
1247# ── Bock concurrency runtime ──
1248import asyncio as __bock_asyncio
1249
1250class __BockChannel:
1251    __slots__ = ('_q',)
1252    def __init__(self):
1253        self._q = __bock_asyncio.Queue()
1254    def send(self, v):
1255        self._q.put_nowait(v)
1256    async def recv(self):
1257        return await self._q.get()
1258    def close(self):
1259        pass
1260
1261def __bock_channel_new():
1262    ch = __BockChannel()
1263    return (ch, ch)
1264
1265def __bock_spawn(x):
1266    # If already a coroutine, wrap it in a Task so it starts eagerly.
1267    if __bock_asyncio.iscoroutine(x):
1268        return __bock_asyncio.create_task(x)
1269    return x
1270";
1271
1272/// Python code generator implementing the `CodeGenerator` trait.
1273#[derive(Debug)]
1274pub struct PyGenerator {
1275    profile: TargetProfile,
1276}
1277
1278impl PyGenerator {
1279    /// Creates a new Python code generator.
1280    #[must_use]
1281    pub fn new() -> Self {
1282        Self {
1283            profile: TargetProfile::python(),
1284        }
1285    }
1286}
1287
1288impl Default for PyGenerator {
1289    fn default() -> Self {
1290        Self::new()
1291    }
1292}
1293
1294impl CodeGenerator for PyGenerator {
1295    fn target(&self) -> &TargetProfile {
1296        &self.profile
1297    }
1298
1299    fn generate_module(&self, module: &AIRModule) -> Result<GeneratedCode, CodegenError> {
1300        // Shared pre-pass: hoist value-position diverging control flow (see
1301        // `hoist_value_cf`) into declare-then-assign temp blocks.
1302        let module =
1303            &crate::generator::hoist_value_cf(crate::generator::lower_blanket_into(module.clone()));
1304        let mut ctx = PyEmitCtx::new();
1305        ctx.enum_variants =
1306            crate::generator::collect_enum_variants(&[(module, std::path::Path::new(""))]);
1307        ctx.trait_decls =
1308            crate::generator::collect_trait_decls(&[(module, std::path::Path::new(""))]);
1309        ctx.const_names =
1310            crate::generator::collect_const_names(&[(module, std::path::Path::new(""))]);
1311        ctx.emit_node(module)?;
1312        let content = ctx.finish();
1313        let source_map = SourceMap {
1314            generated_file: String::new(),
1315            ..Default::default()
1316        };
1317        Ok(GeneratedCode {
1318            files: vec![OutputFile {
1319                path: PathBuf::new(),
1320                content,
1321                source_map: Some(source_map),
1322            }],
1323        })
1324    }
1325
1326    fn entry_invocation(&self, main_is_async: bool) -> Option<String> {
1327        if main_is_async {
1328            Some("if __name__ == \"__main__\":\n    asyncio.run(main())\n".to_string())
1329        } else {
1330            Some("if __name__ == \"__main__\":\n    main()\n".to_string())
1331        }
1332    }
1333
1334    /// Emit a per-module **native import tree** (spec §20.6.1; DQ19 resolved):
1335    /// each module the entry program reaches through a real `use` is emitted to
1336    /// its **own** Python file, and cross-module references resolve through real
1337    /// Python imports (`from core.option import or_else`). This is the sole
1338    /// `bock build` output path.
1339    ///
1340    /// Output-path mapping is keyed on each module's *declared* path, not its
1341    /// on-disk source path, so the file layout and the import path agree:
1342    /// `module core.option` ⇒ `core/option.py` and `from core.option import …`.
1343    /// The **entry** module (the one declaring `main`, else the last in
1344    /// dependency order) is always emitted as `main.py` so the run model
1345    /// (`python3 main.py` from the build root) is stable; Python adds the
1346    /// script's directory to `sys.path`, and `core` resolves as a PEP 420
1347    /// namespace package (no `__init__.py` needed).
1348    ///
1349    /// The four runtime preludes (`Optional`, `Result`, `Ordering`,
1350    /// concurrency) are emitted **once** into a shared `_bock_runtime.py`
1351    /// (see `RUNTIME_MODULE_PY`); every module that references one imports it
1352    /// (`from _bock_runtime import *`). A single shared definition keeps the
1353    /// tagged runtime classes identical across files so cross-module
1354    /// `isinstance` checks succeed.
1355    fn generate_project(
1356        &self,
1357        modules: &[(&AIRModule, &std::path::Path)],
1358    ) -> Result<GeneratedCode, CodegenError> {
1359        // Shared pre-pass: hoist value-position diverging control flow on every
1360        // module before registry collection or emission (see `hoist_value_cf`).
1361        let hoisted: Vec<(AIRModule, &std::path::Path)> = modules
1362            .iter()
1363            .map(|(m, p)| {
1364                (
1365                    crate::generator::hoist_value_cf(crate::generator::lower_blanket_into(
1366                        (*m).clone(),
1367                    )),
1368                    *p,
1369                )
1370            })
1371            .collect();
1372        let modules: Vec<(&AIRModule, &std::path::Path)> =
1373            hoisted.iter().map(|(m, p)| (m, *p)).collect();
1374        let modules = modules.as_slice();
1375        // Emit only modules the entry program actually `use`s (plus the entry
1376        // itself), dependency-ordered — never the prelude-only stdlib (see
1377        // `reachable_modules`).
1378        let reachable = crate::generator::reachable_modules(modules);
1379        let modules = reachable.as_slice();
1380        if modules.is_empty() {
1381            return Ok(GeneratedCode { files: vec![] });
1382        }
1383
1384        // The entry module names `main.py`; every other module is placed at the
1385        // path mirrored from its declared module-path.
1386        let entry_idx = modules
1387            .iter()
1388            .position(|(m, _)| crate::generator::module_declares_main_fn(m))
1389            .unwrap_or(modules.len() - 1);
1390
1391        // Enum-variant / trait registries are collected across the whole
1392        // reachable set so a reference in one file to a type declared in another
1393        // lowers identically to the bundling path.
1394        let enum_variants = crate::generator::collect_enum_variants(modules);
1395        let trait_decls = crate::generator::collect_trait_decls(modules);
1396        let const_names = crate::generator::collect_const_names(modules);
1397        // Map of public symbol → declaring module, for the implicit-import pass.
1398        let public_symbols = collect_public_symbol_modules(modules);
1399        // Program-wide field/method name-collision set (snake_cased). Built across
1400        // *all* reachable modules so a call site in `main.py` to a renamed method
1401        // declared in `core/error.py` agrees with that declaration.
1402        let mut field_method_collisions = std::collections::HashSet::new();
1403        for (module, _) in modules {
1404            field_method_collisions.extend(crate::generator::collect_record_field_names(
1405                module,
1406                to_snake_case,
1407            ));
1408        }
1409
1410        let main_is_async = modules
1411            .iter()
1412            .any(|(m, _)| crate::generator::module_main_fn_is_async(m));
1413        let invocation = self.entry_invocation(main_is_async);
1414
1415        let mut files: Vec<OutputFile> = Vec::with_capacity(modules.len() + 1);
1416        // Which runtime preludes any module references — drives `_bock_runtime.py`.
1417        let mut runtime_optional = false;
1418        let mut runtime_result = false;
1419        let mut runtime_ordering = false;
1420        let mut runtime_concurrency = false;
1421        let mut runtime_list_functional = false;
1422        let mut runtime_list_mutators = false;
1423        let mut runtime_propagate = false;
1424        let mut runtime_str = false;
1425
1426        for (i, (module, source_path)) in modules.iter().enumerate() {
1427            let mut ctx = PyEmitCtx::new();
1428            ctx.per_module = true;
1429            // Entry module (declares `main`, emitted as `main.py`) gets the
1430            // Windows UTF-8 stdout guard in its preamble — entry-only, since it
1431            // is a process-global side effect.
1432            ctx.is_entry_module =
1433                i == entry_idx && crate::generator::module_declares_main_fn(module);
1434            ctx.enum_variants = enum_variants.clone();
1435            ctx.trait_decls = trait_decls.clone();
1436            ctx.const_names = const_names.clone();
1437            ctx.field_method_collisions = field_method_collisions.clone();
1438            // Effect-op resolution needs the whole reachable set: a bare op in
1439            // one module may belong to an effect declared in another.
1440            ctx.seed_effect_registries(modules);
1441            ctx.implicit_imports =
1442                implicit_imports_for(module, &public_symbols, &module_path_string_of(module));
1443            ctx.emit_node(module)?;
1444            runtime_optional |= ctx.needs_runtime_optional;
1445            runtime_result |= ctx.needs_runtime_result;
1446            runtime_ordering |= ctx.needs_runtime_ordering;
1447            runtime_concurrency |= ctx.needs_runtime_concurrency;
1448            runtime_list_functional |= ctx.needs_runtime_list_functional;
1449            runtime_list_mutators |= ctx.needs_runtime_list_mutators;
1450            runtime_propagate |= ctx.needs_runtime_propagate;
1451            runtime_str |= ctx.needs_runtime_str;
1452            let mut content = ctx.finish();
1453
1454            // The entry file gets the `if __name__ == "__main__": main()`
1455            // invocation appended (exactly once, only when it declares `main`).
1456            if i == entry_idx && crate::generator::module_declares_main_fn(module) {
1457                if let Some(invoc) = invocation.as_ref() {
1458                    if !content.is_empty() && !content.ends_with('\n') {
1459                        content.push('\n');
1460                    }
1461                    content.push_str(invoc);
1462                }
1463            }
1464
1465            let out_path = self.module_output_path(module, source_path, i == entry_idx);
1466            let generated_file = out_path
1467                .file_name()
1468                .and_then(|s| s.to_str())
1469                .unwrap_or("")
1470                .to_string();
1471            let source_map = SourceMap {
1472                generated_file,
1473                ..Default::default()
1474            };
1475            files.push(OutputFile {
1476                path: out_path,
1477                content,
1478                source_map: Some(source_map),
1479            });
1480        }
1481
1482        // Emit the shared runtime module with exactly the preludes referenced.
1483        if runtime_optional
1484            || runtime_result
1485            || runtime_ordering
1486            || runtime_concurrency
1487            || runtime_list_functional
1488            || runtime_list_mutators
1489            || runtime_propagate
1490            || runtime_str
1491        {
1492            let mut content = String::new();
1493            // Every runtime name is underscore-prefixed, which `from … import *`
1494            // skips *unless* the module declares `__all__`. Build `__all__`
1495            // explicitly from the emitted preludes so the consuming modules'
1496            // `from _bock_runtime import *` pulls in `_BockSome` / `_bock_none`
1497            // / … (without it, those names resolve to `NameError` at run time).
1498            let mut all_names: Vec<&str> = Vec::new();
1499            if runtime_optional {
1500                content.push_str(OPTIONAL_RUNTIME_PY);
1501                content.push('\n');
1502                all_names.extend(["_BockSome", "_BockNone", "_bock_none"]);
1503            }
1504            if runtime_result {
1505                content.push_str(RESULT_RUNTIME_PY);
1506                content.push('\n');
1507                all_names.extend([
1508                    "_BockOk",
1509                    "_BockErr",
1510                    "_bock_parse_int",
1511                    "_bock_parse_float",
1512                ]);
1513            }
1514            if runtime_ordering {
1515                content.push_str(ORDERING_RUNTIME_PY);
1516                content.push('\n');
1517                all_names.extend([
1518                    "_BockOrderingLess",
1519                    "_BockOrderingEqual",
1520                    "_BockOrderingGreater",
1521                    "_bock_less",
1522                    "_bock_equal",
1523                    "_bock_greater",
1524                    "_bock_compare",
1525                ]);
1526            }
1527            if runtime_concurrency {
1528                content.push_str(CONCURRENCY_RUNTIME_PY);
1529                content.push('\n');
1530                all_names.extend(["__BockChannel", "__bock_channel_new", "__bock_spawn"]);
1531            }
1532            if runtime_list_functional {
1533                // `_bock_find` references `_BockSome`/`_bock_none`; the ctx that
1534                // set `needs_runtime_list_functional` also set
1535                // `needs_runtime_optional`, so the Optional prelude is already in
1536                // `content` above this point.
1537                content.push_str(LIST_FUNCTIONAL_RUNTIME_PY);
1538                content.push('\n');
1539                all_names.extend(["_bock_reduce", "_bock_fold", "_bock_find", "_bock_for_each"]);
1540            }
1541            if runtime_list_mutators {
1542                content.push_str(LIST_MUTATOR_RUNTIME_PY);
1543                content.push('\n');
1544                all_names.push("_bock_list_abort");
1545            }
1546            if runtime_propagate {
1547                // `_bock_try` tests success tags by class *name*, so it has no
1548                // hard reference to `_BockOk`/`_BockSome` and can stand alone even
1549                // when only one of the Optional/Result preludes is present.
1550                content.push_str(PROPAGATE_RUNTIME_PY);
1551                content.push('\n');
1552                all_names.extend(["_BockPropagate", "_bock_try"]);
1553            }
1554            if runtime_str {
1555                content.push_str(STR_RUNTIME_PY);
1556                content.push('\n');
1557                all_names.push("_bock_str");
1558            }
1559            let all_list = all_names
1560                .iter()
1561                .map(|n| format!("\"{n}\""))
1562                .collect::<Vec<_>>()
1563                .join(", ");
1564            content.push_str(&format!("__all__ = [{all_list}]\n"));
1565            files.push(OutputFile {
1566                path: PathBuf::from(format!("{RUNTIME_MODULE_PY}.py")),
1567                content,
1568                source_map: Some(SourceMap {
1569                    generated_file: format!("{RUNTIME_MODULE_PY}.py"),
1570                    ..Default::default()
1571                }),
1572            });
1573        }
1574
1575        Ok(GeneratedCode { files })
1576    }
1577
1578    /// Transpile `@test` functions into a `test_bock.py` file (S7).
1579    ///
1580    /// `framework`: `"unittest"` emits a `unittest.TestCase` subclass with
1581    /// `self.assertEqual`/`assertTrue`/…; anything else (default `"pytest"`)
1582    /// emits module-level `def test_xxx():` with bare `assert` — both discovered
1583    /// by `pytest` and `python -m unittest`. Functions under test are imported by
1584    /// name from their emitted modules; the Optional/Result predicate assertions
1585    /// import the runtime tag classes from `_bock_runtime`.
1586    fn generate_tests(
1587        &self,
1588        modules: &[(&AIRModule, &std::path::Path)],
1589        framework: &str,
1590    ) -> Result<crate::generator::TestArtifacts, CodegenError> {
1591        let reachable = crate::generator::reachable_modules(modules);
1592        let modules = reachable.as_slice();
1593        let tests = crate::generator::collect_test_fns(modules);
1594        if tests.is_empty() {
1595            return Ok(crate::generator::TestArtifacts::default());
1596        }
1597        let entry_idx = modules
1598            .iter()
1599            .position(|(m, _)| crate::generator::module_declares_main_fn(m))
1600            .unwrap_or(modules.len().saturating_sub(1));
1601
1602        // Cross-module registries, mirroring `generate_project`, so the test
1603        // bodies lower references identically to the runtime tree.
1604        let enum_variants = crate::generator::collect_enum_variants(modules);
1605        let trait_decls = crate::generator::collect_trait_decls(modules);
1606        let const_names = crate::generator::collect_const_names(modules);
1607        let mut field_method_collisions = std::collections::HashSet::new();
1608        for (module, _) in modules {
1609            field_method_collisions.extend(crate::generator::collect_record_field_names(
1610                module,
1611                to_snake_case,
1612            ));
1613        }
1614        let mut ctx = PyEmitCtx::new();
1615        ctx.per_module = true;
1616        ctx.enum_variants = enum_variants;
1617        ctx.trait_decls = trait_decls;
1618        ctx.const_names = const_names;
1619        ctx.field_method_collisions = field_method_collisions;
1620        ctx.seed_effect_registries(modules);
1621
1622        // Import the functions under test, snake_cased, from each module.
1623        let mut import_lines: Vec<String> = Vec::new();
1624        for (i, (module, _)) in modules.iter().enumerate() {
1625            let mut import_names: Vec<String> = crate::generator::exportable_value_names(module)
1626                .into_iter()
1627                .filter(|e| e.is_fn)
1628                .map(|e| to_snake_case(&e.name))
1629                .collect();
1630            // Enum-variant constructors a `@test` body may reference *bare* as a
1631            // call argument (e.g. `apply_casing("x", Upper)` → emits an instance
1632            // of the `{enum}_{variant}` `@dataclass`, `Casing_Upper()`). The
1633            // runtime tree emits those dataclasses at module top level, but the
1634            // class name is emitted *verbatim* (no snake_case), so import it under
1635            // its exact value-name. Over-importing an unreferenced variant is a
1636            // harmless dead import; under-importing a referenced one is a
1637            // `NameError` at test runtime — so mirror the non-test path and
1638            // include every public variant value-name.
1639            import_names.extend(crate::generator::enum_variant_value_names(module));
1640            if import_names.is_empty() {
1641                continue;
1642            }
1643            let module_import = if i == entry_idx {
1644                "main".to_string()
1645            } else {
1646                crate::generator::module_path_string(module).unwrap_or_else(|| "main".to_string())
1647            };
1648            import_lines.push(format!(
1649                "from {module_import} import {}",
1650                import_names.join(", ")
1651            ));
1652        }
1653        import_lines.sort_unstable();
1654        import_lines.dedup();
1655
1656        // Import only the Optional/Result runtime tag classes the assertions
1657        // actually reference — `_bock_runtime.py` only defines the runtimes the
1658        // program uses, so importing an absent class (e.g. `_BockOk` in an
1659        // Optional-only program) would be an ImportError at test load.
1660        let mut runtime_imports: std::collections::BTreeSet<&str> =
1661            std::collections::BTreeSet::new();
1662        for (test_fn, _) in &tests {
1663            if let NodeKind::FnDecl { body, .. } = &test_fn.kind {
1664                collect_runtime_tag_imports(body, &mut runtime_imports);
1665            }
1666        }
1667
1668        let is_unittest = framework == "unittest";
1669        let mut out = String::new();
1670        if is_unittest {
1671            out.push_str("import unittest\n");
1672        }
1673        if !runtime_imports.is_empty() {
1674            let names: Vec<&str> = runtime_imports.iter().copied().collect();
1675            out.push_str(&format!("from _bock_runtime import {}\n", names.join(", ")));
1676        }
1677        for line in &import_lines {
1678            out.push_str(line);
1679            out.push('\n');
1680        }
1681        // Black/PEP 8 puts two blank lines between the import block and the first
1682        // top-level definition (`class`/`def`). Emitting them here keeps the
1683        // transpiled test file `black --check`-clean (§20.6.2 codegen-formatter
1684        // agreement), which CI enforces on the certifying lane.
1685        out.push_str("\n\n");
1686
1687        if is_unittest {
1688            out.push_str("class TestBock(unittest.TestCase):\n");
1689            for (i, (test_fn, _module_path)) in tests.iter().enumerate() {
1690                let NodeKind::FnDecl { name, body, .. } = &test_fn.kind else {
1691                    continue;
1692                };
1693                if i > 0 {
1694                    out.push('\n');
1695                }
1696                out.push_str(&format!("    def {}(self):\n", to_snake_case(&name.name)));
1697                ctx.emit_py_test_body(body, true, 2, &mut out)?;
1698            }
1699            out.push_str("\n\nif __name__ == \"__main__\":\n    unittest.main()\n");
1700        } else {
1701            // Two blank lines between top-level `def`s (Black/PEP 8) and exactly
1702            // one trailing newline at end of file.
1703            for (i, (test_fn, _module_path)) in tests.iter().enumerate() {
1704                let NodeKind::FnDecl { name, body, .. } = &test_fn.kind else {
1705                    continue;
1706                };
1707                if i > 0 {
1708                    out.push_str("\n\n");
1709                }
1710                out.push_str(&format!("def {}():\n", to_snake_case(&name.name)));
1711                ctx.emit_py_test_body(body, false, 1, &mut out)?;
1712            }
1713        }
1714
1715        Ok(crate::generator::TestArtifacts {
1716            files: vec![OutputFile {
1717                path: PathBuf::from("test_bock.py"),
1718                content: out,
1719                source_map: None,
1720            }],
1721            entry_append: None,
1722        })
1723    }
1724}
1725
1726impl PyGenerator {
1727    /// Output path for one module in the per-module native-import tree.
1728    ///
1729    /// The entry module is always `main.py` (mirrored from its source path) so
1730    /// the run model `python3 main.py` is stable. Every other module is placed
1731    /// at the path mirrored from its **declared** module-path so the file
1732    /// location and the Python import path agree:
1733    /// `module core.option` ⇒ `core/option.py` (imported as `core.option`).
1734    /// A module without a declared path falls back to its source-mirrored path.
1735    fn module_output_path(
1736        &self,
1737        module: &AIRModule,
1738        source_path: &std::path::Path,
1739        is_entry: bool,
1740    ) -> PathBuf {
1741        if is_entry {
1742            return crate::generator::derive_output_path(source_path, self.target());
1743        }
1744        match crate::generator::module_path_string(module) {
1745            Some(path) if !path.is_empty() => {
1746                let rel: PathBuf = path.split('.').collect();
1747                rel.with_extension(&self.target().conventions.file_extension)
1748            }
1749            _ => crate::generator::derive_output_path(source_path, self.target()),
1750        }
1751    }
1752}
1753
1754// ─── Emission context ────────────────────────────────────────────────────────
1755
1756/// One lexical-block frame on [`PyEmitCtx::shadow_scopes`].
1757///
1758/// Python has function scope, not block scope, for `=`. A Bock `let` that
1759/// shadows a name bound in an enclosing block would therefore, if emitted as a
1760/// plain `name = …`, permanently stomp the outer binding — code after the nested
1761/// block then reads the inner value. Each block frame tracks the Python names
1762/// bound *directly within it* (`bound`) and, for any name it shadows from an
1763/// enclosing frame, the fresh alias it was renamed to (`renames`). Identifier
1764/// emission consults the frame stack innermost-first, so a shadowed name resolves
1765/// to its alias inside the nested block and to the original once the block ends.
1766#[derive(Default)]
1767struct ShadowScope {
1768    /// Python names bound directly in this block (so a *same-block* re-bind is a
1769    /// plain rebind, never renamed — `let acc = …; let acc = acc + 1`).
1770    bound: std::collections::HashSet<String>,
1771    /// Original-python-name → alias for names this block shadows from an
1772    /// enclosing frame.
1773    renames: HashMap<String, String>,
1774}
1775
1776/// Internal state for Python emission.
1777struct PyEmitCtx {
1778    buf: String,
1779    indent: usize,
1780    needs_dataclass_import: bool,
1781    needs_abc_import: bool,
1782    /// Set when any `async def` is emitted; forces `import asyncio` in the
1783    /// preamble so awaited calls, `asyncio.run`, and `asyncio.create_task`
1784    /// resolve at runtime.
1785    needs_asyncio_import: bool,
1786    /// Set when Duration/Instant codegen emits `time.monotonic_ns()`.
1787    needs_time_import: bool,
1788    /// Set when a numeric primitive method emits `math.*` (`Float.floor`/`ceil`/
1789    /// `sqrt`/`is_nan`/`is_infinite`), forcing `import math` in the preamble.
1790    needs_math_import: bool,
1791    /// Names bound in the current block whose call value should be wrapped
1792    /// in `asyncio.create_task(...)` because the binding is later `await`ed
1793    /// within the same block. See [`Self::collect_task_bindings`].
1794    task_bound_names: std::collections::HashSet<String>,
1795    /// Maps effect operation name → effect type name (e.g., "log" → "Logger").
1796    effect_ops: HashMap<String, String>,
1797    /// Maps effect type name → current handler variable name in scope.
1798    current_handler_vars: HashMap<String, String>,
1799    /// Maps function name → effect type names from its `with` clause.
1800    fn_effects: HashMap<String, Vec<String>>,
1801    /// Maps composite effect name → component effect names.
1802    composite_effects: HashMap<String, Vec<String>>,
1803    /// Monotonically-increasing counter used to generate unique handler
1804    /// variable names per handling block. Python lacks block scope for
1805    /// `=` bindings, so without a suffix, nested `handling (...)` blocks
1806    /// would overwrite each other's handler variables.
1807    handling_counter: usize,
1808    /// Trait impls keyed by target record name, collected up front from the
1809    /// current module's items so `RecordDecl` emission can inline the impl
1810    /// methods as class members instead of leaving orphan module-level
1811    /// functions that never get bound to the handler instance.
1812    impls_by_target: HashMap<String, Vec<AIRNode>>,
1813    /// Set once the Optional runtime prelude has been emitted in the
1814    /// single-module self-contained path ([`PyGenerator::generate_module`]), so
1815    /// a module referencing it more than once still inlines it at most once
1816    /// (redefining the `_BockSome`/`_BockNone` helpers is wasteful and risks
1817    /// shadowing surprises). The per-module project path imports the runtime
1818    /// from the shared `RUNTIME_MODULE_PY` module instead.
1819    optional_runtime_emitted: bool,
1820    /// Set once the `Result` runtime prelude has been emitted; deduped exactly as
1821    /// [`Self::optional_runtime_emitted`] (redefining the `_BockOk`/`_BockErr`
1822    /// classes is wasteful).
1823    result_runtime_emitted: bool,
1824    /// Set once the [`ORDERING_RUNTIME_PY`] prelude has been emitted; deduped
1825    /// exactly as [`Self::optional_runtime_emitted`].
1826    ordering_runtime_emitted: bool,
1827    /// Set once the concurrency runtime prelude has been emitted; deduped exactly
1828    /// as [`Self::optional_runtime_emitted`].
1829    concurrency_runtime_emitted: bool,
1830    /// Set once the [`LIST_FUNCTIONAL_RUNTIME_PY`] prelude has been emitted;
1831    /// deduped exactly as [`Self::optional_runtime_emitted`].
1832    list_functional_runtime_emitted: bool,
1833    /// Set once the [`LIST_MUTATOR_RUNTIME_PY`] prelude (`_bock_list_abort`)
1834    /// has been emitted; deduped exactly as
1835    /// [`Self::optional_runtime_emitted`].
1836    list_mutator_runtime_emitted: bool,
1837    /// Set once the [`PROPAGATE_RUNTIME_PY`] prelude (`_bock_try` /
1838    /// `_BockPropagate`) has been emitted; deduped exactly as
1839    /// [`Self::optional_runtime_emitted`].
1840    propagate_runtime_emitted: bool,
1841    /// Set once the display-string runtime ([`STR_RUNTIME_PY`]) has been inlined
1842    /// in the single-module self-contained path. (Q-displayable-interpolation-dispatch.)
1843    str_runtime_emitted: bool,
1844    /// Set when an enum decl emits a `Name = Union[...]` alias, so the preamble
1845    /// imports `Union` from `typing`.
1846    needs_union_import: bool,
1847    /// Typing-import needs accumulated while lowering type annotations. These
1848    /// are `Cell`s because `type_to_py`/`ast_type_to_py` (where the relevant
1849    /// `Callable`/`Any`/`Self`/`Never`/`TypeVar` names are emitted) take
1850    /// `&self` — many of their call sites borrow `self` immutably inside
1851    /// closures, so promoting them to `&mut self` would fight the borrow
1852    /// checker. The `finish` preamble reads them to emit a single merged
1853    /// `from typing import …` line.
1854    needs_typing_callable: Cell<bool>,
1855    needs_typing_any: Cell<bool>,
1856    needs_typing_self: Cell<bool>,
1857    needs_typing_never: Cell<bool>,
1858    /// Set when a generic decl emits `T = TypeVar("T")`, so the preamble
1859    /// imports `TypeVar` (and `Generic`, used in the class base list).
1860    needs_typing_typevar: Cell<bool>,
1861    /// Names already emitted as `T = TypeVar("T")`, deduped within the file so
1862    /// a type parameter shared by several decls is declared exactly once.
1863    emitted_typevars: std::collections::HashSet<String>,
1864    /// User-enum-variant registry (DV14). Routes a construction/pattern to the
1865    /// `{enum}_{variant}` dataclass and recognises a unit variant (needs `()`
1866    /// instantiation). Built-in Optional/Result pre-seeds filtered out where
1867    /// the bespoke `_BockSome`/`_BockNone` lowering applies. Pre-scanned across
1868    /// the reached modules.
1869    enum_variants: crate::generator::EnumVariantRegistry,
1870    /// The reached modules' user-declared traits (keyed by name). Distinguishes a
1871    /// `T: Equatable` bound that is a real user trait from the compiler-provided
1872    /// sealed-core conformance, which must drop the `bound=` on the `TypeVar` and
1873    /// lower `.eq`/`.compare` to native operators (GAP-C). See
1874    /// [`crate::generator::is_unimplemented_sealed_core_trait`].
1875    trait_decls: crate::generator::TraitDeclRegistry,
1876    /// True in the **per-module native-import** emission path
1877    /// ([`PyGenerator::generate_project`], the sole real-build path). When set,
1878    /// the `Module` arm imports each needed runtime prelude from the shared
1879    /// `RUNTIME_MODULE_PY` module instead of inlining its definitions, and the
1880    /// `ImportDecl` arm emits a real `from <module> import …` rather than a
1881    /// no-op. When clear, the module is emitted as a single self-contained file
1882    /// with its runtime preludes inlined — the [`PyGenerator::generate_module`]
1883    /// path used by unit tests.
1884    per_module: bool,
1885    /// In the per-module path, records which shared-runtime names this module
1886    /// must import from `RUNTIME_MODULE_PY`: Optional, Result, Ordering,
1887    /// concurrency — set from the same structural scans the bundling path uses
1888    /// to decide whether to inline a prelude. `finish` turns these into the
1889    /// module's `from _bock_runtime import …` line.
1890    needs_runtime_optional: bool,
1891    needs_runtime_result: bool,
1892    needs_runtime_ordering: bool,
1893    needs_runtime_concurrency: bool,
1894    needs_runtime_list_functional: bool,
1895    /// In the per-module path, set when this module uses a DQ30 in-place
1896    /// `List` mutator with a bounds pre-check (`remove_at`/`insert`/`set`),
1897    /// so it imports `_bock_list_abort` from the shared `RUNTIME_MODULE_PY`.
1898    /// Mirrors [`Self::needs_runtime_optional`].
1899    needs_runtime_list_mutators: bool,
1900    /// In the per-module path, set when this module uses the `?` propagate
1901    /// operator, so it imports `_bock_try` / `_BockPropagate` from the shared
1902    /// `RUNTIME_MODULE_PY`. Mirrors [`Self::needs_runtime_optional`].
1903    needs_runtime_propagate: bool,
1904    /// As [`Self::needs_runtime_propagate`], for the display-string runtime
1905    /// (`_bock_str`). (Q-displayable-interpolation-dispatch.)
1906    needs_runtime_str: bool,
1907    /// Implicit cross-module imports for the per-module path, as
1908    /// `(module_path, symbol_name)` pairs — names this module references but
1909    /// neither declares locally nor imports via an explicit `use` (e.g. a
1910    /// §18.2-prelude trait used as a base class). The `Module` arm emits a
1911    /// `from <module_path> import <symbol_name>` for each, grouped by module,
1912    /// after the explicit imports. Computed in `generate_project`.
1913    implicit_imports: Vec<(String, String)>,
1914    /// Snake-cased record/class field names across the reachable program, used to
1915    /// disambiguate a method whose snake_cased name collides with a field name
1916    /// (`core.error`'s `message` field + `message()` method). A `@dataclass`
1917    /// field overwrites a same-named method attribute on the class, so the
1918    /// *method* is renamed (`message_method`) at its definition and every call
1919    /// site via [`Self::py_method_name`]; the field keeps its name. Pre-seeded
1920    /// program-wide by `generate_project` (and extended per-module by the
1921    /// `Module` arm for the single-module `generate_module` path). Shared policy
1922    /// with go/js/ts.
1923    field_method_collisions: std::collections::HashSet<String>,
1924    /// Set on the **entry** module (the one declaring `main`, emitted as
1925    /// `main.py`) in the per-module path. When set, `finish` prepends a
1926    /// `sys.stdout.reconfigure(encoding="utf-8")` guard so unicode `print`
1927    /// output is correct on Windows, whose Python defaults stdout to the locale
1928    /// codepage rather than UTF-8. Entry-only: the reconfigure is a
1929    /// process-global side effect, so it belongs at the single program entry,
1930    /// not in every imported module.
1931    is_entry_module: bool,
1932    /// Stack of "current loop's value target" used to lower an
1933    /// **expression-position `loop`** assigned to a binding
1934    /// (`let r = loop { … break v }`). Python's `break` carries no value, so a
1935    /// `loop` that yields a value cannot be an expression. When such a loop is
1936    /// hoisted to statement form by [`Self::emit_value_binding`], the target
1937    /// variable is pushed here; a `break <value>` inside then lowers to
1938    /// `<target> = <value>` followed by `break`. `None` is pushed for ordinary
1939    /// statement-position loops (no value), so a bare `break` stays a bare
1940    /// `break`. Only the innermost frame is consulted.
1941    loop_value_targets: Vec<Option<String>>,
1942    /// Declared names of module-scope `const`s, pre-scanned across the reachable
1943    /// program. A const is emitted verbatim at both its declaration and every use
1944    /// so the two agree — the def's `to_snake_case` (`FIZZ_NUM` → `fizz_num`) and
1945    /// the use site's uppercase-preserving `identifier_to_py` (`FIZZ_NUM`) would
1946    /// otherwise disagree, raising `NameError`. See
1947    /// [`crate::generator::collect_const_names`].
1948    const_names: std::collections::HashSet<String>,
1949    /// Stack of lexical-block frames for nested-block `let`-shadow renaming (see
1950    /// [`ShadowScope`]). A frame is pushed on entering a Bock `{ }` block (every
1951    /// function/method body, value-block, `if`/`else`/`match`-arm/loop/guard
1952    /// body) and popped on leaving it.
1953    shadow_scopes: Vec<ShadowScope>,
1954    /// Monotonic counter for generating fresh shadow-alias names
1955    /// (`{name}__s{N}`), unique per emission context.
1956    shadow_counter: usize,
1957    /// Names to seed into the *next* shadow frame pushed by
1958    /// [`Self::emit_block_body`] — used to put a function/method's parameters in
1959    /// the same frame as its body block, so a body-level `let` re-binding a param
1960    /// is a plain Python rebind (the idiom) while a *nested*-block `let`
1961    /// shadowing the param is renamed. Drained (cleared) on the next push.
1962    pending_scope_seed: Vec<String>,
1963    /// `true` while emitting the **immediate** body of a `for`/`while`/`loop`
1964    /// (set by [`Self::emit_loop_body`]). A loop body is statement position: its
1965    /// tail expression is *discarded* (a Bock loop evaluates to Unit), so
1966    /// [`Self::emit_block_body_inner`] must emit the tail as a bare expression
1967    /// statement (`<value>`) rather than a function-body `return <value>` — a
1968    /// `return` inside a loop aborts the enclosing function after one iteration
1969    /// (the fizzbuzz / inventory-system truncation). Saved/restored around the
1970    /// loop body and cleared while emitting any *nested* value context (a
1971    /// value-binding hoist, a value-`if`/`match` arm), so the discard applies
1972    /// only to the loop's own tail and never leaks into a value position. A
1973    /// `break v` value still flows through the separate `loop_value_targets`
1974    /// stack, not this flag.
1975    in_loop_body_tail: bool,
1976    /// `true` while emitting the arm/branch bodies of a **statement-position**
1977    /// control-flow construct — a `match` or an `if`/`else` that sits mid-block
1978    /// as a side-effecting statement, not as the block/function tail nor a
1979    /// value-binding RHS (set by [`Self::emit_stmt`]'s `Match` and `If` arms).
1980    /// Like [`Self::in_loop_body_tail`], such a construct evaluates to Unit:
1981    /// each arm's/branch's tail expression is *discarded*, so
1982    /// [`Self::emit_block_body_inner`] must emit it as a bare expression statement
1983    /// (`<value>`) rather than a function-body `return <value>`. Emitting `return`
1984    /// here aborts the enclosing function after the matched arm/taken branch runs
1985    /// — the chat-protocol truncation, where `match decoded { Ok(m) =>
1986    /// println(..) … }` returned out of `main` after the first arm instead of
1987    /// falling through to the rest of the body (#259), and its `if`/`else`
1988    /// sibling (Q-python-ifelse-truncation), where `if c { println(..) } else {
1989    /// println(..) }` returned out after either branch. Saved/restored around
1990    /// the arm/branch bodies and cleared while emitting any *nested* value
1991    /// context (a nested `fn`/method body, a value-binding hoist), so the
1992    /// discard applies only to the statement construct's own tails and never
1993    /// leaks into a value position.
1994    in_stmt_construct_arm: bool,
1995}
1996
1997impl PyEmitCtx {
1998    fn new() -> Self {
1999        Self {
2000            buf: String::with_capacity(4096),
2001            indent: 0,
2002            needs_dataclass_import: false,
2003            needs_abc_import: false,
2004            needs_asyncio_import: false,
2005            needs_time_import: false,
2006            needs_math_import: false,
2007            task_bound_names: std::collections::HashSet::new(),
2008            effect_ops: HashMap::new(),
2009            current_handler_vars: HashMap::new(),
2010            fn_effects: HashMap::new(),
2011            composite_effects: HashMap::new(),
2012            handling_counter: 0,
2013            impls_by_target: HashMap::new(),
2014            optional_runtime_emitted: false,
2015            result_runtime_emitted: false,
2016            ordering_runtime_emitted: false,
2017            concurrency_runtime_emitted: false,
2018            list_functional_runtime_emitted: false,
2019            list_mutator_runtime_emitted: false,
2020            propagate_runtime_emitted: false,
2021            str_runtime_emitted: false,
2022            needs_union_import: false,
2023            needs_typing_callable: Cell::new(false),
2024            needs_typing_any: Cell::new(false),
2025            needs_typing_self: Cell::new(false),
2026            needs_typing_never: Cell::new(false),
2027            needs_typing_typevar: Cell::new(false),
2028            emitted_typevars: std::collections::HashSet::new(),
2029            enum_variants: crate::generator::EnumVariantRegistry::new(),
2030            trait_decls: crate::generator::TraitDeclRegistry::new(),
2031            per_module: false,
2032            needs_runtime_optional: false,
2033            needs_runtime_result: false,
2034            needs_runtime_ordering: false,
2035            needs_runtime_concurrency: false,
2036            needs_runtime_list_functional: false,
2037            needs_runtime_list_mutators: false,
2038            needs_runtime_propagate: false,
2039            needs_runtime_str: false,
2040            implicit_imports: Vec::new(),
2041            field_method_collisions: std::collections::HashSet::new(),
2042            const_names: std::collections::HashSet::new(),
2043            is_entry_module: false,
2044            loop_value_targets: Vec::new(),
2045            shadow_scopes: Vec::new(),
2046            shadow_counter: 0,
2047            pending_scope_seed: Vec::new(),
2048            in_loop_body_tail: false,
2049            in_stmt_construct_arm: false,
2050        }
2051    }
2052
2053    /// The Python method name for a Bock method, disambiguated against the
2054    /// program's field names so a method whose snake_cased name collides with a
2055    /// field gets a `_method` suffix (`message` → `message_method`). Applied
2056    /// identically at the method definition and every call site (shared policy
2057    /// with go/js/ts — see [`crate::generator::disambiguate_method_name`]).
2058    fn py_method_name(&self, name: &str) -> String {
2059        // A method/associated-fn whose snake-cased name is a Python *keyword*
2060        // (e.g. a `From` impl's `from`) cannot be a `def` name or an attribute
2061        // access — `def from()` and `Type.from(...)` are both syntax errors. Such
2062        // names are escaped with a trailing `_` (`from` → `from_`), applied
2063        // identically at the definition and every call site. Ordinary member
2064        // names (`default`, etc.) are legal Python attributes and are not
2065        // escaped; only true keywords are.
2066        let snake = to_snake_case(name);
2067        let escaped =
2068            if crate::generator::is_target_keyword(&snake, crate::generator::KeywordTarget::Python)
2069            {
2070                format!("{snake}_")
2071            } else {
2072                snake
2073            };
2074        crate::generator::disambiguate_method_name(
2075            escaped,
2076            &self.field_method_collisions,
2077            "_method",
2078        )
2079    }
2080
2081    fn finish(mut self) -> String {
2082        // An empty module emits nothing at all — not even a preamble (an empty
2083        // `.py` is the expected output, and a bare `from __future__` import on
2084        // its own would be surprising noise).
2085        if self.buf.is_empty() {
2086            return self.buf;
2087        }
2088        let mut preamble = String::new();
2089        // PEP 563: defer evaluation of every annotation to a string. A method
2090        // declared inside a class body that annotates a parameter with the class
2091        // itself — `class Tag: def equals(self, other: Tag)`, emitted for an
2092        // `impl Eq for Tag` whose `other: Self` resolves to `Tag` — references a
2093        // name that is not yet bound while the class body executes, raising
2094        // `NameError` at import time. `from __future__ import annotations` makes
2095        // all annotations lazy strings, so such (and any other) forward
2096        // references never evaluate eagerly. It must be the first statement in
2097        // the module, so it is prepended ahead of every other import.
2098        preamble.push_str("from __future__ import annotations\n");
2099        // Windows UTF-8 stdout (entry module only). On Windows, Python's stdout
2100        // defaults to the locale codepage, so a unicode `print` (`✓`, `→`, CJK,
2101        // …) raises `UnicodeEncodeError` or mojibakes. Reconfiguring stdout/stderr
2102        // to UTF-8 (py3.7+ `TextIOWrapper.reconfigure`) makes output consistent
2103        // with the POSIX targets. It is a process-global side effect, so it is
2104        // emitted only at the single program entry, never in imported modules.
2105        // The `getattr` guard keeps it a no-op when the stream is not a
2106        // reconfigurable `TextIOWrapper` (e.g. already wrapped/redirected).
2107        if self.is_entry_module {
2108            preamble.push_str(
2109                "import sys as _sys\n\
2110                 if hasattr(_sys.stdout, \"reconfigure\"):\n    \
2111                 _sys.stdout.reconfigure(encoding=\"utf-8\")\n\
2112                 if hasattr(_sys.stderr, \"reconfigure\"):\n    \
2113                 _sys.stderr.reconfigure(encoding=\"utf-8\")\n",
2114            );
2115        }
2116        // Per-module native-import path: pull the runtime-prelude names this
2117        // module references from the shared `_bock_runtime` module so the
2118        // tagged runtime classes are shared (and `isinstance`-compatible)
2119        // across every emitted file (see `RUNTIME_MODULE_PY`). A `*` import
2120        // is intentional — the runtime exposes a small, fixed, underscore-
2121        // prefixed surface and the exact set of referenced names varies with
2122        // how each prelude is used (constructors, singletons, match classes).
2123        if self.per_module
2124            && (self.needs_runtime_optional
2125                || self.needs_runtime_result
2126                || self.needs_runtime_ordering
2127                || self.needs_runtime_concurrency
2128                || self.needs_runtime_list_functional
2129                || self.needs_runtime_list_mutators
2130                || self.needs_runtime_propagate
2131                || self.needs_runtime_str)
2132        {
2133            let _ = writeln!(preamble, "from {RUNTIME_MODULE_PY} import *");
2134        }
2135        if self.needs_asyncio_import {
2136            preamble.push_str("import asyncio\n");
2137        }
2138        if self.needs_time_import {
2139            preamble.push_str("import time\n");
2140        }
2141        if self.needs_math_import {
2142            preamble.push_str("import math\n");
2143        }
2144        // Merge every `typing` need into one `from typing import …` line so a
2145        // module that uses, e.g., both a `Callable` annotation and a generic
2146        // type does not emit two separate (potentially conflicting) imports.
2147        let mut typing_names: Vec<&str> = Vec::new();
2148        if self.needs_union_import {
2149            typing_names.push("Union");
2150        }
2151        if self.needs_typing_callable.get() {
2152            typing_names.push("Callable");
2153        }
2154        if self.needs_typing_any.get() {
2155            typing_names.push("Any");
2156        }
2157        if self.needs_typing_self.get() {
2158            typing_names.push("Self");
2159        }
2160        if self.needs_typing_never.get() {
2161            typing_names.push("Never");
2162        }
2163        if self.needs_typing_typevar.get() {
2164            // `Generic` is always paired with `TypeVar`: a generic class lists
2165            // `Generic[T, …]` in its bases and `T` is a `TypeVar`.
2166            typing_names.push("TypeVar");
2167            typing_names.push("Generic");
2168        }
2169        if !typing_names.is_empty() {
2170            // Stable, de-duplicated ordering for deterministic output.
2171            typing_names.sort_unstable();
2172            typing_names.dedup();
2173            preamble.push_str(&format!("from typing import {}\n", typing_names.join(", ")));
2174        }
2175        if self.needs_abc_import {
2176            preamble.push_str("from abc import ABC, abstractmethod\n");
2177        }
2178        if self.needs_dataclass_import {
2179            preamble.push_str("from dataclasses import dataclass\n");
2180        }
2181        if !preamble.is_empty() {
2182            preamble.push('\n');
2183            self.buf.insert_str(0, &preamble);
2184        }
2185        self.buf
2186    }
2187
2188    /// Emit a `@test` function body (S7) into `out`, lowering `expect(...)`
2189    /// assertion chains to pytest-style `assert` (or `self.assert*` for
2190    /// unittest) and other statements to plain expression/`=` statements.
2191    ///
2192    /// `use_self` selects the unittest idiom (`self.assertEqual(a, e)`); `indent`
2193    /// is the base indentation level (1 for a module-level `def`, 2 for a method
2194    /// inside a `TestCase`). A body with no statements emits `pass`.
2195    fn emit_py_test_body(
2196        &mut self,
2197        body: &AIRNode,
2198        use_self: bool,
2199        indent: usize,
2200        out: &mut String,
2201    ) -> Result<(), CodegenError> {
2202        let pad = "    ".repeat(indent);
2203        let stmts: Vec<&AIRNode> = match &body.kind {
2204            NodeKind::Block { stmts, tail } => stmts.iter().chain(tail.as_deref()).collect(),
2205            _ => vec![body],
2206        };
2207        let mut emitted_any = false;
2208        for stmt in stmts {
2209            emitted_any = true;
2210            if let Some((assertion, actual, expected)) = crate::generator::classify_assertion(stmt)
2211            {
2212                let a = self.expr_to_string(actual)?;
2213                use crate::generator::TestAssertion as T;
2214                let line = if use_self {
2215                    match assertion {
2216                        T::Equal => {
2217                            let e = match expected {
2218                                Some(e) => self.expr_to_string(e)?,
2219                                None => "None".to_string(),
2220                            };
2221                            format!("self.assertEqual({a}, {e})")
2222                        }
2223                        T::BeTrue => format!("self.assertTrue({a})"),
2224                        T::BeFalse => format!("self.assertFalse({a})"),
2225                        T::BeSome => format!("self.assertIsInstance({a}, _BockSome)"),
2226                        T::BeNone => format!("self.assertIsInstance({a}, _BockNone)"),
2227                        T::BeOk => format!("self.assertIsInstance({a}, _BockOk)"),
2228                        T::BeErr => format!("self.assertIsInstance({a}, _BockErr)"),
2229                    }
2230                } else {
2231                    match assertion {
2232                        T::Equal => {
2233                            let e = match expected {
2234                                Some(e) => self.expr_to_string(e)?,
2235                                None => "None".to_string(),
2236                            };
2237                            format!("assert ({a}) == ({e})")
2238                        }
2239                        T::BeTrue => format!("assert ({a}) is True"),
2240                        T::BeFalse => format!("assert ({a}) is False"),
2241                        T::BeSome => format!("assert isinstance({a}, _BockSome)"),
2242                        T::BeNone => format!("assert isinstance({a}, _BockNone)"),
2243                        T::BeOk => format!("assert isinstance({a}, _BockOk)"),
2244                        T::BeErr => format!("assert isinstance({a}, _BockErr)"),
2245                    }
2246                };
2247                out.push_str(&format!("{pad}{line}\n"));
2248            } else if let NodeKind::LetBinding { pattern, value, .. } = &stmt.kind {
2249                let name = match &pattern.kind {
2250                    NodeKind::BindPat { name, .. } => to_snake_case(&name.name),
2251                    _ => {
2252                        emitted_any = false;
2253                        continue;
2254                    }
2255                };
2256                let v = self.expr_to_string(value)?;
2257                out.push_str(&format!("{pad}{name} = {v}\n"));
2258            } else {
2259                let s = self.expr_to_string(stmt)?;
2260                out.push_str(&format!("{pad}{s}\n"));
2261            }
2262        }
2263        if !emitted_any {
2264            out.push_str(&format!("{pad}pass\n"));
2265        }
2266        Ok(())
2267    }
2268
2269    /// Pre-seed the effect registries (`effect_ops`, `composite_effects`) from
2270    /// every module's top-level `EffectDecl`s. In the per-module path each
2271    /// module is emitted by its own context, so a bare op `log(...)` used in
2272    /// `main` whose effect `Log` is declared in another module would not be
2273    /// recognised as an effect op (and not rewritten to `handler.log(...)`)
2274    /// without pre-seeding from the whole reachable set. Mirrors how
2275    /// `enum_variants` / `trait_decls` are collected across the reached modules.
2276    fn seed_effect_registries(&mut self, modules: &[(&AIRModule, &std::path::Path)]) {
2277        for (module, _) in modules {
2278            let NodeKind::Module { items, .. } = &module.kind else {
2279                continue;
2280            };
2281            for item in items {
2282                let NodeKind::EffectDecl {
2283                    name,
2284                    components,
2285                    operations,
2286                    ..
2287                } = &item.kind
2288                else {
2289                    continue;
2290                };
2291                if !components.is_empty() {
2292                    let comp_names: Vec<String> = components
2293                        .iter()
2294                        .map(|tp| {
2295                            tp.segments
2296                                .last()
2297                                .map_or("effect".to_string(), |s| s.name.clone())
2298                        })
2299                        .collect();
2300                    self.composite_effects.insert(name.name.clone(), comp_names);
2301                    continue;
2302                }
2303                for op in operations {
2304                    if let NodeKind::FnDecl { name: op_name, .. } = &op.kind {
2305                        self.effect_ops
2306                            .insert(op_name.name.clone(), name.name.clone());
2307                    }
2308                }
2309            }
2310        }
2311    }
2312
2313    /// Variant info for `path` when its last segment is a registered *user*
2314    /// enum variant (built-in Optional/Result pre-seeds excluded — those go
2315    /// through the bespoke `_BockSome`/`_BockNone` lowering).
2316    fn user_variant_for_path(
2317        &self,
2318        path: &bock_ast::TypePath,
2319    ) -> Option<&crate::generator::EnumVariantInfo> {
2320        let info = crate::generator::registered_variant(&self.enum_variants, path)?;
2321        if matches!(info.enum_name.as_str(), "Optional" | "Result") {
2322            return None;
2323        }
2324        Some(info)
2325    }
2326
2327    /// As [`Self::user_variant_for_path`] but keyed by a bare identifier name.
2328    fn user_variant_for_name(&self, name: &str) -> Option<&crate::generator::EnumVariantInfo> {
2329        let info = self.enum_variants.get(name)?;
2330        if matches!(info.enum_name.as_str(), "Optional" | "Result") {
2331            return None;
2332        }
2333        Some(info)
2334    }
2335
2336    fn indent_str(&self) -> String {
2337        "    ".repeat(self.indent)
2338    }
2339
2340    fn write_indent(&mut self) {
2341        let indent = self.indent_str();
2342        self.buf.push_str(&indent);
2343    }
2344
2345    fn writeln(&mut self, s: &str) {
2346        self.write_indent();
2347        self.buf.push_str(s);
2348        self.buf.push('\n');
2349    }
2350
2351    // ── Prelude function mapping ──────────────────────────────────────────
2352
2353    /// Emit an expression into a temporary buffer and return the string.
2354    fn expr_to_string(&mut self, node: &AIRNode) -> Result<String, CodegenError> {
2355        let start = self.buf.len();
2356        self.emit_expr(node)?;
2357        let s = self.buf[start..].to_string();
2358        self.buf.truncate(start);
2359        Ok(s)
2360    }
2361
2362    /// Render a pattern node to a Python `case` sub-pattern string by running
2363    /// [`Self::emit_pattern`] against a scratch slice of the buffer. Lets a
2364    /// constructor / record field embed a *nested* sub-pattern (`_BockSome(x)`,
2365    /// `_BockOk(v)`, a nested tuple) instead of a flat binding name — the fix for
2366    /// `Some(Ok(v))` losing its inner bindings.
2367    fn pattern_to_py(&mut self, pat: &AIRNode) -> Result<String, CodegenError> {
2368        let start = self.buf.len();
2369        self.emit_pattern(pat)?;
2370        let s = self.buf[start..].to_string();
2371        self.buf.truncate(start);
2372        Ok(s)
2373    }
2374
2375    /// Map Bock prelude functions to Python equivalents.
2376    fn map_prelude_call(
2377        &mut self,
2378        callee: &AIRNode,
2379        args: &[bock_air::AirArg],
2380    ) -> Result<Option<String>, CodegenError> {
2381        let name = match &callee.kind {
2382            NodeKind::Identifier { name } => name.name.as_str(),
2383            _ => return Ok(None),
2384        };
2385        let arg_strs: Vec<String> = args
2386            .iter()
2387            .map(|a| self.expr_to_string(&a.value))
2388            .collect::<Result<_, _>>()?;
2389        let code = match name {
2390            "println" => {
2391                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
2392                format!("print({a})")
2393            }
2394            "print" => {
2395                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
2396                format!("print({a}, end=\"\")")
2397            }
2398            "debug" => {
2399                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
2400                format!("print(repr({a}))")
2401            }
2402            "assert" => {
2403                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
2404                format!("assert {a}")
2405            }
2406            "todo" => "raise NotImplementedError()".to_string(),
2407            "unreachable" => "raise RuntimeError(\"unreachable\")".to_string(),
2408            // Optional `Some(x)` constructor → tagged runtime value (see
2409            // `OPTIONAL_RUNTIME_PY`). `None` is not a call; it lowers in the
2410            // `Identifier` arm to the `_bock_none` singleton.
2411            "Some" => {
2412                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
2413                format!("_BockSome({a})")
2414            }
2415            // Result `Ok(x)` / `Err(e)` constructors → tagged runtime values
2416            // (see `RESULT_RUNTIME_PY`), mirroring the `Some` handling above so
2417            // construction agrees with the `case _BockOk(..)` / `_BockErr(..)`
2418            // match arms.
2419            "Ok" => {
2420                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
2421                format!("_BockOk({a})")
2422            }
2423            "Err" => {
2424                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
2425                format!("_BockErr({a})")
2426            }
2427            "sleep" => {
2428                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
2429                // Route through an installed `Clock` handler if one is in scope;
2430                // otherwise fall through to the host primitive (default).
2431                if let Some(handler) = self.clock_handler_var() {
2432                    format!("{handler}.{}({a})", to_snake_case("sleep"))
2433                } else {
2434                    self.needs_asyncio_import = true;
2435                    // Duration is ns → asyncio.sleep takes seconds.
2436                    format!("asyncio.sleep(({a}) / 1_000_000_000)")
2437                }
2438            }
2439            _ => return Ok(None),
2440        };
2441        Ok(Some(code))
2442    }
2443
2444    /// Emit a built-in `Optional`/`Result` method call to its Python form.
2445    ///
2446    /// Recognised via the checker's `recv_kind` annotation
2447    /// ([`crate::generator::desugared_optional_method`] /
2448    /// [`crate::generator::desugared_result_method`]) so the overloaded names
2449    /// (`unwrap`/`unwrap_or`/`map`) dispatch to the right `isinstance` test on the
2450    /// tagged runtime classes (`_BockSome`/`_BockOk` carry the payload as `._0`).
2451    /// The receiver is bound once in a `lambda` so it is evaluated exactly once.
2452    /// Returns `true` if handled.
2453    fn try_emit_container_method(
2454        &mut self,
2455        node: &AIRNode,
2456        callee: &AIRNode,
2457        args: &[bock_air::AirArg],
2458    ) -> Result<bool, CodegenError> {
2459        if let Some((recv, method, rest)) =
2460            crate::generator::desugared_optional_method(node, callee, args)
2461        {
2462            // Optional: present = `_BockSome`; the "map" reconstruction also uses
2463            // `_BockSome` for the present case and the receiver `__c` (a
2464            // `_bock_none`) for the empty case.
2465            self.emit_tagged_container_method(recv, method, rest, "_BockSome", "_BockSome")?;
2466            return Ok(true);
2467        }
2468        if let Some((recv, method, rest)) =
2469            crate::generator::desugared_result_method(node, callee, args)
2470        {
2471            self.emit_tagged_container_method(recv, method, rest, "_BockOk", "_BockErr")?;
2472            return Ok(true);
2473        }
2474        Ok(false)
2475    }
2476
2477    /// Lower a tagged-container method on `recv`. `present_cls` is the
2478    /// payload-carrying runtime class (`_BockSome`/`_BockOk`); `err_cls` is the
2479    /// other class (`_BockNone` for Optional — unused as a constructor since the
2480    /// empty case passes the receiver through; `_BockErr` for Result, used by
2481    /// `map_err`).
2482    fn emit_tagged_container_method(
2483        &mut self,
2484        recv: &AIRNode,
2485        method: &str,
2486        rest: &[bock_air::AirArg],
2487        present_cls: &str,
2488        err_cls: &str,
2489    ) -> Result<(), CodegenError> {
2490        // Tag tests read the receiver once → emit inline.
2491        match method {
2492            "is_some" | "is_ok" => {
2493                self.buf.push_str("isinstance(");
2494                self.emit_expr(recv)?;
2495                let _ = write!(self.buf, ", {present_cls})");
2496                return Ok(());
2497            }
2498            "is_none" | "is_err" => {
2499                self.buf.push_str("(not isinstance(");
2500                self.emit_expr(recv)?;
2501                let _ = write!(self.buf, ", {present_cls}))");
2502                return Ok(());
2503            }
2504            _ => {}
2505        }
2506        self.buf.push_str("(lambda __c: ");
2507        match method {
2508            "unwrap" => {
2509                let _ = write!(
2510                    self.buf,
2511                    "__c._0 if isinstance(__c, {present_cls}) else None"
2512                );
2513            }
2514            "unwrap_or" => {
2515                let _ = write!(self.buf, "__c._0 if isinstance(__c, {present_cls}) else (");
2516                if let Some(d) = rest.first() {
2517                    self.emit_expr(&d.value)?;
2518                } else {
2519                    self.buf.push_str("None");
2520                }
2521                self.buf.push(')');
2522            }
2523            "map" => {
2524                let _ = write!(self.buf, "{present_cls}((");
2525                if let Some(f) = rest.first() {
2526                    self.emit_expr(&f.value)?;
2527                } else {
2528                    self.buf.push_str("lambda x: x");
2529                }
2530                let _ = write!(
2531                    self.buf,
2532                    ")(__c._0)) if isinstance(__c, {present_cls}) else __c"
2533                );
2534            }
2535            "flat_map" => {
2536                let _ = write!(self.buf, "(");
2537                if let Some(f) = rest.first() {
2538                    self.emit_expr(&f.value)?;
2539                } else {
2540                    self.buf.push_str("lambda x: x");
2541                }
2542                let _ = write!(
2543                    self.buf,
2544                    ")(__c._0) if isinstance(__c, {present_cls}) else __c"
2545                );
2546            }
2547            "map_err" => {
2548                let _ = write!(self.buf, "{err_cls}((");
2549                if let Some(f) = rest.first() {
2550                    self.emit_expr(&f.value)?;
2551                } else {
2552                    self.buf.push_str("lambda x: x");
2553                }
2554                let _ = write!(
2555                    self.buf,
2556                    ")(__c._0)) if isinstance(__c, {err_cls}) else __c"
2557                );
2558            }
2559            _ => self.buf.push_str("None"),
2560        }
2561        self.buf.push_str(")(");
2562        self.emit_expr(recv)?;
2563        self.buf.push(')');
2564        Ok(())
2565    }
2566
2567    /// Emit a read-only `List` built-in method call to its Python form.
2568    ///
2569    /// Python lists are native, so `len`/`is_empty`/`contains`/`concat` map to
2570    /// `len(r)`/`(len(r) == 0)`/`(x in r)`/`(r + o)`. `Optional`-returning
2571    /// methods (`get`/`first`/`last`/`index_of`) build the tagged Optional
2572    /// runtime values (`_BockSome(v)` / `_bock_none`); they wrap the receiver in
2573    /// a `lambda` so it is evaluated exactly once.
2574    fn try_emit_list_method(
2575        &mut self,
2576        node: &AIRNode,
2577        callee: &AIRNode,
2578        args: &[bock_air::AirArg],
2579    ) -> Result<bool, CodegenError> {
2580        let Some((recv, method, rest)) =
2581            crate::generator::desugared_list_method(node, callee, args)
2582        else {
2583            return Ok(false);
2584        };
2585        match method {
2586            "len" | "length" | "count" => {
2587                self.buf.push_str("len(");
2588                self.emit_expr(recv)?;
2589                self.buf.push(')');
2590            }
2591            "is_empty" => {
2592                self.buf.push_str("(len(");
2593                self.emit_expr(recv)?;
2594                self.buf.push_str(") == 0)");
2595            }
2596            "get" => {
2597                let Some(idx) = rest.first() else {
2598                    return Ok(false);
2599                };
2600                self.buf
2601                    .push_str("(lambda __r, __i: _BockSome(__r[__i]) if 0 <= __i < len(__r) else _bock_none)(");
2602                self.emit_expr(recv)?;
2603                self.buf.push_str(", ");
2604                self.emit_expr(&idx.value)?;
2605                self.buf.push(')');
2606            }
2607            "first" => {
2608                self.buf
2609                    .push_str("(lambda __r: _BockSome(__r[0]) if len(__r) > 0 else _bock_none)(");
2610                self.emit_expr(recv)?;
2611                self.buf.push(')');
2612            }
2613            "last" => {
2614                self.buf
2615                    .push_str("(lambda __r: _BockSome(__r[-1]) if len(__r) > 0 else _bock_none)(");
2616                self.emit_expr(recv)?;
2617                self.buf.push(')');
2618            }
2619            "contains" => {
2620                let Some(x) = rest.first() else {
2621                    return Ok(false);
2622                };
2623                self.buf.push('(');
2624                self.emit_expr(&x.value)?;
2625                self.buf.push_str(" in ");
2626                self.emit_expr(recv)?;
2627                self.buf.push(')');
2628            }
2629            "index_of" => {
2630                let Some(x) = rest.first() else {
2631                    return Ok(false);
2632                };
2633                self.buf.push_str(
2634                    "(lambda __r, __x: _BockSome(__r.index(__x)) if __x in __r else _bock_none)(",
2635                );
2636                self.emit_expr(recv)?;
2637                self.buf.push_str(", ");
2638                self.emit_expr(&x.value)?;
2639                self.buf.push(')');
2640            }
2641            "concat" => {
2642                let Some(o) = rest.first() else {
2643                    return Ok(false);
2644                };
2645                self.buf.push('(');
2646                self.emit_expr(recv)?;
2647                self.buf.push_str(" + ");
2648                self.emit_expr(&o.value)?;
2649                self.buf.push(')');
2650            }
2651            "join" => {
2652                let Some(sep) = rest.first() else {
2653                    return Ok(false);
2654                };
2655                self.buf.push('(');
2656                self.emit_expr(&sep.value)?;
2657                self.buf.push_str(").join(");
2658                self.emit_expr(recv)?;
2659                self.buf.push(')');
2660            }
2661            _ => return Ok(false),
2662        }
2663        Ok(true)
2664    }
2665
2666    /// Emit an in-place `List` mutator (`push`/`append`, DQ18) to its Python
2667    /// form.
2668    ///
2669    /// Recognised via [`crate::generator::desugared_list_mutating_method`].
2670    /// Python lists grow in place with `.append(x)`, so `recv.push(x)` lowers to
2671    /// `(recv).append(x)`. The checker types these as `Void`, so they appear in
2672    /// statement position (Python's `list.append` returns `None`); the receiver
2673    /// is a `mut` lvalue (ownership-enforced), evaluated once.
2674    fn try_emit_list_mutating_method(
2675        &mut self,
2676        node: &AIRNode,
2677        callee: &AIRNode,
2678        args: &[bock_air::AirArg],
2679    ) -> Result<bool, CodegenError> {
2680        let Some((recv, _method, rest)) =
2681            crate::generator::desugared_list_mutating_method(node, callee, args)
2682        else {
2683            return Ok(false);
2684        };
2685        let Some(x) = rest.first() else {
2686            return Ok(false);
2687        };
2688        self.buf.push('(');
2689        self.emit_expr(recv)?;
2690        self.buf.push_str(").append(");
2691        self.emit_expr(&x.value)?;
2692        self.buf.push(')');
2693        Ok(true)
2694    }
2695
2696    /// Emit a DQ30 in-place `List` mutator
2697    /// (`pop`/`remove_at`/`insert`/`reverse`/`set`) to its Python form.
2698    ///
2699    /// Recognised via [`crate::generator::desugared_list_inplace_mutator`].
2700    /// Python lists mutate in place natively, and a `lambda` parameter aliases
2701    /// the receiver, so each lowering is a single-evaluation conditional
2702    /// expression:
2703    ///
2704    /// - `pop` → `(lambda __r: _BockSome(__r.pop()) if len(__r) > 0 else
2705    ///   _bock_none)(recv)` — emptiness is `None`, never an abort;
2706    /// - `remove_at(i)` → pre-check + `__r.pop(__i)` — the `0 <= __i` half is
2707    ///   load-bearing (native `pop(-1)` would remove from the end);
2708    /// - `insert(i, x)` → pre-check (`0 <= __i <= len`) + `__r.insert(__i,
2709    ///   __x)` — the pre-check is REQUIRED: native `insert` clamps
2710    ///   out-of-range indices instead of failing;
2711    /// - `reverse` → native in-place `(recv).reverse()`;
2712    /// - `set(i, x)` → pre-check + `__r.__setitem__(__i, __x)` (the statement
2713    ///   form `__r[__i] = __x` is not an expression; the dunder call is) —
2714    ///   `0 <= __i` again excludes Python's negative indexing.
2715    ///
2716    /// The violated-contract branches call the raising helper
2717    /// `_bock_list_abort(op, i, len)` ([`LIST_MUTATOR_RUNTIME_PY`]), producing
2718    /// the normalized abort message `List.<op>: index <i> out of bounds
2719    /// (len <n>)`.
2720    fn try_emit_list_inplace_mutator(
2721        &mut self,
2722        node: &AIRNode,
2723        callee: &AIRNode,
2724        args: &[bock_air::AirArg],
2725    ) -> Result<bool, CodegenError> {
2726        let Some((recv, method, rest)) =
2727            crate::generator::desugared_list_inplace_mutator(node, callee, args)
2728        else {
2729            return Ok(false);
2730        };
2731        match method {
2732            "pop" => {
2733                self.buf.push_str(
2734                    "(lambda __r: _BockSome(__r.pop()) if len(__r) > 0 else _bock_none)(",
2735                );
2736                self.emit_expr(recv)?;
2737                self.buf.push(')');
2738            }
2739            "remove_at" => {
2740                let Some(idx) = rest.first() else {
2741                    return Ok(false);
2742                };
2743                self.buf.push_str(
2744                    "(lambda __r, __i: __r.pop(__i) if 0 <= __i < len(__r) \
2745                     else _bock_list_abort('remove_at', __i, len(__r)))(",
2746                );
2747                self.emit_expr(recv)?;
2748                self.buf.push_str(", ");
2749                self.emit_expr(&idx.value)?;
2750                self.buf.push(')');
2751            }
2752            "insert" => {
2753                let (Some(idx), Some(x)) = (rest.first(), rest.get(1)) else {
2754                    return Ok(false);
2755                };
2756                self.buf.push_str(
2757                    "(lambda __r, __i, __x: __r.insert(__i, __x) if 0 <= __i <= len(__r) \
2758                     else _bock_list_abort('insert', __i, len(__r)))(",
2759                );
2760                self.emit_expr(recv)?;
2761                self.buf.push_str(", ");
2762                self.emit_expr(&idx.value)?;
2763                self.buf.push_str(", ");
2764                self.emit_expr(&x.value)?;
2765                self.buf.push(')');
2766            }
2767            "reverse" => {
2768                self.buf.push('(');
2769                self.emit_expr(recv)?;
2770                self.buf.push_str(").reverse()");
2771            }
2772            "set" => {
2773                let (Some(idx), Some(x)) = (rest.first(), rest.get(1)) else {
2774                    return Ok(false);
2775                };
2776                self.buf.push_str(
2777                    "(lambda __r, __i, __x: __r.__setitem__(__i, __x) if 0 <= __i < len(__r) \
2778                     else _bock_list_abort('set', __i, len(__r)))(",
2779                );
2780                self.emit_expr(recv)?;
2781                self.buf.push_str(", ");
2782                self.emit_expr(&idx.value)?;
2783                self.buf.push_str(", ");
2784                self.emit_expr(&x.value)?;
2785                self.buf.push(')');
2786            }
2787            _ => return Ok(false),
2788        }
2789        Ok(true)
2790    }
2791
2792    /// Emit a functional (closure-taking) `List` built-in method call to its
2793    /// Python form.
2794    ///
2795    /// Recognised via [`crate::generator::desugared_list_functional_method`].
2796    /// `map`/`filter` lower to `list(map(cb, r))` / `list(filter(cb, r))`;
2797    /// `any`/`all` to the `any(...)`/`all(...)` builtins over `map`; `flat_map`
2798    /// to a nested comprehension. `reduce`/`fold`/`find`/`for_each` lower to the
2799    /// `_bock_*` helpers of [`LIST_FUNCTIONAL_RUNTIME_PY`] (a left fold and the
2800    /// tagged-`Optional` `find` cannot be expressed as a single Python builtin
2801    /// call). In all cases the closure is passed *once* — the desugared
2802    /// `recv.map(recv, cb)` shape the generic fall-through would emit fails on a
2803    /// `list` (`'list' object has no attribute 'map'`).
2804    fn try_emit_list_functional_method(
2805        &mut self,
2806        node: &AIRNode,
2807        callee: &AIRNode,
2808        args: &[bock_air::AirArg],
2809    ) -> Result<bool, CodegenError> {
2810        let Some((recv, method, rest)) =
2811            crate::generator::desugared_list_functional_method(node, callee, args)
2812        else {
2813            return Ok(false);
2814        };
2815        match method {
2816            "map" | "filter" => {
2817                let Some(cb) = rest.first() else {
2818                    return Ok(false);
2819                };
2820                let _ = write!(self.buf, "list({method}(");
2821                self.emit_expr(&cb.value)?;
2822                self.buf.push_str(", ");
2823                self.emit_expr(recv)?;
2824                self.buf.push_str("))");
2825            }
2826            "any" | "all" => {
2827                let Some(cb) = rest.first() else {
2828                    return Ok(false);
2829                };
2830                let _ = write!(self.buf, "{method}(map(");
2831                self.emit_expr(&cb.value)?;
2832                self.buf.push_str(", ");
2833                self.emit_expr(recv)?;
2834                self.buf.push_str("))");
2835            }
2836            "flat_map" => {
2837                let Some(cb) = rest.first() else {
2838                    return Ok(false);
2839                };
2840                self.buf.push_str("[__y for __x in ");
2841                self.emit_expr(recv)?;
2842                self.buf.push_str(" for __y in (");
2843                self.emit_expr(&cb.value)?;
2844                self.buf.push_str(")(__x)]");
2845            }
2846            "reduce" => {
2847                let Some(cb) = rest.first() else {
2848                    return Ok(false);
2849                };
2850                self.buf.push_str("_bock_reduce(");
2851                self.emit_expr(recv)?;
2852                self.buf.push_str(", ");
2853                self.emit_expr(&cb.value)?;
2854                self.buf.push(')');
2855            }
2856            "fold" => {
2857                let (Some(init), Some(cb)) = (rest.first(), rest.get(1)) else {
2858                    return Ok(false);
2859                };
2860                self.buf.push_str("_bock_fold(");
2861                self.emit_expr(recv)?;
2862                self.buf.push_str(", ");
2863                self.emit_expr(&init.value)?;
2864                self.buf.push_str(", ");
2865                self.emit_expr(&cb.value)?;
2866                self.buf.push(')');
2867            }
2868            "find" => {
2869                let Some(cb) = rest.first() else {
2870                    return Ok(false);
2871                };
2872                self.buf.push_str("_bock_find(");
2873                self.emit_expr(recv)?;
2874                self.buf.push_str(", ");
2875                self.emit_expr(&cb.value)?;
2876                self.buf.push(')');
2877            }
2878            "for_each" => {
2879                let Some(cb) = rest.first() else {
2880                    return Ok(false);
2881                };
2882                self.buf.push_str("_bock_for_each(");
2883                self.emit_expr(recv)?;
2884                self.buf.push_str(", ");
2885                self.emit_expr(&cb.value)?;
2886                self.buf.push(')');
2887            }
2888            _ => return Ok(false),
2889        }
2890        Ok(true)
2891    }
2892
2893    /// Emit a built-in `Map[K, V]` method call to its Python form (native
2894    /// `dict`).
2895    ///
2896    /// Recognised via [`crate::generator::desugared_map_method`] (gated on
2897    /// `recv_kind = "Map"`) and wired *before* [`Self::try_emit_list_method`],
2898    /// so a `Map` receiver's `get`/`contains_key`/`len` no longer route through
2899    /// the `List` path (where `get` would index `__m[__i]` instead of testing
2900    /// key membership, and `set`/`contains_key` would call non-existent `dict`
2901    /// methods). `get` returns the tagged `Optional` rep
2902    /// (`_BockSome(v)`/`_bock_none`). Mutating methods (`set`/`delete`/`merge`)
2903    /// mutate in place via the `(side_effect, recv)[1]` tuple idiom (Python
2904    /// lambdas are expression-only) and return the receiver. Returns `true` if
2905    /// handled.
2906    fn try_emit_map_method(
2907        &mut self,
2908        node: &AIRNode,
2909        callee: &AIRNode,
2910        args: &[bock_air::AirArg],
2911    ) -> Result<bool, CodegenError> {
2912        let Some((recv, method, rest)) = crate::generator::desugared_map_method(node, callee, args)
2913        else {
2914            return Ok(false);
2915        };
2916        match method {
2917            "len" | "length" | "count" => {
2918                self.buf.push_str("len(");
2919                self.emit_expr(recv)?;
2920                self.buf.push(')');
2921            }
2922            "is_empty" => {
2923                self.buf.push_str("(len(");
2924                self.emit_expr(recv)?;
2925                self.buf.push_str(") == 0)");
2926            }
2927            "contains_key" => {
2928                let Some(k) = rest.first() else {
2929                    return Ok(false);
2930                };
2931                self.buf.push('(');
2932                self.emit_expr(&k.value)?;
2933                self.buf.push_str(" in ");
2934                self.emit_expr(recv)?;
2935                self.buf.push(')');
2936            }
2937            "get" => {
2938                let Some(k) = rest.first() else {
2939                    return Ok(false);
2940                };
2941                self.buf.push_str(
2942                    "(lambda __m, __k: _BockSome(__m[__k]) if __k in __m else _bock_none)(",
2943                );
2944                self.emit_expr(recv)?;
2945                self.buf.push_str(", ");
2946                self.emit_expr(&k.value)?;
2947                self.buf.push(')');
2948            }
2949            "set" => {
2950                let (Some(k), Some(v)) = (rest.first(), rest.get(1)) else {
2951                    return Ok(false);
2952                };
2953                self.buf
2954                    .push_str("(lambda __m, __k, __v: (__m.__setitem__(__k, __v), __m)[1])(");
2955                self.emit_expr(recv)?;
2956                self.buf.push_str(", ");
2957                self.emit_expr(&k.value)?;
2958                self.buf.push_str(", ");
2959                self.emit_expr(&v.value)?;
2960                self.buf.push(')');
2961            }
2962            "delete" => {
2963                let Some(k) = rest.first() else {
2964                    return Ok(false);
2965                };
2966                self.buf
2967                    .push_str("(lambda __m, __k: (__m.pop(__k, None), __m)[1])(");
2968                self.emit_expr(recv)?;
2969                self.buf.push_str(", ");
2970                self.emit_expr(&k.value)?;
2971                self.buf.push(')');
2972            }
2973            "merge" => {
2974                let Some(o) = rest.first() else {
2975                    return Ok(false);
2976                };
2977                self.buf
2978                    .push_str("(lambda __m, __o: (__m.update(__o), __m)[1])(");
2979                self.emit_expr(recv)?;
2980                self.buf.push_str(", ");
2981                self.emit_expr(&o.value)?;
2982                self.buf.push(')');
2983            }
2984            "filter" => {
2985                let Some(f) = rest.first() else {
2986                    return Ok(false);
2987                };
2988                self.buf.push_str(
2989                    "(lambda __m, __f: {__k: __v for __k, __v in __m.items() if __f(__k, __v)})(",
2990                );
2991                self.emit_expr(recv)?;
2992                self.buf.push_str(", ");
2993                self.emit_expr(&f.value)?;
2994                self.buf.push(')');
2995            }
2996            "keys" => {
2997                self.buf.push_str("list(");
2998                self.emit_expr(recv)?;
2999                self.buf.push_str(".keys())");
3000            }
3001            "values" => {
3002                self.buf.push_str("list(");
3003                self.emit_expr(recv)?;
3004                self.buf.push_str(".values())");
3005            }
3006            "entries" | "to_list" => {
3007                self.buf.push_str("list(");
3008                self.emit_expr(recv)?;
3009                self.buf.push_str(".items())");
3010            }
3011            "for_each" => {
3012                let Some(f) = rest.first() else {
3013                    return Ok(false);
3014                };
3015                self.buf.push_str("[(");
3016                self.emit_expr(&f.value)?;
3017                self.buf.push_str(")(__k, __v) for __k, __v in (");
3018                self.emit_expr(recv)?;
3019                self.buf.push_str(").items()]");
3020            }
3021            _ => return Ok(false),
3022        }
3023        Ok(true)
3024    }
3025
3026    /// Emit a built-in `Set[E]` method call to its Python form (native `set`).
3027    ///
3028    /// Recognised via [`crate::generator::desugared_set_method`] (gated on
3029    /// `recv_kind = "Set"`) and wired *before* [`Self::try_emit_list_method`].
3030    /// Set algebra maps to Python's operators (`|`/`&`/`-`/`<=`/`>=`). Mutating
3031    /// methods (`add`/`remove`) mutate in place via the `(side_effect, recv)[1]`
3032    /// idiom and return the receiver.
3033    fn try_emit_set_method(
3034        &mut self,
3035        node: &AIRNode,
3036        callee: &AIRNode,
3037        args: &[bock_air::AirArg],
3038    ) -> Result<bool, CodegenError> {
3039        let Some((recv, method, rest)) = crate::generator::desugared_set_method(node, callee, args)
3040        else {
3041            return Ok(false);
3042        };
3043        match method {
3044            "len" | "length" | "count" => {
3045                self.buf.push_str("len(");
3046                self.emit_expr(recv)?;
3047                self.buf.push(')');
3048            }
3049            "is_empty" => {
3050                self.buf.push_str("(len(");
3051                self.emit_expr(recv)?;
3052                self.buf.push_str(") == 0)");
3053            }
3054            "contains" => {
3055                let Some(x) = rest.first() else {
3056                    return Ok(false);
3057                };
3058                self.buf.push('(');
3059                self.emit_expr(&x.value)?;
3060                self.buf.push_str(" in ");
3061                self.emit_expr(recv)?;
3062                self.buf.push(')');
3063            }
3064            "add" => {
3065                let Some(x) = rest.first() else {
3066                    return Ok(false);
3067                };
3068                self.buf
3069                    .push_str("(lambda __s, __x: (__s.add(__x), __s)[1])(");
3070                self.emit_expr(recv)?;
3071                self.buf.push_str(", ");
3072                self.emit_expr(&x.value)?;
3073                self.buf.push(')');
3074            }
3075            "remove" => {
3076                let Some(x) = rest.first() else {
3077                    return Ok(false);
3078                };
3079                self.buf
3080                    .push_str("(lambda __s, __x: (__s.discard(__x), __s)[1])(");
3081                self.emit_expr(recv)?;
3082                self.buf.push_str(", ");
3083                self.emit_expr(&x.value)?;
3084                self.buf.push(')');
3085            }
3086            "union" => {
3087                let Some(o) = rest.first() else {
3088                    return Ok(false);
3089                };
3090                self.buf.push('(');
3091                self.emit_expr(recv)?;
3092                self.buf.push_str(" | ");
3093                self.emit_expr(&o.value)?;
3094                self.buf.push(')');
3095            }
3096            "intersection" => {
3097                let Some(o) = rest.first() else {
3098                    return Ok(false);
3099                };
3100                self.buf.push('(');
3101                self.emit_expr(recv)?;
3102                self.buf.push_str(" & ");
3103                self.emit_expr(&o.value)?;
3104                self.buf.push(')');
3105            }
3106            "difference" => {
3107                let Some(o) = rest.first() else {
3108                    return Ok(false);
3109                };
3110                self.buf.push('(');
3111                self.emit_expr(recv)?;
3112                self.buf.push_str(" - ");
3113                self.emit_expr(&o.value)?;
3114                self.buf.push(')');
3115            }
3116            "is_subset" => {
3117                let Some(o) = rest.first() else {
3118                    return Ok(false);
3119                };
3120                self.buf.push('(');
3121                self.emit_expr(recv)?;
3122                self.buf.push_str(" <= ");
3123                self.emit_expr(&o.value)?;
3124                self.buf.push(')');
3125            }
3126            "is_superset" => {
3127                let Some(o) = rest.first() else {
3128                    return Ok(false);
3129                };
3130                self.buf.push('(');
3131                self.emit_expr(recv)?;
3132                self.buf.push_str(" >= ");
3133                self.emit_expr(&o.value)?;
3134                self.buf.push(')');
3135            }
3136            "filter" => {
3137                let Some(f) = rest.first() else {
3138                    return Ok(false);
3139                };
3140                self.buf.push_str("{__x for __x in (");
3141                self.emit_expr(recv)?;
3142                self.buf.push_str(") if (");
3143                self.emit_expr(&f.value)?;
3144                self.buf.push_str(")(__x)}");
3145            }
3146            "map" => {
3147                let Some(f) = rest.first() else {
3148                    return Ok(false);
3149                };
3150                self.buf.push_str("{(");
3151                self.emit_expr(&f.value)?;
3152                self.buf.push_str(")(__x) for __x in (");
3153                self.emit_expr(recv)?;
3154                self.buf.push_str(")}");
3155            }
3156            "to_list" => {
3157                self.buf.push_str("list(");
3158                self.emit_expr(recv)?;
3159                self.buf.push(')');
3160            }
3161            "for_each" => {
3162                let Some(f) = rest.first() else {
3163                    return Ok(false);
3164                };
3165                self.buf.push_str("[(");
3166                self.emit_expr(&f.value)?;
3167                self.buf.push_str(")(__x) for __x in (");
3168                self.emit_expr(recv)?;
3169                self.buf.push_str(")]");
3170            }
3171            _ => return Ok(false),
3172        }
3173        Ok(true)
3174    }
3175
3176    /// Lower a primitive trait-bridge method call (`compare`/`eq`/`to_string`/
3177    /// `display` on a primitive receiver) to its Python form.
3178    ///
3179    /// `(1).compare(2)` resolves to `Ordering`; this produces the
3180    /// Ordering-runtime singleton (`_bock_less` / `_bock_equal` /
3181    /// `_bock_greater`) via a conditional expression, matching the
3182    /// construction/`case` sides. `eq` → `==`; `to_string`/`display` → `str(x)`.
3183    /// Lower a desugared `String` built-in method call (`recv_kind =
3184    /// "Primitive:String"`) to its native Python string op. Wired into the
3185    /// `Call` arm *before* `try_emit_list_method` so a String receiver's
3186    /// `len`/`contains`/`is_empty` dispatch here, not through the List path.
3187    ///
3188    /// `len` is the Unicode SCALAR count: Python `str` is a sequence of code
3189    /// points, so `len(s)` already yields the scalar count (spec §18.3).
3190    /// `byte_len` encodes to UTF-8 first (`len(s.encode())`). `replace` replaces
3191    /// ALL occurrences (Python's default). `split` returns a Python list, the
3192    /// List runtime rep.
3193    ///
3194    /// Gated on `recv_kind = "Primitive:String"` directly (not the cross-backend
3195    /// [`crate::generator::desugared_string_method`] subset) so Python can lower
3196    /// the wider resolved String surface — `slice`/`substring`/`char_at`/
3197    /// `index_of`/`repeat`/`reverse`/`trim_start`/`trim_end` — to native ops,
3198    /// matching the Rust backend. Python `str` is already a code-point sequence,
3199    /// so scalar slicing is plain `s[a:b]` and `reverse` is `s[::-1]`.
3200    /// `char_at`/`index_of` build the tagged `Optional` runtime (`_BockSome(v)` /
3201    /// `_bock_none`); the Optional prelude is pulled in by the structural scan
3202    /// over the (Optional-typed) call.
3203    fn try_emit_string_method(
3204        &mut self,
3205        node: &AIRNode,
3206        callee: &AIRNode,
3207        args: &[bock_air::AirArg],
3208    ) -> Result<bool, CodegenError> {
3209        if crate::generator::primitive_recv_kind(node) != Some("String") {
3210            return Ok(false);
3211        }
3212        let Some((recv, field, rest)) = crate::generator::desugared_self_call(callee, args) else {
3213            return Ok(false);
3214        };
3215        let method = field.name.as_str();
3216        let recv_str = self.expr_to_string(recv)?;
3217        let arg0 = |this: &mut Self| -> Result<Option<String>, CodegenError> {
3218            rest.first()
3219                .map(|a| this.expr_to_string(&a.value))
3220                .transpose()
3221        };
3222        let code = match method {
3223            "len" | "length" | "count" => format!("len({recv_str})"),
3224            "byte_len" => format!("len(({recv_str}).encode())"),
3225            "is_empty" => format!("(len({recv_str}) == 0)"),
3226            "to_upper" => format!("({recv_str}).upper()"),
3227            "to_lower" => format!("({recv_str}).lower()"),
3228            "trim" => format!("({recv_str}).strip()"),
3229            "trim_start" => format!("({recv_str}).lstrip()"),
3230            "trim_end" => format!("({recv_str}).rstrip()"),
3231            "reverse" => format!("({recv_str})[::-1]"),
3232            "to_string" | "display" => format!("str({recv_str})"),
3233            "repeat" => {
3234                let Some(n) = arg0(self)? else {
3235                    return Ok(false);
3236                };
3237                format!("(({recv_str}) * ({n}))")
3238            }
3239            "contains" => {
3240                let Some(p) = arg0(self)? else {
3241                    return Ok(false);
3242                };
3243                format!("(({p}) in ({recv_str}))")
3244            }
3245            "starts_with" => {
3246                let Some(p) = arg0(self)? else {
3247                    return Ok(false);
3248                };
3249                format!("({recv_str}).startswith({p})")
3250            }
3251            "ends_with" => {
3252                let Some(p) = arg0(self)? else {
3253                    return Ok(false);
3254                };
3255                format!("({recv_str}).endswith({p})")
3256            }
3257            "replace" => {
3258                let Some(from) = arg0(self)? else {
3259                    return Ok(false);
3260                };
3261                let Some(to) = rest
3262                    .get(1)
3263                    .map(|a| self.expr_to_string(&a.value))
3264                    .transpose()?
3265                else {
3266                    return Ok(false);
3267                };
3268                format!("({recv_str}).replace({from}, {to})")
3269            }
3270            "split" => {
3271                let Some(sep) = arg0(self)? else {
3272                    return Ok(false);
3273                };
3274                format!("({recv_str}).split({sep})")
3275            }
3276            // `slice`/`substring(start, end)`: scalar-index half-open substring
3277            // (spec §18.3). Python `str` slicing is already code-point based, and
3278            // out-of-range indices clamp rather than raise — matching the spec.
3279            "slice" | "substring" => {
3280                let Some(start) = arg0(self)? else {
3281                    return Ok(false);
3282                };
3283                let Some(end) = rest
3284                    .get(1)
3285                    .map(|a| self.expr_to_string(&a.value))
3286                    .transpose()?
3287                else {
3288                    return Ok(false);
3289                };
3290                format!("({recv_str})[{start}:{end}]")
3291            }
3292            // `char_at(i)` returns `Optional[Char]` — `None` when out of range.
3293            "char_at" => {
3294                let Some(i) = arg0(self)? else {
3295                    return Ok(false);
3296                };
3297                format!(
3298                    "(lambda __s, __i: _BockSome(__s[__i]) if 0 <= __i < len(__s) else _bock_none)({recv_str}, {i})"
3299                )
3300            }
3301            // `index_of(needle)` returns `Optional[Int]` — scalar index of the
3302            // first match, or `None`. Python `str.find` is already code-point based.
3303            "index_of" => {
3304                let Some(p) = arg0(self)? else {
3305                    return Ok(false);
3306                };
3307                format!(
3308                    "(lambda __s, __p: (lambda __b: _BockSome(__b) if __b >= 0 else _bock_none)(__s.find(__p)))({recv_str}, {p})"
3309                )
3310            }
3311            _ => return Ok(false),
3312        };
3313        self.buf.push_str(&code);
3314        Ok(true)
3315    }
3316
3317    /// Q-prim-assoc: lower a primitive associated-conversion call
3318    /// (`Float.from(x)` / `Int.try_from(s)` / `String.from(c)`) to Python's
3319    /// native conversion. CRITICAL on Python: `from` is a keyword, so the
3320    /// generic associated-call form would emit `Float.from_(...)` (an undefined
3321    /// name and the wrong shape). `from` becomes `float(...)`/`int(...)`/
3322    /// `str(...)`; `try_from` calls the self-contained `_bock_parse_int` /
3323    /// `_bock_parse_float` runtime helpers (which return `_BockOk`/`_BockErr`),
3324    /// passing a `ConvertError`-factory lambda so the helper need not import the
3325    /// stdlib type (`ConvertError` is in scope at the call site via the
3326    /// `Result[T, ConvertError]` return type). Returns `true` when handled.
3327    fn try_emit_primitive_conversion(
3328        &mut self,
3329        node: &AIRNode,
3330        callee: &AIRNode,
3331        args: &[bock_air::AirArg],
3332    ) -> Result<bool, CodegenError> {
3333        let Some((target, method, arg)) =
3334            crate::generator::primitive_conversion_call(node, callee, args)
3335        else {
3336            return Ok(false);
3337        };
3338        let arg_str = self.expr_to_string(arg)?;
3339        let code = match (target, method) {
3340            ("Float", "from") => format!("float({arg_str})"),
3341            ("Int", "from") => format!("int({arg_str})"),
3342            ("String", "from") => format!("str({arg_str})"),
3343            ("Int", "try_from") => {
3344                self.needs_runtime_result = true;
3345                format!("_bock_parse_int({arg_str}, lambda __m: ConvertError(message=__m))")
3346            }
3347            ("Float", "try_from") => {
3348                self.needs_runtime_result = true;
3349                format!("_bock_parse_float({arg_str}, lambda __m: ConvertError(message=__m))")
3350            }
3351            _ => return Ok(false),
3352        };
3353        self.buf.push_str(&code);
3354        Ok(true)
3355    }
3356
3357    /// Lower a desugared numeric/`Char`/`Bool` primitive method (`recv_kind =
3358    /// "Primitive:Int" | "Primitive:Float" | "Primitive:Char" | "Primitive:Bool"`)
3359    /// to its native Python form. Covers the conversion and math methods the
3360    /// checker resolves on the scalar primitives — `to_float`/`to_int`/`abs`/`min`/
3361    /// `max`/`clamp`/`floor`/`ceil`/`round`/`sqrt`/… . Wired into the `Call` arm
3362    /// alongside [`Self::try_emit_string_method`], before the generic
3363    /// desugared-self-call fall-through (which would emit `n.to_float(n)`).
3364    /// `floor`/`ceil`/`sqrt` need `math`, so they set `needs_math_import`.
3365    /// `compare`/`eq`/`to_string`/`display`/`hash_code` stay on the primitive
3366    /// *bridge* path. `Char` is a one-code-point Python `str`.
3367    fn try_emit_numeric_method(
3368        &mut self,
3369        node: &AIRNode,
3370        callee: &AIRNode,
3371        args: &[bock_air::AirArg],
3372    ) -> Result<bool, CodegenError> {
3373        let prim = match crate::generator::primitive_recv_kind(node) {
3374            Some(p @ ("Int" | "Float" | "Char" | "Bool")) => p,
3375            _ => return Ok(false),
3376        };
3377        let Some((recv, field, rest)) = crate::generator::desugared_self_call(callee, args) else {
3378            return Ok(false);
3379        };
3380        let method = field.name.as_str();
3381        let recv_str = self.expr_to_string(recv)?;
3382        let arg = |this: &mut Self, i: usize| -> Result<Option<String>, CodegenError> {
3383            rest.get(i)
3384                .map(|a| this.expr_to_string(&a.value))
3385                .transpose()
3386        };
3387        let code = match (prim, method) {
3388            // Conversions.
3389            ("Int", "to_float") => format!("float({recv_str})"),
3390            ("Float", "to_int") => format!("int({recv_str})"),
3391            ("Char", "to_int") => format!("ord({recv_str})"),
3392            ("Bool", "to_int") => format!("(1 if ({recv_str}) else 0)"),
3393            // Int math.
3394            ("Int", "abs") => format!("abs({recv_str})"),
3395            ("Int" | "Float", "min") => {
3396                let Some(o) = arg(self, 0)? else {
3397                    return Ok(false);
3398                };
3399                format!("min({recv_str}, {o})")
3400            }
3401            ("Int" | "Float", "max") => {
3402                let Some(o) = arg(self, 0)? else {
3403                    return Ok(false);
3404                };
3405                format!("max({recv_str}, {o})")
3406            }
3407            ("Int" | "Float", "clamp") => {
3408                let (Some(lo), Some(hi)) = (arg(self, 0)?, arg(self, 1)?) else {
3409                    return Ok(false);
3410                };
3411                format!("min(max({recv_str}, {lo}), {hi})")
3412            }
3413            ("Int", "shift_left") => {
3414                let Some(o) = arg(self, 0)? else {
3415                    return Ok(false);
3416                };
3417                format!("(({recv_str}) << ({o}))")
3418            }
3419            ("Int", "shift_right") => {
3420                let Some(o) = arg(self, 0)? else {
3421                    return Ok(false);
3422                };
3423                format!("(({recv_str}) >> ({o}))")
3424            }
3425            // Float math.
3426            ("Float", "abs") => format!("abs({recv_str})"),
3427            ("Float", "floor") => {
3428                self.needs_math_import = true;
3429                format!("float(math.floor({recv_str}))")
3430            }
3431            ("Float", "ceil") => {
3432                self.needs_math_import = true;
3433                format!("float(math.ceil({recv_str}))")
3434            }
3435            ("Float", "round") => format!("float(round({recv_str}))"),
3436            ("Float", "sqrt") => {
3437                self.needs_math_import = true;
3438                format!("math.sqrt({recv_str})")
3439            }
3440            ("Float", "is_nan") => {
3441                self.needs_math_import = true;
3442                format!("math.isnan({recv_str})")
3443            }
3444            ("Float", "is_infinite") => {
3445                self.needs_math_import = true;
3446                format!("math.isinf({recv_str})")
3447            }
3448            // Bool.
3449            ("Bool", "negate") => format!("(not ({recv_str}))"),
3450            // `Bool.to_string()` / `.display()` must yield the canonical lowercase
3451            // `"true"`/`"false"` (§3.5), not Python's `str(b)` → `"True"`/`"False"`.
3452            // Handled here (before the primitive *bridge* path that maps
3453            // `to_string` → `str(..)`) so the Bool case is intercepted.
3454            ("Bool", "to_string" | "display") => {
3455                format!("('true' if ({recv_str}) else 'false')")
3456            }
3457            // Char (a one-code-point Python `str`).
3458            ("Char", "to_upper") => format!("({recv_str}).upper()"),
3459            ("Char", "to_lower") => format!("({recv_str}).lower()"),
3460            ("Char", "is_alpha") => format!("({recv_str}).isalpha()"),
3461            ("Char", "is_digit") => format!("({recv_str}).isdigit()"),
3462            ("Char", "is_whitespace") => format!("({recv_str}).isspace()"),
3463            _ => return Ok(false),
3464        };
3465        self.buf.push_str(&code);
3466        Ok(true)
3467    }
3468
3469    fn try_emit_primitive_bridge(
3470        &mut self,
3471        node: &AIRNode,
3472        callee: &AIRNode,
3473        args: &[bock_air::AirArg],
3474    ) -> Result<bool, CodegenError> {
3475        let Some((recv, method, rest, _prim)) =
3476            crate::generator::primitive_bridge_call(node, callee, args)
3477        else {
3478            return Ok(false);
3479        };
3480        self.emit_bridge_method(recv, method, rest)
3481    }
3482
3483    /// Lower a sealed-core-trait bridge method on a *bounded generic type
3484    /// variable* (`a.eq(b)` / `a.compare(b)` inside `eq_check[T: Equatable]`) to
3485    /// its Python form (GAP-C). The method body is identical to the
3486    /// `Primitive:<Ty>` bridge; the `bound=Equatable` on the `TypeVar` is
3487    /// separately dropped (see the generic-decl emission). Fires only when the
3488    /// bound trait is sealed-core and NOT a user-declared trait.
3489    fn try_emit_trait_bound_bridge(
3490        &mut self,
3491        node: &AIRNode,
3492        callee: &AIRNode,
3493        args: &[bock_air::AirArg],
3494    ) -> Result<bool, CodegenError> {
3495        let Some((recv, method, rest, _tr)) =
3496            crate::generator::trait_bound_bridge_call(node, callee, args, &self.trait_decls)
3497        else {
3498            return Ok(false);
3499        };
3500        // Q-bounded-comparable-codegen: a bounded `T: Comparable` `compare`
3501        // receiver may be instantiated with a RECORD whose ordering lives in its
3502        // own `compare` method — the native `<`/`==` ternary the
3503        // `emit_bridge_method` `compare` arm emits raises `TypeError: '<' not
3504        // supported between instances of 'Money'`. Route through the
3505        // `_bock_compare` runtime helper, which calls the value's `compare`
3506        // method when present and falls back to the native ternary for a
3507        // primitive instantiation.
3508        if method == "compare" {
3509            let Some(other) = rest.first() else {
3510                return Ok(false);
3511            };
3512            let recv_str = self.expr_to_string(recv)?;
3513            let other = self.expr_to_string(&other.value)?;
3514            self.needs_runtime_ordering = true;
3515            let _ = write!(self.buf, "_bock_compare({recv_str}, {other})");
3516            return Ok(true);
3517        }
3518        self.emit_bridge_method(recv, method, rest)
3519    }
3520
3521    /// Shared body of the primitive / trait-bound bridges: emit the native Python
3522    /// form of `compare` (the `_bock_less`/`_bock_equal`/`_bock_greater`
3523    /// conditional), `eq` (`==`), or `to_string`/`display` (`str(..)`).
3524    fn emit_bridge_method(
3525        &mut self,
3526        recv: &AIRNode,
3527        method: &str,
3528        rest: &[bock_air::AirArg],
3529    ) -> Result<bool, CodegenError> {
3530        let recv_str = self.expr_to_string(recv)?;
3531        match method {
3532            "compare" => {
3533                let Some(other) = rest.first() else {
3534                    return Ok(false);
3535                };
3536                let other = self.expr_to_string(&other.value)?;
3537                let _ = write!(
3538                    self.buf,
3539                    "(_bock_less if ({recv_str}) < ({other}) else \
3540                     (_bock_equal if ({recv_str}) == ({other}) else _bock_greater))"
3541                );
3542            }
3543            "eq" => {
3544                let Some(other) = rest.first() else {
3545                    return Ok(false);
3546                };
3547                let other = self.expr_to_string(&other.value)?;
3548                let _ = write!(self.buf, "(({recv_str}) == ({other}))");
3549            }
3550            "to_string" | "display" => {
3551                let _ = write!(self.buf, "str({recv_str})");
3552            }
3553            _ => return Ok(false),
3554        }
3555        Ok(true)
3556    }
3557
3558    /// Recognise `Duration.xxx(...)` / `Instant.xxx(...)` associated-function
3559    /// calls and emit inline arithmetic. Durations are ints (nanoseconds);
3560    /// Instants are ints representing `time.monotonic_ns()`.
3561    fn try_emit_time_assoc_call(
3562        &mut self,
3563        callee: &AIRNode,
3564        args: &[bock_air::AirArg],
3565    ) -> Result<bool, CodegenError> {
3566        let NodeKind::FieldAccess { object, field } = &callee.kind else {
3567            return Ok(false);
3568        };
3569        let NodeKind::Identifier { name: type_name } = &object.kind else {
3570            return Ok(false);
3571        };
3572        let arg_strs: Vec<String> = args
3573            .iter()
3574            .map(|a| self.expr_to_string(&a.value))
3575            .collect::<Result<_, _>>()?;
3576        let arg0 = || arg_strs.first().cloned().unwrap_or_default();
3577        let code = match (type_name.name.as_str(), field.name.as_str()) {
3578            ("Duration", "zero") => "0".to_string(),
3579            ("Duration", "nanos") => arg0(),
3580            ("Duration", "micros") => format!("(({}) * 1_000)", arg0()),
3581            ("Duration", "millis") => format!("(({}) * 1_000_000)", arg0()),
3582            ("Duration", "seconds") => format!("(({}) * 1_000_000_000)", arg0()),
3583            ("Duration", "minutes") => format!("(({}) * 60_000_000_000)", arg0()),
3584            ("Duration", "hours") => format!("(({}) * 3_600_000_000_000)", arg0()),
3585            ("Instant", "now") => {
3586                // Route through an installed `Clock` handler's `now_monotonic`
3587                // op if one is in scope; otherwise emit the host primitive.
3588                if let Some(handler) = self.clock_handler_var() {
3589                    format!("{handler}.{}()", to_snake_case("now_monotonic"))
3590                } else {
3591                    self.needs_time_import = true;
3592                    "time.monotonic_ns()".to_string()
3593                }
3594            }
3595            _ => return Ok(false),
3596        };
3597        self.buf.push_str(&code);
3598        Ok(true)
3599    }
3600
3601    /// Recognise `Channel.new()`, `spawn(...)`, and method calls on a
3602    /// channel value (`send`, `recv`, `close`) and emit the Python
3603    /// runtime helper equivalents.
3604    fn try_emit_concurrency_call(
3605        &mut self,
3606        callee: &AIRNode,
3607        args: &[bock_air::AirArg],
3608    ) -> Result<bool, CodegenError> {
3609        if let NodeKind::Identifier { name } = &callee.kind {
3610            if name.name == "spawn" {
3611                self.buf.push_str("__bock_spawn(");
3612                for (i, arg) in args.iter().enumerate() {
3613                    if i > 0 {
3614                        self.buf.push_str(", ");
3615                    }
3616                    self.emit_expr(&arg.value)?;
3617                }
3618                self.buf.push(')');
3619                return Ok(true);
3620            }
3621        }
3622        let NodeKind::FieldAccess { object, field } = &callee.kind else {
3623            return Ok(false);
3624        };
3625        if let NodeKind::Identifier { name: type_name } = &object.kind {
3626            if type_name.name == "Channel" && field.name == "new" {
3627                self.buf.push_str("__bock_channel_new()");
3628                return Ok(true);
3629            }
3630        }
3631        if matches!(field.name.as_str(), "send" | "recv" | "close") {
3632            self.emit_expr(object)?;
3633            let _ = write!(self.buf, ".{}", field.name);
3634            self.buf.push('(');
3635            for (i, arg) in args.iter().skip(1).enumerate() {
3636                if i > 0 {
3637                    self.buf.push_str(", ");
3638                }
3639                self.emit_expr(&arg.value)?;
3640            }
3641            self.buf.push(')');
3642            return Ok(true);
3643        }
3644        Ok(false)
3645    }
3646
3647    /// Recognise desugared method calls `Call(FieldAccess(recv, m), [recv, ...args])`
3648    /// on Duration/Instant values and emit inline arithmetic.
3649    ///
3650    /// `node` is the full `Call` AIR node, consulted only to *exclude* primitive
3651    /// receivers: [`is_time_method_name`] alone is ambiguous (`abs` is both
3652    /// `Duration.abs` and `Int.abs`/`Float.abs`), so when the checker has stamped
3653    /// `recv_kind = "Primitive:<Ty>"` this is a numeric method, not a time method —
3654    /// bail so [`Self::try_emit_numeric_method`] handles it.
3655    fn try_emit_time_desugared_method(
3656        &mut self,
3657        node: &AIRNode,
3658        callee: &AIRNode,
3659        args: &[bock_air::AirArg],
3660    ) -> Result<bool, CodegenError> {
3661        if crate::generator::primitive_recv_kind(node).is_some() {
3662            return Ok(false);
3663        }
3664        let NodeKind::FieldAccess { object, field } = &callee.kind else {
3665            return Ok(false);
3666        };
3667        if let NodeKind::Identifier { name } = &object.kind {
3668            if matches!(name.name.as_str(), "Duration" | "Instant") {
3669                return Ok(false);
3670            }
3671        }
3672        if !is_time_method_name(&field.name) {
3673            return Ok(false);
3674        }
3675        let remaining: Vec<bock_air::AirArg> = args.iter().skip(1).cloned().collect();
3676        self.try_emit_time_method(object, &field.name, &remaining)
3677    }
3678
3679    /// Recognise instance methods on Duration/Instant values and emit inline
3680    /// arithmetic.
3681    fn try_emit_time_method(
3682        &mut self,
3683        receiver: &AIRNode,
3684        method: &str,
3685        args: &[bock_air::AirArg],
3686    ) -> Result<bool, CodegenError> {
3687        let recv_str = self.expr_to_string(receiver)?;
3688        let arg_strs: Vec<String> = args
3689            .iter()
3690            .map(|a| self.expr_to_string(&a.value))
3691            .collect::<Result<_, _>>()?;
3692        let code = match method {
3693            "as_nanos" => format!("({recv_str})"),
3694            "as_millis" => format!("(({recv_str}) // 1_000_000)"),
3695            "as_seconds" => format!("(({recv_str}) // 1_000_000_000)"),
3696            "is_zero" => format!("(({recv_str}) == 0)"),
3697            "is_negative" => format!("(({recv_str}) < 0)"),
3698            "abs" => format!("abs({recv_str})"),
3699            "elapsed" => {
3700                // `instant.elapsed()` is derived: `now - instant`. Route the
3701                // "now" read through an installed `Clock` handler if in scope;
3702                // otherwise read the host monotonic clock (default).
3703                if let Some(handler) = self.clock_handler_var() {
3704                    format!(
3705                        "({handler}.{}() - ({recv_str}))",
3706                        to_snake_case("now_monotonic")
3707                    )
3708                } else {
3709                    self.needs_time_import = true;
3710                    format!("(time.monotonic_ns() - ({recv_str}))")
3711                }
3712            }
3713            "duration_since" => {
3714                let other = arg_strs.first().cloned().unwrap_or_default();
3715                format!("(({recv_str}) - ({other}))")
3716            }
3717            _ => return Ok(false),
3718        };
3719        self.buf.push_str(&code);
3720        Ok(true)
3721    }
3722
3723    // ── Top-level dispatch ──────────────────────────────────────────────────
3724
3725    fn emit_node(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
3726        match &node.kind {
3727            NodeKind::Module { items, imports, .. } => {
3728                // Field/method name-collision set (snake_cased). Pre-seeded
3729                // program-wide by `generate_project` so a call site in one file
3730                // agrees with the renamed method declared in another; extended
3731                // here so the single-module `generate_module` path (no pre-seed)
3732                // is also covered.
3733                self.field_method_collisions
3734                    .extend(crate::generator::collect_record_field_names(
3735                        node,
3736                        to_snake_case,
3737                    ));
3738                if self.per_module {
3739                    // Per-module native-import path (the real build): each module
3740                    // is emitted to its own file and the shared runtime preludes
3741                    // live in `_bock_runtime.py`. Record which prelude names this
3742                    // module references; `finish` emits the single
3743                    // `from _bock_runtime import *` line.
3744                    if py_module_uses_optional(items) {
3745                        self.needs_runtime_optional = true;
3746                    }
3747                    if py_module_uses_result(items) {
3748                        self.needs_runtime_result = true;
3749                    }
3750                    if py_module_uses_ordering(items) {
3751                        self.needs_runtime_ordering = true;
3752                    }
3753                    if py_module_uses_concurrency(items) {
3754                        self.needs_runtime_concurrency = true;
3755                    }
3756                    if py_module_uses_list_functional(items) {
3757                        self.needs_runtime_list_functional = true;
3758                        // `_bock_find` builds tagged `Optional` runtime values, so
3759                        // the Optional prelude must be present alongside it.
3760                        self.needs_runtime_optional = true;
3761                    }
3762                    if py_module_uses_list_mutators(items) {
3763                        self.needs_runtime_list_mutators = true;
3764                    }
3765                    if py_module_uses_propagate(items) {
3766                        self.needs_runtime_propagate = true;
3767                    }
3768                    if py_module_uses_str(items) {
3769                        self.needs_runtime_str = true;
3770                    }
3771                } else {
3772                    // Single-module self-contained emit (`generate_module`, used
3773                    // by unit tests): the module's runtime preludes are inlined
3774                    // into this one file and `ImportDecl`s are dropped. Each
3775                    // prelude is inlined at most once, gated on a ctx flag.
3776                    if !self.optional_runtime_emitted && py_module_uses_optional(items) {
3777                        self.buf.push_str(OPTIONAL_RUNTIME_PY);
3778                        self.buf.push('\n');
3779                        self.optional_runtime_emitted = true;
3780                    }
3781                    if !self.result_runtime_emitted && py_module_uses_result(items) {
3782                        self.buf.push_str(RESULT_RUNTIME_PY);
3783                        self.buf.push('\n');
3784                        self.result_runtime_emitted = true;
3785                    }
3786                    if !self.ordering_runtime_emitted && py_module_uses_ordering(items) {
3787                        self.buf.push_str(ORDERING_RUNTIME_PY);
3788                        self.buf.push('\n');
3789                        self.ordering_runtime_emitted = true;
3790                    }
3791                    if !self.concurrency_runtime_emitted && py_module_uses_concurrency(items) {
3792                        self.buf.push_str(CONCURRENCY_RUNTIME_PY);
3793                        self.buf.push('\n');
3794                        self.concurrency_runtime_emitted = true;
3795                    }
3796                    // `_bock_find` references `_BockSome`/`_bock_none`, so the
3797                    // Optional prelude (emitted just above when used) must precede
3798                    // this one; both are inlined in source order here.
3799                    if !self.list_functional_runtime_emitted
3800                        && py_module_uses_list_functional(items)
3801                    {
3802                        if !self.optional_runtime_emitted {
3803                            self.buf.push_str(OPTIONAL_RUNTIME_PY);
3804                            self.buf.push('\n');
3805                            self.optional_runtime_emitted = true;
3806                        }
3807                        self.buf.push_str(LIST_FUNCTIONAL_RUNTIME_PY);
3808                        self.buf.push('\n');
3809                        self.list_functional_runtime_emitted = true;
3810                    }
3811                    if !self.list_mutator_runtime_emitted && py_module_uses_list_mutators(items) {
3812                        self.buf.push_str(LIST_MUTATOR_RUNTIME_PY);
3813                        self.buf.push('\n');
3814                        self.list_mutator_runtime_emitted = true;
3815                    }
3816                    if !self.propagate_runtime_emitted && py_module_uses_propagate(items) {
3817                        self.buf.push_str(PROPAGATE_RUNTIME_PY);
3818                        self.buf.push('\n');
3819                        self.propagate_runtime_emitted = true;
3820                    }
3821                    if !self.str_runtime_emitted && py_module_uses_str(items) {
3822                        self.buf.push_str(STR_RUNTIME_PY);
3823                        self.buf.push('\n');
3824                        self.str_runtime_emitted = true;
3825                    }
3826                }
3827                // Per-module path: emit the module's cross-module imports as
3828                // real Python `from <module> import …` statements at the top of
3829                // the body (the runtime-prelude import is emitted into the
3830                // preamble by `finish`). The single-module path drops these.
3831                if self.per_module {
3832                    for import in imports {
3833                        self.emit_node(import)?;
3834                    }
3835                    // Implicit imports: prelude-visible names this module
3836                    // references but does not explicitly `use` (e.g. a base
3837                    // trait). Grouped per declaring module for one import line
3838                    // each, in deterministic (sorted) order.
3839                    let mut by_module: std::collections::BTreeMap<String, Vec<String>> =
3840                        std::collections::BTreeMap::new();
3841                    for (module_path, name) in &self.implicit_imports {
3842                        by_module
3843                            .entry(module_path.clone())
3844                            .or_default()
3845                            .push(name.clone());
3846                    }
3847                    let import_lines: Vec<String> = by_module
3848                        .into_iter()
3849                        .map(|(module_path, mut names)| {
3850                            names.sort_unstable();
3851                            names.dedup();
3852                            format!("from {module_path} import {}", names.join(", "))
3853                        })
3854                        .collect();
3855                    for line in import_lines {
3856                        self.writeln(&line);
3857                    }
3858                }
3859                // Pre-scan impl blocks so we can attach their methods to the
3860                // target record/class body instead of leaving them as orphan
3861                // module-level functions with a `self` parameter. Both trait
3862                // impls (`impl Trait for T`) and bare inherent impls (`impl T`)
3863                // are folded; only impls whose target is a record/class
3864                // declared in this module are consumed (others stay as-is).
3865                self.impls_by_target.clear();
3866                let class_targets: std::collections::HashSet<String> = items
3867                    .iter()
3868                    .filter_map(|it| match &it.kind {
3869                        NodeKind::RecordDecl { name, .. } | NodeKind::ClassDecl { name, .. } => {
3870                            Some(name.name.clone())
3871                        }
3872                        _ => None,
3873                    })
3874                    .collect();
3875                let mut consumed_impls: std::collections::HashSet<bock_air::NodeId> =
3876                    std::collections::HashSet::new();
3877                for item in items.iter() {
3878                    if let NodeKind::ImplBlock { target, .. } = &item.kind {
3879                        if let Some(target_name) = ast_type_name(target) {
3880                            if class_targets.contains(&target_name) {
3881                                self.impls_by_target
3882                                    .entry(target_name)
3883                                    .or_default()
3884                                    .push(item.clone());
3885                                consumed_impls.insert(item.id);
3886                            }
3887                        }
3888                    }
3889                }
3890                // A trait/base class becomes a Python base class of every type
3891                // that subclasses it (`class Sub(Base):`). Python evaluates the
3892                // base list at `class` statement time, so the base MUST already be
3893                // defined — else `NameError: name 'Base' is not defined`. Source
3894                // order does not guarantee this: a `trait` may be declared after
3895                // the record/class that impls it (chat-protocol's `Serializable`),
3896                // and a `record`+inlined-impl is emitted at the record's source
3897                // position, which can precede the trait. Reorder the *type
3898                // declarations* so each base precedes its subclasses, keeping the
3899                // emission otherwise stable (Q-py-impl-before-trait, py slice).
3900                let order = type_decl_emission_order(items, &self.impls_by_target);
3901                for (idx, &i) in order.iter().enumerate() {
3902                    let item = &items[i];
3903                    if consumed_impls.contains(&item.id) {
3904                        continue;
3905                    }
3906                    // `@test` functions are transpiled separately into pytest/
3907                    // unittest test files (project mode, §20.6.2 — see
3908                    // `generate_tests`), never into the runtime module tree: their
3909                    // `expect(...)` assertion DSL has no runtime definition in the
3910                    // emitted source.
3911                    if crate::generator::fn_is_test(item) {
3912                        continue;
3913                    }
3914                    if idx > 0 && !self.buf.is_empty() && !self.buf.ends_with("\n\n") {
3915                        self.buf.push('\n');
3916                    }
3917                    self.emit_node(item)?;
3918                }
3919                Ok(())
3920            }
3921            NodeKind::ImportDecl { path, items } => {
3922                if !self.per_module {
3923                    // Single-module self-contained emit: there is no sibling file
3924                    // to import from, so the import is a no-op. (Only
3925                    // `generate_module` — the unit-test path — takes this branch;
3926                    // the per-module project path emits real imports below.)
3927                    return Ok(());
3928                }
3929                // Per-module native-import path (Python S1): emit a real Python
3930                // import. The module path's dotted form is both the on-disk
3931                // package path (`core.option` ⇒ `core/option.py`, emitted by
3932                // `generate_project`) and the import path, so this resolves when
3933                // the entry is run from the build root (Python adds the script's
3934                // dir to `sys.path`, and `core` resolves as a PEP 420 namespace
3935                // package).
3936                let module_path = path
3937                    .segments
3938                    .iter()
3939                    .map(|s| s.name.as_str())
3940                    .collect::<Vec<_>>()
3941                    .join(".");
3942                if module_path.is_empty() {
3943                    return Ok(());
3944                }
3945                match items {
3946                    bock_ast::ImportItems::Named(names) => {
3947                        // A braced cross-module enum VARIANT (`use core.compare.
3948                        // {Ordering, Less, Equal, Greater}`) is NOT a free
3949                        // module-level symbol in the emitted Python: the py backend
3950                        // lowers a user enum variant to a dataclass named
3951                        // `{Enum}_{Variant}` (`Ordering_Less`), never the bare
3952                        // `Less`, so `from core.compare import Less` raises
3953                        // `ImportError: cannot import name 'Less'` at runtime. Drop
3954                        // the (unaliased) variant leaf names here: the variant is
3955                        // reached at its use sites as the `Ordering_Less` dataclass
3956                        // (the use-site lowering already emits that name), and the
3957                        // implicit-import pass (`implicit_imports_for`) pulls that
3958                        // dataclass into the module. This mirrors the js/ts filter
3959                        // (js.rs `ImportItems::Named`, which drops non-js-value
3960                        // leaves) and the Rust fix (rs.rs `emit_cross_module_uses`,
3961                        // which reaches a variant via its enum type). The enum TYPE
3962                        // name (`Ordering`) IS a real module-level symbol
3963                        // (`Ordering = Union[Ordering_Less, …]`) and is kept, as is
3964                        // any non-variant leaf. (`user_variant_for_name` returns
3965                        // `Some` only for user enum variants and excludes the
3966                        // built-in `Optional`/`Result`.) An *aliased* variant
3967                        // (`{Less as L}`) is left untouched — aliased-variant
3968                        // rebinding is a separate, unexercised concern.
3969                        let rendered: Vec<String> = names
3970                            .iter()
3971                            .filter(|n| {
3972                                n.alias.is_some()
3973                                    || self.user_variant_for_name(&n.name.name).is_none()
3974                            })
3975                            .map(|n| match &n.alias {
3976                                Some(alias) => format!("{} as {}", n.name.name, alias.name),
3977                                None => n.name.name.clone(),
3978                            })
3979                            .collect();
3980                        if rendered.is_empty() {
3981                            // A genuinely-empty braced list (`use mod.{}`) keeps the
3982                            // `import {module_path}` fallback. But if filtering the
3983                            // variant leaves emptied a *non-empty* original list,
3984                            // emit nothing — the dropped variants are covered by the
3985                            // implicit-import pass + use sites, exactly as js does.
3986                            if names.is_empty() {
3987                                self.writeln(&format!("import {module_path}"));
3988                            }
3989                        } else {
3990                            self.writeln(&format!(
3991                                "from {module_path} import {}",
3992                                rendered.join(", ")
3993                            ));
3994                        }
3995                    }
3996                    bock_ast::ImportItems::Glob => {
3997                        self.writeln(&format!("from {module_path} import *"));
3998                    }
3999                    bock_ast::ImportItems::Module => {
4000                        // `use Foo` brings the module's exported names into
4001                        // scope unqualified in Bock; a `*` import mirrors that.
4002                        self.writeln(&format!("from {module_path} import *"));
4003                    }
4004                }
4005                Ok(())
4006            }
4007            NodeKind::FnDecl {
4008                visibility,
4009                is_async,
4010                name,
4011                generic_params,
4012                params,
4013                return_type,
4014                effect_clause,
4015                body,
4016                ..
4017            } => self.emit_fn_decl(
4018                *visibility,
4019                *is_async,
4020                &name.name,
4021                generic_params,
4022                params,
4023                return_type.as_deref(),
4024                effect_clause,
4025                body,
4026            ),
4027            NodeKind::RecordDecl {
4028                name,
4029                fields,
4030                generic_params,
4031                ..
4032            } => {
4033                // A `record R[T] { … }` needs `T` to resolve at class-eval time:
4034                // emit `T = TypeVar("T")` and add `Generic[T, …]` to the bases,
4035                // else the field annotation `value: T` raises `NameError`
4036                // (DV12, Python slice).
4037                self.emit_typevars(generic_params);
4038                // Pull any previously-collected `impl Trait for Name` blocks
4039                // so their methods become part of this class body and the
4040                // class inherits from every implemented trait — giving real
4041                // method dispatch (a bare instance has no orphan methods).
4042                let impls = self.impls_by_target.remove(&name.name).unwrap_or_default();
4043                let mut bases: Vec<String> = impls
4044                    .iter()
4045                    .filter_map(|im| {
4046                        if let NodeKind::ImplBlock {
4047                            trait_path: Some(tp),
4048                            ..
4049                        } = &im.kind
4050                        {
4051                            let trait_name = tp.segments.last().map(|s| s.name.clone())?;
4052                            // A prelude (compiler-sealed) trait with no user
4053                            // `trait` declaration — `Comparable`/`Equatable`/
4054                            // `Displayable`/`Hashable` used without a definition,
4055                            // §18.2 — emits NO Python ABC, so it must not be a
4056                            // base class (`class Foo(Comparable)` raises
4057                            // `NameError: Comparable`). Its `compare`/`eq`/… is
4058                            // emitted directly on the class, which Python dispatches
4059                            // by duck typing. (Q-prelude-impl-missing-import.)
4060                            if crate::generator::is_unimplemented_sealed_core_trait(
4061                                &trait_name,
4062                                &self.trait_decls,
4063                            ) {
4064                                return None;
4065                            }
4066                            // An impl with no instance methods (e.g. `From`, whose
4067                            // only method `from` is associated) carries no
4068                            // instance contract and is often a prelude trait not
4069                            // emitted here, so it must not be a Python base class
4070                            // (`class Foot(From)` would raise `NameError`). Its
4071                            // `from` static method is emitted directly on the
4072                            // class.
4073                            if crate::generator::impl_has_instance_method(im, &self.effect_ops) {
4074                                Some(trait_name)
4075                            } else {
4076                                None
4077                            }
4078                        } else {
4079                            None
4080                        }
4081                    })
4082                    .collect();
4083                bases.extend(self.generic_base(generic_params));
4084                let base_list = if bases.is_empty() {
4085                    String::new()
4086                } else {
4087                    format!("({})", bases.join(", "))
4088                };
4089                // DQ31 (§18.5): a record with an explicit `impl Equatable` (its
4090                // custom `eq` IS its equality) gets `@dataclass(eq=False)` plus
4091                // a `__eq__` that DELEGATES to that `eq`. Otherwise the
4092                // dataclass-generated structural `__eq__` would shadow the
4093                // custom `eq`, and a `list`/`dict`/`set`/`tuple` of the type
4094                // would compare structurally — silently ignoring the user's
4095                // equality and diverging from the other targets. With the
4096                // delegating `__eq__`, native container `==` (and `dict`-value
4097                // / `list` element comparison) route through the custom `eq`,
4098                // giving the type its ONE equality inside containers as outside.
4099                let custom_eq = matches!(
4100                    node.metadata.get(bock_types::checker::CUSTOM_EQ_META_KEY),
4101                    Some(bock_air::Value::Bool(true))
4102                );
4103                // `@dataclass` is only appropriate when the class actually
4104                // carries data. Empty handler structs are cleaner as plain
4105                // classes — `@dataclass` on an ABC subclass without fields
4106                // adds no value and drags in the dataclass metaclass.
4107                if !fields.is_empty() {
4108                    self.needs_dataclass_import = true;
4109                    if custom_eq {
4110                        self.writeln("@dataclass(eq=False)");
4111                    } else {
4112                        self.writeln("@dataclass");
4113                    }
4114                }
4115                self.writeln(&format!("class {}{base_list}:", name.name));
4116                self.indent += 1;
4117                let has_members = !fields.is_empty()
4118                    || impls
4119                        .iter()
4120                        .any(|im| matches!(&im.kind, NodeKind::ImplBlock { methods, .. } if !methods.is_empty()));
4121                if !has_members {
4122                    self.writeln("pass");
4123                } else {
4124                    for f in fields {
4125                        let type_hint = self.ast_type_to_py(&f.ty);
4126                        // `py_field_ident`: a field named after a Python
4127                        // keyword (`pass`) must be escaped (`pass_`) or the
4128                        // dataclass declaration is a SyntaxError.
4129                        self.writeln(&format!("{}: {type_hint}", py_field_ident(&f.name.name)));
4130                    }
4131                    for method in Self::dedup_impl_methods(&impls) {
4132                        self.buf.push('\n');
4133                        self.emit_class_method(method)?;
4134                    }
4135                    if custom_eq {
4136                        self.buf.push('\n');
4137                        self.writeln("def __eq__(self, other: object) -> bool:");
4138                        self.indent += 1;
4139                        self.writeln(&format!("if not isinstance(other, {}):", name.name));
4140                        self.indent += 1;
4141                        self.writeln("return NotImplemented");
4142                        self.indent -= 1;
4143                        self.writeln("return self.eq(other)");
4144                        self.indent -= 1;
4145                    }
4146                }
4147                self.indent -= 1;
4148                Ok(())
4149            }
4150            NodeKind::EnumDecl {
4151                name,
4152                variants,
4153                generic_params,
4154                ..
4155            } => {
4156                self.needs_dataclass_import = true;
4157                // A generic `enum E[T]` whose variants carry `T`-typed payloads
4158                // needs `T = TypeVar("T")` so those field annotations resolve.
4159                // (Full generic-enum codegen — `Generic[T]` variant bases — is
4160                // tracked separately under DV12/P1; the TypeVar declaration is
4161                // the minimum that keeps the module from raising `NameError`.)
4162                self.emit_typevars(generic_params);
4163                for (i, variant) in variants.iter().enumerate() {
4164                    if i > 0 {
4165                        self.buf.push('\n');
4166                    }
4167                    self.emit_enum_variant(&name.name, variant)?;
4168                }
4169                // A union type alias so the enum's *name* (`Shape`) resolves as
4170                // a type annotation — `def area(s: Shape)` evaluates `Shape` at
4171                // def time, so without this alias the module raises `NameError`
4172                // before `main` ever runs (DV14).
4173                let variant_types: Vec<String> = variants
4174                    .iter()
4175                    .filter_map(|v| {
4176                        if let NodeKind::EnumVariant { name: vname, .. } = &v.kind {
4177                            Some(format!("{}_{}", name.name, vname.name))
4178                        } else {
4179                            None
4180                        }
4181                    })
4182                    .collect();
4183                if !variant_types.is_empty() {
4184                    self.needs_union_import = true;
4185                    self.writeln(&format!(
4186                        "{} = Union[{}]",
4187                        name.name,
4188                        variant_types.join(", ")
4189                    ));
4190                }
4191                Ok(())
4192            }
4193            NodeKind::ClassDecl {
4194                name,
4195                fields,
4196                methods,
4197                generic_params,
4198                base,
4199                traits,
4200                ..
4201            } => {
4202                // A generic `class C[T]` needs `T = TypeVar("T")` + a
4203                // `Generic[T, …]` base so `T`-typed members resolve (DV12).
4204                self.emit_typevars(generic_params);
4205                // Pull any `impl T { … }` / `impl Trait for T { … }` blocks
4206                // collected up front (Module pre-scan) so their methods become
4207                // part of THIS class body — the same path records already use.
4208                // Without this the inherent/trait methods were silently dropped:
4209                // the emitted class had only `__init__`, so `t.render()` raised
4210                // `AttributeError` at runtime (Q-class-codegen, py slice).
4211                let impls = self.impls_by_target.remove(&name.name).unwrap_or_default();
4212                // Bases: the declared `base` class, then every implemented trait
4213                // (both the class-decl `traits` list and any `impl Trait for T`
4214                // trait paths), then `Generic[..]` for generic params. Dedup so a
4215                // trait named both on the class header and via an impl block isn't
4216                // listed twice.
4217                let mut bases: Vec<String> = Vec::new();
4218                if let Some(b) = base {
4219                    bases.push(
4220                        b.segments
4221                            .last()
4222                            .map(|s| s.name.clone())
4223                            .unwrap_or_default(),
4224                    );
4225                }
4226                for tp in traits {
4227                    if let Some(seg) = tp.segments.last() {
4228                        // Skip a prelude (compiler-sealed) trait with no user
4229                        // `trait` decl: it emits no Python ABC, so it cannot be a
4230                        // base class. See the record-decl arm
4231                        // (Q-prelude-impl-missing-import).
4232                        if crate::generator::is_unimplemented_sealed_core_trait(
4233                            &seg.name,
4234                            &self.trait_decls,
4235                        ) {
4236                            continue;
4237                        }
4238                        bases.push(seg.name.clone());
4239                    }
4240                }
4241                for im in &impls {
4242                    if let NodeKind::ImplBlock {
4243                        trait_path: Some(tp),
4244                        ..
4245                    } = &im.kind
4246                    {
4247                        if let Some(seg) = tp.segments.last() {
4248                            if crate::generator::is_unimplemented_sealed_core_trait(
4249                                &seg.name,
4250                                &self.trait_decls,
4251                            ) {
4252                                continue;
4253                            }
4254                            bases.push(seg.name.clone());
4255                        }
4256                    }
4257                }
4258                // Order-preserving dedup: a trait named on both the class header
4259                // and an `impl` block would otherwise repeat in the base list
4260                // (which Python rejects — duplicate bases are a `TypeError`).
4261                let mut seen_bases: std::collections::HashSet<String> =
4262                    std::collections::HashSet::new();
4263                bases.retain(|b| seen_bases.insert(b.clone()));
4264                bases.extend(self.generic_base(generic_params));
4265                let base_list = if bases.is_empty() {
4266                    String::new()
4267                } else {
4268                    format!("({})", bases.join(", "))
4269                };
4270                self.writeln(&format!("class {}{base_list}:", name.name));
4271                self.indent += 1;
4272                // __init__
4273                if !fields.is_empty() {
4274                    let params: Vec<String> = fields
4275                        .iter()
4276                        .map(|f| {
4277                            // `py_field_ident`: keyword-named fields (`pass`)
4278                            // must be escaped (`pass_`) in the `__init__`
4279                            // parameter list and attribute assignments alike.
4280                            let fname = py_field_ident(&f.name.name);
4281                            let type_hint = self.ast_type_to_py(&f.ty);
4282                            format!("{fname}: {type_hint}")
4283                        })
4284                        .collect();
4285                    self.writeln(&format!("def __init__(self, {}):", params.join(", ")));
4286                    self.indent += 1;
4287                    for f in fields {
4288                        let fname = py_field_ident(&f.name.name);
4289                        self.writeln(&format!("self.{fname} = {fname}"));
4290                    }
4291                    self.indent -= 1;
4292                }
4293                // Names already taken by an inline `class T { fn … }` method
4294                // (rare in surface Bock, which puts methods in `impl` blocks, but
4295                // kept for completeness) — so a same-named impl method does not
4296                // re-emit and shadow them.
4297                let mut inline_names: std::collections::HashSet<String> =
4298                    std::collections::HashSet::new();
4299                for method in methods {
4300                    if let NodeKind::FnDecl { name, .. } = &method.kind {
4301                        inline_names.insert(name.name.clone());
4302                    }
4303                    self.buf.push('\n');
4304                    self.emit_class_method(method)?;
4305                }
4306                // Methods pulled in from inherent + trait impl blocks, deduped by
4307                // name (inherent precedence) so a delegating trait method never
4308                // overwrites and self-recurses the inherent one.
4309                let impl_methods: Vec<&AIRNode> = Self::dedup_impl_methods(&impls)
4310                    .into_iter()
4311                    .filter(|m| {
4312                        !matches!(&m.kind, NodeKind::FnDecl { name, .. } if inline_names.contains(&name.name))
4313                    })
4314                    .collect();
4315                let has_impl_methods = !impl_methods.is_empty();
4316                for method in impl_methods {
4317                    self.buf.push('\n');
4318                    self.emit_class_method(method)?;
4319                }
4320                if fields.is_empty() && methods.is_empty() && !has_impl_methods {
4321                    self.writeln("pass");
4322                }
4323                self.indent -= 1;
4324                Ok(())
4325            }
4326            NodeKind::TraitDecl { name, methods, .. } => {
4327                // Traits → abstract base class (comment + class with pass methods).
4328                self.writeln(&format!("# trait {}", name.name));
4329                self.writeln(&format!("class {}:", name.name));
4330                self.indent += 1;
4331                if methods.is_empty() {
4332                    self.writeln("pass");
4333                } else {
4334                    for (i, method) in methods.iter().enumerate() {
4335                        if i > 0 {
4336                            self.buf.push('\n');
4337                        }
4338                        self.emit_class_method(method)?;
4339                    }
4340                }
4341                self.indent -= 1;
4342                Ok(())
4343            }
4344            NodeKind::ImplBlock {
4345                trait_path,
4346                target,
4347                methods,
4348                ..
4349            } => {
4350                let target_name = self.type_expr_to_string(target);
4351                if let Some(tp) = trait_path {
4352                    let trait_name = tp
4353                        .segments
4354                        .iter()
4355                        .map(|s| s.name.as_str())
4356                        .collect::<Vec<_>>()
4357                        .join(".");
4358                    self.writeln(&format!("# impl {trait_name} for {target_name}"));
4359                } else {
4360                    self.writeln(&format!("# impl {target_name}"));
4361                }
4362                for method in methods {
4363                    if let NodeKind::FnDecl {
4364                        is_async,
4365                        name,
4366                        params,
4367                        return_type,
4368                        effect_clause,
4369                        body,
4370                        ..
4371                    } = &method.kind
4372                    {
4373                        if *is_async {
4374                            self.needs_asyncio_import = true;
4375                        }
4376                        let async_kw = if *is_async { "async " } else { "" };
4377                        let rest = match params.first().map(crate::generator::param_binds_self) {
4378                            Some(Some(_)) => &params[1..],
4379                            _ => &params[..],
4380                        };
4381                        let param_strs = self.collect_param_strs(rest);
4382                        let effects = self.effects_params(effect_clause);
4383                        let mut all_params = vec!["self".to_string()];
4384                        all_params.extend(param_strs);
4385                        all_params.extend(effects);
4386                        let ret = return_type
4387                            .as_deref()
4388                            .map(|t| format!(" -> {}", self.type_to_py(t)))
4389                            .unwrap_or_default();
4390                        // Rename a field-colliding method consistently with the
4391                        // inlined-impl path (`emit_class_method`) and call sites.
4392                        let fn_name = self.py_method_name(&name.name);
4393                        self.writeln(&format!(
4394                            "{async_kw}def {fn_name}({}){}:",
4395                            all_params.join(", "),
4396                            ret,
4397                        ));
4398                        self.indent += 1;
4399                        let old_handler_vars = self.current_handler_vars.clone();
4400                        let expanded = self.expand_effect_names(effect_clause);
4401                        for ename in &expanded {
4402                            self.current_handler_vars
4403                                .insert(ename.clone(), to_snake_case(ename));
4404                        }
4405                        self.emit_block_body(body)?;
4406                        self.current_handler_vars = old_handler_vars;
4407                        self.indent -= 1;
4408                    }
4409                }
4410                Ok(())
4411            }
4412            NodeKind::EffectDecl {
4413                name,
4414                components,
4415                operations,
4416                ..
4417            } => {
4418                if !components.is_empty() {
4419                    let comp_names: Vec<String> = components
4420                        .iter()
4421                        .map(|tp| {
4422                            tp.segments
4423                                .last()
4424                                .map_or("effect".to_string(), |s| s.name.clone())
4425                        })
4426                        .collect();
4427                    self.writeln(&format!(
4428                        "# composite effect {} = {}",
4429                        name.name,
4430                        comp_names.join(" + ")
4431                    ));
4432                    self.composite_effects.insert(name.name.clone(), comp_names);
4433                    return Ok(());
4434                }
4435                // Record effect operations for Call → handler.op rewriting.
4436                for op in operations {
4437                    if let NodeKind::FnDecl { name: op_name, .. } = &op.kind {
4438                        self.effect_ops
4439                            .insert(op_name.name.clone(), name.name.clone());
4440                    }
4441                }
4442                // Effects → abstract base class with @abstractmethod.
4443                self.needs_abc_import = true;
4444                self.writeln(&format!("class {}(ABC):", name.name));
4445                self.indent += 1;
4446                if operations.is_empty() {
4447                    self.writeln("pass");
4448                } else {
4449                    for (i, op) in operations.iter().enumerate() {
4450                        if i > 0 {
4451                            self.buf.push('\n');
4452                        }
4453                        if let NodeKind::FnDecl {
4454                            name,
4455                            params,
4456                            return_type,
4457                            ..
4458                        } = &op.kind
4459                        {
4460                            self.writeln("@abstractmethod");
4461                            let rest = match params.first().map(crate::generator::param_binds_self)
4462                            {
4463                                Some(Some(_)) => &params[1..],
4464                                _ => &params[..],
4465                            };
4466                            let param_strs = self.collect_param_strs(rest);
4467                            let mut all_params = vec!["self".to_string()];
4468                            all_params.extend(param_strs);
4469                            let ret = return_type
4470                                .as_deref()
4471                                .map(|t| format!(" -> {}", self.type_to_py(t)))
4472                                .unwrap_or_default();
4473                            let fn_name = to_snake_case(&name.name);
4474                            self.writeln(&format!(
4475                                "def {fn_name}({}){}:",
4476                                all_params.join(", "),
4477                                ret,
4478                            ));
4479                            self.indent += 1;
4480                            self.writeln("...");
4481                            self.indent -= 1;
4482                        }
4483                    }
4484                }
4485                self.indent -= 1;
4486                Ok(())
4487            }
4488            NodeKind::TypeAlias { name, .. } => {
4489                self.writeln(&format!("# type {} = ...", name.name));
4490                Ok(())
4491            }
4492            NodeKind::ConstDecl {
4493                name, value, ty, ..
4494            } => {
4495                let type_hint = format!(": {}", self.type_to_py(ty));
4496                let ind = self.indent_str();
4497                // Emit the const's declared name verbatim (not snake_cased) so it
4498                // matches the verbatim spelling the `Identifier` use-site arm emits
4499                // for a known const — `to_snake_case` would lower `FIZZ_NUM` to
4500                // `fizz_num` here while the use site keeps `FIZZ_NUM`, a `NameError`.
4501                let _ = write!(self.buf, "{ind}{}{type_hint} = ", name.name);
4502                self.emit_expr(value)?;
4503                self.buf.push('\n');
4504                Ok(())
4505            }
4506            NodeKind::ModuleHandle { effect, handler } => {
4507                // Emit `__<effect>: Effect = Handler()` at module scope and
4508                // register it as the default handler. Effectful calls later
4509                // in the module will pick it up via `current_handler_vars`
4510                // unless a local handling block overrides it.
4511                let effect_name = effect.segments.last().map_or("effect", |s| s.name.as_str());
4512                let var_name = format!("__{}", to_snake_case(effect_name));
4513                let ind = self.indent_str();
4514                let _ = write!(self.buf, "{ind}{var_name}: {effect_name} = ");
4515                self.emit_expr(handler)?;
4516                self.buf.push('\n');
4517                self.current_handler_vars
4518                    .insert(effect_name.to_string(), var_name);
4519                Ok(())
4520            }
4521            NodeKind::PropertyTest { name, body, .. } => {
4522                self.writeln(&format!("# property test: {name}"));
4523                self.writeln("# (property tests are not emitted in Python output)");
4524                let _ = body;
4525                Ok(())
4526            }
4527            // Statement / expression nodes at top level:
4528            NodeKind::LetBinding { .. }
4529            | NodeKind::If { .. }
4530            | NodeKind::For { .. }
4531            | NodeKind::While { .. }
4532            | NodeKind::Loop { .. }
4533            | NodeKind::Return { .. }
4534            | NodeKind::Break { .. }
4535            | NodeKind::Continue
4536            | NodeKind::Guard { .. }
4537            | NodeKind::Match { .. }
4538            | NodeKind::Block { .. }
4539            | NodeKind::HandlingBlock { .. }
4540            | NodeKind::Assign { .. } => self.emit_stmt(node),
4541            // Expression nodes that appear as statements:
4542            _ => {
4543                self.write_indent();
4544                self.emit_expr(node)?;
4545                self.buf.push('\n');
4546                Ok(())
4547            }
4548        }
4549    }
4550
4551    // ── Generic type parameters ─────────────────────────────────────────────
4552
4553    /// Emit `T = TypeVar("T")` for each generic parameter not already declared,
4554    /// deduped within the file via [`Self::emitted_typevars`]. A param
4555    /// with a single bound (`T: Comparable`) becomes
4556    /// `T = TypeVar("T", bound=Comparable)` so static checkers see the
4557    /// constraint; multiple bounds collapse to the first (Python `TypeVar`
4558    /// takes one `bound=`). Sets [`Self::needs_typing_typevar`] when anything is
4559    /// emitted so the preamble imports `TypeVar`/`Generic`.
4560    fn emit_typevars(&mut self, generic_params: &[bock_ast::GenericParam]) {
4561        for gp in generic_params {
4562            let name = gp.name.name.clone();
4563            if !self.emitted_typevars.insert(name.clone()) {
4564                continue;
4565            }
4566            self.needs_typing_typevar.set(true);
4567            // A bound becomes `bound=<Name>`. Python's `TypeVar` accepts a
4568            // single `bound`; if Bock ever allows several, the first wins and
4569            // the rest are dropped (a static-checker approximation only —
4570            // Python erases generics at runtime regardless). A compiler-provided
4571            // sealed-core bound (`Equatable`/…) with no user `impl` is dropped
4572            // entirely: there is no such Python class, so `bound=Equatable` raises
4573            // `NameError` at def time (GAP-C). The `.eq`/`.compare` call is lowered
4574            // to a native operator by `try_emit_trait_bound_bridge`.
4575            let bound = gp
4576                .bounds
4577                .first()
4578                .and_then(|tp| tp.segments.last())
4579                .filter(|seg| {
4580                    !crate::generator::is_unimplemented_sealed_core_trait(
4581                        &seg.name,
4582                        &self.trait_decls,
4583                    )
4584                })
4585                .map(|seg| format!(", bound={}", self.map_type_name(&seg.name)))
4586                .unwrap_or_default();
4587            self.writeln(&format!("{name} = TypeVar(\"{name}\"{bound})"));
4588        }
4589    }
4590
4591    /// Build the `Generic[T, …]` base-class fragment for a generic decl. Returns
4592    /// an empty `Vec` when there are no type parameters. Also sets
4593    /// [`Self::needs_typing_typevar`] (the typevars are emitted separately by
4594    /// [`Self::emit_typevars`]).
4595    fn generic_base(&self, generic_params: &[bock_ast::GenericParam]) -> Vec<String> {
4596        if generic_params.is_empty() {
4597            return Vec::new();
4598        }
4599        self.needs_typing_typevar.set(true);
4600        let names: Vec<String> = generic_params
4601            .iter()
4602            .map(|gp| gp.name.name.clone())
4603            .collect();
4604        vec![format!("Generic[{}]", names.join(", "))]
4605    }
4606
4607    // ── Function declarations ───────────────────────────────────────────────
4608
4609    #[allow(clippy::too_many_arguments)]
4610    fn emit_fn_decl(
4611        &mut self,
4612        _visibility: Visibility,
4613        is_async: bool,
4614        name: &str,
4615        generic_params: &[bock_ast::GenericParam],
4616        params: &[AIRNode],
4617        return_type: Option<&AIRNode>,
4618        effect_clause: &[bock_ast::TypePath],
4619        body: &AIRNode,
4620    ) -> Result<(), CodegenError> {
4621        if is_async {
4622            self.needs_asyncio_import = true;
4623        }
4624        // A generic `fn f[T](…) -> T` references `T` in its param/return
4625        // annotations, which Python evaluates at def time — declare
4626        // `T = TypeVar("T")` first so those names resolve (DV12).
4627        self.emit_typevars(generic_params);
4628        let async_kw = if is_async { "async " } else { "" };
4629        let param_strs = self.collect_param_strs(params);
4630        let effects = self.effects_params(effect_clause);
4631        let mut all_params = param_strs;
4632        all_params.extend(effects);
4633        let ret = return_type
4634            .map(|t| format!(" -> {}", self.type_to_py(t)))
4635            .unwrap_or_default();
4636        if !effect_clause.is_empty() {
4637            let effect_names = self.expand_effect_names(effect_clause);
4638            self.fn_effects.insert(name.to_string(), effect_names);
4639        }
4640        let fn_name = py_value_ident(name);
4641        self.writeln(&format!(
4642            "{async_kw}def {fn_name}({}){}:",
4643            all_params.join(", "),
4644            ret,
4645        ));
4646        self.indent += 1;
4647        let old_handler_vars = self.current_handler_vars.clone();
4648        let expanded = self.expand_effect_names(effect_clause);
4649        for ename in &expanded {
4650            self.current_handler_vars
4651                .insert(ename.clone(), to_snake_case(ename));
4652        }
4653        // Seed the body's shadow frame with the parameters (so a body-level `let`
4654        // re-binding a param is a plain rebind, a nested-block one is renamed).
4655        self.pending_scope_seed = Self::param_value_names(params);
4656        // A function-body tail is the function's return value, even for a `fn`
4657        // *nested inside a loop body or a statement-`match`/`if` arm*: clear the discard
4658        // flags so this body returns its tail rather than dropping it.
4659        let prev_discard = std::mem::replace(&mut self.in_loop_body_tail, false);
4660        let prev_match = std::mem::replace(&mut self.in_stmt_construct_arm, false);
4661        let body_res = self.emit_fn_body(body);
4662        self.in_loop_body_tail = prev_discard;
4663        self.in_stmt_construct_arm = prev_match;
4664        body_res?;
4665        self.current_handler_vars = old_handler_vars;
4666        self.indent -= 1;
4667        Ok(())
4668    }
4669
4670    fn emit_class_method(&mut self, method: &AIRNode) -> Result<(), CodegenError> {
4671        if let NodeKind::FnDecl {
4672            is_async,
4673            name,
4674            params,
4675            return_type,
4676            effect_clause,
4677            body,
4678            ..
4679        } = &method.kind
4680        {
4681            if *is_async {
4682                self.needs_asyncio_import = true;
4683            }
4684            let async_kw = if *is_async { "async " } else { "" };
4685            // An associated function (no `self` receiver, e.g. a `From` impl's
4686            // `from`) is a `@staticmethod`: it is called as `Type.method(...)`
4687            // and takes no implicit `self`. A regular method takes `self`.
4688            let is_assoc = crate::generator::is_associated_impl_method(method, &self.effect_ops);
4689            // The AIR keeps `self` as a leading `Param`; Python methods need
4690            // exactly one explicit `self`. Skip the bound `self` param if
4691            // present so it isn't emitted twice (`def m(self, self)`).
4692            let rest = match params.first().map(crate::generator::param_binds_self) {
4693                Some(Some(_)) => &params[1..],
4694                _ => &params[..],
4695            };
4696            let param_strs = self.collect_param_strs(rest);
4697            let effects = self.effects_params(effect_clause);
4698            let mut all_params = if is_assoc {
4699                Vec::new()
4700            } else {
4701                vec!["self".to_string()]
4702            };
4703            all_params.extend(param_strs);
4704            all_params.extend(effects);
4705            let ret = return_type
4706                .as_deref()
4707                .map(|t| format!(" -> {}", self.type_to_py(t)))
4708                .unwrap_or_default();
4709            // A method whose name collides with a field is renamed (`message`
4710            // → `message_method`); the dataclass field would otherwise overwrite
4711            // the method attribute. Renamed identically at every call site.
4712            let fn_name = self.py_method_name(&name.name);
4713            if is_assoc {
4714                self.writeln("@staticmethod");
4715            }
4716            self.writeln(&format!(
4717                "{async_kw}def {fn_name}({}){}:",
4718                all_params.join(", "),
4719                ret,
4720            ));
4721            self.indent += 1;
4722            let old_handler_vars = self.current_handler_vars.clone();
4723            let expanded = self.expand_effect_names(effect_clause);
4724            for ename in &expanded {
4725                self.current_handler_vars
4726                    .insert(ename.clone(), to_snake_case(ename));
4727            }
4728            // Seed the body frame with `self` (regular methods only) + the
4729            // method params (see `emit_fn_decl`). An associated `@staticmethod`
4730            // has no `self`.
4731            let mut seed = if is_assoc {
4732                Vec::new()
4733            } else {
4734                vec!["self".to_string()]
4735            };
4736            seed.extend(Self::param_value_names(rest));
4737            self.pending_scope_seed = seed;
4738            // A method body's tail is its return value — clear any enclosing
4739            // discard flags (loop-body or statement-`match`/`if` arm) so it returns
4740            // rather than dropping its tail.
4741            let prev_discard = std::mem::replace(&mut self.in_loop_body_tail, false);
4742            let prev_match = std::mem::replace(&mut self.in_stmt_construct_arm, false);
4743            let body_res = self.emit_fn_body(body);
4744            self.in_loop_body_tail = prev_discard;
4745            self.in_stmt_construct_arm = prev_match;
4746            body_res?;
4747            self.current_handler_vars = old_handler_vars;
4748            self.indent -= 1;
4749        }
4750        Ok(())
4751    }
4752
4753    /// Flatten the methods of a type's impl blocks into the emission order for a
4754    /// single Python class body, **deduplicating by method name** so the same
4755    /// `def` is never emitted twice into one class.
4756    ///
4757    /// A type can have both an inherent impl (`impl T { fn render }`) and a trait
4758    /// impl (`impl Trait for T { fn render }`) whose methods share a name. In
4759    /// Bock those are distinct (the trait method typically delegates to the
4760    /// inherent one via `self.render()`), but Python has a single per-class
4761    /// method namespace: emitting both means the second `def render` silently
4762    /// overwrites the first, and a delegating trait body (`return self.render()`)
4763    /// then calls *itself* — unbounded recursion (`RecursionError`, seen on
4764    /// react-components' `Button`). The **inherent** method is the concrete
4765    /// implementation and the one a direct `btn.render()` call resolves to, so it
4766    /// wins; a colliding trait method (which would only shadow it and recurse) is
4767    /// dropped. Method order is otherwise preserved (inherent impls, then trait
4768    /// impls, in source order).
4769    fn dedup_impl_methods<'a>(impls: &'a [AIRNode]) -> Vec<&'a AIRNode> {
4770        // Inherent impls (no trait_path) take precedence, so visit them first;
4771        // within each group, source order is preserved.
4772        let mut out: Vec<&'a AIRNode> = Vec::new();
4773        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
4774        let inherent_first = impls.iter().filter(|im| {
4775            matches!(
4776                &im.kind,
4777                NodeKind::ImplBlock {
4778                    trait_path: None,
4779                    ..
4780                }
4781            )
4782        });
4783        let trait_after = impls.iter().filter(|im| {
4784            matches!(
4785                &im.kind,
4786                NodeKind::ImplBlock {
4787                    trait_path: Some(_),
4788                    ..
4789                }
4790            )
4791        });
4792        for im in inherent_first.chain(trait_after) {
4793            if let NodeKind::ImplBlock { methods, .. } = &im.kind {
4794                for method in methods {
4795                    if let NodeKind::FnDecl { name, .. } = &method.kind {
4796                        if seen.insert(name.name.clone()) {
4797                            out.push(method);
4798                        }
4799                    }
4800                }
4801            }
4802        }
4803        out
4804    }
4805
4806    /// The Python value-names a parameter list binds (simple `BindPat` params
4807    /// only). Used to seed the function/method body's shadow frame so a body
4808    /// `let` re-binding a param is a plain rebind, while a nested-block `let`
4809    /// shadowing the param is renamed.
4810    fn param_value_names(params: &[AIRNode]) -> Vec<String> {
4811        params
4812            .iter()
4813            .filter_map(|p| {
4814                if let NodeKind::Param { pattern, .. } = &p.kind {
4815                    Self::simple_bind_name(pattern)
4816                } else {
4817                    None
4818                }
4819            })
4820            .collect()
4821    }
4822
4823    /// Collect parameter strings for a `def`/method signature.
4824    ///
4825    /// Each emitted param carries its `: type` annotation and any default.
4826    /// Use [`Self::collect_param_strs_bare`] for `lambda` params, where Python
4827    /// forbids type annotations (`lambda x: int: body` is a `SyntaxError`).
4828    fn collect_param_strs(&self, params: &[AIRNode]) -> Vec<String> {
4829        self.collect_param_strs_inner(params, true)
4830    }
4831
4832    /// Collect bare parameter names (no `: type` annotations) for a `lambda`.
4833    ///
4834    /// A Python `lambda` parameter list cannot carry annotations: emitting one
4835    /// produces `lambda x: int: body`, where the first `:` ends the parameter
4836    /// list, so the type hint becomes a second, syntactically invalid `:`.
4837    fn collect_param_strs_bare(&self, params: &[AIRNode]) -> Vec<String> {
4838        self.collect_param_strs_inner(params, false)
4839    }
4840
4841    /// Shared implementation of [`Self::collect_param_strs`] and
4842    /// [`Self::collect_param_strs_bare`]. When `hints` is `false`, the `: type`
4843    /// annotation is omitted (required for `lambda` params).
4844    fn collect_param_strs_inner(&self, params: &[AIRNode], hints: bool) -> Vec<String> {
4845        params
4846            .iter()
4847            .filter_map(|p| {
4848                if let NodeKind::Param {
4849                    pattern,
4850                    ty,
4851                    default,
4852                } = &p.kind
4853                {
4854                    let name = to_snake_case(&self.pattern_to_binding_name(pattern));
4855                    let type_hint = if hints {
4856                        ty.as_ref()
4857                            .map(|t| format!(": {}", self.type_to_py(t)))
4858                            .unwrap_or_default()
4859                    } else {
4860                        String::new()
4861                    };
4862                    if let Some(def) = default {
4863                        let mut ctx = PyEmitCtx::new();
4864                        ctx.indent = self.indent;
4865                        ctx.enum_variants = self.enum_variants.clone();
4866                        if ctx.emit_expr(def).is_ok() {
4867                            let def_str = ctx.buf;
4868                            return Some(format!("{name}{type_hint} = {def_str}"));
4869                        }
4870                    }
4871                    Some(format!("{name}{type_hint}"))
4872                } else {
4873                    None
4874                }
4875            })
4876            .collect()
4877    }
4878
4879    /// Expand effect names, replacing composite effects with their components.
4880    fn expand_effect_names(&self, effects: &[bock_ast::TypePath]) -> Vec<String> {
4881        let mut result = Vec::new();
4882        for tp in effects {
4883            let name = tp
4884                .segments
4885                .last()
4886                .map_or("effect".to_string(), |s| s.name.clone());
4887            if let Some(components) = self.composite_effects.get(&name) {
4888                result.extend(components.iter().cloned());
4889            } else {
4890                result.push(name);
4891            }
4892        }
4893        result
4894    }
4895
4896    /// Effects → keyword arguments: `*, log: Log, clock: Clock`.
4897    fn effects_params(&self, effects: &[bock_ast::TypePath]) -> Vec<String> {
4898        if effects.is_empty() {
4899            return vec![];
4900        }
4901        let expanded = self.expand_effect_names(effects);
4902        let mut result = vec!["*".to_string()];
4903        for name in &expanded {
4904            let param_name = to_snake_case(name);
4905            result.push(format!("{param_name}: {name}"));
4906        }
4907        result
4908    }
4909
4910    /// The in-scope `Clock` effect handler variable, if one is installed.
4911    ///
4912    /// Returns the emitted name of the handler bound for the `Clock` effect at
4913    /// the current point (a `with Clock` parameter such as `clock`, or a
4914    /// `handling (Clock with ...)` block's synthesised `__clock_hN`). When this
4915    /// is `Some`, the `Clock` time operations (`Instant.now`, `sleep`, `elapsed`)
4916    /// are routed through the handler instead of inlining the host primitive
4917    /// (Q-clock-handler-routing, §18.3.1/§18.4); when `None`, no handler is in
4918    /// scope and the default host primitive is emitted.
4919    fn clock_handler_var(&self) -> Option<&str> {
4920        self.current_handler_vars.get("Clock").map(String::as_str)
4921    }
4922
4923    /// Build `effect=handler_var, ...` keyword arguments for calling an effectful function.
4924    fn build_effects_call_args_py(&self, fn_name: &str) -> Option<String> {
4925        let effects = self.fn_effects.get(fn_name)?;
4926        let entries: Vec<String> = effects
4927            .iter()
4928            .filter_map(|e| {
4929                let handler_var = self.current_handler_vars.get(e)?;
4930                let param_name = to_snake_case(e);
4931                Some(format!("{param_name}={handler_var}"))
4932            })
4933            .collect();
4934        if entries.is_empty() {
4935            return None;
4936        }
4937        Some(entries.join(", "))
4938    }
4939
4940    // ── Enum variant factories ──────────────────────────────────────────────
4941
4942    fn emit_enum_variant(
4943        &mut self,
4944        enum_name: &str,
4945        variant: &AIRNode,
4946    ) -> Result<(), CodegenError> {
4947        if let NodeKind::EnumVariant { name, payload } = &variant.kind {
4948            let vname = &name.name;
4949            match payload {
4950                EnumVariantPayload::Unit => {
4951                    self.writeln("@dataclass(frozen=True)");
4952                    self.writeln(&format!("class {enum_name}_{vname}:"));
4953                    self.indent += 1;
4954                    self.writeln(&format!("_tag: str = \"{vname}\""));
4955                    self.indent -= 1;
4956                }
4957                EnumVariantPayload::Struct(fields) => {
4958                    self.writeln("@dataclass");
4959                    self.writeln(&format!("class {enum_name}_{vname}:"));
4960                    self.indent += 1;
4961                    for f in fields {
4962                        let type_hint = self.ast_type_to_py(&f.ty);
4963                        // `py_field_ident`: keyword-named payload fields
4964                        // (`lambda`) must be escaped (`lambda_`).
4965                        self.writeln(&format!("{}: {type_hint}", py_field_ident(&f.name.name)));
4966                    }
4967                    self.writeln(&format!("_tag: str = \"{vname}\""));
4968                    self.indent -= 1;
4969                }
4970                EnumVariantPayload::Tuple(elems) => {
4971                    self.writeln("@dataclass");
4972                    self.writeln(&format!("class {enum_name}_{vname}:"));
4973                    self.indent += 1;
4974                    for (i, elem) in elems.iter().enumerate() {
4975                        let type_hint = self.type_to_py(elem);
4976                        self.writeln(&format!("_{i}: {type_hint}"));
4977                    }
4978                    self.writeln(&format!("_tag: str = \"{vname}\""));
4979                    self.indent -= 1;
4980                }
4981            }
4982        }
4983        Ok(())
4984    }
4985
4986    // ── Statements ──────────────────────────────────────────────────────────
4987
4988    fn emit_stmt(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
4989        match &node.kind {
4990            NodeKind::LetBinding {
4991                pattern, value, ty, ..
4992            } => {
4993                // Nested-block `let`-shadow handling (simple `BindPat` only): a
4994                // binding that shadows an enclosing-scope name is renamed to a
4995                // fresh alias so Python's function-scoped `=` doesn't stomp the
4996                // outer binding. The rename is *planned* now (so the LHS uses the
4997                // alias) but *committed* only after the RHS is emitted — the RHS
4998                // reads the prior binding (`let y = y + 10` reads the outer `y`).
4999                let raw_name = Self::simple_bind_name(pattern);
5000                let (binding, pending) = match &raw_name {
5001                    Some(n) => self.plan_shadow_let(n),
5002                    None => (self.pattern_to_py_binding(pattern), None),
5003                };
5004                let type_hint = ty
5005                    .as_ref()
5006                    .map(|t| format!(": {}", self.type_to_py(t)))
5007                    .unwrap_or_default();
5008                // Declare-only temp from the shared value-CF hoist: Python has no
5009                // declarations, so pre-bind `name = None`; the relocated control
5010                // flow that follows assigns it on every non-diverging path.
5011                if node.metadata.contains_key(crate::generator::DECL_ONLY_META) {
5012                    let ind = self.indent_str();
5013                    let _ = writeln!(self.buf, "{ind}{binding}{type_hint} = None");
5014                    if let Some(n) = &raw_name {
5015                        self.commit_shadow_let(n, pending);
5016                    }
5017                    return Ok(());
5018                }
5019                // Expression-position control flow (a value-`loop`, a `match`
5020                // with a diverging/statement arm, a statement-`if`) cannot be a
5021                // Python expression. Pre-declare the binding (so it is always
5022                // bound, including the diverging-arm path) and fill it in via
5023                // real statements. See `value_needs_stmt_form`.
5024                if value_needs_stmt_form(value) {
5025                    let ind = self.indent_str();
5026                    let _ = writeln!(self.buf, "{ind}{binding}{type_hint} = None");
5027                    let r = self.emit_value_binding(&binding, value);
5028                    if let Some(n) = &raw_name {
5029                        self.commit_shadow_let(n, pending);
5030                    }
5031                    return r;
5032                }
5033                // `let x = todo()` — the value diverges (a `raise`), so it cannot
5034                // sit on the RHS of `=`. Emit the raise bare; the binding is never
5035                // reached.
5036                if is_raise_expr(value) {
5037                    self.write_indent();
5038                    self.emit_expr(value)?;
5039                    self.buf.push('\n');
5040                    return Ok(());
5041                }
5042                let ind = self.indent_str();
5043                let _ = write!(self.buf, "{ind}{binding}{type_hint} = ");
5044                let wrap_task = matches!(&value.kind, NodeKind::Call { .. })
5045                    && self.task_bound_names.contains(&binding);
5046                if wrap_task {
5047                    self.needs_asyncio_import = true;
5048                    self.buf.push_str("asyncio.create_task(");
5049                    self.emit_expr(value)?;
5050                    self.buf.push(')');
5051                } else {
5052                    self.emit_expr(value)?;
5053                }
5054                self.buf.push('\n');
5055                // Commit the rename only now — after the RHS read the prior binding.
5056                if let Some(n) = &raw_name {
5057                    self.commit_shadow_let(n, pending);
5058                }
5059                Ok(())
5060            }
5061            NodeKind::If { .. } => {
5062                // Statement position: a mid-block `if`/`else` is a Unit
5063                // statement, so each branch's tail expression is discarded
5064                // (emitted as a bare expression statement) rather than
5065                // `return`ed — the same contract as the statement-`match` arm
5066                // below (Q-python-ifelse-truncation; sibling of the #259
5067                // chat-protocol truncation). Without the flag, a branch whose
5068                // body is a bare expression (`if c { println(..) }`) emitted
5069                // `return print(..)`, aborting the enclosing function after
5070                // the taken branch so every following statement was skipped.
5071                // Saved/restored, and cleared inside any nested value context
5072                // (a nested fn/method body, a value-binding hoist), so it
5073                // scopes only to this statement's own branch tails.
5074                let prev = std::mem::replace(&mut self.in_stmt_construct_arm, true);
5075                let r = self.emit_stmt_if(node, false);
5076                self.in_stmt_construct_arm = prev;
5077                r
5078            }
5079            NodeKind::For {
5080                pattern,
5081                iterable,
5082                body,
5083            } => {
5084                let binding = self.pattern_to_py_binding(pattern);
5085                let ind = self.indent_str();
5086                let _ = write!(self.buf, "{ind}for {binding} in ");
5087                self.emit_expr(iterable)?;
5088                self.buf.push_str(":\n");
5089                self.indent += 1;
5090                self.loop_value_targets.push(None);
5091                // Loop body is statement position: a tail expression is
5092                // discarded, not `return`ed (see `emit_loop_body`).
5093                self.emit_loop_body(body)?;
5094                self.loop_value_targets.pop();
5095                self.indent -= 1;
5096                Ok(())
5097            }
5098            NodeKind::While { condition, body } => {
5099                let ind = self.indent_str();
5100                let _ = write!(self.buf, "{ind}while ");
5101                self.emit_expr(condition)?;
5102                self.buf.push_str(":\n");
5103                self.indent += 1;
5104                self.loop_value_targets.push(None);
5105                // Loop body is statement position: a tail expression is
5106                // discarded, not `return`ed (see `emit_loop_body`).
5107                self.emit_loop_body(body)?;
5108                self.loop_value_targets.pop();
5109                self.indent -= 1;
5110                Ok(())
5111            }
5112            NodeKind::Loop { body } => {
5113                self.writeln("while True:");
5114                self.indent += 1;
5115                // Statement-position loop yields no value; push a `None` frame so
5116                // a bare `break` inside stays a bare `break` (and isn't mistaken
5117                // for an enclosing value-loop's break).
5118                self.loop_value_targets.push(None);
5119                // Loop body is statement position: a tail expression is
5120                // discarded, not `return`ed (see `emit_loop_body`).
5121                self.emit_loop_body(body)?;
5122                self.loop_value_targets.pop();
5123                self.indent -= 1;
5124                Ok(())
5125            }
5126            NodeKind::Return { value } => {
5127                if let Some(val) = value {
5128                    let ind = self.indent_str();
5129                    let _ = write!(self.buf, "{ind}return ");
5130                    self.emit_expr(val)?;
5131                    self.buf.push('\n');
5132                } else {
5133                    self.writeln("return");
5134                }
5135                Ok(())
5136            }
5137            NodeKind::Break { value } => {
5138                if let Some(val) = value {
5139                    // A value-`loop` hoisted by `emit_value_binding` records its
5140                    // assignment target; `break <v>` lowers to `<target> = <v>`
5141                    // then `break`. Python's `break` itself carries no value.
5142                    if let Some(Some(target)) = self.loop_value_targets.last() {
5143                        let target = target.clone();
5144                        let ind = self.indent_str();
5145                        let _ = write!(self.buf, "{ind}{target} = ");
5146                        self.emit_expr(val)?;
5147                        self.buf.push('\n');
5148                        self.writeln("break");
5149                    } else {
5150                        // No value target in scope (statement-position loop):
5151                        // record the value as a comment, then break.
5152                        let ind = self.indent_str();
5153                        let _ = write!(self.buf, "{ind}# break value: ");
5154                        self.emit_expr(val)?;
5155                        self.buf.push('\n');
5156                        self.writeln("break");
5157                    }
5158                } else {
5159                    self.writeln("break");
5160                }
5161                Ok(())
5162            }
5163            NodeKind::Continue => {
5164                self.writeln("continue");
5165                Ok(())
5166            }
5167            NodeKind::Guard {
5168                let_pattern,
5169                condition,
5170                else_block,
5171            } => {
5172                // The guard `else` block is statement position. Per §8.4 it
5173                // must diverge (`return`/`break`/`continue`/`Never`) — a
5174                // diverging tail is a statement and unaffected by the discard
5175                // flag — but the checker does not currently enforce the
5176                // divergence (surfaced as OPEN with this fix), and for an
5177                // accepted non-diverging else every other backend (js/ts/go/
5178                // rust and the interpreter) falls through to the statements
5179                // after the guard. Without the flag the bare-expression tail
5180                // lowered to `return print(..)`, silently truncating the
5181                // function on Python alone — the same early-`return` family as
5182                // the statement `match`/`if` fixes above.
5183                let prev = std::mem::replace(&mut self.in_stmt_construct_arm, true);
5184                let r = self.emit_stmt_guard(let_pattern.as_deref(), condition, else_block);
5185                self.in_stmt_construct_arm = prev;
5186                r
5187            }
5188            NodeKind::Match { scrutinee, arms } => {
5189                // Statement position: a mid-block `match` is a Unit statement, so
5190                // each arm's tail expression is discarded (emitted as a bare
5191                // expression statement) rather than `return`ed. Without this, an
5192                // arm whose body is a bare expression (`Ok(m) => println(..)`)
5193                // emits `return println(..)`, aborting the enclosing function
5194                // after the matched arm — the chat-protocol truncation. The flag
5195                // is saved/restored and cleared inside any nested value context
5196                // (a nested fn/method body, a value-binding hoist), so it scopes
5197                // only to this match's own arm tails.
5198                let prev = std::mem::replace(&mut self.in_stmt_construct_arm, true);
5199                let r = self.emit_match(scrutinee, arms);
5200                self.in_stmt_construct_arm = prev;
5201                r
5202            }
5203            NodeKind::Block { stmts, tail } => {
5204                for s in stmts {
5205                    self.emit_node(s)?;
5206                }
5207                if let Some(t) = tail {
5208                    self.write_indent();
5209                    self.emit_expr(t)?;
5210                    self.buf.push('\n');
5211                }
5212                Ok(())
5213            }
5214            NodeKind::HandlingBlock { handlers, body } => {
5215                // handling block → handler variable bindings then body.
5216                // Each handling block gets a fresh numeric suffix so nested
5217                // blocks do not overwrite each other's handler variables —
5218                // Python has function scope, not block scope, for `=`, so
5219                // `__logger = X()` in an inner block would otherwise stomp
5220                // the outer binding permanently.
5221                let old_handler_vars = self.current_handler_vars.clone();
5222                self.handling_counter += 1;
5223                let suffix = format!("_h{}", self.handling_counter);
5224                for h in handlers {
5225                    let effect_name = h
5226                        .effect
5227                        .segments
5228                        .last()
5229                        .map_or("effect", |s| s.name.as_str());
5230                    let var_name = format!("__{}{suffix}", to_snake_case(effect_name));
5231                    let ind = self.indent_str();
5232                    let _ = write!(self.buf, "{ind}{var_name}: {effect_name} = ");
5233                    self.emit_expr(&h.handler)?;
5234                    self.buf.push('\n');
5235                    self.current_handler_vars
5236                        .insert(effect_name.to_string(), var_name);
5237                }
5238                if let NodeKind::Block { stmts, tail } = &body.kind {
5239                    for s in stmts {
5240                        self.emit_node(s)?;
5241                    }
5242                    if let Some(t) = tail {
5243                        self.write_indent();
5244                        self.emit_expr(t)?;
5245                        self.buf.push('\n');
5246                    }
5247                } else {
5248                    self.emit_stmt(body)?;
5249                }
5250                self.current_handler_vars = old_handler_vars;
5251                Ok(())
5252            }
5253            NodeKind::Assign { op, target, value } => {
5254                let ind = self.indent_str();
5255                let _ = write!(self.buf, "{ind}");
5256                self.emit_expr(target)?;
5257                let op_str = match op {
5258                    AssignOp::Assign => " = ",
5259                    AssignOp::AddAssign => " += ",
5260                    AssignOp::SubAssign => " -= ",
5261                    AssignOp::MulAssign => " *= ",
5262                    AssignOp::DivAssign => " /= ",
5263                    AssignOp::RemAssign => " %= ",
5264                };
5265                self.buf.push_str(op_str);
5266                self.emit_expr(value)?;
5267                self.buf.push('\n');
5268                Ok(())
5269            }
5270            _ => {
5271                self.write_indent();
5272                self.emit_expr(node)?;
5273                self.buf.push('\n');
5274                Ok(())
5275            }
5276        }
5277    }
5278
5279    /// Emit a **statement-position** `if` (with its optional `else`/`else if`
5280    /// chain). `inline` is true for an `else if` continuation: the caller has
5281    /// already written this line's indentation plus the `el` prefix, so the
5282    /// emission starts at `if <cond>:` with no leading indent. (The previous
5283    /// code re-entered the generic statement emitter after writing `el`, which
5284    /// wrote its own indentation — `el    if (…):`, a Python SyntaxError, for
5285    /// every mid-block `else if` chain. The tail-position twin
5286    /// [`Self::emit_tail_control_flow_inline`] already chained correctly.)
5287    ///
5288    /// The caller ([`Self::emit_stmt`]'s `If` arm) sets
5289    /// [`Self::in_stmt_construct_arm`] around the whole chain, so each branch
5290    /// body's bare-expression tail lowers to a bare statement, never a
5291    /// function-body `return` (Q-python-ifelse-truncation).
5292    fn emit_stmt_if(&mut self, node: &AIRNode, inline: bool) -> Result<(), CodegenError> {
5293        let NodeKind::If {
5294            let_pattern,
5295            condition,
5296            then_block,
5297            else_block,
5298        } = &node.kind
5299        else {
5300            // Defensive: only `If` nodes are routed here.
5301            return self.emit_stmt(node);
5302        };
5303        if let Some(pat) = let_pattern {
5304            // `if let` — bind first, then test. Never reached with `inline`
5305            // (an `else if let` continuation is emitted under a plain `else:`
5306            // below, because its binding statement needs its own line).
5307            let ind = self.indent_str();
5308            let binding = self.pattern_to_py_binding(pat);
5309            let _ = write!(self.buf, "{ind}{binding} = ");
5310            self.emit_expr(condition)?;
5311            self.buf.push('\n');
5312            self.writeln(&format!("if {binding} is not None:"));
5313        } else {
5314            if inline {
5315                self.buf.push_str("if ");
5316            } else {
5317                let ind = self.indent_str();
5318                let _ = write!(self.buf, "{ind}if ");
5319            }
5320            self.emit_expr(condition)?;
5321            self.buf.push_str(":\n");
5322        }
5323        self.indent += 1;
5324        self.emit_block_body(then_block)?;
5325        self.indent -= 1;
5326        if let Some(else_b) = else_block {
5327            if let NodeKind::If {
5328                let_pattern: nested_let,
5329                ..
5330            } = &else_b.kind
5331            {
5332                if nested_let.is_none() {
5333                    // `else if` → `elif`: write the `el` prefix, then continue
5334                    // on the same line.
5335                    let ind = self.indent_str();
5336                    let _ = write!(self.buf, "{ind}el");
5337                    return self.emit_stmt_if(else_b, true);
5338                }
5339                // `else if let` needs its binding statement first — emit the
5340                // whole continuation indented under a plain `else:`.
5341                self.writeln("else:");
5342                self.indent += 1;
5343                let r = self.emit_stmt_if(else_b, false);
5344                self.indent -= 1;
5345                return r;
5346            }
5347            self.writeln("else:");
5348            self.indent += 1;
5349            self.emit_block_body(else_b)?;
5350            self.indent -= 1;
5351        }
5352        Ok(())
5353    }
5354
5355    /// Emit a **statement-position** `guard (cond) else { … }` /
5356    /// `guard (let PAT = EXPR) else { … }`. The caller ([`Self::emit_stmt`]'s
5357    /// `Guard` arm) sets [`Self::in_stmt_construct_arm`] around the call so a
5358    /// bare-expression tail in the `else` block lowers to a bare statement —
5359    /// see the rationale there; a spec-conforming diverging `else` (§8.4) is a
5360    /// statement tail and is emitted unchanged.
5361    fn emit_stmt_guard(
5362        &mut self,
5363        let_pattern: Option<&AIRNode>,
5364        condition: &AIRNode,
5365        else_block: &AIRNode,
5366    ) -> Result<(), CodegenError> {
5367        if let Some(pat) = let_pattern {
5368            // `guard (let PAT = EXPR) else { ELSE }` — a refutable
5369            // binding guard. Lower to a two-arm `match` so PAT's bindings
5370            // (e.g. `val` in `Ok(val)`) are extracted on success and stay
5371            // in scope after the guard (Python `match` bindings persist as
5372            // ordinary assignments); the `_` arm runs the diverging ELSE.
5373            let ind = self.indent_str();
5374            let _ = write!(self.buf, "{ind}match ");
5375            self.emit_expr(condition)?;
5376            self.buf.push_str(":\n");
5377            self.indent += 1;
5378            let ind = self.indent_str();
5379            let _ = write!(self.buf, "{ind}case ");
5380            self.emit_pattern(pat)?;
5381            self.buf.push_str(":\n");
5382            self.indent += 1;
5383            self.writeln("pass");
5384            self.indent -= 1;
5385            self.writeln("case _:");
5386            self.indent += 1;
5387            self.emit_block_body(else_block)?;
5388            self.indent -= 1;
5389            self.indent -= 1;
5390            return Ok(());
5391        }
5392        let ind = self.indent_str();
5393        let _ = write!(self.buf, "{ind}if not (");
5394        self.emit_expr(condition)?;
5395        self.buf.push_str("):\n");
5396        self.indent += 1;
5397        self.emit_block_body(else_block)?;
5398        self.indent -= 1;
5399        Ok(())
5400    }
5401
5402    // ── Expressions ─────────────────────────────────────────────────────────
5403
5404    fn emit_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
5405        match &node.kind {
5406            NodeKind::Literal { lit } => {
5407                match lit {
5408                    Literal::Int(s) => self.buf.push_str(s),
5409                    Literal::Float(s) => self.buf.push_str(s),
5410                    Literal::Bool(b) => self.buf.push_str(if *b { "True" } else { "False" }),
5411                    Literal::Char(s) => {
5412                        self.buf.push('\'');
5413                        self.buf.push_str(s);
5414                        self.buf.push('\'');
5415                    }
5416                    Literal::String(s) => {
5417                        self.buf.push('"');
5418                        self.buf.push_str(&escape_py_string(s));
5419                        self.buf.push('"');
5420                    }
5421                    Literal::Unit => self.buf.push_str("None"),
5422                }
5423                Ok(())
5424            }
5425            NodeKind::Identifier { name } => {
5426                // Bock's Optional `None` constructor must not collide with
5427                // Python's `None` keyword: it lowers to the `_bock_none`
5428                // singleton of the Optional runtime (see `OPTIONAL_RUNTIME_PY`).
5429                // Python's own `None` (Void/Unit) is emitted as a `Literal::Unit`,
5430                // not an identifier, so this rewrite is unambiguous.
5431                if name.name == "None" {
5432                    self.buf.push_str("_bock_none");
5433                } else if let Some(variant) = crate::generator::ordering_variant(&name.name) {
5434                    // Prelude `Ordering` variant → the Ordering-runtime singleton
5435                    // (`_bock_less` / `_bock_equal` / `_bock_greater`). When the
5436                    // `core.compare` enum decl is not among the reached modules,
5437                    // the runtime stands in (mirrors `_bock_none`).
5438                    self.buf.push_str(ordering_singleton_py(variant));
5439                } else if let Some(enum_name) = self
5440                    .user_variant_for_name(&name.name)
5441                    .map(|i| i.enum_name.clone())
5442                {
5443                    // A unit-variant reference (`Empty`) → an instance of its
5444                    // `@dataclass(frozen=True)` class: `Shape_Empty()`.
5445                    let _ = write!(self.buf, "{enum_name}_{}()", name.name);
5446                } else if self.const_names.contains(&name.name) {
5447                    // A module-scope `const` is emitted verbatim at its
5448                    // declaration (see the `ConstDecl` arm); spell its use site
5449                    // identically rather than through `identifier_to_py`.
5450                    self.buf.push_str(&name.name);
5451                } else {
5452                    // Resolve through the shadow-scope stack so a reference inside
5453                    // a nested block reads the (renamed) shadowing binding while
5454                    // code outside reads the original (see `ShadowScope`).
5455                    let py = identifier_to_py(&name.name);
5456                    self.buf.push_str(&self.resolve_shadow_name(&py));
5457                }
5458                Ok(())
5459            }
5460            NodeKind::BinaryOp { op, left, right } => {
5461                // Integer `/` and `%` (DQ23, §3.6). Python's native operators do
5462                // NOT match the contract: `//` *floors* (`-17 // 5 == -4`, the
5463                // ruling wants `-3`) and `%` follows floor division (`-17 % 5 == 3`,
5464                // the ruling wants `-2`); `int(a / b)` routes through lossy float
5465                // true-division and loses precision on large integers. Lower to an
5466                // integer-only IIFE that truncates toward zero (quotient magnitude
5467                // from `abs(a) // abs(b)`, sign from the operands) and gives a
5468                // dividend-sign remainder. The `//` / `%` inside still raise
5469                // `ZeroDivisionError` on a zero divisor — an abort — matching the
5470                // other targets.
5471                if matches!(op, BinOp::Div | BinOp::Rem) && crate::generator::is_int_arith(node) {
5472                    let lam = if matches!(op, BinOp::Div) {
5473                        "(lambda __a, __b: (abs(__a) // abs(__b)) * (1 if (__a < 0) == (__b < 0) else -1))("
5474                    } else {
5475                        "(lambda __a, __b: (abs(__a) % abs(__b)) * (1 if __a >= 0 else -1))("
5476                    };
5477                    self.buf.push_str(lam);
5478                    self.emit_expr(left)?;
5479                    self.buf.push_str(", ");
5480                    self.emit_expr(right)?;
5481                    self.buf.push(')');
5482                    return Ok(());
5483                }
5484                // Ordering operators on a user `Comparable` type lower through the
5485                // type's `compare` (Python's `<` on two instances raises
5486                // `TypeError` unless they define `__lt__`). The returned `Ordering`
5487                // is one of the `_BockOrdering*` runtime singletons, tested with
5488                // `isinstance`: `a < b` ⇒ `isinstance(a.compare(b), …Less)`,
5489                // `a <= b` ⇒ `not isinstance(a.compare(b), …Greater)`, etc.
5490                if crate::generator::is_user_compare(node) {
5491                    if let Some((tag, is_eq)) = crate::generator::user_compare_variant(*op) {
5492                        let recv = self.expr_to_string(left)?;
5493                        let other = self.expr_to_string(right)?;
5494                        let class = ordering_class_py(tag);
5495                        let neg = if is_eq { "" } else { "not " };
5496                        let _ = write!(
5497                            self.buf,
5498                            "({neg}isinstance(({recv}).compare({other}), {class}))"
5499                        );
5500                        return Ok(());
5501                    }
5502                }
5503                // DQ29 (§18.5): `==`/`!=` on a type with an explicit
5504                // `impl Equatable` dispatch through its `eq` method — Python's
5505                // native `==` is the dataclass-generated STRUCTURAL equality,
5506                // which would silently ignore the user's custom `eq`. The
5507                // structural/deep lanes stay native: dataclass `==`, `list`/
5508                // `dict`/`set`/`tuple` equality are already field-wise /
5509                // content-based (and `dict`/`set` order-independent).
5510                if matches!(op, BinOp::Eq | BinOp::Ne)
5511                    && crate::generator::user_eq_kind(node) == Some("impl")
5512                {
5513                    let recv = self.expr_to_string(left)?;
5514                    let other = self.expr_to_string(right)?;
5515                    let neg = if *op == BinOp::Ne { "not " } else { "" };
5516                    let _ = write!(self.buf, "({neg}({recv}).eq({other}))");
5517                    return Ok(());
5518                }
5519                self.buf.push('(');
5520                self.emit_expr(left)?;
5521                let op_str = match op {
5522                    BinOp::Add => " + ",
5523                    BinOp::Sub => " - ",
5524                    BinOp::Mul => " * ",
5525                    BinOp::Div => " / ",
5526                    BinOp::Rem => " % ",
5527                    BinOp::Pow => " ** ",
5528                    BinOp::Eq => " == ",
5529                    BinOp::Ne => " != ",
5530                    BinOp::Lt => " < ",
5531                    BinOp::Le => " <= ",
5532                    BinOp::Gt => " > ",
5533                    BinOp::Ge => " >= ",
5534                    BinOp::And => " and ",
5535                    BinOp::Or => " or ",
5536                    BinOp::BitAnd => " & ",
5537                    BinOp::BitOr => " | ",
5538                    BinOp::BitXor => " ^ ",
5539                    BinOp::Compose => " # compose ",
5540                    BinOp::Is => " is ",
5541                };
5542                self.buf.push_str(op_str);
5543                self.emit_expr(right)?;
5544                self.buf.push(')');
5545                Ok(())
5546            }
5547            NodeKind::UnaryOp { op, operand } => {
5548                let op_str = match op {
5549                    UnaryOp::Neg => "-",
5550                    UnaryOp::Not => "not ",
5551                    UnaryOp::BitNot => "~",
5552                };
5553                self.buf.push_str(op_str);
5554                self.emit_expr(operand)?;
5555                Ok(())
5556            }
5557            NodeKind::Call { callee, args, .. } => {
5558                if let Some(code) = self.map_prelude_call(callee, args)? {
5559                    self.buf.push_str(&code);
5560                    return Ok(());
5561                }
5562                // A call whose callee names a registered tuple variant is a
5563                // construction (`Rect(3.0, 4.0)` → `Shape_Rect(3.0, 4.0)`).
5564                // Handled here so the callee emits the bare class name, not the
5565                // unit-variant `Shape_Rect()` the `Identifier` arm would.
5566                if let NodeKind::Identifier { name } = &callee.kind {
5567                    if let Some(enum_name) = self
5568                        .user_variant_for_name(&name.name)
5569                        .map(|i| i.enum_name.clone())
5570                    {
5571                        let _ = write!(self.buf, "{enum_name}_{}(", name.name);
5572                        for (i, arg) in args.iter().enumerate() {
5573                            if i > 0 {
5574                                self.buf.push_str(", ");
5575                            }
5576                            self.emit_expr(&arg.value)?;
5577                        }
5578                        self.buf.push(')');
5579                        return Ok(());
5580                    }
5581                }
5582                if self.try_emit_time_assoc_call(callee, args)? {
5583                    return Ok(());
5584                }
5585                if self.try_emit_time_desugared_method(node, callee, args)? {
5586                    return Ok(());
5587                }
5588                if self.try_emit_concurrency_call(callee, args)? {
5589                    return Ok(());
5590                }
5591                // Map/Set dispatch precedes the List recogniser so the
5592                // overlapping method names route by `recv_kind`, not by name.
5593                if self.try_emit_map_method(node, callee, args)? {
5594                    return Ok(());
5595                }
5596                if self.try_emit_set_method(node, callee, args)? {
5597                    return Ok(());
5598                }
5599                // String method dispatch runs *before* the List recogniser so the
5600                // overlapping `len`/`contains`/`is_empty` names route by the
5601                // checker's `recv_kind = "Primitive:String"`, not by name alone.
5602                if self.try_emit_string_method(node, callee, args)? {
5603                    return Ok(());
5604                }
5605                // Numeric/Char/Bool primitive methods (`to_float`/`abs`/`sqrt`/…)
5606                // likewise route by the checker's `recv_kind = "Primitive:Int|…"`
5607                // before the generic fall-through, which would emit `n.to_float(n)`.
5608                if self.try_emit_numeric_method(node, callee, args)? {
5609                    return Ok(());
5610                }
5611                if self.try_emit_list_mutating_method(node, callee, args)? {
5612                    return Ok(());
5613                }
5614                if self.try_emit_list_inplace_mutator(node, callee, args)? {
5615                    return Ok(());
5616                }
5617                if self.try_emit_list_method(node, callee, args)? {
5618                    return Ok(());
5619                }
5620                if self.try_emit_list_functional_method(node, callee, args)? {
5621                    return Ok(());
5622                }
5623                if self.try_emit_primitive_bridge(node, callee, args)? {
5624                    return Ok(());
5625                }
5626                if self.try_emit_trait_bound_bridge(node, callee, args)? {
5627                    return Ok(());
5628                }
5629                if self.try_emit_container_method(node, callee, args)? {
5630                    return Ok(());
5631                }
5632                // Q-prim-assoc: a primitive associated-conversion call
5633                // (`Float.from(x)` / `Int.try_from(s)` / `String.from(c)`)
5634                // lowers to Python's native conversion. CRITICAL: `from` is a
5635                // Python keyword, so the static-member form below would emit
5636                // `Float.from_(...)` against an undefined `Float` — a hard error.
5637                if self.try_emit_primitive_conversion(node, callee, args)? {
5638                    return Ok(());
5639                }
5640                // Associated-function call (`Type.method(args)` — stamped by the
5641                // lowerer, no `self` prepended) resolves to the `@staticmethod`
5642                // on the class. Emit `Type.method(args)` with the type name
5643                // preserved and the method name run through `py_method_name` (so a
5644                // keyword like `from` → `from_`, matching the `@staticmethod`
5645                // definition); the generic fall-through would snake-case the type
5646                // identifier into a non-existent value.
5647                if crate::generator::is_associated_call(node) {
5648                    if let NodeKind::FieldAccess { object, field } = &callee.kind {
5649                        if let NodeKind::Identifier { name: type_name } = &object.kind {
5650                            let _ = write!(
5651                                self.buf,
5652                                "{}.{}",
5653                                type_name.name,
5654                                self.py_method_name(&field.name)
5655                            );
5656                            self.buf.push('(');
5657                            for (i, arg) in args.iter().enumerate() {
5658                                if i > 0 {
5659                                    self.buf.push_str(", ");
5660                                }
5661                                self.emit_expr(&arg.value)?;
5662                            }
5663                            self.buf.push(')');
5664                            return Ok(());
5665                        }
5666                    }
5667                }
5668                // Desugared instance method call `Call(FieldAccess(recv, m),
5669                // [recv, ...rest])`: emit `recv.m(rest)` so the receiver binds
5670                // Python's `self` rather than being passed twice.
5671                if let Some((recv, method, rest)) =
5672                    crate::generator::desugared_self_call(callee, args)
5673                {
5674                    self.emit_expr(recv)?;
5675                    let _ = write!(self.buf, ".{}", self.py_method_name(&method.name));
5676                    self.buf.push('(');
5677                    for (i, arg) in rest.iter().enumerate() {
5678                        if i > 0 {
5679                            self.buf.push_str(", ");
5680                        }
5681                        self.emit_expr(&arg.value)?;
5682                    }
5683                    self.buf.push(')');
5684                    return Ok(());
5685                }
5686                // Rewrite bare effect operation calls: log(...) → handler.log(...)
5687                if let NodeKind::Identifier { name } = &callee.kind {
5688                    if let Some(effect_name) = self.effect_ops.get(&name.name).cloned() {
5689                        if let Some(handler_var) =
5690                            self.current_handler_vars.get(&effect_name).cloned()
5691                        {
5692                            let _ =
5693                                write!(self.buf, "{}.{}", handler_var, to_snake_case(&name.name));
5694                            self.buf.push('(');
5695                            for (i, arg) in args.iter().enumerate() {
5696                                if i > 0 {
5697                                    self.buf.push_str(", ");
5698                                }
5699                                self.emit_expr(&arg.value)?;
5700                            }
5701                            self.buf.push(')');
5702                            return Ok(());
5703                        }
5704                    }
5705                }
5706                // Pass handler args to effectful function calls.
5707                let effects_args = if let NodeKind::Identifier { name } = &callee.kind {
5708                    self.build_effects_call_args_py(&name.name)
5709                } else {
5710                    None
5711                };
5712                self.emit_callee(callee)?;
5713                self.buf.push('(');
5714                for (i, arg) in args.iter().enumerate() {
5715                    if i > 0 {
5716                        self.buf.push_str(", ");
5717                    }
5718                    self.emit_expr(&arg.value)?;
5719                }
5720                if let Some(ea) = effects_args {
5721                    if !args.is_empty() {
5722                        self.buf.push_str(", ");
5723                    }
5724                    self.buf.push_str(&ea);
5725                }
5726                self.buf.push(')');
5727                Ok(())
5728            }
5729            NodeKind::MethodCall {
5730                receiver,
5731                method,
5732                args,
5733                ..
5734            } => {
5735                if self.try_emit_time_method(receiver, &method.name, args)? {
5736                    return Ok(());
5737                }
5738                self.emit_expr(receiver)?;
5739                let _ = write!(self.buf, ".{}", self.py_method_name(&method.name));
5740                self.buf.push('(');
5741                for (i, arg) in args.iter().enumerate() {
5742                    if i > 0 {
5743                        self.buf.push_str(", ");
5744                    }
5745                    self.emit_expr(&arg.value)?;
5746                }
5747                self.buf.push(')');
5748                Ok(())
5749            }
5750            NodeKind::FieldAccess { object, field } => {
5751                self.emit_expr(object)?;
5752                // `py_field_ident`: a keyword-named field (`pass`) reads as the
5753                // escaped attribute (`t.pass_`) — `t.pass` is a SyntaxError —
5754                // matching the escaped dataclass/`__init__` declaration.
5755                let _ = write!(self.buf, ".{}", py_field_ident(&field.name));
5756                Ok(())
5757            }
5758            NodeKind::Index { object, index } => {
5759                self.emit_expr(object)?;
5760                self.buf.push('[');
5761                self.emit_expr(index)?;
5762                self.buf.push(']');
5763                Ok(())
5764            }
5765            NodeKind::Lambda { params, body } => {
5766                // Python `lambda` params take no type annotations — see
5767                // `collect_param_strs_bare`.
5768                let param_strs = self.collect_param_strs_bare(params);
5769                let _ = write!(self.buf, "lambda {}: ", param_strs.join(", "));
5770                self.emit_expr(body)?;
5771                Ok(())
5772            }
5773            NodeKind::Pipe { left, right } => self.emit_pipe(left, right),
5774            NodeKind::Compose { left, right } => {
5775                // `f >> g` → `(lambda x: g(f(x)))`. The whole lambda is wrapped
5776                // so that a nested compose — emitted here as a `lambda x: ...`
5777                // for `left`/`right` — is itself parenthesized before the
5778                // `(x)` call is appended; otherwise Python binds the `(x)` to
5779                // the inner lambda's body rather than invoking it. (In practice
5780                // the AIR lowers `>>` to a `Lambda` before codegen, so this arm
5781                // is a defensive fall-through; `emit_callee` covers the lowered
5782                // form.)
5783                self.buf.push_str("(lambda x: ");
5784                self.emit_callee(right)?;
5785                self.buf.push('(');
5786                self.emit_callee(left)?;
5787                self.buf.push_str("(x)))");
5788                Ok(())
5789            }
5790            NodeKind::Await { expr } => {
5791                self.buf.push_str("(await ");
5792                self.emit_expr(expr)?;
5793                self.buf.push(')');
5794                Ok(())
5795            }
5796            NodeKind::Propagate { expr } => {
5797                // `expr?` → `_bock_try(expr)`: unwrap the `Ok`/`Some` payload, or
5798                // raise the `_BockPropagate` sentinel (carrying the `Err`/`None`)
5799                // that the enclosing function's `try/except` re-returns. The wrap
5800                // is installed by `emit_fn_body_with_propagate` for any function or
5801                // method whose body contains a `?` (see `body_contains_propagate`).
5802                self.buf.push_str("_bock_try(");
5803                self.emit_expr(expr)?;
5804                self.buf.push(')');
5805                Ok(())
5806            }
5807            NodeKind::Range { lo, hi, inclusive } => {
5808                self.buf.push_str("range(");
5809                self.emit_expr(lo)?;
5810                self.buf.push_str(", ");
5811                self.emit_expr(hi)?;
5812                if *inclusive {
5813                    self.buf.push_str(" + 1");
5814                }
5815                self.buf.push(')');
5816                Ok(())
5817            }
5818            NodeKind::RecordConstruct {
5819                path,
5820                fields,
5821                spread,
5822            } => {
5823                // A struct-variant construction (`Circle { radius: 2.0 }`) → an
5824                // instance of the `{enum}_{variant}` dataclass, built with
5825                // keyword args (`Shape_Circle(radius=2.0)`). Plain records keep
5826                // their dotted path name.
5827                let type_name = if let Some(info) = self.user_variant_for_path(path) {
5828                    let variant = path.segments.last().map_or("", |s| s.name.as_str());
5829                    format!("{}_{variant}", info.enum_name)
5830                } else {
5831                    path.segments
5832                        .iter()
5833                        .map(|s| s.name.as_str())
5834                        .collect::<Vec<_>>()
5835                        .join(".")
5836                };
5837                // `py_field_ident` throughout: keyword-named fields (`pass`)
5838                // construct through their escaped spelling — kwargs
5839                // (`Tally(pass_=7)`) and spread dict keys (`"pass_": 9`) must
5840                // match the escaped dataclass field, and `pass=7` is a
5841                // SyntaxError besides. A shorthand field's *value* is the
5842                // same-named value binding, whose spelling `py_value_ident`
5843                // escapes identically.
5844                if let Some(sp) = spread {
5845                    // Spread: create dict, update, then construct
5846                    self.buf.push_str(&format!("{type_name}(**{{**vars("));
5847                    self.emit_expr(sp)?;
5848                    self.buf.push_str("), ");
5849                    for (i, f) in fields.iter().enumerate() {
5850                        if i > 0 {
5851                            self.buf.push_str(", ");
5852                        }
5853                        let _ = write!(self.buf, "\"{}\": ", py_field_ident(&f.name.name));
5854                        if let Some(val) = &f.value {
5855                            self.emit_expr(val)?;
5856                        } else {
5857                            self.buf.push_str(&py_value_ident(&f.name.name));
5858                        }
5859                    }
5860                    self.buf.push_str("})");
5861                } else {
5862                    self.buf.push_str(&type_name);
5863                    self.buf.push('(');
5864                    for (i, f) in fields.iter().enumerate() {
5865                        if i > 0 {
5866                            self.buf.push_str(", ");
5867                        }
5868                        let _ = write!(self.buf, "{}=", py_field_ident(&f.name.name));
5869                        if let Some(val) = &f.value {
5870                            self.emit_expr(val)?;
5871                        } else {
5872                            self.buf.push_str(&py_value_ident(&f.name.name));
5873                        }
5874                    }
5875                    self.buf.push(')');
5876                }
5877                Ok(())
5878            }
5879            NodeKind::ListLiteral { elems } => {
5880                self.buf.push('[');
5881                for (i, e) in elems.iter().enumerate() {
5882                    if i > 0 {
5883                        self.buf.push_str(", ");
5884                    }
5885                    self.emit_expr(e)?;
5886                }
5887                self.buf.push(']');
5888                Ok(())
5889            }
5890            NodeKind::MapLiteral { entries } => {
5891                self.buf.push('{');
5892                for (i, entry) in entries.iter().enumerate() {
5893                    if i > 0 {
5894                        self.buf.push_str(", ");
5895                    }
5896                    self.emit_expr(&entry.key)?;
5897                    self.buf.push_str(": ");
5898                    self.emit_expr(&entry.value)?;
5899                }
5900                self.buf.push('}');
5901                Ok(())
5902            }
5903            NodeKind::SetLiteral { elems } => {
5904                if elems.is_empty() {
5905                    self.buf.push_str("set()");
5906                } else {
5907                    self.buf.push('{');
5908                    for (i, e) in elems.iter().enumerate() {
5909                        if i > 0 {
5910                            self.buf.push_str(", ");
5911                        }
5912                        self.emit_expr(e)?;
5913                    }
5914                    self.buf.push('}');
5915                }
5916                Ok(())
5917            }
5918            NodeKind::TupleLiteral { elems } => {
5919                self.buf.push('(');
5920                for (i, e) in elems.iter().enumerate() {
5921                    if i > 0 {
5922                        self.buf.push_str(", ");
5923                    }
5924                    self.emit_expr(e)?;
5925                }
5926                if elems.len() == 1 {
5927                    self.buf.push(',');
5928                }
5929                self.buf.push(')');
5930                Ok(())
5931            }
5932            NodeKind::Interpolation { parts } => {
5933                let has_newline = parts.iter().any(|p| {
5934                    matches!(p,
5935                        AirInterpolationPart::Literal(s) if s.contains('\n')
5936                    )
5937                });
5938                if has_newline {
5939                    self.buf.push_str("f\"\"\"");
5940                } else {
5941                    self.buf.push_str("f\"");
5942                }
5943                for part in parts {
5944                    match part {
5945                        AirInterpolationPart::Literal(s) => {
5946                            if has_newline {
5947                                self.buf.push_str(&escape_fstring_triple(s));
5948                            } else {
5949                                self.buf.push_str(&escape_fstring(s));
5950                            }
5951                        }
5952                        AirInterpolationPart::Expr(expr) => {
5953                            self.buf.push('{');
5954                            // A `Bool`-typed part must print the canonical
5955                            // lowercase `true`/`false` (§3.5); a bare `f"{b}"`
5956                            // would print Python's `True`/`False`. The checker
5957                            // stamps such parts (`is_bool_stringify`); map them
5958                            // through a lowercasing conditional expression.
5959                            if crate::generator::is_bool_stringify(expr) {
5960                                self.buf.push_str("'true' if (");
5961                                self.emit_expr(expr)?;
5962                                self.buf.push_str(") else 'false'");
5963                            } else {
5964                                // Q-displayable-interpolation-dispatch: render
5965                                // through `_bock_str` so a user value with a
5966                                // `Displayable` impl (its `to_string` method)
5967                                // shows via that method, not the dataclass
5968                                // `repr`. Primitives fall back to `str(x)`.
5969                                self.needs_runtime_str = true;
5970                                self.buf.push_str("_bock_str(");
5971                                self.emit_expr(expr)?;
5972                                self.buf.push(')');
5973                            }
5974                            self.buf.push('}');
5975                        }
5976                    }
5977                }
5978                if has_newline {
5979                    self.buf.push_str("\"\"\"");
5980                } else {
5981                    self.buf.push('"');
5982                }
5983                Ok(())
5984            }
5985            NodeKind::Placeholder => {
5986                self.buf.push('_');
5987                Ok(())
5988            }
5989            NodeKind::Unreachable => {
5990                self.buf.push_str("raise RuntimeError(\"unreachable\")");
5991                Ok(())
5992            }
5993            NodeKind::ResultConstruct { variant, value } => {
5994                // Construct the Result-runtime classes (`_BockOk`/`_BockErr`) —
5995                // the same shape the surface `Ok(..)`/`Err(..)` construction and
5996                // the `case _BockOk(..)`/`_BockErr(..)` match use. The old
5997                // dict-with-`value`/`error`-keys shape disagreed with the match
5998                // (which reads the runtime classes), so reconcile on the classes.
5999                let cls = match variant {
6000                    ResultVariant::Ok => "_BockOk",
6001                    ResultVariant::Err => "_BockErr",
6002                };
6003                let _ = write!(self.buf, "{cls}(");
6004                if let Some(v) = value {
6005                    self.emit_expr(v)?;
6006                } else {
6007                    self.buf.push_str("None");
6008                }
6009                self.buf.push(')');
6010                Ok(())
6011            }
6012            NodeKind::Assign { op, target, value } => {
6013                self.emit_expr(target)?;
6014                let op_str = match op {
6015                    AssignOp::Assign => " = ",
6016                    AssignOp::AddAssign => " += ",
6017                    AssignOp::SubAssign => " -= ",
6018                    AssignOp::MulAssign => " *= ",
6019                    AssignOp::DivAssign => " /= ",
6020                    AssignOp::RemAssign => " %= ",
6021                };
6022                self.buf.push_str(op_str);
6023                self.emit_expr(value)?;
6024                Ok(())
6025            }
6026            NodeKind::If {
6027                condition,
6028                then_block,
6029                else_block,
6030                ..
6031            } => {
6032                // Ternary for expression-position if.
6033                self.buf.push('(');
6034                self.emit_block_as_expr(then_block)?;
6035                self.buf.push_str(" if ");
6036                self.emit_expr(condition)?;
6037                self.buf.push_str(" else ");
6038                if let Some(eb) = else_block {
6039                    self.emit_block_as_expr(eb)?;
6040                } else {
6041                    self.buf.push_str("None");
6042                }
6043                self.buf.push(')');
6044                Ok(())
6045            }
6046            NodeKind::Block { stmts, tail } => {
6047                // Blocks in expression position. Python `lambda` bodies are
6048                // expression-only, so leading statements can't live inside an
6049                // IIFE the way they do in JS. When every leading statement is a
6050                // pure-expressible `let`/expression statement we fold them into
6051                // immediately-applied lambdas (`try_emit_block_stmts_as_expr`)
6052                // so their effects run and their bindings reach the tail.
6053                if stmts.is_empty() {
6054                    if let Some(t) = tail {
6055                        return self.emit_expr(t);
6056                    }
6057                } else if self.try_emit_block_stmts_as_expr(stmts, tail.as_deref())? {
6058                    return Ok(());
6059                }
6060                // Fallback for shapes the fold can't model (mutable `let`,
6061                // assignment, loops): wrap the tail alone (best effort).
6062                self.buf.push_str("(lambda: ");
6063                if let Some(t) = tail {
6064                    self.emit_expr(t)?;
6065                } else {
6066                    self.buf.push_str("None");
6067                }
6068                self.buf.push_str(")()");
6069                Ok(())
6070            }
6071            NodeKind::Match { scrutinee, arms } => {
6072                // Match in expression position: not directly supported in Python.
6073                // Emit as IIFE-like lambda with internal match.
6074                // For simplicity, try to emit as a series of ternary if-else.
6075                self.buf.push_str("(lambda __v: ");
6076                self.emit_match_expr(scrutinee, arms)?;
6077                self.buf.push_str(")(");
6078                self.emit_expr(scrutinee)?;
6079                self.buf.push(')');
6080                Ok(())
6081            }
6082            // Ownership nodes: erase in Python.
6083            NodeKind::Move { expr }
6084            | NodeKind::Borrow { expr }
6085            | NodeKind::MutableBorrow { expr } => self.emit_expr(expr),
6086            // Effect operation invocation.
6087            NodeKind::EffectOp {
6088                effect,
6089                operation,
6090                args,
6091            } => {
6092                let effect_name = effect.segments.last().map_or("effect", |s| s.name.as_str());
6093                let _ = write!(
6094                    self.buf,
6095                    "{}.{}",
6096                    to_snake_case(effect_name),
6097                    to_snake_case(&operation.name)
6098                );
6099                self.buf.push('(');
6100                for (i, arg) in args.iter().enumerate() {
6101                    if i > 0 {
6102                        self.buf.push_str(", ");
6103                    }
6104                    self.emit_expr(&arg.value)?;
6105                }
6106                self.buf.push(')');
6107                Ok(())
6108            }
6109            // Type expressions: erased in Python expression context.
6110            NodeKind::TypeNamed { .. }
6111            | NodeKind::TypeTuple { .. }
6112            | NodeKind::TypeFunction { .. }
6113            | NodeKind::TypeOptional { .. }
6114            | NodeKind::TypeSelf => {
6115                self.buf.push_str("# type");
6116                Ok(())
6117            }
6118            NodeKind::EffectRef { path } => {
6119                let name = path
6120                    .segments
6121                    .iter()
6122                    .map(|s| s.name.as_str())
6123                    .collect::<Vec<_>>()
6124                    .join(".");
6125                self.buf.push_str(&name);
6126                Ok(())
6127            }
6128            NodeKind::Error => {
6129                self.buf.push_str("# error");
6130                Ok(())
6131            }
6132            _ => {
6133                self.buf.push_str("# unsupported");
6134                Ok(())
6135            }
6136        }
6137    }
6138
6139    // ── Match → match/case (Python 3.10+) ───────────────────────────────────
6140
6141    fn emit_match(&mut self, scrutinee: &AIRNode, arms: &[AIRNode]) -> Result<(), CodegenError> {
6142        let ind = self.indent_str();
6143        let _ = write!(self.buf, "{ind}match ");
6144        self.emit_expr(scrutinee)?;
6145        self.buf.push_str(":\n");
6146        self.indent += 1;
6147        for arm in arms {
6148            self.emit_match_arm(arm)?;
6149        }
6150        self.indent -= 1;
6151        Ok(())
6152    }
6153
6154    fn emit_match_arm(&mut self, arm: &AIRNode) -> Result<(), CodegenError> {
6155        if let NodeKind::MatchArm {
6156            pattern,
6157            guard,
6158            body,
6159        } = &arm.kind
6160        {
6161            let ind = self.indent_str();
6162            let _ = write!(self.buf, "{ind}case ");
6163            // A range pattern (`1..10 => …`) has no Python `case` literal form:
6164            // lower it to a capture-plus-guard `case __rv if lo <= __rv < hi:`.
6165            // The capture binds the whole scrutinee so the relational test can run
6166            // (Python `match`/`case` cannot reference the scrutinee name inside a
6167            // `case`). A user guard, if any, is AND-ed onto the range test.
6168            if let NodeKind::RangePat { lo, hi, inclusive } = &pattern.kind {
6169                let lo_s = range_bound_to_py(lo);
6170                let hi_s = range_bound_to_py(hi);
6171                let upper = if *inclusive { "<=" } else { "<" };
6172                let _ = write!(self.buf, "__rv if {lo_s} <= __rv {upper} {hi_s}");
6173                if let Some(g) = guard {
6174                    self.buf.push_str(" and (");
6175                    self.emit_expr(g)?;
6176                    self.buf.push(')');
6177                }
6178            } else {
6179                self.emit_pattern(pattern)?;
6180                if let Some(g) = guard {
6181                    self.buf.push_str(" if ");
6182                    self.emit_expr(g)?;
6183                }
6184            }
6185            self.buf.push_str(":\n");
6186            self.indent += 1;
6187            self.emit_block_body(body)?;
6188            self.indent -= 1;
6189        }
6190        Ok(())
6191    }
6192
6193    fn emit_pattern(&mut self, pat: &AIRNode) -> Result<(), CodegenError> {
6194        match &pat.kind {
6195            NodeKind::WildcardPat => {
6196                self.buf.push('_');
6197            }
6198            NodeKind::BindPat { name, .. } => {
6199                self.buf.push_str(&py_value_ident(&name.name));
6200            }
6201            NodeKind::LiteralPat { lit } => match lit {
6202                Literal::Int(s) => self.buf.push_str(s),
6203                Literal::Float(s) => self.buf.push_str(s),
6204                Literal::Bool(b) => self.buf.push_str(if *b { "True" } else { "False" }),
6205                Literal::Char(s) => {
6206                    self.buf.push('\'');
6207                    self.buf.push_str(s);
6208                    self.buf.push('\'');
6209                }
6210                Literal::String(s) => {
6211                    self.buf.push('"');
6212                    self.buf.push_str(&escape_py_string(s));
6213                    self.buf.push('"');
6214                }
6215                Literal::Unit => self.buf.push_str("None"),
6216            },
6217            NodeKind::ConstructorPat { path, fields } => {
6218                // Optional `Some`/`None` patterns dispatch on the Optional
6219                // runtime classes (see `OPTIONAL_RUNTIME_PY`), not on a bare
6220                // `Some(...)` / `None()` class (the latter is undefined, and
6221                // `case None():` is a Python `SyntaxError`). `_BockSome` exposes
6222                // `__match_args__ = ('_0',)` so the payload binds positionally.
6223                let leaf = path.segments.last().map_or("", |s| s.name.as_str());
6224                match leaf {
6225                    "Some" => {
6226                        if let Some(f) = fields.first() {
6227                            // Recurse so a *nested* payload pattern (`Some(Ok(v))`)
6228                            // keeps its inner bindings, instead of flattening to a
6229                            // bare name / `_` (which dropped `v`).
6230                            let sub = self.pattern_to_py(f)?;
6231                            let _ = write!(self.buf, "_BockSome({sub})");
6232                        } else {
6233                            self.buf.push_str("_BockSome(_)");
6234                        }
6235                        return Ok(());
6236                    }
6237                    "None" => {
6238                        self.buf.push_str("_BockNone()");
6239                        return Ok(());
6240                    }
6241                    // Result `Ok`/`Err` patterns dispatch on the Result runtime
6242                    // classes (see `RESULT_RUNTIME_PY`), mirroring `Some`/`None`.
6243                    // Both carry a single payload bound positionally via
6244                    // `__match_args__ = ('_0',)`.
6245                    "Ok" | "Err" => {
6246                        let cls = if leaf == "Ok" { "_BockOk" } else { "_BockErr" };
6247                        if let Some(f) = fields.first() {
6248                            let sub = self.pattern_to_py(f)?;
6249                            let _ = write!(self.buf, "{cls}({sub})");
6250                        } else {
6251                            let _ = write!(self.buf, "{cls}(_)");
6252                        }
6253                        return Ok(());
6254                    }
6255                    _ => {}
6256                }
6257                // Prelude `Ordering` variant pattern → its Ordering-runtime class
6258                // (`case _BockOrderingLess():`), matching the singleton the
6259                // construction/bridge side produces.
6260                if let Some(variant) = crate::generator::ordering_variant(leaf) {
6261                    let _ = write!(self.buf, "{}()", ordering_class_py(variant));
6262                    return Ok(());
6263                }
6264                let variant_name = if let Some(info) = self.user_variant_for_path(path) {
6265                    let variant = path.segments.last().map_or("", |s| s.name.as_str());
6266                    format!("{}_{variant}", info.enum_name)
6267                } else {
6268                    path.segments
6269                        .iter()
6270                        .map(|s| s.name.as_str())
6271                        .collect::<Vec<_>>()
6272                        .join("_")
6273                };
6274                if fields.is_empty() {
6275                    let _ = write!(self.buf, "{variant_name}()");
6276                } else {
6277                    let mut field_pats: Vec<String> = Vec::with_capacity(fields.len());
6278                    for (i, f) in fields.iter().enumerate() {
6279                        // Recurse so a nested sub-pattern keeps its inner bindings.
6280                        let sub = self.pattern_to_py(f)?;
6281                        field_pats.push(format!("_{i}={sub}"));
6282                    }
6283                    let _ = write!(self.buf, "{variant_name}({})", field_pats.join(", "));
6284                }
6285            }
6286            NodeKind::RecordPat { path, fields, .. } => {
6287                let type_name = if let Some(info) = self.user_variant_for_path(path) {
6288                    let variant = path.segments.last().map_or("", |s| s.name.as_str());
6289                    format!("{}_{variant}", info.enum_name)
6290                } else {
6291                    path.segments
6292                        .iter()
6293                        .map(|s| s.name.as_str())
6294                        .collect::<Vec<_>>()
6295                        .join("_")
6296                };
6297                let mut field_pats: Vec<String> = Vec::with_capacity(fields.len());
6298                for f in fields {
6299                    // `py_field_ident`: a keyword-named field (`pass`)
6300                    // destructures through its escaped spelling
6301                    // (`case Tally(pass_=p)`), matching the escaped dataclass
6302                    // field declaration.
6303                    let field_name = py_field_ident(&f.name.name);
6304                    if let Some(pat) = &f.pattern {
6305                        // Recurse so a nested record/constructor/tuple sub-pattern
6306                        // keeps its inner bindings.
6307                        let sub = self.pattern_to_py(pat)?;
6308                        field_pats.push(format!("{field_name}={sub}"));
6309                    } else {
6310                        // Shorthand `{ radius }` ≡ `{ radius: radius }`. Emit the
6311                        // keyword form `radius=radius` so the bind is by field
6312                        // name, not by `__match_args__` position (a dataclass's
6313                        // positional order is field-decl order *plus* the trailing
6314                        // `_tag`, so a bare positional sub-pattern would mis-bind
6315                        // multi-field variants). For a keyword-named field both
6316                        // sides escape identically (`pass_=pass_`): the bound
6317                        // value identifier's references go through
6318                        // `py_value_ident`, which produces the same spelling.
6319                        field_pats.push(format!("{field_name}={field_name}"));
6320                    }
6321                }
6322                let _ = write!(self.buf, "{type_name}({})", field_pats.join(", "));
6323            }
6324            NodeKind::TuplePat { elems } => {
6325                self.buf.push('(');
6326                for (i, e) in elems.iter().enumerate() {
6327                    if i > 0 {
6328                        self.buf.push_str(", ");
6329                    }
6330                    self.emit_pattern(e)?;
6331                }
6332                if elems.len() == 1 {
6333                    self.buf.push(',');
6334                }
6335                self.buf.push(')');
6336            }
6337            // Python `match`/`case` supports native or-patterns: `case 1 | 2 | 3:`.
6338            // Without this, an `OrPat` fell through to the `_` catch-all, so
6339            // `1 | 2 | 3 => …` collapsed to `case _:` — shadowing every later arm
6340            // ("wildcard makes remaining patterns unreachable").
6341            NodeKind::OrPat { alternatives } => {
6342                for (i, alt) in alternatives.iter().enumerate() {
6343                    if i > 0 {
6344                        self.buf.push_str(" | ");
6345                    }
6346                    self.emit_pattern(alt)?;
6347                }
6348            }
6349            // Python `match`/`case` sequence patterns: `case []:`,
6350            // `case [only]:`, `case [first, *rest]:`. Without this, every list
6351            // pattern fell through to the `_` catch-all below, so `[]` and
6352            // `[first, ..rest]` both became `case _:` — the first shadowing the
6353            // rest ("wildcard makes remaining patterns unreachable"). The `*rest`
6354            // star-capture mirrors Bock's `..rest`; a `..` with no binding maps to
6355            // an anonymous `*_`.
6356            NodeKind::ListPat { elems, rest } => {
6357                self.buf.push('[');
6358                for (i, e) in elems.iter().enumerate() {
6359                    if i > 0 {
6360                        self.buf.push_str(", ");
6361                    }
6362                    self.emit_pattern(e)?;
6363                }
6364                if let Some(r) = rest {
6365                    if !elems.is_empty() {
6366                        self.buf.push_str(", ");
6367                    }
6368                    match &r.kind {
6369                        NodeKind::BindPat { name, .. } => {
6370                            let _ = write!(self.buf, "*{}", py_value_ident(&name.name));
6371                        }
6372                        // `..` rest with no binding (or a wildcard) captures and
6373                        // discards the tail.
6374                        _ => self.buf.push_str("*_"),
6375                    }
6376                }
6377                self.buf.push(']');
6378            }
6379            _ => {
6380                self.buf.push('_');
6381            }
6382        }
6383        Ok(())
6384    }
6385
6386    /// Emit a `match` *expression* (each arm yields a value, no statement arm)
6387    /// as a nested Python conditional over the scrutinee, which the caller has
6388    /// bound to `__v` via the enclosing `(lambda __v: …)(<scrutinee>)`.
6389    ///
6390    /// Each non-final arm becomes `<body> if <test on __v> else (<rest>)`. The
6391    /// arm body may reference a pattern binding (the `x` in `Some(x)`, or a
6392    /// whole-scrutinee bind pattern); since a Python conditional can't introduce
6393    /// a binding, the body is wrapped in an immediately-applied
6394    /// `(lambda <bind>: <body>)(<value>)` so the name resolves. Patterns:
6395    ///
6396    /// - `Some(x)` → test `isinstance(__v, _BockSome)`, bind `x = __v._0`
6397    /// - `None`    → test `isinstance(__v, _BockNone)`
6398    /// - literal   → test `__v == <lit>`
6399    /// - wildcard / bind / final arm → the `else` (catch-all); a bind pattern
6400    ///   binds the whole scrutinee
6401    ///
6402    /// This replaces an earlier stub that emitted a hardcoded `… if False else …`
6403    /// chain (which always selected the *last* arm and never bound the payload),
6404    /// mis-compiling every expression-position `match` whose arms were not all
6405    /// `return`s — e.g. `let r = match o { Some(x) => x + 1; None => 0 }`.
6406    fn emit_match_expr(
6407        &mut self,
6408        _scrutinee: &AIRNode,
6409        arms: &[AIRNode],
6410    ) -> Result<(), CodegenError> {
6411        self.emit_match_expr_from(arms, 0)
6412    }
6413
6414    /// Tail of [`Self::emit_match_expr`]: emit the conditional for `arms[idx..]`.
6415    fn emit_match_expr_from(&mut self, arms: &[AIRNode], idx: usize) -> Result<(), CodegenError> {
6416        let Some(NodeKind::MatchArm { pattern, body, .. }) = arms.get(idx).map(|a| &a.kind) else {
6417            // No arm at this index: Bock matches are exhaustive, so this is
6418            // unreachable, but emit a defined value to keep the expression valid.
6419            self.buf.push_str("None");
6420            return Ok(());
6421        };
6422        let is_last = idx + 1 >= arms.len();
6423        let is_catch_all = matches!(
6424            pattern.kind,
6425            NodeKind::WildcardPat | NodeKind::BindPat { .. }
6426        );
6427        // The final arm, or any catch-all, is the unconditional `else` value.
6428        if is_last || is_catch_all {
6429            return self.emit_arm_value(pattern, body, /*whole_scrutinee_bind=*/ true);
6430        }
6431        // Otherwise: `<value> if <test> else (<rest>)`.
6432        self.emit_arm_value(pattern, body, /*whole_scrutinee_bind=*/ false)?;
6433        self.buf.push_str(" if ");
6434        self.emit_match_expr_test(pattern)?;
6435        self.buf.push_str(" else (");
6436        self.emit_match_expr_from(arms, idx + 1)?;
6437        self.buf.push(')');
6438        Ok(())
6439    }
6440
6441    /// Emit the boolean test (over the bound `__v`) that selects `pattern`.
6442    fn emit_match_expr_test(&mut self, pattern: &AIRNode) -> Result<(), CodegenError> {
6443        match &pattern.kind {
6444            NodeKind::ConstructorPat { path, .. } => {
6445                let leaf = path.segments.last().map_or("", |s| s.name.as_str());
6446                let cls: String = match leaf {
6447                    "Some" => "_BockSome".to_string(),
6448                    "None" => "_BockNone".to_string(),
6449                    // Result `Ok`/`Err` test against the Result-runtime classes,
6450                    // mirroring `Some`/`None`.
6451                    "Ok" => "_BockOk".to_string(),
6452                    "Err" => "_BockErr".to_string(),
6453                    other => {
6454                        // Prelude `Ordering` variants test against the
6455                        // Ordering-runtime class so an expression-position match
6456                        // (`lambda __v: isinstance(__v, _BockOrderingLess) …`)
6457                        // recognises the singleton the bridge/construction
6458                        // produces.
6459                        if let Some(v) = crate::generator::ordering_variant(other) {
6460                            ordering_class_py(v).to_string()
6461                        } else if let Some(info) = self.user_variant_for_path(path) {
6462                            // A user-enum variant tests against its dataclass
6463                            // `{enum}_{variant}` — the same class the statement
6464                            // `emit_pattern` and the construction side produce.
6465                            // Without this the test used the bare variant leaf
6466                            // (`isinstance(__v, Red)`), an undefined name.
6467                            format!("{}_{other}", info.enum_name)
6468                        } else {
6469                            other.to_string()
6470                        }
6471                    }
6472                };
6473                let _ = write!(self.buf, "isinstance(__v, {cls})");
6474            }
6475            NodeKind::LiteralPat { .. } => {
6476                self.buf.push_str("__v == ");
6477                self.emit_pattern(pattern)?;
6478            }
6479            NodeKind::ListPat { elems, rest } => {
6480                // `[a, b]` requires a list of exactly len(elems); `[a, ..rest]`
6481                // requires at least len(elems). Element literal sub-patterns add
6482                // positional `__v[i] == <lit>` tests; bind/wildcard elements add
6483                // none. Mirrors the js/ts/go list test.
6484                let n = elems.len();
6485                let len_test = if rest.is_some() {
6486                    format!("isinstance(__v, list) and len(__v) >= {n}")
6487                } else {
6488                    format!("isinstance(__v, list) and len(__v) == {n}")
6489                };
6490                self.buf.push_str(&len_test);
6491                for (i, e) in elems.iter().enumerate() {
6492                    if let NodeKind::LiteralPat { .. } = &e.kind {
6493                        self.buf.push_str(&format!(" and __v[{i}] == "));
6494                        self.emit_pattern(e)?;
6495                    }
6496                }
6497            }
6498            NodeKind::RangePat { lo, hi, inclusive } => {
6499                // `lo..hi` → `lo <= __v < hi`; `lo..=hi` → `lo <= __v <= hi`.
6500                let lo_s = range_bound_to_py(lo);
6501                let hi_s = range_bound_to_py(hi);
6502                let upper = if *inclusive { "<=" } else { "<" };
6503                let _ = write!(self.buf, "{lo_s} <= __v {upper} {hi_s}");
6504            }
6505            // Catch-alls never produce a test (handled as the `else`).
6506            _ => self.buf.push_str("True"),
6507        }
6508        Ok(())
6509    }
6510
6511    /// Emit one arm's value, binding any pattern variable via an applied lambda
6512    /// so it resolves inside the conditional. `whole_scrutinee_bind` allows a
6513    /// bind pattern in `else` position to capture the whole scrutinee (`__v`).
6514    fn emit_arm_value(
6515        &mut self,
6516        pattern: &AIRNode,
6517        body: &AIRNode,
6518        whole_scrutinee_bind: bool,
6519    ) -> Result<(), CodegenError> {
6520        match &pattern.kind {
6521            // `Some(x)` / `Ok(x)` / `Err(e)` bind the payload `__v._0`.
6522            NodeKind::ConstructorPat { path, fields }
6523                if path
6524                    .segments
6525                    .last()
6526                    .is_some_and(|s| matches!(s.name.as_str(), "Some" | "Ok" | "Err")) =>
6527            {
6528                if let Some(f) = fields.first() {
6529                    let name = self.pattern_to_binding_name(f);
6530                    if name != "_" {
6531                        let _ = write!(self.buf, "(lambda {name}: ");
6532                        self.emit_block_as_expr(body)?;
6533                        self.buf.push_str(")(__v._0)");
6534                        return Ok(());
6535                    }
6536                }
6537                self.emit_block_as_expr(body)
6538            }
6539            // A bind pattern (`x => …`) captures the whole scrutinee.
6540            NodeKind::BindPat { name, .. } if whole_scrutinee_bind => {
6541                let bind = py_value_ident(&name.name);
6542                let _ = write!(self.buf, "(lambda {bind}: ");
6543                self.emit_block_as_expr(body)?;
6544                self.buf.push_str(")(__v)");
6545                Ok(())
6546            }
6547            // A list pattern binds its elements positionally (`__v[i]`) and a
6548            // `..rest` to the tail slice (`__v[n:]`) via an applied lambda, so the
6549            // names resolve inside the conditional. Wildcard/literal elements bind
6550            // nothing. Without this the `first`/`rest` in `[first, ..rest] => …`
6551            // were undefined (a `NameError` at runtime).
6552            NodeKind::ListPat { elems, rest } => {
6553                let mut params: Vec<String> = Vec::new();
6554                let mut argvals: Vec<String> = Vec::new();
6555                for (i, e) in elems.iter().enumerate() {
6556                    if let NodeKind::BindPat { name, .. } = &e.kind {
6557                        params.push(py_value_ident(&name.name));
6558                        argvals.push(format!("__v[{i}]"));
6559                    }
6560                }
6561                if let Some(r) = rest {
6562                    if let NodeKind::BindPat { name, .. } = &r.kind {
6563                        params.push(py_value_ident(&name.name));
6564                        argvals.push(format!("__v[{}:]", elems.len()));
6565                    }
6566                }
6567                if params.is_empty() {
6568                    return self.emit_block_as_expr(body);
6569                }
6570                let _ = write!(self.buf, "(lambda {}: ", params.join(", "));
6571                self.emit_block_as_expr(body)?;
6572                let _ = write!(self.buf, ")({})", argvals.join(", "));
6573                Ok(())
6574            }
6575            _ => self.emit_block_as_expr(body),
6576        }
6577    }
6578
6579    // ── Pipe operator ───────────────────────────────────────────────────────
6580
6581    /// Emit an expression in callee position, parenthesizing it when its
6582    /// surface syntax would otherwise swallow the trailing argument list.
6583    ///
6584    /// A bare Python `lambda` is the case that matters: `lambda x: body`
6585    /// followed by `(arg)` parses as `lambda x: (body(arg))` — the call binds
6586    /// to the body, never invoking the lambda. Wrapping it as `(lambda x:
6587    /// body)(arg)` makes the call apply to the lambda itself. This shows up
6588    /// whenever the AIR compose desugar (`f >> g` → `(__compose_x) =>
6589    /// g(f(__compose_x))`) nests: chained `>>` lowers the inner compose to a
6590    /// `Lambda`, which then appears as the callee `f` inside `f(__compose_x)`.
6591    fn emit_callee(&mut self, callee: &AIRNode) -> Result<(), CodegenError> {
6592        if matches!(callee.kind, NodeKind::Lambda { .. }) {
6593            self.buf.push('(');
6594            self.emit_expr(callee)?;
6595            self.buf.push(')');
6596            Ok(())
6597        } else {
6598            self.emit_expr(callee)
6599        }
6600    }
6601
6602    fn emit_pipe(&mut self, left: &AIRNode, right: &AIRNode) -> Result<(), CodegenError> {
6603        if let NodeKind::Call { callee, args, .. } = &right.kind {
6604            let has_placeholder = args
6605                .iter()
6606                .any(|a| matches!(a.value.kind, NodeKind::Placeholder));
6607            if has_placeholder {
6608                self.emit_callee(callee)?;
6609                self.buf.push('(');
6610                for (i, arg) in args.iter().enumerate() {
6611                    if i > 0 {
6612                        self.buf.push_str(", ");
6613                    }
6614                    if matches!(arg.value.kind, NodeKind::Placeholder) {
6615                        self.emit_expr(left)?;
6616                    } else {
6617                        self.emit_expr(&arg.value)?;
6618                    }
6619                }
6620                self.buf.push(')');
6621                return Ok(());
6622            }
6623        }
6624        self.emit_callee(right)?;
6625        self.buf.push('(');
6626        self.emit_expr(left)?;
6627        self.buf.push(')');
6628        Ok(())
6629    }
6630
6631    // ── Type emission ───────────────────────────────────────────────────────
6632
6633    fn type_to_py(&self, node: &AIRNode) -> String {
6634        match &node.kind {
6635            NodeKind::TypeNamed { path, args } => {
6636                let name = path
6637                    .segments
6638                    .iter()
6639                    .map(|s| s.name.as_str())
6640                    .collect::<Vec<_>>()
6641                    .join(".");
6642                // `Result[T, E]` lowers to the tagged Result-runtime classes, not
6643                // a subscripted generic — the value is `_BockOk(...)` /
6644                // `_BockErr(...)`, so the annotation is the union `_BockOk |
6645                // _BockErr` with no `[T, E]` (which would be a Python error on a
6646                // union). Mirrors the `TypeOptional` arm below.
6647                if name == "Result" {
6648                    return "_BockOk | _BockErr".to_string();
6649                }
6650                let py_name = self.map_type_name(&name);
6651                if args.is_empty() {
6652                    py_name
6653                } else {
6654                    let arg_strs: Vec<String> = args.iter().map(|a| self.type_to_py(a)).collect();
6655                    format!("{py_name}[{}]", arg_strs.join(", "))
6656                }
6657            }
6658            NodeKind::TypeTuple { elems } => {
6659                let elem_strs: Vec<String> = elems.iter().map(|e| self.type_to_py(e)).collect();
6660                format!("tuple[{}]", elem_strs.join(", "))
6661            }
6662            NodeKind::TypeFunction { params, ret, .. } => {
6663                self.needs_typing_callable.set(true);
6664                let param_strs: Vec<String> = params.iter().map(|p| self.type_to_py(p)).collect();
6665                format!(
6666                    "Callable[[{}], {}]",
6667                    param_strs.join(", "),
6668                    self.type_to_py(ret)
6669                )
6670            }
6671            NodeKind::TypeOptional { inner } => {
6672                // `T?` lowers to the tagged Optional runtime, not `T | None`:
6673                // the value is `_BockSome(...)` / `_bock_none`, so the annotation
6674                // must describe those classes for type and value to agree (mirrors
6675                // Go's `__bockOption` and TS's `BockOption<T>`). The element type
6676                // `T` is preserved as a comment for readability; Python does not
6677                // enforce annotations at runtime.
6678                let _ = inner;
6679                "_BockSome | _BockNone".to_string()
6680            }
6681            NodeKind::TypeSelf => {
6682                self.needs_typing_self.set(true);
6683                "Self".into()
6684            }
6685            _ => {
6686                self.needs_typing_any.set(true);
6687                "Any".into()
6688            }
6689        }
6690    }
6691
6692    fn map_type_name(&self, name: &str) -> String {
6693        match name {
6694            "Int" => "int".into(),
6695            "Float" => "float".into(),
6696            "Bool" => "bool".into(),
6697            "String" => "str".into(),
6698            "Void" | "Unit" => "None".into(),
6699            "List" => "list".into(),
6700            "Map" => "dict".into(),
6701            "Set" => "set".into(),
6702            "Any" => {
6703                self.needs_typing_any.set(true);
6704                "Any".into()
6705            }
6706            "Never" => {
6707                self.needs_typing_never.set(true);
6708                "Never".into()
6709            }
6710            other => other.into(),
6711        }
6712    }
6713
6714    fn ast_type_to_py(&self, ty: &TypeExpr) -> String {
6715        match ty {
6716            TypeExpr::Named { path, args, .. } => {
6717                let name = path
6718                    .segments
6719                    .iter()
6720                    .map(|s| s.name.as_str())
6721                    .collect::<Vec<_>>()
6722                    .join(".");
6723                // See the `Result` case in `type_to_py`: lowers to the tagged
6724                // Result-runtime union, no subscript.
6725                if name == "Result" {
6726                    return "_BockOk | _BockErr".to_string();
6727                }
6728                let py_name = self.map_type_name(&name);
6729                if args.is_empty() {
6730                    py_name
6731                } else {
6732                    let arg_strs: Vec<String> =
6733                        args.iter().map(|a| self.ast_type_to_py(a)).collect();
6734                    format!("{py_name}[{}]", arg_strs.join(", "))
6735                }
6736            }
6737            TypeExpr::Tuple { elems, .. } => {
6738                let elem_strs: Vec<String> = elems.iter().map(|e| self.ast_type_to_py(e)).collect();
6739                format!("tuple[{}]", elem_strs.join(", "))
6740            }
6741            TypeExpr::Function { params, ret, .. } => {
6742                self.needs_typing_callable.set(true);
6743                let param_strs: Vec<String> =
6744                    params.iter().map(|p| self.ast_type_to_py(p)).collect();
6745                format!(
6746                    "Callable[[{}], {}]",
6747                    param_strs.join(", "),
6748                    self.ast_type_to_py(ret)
6749                )
6750            }
6751            TypeExpr::Optional { inner, .. } => {
6752                // See the `TypeOptional` arm of `type_to_py`: the tagged Optional
6753                // runtime classes must match the emitted tagged value.
6754                let _ = inner;
6755                "_BockSome | _BockNone".to_string()
6756            }
6757            TypeExpr::SelfType { .. } => {
6758                self.needs_typing_self.set(true);
6759                "Self".into()
6760            }
6761        }
6762    }
6763
6764    // ── Helpers ─────────────────────────────────────────────────────────────
6765
6766    /// Scan a sequence of block statements and return the set of bound names
6767    /// that are later `await`ed as bare identifiers within the same block.
6768    /// The caller wraps those LetBindings' Call values in `asyncio.create_task`.
6769    ///
6770    /// Only direct `let name = call(...)` bindings qualify. Non-call RHS are
6771    /// skipped (not awaitable work we can parallelise). The binding must be
6772    /// awaited in the same flat block — nested scopes are ignored because we
6773    /// can't prove the binding is still live once control leaves the block.
6774    fn collect_task_bindings(stmts: &[AIRNode]) -> std::collections::HashSet<String> {
6775        let mut awaited: std::collections::HashSet<String> = std::collections::HashSet::new();
6776        for s in stmts {
6777            Self::collect_awaited_identifiers(s, &mut awaited);
6778        }
6779        let mut out = std::collections::HashSet::new();
6780        for s in stmts {
6781            if let NodeKind::LetBinding { pattern, value, .. } = &s.kind {
6782                if let NodeKind::BindPat { name, .. } = &pattern.kind {
6783                    let py_name = py_value_ident(&name.name);
6784                    if matches!(&value.kind, NodeKind::Call { .. }) && awaited.contains(&py_name) {
6785                        out.insert(py_name);
6786                    }
6787                }
6788            }
6789        }
6790        out
6791    }
6792
6793    /// Walk an AIR subtree and record every `await name` where `name` is a
6794    /// bare identifier. Nested function / lambda bodies are not descended —
6795    /// an inner closure awaiting the name doesn't imply the outer block
6796    /// wants a task.
6797    fn collect_awaited_identifiers(node: &AIRNode, out: &mut std::collections::HashSet<String>) {
6798        match &node.kind {
6799            NodeKind::Await { expr } => {
6800                if let NodeKind::Identifier { name } = &expr.kind {
6801                    out.insert(py_value_ident(&name.name));
6802                }
6803                Self::collect_awaited_identifiers(expr, out);
6804            }
6805            NodeKind::Lambda { .. } | NodeKind::FnDecl { .. } => {
6806                // Don't cross function boundaries.
6807            }
6808            NodeKind::Block { stmts, tail } => {
6809                for s in stmts {
6810                    Self::collect_awaited_identifiers(s, out);
6811                }
6812                if let Some(t) = tail {
6813                    Self::collect_awaited_identifiers(t, out);
6814                }
6815            }
6816            NodeKind::LetBinding { value, .. } => {
6817                Self::collect_awaited_identifiers(value, out);
6818            }
6819            NodeKind::Call { callee, args, .. } => {
6820                Self::collect_awaited_identifiers(callee, out);
6821                for a in args {
6822                    Self::collect_awaited_identifiers(&a.value, out);
6823                }
6824            }
6825            NodeKind::MethodCall { receiver, args, .. } => {
6826                Self::collect_awaited_identifiers(receiver, out);
6827                for a in args {
6828                    Self::collect_awaited_identifiers(&a.value, out);
6829                }
6830            }
6831            NodeKind::BinaryOp { left, right, .. } => {
6832                Self::collect_awaited_identifiers(left, out);
6833                Self::collect_awaited_identifiers(right, out);
6834            }
6835            NodeKind::UnaryOp { operand, .. } => {
6836                Self::collect_awaited_identifiers(operand, out);
6837            }
6838            NodeKind::If {
6839                condition,
6840                then_block,
6841                else_block,
6842                ..
6843            } => {
6844                Self::collect_awaited_identifiers(condition, out);
6845                Self::collect_awaited_identifiers(then_block, out);
6846                if let Some(e) = else_block {
6847                    Self::collect_awaited_identifiers(e, out);
6848                }
6849            }
6850            NodeKind::While { condition, body } => {
6851                Self::collect_awaited_identifiers(condition, out);
6852                Self::collect_awaited_identifiers(body, out);
6853            }
6854            NodeKind::For { iterable, body, .. } => {
6855                Self::collect_awaited_identifiers(iterable, out);
6856                Self::collect_awaited_identifiers(body, out);
6857            }
6858            NodeKind::Return { value: Some(v) } | NodeKind::Break { value: Some(v) } => {
6859                Self::collect_awaited_identifiers(v, out);
6860            }
6861            NodeKind::Assign { value, .. } => {
6862                Self::collect_awaited_identifiers(value, out);
6863            }
6864            NodeKind::TupleLiteral { elems } | NodeKind::ListLiteral { elems } => {
6865                for e in elems {
6866                    Self::collect_awaited_identifiers(e, out);
6867                }
6868            }
6869            _ => {}
6870        }
6871    }
6872
6873    /// Push a fresh lexical-block frame for nested-block `let`-shadow renaming,
6874    /// seeded with any names queued in [`Self::pending_scope_seed`] (a function's
6875    /// parameters, so they share the body frame). The seed is drained.
6876    fn enter_shadow_scope(&mut self) {
6877        let mut frame = ShadowScope::default();
6878        for n in self.pending_scope_seed.drain(..) {
6879            frame.bound.insert(n);
6880        }
6881        self.shadow_scopes.push(frame);
6882    }
6883
6884    /// Pop the innermost lexical-block frame pushed by [`Self::enter_shadow_scope`].
6885    fn leave_shadow_scope(&mut self) {
6886        self.shadow_scopes.pop();
6887    }
6888
6889    /// Whether `py_name` is bound in any *enclosing* frame (every frame except
6890    /// the innermost), i.e. binding it again in the current block shadows an
6891    /// outer binding and — on Python's function-scoped `=` — would stomp it.
6892    fn shadowed_in_outer_scope(&self, py_name: &str) -> bool {
6893        let n = self.shadow_scopes.len();
6894        if n < 2 {
6895            return false;
6896        }
6897        self.shadow_scopes[..n - 1]
6898            .iter()
6899            .any(|s| s.bound.contains(py_name) || s.renames.contains_key(py_name))
6900    }
6901
6902    /// Resolve a Python identifier through the shadow-scope stack: the innermost
6903    /// frame that renamed `py_name` wins, else the name is unchanged. Used at
6904    /// every identifier *use* site so a reference inside a shadowing block reads
6905    /// the alias while code outside reads the original.
6906    fn resolve_shadow_name(&self, py_name: &str) -> String {
6907        for s in self.shadow_scopes.iter().rev() {
6908            if let Some(alias) = s.renames.get(py_name) {
6909                return alias.clone();
6910            }
6911            // A frame that *binds* the name directly (without a rename) stops the
6912            // search: an inner block's same-name binding is the live one there.
6913            if s.bound.contains(py_name) {
6914                return py_name.to_string();
6915            }
6916        }
6917        py_name.to_string()
6918    }
6919
6920    /// Plan a `let`-binding's LHS Python name without yet activating it in the
6921    /// scope frame. A `let`'s RHS reads the *prior* binding (`let y = y + 10`
6922    /// reads the outer `y`), so the rename must take effect only *after* the RHS
6923    /// is emitted — [`Self::commit_shadow_let`] does that. Returns the LHS name to
6924    /// emit (a fresh alias when the binding shadows an enclosing frame, else the
6925    /// name unchanged) paired with the rename to commit (`Some((orig, alias))`
6926    /// only for a fresh shadow; `None` for a same-block rebind or first binding).
6927    fn plan_shadow_let(&mut self, py_name: &str) -> (String, Option<(String, String)>) {
6928        // No active frame (defensive) → emit verbatim, nothing to commit.
6929        if self.shadow_scopes.is_empty() {
6930            return (py_name.to_string(), None);
6931        }
6932        // Same-block re-bind: an existing alias or direct binding here is reused
6933        // (a plain Python rebind), with nothing new to commit.
6934        if let Some(cur) = self.shadow_scopes.last() {
6935            if let Some(alias) = cur.renames.get(py_name) {
6936                return (alias.clone(), None);
6937            }
6938            if cur.bound.contains(py_name) {
6939                return (py_name.to_string(), None);
6940            }
6941        }
6942        if self.shadowed_in_outer_scope(py_name) {
6943            self.shadow_counter += 1;
6944            let alias = format!("{py_name}__s{}", self.shadow_counter);
6945            return (alias.clone(), Some((py_name.to_string(), alias)));
6946        }
6947        // A `let` whose name collides with a Python builtin the codegen *itself*
6948        // emits as a call (`list(map.keys())`, `set(...)`, `map(...)`) must be
6949        // renamed even when it shadows nothing — binding `list = [...]` rebinds
6950        // the global `list` for the rest of the function scope, so a later
6951        // codegen-emitted `list(...)` would raise `TypeError: 'list' object is
6952        // not callable`. The rename flows to references via the same
6953        // `renames` map `resolve_shadow_name` consults, so use sites stay in sync.
6954        if is_shadow_sensitive_py_builtin(py_name) {
6955            self.shadow_counter += 1;
6956            let alias = format!("{py_name}__b{}", self.shadow_counter);
6957            return (alias.clone(), Some((py_name.to_string(), alias)));
6958        }
6959        (py_name.to_string(), None)
6960    }
6961
6962    /// Activate a planned `let` binding in the current frame, after its RHS has
6963    /// been emitted. `pending` is the rename returned by [`Self::plan_shadow_let`]
6964    /// (`Some` only for a fresh shadow); `py_name` is the original name (used to
6965    /// record a non-shadowing first binding so later same-block rebinds and
6966    /// nested shadows resolve correctly).
6967    fn commit_shadow_let(&mut self, py_name: &str, pending: Option<(String, String)>) {
6968        let Some(cur) = self.shadow_scopes.last_mut() else {
6969            return;
6970        };
6971        if let Some((orig, alias)) = pending {
6972            cur.renames.insert(orig, alias.clone());
6973            cur.bound.insert(alias);
6974        } else {
6975            cur.bound.insert(py_name.to_string());
6976        }
6977    }
6978
6979    /// Emit a **function/method body**, wrapping it in the `?`-propagate
6980    /// envelope when the body contains a `?` operator. `expr?` lowers to
6981    /// `_bock_try(expr)`, which raises `_BockPropagate` on an `Err`/`None`; the
6982    /// envelope catches that and re-returns the carried value, giving Rust-`?`
6983    /// early-return semantics:
6984    ///
6985    /// ```python
6986    /// try:
6987    ///     <body>
6988    /// except _BockPropagate as __bock_p:
6989    ///     return __bock_p.value
6990    /// ```
6991    ///
6992    /// A body with no `?` is emitted unchanged (no envelope, no runtime cost).
6993    fn emit_fn_body(&mut self, body: &AIRNode) -> Result<(), CodegenError> {
6994        if body_contains_propagate(body) {
6995            self.writeln("try:");
6996            self.indent += 1;
6997            self.emit_block_body(body)?;
6998            self.indent -= 1;
6999            self.writeln("except _BockPropagate as __bock_p:");
7000            self.indent += 1;
7001            self.writeln("return __bock_p.value");
7002            self.indent -= 1;
7003            Ok(())
7004        } else {
7005            self.emit_block_body(body)
7006        }
7007    }
7008
7009    /// Emit a block (or bare-body) in statement/`return` position, opening a
7010    /// fresh shadow-scope frame so a nested-block `let` that shadows an enclosing
7011    /// binding is renamed rather than stomping it (see [`ShadowScope`]).
7012    fn emit_block_body(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
7013        self.enter_shadow_scope();
7014        let r = self.emit_block_body_inner(node);
7015        self.leave_shadow_scope();
7016        r
7017    }
7018
7019    /// Emit the **body of a `for`/`while`/`loop`** — statement position, so its
7020    /// tail expression is *discarded* (a Bock loop evaluates to Unit). Sets
7021    /// [`Self::in_loop_body_tail`] for the body's duration so
7022    /// [`Self::emit_block_body_inner`] emits the tail as a bare expression
7023    /// statement (`<value>`) instead of a function-body `return <value>` — a
7024    /// `return` inside a loop aborts the enclosing function after one iteration
7025    /// (the fizzbuzz / inventory-system one-line truncation). The flag is
7026    /// saved/restored so it never leaks past the loop, and any nested value
7027    /// context (a value-binding hoist, a value-`if`/`match` arm) clears it — see
7028    /// [`Self::emit_block_body_inner`]. A `break v` value flows through the
7029    /// separate `loop_value_targets` stack, not this flag.
7030    fn emit_loop_body(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
7031        let prev = std::mem::replace(&mut self.in_loop_body_tail, true);
7032        let r = self.emit_block_body(node);
7033        self.in_loop_body_tail = prev;
7034        r
7035    }
7036
7037    fn emit_block_body_inner(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
7038        if let NodeKind::Block { stmts, tail } = &node.kind {
7039            if stmts.is_empty() && tail.is_none() {
7040                self.writeln("pass");
7041                return Ok(());
7042            }
7043            // Concurrent-pattern detection: names bound in this block whose
7044            // Call RHS should be scheduled as tasks because the same name is
7045            // later `await`ed in the same block. Python coroutines don't run
7046            // until awaited, so without this, independent async calls
7047            // serialise — wrapping with `asyncio.create_task` preserves the
7048            // author's concurrent intent (JS/TS get this for free because
7049            // Promises are eager).
7050            let task_bindings = Self::collect_task_bindings(stmts);
7051            let prev = std::mem::replace(&mut self.task_bound_names, task_bindings);
7052            for s in stmts {
7053                self.emit_node(s)?;
7054            }
7055            self.task_bound_names = prev;
7056            if let Some(t) = tail {
7057                // A statement tail (`break`/`continue`/`return`/assignment) is
7058                // emitted as a statement, never wrapped in `return`.
7059                if crate::generator::node_is_statement(t) {
7060                    self.emit_node(t)?;
7061                    return Ok(());
7062                }
7063                // A bare `loop`/`while`/`for` in tail position yields no value (it
7064                // exits only via `break`/`return`, and a value-`loop` is rewritten
7065                // by `emit_value_binding`, not here). Emit it as a Python loop
7066                // statement — never `return <loop>`, which `emit_expr` would lower
7067                // to the `# unsupported` fallthrough, silently discarding the whole
7068                // loop body (the guessing-game `play()` tail-`loop` shape).
7069                if matches!(
7070                    t.kind,
7071                    NodeKind::Loop { .. } | NodeKind::While { .. } | NodeKind::For { .. }
7072                ) {
7073                    self.emit_node(t)?;
7074                    return Ok(());
7075                }
7076                // A diverging `raise` expression (`todo()` / `unreachable()`)
7077                // yields no value: emit it bare, never `return raise …` (a
7078                // `SyntaxError`).
7079                if is_raise_expr(t) {
7080                    self.write_indent();
7081                    self.emit_expr(t)?;
7082                    self.buf.push('\n');
7083                    return Ok(());
7084                }
7085                // A value-`if`/`match` with a diverging `raise` branch can't be a
7086                // ternary (`return raise … if … else …`). Hoist it to a
7087                // statement-form `if`/`match` whose branches `return`/`raise`.
7088                if control_flow_has_raise_branch(t) {
7089                    return self.emit_tail_control_flow(t);
7090                }
7091                // A `match` with statement arms yields no value: emit a Python
7092                // `match`/`case` statement, not a `return (lambda ...)`. A value
7093                // match whose arms need the structural if-chain (guards,
7094                // or/tuple/record/range/list patterns, or a nested constructor)
7095                // is *also* routed here: the `(lambda __v: …)` conditional chain
7096                // cannot test or bind those — it dropped guards, tested record /
7097                // tuple / or arms as `if True`, and emitted the arm body with the
7098                // pattern binding free (`(lambda __v: f"x={x}")(p)` → `NameError`).
7099                // The statement-form `emit_pattern` binds and tests every pattern
7100                // kind correctly (each expression arm body becomes `return <v>`).
7101                if let NodeKind::Match { scrutinee, arms } = &t.kind {
7102                    if crate::generator::match_has_statement_arm(arms)
7103                        || match_value_needs_stmt_form(arms)
7104                    {
7105                        self.emit_match(scrutinee, arms)?;
7106                        return Ok(());
7107                    }
7108                }
7109                // A value-position `if` whose branch block carries statements (a
7110                // `let` binding) can't be a ternary: the ternary emits only each
7111                // branch's tail, dropping the `let` (a later reference then
7112                // `NameError`s — the microservice `handle_delete_user` case). Emit
7113                // it as statement-form `if`/`elif`/`else`, each branch recursing
7114                // through `emit_block_body` so the binding is kept and the tail
7115                // `return`ed.
7116                if if_value_needs_stmt_form(t) {
7117                    return self.emit_tail_control_flow(t);
7118                }
7119                // Plain value-expression tail. In a loop body this is statement
7120                // position — the value is discarded — so emit a bare expression
7121                // statement, not `return <value>` (a `return` in a loop aborts
7122                // the function after one iteration: the fizzbuzz / inventory
7123                // truncation). Elsewhere this is the function-body tail: `return`.
7124                self.emit_tail_value_or_discard(t)?;
7125            }
7126        } else if crate::generator::node_is_statement(node) {
7127            // A bare statement body (`break`/`continue`/`return`/assignment).
7128            self.emit_node(node)?;
7129        } else if matches!(
7130            node.kind,
7131            NodeKind::Loop { .. } | NodeKind::While { .. } | NodeKind::For { .. }
7132        ) {
7133            // A bare `loop`/`while`/`for` body yields no value — emit the loop
7134            // statement, never `return <loop>` (see the tail-position note above).
7135            self.emit_node(node)?;
7136        } else if is_raise_expr(node) {
7137            self.write_indent();
7138            self.emit_expr(node)?;
7139            self.buf.push('\n');
7140        } else if control_flow_has_raise_branch(node) {
7141            return self.emit_tail_control_flow(node);
7142        } else if let NodeKind::Match { scrutinee, arms } = &node.kind {
7143            // See the tail-position note above: a value match needing the
7144            // structural if-chain (guards, or/tuple/record/range/list, nested
7145            // constructor) is lowered to statement-form `match`/`case` so every
7146            // pattern binds and tests correctly, instead of a `(lambda __v: …)`
7147            // chain that drops guards and leaves pattern bindings free.
7148            if crate::generator::match_has_statement_arm(arms) || match_value_needs_stmt_form(arms)
7149            {
7150                self.emit_match(scrutinee, arms)?;
7151            } else {
7152                self.emit_tail_value_or_discard(node)?;
7153            }
7154        } else if if_value_needs_stmt_form(node) {
7155            // See the tail-position note above: a value `if` whose branch block
7156            // carries a `let` can't be a ternary (the binding would be dropped) —
7157            // emit statement-form `if`/`elif`/`else`.
7158            return self.emit_tail_control_flow(node);
7159        } else {
7160            // Single expression as body.
7161            self.emit_tail_value_or_discard(node)?;
7162        }
7163        Ok(())
7164    }
7165
7166    /// Emit a block/body **tail value expression** in the correct position.
7167    ///
7168    /// In a function/method body (the default) the tail is the body's value, so
7169    /// it is `return <value>`. Inside the body of a `for`/`while`/`loop`
7170    /// ([`Self::in_loop_body_tail`] set by [`Self::emit_loop_body`]) the tail is
7171    /// *statement* position — a Bock loop evaluates to Unit, so the value is
7172    /// discarded — and a `return` would abort the enclosing function after the
7173    /// first iteration (the fizzbuzz one-line / inventory single-product
7174    /// truncation). There it is emitted as a bare expression statement. The
7175    /// arm/branch body of a statement-position `match` or `if`/`else`
7176    /// ([`Self::in_stmt_construct_arm`], set by [`Self::emit_stmt`]'s `Match`
7177    /// and `If` arms) is discarded for the same reason — a mid-block
7178    /// `match`/`if` is a Unit statement, and a `return` there aborts the
7179    /// enclosing function after the matched arm / taken branch (the
7180    /// chat-protocol truncation and its if/else sibling,
7181    /// Q-python-ifelse-truncation).
7182    fn emit_tail_value_or_discard(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
7183        let ind = self.indent_str();
7184        if self.in_loop_body_tail || self.in_stmt_construct_arm {
7185            self.buf.push_str(&ind);
7186        } else {
7187            let _ = write!(self.buf, "{ind}return ");
7188        }
7189        self.emit_expr(node)?;
7190        self.buf.push('\n');
7191        Ok(())
7192    }
7193
7194    /// Emit a value-position `if`/`match` that carries a diverging `raise`
7195    /// branch (`todo()` / `unreachable()`) in **tail/return** position as
7196    /// statement-form Python: each branch/arm recurses through
7197    /// [`Self::emit_block_body`], so a non-diverging branch `return`s its value
7198    /// while the diverging branch `raise`s. This replaces the invalid ternary
7199    /// (`return raise … if … else …`).
7200    fn emit_tail_control_flow(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
7201        match &node.kind {
7202            NodeKind::If {
7203                condition,
7204                then_block,
7205                else_block,
7206                ..
7207            } => {
7208                let ind = self.indent_str();
7209                let _ = write!(self.buf, "{ind}if ");
7210                self.emit_expr(condition)?;
7211                self.buf.push_str(":\n");
7212                self.indent += 1;
7213                self.emit_block_body(then_block)?;
7214                self.indent -= 1;
7215                if let Some(eb) = else_block {
7216                    if matches!(eb.kind, NodeKind::If { .. }) {
7217                        let ind = self.indent_str();
7218                        let _ = write!(self.buf, "{ind}el");
7219                        // Chain `elif` by re-emitting the nested `if` inline.
7220                        return self.emit_tail_control_flow_inline(eb);
7221                    }
7222                    self.writeln("else:");
7223                    self.indent += 1;
7224                    self.emit_block_body(eb)?;
7225                    self.indent -= 1;
7226                }
7227                Ok(())
7228            }
7229            NodeKind::Match { scrutinee, arms } => self.emit_match(scrutinee, arms),
7230            // Not a control-flow node — fall back to a plain tail value (or a
7231            // bare statement inside a loop body; see `emit_tail_value_or_discard`).
7232            _ => self.emit_tail_value_or_discard(node),
7233        }
7234    }
7235
7236    /// `elif`-chaining tail for [`Self::emit_tail_control_flow`]: the caller has
7237    /// already written the `el` prefix, so emit `if <cond>: … (elif/else)`.
7238    fn emit_tail_control_flow_inline(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
7239        let NodeKind::If {
7240            condition,
7241            then_block,
7242            else_block,
7243            ..
7244        } = &node.kind
7245        else {
7246            return self.emit_tail_control_flow(node);
7247        };
7248        self.buf.push_str("if ");
7249        self.emit_expr(condition)?;
7250        self.buf.push_str(":\n");
7251        self.indent += 1;
7252        self.emit_block_body(then_block)?;
7253        self.indent -= 1;
7254        if let Some(eb) = else_block {
7255            if matches!(eb.kind, NodeKind::If { .. }) {
7256                let ind = self.indent_str();
7257                let _ = write!(self.buf, "{ind}el");
7258                return self.emit_tail_control_flow_inline(eb);
7259            }
7260            self.writeln("else:");
7261            self.indent += 1;
7262            self.emit_block_body(eb)?;
7263            self.indent -= 1;
7264        }
7265        Ok(())
7266    }
7267
7268    /// Emit a block (or bare body) in statement position, **assigning** its
7269    /// value to `target` instead of `return`ing it — the value-producing twin of
7270    /// [`Self::emit_block_body`]. Used by [`Self::emit_value_binding`] to hoist
7271    /// an expression-position control-flow construct into Python statements.
7272    ///
7273    /// A *diverging* body (one ending in `return`/`break`/`continue`, or a
7274    /// statement-only block) is emitted as-is with **no** assignment: control
7275    /// leaves the construct before the binding is read, exactly as in Bock where
7276    /// such an arm has type `Never` and unifies with the binding's type.
7277    fn emit_block_body_assigning(
7278        &mut self,
7279        target: &str,
7280        node: &AIRNode,
7281    ) -> Result<(), CodegenError> {
7282        // A value-binding RHS is a *value* context: its tail is assigned to
7283        // `target`, never discarded. Clear any active discard flag (a loop-body
7284        // tail set by an enclosing `emit_loop_body`, or a statement-`match`/`if`
7285        // arm set by `emit_stmt`) so it doesn't leak into this value position; a
7286        // nested loop/statement-construct inside the RHS re-sets the relevant
7287        // flag. Restored after.
7288        let prev_discard = std::mem::replace(&mut self.in_loop_body_tail, false);
7289        let prev_match = std::mem::replace(&mut self.in_stmt_construct_arm, false);
7290        self.enter_shadow_scope();
7291        let r = self.emit_block_body_assigning_inner(target, node);
7292        self.leave_shadow_scope();
7293        self.in_loop_body_tail = prev_discard;
7294        self.in_stmt_construct_arm = prev_match;
7295        r
7296    }
7297
7298    fn emit_block_body_assigning_inner(
7299        &mut self,
7300        target: &str,
7301        node: &AIRNode,
7302    ) -> Result<(), CodegenError> {
7303        if let NodeKind::Block { stmts, tail } = &node.kind {
7304            let task_bindings = Self::collect_task_bindings(stmts);
7305            let prev = std::mem::replace(&mut self.task_bound_names, task_bindings);
7306            for s in stmts {
7307                self.emit_node(s)?;
7308            }
7309            self.task_bound_names = prev;
7310            match tail {
7311                None => {
7312                    // No tail value. If the block had no statements either, keep
7313                    // the suite non-empty.
7314                    if stmts.is_empty() {
7315                        self.writeln("pass");
7316                    }
7317                }
7318                Some(t) if crate::generator::node_is_statement(t) => {
7319                    // Diverging / statement tail: emit as a statement (it leaves
7320                    // the construct; nothing is assigned).
7321                    self.emit_node(t)?;
7322                }
7323                Some(t) => self.emit_value_assign(target, t)?,
7324            }
7325        } else if crate::generator::node_is_statement(node) {
7326            self.emit_node(node)?;
7327        } else {
7328            self.emit_value_assign(target, node)?;
7329        }
7330        Ok(())
7331    }
7332
7333    /// Emit `<target> = <expr>` for a value expression, recursing through nested
7334    /// control-flow that itself needs statement form (so an arm whose value is
7335    /// another `match`/`loop`/`if`-statement assigns the same target).
7336    fn emit_value_assign(&mut self, target: &str, expr: &AIRNode) -> Result<(), CodegenError> {
7337        if value_needs_stmt_form(expr) {
7338            return self.emit_value_binding(target, expr);
7339        }
7340        // A diverging `raise` (`todo()`/`unreachable()`) cannot be assigned;
7341        // emit it bare (the assignment target is never reached).
7342        if is_raise_expr(expr) {
7343            self.write_indent();
7344            self.emit_expr(expr)?;
7345            self.buf.push('\n');
7346            return Ok(());
7347        }
7348        let ind = self.indent_str();
7349        let _ = write!(self.buf, "{ind}{target} = ");
7350        self.emit_expr(expr)?;
7351        self.buf.push('\n');
7352        Ok(())
7353    }
7354
7355    /// Lower an **expression-position control-flow** value (`match` with
7356    /// statement/diverging arms, a value-`loop`, or a statement-form `if`) bound
7357    /// to `target` into Python statements that assign `target`.
7358    ///
7359    /// Python has no statement-admitting expression form, so
7360    /// `let r = loop { … break v }` / `let l = match n { _ => { return … } }`
7361    /// cannot be emitted as a ternary/IIFE. Callers ([`Self::emit_stmt`]'s
7362    /// `LetBinding` arm) first declare `target = None` so it is always bound,
7363    /// then call this to fill it in via real `if`/`while`/`match` statements.
7364    fn emit_value_binding(&mut self, target: &str, value: &AIRNode) -> Result<(), CodegenError> {
7365        match &value.kind {
7366            NodeKind::Block { .. } => self.emit_block_body_assigning(target, value),
7367            NodeKind::Match { scrutinee, arms } => {
7368                self.emit_match_assigning(target, scrutinee, arms)
7369            }
7370            NodeKind::If {
7371                condition,
7372                then_block,
7373                else_block,
7374                ..
7375            } => {
7376                let ind = self.indent_str();
7377                let _ = write!(self.buf, "{ind}if ");
7378                self.emit_expr(condition)?;
7379                self.buf.push_str(":\n");
7380                self.indent += 1;
7381                self.emit_block_body_assigning(target, then_block)?;
7382                self.indent -= 1;
7383                if let Some(eb) = else_block {
7384                    if matches!(eb.kind, NodeKind::If { .. }) {
7385                        let ind = self.indent_str();
7386                        let _ = write!(self.buf, "{ind}el");
7387                        // Re-enter via `emit_value_binding` to chain `elif`.
7388                        self.emit_value_binding_if_chain(target, eb)?;
7389                    } else {
7390                        self.writeln("else:");
7391                        self.indent += 1;
7392                        self.emit_block_body_assigning(target, eb)?;
7393                        self.indent -= 1;
7394                    }
7395                }
7396                Ok(())
7397            }
7398            NodeKind::Loop { body } => {
7399                self.writeln("while True:");
7400                self.indent += 1;
7401                self.loop_value_targets.push(Some(target.to_string()));
7402                // The loop's value arrives via `break v` (recorded in
7403                // `loop_value_targets`), not the body's tail; the body is
7404                // statement position, so a tail expr is discarded.
7405                self.emit_loop_body(body)?;
7406                self.loop_value_targets.pop();
7407                self.indent -= 1;
7408                Ok(())
7409            }
7410            NodeKind::While { condition, body } => {
7411                let ind = self.indent_str();
7412                let _ = write!(self.buf, "{ind}while ");
7413                self.emit_expr(condition)?;
7414                self.buf.push_str(":\n");
7415                self.indent += 1;
7416                self.loop_value_targets.push(Some(target.to_string()));
7417                // The loop's value arrives via `break v` (recorded in
7418                // `loop_value_targets`), not the body's tail; the body is
7419                // statement position, so a tail expr is discarded.
7420                self.emit_loop_body(body)?;
7421                self.loop_value_targets.pop();
7422                self.indent -= 1;
7423                Ok(())
7424            }
7425            // Not a hoisted construct: plain assignment.
7426            _ => self.emit_value_assign(target, value),
7427        }
7428    }
7429
7430    /// Helper for [`Self::emit_value_binding`]'s `elif` chaining: emit the
7431    /// keyword tail of an `if` (`if <cond>: … elif …`) for an `else if`. The
7432    /// caller has already written the `el` prefix.
7433    fn emit_value_binding_if_chain(
7434        &mut self,
7435        target: &str,
7436        node: &AIRNode,
7437    ) -> Result<(), CodegenError> {
7438        let NodeKind::If {
7439            condition,
7440            then_block,
7441            else_block,
7442            ..
7443        } = &node.kind
7444        else {
7445            return self.emit_value_binding(target, node);
7446        };
7447        self.buf.push_str("if ");
7448        self.emit_expr(condition)?;
7449        self.buf.push_str(":\n");
7450        self.indent += 1;
7451        self.emit_block_body_assigning(target, then_block)?;
7452        self.indent -= 1;
7453        if let Some(eb) = else_block {
7454            if matches!(eb.kind, NodeKind::If { .. }) {
7455                let ind = self.indent_str();
7456                let _ = write!(self.buf, "{ind}el");
7457                self.emit_value_binding_if_chain(target, eb)?;
7458            } else {
7459                self.writeln("else:");
7460                self.indent += 1;
7461                self.emit_block_body_assigning(target, eb)?;
7462                self.indent -= 1;
7463            }
7464        }
7465        Ok(())
7466    }
7467
7468    /// Emit a `match` (statement form) whose arms **assign** `target` rather than
7469    /// `return`. Mirrors [`Self::emit_match`] but uses
7470    /// [`Self::emit_block_body_assigning`] for arm bodies.
7471    fn emit_match_assigning(
7472        &mut self,
7473        target: &str,
7474        scrutinee: &AIRNode,
7475        arms: &[AIRNode],
7476    ) -> Result<(), CodegenError> {
7477        let ind = self.indent_str();
7478        let _ = write!(self.buf, "{ind}match ");
7479        self.emit_expr(scrutinee)?;
7480        self.buf.push_str(":\n");
7481        self.indent += 1;
7482        for arm in arms {
7483            if let NodeKind::MatchArm {
7484                pattern,
7485                guard,
7486                body,
7487            } = &arm.kind
7488            {
7489                let ind = self.indent_str();
7490                let _ = write!(self.buf, "{ind}case ");
7491                self.emit_pattern(pattern)?;
7492                if let Some(g) = guard {
7493                    self.buf.push_str(" if ");
7494                    self.emit_expr(g)?;
7495                }
7496                self.buf.push_str(":\n");
7497                self.indent += 1;
7498                self.emit_block_body_assigning(target, body)?;
7499                self.indent -= 1;
7500            }
7501        }
7502        self.indent -= 1;
7503        Ok(())
7504    }
7505
7506    fn emit_block_as_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
7507        if let NodeKind::Block { stmts, tail } = &node.kind {
7508            if stmts.is_empty() {
7509                if let Some(t) = tail {
7510                    return self.emit_expr(t);
7511                }
7512            } else if self.try_emit_block_stmts_as_expr(stmts, tail.as_deref())? {
7513                // A block with leading statements *and* a tail value — e.g. a
7514                // value-position `match` arm `Ok(sum) => { let s = …; … }`. The
7515                // leading `let`s / side-effecting expression statements are
7516                // folded into immediately-applied lambdas so they actually run
7517                // and their bindings are in scope for the tail. See
7518                // `try_emit_block_stmts_as_expr`.
7519                return Ok(());
7520            }
7521        }
7522        self.emit_expr(node)
7523    }
7524
7525    /// Emit a block's leading statements + tail as a single Python expression by
7526    /// folding each statement into an immediately-applied lambda, preserving
7527    /// both the statement's effect and any binding it introduces:
7528    ///
7529    /// ```text
7530    /// { let x = V; REST }         →  (lambda x: <REST>)(V)
7531    /// { side_effect(); REST }     →  (lambda _: <REST>)(side_effect())
7532    /// ```
7533    ///
7534    /// Python `lambda` bodies are expression-only, so a block with statements in
7535    /// expression position (a value-position `match`/`if` arm) otherwise loses
7536    /// its leading statements — the old "best effort" emitted `(lambda: <tail>)()`
7537    /// and dropped them, leaving later references unbound (the calculator
7538    /// `let step2 = …` bug) or skipping a side effect (microservice's dropped
7539    /// `log`).
7540    ///
7541    /// Returns `Ok(true)` when the whole block was emitted this way. Returns
7542    /// `Ok(false)` — emitting nothing — when a leading statement can't be
7543    /// expressed as a pure expression (mutable `let`, assignment, loop, …); the
7544    /// caller then falls back to the prior best-effort path. The conservative
7545    /// gate keeps this from emitting broken code for shapes it can't model.
7546    fn try_emit_block_stmts_as_expr(
7547        &mut self,
7548        stmts: &[AIRNode],
7549        tail: Option<&AIRNode>,
7550    ) -> Result<bool, CodegenError> {
7551        // Every leading statement must be expressible as a value-producing
7552        // expression: an immutable simple `let`, or a plain expression
7553        // statement. Anything else (mutable/destructuring `let`, assignment,
7554        // loop, `return`, nested block) bails so the caller can fall back.
7555        for s in stmts {
7556            match &s.kind {
7557                NodeKind::LetBinding {
7558                    is_mut, pattern, ..
7559                } if *is_mut || Self::simple_bind_name(pattern).is_none() => {
7560                    return Ok(false);
7561                }
7562                NodeKind::LetBinding { .. } => {}
7563                NodeKind::Assign { .. }
7564                | NodeKind::While { .. }
7565                | NodeKind::For { .. }
7566                | NodeKind::Loop { .. }
7567                | NodeKind::Return { .. }
7568                | NodeKind::Break { .. }
7569                | NodeKind::Continue
7570                | NodeKind::Block { .. } => return Ok(false),
7571                _ => {}
7572            }
7573        }
7574        self.emit_block_stmt_chain(stmts, tail)?;
7575        Ok(true)
7576    }
7577
7578    /// Recursively emit the `(lambda …: …)(…)` chain validated by
7579    /// [`Self::try_emit_block_stmts_as_expr`].
7580    fn emit_block_stmt_chain(
7581        &mut self,
7582        stmts: &[AIRNode],
7583        tail: Option<&AIRNode>,
7584    ) -> Result<(), CodegenError> {
7585        let Some((first, rest)) = stmts.split_first() else {
7586            // No more leading statements — emit the tail value (or `None`).
7587            return match tail {
7588                Some(t) => self.emit_expr(t),
7589                None => {
7590                    self.buf.push_str("None");
7591                    Ok(())
7592                }
7593            };
7594        };
7595        match &first.kind {
7596            NodeKind::LetBinding { pattern, value, .. } => {
7597                let name = Self::simple_bind_name(pattern).unwrap_or_else(|| "_".to_string());
7598                let _ = write!(self.buf, "(lambda {name}: ");
7599                self.emit_block_stmt_chain(rest, tail)?;
7600                self.buf.push_str(")(");
7601                self.emit_expr(value)?;
7602                self.buf.push(')');
7603                Ok(())
7604            }
7605            // A bare expression statement: bind its value to a throwaway
7606            // parameter so the effect runs, then continue with the rest.
7607            _ => {
7608                self.buf.push_str("(lambda _: ");
7609                self.emit_block_stmt_chain(rest, tail)?;
7610                self.buf.push_str(")(");
7611                self.emit_expr(first)?;
7612                self.buf.push(')');
7613                Ok(())
7614            }
7615        }
7616    }
7617
7618    /// The single Python value-name a *simple* `let` pattern binds, if any: only
7619    /// a `BindPat` qualifies (tuple/record patterns bind several names or use
7620    /// Python-side destructuring that shadow-renaming does not rewrite). Used to
7621    /// gate nested-block `let`-shadow renaming to the simple, common case.
7622    fn simple_bind_name(pat: &AIRNode) -> Option<String> {
7623        match &pat.kind {
7624            NodeKind::BindPat { name, .. } => Some(py_value_ident(&name.name)),
7625            _ => None,
7626        }
7627    }
7628
7629    fn pattern_to_binding_name(&self, pat: &AIRNode) -> String {
7630        match &pat.kind {
7631            NodeKind::BindPat { name, .. } => py_value_ident(&name.name),
7632            NodeKind::WildcardPat => "_".into(),
7633            NodeKind::TuplePat { elems } => {
7634                format!(
7635                    "({})",
7636                    elems
7637                        .iter()
7638                        .map(|e| self.pattern_to_binding_name(e))
7639                        .collect::<Vec<_>>()
7640                        .join(", ")
7641                )
7642            }
7643            NodeKind::RecordPat { fields, .. } => {
7644                // Python doesn't have destructuring; use first field name or
7645                // underscore (keyword-escaped like every value binding).
7646                fields
7647                    .first()
7648                    .map(|f| py_value_ident(&f.name.name))
7649                    .unwrap_or_else(|| "_".into())
7650            }
7651            _ => "_".into(),
7652        }
7653    }
7654
7655    fn pattern_to_py_binding(&self, pat: &AIRNode) -> String {
7656        self.pattern_to_binding_name(pat)
7657    }
7658
7659    fn type_expr_to_string(&self, node: &AIRNode) -> String {
7660        match &node.kind {
7661            NodeKind::TypeNamed { path, .. } => path
7662                .segments
7663                .iter()
7664                .map(|s| s.name.as_str())
7665                .collect::<Vec<_>>()
7666                .join("."),
7667            NodeKind::Identifier { name } => name.name.clone(),
7668            _ => "Unknown".into(),
7669        }
7670    }
7671}
7672
7673// ─── Utility functions ───────────────────────────────────────────────────────
7674
7675/// Convert a `PascalCase` or `camelCase` name to `snake_case`.
7676/// Extract the unqualified type name from an `impl` target AIR node.
7677/// Returns `None` for types that aren't simple named references
7678/// (tuples, function types, etc.).
7679fn ast_type_name(node: &AIRNode) -> Option<String> {
7680    if let NodeKind::TypeNamed { path, .. } = &node.kind {
7681        path.segments.last().map(|s| s.name.clone())
7682    } else {
7683        None
7684    }
7685}
7686
7687/// Compute a stable emission order over a module's top-level `items` such that a
7688/// type declaration (`trait` / `record` / `class`) that becomes a Python base
7689/// class of another (its supertype) is always emitted *before* the subtype.
7690///
7691/// Python evaluates a `class Sub(Base):` statement's base list eagerly, so
7692/// `Base` must already be a bound name when `Sub` is defined; emitting them in
7693/// source order risks `NameError` when a trait is declared after the type that
7694/// impls it, or when an inlined-impl record precedes its trait
7695/// (Q-py-impl-before-trait).
7696///
7697/// The reorder is a **stable topological sort**: items are emitted in original
7698/// order except that any type decl is delayed until all the type decls it
7699/// depends on (its declared `base`, declared `traits`, and the trait paths of
7700/// the impl blocks targeting it in `impls_by_target`) have been emitted. Only
7701/// dependencies on types *declared in this same module* create edges; references
7702/// to imported/prelude bases never block (they resolve via imports). A
7703/// dependency cycle (which Python could not represent anyway) degrades
7704/// gracefully to source order for the involved nodes rather than dropping them.
7705fn type_decl_emission_order(
7706    items: &[AIRNode],
7707    impls_by_target: &HashMap<String, Vec<AIRNode>>,
7708) -> Vec<usize> {
7709    use std::collections::HashMap as Map;
7710
7711    // name → index for every type decl declared in this module. Effects are
7712    // included because the Python backend also emits an `effect` as an ABC class
7713    // that an `impl Effect for T` makes a *base* of `T` (`class StubChannel(
7714    // Channel)`), so an effect declared after its impl is the same base-ordering
7715    // hazard as a trait.
7716    let mut decl_index: Map<String, usize> = Map::new();
7717    for (i, item) in items.iter().enumerate() {
7718        match &item.kind {
7719            NodeKind::TraitDecl { name, .. }
7720            | NodeKind::RecordDecl { name, .. }
7721            | NodeKind::ClassDecl { name, .. }
7722            | NodeKind::EffectDecl { name, .. } => {
7723                decl_index.entry(name.name.clone()).or_insert(i);
7724            }
7725            _ => {}
7726        }
7727    }
7728
7729    // deps[i] = set of item indices that item i must follow (its in-module
7730    // base/trait supertypes). Non-type items have no deps.
7731    let mut deps: Vec<Vec<usize>> = vec![Vec::new(); items.len()];
7732    let add_dep = |deps: &mut Vec<Vec<usize>>, i: usize, dep_name: &str| {
7733        if let Some(&j) = decl_index.get(dep_name) {
7734            if j != i && !deps[i].contains(&j) {
7735                deps[i].push(j);
7736            }
7737        }
7738    };
7739    for (i, item) in items.iter().enumerate() {
7740        let (name, declared_base, declared_traits) = match &item.kind {
7741            NodeKind::ClassDecl {
7742                name, base, traits, ..
7743            } => (Some(name), base.as_ref(), traits.as_slice()),
7744            NodeKind::RecordDecl { name, .. } | NodeKind::TraitDecl { name, .. } => {
7745                (Some(name), None, [].as_slice())
7746            }
7747            _ => (None, None, [].as_slice()),
7748        };
7749        let Some(name) = name else { continue };
7750        if let Some(b) = declared_base {
7751            if let Some(seg) = b.segments.last() {
7752                add_dep(&mut deps, i, &seg.name);
7753            }
7754        }
7755        for tp in declared_traits {
7756            if let Some(seg) = tp.segments.last() {
7757                add_dep(&mut deps, i, &seg.name);
7758            }
7759        }
7760        // Trait paths from the impl blocks that fold into this type's body.
7761        if let Some(impls) = impls_by_target.get(&name.name) {
7762            for im in impls {
7763                if let NodeKind::ImplBlock {
7764                    trait_path: Some(tp),
7765                    ..
7766                } = &im.kind
7767                {
7768                    if let Some(seg) = tp.segments.last() {
7769                        add_dep(&mut deps, i, &seg.name);
7770                    }
7771                }
7772            }
7773        }
7774    }
7775
7776    // Stable topological emit: repeatedly take the earliest not-yet-emitted item
7777    // whose deps are all emitted. If none qualifies (a cycle), take the earliest
7778    // remaining item to make progress (graceful degradation).
7779    let n = items.len();
7780    let mut emitted = vec![false; n];
7781    let mut order = Vec::with_capacity(n);
7782    for _ in 0..n {
7783        let mut pick = None;
7784        for i in 0..n {
7785            if emitted[i] {
7786                continue;
7787            }
7788            if deps[i].iter().all(|&d| emitted[d]) {
7789                pick = Some(i);
7790                break;
7791            }
7792        }
7793        let i = pick.unwrap_or_else(|| (0..n).find(|&i| !emitted[i]).unwrap_or(0));
7794        emitted[i] = true;
7795        order.push(i);
7796    }
7797    order
7798}
7799
7800/// Emit a Bock identifier as a Python identifier. PascalCase names are
7801/// preserved — they denote classes, ABC traits, or enum variant constructors,
7802/// all of which stay PascalCase in Python by convention.
7803fn identifier_to_py(s: &str) -> String {
7804    if s.chars().next().is_some_and(char::is_uppercase) {
7805        s.to_string()
7806    } else {
7807        py_value_ident(s)
7808    }
7809}
7810
7811/// Convert a Bock *value* identifier (a param, local binding, or free-function
7812/// name) to its Python form: `snake_case`, then escaped against the Python
7813/// keyword set so a binding named e.g. `def` emits `def_` rather than the
7814/// illegal bare keyword. Apply at every value declaration and reference site so
7815/// the escaped name is used uniformly; member/method names use bare
7816/// [`to_snake_case`]. See [`crate::generator::escape_target_keyword`].
7817fn py_value_ident(name: &str) -> String {
7818    crate::generator::escape_target_keyword(
7819        &to_snake_case(name),
7820        crate::generator::KeywordTarget::Python,
7821    )
7822}
7823
7824/// The Python spelling of a record / class / enum-struct-variant **field**
7825/// name: `snake_case`, then escaped against the Python keyword set — the same
7826/// policy as value identifiers ([`py_value_ident`]), extended to field
7827/// position (Q-python-keyword-record-fields). A Bock field named `pass` emits
7828/// as `pass_`; left verbatim it is a `SyntaxError` in the dataclass
7829/// declaration (`pass: int`), the constructor keyword args (`Tally(pass=7)`),
7830/// the attribute access (`t.pass`), and the match pattern
7831/// (`case Tally(pass=p)`). Applied identically at every field position —
7832/// dataclass / `__init__` declaration, constructor kwargs (plain and spread
7833/// dict keys), field access, and record-pattern destructuring — so the escaped
7834/// spelling always agrees. A record-pattern *shorthand* (`{ pass }`) binds a
7835/// value identifier of the same Bock name, which [`py_value_ident`] escapes to
7836/// the identical spelling at every reference site.
7837fn py_field_ident(name: &str) -> String {
7838    py_value_ident(name)
7839}
7840
7841/// True when `name` (an already snake_cased Python value identifier) collides
7842/// with a built-in that the Python backend *emits as a call* in generated code —
7843/// the collection constructors and functional combinators a list/set/map literal
7844/// or method lowers to (`list(...)`, `set(...)`, `dict(...)`, `map(...)`,
7845/// `filter(...)`, `sorted(...)`, `enumerate(...)`, `range(...)`, `len(...)`,
7846/// `tuple(...)`, `frozenset(...)`, `zip(...)`, `iter(...)`, `print(...)`).
7847///
7848/// A local `let list = [...]` rebinds these names for the rest of the
7849/// (function-scoped) Python frame, so a subsequent codegen-emitted `list(...)`
7850/// would raise `TypeError: 'list' object is not callable`. [`PyEmitCtx::
7851/// plan_shadow_let`] renames such bindings to a fresh alias so the builtin stays
7852/// callable; references resolve through the same alias map. The set is limited to
7853/// builtins the codegen actually emits (not every Python builtin) so unrelated
7854/// names are never mangled. Bock keywords (`type`, etc.) are handled separately
7855/// by [`crate::generator::escape_target_keyword`].
7856fn is_shadow_sensitive_py_builtin(name: &str) -> bool {
7857    matches!(
7858        name,
7859        "list"
7860            | "set"
7861            | "dict"
7862            | "map"
7863            | "filter"
7864            | "sorted"
7865            | "enumerate"
7866            | "range"
7867            | "len"
7868            | "tuple"
7869            | "frozenset"
7870            | "zip"
7871            | "iter"
7872            | "next"
7873            | "print"
7874            | "str"
7875            | "int"
7876            | "float"
7877            | "bool"
7878            | "abs"
7879            | "min"
7880            | "max"
7881            | "sum"
7882            | "round"
7883    )
7884}
7885
7886/// Render a `RangePat` bound (`lo`/`hi`) as a Python expression. Range bounds
7887/// are literals (`1..10`) or a const identifier (`MIN..MAX`); anything else
7888/// falls back to `0` for an unrecognised node. Mirrors `range_bound_to_js`.
7889fn range_bound_to_py(node: &AIRNode) -> String {
7890    let lit = match &node.kind {
7891        NodeKind::LiteralPat { lit } | NodeKind::Literal { lit } => Some(lit),
7892        NodeKind::Identifier { name } => return py_value_ident(&name.name),
7893        _ => None,
7894    };
7895    match lit {
7896        Some(Literal::Int(s)) | Some(Literal::Float(s)) => s.clone(),
7897        Some(Literal::Bool(b)) => if *b { "True" } else { "False" }.to_string(),
7898        Some(Literal::Char(s)) => format!("'{s}'"),
7899        Some(Literal::String(s)) => format!("\"{}\"", escape_py_string(s)),
7900        Some(Literal::Unit) | None => "0".to_string(),
7901    }
7902}
7903
7904/// Returns true if `name` is the identifier of a Duration or Instant instance
7905/// method. Used to recognise `d.as_millis()` / `i.elapsed()` calls during codegen.
7906fn is_time_method_name(name: &str) -> bool {
7907    matches!(
7908        name,
7909        "as_nanos"
7910            | "as_millis"
7911            | "as_seconds"
7912            | "is_zero"
7913            | "is_negative"
7914            | "abs"
7915            | "elapsed"
7916            | "duration_since"
7917    )
7918}
7919
7920/// Walk a `@test` body and record the Optional/Result runtime tag classes its
7921/// predicate assertions (`to_be_some`/`to_be_none`/`to_be_ok`/`to_be_err`)
7922/// reference, so the Python test file imports exactly those from
7923/// `_bock_runtime` (which only defines the runtimes the program uses).
7924fn collect_runtime_tag_imports(node: &AIRNode, out: &mut std::collections::BTreeSet<&'static str>) {
7925    if let Some((assertion, _actual, _expected)) = crate::generator::classify_assertion(node) {
7926        use crate::generator::TestAssertion as T;
7927        match assertion {
7928            T::BeSome => {
7929                out.insert("_BockSome");
7930            }
7931            T::BeNone => {
7932                out.insert("_BockNone");
7933            }
7934            T::BeOk => {
7935                out.insert("_BockOk");
7936            }
7937            T::BeErr => {
7938                out.insert("_BockErr");
7939            }
7940            _ => {}
7941        }
7942    }
7943    if let NodeKind::Block { stmts, tail } = &node.kind {
7944        for s in stmts {
7945            collect_runtime_tag_imports(s, out);
7946        }
7947        if let Some(t) = tail {
7948            collect_runtime_tag_imports(t, out);
7949        }
7950    }
7951}
7952
7953fn to_snake_case(s: &str) -> String {
7954    // If already snake_case or a single word, return as-is
7955    if s.is_empty() || s == "_" {
7956        return s.to_string();
7957    }
7958    // Don't convert if it's already snake_case (contains underscores but no uppercase)
7959    if s.contains('_') && !s.chars().any(|c| c.is_uppercase()) {
7960        return s.to_string();
7961    }
7962    // Don't convert simple lowercase words or all-uppercase words
7963    if !s.chars().any(|c| c.is_uppercase()) {
7964        return s.to_string();
7965    }
7966    // Special case: single char
7967    if s.len() == 1 {
7968        return s.to_lowercase();
7969    }
7970
7971    let mut result = String::with_capacity(s.len() + 4);
7972    let chars: Vec<char> = s.chars().collect();
7973
7974    for (i, &ch) in chars.iter().enumerate() {
7975        if ch.is_uppercase() {
7976            let prev_is_upper = i > 0 && chars[i - 1].is_uppercase();
7977            let prev_is_underscore = i > 0 && chars[i - 1] == '_';
7978            let next_is_lower = i + 1 < chars.len() && chars[i + 1].is_lowercase();
7979            if i > 0 && !prev_is_underscore && (!prev_is_upper || next_is_lower) {
7980                result.push('_');
7981            }
7982            result.push(
7983                ch.to_lowercase()
7984                    .next()
7985                    .expect("lowercase yields at least one char"),
7986            );
7987        } else {
7988            result.push(ch);
7989        }
7990    }
7991    result
7992}
7993
7994/// Escape special characters in a Python string literal.
7995fn escape_py_string(s: &str) -> String {
7996    let mut out = String::with_capacity(s.len());
7997    for ch in s.chars() {
7998        match ch {
7999            '"' => out.push_str("\\\""),
8000            '\\' => out.push_str("\\\\"),
8001            '\n' => out.push_str("\\n"),
8002            '\r' => out.push_str("\\r"),
8003            '\t' => out.push_str("\\t"),
8004            _ => out.push(ch),
8005        }
8006    }
8007    out
8008}
8009
8010/// Escape special characters in a Python f-string.
8011fn escape_fstring(s: &str) -> String {
8012    let mut out = String::with_capacity(s.len());
8013    for ch in s.chars() {
8014        match ch {
8015            '"' => out.push_str("\\\""),
8016            '\\' => out.push_str("\\\\"),
8017            '\n' => out.push_str("\\n"),
8018            '\r' => out.push_str("\\r"),
8019            '\t' => out.push_str("\\t"),
8020            '{' => out.push_str("{{"),
8021            '}' => out.push_str("}}"),
8022            _ => out.push(ch),
8023        }
8024    }
8025    out
8026}
8027
8028/// Escape special characters in a triple-quoted Python f-string.
8029/// Newlines pass through literally; quotes don't need escaping.
8030fn escape_fstring_triple(s: &str) -> String {
8031    let mut out = String::with_capacity(s.len());
8032    for ch in s.chars() {
8033        match ch {
8034            '\\' => out.push_str("\\\\"),
8035            '{' => out.push_str("{{"),
8036            '}' => out.push_str("}}"),
8037            _ => out.push(ch),
8038        }
8039    }
8040    out
8041}
8042
8043// ─── Tests ───────────────────────────────────────────────────────────────────
8044
8045#[cfg(test)]
8046mod tests {
8047    use super::*;
8048    use bock_air::{AirArg, AirRecordField, AirRecordPatternField};
8049    use bock_ast::{Ident, TypePath};
8050    use bock_errors::{FileId, Span};
8051
8052    fn span() -> Span {
8053        Span {
8054            file: FileId(0),
8055            start: 0,
8056            end: 0,
8057        }
8058    }
8059
8060    fn ident(name: &str) -> Ident {
8061        Ident {
8062            name: name.to_string(),
8063            span: span(),
8064        }
8065    }
8066
8067    fn type_path(segments: &[&str]) -> TypePath {
8068        TypePath {
8069            segments: segments.iter().map(|s| ident(s)).collect(),
8070            span: span(),
8071        }
8072    }
8073
8074    fn node(id: u32, kind: NodeKind) -> AIRNode {
8075        AIRNode::new(id, span(), kind)
8076    }
8077
8078    fn int_lit(id: u32, val: &str) -> AIRNode {
8079        node(
8080            id,
8081            NodeKind::Literal {
8082                lit: Literal::Int(val.into()),
8083            },
8084        )
8085    }
8086
8087    fn str_lit(id: u32, val: &str) -> AIRNode {
8088        node(
8089            id,
8090            NodeKind::Literal {
8091                lit: Literal::String(val.into()),
8092            },
8093        )
8094    }
8095
8096    fn bool_lit(id: u32, val: bool) -> AIRNode {
8097        node(
8098            id,
8099            NodeKind::Literal {
8100                lit: Literal::Bool(val),
8101            },
8102        )
8103    }
8104
8105    fn id_node(id: u32, name: &str) -> AIRNode {
8106        node(id, NodeKind::Identifier { name: ident(name) })
8107    }
8108
8109    fn bind_pat(id: u32, name: &str) -> AIRNode {
8110        node(
8111            id,
8112            NodeKind::BindPat {
8113                name: ident(name),
8114                is_mut: false,
8115            },
8116        )
8117    }
8118
8119    fn param_node(id: u32, name: &str) -> AIRNode {
8120        node(
8121            id,
8122            NodeKind::Param {
8123                pattern: Box::new(bind_pat(id + 100, name)),
8124                ty: None,
8125                default: None,
8126            },
8127        )
8128    }
8129
8130    fn typed_param_node(id: u32, name: &str, ty_name: &str) -> AIRNode {
8131        node(
8132            id,
8133            NodeKind::Param {
8134                pattern: Box::new(bind_pat(id + 100, name)),
8135                ty: Some(Box::new(node(
8136                    id + 200,
8137                    NodeKind::TypeNamed {
8138                        path: type_path(&[ty_name]),
8139                        args: vec![],
8140                    },
8141                ))),
8142                default: None,
8143            },
8144        )
8145    }
8146
8147    fn block(id: u32, stmts: Vec<AIRNode>, tail: Option<AIRNode>) -> AIRNode {
8148        node(
8149            id,
8150            NodeKind::Block {
8151                stmts,
8152                tail: tail.map(Box::new),
8153            },
8154        )
8155    }
8156
8157    fn module(imports: Vec<AIRNode>, items: Vec<AIRNode>) -> AIRNode {
8158        node(
8159            0,
8160            NodeKind::Module {
8161                path: None,
8162                annotations: vec![],
8163                imports,
8164                items,
8165            },
8166        )
8167    }
8168
8169    fn gen(module: &AIRNode) -> String {
8170        let gen = PyGenerator::new();
8171        let result = gen.generate_module(module).unwrap();
8172        result.files[0].content.clone()
8173    }
8174
8175    // ── Basic tests ─────────────────────────────────────────────────────────
8176
8177    #[test]
8178    fn implements_code_generator_trait() {
8179        let gen = PyGenerator::new();
8180        assert_eq!(gen.target().id, "python");
8181    }
8182
8183    #[test]
8184    fn empty_module() {
8185        let m = module(vec![], vec![]);
8186        let out = gen(&m);
8187        assert_eq!(out, "");
8188    }
8189
8190    /// A module node with a declared dotted `path` (e.g. `core.option`), used
8191    /// by the per-module emission tests where the file layout and import path
8192    /// are keyed on the declared module-path.
8193    fn module_with_path(path: &[&str], imports: Vec<AIRNode>, items: Vec<AIRNode>) -> AIRNode {
8194        node(
8195            0,
8196            NodeKind::Module {
8197                path: Some(bock_ast::ModulePath {
8198                    segments: path.iter().map(|s| ident(s)).collect(),
8199                    span: span(),
8200                }),
8201                annotations: vec![],
8202                imports,
8203                items,
8204            },
8205        )
8206    }
8207
8208    /// An `import <path>.{ name }` AIR node (a `Named` single-item import).
8209    fn import_named(id: u32, path: &[&str], name: &str) -> AIRNode {
8210        node(
8211            id,
8212            NodeKind::ImportDecl {
8213                path: bock_ast::ModulePath {
8214                    segments: path.iter().map(|s| ident(s)).collect(),
8215                    span: span(),
8216                },
8217                items: bock_ast::ImportItems::Named(vec![bock_ast::ImportedName {
8218                    span: span(),
8219                    name: ident(name),
8220                    alias: None,
8221                }]),
8222            },
8223        )
8224    }
8225
8226    /// A bare `fn <name>() -> <ret? expr>` declaration with the given visibility
8227    /// and a single tail expression as its body.
8228    fn fn_decl_tail(id: u32, vis: Visibility, name: &str, tail: AIRNode) -> AIRNode {
8229        node(
8230            id,
8231            NodeKind::FnDecl {
8232                annotations: vec![],
8233                visibility: vis,
8234                is_async: false,
8235                name: ident(name),
8236                generic_params: vec![],
8237                params: vec![],
8238                return_type: None,
8239                effect_clause: vec![],
8240                where_clause: vec![],
8241                body: Box::new(block(id + 1, vec![], Some(tail))),
8242            },
8243        )
8244    }
8245
8246    #[test]
8247    fn per_module_emits_native_import_tree() {
8248        // entry `module main` uses `mathutil.add_one`; `module mathutil` exports
8249        // a `public fn add_one`. Per-module emission must produce TWO files —
8250        // `main.py` (with a real `from mathutil import add_one`) and a separate
8251        // `mathutil.py` — not a single collapsed file.
8252        let call = node(
8253            10,
8254            NodeKind::Call {
8255                callee: Box::new(id_node(11, "add_one")),
8256                args: vec![AirArg {
8257                    label: None,
8258                    value: int_lit(12, "6"),
8259                }],
8260                type_args: vec![],
8261            },
8262        );
8263        let main_mod = module_with_path(
8264            &["main"],
8265            vec![import_named(5, &["mathutil"], "add_one")],
8266            vec![fn_decl_tail(1, Visibility::Private, "main", call)],
8267        );
8268        let mathutil_mod = module_with_path(
8269            &["mathutil"],
8270            vec![],
8271            vec![fn_decl_tail(
8272                20,
8273                Visibility::Public,
8274                "add_one",
8275                int_lit(22, "7"),
8276            )],
8277        );
8278
8279        let gen = PyGenerator::new();
8280        let main_path = std::path::Path::new("src/main.bock");
8281        let util_path = std::path::Path::new("src/mathutil.bock");
8282        let out = gen
8283            .generate_project(&[(&main_mod, main_path), (&mathutil_mod, util_path)])
8284            .unwrap();
8285
8286        // Two module files (no shared runtime needed here).
8287        let by_name = |p: &str| out.files.iter().find(|f| f.path == std::path::Path::new(p));
8288        let main_file = by_name("main.py").expect("main.py emitted");
8289        let util_file = by_name("mathutil.py").expect("mathutil.py emitted");
8290        assert!(
8291            main_file.content.contains("from mathutil import add_one"),
8292            "main.py must import from the sibling module; got:\n{}",
8293            main_file.content
8294        );
8295        assert!(
8296            main_file.content.contains("if __name__ == \"__main__\":"),
8297            "main.py must carry the entry invocation; got:\n{}",
8298            main_file.content
8299        );
8300        assert!(
8301            util_file.content.contains("def add_one():"),
8302            "mathutil.py must carry the exported fn; got:\n{}",
8303            util_file.content
8304        );
8305        // The bundling no-op import comment must NOT appear (real import only).
8306        assert!(
8307            !main_file.content.contains("# import"),
8308            "per-module path emits a real import, not a comment"
8309        );
8310    }
8311
8312    #[test]
8313    fn per_module_shares_optional_runtime() {
8314        // Two modules both referencing `None` must share ONE `_bock_runtime.py`
8315        // (so `_bock_none` is the same object across files) and import it.
8316        let main_mod = module_with_path(
8317            &["main"],
8318            vec![import_named(5, &["other"], "thing")],
8319            vec![fn_decl_tail(
8320                1,
8321                Visibility::Private,
8322                "main",
8323                id_node(12, "None"),
8324            )],
8325        );
8326        let other_mod = module_with_path(
8327            &["other"],
8328            vec![],
8329            vec![fn_decl_tail(
8330                20,
8331                Visibility::Public,
8332                "thing",
8333                id_node(22, "None"),
8334            )],
8335        );
8336        let gen = PyGenerator::new();
8337        let out = gen
8338            .generate_project(&[
8339                (&main_mod, std::path::Path::new("src/main.bock")),
8340                (&other_mod, std::path::Path::new("src/other.bock")),
8341            ])
8342            .unwrap();
8343        let runtime = out
8344            .files
8345            .iter()
8346            .find(|f| f.path == std::path::Path::new("_bock_runtime.py"))
8347            .expect("_bock_runtime.py emitted once");
8348        assert!(runtime.content.contains("class _BockNone:"), "got runtime");
8349        assert!(
8350            runtime.content.contains("__all__") && runtime.content.contains("\"_bock_none\""),
8351            "runtime must export underscore names via __all__; got:\n{}",
8352            runtime.content
8353        );
8354        // Every consuming module imports the shared runtime.
8355        let importers = out
8356            .files
8357            .iter()
8358            .filter(|f| f.content.contains("from _bock_runtime import *"))
8359            .count();
8360        assert_eq!(importers, 2, "both modules import the shared runtime");
8361    }
8362
8363    #[test]
8364    fn simple_function() {
8365        let body = block(2, vec![], Some(int_lit(3, "42")));
8366        let f = node(
8367            1,
8368            NodeKind::FnDecl {
8369                annotations: vec![],
8370                visibility: Visibility::Private,
8371                is_async: false,
8372                name: ident("answer"),
8373                generic_params: vec![],
8374                params: vec![],
8375                return_type: None,
8376                effect_clause: vec![],
8377                where_clause: vec![],
8378                body: Box::new(body),
8379            },
8380        );
8381        let out = gen(&module(vec![], vec![f]));
8382        assert!(out.contains("def answer():"), "got: {out}");
8383        assert!(out.contains("return 42"), "got: {out}");
8384    }
8385
8386    #[test]
8387    fn function_with_params_and_type_hints() {
8388        let body = block(
8389            5,
8390            vec![],
8391            Some(node(
8392                6,
8393                NodeKind::BinaryOp {
8394                    op: BinOp::Add,
8395                    left: Box::new(id_node(7, "a")),
8396                    right: Box::new(id_node(8, "b")),
8397                },
8398            )),
8399        );
8400        let f = node(
8401            1,
8402            NodeKind::FnDecl {
8403                annotations: vec![],
8404                visibility: Visibility::Public,
8405                is_async: false,
8406                name: ident("add"),
8407                generic_params: vec![],
8408                params: vec![
8409                    typed_param_node(2, "a", "Int"),
8410                    typed_param_node(3, "b", "Int"),
8411                ],
8412                return_type: Some(Box::new(node(
8413                    4,
8414                    NodeKind::TypeNamed {
8415                        path: type_path(&["Int"]),
8416                        args: vec![],
8417                    },
8418                ))),
8419                effect_clause: vec![],
8420                where_clause: vec![],
8421                body: Box::new(body),
8422            },
8423        );
8424        let out = gen(&module(vec![], vec![f]));
8425        assert!(
8426            out.contains("def add(a: int, b: int) -> int:"),
8427            "got: {out}"
8428        );
8429        assert!(out.contains("(a + b)"), "got: {out}");
8430    }
8431
8432    #[test]
8433    fn async_function() {
8434        let body = block(
8435            3,
8436            vec![],
8437            Some(node(
8438                4,
8439                NodeKind::Await {
8440                    expr: Box::new(node(
8441                        5,
8442                        NodeKind::Call {
8443                            callee: Box::new(id_node(6, "fetch")),
8444                            args: vec![AirArg {
8445                                label: None,
8446                                value: str_lit(7, "https://example.com"),
8447                            }],
8448                            type_args: vec![],
8449                        },
8450                    )),
8451                },
8452            )),
8453        );
8454        let f = node(
8455            1,
8456            NodeKind::FnDecl {
8457                annotations: vec![],
8458                visibility: Visibility::Private,
8459                is_async: true,
8460                name: ident("fetchData"),
8461                generic_params: vec![],
8462                params: vec![],
8463                return_type: None,
8464                effect_clause: vec![],
8465                where_clause: vec![],
8466                body: Box::new(body),
8467            },
8468        );
8469        let out = gen(&module(vec![], vec![f]));
8470        assert!(out.contains("async def fetch_data():"), "got: {out}");
8471        assert!(out.contains("await fetch"), "got: {out}");
8472    }
8473
8474    #[test]
8475    fn effects_as_keyword_args() {
8476        let body = block(
8477            3,
8478            vec![node(
8479                4,
8480                NodeKind::LetBinding {
8481                    is_mut: false,
8482                    pattern: Box::new(bind_pat(5, "msg")),
8483                    ty: None,
8484                    value: Box::new(str_lit(6, "hello")),
8485                },
8486            )],
8487            Some(node(
8488                7,
8489                NodeKind::EffectOp {
8490                    effect: type_path(&["Log"]),
8491                    operation: ident("info"),
8492                    args: vec![AirArg {
8493                        label: None,
8494                        value: id_node(8, "msg"),
8495                    }],
8496                },
8497            )),
8498        );
8499        let f = node(
8500            1,
8501            NodeKind::FnDecl {
8502                annotations: vec![],
8503                visibility: Visibility::Private,
8504                is_async: false,
8505                name: ident("process"),
8506                generic_params: vec![],
8507                params: vec![param_node(2, "data")],
8508                return_type: None,
8509                effect_clause: vec![type_path(&["Log"]), type_path(&["Clock"])],
8510                where_clause: vec![],
8511                body: Box::new(body),
8512            },
8513        );
8514        let out = gen(&module(vec![], vec![f]));
8515        assert!(
8516            out.contains("def process(data, *, log: Log, clock: Clock):"),
8517            "got: {out}"
8518        );
8519        assert!(out.contains("log.info(msg)"), "got: {out}");
8520    }
8521
8522    /// Q-clock-handler-routing: inside a `with Clock` function (where the
8523    /// `clock` handler is in scope), the §18.3.1 time builtins must route
8524    /// through the handler — `Instant.now()` → `clock.now_monotonic()`,
8525    /// `sleep(d)` → `clock.sleep(d)`, `start.elapsed()` → `clock.now_monotonic()
8526    /// - start` — NOT the inlined host primitives (`time.monotonic_ns()` /
8527    /// `asyncio.sleep`).
8528    #[test]
8529    fn clock_time_ops_route_through_handler() {
8530        let out = gen(&module(vec![], vec![clock_timed_fn()]));
8531        assert!(out.contains("clock.now_monotonic()"), "got: {out}");
8532        assert!(out.contains("clock.sleep("), "got: {out}");
8533        assert!(
8534            !out.contains("time.monotonic_ns()"),
8535            "host clock primitive leaked past the handler: {out}"
8536        );
8537        assert!(
8538            !out.contains("asyncio.sleep("),
8539            "host sleep primitive leaked past the handler: {out}"
8540        );
8541    }
8542
8543    /// Builds `fn timed() with Clock { let start = Instant.now(); sleep(
8544    /// Duration.millis(1)); let d = start.elapsed() }`. The `with Clock` clause
8545    /// puts the `clock` handler in scope so the time builtins route through it.
8546    fn clock_timed_fn() -> AIRNode {
8547        let instant_now = node(
8548            40,
8549            NodeKind::Call {
8550                callee: Box::new(node(
8551                    41,
8552                    NodeKind::FieldAccess {
8553                        object: Box::new(id_node(42, "Instant")),
8554                        field: ident("now"),
8555                    },
8556                )),
8557                args: vec![],
8558                type_args: vec![],
8559            },
8560        );
8561        let duration_millis = node(
8562            50,
8563            NodeKind::Call {
8564                callee: Box::new(node(
8565                    51,
8566                    NodeKind::FieldAccess {
8567                        object: Box::new(id_node(52, "Duration")),
8568                        field: ident("millis"),
8569                    },
8570                )),
8571                args: vec![AirArg {
8572                    label: None,
8573                    value: int_lit(53, "1"),
8574                }],
8575                type_args: vec![],
8576            },
8577        );
8578        let sleep_call = node(
8579            60,
8580            NodeKind::Call {
8581                callee: Box::new(id_node(61, "sleep")),
8582                args: vec![AirArg {
8583                    label: None,
8584                    value: duration_millis,
8585                }],
8586                type_args: vec![],
8587            },
8588        );
8589        let elapsed_call = node(
8590            70,
8591            NodeKind::MethodCall {
8592                receiver: Box::new(id_node(71, "start")),
8593                method: ident("elapsed"),
8594                type_args: vec![],
8595                args: vec![],
8596            },
8597        );
8598        let body = block(
8599            30,
8600            vec![
8601                node(
8602                    31,
8603                    NodeKind::LetBinding {
8604                        is_mut: false,
8605                        pattern: Box::new(bind_pat(32, "start")),
8606                        ty: None,
8607                        value: Box::new(instant_now),
8608                    },
8609                ),
8610                sleep_call,
8611                node(
8612                    33,
8613                    NodeKind::LetBinding {
8614                        is_mut: false,
8615                        pattern: Box::new(bind_pat(34, "d")),
8616                        ty: None,
8617                        value: Box::new(elapsed_call),
8618                    },
8619                ),
8620            ],
8621            None,
8622        );
8623        node(
8624            1,
8625            NodeKind::FnDecl {
8626                annotations: vec![],
8627                visibility: Visibility::Private,
8628                is_async: false,
8629                name: ident("timed"),
8630                generic_params: vec![],
8631                params: vec![],
8632                return_type: None,
8633                effect_clause: vec![type_path(&["Clock"])],
8634                where_clause: vec![],
8635                body: Box::new(body),
8636            },
8637        )
8638    }
8639
8640    #[test]
8641    fn record_to_dataclass() {
8642        let rec = node(
8643            1,
8644            NodeKind::RecordDecl {
8645                annotations: vec![],
8646                visibility: Visibility::Public,
8647                name: ident("Point"),
8648                generic_params: vec![],
8649                fields: vec![
8650                    bock_ast::RecordDeclField {
8651                        id: 0,
8652                        span: span(),
8653                        name: ident("x"),
8654                        ty: bock_ast::TypeExpr::Named {
8655                            id: 0,
8656                            span: span(),
8657                            path: type_path(&["Float"]),
8658                            args: vec![],
8659                        },
8660                        default: None,
8661                    },
8662                    bock_ast::RecordDeclField {
8663                        id: 0,
8664                        span: span(),
8665                        name: ident("y"),
8666                        ty: bock_ast::TypeExpr::Named {
8667                            id: 0,
8668                            span: span(),
8669                            path: type_path(&["Float"]),
8670                            args: vec![],
8671                        },
8672                        default: None,
8673                    },
8674                ],
8675            },
8676        );
8677        let out = gen(&module(vec![], vec![rec]));
8678        assert!(
8679            out.contains("from dataclasses import dataclass"),
8680            "got: {out}"
8681        );
8682        assert!(out.contains("@dataclass"), "got: {out}");
8683        assert!(out.contains("class Point:"), "got: {out}");
8684        assert!(out.contains("x: float"), "got: {out}");
8685        assert!(out.contains("y: float"), "got: {out}");
8686    }
8687
8688    /// Builds `print("<s>")` as a bare call node.
8689    fn print_call(id: u32, s: &str) -> AIRNode {
8690        node(
8691            id,
8692            NodeKind::Call {
8693                callee: Box::new(id_node(id + 1, "print")),
8694                args: vec![AirArg {
8695                    label: None,
8696                    value: str_lit(id + 2, s),
8697                }],
8698                type_args: vec![],
8699            },
8700        )
8701    }
8702
8703    /// A mid-block (statement-position) `if`/`else if`/`else` whose branches
8704    /// are bare `print` expressions must lower each branch tail to a *bare
8705    /// statement*, never a function-body `return`, and chain `else if` as
8706    /// `elif` (Q-python-ifelse-truncation). Before the fix: each branch
8707    /// emitted `return print(..)` — aborting the function after the taken
8708    /// branch so the trailing statement never ran — and the chain emitted
8709    /// `el    if (..):`, a SyntaxError, because the `el` prefix was followed
8710    /// by a fully-indented statement `if`. Sibling of the statement-`match`
8711    /// fix (#259).
8712    #[test]
8713    fn stmt_position_if_else_discards_branch_tails_and_chains_elif() {
8714        // if c1 { print("a") } else if c2 { print("b") } else { print("c") }
8715        // print("after")
8716        let chain = node(
8717            10,
8718            NodeKind::If {
8719                let_pattern: None,
8720                condition: Box::new(id_node(11, "c1")),
8721                then_block: Box::new(block(12, vec![], Some(print_call(13, "a")))),
8722                else_block: Some(Box::new(node(
8723                    20,
8724                    NodeKind::If {
8725                        let_pattern: None,
8726                        condition: Box::new(id_node(21, "c2")),
8727                        then_block: Box::new(block(22, vec![], Some(print_call(23, "b")))),
8728                        else_block: Some(Box::new(block(30, vec![], Some(print_call(31, "c"))))),
8729                    },
8730                ))),
8731            },
8732        );
8733        let f = node(
8734            1,
8735            NodeKind::FnDecl {
8736                annotations: vec![],
8737                visibility: Visibility::Private,
8738                is_async: false,
8739                name: ident("report"),
8740                generic_params: vec![],
8741                params: vec![param_node(2, "c1"), param_node(3, "c2")],
8742                return_type: None,
8743                effect_clause: vec![],
8744                where_clause: vec![],
8745                body: Box::new(block(4, vec![chain, print_call(40, "after")], None)),
8746            },
8747        );
8748        let out = gen(&module(vec![], vec![f]));
8749        assert!(
8750            !out.contains("return print("),
8751            "statement-if branch tails must be discarded, not returned: {out}"
8752        );
8753        assert!(
8754            out.contains("elif c2:"),
8755            "`else if` must chain as a single `elif`: {out}"
8756        );
8757        assert!(
8758            !out.contains("el    "),
8759            "the `el` prefix must not be followed by an indented statement: {out}"
8760        );
8761        assert!(
8762            out.contains("print(\"after\""),
8763            "the statement after the chain must still be emitted: {out}"
8764        );
8765    }
8766
8767    /// A `guard` whose `else` block does **not** diverge (spec §8.4 requires
8768    /// divergence, but the checker currently accepts this — surfaced as OPEN)
8769    /// must still not `return` out of the enclosing function: every other
8770    /// backend and the interpreter fall through to the statements after the
8771    /// guard. A spec-conforming diverging `else` is a statement tail and is
8772    /// unaffected by this. Same early-`return` family as the statement
8773    /// `match`/`if` truncations.
8774    #[test]
8775    fn stmt_guard_nondiverging_else_discards_tail() {
8776        let guard = node(
8777            10,
8778            NodeKind::Guard {
8779                let_pattern: None,
8780                condition: Box::new(id_node(11, "ok")),
8781                else_block: Box::new(block(12, vec![], Some(print_call(13, "warn")))),
8782            },
8783        );
8784        let f = node(
8785            1,
8786            NodeKind::FnDecl {
8787                annotations: vec![],
8788                visibility: Visibility::Private,
8789                is_async: false,
8790                name: ident("report"),
8791                generic_params: vec![],
8792                params: vec![param_node(2, "ok")],
8793                return_type: None,
8794                effect_clause: vec![],
8795                where_clause: vec![],
8796                body: Box::new(block(4, vec![guard, print_call(40, "after")], None)),
8797            },
8798        );
8799        let out = gen(&module(vec![], vec![f]));
8800        assert!(
8801            !out.contains("return print("),
8802            "a non-diverging guard else must fall through, not return: {out}"
8803        );
8804        assert!(
8805            out.contains("print(\"after\""),
8806            "the statement after the guard must still be emitted: {out}"
8807        );
8808    }
8809
8810    /// Record / enum-struct-variant fields named after Python keywords must be
8811    /// escaped (`pass` → `pass_`, `lambda` → `lambda_`) at every field
8812    /// position with one agreed spelling: the dataclass declaration, the
8813    /// constructor keyword args, attribute access, and record-pattern
8814    /// destructuring (Q-python-keyword-record-fields; extends #162's value-
8815    /// identifier escaping to field position). Unescaped, the dataclass
8816    /// declaration `pass: int` is a SyntaxError before `main` even runs.
8817    #[test]
8818    fn keyword_record_fields_escaped_at_every_site() {
8819        let int_ty = || bock_ast::TypeExpr::Named {
8820            id: 0,
8821            span: span(),
8822            path: type_path(&["Int"]),
8823            args: vec![],
8824        };
8825        let rec = node(
8826            1,
8827            NodeKind::RecordDecl {
8828                annotations: vec![],
8829                visibility: Visibility::Public,
8830                name: ident("Tally"),
8831                generic_params: vec![],
8832                fields: vec![bock_ast::RecordDeclField {
8833                    id: 0,
8834                    span: span(),
8835                    name: ident("pass"),
8836                    ty: int_ty(),
8837                    default: None,
8838                }],
8839            },
8840        );
8841        let enum_decl = node(
8842            2,
8843            NodeKind::EnumDecl {
8844                annotations: vec![],
8845                visibility: Visibility::Public,
8846                name: ident("Gate"),
8847                generic_params: vec![],
8848                variants: vec![node(
8849                    3,
8850                    NodeKind::EnumVariant {
8851                        name: ident("Open"),
8852                        payload: EnumVariantPayload::Struct(vec![bock_ast::RecordDeclField {
8853                            id: 0,
8854                            span: span(),
8855                            name: ident("lambda"),
8856                            ty: int_ty(),
8857                            default: None,
8858                        }]),
8859                    },
8860                )],
8861            },
8862        );
8863        // let t = Tally { pass: 7 }
8864        let let_t = node(
8865            10,
8866            NodeKind::LetBinding {
8867                is_mut: false,
8868                pattern: Box::new(bind_pat(11, "t")),
8869                ty: None,
8870                value: Box::new(node(
8871                    12,
8872                    NodeKind::RecordConstruct {
8873                        path: type_path(&["Tally"]),
8874                        fields: vec![AirRecordField {
8875                            name: ident("pass"),
8876                            value: Some(Box::new(int_lit(13, "7"))),
8877                        }],
8878                        spread: None,
8879                    },
8880                )),
8881            },
8882        );
8883        // print(t.pass)
8884        let access = node(
8885            20,
8886            NodeKind::Call {
8887                callee: Box::new(id_node(21, "print")),
8888                args: vec![AirArg {
8889                    label: None,
8890                    value: node(
8891                        22,
8892                        NodeKind::FieldAccess {
8893                            object: Box::new(id_node(23, "t")),
8894                            field: ident("pass"),
8895                        },
8896                    ),
8897                }],
8898                type_args: vec![],
8899            },
8900        );
8901        // match t { Tally { pass: p } => print("x") }
8902        let m = node(
8903            30,
8904            NodeKind::Match {
8905                scrutinee: Box::new(id_node(31, "t")),
8906                arms: vec![node(
8907                    32,
8908                    NodeKind::MatchArm {
8909                        pattern: Box::new(node(
8910                            33,
8911                            NodeKind::RecordPat {
8912                                path: type_path(&["Tally"]),
8913                                fields: vec![AirRecordPatternField {
8914                                    name: ident("pass"),
8915                                    pattern: Some(Box::new(bind_pat(34, "p"))),
8916                                }],
8917                                rest: false,
8918                            },
8919                        )),
8920                        guard: None,
8921                        body: Box::new(block(35, vec![], Some(print_call(36, "x")))),
8922                    },
8923                )],
8924            },
8925        );
8926        // let g = Open { lambda: 3 }
8927        let let_g = node(
8928            40,
8929            NodeKind::LetBinding {
8930                is_mut: false,
8931                pattern: Box::new(bind_pat(41, "g")),
8932                ty: None,
8933                value: Box::new(node(
8934                    42,
8935                    NodeKind::RecordConstruct {
8936                        path: type_path(&["Open"]),
8937                        fields: vec![AirRecordField {
8938                            name: ident("lambda"),
8939                            value: Some(Box::new(int_lit(43, "3"))),
8940                        }],
8941                        spread: None,
8942                    },
8943                )),
8944            },
8945        );
8946        let f = node(
8947            5,
8948            NodeKind::FnDecl {
8949                annotations: vec![],
8950                visibility: Visibility::Private,
8951                is_async: false,
8952                name: ident("main"),
8953                generic_params: vec![],
8954                params: vec![],
8955                return_type: None,
8956                effect_clause: vec![],
8957                where_clause: vec![],
8958                body: Box::new(block(6, vec![let_t, access, m, let_g], None)),
8959            },
8960        );
8961        let out = gen(&module(vec![], vec![rec, enum_decl, f]));
8962        assert!(
8963            out.contains("pass_: int"),
8964            "dataclass field must be keyword-escaped: {out}"
8965        );
8966        assert!(
8967            out.contains("Tally(pass_=7)"),
8968            "constructor kwargs must be keyword-escaped: {out}"
8969        );
8970        assert!(
8971            out.contains("t.pass_"),
8972            "field access must be keyword-escaped: {out}"
8973        );
8974        assert!(
8975            out.contains("Tally(pass_=p)"),
8976            "record-pattern destructuring must be keyword-escaped: {out}"
8977        );
8978        assert!(
8979            out.contains("lambda_: int"),
8980            "enum struct-variant field must be keyword-escaped: {out}"
8981        );
8982        assert!(
8983            out.contains("Gate_Open(lambda_=3)"),
8984            "variant constructor kwargs must be keyword-escaped: {out}"
8985        );
8986        assert!(
8987            !out.contains("pass:") && !out.contains("pass=") && !out.contains("lambda:"),
8988            "no field position may emit the unescaped keyword: {out}"
8989        );
8990    }
8991
8992    #[test]
8993    fn enum_to_dataclass_variants() {
8994        let enum_decl = node(
8995            1,
8996            NodeKind::EnumDecl {
8997                annotations: vec![],
8998                visibility: Visibility::Public,
8999                name: ident("Shape"),
9000                generic_params: vec![],
9001                variants: vec![
9002                    node(
9003                        2,
9004                        NodeKind::EnumVariant {
9005                            name: ident("Circle"),
9006                            payload: EnumVariantPayload::Struct(vec![bock_ast::RecordDeclField {
9007                                id: 0,
9008                                span: span(),
9009                                name: ident("radius"),
9010                                ty: bock_ast::TypeExpr::Named {
9011                                    id: 0,
9012                                    span: span(),
9013                                    path: type_path(&["Float"]),
9014                                    args: vec![],
9015                                },
9016                                default: None,
9017                            }]),
9018                        },
9019                    ),
9020                    node(
9021                        3,
9022                        NodeKind::EnumVariant {
9023                            name: ident("None"),
9024                            payload: EnumVariantPayload::Unit,
9025                        },
9026                    ),
9027                ],
9028            },
9029        );
9030        let out = gen(&module(vec![], vec![enum_decl]));
9031        assert!(
9032            out.contains("from dataclasses import dataclass"),
9033            "got: {out}"
9034        );
9035        assert!(out.contains("@dataclass"), "got: {out}");
9036        assert!(out.contains("class Shape_Circle:"), "got: {out}");
9037        assert!(out.contains("radius: float"), "got: {out}");
9038        assert!(out.contains("_tag: str = \"Circle\""), "got: {out}");
9039        assert!(out.contains("class Shape_None:"), "got: {out}");
9040        assert!(out.contains("_tag: str = \"None\""), "got: {out}");
9041    }
9042
9043    #[test]
9044    fn match_to_match_case() {
9045        let scrutinee = id_node(10, "shape");
9046        let arms = vec![
9047            node(
9048                11,
9049                NodeKind::MatchArm {
9050                    pattern: Box::new(node(
9051                        12,
9052                        NodeKind::ConstructorPat {
9053                            path: type_path(&["Shape", "Circle"]),
9054                            fields: vec![bind_pat(13, "r")],
9055                        },
9056                    )),
9057                    guard: None,
9058                    body: Box::new(block(
9059                        14,
9060                        vec![],
9061                        Some(node(
9062                            15,
9063                            NodeKind::BinaryOp {
9064                                op: BinOp::Mul,
9065                                left: Box::new(id_node(16, "r")),
9066                                right: Box::new(id_node(17, "r")),
9067                            },
9068                        )),
9069                    )),
9070                },
9071            ),
9072            node(
9073                18,
9074                NodeKind::MatchArm {
9075                    pattern: Box::new(node(19, NodeKind::WildcardPat)),
9076                    guard: None,
9077                    body: Box::new(block(20, vec![], Some(int_lit(21, "0")))),
9078                },
9079            ),
9080        ];
9081        let match_stmt = node(
9082            9,
9083            NodeKind::Match {
9084                scrutinee: Box::new(scrutinee),
9085                arms,
9086            },
9087        );
9088        let f = node(
9089            1,
9090            NodeKind::FnDecl {
9091                annotations: vec![],
9092                visibility: Visibility::Private,
9093                is_async: false,
9094                name: ident("area"),
9095                generic_params: vec![],
9096                params: vec![param_node(2, "shape")],
9097                return_type: None,
9098                effect_clause: vec![],
9099                where_clause: vec![],
9100                body: Box::new(block(3, vec![match_stmt], None)),
9101            },
9102        );
9103        let out = gen(&module(vec![], vec![f]));
9104        assert!(out.contains("match shape:"), "got: {out}");
9105        assert!(out.contains("case Shape_Circle(_0=r):"), "got: {out}");
9106        assert!(out.contains("case _:"), "got: {out}");
9107    }
9108
9109    /// An EXPRESSION-position user-enum `match` (a `match` consumed as a value,
9110    /// here bound into a `let`) lowers to a `(lambda __v: …)(scrutinee)` whose
9111    /// per-arm test is `isinstance(__v, <cls>)`. The variant class must be
9112    /// resolved through the registry to its `{enum}_{variant}` dataclass
9113    /// (`isinstance(__v, Light_Red)`), NOT the bare variant leaf name
9114    /// (`isinstance(__v, Red)`, an undefined name → `NameError`). Mirrors the
9115    /// statement-position `emit_pattern` resolution (Q-match-exprpos P4).
9116    #[test]
9117    fn expr_position_user_enum_match_test_resolves_variant_class() {
9118        let enum_decl = node(
9119            1,
9120            NodeKind::EnumDecl {
9121                annotations: vec![],
9122                visibility: Visibility::Public,
9123                name: ident("Light"),
9124                generic_params: vec![],
9125                variants: vec![
9126                    node(
9127                        2,
9128                        NodeKind::EnumVariant {
9129                            name: ident("Red"),
9130                            payload: EnumVariantPayload::Unit,
9131                        },
9132                    ),
9133                    node(
9134                        3,
9135                        NodeKind::EnumVariant {
9136                            name: ident("Green"),
9137                            payload: EnumVariantPayload::Unit,
9138                        },
9139                    ),
9140                ],
9141            },
9142        );
9143        // let n: Int = match l { Red => 1; _ => 0 }   (a value-position match)
9144        let red_arm = node(
9145            20,
9146            NodeKind::MatchArm {
9147                pattern: Box::new(node(
9148                    21,
9149                    NodeKind::ConstructorPat {
9150                        path: type_path(&["Red"]),
9151                        fields: vec![],
9152                    },
9153                )),
9154                guard: None,
9155                body: Box::new(block(22, vec![], Some(int_lit(23, "1")))),
9156            },
9157        );
9158        let default_arm = node(
9159            30,
9160            NodeKind::MatchArm {
9161                pattern: Box::new(node(31, NodeKind::WildcardPat)),
9162                guard: None,
9163                body: Box::new(block(32, vec![], Some(int_lit(33, "0")))),
9164            },
9165        );
9166        let m = node(
9167            40,
9168            NodeKind::Match {
9169                scrutinee: Box::new(id_node(41, "l")),
9170                arms: vec![red_arm, default_arm],
9171            },
9172        );
9173        let let_n = node(
9174            50,
9175            NodeKind::LetBinding {
9176                is_mut: false,
9177                pattern: Box::new(bind_pat(51, "n")),
9178                ty: Some(Box::new(node(
9179                    52,
9180                    NodeKind::TypeNamed {
9181                        path: type_path(&["Int"]),
9182                        args: vec![],
9183                    },
9184                ))),
9185                value: Box::new(m),
9186            },
9187        );
9188        let f = node(
9189            5,
9190            NodeKind::FnDecl {
9191                annotations: vec![],
9192                visibility: Visibility::Private,
9193                is_async: false,
9194                name: ident("rank"),
9195                generic_params: vec![],
9196                params: vec![param_node(6, "l")],
9197                return_type: None,
9198                effect_clause: vec![],
9199                where_clause: vec![],
9200                body: Box::new(block(7, vec![let_n], None)),
9201            },
9202        );
9203        let out = gen(&module(vec![], vec![enum_decl, f]));
9204        assert!(
9205            out.contains("isinstance(__v, Light_Red)"),
9206            "expr-position variant test must resolve to the dataclass Light_Red, got: {out}"
9207        );
9208        assert!(
9209            !out.contains("isinstance(__v, Red)"),
9210            "must not test against the bare variant leaf name (undefined), got: {out}"
9211        );
9212    }
9213
9214    #[test]
9215    fn ownership_erased() {
9216        let move_expr = node(
9217            1,
9218            NodeKind::Move {
9219                expr: Box::new(id_node(2, "x")),
9220            },
9221        );
9222        let borrow_expr = node(
9223            3,
9224            NodeKind::Borrow {
9225                expr: Box::new(id_node(4, "y")),
9226            },
9227        );
9228        let mut_borrow_expr = node(
9229            5,
9230            NodeKind::MutableBorrow {
9231                expr: Box::new(id_node(6, "z")),
9232            },
9233        );
9234        let body = block(
9235            7,
9236            vec![
9237                node(
9238                    8,
9239                    NodeKind::LetBinding {
9240                        is_mut: false,
9241                        pattern: Box::new(bind_pat(9, "a")),
9242                        ty: None,
9243                        value: Box::new(move_expr),
9244                    },
9245                ),
9246                node(
9247                    10,
9248                    NodeKind::LetBinding {
9249                        is_mut: false,
9250                        pattern: Box::new(bind_pat(11, "b")),
9251                        ty: None,
9252                        value: Box::new(borrow_expr),
9253                    },
9254                ),
9255                node(
9256                    12,
9257                    NodeKind::LetBinding {
9258                        is_mut: false,
9259                        pattern: Box::new(bind_pat(13, "c")),
9260                        ty: None,
9261                        value: Box::new(mut_borrow_expr),
9262                    },
9263                ),
9264            ],
9265            None,
9266        );
9267        let f = node(
9268            0,
9269            NodeKind::FnDecl {
9270                annotations: vec![],
9271                visibility: Visibility::Private,
9272                is_async: false,
9273                name: ident("test"),
9274                generic_params: vec![],
9275                params: vec![],
9276                return_type: None,
9277                effect_clause: vec![],
9278                where_clause: vec![],
9279                body: Box::new(body),
9280            },
9281        );
9282        let out = gen(&module(vec![], vec![f]));
9283        assert!(out.contains("a = x"), "got: {out}");
9284        assert!(out.contains("b = y"), "got: {out}");
9285        assert!(out.contains("c = z"), "got: {out}");
9286    }
9287
9288    #[test]
9289    fn string_interpolation_fstring() {
9290        let interp = node(
9291            1,
9292            NodeKind::Interpolation {
9293                parts: vec![
9294                    AirInterpolationPart::Literal("Hello, ".into()),
9295                    AirInterpolationPart::Expr(Box::new(id_node(2, "name"))),
9296                    AirInterpolationPart::Literal("!".into()),
9297                ],
9298            },
9299        );
9300        let binding = node(
9301            3,
9302            NodeKind::LetBinding {
9303                is_mut: false,
9304                pattern: Box::new(bind_pat(4, "msg")),
9305                ty: None,
9306                value: Box::new(interp),
9307            },
9308        );
9309        let f = node(
9310            0,
9311            NodeKind::FnDecl {
9312                annotations: vec![],
9313                visibility: Visibility::Private,
9314                is_async: false,
9315                name: ident("greet"),
9316                generic_params: vec![],
9317                params: vec![param_node(5, "name")],
9318                return_type: None,
9319                effect_clause: vec![],
9320                where_clause: vec![],
9321                body: Box::new(block(6, vec![binding], Some(id_node(7, "msg")))),
9322            },
9323        );
9324        let out = gen(&module(vec![], vec![f]));
9325        // `${name}` renders through `_bock_str`
9326        // (Q-displayable-interpolation-dispatch).
9327        assert!(out.contains("f\"Hello, {_bock_str(name)}!\""), "got: {out}");
9328    }
9329
9330    #[test]
9331    fn multiline_interpolation_uses_triple_quoted_fstring() {
9332        let interp = node(
9333            1,
9334            NodeKind::Interpolation {
9335                parts: vec![
9336                    AirInterpolationPart::Literal("=== ".into()),
9337                    AirInterpolationPart::Expr(Box::new(id_node(2, "title"))),
9338                    AirInterpolationPart::Literal(" ===\n".into()),
9339                    AirInterpolationPart::Expr(Box::new(id_node(3, "msg"))),
9340                    AirInterpolationPart::Literal("\n================".into()),
9341                ],
9342            },
9343        );
9344        let binding = node(
9345            4,
9346            NodeKind::LetBinding {
9347                is_mut: false,
9348                pattern: Box::new(bind_pat(5, "result")),
9349                ty: None,
9350                value: Box::new(interp),
9351            },
9352        );
9353        let f = node(
9354            0,
9355            NodeKind::FnDecl {
9356                annotations: vec![],
9357                visibility: Visibility::Private,
9358                is_async: false,
9359                name: ident("banner"),
9360                generic_params: vec![],
9361                params: vec![param_node(6, "title"), param_node(7, "msg")],
9362                return_type: None,
9363                effect_clause: vec![],
9364                where_clause: vec![],
9365                body: Box::new(block(8, vec![binding], Some(id_node(9, "result")))),
9366            },
9367        );
9368        let out = gen(&module(vec![], vec![f]));
9369        // Each `${expr}` part renders through `_bock_str` so a user value with a
9370        // `Displayable` impl dispatches through its `to_string`
9371        // (Q-displayable-interpolation-dispatch); primitives fall back to
9372        // `str(x)`.
9373        assert!(
9374            out.contains(
9375                "f\"\"\"=== {_bock_str(title)} ===\n{_bock_str(msg)}\n================\"\"\""
9376            ),
9377            "got: {out}"
9378        );
9379        // Single-line interpolation should still use regular f-string
9380        assert!(
9381            !out.contains("f\"Hello"),
9382            "single-line should not appear: {out}"
9383        );
9384    }
9385
9386    #[test]
9387    fn single_line_interpolation_still_uses_regular_fstring() {
9388        let interp = node(
9389            1,
9390            NodeKind::Interpolation {
9391                parts: vec![
9392                    AirInterpolationPart::Literal("Hi ".into()),
9393                    AirInterpolationPart::Expr(Box::new(id_node(2, "name"))),
9394                ],
9395            },
9396        );
9397        let binding = node(
9398            3,
9399            NodeKind::LetBinding {
9400                is_mut: false,
9401                pattern: Box::new(bind_pat(4, "greeting")),
9402                ty: None,
9403                value: Box::new(interp),
9404            },
9405        );
9406        let f = node(
9407            0,
9408            NodeKind::FnDecl {
9409                annotations: vec![],
9410                visibility: Visibility::Private,
9411                is_async: false,
9412                name: ident("greet"),
9413                generic_params: vec![],
9414                params: vec![param_node(5, "name")],
9415                return_type: None,
9416                effect_clause: vec![],
9417                where_clause: vec![],
9418                body: Box::new(block(6, vec![binding], Some(id_node(7, "greeting")))),
9419            },
9420        );
9421        let out = gen(&module(vec![], vec![f]));
9422        // `${name}` renders through `_bock_str`
9423        // (Q-displayable-interpolation-dispatch).
9424        assert!(out.contains("f\"Hi {_bock_str(name)}\""), "got: {out}");
9425        assert!(
9426            !out.contains("f\"\"\""),
9427            "should not use triple quotes: {out}"
9428        );
9429    }
9430
9431    #[test]
9432    fn list_map_set_literals() {
9433        let list = node(
9434            1,
9435            NodeKind::ListLiteral {
9436                elems: vec![int_lit(2, "1"), int_lit(3, "2"), int_lit(4, "3")],
9437            },
9438        );
9439        let map = node(
9440            5,
9441            NodeKind::MapLiteral {
9442                entries: vec![bock_air::AirMapEntry {
9443                    key: str_lit(6, "a"),
9444                    value: int_lit(7, "1"),
9445                }],
9446            },
9447        );
9448        let set = node(
9449            8,
9450            NodeKind::SetLiteral {
9451                elems: vec![int_lit(9, "1"), int_lit(10, "2")],
9452            },
9453        );
9454        let body = block(
9455            11,
9456            vec![
9457                node(
9458                    12,
9459                    NodeKind::LetBinding {
9460                        is_mut: false,
9461                        pattern: Box::new(bind_pat(13, "xs")),
9462                        ty: None,
9463                        value: Box::new(list),
9464                    },
9465                ),
9466                node(
9467                    14,
9468                    NodeKind::LetBinding {
9469                        is_mut: false,
9470                        pattern: Box::new(bind_pat(15, "m")),
9471                        ty: None,
9472                        value: Box::new(map),
9473                    },
9474                ),
9475                node(
9476                    16,
9477                    NodeKind::LetBinding {
9478                        is_mut: false,
9479                        pattern: Box::new(bind_pat(17, "s")),
9480                        ty: None,
9481                        value: Box::new(set),
9482                    },
9483                ),
9484            ],
9485            None,
9486        );
9487        let f = node(
9488            0,
9489            NodeKind::FnDecl {
9490                annotations: vec![],
9491                visibility: Visibility::Private,
9492                is_async: false,
9493                name: ident("collections"),
9494                generic_params: vec![],
9495                params: vec![],
9496                return_type: None,
9497                effect_clause: vec![],
9498                where_clause: vec![],
9499                body: Box::new(body),
9500            },
9501        );
9502        let out = gen(&module(vec![], vec![f]));
9503        assert!(out.contains("[1, 2, 3]"), "got: {out}");
9504        assert!(out.contains("{\"a\": 1}"), "got: {out}");
9505        assert!(out.contains("{1, 2}"), "got: {out}");
9506    }
9507
9508    #[test]
9509    fn record_construction() {
9510        let rec = node(
9511            1,
9512            NodeKind::RecordConstruct {
9513                path: type_path(&["User"]),
9514                fields: vec![
9515                    AirRecordField {
9516                        name: ident("name"),
9517                        value: Some(Box::new(str_lit(2, "Alice"))),
9518                    },
9519                    AirRecordField {
9520                        name: ident("age"),
9521                        value: Some(Box::new(int_lit(3, "30"))),
9522                    },
9523                ],
9524                spread: None,
9525            },
9526        );
9527        let binding = node(
9528            4,
9529            NodeKind::LetBinding {
9530                is_mut: false,
9531                pattern: Box::new(bind_pat(5, "user")),
9532                ty: None,
9533                value: Box::new(rec),
9534            },
9535        );
9536        let f = node(
9537            0,
9538            NodeKind::FnDecl {
9539                annotations: vec![],
9540                visibility: Visibility::Private,
9541                is_async: false,
9542                name: ident("test"),
9543                generic_params: vec![],
9544                params: vec![],
9545                return_type: None,
9546                effect_clause: vec![],
9547                where_clause: vec![],
9548                body: Box::new(block(6, vec![binding], None)),
9549            },
9550        );
9551        let out = gen(&module(vec![], vec![f]));
9552        assert!(out.contains("User(name=\"Alice\", age=30)"), "got: {out}");
9553    }
9554
9555    #[test]
9556    fn control_flow() {
9557        let if_stmt = node(
9558            1,
9559            NodeKind::If {
9560                let_pattern: None,
9561                condition: Box::new(bool_lit(2, true)),
9562                then_block: Box::new(block(3, vec![], Some(int_lit(4, "1")))),
9563                else_block: Some(Box::new(block(5, vec![], Some(int_lit(6, "2"))))),
9564            },
9565        );
9566        let for_stmt = node(
9567            7,
9568            NodeKind::For {
9569                pattern: Box::new(bind_pat(8, "x")),
9570                iterable: Box::new(id_node(9, "items")),
9571                body: Box::new(block(10, vec![], None)),
9572            },
9573        );
9574        let while_stmt = node(
9575            11,
9576            NodeKind::While {
9577                condition: Box::new(bool_lit(12, true)),
9578                body: Box::new(block(
9579                    13,
9580                    vec![node(14, NodeKind::Break { value: None })],
9581                    None,
9582                )),
9583            },
9584        );
9585        let body = block(15, vec![if_stmt, for_stmt, while_stmt], None);
9586        let f = node(
9587            0,
9588            NodeKind::FnDecl {
9589                annotations: vec![],
9590                visibility: Visibility::Private,
9591                is_async: false,
9592                name: ident("flow"),
9593                generic_params: vec![],
9594                params: vec![param_node(16, "items")],
9595                return_type: None,
9596                effect_clause: vec![],
9597                where_clause: vec![],
9598                body: Box::new(body),
9599            },
9600        );
9601        let out = gen(&module(vec![], vec![f]));
9602        assert!(out.contains("if True:"), "got: {out}");
9603        assert!(out.contains("else:"), "got: {out}");
9604        assert!(out.contains("for x in items:"), "got: {out}");
9605        assert!(out.contains("while True:"), "got: {out}");
9606        assert!(out.contains("break"), "got: {out}");
9607    }
9608
9609    // ── Statement-position loop tails must NOT be `return`ed ─────────────────
9610    //
9611    // A loop body's final expression is a *statement* in Bock — the loop
9612    // evaluates to Unit, the body's value is discarded. The Python backend's
9613    // shared `emit_block_body` had emitted a tail expression as a function-body
9614    // `return`, so e.g. `for i in 1..=3 { println(i) }` lowered to
9615    // `for i in …: return print(i)` — the `return` exits `main` on the FIRST
9616    // iteration (the loop runs once, then the function returns). fizzbuzz
9617    // printed one line, inventory-system listed one product. These tests pin
9618    // the bare-statement discard for each loop kind.
9619
9620    /// Build a `println(<arg>)` call node.
9621    fn py_println_call(id: u32, arg: AIRNode) -> AIRNode {
9622        node(
9623            id,
9624            NodeKind::Call {
9625                callee: Box::new(id_node(id + 1, "println")),
9626                args: vec![AirArg {
9627                    label: None,
9628                    value: arg,
9629                }],
9630                type_args: vec![],
9631            },
9632        )
9633    }
9634
9635    #[test]
9636    fn py_for_loop_body_tail_call_is_statement_not_returned() {
9637        // fn main() { for i in 1..=3 { println(i) } }
9638        let range = node(
9639            20,
9640            NodeKind::Range {
9641                lo: Box::new(int_lit(21, "1")),
9642                hi: Box::new(int_lit(22, "3")),
9643                inclusive: true,
9644            },
9645        );
9646        let loop_body = block(30, vec![], Some(py_println_call(31, id_node(33, "i"))));
9647        let for_loop = node(
9648            10,
9649            NodeKind::For {
9650                pattern: Box::new(bind_pat(11, "i")),
9651                iterable: Box::new(range),
9652                body: Box::new(loop_body),
9653            },
9654        );
9655        let f = fn_decl_body(0, "main", block(2, vec![for_loop], None));
9656        let out = gen(&module(vec![], vec![f]));
9657        assert!(
9658            !out.contains("return print(i)"),
9659            "for-loop body tail must be a bare statement, not `return` (would abort \
9660             main after one iteration); got:\n{out}"
9661        );
9662        assert!(
9663            out.contains("print(i)"),
9664            "for-loop body tail call must still be emitted; got:\n{out}"
9665        );
9666    }
9667
9668    #[test]
9669    fn py_while_loop_body_tail_call_is_statement_not_returned() {
9670        // fn main() { while cond { println(i) } }
9671        let loop_body = block(30, vec![], Some(py_println_call(31, id_node(33, "i"))));
9672        let while_loop = node(
9673            10,
9674            NodeKind::While {
9675                condition: Box::new(bool_lit(12, true)),
9676                body: Box::new(loop_body),
9677            },
9678        );
9679        let f = fn_decl_body(0, "main", block(2, vec![while_loop], None));
9680        let out = gen(&module(vec![], vec![f]));
9681        assert!(
9682            !out.contains("return print(i)"),
9683            "while-loop body tail must be a bare statement, not `return`; got:\n{out}"
9684        );
9685        assert!(
9686            out.contains("print(i)"),
9687            "while-loop body tail call must still be emitted; got:\n{out}"
9688        );
9689    }
9690
9691    #[test]
9692    fn py_infinite_loop_body_tail_call_is_statement_not_returned() {
9693        // fn main() { loop { println(i) } }
9694        let loop_body = block(30, vec![], Some(py_println_call(31, id_node(33, "i"))));
9695        let inf_loop = node(
9696            10,
9697            NodeKind::Loop {
9698                body: Box::new(loop_body),
9699            },
9700        );
9701        let f = fn_decl_body(0, "main", block(2, vec![inf_loop], None));
9702        let out = gen(&module(vec![], vec![f]));
9703        assert!(
9704            !out.contains("return print(i)"),
9705            "loop body tail must be a bare statement, not `return`; got:\n{out}"
9706        );
9707        assert!(
9708            out.contains("print(i)"),
9709            "loop body tail call must still be emitted; got:\n{out}"
9710        );
9711    }
9712
9713    #[test]
9714    fn py_function_body_tail_call_still_returned() {
9715        // Guard against over-correction: a *function* body tail must still
9716        // `return` its value (this is NOT statement position).
9717        // fn answer() { 42 }
9718        let f = fn_decl_body(0, "answer", block(2, vec![], Some(int_lit(3, "42"))));
9719        let out = gen(&module(vec![], vec![f]));
9720        assert!(
9721            out.contains("return 42"),
9722            "function-body tail must still be returned; got:\n{out}"
9723        );
9724    }
9725
9726    #[test]
9727    fn lambda_and_pipe() {
9728        let lambda = node(
9729            1,
9730            NodeKind::Lambda {
9731                params: vec![param_node(2, "x")],
9732                body: Box::new(node(
9733                    3,
9734                    NodeKind::BinaryOp {
9735                        op: BinOp::Mul,
9736                        left: Box::new(id_node(4, "x")),
9737                        right: Box::new(int_lit(5, "2")),
9738                    },
9739                )),
9740            },
9741        );
9742        let pipe = node(
9743            6,
9744            NodeKind::Pipe {
9745                left: Box::new(int_lit(7, "5")),
9746                right: Box::new(id_node(8, "double")),
9747            },
9748        );
9749        let body = block(
9750            9,
9751            vec![
9752                node(
9753                    10,
9754                    NodeKind::LetBinding {
9755                        is_mut: false,
9756                        pattern: Box::new(bind_pat(11, "double")),
9757                        ty: None,
9758                        value: Box::new(lambda),
9759                    },
9760                ),
9761                node(
9762                    12,
9763                    NodeKind::LetBinding {
9764                        is_mut: false,
9765                        pattern: Box::new(bind_pat(13, "result")),
9766                        ty: None,
9767                        value: Box::new(pipe),
9768                    },
9769                ),
9770            ],
9771            None,
9772        );
9773        let f = node(
9774            0,
9775            NodeKind::FnDecl {
9776                annotations: vec![],
9777                visibility: Visibility::Private,
9778                is_async: false,
9779                name: ident("test"),
9780                generic_params: vec![],
9781                params: vec![],
9782                return_type: None,
9783                effect_clause: vec![],
9784                where_clause: vec![],
9785                body: Box::new(body),
9786            },
9787        );
9788        let out = gen(&module(vec![], vec![f]));
9789        assert!(out.contains("lambda x: (x * 2)"), "got: {out}");
9790        assert!(out.contains("double(5)"), "got: {out}");
9791    }
9792
9793    /// A `Call` whose callee is a `Lambda` must parenthesize the lambda so the
9794    /// trailing argument list invokes the lambda, not its body. Without the
9795    /// grouping, `lambda x: x(42)` parses as `lambda x: (x(42))` — the `(42)`
9796    /// binds to the body, never calling the lambda.
9797    ///
9798    /// This is the shape the AIR compose desugar (`f >> g` →
9799    /// `(__compose_x) => g(f(__compose_x))`) produces for chained `>>`: the
9800    /// inner compose lowers to a `Lambda`, which then appears as the callee
9801    /// `f` in the outer `f(__compose_x)`. See examples/real-world/data-pipeline
9802    /// (`normalize >> compute_summary >> format_summary`).
9803    #[test]
9804    fn py_call_with_lambda_callee_parenthesizes() {
9805        // (lambda x: x)(42)
9806        let lambda = node(
9807            1,
9808            NodeKind::Lambda {
9809                params: vec![param_node(2, "x")],
9810                body: Box::new(id_node(3, "x")),
9811            },
9812        );
9813        let call = node(
9814            4,
9815            NodeKind::Call {
9816                callee: Box::new(lambda),
9817                args: vec![AirArg {
9818                    label: None,
9819                    value: int_lit(5, "42"),
9820                }],
9821                type_args: vec![],
9822            },
9823        );
9824        let f = fn_decl_tail(0, Visibility::Private, "test", call);
9825        let out = gen(&module(vec![], vec![f]));
9826        assert!(
9827            out.contains("(lambda x: x)(42)"),
9828            "lambda callee must be parenthesized so it is invoked; got: {out}"
9829        );
9830    }
9831
9832    #[test]
9833    fn const_declaration() {
9834        let c = node(
9835            1,
9836            NodeKind::ConstDecl {
9837                annotations: vec![],
9838                visibility: Visibility::Public,
9839                name: ident("PI"),
9840                ty: Box::new(node(
9841                    2,
9842                    NodeKind::TypeNamed {
9843                        path: type_path(&["Float"]),
9844                        args: vec![],
9845                    },
9846                )),
9847                value: Box::new(node(
9848                    3,
9849                    NodeKind::Literal {
9850                        lit: Literal::Float("3.14159".into()),
9851                    },
9852                )),
9853            },
9854        );
9855        let out = gen(&module(vec![], vec![c]));
9856        // PI should become snake case, but since it's all-caps we leave it
9857        assert!(out.contains("= 3.14159"), "got: {out}");
9858        assert!(out.contains(": float"), "got: {out}");
9859    }
9860
9861    #[test]
9862    fn class_declaration() {
9863        let method_body = block(10, vec![], Some(id_node(11, "undefined")));
9864        let method = node(
9865            5,
9866            NodeKind::FnDecl {
9867                annotations: vec![],
9868                visibility: Visibility::Public,
9869                is_async: false,
9870                name: ident("greet"),
9871                generic_params: vec![],
9872                // Instance method leads with `self` (real lowering); a no-`self`
9873                // method is an associated `@staticmethod`.
9874                params: vec![param_node(6, "self")],
9875                return_type: None,
9876                effect_clause: vec![],
9877                where_clause: vec![],
9878                body: Box::new(method_body),
9879            },
9880        );
9881        let cls = node(
9882            1,
9883            NodeKind::ClassDecl {
9884                annotations: vec![],
9885                visibility: Visibility::Public,
9886                name: ident("Person"),
9887                generic_params: vec![],
9888                base: None,
9889                traits: vec![],
9890                fields: vec![bock_ast::RecordDeclField {
9891                    id: 0,
9892                    span: span(),
9893                    name: ident("name"),
9894                    ty: bock_ast::TypeExpr::Named {
9895                        id: 0,
9896                        span: span(),
9897                        path: type_path(&["String"]),
9898                        args: vec![],
9899                    },
9900                    default: None,
9901                }],
9902                methods: vec![method],
9903            },
9904        );
9905        let out = gen(&module(vec![], vec![cls]));
9906        assert!(out.contains("class Person:"), "got: {out}");
9907        assert!(out.contains("def __init__(self, name: str):"), "got: {out}");
9908        assert!(out.contains("self.name = name"), "got: {out}");
9909        assert!(out.contains("def greet(self):"), "got: {out}");
9910    }
9911
9912    /// A self-method `fn <name>(self) -> String { "<lit>" }`, for class/impl
9913    /// fixtures.
9914    fn self_method_returning(id: u32, name: &str, lit: &str) -> AIRNode {
9915        node(
9916            id,
9917            NodeKind::FnDecl {
9918                annotations: vec![],
9919                visibility: Visibility::Public,
9920                is_async: false,
9921                name: ident(name),
9922                generic_params: vec![],
9923                params: vec![param_node(id + 1, "self")],
9924                return_type: Some(Box::new(node(
9925                    id + 2,
9926                    NodeKind::TypeNamed {
9927                        path: type_path(&["String"]),
9928                        args: vec![],
9929                    },
9930                ))),
9931                effect_clause: vec![],
9932                where_clause: vec![],
9933                body: Box::new(block(id + 3, vec![], Some(str_lit(id + 4, lit)))),
9934            },
9935        )
9936    }
9937
9938    /// An `impl <trait?> for <target>` block carrying `methods`.
9939    fn impl_block_node(
9940        id: u32,
9941        target: &str,
9942        trait_name: Option<&str>,
9943        methods: Vec<AIRNode>,
9944    ) -> AIRNode {
9945        node(
9946            id,
9947            NodeKind::ImplBlock {
9948                annotations: vec![],
9949                target: Box::new(node(
9950                    id + 1,
9951                    NodeKind::TypeNamed {
9952                        path: type_path(&[target]),
9953                        args: vec![],
9954                    },
9955                )),
9956                trait_path: trait_name.map(|t| type_path(&[t])),
9957                trait_args: vec![],
9958                generic_params: vec![],
9959                where_clause: vec![],
9960                methods,
9961            },
9962        )
9963    }
9964
9965    /// A `trait <name> { fn <m>(self) -> String }` declaration (ABC stub).
9966    fn trait_node(id: u32, name: &str, method: &str) -> AIRNode {
9967        node(
9968            id,
9969            NodeKind::TraitDecl {
9970                annotations: vec![],
9971                visibility: Visibility::Public,
9972                is_platform: false,
9973                name: ident(name),
9974                generic_params: vec![],
9975                associated_types: vec![],
9976                methods: vec![self_method_returning(id + 50, method, "")],
9977            },
9978        )
9979    }
9980
9981    /// A `class <name> { <field>: String }` with no inline methods.
9982    fn class_with_field(id: u32, name: &str, field: &str) -> AIRNode {
9983        node(
9984            id,
9985            NodeKind::ClassDecl {
9986                annotations: vec![],
9987                visibility: Visibility::Public,
9988                name: ident(name),
9989                generic_params: vec![],
9990                base: None,
9991                traits: vec![],
9992                fields: vec![named_field(field, "String")],
9993                methods: vec![],
9994            },
9995        )
9996    }
9997
9998    /// Q-class-codegen (py): a `class T` with an inherent `impl T` and a trait
9999    /// `impl Trait for T` must route BOTH impls' methods into the class body —
10000    /// the same path records already use — and subclass the trait ABC. Before
10001    /// the fix the Python backend emitted `class T:` with only `__init__`,
10002    /// silently DROPPING every impl/trait method.
10003    #[test]
10004    fn py_class_attaches_inherent_and_trait_impl_methods() {
10005        let cls = class_with_field(1, "Widget", "name");
10006        let inherent = impl_block_node(
10007            10,
10008            "Widget",
10009            None,
10010            vec![self_method_returning(11, "describe", "a widget")],
10011        );
10012        let trait_decl = trait_node(20, "Render", "render");
10013        let trait_impl = impl_block_node(
10014            30,
10015            "Widget",
10016            Some("Render"),
10017            vec![self_method_returning(31, "render", "<widget/>")],
10018        );
10019        let out = gen(&module(vec![], vec![trait_decl, cls, inherent, trait_impl]));
10020        // The inherent-impl method is attached to the class body.
10021        assert!(
10022            out.contains("def describe(self)"),
10023            "inherent-impl method must be attached to the class, got:\n{out}"
10024        );
10025        // The trait-impl method is attached to the class body.
10026        assert!(
10027            out.contains("def render(self)"),
10028            "trait-impl method must be attached to the class, got:\n{out}"
10029        );
10030        // The class subclasses the trait ABC for real dispatch.
10031        assert!(
10032            out.contains("class Widget(Render):"),
10033            "class must subclass the implemented trait, got:\n{out}"
10034        );
10035        // No orphan module-level `# impl` functions left behind.
10036        assert!(
10037            !out.contains("\ndef describe("),
10038            "impl method must not leak as a module-level function, got:\n{out}"
10039        );
10040    }
10041
10042    /// Q-class-codegen behavioral check: a generated class actually dispatches
10043    /// its inherent and trait methods at runtime.
10044    #[test]
10045    fn py_class_methods_dispatch_at_runtime() {
10046        if !has_python3() {
10047            return;
10048        }
10049        let cls = class_with_field(1, "Widget", "name");
10050        let inherent = impl_block_node(
10051            10,
10052            "Widget",
10053            None,
10054            vec![self_method_returning(11, "describe", "a widget")],
10055        );
10056        let trait_decl = trait_node(20, "Render", "render");
10057        let trait_impl = impl_block_node(
10058            30,
10059            "Widget",
10060            Some("Render"),
10061            vec![self_method_returning(31, "render", "<widget/>")],
10062        );
10063        let out = gen(&module(vec![], vec![trait_decl, cls, inherent, trait_impl]));
10064        let program =
10065            format!("{out}\nw = Widget(name=\"x\")\nprint(w.describe())\nprint(w.render())\n");
10066        let got = run_py(&program);
10067        assert_eq!(got, "a widget\n<widget/>", "got:\n{got}\nfrom:\n{out}");
10068    }
10069
10070    /// Q-py-impl-before-trait (ordering): a class/record that subclasses a trait
10071    /// ABC must be emitted AFTER the trait is defined. Here the trait `Render`
10072    /// is declared textually AFTER the record `Widget` that impls it; naive
10073    /// source-order emission produced `class Widget(Render):` before `Render`
10074    /// existed → `NameError`. The fix topologically orders type decls so a base
10075    /// precedes every subclass.
10076    #[test]
10077    fn py_trait_emitted_before_subclassing_record() {
10078        // record Widget { name: String } — declared FIRST.
10079        let rec = node(
10080            1,
10081            NodeKind::RecordDecl {
10082                annotations: vec![],
10083                visibility: Visibility::Public,
10084                name: ident("Widget"),
10085                generic_params: vec![],
10086                fields: vec![named_field("name", "String")],
10087            },
10088        );
10089        let trait_impl = impl_block_node(
10090            10,
10091            "Widget",
10092            Some("Render"),
10093            vec![self_method_returning(11, "render", "<w/>")],
10094        );
10095        // trait Render — declared LAST, after the record + its impl.
10096        let trait_decl = trait_node(20, "Render", "render");
10097        let out = gen(&module(vec![], vec![rec, trait_impl, trait_decl]));
10098        let widget_pos = out
10099            .find("class Widget(Render):")
10100            .unwrap_or_else(|| panic!("expected `class Widget(Render):`, got:\n{out}"));
10101        let render_pos = out
10102            .find("class Render:")
10103            .unwrap_or_else(|| panic!("expected `class Render:`, got:\n{out}"));
10104        assert!(
10105            render_pos < widget_pos,
10106            "trait ABC `Render` must be emitted before subclass `Widget`, got:\n{out}"
10107        );
10108        // And it must actually import/parse + run without NameError.
10109        if has_python3() {
10110            assert!(
10111                check_py_syntax(&out),
10112                "ordered output must parse, got:\n{out}"
10113            );
10114            let program = format!("{out}\nw = Widget(name=\"x\")\nprint(w.render())\n");
10115            assert_eq!(run_py(&program), "<w/>", "got from:\n{out}");
10116        }
10117    }
10118
10119    /// Q-class-codegen recursion guard: when an inherent `impl T { fn render }`
10120    /// and a trait `impl Trait for T { fn render }` share a method name, Python's
10121    /// single per-class namespace must keep exactly ONE `def render` — the
10122    /// inherent (concrete) one. Emitting both made the delegating trait body
10123    /// (`self.render()`) overwrite and call itself → `RecursionError`
10124    /// (react-components' `Button`).
10125    #[test]
10126    fn py_inherent_method_wins_over_colliding_trait_method() {
10127        let cls = class_with_field(1, "Button", "label");
10128        // inherent: concrete body.
10129        let inherent = impl_block_node(
10130            10,
10131            "Button",
10132            None,
10133            vec![self_method_returning(11, "render", "<button/>")],
10134        );
10135        let trait_decl = trait_node(20, "Component", "render");
10136        // trait impl: delegates to the inherent method (`self.render()`).
10137        let trait_render = node(
10138            31,
10139            NodeKind::FnDecl {
10140                annotations: vec![],
10141                visibility: Visibility::Public,
10142                is_async: false,
10143                name: ident("render"),
10144                generic_params: vec![],
10145                params: vec![param_node(32, "self")],
10146                return_type: Some(Box::new(node(
10147                    33,
10148                    NodeKind::TypeNamed {
10149                        path: type_path(&["String"]),
10150                        args: vec![],
10151                    },
10152                ))),
10153                effect_clause: vec![],
10154                where_clause: vec![],
10155                body: Box::new(block(
10156                    34,
10157                    vec![],
10158                    Some(node(
10159                        35,
10160                        NodeKind::Call {
10161                            callee: Box::new(node(
10162                                36,
10163                                NodeKind::FieldAccess {
10164                                    object: Box::new(id_node(37, "self")),
10165                                    field: ident("render"),
10166                                },
10167                            )),
10168                            type_args: vec![],
10169                            args: vec![],
10170                        },
10171                    )),
10172                )),
10173            },
10174        );
10175        let trait_impl = impl_block_node(30, "Button", Some("Component"), vec![trait_render]);
10176        let out = gen(&module(vec![], vec![trait_decl, cls, inherent, trait_impl]));
10177        // Exactly one `def render` in the Button class — count occurrences.
10178        let count = out.matches("def render(self)").count();
10179        assert_eq!(
10180            count,
10181            2, // one in the `Component` ABC stub, one in `Button`
10182            "expected one `render` in Button + one in the ABC, got {count}:\n{out}"
10183        );
10184        // The kept Button method is the inherent (concrete) one, not the
10185        // self-delegating trait one.
10186        assert!(
10187            out.contains("return \"<button/>\""),
10188            "Button.render must be the concrete inherent body, got:\n{out}"
10189        );
10190        if has_python3() {
10191            let program = format!("{out}\nb = Button(label=\"x\")\nprint(b.render())\n");
10192            assert_eq!(run_py(&program), "<button/>", "got from:\n{out}");
10193        }
10194    }
10195
10196    /// A class declared with an inline `base` (another class) must be emitted
10197    /// after that base class, even when source order puts the subclass first.
10198    #[test]
10199    fn py_base_class_emitted_before_subclass() {
10200        // class Sub (base = Base) — declared FIRST.
10201        let sub = node(
10202            1,
10203            NodeKind::ClassDecl {
10204                annotations: vec![],
10205                visibility: Visibility::Public,
10206                name: ident("Sub"),
10207                generic_params: vec![],
10208                base: Some(type_path(&["Base"])),
10209                traits: vec![],
10210                fields: vec![named_field("name", "String")],
10211                methods: vec![],
10212            },
10213        );
10214        // class Base — declared LAST.
10215        let base = class_with_field(10, "Base", "id");
10216        let out = gen(&module(vec![], vec![sub, base]));
10217        let base_pos = out
10218            .find("class Base:")
10219            .unwrap_or_else(|| panic!("expected `class Base:`, got:\n{out}"));
10220        let sub_pos = out
10221            .find("class Sub(Base):")
10222            .unwrap_or_else(|| panic!("expected `class Sub(Base):`, got:\n{out}"));
10223        assert!(
10224            base_pos < sub_pos,
10225            "base class must be emitted before subclass, got:\n{out}"
10226        );
10227    }
10228
10229    /// An `effect` is emitted as an `(ABC)` base class that an `impl Effect for
10230    /// T` makes a base of `T` (`class StubChannel(Channel):`). An effect declared
10231    /// AFTER its impl must still be emitted before the implementing record, else
10232    /// the base list `(Channel)` raises `NameError` (chat-protocol's `Channel`).
10233    #[test]
10234    fn py_effect_emitted_before_subclassing_record() {
10235        // record StubChannel {} — declared FIRST, impls the effect.
10236        let rec = node(
10237            1,
10238            NodeKind::RecordDecl {
10239                annotations: vec![],
10240                visibility: Visibility::Public,
10241                name: ident("StubChannel"),
10242                generic_params: vec![],
10243                fields: vec![named_field("tag", "String")],
10244            },
10245        );
10246        let chan_impl = impl_block_node(
10247            10,
10248            "StubChannel",
10249            Some("Channel"),
10250            vec![self_method_returning(11, "send", "sent")],
10251        );
10252        // effect Channel { fn send(self) -> String } — declared LAST.
10253        let effect = node(
10254            20,
10255            NodeKind::EffectDecl {
10256                annotations: vec![],
10257                visibility: Visibility::Public,
10258                name: ident("Channel"),
10259                generic_params: vec![],
10260                components: vec![],
10261                operations: vec![self_method_returning(21, "send", "")],
10262            },
10263        );
10264        let out = gen(&module(vec![], vec![rec, chan_impl, effect]));
10265        let chan_pos = out
10266            .find("class Channel(ABC):")
10267            .unwrap_or_else(|| panic!("expected `class Channel(ABC):`, got:\n{out}"));
10268        let stub_pos = out
10269            .find("class StubChannel(Channel):")
10270            .unwrap_or_else(|| panic!("expected `class StubChannel(Channel):`, got:\n{out}"));
10271        assert!(
10272            chan_pos < stub_pos,
10273            "effect ABC `Channel` must precede the record that impls it, got:\n{out}"
10274        );
10275    }
10276
10277    #[test]
10278    fn boolean_operators() {
10279        let expr = node(
10280            1,
10281            NodeKind::BinaryOp {
10282                op: BinOp::And,
10283                left: Box::new(bool_lit(2, true)),
10284                right: Box::new(bool_lit(3, false)),
10285            },
10286        );
10287        let not_expr = node(
10288            4,
10289            NodeKind::UnaryOp {
10290                op: UnaryOp::Not,
10291                operand: Box::new(bool_lit(5, true)),
10292            },
10293        );
10294        let body = block(
10295            6,
10296            vec![
10297                node(
10298                    7,
10299                    NodeKind::LetBinding {
10300                        is_mut: false,
10301                        pattern: Box::new(bind_pat(8, "a")),
10302                        ty: None,
10303                        value: Box::new(expr),
10304                    },
10305                ),
10306                node(
10307                    9,
10308                    NodeKind::LetBinding {
10309                        is_mut: false,
10310                        pattern: Box::new(bind_pat(10, "b")),
10311                        ty: None,
10312                        value: Box::new(not_expr),
10313                    },
10314                ),
10315            ],
10316            None,
10317        );
10318        let f = node(
10319            0,
10320            NodeKind::FnDecl {
10321                annotations: vec![],
10322                visibility: Visibility::Private,
10323                is_async: false,
10324                name: ident("test"),
10325                generic_params: vec![],
10326                params: vec![],
10327                return_type: None,
10328                effect_clause: vec![],
10329                where_clause: vec![],
10330                body: Box::new(body),
10331            },
10332        );
10333        let out = gen(&module(vec![], vec![f]));
10334        assert!(out.contains("(True and False)"), "got: {out}");
10335        assert!(out.contains("not True"), "got: {out}");
10336    }
10337
10338    #[test]
10339    fn result_construct() {
10340        let ok = node(
10341            1,
10342            NodeKind::ResultConstruct {
10343                variant: ResultVariant::Ok,
10344                value: Some(Box::new(int_lit(2, "42"))),
10345            },
10346        );
10347        let err = node(
10348            3,
10349            NodeKind::ResultConstruct {
10350                variant: ResultVariant::Err,
10351                value: Some(Box::new(str_lit(4, "failed"))),
10352            },
10353        );
10354        let body = block(
10355            5,
10356            vec![
10357                node(
10358                    6,
10359                    NodeKind::LetBinding {
10360                        is_mut: false,
10361                        pattern: Box::new(bind_pat(7, "good")),
10362                        ty: None,
10363                        value: Box::new(ok),
10364                    },
10365                ),
10366                node(
10367                    8,
10368                    NodeKind::LetBinding {
10369                        is_mut: false,
10370                        pattern: Box::new(bind_pat(9, "bad")),
10371                        ty: None,
10372                        value: Box::new(err),
10373                    },
10374                ),
10375            ],
10376            None,
10377        );
10378        let f = node(
10379            0,
10380            NodeKind::FnDecl {
10381                annotations: vec![],
10382                visibility: Visibility::Private,
10383                is_async: false,
10384                name: ident("test"),
10385                generic_params: vec![],
10386                params: vec![],
10387                return_type: None,
10388                effect_clause: vec![],
10389                where_clause: vec![],
10390                body: Box::new(body),
10391            },
10392        );
10393        let out = gen(&module(vec![], vec![f]));
10394        // Reconciled on the `_BockOk`/`_BockErr` runtime classes the `Result`
10395        // match reads (the old dict-with-`value`/`error`-keys shape disagreed).
10396        assert!(out.contains("_BockOk(42)"), "got: {out}");
10397        assert!(out.contains("_BockErr(\"failed\")"), "got: {out}");
10398    }
10399
10400    /// A `let`-binding node (immutable, simple bind pattern).
10401    fn let_node(id: u32, name: &str, value: AIRNode) -> AIRNode {
10402        node(
10403            id,
10404            NodeKind::LetBinding {
10405                is_mut: false,
10406                pattern: Box::new(bind_pat(id + 1, name)),
10407                ty: None,
10408                value: Box::new(value),
10409            },
10410        )
10411    }
10412
10413    /// `expr?` — a `Propagate` over `expr`.
10414    fn propagate(id: u32, expr: AIRNode) -> AIRNode {
10415        node(
10416            id,
10417            NodeKind::Propagate {
10418                expr: Box::new(expr),
10419            },
10420        )
10421    }
10422
10423    /// `fn() -> Result[..]` whose body is `let v = inner?` then a tail `Ok(v)`.
10424    /// Exercises the `?` lowering: `_bock_try(..)` + the try/except envelope.
10425    #[test]
10426    fn propagate_unwraps_and_wraps_body() {
10427        let inner_call = node(
10428            10,
10429            NodeKind::Call {
10430                callee: Box::new(id_node(11, "fallible")),
10431                args: vec![],
10432                type_args: vec![],
10433            },
10434        );
10435        let body = block(
10436            2,
10437            vec![let_node(3, "v", propagate(4, inner_call))],
10438            Some(node(
10439                6,
10440                NodeKind::ResultConstruct {
10441                    variant: ResultVariant::Ok,
10442                    value: Some(Box::new(id_node(7, "v"))),
10443                },
10444            )),
10445        );
10446        let f = fn_decl_body(1, "do_it", body);
10447        let out = gen(&module(vec![], vec![f]));
10448        // `?` lowers to the unwrap helper, not a bare passthrough.
10449        assert!(out.contains("_bock_try(fallible())"), "got: {out}");
10450        // The function body is wrapped in the propagate envelope.
10451        assert!(out.contains("try:"), "got: {out}");
10452        assert!(
10453            out.contains("except _BockPropagate as __bock_p:"),
10454            "got: {out}"
10455        );
10456        assert!(out.contains("return __bock_p.value"), "got: {out}");
10457        // The propagate runtime prelude is emitted.
10458        assert!(out.contains("def _bock_try(v):"), "got: {out}");
10459    }
10460
10461    /// A function with no `?` must NOT gain the try/except envelope or the
10462    /// propagate runtime (no needless cost / behavioural change).
10463    #[test]
10464    fn no_propagate_no_envelope() {
10465        let body = block(2, vec![], Some(int_lit(3, "1")));
10466        let f = fn_decl_body(1, "plain", body);
10467        let out = gen(&module(vec![], vec![f]));
10468        assert!(!out.contains("_bock_try"), "got: {out}");
10469        assert!(!out.contains("_BockPropagate"), "got: {out}");
10470    }
10471
10472    /// `fn() { let y = 1; let z = { let y = y + 10; y * 2 }; y + z }` — the inner
10473    /// block's `let y` shadows the outer `y` and must be renamed so the outer `y`
10474    /// is untouched (Python has no block scope for `=`).
10475    #[test]
10476    fn nested_block_let_shadow_is_renamed() {
10477        let add = |id, l: AIRNode, r: AIRNode| {
10478            node(
10479                id,
10480                NodeKind::BinaryOp {
10481                    op: BinOp::Add,
10482                    left: Box::new(l),
10483                    right: Box::new(r),
10484                },
10485            )
10486        };
10487        let mul = |id, l: AIRNode, r: AIRNode| {
10488            node(
10489                id,
10490                NodeKind::BinaryOp {
10491                    op: BinOp::Mul,
10492                    left: Box::new(l),
10493                    right: Box::new(r),
10494                },
10495            )
10496        };
10497        // inner block: { let y = y + 10; y * 2 }
10498        let inner_block = block(
10499            20,
10500            vec![let_node(
10501                21,
10502                "y",
10503                add(22, id_node(23, "y"), int_lit(24, "10")),
10504            )],
10505            Some(mul(25, id_node(26, "y"), int_lit(27, "2"))),
10506        );
10507        let body = block(
10508            2,
10509            vec![
10510                let_node(3, "y", int_lit(4, "1")),
10511                let_node(5, "z", inner_block),
10512            ],
10513            Some(add(8, id_node(9, "y"), id_node(10, "z"))),
10514        );
10515        let f = fn_decl_body(1, "nested", body);
10516        let out = gen(&module(vec![], vec![f]));
10517        // The inner `let y` is renamed; the outer `y = 1` and the final `y + z`
10518        // read the original `y`.
10519        assert!(out.contains("y__s"), "expected a shadow alias, got: {out}");
10520        assert!(out.contains("y = 1"), "got: {out}");
10521        // The final tail uses the *un*-aliased outer `y` (it appears as `(y + z)`
10522        // — the alias name is `y__s1`, which `(y + z)` does not contain).
10523        assert!(out.contains("return (y + z)"), "got: {out}");
10524    }
10525
10526    /// A same-block re-bind (`let acc = …; let acc = acc + 1`) is a plain Python
10527    /// rebind — no alias, no duplicate.
10528    #[test]
10529    fn same_block_rebind_is_not_renamed() {
10530        let add = |id, l: AIRNode, r: AIRNode| {
10531            node(
10532                id,
10533                NodeKind::BinaryOp {
10534                    op: BinOp::Add,
10535                    left: Box::new(l),
10536                    right: Box::new(r),
10537                },
10538            )
10539        };
10540        let body = block(
10541            2,
10542            vec![
10543                let_node(3, "acc", int_lit(4, "1")),
10544                let_node(5, "acc", add(6, id_node(7, "acc"), int_lit(8, "2"))),
10545            ],
10546            Some(id_node(9, "acc")),
10547        );
10548        let f = fn_decl_body(1, "rebind", body);
10549        let out = gen(&module(vec![], vec![f]));
10550        assert!(!out.contains("acc__s"), "must not rename, got: {out}");
10551        assert!(out.contains("acc = 1"), "got: {out}");
10552        assert!(out.contains("acc = (acc + 2)"), "got: {out}");
10553    }
10554
10555    /// A Void function whose tail is a bare `loop { break }` must emit a
10556    /// `while True:` statement, never `return # unsupported`.
10557    #[test]
10558    fn tail_loop_emits_while_not_unsupported() {
10559        let loop_body = block(10, vec![node(11, NodeKind::Break { value: None })], None);
10560        let tail_loop = node(
10561            5,
10562            NodeKind::Loop {
10563                body: Box::new(loop_body),
10564            },
10565        );
10566        let body = block(2, vec![], Some(tail_loop));
10567        let f = fn_decl_body(1, "spin", body);
10568        let out = gen(&module(vec![], vec![f]));
10569        assert!(out.contains("while True:"), "got: {out}");
10570        assert!(!out.contains("# unsupported"), "got: {out}");
10571        assert!(!out.contains("return # unsupported"), "got: {out}");
10572    }
10573
10574    /// Helper: a private `fn <name>()` with an explicit body block.
10575    fn fn_decl_body(id: u32, name: &str, body: AIRNode) -> AIRNode {
10576        node(
10577            id,
10578            NodeKind::FnDecl {
10579                annotations: vec![],
10580                visibility: Visibility::Private,
10581                is_async: false,
10582                name: ident(name),
10583                generic_params: vec![],
10584                params: vec![],
10585                return_type: None,
10586                effect_clause: vec![],
10587                where_clause: vec![],
10588                body: Box::new(body),
10589            },
10590        )
10591    }
10592
10593    #[test]
10594    fn to_snake_case_conversions() {
10595        assert_eq!(to_snake_case("fetchData"), "fetch_data");
10596        assert_eq!(to_snake_case("MyClass"), "my_class");
10597        assert_eq!(to_snake_case("already_snake"), "already_snake");
10598        assert_eq!(to_snake_case("simple"), "simple");
10599        assert_eq!(to_snake_case("HTMLParser"), "html_parser");
10600        assert_eq!(to_snake_case("x"), "x");
10601        assert_eq!(to_snake_case("_"), "_");
10602    }
10603
10604    // ── End-to-end tests (python3 --check + python3 execution) ──────────────
10605
10606    fn has_python3() -> bool {
10607        std::process::Command::new("which")
10608            .arg("python3")
10609            .output()
10610            .map(|o| o.status.success())
10611            .unwrap_or(false)
10612    }
10613
10614    /// Run generated Python through `python3 -m py_compile` for syntax validation.
10615    fn check_py_syntax(code: &str) -> bool {
10616        use std::sync::atomic::{AtomicU64, Ordering};
10617        static SEQ: AtomicU64 = AtomicU64::new(0);
10618        let dir = std::env::temp_dir();
10619        // Unique filename per call: `cargo test` runs these checks on parallel
10620        // threads, so a shared fixed path races — one test's `py_compile` reads or
10621        // removes the file another test just wrote, yielding spurious "must parse"
10622        // failures (this flaked every CI lane except ubuntu-stable once the new
10623        // value-position match tests added more concurrent callers).
10624        let path = dir.join(format!(
10625            "bock_test_output_{}_{}.py",
10626            std::process::id(),
10627            SEQ.fetch_add(1, Ordering::Relaxed)
10628        ));
10629        std::fs::write(&path, code).expect("failed to write temp file");
10630        let result = std::process::Command::new("python3")
10631            .arg("-m")
10632            .arg("py_compile")
10633            .arg(&path)
10634            .output()
10635            .expect("failed to spawn python3");
10636        let _ = std::fs::remove_file(&path);
10637        result.status.success()
10638    }
10639
10640    /// Run generated Python with `python3` and capture stdout.
10641    fn run_py(code: &str) -> String {
10642        let output = std::process::Command::new("python3")
10643            .arg("-c")
10644            .arg(code)
10645            .output()
10646            .expect("failed to run python3");
10647        // Normalize CRLF→LF: on Windows python writes `\r\n` line endings, which
10648        // would fail exact-match assertions against `\n`-terminated expectations.
10649        String::from_utf8(output.stdout)
10650            .unwrap()
10651            .replace("\r\n", "\n")
10652            .trim()
10653            .to_string()
10654    }
10655
10656    #[test]
10657    #[ignore]
10658    fn e2e_hello_world() {
10659        if !has_python3() {
10660            return;
10661        }
10662        let body = block(
10663            2,
10664            vec![],
10665            Some(node(
10666                3,
10667                NodeKind::Call {
10668                    callee: Box::new(id_node(4, "print")),
10669                    args: vec![AirArg {
10670                        label: None,
10671                        value: str_lit(5, "Hello, World!"),
10672                    }],
10673                    type_args: vec![],
10674                },
10675            )),
10676        );
10677        let f = node(
10678            1,
10679            NodeKind::FnDecl {
10680                annotations: vec![],
10681                visibility: Visibility::Private,
10682                is_async: false,
10683                name: ident("main"),
10684                generic_params: vec![],
10685                params: vec![],
10686                return_type: None,
10687                effect_clause: vec![],
10688                where_clause: vec![],
10689                body: Box::new(body),
10690            },
10691        );
10692        let code = gen(&module(vec![], vec![f]));
10693        let full = format!("{code}\nmain()\n");
10694        assert!(
10695            check_py_syntax(&full),
10696            "Python syntax check failed:\n{full}"
10697        );
10698        assert_eq!(run_py(&full), "Hello, World!");
10699    }
10700
10701    #[test]
10702    #[ignore]
10703    fn e2e_arithmetic() {
10704        if !has_python3() {
10705            return;
10706        }
10707        let body = block(
10708            2,
10709            vec![],
10710            Some(node(
10711                3,
10712                NodeKind::BinaryOp {
10713                    op: BinOp::Add,
10714                    left: Box::new(int_lit(4, "10")),
10715                    right: Box::new(int_lit(5, "32")),
10716                },
10717            )),
10718        );
10719        let f = node(
10720            1,
10721            NodeKind::FnDecl {
10722                annotations: vec![],
10723                visibility: Visibility::Private,
10724                is_async: false,
10725                name: ident("calc"),
10726                generic_params: vec![],
10727                params: vec![],
10728                return_type: None,
10729                effect_clause: vec![],
10730                where_clause: vec![],
10731                body: Box::new(body),
10732            },
10733        );
10734        let code = gen(&module(vec![], vec![f]));
10735        let full = format!("{code}\nprint(calc())\n");
10736        assert!(
10737            check_py_syntax(&full),
10738            "Python syntax check failed:\n{full}"
10739        );
10740        assert_eq!(run_py(&full), "42");
10741    }
10742
10743    #[test]
10744    #[ignore]
10745    fn e2e_if_else() {
10746        if !has_python3() {
10747            return;
10748        }
10749        let if_stmt = node(
10750            3,
10751            NodeKind::If {
10752                let_pattern: None,
10753                condition: Box::new(node(
10754                    4,
10755                    NodeKind::BinaryOp {
10756                        op: BinOp::Gt,
10757                        left: Box::new(id_node(5, "x")),
10758                        right: Box::new(int_lit(6, "0")),
10759                    },
10760                )),
10761                then_block: Box::new(block(
10762                    7,
10763                    vec![node(
10764                        8,
10765                        NodeKind::Return {
10766                            value: Some(Box::new(str_lit(9, "positive"))),
10767                        },
10768                    )],
10769                    None,
10770                )),
10771                else_block: Some(Box::new(block(
10772                    10,
10773                    vec![node(
10774                        11,
10775                        NodeKind::Return {
10776                            value: Some(Box::new(str_lit(12, "non-positive"))),
10777                        },
10778                    )],
10779                    None,
10780                ))),
10781            },
10782        );
10783        let body = block(2, vec![if_stmt], None);
10784        let f = node(
10785            1,
10786            NodeKind::FnDecl {
10787                annotations: vec![],
10788                visibility: Visibility::Private,
10789                is_async: false,
10790                name: ident("classify"),
10791                generic_params: vec![],
10792                params: vec![param_node(13, "x")],
10793                return_type: None,
10794                effect_clause: vec![],
10795                where_clause: vec![],
10796                body: Box::new(body),
10797            },
10798        );
10799        let code = gen(&module(vec![], vec![f]));
10800        let full = format!("{code}\nprint(classify(5))\nprint(classify(-1))\n");
10801        assert!(
10802            check_py_syntax(&full),
10803            "Python syntax check failed:\n{full}"
10804        );
10805        let output = run_py(&full);
10806        assert!(output.contains("positive"), "got: {output}");
10807        assert!(output.contains("non-positive"), "got: {output}");
10808    }
10809
10810    #[test]
10811    #[ignore]
10812    fn e2e_for_loop() {
10813        if !has_python3() {
10814            return;
10815        }
10816        let body = block(
10817            2,
10818            vec![
10819                node(
10820                    3,
10821                    NodeKind::LetBinding {
10822                        is_mut: true,
10823                        pattern: Box::new(bind_pat(4, "sum")),
10824                        ty: None,
10825                        value: Box::new(int_lit(5, "0")),
10826                    },
10827                ),
10828                node(
10829                    6,
10830                    NodeKind::For {
10831                        pattern: Box::new(bind_pat(7, "x")),
10832                        iterable: Box::new(node(
10833                            8,
10834                            NodeKind::ListLiteral {
10835                                elems: vec![int_lit(9, "1"), int_lit(10, "2"), int_lit(11, "3")],
10836                            },
10837                        )),
10838                        body: Box::new(block(
10839                            12,
10840                            vec![node(
10841                                13,
10842                                NodeKind::Assign {
10843                                    op: AssignOp::AddAssign,
10844                                    target: Box::new(id_node(14, "sum")),
10845                                    value: Box::new(id_node(15, "x")),
10846                                },
10847                            )],
10848                            None,
10849                        )),
10850                    },
10851                ),
10852            ],
10853            Some(id_node(16, "sum")),
10854        );
10855        let f = node(
10856            1,
10857            NodeKind::FnDecl {
10858                annotations: vec![],
10859                visibility: Visibility::Private,
10860                is_async: false,
10861                name: ident("total"),
10862                generic_params: vec![],
10863                params: vec![],
10864                return_type: None,
10865                effect_clause: vec![],
10866                where_clause: vec![],
10867                body: Box::new(body),
10868            },
10869        );
10870        let code = gen(&module(vec![], vec![f]));
10871        let full = format!("{code}\nprint(total())\n");
10872        assert!(
10873            check_py_syntax(&full),
10874            "Python syntax check failed:\n{full}"
10875        );
10876        assert_eq!(run_py(&full), "6");
10877    }
10878
10879    #[test]
10880    #[ignore]
10881    fn e2e_dataclass() {
10882        if !has_python3() {
10883            return;
10884        }
10885        let rec = node(
10886            1,
10887            NodeKind::RecordDecl {
10888                annotations: vec![],
10889                visibility: Visibility::Public,
10890                name: ident("Point"),
10891                generic_params: vec![],
10892                fields: vec![
10893                    bock_ast::RecordDeclField {
10894                        id: 0,
10895                        span: span(),
10896                        name: ident("x"),
10897                        ty: bock_ast::TypeExpr::Named {
10898                            id: 0,
10899                            span: span(),
10900                            path: type_path(&["Float"]),
10901                            args: vec![],
10902                        },
10903                        default: None,
10904                    },
10905                    bock_ast::RecordDeclField {
10906                        id: 0,
10907                        span: span(),
10908                        name: ident("y"),
10909                        ty: bock_ast::TypeExpr::Named {
10910                            id: 0,
10911                            span: span(),
10912                            path: type_path(&["Float"]),
10913                            args: vec![],
10914                        },
10915                        default: None,
10916                    },
10917                ],
10918            },
10919        );
10920        let code = gen(&module(vec![], vec![rec]));
10921        let full = format!("{code}\np = Point(x=1.0, y=2.0)\nprint(f\"{{p.x}}, {{p.y}}\")\n");
10922        assert!(
10923            check_py_syntax(&full),
10924            "Python syntax check failed:\n{full}"
10925        );
10926        let output = run_py(&full);
10927        assert!(output.contains("1.0, 2.0"), "got: {output}");
10928    }
10929
10930    #[test]
10931    #[ignore]
10932    fn e2e_match_statement() {
10933        if !has_python3() {
10934            return;
10935        }
10936        // match on literal values
10937        let scrutinee = id_node(10, "x");
10938        let arms = vec![
10939            node(
10940                11,
10941                NodeKind::MatchArm {
10942                    pattern: Box::new(node(
10943                        12,
10944                        NodeKind::LiteralPat {
10945                            lit: Literal::Int("1".into()),
10946                        },
10947                    )),
10948                    guard: None,
10949                    body: Box::new(block(
10950                        13,
10951                        vec![node(
10952                            14,
10953                            NodeKind::Return {
10954                                value: Some(Box::new(str_lit(15, "one"))),
10955                            },
10956                        )],
10957                        None,
10958                    )),
10959                },
10960            ),
10961            node(
10962                16,
10963                NodeKind::MatchArm {
10964                    pattern: Box::new(node(17, NodeKind::WildcardPat)),
10965                    guard: None,
10966                    body: Box::new(block(
10967                        18,
10968                        vec![node(
10969                            19,
10970                            NodeKind::Return {
10971                                value: Some(Box::new(str_lit(20, "other"))),
10972                            },
10973                        )],
10974                        None,
10975                    )),
10976                },
10977            ),
10978        ];
10979        let match_stmt = node(
10980            9,
10981            NodeKind::Match {
10982                scrutinee: Box::new(scrutinee),
10983                arms,
10984            },
10985        );
10986        let f = node(
10987            1,
10988            NodeKind::FnDecl {
10989                annotations: vec![],
10990                visibility: Visibility::Private,
10991                is_async: false,
10992                name: ident("describe"),
10993                generic_params: vec![],
10994                params: vec![param_node(2, "x")],
10995                return_type: None,
10996                effect_clause: vec![],
10997                where_clause: vec![],
10998                body: Box::new(block(3, vec![match_stmt], None)),
10999            },
11000        );
11001        let code = gen(&module(vec![], vec![f]));
11002        let full = format!("{code}\nprint(describe(1))\nprint(describe(99))\n");
11003        assert!(
11004            check_py_syntax(&full),
11005            "Python syntax check failed:\n{full}"
11006        );
11007        let output = run_py(&full);
11008        assert!(output.contains("one"), "got: {output}");
11009        assert!(output.contains("other"), "got: {output}");
11010    }
11011
11012    // ── Prelude function mapping tests ──────────────────────────────────────
11013
11014    /// Helper: generate Python for a module with a `main` function containing a single call.
11015    fn gen_prelude_call(func_name: &str, arg: AIRNode) -> String {
11016        let call = node(
11017            10,
11018            NodeKind::Call {
11019                callee: Box::new(id_node(11, func_name)),
11020                args: vec![AirArg {
11021                    label: None,
11022                    value: arg,
11023                }],
11024                type_args: vec![],
11025            },
11026        );
11027        let body = block(2, vec![call], None);
11028        let f = node(
11029            1,
11030            NodeKind::FnDecl {
11031                name: ident("main"),
11032                params: vec![],
11033                return_type: None,
11034                body: Box::new(body),
11035                generic_params: vec![],
11036                visibility: Visibility::Private,
11037                annotations: vec![],
11038                effect_clause: vec![],
11039                where_clause: vec![],
11040                is_async: false,
11041            },
11042        );
11043        gen(&module(vec![], vec![f]))
11044    }
11045
11046    /// Helper: generate Python for a nullary prelude call (no args).
11047    fn gen_prelude_call_no_args(func_name: &str) -> String {
11048        let call = node(
11049            10,
11050            NodeKind::Call {
11051                callee: Box::new(id_node(11, func_name)),
11052                args: vec![],
11053                type_args: vec![],
11054            },
11055        );
11056        let body = block(2, vec![call], None);
11057        let f = node(
11058            1,
11059            NodeKind::FnDecl {
11060                name: ident("main"),
11061                params: vec![],
11062                return_type: None,
11063                body: Box::new(body),
11064                generic_params: vec![],
11065                visibility: Visibility::Private,
11066                annotations: vec![],
11067                effect_clause: vec![],
11068                where_clause: vec![],
11069                is_async: false,
11070            },
11071        );
11072        gen(&module(vec![], vec![f]))
11073    }
11074
11075    #[test]
11076    fn prelude_println_maps_to_print() {
11077        let out = gen_prelude_call("println", str_lit(12, "hello"));
11078        assert!(
11079            out.contains("print("),
11080            "println should map to print, got: {out}"
11081        );
11082        assert!(
11083            !out.contains("println("),
11084            "should not emit bare println(, got: {out}"
11085        );
11086    }
11087
11088    #[test]
11089    fn prelude_print_maps_to_print_no_newline() {
11090        let out = gen_prelude_call("print", str_lit(12, "hello"));
11091        assert!(
11092            out.contains("print(") && out.contains("end=\"\""),
11093            "print should map to print with end=\"\", got: {out}"
11094        );
11095    }
11096
11097    #[test]
11098    fn prelude_debug_maps_to_repr() {
11099        let out = gen_prelude_call("debug", str_lit(12, "val"));
11100        assert!(
11101            out.contains("print(repr("),
11102            "debug should map to print(repr(...)), got: {out}"
11103        );
11104    }
11105
11106    #[test]
11107    fn prelude_assert_maps_to_assert() {
11108        let out = gen_prelude_call("assert", bool_lit(12, true));
11109        assert!(
11110            out.contains("assert "),
11111            "assert should map to Python assert, got: {out}"
11112        );
11113        assert!(
11114            !out.contains("assert("),
11115            "should not emit assert as function call, got: {out}"
11116        );
11117    }
11118
11119    #[test]
11120    fn prelude_todo_maps_to_not_implemented_error() {
11121        let out = gen_prelude_call_no_args("todo");
11122        assert!(
11123            out.contains("raise NotImplementedError()"),
11124            "todo should map to raise NotImplementedError, got: {out}"
11125        );
11126    }
11127
11128    #[test]
11129    fn prelude_unreachable_maps_to_runtime_error() {
11130        let out = gen_prelude_call_no_args("unreachable");
11131        assert!(
11132            out.contains("raise RuntimeError(\"unreachable\")"),
11133            "unreachable should map to raise RuntimeError, got: {out}"
11134        );
11135    }
11136
11137    #[test]
11138    fn non_prelude_call_passes_through() {
11139        let out = gen_prelude_call("my_custom_func", str_lit(12, "arg"));
11140        assert!(
11141            out.contains("my_custom_func("),
11142            "non-prelude call should use snake_case, got: {out}"
11143        );
11144    }
11145
11146    // ── Effect declaration tests ────────────────────────────────────────────
11147
11148    /// Build a `MatchArm` with a wildcard pattern and the given body.
11149    fn wildcard_arm(id: u32, body: AIRNode) -> AIRNode {
11150        node(
11151            id,
11152            NodeKind::MatchArm {
11153                pattern: Box::new(node(id + 100, NodeKind::WildcardPat)),
11154                guard: None,
11155                body: Box::new(body),
11156            },
11157        )
11158    }
11159
11160    /// A `Block` with the given leading statements and a string tail value.
11161    fn block_with_tail(id: u32, stmts: Vec<AIRNode>, tail: &str) -> AIRNode {
11162        node(
11163            id,
11164            NodeKind::Block {
11165                stmts,
11166                tail: Some(Box::new(str_lit(id + 1, tail))),
11167            },
11168        )
11169    }
11170
11171    #[test]
11172    fn valpos_arm_with_loop_leading_stmt_needs_stmt_form() {
11173        // `_ => { for _ in xs { f() } "v" }` — the leading `for` loop has no
11174        // Python expression form, so the lambda chain would drop it. The arm
11175        // must be routed to statement form.
11176        let loop_stmt = node(
11177            10,
11178            NodeKind::For {
11179                pattern: Box::new(node(11, NodeKind::WildcardPat)),
11180                iterable: Box::new(id_node(12, "xs")),
11181                body: Box::new(node(
11182                    13,
11183                    NodeKind::Block {
11184                        stmts: vec![],
11185                        tail: None,
11186                    },
11187                )),
11188            },
11189        );
11190        let arms = vec![wildcard_arm(1, block_with_tail(20, vec![loop_stmt], "v"))];
11191        assert!(
11192            match_arm_drops_leading_stmts(&arms),
11193            "a value-tail arm with a leading `for` loop must route to statement form"
11194        );
11195        assert!(match_value_needs_stmt_form(&arms));
11196        assert!(value_needs_stmt_form(&node(
11197            30,
11198            NodeKind::Match {
11199                scrutinee: Box::new(id_node(31, "j")),
11200                arms,
11201            }
11202        )));
11203    }
11204
11205    #[test]
11206    fn valpos_arm_with_assign_leading_stmt_needs_stmt_form() {
11207        // `_ => { x = 1; "v" }` — a leading assignment is not lambda-expressible.
11208        let assign = node(
11209            10,
11210            NodeKind::Assign {
11211                target: Box::new(id_node(11, "x")),
11212                op: AssignOp::Assign,
11213                value: Box::new(int_lit(12, "1")),
11214            },
11215        );
11216        let arms = vec![wildcard_arm(1, block_with_tail(20, vec![assign], "v"))];
11217        assert!(match_arm_drops_leading_stmts(&arms));
11218    }
11219
11220    #[test]
11221    fn valpos_arm_with_mut_let_leading_stmt_needs_stmt_form() {
11222        // `_ => { let mut x = 1; "v" }` — a mutable `let` cannot be a lambda
11223        // parameter, so the chain would drop it.
11224        let mut_let = node(
11225            10,
11226            NodeKind::LetBinding {
11227                is_mut: true,
11228                pattern: Box::new(node(
11229                    11,
11230                    NodeKind::BindPat {
11231                        name: ident("x"),
11232                        is_mut: true,
11233                    },
11234                )),
11235                ty: None,
11236                value: Box::new(int_lit(12, "1")),
11237            },
11238        );
11239        let arms = vec![wildcard_arm(1, block_with_tail(20, vec![mut_let], "v"))];
11240        assert!(match_arm_drops_leading_stmts(&arms));
11241    }
11242
11243    #[test]
11244    fn valpos_arm_with_simple_let_stays_on_lambda_chain() {
11245        // `_ => { let x = 1; "v" }` — a simple immutable `let` folds into a
11246        // `lambda x:` parameter, so the chain handles it and we must NOT force
11247        // statement form (that would regress the proven lambda-chain path).
11248        let simple_let = node(
11249            10,
11250            NodeKind::LetBinding {
11251                is_mut: false,
11252                pattern: Box::new(node(
11253                    11,
11254                    NodeKind::BindPat {
11255                        name: ident("x"),
11256                        is_mut: false,
11257                    },
11258                )),
11259                ty: None,
11260                value: Box::new(int_lit(12, "1")),
11261            },
11262        );
11263        let arms = vec![wildcard_arm(1, block_with_tail(20, vec![simple_let], "v"))];
11264        assert!(
11265            !match_arm_drops_leading_stmts(&arms),
11266            "a simple immutable `let` is lambda-expressible — keep it on the chain"
11267        );
11268    }
11269
11270    #[test]
11271    fn valpos_arm_with_bare_call_stays_on_lambda_chain() {
11272        // `_ => { f(); "v" }` — a bare expression statement folds into a
11273        // `lambda _:` and stays on the chain.
11274        let call = node(
11275            10,
11276            NodeKind::Call {
11277                callee: Box::new(id_node(11, "f")),
11278                args: vec![],
11279                type_args: vec![],
11280            },
11281        );
11282        let arms = vec![wildcard_arm(1, block_with_tail(20, vec![call], "v"))];
11283        assert!(!match_arm_drops_leading_stmts(&arms));
11284    }
11285
11286    #[test]
11287    fn valpos_arm_tail_only_block_stays_on_lambda_chain() {
11288        // `_ => { "v" }` — no leading statements, nothing to drop.
11289        let arms = vec![wildcard_arm(1, block_with_tail(20, vec![], "v"))];
11290        assert!(!match_arm_drops_leading_stmts(&arms));
11291    }
11292
11293    #[test]
11294    fn effect_decl_becomes_abc() {
11295        let effect = node(
11296            1,
11297            NodeKind::EffectDecl {
11298                annotations: vec![],
11299                visibility: Visibility::Public,
11300                name: ident("Logger"),
11301                generic_params: vec![],
11302                components: vec![],
11303                operations: vec![
11304                    node(
11305                        2,
11306                        NodeKind::FnDecl {
11307                            annotations: vec![],
11308                            visibility: Visibility::Public,
11309                            is_async: false,
11310                            name: ident("log"),
11311                            generic_params: vec![],
11312                            params: vec![
11313                                typed_param_node(3, "level", "String"),
11314                                typed_param_node(4, "msg", "String"),
11315                            ],
11316                            return_type: Some(Box::new(node(
11317                                5,
11318                                NodeKind::TypeNamed {
11319                                    path: type_path(&["Void"]),
11320                                    args: vec![],
11321                                },
11322                            ))),
11323                            effect_clause: vec![],
11324                            where_clause: vec![],
11325                            body: Box::new(block(6, vec![], None)),
11326                        },
11327                    ),
11328                    node(
11329                        7,
11330                        NodeKind::FnDecl {
11331                            annotations: vec![],
11332                            visibility: Visibility::Public,
11333                            is_async: false,
11334                            name: ident("flush"),
11335                            generic_params: vec![],
11336                            params: vec![],
11337                            return_type: Some(Box::new(node(
11338                                8,
11339                                NodeKind::TypeNamed {
11340                                    path: type_path(&["Void"]),
11341                                    args: vec![],
11342                                },
11343                            ))),
11344                            effect_clause: vec![],
11345                            where_clause: vec![],
11346                            body: Box::new(block(9, vec![], None)),
11347                        },
11348                    ),
11349                ],
11350            },
11351        );
11352        let out = gen(&module(vec![], vec![effect]));
11353        assert!(
11354            out.contains("from abc import ABC, abstractmethod"),
11355            "got: {out}"
11356        );
11357        assert!(out.contains("class Logger(ABC):"), "got: {out}");
11358        assert!(out.contains("@abstractmethod"), "got: {out}");
11359        assert!(
11360            out.contains("def log(self, level: str, msg: str) -> None:"),
11361            "got: {out}"
11362        );
11363        assert!(out.contains("def flush(self) -> None:"), "got: {out}");
11364        assert!(out.contains("        ..."), "got: {out}");
11365    }
11366
11367    #[test]
11368    fn effect_decl_empty_operations() {
11369        let effect = node(
11370            1,
11371            NodeKind::EffectDecl {
11372                annotations: vec![],
11373                visibility: Visibility::Public,
11374                name: ident("Empty"),
11375                generic_params: vec![],
11376                components: vec![],
11377                operations: vec![],
11378            },
11379        );
11380        let out = gen(&module(vec![], vec![effect]));
11381        assert!(out.contains("class Empty(ABC):"), "got: {out}");
11382        assert!(out.contains("    pass"), "got: {out}");
11383    }
11384
11385    #[test]
11386    fn handling_block_passes_handlers_to_effectful_call() {
11387        use bock_air::AirHandlerPair;
11388
11389        let effect_decl = node(
11390            1,
11391            NodeKind::EffectDecl {
11392                annotations: vec![],
11393                visibility: Visibility::Public,
11394                name: ident("Logger"),
11395                generic_params: vec![],
11396                components: vec![],
11397                operations: vec![node(
11398                    2,
11399                    NodeKind::FnDecl {
11400                        annotations: vec![],
11401                        visibility: Visibility::Public,
11402                        is_async: false,
11403                        name: ident("log"),
11404                        generic_params: vec![],
11405                        params: vec![typed_param_node(3, "msg", "String")],
11406                        return_type: None,
11407                        effect_clause: vec![],
11408                        where_clause: vec![],
11409                        body: Box::new(block(4, vec![], None)),
11410                    },
11411                )],
11412            },
11413        );
11414
11415        let inner_fn = node(
11416            10,
11417            NodeKind::FnDecl {
11418                annotations: vec![],
11419                visibility: Visibility::Private,
11420                is_async: false,
11421                name: ident("inner"),
11422                generic_params: vec![],
11423                params: vec![],
11424                return_type: None,
11425                effect_clause: vec![type_path(&["Logger"])],
11426                where_clause: vec![],
11427                body: Box::new(block(12, vec![], Some(str_lit(13, "hello")))),
11428            },
11429        );
11430
11431        let call_inner = node(
11432            20,
11433            NodeKind::Call {
11434                callee: Box::new(id_node(21, "inner")),
11435                args: vec![],
11436                type_args: vec![],
11437            },
11438        );
11439        let handling = node(
11440            30,
11441            NodeKind::HandlingBlock {
11442                handlers: vec![AirHandlerPair {
11443                    effect: type_path(&["Logger"]),
11444                    handler: Box::new(node(
11445                        31,
11446                        NodeKind::Call {
11447                            callee: Box::new(id_node(32, "StdoutLogger")),
11448                            args: vec![],
11449                            type_args: vec![],
11450                        },
11451                    )),
11452                }],
11453                body: Box::new(block(33, vec![], Some(call_inner))),
11454            },
11455        );
11456        let main_fn = node(
11457            40,
11458            NodeKind::FnDecl {
11459                annotations: vec![],
11460                visibility: Visibility::Private,
11461                is_async: false,
11462                name: ident("main"),
11463                generic_params: vec![],
11464                params: vec![],
11465                return_type: None,
11466                effect_clause: vec![],
11467                where_clause: vec![],
11468                body: Box::new(block(41, vec![handling], None)),
11469            },
11470        );
11471
11472        let out = gen(&module(vec![], vec![effect_decl, inner_fn, main_fn]));
11473        // Python: inner(logger=__logger_h<N>) — handling blocks get a fresh
11474        // numeric suffix so nested blocks don't shadow each other.
11475        assert!(
11476            out.contains("inner(logger=__logger_h"),
11477            "handling block should pass handler to effectful call, got: {out}"
11478        );
11479        // Handler constructors are PascalCase in Python — they name a class.
11480        assert!(
11481            out.contains(": Logger = StdoutLogger()"),
11482            "handling block should instantiate handler, got: {out}"
11483        );
11484    }
11485
11486    // ── Async / concurrent patterns ────────────────────────────────────────
11487
11488    #[test]
11489    fn async_function_imports_asyncio() {
11490        let body = block(3, vec![], None);
11491        let f = node(
11492            1,
11493            NodeKind::FnDecl {
11494                annotations: vec![],
11495                visibility: Visibility::Private,
11496                is_async: true,
11497                name: ident("tick"),
11498                generic_params: vec![],
11499                params: vec![],
11500                return_type: None,
11501                effect_clause: vec![],
11502                where_clause: vec![],
11503                body: Box::new(body),
11504            },
11505        );
11506        let out = gen(&module(vec![], vec![f]));
11507        assert!(out.contains("import asyncio"), "got: {out}");
11508        assert!(out.contains("async def tick():"), "got: {out}");
11509    }
11510
11511    #[test]
11512    fn sync_module_has_no_asyncio_import() {
11513        let body = block(3, vec![], Some(int_lit(4, "1")));
11514        let f = node(
11515            1,
11516            NodeKind::FnDecl {
11517                annotations: vec![],
11518                visibility: Visibility::Private,
11519                is_async: false,
11520                name: ident("one"),
11521                generic_params: vec![],
11522                params: vec![],
11523                return_type: None,
11524                effect_clause: vec![],
11525                where_clause: vec![],
11526                body: Box::new(body),
11527            },
11528        );
11529        let out = gen(&module(vec![], vec![f]));
11530        assert!(!out.contains("import asyncio"), "got: {out}");
11531    }
11532
11533    #[test]
11534    fn entry_invocation_async_main_python() {
11535        let inv = PyGenerator::new().entry_invocation(true).unwrap();
11536        assert!(inv.contains("asyncio.run(main())"), "got: {inv}");
11537    }
11538
11539    #[test]
11540    fn entry_invocation_sync_main_python() {
11541        let inv = PyGenerator::new().entry_invocation(false).unwrap();
11542        assert_eq!(inv, "if __name__ == \"__main__\":\n    main()\n");
11543    }
11544
11545    #[test]
11546    fn generate_project_async_main_uses_asyncio_run() {
11547        let main_fn = node(
11548            1,
11549            NodeKind::FnDecl {
11550                annotations: vec![],
11551                visibility: Visibility::Private,
11552                is_async: true,
11553                name: ident("main"),
11554                generic_params: vec![],
11555                params: vec![],
11556                return_type: None,
11557                effect_clause: vec![],
11558                where_clause: vec![],
11559                body: Box::new(block(2, vec![], None)),
11560            },
11561        );
11562        let m = module(vec![], vec![main_fn]);
11563        let gen = PyGenerator::new();
11564        let src_path = std::path::Path::new("src/main.bock");
11565        let out = gen.generate_project(&[(&m, src_path)]).unwrap();
11566        let src = &out.files[0].content;
11567        assert_eq!(out.files[0].path, std::path::PathBuf::from("main.py"));
11568        assert!(src.contains("import asyncio"), "got: {src}");
11569        assert!(src.contains("async def main():"), "got: {src}");
11570        assert!(src.contains("asyncio.run(main())"), "got: {src}");
11571    }
11572
11573    #[test]
11574    fn concurrent_pattern_wraps_in_create_task() {
11575        // Block:
11576        //   let a = task1()
11577        //   let b = task2()
11578        //   let ra = await a
11579        //   let rb = await b
11580        //   return ra
11581        let call_task = |id: u32, name: &str| {
11582            node(
11583                id,
11584                NodeKind::Call {
11585                    callee: Box::new(id_node(id + 1, name)),
11586                    args: vec![],
11587                    type_args: vec![],
11588                },
11589            )
11590        };
11591        let let_stmt = |id: u32, name: &str, val: AIRNode| {
11592            node(
11593                id,
11594                NodeKind::LetBinding {
11595                    is_mut: false,
11596                    pattern: Box::new(bind_pat(id + 1, name)),
11597                    ty: None,
11598                    value: Box::new(val),
11599                },
11600            )
11601        };
11602        let await_id = |id: u32, name: &str| {
11603            node(
11604                id,
11605                NodeKind::Await {
11606                    expr: Box::new(id_node(id + 1, name)),
11607                },
11608            )
11609        };
11610
11611        let body = block(
11612            10,
11613            vec![
11614                let_stmt(20, "a", call_task(21, "task1")),
11615                let_stmt(30, "b", call_task(31, "task2")),
11616                let_stmt(40, "ra", await_id(41, "a")),
11617                let_stmt(50, "rb", await_id(51, "b")),
11618            ],
11619            Some(id_node(60, "ra")),
11620        );
11621        let f = node(
11622            1,
11623            NodeKind::FnDecl {
11624                annotations: vec![],
11625                visibility: Visibility::Private,
11626                is_async: true,
11627                name: ident("run"),
11628                generic_params: vec![],
11629                params: vec![],
11630                return_type: None,
11631                effect_clause: vec![],
11632                where_clause: vec![],
11633                body: Box::new(body),
11634            },
11635        );
11636        let out = gen(&module(vec![], vec![f]));
11637        assert!(
11638            out.contains("a = asyncio.create_task(task1())"),
11639            "task1 should be scheduled as a task, got: {out}"
11640        );
11641        assert!(
11642            out.contains("b = asyncio.create_task(task2())"),
11643            "task2 should be scheduled as a task, got: {out}"
11644        );
11645        assert!(out.contains("ra = (await a)"), "got: {out}");
11646        assert!(out.contains("rb = (await b)"), "got: {out}");
11647    }
11648
11649    #[test]
11650    fn sequential_await_no_task_wrapping() {
11651        // `let a = await task1()` directly awaits — no task wrap needed.
11652        let await_call = node(
11653            20,
11654            NodeKind::Await {
11655                expr: Box::new(node(
11656                    21,
11657                    NodeKind::Call {
11658                        callee: Box::new(id_node(22, "task1")),
11659                        args: vec![],
11660                        type_args: vec![],
11661                    },
11662                )),
11663            },
11664        );
11665        let let_stmt = node(
11666            10,
11667            NodeKind::LetBinding {
11668                is_mut: false,
11669                pattern: Box::new(bind_pat(11, "a")),
11670                ty: None,
11671                value: Box::new(await_call),
11672            },
11673        );
11674        let body = block(30, vec![let_stmt], Some(id_node(40, "a")));
11675        let f = node(
11676            1,
11677            NodeKind::FnDecl {
11678                annotations: vec![],
11679                visibility: Visibility::Private,
11680                is_async: true,
11681                name: ident("run"),
11682                generic_params: vec![],
11683                params: vec![],
11684                return_type: None,
11685                effect_clause: vec![],
11686                where_clause: vec![],
11687                body: Box::new(body),
11688            },
11689        );
11690        let out = gen(&module(vec![], vec![f]));
11691        assert!(
11692            !out.contains("create_task"),
11693            "sequential await should not wrap in create_task, got: {out}"
11694        );
11695        assert!(out.contains("a = (await task1())"), "got: {out}");
11696    }
11697
11698    #[test]
11699    fn non_call_rhs_not_wrapped_in_task() {
11700        // `let a = 42 ; ... await a` — RHS is not a Call, so we can't wrap.
11701        let let_stmt = node(
11702            10,
11703            NodeKind::LetBinding {
11704                is_mut: false,
11705                pattern: Box::new(bind_pat(11, "a")),
11706                ty: None,
11707                value: Box::new(int_lit(12, "42")),
11708            },
11709        );
11710        let await_a = node(
11711            20,
11712            NodeKind::Await {
11713                expr: Box::new(id_node(21, "a")),
11714            },
11715        );
11716        let body = block(30, vec![let_stmt], Some(await_a));
11717        let f = node(
11718            1,
11719            NodeKind::FnDecl {
11720                annotations: vec![],
11721                visibility: Visibility::Private,
11722                is_async: true,
11723                name: ident("run"),
11724                generic_params: vec![],
11725                params: vec![],
11726                return_type: None,
11727                effect_clause: vec![],
11728                where_clause: vec![],
11729                body: Box::new(body),
11730            },
11731        );
11732        let out = gen(&module(vec![], vec![f]));
11733        assert!(!out.contains("create_task"), "got: {out}");
11734        assert!(out.contains("a = 42"), "got: {out}");
11735    }
11736
11737    /// Q-py-optional: the Python Optional runtime is emitted when a module uses
11738    /// Optional/`Some`/`None`; `Some(x)` and `None` lower to the tagged runtime
11739    /// values (`_BockSome(...)` / `_bock_none`), and an Optional `match` lowers
11740    /// to structural arms (`case _BockSome(x):` / `case _BockNone():`) — not the
11741    /// old bare `Some`/`None` (undefined) and `case None():` (a SyntaxError).
11742    #[test]
11743    fn optional_runtime_construct_and_match() {
11744        // fn describe(o: Int?) -> Int {
11745        //   match o { Some(x) => return x; None => return Some(0); }  (Some forces construction)
11746        // }
11747        let opt_int_ty = node(
11748            200,
11749            NodeKind::TypeOptional {
11750                inner: Box::new(node(
11751                    201,
11752                    NodeKind::TypeNamed {
11753                        path: type_path(&["Int"]),
11754                        args: vec![],
11755                    },
11756                )),
11757            },
11758        );
11759        let o_param = node(
11760            30,
11761            NodeKind::Param {
11762                pattern: Box::new(bind_pat(31, "o")),
11763                ty: Some(Box::new(opt_int_ty)),
11764                default: None,
11765            },
11766        );
11767        // Construct Some(1) and None in the body so the prelude + constructors
11768        // are exercised.
11769        let some_call = node(
11770            70,
11771            NodeKind::Call {
11772                callee: Box::new(id_node(71, "Some")),
11773                args: vec![AirArg {
11774                    label: None,
11775                    value: int_lit(72, "1"),
11776                }],
11777                type_args: vec![],
11778            },
11779        );
11780        let none_ref = id_node(73, "None");
11781        let some_arm = node(
11782            40,
11783            NodeKind::MatchArm {
11784                pattern: Box::new(node(
11785                    41,
11786                    NodeKind::ConstructorPat {
11787                        path: type_path(&["Some"]),
11788                        fields: vec![bind_pat(42, "x")],
11789                    },
11790                )),
11791                guard: None,
11792                body: Box::new(block(
11793                    43,
11794                    vec![node(
11795                        44,
11796                        NodeKind::Return {
11797                            value: Some(Box::new(id_node(45, "x"))),
11798                        },
11799                    )],
11800                    None,
11801                )),
11802            },
11803        );
11804        let none_arm = node(
11805            50,
11806            NodeKind::MatchArm {
11807                pattern: Box::new(node(
11808                    51,
11809                    NodeKind::ConstructorPat {
11810                        path: type_path(&["None"]),
11811                        fields: vec![],
11812                    },
11813                )),
11814                guard: None,
11815                body: Box::new(block(
11816                    52,
11817                    vec![node(
11818                        53,
11819                        NodeKind::Return {
11820                            value: Some(Box::new(int_lit(54, "0"))),
11821                        },
11822                    )],
11823                    None,
11824                )),
11825            },
11826        );
11827        let match_stmt = node(
11828            60,
11829            NodeKind::Match {
11830                scrutinee: Box::new(id_node(61, "o")),
11831                arms: vec![some_arm, none_arm],
11832            },
11833        );
11834        let f = node(
11835            1,
11836            NodeKind::FnDecl {
11837                annotations: vec![],
11838                visibility: Visibility::Private,
11839                is_async: false,
11840                name: ident("describe"),
11841                generic_params: vec![],
11842                params: vec![o_param],
11843                return_type: Some(Box::new(node(
11844                    2,
11845                    NodeKind::TypeNamed {
11846                        path: type_path(&["Int"]),
11847                        args: vec![],
11848                    },
11849                ))),
11850                effect_clause: vec![],
11851                where_clause: vec![],
11852                body: Box::new(block(
11853                    3,
11854                    vec![
11855                        node(
11856                            80,
11857                            NodeKind::LetBinding {
11858                                is_mut: false,
11859                                pattern: Box::new(bind_pat(81, "a")),
11860                                ty: None,
11861                                value: Box::new(some_call),
11862                            },
11863                        ),
11864                        node(
11865                            82,
11866                            NodeKind::LetBinding {
11867                                is_mut: false,
11868                                pattern: Box::new(bind_pat(83, "b")),
11869                                ty: None,
11870                                value: Box::new(none_ref),
11871                            },
11872                        ),
11873                        match_stmt,
11874                    ],
11875                    None,
11876                )),
11877            },
11878        );
11879        let out = gen(&module(vec![], vec![f]));
11880        // Runtime prelude is present.
11881        assert!(out.contains("class _BockSome:"), "got: {out}");
11882        assert!(out.contains("class _BockNone:"), "got: {out}");
11883        assert!(out.contains("_bock_none = _BockNone()"), "got: {out}");
11884        // Constructors lower to the runtime values, not bare `Some`/`None`.
11885        assert!(out.contains("_BockSome(1)"), "got: {out}");
11886        assert!(out.contains("b = _bock_none"), "got: {out}");
11887        // Match arms are structural, not `Some(...)` / `case None():`.
11888        assert!(out.contains("case _BockSome(x):"), "got: {out}");
11889        assert!(out.contains("case _BockNone():"), "got: {out}");
11890        assert!(!out.contains("case None()"), "got: {out}");
11891    }
11892
11893    /// An Optional `match` in *expression* position (value of a `let`) with
11894    /// *non-`return`* arms must lower to a real conditional over the bound
11895    /// scrutinee that tests the tag and binds the payload — NOT the old stub
11896    /// `(lambda __v: <some> if False else <none>)` (which always selected the
11897    /// last arm and never bound the payload). Regression-locking the Python
11898    /// expression-position Optional-match defect.
11899    #[test]
11900    fn optional_match_in_expression_position_binds_payload() {
11901        // fn pick(o: Int?) -> Int { let r = match o { Some(x) => x + 1; None => 0 }; return r }
11902        let opt_int_ty = node(
11903            200,
11904            NodeKind::TypeOptional {
11905                inner: Box::new(node(
11906                    201,
11907                    NodeKind::TypeNamed {
11908                        path: type_path(&["Int"]),
11909                        args: vec![],
11910                    },
11911                )),
11912            },
11913        );
11914        let o_param = node(
11915            30,
11916            NodeKind::Param {
11917                pattern: Box::new(bind_pat(31, "o")),
11918                ty: Some(Box::new(opt_int_ty)),
11919                default: None,
11920            },
11921        );
11922        // Some(x) => x + 1  (a value, not a `return`).
11923        let some_arm = node(
11924            40,
11925            NodeKind::MatchArm {
11926                pattern: Box::new(node(
11927                    41,
11928                    NodeKind::ConstructorPat {
11929                        path: type_path(&["Some"]),
11930                        fields: vec![bind_pat(42, "x")],
11931                    },
11932                )),
11933                guard: None,
11934                body: Box::new(block(
11935                    43,
11936                    vec![],
11937                    Some(node(
11938                        44,
11939                        NodeKind::BinaryOp {
11940                            op: BinOp::Add,
11941                            left: Box::new(id_node(45, "x")),
11942                            right: Box::new(int_lit(46, "1")),
11943                        },
11944                    )),
11945                )),
11946            },
11947        );
11948        // None => 0
11949        let none_arm = node(
11950            50,
11951            NodeKind::MatchArm {
11952                pattern: Box::new(node(
11953                    51,
11954                    NodeKind::ConstructorPat {
11955                        path: type_path(&["None"]),
11956                        fields: vec![],
11957                    },
11958                )),
11959                guard: None,
11960                body: Box::new(block(52, vec![], Some(int_lit(53, "0")))),
11961            },
11962        );
11963        let match_expr = node(
11964            60,
11965            NodeKind::Match {
11966                scrutinee: Box::new(id_node(61, "o")),
11967                arms: vec![some_arm, none_arm],
11968            },
11969        );
11970        // let r = <match_expr>  (match appears in expression position).
11971        let let_r = node(
11972            70,
11973            NodeKind::LetBinding {
11974                is_mut: false,
11975                pattern: Box::new(bind_pat(71, "r")),
11976                ty: None,
11977                value: Box::new(match_expr),
11978            },
11979        );
11980        let f = node(
11981            1,
11982            NodeKind::FnDecl {
11983                annotations: vec![],
11984                visibility: Visibility::Private,
11985                is_async: false,
11986                name: ident("pick"),
11987                generic_params: vec![],
11988                params: vec![o_param],
11989                return_type: Some(Box::new(node(
11990                    2,
11991                    NodeKind::TypeNamed {
11992                        path: type_path(&["Int"]),
11993                        args: vec![],
11994                    },
11995                ))),
11996                effect_clause: vec![],
11997                where_clause: vec![],
11998                body: Box::new(block(
11999                    3,
12000                    vec![
12001                        let_r,
12002                        node(
12003                            80,
12004                            NodeKind::Return {
12005                                value: Some(Box::new(id_node(81, "r"))),
12006                            },
12007                        ),
12008                    ],
12009                    None,
12010                )),
12011            },
12012        );
12013        let out = gen(&module(vec![], vec![f]));
12014        // No hardcoded `if False` stub.
12015        assert!(
12016            !out.contains("if False"),
12017            "expression-position match must not emit the `if False` stub, got: {out}"
12018        );
12019        // Tests the tag and binds the payload via an applied lambda.
12020        assert!(
12021            out.contains("isinstance(__v, _BockSome)"),
12022            "expected a tag test, got: {out}"
12023        );
12024        assert!(
12025            out.contains("(lambda x:") && out.contains(")(__v._0)"),
12026            "expected the Some payload bound from __v._0, got: {out}"
12027        );
12028    }
12029
12030    // ── Lambdas, typing imports, and generics (DV12 + lambda fix) ─────────────
12031
12032    /// A `GenericParam` named `name` with optional single trait `bound`.
12033    fn generic_param(name: &str, bound: Option<&str>) -> bock_ast::GenericParam {
12034        bock_ast::GenericParam {
12035            id: 0,
12036            span: span(),
12037            name: ident(name),
12038            bounds: bound.map(|b| vec![type_path(&[b])]).unwrap_or_default(),
12039        }
12040    }
12041
12042    /// A record field whose declared type is the bare named type `ty_name`
12043    /// (e.g. a type parameter `T`).
12044    fn named_field(field: &str, ty_name: &str) -> bock_ast::RecordDeclField {
12045        bock_ast::RecordDeclField {
12046            id: 0,
12047            span: span(),
12048            name: ident(field),
12049            ty: bock_ast::TypeExpr::Named {
12050                id: 0,
12051                span: span(),
12052                path: type_path(&[ty_name]),
12053                args: vec![],
12054            },
12055            default: None,
12056        }
12057    }
12058
12059    #[test]
12060    fn lambda_params_have_no_type_hints() {
12061        // `(x: Int) => x + 1` must emit `lambda x: …`, never `lambda x: int: …`
12062        // (the latter is a Python `SyntaxError` — the bug this fix closes).
12063        let lambda = node(
12064            1,
12065            NodeKind::Lambda {
12066                params: vec![typed_param_node(2, "x", "Int")],
12067                body: Box::new(node(
12068                    3,
12069                    NodeKind::BinaryOp {
12070                        op: BinOp::Add,
12071                        left: Box::new(id_node(4, "x")),
12072                        right: Box::new(int_lit(5, "1")),
12073                    },
12074                )),
12075            },
12076        );
12077        let body = block(
12078            6,
12079            vec![node(
12080                7,
12081                NodeKind::LetBinding {
12082                    is_mut: false,
12083                    pattern: Box::new(bind_pat(8, "inc")),
12084                    ty: None,
12085                    value: Box::new(lambda),
12086                },
12087            )],
12088            None,
12089        );
12090        let f = node(
12091            9,
12092            NodeKind::FnDecl {
12093                annotations: vec![],
12094                visibility: Visibility::Private,
12095                is_async: false,
12096                name: ident("run"),
12097                generic_params: vec![],
12098                params: vec![],
12099                return_type: None,
12100                effect_clause: vec![],
12101                where_clause: vec![],
12102                body: Box::new(body),
12103            },
12104        );
12105        let out = gen(&module(vec![], vec![f]));
12106        assert!(
12107            out.contains("lambda x: "),
12108            "lambda must emit a bare param list, got: {out}"
12109        );
12110        assert!(
12111            !out.contains("lambda x: int"),
12112            "lambda param must NOT carry a type hint (SyntaxError), got: {out}"
12113        );
12114    }
12115
12116    #[test]
12117    fn fn_type_param_emits_callable_import() {
12118        // A parameter of function type lowers to `Callable[[int], int]`, which
12119        // must be imported from `typing` or it raises `NameError`.
12120        let f_param = node(
12121            2,
12122            NodeKind::Param {
12123                pattern: Box::new(bind_pat(3, "f")),
12124                ty: Some(Box::new(node(
12125                    4,
12126                    NodeKind::TypeFunction {
12127                        params: vec![node(
12128                            5,
12129                            NodeKind::TypeNamed {
12130                                path: type_path(&["Int"]),
12131                                args: vec![],
12132                            },
12133                        )],
12134                        ret: Box::new(node(
12135                            6,
12136                            NodeKind::TypeNamed {
12137                                path: type_path(&["Int"]),
12138                                args: vec![],
12139                            },
12140                        )),
12141                        effects: vec![],
12142                    },
12143                ))),
12144                default: None,
12145            },
12146        );
12147        let body = block(7, vec![], Some(int_lit(8, "0")));
12148        let f = node(
12149            1,
12150            NodeKind::FnDecl {
12151                annotations: vec![],
12152                visibility: Visibility::Private,
12153                is_async: false,
12154                name: ident("apply"),
12155                generic_params: vec![],
12156                params: vec![f_param],
12157                return_type: None,
12158                effect_clause: vec![],
12159                where_clause: vec![],
12160                body: Box::new(body),
12161            },
12162        );
12163        let out = gen(&module(vec![], vec![f]));
12164        assert!(
12165            out.contains("from typing import Callable"),
12166            "Callable annotation needs its typing import, got: {out}"
12167        );
12168        assert!(
12169            out.contains("f: Callable[[int], int]"),
12170            "expected the Callable annotation, got: {out}"
12171        );
12172    }
12173
12174    #[test]
12175    fn generic_record_emits_typevar_and_generic() {
12176        // `record Box[T] { value: T }` must declare `T = TypeVar("T")`, list
12177        // `Generic[T]` in the class bases, and import both from `typing`, or
12178        // the field annotation `value: T` raises `NameError` at class-eval time.
12179        let rec = node(
12180            1,
12181            NodeKind::RecordDecl {
12182                annotations: vec![],
12183                visibility: Visibility::Public,
12184                name: ident("Box"),
12185                generic_params: vec![generic_param("T", None)],
12186                fields: vec![named_field("value", "T")],
12187            },
12188        );
12189        let out = gen(&module(vec![], vec![rec]));
12190        assert!(
12191            out.contains("from typing import Generic, TypeVar"),
12192            "expected merged typing import, got: {out}"
12193        );
12194        assert!(
12195            out.contains("T = TypeVar(\"T\")"),
12196            "expected a TypeVar declaration, got: {out}"
12197        );
12198        assert!(
12199            out.contains("class Box(Generic[T]):"),
12200            "expected Generic[T] in the class bases, got: {out}"
12201        );
12202        assert!(out.contains("value: T"), "got: {out}");
12203    }
12204
12205    #[test]
12206    fn bounded_type_param_emits_typevar_bound() {
12207        // `fn describe[T: Named](x: T) -> ...` → `T = TypeVar("T", bound=Named)`.
12208        let body = block(3, vec![], Some(int_lit(4, "0")));
12209        let f = node(
12210            1,
12211            NodeKind::FnDecl {
12212                annotations: vec![],
12213                visibility: Visibility::Private,
12214                is_async: false,
12215                name: ident("describe"),
12216                generic_params: vec![generic_param("T", Some("Named"))],
12217                params: vec![typed_param_node(2, "x", "T")],
12218                return_type: None,
12219                effect_clause: vec![],
12220                where_clause: vec![],
12221                body: Box::new(body),
12222            },
12223        );
12224        let out = gen(&module(vec![], vec![f]));
12225        assert!(
12226            out.contains("T = TypeVar(\"T\", bound=Named)"),
12227            "expected a bounded TypeVar, got: {out}"
12228        );
12229    }
12230
12231    #[test]
12232    fn shared_type_param_typevar_is_deduped() {
12233        // Two generic records sharing the parameter name `T` must declare
12234        // `T = TypeVar("T")` exactly once in the file.
12235        let box_a = node(
12236            1,
12237            NodeKind::RecordDecl {
12238                annotations: vec![],
12239                visibility: Visibility::Public,
12240                name: ident("Boxed"),
12241                generic_params: vec![generic_param("T", None)],
12242                fields: vec![named_field("value", "T")],
12243            },
12244        );
12245        let box_b = node(
12246            2,
12247            NodeKind::RecordDecl {
12248                annotations: vec![],
12249                visibility: Visibility::Public,
12250                name: ident("Wrapped"),
12251                generic_params: vec![generic_param("T", None)],
12252                fields: vec![named_field("inner", "T")],
12253            },
12254        );
12255        let out = gen(&module(vec![], vec![box_a, box_b]));
12256        let typevar_count = out.matches("T = TypeVar(\"T\")").count();
12257        assert_eq!(
12258            typevar_count, 1,
12259            "shared type param T must be declared exactly once, got {typevar_count} in: {out}"
12260        );
12261    }
12262
12263    #[test]
12264    fn method_colliding_with_field_is_disambiguated() {
12265        // record SimpleError { message: String }
12266        let record_decl = node(
12267            1,
12268            NodeKind::RecordDecl {
12269                annotations: vec![],
12270                visibility: Visibility::Public,
12271                name: ident("SimpleError"),
12272                generic_params: vec![],
12273                fields: vec![named_field("message", "String")],
12274            },
12275        );
12276        // impl Error for SimpleError { fn message(self) -> String { self.message } }
12277        let method = node(
12278            10,
12279            NodeKind::FnDecl {
12280                annotations: vec![],
12281                visibility: Visibility::Public,
12282                is_async: false,
12283                name: ident("message"),
12284                generic_params: vec![],
12285                params: vec![param_node(11, "self")],
12286                return_type: None,
12287                effect_clause: vec![],
12288                where_clause: vec![],
12289                body: Box::new(block(
12290                    12,
12291                    vec![],
12292                    Some(node(
12293                        13,
12294                        NodeKind::FieldAccess {
12295                            object: Box::new(id_node(14, "self")),
12296                            field: ident("message"),
12297                        },
12298                    )),
12299                )),
12300            },
12301        );
12302        let impl_block = node(
12303            20,
12304            NodeKind::ImplBlock {
12305                annotations: vec![],
12306                target: Box::new(node(
12307                    21,
12308                    NodeKind::TypeNamed {
12309                        path: type_path(&["SimpleError"]),
12310                        args: vec![],
12311                    },
12312                )),
12313                trait_path: Some(type_path(&["Error"])),
12314                trait_args: vec![],
12315                generic_params: vec![],
12316                where_clause: vec![],
12317                methods: vec![method],
12318            },
12319        );
12320        // fn read(e: SimpleError) -> String { e.message() }
12321        let read_fn = node(
12322            30,
12323            NodeKind::FnDecl {
12324                annotations: vec![],
12325                visibility: Visibility::Public,
12326                is_async: false,
12327                name: ident("read"),
12328                generic_params: vec![],
12329                params: vec![typed_param_node(31, "e", "SimpleError")],
12330                return_type: None,
12331                effect_clause: vec![],
12332                where_clause: vec![],
12333                body: Box::new(block(
12334                    32,
12335                    vec![],
12336                    Some(node(
12337                        33,
12338                        NodeKind::Call {
12339                            callee: Box::new(node(
12340                                34,
12341                                NodeKind::FieldAccess {
12342                                    // The lowerer reuses the *same* receiver node
12343                                    // in both the field-access object and the
12344                                    // self arg; `desugared_self_call` keys on the
12345                                    // shared NodeId, so the test must too.
12346                                    object: Box::new(id_node(35, "e")),
12347                                    field: ident("message"),
12348                                },
12349                            )),
12350                            type_args: vec![],
12351                            args: vec![AirArg {
12352                                label: None,
12353                                value: id_node(35, "e"),
12354                            }],
12355                        },
12356                    )),
12357                )),
12358            },
12359        );
12360        let out = gen(&module(vec![], vec![record_decl, impl_block, read_fn]));
12361        // The dataclass field stays `message`.
12362        assert!(
12363            out.contains("message: str"),
12364            "dataclass field should remain `message: str`, got: {out}"
12365        );
12366        // The inlined method and the call site are renamed to `message_method`
12367        // so the dataclass field no longer overwrites the method attribute.
12368        assert!(
12369            out.contains("def message_method(self)"),
12370            "method should be `def message_method`, got: {out}"
12371        );
12372        assert!(
12373            out.contains(".message_method()"),
12374            "call site should be `.message_method()`, got: {out}"
12375        );
12376        // The method body still reads the field via `self.message`.
12377        assert!(
12378            out.contains("return self.message"),
12379            "method body should read the field `self.message`, got: {out}"
12380        );
12381        // No bare `def message(self)` that the field would clobber.
12382        assert!(
12383            !out.contains("def message(self)"),
12384            "must NOT emit a `def message(self)` clobbered by the field, got: {out}"
12385        );
12386    }
12387
12388    // ── Python-specific control-flow / import lowering ──────────────────────
12389
12390    fn call_no_args(id: u32, name: &str) -> AIRNode {
12391        node(
12392            id,
12393            NodeKind::Call {
12394                callee: Box::new(id_node(id + 1, name)),
12395                args: vec![],
12396                type_args: vec![],
12397            },
12398        )
12399    }
12400
12401    /// `todo()` / `unreachable()` are diverging `raise` expressions; an arbitrary
12402    /// call or the `Unreachable` node is not (the latter *prints* a `raise` but
12403    /// is the dedicated node, recognised separately).
12404    #[test]
12405    fn is_raise_expr_recognises_todo_and_unreachable() {
12406        assert!(is_raise_expr(&call_no_args(1, "todo")));
12407        assert!(is_raise_expr(&call_no_args(3, "unreachable")));
12408        assert!(is_raise_expr(&node(5, NodeKind::Unreachable)));
12409        assert!(!is_raise_expr(&call_no_args(6, "compute")));
12410        assert!(!is_raise_expr(&int_lit(8, "1")));
12411    }
12412
12413    /// A `let x = todo()` body emits a bare `raise`, never `x = raise …` (a
12414    /// `SyntaxError`), and a `todo()` function tail emits a bare `raise`, never
12415    /// `return raise …`.
12416    #[test]
12417    fn todo_in_return_and_let_position_emits_bare_raise() {
12418        // Tail position: `fn f() { todo() }`.
12419        let f = fn_decl_tail(1, Visibility::Private, "f", call_no_args(10, "todo"));
12420        let out = gen(&module(vec![], vec![f]));
12421        assert!(
12422            out.contains("raise NotImplementedError()"),
12423            "expected a `raise`, got: {out}"
12424        );
12425        assert!(
12426            !out.contains("return raise"),
12427            "must NOT emit `return raise …`, got: {out}"
12428        );
12429    }
12430
12431    /// An expression-position `loop` bound into a `let`, yielding via `break v`,
12432    /// is hoisted to a `while True:` whose `break v` becomes `<target> = v` then
12433    /// `break` — never the invalid expression `let x = <loop>`.
12434    #[test]
12435    fn value_loop_break_hoists_to_while_assign() {
12436        // `fn f() { let r = loop { break 5 }  r }`
12437        let break_node = node(
12438            30,
12439            NodeKind::Break {
12440                value: Some(Box::new(int_lit(31, "5"))),
12441            },
12442        );
12443        let loop_node = node(
12444            20,
12445            NodeKind::Loop {
12446                body: Box::new(block(21, vec![], Some(break_node))),
12447            },
12448        );
12449        let let_r = node(
12450            10,
12451            NodeKind::LetBinding {
12452                is_mut: false,
12453                pattern: Box::new(bind_pat(11, "r")),
12454                ty: None,
12455                value: Box::new(loop_node),
12456            },
12457        );
12458        let body = block(2, vec![let_r], Some(id_node(40, "r")));
12459        let f = node(
12460            1,
12461            NodeKind::FnDecl {
12462                annotations: vec![],
12463                visibility: Visibility::Private,
12464                is_async: false,
12465                name: ident("f"),
12466                generic_params: vec![],
12467                params: vec![],
12468                return_type: None,
12469                effect_clause: vec![],
12470                where_clause: vec![],
12471                body: Box::new(body),
12472            },
12473        );
12474        let out = gen(&module(vec![], vec![f]));
12475        assert!(
12476            out.contains("while True:"),
12477            "value-loop should hoist to `while True:`, got: {out}"
12478        );
12479        // The shared AIR value-CF hoist introduces a `__bock_cf_N` temp: the
12480        // `break 5` assigns it (`__bock_cf_0 = 5`) then `break`, and `r` reads it.
12481        assert!(
12482            out.contains("__bock_cf_0 = 5"),
12483            "break value should assign the hoisted temp `__bock_cf_0 = 5`, got: {out}"
12484        );
12485        assert!(
12486            out.contains("r = __bock_cf_0"),
12487            "the let should read the hoisted temp `r = __bock_cf_0`, got: {out}"
12488        );
12489        assert!(
12490            out.contains("break"),
12491            "the loop should still `break`, got: {out}"
12492        );
12493        assert!(
12494            !out.contains("# unsupported"),
12495            "must NOT emit `# unsupported`, got: {out}"
12496        );
12497    }
12498
12499    /// A record field declaration whose name matches a sibling-module public
12500    /// function must NOT be counted as a reference: the field-label occurrence is
12501    /// subtracted, so the implicit-import scan does not pull in
12502    /// `from <sibling> import <name>` (which closes a Python import cycle).
12503    #[test]
12504    fn field_label_does_not_trigger_implicit_import() {
12505        // `module models` declares `record Summary { total: Int }`. `total` is a
12506        // FIELD name; it must not match a sibling `fn total`.
12507        let summary = node(
12508            5,
12509            NodeKind::RecordDecl {
12510                annotations: vec![],
12511                visibility: Visibility::Public,
12512                name: ident("Summary"),
12513                generic_params: vec![],
12514                fields: vec![bock_ast::RecordDeclField {
12515                    id: 6,
12516                    span: span(),
12517                    name: ident("total"),
12518                    ty: bock_ast::TypeExpr::Named {
12519                        id: 7,
12520                        span: span(),
12521                        path: type_path(&["Int"]),
12522                        args: vec![],
12523                    },
12524                    default: None,
12525                }],
12526            },
12527        );
12528        let models = module_with_path(&["models"], vec![], vec![summary]);
12529        // Public-symbol map says `total` is declared by `service`.
12530        let mut public_symbols = HashMap::new();
12531        public_symbols.insert("total".to_string(), "service".to_string());
12532        let imports = implicit_imports_for(&models, &public_symbols, "models");
12533        assert!(
12534            imports.is_empty(),
12535            "a field named `total` must not implicit-import `service.total`, got: {imports:?}"
12536        );
12537    }
12538
12539    /// `fn f() { let x = if (c) { 1 } else { return 0 }  x }` — value-position
12540    /// `if` with a diverging else. The shared value-CF hoist pre-binds a temp
12541    /// and lowers the `if` to statements, never `# unsupported` or an invalid
12542    /// `lambda` capturing the `return`.
12543    fn diverging_value_if_fn() -> AIRNode {
12544        let then_b = block(2, vec![], Some(int_lit(3, "1")));
12545        let ret = node(
12546            5,
12547            NodeKind::Return {
12548                value: Some(Box::new(int_lit(6, "0"))),
12549            },
12550        );
12551        let else_b = block(4, vec![], Some(ret));
12552        let if_node = node(
12553            1,
12554            NodeKind::If {
12555                let_pattern: None,
12556                condition: Box::new(id_node(7, "c")),
12557                then_block: Box::new(then_b),
12558                else_block: Some(Box::new(else_b)),
12559            },
12560        );
12561        let let_x = node(
12562            10,
12563            NodeKind::LetBinding {
12564                is_mut: false,
12565                pattern: Box::new(bind_pat(11, "x")),
12566                ty: None,
12567                value: Box::new(if_node),
12568            },
12569        );
12570        let body = block(20, vec![let_x], Some(id_node(21, "x")));
12571        let f = node(
12572            30,
12573            NodeKind::FnDecl {
12574                annotations: vec![],
12575                visibility: Visibility::Private,
12576                is_async: false,
12577                name: ident("f"),
12578                generic_params: vec![],
12579                params: vec![],
12580                return_type: None,
12581                effect_clause: vec![],
12582                where_clause: vec![],
12583                body: Box::new(body),
12584            },
12585        );
12586        module(vec![], vec![f])
12587    }
12588
12589    #[test]
12590    fn diverging_value_if_hoists_to_stmt_form_no_unsupported() {
12591        let out = gen(&diverging_value_if_fn());
12592        assert!(
12593            !out.contains("# unsupported"),
12594            "diverging value-if must not emit `# unsupported`, got: {out}"
12595        );
12596        assert!(
12597            out.contains("__bock_cf_0 = 1"),
12598            "value arm must assign the temp, got: {out}"
12599        );
12600        assert!(
12601            out.contains("return 0"),
12602            "diverging arm must keep its return, got: {out}"
12603        );
12604    }
12605
12606    // ── Value-position match: plain-record / tuple / guard / or / nested ────────
12607    //
12608    // These cover the value-position (`match` consumed as a value / function
12609    // tail) lowering for the pattern kinds that the legacy `(lambda __v: …)`
12610    // conditional chain could not bind: a bare-bind record field
12611    // (`Point { x, .. } => "x=${x}"` — Q-plainrecord-valpos-match, py half), a
12612    // tuple destructure, a guard arm (`n if (n < 0) => …`), an or-pattern, and a
12613    // nested constructor (`Some(Ok(n)) => …`). The chain emitted the body lambda
12614    // with the binding free (`(lambda __v: f"x={x}")(p)` → `NameError: name 'x'`),
12615    // dropped the guard entirely, and tested or/record/tuple arms as `if True`
12616    // (collapsing every later arm). The fix routes a value-position match needing
12617    // the if-chain to the statement-form `match`/`case` machinery, which binds
12618    // and tests every pattern kind correctly.
12619
12620    /// Build a single-arg `fn <name>(p) -> ...` whose body is a value-position
12621    /// (tail) `match p { <arms> }`. The arms are expression-bodied.
12622    fn match_fn(name: &str, arms: Vec<AIRNode>) -> AIRNode {
12623        let match_node = node(
12624            900,
12625            NodeKind::Match {
12626                scrutinee: Box::new(id_node(901, "p")),
12627                arms,
12628            },
12629        );
12630        let f = node(
12631            910,
12632            NodeKind::FnDecl {
12633                annotations: vec![],
12634                visibility: Visibility::Public,
12635                is_async: false,
12636                name: ident(name),
12637                generic_params: vec![],
12638                params: vec![param_node(911, "p")],
12639                return_type: None,
12640                effect_clause: vec![],
12641                where_clause: vec![],
12642                body: Box::new(block(912, vec![], Some(match_node))),
12643            },
12644        );
12645        module(vec![], vec![f])
12646    }
12647
12648    fn record_pat_field(_id: u32, name: &str, pat: Option<AIRNode>) -> AirRecordPatternField {
12649        AirRecordPatternField {
12650            name: ident(name),
12651            pattern: pat.map(Box::new),
12652        }
12653    }
12654
12655    /// `Point { x, .. } => "x=${x}"` — a bare-bind record field in value
12656    /// position. Must bind `x` from the scrutinee, never emit a free `x`.
12657    #[test]
12658    fn py_plainrecord_match_binds_field_in_value_position() {
12659        let arm = node(
12660            100,
12661            NodeKind::MatchArm {
12662                pattern: Box::new(node(
12663                    101,
12664                    NodeKind::RecordPat {
12665                        path: type_path(&["Point"]),
12666                        fields: vec![record_pat_field(102, "x", None)],
12667                        rest: true,
12668                    },
12669                )),
12670                guard: None,
12671                body: Box::new(block(103, vec![], Some(id_node(104, "x")))),
12672            },
12673        );
12674        let out = gen(&match_fn("get_x", vec![arm]));
12675        // The field bind must be introduced (statement-form `case Point(x=x):`),
12676        // not left free inside a `(lambda __v: … x …)` chain.
12677        assert!(
12678            out.contains("match p:") && out.contains("case Point(x=x):"),
12679            "plain-record value match must bind the field via case Point(x=x), got:\n{out}"
12680        );
12681        assert!(
12682            !out.contains("(lambda __v: x)"),
12683            "must not emit the field name free inside a value lambda, got:\n{out}"
12684        );
12685        // Inject a real `Point` dataclass (after the leading `from __future__`
12686        // line, which must stay first) so `case Point(x=x):` has a class to bind.
12687        let stubbed = out.replacen(
12688            "from __future__ import annotations\n",
12689            "from __future__ import annotations\nfrom dataclasses import dataclass as _dc\n@_dc\nclass Point:\n    x: int = 0\n",
12690            1,
12691        );
12692        assert!(
12693            !has_python3() || check_py_syntax(&stubbed),
12694            "generated python must parse, got:\n{stubbed}"
12695        );
12696    }
12697
12698    /// The predicate that drives the fix: a user-enum constructor pattern that
12699    /// binds a payload (`Circle(r)`) needs statement form, but the runtime
12700    /// `Some`/`None`/`Ok`/`Err` shapes (handled by the value chain) do not.
12701    #[test]
12702    fn arm_constructor_binds_payload_distinguishes_user_from_runtime() {
12703        let user_bind = node(
12704            1,
12705            NodeKind::ConstructorPat {
12706                path: type_path(&["Circle"]),
12707                fields: vec![bind_pat(2, "r")],
12708            },
12709        );
12710        assert!(
12711            arm_constructor_binds_payload(&user_bind),
12712            "user-enum constructor binding a payload must route to statement form"
12713        );
12714        let some_bind = node(
12715            3,
12716            NodeKind::ConstructorPat {
12717                path: type_path(&["Some"]),
12718                fields: vec![bind_pat(4, "x")],
12719            },
12720        );
12721        assert!(
12722            !arm_constructor_binds_payload(&some_bind),
12723            "Some(x) is bound by the value chain, stays off statement form"
12724        );
12725        let user_unit = node(
12726            5,
12727            NodeKind::ConstructorPat {
12728                path: type_path(&["Red"]),
12729                fields: vec![],
12730            },
12731        );
12732        assert!(
12733            !arm_constructor_binds_payload(&user_unit),
12734            "a payload-less user variant binds nothing, stays off statement form"
12735        );
12736    }
12737
12738    /// Q-py-letexpr-match-namerror: a user-enum constructor-payload bind in a
12739    /// LET-EXPRESSION-position match (`let a = match s { Circle(r) => r … }`)
12740    /// must route to the statement-form `match`/`case` so `r` is bound — never
12741    /// the `(lambda __v: r …)(s)` chain that leaves `r` free (`NameError`).
12742    #[test]
12743    fn py_letexpr_constructor_payload_routes_to_statement_form() {
12744        let circle = node(
12745            100,
12746            NodeKind::MatchArm {
12747                pattern: Box::new(node(
12748                    101,
12749                    NodeKind::ConstructorPat {
12750                        path: type_path(&["Circle"]),
12751                        fields: vec![bind_pat(102, "r")],
12752                    },
12753                )),
12754                guard: None,
12755                body: Box::new(block(103, vec![], Some(id_node(104, "r")))),
12756            },
12757        );
12758        let other = node(
12759            110,
12760            NodeKind::MatchArm {
12761                pattern: Box::new(node(111, NodeKind::WildcardPat)),
12762                guard: None,
12763                body: Box::new(block(112, vec![], Some(int_lit(113, "0")))),
12764            },
12765        );
12766        let match_node = node(
12767            120,
12768            NodeKind::Match {
12769                scrutinee: Box::new(id_node(121, "s")),
12770                arms: vec![circle, other],
12771            },
12772        );
12773        // `let a = match (s) { … }`; tail reads `a`.
12774        let let_a = node(
12775            130,
12776            NodeKind::LetBinding {
12777                is_mut: false,
12778                pattern: Box::new(bind_pat(131, "a")),
12779                ty: None,
12780                value: Box::new(match_node),
12781            },
12782        );
12783        let f = node(
12784            140,
12785            NodeKind::FnDecl {
12786                annotations: vec![],
12787                visibility: Visibility::Public,
12788                is_async: false,
12789                name: ident("size"),
12790                generic_params: vec![],
12791                params: vec![param_node(141, "s")],
12792                return_type: None,
12793                effect_clause: vec![],
12794                where_clause: vec![],
12795                body: Box::new(block(142, vec![let_a], Some(id_node(143, "a")))),
12796            },
12797        );
12798        let out = gen(&module(vec![], vec![f]));
12799        assert!(
12800            out.contains("match s:") && out.contains("case Circle(_0=r):"),
12801            "let-expr constructor-payload match must bind r via statement form, got:\n{out}"
12802        );
12803        assert!(
12804            !out.contains("lambda __v"),
12805            "must not lower the payload-binding match to a value lambda, got:\n{out}"
12806        );
12807    }
12808
12809    /// `n if (n < 0) => "neg"  _ => "nonneg"` — a guard arm in value position.
12810    /// The guard test must survive (the legacy chain dropped it, so every input
12811    /// took the first arm).
12812    #[test]
12813    fn py_matcharm_guard_value_position_keeps_guard() {
12814        let guarded = node(
12815            200,
12816            NodeKind::MatchArm {
12817                pattern: Box::new(bind_pat(201, "n")),
12818                guard: Some(Box::new(node(
12819                    202,
12820                    NodeKind::BinaryOp {
12821                        op: BinOp::Lt,
12822                        left: Box::new(id_node(203, "n")),
12823                        right: Box::new(int_lit(204, "0")),
12824                    },
12825                ))),
12826                body: Box::new(block(205, vec![], Some(str_lit(206, "neg")))),
12827            },
12828        );
12829        let default = node(
12830            210,
12831            NodeKind::MatchArm {
12832                pattern: Box::new(node(211, NodeKind::WildcardPat)),
12833                guard: None,
12834                body: Box::new(block(212, vec![], Some(str_lit(213, "nonneg")))),
12835            },
12836        );
12837        let out = gen(&match_fn("classify", vec![guarded, default]));
12838        assert!(
12839            out.contains("match p:") && out.contains("case n if (n < 0):"),
12840            "guard arm in value position must keep its guard test, got:\n{out}"
12841        );
12842        assert!(
12843            !has_python3() || check_py_syntax(&out),
12844            "generated python must parse, got:\n{out}"
12845        );
12846    }
12847
12848    /// `(0, _) => "zero"  (n, s) => "${n}: ${s}"` — tuple patterns in value
12849    /// position must bind `n`/`s` and test the literal element.
12850    #[test]
12851    fn py_tuple_match_value_position_binds_and_tests() {
12852        let zero_arm = node(
12853            300,
12854            NodeKind::MatchArm {
12855                pattern: Box::new(node(
12856                    301,
12857                    NodeKind::TuplePat {
12858                        elems: vec![
12859                            node(
12860                                302,
12861                                NodeKind::LiteralPat {
12862                                    lit: Literal::Int("0".into()),
12863                                },
12864                            ),
12865                            node(303, NodeKind::WildcardPat),
12866                        ],
12867                    },
12868                )),
12869                guard: None,
12870                body: Box::new(block(304, vec![], Some(str_lit(305, "zero")))),
12871            },
12872        );
12873        let bind_arm = node(
12874            310,
12875            NodeKind::MatchArm {
12876                pattern: Box::new(node(
12877                    311,
12878                    NodeKind::TuplePat {
12879                        elems: vec![bind_pat(312, "n"), bind_pat(313, "s")],
12880                    },
12881                )),
12882                guard: None,
12883                body: Box::new(block(314, vec![], Some(id_node(315, "n")))),
12884            },
12885        );
12886        let out = gen(&match_fn("describe", vec![zero_arm, bind_arm]));
12887        assert!(
12888            out.contains("match p:")
12889                && out.contains("case (0, _):")
12890                && out.contains("case (n, s):"),
12891            "tuple value match must test the literal and bind elements, got:\n{out}"
12892        );
12893        assert!(
12894            !has_python3() || check_py_syntax(&out),
12895            "generated python must parse, got:\n{out}"
12896        );
12897    }
12898
12899    /// `Some(Ok(n)) => "${n}"  …` — a nested constructor in value position must
12900    /// test the inner `Ok` and bind `n`, not collapse to `isinstance(__v, …)`
12901    /// with `n` free.
12902    #[test]
12903    fn py_nested_constructor_match_value_position_binds_inner() {
12904        let some_ok = node(
12905            400,
12906            NodeKind::MatchArm {
12907                pattern: Box::new(node(
12908                    401,
12909                    NodeKind::ConstructorPat {
12910                        path: type_path(&["Some"]),
12911                        fields: vec![node(
12912                            402,
12913                            NodeKind::ConstructorPat {
12914                                path: type_path(&["Ok"]),
12915                                fields: vec![bind_pat(403, "n")],
12916                            },
12917                        )],
12918                    },
12919                )),
12920                guard: None,
12921                body: Box::new(block(404, vec![], Some(id_node(405, "n")))),
12922            },
12923        );
12924        let none_arm = node(
12925            410,
12926            NodeKind::MatchArm {
12927                pattern: Box::new(node(
12928                    411,
12929                    NodeKind::ConstructorPat {
12930                        path: type_path(&["None"]),
12931                        fields: vec![],
12932                    },
12933                )),
12934                guard: None,
12935                body: Box::new(block(412, vec![], Some(str_lit(413, "none")))),
12936            },
12937        );
12938        let out = gen(&match_fn("nested", vec![some_ok, none_arm]));
12939        assert!(
12940            out.contains("match p:") && out.contains("case _BockSome(_BockOk(n)):"),
12941            "nested constructor value match must test+bind the inner Ok, got:\n{out}"
12942        );
12943        assert!(
12944            !has_python3() || check_py_syntax(&out),
12945            "generated python must parse, got:\n{out}"
12946        );
12947    }
12948
12949    /// Q-py-valpos-stmt-arms: a value-position `match` arm whose body is a
12950    /// *block with leading statements* must run those statements and keep their
12951    /// bindings in scope for the tail. The calculator's chained-computation arm
12952    /// `Ok(sum) => { let step2 = …; <inner match> }` previously emitted
12953    /// `(lambda: <tail>)()`, dropping the `let step2` and leaving `step2`
12954    /// unbound at runtime. The fix folds the `let` into an immediately-applied
12955    /// lambda: `(lambda step2: <tail>)(<value>)`.
12956    #[test]
12957    fn py_valpos_match_arm_block_keeps_leading_let() {
12958        // Ok(n) => { let doubled = (n + n); doubled }
12959        let let_stmt = node(
12960            500,
12961            NodeKind::LetBinding {
12962                is_mut: false,
12963                pattern: Box::new(bind_pat(501, "doubled")),
12964                ty: None,
12965                value: Box::new(node(
12966                    502,
12967                    NodeKind::BinaryOp {
12968                        op: BinOp::Add,
12969                        left: Box::new(id_node(503, "n")),
12970                        right: Box::new(id_node(504, "n")),
12971                    },
12972                )),
12973            },
12974        );
12975        let ok_arm = node(
12976            510,
12977            NodeKind::MatchArm {
12978                pattern: Box::new(node(
12979                    511,
12980                    NodeKind::ConstructorPat {
12981                        path: type_path(&["Ok"]),
12982                        fields: vec![bind_pat(512, "n")],
12983                    },
12984                )),
12985                guard: None,
12986                body: Box::new(block(513, vec![let_stmt], Some(id_node(514, "doubled")))),
12987            },
12988        );
12989        let err_arm = node(
12990            520,
12991            NodeKind::MatchArm {
12992                pattern: Box::new(node(
12993                    521,
12994                    NodeKind::ConstructorPat {
12995                        path: type_path(&["Err"]),
12996                        fields: vec![bind_pat(522, "e")],
12997                    },
12998                )),
12999                guard: None,
13000                body: Box::new(block(523, vec![], Some(id_node(524, "e")))),
13001            },
13002        );
13003        let out = gen(&match_fn("keep_let", vec![ok_arm, err_arm]));
13004        assert!(
13005            out.contains("lambda doubled:"),
13006            "value-position match-arm block must keep its `let doubled` binding, got:\n{out}"
13007        );
13008        assert!(
13009            !out.contains("(lambda: "),
13010            "must not emit the statement-dropping `(lambda: <tail>)()` form, got:\n{out}"
13011        );
13012        assert!(
13013            !has_python3() || check_py_syntax(&out),
13014            "generated python must parse, got:\n{out}"
13015        );
13016    }
13017}