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 → erased (Python uses runtime typing)
10//! - Type hints on all declarations
11
12use std::collections::HashMap;
13use std::fmt::Write;
14use std::path::PathBuf;
15
16use bock_air::{AIRNode, AirInterpolationPart, EnumVariantPayload, NodeKind, ResultVariant};
17use bock_ast::{AssignOp, BinOp, ImportItems, Literal, TypeExpr, UnaryOp, Visibility};
18use bock_types::AIRModule;
19
20use crate::error::CodegenError;
21use crate::generator::{CodeGenerator, GeneratedCode, OutputFile, SourceMap};
22use crate::profile::TargetProfile;
23
24/// Runtime helpers for Bock concurrency in Python. Backed by
25/// `asyncio.Queue`, which supports multiple producers and a single
26/// consumer awaiting `.get()` at a time. Injected at the top of any
27/// module that references `Channel` or `spawn`.
28/// Conservative module scan for `Channel` / `spawn` references.
29fn py_module_uses_concurrency(items: &[AIRNode]) -> bool {
30    items.iter().any(|n| {
31        let s = format!("{n:?}");
32        s.contains("\"Channel\"") || s.contains("\"spawn\"")
33    })
34}
35
36const CONCURRENCY_RUNTIME_PY: &str = "\
37# ── Bock concurrency runtime ──
38import asyncio as __bock_asyncio
39
40class __BockChannel:
41    __slots__ = ('_q',)
42    def __init__(self):
43        self._q = __bock_asyncio.Queue()
44    def send(self, v):
45        self._q.put_nowait(v)
46    async def recv(self):
47        return await self._q.get()
48    def close(self):
49        pass
50
51def __bock_channel_new():
52    ch = __BockChannel()
53    return (ch, ch)
54
55def __bock_spawn(x):
56    # If already a coroutine, wrap it in a Task so it starts eagerly.
57    if __bock_asyncio.iscoroutine(x):
58        return __bock_asyncio.create_task(x)
59    return x
60";
61
62/// Python code generator implementing the `CodeGenerator` trait.
63#[derive(Debug)]
64pub struct PyGenerator {
65    profile: TargetProfile,
66}
67
68impl PyGenerator {
69    /// Creates a new Python code generator.
70    #[must_use]
71    pub fn new() -> Self {
72        Self {
73            profile: TargetProfile::python(),
74        }
75    }
76}
77
78impl Default for PyGenerator {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl CodeGenerator for PyGenerator {
85    fn target(&self) -> &TargetProfile {
86        &self.profile
87    }
88
89    fn generate_module(&self, module: &AIRModule) -> Result<GeneratedCode, CodegenError> {
90        let mut ctx = PyEmitCtx::new();
91        ctx.emit_node(module)?;
92        let content = ctx.finish();
93        let source_map = SourceMap {
94            generated_file: "output.py".to_string(),
95            ..Default::default()
96        };
97        Ok(GeneratedCode {
98            files: vec![OutputFile {
99                path: PathBuf::from("output.py"),
100                content,
101            }],
102            source_map: Some(source_map),
103        })
104    }
105
106    fn entry_invocation(&self, main_is_async: bool) -> Option<String> {
107        if main_is_async {
108            Some(
109                "if __name__ == \"__main__\":\n    asyncio.run(main())\n"
110                    .to_string(),
111            )
112        } else {
113            Some("if __name__ == \"__main__\":\n    main()\n".to_string())
114        }
115    }
116}
117
118// ─── Emission context ────────────────────────────────────────────────────────
119
120/// Internal state for Python emission.
121struct PyEmitCtx {
122    buf: String,
123    indent: usize,
124    needs_dataclass_import: bool,
125    needs_abc_import: bool,
126    /// Set when any `async def` is emitted; forces `import asyncio` in the
127    /// preamble so awaited calls, `asyncio.run`, and `asyncio.create_task`
128    /// resolve at runtime.
129    needs_asyncio_import: bool,
130    /// Set when Duration/Instant codegen emits `time.monotonic_ns()`.
131    needs_time_import: bool,
132    /// Names bound in the current block whose call value should be wrapped
133    /// in `asyncio.create_task(...)` because the binding is later `await`ed
134    /// within the same block. See [`Self::collect_task_bindings`].
135    task_bound_names: std::collections::HashSet<String>,
136    /// Maps effect operation name → effect type name (e.g., "log" → "Logger").
137    effect_ops: HashMap<String, String>,
138    /// Maps effect type name → current handler variable name in scope.
139    current_handler_vars: HashMap<String, String>,
140    /// Maps function name → effect type names from its `with` clause.
141    fn_effects: HashMap<String, Vec<String>>,
142    /// Maps composite effect name → component effect names.
143    composite_effects: HashMap<String, Vec<String>>,
144    /// Monotonically-increasing counter used to generate unique handler
145    /// variable names per handling block. Python lacks block scope for
146    /// `=` bindings, so without a suffix, nested `handling (...)` blocks
147    /// would overwrite each other's handler variables.
148    handling_counter: usize,
149    /// Trait impls keyed by target record name, collected up front from the
150    /// current module's items so `RecordDecl` emission can inline the impl
151    /// methods as class members instead of leaving orphan module-level
152    /// functions that never get bound to the handler instance.
153    impls_by_target: HashMap<String, Vec<AIRNode>>,
154}
155
156impl PyEmitCtx {
157    fn new() -> Self {
158        Self {
159            buf: String::with_capacity(4096),
160            indent: 0,
161            needs_dataclass_import: false,
162            needs_abc_import: false,
163            needs_asyncio_import: false,
164            needs_time_import: false,
165            task_bound_names: std::collections::HashSet::new(),
166            effect_ops: HashMap::new(),
167            current_handler_vars: HashMap::new(),
168            fn_effects: HashMap::new(),
169            composite_effects: HashMap::new(),
170            handling_counter: 0,
171            impls_by_target: HashMap::new(),
172        }
173    }
174
175    fn finish(mut self) -> String {
176        let mut preamble = String::new();
177        if self.needs_asyncio_import {
178            preamble.push_str("import asyncio\n");
179        }
180        if self.needs_time_import {
181            preamble.push_str("import time\n");
182        }
183        if self.needs_abc_import {
184            preamble.push_str("from abc import ABC, abstractmethod\n");
185        }
186        if self.needs_dataclass_import {
187            preamble.push_str("from dataclasses import dataclass\n");
188        }
189        if !preamble.is_empty() {
190            preamble.push('\n');
191            self.buf.insert_str(0, &preamble);
192        }
193        self.buf
194    }
195
196    fn indent_str(&self) -> String {
197        "    ".repeat(self.indent)
198    }
199
200    fn write_indent(&mut self) {
201        let indent = self.indent_str();
202        self.buf.push_str(&indent);
203    }
204
205    fn writeln(&mut self, s: &str) {
206        self.write_indent();
207        self.buf.push_str(s);
208        self.buf.push('\n');
209    }
210
211    // ── Prelude function mapping ──────────────────────────────────────────
212
213    /// Emit an expression into a temporary buffer and return the string.
214    fn expr_to_string(&mut self, node: &AIRNode) -> Result<String, CodegenError> {
215        let start = self.buf.len();
216        self.emit_expr(node)?;
217        let s = self.buf[start..].to_string();
218        self.buf.truncate(start);
219        Ok(s)
220    }
221
222    /// Map Bock prelude functions to Python equivalents.
223    fn map_prelude_call(
224        &mut self,
225        callee: &AIRNode,
226        args: &[bock_air::AirArg],
227    ) -> Result<Option<String>, CodegenError> {
228        let name = match &callee.kind {
229            NodeKind::Identifier { name } => name.name.as_str(),
230            _ => return Ok(None),
231        };
232        let arg_strs: Vec<String> = args
233            .iter()
234            .map(|a| self.expr_to_string(&a.value))
235            .collect::<Result<_, _>>()?;
236        let code = match name {
237            "println" => {
238                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
239                format!("print({a})")
240            }
241            "print" => {
242                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
243                format!("print({a}, end=\"\")")
244            }
245            "debug" => {
246                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
247                format!("print(repr({a}))")
248            }
249            "assert" => {
250                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
251                format!("assert {a}")
252            }
253            "todo" => "raise NotImplementedError()".to_string(),
254            "unreachable" => "raise RuntimeError(\"unreachable\")".to_string(),
255            "sleep" => {
256                self.needs_asyncio_import = true;
257                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
258                // Duration is ns → asyncio.sleep takes seconds.
259                format!("asyncio.sleep(({a}) / 1_000_000_000)")
260            }
261            _ => return Ok(None),
262        };
263        Ok(Some(code))
264    }
265
266    /// Recognise `Duration.xxx(...)` / `Instant.xxx(...)` associated-function
267    /// calls and emit inline arithmetic. Durations are ints (nanoseconds);
268    /// Instants are ints representing `time.monotonic_ns()`.
269    fn try_emit_time_assoc_call(
270        &mut self,
271        callee: &AIRNode,
272        args: &[bock_air::AirArg],
273    ) -> Result<bool, CodegenError> {
274        let NodeKind::FieldAccess { object, field } = &callee.kind else {
275            return Ok(false);
276        };
277        let NodeKind::Identifier { name: type_name } = &object.kind else {
278            return Ok(false);
279        };
280        let arg_strs: Vec<String> = args
281            .iter()
282            .map(|a| self.expr_to_string(&a.value))
283            .collect::<Result<_, _>>()?;
284        let arg0 = || arg_strs.first().cloned().unwrap_or_default();
285        let code = match (type_name.name.as_str(), field.name.as_str()) {
286            ("Duration", "zero") => "0".to_string(),
287            ("Duration", "nanos") => arg0(),
288            ("Duration", "micros") => format!("(({}) * 1_000)", arg0()),
289            ("Duration", "millis") => format!("(({}) * 1_000_000)", arg0()),
290            ("Duration", "seconds") => format!("(({}) * 1_000_000_000)", arg0()),
291            ("Duration", "minutes") => format!("(({}) * 60_000_000_000)", arg0()),
292            ("Duration", "hours") => format!("(({}) * 3_600_000_000_000)", arg0()),
293            ("Instant", "now") => {
294                self.needs_time_import = true;
295                "time.monotonic_ns()".to_string()
296            }
297            _ => return Ok(false),
298        };
299        self.buf.push_str(&code);
300        Ok(true)
301    }
302
303    /// Recognise `Channel.new()`, `spawn(...)`, and method calls on a
304    /// channel value (`send`, `recv`, `close`) and emit the Python
305    /// runtime helper equivalents.
306    fn try_emit_concurrency_call(
307        &mut self,
308        callee: &AIRNode,
309        args: &[bock_air::AirArg],
310    ) -> Result<bool, CodegenError> {
311        if let NodeKind::Identifier { name } = &callee.kind {
312            if name.name == "spawn" {
313                self.buf.push_str("__bock_spawn(");
314                for (i, arg) in args.iter().enumerate() {
315                    if i > 0 {
316                        self.buf.push_str(", ");
317                    }
318                    self.emit_expr(&arg.value)?;
319                }
320                self.buf.push(')');
321                return Ok(true);
322            }
323        }
324        let NodeKind::FieldAccess { object, field } = &callee.kind else {
325            return Ok(false);
326        };
327        if let NodeKind::Identifier { name: type_name } = &object.kind {
328            if type_name.name == "Channel" && field.name == "new" {
329                self.buf.push_str("__bock_channel_new()");
330                return Ok(true);
331            }
332        }
333        if matches!(field.name.as_str(), "send" | "recv" | "close") {
334            self.emit_expr(object)?;
335            let _ = write!(self.buf, ".{}", field.name);
336            self.buf.push('(');
337            for (i, arg) in args.iter().skip(1).enumerate() {
338                if i > 0 {
339                    self.buf.push_str(", ");
340                }
341                self.emit_expr(&arg.value)?;
342            }
343            self.buf.push(')');
344            return Ok(true);
345        }
346        Ok(false)
347    }
348
349    /// Recognise desugared method calls `Call(FieldAccess(recv, m), [recv, ...args])`
350    /// on Duration/Instant values and emit inline arithmetic.
351    fn try_emit_time_desugared_method(
352        &mut self,
353        callee: &AIRNode,
354        args: &[bock_air::AirArg],
355    ) -> Result<bool, CodegenError> {
356        let NodeKind::FieldAccess { object, field } = &callee.kind else {
357            return Ok(false);
358        };
359        if let NodeKind::Identifier { name } = &object.kind {
360            if matches!(name.name.as_str(), "Duration" | "Instant") {
361                return Ok(false);
362            }
363        }
364        if !is_time_method_name(&field.name) {
365            return Ok(false);
366        }
367        let remaining: Vec<bock_air::AirArg> = args.iter().skip(1).cloned().collect();
368        self.try_emit_time_method(object, &field.name, &remaining)
369    }
370
371    /// Recognise instance methods on Duration/Instant values and emit inline
372    /// arithmetic.
373    fn try_emit_time_method(
374        &mut self,
375        receiver: &AIRNode,
376        method: &str,
377        args: &[bock_air::AirArg],
378    ) -> Result<bool, CodegenError> {
379        let recv_str = self.expr_to_string(receiver)?;
380        let arg_strs: Vec<String> = args
381            .iter()
382            .map(|a| self.expr_to_string(&a.value))
383            .collect::<Result<_, _>>()?;
384        let code = match method {
385            "as_nanos" => format!("({recv_str})"),
386            "as_millis" => format!("(({recv_str}) // 1_000_000)"),
387            "as_seconds" => format!("(({recv_str}) // 1_000_000_000)"),
388            "is_zero" => format!("(({recv_str}) == 0)"),
389            "is_negative" => format!("(({recv_str}) < 0)"),
390            "abs" => format!("abs({recv_str})"),
391            "elapsed" => {
392                self.needs_time_import = true;
393                format!("(time.monotonic_ns() - ({recv_str}))")
394            }
395            "duration_since" => {
396                let other = arg_strs.first().cloned().unwrap_or_default();
397                format!("(({recv_str}) - ({other}))")
398            }
399            _ => return Ok(false),
400        };
401        self.buf.push_str(&code);
402        Ok(true)
403    }
404
405    // ── Top-level dispatch ──────────────────────────────────────────────────
406
407    fn emit_node(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
408        match &node.kind {
409            NodeKind::Module { imports, items, .. } => {
410                if py_module_uses_concurrency(items) {
411                    self.buf.push_str(CONCURRENCY_RUNTIME_PY);
412                    self.buf.push('\n');
413                }
414                for imp in imports {
415                    self.emit_node(imp)?;
416                }
417                if !imports.is_empty() && !items.is_empty() {
418                    self.buf.push('\n');
419                }
420                // Pre-scan trait impls so we can attach their methods to the
421                // target record's class body instead of leaving them as
422                // orphan module-level functions with a `self` parameter.
423                self.impls_by_target.clear();
424                let mut consumed_impls: std::collections::HashSet<bock_air::NodeId> =
425                    std::collections::HashSet::new();
426                for item in items.iter() {
427                    if let NodeKind::ImplBlock {
428                        trait_path: Some(_),
429                        target,
430                        ..
431                    } = &item.kind
432                    {
433                        if let Some(target_name) = ast_type_name(target) {
434                            self.impls_by_target
435                                .entry(target_name)
436                                .or_default()
437                                .push(item.clone());
438                            consumed_impls.insert(item.id);
439                        }
440                    }
441                }
442                for (i, item) in items.iter().enumerate() {
443                    if consumed_impls.contains(&item.id) {
444                        continue;
445                    }
446                    if i > 0 && !self.buf.is_empty() && !self.buf.ends_with("\n\n") {
447                        self.buf.push('\n');
448                    }
449                    self.emit_node(item)?;
450                }
451                Ok(())
452            }
453            NodeKind::ImportDecl { path, items } => {
454                let path_str = path
455                    .segments
456                    .iter()
457                    .map(|s| s.name.as_str())
458                    .collect::<Vec<_>>()
459                    .join(".");
460                match items {
461                    ImportItems::Module => {
462                        self.writeln(&format!("import {path_str}"));
463                    }
464                    ImportItems::Named(names) => {
465                        let names_str = names
466                            .iter()
467                            .map(|n| to_snake_case(&n.name.name))
468                            .collect::<Vec<_>>()
469                            .join(", ");
470                        self.writeln(&format!("from {path_str} import {names_str}"));
471                    }
472                    ImportItems::Glob => {
473                        self.writeln(&format!("from {path_str} import *"));
474                    }
475                }
476                Ok(())
477            }
478            NodeKind::FnDecl {
479                visibility,
480                is_async,
481                name,
482                params,
483                return_type,
484                effect_clause,
485                body,
486                ..
487            } => self.emit_fn_decl(
488                *visibility,
489                *is_async,
490                &name.name,
491                params,
492                return_type.as_deref(),
493                effect_clause,
494                body,
495            ),
496            NodeKind::RecordDecl { name, fields, .. } => {
497                // Pull any previously-collected `impl Trait for Name` blocks
498                // so their methods become part of this class body and the
499                // class inherits from every implemented trait — giving real
500                // method dispatch (a bare instance has no orphan methods).
501                let impls = self.impls_by_target.remove(&name.name).unwrap_or_default();
502                let bases: Vec<String> = impls
503                    .iter()
504                    .filter_map(|im| {
505                        if let NodeKind::ImplBlock {
506                            trait_path: Some(tp),
507                            ..
508                        } = &im.kind
509                        {
510                            tp.segments.last().map(|s| s.name.clone())
511                        } else {
512                            None
513                        }
514                    })
515                    .collect();
516                let base_list = if bases.is_empty() {
517                    String::new()
518                } else {
519                    format!("({})", bases.join(", "))
520                };
521                // `@dataclass` is only appropriate when the class actually
522                // carries data. Empty handler structs are cleaner as plain
523                // classes — `@dataclass` on an ABC subclass without fields
524                // adds no value and drags in the dataclass metaclass.
525                if !fields.is_empty() {
526                    self.needs_dataclass_import = true;
527                    self.writeln("@dataclass");
528                }
529                self.writeln(&format!("class {}{base_list}:", name.name));
530                self.indent += 1;
531                let has_members = !fields.is_empty()
532                    || impls
533                        .iter()
534                        .any(|im| matches!(&im.kind, NodeKind::ImplBlock { methods, .. } if !methods.is_empty()));
535                if !has_members {
536                    self.writeln("pass");
537                } else {
538                    for f in fields {
539                        let type_hint = self.ast_type_to_py(&f.ty);
540                        self.writeln(&format!("{}: {type_hint}", to_snake_case(&f.name.name)));
541                    }
542                    for im in &impls {
543                        if let NodeKind::ImplBlock { methods, .. } = &im.kind {
544                            for method in methods {
545                                self.buf.push('\n');
546                                self.emit_class_method(method)?;
547                            }
548                        }
549                    }
550                }
551                self.indent -= 1;
552                Ok(())
553            }
554            NodeKind::EnumDecl { name, variants, .. } => {
555                self.needs_dataclass_import = true;
556                for (i, variant) in variants.iter().enumerate() {
557                    if i > 0 {
558                        self.buf.push('\n');
559                    }
560                    self.emit_enum_variant(&name.name, variant)?;
561                }
562                Ok(())
563            }
564            NodeKind::ClassDecl {
565                name,
566                fields,
567                methods,
568                ..
569            } => {
570                self.writeln(&format!("class {}:", name.name));
571                self.indent += 1;
572                // __init__
573                if !fields.is_empty() {
574                    let params: Vec<String> = fields
575                        .iter()
576                        .map(|f| {
577                            let fname = to_snake_case(&f.name.name);
578                            let type_hint = self.ast_type_to_py(&f.ty);
579                            format!("{fname}: {type_hint}")
580                        })
581                        .collect();
582                    self.writeln(&format!("def __init__(self, {}):", params.join(", ")));
583                    self.indent += 1;
584                    for f in fields {
585                        let fname = to_snake_case(&f.name.name);
586                        self.writeln(&format!("self.{fname} = {fname}"));
587                    }
588                    self.indent -= 1;
589                }
590                // Methods
591                for method in methods {
592                    self.buf.push('\n');
593                    self.emit_class_method(method)?;
594                }
595                if fields.is_empty() && methods.is_empty() {
596                    self.writeln("pass");
597                }
598                self.indent -= 1;
599                Ok(())
600            }
601            NodeKind::TraitDecl { name, methods, .. } => {
602                // Traits → abstract base class (comment + class with pass methods).
603                self.writeln(&format!("# trait {}", name.name));
604                self.writeln(&format!("class {}:", name.name));
605                self.indent += 1;
606                if methods.is_empty() {
607                    self.writeln("pass");
608                } else {
609                    for (i, method) in methods.iter().enumerate() {
610                        if i > 0 {
611                            self.buf.push('\n');
612                        }
613                        self.emit_class_method(method)?;
614                    }
615                }
616                self.indent -= 1;
617                Ok(())
618            }
619            NodeKind::ImplBlock {
620                trait_path,
621                target,
622                methods,
623                ..
624            } => {
625                let target_name = self.type_expr_to_string(target);
626                if let Some(tp) = trait_path {
627                    let trait_name = tp
628                        .segments
629                        .iter()
630                        .map(|s| s.name.as_str())
631                        .collect::<Vec<_>>()
632                        .join(".");
633                    self.writeln(&format!("# impl {trait_name} for {target_name}"));
634                } else {
635                    self.writeln(&format!("# impl {target_name}"));
636                }
637                for method in methods {
638                    if let NodeKind::FnDecl {
639                        is_async,
640                        name,
641                        params,
642                        return_type,
643                        effect_clause,
644                        body,
645                        ..
646                    } = &method.kind
647                    {
648                        if *is_async {
649                            self.needs_asyncio_import = true;
650                        }
651                        let async_kw = if *is_async { "async " } else { "" };
652                        let param_strs = self.collect_param_strs(params);
653                        let effects = self.effects_params(effect_clause);
654                        let mut all_params = vec!["self".to_string()];
655                        all_params.extend(param_strs);
656                        all_params.extend(effects);
657                        let ret = return_type
658                            .as_deref()
659                            .map(|t| format!(" -> {}", self.type_to_py(t)))
660                            .unwrap_or_default();
661                        let fn_name = to_snake_case(&name.name);
662                        self.writeln(&format!(
663                            "{async_kw}def {fn_name}({}){}:",
664                            all_params.join(", "),
665                            ret,
666                        ));
667                        self.indent += 1;
668                        let old_handler_vars = self.current_handler_vars.clone();
669                        let expanded = self.expand_effect_names(effect_clause);
670                        for ename in &expanded {
671                            self.current_handler_vars
672                                .insert(ename.clone(), to_snake_case(ename));
673                        }
674                        self.emit_block_body(body)?;
675                        self.current_handler_vars = old_handler_vars;
676                        self.indent -= 1;
677                    }
678                }
679                Ok(())
680            }
681            NodeKind::EffectDecl {
682                name,
683                components,
684                operations,
685                ..
686            } => {
687                if !components.is_empty() {
688                    let comp_names: Vec<String> = components
689                        .iter()
690                        .map(|tp| {
691                            tp.segments
692                                .last()
693                                .map_or("effect".to_string(), |s| s.name.clone())
694                        })
695                        .collect();
696                    self.writeln(&format!(
697                        "# composite effect {} = {}",
698                        name.name,
699                        comp_names.join(" + ")
700                    ));
701                    self.composite_effects
702                        .insert(name.name.clone(), comp_names);
703                    return Ok(());
704                }
705                // Record effect operations for Call → handler.op rewriting.
706                for op in operations {
707                    if let NodeKind::FnDecl {
708                        name: op_name, ..
709                    } = &op.kind
710                    {
711                        self.effect_ops
712                            .insert(op_name.name.clone(), name.name.clone());
713                    }
714                }
715                // Effects → abstract base class with @abstractmethod.
716                self.needs_abc_import = true;
717                self.writeln(&format!("class {}(ABC):", name.name));
718                self.indent += 1;
719                if operations.is_empty() {
720                    self.writeln("pass");
721                } else {
722                    for (i, op) in operations.iter().enumerate() {
723                        if i > 0 {
724                            self.buf.push('\n');
725                        }
726                        if let NodeKind::FnDecl {
727                            name,
728                            params,
729                            return_type,
730                            ..
731                        } = &op.kind
732                        {
733                            self.writeln("@abstractmethod");
734                            let param_strs = self.collect_param_strs(params);
735                            let mut all_params = vec!["self".to_string()];
736                            all_params.extend(param_strs);
737                            let ret = return_type
738                                .as_deref()
739                                .map(|t| format!(" -> {}", self.type_to_py(t)))
740                                .unwrap_or_default();
741                            let fn_name = to_snake_case(&name.name);
742                            self.writeln(&format!(
743                                "def {fn_name}({}){}:",
744                                all_params.join(", "),
745                                ret,
746                            ));
747                            self.indent += 1;
748                            self.writeln("...");
749                            self.indent -= 1;
750                        }
751                    }
752                }
753                self.indent -= 1;
754                Ok(())
755            }
756            NodeKind::TypeAlias { name, .. } => {
757                self.writeln(&format!("# type {} = ...", name.name));
758                Ok(())
759            }
760            NodeKind::ConstDecl {
761                name, value, ty, ..
762            } => {
763                let type_hint = format!(": {}", self.type_to_py(ty));
764                let ind = self.indent_str();
765                let _ = write!(self.buf, "{ind}{}{type_hint} = ", to_snake_case(&name.name));
766                self.emit_expr(value)?;
767                self.buf.push('\n');
768                Ok(())
769            }
770            NodeKind::ModuleHandle { effect, handler } => {
771                // Emit `__<effect>: Effect = Handler()` at module scope and
772                // register it as the default handler. Effectful calls later
773                // in the module will pick it up via `current_handler_vars`
774                // unless a local handling block overrides it.
775                let effect_name =
776                    effect.segments.last().map_or("effect", |s| s.name.as_str());
777                let var_name = format!("__{}", to_snake_case(effect_name));
778                let ind = self.indent_str();
779                let _ = write!(self.buf, "{ind}{var_name}: {effect_name} = ");
780                self.emit_expr(handler)?;
781                self.buf.push('\n');
782                self.current_handler_vars
783                    .insert(effect_name.to_string(), var_name);
784                Ok(())
785            }
786            NodeKind::PropertyTest { name, body, .. } => {
787                self.writeln(&format!("# property test: {name}"));
788                self.writeln("# (property tests are not emitted in Python output)");
789                let _ = body;
790                Ok(())
791            }
792            // Statement / expression nodes at top level:
793            NodeKind::LetBinding { .. }
794            | NodeKind::If { .. }
795            | NodeKind::For { .. }
796            | NodeKind::While { .. }
797            | NodeKind::Loop { .. }
798            | NodeKind::Return { .. }
799            | NodeKind::Break { .. }
800            | NodeKind::Continue
801            | NodeKind::Guard { .. }
802            | NodeKind::Match { .. }
803            | NodeKind::Block { .. }
804            | NodeKind::HandlingBlock { .. }
805            | NodeKind::Assign { .. } => self.emit_stmt(node),
806            // Expression nodes that appear as statements:
807            _ => {
808                self.write_indent();
809                self.emit_expr(node)?;
810                self.buf.push('\n');
811                Ok(())
812            }
813        }
814    }
815
816    // ── Function declarations ───────────────────────────────────────────────
817
818    #[allow(clippy::too_many_arguments)]
819    fn emit_fn_decl(
820        &mut self,
821        _visibility: Visibility,
822        is_async: bool,
823        name: &str,
824        params: &[AIRNode],
825        return_type: Option<&AIRNode>,
826        effect_clause: &[bock_ast::TypePath],
827        body: &AIRNode,
828    ) -> Result<(), CodegenError> {
829        if is_async {
830            self.needs_asyncio_import = true;
831        }
832        let async_kw = if is_async { "async " } else { "" };
833        let param_strs = self.collect_param_strs(params);
834        let effects = self.effects_params(effect_clause);
835        let mut all_params = param_strs;
836        all_params.extend(effects);
837        let ret = return_type
838            .map(|t| format!(" -> {}", self.type_to_py(t)))
839            .unwrap_or_default();
840        if !effect_clause.is_empty() {
841            let effect_names = self.expand_effect_names(effect_clause);
842            self.fn_effects.insert(name.to_string(), effect_names);
843        }
844        let fn_name = to_snake_case(name);
845        self.writeln(&format!(
846            "{async_kw}def {fn_name}({}){}:",
847            all_params.join(", "),
848            ret,
849        ));
850        self.indent += 1;
851        let old_handler_vars = self.current_handler_vars.clone();
852        let expanded = self.expand_effect_names(effect_clause);
853        for ename in &expanded {
854            self.current_handler_vars
855                .insert(ename.clone(), to_snake_case(ename));
856        }
857        self.emit_block_body(body)?;
858        self.current_handler_vars = old_handler_vars;
859        self.indent -= 1;
860        Ok(())
861    }
862
863    fn emit_class_method(&mut self, method: &AIRNode) -> Result<(), CodegenError> {
864        if let NodeKind::FnDecl {
865            is_async,
866            name,
867            params,
868            return_type,
869            effect_clause,
870            body,
871            ..
872        } = &method.kind
873        {
874            if *is_async {
875                self.needs_asyncio_import = true;
876            }
877            let async_kw = if *is_async { "async " } else { "" };
878            let param_strs = self.collect_param_strs(params);
879            let effects = self.effects_params(effect_clause);
880            let mut all_params = vec!["self".to_string()];
881            all_params.extend(param_strs);
882            all_params.extend(effects);
883            let ret = return_type
884                .as_deref()
885                .map(|t| format!(" -> {}", self.type_to_py(t)))
886                .unwrap_or_default();
887            let fn_name = to_snake_case(&name.name);
888            self.writeln(&format!(
889                "{async_kw}def {fn_name}({}){}:",
890                all_params.join(", "),
891                ret,
892            ));
893            self.indent += 1;
894            let old_handler_vars = self.current_handler_vars.clone();
895            let expanded = self.expand_effect_names(effect_clause);
896            for ename in &expanded {
897                self.current_handler_vars
898                    .insert(ename.clone(), to_snake_case(ename));
899            }
900            self.emit_block_body(body)?;
901            self.current_handler_vars = old_handler_vars;
902            self.indent -= 1;
903        }
904        Ok(())
905    }
906
907    fn collect_param_strs(&self, params: &[AIRNode]) -> Vec<String> {
908        params
909            .iter()
910            .filter_map(|p| {
911                if let NodeKind::Param {
912                    pattern,
913                    ty,
914                    default,
915                } = &p.kind
916                {
917                    let name = to_snake_case(&self.pattern_to_binding_name(pattern));
918                    let type_hint = ty
919                        .as_ref()
920                        .map(|t| format!(": {}", self.type_to_py(t)))
921                        .unwrap_or_default();
922                    if let Some(def) = default {
923                        let mut ctx = PyEmitCtx::new();
924                        ctx.indent = self.indent;
925                        if ctx.emit_expr(def).is_ok() {
926                            let def_str = ctx.buf;
927                            return Some(format!("{name}{type_hint} = {def_str}"));
928                        }
929                    }
930                    Some(format!("{name}{type_hint}"))
931                } else {
932                    None
933                }
934            })
935            .collect()
936    }
937
938    /// Expand effect names, replacing composite effects with their components.
939    fn expand_effect_names(&self, effects: &[bock_ast::TypePath]) -> Vec<String> {
940        let mut result = Vec::new();
941        for tp in effects {
942            let name = tp
943                .segments
944                .last()
945                .map_or("effect".to_string(), |s| s.name.clone());
946            if let Some(components) = self.composite_effects.get(&name) {
947                result.extend(components.iter().cloned());
948            } else {
949                result.push(name);
950            }
951        }
952        result
953    }
954
955    /// Effects → keyword arguments: `*, log: Log, clock: Clock`.
956    fn effects_params(&self, effects: &[bock_ast::TypePath]) -> Vec<String> {
957        if effects.is_empty() {
958            return vec![];
959        }
960        let expanded = self.expand_effect_names(effects);
961        let mut result = vec!["*".to_string()];
962        for name in &expanded {
963            let param_name = to_snake_case(name);
964            result.push(format!("{param_name}: {name}"));
965        }
966        result
967    }
968
969    /// Build `effect=handler_var, ...` keyword arguments for calling an effectful function.
970    fn build_effects_call_args_py(&self, fn_name: &str) -> Option<String> {
971        let effects = self.fn_effects.get(fn_name)?;
972        let entries: Vec<String> = effects
973            .iter()
974            .filter_map(|e| {
975                let handler_var = self.current_handler_vars.get(e)?;
976                let param_name = to_snake_case(e);
977                Some(format!("{param_name}={handler_var}"))
978            })
979            .collect();
980        if entries.is_empty() {
981            return None;
982        }
983        Some(entries.join(", "))
984    }
985
986    // ── Enum variant factories ──────────────────────────────────────────────
987
988    fn emit_enum_variant(
989        &mut self,
990        enum_name: &str,
991        variant: &AIRNode,
992    ) -> Result<(), CodegenError> {
993        if let NodeKind::EnumVariant { name, payload } = &variant.kind {
994            let vname = &name.name;
995            match payload {
996                EnumVariantPayload::Unit => {
997                    self.writeln("@dataclass(frozen=True)");
998                    self.writeln(&format!("class {enum_name}_{vname}:"));
999                    self.indent += 1;
1000                    self.writeln(&format!("_tag: str = \"{vname}\""));
1001                    self.indent -= 1;
1002                }
1003                EnumVariantPayload::Struct(fields) => {
1004                    self.writeln("@dataclass");
1005                    self.writeln(&format!("class {enum_name}_{vname}:"));
1006                    self.indent += 1;
1007                    for f in fields {
1008                        let type_hint = self.ast_type_to_py(&f.ty);
1009                        self.writeln(&format!("{}: {type_hint}", to_snake_case(&f.name.name)));
1010                    }
1011                    self.writeln(&format!("_tag: str = \"{vname}\""));
1012                    self.indent -= 1;
1013                }
1014                EnumVariantPayload::Tuple(elems) => {
1015                    self.writeln("@dataclass");
1016                    self.writeln(&format!("class {enum_name}_{vname}:"));
1017                    self.indent += 1;
1018                    for (i, elem) in elems.iter().enumerate() {
1019                        let type_hint = self.type_to_py(elem);
1020                        self.writeln(&format!("_{i}: {type_hint}"));
1021                    }
1022                    self.writeln(&format!("_tag: str = \"{vname}\""));
1023                    self.indent -= 1;
1024                }
1025            }
1026        }
1027        Ok(())
1028    }
1029
1030    // ── Statements ──────────────────────────────────────────────────────────
1031
1032    fn emit_stmt(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
1033        match &node.kind {
1034            NodeKind::LetBinding {
1035                pattern, value, ty, ..
1036            } => {
1037                let binding = self.pattern_to_py_binding(pattern);
1038                let type_hint = ty
1039                    .as_ref()
1040                    .map(|t| format!(": {}", self.type_to_py(t)))
1041                    .unwrap_or_default();
1042                let ind = self.indent_str();
1043                let _ = write!(self.buf, "{ind}{binding}{type_hint} = ");
1044                let wrap_task = matches!(&value.kind, NodeKind::Call { .. })
1045                    && self.task_bound_names.contains(&binding);
1046                if wrap_task {
1047                    self.needs_asyncio_import = true;
1048                    self.buf.push_str("asyncio.create_task(");
1049                    self.emit_expr(value)?;
1050                    self.buf.push(')');
1051                } else {
1052                    self.emit_expr(value)?;
1053                }
1054                self.buf.push('\n');
1055                Ok(())
1056            }
1057            NodeKind::If {
1058                let_pattern,
1059                condition,
1060                then_block,
1061                else_block,
1062            } => {
1063                if let Some(pat) = let_pattern {
1064                    let ind = self.indent_str();
1065                    let binding = self.pattern_to_py_binding(pat);
1066                    let _ = write!(self.buf, "{ind}{binding} = ");
1067                    self.emit_expr(condition)?;
1068                    self.buf.push('\n');
1069                    self.writeln(&format!("if {binding} is not None:"));
1070                    self.indent += 1;
1071                    self.emit_block_body(then_block)?;
1072                    self.indent -= 1;
1073                } else {
1074                    let ind = self.indent_str();
1075                    let _ = write!(self.buf, "{ind}if ");
1076                    self.emit_expr(condition)?;
1077                    self.buf.push_str(":\n");
1078                    self.indent += 1;
1079                    self.emit_block_body(then_block)?;
1080                    self.indent -= 1;
1081                }
1082                if let Some(else_b) = else_block {
1083                    if matches!(else_b.kind, NodeKind::If { .. }) {
1084                        let ind = self.indent_str();
1085                        let _ = write!(self.buf, "{ind}el");
1086                        self.emit_stmt(else_b)?;
1087                        return Ok(());
1088                    }
1089                    self.writeln("else:");
1090                    self.indent += 1;
1091                    self.emit_block_body(else_b)?;
1092                    self.indent -= 1;
1093                }
1094                Ok(())
1095            }
1096            NodeKind::For {
1097                pattern,
1098                iterable,
1099                body,
1100            } => {
1101                let binding = self.pattern_to_py_binding(pattern);
1102                let ind = self.indent_str();
1103                let _ = write!(self.buf, "{ind}for {binding} in ");
1104                self.emit_expr(iterable)?;
1105                self.buf.push_str(":\n");
1106                self.indent += 1;
1107                self.emit_block_body(body)?;
1108                self.indent -= 1;
1109                Ok(())
1110            }
1111            NodeKind::While { condition, body } => {
1112                let ind = self.indent_str();
1113                let _ = write!(self.buf, "{ind}while ");
1114                self.emit_expr(condition)?;
1115                self.buf.push_str(":\n");
1116                self.indent += 1;
1117                self.emit_block_body(body)?;
1118                self.indent -= 1;
1119                Ok(())
1120            }
1121            NodeKind::Loop { body } => {
1122                self.writeln("while True:");
1123                self.indent += 1;
1124                self.emit_block_body(body)?;
1125                self.indent -= 1;
1126                Ok(())
1127            }
1128            NodeKind::Return { value } => {
1129                if let Some(val) = value {
1130                    let ind = self.indent_str();
1131                    let _ = write!(self.buf, "{ind}return ");
1132                    self.emit_expr(val)?;
1133                    self.buf.push('\n');
1134                } else {
1135                    self.writeln("return");
1136                }
1137                Ok(())
1138            }
1139            NodeKind::Break { value } => {
1140                if let Some(val) = value {
1141                    // Python break doesn't support values; emit as comment + break.
1142                    let ind = self.indent_str();
1143                    let _ = write!(self.buf, "{ind}# break value: ");
1144                    self.emit_expr(val)?;
1145                    self.buf.push('\n');
1146                    self.writeln("break");
1147                } else {
1148                    self.writeln("break");
1149                }
1150                Ok(())
1151            }
1152            NodeKind::Continue => {
1153                self.writeln("continue");
1154                Ok(())
1155            }
1156            NodeKind::Guard {
1157                condition,
1158                else_block,
1159                ..
1160            } => {
1161                let ind = self.indent_str();
1162                let _ = write!(self.buf, "{ind}if not (");
1163                self.emit_expr(condition)?;
1164                self.buf.push_str("):\n");
1165                self.indent += 1;
1166                self.emit_block_body(else_block)?;
1167                self.indent -= 1;
1168                Ok(())
1169            }
1170            NodeKind::Match { scrutinee, arms } => self.emit_match(scrutinee, arms),
1171            NodeKind::Block { stmts, tail } => {
1172                for s in stmts {
1173                    self.emit_node(s)?;
1174                }
1175                if let Some(t) = tail {
1176                    self.write_indent();
1177                    self.emit_expr(t)?;
1178                    self.buf.push('\n');
1179                }
1180                Ok(())
1181            }
1182            NodeKind::HandlingBlock { handlers, body } => {
1183                // handling block → handler variable bindings then body.
1184                // Each handling block gets a fresh numeric suffix so nested
1185                // blocks do not overwrite each other's handler variables —
1186                // Python has function scope, not block scope, for `=`, so
1187                // `__logger = X()` in an inner block would otherwise stomp
1188                // the outer binding permanently.
1189                let old_handler_vars = self.current_handler_vars.clone();
1190                self.handling_counter += 1;
1191                let suffix = format!("_h{}", self.handling_counter);
1192                for h in handlers {
1193                    let effect_name =
1194                        h.effect.segments.last().map_or("effect", |s| s.name.as_str());
1195                    let var_name = format!("__{}{suffix}", to_snake_case(effect_name));
1196                    let ind = self.indent_str();
1197                    let _ = write!(self.buf, "{ind}{var_name}: {effect_name} = ");
1198                    self.emit_expr(&h.handler)?;
1199                    self.buf.push('\n');
1200                    self.current_handler_vars
1201                        .insert(effect_name.to_string(), var_name);
1202                }
1203                if let NodeKind::Block { stmts, tail } = &body.kind {
1204                    for s in stmts {
1205                        self.emit_node(s)?;
1206                    }
1207                    if let Some(t) = tail {
1208                        self.write_indent();
1209                        self.emit_expr(t)?;
1210                        self.buf.push('\n');
1211                    }
1212                } else {
1213                    self.emit_stmt(body)?;
1214                }
1215                self.current_handler_vars = old_handler_vars;
1216                Ok(())
1217            }
1218            NodeKind::Assign { op, target, value } => {
1219                let ind = self.indent_str();
1220                let _ = write!(self.buf, "{ind}");
1221                self.emit_expr(target)?;
1222                let op_str = match op {
1223                    AssignOp::Assign => " = ",
1224                    AssignOp::AddAssign => " += ",
1225                    AssignOp::SubAssign => " -= ",
1226                    AssignOp::MulAssign => " *= ",
1227                    AssignOp::DivAssign => " /= ",
1228                    AssignOp::RemAssign => " %= ",
1229                };
1230                self.buf.push_str(op_str);
1231                self.emit_expr(value)?;
1232                self.buf.push('\n');
1233                Ok(())
1234            }
1235            _ => {
1236                self.write_indent();
1237                self.emit_expr(node)?;
1238                self.buf.push('\n');
1239                Ok(())
1240            }
1241        }
1242    }
1243
1244    // ── Expressions ─────────────────────────────────────────────────────────
1245
1246    fn emit_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
1247        match &node.kind {
1248            NodeKind::Literal { lit } => {
1249                match lit {
1250                    Literal::Int(s) => self.buf.push_str(s),
1251                    Literal::Float(s) => self.buf.push_str(s),
1252                    Literal::Bool(b) => self.buf.push_str(if *b { "True" } else { "False" }),
1253                    Literal::Char(s) => {
1254                        self.buf.push('\'');
1255                        self.buf.push_str(s);
1256                        self.buf.push('\'');
1257                    }
1258                    Literal::String(s) => {
1259                        self.buf.push('"');
1260                        self.buf.push_str(&escape_py_string(s));
1261                        self.buf.push('"');
1262                    }
1263                    Literal::Unit => self.buf.push_str("None"),
1264                }
1265                Ok(())
1266            }
1267            NodeKind::Identifier { name } => {
1268                self.buf.push_str(&identifier_to_py(&name.name));
1269                Ok(())
1270            }
1271            NodeKind::BinaryOp { op, left, right } => {
1272                self.buf.push('(');
1273                self.emit_expr(left)?;
1274                let op_str = match op {
1275                    BinOp::Add => " + ",
1276                    BinOp::Sub => " - ",
1277                    BinOp::Mul => " * ",
1278                    BinOp::Div => " / ",
1279                    BinOp::Rem => " % ",
1280                    BinOp::Pow => " ** ",
1281                    BinOp::Eq => " == ",
1282                    BinOp::Ne => " != ",
1283                    BinOp::Lt => " < ",
1284                    BinOp::Le => " <= ",
1285                    BinOp::Gt => " > ",
1286                    BinOp::Ge => " >= ",
1287                    BinOp::And => " and ",
1288                    BinOp::Or => " or ",
1289                    BinOp::BitAnd => " & ",
1290                    BinOp::BitOr => " | ",
1291                    BinOp::BitXor => " ^ ",
1292                    BinOp::Compose => " # compose ",
1293                    BinOp::Is => " is ",
1294                };
1295                self.buf.push_str(op_str);
1296                self.emit_expr(right)?;
1297                self.buf.push(')');
1298                Ok(())
1299            }
1300            NodeKind::UnaryOp { op, operand } => {
1301                let op_str = match op {
1302                    UnaryOp::Neg => "-",
1303                    UnaryOp::Not => "not ",
1304                    UnaryOp::BitNot => "~",
1305                };
1306                self.buf.push_str(op_str);
1307                self.emit_expr(operand)?;
1308                Ok(())
1309            }
1310            NodeKind::Call { callee, args, .. } => {
1311                if let Some(code) = self.map_prelude_call(callee, args)? {
1312                    self.buf.push_str(&code);
1313                    return Ok(());
1314                }
1315                if self.try_emit_time_assoc_call(callee, args)? {
1316                    return Ok(());
1317                }
1318                if self.try_emit_time_desugared_method(callee, args)? {
1319                    return Ok(());
1320                }
1321                if self.try_emit_concurrency_call(callee, args)? {
1322                    return Ok(());
1323                }
1324                // Rewrite bare effect operation calls: log(...) → handler.log(...)
1325                if let NodeKind::Identifier { name } = &callee.kind {
1326                    if let Some(effect_name) = self.effect_ops.get(&name.name).cloned() {
1327                        if let Some(handler_var) =
1328                            self.current_handler_vars.get(&effect_name).cloned()
1329                        {
1330                            let _ = write!(self.buf, "{}.{}", handler_var, to_snake_case(&name.name));
1331                            self.buf.push('(');
1332                            for (i, arg) in args.iter().enumerate() {
1333                                if i > 0 {
1334                                    self.buf.push_str(", ");
1335                                }
1336                                self.emit_expr(&arg.value)?;
1337                            }
1338                            self.buf.push(')');
1339                            return Ok(());
1340                        }
1341                    }
1342                }
1343                // Pass handler args to effectful function calls.
1344                let effects_args = if let NodeKind::Identifier { name } = &callee.kind {
1345                    self.build_effects_call_args_py(&name.name)
1346                } else {
1347                    None
1348                };
1349                self.emit_expr(callee)?;
1350                self.buf.push('(');
1351                for (i, arg) in args.iter().enumerate() {
1352                    if i > 0 {
1353                        self.buf.push_str(", ");
1354                    }
1355                    self.emit_expr(&arg.value)?;
1356                }
1357                if let Some(ea) = effects_args {
1358                    if !args.is_empty() {
1359                        self.buf.push_str(", ");
1360                    }
1361                    self.buf.push_str(&ea);
1362                }
1363                self.buf.push(')');
1364                Ok(())
1365            }
1366            NodeKind::MethodCall {
1367                receiver,
1368                method,
1369                args,
1370                ..
1371            } => {
1372                if self.try_emit_time_method(receiver, &method.name, args)? {
1373                    return Ok(());
1374                }
1375                self.emit_expr(receiver)?;
1376                let _ = write!(self.buf, ".{}", to_snake_case(&method.name));
1377                self.buf.push('(');
1378                for (i, arg) in args.iter().enumerate() {
1379                    if i > 0 {
1380                        self.buf.push_str(", ");
1381                    }
1382                    self.emit_expr(&arg.value)?;
1383                }
1384                self.buf.push(')');
1385                Ok(())
1386            }
1387            NodeKind::FieldAccess { object, field } => {
1388                self.emit_expr(object)?;
1389                let _ = write!(self.buf, ".{}", to_snake_case(&field.name));
1390                Ok(())
1391            }
1392            NodeKind::Index { object, index } => {
1393                self.emit_expr(object)?;
1394                self.buf.push('[');
1395                self.emit_expr(index)?;
1396                self.buf.push(']');
1397                Ok(())
1398            }
1399            NodeKind::Lambda { params, body } => {
1400                let param_strs = self.collect_param_strs(params);
1401                let _ = write!(self.buf, "lambda {}: ", param_strs.join(", "));
1402                self.emit_expr(body)?;
1403                Ok(())
1404            }
1405            NodeKind::Pipe { left, right } => self.emit_pipe(left, right),
1406            NodeKind::Compose { left, right } => {
1407                // `f >> g` → `lambda x: g(f(x))`
1408                let _ = write!(self.buf, "lambda x: ");
1409                self.emit_expr(right)?;
1410                self.buf.push('(');
1411                self.emit_expr(left)?;
1412                self.buf.push_str("(x))");
1413                Ok(())
1414            }
1415            NodeKind::Await { expr } => {
1416                self.buf.push_str("(await ");
1417                self.emit_expr(expr)?;
1418                self.buf.push(')');
1419                Ok(())
1420            }
1421            NodeKind::Propagate { expr } => {
1422                // Python doesn't have `?`; just emit the expression.
1423                self.emit_expr(expr)?;
1424                Ok(())
1425            }
1426            NodeKind::Range { lo, hi, inclusive } => {
1427                self.buf.push_str("range(");
1428                self.emit_expr(lo)?;
1429                self.buf.push_str(", ");
1430                self.emit_expr(hi)?;
1431                if *inclusive {
1432                    self.buf.push_str(" + 1");
1433                }
1434                self.buf.push(')');
1435                Ok(())
1436            }
1437            NodeKind::RecordConstruct {
1438                path,
1439                fields,
1440                spread,
1441            } => {
1442                let type_name = path
1443                    .segments
1444                    .iter()
1445                    .map(|s| s.name.as_str())
1446                    .collect::<Vec<_>>()
1447                    .join(".");
1448                if let Some(sp) = spread {
1449                    // Spread: create dict, update, then construct
1450                    self.buf.push_str(&format!("{type_name}(**{{**vars("));
1451                    self.emit_expr(sp)?;
1452                    self.buf.push_str("), ");
1453                    for (i, f) in fields.iter().enumerate() {
1454                        if i > 0 {
1455                            self.buf.push_str(", ");
1456                        }
1457                        let _ = write!(self.buf, "\"{}\": ", to_snake_case(&f.name.name));
1458                        if let Some(val) = &f.value {
1459                            self.emit_expr(val)?;
1460                        } else {
1461                            self.buf.push_str(&to_snake_case(&f.name.name));
1462                        }
1463                    }
1464                    self.buf.push_str("})");
1465                } else {
1466                    self.buf.push_str(&type_name);
1467                    self.buf.push('(');
1468                    for (i, f) in fields.iter().enumerate() {
1469                        if i > 0 {
1470                            self.buf.push_str(", ");
1471                        }
1472                        let _ = write!(self.buf, "{}=", to_snake_case(&f.name.name));
1473                        if let Some(val) = &f.value {
1474                            self.emit_expr(val)?;
1475                        } else {
1476                            self.buf.push_str(&to_snake_case(&f.name.name));
1477                        }
1478                    }
1479                    self.buf.push(')');
1480                }
1481                Ok(())
1482            }
1483            NodeKind::ListLiteral { elems } => {
1484                self.buf.push('[');
1485                for (i, e) in elems.iter().enumerate() {
1486                    if i > 0 {
1487                        self.buf.push_str(", ");
1488                    }
1489                    self.emit_expr(e)?;
1490                }
1491                self.buf.push(']');
1492                Ok(())
1493            }
1494            NodeKind::MapLiteral { entries } => {
1495                self.buf.push('{');
1496                for (i, entry) in entries.iter().enumerate() {
1497                    if i > 0 {
1498                        self.buf.push_str(", ");
1499                    }
1500                    self.emit_expr(&entry.key)?;
1501                    self.buf.push_str(": ");
1502                    self.emit_expr(&entry.value)?;
1503                }
1504                self.buf.push('}');
1505                Ok(())
1506            }
1507            NodeKind::SetLiteral { elems } => {
1508                if elems.is_empty() {
1509                    self.buf.push_str("set()");
1510                } else {
1511                    self.buf.push('{');
1512                    for (i, e) in elems.iter().enumerate() {
1513                        if i > 0 {
1514                            self.buf.push_str(", ");
1515                        }
1516                        self.emit_expr(e)?;
1517                    }
1518                    self.buf.push('}');
1519                }
1520                Ok(())
1521            }
1522            NodeKind::TupleLiteral { elems } => {
1523                self.buf.push('(');
1524                for (i, e) in elems.iter().enumerate() {
1525                    if i > 0 {
1526                        self.buf.push_str(", ");
1527                    }
1528                    self.emit_expr(e)?;
1529                }
1530                if elems.len() == 1 {
1531                    self.buf.push(',');
1532                }
1533                self.buf.push(')');
1534                Ok(())
1535            }
1536            NodeKind::Interpolation { parts } => {
1537                let has_newline = parts.iter().any(|p| matches!(p,
1538                    AirInterpolationPart::Literal(s) if s.contains('\n')
1539                ));
1540                if has_newline {
1541                    self.buf.push_str("f\"\"\"");
1542                } else {
1543                    self.buf.push_str("f\"");
1544                }
1545                for part in parts {
1546                    match part {
1547                        AirInterpolationPart::Literal(s) => {
1548                            if has_newline {
1549                                self.buf.push_str(&escape_fstring_triple(s));
1550                            } else {
1551                                self.buf.push_str(&escape_fstring(s));
1552                            }
1553                        }
1554                        AirInterpolationPart::Expr(expr) => {
1555                            self.buf.push('{');
1556                            self.emit_expr(expr)?;
1557                            self.buf.push('}');
1558                        }
1559                    }
1560                }
1561                if has_newline {
1562                    self.buf.push_str("\"\"\"");
1563                } else {
1564                    self.buf.push('"');
1565                }
1566                Ok(())
1567            }
1568            NodeKind::Placeholder => {
1569                self.buf.push('_');
1570                Ok(())
1571            }
1572            NodeKind::Unreachable => {
1573                self.buf.push_str("raise RuntimeError(\"unreachable\")");
1574                Ok(())
1575            }
1576            NodeKind::ResultConstruct { variant, value } => {
1577                match variant {
1578                    ResultVariant::Ok => {
1579                        self.buf.push_str("{\"_tag\": \"Ok\", \"value\": ");
1580                        if let Some(v) = value {
1581                            self.emit_expr(v)?;
1582                        } else {
1583                            self.buf.push_str("None");
1584                        }
1585                        self.buf.push('}');
1586                    }
1587                    ResultVariant::Err => {
1588                        self.buf.push_str("{\"_tag\": \"Err\", \"error\": ");
1589                        if let Some(v) = value {
1590                            self.emit_expr(v)?;
1591                        } else {
1592                            self.buf.push_str("None");
1593                        }
1594                        self.buf.push('}');
1595                    }
1596                }
1597                Ok(())
1598            }
1599            NodeKind::Assign { op, target, value } => {
1600                self.emit_expr(target)?;
1601                let op_str = match op {
1602                    AssignOp::Assign => " = ",
1603                    AssignOp::AddAssign => " += ",
1604                    AssignOp::SubAssign => " -= ",
1605                    AssignOp::MulAssign => " *= ",
1606                    AssignOp::DivAssign => " /= ",
1607                    AssignOp::RemAssign => " %= ",
1608                };
1609                self.buf.push_str(op_str);
1610                self.emit_expr(value)?;
1611                Ok(())
1612            }
1613            NodeKind::If {
1614                condition,
1615                then_block,
1616                else_block,
1617                ..
1618            } => {
1619                // Ternary for expression-position if.
1620                self.buf.push('(');
1621                self.emit_block_as_expr(then_block)?;
1622                self.buf.push_str(" if ");
1623                self.emit_expr(condition)?;
1624                self.buf.push_str(" else ");
1625                if let Some(eb) = else_block {
1626                    self.emit_block_as_expr(eb)?;
1627                } else {
1628                    self.buf.push_str("None");
1629                }
1630                self.buf.push(')');
1631                Ok(())
1632            }
1633            NodeKind::Block { stmts, tail } => {
1634                // Blocks in expression position: emit last expression.
1635                // Python doesn't have IIFEs like JS; if there are stmts we just
1636                // emit the tail (best effort).
1637                if stmts.is_empty() {
1638                    if let Some(t) = tail {
1639                        return self.emit_expr(t);
1640                    }
1641                }
1642                // Fallback: wrap in a lambda (only works for simple cases).
1643                self.buf.push_str("(lambda: ");
1644                if let Some(t) = tail {
1645                    self.emit_expr(t)?;
1646                } else {
1647                    self.buf.push_str("None");
1648                }
1649                self.buf.push_str(")()");
1650                Ok(())
1651            }
1652            NodeKind::Match { scrutinee, arms } => {
1653                // Match in expression position: not directly supported in Python.
1654                // Emit as IIFE-like lambda with internal match.
1655                // For simplicity, try to emit as a series of ternary if-else.
1656                self.buf.push_str("(lambda __v: ");
1657                self.emit_match_expr(scrutinee, arms)?;
1658                self.buf.push_str(")(");
1659                self.emit_expr(scrutinee)?;
1660                self.buf.push(')');
1661                Ok(())
1662            }
1663            // Ownership nodes: erase in Python.
1664            NodeKind::Move { expr }
1665            | NodeKind::Borrow { expr }
1666            | NodeKind::MutableBorrow { expr } => self.emit_expr(expr),
1667            // Effect operation invocation.
1668            NodeKind::EffectOp {
1669                effect,
1670                operation,
1671                args,
1672            } => {
1673                let effect_name = effect.segments.last().map_or("effect", |s| s.name.as_str());
1674                let _ = write!(
1675                    self.buf,
1676                    "{}.{}",
1677                    to_snake_case(effect_name),
1678                    to_snake_case(&operation.name)
1679                );
1680                self.buf.push('(');
1681                for (i, arg) in args.iter().enumerate() {
1682                    if i > 0 {
1683                        self.buf.push_str(", ");
1684                    }
1685                    self.emit_expr(&arg.value)?;
1686                }
1687                self.buf.push(')');
1688                Ok(())
1689            }
1690            // Type expressions: erased in Python expression context.
1691            NodeKind::TypeNamed { .. }
1692            | NodeKind::TypeTuple { .. }
1693            | NodeKind::TypeFunction { .. }
1694            | NodeKind::TypeOptional { .. }
1695            | NodeKind::TypeSelf => {
1696                self.buf.push_str("# type");
1697                Ok(())
1698            }
1699            NodeKind::EffectRef { path } => {
1700                let name = path
1701                    .segments
1702                    .iter()
1703                    .map(|s| s.name.as_str())
1704                    .collect::<Vec<_>>()
1705                    .join(".");
1706                self.buf.push_str(&name);
1707                Ok(())
1708            }
1709            NodeKind::Error => {
1710                self.buf.push_str("# error");
1711                Ok(())
1712            }
1713            _ => {
1714                self.buf.push_str("# unsupported");
1715                Ok(())
1716            }
1717        }
1718    }
1719
1720    // ── Match → match/case (Python 3.10+) ───────────────────────────────────
1721
1722    fn emit_match(&mut self, scrutinee: &AIRNode, arms: &[AIRNode]) -> Result<(), CodegenError> {
1723        let ind = self.indent_str();
1724        let _ = write!(self.buf, "{ind}match ");
1725        self.emit_expr(scrutinee)?;
1726        self.buf.push_str(":\n");
1727        self.indent += 1;
1728        for arm in arms {
1729            self.emit_match_arm(arm)?;
1730        }
1731        self.indent -= 1;
1732        Ok(())
1733    }
1734
1735    fn emit_match_arm(&mut self, arm: &AIRNode) -> Result<(), CodegenError> {
1736        if let NodeKind::MatchArm {
1737            pattern,
1738            guard,
1739            body,
1740        } = &arm.kind
1741        {
1742            let ind = self.indent_str();
1743            let _ = write!(self.buf, "{ind}case ");
1744            self.emit_pattern(pattern)?;
1745            if let Some(g) = guard {
1746                self.buf.push_str(" if ");
1747                self.emit_expr(g)?;
1748            }
1749            self.buf.push_str(":\n");
1750            self.indent += 1;
1751            self.emit_block_body(body)?;
1752            self.indent -= 1;
1753        }
1754        Ok(())
1755    }
1756
1757    fn emit_pattern(&mut self, pat: &AIRNode) -> Result<(), CodegenError> {
1758        match &pat.kind {
1759            NodeKind::WildcardPat => {
1760                self.buf.push('_');
1761            }
1762            NodeKind::BindPat { name, .. } => {
1763                self.buf.push_str(&to_snake_case(&name.name));
1764            }
1765            NodeKind::LiteralPat { lit } => match lit {
1766                Literal::Int(s) => self.buf.push_str(s),
1767                Literal::Float(s) => self.buf.push_str(s),
1768                Literal::Bool(b) => self.buf.push_str(if *b { "True" } else { "False" }),
1769                Literal::Char(s) => {
1770                    self.buf.push('\'');
1771                    self.buf.push_str(s);
1772                    self.buf.push('\'');
1773                }
1774                Literal::String(s) => {
1775                    self.buf.push('"');
1776                    self.buf.push_str(&escape_py_string(s));
1777                    self.buf.push('"');
1778                }
1779                Literal::Unit => self.buf.push_str("None"),
1780            },
1781            NodeKind::ConstructorPat { path, fields } => {
1782                let variant_name = path
1783                    .segments
1784                    .iter()
1785                    .map(|s| s.name.as_str())
1786                    .collect::<Vec<_>>()
1787                    .join("_");
1788                if fields.is_empty() {
1789                    let _ = write!(self.buf, "{variant_name}()");
1790                } else {
1791                    let field_pats: Vec<String> = fields
1792                        .iter()
1793                        .enumerate()
1794                        .map(|(i, f)| {
1795                            let name = self.pattern_to_binding_name(f);
1796                            format!("_{i}={name}")
1797                        })
1798                        .collect();
1799                    let _ = write!(self.buf, "{variant_name}({})", field_pats.join(", "));
1800                }
1801            }
1802            NodeKind::RecordPat { path, fields, .. } => {
1803                let type_name = path
1804                    .segments
1805                    .iter()
1806                    .map(|s| s.name.as_str())
1807                    .collect::<Vec<_>>()
1808                    .join("_");
1809                let field_pats: Vec<String> = fields
1810                    .iter()
1811                    .map(|f| {
1812                        let field_name = to_snake_case(&f.name.name);
1813                        if let Some(pat) = &f.pattern {
1814                            let binding = self.pattern_to_binding_name(pat);
1815                            format!("{field_name}={binding}")
1816                        } else {
1817                            field_name
1818                        }
1819                    })
1820                    .collect();
1821                let _ = write!(self.buf, "{type_name}({})", field_pats.join(", "));
1822            }
1823            NodeKind::TuplePat { elems } => {
1824                self.buf.push('(');
1825                for (i, e) in elems.iter().enumerate() {
1826                    if i > 0 {
1827                        self.buf.push_str(", ");
1828                    }
1829                    self.emit_pattern(e)?;
1830                }
1831                if elems.len() == 1 {
1832                    self.buf.push(',');
1833                }
1834                self.buf.push(')');
1835            }
1836            _ => {
1837                self.buf.push('_');
1838            }
1839        }
1840        Ok(())
1841    }
1842
1843    /// Emit a match expression as nested ternary (best effort for expression context).
1844    fn emit_match_expr(
1845        &mut self,
1846        _scrutinee: &AIRNode,
1847        arms: &[AIRNode],
1848    ) -> Result<(), CodegenError> {
1849        // Simple fallback: just emit first arm body or None
1850        for (i, arm) in arms.iter().enumerate() {
1851            if let NodeKind::MatchArm { body, pattern, .. } = &arm.kind {
1852                if i > 0 {
1853                    self.buf.push_str(" if False else ");
1854                }
1855                if matches!(pattern.kind, NodeKind::WildcardPat) || i == arms.len() - 1 {
1856                    self.emit_block_as_expr(body)?;
1857                    return Ok(());
1858                }
1859                self.emit_block_as_expr(body)?;
1860            }
1861        }
1862        self.buf.push_str("None");
1863        Ok(())
1864    }
1865
1866    // ── Pipe operator ───────────────────────────────────────────────────────
1867
1868    fn emit_pipe(&mut self, left: &AIRNode, right: &AIRNode) -> Result<(), CodegenError> {
1869        if let NodeKind::Call { callee, args, .. } = &right.kind {
1870            let has_placeholder = args
1871                .iter()
1872                .any(|a| matches!(a.value.kind, NodeKind::Placeholder));
1873            if has_placeholder {
1874                self.emit_expr(callee)?;
1875                self.buf.push('(');
1876                for (i, arg) in args.iter().enumerate() {
1877                    if i > 0 {
1878                        self.buf.push_str(", ");
1879                    }
1880                    if matches!(arg.value.kind, NodeKind::Placeholder) {
1881                        self.emit_expr(left)?;
1882                    } else {
1883                        self.emit_expr(&arg.value)?;
1884                    }
1885                }
1886                self.buf.push(')');
1887                return Ok(());
1888            }
1889        }
1890        self.emit_expr(right)?;
1891        self.buf.push('(');
1892        self.emit_expr(left)?;
1893        self.buf.push(')');
1894        Ok(())
1895    }
1896
1897    // ── Type emission ───────────────────────────────────────────────────────
1898
1899    fn type_to_py(&self, node: &AIRNode) -> String {
1900        match &node.kind {
1901            NodeKind::TypeNamed { path, args } => {
1902                let name = path
1903                    .segments
1904                    .iter()
1905                    .map(|s| s.name.as_str())
1906                    .collect::<Vec<_>>()
1907                    .join(".");
1908                let py_name = self.map_type_name(&name);
1909                if args.is_empty() {
1910                    py_name
1911                } else {
1912                    let arg_strs: Vec<String> = args.iter().map(|a| self.type_to_py(a)).collect();
1913                    format!("{py_name}[{}]", arg_strs.join(", "))
1914                }
1915            }
1916            NodeKind::TypeTuple { elems } => {
1917                let elem_strs: Vec<String> = elems.iter().map(|e| self.type_to_py(e)).collect();
1918                format!("tuple[{}]", elem_strs.join(", "))
1919            }
1920            NodeKind::TypeFunction { params, ret, .. } => {
1921                let param_strs: Vec<String> = params.iter().map(|p| self.type_to_py(p)).collect();
1922                format!(
1923                    "Callable[[{}], {}]",
1924                    param_strs.join(", "),
1925                    self.type_to_py(ret)
1926                )
1927            }
1928            NodeKind::TypeOptional { inner } => {
1929                format!("{} | None", self.type_to_py(inner))
1930            }
1931            NodeKind::TypeSelf => "Self".into(),
1932            _ => "Any".into(),
1933        }
1934    }
1935
1936    fn map_type_name(&self, name: &str) -> String {
1937        match name {
1938            "Int" => "int".into(),
1939            "Float" => "float".into(),
1940            "Bool" => "bool".into(),
1941            "String" => "str".into(),
1942            "Void" | "Unit" => "None".into(),
1943            "List" => "list".into(),
1944            "Map" => "dict".into(),
1945            "Set" => "set".into(),
1946            "Any" => "Any".into(),
1947            "Never" => "Never".into(),
1948            other => other.into(),
1949        }
1950    }
1951
1952    fn ast_type_to_py(&self, ty: &TypeExpr) -> String {
1953        match ty {
1954            TypeExpr::Named { path, args, .. } => {
1955                let name = path
1956                    .segments
1957                    .iter()
1958                    .map(|s| s.name.as_str())
1959                    .collect::<Vec<_>>()
1960                    .join(".");
1961                let py_name = self.map_type_name(&name);
1962                if args.is_empty() {
1963                    py_name
1964                } else {
1965                    let arg_strs: Vec<String> =
1966                        args.iter().map(|a| self.ast_type_to_py(a)).collect();
1967                    format!("{py_name}[{}]", arg_strs.join(", "))
1968                }
1969            }
1970            TypeExpr::Tuple { elems, .. } => {
1971                let elem_strs: Vec<String> = elems.iter().map(|e| self.ast_type_to_py(e)).collect();
1972                format!("tuple[{}]", elem_strs.join(", "))
1973            }
1974            TypeExpr::Function { params, ret, .. } => {
1975                let param_strs: Vec<String> =
1976                    params.iter().map(|p| self.ast_type_to_py(p)).collect();
1977                format!(
1978                    "Callable[[{}], {}]",
1979                    param_strs.join(", "),
1980                    self.ast_type_to_py(ret)
1981                )
1982            }
1983            TypeExpr::Optional { inner, .. } => {
1984                format!("{} | None", self.ast_type_to_py(inner))
1985            }
1986            TypeExpr::SelfType { .. } => "Self".into(),
1987        }
1988    }
1989
1990    // ── Helpers ─────────────────────────────────────────────────────────────
1991
1992    /// Scan a sequence of block statements and return the set of bound names
1993    /// that are later `await`ed as bare identifiers within the same block.
1994    /// The caller wraps those LetBindings' Call values in `asyncio.create_task`.
1995    ///
1996    /// Only direct `let name = call(...)` bindings qualify. Non-call RHS are
1997    /// skipped (not awaitable work we can parallelise). The binding must be
1998    /// awaited in the same flat block — nested scopes are ignored because we
1999    /// can't prove the binding is still live once control leaves the block.
2000    fn collect_task_bindings(
2001        stmts: &[AIRNode],
2002    ) -> std::collections::HashSet<String> {
2003        let mut awaited: std::collections::HashSet<String> =
2004            std::collections::HashSet::new();
2005        for s in stmts {
2006            Self::collect_awaited_identifiers(s, &mut awaited);
2007        }
2008        let mut out = std::collections::HashSet::new();
2009        for s in stmts {
2010            if let NodeKind::LetBinding { pattern, value, .. } = &s.kind {
2011                if let NodeKind::BindPat { name, .. } = &pattern.kind {
2012                    let py_name = to_snake_case(&name.name);
2013                    if matches!(&value.kind, NodeKind::Call { .. })
2014                        && awaited.contains(&py_name)
2015                    {
2016                        out.insert(py_name);
2017                    }
2018                }
2019            }
2020        }
2021        out
2022    }
2023
2024    /// Walk an AIR subtree and record every `await name` where `name` is a
2025    /// bare identifier. Nested function / lambda bodies are not descended —
2026    /// an inner closure awaiting the name doesn't imply the outer block
2027    /// wants a task.
2028    fn collect_awaited_identifiers(
2029        node: &AIRNode,
2030        out: &mut std::collections::HashSet<String>,
2031    ) {
2032        match &node.kind {
2033            NodeKind::Await { expr } => {
2034                if let NodeKind::Identifier { name } = &expr.kind {
2035                    out.insert(to_snake_case(&name.name));
2036                }
2037                Self::collect_awaited_identifiers(expr, out);
2038            }
2039            NodeKind::Lambda { .. } | NodeKind::FnDecl { .. } => {
2040                // Don't cross function boundaries.
2041            }
2042            NodeKind::Block { stmts, tail } => {
2043                for s in stmts {
2044                    Self::collect_awaited_identifiers(s, out);
2045                }
2046                if let Some(t) = tail {
2047                    Self::collect_awaited_identifiers(t, out);
2048                }
2049            }
2050            NodeKind::LetBinding { value, .. } => {
2051                Self::collect_awaited_identifiers(value, out);
2052            }
2053            NodeKind::Call { callee, args, .. } => {
2054                Self::collect_awaited_identifiers(callee, out);
2055                for a in args {
2056                    Self::collect_awaited_identifiers(&a.value, out);
2057                }
2058            }
2059            NodeKind::MethodCall { receiver, args, .. } => {
2060                Self::collect_awaited_identifiers(receiver, out);
2061                for a in args {
2062                    Self::collect_awaited_identifiers(&a.value, out);
2063                }
2064            }
2065            NodeKind::BinaryOp { left, right, .. } => {
2066                Self::collect_awaited_identifiers(left, out);
2067                Self::collect_awaited_identifiers(right, out);
2068            }
2069            NodeKind::UnaryOp { operand, .. } => {
2070                Self::collect_awaited_identifiers(operand, out);
2071            }
2072            NodeKind::If {
2073                condition,
2074                then_block,
2075                else_block,
2076                ..
2077            } => {
2078                Self::collect_awaited_identifiers(condition, out);
2079                Self::collect_awaited_identifiers(then_block, out);
2080                if let Some(e) = else_block {
2081                    Self::collect_awaited_identifiers(e, out);
2082                }
2083            }
2084            NodeKind::While { condition, body } => {
2085                Self::collect_awaited_identifiers(condition, out);
2086                Self::collect_awaited_identifiers(body, out);
2087            }
2088            NodeKind::For { iterable, body, .. } => {
2089                Self::collect_awaited_identifiers(iterable, out);
2090                Self::collect_awaited_identifiers(body, out);
2091            }
2092            NodeKind::Return { value: Some(v) } | NodeKind::Break { value: Some(v) } => {
2093                Self::collect_awaited_identifiers(v, out);
2094            }
2095            NodeKind::Assign { value, .. } => {
2096                Self::collect_awaited_identifiers(value, out);
2097            }
2098            NodeKind::TupleLiteral { elems }
2099            | NodeKind::ListLiteral { elems } => {
2100                for e in elems {
2101                    Self::collect_awaited_identifiers(e, out);
2102                }
2103            }
2104            _ => {}
2105        }
2106    }
2107
2108    fn emit_block_body(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
2109        if let NodeKind::Block { stmts, tail } = &node.kind {
2110            if stmts.is_empty() && tail.is_none() {
2111                self.writeln("pass");
2112                return Ok(());
2113            }
2114            // Concurrent-pattern detection: names bound in this block whose
2115            // Call RHS should be scheduled as tasks because the same name is
2116            // later `await`ed in the same block. Python coroutines don't run
2117            // until awaited, so without this, independent async calls
2118            // serialise — wrapping with `asyncio.create_task` preserves the
2119            // author's concurrent intent (JS/TS get this for free because
2120            // Promises are eager).
2121            let task_bindings = Self::collect_task_bindings(stmts);
2122            let prev = std::mem::replace(&mut self.task_bound_names, task_bindings);
2123            for s in stmts {
2124                self.emit_node(s)?;
2125            }
2126            self.task_bound_names = prev;
2127            if let Some(t) = tail {
2128                let ind = self.indent_str();
2129                let _ = write!(self.buf, "{ind}return ");
2130                self.emit_expr(t)?;
2131                self.buf.push('\n');
2132            }
2133        } else {
2134            // Single expression as body.
2135            let ind = self.indent_str();
2136            let _ = write!(self.buf, "{ind}return ");
2137            self.emit_expr(node)?;
2138            self.buf.push('\n');
2139        }
2140        Ok(())
2141    }
2142
2143    fn emit_block_as_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
2144        if let NodeKind::Block { stmts, tail } = &node.kind {
2145            if stmts.is_empty() {
2146                if let Some(t) = tail {
2147                    return self.emit_expr(t);
2148                }
2149            }
2150        }
2151        self.emit_expr(node)
2152    }
2153
2154    fn pattern_to_binding_name(&self, pat: &AIRNode) -> String {
2155        match &pat.kind {
2156            NodeKind::BindPat { name, .. } => to_snake_case(&name.name),
2157            NodeKind::WildcardPat => "_".into(),
2158            NodeKind::TuplePat { elems } => {
2159                format!(
2160                    "({})",
2161                    elems
2162                        .iter()
2163                        .map(|e| self.pattern_to_binding_name(e))
2164                        .collect::<Vec<_>>()
2165                        .join(", ")
2166                )
2167            }
2168            NodeKind::RecordPat { fields, .. } => {
2169                // Python doesn't have destructuring; use first field name or underscore
2170                fields
2171                    .first()
2172                    .map(|f| to_snake_case(&f.name.name))
2173                    .unwrap_or_else(|| "_".into())
2174            }
2175            _ => "_".into(),
2176        }
2177    }
2178
2179    fn pattern_to_py_binding(&self, pat: &AIRNode) -> String {
2180        self.pattern_to_binding_name(pat)
2181    }
2182
2183    fn type_expr_to_string(&self, node: &AIRNode) -> String {
2184        match &node.kind {
2185            NodeKind::TypeNamed { path, .. } => path
2186                .segments
2187                .iter()
2188                .map(|s| s.name.as_str())
2189                .collect::<Vec<_>>()
2190                .join("."),
2191            NodeKind::Identifier { name } => name.name.clone(),
2192            _ => "Unknown".into(),
2193        }
2194    }
2195}
2196
2197// ─── Utility functions ───────────────────────────────────────────────────────
2198
2199/// Convert a `PascalCase` or `camelCase` name to `snake_case`.
2200/// Extract the unqualified type name from an `impl` target AIR node.
2201/// Returns `None` for types that aren't simple named references
2202/// (tuples, function types, etc.).
2203fn ast_type_name(node: &AIRNode) -> Option<String> {
2204    if let NodeKind::TypeNamed { path, .. } = &node.kind {
2205        path.segments.last().map(|s| s.name.clone())
2206    } else {
2207        None
2208    }
2209}
2210
2211/// Emit a Bock identifier as a Python identifier. PascalCase names are
2212/// preserved — they denote classes, ABC traits, or enum variant constructors,
2213/// all of which stay PascalCase in Python by convention.
2214fn identifier_to_py(s: &str) -> String {
2215    if s.chars().next().is_some_and(char::is_uppercase) {
2216        s.to_string()
2217    } else {
2218        to_snake_case(s)
2219    }
2220}
2221
2222/// Returns true if `name` is the identifier of a Duration or Instant instance
2223/// method. Used to recognise `d.as_millis()` / `i.elapsed()` calls during codegen.
2224fn is_time_method_name(name: &str) -> bool {
2225    matches!(
2226        name,
2227        "as_nanos"
2228            | "as_millis"
2229            | "as_seconds"
2230            | "is_zero"
2231            | "is_negative"
2232            | "abs"
2233            | "elapsed"
2234            | "duration_since"
2235    )
2236}
2237
2238fn to_snake_case(s: &str) -> String {
2239    // If already snake_case or a single word, return as-is
2240    if s.is_empty() || s == "_" {
2241        return s.to_string();
2242    }
2243    // Don't convert if it's already snake_case (contains underscores but no uppercase)
2244    if s.contains('_') && !s.chars().any(|c| c.is_uppercase()) {
2245        return s.to_string();
2246    }
2247    // Don't convert simple lowercase words or all-uppercase words
2248    if !s.chars().any(|c| c.is_uppercase()) {
2249        return s.to_string();
2250    }
2251    // Special case: single char
2252    if s.len() == 1 {
2253        return s.to_lowercase();
2254    }
2255
2256    let mut result = String::with_capacity(s.len() + 4);
2257    let chars: Vec<char> = s.chars().collect();
2258
2259    for (i, &ch) in chars.iter().enumerate() {
2260        if ch.is_uppercase() {
2261            let prev_is_upper = i > 0 && chars[i - 1].is_uppercase();
2262            let prev_is_underscore = i > 0 && chars[i - 1] == '_';
2263            let next_is_lower = i + 1 < chars.len() && chars[i + 1].is_lowercase();
2264            if i > 0 && !prev_is_underscore && (!prev_is_upper || next_is_lower) {
2265                result.push('_');
2266            }
2267            result.push(
2268                ch.to_lowercase()
2269                    .next()
2270                    .expect("lowercase yields at least one char"),
2271            );
2272        } else {
2273            result.push(ch);
2274        }
2275    }
2276    result
2277}
2278
2279/// Escape special characters in a Python string literal.
2280fn escape_py_string(s: &str) -> String {
2281    let mut out = String::with_capacity(s.len());
2282    for ch in s.chars() {
2283        match ch {
2284            '"' => out.push_str("\\\""),
2285            '\\' => out.push_str("\\\\"),
2286            '\n' => out.push_str("\\n"),
2287            '\r' => out.push_str("\\r"),
2288            '\t' => out.push_str("\\t"),
2289            _ => out.push(ch),
2290        }
2291    }
2292    out
2293}
2294
2295/// Escape special characters in a Python f-string.
2296fn escape_fstring(s: &str) -> String {
2297    let mut out = String::with_capacity(s.len());
2298    for ch in s.chars() {
2299        match ch {
2300            '"' => out.push_str("\\\""),
2301            '\\' => out.push_str("\\\\"),
2302            '\n' => out.push_str("\\n"),
2303            '\r' => out.push_str("\\r"),
2304            '\t' => out.push_str("\\t"),
2305            '{' => out.push_str("{{"),
2306            '}' => out.push_str("}}"),
2307            _ => out.push(ch),
2308        }
2309    }
2310    out
2311}
2312
2313/// Escape special characters in a triple-quoted Python f-string.
2314/// Newlines pass through literally; quotes don't need escaping.
2315fn escape_fstring_triple(s: &str) -> String {
2316    let mut out = String::with_capacity(s.len());
2317    for ch in s.chars() {
2318        match ch {
2319            '\\' => out.push_str("\\\\"),
2320            '{' => out.push_str("{{"),
2321            '}' => out.push_str("}}"),
2322            _ => out.push(ch),
2323        }
2324    }
2325    out
2326}
2327
2328// ─── Tests ───────────────────────────────────────────────────────────────────
2329
2330#[cfg(test)]
2331mod tests {
2332    use super::*;
2333    use bock_air::{AirArg, AirRecordField};
2334    use bock_ast::{Ident, TypePath};
2335    use bock_errors::{FileId, Span};
2336
2337    fn span() -> Span {
2338        Span {
2339            file: FileId(0),
2340            start: 0,
2341            end: 0,
2342        }
2343    }
2344
2345    fn ident(name: &str) -> Ident {
2346        Ident {
2347            name: name.to_string(),
2348            span: span(),
2349        }
2350    }
2351
2352    fn type_path(segments: &[&str]) -> TypePath {
2353        TypePath {
2354            segments: segments.iter().map(|s| ident(s)).collect(),
2355            span: span(),
2356        }
2357    }
2358
2359    fn node(id: u32, kind: NodeKind) -> AIRNode {
2360        AIRNode::new(id, span(), kind)
2361    }
2362
2363    fn int_lit(id: u32, val: &str) -> AIRNode {
2364        node(
2365            id,
2366            NodeKind::Literal {
2367                lit: Literal::Int(val.into()),
2368            },
2369        )
2370    }
2371
2372    fn str_lit(id: u32, val: &str) -> AIRNode {
2373        node(
2374            id,
2375            NodeKind::Literal {
2376                lit: Literal::String(val.into()),
2377            },
2378        )
2379    }
2380
2381    fn bool_lit(id: u32, val: bool) -> AIRNode {
2382        node(
2383            id,
2384            NodeKind::Literal {
2385                lit: Literal::Bool(val),
2386            },
2387        )
2388    }
2389
2390    fn id_node(id: u32, name: &str) -> AIRNode {
2391        node(id, NodeKind::Identifier { name: ident(name) })
2392    }
2393
2394    fn bind_pat(id: u32, name: &str) -> AIRNode {
2395        node(
2396            id,
2397            NodeKind::BindPat {
2398                name: ident(name),
2399                is_mut: false,
2400            },
2401        )
2402    }
2403
2404    fn param_node(id: u32, name: &str) -> AIRNode {
2405        node(
2406            id,
2407            NodeKind::Param {
2408                pattern: Box::new(bind_pat(id + 100, name)),
2409                ty: None,
2410                default: None,
2411            },
2412        )
2413    }
2414
2415    fn typed_param_node(id: u32, name: &str, ty_name: &str) -> AIRNode {
2416        node(
2417            id,
2418            NodeKind::Param {
2419                pattern: Box::new(bind_pat(id + 100, name)),
2420                ty: Some(Box::new(node(
2421                    id + 200,
2422                    NodeKind::TypeNamed {
2423                        path: type_path(&[ty_name]),
2424                        args: vec![],
2425                    },
2426                ))),
2427                default: None,
2428            },
2429        )
2430    }
2431
2432    fn block(id: u32, stmts: Vec<AIRNode>, tail: Option<AIRNode>) -> AIRNode {
2433        node(
2434            id,
2435            NodeKind::Block {
2436                stmts,
2437                tail: tail.map(Box::new),
2438            },
2439        )
2440    }
2441
2442    fn module(imports: Vec<AIRNode>, items: Vec<AIRNode>) -> AIRNode {
2443        node(
2444            0,
2445            NodeKind::Module {
2446                path: None,
2447                annotations: vec![],
2448                imports,
2449                items,
2450            },
2451        )
2452    }
2453
2454    fn gen(module: &AIRNode) -> String {
2455        let gen = PyGenerator::new();
2456        let result = gen.generate_module(module).unwrap();
2457        result.files[0].content.clone()
2458    }
2459
2460    // ── Basic tests ─────────────────────────────────────────────────────────
2461
2462    #[test]
2463    fn implements_code_generator_trait() {
2464        let gen = PyGenerator::new();
2465        assert_eq!(gen.target().id, "python");
2466    }
2467
2468    #[test]
2469    fn empty_module() {
2470        let m = module(vec![], vec![]);
2471        let out = gen(&m);
2472        assert_eq!(out, "");
2473    }
2474
2475    #[test]
2476    fn simple_function() {
2477        let body = block(2, vec![], Some(int_lit(3, "42")));
2478        let f = node(
2479            1,
2480            NodeKind::FnDecl {
2481                annotations: vec![],
2482                visibility: Visibility::Private,
2483                is_async: false,
2484                name: ident("answer"),
2485                generic_params: vec![],
2486                params: vec![],
2487                return_type: None,
2488                effect_clause: vec![],
2489                where_clause: vec![],
2490                body: Box::new(body),
2491            },
2492        );
2493        let out = gen(&module(vec![], vec![f]));
2494        assert!(out.contains("def answer():"), "got: {out}");
2495        assert!(out.contains("return 42"), "got: {out}");
2496    }
2497
2498    #[test]
2499    fn function_with_params_and_type_hints() {
2500        let body = block(
2501            5,
2502            vec![],
2503            Some(node(
2504                6,
2505                NodeKind::BinaryOp {
2506                    op: BinOp::Add,
2507                    left: Box::new(id_node(7, "a")),
2508                    right: Box::new(id_node(8, "b")),
2509                },
2510            )),
2511        );
2512        let f = node(
2513            1,
2514            NodeKind::FnDecl {
2515                annotations: vec![],
2516                visibility: Visibility::Public,
2517                is_async: false,
2518                name: ident("add"),
2519                generic_params: vec![],
2520                params: vec![
2521                    typed_param_node(2, "a", "Int"),
2522                    typed_param_node(3, "b", "Int"),
2523                ],
2524                return_type: Some(Box::new(node(
2525                    4,
2526                    NodeKind::TypeNamed {
2527                        path: type_path(&["Int"]),
2528                        args: vec![],
2529                    },
2530                ))),
2531                effect_clause: vec![],
2532                where_clause: vec![],
2533                body: Box::new(body),
2534            },
2535        );
2536        let out = gen(&module(vec![], vec![f]));
2537        assert!(
2538            out.contains("def add(a: int, b: int) -> int:"),
2539            "got: {out}"
2540        );
2541        assert!(out.contains("(a + b)"), "got: {out}");
2542    }
2543
2544    #[test]
2545    fn async_function() {
2546        let body = block(
2547            3,
2548            vec![],
2549            Some(node(
2550                4,
2551                NodeKind::Await {
2552                    expr: Box::new(node(
2553                        5,
2554                        NodeKind::Call {
2555                            callee: Box::new(id_node(6, "fetch")),
2556                            args: vec![AirArg {
2557                                label: None,
2558                                value: str_lit(7, "https://example.com"),
2559                            }],
2560                            type_args: vec![],
2561                        },
2562                    )),
2563                },
2564            )),
2565        );
2566        let f = node(
2567            1,
2568            NodeKind::FnDecl {
2569                annotations: vec![],
2570                visibility: Visibility::Private,
2571                is_async: true,
2572                name: ident("fetchData"),
2573                generic_params: vec![],
2574                params: vec![],
2575                return_type: None,
2576                effect_clause: vec![],
2577                where_clause: vec![],
2578                body: Box::new(body),
2579            },
2580        );
2581        let out = gen(&module(vec![], vec![f]));
2582        assert!(out.contains("async def fetch_data():"), "got: {out}");
2583        assert!(out.contains("await fetch"), "got: {out}");
2584    }
2585
2586    #[test]
2587    fn effects_as_keyword_args() {
2588        let body = block(
2589            3,
2590            vec![node(
2591                4,
2592                NodeKind::LetBinding {
2593                    is_mut: false,
2594                    pattern: Box::new(bind_pat(5, "msg")),
2595                    ty: None,
2596                    value: Box::new(str_lit(6, "hello")),
2597                },
2598            )],
2599            Some(node(
2600                7,
2601                NodeKind::EffectOp {
2602                    effect: type_path(&["Log"]),
2603                    operation: ident("info"),
2604                    args: vec![AirArg {
2605                        label: None,
2606                        value: id_node(8, "msg"),
2607                    }],
2608                },
2609            )),
2610        );
2611        let f = node(
2612            1,
2613            NodeKind::FnDecl {
2614                annotations: vec![],
2615                visibility: Visibility::Private,
2616                is_async: false,
2617                name: ident("process"),
2618                generic_params: vec![],
2619                params: vec![param_node(2, "data")],
2620                return_type: None,
2621                effect_clause: vec![type_path(&["Log"]), type_path(&["Clock"])],
2622                where_clause: vec![],
2623                body: Box::new(body),
2624            },
2625        );
2626        let out = gen(&module(vec![], vec![f]));
2627        assert!(
2628            out.contains("def process(data, *, log: Log, clock: Clock):"),
2629            "got: {out}"
2630        );
2631        assert!(out.contains("log.info(msg)"), "got: {out}");
2632    }
2633
2634    #[test]
2635    fn record_to_dataclass() {
2636        let rec = node(
2637            1,
2638            NodeKind::RecordDecl {
2639                annotations: vec![],
2640                visibility: Visibility::Public,
2641                name: ident("Point"),
2642                generic_params: vec![],
2643                fields: vec![
2644                    bock_ast::RecordDeclField {
2645                        id: 0,
2646                        span: span(),
2647                        name: ident("x"),
2648                        ty: bock_ast::TypeExpr::Named {
2649                            id: 0,
2650                            span: span(),
2651                            path: type_path(&["Float"]),
2652                            args: vec![],
2653                        },
2654                        default: None,
2655                    },
2656                    bock_ast::RecordDeclField {
2657                        id: 0,
2658                        span: span(),
2659                        name: ident("y"),
2660                        ty: bock_ast::TypeExpr::Named {
2661                            id: 0,
2662                            span: span(),
2663                            path: type_path(&["Float"]),
2664                            args: vec![],
2665                        },
2666                        default: None,
2667                    },
2668                ],
2669            },
2670        );
2671        let out = gen(&module(vec![], vec![rec]));
2672        assert!(
2673            out.contains("from dataclasses import dataclass"),
2674            "got: {out}"
2675        );
2676        assert!(out.contains("@dataclass"), "got: {out}");
2677        assert!(out.contains("class Point:"), "got: {out}");
2678        assert!(out.contains("x: float"), "got: {out}");
2679        assert!(out.contains("y: float"), "got: {out}");
2680    }
2681
2682    #[test]
2683    fn enum_to_dataclass_variants() {
2684        let enum_decl = node(
2685            1,
2686            NodeKind::EnumDecl {
2687                annotations: vec![],
2688                visibility: Visibility::Public,
2689                name: ident("Shape"),
2690                generic_params: vec![],
2691                variants: vec![
2692                    node(
2693                        2,
2694                        NodeKind::EnumVariant {
2695                            name: ident("Circle"),
2696                            payload: EnumVariantPayload::Struct(vec![bock_ast::RecordDeclField {
2697                                id: 0,
2698                                span: span(),
2699                                name: ident("radius"),
2700                                ty: bock_ast::TypeExpr::Named {
2701                                    id: 0,
2702                                    span: span(),
2703                                    path: type_path(&["Float"]),
2704                                    args: vec![],
2705                                },
2706                                default: None,
2707                            }]),
2708                        },
2709                    ),
2710                    node(
2711                        3,
2712                        NodeKind::EnumVariant {
2713                            name: ident("None"),
2714                            payload: EnumVariantPayload::Unit,
2715                        },
2716                    ),
2717                ],
2718            },
2719        );
2720        let out = gen(&module(vec![], vec![enum_decl]));
2721        assert!(
2722            out.contains("from dataclasses import dataclass"),
2723            "got: {out}"
2724        );
2725        assert!(out.contains("@dataclass"), "got: {out}");
2726        assert!(out.contains("class Shape_Circle:"), "got: {out}");
2727        assert!(out.contains("radius: float"), "got: {out}");
2728        assert!(out.contains("_tag: str = \"Circle\""), "got: {out}");
2729        assert!(out.contains("class Shape_None:"), "got: {out}");
2730        assert!(out.contains("_tag: str = \"None\""), "got: {out}");
2731    }
2732
2733    #[test]
2734    fn match_to_match_case() {
2735        let scrutinee = id_node(10, "shape");
2736        let arms = vec![
2737            node(
2738                11,
2739                NodeKind::MatchArm {
2740                    pattern: Box::new(node(
2741                        12,
2742                        NodeKind::ConstructorPat {
2743                            path: type_path(&["Shape", "Circle"]),
2744                            fields: vec![bind_pat(13, "r")],
2745                        },
2746                    )),
2747                    guard: None,
2748                    body: Box::new(block(
2749                        14,
2750                        vec![],
2751                        Some(node(
2752                            15,
2753                            NodeKind::BinaryOp {
2754                                op: BinOp::Mul,
2755                                left: Box::new(id_node(16, "r")),
2756                                right: Box::new(id_node(17, "r")),
2757                            },
2758                        )),
2759                    )),
2760                },
2761            ),
2762            node(
2763                18,
2764                NodeKind::MatchArm {
2765                    pattern: Box::new(node(19, NodeKind::WildcardPat)),
2766                    guard: None,
2767                    body: Box::new(block(20, vec![], Some(int_lit(21, "0")))),
2768                },
2769            ),
2770        ];
2771        let match_stmt = node(
2772            9,
2773            NodeKind::Match {
2774                scrutinee: Box::new(scrutinee),
2775                arms,
2776            },
2777        );
2778        let f = node(
2779            1,
2780            NodeKind::FnDecl {
2781                annotations: vec![],
2782                visibility: Visibility::Private,
2783                is_async: false,
2784                name: ident("area"),
2785                generic_params: vec![],
2786                params: vec![param_node(2, "shape")],
2787                return_type: None,
2788                effect_clause: vec![],
2789                where_clause: vec![],
2790                body: Box::new(block(3, vec![match_stmt], None)),
2791            },
2792        );
2793        let out = gen(&module(vec![], vec![f]));
2794        assert!(out.contains("match shape:"), "got: {out}");
2795        assert!(out.contains("case Shape_Circle(_0=r):"), "got: {out}");
2796        assert!(out.contains("case _:"), "got: {out}");
2797    }
2798
2799    #[test]
2800    fn ownership_erased() {
2801        let move_expr = node(
2802            1,
2803            NodeKind::Move {
2804                expr: Box::new(id_node(2, "x")),
2805            },
2806        );
2807        let borrow_expr = node(
2808            3,
2809            NodeKind::Borrow {
2810                expr: Box::new(id_node(4, "y")),
2811            },
2812        );
2813        let mut_borrow_expr = node(
2814            5,
2815            NodeKind::MutableBorrow {
2816                expr: Box::new(id_node(6, "z")),
2817            },
2818        );
2819        let body = block(
2820            7,
2821            vec![
2822                node(
2823                    8,
2824                    NodeKind::LetBinding {
2825                        is_mut: false,
2826                        pattern: Box::new(bind_pat(9, "a")),
2827                        ty: None,
2828                        value: Box::new(move_expr),
2829                    },
2830                ),
2831                node(
2832                    10,
2833                    NodeKind::LetBinding {
2834                        is_mut: false,
2835                        pattern: Box::new(bind_pat(11, "b")),
2836                        ty: None,
2837                        value: Box::new(borrow_expr),
2838                    },
2839                ),
2840                node(
2841                    12,
2842                    NodeKind::LetBinding {
2843                        is_mut: false,
2844                        pattern: Box::new(bind_pat(13, "c")),
2845                        ty: None,
2846                        value: Box::new(mut_borrow_expr),
2847                    },
2848                ),
2849            ],
2850            None,
2851        );
2852        let f = node(
2853            0,
2854            NodeKind::FnDecl {
2855                annotations: vec![],
2856                visibility: Visibility::Private,
2857                is_async: false,
2858                name: ident("test"),
2859                generic_params: vec![],
2860                params: vec![],
2861                return_type: None,
2862                effect_clause: vec![],
2863                where_clause: vec![],
2864                body: Box::new(body),
2865            },
2866        );
2867        let out = gen(&module(vec![], vec![f]));
2868        assert!(out.contains("a = x"), "got: {out}");
2869        assert!(out.contains("b = y"), "got: {out}");
2870        assert!(out.contains("c = z"), "got: {out}");
2871    }
2872
2873    #[test]
2874    fn string_interpolation_fstring() {
2875        let interp = node(
2876            1,
2877            NodeKind::Interpolation {
2878                parts: vec![
2879                    AirInterpolationPart::Literal("Hello, ".into()),
2880                    AirInterpolationPart::Expr(Box::new(id_node(2, "name"))),
2881                    AirInterpolationPart::Literal("!".into()),
2882                ],
2883            },
2884        );
2885        let binding = node(
2886            3,
2887            NodeKind::LetBinding {
2888                is_mut: false,
2889                pattern: Box::new(bind_pat(4, "msg")),
2890                ty: None,
2891                value: Box::new(interp),
2892            },
2893        );
2894        let f = node(
2895            0,
2896            NodeKind::FnDecl {
2897                annotations: vec![],
2898                visibility: Visibility::Private,
2899                is_async: false,
2900                name: ident("greet"),
2901                generic_params: vec![],
2902                params: vec![param_node(5, "name")],
2903                return_type: None,
2904                effect_clause: vec![],
2905                where_clause: vec![],
2906                body: Box::new(block(6, vec![binding], Some(id_node(7, "msg")))),
2907            },
2908        );
2909        let out = gen(&module(vec![], vec![f]));
2910        assert!(out.contains("f\"Hello, {name}!\""), "got: {out}");
2911    }
2912
2913    #[test]
2914    fn multiline_interpolation_uses_triple_quoted_fstring() {
2915        let interp = node(
2916            1,
2917            NodeKind::Interpolation {
2918                parts: vec![
2919                    AirInterpolationPart::Literal("=== ".into()),
2920                    AirInterpolationPart::Expr(Box::new(id_node(2, "title"))),
2921                    AirInterpolationPart::Literal(" ===\n".into()),
2922                    AirInterpolationPart::Expr(Box::new(id_node(3, "msg"))),
2923                    AirInterpolationPart::Literal("\n================".into()),
2924                ],
2925            },
2926        );
2927        let binding = node(
2928            4,
2929            NodeKind::LetBinding {
2930                is_mut: false,
2931                pattern: Box::new(bind_pat(5, "result")),
2932                ty: None,
2933                value: Box::new(interp),
2934            },
2935        );
2936        let f = node(
2937            0,
2938            NodeKind::FnDecl {
2939                annotations: vec![],
2940                visibility: Visibility::Private,
2941                is_async: false,
2942                name: ident("banner"),
2943                generic_params: vec![],
2944                params: vec![param_node(6, "title"), param_node(7, "msg")],
2945                return_type: None,
2946                effect_clause: vec![],
2947                where_clause: vec![],
2948                body: Box::new(block(8, vec![binding], Some(id_node(9, "result")))),
2949            },
2950        );
2951        let out = gen(&module(vec![], vec![f]));
2952        assert!(
2953            out.contains("f\"\"\"=== {title} ===\n{msg}\n================\"\"\""),
2954            "got: {out}"
2955        );
2956        // Single-line interpolation should still use regular f-string
2957        assert!(!out.contains("f\"Hello"), "single-line should not appear: {out}");
2958    }
2959
2960    #[test]
2961    fn single_line_interpolation_still_uses_regular_fstring() {
2962        let interp = node(
2963            1,
2964            NodeKind::Interpolation {
2965                parts: vec![
2966                    AirInterpolationPart::Literal("Hi ".into()),
2967                    AirInterpolationPart::Expr(Box::new(id_node(2, "name"))),
2968                ],
2969            },
2970        );
2971        let binding = node(
2972            3,
2973            NodeKind::LetBinding {
2974                is_mut: false,
2975                pattern: Box::new(bind_pat(4, "greeting")),
2976                ty: None,
2977                value: Box::new(interp),
2978            },
2979        );
2980        let f = node(
2981            0,
2982            NodeKind::FnDecl {
2983                annotations: vec![],
2984                visibility: Visibility::Private,
2985                is_async: false,
2986                name: ident("greet"),
2987                generic_params: vec![],
2988                params: vec![param_node(5, "name")],
2989                return_type: None,
2990                effect_clause: vec![],
2991                where_clause: vec![],
2992                body: Box::new(block(6, vec![binding], Some(id_node(7, "greeting")))),
2993            },
2994        );
2995        let out = gen(&module(vec![], vec![f]));
2996        assert!(out.contains("f\"Hi {name}\""), "got: {out}");
2997        assert!(!out.contains("f\"\"\""), "should not use triple quotes: {out}");
2998    }
2999
3000    #[test]
3001    fn list_map_set_literals() {
3002        let list = node(
3003            1,
3004            NodeKind::ListLiteral {
3005                elems: vec![int_lit(2, "1"), int_lit(3, "2"), int_lit(4, "3")],
3006            },
3007        );
3008        let map = node(
3009            5,
3010            NodeKind::MapLiteral {
3011                entries: vec![bock_air::AirMapEntry {
3012                    key: str_lit(6, "a"),
3013                    value: int_lit(7, "1"),
3014                }],
3015            },
3016        );
3017        let set = node(
3018            8,
3019            NodeKind::SetLiteral {
3020                elems: vec![int_lit(9, "1"), int_lit(10, "2")],
3021            },
3022        );
3023        let body = block(
3024            11,
3025            vec![
3026                node(
3027                    12,
3028                    NodeKind::LetBinding {
3029                        is_mut: false,
3030                        pattern: Box::new(bind_pat(13, "xs")),
3031                        ty: None,
3032                        value: Box::new(list),
3033                    },
3034                ),
3035                node(
3036                    14,
3037                    NodeKind::LetBinding {
3038                        is_mut: false,
3039                        pattern: Box::new(bind_pat(15, "m")),
3040                        ty: None,
3041                        value: Box::new(map),
3042                    },
3043                ),
3044                node(
3045                    16,
3046                    NodeKind::LetBinding {
3047                        is_mut: false,
3048                        pattern: Box::new(bind_pat(17, "s")),
3049                        ty: None,
3050                        value: Box::new(set),
3051                    },
3052                ),
3053            ],
3054            None,
3055        );
3056        let f = node(
3057            0,
3058            NodeKind::FnDecl {
3059                annotations: vec![],
3060                visibility: Visibility::Private,
3061                is_async: false,
3062                name: ident("collections"),
3063                generic_params: vec![],
3064                params: vec![],
3065                return_type: None,
3066                effect_clause: vec![],
3067                where_clause: vec![],
3068                body: Box::new(body),
3069            },
3070        );
3071        let out = gen(&module(vec![], vec![f]));
3072        assert!(out.contains("[1, 2, 3]"), "got: {out}");
3073        assert!(out.contains("{\"a\": 1}"), "got: {out}");
3074        assert!(out.contains("{1, 2}"), "got: {out}");
3075    }
3076
3077    #[test]
3078    fn record_construction() {
3079        let rec = node(
3080            1,
3081            NodeKind::RecordConstruct {
3082                path: type_path(&["User"]),
3083                fields: vec![
3084                    AirRecordField {
3085                        name: ident("name"),
3086                        value: Some(Box::new(str_lit(2, "Alice"))),
3087                    },
3088                    AirRecordField {
3089                        name: ident("age"),
3090                        value: Some(Box::new(int_lit(3, "30"))),
3091                    },
3092                ],
3093                spread: None,
3094            },
3095        );
3096        let binding = node(
3097            4,
3098            NodeKind::LetBinding {
3099                is_mut: false,
3100                pattern: Box::new(bind_pat(5, "user")),
3101                ty: None,
3102                value: Box::new(rec),
3103            },
3104        );
3105        let f = node(
3106            0,
3107            NodeKind::FnDecl {
3108                annotations: vec![],
3109                visibility: Visibility::Private,
3110                is_async: false,
3111                name: ident("test"),
3112                generic_params: vec![],
3113                params: vec![],
3114                return_type: None,
3115                effect_clause: vec![],
3116                where_clause: vec![],
3117                body: Box::new(block(6, vec![binding], None)),
3118            },
3119        );
3120        let out = gen(&module(vec![], vec![f]));
3121        assert!(out.contains("User(name=\"Alice\", age=30)"), "got: {out}");
3122    }
3123
3124    #[test]
3125    fn control_flow() {
3126        let if_stmt = node(
3127            1,
3128            NodeKind::If {
3129                let_pattern: None,
3130                condition: Box::new(bool_lit(2, true)),
3131                then_block: Box::new(block(3, vec![], Some(int_lit(4, "1")))),
3132                else_block: Some(Box::new(block(5, vec![], Some(int_lit(6, "2"))))),
3133            },
3134        );
3135        let for_stmt = node(
3136            7,
3137            NodeKind::For {
3138                pattern: Box::new(bind_pat(8, "x")),
3139                iterable: Box::new(id_node(9, "items")),
3140                body: Box::new(block(10, vec![], None)),
3141            },
3142        );
3143        let while_stmt = node(
3144            11,
3145            NodeKind::While {
3146                condition: Box::new(bool_lit(12, true)),
3147                body: Box::new(block(
3148                    13,
3149                    vec![node(14, NodeKind::Break { value: None })],
3150                    None,
3151                )),
3152            },
3153        );
3154        let body = block(15, vec![if_stmt, for_stmt, while_stmt], None);
3155        let f = node(
3156            0,
3157            NodeKind::FnDecl {
3158                annotations: vec![],
3159                visibility: Visibility::Private,
3160                is_async: false,
3161                name: ident("flow"),
3162                generic_params: vec![],
3163                params: vec![param_node(16, "items")],
3164                return_type: None,
3165                effect_clause: vec![],
3166                where_clause: vec![],
3167                body: Box::new(body),
3168            },
3169        );
3170        let out = gen(&module(vec![], vec![f]));
3171        assert!(out.contains("if True:"), "got: {out}");
3172        assert!(out.contains("else:"), "got: {out}");
3173        assert!(out.contains("for x in items:"), "got: {out}");
3174        assert!(out.contains("while True:"), "got: {out}");
3175        assert!(out.contains("break"), "got: {out}");
3176    }
3177
3178    #[test]
3179    fn lambda_and_pipe() {
3180        let lambda = node(
3181            1,
3182            NodeKind::Lambda {
3183                params: vec![param_node(2, "x")],
3184                body: Box::new(node(
3185                    3,
3186                    NodeKind::BinaryOp {
3187                        op: BinOp::Mul,
3188                        left: Box::new(id_node(4, "x")),
3189                        right: Box::new(int_lit(5, "2")),
3190                    },
3191                )),
3192            },
3193        );
3194        let pipe = node(
3195            6,
3196            NodeKind::Pipe {
3197                left: Box::new(int_lit(7, "5")),
3198                right: Box::new(id_node(8, "double")),
3199            },
3200        );
3201        let body = block(
3202            9,
3203            vec![
3204                node(
3205                    10,
3206                    NodeKind::LetBinding {
3207                        is_mut: false,
3208                        pattern: Box::new(bind_pat(11, "double")),
3209                        ty: None,
3210                        value: Box::new(lambda),
3211                    },
3212                ),
3213                node(
3214                    12,
3215                    NodeKind::LetBinding {
3216                        is_mut: false,
3217                        pattern: Box::new(bind_pat(13, "result")),
3218                        ty: None,
3219                        value: Box::new(pipe),
3220                    },
3221                ),
3222            ],
3223            None,
3224        );
3225        let f = node(
3226            0,
3227            NodeKind::FnDecl {
3228                annotations: vec![],
3229                visibility: Visibility::Private,
3230                is_async: false,
3231                name: ident("test"),
3232                generic_params: vec![],
3233                params: vec![],
3234                return_type: None,
3235                effect_clause: vec![],
3236                where_clause: vec![],
3237                body: Box::new(body),
3238            },
3239        );
3240        let out = gen(&module(vec![], vec![f]));
3241        assert!(out.contains("lambda x: (x * 2)"), "got: {out}");
3242        assert!(out.contains("double(5)"), "got: {out}");
3243    }
3244
3245    #[test]
3246    fn const_declaration() {
3247        let c = node(
3248            1,
3249            NodeKind::ConstDecl {
3250                annotations: vec![],
3251                visibility: Visibility::Public,
3252                name: ident("PI"),
3253                ty: Box::new(node(
3254                    2,
3255                    NodeKind::TypeNamed {
3256                        path: type_path(&["Float"]),
3257                        args: vec![],
3258                    },
3259                )),
3260                value: Box::new(node(
3261                    3,
3262                    NodeKind::Literal {
3263                        lit: Literal::Float("3.14159".into()),
3264                    },
3265                )),
3266            },
3267        );
3268        let out = gen(&module(vec![], vec![c]));
3269        // PI should become snake case, but since it's all-caps we leave it
3270        assert!(out.contains("= 3.14159"), "got: {out}");
3271        assert!(out.contains(": float"), "got: {out}");
3272    }
3273
3274    #[test]
3275    fn class_declaration() {
3276        let method_body = block(10, vec![], Some(id_node(11, "undefined")));
3277        let method = node(
3278            5,
3279            NodeKind::FnDecl {
3280                annotations: vec![],
3281                visibility: Visibility::Public,
3282                is_async: false,
3283                name: ident("greet"),
3284                generic_params: vec![],
3285                params: vec![],
3286                return_type: None,
3287                effect_clause: vec![],
3288                where_clause: vec![],
3289                body: Box::new(method_body),
3290            },
3291        );
3292        let cls = node(
3293            1,
3294            NodeKind::ClassDecl {
3295                annotations: vec![],
3296                visibility: Visibility::Public,
3297                name: ident("Person"),
3298                generic_params: vec![],
3299                base: None,
3300                traits: vec![],
3301                fields: vec![bock_ast::RecordDeclField {
3302                    id: 0,
3303                    span: span(),
3304                    name: ident("name"),
3305                    ty: bock_ast::TypeExpr::Named {
3306                        id: 0,
3307                        span: span(),
3308                        path: type_path(&["String"]),
3309                        args: vec![],
3310                    },
3311                    default: None,
3312                }],
3313                methods: vec![method],
3314            },
3315        );
3316        let out = gen(&module(vec![], vec![cls]));
3317        assert!(out.contains("class Person:"), "got: {out}");
3318        assert!(out.contains("def __init__(self, name: str):"), "got: {out}");
3319        assert!(out.contains("self.name = name"), "got: {out}");
3320        assert!(out.contains("def greet(self):"), "got: {out}");
3321    }
3322
3323    #[test]
3324    fn boolean_operators() {
3325        let expr = node(
3326            1,
3327            NodeKind::BinaryOp {
3328                op: BinOp::And,
3329                left: Box::new(bool_lit(2, true)),
3330                right: Box::new(bool_lit(3, false)),
3331            },
3332        );
3333        let not_expr = node(
3334            4,
3335            NodeKind::UnaryOp {
3336                op: UnaryOp::Not,
3337                operand: Box::new(bool_lit(5, true)),
3338            },
3339        );
3340        let body = block(
3341            6,
3342            vec![
3343                node(
3344                    7,
3345                    NodeKind::LetBinding {
3346                        is_mut: false,
3347                        pattern: Box::new(bind_pat(8, "a")),
3348                        ty: None,
3349                        value: Box::new(expr),
3350                    },
3351                ),
3352                node(
3353                    9,
3354                    NodeKind::LetBinding {
3355                        is_mut: false,
3356                        pattern: Box::new(bind_pat(10, "b")),
3357                        ty: None,
3358                        value: Box::new(not_expr),
3359                    },
3360                ),
3361            ],
3362            None,
3363        );
3364        let f = node(
3365            0,
3366            NodeKind::FnDecl {
3367                annotations: vec![],
3368                visibility: Visibility::Private,
3369                is_async: false,
3370                name: ident("test"),
3371                generic_params: vec![],
3372                params: vec![],
3373                return_type: None,
3374                effect_clause: vec![],
3375                where_clause: vec![],
3376                body: Box::new(body),
3377            },
3378        );
3379        let out = gen(&module(vec![], vec![f]));
3380        assert!(out.contains("(True and False)"), "got: {out}");
3381        assert!(out.contains("not True"), "got: {out}");
3382    }
3383
3384    #[test]
3385    fn result_construct() {
3386        let ok = node(
3387            1,
3388            NodeKind::ResultConstruct {
3389                variant: ResultVariant::Ok,
3390                value: Some(Box::new(int_lit(2, "42"))),
3391            },
3392        );
3393        let err = node(
3394            3,
3395            NodeKind::ResultConstruct {
3396                variant: ResultVariant::Err,
3397                value: Some(Box::new(str_lit(4, "failed"))),
3398            },
3399        );
3400        let body = block(
3401            5,
3402            vec![
3403                node(
3404                    6,
3405                    NodeKind::LetBinding {
3406                        is_mut: false,
3407                        pattern: Box::new(bind_pat(7, "good")),
3408                        ty: None,
3409                        value: Box::new(ok),
3410                    },
3411                ),
3412                node(
3413                    8,
3414                    NodeKind::LetBinding {
3415                        is_mut: false,
3416                        pattern: Box::new(bind_pat(9, "bad")),
3417                        ty: None,
3418                        value: Box::new(err),
3419                    },
3420                ),
3421            ],
3422            None,
3423        );
3424        let f = node(
3425            0,
3426            NodeKind::FnDecl {
3427                annotations: vec![],
3428                visibility: Visibility::Private,
3429                is_async: false,
3430                name: ident("test"),
3431                generic_params: vec![],
3432                params: vec![],
3433                return_type: None,
3434                effect_clause: vec![],
3435                where_clause: vec![],
3436                body: Box::new(body),
3437            },
3438        );
3439        let out = gen(&module(vec![], vec![f]));
3440        assert!(
3441            out.contains("{\"_tag\": \"Ok\", \"value\": 42}"),
3442            "got: {out}"
3443        );
3444        assert!(
3445            out.contains("{\"_tag\": \"Err\", \"error\": \"failed\"}"),
3446            "got: {out}"
3447        );
3448    }
3449
3450    #[test]
3451    fn to_snake_case_conversions() {
3452        assert_eq!(to_snake_case("fetchData"), "fetch_data");
3453        assert_eq!(to_snake_case("MyClass"), "my_class");
3454        assert_eq!(to_snake_case("already_snake"), "already_snake");
3455        assert_eq!(to_snake_case("simple"), "simple");
3456        assert_eq!(to_snake_case("HTMLParser"), "html_parser");
3457        assert_eq!(to_snake_case("x"), "x");
3458        assert_eq!(to_snake_case("_"), "_");
3459    }
3460
3461    // ── End-to-end tests (python3 --check + python3 execution) ──────────────
3462
3463    fn has_python3() -> bool {
3464        std::process::Command::new("which")
3465            .arg("python3")
3466            .output()
3467            .map(|o| o.status.success())
3468            .unwrap_or(false)
3469    }
3470
3471    /// Run generated Python through `python3 -m py_compile` for syntax validation.
3472    fn check_py_syntax(code: &str) -> bool {
3473        let dir = std::env::temp_dir();
3474        let path = dir.join("bock_test_output.py");
3475        std::fs::write(&path, code).expect("failed to write temp file");
3476        let result = std::process::Command::new("python3")
3477            .arg("-m")
3478            .arg("py_compile")
3479            .arg(&path)
3480            .output()
3481            .expect("failed to spawn python3");
3482        let _ = std::fs::remove_file(&path);
3483        result.status.success()
3484    }
3485
3486    /// Run generated Python with `python3` and capture stdout.
3487    fn run_py(code: &str) -> String {
3488        let output = std::process::Command::new("python3")
3489            .arg("-c")
3490            .arg(code)
3491            .output()
3492            .expect("failed to run python3");
3493        String::from_utf8(output.stdout).unwrap().trim().to_string()
3494    }
3495
3496    #[test]
3497    #[ignore]
3498    fn e2e_hello_world() {
3499        if !has_python3() {
3500            return;
3501        }
3502        let body = block(
3503            2,
3504            vec![],
3505            Some(node(
3506                3,
3507                NodeKind::Call {
3508                    callee: Box::new(id_node(4, "print")),
3509                    args: vec![AirArg {
3510                        label: None,
3511                        value: str_lit(5, "Hello, World!"),
3512                    }],
3513                    type_args: vec![],
3514                },
3515            )),
3516        );
3517        let f = node(
3518            1,
3519            NodeKind::FnDecl {
3520                annotations: vec![],
3521                visibility: Visibility::Private,
3522                is_async: false,
3523                name: ident("main"),
3524                generic_params: vec![],
3525                params: vec![],
3526                return_type: None,
3527                effect_clause: vec![],
3528                where_clause: vec![],
3529                body: Box::new(body),
3530            },
3531        );
3532        let code = gen(&module(vec![], vec![f]));
3533        let full = format!("{code}\nmain()\n");
3534        assert!(
3535            check_py_syntax(&full),
3536            "Python syntax check failed:\n{full}"
3537        );
3538        assert_eq!(run_py(&full), "Hello, World!");
3539    }
3540
3541    #[test]
3542    #[ignore]
3543    fn e2e_arithmetic() {
3544        if !has_python3() {
3545            return;
3546        }
3547        let body = block(
3548            2,
3549            vec![],
3550            Some(node(
3551                3,
3552                NodeKind::BinaryOp {
3553                    op: BinOp::Add,
3554                    left: Box::new(int_lit(4, "10")),
3555                    right: Box::new(int_lit(5, "32")),
3556                },
3557            )),
3558        );
3559        let f = node(
3560            1,
3561            NodeKind::FnDecl {
3562                annotations: vec![],
3563                visibility: Visibility::Private,
3564                is_async: false,
3565                name: ident("calc"),
3566                generic_params: vec![],
3567                params: vec![],
3568                return_type: None,
3569                effect_clause: vec![],
3570                where_clause: vec![],
3571                body: Box::new(body),
3572            },
3573        );
3574        let code = gen(&module(vec![], vec![f]));
3575        let full = format!("{code}\nprint(calc())\n");
3576        assert!(
3577            check_py_syntax(&full),
3578            "Python syntax check failed:\n{full}"
3579        );
3580        assert_eq!(run_py(&full), "42");
3581    }
3582
3583    #[test]
3584    #[ignore]
3585    fn e2e_if_else() {
3586        if !has_python3() {
3587            return;
3588        }
3589        let if_stmt = node(
3590            3,
3591            NodeKind::If {
3592                let_pattern: None,
3593                condition: Box::new(node(
3594                    4,
3595                    NodeKind::BinaryOp {
3596                        op: BinOp::Gt,
3597                        left: Box::new(id_node(5, "x")),
3598                        right: Box::new(int_lit(6, "0")),
3599                    },
3600                )),
3601                then_block: Box::new(block(
3602                    7,
3603                    vec![node(
3604                        8,
3605                        NodeKind::Return {
3606                            value: Some(Box::new(str_lit(9, "positive"))),
3607                        },
3608                    )],
3609                    None,
3610                )),
3611                else_block: Some(Box::new(block(
3612                    10,
3613                    vec![node(
3614                        11,
3615                        NodeKind::Return {
3616                            value: Some(Box::new(str_lit(12, "non-positive"))),
3617                        },
3618                    )],
3619                    None,
3620                ))),
3621            },
3622        );
3623        let body = block(2, vec![if_stmt], None);
3624        let f = node(
3625            1,
3626            NodeKind::FnDecl {
3627                annotations: vec![],
3628                visibility: Visibility::Private,
3629                is_async: false,
3630                name: ident("classify"),
3631                generic_params: vec![],
3632                params: vec![param_node(13, "x")],
3633                return_type: None,
3634                effect_clause: vec![],
3635                where_clause: vec![],
3636                body: Box::new(body),
3637            },
3638        );
3639        let code = gen(&module(vec![], vec![f]));
3640        let full = format!("{code}\nprint(classify(5))\nprint(classify(-1))\n");
3641        assert!(
3642            check_py_syntax(&full),
3643            "Python syntax check failed:\n{full}"
3644        );
3645        let output = run_py(&full);
3646        assert!(output.contains("positive"), "got: {output}");
3647        assert!(output.contains("non-positive"), "got: {output}");
3648    }
3649
3650    #[test]
3651    #[ignore]
3652    fn e2e_for_loop() {
3653        if !has_python3() {
3654            return;
3655        }
3656        let body = block(
3657            2,
3658            vec![
3659                node(
3660                    3,
3661                    NodeKind::LetBinding {
3662                        is_mut: true,
3663                        pattern: Box::new(bind_pat(4, "sum")),
3664                        ty: None,
3665                        value: Box::new(int_lit(5, "0")),
3666                    },
3667                ),
3668                node(
3669                    6,
3670                    NodeKind::For {
3671                        pattern: Box::new(bind_pat(7, "x")),
3672                        iterable: Box::new(node(
3673                            8,
3674                            NodeKind::ListLiteral {
3675                                elems: vec![int_lit(9, "1"), int_lit(10, "2"), int_lit(11, "3")],
3676                            },
3677                        )),
3678                        body: Box::new(block(
3679                            12,
3680                            vec![node(
3681                                13,
3682                                NodeKind::Assign {
3683                                    op: AssignOp::AddAssign,
3684                                    target: Box::new(id_node(14, "sum")),
3685                                    value: Box::new(id_node(15, "x")),
3686                                },
3687                            )],
3688                            None,
3689                        )),
3690                    },
3691                ),
3692            ],
3693            Some(id_node(16, "sum")),
3694        );
3695        let f = node(
3696            1,
3697            NodeKind::FnDecl {
3698                annotations: vec![],
3699                visibility: Visibility::Private,
3700                is_async: false,
3701                name: ident("total"),
3702                generic_params: vec![],
3703                params: vec![],
3704                return_type: None,
3705                effect_clause: vec![],
3706                where_clause: vec![],
3707                body: Box::new(body),
3708            },
3709        );
3710        let code = gen(&module(vec![], vec![f]));
3711        let full = format!("{code}\nprint(total())\n");
3712        assert!(
3713            check_py_syntax(&full),
3714            "Python syntax check failed:\n{full}"
3715        );
3716        assert_eq!(run_py(&full), "6");
3717    }
3718
3719    #[test]
3720    #[ignore]
3721    fn e2e_dataclass() {
3722        if !has_python3() {
3723            return;
3724        }
3725        let rec = node(
3726            1,
3727            NodeKind::RecordDecl {
3728                annotations: vec![],
3729                visibility: Visibility::Public,
3730                name: ident("Point"),
3731                generic_params: vec![],
3732                fields: vec![
3733                    bock_ast::RecordDeclField {
3734                        id: 0,
3735                        span: span(),
3736                        name: ident("x"),
3737                        ty: bock_ast::TypeExpr::Named {
3738                            id: 0,
3739                            span: span(),
3740                            path: type_path(&["Float"]),
3741                            args: vec![],
3742                        },
3743                        default: None,
3744                    },
3745                    bock_ast::RecordDeclField {
3746                        id: 0,
3747                        span: span(),
3748                        name: ident("y"),
3749                        ty: bock_ast::TypeExpr::Named {
3750                            id: 0,
3751                            span: span(),
3752                            path: type_path(&["Float"]),
3753                            args: vec![],
3754                        },
3755                        default: None,
3756                    },
3757                ],
3758            },
3759        );
3760        let code = gen(&module(vec![], vec![rec]));
3761        let full = format!("{code}\np = Point(x=1.0, y=2.0)\nprint(f\"{{p.x}}, {{p.y}}\")\n");
3762        assert!(
3763            check_py_syntax(&full),
3764            "Python syntax check failed:\n{full}"
3765        );
3766        let output = run_py(&full);
3767        assert!(output.contains("1.0, 2.0"), "got: {output}");
3768    }
3769
3770    #[test]
3771    #[ignore]
3772    fn e2e_match_statement() {
3773        if !has_python3() {
3774            return;
3775        }
3776        // match on literal values
3777        let scrutinee = id_node(10, "x");
3778        let arms = vec![
3779            node(
3780                11,
3781                NodeKind::MatchArm {
3782                    pattern: Box::new(node(
3783                        12,
3784                        NodeKind::LiteralPat {
3785                            lit: Literal::Int("1".into()),
3786                        },
3787                    )),
3788                    guard: None,
3789                    body: Box::new(block(
3790                        13,
3791                        vec![node(
3792                            14,
3793                            NodeKind::Return {
3794                                value: Some(Box::new(str_lit(15, "one"))),
3795                            },
3796                        )],
3797                        None,
3798                    )),
3799                },
3800            ),
3801            node(
3802                16,
3803                NodeKind::MatchArm {
3804                    pattern: Box::new(node(17, NodeKind::WildcardPat)),
3805                    guard: None,
3806                    body: Box::new(block(
3807                        18,
3808                        vec![node(
3809                            19,
3810                            NodeKind::Return {
3811                                value: Some(Box::new(str_lit(20, "other"))),
3812                            },
3813                        )],
3814                        None,
3815                    )),
3816                },
3817            ),
3818        ];
3819        let match_stmt = node(
3820            9,
3821            NodeKind::Match {
3822                scrutinee: Box::new(scrutinee),
3823                arms,
3824            },
3825        );
3826        let f = node(
3827            1,
3828            NodeKind::FnDecl {
3829                annotations: vec![],
3830                visibility: Visibility::Private,
3831                is_async: false,
3832                name: ident("describe"),
3833                generic_params: vec![],
3834                params: vec![param_node(2, "x")],
3835                return_type: None,
3836                effect_clause: vec![],
3837                where_clause: vec![],
3838                body: Box::new(block(3, vec![match_stmt], None)),
3839            },
3840        );
3841        let code = gen(&module(vec![], vec![f]));
3842        let full = format!("{code}\nprint(describe(1))\nprint(describe(99))\n");
3843        assert!(
3844            check_py_syntax(&full),
3845            "Python syntax check failed:\n{full}"
3846        );
3847        let output = run_py(&full);
3848        assert!(output.contains("one"), "got: {output}");
3849        assert!(output.contains("other"), "got: {output}");
3850    }
3851
3852    // ── Prelude function mapping tests ──────────────────────────────────────
3853
3854    /// Helper: generate Python for a module with a `main` function containing a single call.
3855    fn gen_prelude_call(func_name: &str, arg: AIRNode) -> String {
3856        let call = node(
3857            10,
3858            NodeKind::Call {
3859                callee: Box::new(id_node(11, func_name)),
3860                args: vec![AirArg {
3861                    label: None,
3862                    value: arg,
3863                }],
3864                type_args: vec![],
3865            },
3866        );
3867        let body = block(2, vec![call], None);
3868        let f = node(
3869            1,
3870            NodeKind::FnDecl {
3871                name: ident("main"),
3872                params: vec![],
3873                return_type: None,
3874                body: Box::new(body),
3875                generic_params: vec![],
3876                visibility: Visibility::Private,
3877                annotations: vec![],
3878                effect_clause: vec![],
3879                where_clause: vec![],
3880                is_async: false,
3881            },
3882        );
3883        gen(&module(vec![], vec![f]))
3884    }
3885
3886    /// Helper: generate Python for a nullary prelude call (no args).
3887    fn gen_prelude_call_no_args(func_name: &str) -> String {
3888        let call = node(
3889            10,
3890            NodeKind::Call {
3891                callee: Box::new(id_node(11, func_name)),
3892                args: vec![],
3893                type_args: vec![],
3894            },
3895        );
3896        let body = block(2, vec![call], None);
3897        let f = node(
3898            1,
3899            NodeKind::FnDecl {
3900                name: ident("main"),
3901                params: vec![],
3902                return_type: None,
3903                body: Box::new(body),
3904                generic_params: vec![],
3905                visibility: Visibility::Private,
3906                annotations: vec![],
3907                effect_clause: vec![],
3908                where_clause: vec![],
3909                is_async: false,
3910            },
3911        );
3912        gen(&module(vec![], vec![f]))
3913    }
3914
3915    #[test]
3916    fn prelude_println_maps_to_print() {
3917        let out = gen_prelude_call("println", str_lit(12, "hello"));
3918        assert!(
3919            out.contains("print("),
3920            "println should map to print, got: {out}"
3921        );
3922        assert!(
3923            !out.contains("println("),
3924            "should not emit bare println(, got: {out}"
3925        );
3926    }
3927
3928    #[test]
3929    fn prelude_print_maps_to_print_no_newline() {
3930        let out = gen_prelude_call("print", str_lit(12, "hello"));
3931        assert!(
3932            out.contains("print(") && out.contains("end=\"\""),
3933            "print should map to print with end=\"\", got: {out}"
3934        );
3935    }
3936
3937    #[test]
3938    fn prelude_debug_maps_to_repr() {
3939        let out = gen_prelude_call("debug", str_lit(12, "val"));
3940        assert!(
3941            out.contains("print(repr("),
3942            "debug should map to print(repr(...)), got: {out}"
3943        );
3944    }
3945
3946    #[test]
3947    fn prelude_assert_maps_to_assert() {
3948        let out = gen_prelude_call("assert", bool_lit(12, true));
3949        assert!(
3950            out.contains("assert "),
3951            "assert should map to Python assert, got: {out}"
3952        );
3953        assert!(
3954            !out.contains("assert("),
3955            "should not emit assert as function call, got: {out}"
3956        );
3957    }
3958
3959    #[test]
3960    fn prelude_todo_maps_to_not_implemented_error() {
3961        let out = gen_prelude_call_no_args("todo");
3962        assert!(
3963            out.contains("raise NotImplementedError()"),
3964            "todo should map to raise NotImplementedError, got: {out}"
3965        );
3966    }
3967
3968    #[test]
3969    fn prelude_unreachable_maps_to_runtime_error() {
3970        let out = gen_prelude_call_no_args("unreachable");
3971        assert!(
3972            out.contains("raise RuntimeError(\"unreachable\")"),
3973            "unreachable should map to raise RuntimeError, got: {out}"
3974        );
3975    }
3976
3977    #[test]
3978    fn non_prelude_call_passes_through() {
3979        let out = gen_prelude_call("my_custom_func", str_lit(12, "arg"));
3980        assert!(
3981            out.contains("my_custom_func("),
3982            "non-prelude call should use snake_case, got: {out}"
3983        );
3984    }
3985
3986    // ── Effect declaration tests ────────────────────────────────────────────
3987
3988    #[test]
3989    fn effect_decl_becomes_abc() {
3990        let effect = node(
3991            1,
3992            NodeKind::EffectDecl {
3993                annotations: vec![],
3994                visibility: Visibility::Public,
3995                name: ident("Logger"),
3996                generic_params: vec![],
3997                components: vec![],
3998                operations: vec![
3999                    node(
4000                        2,
4001                        NodeKind::FnDecl {
4002                            annotations: vec![],
4003                            visibility: Visibility::Public,
4004                            is_async: false,
4005                            name: ident("log"),
4006                            generic_params: vec![],
4007                            params: vec![
4008                                typed_param_node(3, "level", "String"),
4009                                typed_param_node(4, "msg", "String"),
4010                            ],
4011                            return_type: Some(Box::new(node(
4012                                5,
4013                                NodeKind::TypeNamed {
4014                                    path: type_path(&["Void"]),
4015                                    args: vec![],
4016                                },
4017                            ))),
4018                            effect_clause: vec![],
4019                            where_clause: vec![],
4020                            body: Box::new(block(6, vec![], None)),
4021                        },
4022                    ),
4023                    node(
4024                        7,
4025                        NodeKind::FnDecl {
4026                            annotations: vec![],
4027                            visibility: Visibility::Public,
4028                            is_async: false,
4029                            name: ident("flush"),
4030                            generic_params: vec![],
4031                            params: vec![],
4032                            return_type: Some(Box::new(node(
4033                                8,
4034                                NodeKind::TypeNamed {
4035                                    path: type_path(&["Void"]),
4036                                    args: vec![],
4037                                },
4038                            ))),
4039                            effect_clause: vec![],
4040                            where_clause: vec![],
4041                            body: Box::new(block(9, vec![], None)),
4042                        },
4043                    ),
4044                ],
4045            },
4046        );
4047        let out = gen(&module(vec![], vec![effect]));
4048        assert!(
4049            out.contains("from abc import ABC, abstractmethod"),
4050            "got: {out}"
4051        );
4052        assert!(out.contains("class Logger(ABC):"), "got: {out}");
4053        assert!(out.contains("@abstractmethod"), "got: {out}");
4054        assert!(
4055            out.contains("def log(self, level: str, msg: str) -> None:"),
4056            "got: {out}"
4057        );
4058        assert!(out.contains("def flush(self) -> None:"), "got: {out}");
4059        assert!(out.contains("        ..."), "got: {out}");
4060    }
4061
4062    #[test]
4063    fn effect_decl_empty_operations() {
4064        let effect = node(
4065            1,
4066            NodeKind::EffectDecl {
4067                annotations: vec![],
4068                visibility: Visibility::Public,
4069                name: ident("Empty"),
4070                generic_params: vec![],
4071                components: vec![],
4072                operations: vec![],
4073            },
4074        );
4075        let out = gen(&module(vec![], vec![effect]));
4076        assert!(out.contains("class Empty(ABC):"), "got: {out}");
4077        assert!(out.contains("    pass"), "got: {out}");
4078    }
4079
4080    #[test]
4081    fn handling_block_passes_handlers_to_effectful_call() {
4082        use bock_air::AirHandlerPair;
4083
4084        let effect_decl = node(
4085            1,
4086            NodeKind::EffectDecl {
4087                annotations: vec![],
4088                visibility: Visibility::Public,
4089                name: ident("Logger"),
4090                generic_params: vec![],
4091                components: vec![],
4092                operations: vec![node(
4093                    2,
4094                    NodeKind::FnDecl {
4095                        annotations: vec![],
4096                        visibility: Visibility::Public,
4097                        is_async: false,
4098                        name: ident("log"),
4099                        generic_params: vec![],
4100                        params: vec![typed_param_node(3, "msg", "String")],
4101                        return_type: None,
4102                        effect_clause: vec![],
4103                        where_clause: vec![],
4104                        body: Box::new(block(4, vec![], None)),
4105                    },
4106                )],
4107            },
4108        );
4109
4110        let inner_fn = node(
4111            10,
4112            NodeKind::FnDecl {
4113                annotations: vec![],
4114                visibility: Visibility::Private,
4115                is_async: false,
4116                name: ident("inner"),
4117                generic_params: vec![],
4118                params: vec![],
4119                return_type: None,
4120                effect_clause: vec![type_path(&["Logger"])],
4121                where_clause: vec![],
4122                body: Box::new(block(12, vec![], Some(str_lit(13, "hello")))),
4123            },
4124        );
4125
4126        let call_inner = node(
4127            20,
4128            NodeKind::Call {
4129                callee: Box::new(id_node(21, "inner")),
4130                args: vec![],
4131                type_args: vec![],
4132            },
4133        );
4134        let handling = node(
4135            30,
4136            NodeKind::HandlingBlock {
4137                handlers: vec![AirHandlerPair {
4138                    effect: type_path(&["Logger"]),
4139                    handler: Box::new(node(
4140                        31,
4141                        NodeKind::Call {
4142                            callee: Box::new(id_node(32, "StdoutLogger")),
4143                            args: vec![],
4144                            type_args: vec![],
4145                        },
4146                    )),
4147                }],
4148                body: Box::new(block(33, vec![], Some(call_inner))),
4149            },
4150        );
4151        let main_fn = node(
4152            40,
4153            NodeKind::FnDecl {
4154                annotations: vec![],
4155                visibility: Visibility::Private,
4156                is_async: false,
4157                name: ident("main"),
4158                generic_params: vec![],
4159                params: vec![],
4160                return_type: None,
4161                effect_clause: vec![],
4162                where_clause: vec![],
4163                body: Box::new(block(41, vec![handling], None)),
4164            },
4165        );
4166
4167        let out = gen(&module(vec![], vec![effect_decl, inner_fn, main_fn]));
4168        // Python: inner(logger=__logger_h<N>) — handling blocks get a fresh
4169        // numeric suffix so nested blocks don't shadow each other.
4170        assert!(
4171            out.contains("inner(logger=__logger_h"),
4172            "handling block should pass handler to effectful call, got: {out}"
4173        );
4174        // Handler constructors are PascalCase in Python — they name a class.
4175        assert!(
4176            out.contains(": Logger = StdoutLogger()"),
4177            "handling block should instantiate handler, got: {out}"
4178        );
4179    }
4180
4181    // ── Async / concurrent patterns ────────────────────────────────────────
4182
4183    #[test]
4184    fn async_function_imports_asyncio() {
4185        let body = block(3, vec![], None);
4186        let f = node(
4187            1,
4188            NodeKind::FnDecl {
4189                annotations: vec![],
4190                visibility: Visibility::Private,
4191                is_async: true,
4192                name: ident("tick"),
4193                generic_params: vec![],
4194                params: vec![],
4195                return_type: None,
4196                effect_clause: vec![],
4197                where_clause: vec![],
4198                body: Box::new(body),
4199            },
4200        );
4201        let out = gen(&module(vec![], vec![f]));
4202        assert!(out.contains("import asyncio"), "got: {out}");
4203        assert!(out.contains("async def tick():"), "got: {out}");
4204    }
4205
4206    #[test]
4207    fn sync_module_has_no_asyncio_import() {
4208        let body = block(3, vec![], Some(int_lit(4, "1")));
4209        let f = node(
4210            1,
4211            NodeKind::FnDecl {
4212                annotations: vec![],
4213                visibility: Visibility::Private,
4214                is_async: false,
4215                name: ident("one"),
4216                generic_params: vec![],
4217                params: vec![],
4218                return_type: None,
4219                effect_clause: vec![],
4220                where_clause: vec![],
4221                body: Box::new(body),
4222            },
4223        );
4224        let out = gen(&module(vec![], vec![f]));
4225        assert!(!out.contains("import asyncio"), "got: {out}");
4226    }
4227
4228    #[test]
4229    fn entry_invocation_async_main_python() {
4230        let inv = PyGenerator::new().entry_invocation(true).unwrap();
4231        assert!(inv.contains("asyncio.run(main())"), "got: {inv}");
4232    }
4233
4234    #[test]
4235    fn entry_invocation_sync_main_python() {
4236        let inv = PyGenerator::new().entry_invocation(false).unwrap();
4237        assert_eq!(inv, "if __name__ == \"__main__\":\n    main()\n");
4238    }
4239
4240    #[test]
4241    fn generate_project_async_main_uses_asyncio_run() {
4242        let main_fn = node(
4243            1,
4244            NodeKind::FnDecl {
4245                annotations: vec![],
4246                visibility: Visibility::Private,
4247                is_async: true,
4248                name: ident("main"),
4249                generic_params: vec![],
4250                params: vec![],
4251                return_type: None,
4252                effect_clause: vec![],
4253                where_clause: vec![],
4254                body: Box::new(block(2, vec![], None)),
4255            },
4256        );
4257        let m = module(vec![], vec![main_fn]);
4258        let gen = PyGenerator::new();
4259        let out = gen.generate_project(&[&m]).unwrap();
4260        let src = &out.files[0].content;
4261        assert!(src.contains("import asyncio"), "got: {src}");
4262        assert!(src.contains("async def main():"), "got: {src}");
4263        assert!(src.contains("asyncio.run(main())"), "got: {src}");
4264    }
4265
4266    #[test]
4267    fn concurrent_pattern_wraps_in_create_task() {
4268        // Block:
4269        //   let a = task1()
4270        //   let b = task2()
4271        //   let ra = await a
4272        //   let rb = await b
4273        //   return ra
4274        let call_task = |id: u32, name: &str| {
4275            node(
4276                id,
4277                NodeKind::Call {
4278                    callee: Box::new(id_node(id + 1, name)),
4279                    args: vec![],
4280                    type_args: vec![],
4281                },
4282            )
4283        };
4284        let let_stmt = |id: u32, name: &str, val: AIRNode| {
4285            node(
4286                id,
4287                NodeKind::LetBinding {
4288                    is_mut: false,
4289                    pattern: Box::new(bind_pat(id + 1, name)),
4290                    ty: None,
4291                    value: Box::new(val),
4292                },
4293            )
4294        };
4295        let await_id = |id: u32, name: &str| {
4296            node(
4297                id,
4298                NodeKind::Await {
4299                    expr: Box::new(id_node(id + 1, name)),
4300                },
4301            )
4302        };
4303
4304        let body = block(
4305            10,
4306            vec![
4307                let_stmt(20, "a", call_task(21, "task1")),
4308                let_stmt(30, "b", call_task(31, "task2")),
4309                let_stmt(40, "ra", await_id(41, "a")),
4310                let_stmt(50, "rb", await_id(51, "b")),
4311            ],
4312            Some(id_node(60, "ra")),
4313        );
4314        let f = node(
4315            1,
4316            NodeKind::FnDecl {
4317                annotations: vec![],
4318                visibility: Visibility::Private,
4319                is_async: true,
4320                name: ident("run"),
4321                generic_params: vec![],
4322                params: vec![],
4323                return_type: None,
4324                effect_clause: vec![],
4325                where_clause: vec![],
4326                body: Box::new(body),
4327            },
4328        );
4329        let out = gen(&module(vec![], vec![f]));
4330        assert!(
4331            out.contains("a = asyncio.create_task(task1())"),
4332            "task1 should be scheduled as a task, got: {out}"
4333        );
4334        assert!(
4335            out.contains("b = asyncio.create_task(task2())"),
4336            "task2 should be scheduled as a task, got: {out}"
4337        );
4338        assert!(out.contains("ra = (await a)"), "got: {out}");
4339        assert!(out.contains("rb = (await b)"), "got: {out}");
4340    }
4341
4342    #[test]
4343    fn sequential_await_no_task_wrapping() {
4344        // `let a = await task1()` directly awaits — no task wrap needed.
4345        let await_call = node(
4346            20,
4347            NodeKind::Await {
4348                expr: Box::new(node(
4349                    21,
4350                    NodeKind::Call {
4351                        callee: Box::new(id_node(22, "task1")),
4352                        args: vec![],
4353                        type_args: vec![],
4354                    },
4355                )),
4356            },
4357        );
4358        let let_stmt = node(
4359            10,
4360            NodeKind::LetBinding {
4361                is_mut: false,
4362                pattern: Box::new(bind_pat(11, "a")),
4363                ty: None,
4364                value: Box::new(await_call),
4365            },
4366        );
4367        let body = block(30, vec![let_stmt], Some(id_node(40, "a")));
4368        let f = node(
4369            1,
4370            NodeKind::FnDecl {
4371                annotations: vec![],
4372                visibility: Visibility::Private,
4373                is_async: true,
4374                name: ident("run"),
4375                generic_params: vec![],
4376                params: vec![],
4377                return_type: None,
4378                effect_clause: vec![],
4379                where_clause: vec![],
4380                body: Box::new(body),
4381            },
4382        );
4383        let out = gen(&module(vec![], vec![f]));
4384        assert!(
4385            !out.contains("create_task"),
4386            "sequential await should not wrap in create_task, got: {out}"
4387        );
4388        assert!(out.contains("a = (await task1())"), "got: {out}");
4389    }
4390
4391    #[test]
4392    fn non_call_rhs_not_wrapped_in_task() {
4393        // `let a = 42 ; ... await a` — RHS is not a Call, so we can't wrap.
4394        let let_stmt = node(
4395            10,
4396            NodeKind::LetBinding {
4397                is_mut: false,
4398                pattern: Box::new(bind_pat(11, "a")),
4399                ty: None,
4400                value: Box::new(int_lit(12, "42")),
4401            },
4402        );
4403        let await_a = node(
4404            20,
4405            NodeKind::Await {
4406                expr: Box::new(id_node(21, "a")),
4407            },
4408        );
4409        let body = block(30, vec![let_stmt], Some(await_a));
4410        let f = node(
4411            1,
4412            NodeKind::FnDecl {
4413                annotations: vec![],
4414                visibility: Visibility::Private,
4415                is_async: true,
4416                name: ident("run"),
4417                generic_params: vec![],
4418                params: vec![],
4419                return_type: None,
4420                effect_clause: vec![],
4421                where_clause: vec![],
4422                body: Box::new(body),
4423            },
4424        );
4425        let out = gen(&module(vec![], vec![f]));
4426        assert!(!out.contains("create_task"), "got: {out}");
4427        assert!(out.contains("a = 42"), "got: {out}");
4428    }
4429}