Skip to main content

bock_codegen/
js.rs

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