Skip to main content

bock_codegen/
ts.rs

1//! TypeScript code generator — rule-based (Tier 2) transpilation from AIR to TS.
2//!
3//! Extends the JavaScript codegen with:
4//! - Type annotations on parameters, return types, and bindings
5//! - Generics → TS generics (preserved, not erased)
6//! - Traits → TS interfaces
7//! - Algebraic types → discriminated union types + tagged objects
8//! - Type aliases → `type X = ...`
9
10use std::collections::{HashMap, HashSet};
11use std::fmt::Write;
12use std::path::PathBuf;
13
14use bock_air::{AIRNode, AirInterpolationPart, EnumVariantPayload, NodeKind, ResultVariant};
15use bock_ast::{AssignOp, BinOp, ImportItems, Literal, TypeExpr, UnaryOp, Visibility};
16use bock_errors::Span;
17use bock_types::AIRModule;
18
19use crate::error::CodegenError;
20use crate::generator::{CodeGenerator, GeneratedCode, OutputFile, SourceMap, SourceMapping};
21use crate::profile::TargetProfile;
22
23/// Runtime helpers injected when `Channel` / `spawn` appear in a module.
24/// See the analogous `CONCURRENCY_RUNTIME_JS` in `js.rs`.
25/// Conservative module scan — if the serialized AIR mentions `Channel`
26/// or `spawn`, emit the runtime prelude. Unused helpers are trivially
27/// dead-code eliminated by downstream TS tooling.
28fn module_uses_concurrency(items: &[AIRNode]) -> bool {
29    items.iter().any(|n| {
30        let s = format!("{n:?}");
31        s.contains("\"Channel\"") || s.contains("\"spawn\"")
32    })
33}
34
35const CONCURRENCY_RUNTIME_TS: &str = "\
36// ── Bock concurrency runtime ──
37type __BockChannel<T> = {
38  send(v: T): void;
39  recv(): Promise<T>;
40  close(): void;
41};
42const __bockChannelNew = <T>(): [__BockChannel<T>, __BockChannel<T>] => {
43  const queue: T[] = [];
44  const waiters: Array<(v: T) => void> = [];
45  const ch: __BockChannel<T> = {
46    send(v: T) {
47      if (waiters.length > 0) { waiters.shift()!(v); } else { queue.push(v); }
48    },
49    recv(): Promise<T> {
50      return new Promise<T>((resolve) => {
51        if (queue.length > 0) { resolve(queue.shift()!); }
52        else { waiters.push(resolve); }
53      });
54    },
55    close() {}
56  };
57  return [ch, ch];
58};
59const __bockSpawn = <T>(x: Promise<T>): Promise<T> => x;
60";
61
62/// TypeScript code generator implementing the `CodeGenerator` trait.
63#[derive(Debug)]
64pub struct TsGenerator {
65    profile: TargetProfile,
66}
67
68impl TsGenerator {
69    /// Creates a new TypeScript code generator.
70    #[must_use]
71    pub fn new() -> Self {
72        Self {
73            profile: TargetProfile::typescript(),
74        }
75    }
76}
77
78impl Default for TsGenerator {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl CodeGenerator for TsGenerator {
85    fn target(&self) -> &TargetProfile {
86        &self.profile
87    }
88
89    fn generate_module(&self, module: &AIRModule) -> Result<GeneratedCode, CodegenError> {
90        let mut ctx = TsEmitCtx::new();
91        ctx.emit_node(module)?;
92        let (content, mappings) = ctx.finish();
93        let source_map = SourceMap {
94            generated_file: "output.ts".to_string(),
95            mappings,
96            ..Default::default()
97        };
98        Ok(GeneratedCode {
99            files: vec![OutputFile {
100                path: PathBuf::from("output.ts"),
101                content,
102            }],
103            source_map: Some(source_map),
104        })
105    }
106
107    fn entry_invocation(&self, main_is_async: bool) -> Option<String> {
108        if main_is_async {
109            Some("(async () => { await main(); })();\n".to_string())
110        } else {
111            Some("main();\n".to_string())
112        }
113    }
114}
115
116// ─── Emission context ────────────────────────────────────────────────────────
117
118/// Internal state for TypeScript emission.
119struct TsEmitCtx {
120    buf: String,
121    indent: usize,
122    /// Maps effect operation name → effect type name (e.g., "log" → "Logger").
123    effect_ops: HashMap<String, String>,
124    /// Maps effect type name → current handler variable name in scope.
125    current_handler_vars: HashMap<String, String>,
126    /// Maps function name → effect type names from its `with` clause.
127    fn_effects: HashMap<String, Vec<String>>,
128    /// Maps composite effect name → component effect names.
129    composite_effects: HashMap<String, Vec<String>>,
130    /// Names of records declared in this module (emitted as classes).
131    record_names: HashSet<String>,
132    /// Names of effects declared in this module (for typing handler vars).
133    effect_names: HashSet<String>,
134    /// 1-indexed current line in `buf`, maintained incrementally.
135    cur_line: u32,
136    /// 1-indexed current column (char count) in `buf`, maintained incrementally.
137    cur_col: u32,
138    /// Byte offset in `buf` up to which (cur_line, cur_col) is accurate.
139    scan_pos: usize,
140    /// Last (gen_line, gen_col) we recorded — avoids duplicate mappings.
141    last_marked: Option<(u32, u32)>,
142    /// Collected source-map entries (populated via [`Self::mark_span`]).
143    mappings: Vec<SourceMapping>,
144}
145
146impl TsEmitCtx {
147    fn new() -> Self {
148        Self {
149            buf: String::with_capacity(4096),
150            indent: 0,
151            effect_ops: HashMap::new(),
152            current_handler_vars: HashMap::new(),
153            fn_effects: HashMap::new(),
154            composite_effects: HashMap::new(),
155            record_names: HashSet::new(),
156            effect_names: HashSet::new(),
157            cur_line: 1,
158            cur_col: 1,
159            scan_pos: 0,
160            last_marked: None,
161            mappings: Vec::new(),
162        }
163    }
164
165    fn finish(self) -> (String, Vec<SourceMapping>) {
166        (self.buf, self.mappings)
167    }
168
169    /// Bring `cur_line` / `cur_col` up to date with everything appended to
170    /// `buf` since the last sync.
171    fn sync_pos(&mut self) {
172        if self.scan_pos >= self.buf.len() {
173            return;
174        }
175        let slice = &self.buf[self.scan_pos..];
176        for ch in slice.chars() {
177            if ch == '\n' {
178                self.cur_line += 1;
179                self.cur_col = 1;
180            } else {
181                self.cur_col += 1;
182            }
183        }
184        self.scan_pos = self.buf.len();
185    }
186
187    fn mark_span(&mut self, span: Span) {
188        if span.start == 0 && span.end == 0 {
189            return;
190        }
191        self.sync_pos();
192        let key = (self.cur_line, self.cur_col);
193        if self.last_marked == Some(key) {
194            return;
195        }
196        self.last_marked = Some(key);
197        self.mappings.push(SourceMapping {
198            gen_line: self.cur_line,
199            gen_col: self.cur_col,
200            src_line: 0,
201            src_col: 0,
202            src_offset: span.start as u32,
203            src_file_id: span.file.0,
204        });
205    }
206
207    fn indent_str(&self) -> String {
208        "  ".repeat(self.indent)
209    }
210
211    fn write_indent(&mut self) {
212        let indent = self.indent_str();
213        self.buf.push_str(&indent);
214    }
215
216    fn writeln(&mut self, s: &str) {
217        self.write_indent();
218        self.buf.push_str(s);
219        self.buf.push('\n');
220    }
221
222    // ── Prelude function mapping ──────────────────────────────────────────
223
224    /// Emit an expression into a temporary buffer and return the string.
225    fn expr_to_string(&mut self, node: &AIRNode) -> Result<String, CodegenError> {
226        let start = self.buf.len();
227        let saved_line = self.cur_line;
228        let saved_col = self.cur_col;
229        let saved_scan = self.scan_pos;
230        let saved_marked = self.last_marked;
231        let mappings_len = self.mappings.len();
232        self.emit_expr(node)?;
233        let s = self.buf[start..].to_string();
234        self.buf.truncate(start);
235        self.cur_line = saved_line;
236        self.cur_col = saved_col;
237        self.scan_pos = saved_scan;
238        self.last_marked = saved_marked;
239        self.mappings.truncate(mappings_len);
240        Ok(s)
241    }
242
243    /// Map Bock prelude functions to TypeScript equivalents.
244    fn map_prelude_call(
245        &mut self,
246        callee: &AIRNode,
247        args: &[bock_air::AirArg],
248    ) -> Result<Option<String>, CodegenError> {
249        let name = match &callee.kind {
250            NodeKind::Identifier { name } => name.name.as_str(),
251            _ => return Ok(None),
252        };
253        let arg_strs: Vec<String> = args
254            .iter()
255            .map(|a| self.expr_to_string(&a.value))
256            .collect::<Result<_, _>>()?;
257        let code = match name {
258            "println" => {
259                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
260                format!("console.log({a})")
261            }
262            "print" => {
263                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
264                format!("process.stdout.write(String({a}))")
265            }
266            "debug" => {
267                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
268                format!("console.debug({a})")
269            }
270            "assert" => {
271                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
272                format!("if (!{a}) throw new Error(\"assertion failed\")")
273            }
274            "todo" => "throw new Error(\"not implemented\")".to_string(),
275            "unreachable" => "throw new Error(\"unreachable\")".to_string(),
276            "sleep" => {
277                let a = arg_strs.first().map_or(String::new(), |s| s.clone());
278                format!("new Promise<void>((__r) => setTimeout(__r, Math.floor(({a}) / 1e6)))")
279            }
280            _ => return Ok(None),
281        };
282        Ok(Some(code))
283    }
284
285    /// Recognise `Duration.xxx(...)` / `Instant.xxx(...)` associated-function
286    /// calls and emit inline arithmetic. Durations are plain numbers
287    /// (nanoseconds); Instants are numbers representing ns since
288    /// `performance.timeOrigin`. Returns `Ok(true)` if the call was emitted.
289    fn try_emit_time_assoc_call(
290        &mut self,
291        callee: &AIRNode,
292        args: &[bock_air::AirArg],
293    ) -> Result<bool, CodegenError> {
294        let NodeKind::FieldAccess { object, field } = &callee.kind else {
295            return Ok(false);
296        };
297        let NodeKind::Identifier { name: type_name } = &object.kind else {
298            return Ok(false);
299        };
300        let arg_strs: Vec<String> = args
301            .iter()
302            .map(|a| self.expr_to_string(&a.value))
303            .collect::<Result<_, _>>()?;
304        let arg0 = || arg_strs.first().cloned().unwrap_or_default();
305        let code = match (type_name.name.as_str(), field.name.as_str()) {
306            ("Duration", "zero") => "0".to_string(),
307            ("Duration", "nanos") => arg0(),
308            ("Duration", "micros") => format!("(({}) * 1000)", arg0()),
309            ("Duration", "millis") => format!("(({}) * 1000000)", arg0()),
310            ("Duration", "seconds") => format!("(({}) * 1000000000)", arg0()),
311            ("Duration", "minutes") => format!("(({}) * 60000000000)", arg0()),
312            ("Duration", "hours") => format!("(({}) * 3600000000000)", arg0()),
313            ("Instant", "now") => "(performance.now() * 1000000)".to_string(),
314            _ => return Ok(false),
315        };
316        self.buf.push_str(&code);
317        Ok(true)
318    }
319
320    /// Recognise `Channel.new()`, `spawn(...)`, and method calls on a
321    /// channel value (`send`, `recv`, `close`) and emit the TS runtime
322    /// helper equivalents.
323    fn try_emit_concurrency_call(
324        &mut self,
325        callee: &AIRNode,
326        args: &[bock_air::AirArg],
327    ) -> Result<bool, CodegenError> {
328        if let NodeKind::Identifier { name } = &callee.kind {
329            if name.name == "spawn" {
330                self.buf.push_str("__bockSpawn(");
331                for (i, arg) in args.iter().enumerate() {
332                    if i > 0 {
333                        self.buf.push_str(", ");
334                    }
335                    self.emit_expr(&arg.value)?;
336                }
337                self.buf.push(')');
338                return Ok(true);
339            }
340        }
341        let NodeKind::FieldAccess { object, field } = &callee.kind else {
342            return Ok(false);
343        };
344        if let NodeKind::Identifier { name: type_name } = &object.kind {
345            if type_name.name == "Channel" && field.name == "new" {
346                self.buf.push_str("__bockChannelNew()");
347                return Ok(true);
348            }
349        }
350        if matches!(field.name.as_str(), "send" | "recv" | "close") {
351            // First arg is the receiver duplicate (from desugaring) — skip.
352            self.emit_expr(object)?;
353            let _ = write!(self.buf, ".{}", field.name);
354            self.buf.push('(');
355            for (i, arg) in args.iter().skip(1).enumerate() {
356                if i > 0 {
357                    self.buf.push_str(", ");
358                }
359                self.emit_expr(&arg.value)?;
360            }
361            self.buf.push(')');
362            return Ok(true);
363        }
364        Ok(false)
365    }
366
367    /// Recognise desugared method calls `Call(FieldAccess(recv, m), [recv, ...args])`
368    /// on Duration/Instant values and emit inline arithmetic.
369    fn try_emit_time_desugared_method(
370        &mut self,
371        callee: &AIRNode,
372        args: &[bock_air::AirArg],
373    ) -> Result<bool, CodegenError> {
374        let NodeKind::FieldAccess { object, field } = &callee.kind else {
375            return Ok(false);
376        };
377        if let NodeKind::Identifier { name } = &object.kind {
378            if matches!(name.name.as_str(), "Duration" | "Instant") {
379                return Ok(false);
380            }
381        }
382        if !is_time_method_name(&field.name) {
383            return Ok(false);
384        }
385        let remaining: Vec<bock_air::AirArg> = args.iter().skip(1).cloned().collect();
386        self.try_emit_time_method(object, &field.name, &remaining)
387    }
388
389    /// Recognise instance methods on Duration/Instant values and emit inline
390    /// arithmetic.
391    fn try_emit_time_method(
392        &mut self,
393        receiver: &AIRNode,
394        method: &str,
395        args: &[bock_air::AirArg],
396    ) -> Result<bool, CodegenError> {
397        let recv_str = self.expr_to_string(receiver)?;
398        let arg_strs: Vec<String> = args
399            .iter()
400            .map(|a| self.expr_to_string(&a.value))
401            .collect::<Result<_, _>>()?;
402        let code = match method {
403            "as_nanos" => format!("({recv_str})"),
404            "as_millis" => format!("Math.floor(({recv_str}) / 1000000)"),
405            "as_seconds" => format!("Math.floor(({recv_str}) / 1000000000)"),
406            "is_zero" => format!("(({recv_str}) === 0)"),
407            "is_negative" => format!("(({recv_str}) < 0)"),
408            "abs" => format!("Math.abs({recv_str})"),
409            "elapsed" => format!("((performance.now() * 1000000) - ({recv_str}))"),
410            "duration_since" => {
411                let other = arg_strs.first().cloned().unwrap_or_default();
412                format!("(({recv_str}) - ({other}))")
413            }
414            _ => return Ok(false),
415        };
416        self.buf.push_str(&code);
417        Ok(true)
418    }
419
420    /// Emit Some/Ok/Err calls as tagged-object constructions, matching the
421    /// representation used for user-defined enum variants. Returns true if
422    /// the call was handled.
423    fn try_emit_prelude_ctor(
424        &mut self,
425        callee: &AIRNode,
426        args: &[bock_air::AirArg],
427    ) -> Result<bool, CodegenError> {
428        let name = match &callee.kind {
429            NodeKind::Identifier { name } => name.name.as_str(),
430            _ => return Ok(false),
431        };
432        if !matches!(name, "Some" | "Ok" | "Err") {
433            return Ok(false);
434        }
435        let _ = write!(self.buf, "{{ _tag: \"{name}\" as const");
436        if let Some(arg) = args.first() {
437            self.buf.push_str(", _0: ");
438            self.emit_expr(&arg.value)?;
439        }
440        self.buf.push_str(" }");
441        Ok(true)
442    }
443
444    // ── Type emission ────────────────────────────────────────────────────────
445
446    /// Emit a type expression from an AIR type node to a TS type string.
447    fn type_to_ts(&self, node: &AIRNode) -> String {
448        match &node.kind {
449            NodeKind::TypeNamed { path, args } => {
450                let name = path
451                    .segments
452                    .iter()
453                    .map(|s| s.name.as_str())
454                    .collect::<Vec<_>>()
455                    .join(".");
456                let ts_name = self.map_type_name(&name);
457                if args.is_empty() {
458                    ts_name
459                } else {
460                    let arg_strs: Vec<String> = args.iter().map(|a| self.type_to_ts(a)).collect();
461                    format!("{ts_name}<{}>", arg_strs.join(", "))
462                }
463            }
464            NodeKind::TypeTuple { elems } => {
465                let elem_strs: Vec<String> = elems.iter().map(|e| self.type_to_ts(e)).collect();
466                format!("[{}]", elem_strs.join(", "))
467            }
468            NodeKind::TypeFunction { params, ret, .. } => {
469                let param_strs: Vec<String> = params
470                    .iter()
471                    .enumerate()
472                    .map(|(i, p)| format!("arg{i}: {}", self.type_to_ts(p)))
473                    .collect();
474                format!("({}) => {}", param_strs.join(", "), self.type_to_ts(ret))
475            }
476            NodeKind::TypeOptional { inner } => {
477                format!("{} | null", self.type_to_ts(inner))
478            }
479            NodeKind::TypeSelf => "this".into(),
480            _ => "unknown".into(),
481        }
482    }
483
484    /// Map Bock type names to TS equivalents.
485    fn map_type_name(&self, name: &str) -> String {
486        match name {
487            "Int" => "number".into(),
488            "Float" => "number".into(),
489            "Bool" => "boolean".into(),
490            "String" => "string".into(),
491            "Void" | "Unit" => "void".into(),
492            "List" => "Array".into(),
493            "Map" => "Map".into(),
494            "Set" => "Set".into(),
495            "Any" => "any".into(),
496            "Never" => "never".into(),
497            other => other.into(),
498        }
499    }
500
501    /// Emit an AST TypeExpr to a TS type string (for record fields).
502    fn ast_type_to_ts(&self, ty: &TypeExpr) -> String {
503        match ty {
504            TypeExpr::Named { path, args, .. } => {
505                let name = path
506                    .segments
507                    .iter()
508                    .map(|s| s.name.as_str())
509                    .collect::<Vec<_>>()
510                    .join(".");
511                let ts_name = self.map_type_name(&name);
512                if args.is_empty() {
513                    ts_name
514                } else {
515                    let arg_strs: Vec<String> =
516                        args.iter().map(|a| self.ast_type_to_ts(a)).collect();
517                    format!("{ts_name}<{}>", arg_strs.join(", "))
518                }
519            }
520            TypeExpr::Tuple { elems, .. } => {
521                let elem_strs: Vec<String> = elems.iter().map(|e| self.ast_type_to_ts(e)).collect();
522                format!("[{}]", elem_strs.join(", "))
523            }
524            TypeExpr::Function { params, ret, .. } => {
525                let param_strs: Vec<String> = params
526                    .iter()
527                    .enumerate()
528                    .map(|(i, p)| format!("arg{i}: {}", self.ast_type_to_ts(p)))
529                    .collect();
530                format!(
531                    "({}) => {}",
532                    param_strs.join(", "),
533                    self.ast_type_to_ts(ret)
534                )
535            }
536            TypeExpr::Optional { inner, .. } => {
537                format!("{} | null", self.ast_type_to_ts(inner))
538            }
539            TypeExpr::SelfType { .. } => "this".into(),
540        }
541    }
542
543    /// Emit generic parameter list: `<T, U extends Foo>`.
544    fn generic_params_to_ts(&self, params: &[bock_ast::GenericParam]) -> String {
545        if params.is_empty() {
546            return String::new();
547        }
548        let items: Vec<String> = params
549            .iter()
550            .map(|p| {
551                if p.bounds.is_empty() {
552                    p.name.name.clone()
553                } else {
554                    let bounds: Vec<String> = p
555                        .bounds
556                        .iter()
557                        .map(|b| {
558                            b.segments
559                                .iter()
560                                .map(|s| s.name.as_str())
561                                .collect::<Vec<_>>()
562                                .join(".")
563                        })
564                        .collect();
565                    format!("{} extends {}", p.name.name, bounds.join(" & "))
566                }
567            })
568            .collect();
569        format!("<{}>", items.join(", "))
570    }
571
572    // ── Top-level dispatch ──────────────────────────────────────────────────
573
574    fn emit_node(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
575        self.mark_span(node.span);
576        match &node.kind {
577            NodeKind::Module { imports, items, .. } => {
578                if module_uses_concurrency(items) {
579                    self.buf.push_str(CONCURRENCY_RUNTIME_TS);
580                    self.buf.push('\n');
581                }
582                for imp in imports {
583                    self.emit_node(imp)?;
584                }
585                if !imports.is_empty() && !items.is_empty() {
586                    self.buf.push('\n');
587                }
588                for (i, item) in items.iter().enumerate() {
589                    if i > 0 {
590                        self.buf.push('\n');
591                    }
592                    self.emit_node(item)?;
593                }
594                Ok(())
595            }
596            NodeKind::ImportDecl { path, items } => {
597                let path_str = path
598                    .segments
599                    .iter()
600                    .map(|s| s.name.as_str())
601                    .collect::<Vec<_>>()
602                    .join(".");
603                match items {
604                    ImportItems::Module => {
605                        self.writeln(&format!("// import {path_str}"));
606                    }
607                    ImportItems::Named(names) => {
608                        let names_str = names
609                            .iter()
610                            .map(|n| n.name.name.as_str())
611                            .collect::<Vec<_>>()
612                            .join(", ");
613                        self.writeln(&format!("// import {{ {names_str} }} from {path_str}"));
614                    }
615                    ImportItems::Glob => {
616                        self.writeln(&format!("// import * from {path_str}"));
617                    }
618                }
619                Ok(())
620            }
621            NodeKind::FnDecl {
622                visibility,
623                is_async,
624                name,
625                generic_params,
626                params,
627                return_type,
628                effect_clause,
629                body,
630                ..
631            } => self.emit_fn_decl(
632                *visibility,
633                *is_async,
634                &name.name,
635                generic_params,
636                params,
637                return_type.as_deref(),
638                effect_clause,
639                body,
640                false,
641            ),
642            NodeKind::RecordDecl {
643                visibility,
644                name,
645                generic_params,
646                fields,
647                ..
648            } => {
649                let export = if matches!(visibility, Visibility::Public) {
650                    "export "
651                } else {
652                    ""
653                };
654                let generics = self.generic_params_to_ts(generic_params);
655                self.record_names.insert(name.name.clone());
656                if fields.is_empty() {
657                    self.writeln(&format!("{export}class {}{generics} {{}}", name.name));
658                } else {
659                    self.writeln(&format!("{export}class {}{generics} {{", name.name));
660                    self.indent += 1;
661                    for f in fields {
662                        let ty = self.ast_type_to_ts(&f.ty);
663                        self.writeln(&format!("{}: {};", f.name.name, ty));
664                    }
665                    let init_fields: Vec<String> = fields
666                        .iter()
667                        .map(|f| format!("{}: {}", f.name.name, self.ast_type_to_ts(&f.ty)))
668                        .collect();
669                    let destructure: Vec<&str> =
670                        fields.iter().map(|f| f.name.name.as_str()).collect();
671                    self.writeln(&format!(
672                        "constructor({{ {} }}: {{ {} }}) {{",
673                        destructure.join(", "),
674                        init_fields.join("; "),
675                    ));
676                    self.indent += 1;
677                    for fname in &destructure {
678                        self.writeln(&format!("this.{fname} = {fname};"));
679                    }
680                    self.indent -= 1;
681                    self.writeln("}");
682                    self.indent -= 1;
683                    self.writeln("}");
684                }
685                Ok(())
686            }
687            NodeKind::EnumDecl {
688                visibility,
689                name,
690                generic_params,
691                variants,
692                ..
693            } => {
694                let export = if matches!(visibility, Visibility::Public) {
695                    "export "
696                } else {
697                    ""
698                };
699                let generics = self.generic_params_to_ts(generic_params);
700
701                // Emit discriminated union type
702                let variant_names: Vec<String> = variants
703                    .iter()
704                    .filter_map(|v| {
705                        if let NodeKind::EnumVariant { name: vn, .. } = &v.kind {
706                            Some(format!("{}_{}", name.name, vn.name))
707                        } else {
708                            None
709                        }
710                    })
711                    .collect();
712                if !variant_names.is_empty() {
713                    self.writeln(&format!(
714                        "{export}type {}{generics} = {};",
715                        name.name,
716                        variant_names.join(" | "),
717                    ));
718                    self.buf.push('\n');
719                }
720
721                // Emit interface + factory for each variant
722                for variant in variants {
723                    self.emit_enum_variant(&name.name, generic_params, variant)?;
724                }
725                Ok(())
726            }
727            NodeKind::ClassDecl {
728                visibility,
729                name,
730                generic_params,
731                fields,
732                methods,
733                ..
734            } => {
735                let export = if matches!(visibility, Visibility::Public) {
736                    "export "
737                } else {
738                    ""
739                };
740                let generics = self.generic_params_to_ts(generic_params);
741                self.writeln(&format!("{export}class {}{generics} {{", name.name));
742                self.indent += 1;
743                // Fields
744                for f in fields {
745                    let ty = self.ast_type_to_ts(&f.ty);
746                    self.writeln(&format!("{}: {};", f.name.name, ty));
747                }
748                if !fields.is_empty() {
749                    self.buf.push('\n');
750                }
751                // Constructor
752                let ctor_params: Vec<String> = fields
753                    .iter()
754                    .map(|f| format!("{}: {}", f.name.name, self.ast_type_to_ts(&f.ty)))
755                    .collect();
756                self.writeln(&format!("constructor({}) {{", ctor_params.join(", ")));
757                self.indent += 1;
758                for f in fields {
759                    self.writeln(&format!("this.{} = {};", f.name.name, f.name.name));
760                }
761                self.indent -= 1;
762                self.writeln("}");
763                // Methods
764                for method in methods {
765                    self.buf.push('\n');
766                    self.emit_class_method(method)?;
767                }
768                self.indent -= 1;
769                self.writeln("}");
770                Ok(())
771            }
772            NodeKind::TraitDecl {
773                visibility,
774                name,
775                generic_params,
776                methods,
777                ..
778            } => {
779                let export = if matches!(visibility, Visibility::Public) {
780                    "export "
781                } else {
782                    ""
783                };
784                let generics = self.generic_params_to_ts(generic_params);
785                self.writeln(&format!("{export}interface {}{generics} {{", name.name));
786                self.indent += 1;
787                for (i, method) in methods.iter().enumerate() {
788                    if i > 0 {
789                        self.buf.push('\n');
790                    }
791                    if let NodeKind::FnDecl {
792                        name,
793                        generic_params: method_generics,
794                        params,
795                        return_type,
796                        ..
797                    } = &method.kind
798                    {
799                        let m_generics = self.generic_params_to_ts(method_generics);
800                        let param_list = self.collect_typed_params(params);
801                        let ret = return_type
802                            .as_ref()
803                            .map(|r| self.type_to_ts(r))
804                            .unwrap_or_else(|| "void".into());
805                        self.writeln(&format!(
806                            "{}{m_generics}({}): {};",
807                            name.name,
808                            param_list.join(", "),
809                            ret,
810                        ));
811                    }
812                }
813                self.indent -= 1;
814                self.writeln("}");
815                Ok(())
816            }
817            NodeKind::ImplBlock {
818                trait_path,
819                target,
820                methods,
821                ..
822            } => {
823                let target_name = self.type_expr_to_string(target);
824                if let Some(tp) = trait_path {
825                    let trait_name = tp
826                        .segments
827                        .iter()
828                        .map(|s| s.name.as_str())
829                        .collect::<Vec<_>>()
830                        .join(".");
831                    // Declaration merging: make the class satisfy the trait/effect
832                    // so `.prototype.x = ...` below type-checks and `new Target()`
833                    // is assignable to the trait's interface type.
834                    self.writeln(&format!(
835                        "interface {target_name} extends {trait_name} {{}}"
836                    ));
837                    self.writeln(&format!("// impl {trait_name} for {target_name}"));
838                } else {
839                    self.writeln(&format!("// impl {target_name}"));
840                }
841                for method in methods {
842                    if let NodeKind::FnDecl {
843                        is_async,
844                        name,
845                        generic_params,
846                        params,
847                        return_type,
848                        effect_clause,
849                        body,
850                        ..
851                    } = &method.kind
852                    {
853                        let async_kw = if *is_async { "async " } else { "" };
854                        let generics = self.generic_params_to_ts(generic_params);
855                        let param_list = self.collect_typed_params(params);
856                        let effects_param = self.effects_param(effect_clause);
857                        let mut all_params = param_list;
858                        if let Some(ep) = effects_param {
859                            all_params.push(ep);
860                        }
861                        let ret_str = build_ts_return_type(
862                            *is_async,
863                            return_type.as_deref().map(|r| self.type_to_ts(r)),
864                        );
865                        self.writeln(&format!(
866                            "{target_name}.prototype.{} = {async_kw}function{generics}({}){ret_str} {{",
867                            name.name,
868                            all_params.join(", "),
869                        ));
870                        self.indent += 1;
871                        let old_handler_vars = self.current_handler_vars.clone();
872                        let expanded = self.expand_effect_names(effect_clause);
873                        for ename in &expanded {
874                            self.current_handler_vars
875                                .insert(ename.clone(), to_camel_case(ename));
876                        }
877                        self.emit_block_body(body)?;
878                        self.current_handler_vars = old_handler_vars;
879                        self.indent -= 1;
880                        self.writeln("};");
881                    }
882                }
883                Ok(())
884            }
885            NodeKind::EffectDecl {
886                visibility,
887                name,
888                generic_params,
889                components,
890                operations,
891                ..
892            } => {
893                if !components.is_empty() {
894                    let comp_names: Vec<String> = components
895                        .iter()
896                        .map(|tp| {
897                            tp.segments
898                                .last()
899                                .map_or("effect".to_string(), |s| s.name.clone())
900                        })
901                        .collect();
902                    self.writeln(&format!(
903                        "// composite effect {} = {}",
904                        name.name,
905                        comp_names.join(" + ")
906                    ));
907                    self.composite_effects
908                        .insert(name.name.clone(), comp_names);
909                    return Ok(());
910                }
911                // Record effect operations for Call → handler.op rewriting.
912                for op in operations {
913                    if let NodeKind::FnDecl { name: op_name, .. } = &op.kind {
914                        self.effect_ops
915                            .insert(op_name.name.clone(), name.name.clone());
916                    }
917                }
918                self.effect_names.insert(name.name.clone());
919                // Effects → TS interface
920                let export = if matches!(visibility, Visibility::Public) {
921                    "export "
922                } else {
923                    ""
924                };
925                let generics = self.generic_params_to_ts(generic_params);
926                self.writeln(&format!("{export}interface {}{generics} {{", name.name));
927                self.indent += 1;
928                for op in operations {
929                    if let NodeKind::FnDecl {
930                        name,
931                        params,
932                        return_type,
933                        ..
934                    } = &op.kind
935                    {
936                        let param_list = self.collect_typed_params(params);
937                        let ret = return_type
938                            .as_ref()
939                            .map(|r| self.type_to_ts(r))
940                            .unwrap_or_else(|| "void".into());
941                        self.writeln(&format!(
942                            "{}({}): {};",
943                            name.name,
944                            param_list.join(", "),
945                            ret,
946                        ));
947                    }
948                }
949                self.indent -= 1;
950                self.writeln("}");
951                Ok(())
952            }
953            NodeKind::TypeAlias {
954                visibility,
955                name,
956                generic_params,
957                ty,
958                ..
959            } => {
960                let export = if matches!(visibility, Visibility::Public) {
961                    "export "
962                } else {
963                    ""
964                };
965                let generics = self.generic_params_to_ts(generic_params);
966                let ty_str = self.type_to_ts(ty);
967                self.writeln(&format!("{export}type {}{generics} = {ty_str};", name.name));
968                Ok(())
969            }
970            NodeKind::ConstDecl {
971                visibility,
972                name,
973                ty,
974                value,
975                ..
976            } => {
977                let export = if matches!(visibility, Visibility::Public) {
978                    "export "
979                } else {
980                    ""
981                };
982                let ty_str = self.type_to_ts(ty);
983                let ind = self.indent_str();
984                let _ = write!(self.buf, "{ind}{export}const {}: {ty_str} = ", name.name);
985                self.emit_expr(value)?;
986                self.buf.push_str(";\n");
987                Ok(())
988            }
989            NodeKind::ModuleHandle { effect, handler } => {
990                let effect_name =
991                    effect.segments.last().map_or("effect", |s| s.name.as_str());
992                let var_name = format!("__{}", to_camel_case(effect_name));
993                let type_name = effect_name;
994                let ind = self.indent_str();
995                let _ = write!(self.buf, "{ind}const {var_name}: {type_name} = ");
996                self.emit_expr(handler)?;
997                self.buf.push_str(";\n");
998                // Register as ambient handler so same-module calls pick it up.
999                self.current_handler_vars
1000                    .insert(effect_name.to_string(), var_name);
1001                Ok(())
1002            }
1003            NodeKind::PropertyTest { name, body, .. } => {
1004                self.writeln(&format!("// property test: {name}"));
1005                self.writeln("// (property tests are not emitted in TS output)");
1006                let _ = body;
1007                Ok(())
1008            }
1009            // Statement / expression nodes at top level:
1010            NodeKind::LetBinding { .. }
1011            | NodeKind::If { .. }
1012            | NodeKind::For { .. }
1013            | NodeKind::While { .. }
1014            | NodeKind::Loop { .. }
1015            | NodeKind::Return { .. }
1016            | NodeKind::Break { .. }
1017            | NodeKind::Continue
1018            | NodeKind::Guard { .. }
1019            | NodeKind::Match { .. }
1020            | NodeKind::Block { .. }
1021            | NodeKind::HandlingBlock { .. }
1022            | NodeKind::Assign { .. } => self.emit_stmt(node),
1023            // Expression nodes that appear as statements:
1024            _ => {
1025                self.write_indent();
1026                self.emit_expr(node)?;
1027                self.buf.push_str(";\n");
1028                Ok(())
1029            }
1030        }
1031    }
1032
1033    // ── Function declarations ───────────────────────────────────────────────
1034
1035    #[allow(clippy::too_many_arguments)]
1036    fn emit_fn_decl(
1037        &mut self,
1038        visibility: Visibility,
1039        is_async: bool,
1040        name: &str,
1041        generic_params: &[bock_ast::GenericParam],
1042        params: &[AIRNode],
1043        return_type: Option<&AIRNode>,
1044        effect_clause: &[bock_ast::TypePath],
1045        body: &AIRNode,
1046        _is_method: bool,
1047    ) -> Result<(), CodegenError> {
1048        let export = if matches!(visibility, Visibility::Public) {
1049            "export "
1050        } else {
1051            ""
1052        };
1053        let async_kw = if is_async { "async " } else { "" };
1054        let generics = self.generic_params_to_ts(generic_params);
1055        let param_list = self.collect_typed_params(params);
1056        let effects_param = self.effects_param(effect_clause);
1057        let mut all_params = param_list;
1058        if let Some(ep) = effects_param {
1059            all_params.push(ep);
1060        }
1061        let ret_str = build_ts_return_type(is_async, return_type.map(|r| self.type_to_ts(r)));
1062        if !effect_clause.is_empty() {
1063            let effect_names = self.expand_effect_names(effect_clause);
1064            self.fn_effects.insert(name.to_string(), effect_names);
1065        }
1066        let ts_name = to_camel_case(name);
1067        self.writeln(&format!(
1068            "{export}{async_kw}function {ts_name}{generics}({}){ret_str} {{",
1069            all_params.join(", "),
1070        ));
1071        self.indent += 1;
1072        let old_handler_vars = self.current_handler_vars.clone();
1073        let expanded = self.expand_effect_names(effect_clause);
1074        for ename in &expanded {
1075            self.current_handler_vars
1076                .insert(ename.clone(), to_camel_case(ename));
1077        }
1078        self.emit_block_body(body)?;
1079        self.current_handler_vars = old_handler_vars;
1080        self.indent -= 1;
1081        self.writeln("}");
1082        Ok(())
1083    }
1084
1085    fn emit_class_method(&mut self, method: &AIRNode) -> Result<(), CodegenError> {
1086        if let NodeKind::FnDecl {
1087            is_async,
1088            name,
1089            generic_params,
1090            params,
1091            return_type,
1092            effect_clause,
1093            body,
1094            ..
1095        } = &method.kind
1096        {
1097            let async_kw = if *is_async { "async " } else { "" };
1098            let generics = self.generic_params_to_ts(generic_params);
1099            let param_list = self.collect_typed_params(params);
1100            let effects_param = self.effects_param(effect_clause);
1101            let mut all_params = param_list;
1102            if let Some(ep) = effects_param {
1103                all_params.push(ep);
1104            }
1105            let ret_str = build_ts_return_type(
1106                *is_async,
1107                return_type.as_deref().map(|r| self.type_to_ts(r)),
1108            );
1109            let method_name = to_camel_case(&name.name);
1110            self.writeln(&format!(
1111                "{async_kw}{method_name}{generics}({}){ret_str} {{",
1112                all_params.join(", "),
1113            ));
1114            self.indent += 1;
1115            let old_handler_vars = self.current_handler_vars.clone();
1116            let expanded = self.expand_effect_names(effect_clause);
1117            for ename in &expanded {
1118                self.current_handler_vars
1119                    .insert(ename.clone(), to_camel_case(ename));
1120            }
1121            self.emit_block_body(body)?;
1122            self.current_handler_vars = old_handler_vars;
1123            self.indent -= 1;
1124            self.writeln("}");
1125        }
1126        Ok(())
1127    }
1128
1129    /// Collect typed parameter names: `name: Type`.
1130    fn collect_typed_params(&self, params: &[AIRNode]) -> Vec<String> {
1131        params
1132            .iter()
1133            .filter_map(|p| {
1134                if let NodeKind::Param {
1135                    pattern,
1136                    ty,
1137                    default,
1138                } = &p.kind
1139                {
1140                    let name = self.pattern_to_binding_name(pattern);
1141                    let ty_str = ty
1142                        .as_ref()
1143                        .map(|t| format!(": {}", self.type_to_ts(t)))
1144                        .unwrap_or_default();
1145                    if let Some(def) = default {
1146                        let mut ctx = TsEmitCtx::new();
1147                        ctx.indent = self.indent;
1148                        if ctx.emit_expr_to_string(def).is_ok() {
1149                            let (def_str, _) = ctx.finish();
1150                            return Some(format!("{name}{ty_str} = {def_str}"));
1151                        }
1152                    }
1153                    Some(format!("{name}{ty_str}"))
1154                } else {
1155                    None
1156                }
1157            })
1158            .collect()
1159    }
1160
1161    fn emit_expr_to_string(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
1162        self.emit_expr(node)
1163    }
1164
1165    /// Expand effect names, replacing composite effects with their components.
1166    fn expand_effect_names(&self, effects: &[bock_ast::TypePath]) -> Vec<String> {
1167        let mut result = Vec::new();
1168        for tp in effects {
1169            let name = tp
1170                .segments
1171                .last()
1172                .map_or("effect".to_string(), |s| s.name.clone());
1173            if let Some(components) = self.composite_effects.get(&name) {
1174                result.extend(components.iter().cloned());
1175            } else {
1176                result.push(name);
1177            }
1178        }
1179        result
1180    }
1181
1182    /// Effects → typed destructured parameter object: `{ log, clock }: { log: Log, clock: Clock }`.
1183    fn effects_param(&self, effects: &[bock_ast::TypePath]) -> Option<String> {
1184        if effects.is_empty() {
1185            return None;
1186        }
1187        let expanded = self.expand_effect_names(effects);
1188        if expanded.is_empty() {
1189            return None;
1190        }
1191        let names: Vec<String> = expanded.iter().map(|n| to_camel_case(n)).collect();
1192        let type_entries: Vec<String> = expanded
1193            .iter()
1194            .zip(names.iter())
1195            .map(|(orig, camel)| format!("{camel}: {orig}"))
1196            .collect();
1197        Some(format!(
1198            "{{ {} }}: {{ {} }}",
1199            names.join(", "),
1200            type_entries.join(", ")
1201        ))
1202    }
1203
1204    /// Build a `{ effect: handler_var, ... }` argument for calling an effectful function.
1205    fn build_effects_call_arg_ts(&self, fn_name: &str) -> Option<String> {
1206        let effects = self.fn_effects.get(fn_name)?;
1207        let entries: Vec<String> = effects
1208            .iter()
1209            .filter_map(|e| {
1210                let handler_var = self.current_handler_vars.get(e)?;
1211                let param_name = to_camel_case(e);
1212                Some(format!("{param_name}: {handler_var}"))
1213            })
1214            .collect();
1215        if entries.is_empty() {
1216            return None;
1217        }
1218        Some(format!("{{ {} }}", entries.join(", ")))
1219    }
1220
1221    // ── Enum variant interfaces + factories ──────────────────────────────────
1222
1223    fn emit_enum_variant(
1224        &mut self,
1225        enum_name: &str,
1226        generic_params: &[bock_ast::GenericParam],
1227        variant: &AIRNode,
1228    ) -> Result<(), CodegenError> {
1229        if let NodeKind::EnumVariant { name, payload } = &variant.kind {
1230            let vname = &name.name;
1231            let generics = self.generic_params_to_ts(generic_params);
1232            let qualified = format!("{enum_name}_{vname}");
1233
1234            match payload {
1235                EnumVariantPayload::Unit => {
1236                    // Interface for unit variant
1237                    self.writeln(&format!(
1238                        "interface {qualified}{generics} {{ readonly _tag: \"{vname}\"; }}"
1239                    ));
1240                    self.writeln(&format!(
1241                        "const {qualified}: {qualified} = Object.freeze({{ _tag: \"{vname}\" as const }});"
1242                    ));
1243                }
1244                EnumVariantPayload::Struct(fields) => {
1245                    // Interface for struct variant
1246                    self.writeln(&format!("interface {qualified}{generics} {{"));
1247                    self.indent += 1;
1248                    self.writeln(&format!("readonly _tag: \"{vname}\";"));
1249                    for f in fields {
1250                        let ty = self.ast_type_to_ts(&f.ty);
1251                        self.writeln(&format!("readonly {}: {};", f.name.name, ty));
1252                    }
1253                    self.indent -= 1;
1254                    self.writeln("}");
1255                    let field_params: Vec<String> = fields
1256                        .iter()
1257                        .map(|f| format!("{}: {}", f.name.name, self.ast_type_to_ts(&f.ty)))
1258                        .collect();
1259                    let field_names: Vec<&str> =
1260                        fields.iter().map(|f| f.name.name.as_str()).collect();
1261                    self.writeln(&format!(
1262                        "function {qualified}{generics}({}): {qualified} {{",
1263                        field_params.join(", "),
1264                    ));
1265                    self.indent += 1;
1266                    self.writeln(&format!(
1267                        "return {{ _tag: \"{vname}\" as const, {} }};",
1268                        field_names.join(", "),
1269                    ));
1270                    self.indent -= 1;
1271                    self.writeln("}");
1272                }
1273                EnumVariantPayload::Tuple(elems) => {
1274                    // Interface for tuple variant
1275                    self.writeln(&format!("interface {qualified}{generics} {{"));
1276                    self.indent += 1;
1277                    self.writeln(&format!("readonly _tag: \"{vname}\";"));
1278                    for (i, elem) in elems.iter().enumerate() {
1279                        let ty = self.type_to_ts(elem);
1280                        self.writeln(&format!("readonly _{i}: {ty};"));
1281                    }
1282                    self.indent -= 1;
1283                    self.writeln("}");
1284                    let param_decls: Vec<String> = elems
1285                        .iter()
1286                        .enumerate()
1287                        .map(|(i, e)| format!("_{i}: {}", self.type_to_ts(e)))
1288                        .collect();
1289                    let param_names: Vec<String> =
1290                        (0..elems.len()).map(|i| format!("_{i}")).collect();
1291                    self.writeln(&format!(
1292                        "function {qualified}{generics}({}): {qualified} {{",
1293                        param_decls.join(", "),
1294                    ));
1295                    self.indent += 1;
1296                    self.writeln(&format!(
1297                        "return {{ _tag: \"{vname}\" as const, {} }};",
1298                        param_names
1299                            .iter()
1300                            .enumerate()
1301                            .map(|(i, p)| format!("_{i}: {p}"))
1302                            .collect::<Vec<_>>()
1303                            .join(", ")
1304                    ));
1305                    self.indent -= 1;
1306                    self.writeln("}");
1307                }
1308            }
1309        }
1310        Ok(())
1311    }
1312
1313    // ── Statements ──────────────────────────────────────────────────────────
1314
1315    fn emit_stmt(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
1316        self.mark_span(node.span);
1317        match &node.kind {
1318            NodeKind::LetBinding {
1319                is_mut,
1320                pattern,
1321                ty,
1322                value,
1323                ..
1324            } => {
1325                let kw = if *is_mut { "let" } else { "const" };
1326                let binding = self.pattern_to_ts_destructure(pattern);
1327                let ty_str = ty
1328                    .as_ref()
1329                    .map(|t| format!(": {}", self.type_to_ts(t)))
1330                    .unwrap_or_default();
1331                let ind = self.indent_str();
1332                let _ = write!(self.buf, "{ind}{kw} {binding}{ty_str} = ");
1333                self.emit_expr(value)?;
1334                self.buf.push_str(";\n");
1335                Ok(())
1336            }
1337            NodeKind::If {
1338                let_pattern,
1339                condition,
1340                then_block,
1341                else_block,
1342            } => {
1343                if let Some(pat) = let_pattern {
1344                    let ind = self.indent_str();
1345                    let _ = write!(self.buf, "{ind}if (");
1346                    self.emit_expr(condition)?;
1347                    self.buf.push_str(" != null) {\n");
1348                    self.indent += 1;
1349                    let binding = self.pattern_to_ts_destructure(pat);
1350                    self.writeln(&format!("const {binding} = "));
1351                    self.emit_block_body(then_block)?;
1352                    self.indent -= 1;
1353                } else {
1354                    let ind = self.indent_str();
1355                    let _ = write!(self.buf, "{ind}if (");
1356                    self.emit_expr(condition)?;
1357                    self.buf.push_str(") {\n");
1358                    self.indent += 1;
1359                    self.emit_block_body(then_block)?;
1360                    self.indent -= 1;
1361                }
1362                if let Some(else_b) = else_block {
1363                    if matches!(else_b.kind, NodeKind::If { .. }) {
1364                        let ind = self.indent_str();
1365                        let _ = write!(self.buf, "{ind}}} else ");
1366                        self.emit_stmt(else_b)?;
1367                        return Ok(());
1368                    }
1369                    self.writeln("} else {");
1370                    self.indent += 1;
1371                    self.emit_block_body(else_b)?;
1372                    self.indent -= 1;
1373                }
1374                self.writeln("}");
1375                Ok(())
1376            }
1377            NodeKind::For {
1378                pattern,
1379                iterable,
1380                body,
1381            } => {
1382                let binding = self.pattern_to_ts_destructure(pattern);
1383                let ind = self.indent_str();
1384                let _ = write!(self.buf, "{ind}for (const {binding} of ");
1385                self.emit_expr(iterable)?;
1386                self.buf.push_str(") {\n");
1387                self.indent += 1;
1388                self.emit_block_body(body)?;
1389                self.indent -= 1;
1390                self.writeln("}");
1391                Ok(())
1392            }
1393            NodeKind::While { condition, body } => {
1394                let ind = self.indent_str();
1395                let _ = write!(self.buf, "{ind}while (");
1396                self.emit_expr(condition)?;
1397                self.buf.push_str(") {\n");
1398                self.indent += 1;
1399                self.emit_block_body(body)?;
1400                self.indent -= 1;
1401                self.writeln("}");
1402                Ok(())
1403            }
1404            NodeKind::Loop { body } => {
1405                self.writeln("while (true) {");
1406                self.indent += 1;
1407                self.emit_block_body(body)?;
1408                self.indent -= 1;
1409                self.writeln("}");
1410                Ok(())
1411            }
1412            NodeKind::Return { value } => {
1413                if let Some(val) = value {
1414                    let ind = self.indent_str();
1415                    let _ = write!(self.buf, "{ind}return ");
1416                    self.emit_expr(val)?;
1417                    self.buf.push_str(";\n");
1418                } else {
1419                    self.writeln("return;");
1420                }
1421                Ok(())
1422            }
1423            NodeKind::Break { value } => {
1424                if let Some(val) = value {
1425                    let ind = self.indent_str();
1426                    let _ = write!(self.buf, "{ind}/* break value: ");
1427                    self.emit_expr(val)?;
1428                    self.buf.push_str(" */ break;\n");
1429                } else {
1430                    self.writeln("break;");
1431                }
1432                Ok(())
1433            }
1434            NodeKind::Continue => {
1435                self.writeln("continue;");
1436                Ok(())
1437            }
1438            NodeKind::Guard {
1439                condition,
1440                else_block,
1441                ..
1442            } => {
1443                let ind = self.indent_str();
1444                let _ = write!(self.buf, "{ind}if (!(");
1445                self.emit_expr(condition)?;
1446                self.buf.push_str(")) {\n");
1447                self.indent += 1;
1448                self.emit_block_body(else_block)?;
1449                self.indent -= 1;
1450                self.writeln("}");
1451                Ok(())
1452            }
1453            NodeKind::Match { scrutinee, arms } => self.emit_match(scrutinee, arms),
1454            NodeKind::Block { stmts, tail } => {
1455                self.writeln("{");
1456                self.indent += 1;
1457                for s in stmts {
1458                    self.emit_node(s)?;
1459                }
1460                if let Some(t) = tail {
1461                    self.write_indent();
1462                    self.emit_expr(t)?;
1463                    self.buf.push_str(";\n");
1464                }
1465                self.indent -= 1;
1466                self.writeln("}");
1467                Ok(())
1468            }
1469            NodeKind::HandlingBlock { handlers, body } => {
1470                // handling block → scoped handler instantiation
1471                self.writeln("{");
1472                self.indent += 1;
1473                let old_handler_vars = self.current_handler_vars.clone();
1474                for h in handlers {
1475                    let effect_name =
1476                        h.effect.segments.last().map_or("effect", |s| s.name.as_str());
1477                    let var_name = format!("__{}", to_camel_case(effect_name));
1478                    let type_name = effect_name;
1479                    let ind = self.indent_str();
1480                    let _ = write!(self.buf, "{ind}const {var_name}: {type_name} = ");
1481                    self.emit_expr(&h.handler)?;
1482                    self.buf.push_str(";\n");
1483                    self.current_handler_vars
1484                        .insert(effect_name.to_string(), var_name);
1485                }
1486                if let NodeKind::Block { stmts, tail } = &body.kind {
1487                    for s in stmts {
1488                        self.emit_node(s)?;
1489                    }
1490                    if let Some(t) = tail {
1491                        self.write_indent();
1492                        self.emit_expr(t)?;
1493                        self.buf.push_str(";\n");
1494                    }
1495                } else {
1496                    self.emit_stmt(body)?;
1497                }
1498                self.current_handler_vars = old_handler_vars;
1499                self.indent -= 1;
1500                self.writeln("}");
1501                Ok(())
1502            }
1503            NodeKind::Assign { op, target, value } => {
1504                let ind = self.indent_str();
1505                let _ = write!(self.buf, "{ind}");
1506                self.emit_expr(target)?;
1507                let op_str = match op {
1508                    AssignOp::Assign => "=",
1509                    AssignOp::AddAssign => "+=",
1510                    AssignOp::SubAssign => "-=",
1511                    AssignOp::MulAssign => "*=",
1512                    AssignOp::DivAssign => "/=",
1513                    AssignOp::RemAssign => "%=",
1514                };
1515                let _ = write!(self.buf, " {op_str} ");
1516                self.emit_expr(value)?;
1517                self.buf.push_str(";\n");
1518                Ok(())
1519            }
1520            _ => {
1521                self.write_indent();
1522                self.emit_expr(node)?;
1523                self.buf.push_str(";\n");
1524                Ok(())
1525            }
1526        }
1527    }
1528
1529    // ── Expressions ─────────────────────────────────────────────────────────
1530
1531    fn emit_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
1532        self.mark_span(node.span);
1533        match &node.kind {
1534            NodeKind::Literal { lit } => {
1535                match lit {
1536                    Literal::Int(s) => self.buf.push_str(s),
1537                    Literal::Float(s) => self.buf.push_str(s),
1538                    Literal::Bool(b) => self.buf.push_str(if *b { "true" } else { "false" }),
1539                    Literal::Char(s) => {
1540                        self.buf.push('\'');
1541                        self.buf.push_str(s);
1542                        self.buf.push('\'');
1543                    }
1544                    Literal::String(s) => {
1545                        self.buf.push('"');
1546                        self.buf.push_str(&escape_js_string(s));
1547                        self.buf.push('"');
1548                    }
1549                    Literal::Unit => self.buf.push_str("undefined"),
1550                }
1551                Ok(())
1552            }
1553            NodeKind::Identifier { name } => {
1554                if name.name == "None" {
1555                    self.buf.push_str("{ _tag: \"None\" as const }");
1556                } else {
1557                    self.buf.push_str(&to_camel_case(&name.name));
1558                }
1559                Ok(())
1560            }
1561            NodeKind::BinaryOp { op, left, right } => {
1562                self.buf.push('(');
1563                self.emit_expr(left)?;
1564                let op_str = match op {
1565                    BinOp::Add => " + ",
1566                    BinOp::Sub => " - ",
1567                    BinOp::Mul => " * ",
1568                    BinOp::Div => " / ",
1569                    BinOp::Rem => " % ",
1570                    BinOp::Pow => " ** ",
1571                    BinOp::Eq => " === ",
1572                    BinOp::Ne => " !== ",
1573                    BinOp::Lt => " < ",
1574                    BinOp::Le => " <= ",
1575                    BinOp::Gt => " > ",
1576                    BinOp::Ge => " >= ",
1577                    BinOp::And => " && ",
1578                    BinOp::Or => " || ",
1579                    BinOp::BitAnd => " & ",
1580                    BinOp::BitOr => " | ",
1581                    BinOp::BitXor => " ^ ",
1582                    BinOp::Compose => " /* >> */ ",
1583                    BinOp::Is => " instanceof ",
1584                };
1585                self.buf.push_str(op_str);
1586                self.emit_expr(right)?;
1587                self.buf.push(')');
1588                Ok(())
1589            }
1590            NodeKind::UnaryOp { op, operand } => {
1591                let op_str = match op {
1592                    UnaryOp::Neg => "-",
1593                    UnaryOp::Not => "!",
1594                    UnaryOp::BitNot => "~",
1595                };
1596                self.buf.push_str(op_str);
1597                self.emit_expr(operand)?;
1598                Ok(())
1599            }
1600            NodeKind::Call { callee, args, .. } => {
1601                if let Some(code) = self.map_prelude_call(callee, args)? {
1602                    self.buf.push_str(&code);
1603                    return Ok(());
1604                }
1605                if self.try_emit_prelude_ctor(callee, args)? {
1606                    return Ok(());
1607                }
1608                if self.try_emit_time_assoc_call(callee, args)? {
1609                    return Ok(());
1610                }
1611                if self.try_emit_time_desugared_method(callee, args)? {
1612                    return Ok(());
1613                }
1614                if self.try_emit_concurrency_call(callee, args)? {
1615                    return Ok(());
1616                }
1617                // Rewrite bare effect operation calls: log(...) → handler.log(...)
1618                if let NodeKind::Identifier { name } = &callee.kind {
1619                    if let Some(effect_name) = self.effect_ops.get(&name.name).cloned() {
1620                        if let Some(handler_var) =
1621                            self.current_handler_vars.get(&effect_name).cloned()
1622                        {
1623                            let _ = write!(self.buf, "{}.{}", handler_var, name.name);
1624                            self.buf.push('(');
1625                            for (i, arg) in args.iter().enumerate() {
1626                                if i > 0 {
1627                                    self.buf.push_str(", ");
1628                                }
1629                                self.emit_expr(&arg.value)?;
1630                            }
1631                            self.buf.push(')');
1632                            return Ok(());
1633                        }
1634                    }
1635                }
1636                // Pass handler args to effectful function calls.
1637                let effects_arg = if let NodeKind::Identifier { name } = &callee.kind {
1638                    self.build_effects_call_arg_ts(&name.name)
1639                } else {
1640                    None
1641                };
1642                self.emit_expr(callee)?;
1643                self.buf.push('(');
1644                for (i, arg) in args.iter().enumerate() {
1645                    if i > 0 {
1646                        self.buf.push_str(", ");
1647                    }
1648                    self.emit_expr(&arg.value)?;
1649                }
1650                if let Some(ea) = effects_arg {
1651                    if !args.is_empty() {
1652                        self.buf.push_str(", ");
1653                    }
1654                    self.buf.push_str(&ea);
1655                }
1656                self.buf.push(')');
1657                Ok(())
1658            }
1659            NodeKind::MethodCall {
1660                receiver,
1661                method,
1662                args,
1663                ..
1664            } => {
1665                if self.try_emit_time_method(receiver, &method.name, args)? {
1666                    return Ok(());
1667                }
1668                self.emit_expr(receiver)?;
1669                let _ = write!(self.buf, ".{}", to_camel_case(&method.name));
1670                self.buf.push('(');
1671                for (i, arg) in args.iter().enumerate() {
1672                    if i > 0 {
1673                        self.buf.push_str(", ");
1674                    }
1675                    self.emit_expr(&arg.value)?;
1676                }
1677                self.buf.push(')');
1678                Ok(())
1679            }
1680            NodeKind::FieldAccess { object, field } => {
1681                self.emit_expr(object)?;
1682                let _ = write!(self.buf, ".{}", field.name);
1683                Ok(())
1684            }
1685            NodeKind::Index { object, index } => {
1686                self.emit_expr(object)?;
1687                self.buf.push('[');
1688                self.emit_expr(index)?;
1689                self.buf.push(']');
1690                Ok(())
1691            }
1692            NodeKind::Lambda { params, body } => {
1693                let param_list = self.collect_typed_params(params);
1694                let _ = write!(self.buf, "({}) => ", param_list.join(", "));
1695                if matches!(body.kind, NodeKind::Block { .. }) {
1696                    self.buf.push_str("{\n");
1697                    self.indent += 1;
1698                    self.emit_block_body(body)?;
1699                    self.indent -= 1;
1700                    self.write_indent();
1701                    self.buf.push('}');
1702                } else {
1703                    self.emit_expr(body)?;
1704                }
1705                Ok(())
1706            }
1707            NodeKind::Pipe { left, right } => self.emit_pipe(left, right),
1708            NodeKind::Compose { left, right } => {
1709                let _ = write!(self.buf, "((x: any) => ");
1710                self.emit_expr(right)?;
1711                self.buf.push('(');
1712                self.emit_expr(left)?;
1713                self.buf.push_str("(x)))");
1714                Ok(())
1715            }
1716            NodeKind::Await { expr } => {
1717                self.buf.push_str("(await ");
1718                self.emit_expr(expr)?;
1719                self.buf.push(')');
1720                Ok(())
1721            }
1722            NodeKind::Propagate { expr } => {
1723                self.emit_expr(expr)?;
1724                Ok(())
1725            }
1726            NodeKind::Range { lo, hi, inclusive } => {
1727                if *inclusive {
1728                    self.buf.push_str("rangeInclusive(");
1729                } else {
1730                    self.buf.push_str("range(");
1731                }
1732                self.emit_expr(lo)?;
1733                self.buf.push_str(", ");
1734                self.emit_expr(hi)?;
1735                self.buf.push(')');
1736                Ok(())
1737            }
1738            NodeKind::RecordConstruct {
1739                path,
1740                fields,
1741                spread,
1742            } => {
1743                let type_name = path
1744                    .segments
1745                    .last()
1746                    .map(|s| s.name.as_str())
1747                    .unwrap_or("");
1748                let is_class = self.record_names.contains(type_name);
1749                if is_class {
1750                    let _ = write!(self.buf, "new {type_name}(");
1751                    if fields.is_empty() && spread.is_none() {
1752                        self.buf.push(')');
1753                        return Ok(());
1754                    }
1755                }
1756                if let Some(sp) = spread {
1757                    self.buf.push_str("{ ...");
1758                    self.emit_expr(sp)?;
1759                    if !fields.is_empty() {
1760                        self.buf.push_str(", ");
1761                    }
1762                } else {
1763                    self.buf.push_str("{ ");
1764                }
1765                for (i, f) in fields.iter().enumerate() {
1766                    if i > 0 {
1767                        self.buf.push_str(", ");
1768                    }
1769                    if let Some(val) = &f.value {
1770                        let _ = write!(self.buf, "{}: ", f.name.name);
1771                        self.emit_expr(val)?;
1772                    } else {
1773                        self.buf.push_str(&f.name.name);
1774                    }
1775                }
1776                self.buf.push_str(" }");
1777                if is_class {
1778                    self.buf.push(')');
1779                }
1780                Ok(())
1781            }
1782            NodeKind::ListLiteral { elems } => {
1783                self.buf.push('[');
1784                for (i, e) in elems.iter().enumerate() {
1785                    if i > 0 {
1786                        self.buf.push_str(", ");
1787                    }
1788                    self.emit_expr(e)?;
1789                }
1790                self.buf.push(']');
1791                Ok(())
1792            }
1793            NodeKind::MapLiteral { entries } => {
1794                self.buf.push_str("new Map([");
1795                for (i, entry) in entries.iter().enumerate() {
1796                    if i > 0 {
1797                        self.buf.push_str(", ");
1798                    }
1799                    self.buf.push('[');
1800                    self.emit_expr(&entry.key)?;
1801                    self.buf.push_str(", ");
1802                    self.emit_expr(&entry.value)?;
1803                    self.buf.push(']');
1804                }
1805                self.buf.push_str("])");
1806                Ok(())
1807            }
1808            NodeKind::SetLiteral { elems } => {
1809                self.buf.push_str("new Set([");
1810                for (i, e) in elems.iter().enumerate() {
1811                    if i > 0 {
1812                        self.buf.push_str(", ");
1813                    }
1814                    self.emit_expr(e)?;
1815                }
1816                self.buf.push_str("])");
1817                Ok(())
1818            }
1819            NodeKind::TupleLiteral { elems } => {
1820                // TS tuples are arrays with typed positions.
1821                self.buf.push('[');
1822                for (i, e) in elems.iter().enumerate() {
1823                    if i > 0 {
1824                        self.buf.push_str(", ");
1825                    }
1826                    self.emit_expr(e)?;
1827                }
1828                self.buf.push(']');
1829                Ok(())
1830            }
1831            NodeKind::Interpolation { parts } => {
1832                self.buf.push('`');
1833                for part in parts {
1834                    match part {
1835                        AirInterpolationPart::Literal(s) => {
1836                            self.buf.push_str(&escape_template_literal(s));
1837                        }
1838                        AirInterpolationPart::Expr(expr) => {
1839                            self.buf.push_str("${");
1840                            self.emit_expr(expr)?;
1841                            self.buf.push('}');
1842                        }
1843                    }
1844                }
1845                self.buf.push('`');
1846                Ok(())
1847            }
1848            NodeKind::Placeholder => {
1849                self.buf.push('_');
1850                Ok(())
1851            }
1852            NodeKind::Unreachable => {
1853                self.buf
1854                    .push_str("(() => { throw new Error(\"unreachable\"); })()");
1855                Ok(())
1856            }
1857            NodeKind::ResultConstruct { variant, value } => {
1858                match variant {
1859                    ResultVariant::Ok => {
1860                        self.buf.push_str("{ _tag: \"Ok\" as const, value: ");
1861                        if let Some(v) = value {
1862                            self.emit_expr(v)?;
1863                        } else {
1864                            self.buf.push_str("undefined");
1865                        }
1866                        self.buf.push_str(" }");
1867                    }
1868                    ResultVariant::Err => {
1869                        self.buf.push_str("{ _tag: \"Err\" as const, error: ");
1870                        if let Some(v) = value {
1871                            self.emit_expr(v)?;
1872                        } else {
1873                            self.buf.push_str("undefined");
1874                        }
1875                        self.buf.push_str(" }");
1876                    }
1877                }
1878                Ok(())
1879            }
1880            NodeKind::Assign { op, target, value } => {
1881                self.emit_expr(target)?;
1882                let op_str = match op {
1883                    AssignOp::Assign => " = ",
1884                    AssignOp::AddAssign => " += ",
1885                    AssignOp::SubAssign => " -= ",
1886                    AssignOp::MulAssign => " *= ",
1887                    AssignOp::DivAssign => " /= ",
1888                    AssignOp::RemAssign => " %= ",
1889                };
1890                self.buf.push_str(op_str);
1891                self.emit_expr(value)?;
1892                Ok(())
1893            }
1894            NodeKind::If {
1895                condition,
1896                then_block,
1897                else_block,
1898                ..
1899            } => {
1900                // Ternary for expression-position if.
1901                self.buf.push('(');
1902                self.emit_expr(condition)?;
1903                self.buf.push_str(" ? ");
1904                self.emit_block_as_expr(then_block)?;
1905                self.buf.push_str(" : ");
1906                if let Some(eb) = else_block {
1907                    self.emit_block_as_expr(eb)?;
1908                } else {
1909                    self.buf.push_str("undefined");
1910                }
1911                self.buf.push(')');
1912                Ok(())
1913            }
1914            NodeKind::Block { stmts, tail } => {
1915                // IIFE
1916                self.buf.push_str("(() => {\n");
1917                self.indent += 1;
1918                for s in stmts {
1919                    self.emit_node(s)?;
1920                }
1921                if let Some(t) = tail {
1922                    let ind = self.indent_str();
1923                    let _ = write!(self.buf, "{ind}return ");
1924                    self.emit_expr(t)?;
1925                    self.buf.push_str(";\n");
1926                }
1927                self.indent -= 1;
1928                self.write_indent();
1929                self.buf.push_str("})()");
1930                Ok(())
1931            }
1932            NodeKind::Match { scrutinee, arms } => {
1933                // IIFE
1934                self.buf.push_str("(() => {\n");
1935                self.indent += 1;
1936                self.emit_match(scrutinee, arms)?;
1937                self.indent -= 1;
1938                self.write_indent();
1939                self.buf.push_str("})()");
1940                Ok(())
1941            }
1942            // Ownership: erase
1943            NodeKind::Move { expr }
1944            | NodeKind::Borrow { expr }
1945            | NodeKind::MutableBorrow { expr } => self.emit_expr(expr),
1946            // Effect operation invocation
1947            NodeKind::EffectOp {
1948                effect,
1949                operation,
1950                args,
1951            } => {
1952                let effect_name = effect.segments.last().map_or("effect", |s| s.name.as_str());
1953                let _ = write!(
1954                    self.buf,
1955                    "{}.{}",
1956                    to_camel_case(effect_name),
1957                    operation.name
1958                );
1959                self.buf.push('(');
1960                for (i, arg) in args.iter().enumerate() {
1961                    if i > 0 {
1962                        self.buf.push_str(", ");
1963                    }
1964                    self.emit_expr(&arg.value)?;
1965                }
1966                self.buf.push(')');
1967                Ok(())
1968            }
1969            // Type expressions in expression position: emit the TS type
1970            NodeKind::TypeNamed { .. }
1971            | NodeKind::TypeTuple { .. }
1972            | NodeKind::TypeFunction { .. }
1973            | NodeKind::TypeOptional { .. }
1974            | NodeKind::TypeSelf => {
1975                let ty_str = self.type_to_ts(node);
1976                let _ = write!(self.buf, "/* {ty_str} */");
1977                Ok(())
1978            }
1979            NodeKind::EffectRef { path } => {
1980                let name = path
1981                    .segments
1982                    .iter()
1983                    .map(|s| s.name.as_str())
1984                    .collect::<Vec<_>>()
1985                    .join(".");
1986                self.buf.push_str(&name);
1987                Ok(())
1988            }
1989            NodeKind::Error => {
1990                self.buf.push_str("/* error */");
1991                Ok(())
1992            }
1993            _ => {
1994                self.buf.push_str("/* unsupported */");
1995                Ok(())
1996            }
1997        }
1998    }
1999
2000    // ── Match → switch ──────────────────────────────────────────────────────
2001
2002    fn emit_match(&mut self, scrutinee: &AIRNode, arms: &[AIRNode]) -> Result<(), CodegenError> {
2003        let is_adt = arms.iter().any(|arm| {
2004            if let NodeKind::MatchArm { pattern, .. } = &arm.kind {
2005                matches!(pattern.kind, NodeKind::ConstructorPat { .. })
2006            } else {
2007                false
2008            }
2009        });
2010
2011        if is_adt {
2012            let ind = self.indent_str();
2013            let _ = write!(self.buf, "{ind}switch (");
2014            self.emit_expr(scrutinee)?;
2015            self.buf.push_str("._tag) {\n");
2016        } else {
2017            let ind = self.indent_str();
2018            let _ = write!(self.buf, "{ind}switch (");
2019            self.emit_expr(scrutinee)?;
2020            self.buf.push_str(") {\n");
2021        }
2022        self.indent += 1;
2023        for arm in arms {
2024            self.emit_match_arm(arm, is_adt, scrutinee)?;
2025        }
2026        self.indent -= 1;
2027        self.writeln("}");
2028        Ok(())
2029    }
2030
2031    fn emit_match_arm(
2032        &mut self,
2033        arm: &AIRNode,
2034        is_adt: bool,
2035        scrutinee: &AIRNode,
2036    ) -> Result<(), CodegenError> {
2037        if let NodeKind::MatchArm {
2038            pattern,
2039            guard,
2040            body,
2041        } = &arm.kind
2042        {
2043            match &pattern.kind {
2044                NodeKind::WildcardPat => {
2045                    self.writeln("default: {");
2046                }
2047                NodeKind::BindPat { name, .. } if !is_adt => {
2048                    self.writeln("default: {");
2049                    self.indent += 1;
2050                    let ind = self.indent_str();
2051                    let _ = write!(self.buf, "{ind}const {} = ", name.name);
2052                    self.emit_expr(scrutinee)?;
2053                    self.buf.push_str(";\n");
2054                    self.indent -= 1;
2055                }
2056                NodeKind::LiteralPat { lit } => {
2057                    let ind = self.indent_str();
2058                    let _ = write!(self.buf, "{ind}case ");
2059                    match lit {
2060                        Literal::Int(s) => self.buf.push_str(s),
2061                        Literal::Float(s) => self.buf.push_str(s),
2062                        Literal::Bool(b) => self.buf.push_str(if *b { "true" } else { "false" }),
2063                        Literal::Char(s) => {
2064                            self.buf.push('\'');
2065                            self.buf.push_str(s);
2066                            self.buf.push('\'');
2067                        }
2068                        Literal::String(s) => {
2069                            self.buf.push('"');
2070                            self.buf.push_str(&escape_js_string(s));
2071                            self.buf.push('"');
2072                        }
2073                        Literal::Unit => self.buf.push_str("undefined"),
2074                    }
2075                    self.buf.push_str(": {\n");
2076                }
2077                NodeKind::ConstructorPat { path, fields } => {
2078                    let variant_name = path.segments.last().map_or("_", |s| s.name.as_str());
2079                    self.writeln(&format!("case \"{variant_name}\": {{"));
2080                    if !fields.is_empty() {
2081                        self.indent += 1;
2082                        for (i, field) in fields.iter().enumerate() {
2083                            let binding = self.pattern_to_binding_name(field);
2084                            let ind = self.indent_str();
2085                            let _ = write!(self.buf, "{ind}const {binding} = ");
2086                            self.emit_expr(scrutinee)?;
2087                            let _ = writeln!(self.buf, "._{i};");
2088                        }
2089                        self.indent -= 1;
2090                    }
2091                }
2092                NodeKind::RecordPat { path, fields, .. } => {
2093                    let variant_name = path.segments.last().map_or("_", |s| s.name.as_str());
2094                    if is_adt {
2095                        self.writeln(&format!("case \"{variant_name}\": {{"));
2096                    } else {
2097                        self.writeln("default: {");
2098                    }
2099                    if !fields.is_empty() {
2100                        self.indent += 1;
2101                        for f in fields {
2102                            let field_name = &f.name.name;
2103                            if let Some(pat) = &f.pattern {
2104                                let binding = self.pattern_to_binding_name(pat);
2105                                let ind = self.indent_str();
2106                                let _ = write!(self.buf, "{ind}const {binding} = ");
2107                                self.emit_expr(scrutinee)?;
2108                                let _ = writeln!(self.buf, ".{field_name};");
2109                            } else {
2110                                let ind = self.indent_str();
2111                                let _ = write!(self.buf, "{ind}const {field_name} = ");
2112                                self.emit_expr(scrutinee)?;
2113                                let _ = writeln!(self.buf, ".{field_name};");
2114                            }
2115                        }
2116                        self.indent -= 1;
2117                    }
2118                }
2119                _ => {
2120                    self.writeln("default: {");
2121                }
2122            }
2123
2124            self.indent += 1;
2125            if let Some(g) = guard {
2126                let ind = self.indent_str();
2127                let _ = write!(self.buf, "{ind}if (!(");
2128                self.emit_expr(g)?;
2129                self.buf.push_str(")) break;\n");
2130            }
2131            self.emit_block_body(body)?;
2132            self.writeln("break;");
2133            self.indent -= 1;
2134            self.writeln("}");
2135        }
2136        Ok(())
2137    }
2138
2139    // ── Pipe operator ───────────────────────────────────────────────────────
2140
2141    fn emit_pipe(&mut self, left: &AIRNode, right: &AIRNode) -> Result<(), CodegenError> {
2142        if let NodeKind::Call { callee, args, .. } = &right.kind {
2143            let has_placeholder = args
2144                .iter()
2145                .any(|a| matches!(a.value.kind, NodeKind::Placeholder));
2146            if has_placeholder {
2147                self.emit_expr(callee)?;
2148                self.buf.push('(');
2149                for (i, arg) in args.iter().enumerate() {
2150                    if i > 0 {
2151                        self.buf.push_str(", ");
2152                    }
2153                    if matches!(arg.value.kind, NodeKind::Placeholder) {
2154                        self.emit_expr(left)?;
2155                    } else {
2156                        self.emit_expr(&arg.value)?;
2157                    }
2158                }
2159                self.buf.push(')');
2160                return Ok(());
2161            }
2162        }
2163        self.emit_expr(right)?;
2164        self.buf.push('(');
2165        self.emit_expr(left)?;
2166        self.buf.push(')');
2167        Ok(())
2168    }
2169
2170    // ── Helpers ─────────────────────────────────────────────────────────────
2171
2172    fn emit_block_body(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
2173        if let NodeKind::Block { stmts, tail } = &node.kind {
2174            for s in stmts {
2175                self.emit_node(s)?;
2176            }
2177            if let Some(t) = tail {
2178                let ind = self.indent_str();
2179                let _ = write!(self.buf, "{ind}return ");
2180                self.emit_expr(t)?;
2181                self.buf.push_str(";\n");
2182            }
2183        } else {
2184            let ind = self.indent_str();
2185            let _ = write!(self.buf, "{ind}return ");
2186            self.emit_expr(node)?;
2187            self.buf.push_str(";\n");
2188        }
2189        Ok(())
2190    }
2191
2192    fn emit_block_as_expr(&mut self, node: &AIRNode) -> Result<(), CodegenError> {
2193        if let NodeKind::Block { stmts, tail } = &node.kind {
2194            if stmts.is_empty() {
2195                if let Some(t) = tail {
2196                    return self.emit_expr(t);
2197                }
2198            }
2199        }
2200        self.emit_expr(node)
2201    }
2202
2203    fn pattern_to_binding_name(&self, pat: &AIRNode) -> String {
2204        match &pat.kind {
2205            NodeKind::BindPat { name, .. } => to_camel_case(&name.name),
2206            NodeKind::WildcardPat => "_".into(),
2207            NodeKind::TuplePat { elems } => {
2208                format!(
2209                    "[{}]",
2210                    elems
2211                        .iter()
2212                        .map(|e| self.pattern_to_binding_name(e))
2213                        .collect::<Vec<_>>()
2214                        .join(", ")
2215                )
2216            }
2217            NodeKind::RecordPat { fields, .. } => {
2218                format!(
2219                    "{{ {} }}",
2220                    fields
2221                        .iter()
2222                        .map(|f| to_camel_case(&f.name.name).to_string())
2223                        .collect::<Vec<_>>()
2224                        .join(", ")
2225                )
2226            }
2227            _ => "_".into(),
2228        }
2229    }
2230
2231    fn pattern_to_ts_destructure(&self, pat: &AIRNode) -> String {
2232        self.pattern_to_binding_name(pat)
2233    }
2234
2235    fn type_expr_to_string(&self, node: &AIRNode) -> String {
2236        match &node.kind {
2237            NodeKind::TypeNamed { path, .. } => path
2238                .segments
2239                .iter()
2240                .map(|s| s.name.as_str())
2241                .collect::<Vec<_>>()
2242                .join("."),
2243            NodeKind::Identifier { name } => name.name.clone(),
2244            _ => "Unknown".into(),
2245        }
2246    }
2247}
2248
2249// ─── Utility functions ───────────────────────────────────────────────────────
2250
2251/// Build the `: T` return-type clause for a TS function signature, wrapping
2252/// the inner type in `Promise<...>` when the function is async. An async
2253/// function with no declared return type is typed `Promise<void>`.
2254fn build_ts_return_type(is_async: bool, inner: Option<String>) -> String {
2255    match (is_async, inner) {
2256        (true, Some(t)) => format!(": Promise<{t}>"),
2257        (true, None) => ": Promise<void>".to_string(),
2258        (false, Some(t)) => format!(": {t}"),
2259        (false, None) => String::new(),
2260    }
2261}
2262
2263/// Returns true if `name` is the identifier of a Duration or Instant instance
2264/// method. Used to recognise `d.as_millis()` / `i.elapsed()` calls during codegen.
2265fn is_time_method_name(name: &str) -> bool {
2266    matches!(
2267        name,
2268        "as_nanos"
2269            | "as_millis"
2270            | "as_seconds"
2271            | "is_zero"
2272            | "is_negative"
2273            | "abs"
2274            | "elapsed"
2275            | "duration_since"
2276    )
2277}
2278
2279/// Convert a name to `camelCase` (handles `snake_case`, `PascalCase`, and already `camelCase`).
2280fn to_camel_case(s: &str) -> String {
2281    if s.is_empty() || s == "_" {
2282        return s.to_string();
2283    }
2284    // If already camelCase (starts lowercase, no underscores), return as-is.
2285    if !s.contains('_') && s.starts_with(|c: char| c.is_lowercase()) {
2286        return s.to_string();
2287    }
2288    // If it's snake_case, convert to camelCase.
2289    if s.contains('_') {
2290        let parts: Vec<&str> = s.split('_').filter(|p| !p.is_empty()).collect();
2291        if parts.is_empty() {
2292            return s.to_string();
2293        }
2294        let mut result = parts[0].to_lowercase();
2295        for part in &parts[1..] {
2296            let mut chars = part.chars();
2297            if let Some(first) = chars.next() {
2298                result.push(
2299                    first
2300                        .to_uppercase()
2301                        .next()
2302                        .expect("uppercase yields at least one char"),
2303                );
2304                result.extend(chars);
2305            }
2306        }
2307        return result;
2308    }
2309    // If PascalCase, lowercase first letter.
2310    let mut chars = s.chars();
2311    let first = chars.next().expect("non-empty string guaranteed by caller");
2312    let mut result = first.to_lowercase().to_string();
2313    result.extend(chars);
2314    result
2315}
2316
2317/// Escape special characters in a JS/TS string literal.
2318fn escape_js_string(s: &str) -> String {
2319    let mut out = String::with_capacity(s.len());
2320    for ch in s.chars() {
2321        match ch {
2322            '"' => out.push_str("\\\""),
2323            '\\' => out.push_str("\\\\"),
2324            '\n' => out.push_str("\\n"),
2325            '\r' => out.push_str("\\r"),
2326            '\t' => out.push_str("\\t"),
2327            _ => out.push(ch),
2328        }
2329    }
2330    out
2331}
2332
2333/// Escape special characters in a JS/TS template literal.
2334fn escape_template_literal(s: &str) -> String {
2335    let mut out = String::with_capacity(s.len());
2336    for ch in s.chars() {
2337        match ch {
2338            '`' => out.push_str("\\`"),
2339            '\\' => out.push_str("\\\\"),
2340            '$' => out.push_str("\\$"),
2341            _ => out.push(ch),
2342        }
2343    }
2344    out
2345}
2346
2347// ─── Tests ───────────────────────────────────────────────────────────────────
2348
2349#[cfg(test)]
2350mod tests {
2351    use super::*;
2352    use bock_air::{AirArg, AirRecordField};
2353    use bock_ast::{GenericParam, Ident, TypeExpr, TypePath};
2354    use bock_errors::{FileId, Span};
2355
2356    fn span() -> Span {
2357        Span {
2358            file: FileId(0),
2359            start: 0,
2360            end: 0,
2361        }
2362    }
2363
2364    fn ident(name: &str) -> Ident {
2365        Ident {
2366            name: name.to_string(),
2367            span: span(),
2368        }
2369    }
2370
2371    fn type_path(segments: &[&str]) -> TypePath {
2372        TypePath {
2373            segments: segments.iter().map(|s| ident(s)).collect(),
2374            span: span(),
2375        }
2376    }
2377
2378    fn node(id: u32, kind: NodeKind) -> AIRNode {
2379        AIRNode::new(id, span(), kind)
2380    }
2381
2382    fn int_lit(id: u32, val: &str) -> AIRNode {
2383        node(
2384            id,
2385            NodeKind::Literal {
2386                lit: Literal::Int(val.into()),
2387            },
2388        )
2389    }
2390
2391    fn str_lit(id: u32, val: &str) -> AIRNode {
2392        node(
2393            id,
2394            NodeKind::Literal {
2395                lit: Literal::String(val.into()),
2396            },
2397        )
2398    }
2399
2400    fn id_node(id: u32, name: &str) -> AIRNode {
2401        node(id, NodeKind::Identifier { name: ident(name) })
2402    }
2403
2404    fn bind_pat(id: u32, name: &str) -> AIRNode {
2405        node(
2406            id,
2407            NodeKind::BindPat {
2408                name: ident(name),
2409                is_mut: false,
2410            },
2411        )
2412    }
2413
2414    fn typed_param_node(id: u32, name: &str, ty_name: &str) -> AIRNode {
2415        node(
2416            id,
2417            NodeKind::Param {
2418                pattern: Box::new(bind_pat(id + 100, name)),
2419                ty: Some(Box::new(node(
2420                    id + 200,
2421                    NodeKind::TypeNamed {
2422                        path: type_path(&[ty_name]),
2423                        args: vec![],
2424                    },
2425                ))),
2426                default: None,
2427            },
2428        )
2429    }
2430
2431    fn type_node(id: u32, name: &str) -> AIRNode {
2432        node(
2433            id,
2434            NodeKind::TypeNamed {
2435                path: type_path(&[name]),
2436                args: vec![],
2437            },
2438        )
2439    }
2440
2441    fn block(id: u32, stmts: Vec<AIRNode>, tail: Option<AIRNode>) -> AIRNode {
2442        node(
2443            id,
2444            NodeKind::Block {
2445                stmts,
2446                tail: tail.map(Box::new),
2447            },
2448        )
2449    }
2450
2451    fn module(imports: Vec<AIRNode>, items: Vec<AIRNode>) -> AIRNode {
2452        node(
2453            0,
2454            NodeKind::Module {
2455                path: None,
2456                annotations: vec![],
2457                imports,
2458                items,
2459            },
2460        )
2461    }
2462
2463    fn gen(module: &AIRNode) -> String {
2464        let gen = TsGenerator::new();
2465        let result = gen.generate_module(module).unwrap();
2466        result.files[0].content.clone()
2467    }
2468
2469    fn make_generic_param(name: &str) -> GenericParam {
2470        GenericParam {
2471            id: 0,
2472            span: span(),
2473            name: ident(name),
2474            bounds: vec![],
2475        }
2476    }
2477
2478    fn make_bounded_generic_param(name: &str, bounds: &[&str]) -> GenericParam {
2479        GenericParam {
2480            id: 0,
2481            span: span(),
2482            name: ident(name),
2483            bounds: bounds.iter().map(|b| type_path(&[b])).collect(),
2484        }
2485    }
2486
2487    fn make_type_expr(name: &str) -> TypeExpr {
2488        TypeExpr::Named {
2489            id: 0,
2490            span: span(),
2491            path: type_path(&[name]),
2492            args: vec![],
2493        }
2494    }
2495
2496    fn make_record_field(name: &str, ty_name: &str) -> bock_ast::RecordDeclField {
2497        bock_ast::RecordDeclField {
2498            id: 0,
2499            span: span(),
2500            name: ident(name),
2501            ty: make_type_expr(ty_name),
2502            default: None,
2503        }
2504    }
2505
2506    // ── Basic tests ─────────────────────────────────────────────────────────
2507
2508    #[test]
2509    fn implements_code_generator_trait() {
2510        let gen = TsGenerator::new();
2511        assert_eq!(gen.target().id, "ts");
2512    }
2513
2514    #[test]
2515    fn empty_module() {
2516        let m = module(vec![], vec![]);
2517        let out = gen(&m);
2518        assert_eq!(out, "");
2519    }
2520
2521    #[test]
2522    fn output_has_ts_extension() {
2523        let gen = TsGenerator::new();
2524        let m = module(vec![], vec![]);
2525        let result = gen.generate_module(&m).unwrap();
2526        assert_eq!(result.files[0].path.to_str().unwrap(), "output.ts");
2527    }
2528
2529    // ── Type annotations ────────────────────────────────────────────────────
2530
2531    #[test]
2532    fn function_with_type_annotations() {
2533        let body = block(10, vec![], Some(id_node(11, "x")));
2534        let f = node(
2535            1,
2536            NodeKind::FnDecl {
2537                annotations: vec![],
2538                visibility: Visibility::Public,
2539                is_async: false,
2540                name: ident("add"),
2541                generic_params: vec![],
2542                params: vec![
2543                    typed_param_node(2, "x", "Int"),
2544                    typed_param_node(3, "y", "Int"),
2545                ],
2546                return_type: Some(Box::new(type_node(4, "Int"))),
2547                effect_clause: vec![],
2548                where_clause: vec![],
2549                body: Box::new(body),
2550            },
2551        );
2552        let out = gen(&module(vec![], vec![f]));
2553        assert!(out.contains("x: number"), "got: {out}");
2554        assert!(out.contains("y: number"), "got: {out}");
2555        assert!(out.contains("): number"), "got: {out}");
2556        assert!(out.contains("export function add"));
2557    }
2558
2559    #[test]
2560    fn function_without_type_annotations() {
2561        let body = block(10, vec![], Some(int_lit(11, "42")));
2562        let f = node(
2563            1,
2564            NodeKind::FnDecl {
2565                annotations: vec![],
2566                visibility: Visibility::Private,
2567                is_async: false,
2568                name: ident("answer"),
2569                generic_params: vec![],
2570                params: vec![],
2571                return_type: None,
2572                effect_clause: vec![],
2573                where_clause: vec![],
2574                body: Box::new(body),
2575            },
2576        );
2577        let out = gen(&module(vec![], vec![f]));
2578        assert!(out.contains("function answer()"), "got: {out}");
2579        assert!(!out.contains("export"), "got: {out}");
2580    }
2581
2582    // ── Generics ────────────────────────────────────────────────────────────
2583
2584    #[test]
2585    fn function_with_generics() {
2586        let body = block(10, vec![], Some(id_node(11, "x")));
2587        let f = node(
2588            1,
2589            NodeKind::FnDecl {
2590                annotations: vec![],
2591                visibility: Visibility::Private,
2592                is_async: false,
2593                name: ident("identity"),
2594                generic_params: vec![make_generic_param("T")],
2595                params: vec![typed_param_node(2, "x", "T")],
2596                return_type: Some(Box::new(type_node(3, "T"))),
2597                effect_clause: vec![],
2598                where_clause: vec![],
2599                body: Box::new(body),
2600            },
2601        );
2602        let out = gen(&module(vec![], vec![f]));
2603        assert!(out.contains("function identity<T>"), "got: {out}");
2604        assert!(out.contains("x: T"), "got: {out}");
2605        assert!(out.contains("): T"), "got: {out}");
2606    }
2607
2608    #[test]
2609    fn generics_with_bounds() {
2610        let body = block(10, vec![], Some(id_node(11, "x")));
2611        let f = node(
2612            1,
2613            NodeKind::FnDecl {
2614                annotations: vec![],
2615                visibility: Visibility::Private,
2616                is_async: false,
2617                name: ident("sorted"),
2618                generic_params: vec![make_bounded_generic_param("T", &["Comparable"])],
2619                params: vec![typed_param_node(2, "x", "T")],
2620                return_type: Some(Box::new(type_node(3, "T"))),
2621                effect_clause: vec![],
2622                where_clause: vec![],
2623                body: Box::new(body),
2624            },
2625        );
2626        let out = gen(&module(vec![], vec![f]));
2627        assert!(out.contains("T extends Comparable"), "got: {out}");
2628    }
2629
2630    // ── Traits → Interfaces ─────────────────────────────────────────────────
2631
2632    #[test]
2633    fn trait_becomes_interface() {
2634        let method = node(
2635            2,
2636            NodeKind::FnDecl {
2637                annotations: vec![],
2638                visibility: Visibility::Public,
2639                is_async: false,
2640                name: ident("area"),
2641                generic_params: vec![],
2642                params: vec![],
2643                return_type: Some(Box::new(type_node(3, "Float"))),
2644                effect_clause: vec![],
2645                where_clause: vec![],
2646                body: Box::new(block(4, vec![], None)),
2647            },
2648        );
2649        let trait_decl = node(
2650            1,
2651            NodeKind::TraitDecl {
2652                annotations: vec![],
2653                visibility: Visibility::Public,
2654                is_platform: false,
2655                name: ident("Shape"),
2656                generic_params: vec![],
2657                associated_types: vec![],
2658                methods: vec![method],
2659            },
2660        );
2661        let out = gen(&module(vec![], vec![trait_decl]));
2662        assert!(out.contains("export interface Shape"), "got: {out}");
2663        assert!(out.contains("area(): number"), "got: {out}");
2664    }
2665
2666    #[test]
2667    fn trait_with_generics() {
2668        let method = node(
2669            2,
2670            NodeKind::FnDecl {
2671                annotations: vec![],
2672                visibility: Visibility::Public,
2673                is_async: false,
2674                name: ident("compare"),
2675                generic_params: vec![],
2676                params: vec![typed_param_node(3, "other", "T")],
2677                return_type: Some(Box::new(type_node(4, "Int"))),
2678                effect_clause: vec![],
2679                where_clause: vec![],
2680                body: Box::new(block(5, vec![], None)),
2681            },
2682        );
2683        let trait_decl = node(
2684            1,
2685            NodeKind::TraitDecl {
2686                annotations: vec![],
2687                visibility: Visibility::Public,
2688                is_platform: false,
2689                name: ident("Comparable"),
2690                generic_params: vec![make_generic_param("T")],
2691                associated_types: vec![],
2692                methods: vec![method],
2693            },
2694        );
2695        let out = gen(&module(vec![], vec![trait_decl]));
2696        assert!(out.contains("interface Comparable<T>"), "got: {out}");
2697        assert!(out.contains("compare(other: T): number"), "got: {out}");
2698    }
2699
2700    // ── Records → Interfaces ────────────────────────────────────────────────
2701
2702    #[test]
2703    fn record_becomes_interface_and_factory() {
2704        let record = node(
2705            1,
2706            NodeKind::RecordDecl {
2707                annotations: vec![],
2708                visibility: Visibility::Public,
2709                name: ident("Point"),
2710                generic_params: vec![],
2711                fields: vec![
2712                    make_record_field("x", "Float"),
2713                    make_record_field("y", "Float"),
2714                ],
2715            },
2716        );
2717        let out = gen(&module(vec![], vec![record]));
2718        assert!(out.contains("export class Point"), "got: {out}");
2719        assert!(out.contains("x: number"), "got: {out}");
2720        assert!(out.contains("y: number"), "got: {out}");
2721        assert!(
2722            out.contains("constructor({ x, y }: { x: number; y: number })"),
2723            "got: {out}"
2724        );
2725        assert!(out.contains("this.x = x;"), "got: {out}");
2726        assert!(out.contains("this.y = y;"), "got: {out}");
2727    }
2728
2729    // ── Enums → Discriminated unions ────────────────────────────────────────
2730
2731    #[test]
2732    fn enum_becomes_discriminated_union() {
2733        let variants = vec![
2734            node(
2735                2,
2736                NodeKind::EnumVariant {
2737                    name: ident("None"),
2738                    payload: EnumVariantPayload::Unit,
2739                },
2740            ),
2741            node(
2742                3,
2743                NodeKind::EnumVariant {
2744                    name: ident("Some"),
2745                    payload: EnumVariantPayload::Struct(vec![make_record_field("value", "T")]),
2746                },
2747            ),
2748        ];
2749        let enum_decl = node(
2750            1,
2751            NodeKind::EnumDecl {
2752                annotations: vec![],
2753                visibility: Visibility::Public,
2754                name: ident("Option"),
2755                generic_params: vec![make_generic_param("T")],
2756                variants,
2757            },
2758        );
2759        let out = gen(&module(vec![], vec![enum_decl]));
2760        // Union type
2761        assert!(
2762            out.contains("export type Option<T> = Option_None | Option_Some;"),
2763            "got: {out}"
2764        );
2765        // Unit variant
2766        assert!(out.contains("interface Option_None"), "got: {out}");
2767        assert!(out.contains("readonly _tag: \"None\""), "got: {out}");
2768        // Struct variant
2769        assert!(out.contains("interface Option_Some<T>"), "got: {out}");
2770        assert!(out.contains("readonly value: T"), "got: {out}");
2771        assert!(
2772            out.contains("function Option_Some<T>(value: T): Option_Some"),
2773            "got: {out}"
2774        );
2775    }
2776
2777    // ── Type aliases ────────────────────────────────────────────────────────
2778
2779    #[test]
2780    fn type_alias_emitted() {
2781        let alias = node(
2782            1,
2783            NodeKind::TypeAlias {
2784                annotations: vec![],
2785                visibility: Visibility::Public,
2786                name: ident("UserId"),
2787                generic_params: vec![],
2788                ty: Box::new(type_node(2, "String")),
2789                where_clause: vec![],
2790            },
2791        );
2792        let out = gen(&module(vec![], vec![alias]));
2793        assert!(out.contains("export type UserId = string;"), "got: {out}");
2794    }
2795
2796    #[test]
2797    fn generic_type_alias() {
2798        let alias = node(
2799            1,
2800            NodeKind::TypeAlias {
2801                annotations: vec![],
2802                visibility: Visibility::Private,
2803                name: ident("Pair"),
2804                generic_params: vec![make_generic_param("A"), make_generic_param("B")],
2805                ty: Box::new(node(
2806                    2,
2807                    NodeKind::TypeTuple {
2808                        elems: vec![type_node(3, "A"), type_node(4, "B")],
2809                    },
2810                )),
2811                where_clause: vec![],
2812            },
2813        );
2814        let out = gen(&module(vec![], vec![alias]));
2815        assert!(out.contains("type Pair<A, B> = [A, B];"), "got: {out}");
2816    }
2817
2818    // ── Effects → typed parameters ──────────────────────────────────────────
2819
2820    #[test]
2821    fn effects_as_typed_params() {
2822        let body = block(10, vec![], None);
2823        let f = node(
2824            1,
2825            NodeKind::FnDecl {
2826                annotations: vec![],
2827                visibility: Visibility::Private,
2828                is_async: false,
2829                name: ident("process"),
2830                generic_params: vec![],
2831                params: vec![typed_param_node(2, "data", "String")],
2832                return_type: None,
2833                effect_clause: vec![type_path(&["Log"]), type_path(&["Clock"])],
2834                where_clause: vec![],
2835                body: Box::new(body),
2836            },
2837        );
2838        let out = gen(&module(vec![], vec![f]));
2839        assert!(
2840            out.contains("{ log, clock }: { log: Log, clock: Clock }"),
2841            "got: {out}"
2842        );
2843    }
2844
2845    // ── Async functions ─────────────────────────────────────────────────────
2846
2847    #[test]
2848    fn async_function_with_types() {
2849        let body = block(10, vec![], Some(str_lit(11, "done")));
2850        let f = node(
2851            1,
2852            NodeKind::FnDecl {
2853                annotations: vec![],
2854                visibility: Visibility::Public,
2855                is_async: true,
2856                name: ident("fetch"),
2857                generic_params: vec![],
2858                params: vec![typed_param_node(2, "url", "String")],
2859                return_type: Some(Box::new(type_node(3, "String"))),
2860                effect_clause: vec![],
2861                where_clause: vec![],
2862                body: Box::new(body),
2863            },
2864        );
2865        let out = gen(&module(vec![], vec![f]));
2866        assert!(out.contains("export async function fetch"), "got: {out}");
2867        assert!(out.contains("url: string"), "got: {out}");
2868        // Async declared return type is wrapped in Promise<T>.
2869        assert!(out.contains("): Promise<string>"), "got: {out}");
2870    }
2871
2872    #[test]
2873    fn async_function_without_return_type_is_promise_void() {
2874        let body = block(10, vec![], None);
2875        let f = node(
2876            1,
2877            NodeKind::FnDecl {
2878                annotations: vec![],
2879                visibility: Visibility::Private,
2880                is_async: true,
2881                name: ident("tick"),
2882                generic_params: vec![],
2883                params: vec![],
2884                return_type: None,
2885                effect_clause: vec![],
2886                where_clause: vec![],
2887                body: Box::new(body),
2888            },
2889        );
2890        let out = gen(&module(vec![], vec![f]));
2891        assert!(out.contains("async function tick()"), "got: {out}");
2892        assert!(out.contains("): Promise<void>"), "got: {out}");
2893    }
2894
2895    #[test]
2896    fn sync_function_return_type_unchanged() {
2897        let body = block(10, vec![], Some(str_lit(11, "done")));
2898        let f = node(
2899            1,
2900            NodeKind::FnDecl {
2901                annotations: vec![],
2902                visibility: Visibility::Private,
2903                is_async: false,
2904                name: ident("hello"),
2905                generic_params: vec![],
2906                params: vec![],
2907                return_type: Some(Box::new(type_node(2, "String"))),
2908                effect_clause: vec![],
2909                where_clause: vec![],
2910                body: Box::new(body),
2911            },
2912        );
2913        let out = gen(&module(vec![], vec![f]));
2914        assert!(out.contains("function hello(): string"), "got: {out}");
2915        assert!(!out.contains("Promise"), "got: {out}");
2916    }
2917
2918    #[test]
2919    fn entry_invocation_async_main_ts() {
2920        let inv = TsGenerator::new().entry_invocation(true).unwrap();
2921        assert!(inv.contains("async () =>"));
2922        assert!(inv.contains("await main()"));
2923    }
2924
2925    #[test]
2926    fn generate_project_async_main_wraps_entry_ts() {
2927        let main_fn = node(
2928            1,
2929            NodeKind::FnDecl {
2930                annotations: vec![],
2931                visibility: Visibility::Private,
2932                is_async: true,
2933                name: ident("main"),
2934                generic_params: vec![],
2935                params: vec![],
2936                return_type: None,
2937                effect_clause: vec![],
2938                where_clause: vec![],
2939                body: Box::new(block(2, vec![], None)),
2940            },
2941        );
2942        let m = module(vec![], vec![main_fn]);
2943        let gen = TsGenerator::new();
2944        let out = gen.generate_project(&[&m]).unwrap();
2945        let src = &out.files[0].content;
2946        assert!(src.contains("async function main()"), "got: {src}");
2947        assert!(
2948            src.contains("(async () => { await main(); })();"),
2949            "got: {src}"
2950        );
2951    }
2952
2953    // ── Let bindings with type annotations ──────────────────────────────────
2954
2955    #[test]
2956    fn let_binding_with_type() {
2957        let stmt = node(
2958            1,
2959            NodeKind::LetBinding {
2960                is_mut: false,
2961                pattern: Box::new(bind_pat(2, "x")),
2962                ty: Some(Box::new(type_node(3, "Int"))),
2963                value: Box::new(int_lit(4, "42")),
2964            },
2965        );
2966        let f = node(
2967            5,
2968            NodeKind::FnDecl {
2969                annotations: vec![],
2970                visibility: Visibility::Private,
2971                is_async: false,
2972                name: ident("test"),
2973                generic_params: vec![],
2974                params: vec![],
2975                return_type: None,
2976                effect_clause: vec![],
2977                where_clause: vec![],
2978                body: Box::new(block(6, vec![stmt], None)),
2979            },
2980        );
2981        let out = gen(&module(vec![], vec![f]));
2982        assert!(out.contains("const x: number = 42;"), "got: {out}");
2983    }
2984
2985    #[test]
2986    fn mutable_binding_with_type() {
2987        let stmt = node(
2988            1,
2989            NodeKind::LetBinding {
2990                is_mut: true,
2991                pattern: Box::new(bind_pat(2, "count")),
2992                ty: Some(Box::new(type_node(3, "Int"))),
2993                value: Box::new(int_lit(4, "0")),
2994            },
2995        );
2996        let f = node(
2997            5,
2998            NodeKind::FnDecl {
2999                annotations: vec![],
3000                visibility: Visibility::Private,
3001                is_async: false,
3002                name: ident("test"),
3003                generic_params: vec![],
3004                params: vec![],
3005                return_type: None,
3006                effect_clause: vec![],
3007                where_clause: vec![],
3008                body: Box::new(block(6, vec![stmt], None)),
3009            },
3010        );
3011        let out = gen(&module(vec![], vec![f]));
3012        assert!(out.contains("let count: number = 0;"), "got: {out}");
3013    }
3014
3015    // ── Type mapping ────────────────────────────────────────────────────────
3016
3017    #[test]
3018    fn type_mapping_primitives() {
3019        let ctx = TsEmitCtx::new();
3020        assert_eq!(ctx.map_type_name("Int"), "number");
3021        assert_eq!(ctx.map_type_name("Float"), "number");
3022        assert_eq!(ctx.map_type_name("Bool"), "boolean");
3023        assert_eq!(ctx.map_type_name("String"), "string");
3024        assert_eq!(ctx.map_type_name("Void"), "void");
3025        assert_eq!(ctx.map_type_name("Unit"), "void");
3026        assert_eq!(ctx.map_type_name("List"), "Array");
3027        assert_eq!(ctx.map_type_name("CustomType"), "CustomType");
3028    }
3029
3030    #[test]
3031    fn optional_type_emitted() {
3032        let ctx = TsEmitCtx::new();
3033        let opt = node(
3034            1,
3035            NodeKind::TypeOptional {
3036                inner: Box::new(type_node(2, "String")),
3037            },
3038        );
3039        assert_eq!(ctx.type_to_ts(&opt), "string | null");
3040    }
3041
3042    #[test]
3043    fn generic_type_args() {
3044        let ctx = TsEmitCtx::new();
3045        let list_of_int = node(
3046            1,
3047            NodeKind::TypeNamed {
3048                path: type_path(&["List"]),
3049                args: vec![type_node(2, "Int")],
3050            },
3051        );
3052        assert_eq!(ctx.type_to_ts(&list_of_int), "Array<number>");
3053    }
3054
3055    #[test]
3056    fn function_type() {
3057        let ctx = TsEmitCtx::new();
3058        let fn_type = node(
3059            1,
3060            NodeKind::TypeFunction {
3061                params: vec![type_node(2, "Int"), type_node(3, "String")],
3062                ret: Box::new(type_node(4, "Bool")),
3063                effects: vec![],
3064            },
3065        );
3066        assert_eq!(
3067            ctx.type_to_ts(&fn_type),
3068            "(arg0: number, arg1: string) => boolean"
3069        );
3070    }
3071
3072    #[test]
3073    fn tuple_type() {
3074        let ctx = TsEmitCtx::new();
3075        let tuple = node(
3076            1,
3077            NodeKind::TypeTuple {
3078                elems: vec![type_node(2, "Int"), type_node(3, "String")],
3079            },
3080        );
3081        assert_eq!(ctx.type_to_ts(&tuple), "[number, string]");
3082    }
3083
3084    // ── Constants with types ────────────────────────────────────────────────
3085
3086    #[test]
3087    fn const_with_type() {
3088        let c = node(
3089            1,
3090            NodeKind::ConstDecl {
3091                annotations: vec![],
3092                visibility: Visibility::Public,
3093                name: ident("PI"),
3094                ty: Box::new(type_node(2, "Float")),
3095                value: Box::new(node(
3096                    3,
3097                    NodeKind::Literal {
3098                        lit: Literal::Float("3.14159".into()),
3099                    },
3100                )),
3101            },
3102        );
3103        let out = gen(&module(vec![], vec![c]));
3104        assert!(
3105            out.contains("export const PI: number = 3.14159;"),
3106            "got: {out}"
3107        );
3108    }
3109
3110    // ── Class with types ────────────────────────────────────────────────────
3111
3112    #[test]
3113    fn class_with_typed_fields() {
3114        let class = node(
3115            1,
3116            NodeKind::ClassDecl {
3117                annotations: vec![],
3118                visibility: Visibility::Public,
3119                name: ident("Point"),
3120                generic_params: vec![],
3121                base: None,
3122                traits: vec![],
3123                fields: vec![
3124                    make_record_field("x", "Float"),
3125                    make_record_field("y", "Float"),
3126                ],
3127                methods: vec![],
3128            },
3129        );
3130        let out = gen(&module(vec![], vec![class]));
3131        assert!(out.contains("export class Point"), "got: {out}");
3132        assert!(out.contains("x: number;"), "got: {out}");
3133        assert!(out.contains("y: number;"), "got: {out}");
3134        assert!(
3135            out.contains("constructor(x: number, y: number)"),
3136            "got: {out}"
3137        );
3138    }
3139
3140    // ── Effect declarations → interfaces ────────────────────────────────────
3141
3142    #[test]
3143    fn effect_becomes_interface() {
3144        let op = node(
3145            2,
3146            NodeKind::FnDecl {
3147                annotations: vec![],
3148                visibility: Visibility::Public,
3149                is_async: false,
3150                name: ident("log"),
3151                generic_params: vec![],
3152                params: vec![typed_param_node(3, "msg", "String")],
3153                return_type: Some(Box::new(type_node(4, "Void"))),
3154                effect_clause: vec![],
3155                where_clause: vec![],
3156                body: Box::new(block(5, vec![], None)),
3157            },
3158        );
3159        let effect = node(
3160            1,
3161            NodeKind::EffectDecl {
3162                annotations: vec![],
3163                visibility: Visibility::Public,
3164                name: ident("Logger"),
3165                generic_params: vec![],
3166                components: vec![],
3167                operations: vec![op],
3168            },
3169        );
3170        let out = gen(&module(vec![], vec![effect]));
3171        assert!(out.contains("interface Logger"), "got: {out}");
3172        assert!(out.contains("log(msg: string): void"), "got: {out}");
3173    }
3174
3175    // ── Ownership erasure ───────────────────────────────────────────────────
3176
3177    #[test]
3178    fn ownership_erased() {
3179        let move_expr = node(
3180            1,
3181            NodeKind::Move {
3182                expr: Box::new(id_node(2, "x")),
3183            },
3184        );
3185        let borrow_expr = node(
3186            3,
3187            NodeKind::Borrow {
3188                expr: Box::new(id_node(4, "y")),
3189            },
3190        );
3191        let stmts = vec![
3192            node(
3193                5,
3194                NodeKind::LetBinding {
3195                    is_mut: false,
3196                    pattern: Box::new(bind_pat(6, "a")),
3197                    ty: None,
3198                    value: Box::new(move_expr),
3199                },
3200            ),
3201            node(
3202                7,
3203                NodeKind::LetBinding {
3204                    is_mut: false,
3205                    pattern: Box::new(bind_pat(8, "b")),
3206                    ty: None,
3207                    value: Box::new(borrow_expr),
3208                },
3209            ),
3210        ];
3211        let f = node(
3212            9,
3213            NodeKind::FnDecl {
3214                annotations: vec![],
3215                visibility: Visibility::Private,
3216                is_async: false,
3217                name: ident("test"),
3218                generic_params: vec![],
3219                params: vec![],
3220                return_type: None,
3221                effect_clause: vec![],
3222                where_clause: vec![],
3223                body: Box::new(block(10, stmts, None)),
3224            },
3225        );
3226        let out = gen(&module(vec![], vec![f]));
3227        assert!(out.contains("const a = x;"), "got: {out}");
3228        assert!(out.contains("const b = y;"), "got: {out}");
3229    }
3230
3231    // ── String interpolation ────────────────────────────────────────────────
3232
3233    #[test]
3234    fn string_interpolation() {
3235        let interp = node(
3236            1,
3237            NodeKind::Interpolation {
3238                parts: vec![
3239                    AirInterpolationPart::Literal("Hello, ".into()),
3240                    AirInterpolationPart::Expr(Box::new(id_node(2, "name"))),
3241                    AirInterpolationPart::Literal("!".into()),
3242                ],
3243            },
3244        );
3245        let stmt = node(
3246            3,
3247            NodeKind::LetBinding {
3248                is_mut: false,
3249                pattern: Box::new(bind_pat(4, "msg")),
3250                ty: None,
3251                value: Box::new(interp),
3252            },
3253        );
3254        let f = node(
3255            5,
3256            NodeKind::FnDecl {
3257                annotations: vec![],
3258                visibility: Visibility::Private,
3259                is_async: false,
3260                name: ident("test"),
3261                generic_params: vec![],
3262                params: vec![],
3263                return_type: None,
3264                effect_clause: vec![],
3265                where_clause: vec![],
3266                body: Box::new(block(6, vec![stmt], None)),
3267            },
3268        );
3269        let out = gen(&module(vec![], vec![f]));
3270        assert!(out.contains("`Hello, ${name}!`"), "got: {out}");
3271    }
3272
3273    // ── Collections ─────────────────────────────────────────────────────────
3274
3275    #[test]
3276    fn collections() {
3277        let list = node(
3278            1,
3279            NodeKind::ListLiteral {
3280                elems: vec![int_lit(2, "1"), int_lit(3, "2"), int_lit(4, "3")],
3281            },
3282        );
3283        let map = node(
3284            5,
3285            NodeKind::MapLiteral {
3286                entries: vec![bock_air::AirMapEntry {
3287                    key: str_lit(6, "a"),
3288                    value: int_lit(7, "1"),
3289                }],
3290            },
3291        );
3292        let set = node(
3293            8,
3294            NodeKind::SetLiteral {
3295                elems: vec![int_lit(9, "1"), int_lit(10, "2")],
3296            },
3297        );
3298        let stmts = vec![
3299            node(
3300                11,
3301                NodeKind::LetBinding {
3302                    is_mut: false,
3303                    pattern: Box::new(bind_pat(12, "xs")),
3304                    ty: None,
3305                    value: Box::new(list),
3306                },
3307            ),
3308            node(
3309                13,
3310                NodeKind::LetBinding {
3311                    is_mut: false,
3312                    pattern: Box::new(bind_pat(14, "m")),
3313                    ty: None,
3314                    value: Box::new(map),
3315                },
3316            ),
3317            node(
3318                15,
3319                NodeKind::LetBinding {
3320                    is_mut: false,
3321                    pattern: Box::new(bind_pat(16, "s")),
3322                    ty: None,
3323                    value: Box::new(set),
3324                },
3325            ),
3326        ];
3327        let f = node(
3328            17,
3329            NodeKind::FnDecl {
3330                annotations: vec![],
3331                visibility: Visibility::Private,
3332                is_async: false,
3333                name: ident("test"),
3334                generic_params: vec![],
3335                params: vec![],
3336                return_type: None,
3337                effect_clause: vec![],
3338                where_clause: vec![],
3339                body: Box::new(block(18, stmts, None)),
3340            },
3341        );
3342        let out = gen(&module(vec![], vec![f]));
3343        assert!(out.contains("[1, 2, 3]"), "got: {out}");
3344        assert!(out.contains("new Map("), "got: {out}");
3345        assert!(out.contains("new Set("), "got: {out}");
3346    }
3347
3348    // ── Result types with as const ──────────────────────────────────────────
3349
3350    #[test]
3351    fn result_construct_has_as_const() {
3352        let ok = node(
3353            1,
3354            NodeKind::ResultConstruct {
3355                variant: ResultVariant::Ok,
3356                value: Some(Box::new(int_lit(2, "42"))),
3357            },
3358        );
3359        let stmt = node(
3360            3,
3361            NodeKind::LetBinding {
3362                is_mut: false,
3363                pattern: Box::new(bind_pat(4, "r")),
3364                ty: None,
3365                value: Box::new(ok),
3366            },
3367        );
3368        let f = node(
3369            5,
3370            NodeKind::FnDecl {
3371                annotations: vec![],
3372                visibility: Visibility::Private,
3373                is_async: false,
3374                name: ident("test"),
3375                generic_params: vec![],
3376                params: vec![],
3377                return_type: None,
3378                effect_clause: vec![],
3379                where_clause: vec![],
3380                body: Box::new(block(6, vec![stmt], None)),
3381            },
3382        );
3383        let out = gen(&module(vec![], vec![f]));
3384        assert!(out.contains("\"Ok\" as const"), "got: {out}");
3385    }
3386
3387    // ── Record construct ────────────────────────────────────────────────────
3388
3389    #[test]
3390    fn record_construct() {
3391        let rc = node(
3392            1,
3393            NodeKind::RecordConstruct {
3394                path: type_path(&["Point"]),
3395                fields: vec![
3396                    AirRecordField {
3397                        name: ident("x"),
3398                        value: Some(Box::new(int_lit(2, "1"))),
3399                    },
3400                    AirRecordField {
3401                        name: ident("y"),
3402                        value: Some(Box::new(int_lit(3, "2"))),
3403                    },
3404                ],
3405                spread: None,
3406            },
3407        );
3408        let stmt = node(
3409            4,
3410            NodeKind::LetBinding {
3411                is_mut: false,
3412                pattern: Box::new(bind_pat(5, "p")),
3413                ty: None,
3414                value: Box::new(rc),
3415            },
3416        );
3417        let f = node(
3418            6,
3419            NodeKind::FnDecl {
3420                annotations: vec![],
3421                visibility: Visibility::Private,
3422                is_async: false,
3423                name: ident("test"),
3424                generic_params: vec![],
3425                params: vec![],
3426                return_type: None,
3427                effect_clause: vec![],
3428                where_clause: vec![],
3429                body: Box::new(block(7, vec![stmt], None)),
3430            },
3431        );
3432        let out = gen(&module(vec![], vec![f]));
3433        assert!(out.contains("{ x: 1, y: 2 }"), "got: {out}");
3434    }
3435
3436    #[test]
3437    fn to_camel_case_converts_snake_case() {
3438        assert_eq!(to_camel_case("create_user"), "createUser");
3439        assert_eq!(to_camel_case("get_all_items"), "getAllItems");
3440        assert_eq!(to_camel_case("Log"), "log");
3441        assert_eq!(to_camel_case("createUser"), "createUser");
3442        assert_eq!(to_camel_case("_"), "_");
3443        assert_eq!(to_camel_case(""), "");
3444    }
3445
3446    #[test]
3447    fn snake_case_fn_becomes_camel_case_ts() {
3448        let body = block(2, vec![], Some(int_lit(3, "42")));
3449        let f = node(
3450            1,
3451            NodeKind::FnDecl {
3452                annotations: vec![],
3453                visibility: Visibility::Private,
3454                is_async: false,
3455                name: ident("create_user"),
3456                generic_params: vec![],
3457                params: vec![typed_param_node(4, "name", "String")],
3458                return_type: Some(Box::new(type_node(5, "Int"))),
3459                effect_clause: vec![],
3460                where_clause: vec![],
3461                body: Box::new(body),
3462            },
3463        );
3464        let out = gen(&module(vec![], vec![f]));
3465        assert!(
3466            out.contains("function createUser("),
3467            "expected camelCase function name, got: {out}"
3468        );
3469        assert!(
3470            out.contains("name: string"),
3471            "expected type annotations, got: {out}"
3472        );
3473    }
3474
3475    // ── Prelude function mapping tests ──────────────────────────────────────
3476
3477    /// Helper: generate TypeScript for a module with a `main` function containing a single call.
3478    fn gen_prelude_call(func_name: &str, arg: AIRNode) -> String {
3479        let call = node(
3480            10,
3481            NodeKind::Call {
3482                callee: Box::new(id_node(11, func_name)),
3483                args: vec![AirArg {
3484                    label: None,
3485                    value: arg,
3486                }],
3487                type_args: vec![],
3488            },
3489        );
3490        let body = block(2, vec![call], None);
3491        let f = node(
3492            1,
3493            NodeKind::FnDecl {
3494                name: ident("main"),
3495                params: vec![],
3496                return_type: None,
3497                body: Box::new(body),
3498                generic_params: vec![],
3499                visibility: Visibility::Private,
3500                annotations: vec![],
3501                effect_clause: vec![],
3502                where_clause: vec![],
3503                is_async: false,
3504            },
3505        );
3506        gen(&module(vec![], vec![f]))
3507    }
3508
3509    /// Helper: generate TypeScript for a nullary prelude call (no args).
3510    fn gen_prelude_call_no_args(func_name: &str) -> String {
3511        let call = node(
3512            10,
3513            NodeKind::Call {
3514                callee: Box::new(id_node(11, func_name)),
3515                args: vec![],
3516                type_args: vec![],
3517            },
3518        );
3519        let body = block(2, vec![call], None);
3520        let f = node(
3521            1,
3522            NodeKind::FnDecl {
3523                name: ident("main"),
3524                params: vec![],
3525                return_type: None,
3526                body: Box::new(body),
3527                generic_params: vec![],
3528                visibility: Visibility::Private,
3529                annotations: vec![],
3530                effect_clause: vec![],
3531                where_clause: vec![],
3532                is_async: false,
3533            },
3534        );
3535        gen(&module(vec![], vec![f]))
3536    }
3537
3538    #[test]
3539    fn prelude_println_maps_to_console_log() {
3540        let out = gen_prelude_call("println", str_lit(12, "hello"));
3541        assert!(
3542            out.contains("console.log("),
3543            "println should map to console.log, got: {out}"
3544        );
3545        assert!(
3546            !out.contains("println("),
3547            "should not emit bare println(, got: {out}"
3548        );
3549    }
3550
3551    #[test]
3552    fn prelude_print_maps_to_process_stdout() {
3553        let out = gen_prelude_call("print", str_lit(12, "hello"));
3554        assert!(
3555            out.contains("process.stdout.write(String("),
3556            "print should map to process.stdout.write, got: {out}"
3557        );
3558    }
3559
3560    #[test]
3561    fn prelude_debug_maps_to_console_debug() {
3562        let out = gen_prelude_call("debug", str_lit(12, "val"));
3563        assert!(
3564            out.contains("console.debug("),
3565            "debug should map to console.debug, got: {out}"
3566        );
3567    }
3568
3569    #[test]
3570    fn prelude_assert_maps_to_throw() {
3571        let arg = node(
3572            12,
3573            NodeKind::Literal {
3574                lit: Literal::Bool(true),
3575            },
3576        );
3577        let out = gen_prelude_call("assert", arg);
3578        assert!(
3579            out.contains("if (!true) throw new Error(\"assertion failed\")"),
3580            "assert should map to if-throw, got: {out}"
3581        );
3582    }
3583
3584    #[test]
3585    fn prelude_todo_maps_to_throw_not_implemented() {
3586        let out = gen_prelude_call_no_args("todo");
3587        assert!(
3588            out.contains("throw new Error(\"not implemented\")"),
3589            "todo should map to throw, got: {out}"
3590        );
3591    }
3592
3593    #[test]
3594    fn prelude_unreachable_maps_to_throw_unreachable() {
3595        let out = gen_prelude_call_no_args("unreachable");
3596        assert!(
3597            out.contains("throw new Error(\"unreachable\")"),
3598            "unreachable should map to throw, got: {out}"
3599        );
3600    }
3601
3602    #[test]
3603    fn non_prelude_call_passes_through() {
3604        let out = gen_prelude_call("my_custom_func", str_lit(12, "arg"));
3605        assert!(
3606            out.contains("myCustomFunc("),
3607            "non-prelude call should use camelCase, got: {out}"
3608        );
3609    }
3610
3611    #[test]
3612    fn handling_block_passes_handlers_to_effectful_call() {
3613        use bock_air::AirHandlerPair;
3614
3615        let effect_decl = node(
3616            1,
3617            NodeKind::EffectDecl {
3618                annotations: vec![],
3619                visibility: Visibility::Public,
3620                name: ident("Logger"),
3621                generic_params: vec![],
3622                components: vec![],
3623                operations: vec![node(
3624                    2,
3625                    NodeKind::FnDecl {
3626                        annotations: vec![],
3627                        visibility: Visibility::Public,
3628                        is_async: false,
3629                        name: ident("log"),
3630                        generic_params: vec![],
3631                        params: vec![typed_param_node(3, "msg", "String")],
3632                        return_type: None,
3633                        effect_clause: vec![],
3634                        where_clause: vec![],
3635                        body: Box::new(block(4, vec![], None)),
3636                    },
3637                )],
3638            },
3639        );
3640
3641        let inner_fn = node(
3642            10,
3643            NodeKind::FnDecl {
3644                annotations: vec![],
3645                visibility: Visibility::Private,
3646                is_async: false,
3647                name: ident("inner"),
3648                generic_params: vec![],
3649                params: vec![],
3650                return_type: None,
3651                effect_clause: vec![type_path(&["Logger"])],
3652                where_clause: vec![],
3653                body: Box::new(block(12, vec![], Some(str_lit(13, "hello")))),
3654            },
3655        );
3656
3657        let call_inner = node(
3658            20,
3659            NodeKind::Call {
3660                callee: Box::new(id_node(21, "inner")),
3661                args: vec![],
3662                type_args: vec![],
3663            },
3664        );
3665        let handling = node(
3666            30,
3667            NodeKind::HandlingBlock {
3668                handlers: vec![AirHandlerPair {
3669                    effect: type_path(&["Logger"]),
3670                    handler: Box::new(node(
3671                        31,
3672                        NodeKind::Call {
3673                            callee: Box::new(id_node(32, "StdoutLogger")),
3674                            args: vec![],
3675                            type_args: vec![],
3676                        },
3677                    )),
3678                }],
3679                body: Box::new(block(33, vec![], Some(call_inner))),
3680            },
3681        );
3682        let main_fn = node(
3683            40,
3684            NodeKind::FnDecl {
3685                annotations: vec![],
3686                visibility: Visibility::Private,
3687                is_async: false,
3688                name: ident("main"),
3689                generic_params: vec![],
3690                params: vec![],
3691                return_type: None,
3692                effect_clause: vec![],
3693                where_clause: vec![],
3694                body: Box::new(block(41, vec![handling], None)),
3695            },
3696        );
3697
3698        let out = gen(&module(vec![], vec![effect_decl, inner_fn, main_fn]));
3699        // TS: inner({ logger: __logger })
3700        assert!(
3701            out.contains("inner({ logger: __logger })"),
3702            "handling block should pass handler to effectful call, got: {out}"
3703        );
3704        assert!(
3705            out.contains("const __logger: Logger = stdoutLogger()"),
3706            "handling block should instantiate handler with type, got: {out}"
3707        );
3708    }
3709
3710    #[test]
3711    fn record_becomes_class() {
3712        let rec = node(
3713            1,
3714            NodeKind::RecordDecl {
3715                annotations: vec![],
3716                visibility: Visibility::Public,
3717                name: ident("ConsoleLogger"),
3718                generic_params: vec![],
3719                fields: vec![],
3720            },
3721        );
3722        let out = gen(&module(vec![], vec![rec]));
3723        assert!(
3724            out.contains("export class ConsoleLogger {}"),
3725            "empty record should be an empty exported class, got: {out}"
3726        );
3727    }
3728
3729    #[test]
3730    fn impl_emits_interface_extension_for_declaration_merging() {
3731        use bock_air::AirHandlerPair;
3732        let _ = AirHandlerPair {
3733            effect: type_path(&["X"]),
3734            handler: Box::new(id_node(0, "x")),
3735        };
3736
3737        let effect_decl = node(
3738            1,
3739            NodeKind::EffectDecl {
3740                annotations: vec![],
3741                visibility: Visibility::Public,
3742                name: ident("Logger"),
3743                generic_params: vec![],
3744                components: vec![],
3745                operations: vec![node(
3746                    2,
3747                    NodeKind::FnDecl {
3748                        annotations: vec![],
3749                        visibility: Visibility::Public,
3750                        is_async: false,
3751                        name: ident("log"),
3752                        generic_params: vec![],
3753                        params: vec![typed_param_node(3, "msg", "String")],
3754                        return_type: None,
3755                        effect_clause: vec![],
3756                        where_clause: vec![],
3757                        body: Box::new(block(4, vec![], None)),
3758                    },
3759                )],
3760            },
3761        );
3762
3763        let rec = node(
3764            5,
3765            NodeKind::RecordDecl {
3766                annotations: vec![],
3767                visibility: Visibility::Public,
3768                name: ident("StdLogger"),
3769                generic_params: vec![],
3770                fields: vec![],
3771            },
3772        );
3773
3774        let impl_block = node(
3775            10,
3776            NodeKind::ImplBlock {
3777                annotations: vec![],
3778                trait_path: Some(type_path(&["Logger"])),
3779                target: Box::new(type_node(11, "StdLogger")),
3780                generic_params: vec![],
3781                methods: vec![node(
3782                    12,
3783                    NodeKind::FnDecl {
3784                        annotations: vec![],
3785                        visibility: Visibility::Public,
3786                        is_async: false,
3787                        name: ident("log"),
3788                        generic_params: vec![],
3789                        params: vec![typed_param_node(13, "msg", "String")],
3790                        return_type: None,
3791                        effect_clause: vec![],
3792                        where_clause: vec![],
3793                        body: Box::new(block(14, vec![], None)),
3794                    },
3795                )],
3796                where_clause: vec![],
3797            },
3798        );
3799
3800        let out = gen(&module(vec![], vec![effect_decl, rec, impl_block]));
3801        assert!(
3802            out.contains("interface StdLogger extends Logger {}"),
3803            "impl should emit interface extension for declaration merging, got: {out}"
3804        );
3805        assert!(
3806            out.contains("StdLogger.prototype.log"),
3807            "impl should attach method to prototype, got: {out}"
3808        );
3809    }
3810}