husk_codegen_js/
lib.rs

1//! Minimal JavaScript AST and pretty-printer for Husk codegen.
2//!
3//! This crate currently defines:
4//! - A small JS AST (expressions, statements, modules).
5//! - A pretty-printer that renders the AST to a JS source string.
6//! - A minimal lowering from Husk AST to this JS AST for simple functions.
7//! - Source map generation for debugging.
8
9use husk_ast::{
10    Block, EnumVariantFields, Expr, ExprKind, ExternItemKind, File, FormatSegment, FormatSpec,
11    Ident, ImplItemKind, ItemKind, LiteralKind, Param, Pattern, PatternKind, Span, Stmt, StmtKind,
12    StructField, TypeExpr, TypeExprKind, TypeParam,
13};
14use husk_runtime_js::std_preamble_js;
15use husk_semantic::{NameResolution, TypeResolution, VariantCallMap, VariantPatternMap};
16use sourcemap::SourceMapBuilder;
17use std::collections::HashMap;
18use std::path::Path;
19
20/// Convert snake_case to camelCase.
21/// Examples:
22/// - "status_code" -> "statusCode"
23/// - "last_insert_rowid" -> "lastInsertRowid"
24/// - "body" -> "body" (no change if no underscores)
25fn snake_to_camel(s: &str) -> String {
26    let mut result = String::with_capacity(s.len());
27    let mut capitalize_next = false;
28    for c in s.chars() {
29        if c == '_' {
30            capitalize_next = true;
31        } else if capitalize_next {
32            result.push(c.to_ascii_uppercase());
33            capitalize_next = false;
34        } else {
35            result.push(c);
36        }
37    }
38    result
39}
40
41/// Tracks extern property accessors (getters and setters) for codegen.
42///
43/// When an extern impl method matches the getter/setter pattern, we need to
44/// generate property access instead of method calls:
45/// - `obj.prop()` → `obj.prop` (getter)
46/// - `obj.set_prop(value)` → `obj.prop = value` (setter)
47#[derive(Debug, Clone, Default)]
48struct PropertyAccessors {
49    /// Maps (type_name, method_name) -> property_name for getters
50    /// e.g., ("Request", "body") -> "body"
51    getters: HashMap<(String, String), String>,
52    /// Maps (type_name, method_name) -> property_name for setters
53    /// e.g., ("Request", "set_body") -> "body"
54    setters: HashMap<(String, String), String>,
55    /// Maps (type_name, method_name) -> js_name for #[js_name] overrides
56    /// This allows explicit control over the JavaScript method name.
57    /// e.g., ("String", "len") -> "length" (for String.len() -> str.length)
58    method_js_names: HashMap<(String, String), String>,
59    /// Set of (type_name, method_name) pairs for extern "js" methods.
60    /// Only extern "js" methods get automatic snake_to_camel conversion;
61    /// user-defined Husk methods keep their original names.
62    extern_methods: std::collections::HashSet<(String, String)>,
63}
64
65/// Context for code generation, carrying compile-time state.
66///
67/// This struct is threaded through all lowering functions to provide:
68/// - Property accessors for extern type method rewrites
69/// - Source file path for compile-time operations like include_str
70/// - Name resolution for variable shadowing
71/// - Type resolution for conversion methods (.into(), .parse(), .try_into())
72/// - Variant call map for imported enum variant constructor calls
73/// - Variant pattern map for imported enum variant patterns in match
74#[derive(Debug, Clone)]
75struct CodegenContext<'a> {
76    /// Property accessors for extern types
77    accessors: &'a PropertyAccessors,
78    /// Path to the current source file being compiled (for include_str, etc.)
79    source_path: Option<&'a Path>,
80    /// Name resolution map from semantic analysis for variable shadowing.
81    /// Maps (span_start, span_end) -> resolved_name (e.g., "x", "x$1", "x$2")
82    name_resolution: &'a NameResolution,
83    /// Type resolution map from semantic analysis for conversion methods.
84    /// Maps (span_start, span_end) -> type_name (e.g., "String", "i32")
85    type_resolution: &'a TypeResolution,
86    /// Maps call spans to (enum_name, variant_name) for imported variant constructor calls.
87    variant_calls: &'a VariantCallMap,
88    /// Maps pattern spans to (enum_name, variant_name) for imported variant patterns in match.
89    variant_patterns: &'a VariantPatternMap,
90}
91
92impl<'a> CodegenContext<'a> {
93    fn new(
94        accessors: &'a PropertyAccessors,
95        name_resolution: &'a NameResolution,
96        type_resolution: &'a TypeResolution,
97        variant_calls: &'a VariantCallMap,
98        variant_patterns: &'a VariantPatternMap,
99    ) -> Self {
100        Self {
101            accessors,
102            source_path: None,
103            name_resolution,
104            type_resolution,
105            variant_calls,
106            variant_patterns,
107        }
108    }
109
110    fn with_source_path(
111        accessors: &'a PropertyAccessors,
112        source_path: &'a Path,
113        name_resolution: &'a NameResolution,
114        type_resolution: &'a TypeResolution,
115        variant_calls: &'a VariantCallMap,
116        variant_patterns: &'a VariantPatternMap,
117    ) -> Self {
118        Self {
119            accessors,
120            source_path: Some(source_path),
121            name_resolution,
122            type_resolution,
123            variant_calls,
124            variant_patterns,
125        }
126    }
127
128    /// Get the resolved name for a variable at the given span.
129    /// Returns the original name if not found in resolution map.
130    fn resolve_name(&self, name: &str, span: &Span) -> String {
131        self.name_resolution
132            .get(&(span.range.start, span.range.end))
133            .cloned()
134            .unwrap_or_else(|| name.to_string())
135    }
136
137    /// Generate a fresh temporary variable name.
138    fn fresh_temp(&self, prefix: &str) -> String {
139        use std::sync::atomic::{AtomicUsize, Ordering};
140        static COUNTER: AtomicUsize = AtomicUsize::new(0);
141        format!("{}_{}", prefix, COUNTER.fetch_add(1, Ordering::SeqCst))
142    }
143}
144
145/// Handle include_str("path") - reads file at compile time and returns contents as string literal.
146///
147/// The path is resolved relative to the source file being compiled.
148/// Panics with a clear error message if the file cannot be read.
149fn handle_include_str(args: &[Expr], ctx: &CodegenContext) -> JsExpr {
150    // Validate: exactly one argument
151    if args.len() != 1 {
152        panic!(
153            "include_str: expected 1 argument, got {}",
154            args.len()
155        );
156    }
157
158    // Extract the path argument (must be a string literal)
159    let path_arg = match &args[0].kind {
160        ExprKind::Literal(lit) => match &lit.kind {
161            LiteralKind::String(s) => s.clone(),
162            _ => {
163                panic!("include_str: argument must be a string literal");
164            }
165        },
166        _ => {
167            panic!("include_str: argument must be a string literal");
168        }
169    };
170
171    // Get the source path from context
172    let source_path = match ctx.source_path {
173        Some(path) => path,
174        None => {
175            panic!(
176                "include_str: source file path not available. \
177                 This is required to resolve the relative path '{}'",
178                path_arg
179            );
180        }
181    };
182
183    // Resolve path relative to current source file
184    let base_dir = source_path.parent().unwrap_or(Path::new("."));
185    let full_path = base_dir.join(&path_arg);
186
187    // Read file contents
188    match std::fs::read_to_string(&full_path) {
189        Ok(contents) => JsExpr::String(contents),
190        Err(e) => {
191            panic!(
192                "include_str: failed to read '{}': {}",
193                full_path.display(),
194                e
195            );
196        }
197    }
198}
199
200/// A JavaScript module (ES module) consisting of a list of statements.
201#[derive(Debug, Clone, PartialEq)]
202pub struct JsModule {
203    pub body: Vec<JsStmt>,
204}
205
206/// Source span for mapping back to Husk source.
207#[derive(Debug, Clone, PartialEq, Default)]
208pub struct SourceSpan {
209    /// Source line (0-indexed)
210    pub line: u32,
211    /// Source column (0-indexed)
212    pub column: u32,
213}
214
215/// Compute line and column (0-indexed) from a byte offset in source text.
216pub fn offset_to_line_col(source: &str, offset: usize) -> (u32, u32) {
217    let mut line = 0u32;
218    let mut col = 0u32;
219    for (i, ch) in source.char_indices() {
220        if i >= offset {
221            break;
222        }
223        if ch == '\n' {
224            line += 1;
225            col = 0;
226        } else {
227            col += 1;
228        }
229    }
230    (line, col)
231}
232
233/// Pattern element for array destructuring (supports nesting).
234#[derive(Debug, Clone, PartialEq)]
235pub enum DestructurePattern {
236    /// A simple binding: `a`
237    Binding(String),
238    /// A wildcard/ignored position: `_` (not actually bound)
239    Wildcard,
240    /// A nested array pattern: `[a, b]`
241    Array(Vec<DestructurePattern>),
242}
243
244/// JavaScript statements (subset).
245#[derive(Debug, Clone, PartialEq)]
246pub enum JsStmt {
247    /// `import name from "source";`
248    Import { name: String, source: String },
249    /// `import { name1, name2, ... } from "source";`
250    NamedImport { names: Vec<String>, source: String },
251    /// `const name = require("source");`
252    Require { name: String, source: String },
253    /// `const { name1, name2, ... } = require("source");`
254    NamedRequire { names: Vec<String>, source: String },
255    /// `export { name1, name2, ... };`
256    ExportNamed { names: Vec<String> },
257    /// `function name(params) { body }`
258    Function {
259        name: String,
260        params: Vec<String>,
261        body: Vec<JsStmt>,
262        /// Optional source span for source map generation.
263        source_span: Option<SourceSpan>,
264    },
265    /// `return expr;`
266    Return(JsExpr),
267    /// `let name = expr;`
268    Let { name: String, init: Option<JsExpr> },
269    /// `let [pattern, ...] = expr;` (array destructuring, supports nesting)
270    LetDestructure { pattern: Vec<DestructurePattern>, init: Option<JsExpr> },
271    /// Expression statement: `expr;`
272    Expr(JsExpr),
273    /// `try { ... } catch (e) { ... }`
274    TryCatch {
275        try_block: Vec<JsStmt>,
276        catch_ident: String,
277        catch_block: Vec<JsStmt>,
278    },
279    /// `if (cond) { ... } else { ... }`
280    If {
281        cond: JsExpr,
282        then_block: Vec<JsStmt>,
283        else_block: Option<Vec<JsStmt>>,
284    },
285    /// `for (const binding of iterable) { body }`
286    ForOf {
287        binding: String,
288        iterable: JsExpr,
289        body: Vec<JsStmt>,
290    },
291    /// C-style for loop: `for (let binding = start; binding < end; binding++)`
292    For {
293        binding: String,
294        start: JsExpr,
295        end: JsExpr,
296        inclusive: bool,
297        body: Vec<JsStmt>,
298    },
299    /// Assignment statement: `target = value`, `target += value`, etc.
300    Assign {
301        target: JsExpr,
302        op: JsAssignOp,
303        value: JsExpr,
304    },
305    /// `while (cond) { body }`
306    While { cond: JsExpr, body: Vec<JsStmt> },
307    /// `break;`
308    Break,
309    /// `continue;`
310    Continue,
311    /// A bare block: `{ stmts }`
312    Block(Vec<JsStmt>),
313    /// Multiple statements emitted at the same level (no block wrapper).
314    /// Used for let-else to emit the check and bindings without creating a scope.
315    Sequence(Vec<JsStmt>),
316}
317
318/// Assignment operators in JS.
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320pub enum JsAssignOp {
321    Assign,    // =
322    AddAssign, // +=
323    SubAssign, // -=
324    ModAssign, // %=
325}
326
327/// JavaScript expressions (subset).
328#[derive(Debug, Clone, PartialEq)]
329pub enum JsExpr {
330    Ident(String),
331    Number(i64),
332    BigInt(i64),
333    Float(f64),
334    Bool(bool),
335    String(String),
336    /// Object literal: `{ key: value, ... }`.
337    Object(Vec<(String, JsExpr)>),
338    /// Property access: `object.property`.
339    Member {
340        object: Box<JsExpr>,
341        property: String,
342    },
343    /// Assignment expression: `left = right` or `left += right` etc.
344    Assignment {
345        left: Box<JsExpr>,
346        op: JsAssignOp,
347        right: Box<JsExpr>,
348    },
349    /// Conditional (ternary) expression: `test ? then_branch : else_branch`.
350    Conditional {
351        test: Box<JsExpr>,
352        then_branch: Box<JsExpr>,
353        else_branch: Box<JsExpr>,
354    },
355    Call {
356        callee: Box<JsExpr>,
357        args: Vec<JsExpr>,
358    },
359    Binary {
360        op: JsBinaryOp,
361        left: Box<JsExpr>,
362        right: Box<JsExpr>,
363    },
364    /// Immediately Invoked Function Expression: `(function() { body })()`.
365    Iife {
366        body: Vec<JsStmt>,
367    },
368    /// Function expression: `function(params) { body }`.
369    Function {
370        params: Vec<String>,
371        body: Vec<JsStmt>,
372    },
373    /// Constructor call: `new Foo(args)`.
374    New {
375        constructor: String,
376        args: Vec<JsExpr>,
377    },
378    /// Arrow function: `(x, y) => expr` or `(x, y) => { ... }`.
379    Arrow {
380        params: Vec<String>,
381        body: Vec<JsStmt>,
382    },
383    /// Array literal: `[elem1, elem2, ...]`.
384    Array(Vec<JsExpr>),
385    /// Computed member access (indexing): `object[index]`.
386    Index {
387        object: Box<JsExpr>,
388        index: Box<JsExpr>,
389    },
390    /// Raw JavaScript code, emitted directly (wrapped in parentheses for safety).
391    Raw(String),
392    /// Unary expression: `!expr` or `-expr`.
393    Unary {
394        op: JsUnaryOp,
395        expr: Box<JsExpr>,
396    },
397}
398
399/// Unary operators in JS.
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum JsUnaryOp {
402    Not,  // !
403    Neg,  // -
404}
405
406/// Binary operators in JS (subset we need).
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum JsBinaryOp {
409    Add,
410    Sub,
411    Mul,
412    Div,
413    Mod,
414    EqEq,
415    NotEq,
416    Lt,
417    Gt,
418    Le,
419    Ge,
420    And,
421    Or,
422    StrictEq, // ===
423    StrictNe, // !==
424}
425
426/// Output target style.
427#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428pub enum JsTarget {
429    Esm,
430    Cjs,
431}
432
433/// Lower a Husk AST file into a JS module.
434///
435/// This is intentionally minimal for now and only supports:
436/// - Top-level functions.
437/// - Simple statements (`let`, `return expr`, expression statements).
438/// - Basic expressions (literals, identifiers, calls, and arithmetic/boolean binary ops).
439///
440/// `call_main` controls whether a zero-argument `main` function is
441/// automatically invoked at the end of the module. Library-style
442/// consumers should set this to `false` so `main` can be called
443/// explicitly by a host.
444///
445/// `name_resolution` provides the mapping from variable spans to resolved names
446/// for handling variable shadowing. Use an empty HashMap if shadowing is not needed.
447/// `type_resolution` provides the mapping from expression spans to resolved types
448/// for type-dependent operations like .into(), .parse(), .try_into().
449/// `variant_calls` provides the mapping from call spans to enum/variant names
450/// for imported variant constructor calls.
451/// `variant_patterns` provides the mapping from pattern spans to enum/variant names
452/// for imported variant patterns in match expressions.
453pub fn lower_file_to_js(
454    file: &File,
455    call_main: bool,
456    target: JsTarget,
457    name_resolution: &NameResolution,
458    type_resolution: &TypeResolution,
459    variant_calls: &VariantCallMap,
460    variant_patterns: &VariantPatternMap,
461) -> JsModule {
462    lower_file_to_js_with_source(file, call_main, target, None, None, name_resolution, type_resolution, variant_calls, variant_patterns)
463}
464
465/// Lower a Husk AST file into a JS module with source text for accurate source maps.
466///
467/// When `source` is provided, source spans will have accurate line/column info.
468/// When `source_path` is provided, compile-time operations like `include_str` can resolve
469/// relative file paths.
470/// `name_resolution` provides the mapping from variable spans to resolved names
471/// for handling variable shadowing.
472/// `type_resolution` provides the mapping from expression spans to resolved types
473/// for type-dependent operations like .into(), .parse(), .try_into().
474/// `variant_calls` provides the mapping from call spans to enum/variant names
475/// for imported variant constructor calls.
476/// `variant_patterns` provides the mapping from pattern spans to enum/variant names
477/// for imported variant patterns in match expressions.
478pub fn lower_file_to_js_with_source(
479    file: &File,
480    call_main: bool,
481    target: JsTarget,
482    source: Option<&str>,
483    source_path: Option<&Path>,
484    name_resolution: &NameResolution,
485    type_resolution: &TypeResolution,
486    variant_calls: &VariantCallMap,
487    variant_patterns: &VariantPatternMap,
488) -> JsModule {
489    use std::collections::HashSet;
490
491    let mut imports = Vec::new();
492    let mut body = Vec::new();
493    let mut has_main_entry = false;
494    let mut fn_names: Vec<String> = Vec::new();
495
496    // Collect extern struct names - these are opaque JS types
497    let mut extern_structs: HashSet<String> = HashSet::new();
498    for item in &file.items {
499        if let ItemKind::ExternBlock { abi, items } = &item.kind {
500            if abi == "js" {
501                for ext in items {
502                    if let ExternItemKind::Struct { name, .. } = &ext.kind {
503                        extern_structs.insert(name.name.clone());
504                    }
505                }
506            }
507        }
508    }
509
510    // Collect property accessors from extern impl blocks
511    // Priority 1: Explicit #[getter]/#[setter] extern properties
512    // Priority 2: Heuristic-based detection (deprecated, to be removed)
513    let mut accessors = PropertyAccessors::default();
514    for item in &file.items {
515        if let ItemKind::Impl(impl_block) = &item.kind {
516            let type_name = type_expr_to_js_name(&impl_block.self_ty);
517            for impl_item in &impl_block.items {
518                match &impl_item.kind {
519                    // NEW: Handle explicit extern properties with #[getter]/#[setter]
520                    ImplItemKind::Property(prop) => {
521                        let prop_name = &prop.name.name;
522                        // Get the JS name: use #[js_name] if present, otherwise snake_to_camel
523                        let js_name = prop
524                            .js_name()
525                            .map(|s| s.to_string())
526                            .unwrap_or_else(|| snake_to_camel(prop_name));
527
528                        if prop.has_getter() {
529                            // For property access: obj.prop_name -> obj.jsName
530                            accessors.getters.insert(
531                                (type_name.clone(), prop_name.clone()),
532                                js_name.clone(),
533                            );
534                        }
535                        if prop.has_setter() {
536                            // For property assignment: obj.prop_name = val -> obj.jsName = val
537                            accessors.setters.insert(
538                                (type_name.clone(), prop_name.clone()),
539                                js_name,
540                            );
541                        }
542                    }
543                    // DEPRECATED: Heuristic-based detection for extern methods
544                    // TODO: Remove this once all code migrates to explicit properties
545                    ImplItemKind::Method(method) => {
546                        let method_name = &method.name.name;
547
548                        // Collect #[js_name] overrides for method name mapping
549                        // Keyed by (type_name, method_name) to avoid collisions
550                        if let Some(js_name) = method.js_name() {
551                            accessors.method_js_names.insert(
552                                (type_name.clone(), method_name.clone()),
553                                js_name.to_string(),
554                            );
555                        }
556
557                        if method.is_extern && method.receiver.is_some() {
558                            // Track this as an extern method for snake_to_camel conversion
559                            // Keyed by (type_name, method_name) to avoid renaming user methods
560                            accessors.extern_methods.insert((type_name.clone(), method_name.clone()));
561
562                            // Methods that should NOT be treated as getters even if they have
563                            // no params and a return type. These are actual method calls in JS.
564                            const NON_GETTER_METHODS: &[&str] = &[
565                                "all",         // stmt.all() in better-sqlite3
566                                "toJsValue",   // JsObject.toJsValue() builder method
567                                "open",        // db.open() check if database is open
568                                "run",         // stmt.run() execute statement
569                                "iterate",     // stmt.iterate() in better-sqlite3
570                                "bind",        // stmt.bind() in better-sqlite3
571                                "pluck",       // stmt.pluck() in better-sqlite3
572                                "raw",         // stmt.raw() in better-sqlite3
573                                "columns",     // stmt.columns() in better-sqlite3
574                            ];
575                            let is_non_getter = NON_GETTER_METHODS.contains(&method_name.as_str());
576
577                            // Check if it's a getter: no params, has return type, and not in exclusion list
578                            if method.params.is_empty() && method.ret_type.is_some() && !is_non_getter
579                            {
580                                accessors.getters.insert(
581                                    (type_name.clone(), method_name.clone()),
582                                    method_name.clone(),
583                                );
584                            }
585                            // Check if it's a setter: starts with "set_", one param, no return
586                            else if method_name.starts_with("set_")
587                                && method.params.len() == 1
588                                && method.ret_type.is_none()
589                            {
590                                let prop_name =
591                                    method_name.strip_prefix("set_").unwrap().to_string();
592                                accessors.setters.insert(
593                                    (type_name.clone(), method_name.clone()),
594                                    prop_name,
595                                );
596                            }
597                        }
598                    }
599                }
600            }
601        }
602    }
603
604    // Create codegen context with accessors, optional source path, name resolution, type resolution, and variant maps
605    let ctx = match source_path {
606        Some(path) => CodegenContext::with_source_path(&accessors, path, name_resolution, type_resolution, variant_calls, variant_patterns),
607        None => CodegenContext::new(&accessors, name_resolution, type_resolution, variant_calls, variant_patterns),
608    };
609
610    // First pass: collect module imports
611    for item in &file.items {
612        if let ItemKind::ExternBlock { abi, items } = &item.kind {
613            if abi == "js" {
614                for ext in items {
615                    if let ExternItemKind::Mod {
616                        package,
617                        binding,
618                        items: mod_items,
619                        is_global,
620                    } = &ext.kind
621                    {
622                        // Skip import/require for global JS objects (Array, Math, JSON, etc.)
623                        if *is_global {
624                            continue;
625                        }
626
627                        if mod_items.is_empty() {
628                            // Simple default import: import binding from "package";
629                            match target {
630                                JsTarget::Esm => imports.push(JsStmt::Import {
631                                    name: binding.name.clone(),
632                                    source: package.clone(),
633                                }),
634                                JsTarget::Cjs => imports.push(JsStmt::Require {
635                                    name: binding.name.clone(),
636                                    source: package.clone(),
637                                }),
638                            }
639                        } else {
640                            // Named imports: import { fn1, fn2 } from "package";
641                            let names: Vec<String> = mod_items
642                                .iter()
643                                .filter_map(|mi| match &mi.kind {
644                                    husk_ast::ModItemKind::Fn { name, .. } => {
645                                        Some(name.name.clone())
646                                    }
647                                })
648                                .collect();
649                            match target {
650                                JsTarget::Esm => imports.push(JsStmt::NamedImport {
651                                    names,
652                                    source: package.clone(),
653                                }),
654                                JsTarget::Cjs => imports.push(JsStmt::NamedRequire {
655                                    names,
656                                    source: package.clone(),
657                                }),
658                            }
659                        }
660                    }
661                }
662            }
663        }
664    }
665
666    // Second pass: generate constructor functions for structs (needed for prototype methods)
667    // Skip extern structs - they're opaque JS types and don't need constructors
668    for item in &file.items {
669        if let ItemKind::Struct { name, fields, .. } = &item.kind {
670            if !extern_structs.contains(&name.name) {
671                body.push(lower_struct_constructor(&name.name, fields));
672            }
673        }
674    }
675
676    // Third pass: process functions and extern functions
677    for item in &file.items {
678        match &item.kind {
679            ItemKind::Fn {
680                name,
681                params,
682                body: fn_body,
683                ..
684            } => {
685                body.push(lower_fn_with_span(
686                    &name.name, params, fn_body, &item.span, source, &ctx,
687                ));
688                fn_names.push(name.name.clone());
689                if name.name == "main" && params.is_empty() {
690                    has_main_entry = true;
691                }
692            }
693            ItemKind::ExternBlock { abi, items } => {
694                if abi == "js" {
695                    // Functions that are already defined in the runtime preamble
696                    // and should not have wrappers generated
697                    const PREAMBLE_FUNCTIONS: &[&str] = &[
698                        "JsObject_new",
699                        "jsvalue_get",
700                        "jsvalue_getString",
701                        "jsvalue_getNumber",
702                        "jsvalue_getBool",
703                        "jsvalue_getArray",
704                        "jsvalue_isNull",
705                        "jsvalue_toString",
706                        "jsvalue_toBool",
707                        "jsvalue_toNumber",
708                        "express_json",
709                    ];
710
711                    for ext in items {
712                        match &ext.kind {
713                            ExternItemKind::Fn {
714                                name,
715                                params,
716                                ret_type,
717                            } => {
718                                // Skip functions that are already in the preamble
719                                if !PREAMBLE_FUNCTIONS.contains(&name.name.as_str()) {
720                                    body.push(lower_extern_fn(name, params, ret_type.as_ref()));
721                                }
722                            }
723                            ExternItemKind::Mod { .. } => {
724                                // Already handled in first pass
725                            }
726                            ExternItemKind::Struct { .. } => {
727                                // Extern structs don't generate any code -
728                                // they're opaque JS types
729                            }
730                            ExternItemKind::Static { .. } => {
731                                // Static declarations don't generate code -
732                                // they declare global JS variables
733                            }
734                        }
735                    }
736                }
737            }
738            ItemKind::Impl(impl_block) => {
739                // Generate prototype methods for impl blocks
740                let self_ty_name = type_expr_to_js_name(&impl_block.self_ty);
741
742                for impl_item in &impl_block.items {
743                    if let ImplItemKind::Method(method) = &impl_item.kind {
744                        // Skip extern methods - they're just declarations for JS interop
745                        // The actual method calls will go directly to the JS object
746                        if method.is_extern {
747                            continue;
748                        }
749                        body.push(lower_impl_method(
750                            &self_ty_name,
751                            method,
752                            &impl_item.span,
753                            source,
754                            &ctx,
755                        ));
756                    }
757                }
758            }
759            ItemKind::Trait(_) => {
760                // Traits don't generate code - they're purely compile-time constructs
761            }
762            _ => {}
763        }
764    }
765    if has_main_entry && call_main {
766        body.push(JsStmt::Expr(JsExpr::Call {
767            callee: Box::new(JsExpr::Ident("main".to_string())),
768            args: Vec::new(),
769        }));
770    }
771
772    // Choose export style based on whether we have imports (ESM) or not (CommonJS)
773    if !fn_names.is_empty() {
774        match target {
775            JsTarget::Esm => body.push(JsStmt::ExportNamed { names: fn_names }),
776            JsTarget::Cjs => {
777                let exports_obj = JsExpr::Object(
778                    fn_names
779                        .into_iter()
780                        .map(|name| (name.clone(), JsExpr::Ident(name)))
781                        .collect(),
782                );
783                let assign = JsExpr::Assignment {
784                    left: Box::new(JsExpr::Member {
785                        object: Box::new(JsExpr::Ident("module".to_string())),
786                        property: "exports".to_string(),
787                    }),
788                    op: JsAssignOp::Assign,
789                    right: Box::new(exports_obj),
790                };
791                body.push(JsStmt::Expr(assign));
792            }
793        }
794    }
795
796    // Combine imports at the top, then the rest of the body
797    let mut full_body = imports;
798    full_body.append(&mut body);
799
800    JsModule { body: full_body }
801}
802
803fn lower_fn_with_span(
804    name: &str,
805    params: &[Param],
806    body: &[Stmt],
807    span: &Span,
808    source: Option<&str>,
809    ctx: &CodegenContext,
810) -> JsStmt {
811    let js_params: Vec<String> = params.iter().map(|p| p.name.name.clone()).collect();
812    let mut js_body = Vec::new();
813    for (i, stmt) in body.iter().enumerate() {
814        let is_last = i + 1 == body.len();
815        if is_last {
816            js_body.push(lower_tail_stmt(stmt, ctx));
817        } else {
818            js_body.push(lower_stmt(stmt, ctx));
819        }
820    }
821
822    // Convert byte offset to line/column if source is provided
823    let source_span = source.map(|src| {
824        let (line, column) = offset_to_line_col(src, span.range.start);
825        SourceSpan { line, column }
826    });
827
828    JsStmt::Function {
829        name: name.to_string(),
830        params: js_params,
831        body: js_body,
832        source_span,
833    }
834}
835
836/// Generate a constructor function for a struct.
837///
838/// For `struct Point { x: i32, y: i32 }`, generates:
839/// ```js
840/// function Point(x, y) { this.x = x; this.y = y; }
841/// ```
842fn lower_struct_constructor(name: &str, fields: &[StructField]) -> JsStmt {
843    let param_names: Vec<String> = fields.iter().map(|f| f.name.name.clone()).collect();
844
845    // Generate: this.field = field; for each field
846    let mut body_stmts = Vec::new();
847    for field in fields {
848        let field_name = field.name.name.clone();
849        body_stmts.push(JsStmt::Expr(JsExpr::Assignment {
850            left: Box::new(JsExpr::Member {
851                object: Box::new(JsExpr::Ident("this".to_string())),
852                property: field_name.clone(),
853            }),
854            op: JsAssignOp::Assign,
855            right: Box::new(JsExpr::Ident(field_name)),
856        }));
857    }
858
859    JsStmt::Function {
860        name: name.to_string(),
861        params: param_names,
862        body: body_stmts,
863        source_span: None,
864    }
865}
866
867/// Extract a JavaScript-usable type name from a TypeExpr.
868fn type_expr_to_js_name(ty: &TypeExpr) -> String {
869    match &ty.kind {
870        TypeExprKind::Named(ident) => ident.name.clone(),
871        TypeExprKind::Generic { name, .. } => name.name.clone(),
872        TypeExprKind::Function { params, ret } => {
873            // Generate a TypeScript-style function type name for documentation
874            let param_names: Vec<String> = params.iter().map(type_expr_to_js_name).collect();
875            format!("(({}) => {})", param_names.join(", "), type_expr_to_js_name(ret))
876        }
877        TypeExprKind::Array(elem) => {
878            format!("{}[]", type_expr_to_js_name(elem))
879        }
880        TypeExprKind::Tuple(types) => {
881            // TypeScript tuple type: [T1, T2, T3]
882            let type_names: Vec<String> = types.iter().map(type_expr_to_js_name).collect();
883            format!("[{}]", type_names.join(", "))
884        }
885    }
886}
887
888/// Lower an impl method to JavaScript.
889///
890/// For methods with a receiver (`&self`, `self`, etc.), we generate:
891///   `TypeName.prototype.methodName = function(params) { body };`
892///
893/// For static methods (no receiver), we generate:
894///   `TypeName.methodName = function(params) { body };`
895fn lower_impl_method(
896    type_name: &str,
897    method: &husk_ast::ImplMethod,
898    span: &Span,
899    source: Option<&str>,
900    ctx: &CodegenContext,
901) -> JsStmt {
902    let method_name = &method.name.name;
903
904    // Build params: for methods with receiver, we don't include `self` in params
905    // since it becomes `this` in JavaScript
906    let js_params: Vec<String> = method.params.iter().map(|p| p.name.name.clone()).collect();
907
908    // Lower the body
909    let mut js_body = Vec::new();
910    for (i, stmt) in method.body.iter().enumerate() {
911        let is_last = i + 1 == method.body.len();
912        if is_last {
913            js_body.push(lower_tail_stmt(stmt, ctx));
914        } else {
915            js_body.push(lower_stmt(stmt, ctx));
916        }
917    }
918
919    // Convert byte offset to line/column if source is provided
920    let _source_span = source.map(|src| {
921        let (line, column) = offset_to_line_col(src, span.range.start);
922        SourceSpan { line, column }
923    });
924
925    // Generate: TypeName.prototype.methodName = function(params) { body };
926    // or for static: TypeName.methodName = function(params) { body };
927    let target = if method.receiver.is_some() {
928        // Instance method: TypeName.prototype.methodName
929        JsExpr::Member {
930            object: Box::new(JsExpr::Member {
931                object: Box::new(JsExpr::Ident(type_name.to_string())),
932                property: "prototype".to_string(),
933            }),
934            property: method_name.clone(),
935        }
936    } else {
937        // Static method: TypeName.methodName
938        JsExpr::Member {
939            object: Box::new(JsExpr::Ident(type_name.to_string())),
940            property: method_name.clone(),
941        }
942    };
943
944    // Create the function expression
945    let func_expr = JsExpr::Function {
946        params: js_params,
947        body: js_body,
948    };
949
950    // Generate assignment: target = function(...) { ... }
951    JsStmt::Expr(JsExpr::Assignment {
952        left: Box::new(target),
953        op: JsAssignOp::Assign,
954        right: Box::new(func_expr),
955    })
956}
957
958fn lower_tail_stmt(stmt: &Stmt, ctx: &CodegenContext) -> JsStmt {
959    match &stmt.kind {
960        // Treat a trailing expression statement as an implicit `return expr;`,
961        // to mirror Rust-style expression-bodied functions.
962        StmtKind::Expr(expr) => JsStmt::Return(lower_expr(expr, ctx)),
963        // For if statements in tail position, wrap branch results in returns.
964        StmtKind::If {
965            cond,
966            then_branch,
967            else_branch,
968        } => {
969            let js_cond = lower_expr(cond, ctx);
970            let then_block: Vec<JsStmt> = then_branch
971                .stmts
972                .iter()
973                .enumerate()
974                .map(|(i, s)| {
975                    if i + 1 == then_branch.stmts.len() {
976                        lower_tail_stmt(s, ctx)
977                    } else {
978                        lower_stmt(s, ctx)
979                    }
980                })
981                .collect();
982            let else_block = else_branch.as_ref().map(|else_stmt| {
983                // else_branch is a Box<Stmt>, which may be another If or a Block
984                match &else_stmt.kind {
985                    StmtKind::Block(block) => block
986                        .stmts
987                        .iter()
988                        .enumerate()
989                        .map(|(i, s)| {
990                            if i + 1 == block.stmts.len() {
991                                lower_tail_stmt(s, ctx)
992                            } else {
993                                lower_stmt(s, ctx)
994                            }
995                        })
996                        .collect(),
997                    StmtKind::If { .. } => vec![lower_tail_stmt(else_stmt, ctx)],
998                    _ => vec![lower_tail_stmt(else_stmt, ctx)],
999                }
1000            });
1001            JsStmt::If {
1002                cond: js_cond,
1003                then_block,
1004                else_block,
1005            }
1006        }
1007        _ => lower_stmt(stmt, ctx),
1008    }
1009}
1010
1011fn lower_extern_fn(
1012    name: &husk_ast::Ident,
1013    params: &[Param],
1014    ret_type: Option<&TypeExpr>,
1015) -> JsStmt {
1016    let js_params: Vec<String> = params.iter().map(|p| p.name.name.clone()).collect();
1017    let body = lower_extern_body(&name.name, &js_params, ret_type);
1018    JsStmt::Function {
1019        name: name.name.clone(),
1020        params: js_params,
1021        body,
1022        source_span: None,
1023    }
1024}
1025
1026fn is_result_type(ret_type: &TypeExpr) -> bool {
1027    match &ret_type.kind {
1028        TypeExprKind::Generic { name, args } => name.name == "Result" && args.len() == 2,
1029        _ => false,
1030    }
1031}
1032
1033fn lower_extern_body(
1034    name: &str,
1035    param_names: &[String],
1036    ret_type: Option<&TypeExpr>,
1037) -> Vec<JsStmt> {
1038    // Underlying JS function is expected on globalThis under the same name.
1039    let callee = JsExpr::Member {
1040        object: Box::new(JsExpr::Ident("globalThis".to_string())),
1041        property: name.to_string(),
1042    };
1043    let args: Vec<JsExpr> = param_names.iter().cloned().map(JsExpr::Ident).collect();
1044    let call = JsExpr::Call {
1045        callee: Box::new(callee),
1046        args,
1047    };
1048
1049    if let Some(ret_ty) = ret_type {
1050        if is_result_type(ret_ty) {
1051            // Wrap in Ok/Err with try/catch.
1052            let ok_call = JsExpr::Call {
1053                callee: Box::new(JsExpr::Ident("Ok".to_string())),
1054                args: vec![call],
1055            };
1056            let try_block = vec![JsStmt::Return(ok_call)];
1057
1058            let err_call = JsExpr::Call {
1059                callee: Box::new(JsExpr::Ident("Err".to_string())),
1060                args: vec![JsExpr::Ident("e".to_string())],
1061            };
1062            let catch_block = vec![JsStmt::Return(err_call)];
1063
1064            vec![JsStmt::TryCatch {
1065                try_block,
1066                catch_ident: "e".to_string(),
1067                catch_block,
1068            }]
1069        } else {
1070            vec![JsStmt::Return(call)]
1071        }
1072    } else {
1073        vec![JsStmt::Expr(call)]
1074    }
1075}
1076
1077fn lower_stmt(stmt: &Stmt, ctx: &CodegenContext) -> JsStmt {
1078    match &stmt.kind {
1079        StmtKind::Let {
1080            mutable: _,
1081            pattern,
1082            ty: _,
1083            value,
1084            else_block,
1085        } => {
1086            lower_let_pattern(pattern, value.as_ref(), else_block.as_ref(), ctx)
1087        }
1088        StmtKind::Expr(expr) | StmtKind::Semi(expr) => {
1089            // Special case: match expressions in statement position should be lowered
1090            // to if/else statements, not ternary expressions. This allows break/continue
1091            // to work properly in match arms.
1092            if let ExprKind::Match { scrutinee, arms } = &expr.kind {
1093                return lower_match_stmt(scrutinee, arms, ctx);
1094            }
1095            JsStmt::Expr(lower_expr(expr, ctx))
1096        }
1097        StmtKind::Return { value } => {
1098            let expr = value
1099                .as_ref()
1100                .map(|e| lower_expr(e, ctx))
1101                // Represent `return;` as `return undefined;` for now.
1102                .unwrap_or_else(|| JsExpr::Ident("undefined".to_string()));
1103            JsStmt::Return(expr)
1104        }
1105        StmtKind::Block(block) => {
1106            // Flatten block into a single function body-like statement sequence.
1107            // For now we represent it as an expression statement of the last
1108            // expression, discarding internal structure. This is a placeholder
1109            // until we add proper JS block statements.
1110            if let Some(last) = block.stmts.last() {
1111                lower_stmt(last, ctx)
1112            } else {
1113                JsStmt::Expr(JsExpr::Ident("undefined".to_string()))
1114            }
1115        }
1116        StmtKind::If {
1117            cond,
1118            then_branch,
1119            else_branch,
1120        } => {
1121            let js_cond = lower_expr(cond, ctx);
1122            let then_block: Vec<JsStmt> = then_branch
1123                .stmts
1124                .iter()
1125                .map(|s| lower_stmt(s, ctx))
1126                .collect();
1127            let else_block = else_branch.as_ref().map(|else_stmt| {
1128                // else_branch is a Box<Stmt>, which may be another If or a Block
1129                match &else_stmt.kind {
1130                    StmtKind::Block(block) => block
1131                        .stmts
1132                        .iter()
1133                        .map(|s| lower_stmt(s, ctx))
1134                        .collect(),
1135                    StmtKind::If { .. } => vec![lower_stmt(else_stmt, ctx)],
1136                    _ => vec![lower_stmt(else_stmt, ctx)],
1137                }
1138            });
1139            JsStmt::If {
1140                cond: js_cond,
1141                then_block,
1142                else_block,
1143            }
1144        }
1145        StmtKind::ForIn {
1146            binding,
1147            iterable,
1148            body,
1149        } => {
1150            // Check if iterable is a range expression with both bounds
1151            if let ExprKind::Range {
1152                start: Some(start),
1153                end: Some(end),
1154                inclusive,
1155            } = &iterable.kind
1156            {
1157                let js_start = lower_expr(start, ctx);
1158                let js_end = lower_expr(end, ctx);
1159                let js_body: Vec<JsStmt> = body.stmts.iter().map(|s| lower_stmt(s, ctx)).collect();
1160
1161                JsStmt::For {
1162                    binding: ctx.resolve_name(&binding.name, &binding.span),
1163                    start: js_start,
1164                    end: js_end,
1165                    inclusive: *inclusive,
1166                    body: js_body,
1167                }
1168            } else {
1169                let js_iterable = lower_expr(iterable, ctx);
1170                let js_body: Vec<JsStmt> = body.stmts.iter().map(|s| lower_stmt(s, ctx)).collect();
1171                JsStmt::ForOf {
1172                    binding: ctx.resolve_name(&binding.name, &binding.span),
1173                    iterable: js_iterable,
1174                    body: js_body,
1175                }
1176            }
1177        }
1178        StmtKind::While { cond, body } => {
1179            let js_cond = lower_expr(cond, ctx);
1180            let js_body: Vec<JsStmt> = body.stmts.iter().map(|s| lower_stmt(s, ctx)).collect();
1181            JsStmt::While {
1182                cond: js_cond,
1183                body: js_body,
1184            }
1185        }
1186        StmtKind::Loop { body } => {
1187            // loop { ... } becomes while(true) { ... }
1188            let js_body: Vec<JsStmt> = body.stmts.iter().map(|s| lower_stmt(s, ctx)).collect();
1189            JsStmt::While {
1190                cond: JsExpr::Bool(true),
1191                body: js_body,
1192            }
1193        }
1194        StmtKind::Break => JsStmt::Break,
1195        StmtKind::Continue => JsStmt::Continue,
1196        StmtKind::Assign { target, op, value } => {
1197            let js_op = match op {
1198                husk_ast::AssignOp::Assign => JsAssignOp::Assign,
1199                husk_ast::AssignOp::AddAssign => JsAssignOp::AddAssign,
1200                husk_ast::AssignOp::SubAssign => JsAssignOp::SubAssign,
1201                husk_ast::AssignOp::ModAssign => JsAssignOp::ModAssign,
1202            };
1203            JsStmt::Assign {
1204                target: lower_expr(target, ctx),
1205                op: js_op,
1206                value: lower_expr(value, ctx),
1207            }
1208        }
1209        StmtKind::IfLet {
1210            pattern,
1211            scrutinee,
1212            then_branch,
1213            else_branch,
1214        } => {
1215            lower_if_let_stmt(pattern, scrutinee, then_branch, else_branch, ctx)
1216        }
1217    }
1218}
1219
1220/// Convert a Husk pattern to a JS destructure pattern (supports nesting).
1221fn pattern_to_destructure(pattern: &Pattern, ctx: &CodegenContext) -> DestructurePattern {
1222    match &pattern.kind {
1223        PatternKind::Binding(name) => {
1224            DestructurePattern::Binding(ctx.resolve_name(&name.name, &name.span))
1225        }
1226        PatternKind::Wildcard => DestructurePattern::Wildcard,
1227        PatternKind::Tuple { fields } => {
1228            let elements = fields.iter().map(|p| pattern_to_destructure(p, ctx)).collect();
1229            DestructurePattern::Array(elements)
1230        }
1231        // For other patterns (enum patterns, etc.), treat as wildcard
1232        _ => DestructurePattern::Wildcard,
1233    }
1234}
1235
1236/// Lower a let statement with a pattern (handles both simple identifiers and tuple patterns).
1237fn lower_let_pattern(
1238    pattern: &Pattern,
1239    value: Option<&Expr>,
1240    else_block: Option<&Block>,
1241    ctx: &CodegenContext,
1242) -> JsStmt {
1243    match &pattern.kind {
1244        PatternKind::Binding(name) => {
1245            // Check if this is an imported variant (e.g., `let None = opt else { }`)
1246            let span_key = (pattern.span.range.start, pattern.span.range.end);
1247            if let Some((_, variant_name)) = ctx.variant_patterns.get(&span_key) {
1248                // This is a variant pattern - needs tag check
1249                if let Some(blk) = else_block {
1250                    return lower_let_refutable_binding(name, variant_name, value, blk, ctx);
1251                }
1252            }
1253            // Simple identifier: let x = expr;
1254            JsStmt::Let {
1255                name: ctx.resolve_name(&name.name, &name.span),
1256                init: value.map(|e| lower_expr(e, ctx)),
1257            }
1258        }
1259        PatternKind::Tuple { fields } => {
1260            // Tuple pattern: let (a, b, c) = expr; or let ((a, b), c) = expr;
1261            // Convert to JS array destructuring with nested support
1262            let elements: Vec<DestructurePattern> = fields
1263                .iter()
1264                .map(|p| pattern_to_destructure(p, ctx))
1265                .collect();
1266
1267            JsStmt::LetDestructure {
1268                pattern: elements,
1269                init: value.map(|e| lower_expr(e, ctx)),
1270            }
1271        }
1272        PatternKind::Wildcard => {
1273            // let _ = expr; - just evaluate the expression for side effects
1274            if let Some(e) = value {
1275                JsStmt::Expr(lower_expr(e, ctx))
1276            } else {
1277                JsStmt::Expr(JsExpr::Ident("undefined".to_string()))
1278            }
1279        }
1280        PatternKind::EnumUnit { path } | PatternKind::EnumTuple { path, .. } => {
1281            // Refutable pattern: needs else block
1282            if let Some(blk) = else_block {
1283                lower_let_refutable(pattern, path, value, blk, ctx)
1284            } else {
1285                // Graceful fallback - semantic should catch this
1286                emit_unreachable_error()
1287            }
1288        }
1289        PatternKind::EnumStruct { path, fields } => {
1290            // Refutable struct pattern: needs else block
1291            if let Some(blk) = else_block {
1292                lower_let_refutable_struct(path, fields, value, blk, ctx)
1293            } else {
1294                emit_unreachable_error()
1295            }
1296        }
1297    }
1298}
1299
1300/// Emit a visible error marker when codegen encounters an unreachable state.
1301fn emit_unreachable_error() -> JsStmt {
1302    JsStmt::Expr(JsExpr::Raw(
1303        "/* SEMANTIC ERROR: unreachable - missing else block */ undefined".to_string(),
1304    ))
1305}
1306
1307/// Lower a refutable binding pattern (imported variant like `None`, `Err`)
1308fn lower_let_refutable_binding(
1309    _name: &Ident,
1310    variant_name: &str,
1311    value: Option<&Expr>,
1312    else_block: &Block,
1313    ctx: &CodegenContext,
1314) -> JsStmt {
1315    let expr = match value {
1316        Some(e) => e,
1317        None => {
1318            // Should not happen - semantic should catch
1319            return JsStmt::Expr(JsExpr::Ident("undefined".to_string()));
1320        }
1321    };
1322    let value_js = lower_expr(expr, ctx);
1323
1324    let temp_name = ctx.fresh_temp("__letelse");
1325    let temp_expr = JsExpr::Ident(temp_name.clone());
1326
1327    // Build tag check: if (__tmp.tag !== "VariantName") { else_block }
1328    let condition = JsExpr::Binary {
1329        op: JsBinaryOp::StrictNe,
1330        left: Box::new(JsExpr::Member {
1331            object: Box::new(temp_expr.clone()),
1332            property: "tag".to_string(),
1333        }),
1334        right: Box::new(JsExpr::String(variant_name.to_string())),
1335    };
1336
1337    let else_stmts: Vec<JsStmt> = else_block.stmts.iter().map(|s| lower_stmt(s, ctx)).collect();
1338
1339    // Unit variants have no bindings - just tag check
1340    // Use Sequence to emit statements at same level (no block scope)
1341    JsStmt::Sequence(vec![
1342        JsStmt::Let {
1343            name: temp_name,
1344            init: Some(value_js),
1345        },
1346        JsStmt::If {
1347            cond: condition,
1348            then_block: else_stmts,
1349            else_block: None,
1350        },
1351    ])
1352}
1353
1354/// Lower a refutable pattern (EnumUnit or EnumTuple)
1355fn lower_let_refutable(
1356    pattern: &Pattern,
1357    path: &[Ident],
1358    value: Option<&Expr>,
1359    else_block: &Block,
1360    ctx: &CodegenContext,
1361) -> JsStmt {
1362    let expr = match value {
1363        Some(e) => e,
1364        None => {
1365            return JsStmt::Expr(JsExpr::Ident("undefined".to_string()));
1366        }
1367    };
1368    let value_js = lower_expr(expr, ctx);
1369
1370    let temp_name = ctx.fresh_temp("__letelse");
1371    let temp_expr = JsExpr::Ident(temp_name.clone());
1372
1373    // Build tag check
1374    let variant = path.last().map(|id| id.name.clone()).unwrap_or_default();
1375    let condition = JsExpr::Binary {
1376        op: JsBinaryOp::StrictNe,
1377        left: Box::new(JsExpr::Member {
1378            object: Box::new(temp_expr.clone()),
1379            property: "tag".to_string(),
1380        }),
1381        right: Box::new(JsExpr::String(variant)),
1382    };
1383
1384    let else_stmts: Vec<JsStmt> = else_block.stmts.iter().map(|s| lower_stmt(s, ctx)).collect();
1385
1386    // Extract bindings from pattern
1387    let bindings = extract_refutable_pattern_bindings(pattern, &temp_expr, ctx);
1388
1389    let mut stmts = vec![
1390        JsStmt::Let {
1391            name: temp_name,
1392            init: Some(value_js),
1393        },
1394        JsStmt::If {
1395            cond: condition,
1396            then_block: else_stmts,
1397            else_block: None,
1398        },
1399    ];
1400    stmts.extend(bindings.into_iter().map(|(name, accessor)| JsStmt::Let {
1401        name,
1402        init: Some(accessor),
1403    }));
1404
1405    // Use Sequence to emit statements at same level (no block scope)
1406    JsStmt::Sequence(stmts)
1407}
1408
1409/// Lower a refutable struct pattern (EnumStruct)
1410fn lower_let_refutable_struct(
1411    path: &[Ident],
1412    fields: &[(Ident, Pattern)],
1413    value: Option<&Expr>,
1414    else_block: &Block,
1415    ctx: &CodegenContext,
1416) -> JsStmt {
1417    let expr = match value {
1418        Some(e) => e,
1419        None => {
1420            return JsStmt::Expr(JsExpr::Ident("undefined".to_string()));
1421        }
1422    };
1423    let value_js = lower_expr(expr, ctx);
1424
1425    let temp_name = ctx.fresh_temp("__letelse");
1426    let temp_expr = JsExpr::Ident(temp_name.clone());
1427
1428    // Build tag check
1429    let variant = path.last().map(|id| id.name.clone()).unwrap_or_default();
1430    let condition = JsExpr::Binary {
1431        op: JsBinaryOp::StrictNe,
1432        left: Box::new(JsExpr::Member {
1433            object: Box::new(temp_expr.clone()),
1434            property: "tag".to_string(),
1435        }),
1436        right: Box::new(JsExpr::String(variant)),
1437    };
1438
1439    let else_stmts: Vec<JsStmt> = else_block.stmts.iter().map(|s| lower_stmt(s, ctx)).collect();
1440
1441    // Extract struct field bindings
1442    let bindings = extract_struct_field_bindings(fields, &temp_expr, ctx);
1443
1444    let mut stmts = vec![
1445        JsStmt::Let {
1446            name: temp_name,
1447            init: Some(value_js),
1448        },
1449        JsStmt::If {
1450            cond: condition,
1451            then_block: else_stmts,
1452            else_block: None,
1453        },
1454    ];
1455    stmts.extend(bindings.into_iter().map(|(name, accessor)| JsStmt::Let {
1456        name,
1457        init: Some(accessor),
1458    }));
1459
1460    // Use Sequence to emit statements at same level (no block scope)
1461    JsStmt::Sequence(stmts)
1462}
1463
1464/// Extract bindings from struct enum pattern fields.
1465fn extract_struct_field_bindings(
1466    fields: &[(Ident, Pattern)],
1467    scrutinee_js: &JsExpr,
1468    ctx: &CodegenContext,
1469) -> Vec<(String, JsExpr)> {
1470    let mut bindings = Vec::new();
1471    for (field_name, sub_pattern) in fields {
1472        // Access the field from scrutinee.value.field_name
1473        let field_accessor = JsExpr::Member {
1474            object: Box::new(JsExpr::Member {
1475                object: Box::new(scrutinee_js.clone()),
1476                property: "value".to_string(),
1477            }),
1478            property: field_name.name.clone(),
1479        };
1480
1481        match &sub_pattern.kind {
1482            PatternKind::Binding(ident) => {
1483                bindings.push((ctx.resolve_name(&ident.name, &ident.span), field_accessor));
1484            }
1485            PatternKind::Wildcard => {
1486                // Wildcard - no binding needed
1487            }
1488            _ => {
1489                // Nested patterns not supported - semantic should have caught this
1490            }
1491        }
1492    }
1493    bindings
1494}
1495
1496/// Extract bindings from a refutable pattern for if-let or let-else.
1497/// Uses CodegenContext for proper name resolution.
1498fn extract_refutable_pattern_bindings(
1499    pattern: &Pattern,
1500    scrutinee_js: &JsExpr,
1501    ctx: &CodegenContext,
1502) -> Vec<(String, JsExpr)> {
1503    match &pattern.kind {
1504        PatternKind::EnumTuple { fields, .. } => {
1505            // Access via __temp.value for single field, __temp["0"], __temp["1"] for multiple
1506            if fields.len() == 1 {
1507                if let PatternKind::Binding(ident) = &fields[0].kind {
1508                    let accessor = JsExpr::Member {
1509                        object: Box::new(scrutinee_js.clone()),
1510                        property: "value".to_string(),
1511                    };
1512                    return vec![(ctx.resolve_name(&ident.name, &ident.span), accessor)];
1513                }
1514            } else {
1515                // Multiple fields: access via ["0"], ["1"], etc. (no .value wrapper)
1516                let mut bindings = Vec::new();
1517                for (i, field) in fields.iter().enumerate() {
1518                    if let PatternKind::Binding(ident) = &field.kind {
1519                        let accessor = JsExpr::Index {
1520                            object: Box::new(scrutinee_js.clone()),
1521                            index: Box::new(JsExpr::String(i.to_string())),
1522                        };
1523                        bindings.push((ctx.resolve_name(&ident.name, &ident.span), accessor));
1524                    }
1525                }
1526                return bindings;
1527            }
1528            Vec::new()
1529        }
1530        PatternKind::EnumStruct { fields, .. } => {
1531            extract_struct_field_bindings(fields, scrutinee_js, ctx)
1532        }
1533        _ => Vec::new(),
1534    }
1535}
1536
1537/// Lower an if-let statement
1538fn lower_if_let_stmt(
1539    pattern: &Pattern,
1540    scrutinee: &Expr,
1541    then_branch: &Block,
1542    else_branch: &Option<Box<Stmt>>,
1543    ctx: &CodegenContext,
1544) -> JsStmt {
1545    let scrutinee_js = lower_expr(scrutinee, ctx);
1546    let temp_name = ctx.fresh_temp("__iflet");
1547    let temp_expr = JsExpr::Ident(temp_name.clone());
1548
1549    // Build condition test
1550    let condition = pattern_test_for_if_let(pattern, &temp_expr, ctx)
1551        .unwrap_or(JsExpr::Bool(true));
1552
1553    // Extract bindings and build then block
1554    let bindings = extract_refutable_pattern_bindings(pattern, &temp_expr, ctx);
1555    let mut then_stmts: Vec<JsStmt> = bindings
1556        .into_iter()
1557        .map(|(name, accessor)| JsStmt::Let {
1558            name,
1559            init: Some(accessor),
1560        })
1561        .collect();
1562    then_stmts.extend(then_branch.stmts.iter().map(|s| lower_stmt(s, ctx)));
1563
1564    let else_block = else_branch.as_ref().map(|else_stmt| {
1565        match &else_stmt.kind {
1566            StmtKind::Block(block) => block
1567                .stmts
1568                .iter()
1569                .map(|s| lower_stmt(s, ctx))
1570                .collect(),
1571            StmtKind::If { .. } | StmtKind::IfLet { .. } => vec![lower_stmt(else_stmt, ctx)],
1572            _ => vec![lower_stmt(else_stmt, ctx)],
1573        }
1574    });
1575
1576    JsStmt::Block(vec![
1577        JsStmt::Let {
1578            name: temp_name,
1579            init: Some(scrutinee_js),
1580        },
1581        JsStmt::If {
1582            cond: condition,
1583            then_block: then_stmts,
1584            else_block,
1585        },
1586    ])
1587}
1588
1589/// Pattern test for if-let - generates the condition to check if pattern matches
1590fn pattern_test_for_if_let(
1591    pattern: &Pattern,
1592    scrutinee_js: &JsExpr,
1593    ctx: &CodegenContext,
1594) -> Option<JsExpr> {
1595    match &pattern.kind {
1596        PatternKind::EnumUnit { path }
1597        | PatternKind::EnumTuple { path, .. }
1598        | PatternKind::EnumStruct { path, .. } => {
1599            let variant = path.last().map(|id| id.name.clone()).unwrap_or_default();
1600            Some(JsExpr::Binary {
1601                op: JsBinaryOp::StrictEq,
1602                left: Box::new(JsExpr::Member {
1603                    object: Box::new(scrutinee_js.clone()),
1604                    property: "tag".to_string(),
1605                }),
1606                right: Box::new(JsExpr::String(variant)),
1607            })
1608        }
1609        PatternKind::Binding(_) => {
1610            // Check variant_patterns for imported unit variants
1611            let span_key = (pattern.span.range.start, pattern.span.range.end);
1612            if let Some((_, variant_name)) = ctx.variant_patterns.get(&span_key) {
1613                Some(JsExpr::Binary {
1614                    op: JsBinaryOp::StrictEq,
1615                    left: Box::new(JsExpr::Member {
1616                        object: Box::new(scrutinee_js.clone()),
1617                        property: "tag".to_string(),
1618                    }),
1619                    right: Box::new(JsExpr::String(variant_name.clone())),
1620                })
1621            } else {
1622                None // True binding - catch-all
1623            }
1624        }
1625        _ => None,
1626    }
1627}
1628
1629/// Strip numeric suffix from method names for variadic function emulation.
1630/// E.g., "run1" -> "run", "run2" -> "run", "run" -> "run"
1631/// This allows Husk to declare `fn run1(self, p1: T)`, `fn run2(self, p1: T, p2: U)`
1632/// but emit `.run(p1)`, `.run(p1, p2)` in JavaScript for variadic JS functions.
1633fn strip_variadic_suffix(method_name: &str) -> String {
1634    // Check if the method ends with one or more digits
1635    let last_non_digit = method_name
1636        .char_indices()
1637        .rev()
1638        .find(|(_, c)| !c.is_ascii_digit());
1639
1640    match last_non_digit {
1641        Some((idx, _)) if idx < method_name.len() - 1 => {
1642            // Has trailing digits - strip them
1643            method_name[..=idx].to_string()
1644        }
1645        _ => {
1646            // No trailing digits or all digits - return as-is
1647            method_name.to_string()
1648        }
1649    }
1650}
1651
1652/// Generate JavaScript code for type conversion methods (.into(), .parse(), .try_into()).
1653///
1654/// Based on the target type, generates the appropriate JavaScript conversion:
1655/// - `.into()` for infallible conversions (From trait)
1656/// - `.parse()` for parsing strings (TryFrom<String>)
1657/// - `.try_into()` for fallible conversions (TryFrom trait)
1658fn lower_conversion_method(
1659    receiver: &Expr,
1660    method_name: &str,
1661    target_type: &str,
1662    ctx: &CodegenContext,
1663) -> JsExpr {
1664    let receiver_js = lower_expr(receiver, ctx);
1665
1666    match method_name {
1667        "into" => {
1668            // Generate infallible conversion based on target type
1669            match target_type {
1670                "String" => {
1671                    // value.into() -> String(value)
1672                    // Use String() constructor to avoid issues with number literals like `42.toString()`
1673                    JsExpr::Call {
1674                        callee: Box::new(JsExpr::Ident("String".to_string())),
1675                        args: vec![receiver_js],
1676                    }
1677                }
1678                "i64" => {
1679                    // value.into() -> BigInt(value)
1680                    JsExpr::Call {
1681                        callee: Box::new(JsExpr::Ident("BigInt".to_string())),
1682                        args: vec![receiver_js],
1683                    }
1684                }
1685                "f64" => {
1686                    // value.into() -> Number(value)
1687                    JsExpr::Call {
1688                        callee: Box::new(JsExpr::Ident("Number".to_string())),
1689                        args: vec![receiver_js],
1690                    }
1691                }
1692                _ => {
1693                    // For other types, just return the value (identity conversion or user-defined)
1694                    receiver_js
1695                }
1696            }
1697        }
1698        "parse" => {
1699            // Generate parsing conversion based on target type
1700            // Returns Result<TargetType, String>
1701            let parse_call = match target_type {
1702                "i32" => {
1703                    // "str".parse::<i32>() -> __husk_parse_i32(str)
1704                    JsExpr::Call {
1705                        callee: Box::new(JsExpr::Ident("__husk_parse_i32".to_string())),
1706                        args: vec![receiver_js],
1707                    }
1708                }
1709                "i64" => {
1710                    // "str".parse::<i64>() -> __husk_parse_i64(str)
1711                    JsExpr::Call {
1712                        callee: Box::new(JsExpr::Ident("__husk_parse_i64".to_string())),
1713                        args: vec![receiver_js],
1714                    }
1715                }
1716                "f64" => {
1717                    // "str".parse::<f64>() -> __husk_parse_f64(str)
1718                    JsExpr::Call {
1719                        callee: Box::new(JsExpr::Ident("__husk_parse_f64".to_string())),
1720                        args: vec![receiver_js],
1721                    }
1722                }
1723                _ => {
1724                    // Unsupported parse target - return error Result
1725                    JsExpr::Object(vec![
1726                        ("tag".to_string(), JsExpr::String("Err".to_string())),
1727                        (
1728                            "value".to_string(),
1729                            JsExpr::String(format!("cannot parse to {}", target_type)),
1730                        ),
1731                    ])
1732                }
1733            };
1734            parse_call
1735        }
1736        "try_into" => {
1737            // Generate fallible conversion based on target type
1738            // Returns Result<TargetType, String>
1739            match target_type {
1740                "i32" => {
1741                    // value.try_into::<i32>() -> __husk_try_into_i32(value)
1742                    JsExpr::Call {
1743                        callee: Box::new(JsExpr::Ident("__husk_try_into_i32".to_string())),
1744                        args: vec![receiver_js],
1745                    }
1746                }
1747                _ => {
1748                    // For other types, wrap in Ok
1749                    JsExpr::Object(vec![
1750                        ("tag".to_string(), JsExpr::String("Ok".to_string())),
1751                        ("value".to_string(), receiver_js),
1752                    ])
1753                }
1754            }
1755        }
1756        _ => receiver_js,
1757    }
1758}
1759
1760fn lower_expr(expr: &Expr, ctx: &CodegenContext) -> JsExpr {
1761    match &expr.kind {
1762        ExprKind::Literal(lit) => match &lit.kind {
1763            LiteralKind::Int(n) => JsExpr::Number(*n),
1764            LiteralKind::Float(f) => JsExpr::Float(*f),
1765            LiteralKind::Bool(b) => JsExpr::Bool(*b),
1766            LiteralKind::String(s) => JsExpr::String(s.clone()),
1767        },
1768        ExprKind::Ident(id) => {
1769            // Translate `self` to `this` for JavaScript method bodies
1770            if id.name == "self" {
1771                JsExpr::Ident("this".to_string())
1772            } else if let Some((_enum_name, variant_name)) = ctx.variant_calls.get(&(expr.span.range.start, expr.span.range.end)) {
1773                // Imported unit variant (e.g., `None` from `use Option::*`)
1774                JsExpr::Object(vec![("tag".to_string(), JsExpr::String(variant_name.clone()))])
1775            } else {
1776                // Use resolved name from name_resolution if available
1777                JsExpr::Ident(ctx.resolve_name(&id.name, &id.span))
1778            }
1779        }
1780        ExprKind::Path { segments } => {
1781            // MVP: treat `Enum::Variant` as a tagged union value `{ tag: "Variant" }`.
1782            // We ignore the enum name and any intermediate segments here.
1783            let variant = segments
1784                .last()
1785                .map(|id| id.name.clone())
1786                .unwrap_or_else(|| "Unknown".to_string());
1787            JsExpr::Object(vec![("tag".to_string(), JsExpr::String(variant))])
1788        }
1789        ExprKind::Field { base, member } => {
1790            let field_name = &member.name;
1791            // Check if this field is an extern property (look up by field name across all types)
1792            let js_property = ctx
1793                .accessors
1794                .getters
1795                .iter()
1796                .find(|((_, prop_name), _)| prop_name == field_name)
1797                .map(|(_, js_name)| js_name.clone())
1798                .unwrap_or_else(|| field_name.clone());
1799
1800            JsExpr::Member {
1801                object: Box::new(lower_expr(base, ctx)),
1802                property: js_property,
1803            }
1804        }
1805        ExprKind::MethodCall {
1806            receiver,
1807            method,
1808            type_args: _, // Turbofish types are used for semantic analysis, not codegen
1809            args,
1810        } => {
1811            // Try to determine receiver type for getter/setter lookup.
1812            // For now, we use a heuristic: look up by method name across all types.
1813            // A more robust solution would require type information from semantic analysis.
1814            let method_name = &method.name;
1815
1816            // Check if this is a getter (no args, matches getter pattern)
1817            if args.is_empty() {
1818                // Search for any type that has this method as a getter
1819                for ((_, m), prop) in &ctx.accessors.getters {
1820                    if m == method_name {
1821                        // Found a getter! Emit property access instead of method call.
1822                        return JsExpr::Member {
1823                            object: Box::new(lower_expr(receiver, ctx)),
1824                            property: prop.clone(),
1825                        };
1826                    }
1827                }
1828            }
1829
1830            // Check if this is a setter (one arg, method starts with "set_")
1831            if args.len() == 1 && method_name.starts_with("set_") {
1832                // Search for any type that has this method as a setter
1833                for ((_, m), prop) in &ctx.accessors.setters {
1834                    if m == method_name {
1835                        // Found a setter! Emit property assignment instead of method call.
1836                        return JsExpr::Assignment {
1837                            left: Box::new(JsExpr::Member {
1838                                object: Box::new(lower_expr(receiver, ctx)),
1839                                property: prop.clone(),
1840                            }),
1841                            op: JsAssignOp::Assign,
1842                            right: Box::new(lower_expr(&args[0], ctx)),
1843                        };
1844                    }
1845                }
1846            }
1847
1848            // Handle Result/Option unwrap and expect methods
1849            if method_name == "unwrap" && args.is_empty() {
1850                // result.unwrap() -> __husk_unwrap(result)
1851                return JsExpr::Call {
1852                    callee: Box::new(JsExpr::Ident("__husk_unwrap".to_string())),
1853                    args: vec![lower_expr(receiver, ctx)],
1854                };
1855            }
1856            if method_name == "expect" && args.len() == 1 {
1857                // result.expect("msg") -> __husk_expect(result, "msg")
1858                return JsExpr::Call {
1859                    callee: Box::new(JsExpr::Ident("__husk_expect".to_string())),
1860                    args: vec![lower_expr(receiver, ctx), lower_expr(&args[0], ctx)],
1861                };
1862            }
1863
1864            // Handle type conversion methods (.into(), .parse(), .try_into())
1865            // The semantic phase records the resolved target type in type_resolution
1866            if (method_name == "into" || method_name == "parse" || method_name == "try_into") && args.is_empty() {
1867                if let Some(target_type) = ctx.type_resolution.get(&(expr.span.range.start, expr.span.range.end)) {
1868                    return lower_conversion_method(receiver, method_name, target_type, ctx);
1869                }
1870            }
1871
1872            // Default: emit as a regular method call
1873            // Strip numeric suffix for variadic function emulation (run1, run2, run3 -> run)
1874            // Use #[js_name] override if present, otherwise convert snake_case to camelCase
1875            // (but only for extern "js" methods; user-defined Husk methods keep their names)
1876            let base_method_name = strip_variadic_suffix(&method.name);
1877
1878            // Look up #[js_name] override by searching across all types.
1879            // This is a heuristic since we don't have type info at codegen time.
1880            let js_name_override = ctx.accessors.method_js_names
1881                .iter()
1882                .find(|((_, m), _)| m == &base_method_name)
1883                .map(|(_, js_name)| js_name.clone());
1884
1885            // Check if this method is an extern "js" method for any type.
1886            let is_extern_method = ctx.accessors.extern_methods
1887                .iter()
1888                .any(|(_, m)| m == &base_method_name);
1889
1890            let js_method_name = js_name_override.unwrap_or_else(|| {
1891                // Only apply snake_to_camel for extern "js" methods
1892                if is_extern_method {
1893                    snake_to_camel(&base_method_name)
1894                } else {
1895                    base_method_name.clone()
1896                }
1897            });
1898            JsExpr::Call {
1899                callee: Box::new(JsExpr::Member {
1900                    object: Box::new(lower_expr(receiver, ctx)),
1901                    property: js_method_name,
1902                }),
1903                args: args.iter().map(|a| lower_expr(a, ctx)).collect(),
1904            }
1905        }
1906        ExprKind::Call { callee, type_args: _, args } => {
1907            // Handle built-in functions like println -> console.log
1908            if let ExprKind::Ident(ref id) = callee.kind {
1909                if id.name == "println" {
1910                    return JsExpr::Call {
1911                        callee: Box::new(JsExpr::Member {
1912                            object: Box::new(JsExpr::Ident("console".to_string())),
1913                            property: "log".to_string(),
1914                        }),
1915                        args: args.iter().map(|a| lower_expr(a, ctx)).collect(),
1916                    };
1917                }
1918                // print -> process.stdout.write (no newline)
1919                if id.name == "print" {
1920                    return JsExpr::Call {
1921                        callee: Box::new(JsExpr::Member {
1922                            object: Box::new(JsExpr::Member {
1923                                object: Box::new(JsExpr::Ident("process".to_string())),
1924                                property: "stdout".to_string(),
1925                            }),
1926                            property: "write".to_string(),
1927                        }),
1928                        args: args.iter().map(|a| lower_expr(a, ctx)).collect(),
1929                    };
1930                }
1931                // Rewrite express() to __husk_express() to patch use -> use_middleware
1932                if id.name == "express" {
1933                    return JsExpr::Call {
1934                        callee: Box::new(JsExpr::Ident("__husk_express".to_string())),
1935                        args: args.iter().map(|a| lower_expr(a, ctx)).collect(),
1936                    };
1937                }
1938
1939                // Handle include_str("path") - compile-time file inclusion
1940                if id.name == "include_str" {
1941                    return handle_include_str(args, ctx);
1942                }
1943
1944                // Handle parse_int -> parseInt
1945                if id.name == "parse_int" {
1946                    return JsExpr::Call {
1947                        callee: Box::new(JsExpr::Ident("parseInt".to_string())),
1948                        args: args.iter().map(|a| lower_expr(a, ctx)).collect(),
1949                    };
1950                }
1951
1952                // Handle parseLong -> BigInt (for i64)
1953                if id.name == "parseLong" {
1954                    return JsExpr::Call {
1955                        callee: Box::new(JsExpr::Ident("BigInt".to_string())),
1956                        args: args.iter().map(|a| lower_expr(a, ctx)).collect(),
1957                    };
1958                }
1959            }
1960
1961            // Handle imported variant construction: Some(x) -> {tag: "Some", value: x}
1962            // Check if this call is an imported variant (e.g., from `use Option::*`)
1963            if let Some((_enum_name, variant_name)) = ctx.variant_calls.get(&(expr.span.range.start, expr.span.range.end)) {
1964                let mut fields = vec![("tag".to_string(), JsExpr::String(variant_name.clone()))];
1965
1966                // Add the value(s) based on argument count
1967                if args.len() == 1 {
1968                    fields.push(("value".to_string(), lower_expr(&args[0], ctx)));
1969                } else {
1970                    for (i, arg) in args.iter().enumerate() {
1971                        fields.push((i.to_string(), lower_expr(arg, ctx)));
1972                    }
1973                }
1974
1975                return JsExpr::Object(fields);
1976            }
1977
1978            // Handle enum variant construction: Option::Some(x) -> {tag: "Some", value: x}
1979            if let ExprKind::Path { segments } = &callee.kind {
1980                let variant = segments
1981                    .last()
1982                    .map(|id| id.name.clone())
1983                    .unwrap_or_else(|| "Unknown".to_string());
1984
1985                let mut fields = vec![("tag".to_string(), JsExpr::String(variant))];
1986
1987                // Add the value(s) based on argument count
1988                if args.len() == 1 {
1989                    // Single argument: { tag: "Variant", value: arg }
1990                    fields.push(("value".to_string(), lower_expr(&args[0], ctx)));
1991                } else {
1992                    // Multiple arguments: { tag: "Variant", "0": arg0, "1": arg1, ... }
1993                    for (i, arg) in args.iter().enumerate() {
1994                        fields.push((i.to_string(), lower_expr(arg, ctx)));
1995                    }
1996                }
1997
1998                return JsExpr::Object(fields);
1999            }
2000
2001            JsExpr::Call {
2002                callee: Box::new(lower_expr(callee, ctx)),
2003                args: args.iter().map(|a| lower_expr(a, ctx)).collect(),
2004            }
2005        }
2006        ExprKind::Binary { op, left, right } => {
2007            use husk_ast::BinaryOp::*;
2008
2009            // Use deep equality for == and != to handle arrays and objects
2010            match op {
2011                Eq => {
2012                    return JsExpr::Call {
2013                        callee: Box::new(JsExpr::Ident("__husk_eq".to_string())),
2014                        args: vec![lower_expr(left, ctx), lower_expr(right, ctx)],
2015                    };
2016                }
2017                NotEq => {
2018                    // !__husk_eq(left, right)
2019                    return JsExpr::Unary {
2020                        op: JsUnaryOp::Not,
2021                        expr: Box::new(JsExpr::Call {
2022                            callee: Box::new(JsExpr::Ident("__husk_eq".to_string())),
2023                            args: vec![lower_expr(left, ctx), lower_expr(right, ctx)],
2024                        }),
2025                    };
2026                }
2027                _ => {}
2028            }
2029
2030            let js_op = match op {
2031                Add => JsBinaryOp::Add,
2032                Sub => JsBinaryOp::Sub,
2033                Mul => JsBinaryOp::Mul,
2034                Div => JsBinaryOp::Div,
2035                Mod => JsBinaryOp::Mod,
2036                Eq | NotEq => unreachable!(), // handled above
2037                Lt => JsBinaryOp::Lt,
2038                Gt => JsBinaryOp::Gt,
2039                Le => JsBinaryOp::Le,
2040                Ge => JsBinaryOp::Ge,
2041                And => JsBinaryOp::And,
2042                Or => JsBinaryOp::Or,
2043            };
2044            JsExpr::Binary {
2045                op: js_op,
2046                left: Box::new(lower_expr(left, ctx)),
2047                right: Box::new(lower_expr(right, ctx)),
2048            }
2049        }
2050        ExprKind::Match { scrutinee, arms } => lower_match_expr(scrutinee, arms, ctx),
2051        ExprKind::Struct { name, fields } => {
2052            // Lower struct instantiation to a constructor call.
2053            // `Point { x: 1, y: 2 }` -> `new Point(1, 2)`
2054            // The order of args must match the order of fields in the struct definition.
2055            // For now, we pass them in the order they appear in the instantiation.
2056            let args: Vec<JsExpr> = fields
2057                .iter()
2058                .map(|f| lower_expr(&f.value, ctx))
2059                .collect();
2060            // name is a Vec<Ident> representing the path (e.g., ["Point"] or ["module", "Point"])
2061            let constructor_name = name
2062                .last()
2063                .map(|id| id.name.clone())
2064                .unwrap_or_else(|| "UnknownType".to_string());
2065            JsExpr::New {
2066                constructor: constructor_name,
2067                args,
2068            }
2069        }
2070        ExprKind::Block(block) => {
2071            // Lower a block expression to an IIFE (Immediately Invoked Function Expression).
2072            // This allows blocks to be used as expressions in JavaScript.
2073            let mut body: Vec<JsStmt> = block
2074                .stmts
2075                .iter()
2076                .map(|s| lower_stmt(s, ctx))
2077                .collect();
2078            // Wrap the last statement in a return if it's an expression.
2079            if let Some(last) = body.pop() {
2080                match last {
2081                    JsStmt::Expr(expr) => body.push(JsStmt::Return(expr)),
2082                    other => body.push(other),
2083                }
2084            }
2085            JsExpr::Iife { body }
2086        }
2087        ExprKind::Unary { op, expr: inner } => {
2088            let js_op = match op {
2089                husk_ast::UnaryOp::Not => JsUnaryOp::Not,
2090                husk_ast::UnaryOp::Neg => JsUnaryOp::Neg,
2091            };
2092            JsExpr::Unary {
2093                op: js_op,
2094                expr: Box::new(lower_expr(inner, ctx)),
2095            }
2096        }
2097        ExprKind::FormatPrint { format, args, newline } => {
2098            // Generate console.log (with newline) or process.stdout.write (without newline)
2099            // Build the formatted string by concatenating literal segments and formatted args.
2100            lower_format_print(&format.segments, args, *newline, ctx)
2101        }
2102        ExprKind::Format { format, args } => {
2103            // Generate the formatted string (without console.log).
2104            lower_format_string(&format.segments, args, ctx)
2105        }
2106        ExprKind::Closure {
2107            params,
2108            ret_type: _,
2109            body,
2110        } => {
2111            // Lower closure to JS arrow function.
2112            // `|x, y| x + y` -> `(x, y) => x + y`
2113            // `|x: i32| -> i32 { x + 1 }` -> `(x) => { return x + 1; }`
2114            let js_params: Vec<String> = params
2115                .iter()
2116                .map(|p| ctx.resolve_name(&p.name.name, &p.name.span))
2117                .collect();
2118
2119            // Check if the body is a block or a simple expression
2120            let js_body = match &body.kind {
2121                ExprKind::Block(block) => {
2122                    // For block bodies, lower each statement
2123                    let mut stmts: Vec<JsStmt> = Vec::new();
2124                    for (i, stmt) in block.stmts.iter().enumerate() {
2125                        let is_last = i + 1 == block.stmts.len();
2126                        if is_last {
2127                            stmts.push(lower_tail_stmt(stmt, ctx));
2128                        } else {
2129                            stmts.push(lower_stmt(stmt, ctx));
2130                        }
2131                    }
2132                    stmts
2133                }
2134                _ => {
2135                    // For expression bodies, wrap in a return statement
2136                    vec![JsStmt::Return(lower_expr(body, ctx))]
2137                }
2138            };
2139
2140            JsExpr::Arrow {
2141                params: js_params,
2142                body: js_body,
2143            }
2144        }
2145        ExprKind::Array { elements } => {
2146            let js_elements: Vec<JsExpr> = elements.iter().map(|e| lower_expr(e, ctx)).collect();
2147            JsExpr::Array(js_elements)
2148        }
2149        ExprKind::Index { base, index } => {
2150            // Check if this is a slice operation (index is a Range)
2151            if let ExprKind::Range {
2152                start,
2153                end,
2154                inclusive,
2155            } = &index.kind
2156            {
2157                // Generate arr.slice(start, end) call
2158                let base_js = lower_expr(base, ctx);
2159                let slice_callee = JsExpr::Member {
2160                    object: Box::new(base_js),
2161                    property: "slice".to_string(),
2162                };
2163
2164                let mut args = Vec::new();
2165
2166                // Start argument: default to 0 if not present
2167                match start {
2168                    Some(s) => args.push(lower_expr(s, ctx)),
2169                    None => args.push(JsExpr::Number(0)),
2170                }
2171
2172                // End argument: omit for arr[start..] (slice to end), or adjust for inclusive
2173                if let Some(e) = end {
2174                    if *inclusive {
2175                        // For inclusive range, add 1 to the end
2176                        args.push(JsExpr::Binary {
2177                            op: JsBinaryOp::Add,
2178                            left: Box::new(lower_expr(e, ctx)),
2179                            right: Box::new(JsExpr::Number(1)),
2180                        });
2181                    } else {
2182                        args.push(lower_expr(e, ctx));
2183                    }
2184                }
2185                // If end is None, we don't add a second argument - slice(start) goes to the end
2186
2187                JsExpr::Call {
2188                    callee: Box::new(slice_callee),
2189                    args,
2190                }
2191            } else {
2192                // Simple index: arr[i]
2193                JsExpr::Index {
2194                    object: Box::new(lower_expr(base, ctx)),
2195                    index: Box::new(lower_expr(index, ctx)),
2196                }
2197            }
2198        }
2199        ExprKind::Range { .. } => {
2200            // Range expressions are handled specially in ForIn lowering.
2201            // If we encounter a standalone range, just return a placeholder.
2202            // In practice, ranges should only appear in for-in loops.
2203            JsExpr::Object(vec![
2204                ("start".to_string(), JsExpr::Number(0)),
2205                ("end".to_string(), JsExpr::Number(0)),
2206            ])
2207        }
2208        ExprKind::Assign { target, op, value } => {
2209            let js_op = match op {
2210                husk_ast::AssignOp::Assign => JsAssignOp::Assign,
2211                husk_ast::AssignOp::AddAssign => JsAssignOp::AddAssign,
2212                husk_ast::AssignOp::SubAssign => JsAssignOp::SubAssign,
2213                husk_ast::AssignOp::ModAssign => JsAssignOp::ModAssign,
2214            };
2215            JsExpr::Assignment {
2216                left: Box::new(lower_expr(target, ctx)),
2217                op: js_op,
2218                right: Box::new(lower_expr(value, ctx)),
2219            }
2220        }
2221        ExprKind::JsLiteral { code } => {
2222            // Emit raw JavaScript code wrapped in parentheses for safe precedence
2223            JsExpr::Raw(format!("({})", code))
2224        }
2225        ExprKind::Cast {
2226            expr: inner,
2227            target_ty,
2228        } => {
2229            let js_inner = lower_expr(inner, ctx);
2230
2231            // Generate JavaScript conversion based on target type
2232            match &target_ty.kind {
2233                TypeExprKind::Named(ident) => match ident.name.as_str() {
2234                    "i32" => {
2235                        // f64/i64/bool → i32: truncate and convert to 32-bit signed integer
2236                        // Math.trunc handles the float→int conversion
2237                        // | 0 ensures 32-bit signed integer semantics (handles overflow wrapping)
2238                        // We need to emit this as raw JS since JsBinaryOp doesn't have BitOr
2239                        let mut inner_str = String::new();
2240                        write_expr(&js_inner, &mut inner_str);
2241                        JsExpr::Raw(format!("(Math.trunc(Number({})) | 0)", inner_str))
2242                    }
2243                    "i64" => {
2244                        // i32/f64/bool → i64: Convert to BigInt
2245                        // BigInt() handles the conversion, Math.trunc needed for f64
2246                        let mut inner_str = String::new();
2247                        write_expr(&js_inner, &mut inner_str);
2248                        JsExpr::Raw(format!("BigInt(Math.trunc({}))", inner_str))
2249                    }
2250                    "f64" => {
2251                        // i32/i64/bool → f64: Use Number() to ensure boolean/BigInt becomes numeric
2252                        JsExpr::Call {
2253                            callee: Box::new(JsExpr::Ident("Number".to_string())),
2254                            args: vec![js_inner],
2255                        }
2256                    }
2257                    "String" => {
2258                        // any → String: use String() constructor
2259                        JsExpr::Call {
2260                            callee: Box::new(JsExpr::Ident("String".to_string())),
2261                            args: vec![js_inner],
2262                        }
2263                    }
2264                    "bool" => {
2265                        // Should have been caught by semantic analysis
2266                        panic!("invalid cast to bool should have been rejected by semantic analysis")
2267                    }
2268                    _ => {
2269                        // Unknown type, pass through (shouldn't happen for valid casts)
2270                        js_inner
2271                    }
2272                },
2273                _ => {
2274                    // Non-simple type casts not supported, pass through
2275                    js_inner
2276                }
2277            }
2278        }
2279        ExprKind::Tuple { elements } => {
2280            // Tuples are represented as JavaScript arrays
2281            let js_elements: Vec<JsExpr> = elements.iter().map(|e| lower_expr(e, ctx)).collect();
2282            JsExpr::Array(js_elements)
2283        }
2284        ExprKind::TupleField { base, index } => {
2285            // Tuple field access: tuple.0 -> tuple[0]
2286            let js_base = lower_expr(base, ctx);
2287            JsExpr::Index {
2288                object: Box::new(js_base),
2289                index: Box::new(JsExpr::Number(*index as i64)),
2290            }
2291        }
2292    }
2293}
2294
2295/// Lower a FormatPrint expression to a console.log (println) or process.stdout.write (print) call.
2296fn lower_format_print(
2297    segments: &[FormatSegment],
2298    args: &[Expr],
2299    newline: bool,
2300    ctx: &CodegenContext,
2301) -> JsExpr {
2302    // Track which argument we're using for implicit positioning
2303    let mut implicit_index = 0;
2304    let mut parts: Vec<JsExpr> = Vec::new();
2305
2306    for segment in segments {
2307        match segment {
2308            FormatSegment::Literal(text) => {
2309                if !text.is_empty() {
2310                    parts.push(JsExpr::String(text.clone()));
2311                }
2312            }
2313            FormatSegment::Placeholder(ph) => {
2314                // Determine which argument to use
2315                let arg_index = ph.position.unwrap_or_else(|| {
2316                    let idx = implicit_index;
2317                    implicit_index += 1;
2318                    idx
2319                });
2320
2321                if let Some(arg) = args.get(arg_index) {
2322                    let arg_js = lower_expr(arg, ctx);
2323                    let formatted = format_arg(arg_js, &ph.spec);
2324                    parts.push(formatted);
2325                }
2326            }
2327        }
2328    }
2329
2330    // Build the formatted string
2331    // If we only have one part, use it directly
2332    // If we have multiple parts, concatenate them
2333    let output_arg = if parts.is_empty() {
2334        JsExpr::String(String::new())
2335    } else if parts.len() == 1 {
2336        parts.pop().unwrap()
2337    } else {
2338        // Concatenate all parts with +
2339        let mut iter = parts.into_iter();
2340        let mut acc = iter.next().unwrap();
2341        for part in iter {
2342            acc = JsExpr::Binary {
2343                op: JsBinaryOp::Add,
2344                left: Box::new(acc),
2345                right: Box::new(part),
2346            };
2347        }
2348        acc
2349    };
2350
2351    if newline {
2352        // println -> console.log (adds newline automatically)
2353        JsExpr::Call {
2354            callee: Box::new(JsExpr::Member {
2355                object: Box::new(JsExpr::Ident("console".to_string())),
2356                property: "log".to_string(),
2357            }),
2358            args: vec![output_arg],
2359        }
2360    } else {
2361        // print -> process.stdout.write (no newline)
2362        JsExpr::Call {
2363            callee: Box::new(JsExpr::Member {
2364                object: Box::new(JsExpr::Member {
2365                    object: Box::new(JsExpr::Ident("process".to_string())),
2366                    property: "stdout".to_string(),
2367                }),
2368                property: "write".to_string(),
2369            }),
2370            args: vec![output_arg],
2371        }
2372    }
2373}
2374
2375/// Lower a Format expression to a string (without console.log).
2376fn lower_format_string(
2377    segments: &[FormatSegment],
2378    args: &[Expr],
2379    ctx: &CodegenContext,
2380) -> JsExpr {
2381    // Track which argument we're using for implicit positioning
2382    let mut implicit_index = 0;
2383    let mut parts: Vec<JsExpr> = Vec::new();
2384
2385    for segment in segments {
2386        match segment {
2387            FormatSegment::Literal(text) => {
2388                if !text.is_empty() {
2389                    parts.push(JsExpr::String(text.clone()));
2390                }
2391            }
2392            FormatSegment::Placeholder(ph) => {
2393                // Determine which argument to use
2394                let arg_index = ph.position.unwrap_or_else(|| {
2395                    let idx = implicit_index;
2396                    implicit_index += 1;
2397                    idx
2398                });
2399
2400                if let Some(arg) = args.get(arg_index) {
2401                    let arg_js = lower_expr(arg, ctx);
2402                    let formatted = format_arg(arg_js, &ph.spec);
2403                    parts.push(formatted);
2404                }
2405            }
2406        }
2407    }
2408
2409    // Build the formatted string
2410    // If we only have one part, return it directly
2411    // If we have multiple parts, concatenate them
2412    if parts.is_empty() {
2413        JsExpr::String(String::new())
2414    } else if parts.len() == 1 {
2415        parts.pop().unwrap()
2416    } else {
2417        // Concatenate all parts with +
2418        let mut iter = parts.into_iter();
2419        let mut acc = iter.next().unwrap();
2420        for part in iter {
2421            acc = JsExpr::Binary {
2422                op: JsBinaryOp::Add,
2423                left: Box::new(acc),
2424                right: Box::new(part),
2425            };
2426        }
2427        acc
2428    }
2429}
2430
2431/// Format an argument according to the format spec.
2432fn format_arg(arg: JsExpr, spec: &FormatSpec) -> JsExpr {
2433    // Check for special type specifiers
2434    match spec.ty {
2435        Some('?') => {
2436            // Debug format: __husk_fmt_debug(arg, pretty)
2437            let pretty = spec.alternate;
2438            JsExpr::Call {
2439                callee: Box::new(JsExpr::Ident("__husk_fmt_debug".to_string())),
2440                args: vec![arg, JsExpr::Bool(pretty)],
2441            }
2442        }
2443        Some('x') | Some('X') | Some('b') | Some('o') => {
2444            // Numeric format: __husk_fmt_num(value, base, width, precision, fill, align, sign, alternate, zeroPad, uppercase)
2445            let base = match spec.ty {
2446                Some('x') | Some('X') => 16,
2447                Some('b') => 2,
2448                Some('o') => 8,
2449                _ => 10,
2450            };
2451            let uppercase = spec.ty == Some('X');
2452
2453            JsExpr::Call {
2454                callee: Box::new(JsExpr::Ident("__husk_fmt_num".to_string())),
2455                args: vec![
2456                    arg,
2457                    JsExpr::Number(base),
2458                    spec.width
2459                        .map_or(JsExpr::Number(0), |w| JsExpr::Number(w as i64)),
2460                    spec.precision
2461                        .map_or(JsExpr::Ident("null".to_string()), |p| {
2462                            JsExpr::Number(p as i64)
2463                        }),
2464                    spec.fill.map_or(JsExpr::Ident("null".to_string()), |c| {
2465                        JsExpr::String(c.to_string())
2466                    }),
2467                    spec.align.map_or(JsExpr::Ident("null".to_string()), |c| {
2468                        JsExpr::String(c.to_string())
2469                    }),
2470                    JsExpr::Bool(spec.sign),
2471                    JsExpr::Bool(spec.alternate),
2472                    JsExpr::Bool(spec.zero_pad),
2473                    JsExpr::Bool(uppercase),
2474                ],
2475            }
2476        }
2477        None if has_formatting(spec) => {
2478            // Basic display format with formatting options
2479            // Use __husk_fmt_num for numeric formatting (zero-pad, width with numbers)
2480            if spec.zero_pad || spec.sign {
2481                // Use numeric formatting
2482                JsExpr::Call {
2483                    callee: Box::new(JsExpr::Ident("__husk_fmt_num".to_string())),
2484                    args: vec![
2485                        arg,
2486                        JsExpr::Number(10), // base 10
2487                        spec.width
2488                            .map_or(JsExpr::Number(0), |w| JsExpr::Number(w as i64)),
2489                        spec.precision
2490                            .map_or(JsExpr::Ident("null".to_string()), |p| {
2491                                JsExpr::Number(p as i64)
2492                            }),
2493                        spec.fill.map_or(JsExpr::Ident("null".to_string()), |c| {
2494                            JsExpr::String(c.to_string())
2495                        }),
2496                        spec.align.map_or(JsExpr::Ident("null".to_string()), |c| {
2497                            JsExpr::String(c.to_string())
2498                        }),
2499                        JsExpr::Bool(spec.sign),
2500                        JsExpr::Bool(spec.alternate),
2501                        JsExpr::Bool(spec.zero_pad),
2502                        JsExpr::Bool(false), // uppercase
2503                    ],
2504                }
2505            } else if spec.width.is_some() {
2506                // Use string padding for width without zero-pad
2507                let str_arg = JsExpr::Call {
2508                    callee: Box::new(JsExpr::Ident("String".to_string())),
2509                    args: vec![arg],
2510                };
2511                JsExpr::Call {
2512                    callee: Box::new(JsExpr::Ident("__husk_fmt_pad".to_string())),
2513                    args: vec![
2514                        str_arg,
2515                        JsExpr::Number(spec.width.unwrap_or(0) as i64),
2516                        spec.fill.map_or(JsExpr::Ident("null".to_string()), |c| {
2517                            JsExpr::String(c.to_string())
2518                        }),
2519                        spec.align.map_or(JsExpr::Ident("null".to_string()), |c| {
2520                            JsExpr::String(c.to_string())
2521                        }),
2522                    ],
2523                }
2524            } else {
2525                // Just convert to string with __husk_fmt
2526                JsExpr::Call {
2527                    callee: Box::new(JsExpr::Ident("__husk_fmt".to_string())),
2528                    args: vec![arg],
2529                }
2530            }
2531        }
2532        None => {
2533            // Simple {} - just use __husk_fmt for proper display
2534            JsExpr::Call {
2535                callee: Box::new(JsExpr::Ident("__husk_fmt".to_string())),
2536                args: vec![arg],
2537            }
2538        }
2539        _ => {
2540            // Unknown format specifier, just convert to string
2541            JsExpr::Call {
2542                callee: Box::new(JsExpr::Ident("__husk_fmt".to_string())),
2543                args: vec![arg],
2544            }
2545        }
2546    }
2547}
2548
2549/// Check if a format spec has any non-default formatting options.
2550fn has_formatting(spec: &FormatSpec) -> bool {
2551    spec.fill.is_some()
2552        || spec.align.is_some()
2553        || spec.sign
2554        || spec.alternate
2555        || spec.zero_pad
2556        || spec.width.is_some()
2557        || spec.precision.is_some()
2558}
2559
2560/// Extract bindings from a pattern for use in match arms.
2561/// Returns a list of (binding_name, accessor_expr) pairs.
2562fn extract_pattern_bindings(
2563    pattern: &husk_ast::Pattern,
2564    scrutinee_js: &JsExpr,
2565) -> Vec<(String, JsExpr)> {
2566    use husk_ast::PatternKind;
2567
2568    match &pattern.kind {
2569        PatternKind::EnumTuple { fields, .. } => {
2570            let mut bindings = Vec::new();
2571            for (i, field) in fields.iter().enumerate() {
2572                if let PatternKind::Binding(ident) = &field.kind {
2573                    // For single-field tuple, use .value; for multi-field, use ["0"], ["1"], etc.
2574                    let accessor = if fields.len() == 1 {
2575                        JsExpr::Member {
2576                            object: Box::new(scrutinee_js.clone()),
2577                            property: "value".to_string(),
2578                        }
2579                    } else {
2580                        JsExpr::Index {
2581                            object: Box::new(scrutinee_js.clone()),
2582                            index: Box::new(JsExpr::String(i.to_string())),
2583                        }
2584                    };
2585                    bindings.push((ident.name.clone(), accessor));
2586                }
2587            }
2588            bindings
2589        }
2590        PatternKind::Tuple { fields } => {
2591            // Tuple pattern: (x, y, z) -> x = scrutinee[0], y = scrutinee[1], etc.
2592            let mut bindings = Vec::new();
2593            for (i, field) in fields.iter().enumerate() {
2594                let accessor = JsExpr::Index {
2595                    object: Box::new(scrutinee_js.clone()),
2596                    index: Box::new(JsExpr::Number(i as i64)),
2597                };
2598                // Recursively extract bindings from nested patterns
2599                match &field.kind {
2600                    PatternKind::Binding(ident) => {
2601                        bindings.push((ident.name.clone(), accessor));
2602                    }
2603                    PatternKind::Wildcard => {
2604                        // Ignore wildcards
2605                    }
2606                    PatternKind::Tuple { .. } => {
2607                        // Nested tuple pattern - extract recursively
2608                        bindings.extend(extract_pattern_bindings(field, &accessor));
2609                    }
2610                    _ => {}
2611                }
2612            }
2613            bindings
2614        }
2615        _ => Vec::new(),
2616    }
2617}
2618
2619fn lower_match_expr(
2620    scrutinee: &Expr,
2621    arms: &[husk_ast::MatchArm],
2622    ctx: &CodegenContext,
2623) -> JsExpr {
2624    use husk_ast::PatternKind;
2625
2626    let scrutinee_js = lower_expr(scrutinee, ctx);
2627
2628    // Build nested conditional expression from the arms, folding from the end.
2629    // For a catch-all (wildcard or true binding) arm, we treat its body as the final `else`.
2630    // Note: A Binding pattern might actually be an imported unit variant (e.g., `None` from `use Option::*`),
2631    // so we check the variant_patterns map to distinguish.
2632    fn pattern_test(
2633        pattern: &husk_ast::Pattern,
2634        scrutinee_js: &JsExpr,
2635        variant_patterns: &VariantPatternMap,
2636    ) -> Option<JsExpr> {
2637        match &pattern.kind {
2638            PatternKind::EnumUnit { path } | PatternKind::EnumTuple { path, .. } => {
2639                // Use the last path segment as the variant tag string.
2640                let variant = path
2641                    .last()
2642                    .map(|id| id.name.clone())
2643                    .unwrap_or_else(|| "Unknown".to_string());
2644                let tag_access = JsExpr::Member {
2645                    object: Box::new(scrutinee_js.clone()),
2646                    property: "tag".to_string(),
2647                };
2648                Some(JsExpr::Binary {
2649                    op: JsBinaryOp::EqEq,
2650                    left: Box::new(tag_access),
2651                    right: Box::new(JsExpr::String(variant)),
2652                })
2653            }
2654            PatternKind::Binding(_) => {
2655                // Check if this binding is actually an imported unit variant
2656                if let Some((_enum_name, variant_name)) =
2657                    variant_patterns.get(&(pattern.span.range.start, pattern.span.range.end))
2658                {
2659                    let tag_access = JsExpr::Member {
2660                        object: Box::new(scrutinee_js.clone()),
2661                        property: "tag".to_string(),
2662                    };
2663                    Some(JsExpr::Binary {
2664                        op: JsBinaryOp::EqEq,
2665                        left: Box::new(tag_access),
2666                        right: Box::new(JsExpr::String(variant_name.clone())),
2667                    })
2668                } else {
2669                    // True binding pattern - acts as catch-all
2670                    None
2671                }
2672            }
2673            PatternKind::Wildcard => None,
2674            PatternKind::EnumStruct { .. } => None,
2675            // Tuple patterns are always matched (irrefutable)
2676            PatternKind::Tuple { .. } => None,
2677        }
2678    }
2679
2680    // Lower an arm body, potentially wrapping it with let bindings for enum tuple patterns.
2681    fn lower_arm_body(
2682        arm: &husk_ast::MatchArm,
2683        scrutinee_js: &JsExpr,
2684        ctx: &CodegenContext,
2685    ) -> JsExpr {
2686        let bindings = extract_pattern_bindings(&arm.pattern, scrutinee_js);
2687        if bindings.is_empty() {
2688            lower_expr(&arm.expr, ctx)
2689        } else {
2690            // Wrap in an IIFE to introduce bindings:
2691            // (function() { let x = scrutinee.value; return <body>; })()
2692            let mut body = Vec::new();
2693            for (name, accessor) in bindings {
2694                body.push(JsStmt::Let {
2695                    name,
2696                    init: Some(accessor),
2697                });
2698            }
2699            body.push(JsStmt::Return(lower_expr(&arm.expr, ctx)));
2700            JsExpr::Iife { body }
2701        }
2702    }
2703
2704    if arms.is_empty() {
2705        return JsExpr::Ident("undefined".to_string());
2706    }
2707
2708    // Start from the last arm as the innermost expression.
2709    let mut iter = arms.iter().rev();
2710    let last_arm = iter.next().unwrap();
2711    let mut acc = lower_arm_body(last_arm, &scrutinee_js, ctx);
2712
2713    for arm in iter {
2714        if let Some(test) = pattern_test(&arm.pattern, &scrutinee_js, ctx.variant_patterns) {
2715            let then_expr = lower_arm_body(arm, &scrutinee_js, ctx);
2716            acc = JsExpr::Conditional {
2717                test: Box::new(test),
2718                then_branch: Box::new(then_expr),
2719                else_branch: Box::new(acc),
2720            };
2721        } else {
2722            // Catch-all arm: replace accumulator with its expression.
2723            acc = lower_arm_body(arm, &scrutinee_js, ctx);
2724        }
2725    }
2726
2727    acc
2728}
2729
2730/// Lower a match expression to if/else statements (for use in statement position).
2731/// This allows break/continue in match arms to work correctly.
2732fn lower_match_stmt(
2733    scrutinee: &Expr,
2734    arms: &[husk_ast::MatchArm],
2735    ctx: &CodegenContext,
2736) -> JsStmt {
2737    use husk_ast::PatternKind;
2738
2739    let scrutinee_js = lower_expr(scrutinee, ctx);
2740
2741    // Generate condition for pattern matching.
2742    // Note: A Binding pattern might actually be an imported unit variant (e.g., `None` from `use Option::*`),
2743    // so we check the variant_patterns map to distinguish.
2744    fn pattern_test(
2745        pattern: &husk_ast::Pattern,
2746        scrutinee_js: &JsExpr,
2747        variant_patterns: &VariantPatternMap,
2748    ) -> Option<JsExpr> {
2749        match &pattern.kind {
2750            PatternKind::EnumUnit { path } | PatternKind::EnumTuple { path, .. } => {
2751                let variant = path
2752                    .last()
2753                    .map(|id| id.name.clone())
2754                    .unwrap_or_else(|| "Unknown".to_string());
2755                let tag_access = JsExpr::Member {
2756                    object: Box::new(scrutinee_js.clone()),
2757                    property: "tag".to_string(),
2758                };
2759                Some(JsExpr::Binary {
2760                    op: JsBinaryOp::EqEq,
2761                    left: Box::new(tag_access),
2762                    right: Box::new(JsExpr::String(variant)),
2763                })
2764            }
2765            PatternKind::Binding(_) => {
2766                // Check if this binding is actually an imported unit variant
2767                if let Some((_enum_name, variant_name)) =
2768                    variant_patterns.get(&(pattern.span.range.start, pattern.span.range.end))
2769                {
2770                    let tag_access = JsExpr::Member {
2771                        object: Box::new(scrutinee_js.clone()),
2772                        property: "tag".to_string(),
2773                    };
2774                    Some(JsExpr::Binary {
2775                        op: JsBinaryOp::EqEq,
2776                        left: Box::new(tag_access),
2777                        right: Box::new(JsExpr::String(variant_name.clone())),
2778                    })
2779                } else {
2780                    // True binding pattern - acts as catch-all
2781                    None
2782                }
2783            }
2784            PatternKind::Wildcard => None,
2785            PatternKind::EnumStruct { .. } => None,
2786            // Tuple patterns are always matched (irrefutable)
2787            PatternKind::Tuple { .. } => None,
2788        }
2789    }
2790
2791    // Lower arm body to statements, including any pattern bindings
2792    fn lower_arm_body_stmts(
2793        arm: &husk_ast::MatchArm,
2794        scrutinee_js: &JsExpr,
2795        ctx: &CodegenContext,
2796    ) -> Vec<JsStmt> {
2797        let bindings = extract_pattern_bindings(&arm.pattern, scrutinee_js);
2798        let mut stmts = Vec::new();
2799
2800        // Add let bindings for pattern variables
2801        for (name, accessor) in bindings {
2802            stmts.push(JsStmt::Let {
2803                name,
2804                init: Some(accessor),
2805            });
2806        }
2807
2808        // Lower the arm expression - if it's a block, expand its statements
2809        match &arm.expr.kind {
2810            ExprKind::Block(block) => {
2811                for stmt in &block.stmts {
2812                    stmts.push(lower_stmt(stmt, ctx));
2813                }
2814            }
2815            _ => {
2816                stmts.push(JsStmt::Expr(lower_expr(&arm.expr, ctx)));
2817            }
2818        }
2819
2820        stmts
2821    }
2822
2823    if arms.is_empty() {
2824        return JsStmt::Expr(JsExpr::Ident("undefined".to_string()));
2825    }
2826
2827    // Build nested if/else chain from arms
2828    let mut result: Option<JsStmt> = None;
2829
2830    for arm in arms.iter().rev() {
2831        let body = lower_arm_body_stmts(arm, &scrutinee_js, ctx);
2832
2833        if let Some(test) = pattern_test(&arm.pattern, &scrutinee_js, ctx.variant_patterns) {
2834            let else_block = result.map(|s| vec![s]);
2835            result = Some(JsStmt::If {
2836                cond: test,
2837                then_block: body,
2838                else_block,
2839            });
2840        } else {
2841            // Catch-all arm - just use its body as the innermost else
2842            // For simplicity, wrap in a block-like structure
2843            if body.len() == 1 {
2844                result = Some(body.into_iter().next().unwrap());
2845            } else {
2846                // Multiple statements - wrap in if(true) for now
2847                result = Some(JsStmt::If {
2848                    cond: JsExpr::Bool(true),
2849                    then_block: body,
2850                    else_block: None,
2851                });
2852            }
2853        }
2854    }
2855
2856    result.unwrap_or_else(|| JsStmt::Expr(JsExpr::Ident("undefined".to_string())))
2857}
2858
2859impl JsModule {
2860    /// Render the module to a JavaScript source string.
2861    pub fn to_source(&self) -> String {
2862        let mut out = String::new();
2863        for stmt in &self.body {
2864            write_stmt(stmt, 0, &mut out);
2865            out.push('\n');
2866        }
2867        out
2868    }
2869
2870    /// Render the module to a JavaScript source string with the Husk preamble.
2871    /// Import/require statements are placed at the very top, followed by the preamble,
2872    /// then the rest of the code.
2873    pub fn to_source_with_preamble(&self) -> String {
2874        let mut out = String::new();
2875
2876        // First, output all import/require statements at the top
2877        let mut has_imports = false;
2878        for stmt in &self.body {
2879            if is_import_stmt(stmt) {
2880                write_stmt(stmt, 0, &mut out);
2881                out.push('\n');
2882                has_imports = true;
2883            }
2884        }
2885        if has_imports {
2886            out.push('\n');
2887        }
2888
2889        // Then output the preamble
2890        out.push_str(std_preamble_js());
2891        if !out.ends_with('\n') {
2892            out.push('\n');
2893        }
2894        out.push('\n');
2895
2896        // Finally output the rest of the code (excluding imports/requires)
2897        for stmt in &self.body {
2898            if !is_import_stmt(stmt) {
2899                write_stmt(stmt, 0, &mut out);
2900                out.push('\n');
2901            }
2902        }
2903
2904        out
2905    }
2906
2907    /// Render the module to JavaScript source with a source map.
2908    /// Returns (js_source, source_map_json).
2909    pub fn to_source_with_sourcemap(
2910        &self,
2911        source_file: &str,
2912        source_content: &str,
2913    ) -> (String, String) {
2914        let mut out = String::new();
2915        let mut builder = SourceMapBuilder::new(Some(source_file));
2916
2917        // Add source file to the source map
2918        let source_id = builder.add_source(source_file);
2919        builder.set_source_contents(source_id, Some(source_content));
2920
2921        // Count preamble lines
2922        let preamble = std_preamble_js();
2923        let preamble_lines = preamble.lines().count() as u32;
2924
2925        // First, output all import/require statements at the top
2926        let mut has_imports = false;
2927        for stmt in &self.body {
2928            if is_import_stmt(stmt) {
2929                write_stmt(stmt, 0, &mut out);
2930                out.push('\n');
2931                has_imports = true;
2932            }
2933        }
2934        let import_lines = if has_imports {
2935            out.lines().count() as u32 + 1 // +1 for blank line
2936        } else {
2937            0
2938        };
2939        if has_imports {
2940            out.push('\n');
2941        }
2942
2943        // Then output the preamble
2944        out.push_str(preamble);
2945        if !out.ends_with('\n') {
2946            out.push('\n');
2947        }
2948        out.push('\n');
2949
2950        // Track line number as we output code
2951        let mut current_line = import_lines + preamble_lines + 1; // +1 for blank line after preamble
2952
2953        // Finally output the rest of the code (excluding imports/requires)
2954        for stmt in &self.body {
2955            if !is_import_stmt(stmt) {
2956                // Add source mapping for functions
2957                if let JsStmt::Function {
2958                    name,
2959                    source_span: Some(span),
2960                    ..
2961                } = stmt
2962                {
2963                    builder.add(
2964                        current_line,
2965                        0,
2966                        span.line,
2967                        span.column,
2968                        Some(source_file),
2969                        Some(name.as_str()),
2970                        false,
2971                    );
2972                }
2973                write_stmt(stmt, 0, &mut out);
2974                out.push('\n');
2975                current_line += count_newlines_in_stmt(stmt) + 1;
2976            }
2977        }
2978
2979        let source_map = builder.into_sourcemap();
2980        let mut sm_out = Vec::new();
2981        source_map
2982            .to_writer(&mut sm_out)
2983            .expect("failed to write source map");
2984        let sm_json = String::from_utf8(sm_out).expect("source map is utf8");
2985
2986        (out, sm_json)
2987    }
2988}
2989
2990/// Count newlines in a statement (for line tracking in source maps)
2991fn count_newlines_in_stmt(stmt: &JsStmt) -> u32 {
2992    match stmt {
2993        JsStmt::Function { body, .. } => {
2994            // function header + body lines + closing brace
2995            1 + body
2996                .iter()
2997                .map(|s| count_newlines_in_stmt(s) + 1)
2998                .sum::<u32>()
2999        }
3000        JsStmt::TryCatch {
3001            try_block,
3002            catch_block,
3003            ..
3004        } => {
3005            // try { + try body + } catch { + catch body + }
3006            2 + try_block
3007                .iter()
3008                .map(|s| count_newlines_in_stmt(s) + 1)
3009                .sum::<u32>()
3010                + catch_block
3011                    .iter()
3012                    .map(|s| count_newlines_in_stmt(s) + 1)
3013                    .sum::<u32>()
3014        }
3015        JsStmt::If {
3016            then_block,
3017            else_block,
3018            ..
3019        } => {
3020            // if { + then body + } else { + else body + }
3021            let then_lines = then_block
3022                .iter()
3023                .map(|s| count_newlines_in_stmt(s) + 1)
3024                .sum::<u32>();
3025            let else_lines = else_block.as_ref().map_or(0, |eb| {
3026                1 + eb
3027                    .iter()
3028                    .map(|s| count_newlines_in_stmt(s) + 1)
3029                    .sum::<u32>()
3030            });
3031            1 + then_lines + else_lines
3032        }
3033        JsStmt::Block(stmts) => {
3034            // { + body lines + }
3035            1 + stmts
3036                .iter()
3037                .map(|s| count_newlines_in_stmt(s) + 1)
3038                .sum::<u32>()
3039        }
3040        JsStmt::Sequence(stmts) => {
3041            // Statements at same level, each followed by newline except last
3042            stmts
3043                .iter()
3044                .map(|s| count_newlines_in_stmt(s) + 1)
3045                .sum::<u32>()
3046                .saturating_sub(1)
3047        }
3048        _ => 0, // single-line statements
3049    }
3050}
3051
3052fn indent(level: usize, out: &mut String) {
3053    for _ in 0..level {
3054        out.push_str("    ");
3055    }
3056}
3057
3058/// Check if a statement is an import/require statement.
3059fn is_import_stmt(stmt: &JsStmt) -> bool {
3060    matches!(
3061        stmt,
3062        JsStmt::Import { .. }
3063            | JsStmt::NamedImport { .. }
3064            | JsStmt::Require { .. }
3065            | JsStmt::NamedRequire { .. }
3066    )
3067}
3068
3069fn write_stmt(stmt: &JsStmt, indent_level: usize, out: &mut String) {
3070    match stmt {
3071        JsStmt::Import { name, source } => {
3072            indent(indent_level, out);
3073            out.push_str("import ");
3074            out.push_str(name);
3075            out.push_str(" from \"");
3076            out.push_str(&source.replace('"', "\\\""));
3077            out.push_str("\";");
3078        }
3079        JsStmt::NamedImport { names, source } => {
3080            // Universal pattern that works with both CJS and ESM packages:
3081            // import * as __pkg from "pkg";
3082            // const _pkg = __pkg.default || __pkg;
3083            // const { a, b, c } = _pkg;
3084            let safe_name = source.replace('-', "_").replace('@', "").replace('/', "_");
3085            let star_name = format!("__{}", safe_name);
3086            let pkg_name = format!("_{}", safe_name);
3087
3088            indent(indent_level, out);
3089            out.push_str("import * as ");
3090            out.push_str(&star_name);
3091            out.push_str(" from \"");
3092            out.push_str(&source.replace('"', "\\\""));
3093            out.push_str("\";\n");
3094
3095            indent(indent_level, out);
3096            out.push_str("const ");
3097            out.push_str(&pkg_name);
3098            out.push_str(" = ");
3099            out.push_str(&star_name);
3100            out.push_str(".default || ");
3101            out.push_str(&star_name);
3102            out.push_str(";\n");
3103
3104            indent(indent_level, out);
3105            out.push_str("const { ");
3106            for (i, name) in names.iter().enumerate() {
3107                if i > 0 {
3108                    out.push_str(", ");
3109                }
3110                out.push_str(name);
3111            }
3112            out.push_str(" } = ");
3113            out.push_str(&pkg_name);
3114            out.push(';');
3115        }
3116        JsStmt::Require { name, source } => {
3117            indent(indent_level, out);
3118            out.push_str("const ");
3119            out.push_str(name);
3120            out.push_str(" = require(\"");
3121            out.push_str(&source.replace('"', "\\\""));
3122            out.push_str("\");");
3123        }
3124        JsStmt::NamedRequire { names, source } => {
3125            indent(indent_level, out);
3126            out.push_str("const { ");
3127            for (i, name) in names.iter().enumerate() {
3128                if i > 0 {
3129                    out.push_str(", ");
3130                }
3131                out.push_str(name);
3132            }
3133            out.push_str(" } = require(\"");
3134            out.push_str(&source.replace('"', "\\\""));
3135            out.push_str("\");");
3136        }
3137        JsStmt::ExportNamed { names } => {
3138            indent(indent_level, out);
3139            out.push_str("export { ");
3140            for (i, name) in names.iter().enumerate() {
3141                if i > 0 {
3142                    out.push_str(", ");
3143                }
3144                out.push_str(name);
3145            }
3146            out.push_str(" };");
3147        }
3148        JsStmt::Function {
3149            name, params, body, ..
3150        } => {
3151            indent(indent_level, out);
3152            out.push_str("function ");
3153            out.push_str(name);
3154            out.push('(');
3155            for (i, p) in params.iter().enumerate() {
3156                if i > 0 {
3157                    out.push_str(", ");
3158                }
3159                out.push_str(p);
3160            }
3161            out.push_str(") {\n");
3162            for s in body {
3163                write_stmt(s, indent_level + 1, out);
3164                out.push('\n');
3165            }
3166            indent(indent_level, out);
3167            out.push('}');
3168        }
3169        JsStmt::Return(expr) => {
3170            indent(indent_level, out);
3171            out.push_str("return ");
3172            write_expr(expr, out);
3173            out.push(';');
3174        }
3175        JsStmt::Let { name, init } => {
3176            indent(indent_level, out);
3177            out.push_str("let ");
3178            out.push_str(name);
3179            if let Some(expr) = init {
3180                out.push_str(" = ");
3181                write_expr(expr, out);
3182            }
3183            out.push(';');
3184        }
3185        JsStmt::LetDestructure { pattern, init } => {
3186            indent(indent_level, out);
3187            out.push_str("let ");
3188            write_destructure_pattern_list(pattern, out);
3189            if let Some(expr) = init {
3190                out.push_str(" = ");
3191                write_expr(expr, out);
3192            }
3193            out.push(';');
3194        }
3195        JsStmt::Expr(expr) => {
3196            indent(indent_level, out);
3197            write_expr(expr, out);
3198            out.push(';');
3199        }
3200        JsStmt::TryCatch {
3201            try_block,
3202            catch_ident,
3203            catch_block,
3204        } => {
3205            indent(indent_level, out);
3206            out.push_str("try {\n");
3207            for s in try_block {
3208                write_stmt(s, indent_level + 1, out);
3209                out.push('\n');
3210            }
3211            indent(indent_level, out);
3212            out.push_str("} catch (");
3213            out.push_str(catch_ident);
3214            out.push_str(") {\n");
3215            for s in catch_block {
3216                write_stmt(s, indent_level + 1, out);
3217                out.push('\n');
3218            }
3219            indent(indent_level, out);
3220            out.push('}');
3221        }
3222        JsStmt::If {
3223            cond,
3224            then_block,
3225            else_block,
3226        } => {
3227            indent(indent_level, out);
3228            out.push_str("if (");
3229            write_expr(cond, out);
3230            out.push_str(") {\n");
3231            for s in then_block {
3232                write_stmt(s, indent_level + 1, out);
3233                out.push('\n');
3234            }
3235            indent(indent_level, out);
3236            out.push('}');
3237            if let Some(else_stmts) = else_block {
3238                // Check if the else block contains a single If statement (else if)
3239                if else_stmts.len() == 1 {
3240                    if let JsStmt::If { .. } = &else_stmts[0] {
3241                        out.push_str(" else ");
3242                        // Write without indent since it's an else-if chain
3243                        write_stmt(&else_stmts[0], 0, out);
3244                        // Trim leading whitespace that write_stmt added
3245                        return;
3246                    }
3247                }
3248                out.push_str(" else {\n");
3249                for s in else_stmts {
3250                    write_stmt(s, indent_level + 1, out);
3251                    out.push('\n');
3252                }
3253                indent(indent_level, out);
3254                out.push('}');
3255            }
3256        }
3257        JsStmt::ForOf {
3258            binding,
3259            iterable,
3260            body,
3261        } => {
3262            indent(indent_level, out);
3263            out.push_str("for (const ");
3264            out.push_str(binding);
3265            out.push_str(" of ");
3266            write_expr(iterable, out);
3267            out.push_str(") {\n");
3268            for s in body {
3269                write_stmt(s, indent_level + 1, out);
3270                out.push('\n');
3271            }
3272            indent(indent_level, out);
3273            out.push('}');
3274        }
3275        JsStmt::For {
3276            binding,
3277            start,
3278            end,
3279            inclusive,
3280            body,
3281        } => {
3282            indent(indent_level, out);
3283            out.push_str("for (let ");
3284            out.push_str(binding);
3285            out.push_str(" = ");
3286            write_expr(start, out);
3287            out.push_str("; ");
3288            out.push_str(binding);
3289            if *inclusive {
3290                out.push_str(" <= ");
3291            } else {
3292                out.push_str(" < ");
3293            }
3294            write_expr(end, out);
3295            out.push_str("; ");
3296            out.push_str(binding);
3297            out.push_str("++) {\n");
3298            for s in body {
3299                write_stmt(s, indent_level + 1, out);
3300                out.push('\n');
3301            }
3302            indent(indent_level, out);
3303            out.push('}');
3304        }
3305        JsStmt::Assign { target, op, value } => {
3306            indent(indent_level, out);
3307            write_expr(target, out);
3308            out.push_str(match op {
3309                JsAssignOp::Assign => " = ",
3310                JsAssignOp::AddAssign => " += ",
3311                JsAssignOp::SubAssign => " -= ",
3312                JsAssignOp::ModAssign => " %= ",
3313            });
3314            write_expr(value, out);
3315            out.push(';');
3316        }
3317        JsStmt::While { cond, body } => {
3318            indent(indent_level, out);
3319            out.push_str("while (");
3320            write_expr(cond, out);
3321            out.push_str(") {\n");
3322            for s in body {
3323                write_stmt(s, indent_level + 1, out);
3324                out.push('\n');
3325            }
3326            indent(indent_level, out);
3327            out.push('}');
3328        }
3329        JsStmt::Break => {
3330            indent(indent_level, out);
3331            out.push_str("break;");
3332        }
3333        JsStmt::Continue => {
3334            indent(indent_level, out);
3335            out.push_str("continue;");
3336        }
3337        JsStmt::Block(stmts) => {
3338            indent(indent_level, out);
3339            out.push_str("{\n");
3340            for s in stmts {
3341                write_stmt(s, indent_level + 1, out);
3342                out.push('\n');
3343            }
3344            indent(indent_level, out);
3345            out.push('}');
3346        }
3347        JsStmt::Sequence(stmts) => {
3348            // Emit statements at same level without wrapping in a block.
3349            // No leading indent since this is a pseudo-statement.
3350            for (i, s) in stmts.iter().enumerate() {
3351                if i > 0 {
3352                    out.push('\n');
3353                }
3354                write_stmt(s, indent_level, out);
3355            }
3356        }
3357    }
3358}
3359
3360/// Write a single destructure pattern element (binding, wildcard, or nested array).
3361fn write_destructure_pattern(pattern: &DestructurePattern, out: &mut String) {
3362    match pattern {
3363        DestructurePattern::Binding(name) => out.push_str(name),
3364        DestructurePattern::Wildcard => out.push('_'),
3365        DestructurePattern::Array(elements) => {
3366            write_destructure_pattern_list(elements, out);
3367        }
3368    }
3369}
3370
3371/// Write an array destructure pattern like `[a, [b, c], _]`.
3372fn write_destructure_pattern_list(patterns: &[DestructurePattern], out: &mut String) {
3373    out.push('[');
3374    for (i, pat) in patterns.iter().enumerate() {
3375        if i > 0 {
3376            out.push_str(", ");
3377        }
3378        write_destructure_pattern(pat, out);
3379    }
3380    out.push(']');
3381}
3382
3383fn write_expr(expr: &JsExpr, out: &mut String) {
3384    match expr {
3385        JsExpr::Ident(name) => out.push_str(name),
3386        JsExpr::Number(n) => out.push_str(&n.to_string()),
3387        JsExpr::BigInt(n) => {
3388            out.push_str(&n.to_string());
3389            out.push('n');
3390        }
3391        JsExpr::Float(f) => out.push_str(&f.to_string()),
3392        JsExpr::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
3393        JsExpr::Object(props) => {
3394            out.push('{');
3395            for (i, (key, value)) in props.iter().enumerate() {
3396                if i > 0 {
3397                    out.push_str(", ");
3398                }
3399                out.push_str(key);
3400                out.push_str(": ");
3401                write_expr(value, out);
3402            }
3403            out.push('}');
3404        }
3405        JsExpr::Member { object, property } => {
3406            write_expr(object, out);
3407            out.push('.');
3408            out.push_str(property);
3409        }
3410        JsExpr::String(s) => {
3411            out.push('"');
3412            for ch in s.chars() {
3413                match ch {
3414                    '\\' => out.push_str("\\\\"),
3415                    '"' => out.push_str("\\\""),
3416                    '\n' => out.push_str("\\n"),
3417                    '\r' => out.push_str("\\r"),
3418                    '\t' => out.push_str("\\t"),
3419                    '\0' => out.push_str("\\0"),
3420                    c if c.is_control() => {
3421                        out.push_str(&format!("\\u{:04x}", c as u32));
3422                    }
3423                    c => out.push(c),
3424                }
3425            }
3426            out.push('"');
3427        }
3428        JsExpr::Assignment { left, op, right } => {
3429            write_expr(left, out);
3430            match op {
3431                JsAssignOp::Assign => out.push_str(" = "),
3432                JsAssignOp::AddAssign => out.push_str(" += "),
3433                JsAssignOp::SubAssign => out.push_str(" -= "),
3434                JsAssignOp::ModAssign => out.push_str(" %= "),
3435            }
3436            write_expr(right, out);
3437        }
3438        JsExpr::Call { callee, args } => {
3439            write_expr(callee, out);
3440            out.push('(');
3441            for (i, arg) in args.iter().enumerate() {
3442                if i > 0 {
3443                    out.push_str(", ");
3444                }
3445                write_expr(arg, out);
3446            }
3447            out.push(')');
3448        }
3449        JsExpr::Conditional {
3450            test,
3451            then_branch,
3452            else_branch,
3453        } => {
3454            out.push('(');
3455            write_expr(test, out);
3456            out.push_str(" ? ");
3457            write_expr(then_branch, out);
3458            out.push_str(" : ");
3459            write_expr(else_branch, out);
3460            out.push(')');
3461        }
3462        JsExpr::Binary { op, left, right } => {
3463            write_expr(left, out);
3464            out.push(' ');
3465            out.push_str(match op {
3466                JsBinaryOp::Add => "+",
3467                JsBinaryOp::Sub => "-",
3468                JsBinaryOp::Mul => "*",
3469                JsBinaryOp::Div => "/",
3470                JsBinaryOp::Mod => "%",
3471                JsBinaryOp::EqEq => "==",
3472                JsBinaryOp::NotEq => "!=",
3473                JsBinaryOp::Lt => "<",
3474                JsBinaryOp::Gt => ">",
3475                JsBinaryOp::Le => "<=",
3476                JsBinaryOp::Ge => ">=",
3477                JsBinaryOp::And => "&&",
3478                JsBinaryOp::Or => "||",
3479                JsBinaryOp::StrictEq => "===",
3480                JsBinaryOp::StrictNe => "!==",
3481            });
3482            out.push(' ');
3483            write_expr(right, out);
3484        }
3485        JsExpr::Iife { body } => {
3486            out.push_str("(function() {\n");
3487            for s in body {
3488                write_stmt(s, 1, out);
3489                out.push('\n');
3490            }
3491            out.push_str("})()");
3492        }
3493        JsExpr::Function { params, body } => {
3494            out.push_str("function(");
3495            for (i, p) in params.iter().enumerate() {
3496                if i > 0 {
3497                    out.push_str(", ");
3498                }
3499                out.push_str(p);
3500            }
3501            out.push_str(") {\n");
3502            for s in body {
3503                write_stmt(s, 1, out);
3504                out.push('\n');
3505            }
3506            out.push('}');
3507        }
3508        JsExpr::New { constructor, args } => {
3509            out.push_str("new ");
3510            out.push_str(constructor);
3511            out.push('(');
3512            for (i, arg) in args.iter().enumerate() {
3513                if i > 0 {
3514                    out.push_str(", ");
3515                }
3516                write_expr(arg, out);
3517            }
3518            out.push(')');
3519        }
3520        JsExpr::Arrow { params, body } => {
3521            // Generate arrow function: `(x, y) => { ... }` or `(x) => expr`
3522            out.push('(');
3523            for (i, p) in params.iter().enumerate() {
3524                if i > 0 {
3525                    out.push_str(", ");
3526                }
3527                out.push_str(p);
3528            }
3529            out.push_str(") => ");
3530
3531            // If body is a single return statement, emit concise form
3532            if body.len() == 1 {
3533                if let JsStmt::Return(expr) = &body[0] {
3534                    write_expr(expr, out);
3535                    return;
3536                }
3537            }
3538
3539            // Otherwise emit block form
3540            out.push_str("{\n");
3541            for s in body {
3542                write_stmt(s, 1, out);
3543                out.push('\n');
3544            }
3545            out.push('}');
3546        }
3547        JsExpr::Array(elements) => {
3548            out.push('[');
3549            for (i, elem) in elements.iter().enumerate() {
3550                if i > 0 {
3551                    out.push_str(", ");
3552                }
3553                write_expr(elem, out);
3554            }
3555            out.push(']');
3556        }
3557        JsExpr::Index { object, index } => {
3558            write_expr(object, out);
3559            out.push('[');
3560            write_expr(index, out);
3561            out.push(']');
3562        }
3563        JsExpr::Raw(code) => {
3564            // Emit raw JavaScript code directly (already wrapped in parentheses)
3565            out.push_str(code);
3566        }
3567        JsExpr::Unary { op, expr } => {
3568            let op_str = match op {
3569                JsUnaryOp::Not => "!",
3570                JsUnaryOp::Neg => "-",
3571            };
3572            out.push_str(op_str);
3573            // Wrap in parens for safety with complex expressions
3574            out.push('(');
3575            write_expr(expr, out);
3576            out.push(')');
3577        }
3578    }
3579}
3580
3581// ========== TypeScript .d.ts emission ==========
3582
3583/// Convert a Husk AST file into a `.d.ts` TypeScript declaration string.
3584pub fn file_to_dts(file: &File) -> String {
3585    let mut out = String::new();
3586
3587    for item in &file.items {
3588        match &item.kind {
3589            ItemKind::Struct {
3590                name,
3591                type_params,
3592                fields,
3593            } => {
3594                write_struct_dts(name, type_params, fields, &mut out);
3595                out.push('\n');
3596            }
3597            ItemKind::Enum {
3598                name,
3599                type_params,
3600                variants,
3601            } => {
3602                write_enum_dts(name, type_params, variants, &mut out);
3603                out.push('\n');
3604            }
3605            ItemKind::Fn {
3606                name,
3607                type_params,
3608                params,
3609                ret_type,
3610                ..
3611            } => {
3612                write_fn_dts(name, type_params, params, ret_type.as_ref(), &mut out);
3613                out.push('\n');
3614            }
3615            _ => {}
3616        }
3617    }
3618
3619    out
3620}
3621
3622fn write_struct_dts(name: &Ident, type_params: &[Ident], fields: &[StructField], out: &mut String) {
3623    out.push_str("export interface ");
3624    out.push_str(&name.name);
3625    write_simple_type_params(type_params, out);
3626    out.push_str(" {\n");
3627    for field in fields {
3628        out.push_str("    ");
3629        out.push_str(&field.name.name);
3630        out.push_str(": ");
3631        write_type_expr(&field.ty, out);
3632        out.push_str(";\n");
3633    }
3634    out.push_str("}\n");
3635}
3636
3637fn write_enum_dts(
3638    name: &Ident,
3639    type_params: &[Ident],
3640    variants: &[husk_ast::EnumVariant],
3641    out: &mut String,
3642) {
3643    out.push_str("export type ");
3644    out.push_str(&name.name);
3645    write_simple_type_params(type_params, out);
3646    out.push_str(" =\n");
3647
3648    for (i, variant) in variants.iter().enumerate() {
3649        out.push_str("  | { tag: \"");
3650        out.push_str(&variant.name.name);
3651        out.push('"');
3652
3653        match &variant.fields {
3654            EnumVariantFields::Unit => {}
3655            EnumVariantFields::Tuple(tys) => {
3656                if tys.len() == 1 {
3657                    out.push_str("; value: ");
3658                    write_type_expr(&tys[0], out);
3659                } else {
3660                    for (idx, ty) in tys.iter().enumerate() {
3661                        out.push_str("; value");
3662                        out.push(char::from(b'0' + (idx as u8)));
3663                        out.push_str(": ");
3664                        write_type_expr(ty, out);
3665                    }
3666                }
3667            }
3668            EnumVariantFields::Struct(fields) => {
3669                for field in fields {
3670                    out.push_str("; ");
3671                    out.push_str(&field.name.name);
3672                    out.push_str(": ");
3673                    write_type_expr(&field.ty, out);
3674                }
3675            }
3676        }
3677
3678        out.push_str(" }");
3679        if i + 1 < variants.len() {
3680            out.push('\n');
3681        } else {
3682            out.push_str(";\n");
3683        }
3684    }
3685}
3686
3687fn write_fn_dts(
3688    name: &Ident,
3689    type_params: &[TypeParam],
3690    params: &[Param],
3691    ret_type: Option<&TypeExpr>,
3692    out: &mut String,
3693) {
3694    out.push_str("export function ");
3695    out.push_str(&name.name);
3696    write_type_params(type_params, out);
3697    out.push('(');
3698    for (i, param) in params.iter().enumerate() {
3699        if i > 0 {
3700            out.push_str(", ");
3701        }
3702        out.push_str(&param.name.name);
3703        out.push_str(": ");
3704        write_type_expr(&param.ty, out);
3705    }
3706    out.push(')');
3707    out.push_str(": ");
3708    if let Some(ret) = ret_type {
3709        write_type_expr(ret, out);
3710    } else {
3711        out.push_str("void");
3712    }
3713    out.push_str(";\n");
3714}
3715
3716fn write_type_params(type_params: &[TypeParam], out: &mut String) {
3717    if !type_params.is_empty() {
3718        out.push('<');
3719        for (i, tp) in type_params.iter().enumerate() {
3720            if i > 0 {
3721                out.push_str(", ");
3722            }
3723            out.push_str(&tp.name.name);
3724            // Write trait bounds if present (for TypeScript, we use `extends`)
3725            if !tp.bounds.is_empty() {
3726                out.push_str(" extends ");
3727                for (j, bound) in tp.bounds.iter().enumerate() {
3728                    if j > 0 {
3729                        out.push_str(" & ");
3730                    }
3731                    write_type_expr(bound, out);
3732                }
3733            }
3734        }
3735        out.push('>');
3736    }
3737}
3738
3739/// Write type params for structs/enums (which use Ident, not TypeParam)
3740fn write_simple_type_params(type_params: &[Ident], out: &mut String) {
3741    if !type_params.is_empty() {
3742        out.push('<');
3743        for (i, tp) in type_params.iter().enumerate() {
3744            if i > 0 {
3745                out.push_str(", ");
3746            }
3747            out.push_str(&tp.name);
3748        }
3749        out.push('>');
3750    }
3751}
3752
3753fn write_type_expr(ty: &TypeExpr, out: &mut String) {
3754    match &ty.kind {
3755        TypeExprKind::Named(id) => match id.name.as_str() {
3756            "i32" => out.push_str("number"),
3757            "bool" => out.push_str("boolean"),
3758            "String" => out.push_str("string"),
3759            "()" => out.push_str("void"),
3760            other => out.push_str(other),
3761        },
3762        TypeExprKind::Generic { name, args } => {
3763            out.push_str(&name.name);
3764            out.push('<');
3765            for (i, arg) in args.iter().enumerate() {
3766                if i > 0 {
3767                    out.push_str(", ");
3768                }
3769                write_type_expr(arg, out);
3770            }
3771            out.push('>');
3772        }
3773        TypeExprKind::Function { params, ret } => {
3774            // Generate TypeScript function type: `(x: T, y: U) => R`
3775            out.push('(');
3776            for (i, param) in params.iter().enumerate() {
3777                if i > 0 {
3778                    out.push_str(", ");
3779                }
3780                out.push_str(&format!("arg{}: ", i));
3781                write_type_expr(param, out);
3782            }
3783            out.push_str(") => ");
3784            write_type_expr(ret, out);
3785        }
3786        TypeExprKind::Array(elem) => {
3787            // Generate TypeScript array type: T[]
3788            write_type_expr(elem, out);
3789            out.push_str("[]");
3790        }
3791        TypeExprKind::Tuple(types) => {
3792            // Generate TypeScript tuple type: [T1, T2, T3]
3793            out.push('[');
3794            for (i, ty) in types.iter().enumerate() {
3795                if i > 0 {
3796                    out.push_str(", ");
3797                }
3798                write_type_expr(ty, out);
3799            }
3800            out.push(']');
3801        }
3802    }
3803}
3804
3805#[cfg(test)]
3806mod tests {
3807    use super::*;
3808    use husk_ast::{
3809        ExprKind as HuskExprKind, Ident as HuskIdent, Literal as HuskLiteral,
3810        LiteralKind as HuskLiteralKind, MatchArm as HuskMatchArm, Pattern as HuskPattern,
3811        PatternKind as HuskPatternKind, Span as HuskSpan, TypeExpr as HuskTypeExpr,
3812        TypeExprKind as HuskTypeExprKind,
3813    };
3814
3815    #[test]
3816    fn prints_simple_function() {
3817        let module = JsModule {
3818            body: vec![JsStmt::Function {
3819                name: "main".to_string(),
3820                params: vec!["a".into(), "b".into()],
3821                body: vec![
3822                    JsStmt::Let {
3823                        name: "x".to_string(),
3824                        init: Some(JsExpr::Binary {
3825                            op: JsBinaryOp::Add,
3826                            left: Box::new(JsExpr::Ident("a".into())),
3827                            right: Box::new(JsExpr::Ident("b".into())),
3828                        }),
3829                    },
3830                    JsStmt::Return(JsExpr::Ident("x".into())),
3831                ],
3832                source_span: None,
3833            }],
3834        };
3835
3836        let src = module.to_source();
3837        assert!(src.contains("function main"));
3838        assert!(src.contains("let x = a + b;"));
3839        assert!(src.contains("return x;"));
3840    }
3841
3842    #[test]
3843    fn appends_module_exports_for_top_level_functions() {
3844        // file with two top-level functions: main and helper
3845        let span = |s: usize, e: usize| HuskSpan { range: s..e };
3846        let ident = |name: &str, s: usize| HuskIdent {
3847            name: name.to_string(),
3848            span: span(s, s + name.len()),
3849        };
3850
3851        let main_fn = husk_ast::Item {
3852            attributes: Vec::new(),
3853            visibility: husk_ast::Visibility::Private,
3854            kind: husk_ast::ItemKind::Fn {
3855                name: ident("main", 0),
3856                type_params: Vec::new(),
3857                params: Vec::new(),
3858                ret_type: None,
3859                body: Vec::new(),
3860            },
3861            span: span(0, 10),
3862        };
3863        let helper_fn = husk_ast::Item {
3864            attributes: Vec::new(),
3865            visibility: husk_ast::Visibility::Private,
3866            kind: husk_ast::ItemKind::Fn {
3867                name: ident("helper", 20),
3868                type_params: Vec::new(),
3869                params: Vec::new(),
3870                ret_type: None,
3871                body: Vec::new(),
3872            },
3873            span: span(20, 30),
3874        };
3875
3876        let file = husk_ast::File {
3877            items: vec![main_fn, helper_fn],
3878        };
3879
3880        let empty_resolution = HashMap::new();
3881        let empty_type_resolution = HashMap::new();
3882        let empty_variant_calls = HashMap::new();
3883        let empty_variant_patterns = HashMap::new();
3884        let module = lower_file_to_js(&file, true, JsTarget::Cjs, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
3885        let src = module.to_source();
3886
3887        assert!(src.contains("function main()"));
3888        assert!(src.contains("function helper()"));
3889        assert!(
3890            src.contains("module.exports = {main: main, helper: helper};")
3891                || src.contains("module.exports = {main: main, helper: helper}")
3892        );
3893    }
3894
3895    #[test]
3896    fn prints_object_and_member_access() {
3897        let obj = JsExpr::Object(vec![
3898            ("tag".to_string(), JsExpr::String("Red".to_string())),
3899            ("value".to_string(), JsExpr::Number(1)),
3900        ]);
3901        let member = JsExpr::Member {
3902            object: Box::new(JsExpr::Ident("color".into())),
3903            property: "tag".to_string(),
3904        };
3905
3906        let mut out = String::new();
3907        write_expr(&obj, &mut out);
3908        assert!(out.contains("{tag: \"Red\", value: 1}"));
3909
3910        out.clear();
3911        write_expr(&member, &mut out);
3912        assert_eq!(out, "color.tag");
3913    }
3914
3915    #[test]
3916    fn lowers_simple_enum_match_to_conditional() {
3917        // Construct a minimal AST for:
3918        // enum Color { Red, Blue }
3919        // fn f(c: Color) -> i32 {
3920        //     match c {
3921        //         Color::Red => 1,
3922        //         Color::Blue => 2,
3923        //     }
3924        // }
3925
3926        let span = |s: usize, e: usize| HuskSpan { range: s..e };
3927        let ident = |name: &str, s: usize| HuskIdent {
3928            name: name.to_string(),
3929            span: span(s, s + name.len()),
3930        };
3931
3932        let c_ident = ident("c", 0);
3933        let scrutinee = husk_ast::Expr {
3934            kind: HuskExprKind::Ident(c_ident.clone()),
3935            span: c_ident.span.clone(),
3936        };
3937
3938        let color_ident = ident("Color", 10);
3939        let red_pat = HuskPattern {
3940            kind: HuskPatternKind::EnumUnit {
3941                path: vec![color_ident.clone(), ident("Red", 20)],
3942            },
3943            span: span(10, 23),
3944        };
3945        let blue_pat = HuskPattern {
3946            kind: HuskPatternKind::EnumUnit {
3947                path: vec![color_ident.clone(), ident("Blue", 30)],
3948            },
3949            span: span(24, 38),
3950        };
3951
3952        let arm_red = HuskMatchArm {
3953            pattern: red_pat,
3954            expr: husk_ast::Expr {
3955                kind: HuskExprKind::Literal(HuskLiteral {
3956                    kind: HuskLiteralKind::Int(1),
3957                    span: span(40, 41),
3958                }),
3959                span: span(40, 41),
3960            },
3961        };
3962        let arm_blue = HuskMatchArm {
3963            pattern: blue_pat,
3964            expr: husk_ast::Expr {
3965                kind: HuskExprKind::Literal(HuskLiteral {
3966                    kind: HuskLiteralKind::Int(2),
3967                    span: span(50, 51),
3968                }),
3969                span: span(50, 51),
3970            },
3971        };
3972
3973        let match_expr = husk_ast::Expr {
3974            kind: HuskExprKind::Match {
3975                scrutinee: Box::new(scrutinee),
3976                arms: vec![arm_red, arm_blue],
3977            },
3978            span: span(0, 60),
3979        };
3980
3981        let empty_resolution = HashMap::new();
3982        let empty_type_resolution = HashMap::new();
3983        let empty_variant_calls = HashMap::new();
3984        let empty_variant_patterns = HashMap::new();
3985        let js = lower_expr(&match_expr, &CodegenContext::new(&PropertyAccessors::default(), &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns));
3986        let mut out = String::new();
3987        write_expr(&js, &mut out);
3988
3989        // Should produce something like: (c.tag == "Red" ? 1 : 2)
3990        assert!(out.contains("c.tag"));
3991        assert!(out.contains("=="));
3992        assert!(out.contains("\"Red\""));
3993        assert!(out.contains(" ? "));
3994        assert!(out.contains(" : "));
3995    }
3996
3997    #[test]
3998    fn adds_main_call_for_zero_arg_main_function() {
3999        // Build a minimal Husk file with:
4000        // fn main() { }
4001        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4002        let ident = |name: &str, s: usize| HuskIdent {
4003            name: name.to_string(),
4004            span: span(s, s + name.len()),
4005        };
4006
4007        let main_ident = ident("main", 0);
4008        let fn_item = husk_ast::Item {
4009            attributes: Vec::new(),
4010            visibility: husk_ast::Visibility::Private,
4011            kind: husk_ast::ItemKind::Fn {
4012                name: main_ident.clone(),
4013                type_params: Vec::new(),
4014                params: Vec::new(),
4015                ret_type: None,
4016                body: Vec::new(),
4017            },
4018            span: span(0, 10),
4019        };
4020
4021        let file = husk_ast::File {
4022            items: vec![fn_item],
4023        };
4024
4025        let empty_resolution = HashMap::new();
4026        let empty_type_resolution = HashMap::new();
4027        let empty_variant_calls = HashMap::new();
4028        let empty_variant_patterns = HashMap::new();
4029        let module = lower_file_to_js(&file, true, JsTarget::Cjs, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4030        let src = module.to_source();
4031
4032        assert!(src.contains("function main()"));
4033        assert!(src.contains("main();"));
4034    }
4035
4036    #[test]
4037    fn emits_basic_dts_for_struct_enum_and_function() {
4038        // struct User { name: String, id: i32 }
4039        // enum Color { Red, Blue }
4040        // fn add(a: i32, b: i32) -> i32 { a + b }
4041
4042        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4043        let ident = |name: &str, s: usize| HuskIdent {
4044            name: name.to_string(),
4045            span: span(s, s + name.len()),
4046        };
4047
4048        // struct User { name: String, id: i32 }
4049        let user_ident = ident("User", 0);
4050        let name_field_ident = ident("name", 10);
4051        let id_field_ident = ident("id", 20);
4052        let string_ty_ident = ident("String", 30);
4053        let i32_ty_ident = ident("i32", 40);
4054
4055        let string_ty = HuskTypeExpr {
4056            kind: HuskTypeExprKind::Named(string_ty_ident.clone()),
4057            span: string_ty_ident.span.clone(),
4058        };
4059        let i32_ty = HuskTypeExpr {
4060            kind: HuskTypeExprKind::Named(i32_ty_ident.clone()),
4061            span: i32_ty_ident.span.clone(),
4062        };
4063
4064        let user_struct = husk_ast::Item {
4065            attributes: Vec::new(),
4066            visibility: husk_ast::Visibility::Private,
4067            kind: husk_ast::ItemKind::Struct {
4068                name: user_ident.clone(),
4069                type_params: Vec::new(),
4070                fields: vec![
4071                    husk_ast::StructField {
4072                        name: name_field_ident.clone(),
4073                        ty: string_ty.clone(),
4074                    },
4075                    husk_ast::StructField {
4076                        name: id_field_ident.clone(),
4077                        ty: i32_ty.clone(),
4078                    },
4079                ],
4080            },
4081            span: span(0, 50),
4082        };
4083
4084        // enum Color { Red, Blue }
4085        let color_ident = ident("Color", 60);
4086        let enum_item = husk_ast::Item {
4087            attributes: Vec::new(),
4088            visibility: husk_ast::Visibility::Private,
4089            kind: husk_ast::ItemKind::Enum {
4090                name: color_ident.clone(),
4091                type_params: Vec::new(),
4092                variants: vec![
4093                    husk_ast::EnumVariant {
4094                        name: ident("Red", 70),
4095                        fields: husk_ast::EnumVariantFields::Unit,
4096                    },
4097                    husk_ast::EnumVariant {
4098                        name: ident("Blue", 80),
4099                        fields: husk_ast::EnumVariantFields::Unit,
4100                    },
4101                ],
4102            },
4103            span: span(60, 100),
4104        };
4105
4106        // fn add(a: i32, b: i32) -> i32 { a + b }
4107        let add_ident = ident("add", 110);
4108        let a_ident = ident("a", 120);
4109        let b_ident = ident("b", 130);
4110
4111        let a_param = husk_ast::Param {
4112            name: a_ident.clone(),
4113            ty: i32_ty.clone(),
4114        };
4115        let b_param = husk_ast::Param {
4116            name: b_ident.clone(),
4117            ty: i32_ty.clone(),
4118        };
4119        let ret_ty = i32_ty.clone();
4120
4121        let add_fn = husk_ast::Item {
4122            attributes: Vec::new(),
4123            visibility: husk_ast::Visibility::Private,
4124            kind: husk_ast::ItemKind::Fn {
4125                name: add_ident.clone(),
4126                type_params: Vec::new(),
4127                params: vec![a_param, b_param],
4128                ret_type: Some(ret_ty),
4129                body: Vec::new(),
4130            },
4131            span: span(110, 150),
4132        };
4133
4134        let file = husk_ast::File {
4135            items: vec![user_struct, enum_item, add_fn],
4136        };
4137
4138        let dts = file_to_dts(&file);
4139
4140        assert!(dts.contains("export interface User"));
4141        assert!(dts.contains("name: string;"));
4142        assert!(dts.contains("id: number;"));
4143
4144        assert!(dts.contains("export type Color"));
4145        assert!(dts.contains("{ tag: \"Red\" }"));
4146        assert!(dts.contains("{ tag: \"Blue\" }"));
4147
4148        assert!(dts.contains("export function add"));
4149        assert!(dts.contains("a: number"));
4150        assert!(dts.contains("b: number"));
4151        assert!(dts.contains("): number;"));
4152    }
4153
4154    #[test]
4155    fn lowers_extern_js_result_function_with_try_catch() {
4156        // extern "js" { fn parse(text: String) -> Result<i32, String>; }
4157        // Expect:
4158        // function parse(text) {
4159        //   try {
4160        //     return Ok(globalThis.parse(text));
4161        //   } catch (e) {
4162        //     return Err(e);
4163        //   }
4164        // }
4165
4166        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4167        let ident = |name: &str, s: usize| HuskIdent {
4168            name: name.to_string(),
4169            span: span(s, s + name.len()),
4170        };
4171
4172        let name = ident("parse", 0);
4173        let param_ident = ident("text", 10);
4174        let string_ty_ident = ident("String", 20);
4175        let result_ident = ident("Result", 30);
4176        let i32_ident = ident("i32", 40);
4177        let err_ident = ident("String", 50);
4178
4179        let string_ty = HuskTypeExpr {
4180            kind: HuskTypeExprKind::Named(string_ty_ident.clone()),
4181            span: string_ty_ident.span.clone(),
4182        };
4183        let i32_ty = HuskTypeExpr {
4184            kind: HuskTypeExprKind::Named(i32_ident.clone()),
4185            span: i32_ident.span.clone(),
4186        };
4187        let err_ty = HuskTypeExpr {
4188            kind: HuskTypeExprKind::Named(err_ident.clone()),
4189            span: err_ident.span.clone(),
4190        };
4191
4192        let result_ty = HuskTypeExpr {
4193            kind: HuskTypeExprKind::Generic {
4194                name: result_ident.clone(),
4195                args: vec![i32_ty, err_ty],
4196            },
4197            span: result_ident.span.clone(),
4198        };
4199
4200        let param = husk_ast::Param {
4201            name: param_ident.clone(),
4202            ty: string_ty,
4203        };
4204
4205        let ext_item = husk_ast::ExternItem {
4206            kind: husk_ast::ExternItemKind::Fn {
4207                name: name.clone(),
4208                params: vec![param],
4209                ret_type: Some(result_ty),
4210            },
4211            span: span(0, 60),
4212        };
4213
4214        let file = husk_ast::File {
4215            items: vec![husk_ast::Item {
4216                attributes: Vec::new(),
4217                visibility: husk_ast::Visibility::Private,
4218                kind: husk_ast::ItemKind::ExternBlock {
4219                    abi: "js".to_string(),
4220                    items: vec![ext_item],
4221                },
4222                span: span(0, 60),
4223            }],
4224        };
4225
4226        let empty_resolution = HashMap::new();
4227        let empty_type_resolution = HashMap::new();
4228        let empty_variant_calls = HashMap::new();
4229        let empty_variant_patterns = HashMap::new();
4230        let module = lower_file_to_js(&file, true, JsTarget::Cjs, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4231        let src = module.to_source();
4232
4233        assert!(src.contains("function parse"));
4234        assert!(src.contains("try {"));
4235        assert!(src.contains("return Ok("));
4236        assert!(src.contains("globalThis.parse"));
4237        assert!(src.contains("} catch (e) {"));
4238        assert!(src.contains("return Err(e);"));
4239    }
4240
4241    #[test]
4242    fn generates_esm_imports_for_extern_mod_declarations() {
4243        // extern "js" {
4244        //     mod express;
4245        //     mod fs;
4246        // }
4247        // fn main() { }
4248
4249        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4250        let ident = |name: &str, s: usize| HuskIdent {
4251            name: name.to_string(),
4252            span: span(s, s + name.len()),
4253        };
4254
4255        let express_mod = husk_ast::ExternItem {
4256            kind: husk_ast::ExternItemKind::Mod {
4257                package: "express".to_string(),
4258                binding: ident("express", 0),
4259                items: Vec::new(),
4260                is_global: false,
4261            },
4262            span: span(0, 10),
4263        };
4264
4265        let fs_mod = husk_ast::ExternItem {
4266            kind: husk_ast::ExternItemKind::Mod {
4267                package: "fs".to_string(),
4268                binding: ident("fs", 20),
4269                items: Vec::new(),
4270                is_global: false,
4271            },
4272            span: span(20, 25),
4273        };
4274
4275        let main_fn = husk_ast::Item {
4276            attributes: Vec::new(),
4277            visibility: husk_ast::Visibility::Private,
4278            kind: husk_ast::ItemKind::Fn {
4279                name: ident("main", 40),
4280                type_params: Vec::new(),
4281                params: Vec::new(),
4282                ret_type: None,
4283                body: Vec::new(),
4284            },
4285            span: span(40, 50),
4286        };
4287
4288        let file = husk_ast::File {
4289            items: vec![
4290                husk_ast::Item {
4291                    attributes: Vec::new(),
4292                    visibility: husk_ast::Visibility::Private,
4293                    kind: husk_ast::ItemKind::ExternBlock {
4294                        abi: "js".to_string(),
4295                        items: vec![express_mod, fs_mod],
4296                    },
4297                    span: span(0, 30),
4298                },
4299                main_fn,
4300            ],
4301        };
4302
4303        let empty_resolution = HashMap::new();
4304        let empty_type_resolution = HashMap::new();
4305        let empty_variant_calls = HashMap::new();
4306        let empty_variant_patterns = HashMap::new();
4307        let module = lower_file_to_js(&file, true, JsTarget::Esm, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4308        let src = module.to_source();
4309
4310        // Check for ESM imports at the top
4311        assert!(src.contains("import express from \"express\";"));
4312        assert!(src.contains("import fs from \"fs\";"));
4313
4314        // Check for ESM exports instead of CommonJS (explicit ESM target)
4315        assert!(src.contains("export { main }"));
4316        assert!(!src.contains("module.exports"));
4317    }
4318
4319    #[test]
4320    fn esm_exports_even_without_imports_when_target_forced() {
4321        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4322        let ident = |name: &str, s: usize| HuskIdent {
4323            name: name.to_string(),
4324            span: span(s, s + name.len()),
4325        };
4326
4327        let main_fn = husk_ast::Item {
4328            attributes: Vec::new(),
4329            visibility: husk_ast::Visibility::Private,
4330            kind: husk_ast::ItemKind::Fn {
4331                name: ident("main", 0),
4332                type_params: Vec::new(),
4333                params: Vec::new(),
4334                ret_type: None,
4335                body: Vec::new(),
4336            },
4337            span: span(0, 10),
4338        };
4339
4340        let file = husk_ast::File {
4341            items: vec![main_fn],
4342        };
4343        let empty_resolution = HashMap::new();
4344        let empty_type_resolution = HashMap::new();
4345        let empty_variant_calls = HashMap::new();
4346        let empty_variant_patterns = HashMap::new();
4347        let module = lower_file_to_js(&file, true, JsTarget::Esm, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4348        let src = module.to_source();
4349
4350        assert!(src.contains("export { main }"));
4351        assert!(!src.contains("module.exports"));
4352    }
4353
4354    #[test]
4355    fn generates_cjs_requires_for_extern_mod_declarations() {
4356        // extern "js" { mod express; } targeting CommonJS.
4357
4358        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4359        let ident = |name: &str, s: usize| HuskIdent {
4360            name: name.to_string(),
4361            span: span(s, s + name.len()),
4362        };
4363
4364        let express_mod = husk_ast::ExternItem {
4365            kind: husk_ast::ExternItemKind::Mod {
4366                package: "express".to_string(),
4367                binding: ident("express", 0),
4368                items: Vec::new(),
4369                is_global: false,
4370            },
4371            span: span(0, 10),
4372        };
4373
4374        let main_fn = husk_ast::Item {
4375            attributes: Vec::new(),
4376            visibility: husk_ast::Visibility::Private,
4377            kind: husk_ast::ItemKind::Fn {
4378                name: ident("main", 20),
4379                type_params: Vec::new(),
4380                params: Vec::new(),
4381                ret_type: None,
4382                body: Vec::new(),
4383            },
4384            span: span(20, 30),
4385        };
4386
4387        let file = husk_ast::File {
4388            items: vec![
4389                husk_ast::Item {
4390                    attributes: Vec::new(),
4391                    visibility: husk_ast::Visibility::Private,
4392                    kind: husk_ast::ItemKind::ExternBlock {
4393                        abi: "js".to_string(),
4394                        items: vec![express_mod],
4395                    },
4396                    span: span(0, 15),
4397                },
4398                main_fn,
4399            ],
4400        };
4401
4402        let empty_resolution = HashMap::new();
4403        let empty_type_resolution = HashMap::new();
4404        let empty_variant_calls = HashMap::new();
4405        let empty_variant_patterns = HashMap::new();
4406        let module = lower_file_to_js(&file, true, JsTarget::Cjs, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4407        let src = module.to_source();
4408
4409        assert!(src.contains("const express = require(\"express\");"));
4410        assert!(src.contains("module.exports"));
4411        assert!(!src.contains("export {"));
4412    }
4413
4414    #[test]
4415    fn lowers_format_to_string_concatenation() {
4416        // Test that format("hello {}!", name) generates string concatenation
4417        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4418        let ident = |name: &str, s: usize| HuskIdent {
4419            name: name.to_string(),
4420            span: span(s, s + name.len()),
4421        };
4422
4423        // Build: let s = format("Hello, {}!", name);
4424        let format_expr = husk_ast::Expr {
4425            kind: HuskExprKind::Format {
4426                format: husk_ast::FormatString {
4427                    span: span(0, 12),
4428                    segments: vec![
4429                        husk_ast::FormatSegment::Literal("Hello, ".to_string()),
4430                        husk_ast::FormatSegment::Placeholder(husk_ast::FormatPlaceholder {
4431                            position: None,
4432                            name: None,
4433                            spec: husk_ast::FormatSpec::default(),
4434                            span: span(7, 9),
4435                        }),
4436                        husk_ast::FormatSegment::Literal("!".to_string()),
4437                    ],
4438                },
4439                args: vec![husk_ast::Expr {
4440                    kind: HuskExprKind::Ident(ident("name", 20)),
4441                    span: span(20, 24),
4442                }],
4443            },
4444            span: span(0, 25),
4445        };
4446
4447        let accessors = PropertyAccessors::default();
4448        let empty_resolution = HashMap::new();
4449        let empty_type_resolution = HashMap::new();
4450        let empty_variant_calls = HashMap::new();
4451        let empty_variant_patterns = HashMap::new();
4452        let ctx = CodegenContext::new(&accessors, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4453        let js_expr = lower_expr(&format_expr, &ctx);
4454        let mut js_str = String::new();
4455        write_expr(&js_expr, &mut js_str);
4456
4457        // Should generate: "Hello, " + __husk_fmt(name) + "!"
4458        assert!(
4459            js_str.contains("\"Hello, \""),
4460            "expected 'Hello, ' literal in output: {}",
4461            js_str
4462        );
4463        assert!(
4464            js_str.contains("__husk_fmt"),
4465            "expected __husk_fmt call in output: {}",
4466            js_str
4467        );
4468        assert!(
4469            js_str.contains("\"!\""),
4470            "expected '!' literal in output: {}",
4471            js_str
4472        );
4473        // Should NOT contain console.log
4474        assert!(
4475            !js_str.contains("console.log"),
4476            "format should not generate console.log: {}",
4477            js_str
4478        );
4479    }
4480
4481    #[test]
4482    fn lowers_format_with_hex_specifier() {
4483        // Test that format("{:x}", num) generates __husk_fmt_num call
4484        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4485        let ident = |name: &str, s: usize| HuskIdent {
4486            name: name.to_string(),
4487            span: span(s, s + name.len()),
4488        };
4489
4490        let format_expr = husk_ast::Expr {
4491            kind: HuskExprKind::Format {
4492                format: husk_ast::FormatString {
4493                    span: span(0, 5),
4494                    segments: vec![husk_ast::FormatSegment::Placeholder(
4495                        husk_ast::FormatPlaceholder {
4496                            position: None,
4497                            name: None,
4498                            spec: husk_ast::FormatSpec {
4499                                ty: Some('x'),
4500                                ..Default::default()
4501                            },
4502                            span: span(0, 4),
4503                        },
4504                    )],
4505                },
4506                args: vec![husk_ast::Expr {
4507                    kind: HuskExprKind::Ident(ident("num", 10)),
4508                    span: span(10, 13),
4509                }],
4510            },
4511            span: span(0, 15),
4512        };
4513
4514        let accessors = PropertyAccessors::default();
4515        let empty_resolution = HashMap::new();
4516        let empty_type_resolution = HashMap::new();
4517        let empty_variant_calls = HashMap::new();
4518        let empty_variant_patterns = HashMap::new();
4519        let ctx = CodegenContext::new(&accessors, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4520        let js_expr = lower_expr(&format_expr, &ctx);
4521        let mut js_str = String::new();
4522        write_expr(&js_expr, &mut js_str);
4523
4524        // Should generate __husk_fmt_num with base 16
4525        assert!(
4526            js_str.contains("__husk_fmt_num"),
4527            "expected __husk_fmt_num call in output: {}",
4528            js_str
4529        );
4530        assert!(
4531            js_str.contains("16"),
4532            "expected base 16 for hex format: {}",
4533            js_str
4534        );
4535    }
4536
4537    #[test]
4538    fn lowers_format_simple_string_returns_string_directly() {
4539        // Test that format("hello") generates just the string
4540        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4541
4542        let format_expr = husk_ast::Expr {
4543            kind: HuskExprKind::Format {
4544                format: husk_ast::FormatString {
4545                    span: span(0, 7),
4546                    segments: vec![husk_ast::FormatSegment::Literal("hello".to_string())],
4547                },
4548                args: vec![],
4549            },
4550            span: span(0, 10),
4551        };
4552
4553        let accessors = PropertyAccessors::default();
4554        let empty_resolution = HashMap::new();
4555        let empty_type_resolution = HashMap::new();
4556        let empty_variant_calls = HashMap::new();
4557        let empty_variant_patterns = HashMap::new();
4558        let ctx = CodegenContext::new(&accessors, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4559        let js_expr = lower_expr(&format_expr, &ctx);
4560        let mut js_str = String::new();
4561        write_expr(&js_expr, &mut js_str);
4562
4563        // Should generate just "hello"
4564        assert_eq!(js_str, "\"hello\"");
4565    }
4566
4567    #[test]
4568    fn lowers_loop_to_while_true() {
4569        // Test that loop { break; } generates while (true) { break; }
4570        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4571        let ident = |name: &str, s: usize| HuskIdent {
4572            name: name.to_string(),
4573            span: span(s, s + name.len()),
4574        };
4575
4576        let loop_stmt = husk_ast::Stmt {
4577            kind: husk_ast::StmtKind::Loop {
4578                body: husk_ast::Block {
4579                    stmts: vec![husk_ast::Stmt {
4580                        kind: husk_ast::StmtKind::Break,
4581                        span: span(10, 16),
4582                    }],
4583                    span: span(5, 18),
4584                },
4585            },
4586            span: span(0, 20),
4587        };
4588
4589        let main_fn = husk_ast::Item {
4590            attributes: Vec::new(),
4591            visibility: husk_ast::Visibility::Private,
4592            kind: husk_ast::ItemKind::Fn {
4593                name: ident("main", 0),
4594                type_params: Vec::new(),
4595                params: Vec::new(),
4596                ret_type: None,
4597                body: vec![loop_stmt],
4598            },
4599            span: span(0, 25),
4600        };
4601
4602        let file = husk_ast::File {
4603            items: vec![main_fn],
4604        };
4605        let empty_resolution = HashMap::new();
4606        let empty_type_resolution = HashMap::new();
4607        let empty_variant_calls = HashMap::new();
4608        let empty_variant_patterns = HashMap::new();
4609        let module = lower_file_to_js(&file, true, JsTarget::Cjs, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4610        let src = module.to_source();
4611
4612        assert!(
4613            src.contains("while (true)"),
4614            "expected 'while (true)' in output: {}",
4615            src
4616        );
4617        assert!(
4618            src.contains("break;"),
4619            "expected 'break;' in output: {}",
4620            src
4621        );
4622    }
4623
4624    #[test]
4625    fn lowers_while_with_condition() {
4626        // Test that while cond { ... } generates while (cond) { ... }
4627        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4628        let ident = |name: &str, s: usize| HuskIdent {
4629            name: name.to_string(),
4630            span: span(s, s + name.len()),
4631        };
4632
4633        let while_stmt = husk_ast::Stmt {
4634            kind: husk_ast::StmtKind::While {
4635                cond: husk_ast::Expr {
4636                    kind: HuskExprKind::Ident(ident("running", 6)),
4637                    span: span(6, 13),
4638                },
4639                body: husk_ast::Block {
4640                    stmts: vec![husk_ast::Stmt {
4641                        kind: husk_ast::StmtKind::Continue,
4642                        span: span(16, 25),
4643                    }],
4644                    span: span(14, 27),
4645                },
4646            },
4647            span: span(0, 28),
4648        };
4649
4650        let main_fn = husk_ast::Item {
4651            attributes: Vec::new(),
4652            visibility: husk_ast::Visibility::Private,
4653            kind: husk_ast::ItemKind::Fn {
4654                name: ident("main", 0),
4655                type_params: Vec::new(),
4656                params: Vec::new(),
4657                ret_type: None,
4658                body: vec![while_stmt],
4659            },
4660            span: span(0, 30),
4661        };
4662
4663        let file = husk_ast::File {
4664            items: vec![main_fn],
4665        };
4666        let empty_resolution = HashMap::new();
4667        let empty_type_resolution = HashMap::new();
4668        let empty_variant_calls = HashMap::new();
4669        let empty_variant_patterns = HashMap::new();
4670        let module = lower_file_to_js(&file, true, JsTarget::Cjs, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4671        let src = module.to_source();
4672
4673        assert!(
4674            src.contains("while (running)"),
4675            "expected 'while (running)' in output: {}",
4676            src
4677        );
4678        assert!(
4679            src.contains("continue;"),
4680            "expected 'continue;' in output: {}",
4681            src
4682        );
4683    }
4684
4685    #[test]
4686    fn lowers_break_and_continue() {
4687        // Test that break and continue statements generate proper JS
4688        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4689
4690        let break_stmt = husk_ast::Stmt {
4691            kind: husk_ast::StmtKind::Break,
4692            span: span(0, 6),
4693        };
4694
4695        let continue_stmt = husk_ast::Stmt {
4696            kind: husk_ast::StmtKind::Continue,
4697            span: span(7, 16),
4698        };
4699
4700        let accessors = PropertyAccessors::default();
4701        let empty_resolution = HashMap::new();
4702        let empty_type_resolution = HashMap::new();
4703        let empty_variant_calls = HashMap::new();
4704        let empty_variant_patterns = HashMap::new();
4705        let ctx = CodegenContext::new(&accessors, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4706
4707        let js_break = lower_stmt(&break_stmt, &ctx);
4708        let js_continue = lower_stmt(&continue_stmt, &ctx);
4709
4710        assert!(matches!(js_break, JsStmt::Break), "expected JsStmt::Break");
4711        assert!(
4712            matches!(js_continue, JsStmt::Continue),
4713            "expected JsStmt::Continue"
4714        );
4715    }
4716
4717    #[test]
4718    fn lowers_enum_variant_with_single_arg_to_tagged_object() {
4719        // Test that Option::Some(x) generates {tag: "Some", value: x}
4720        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4721        let ident = |name: &str, s: usize| HuskIdent {
4722            name: name.to_string(),
4723            span: span(s, s + name.len()),
4724        };
4725
4726        // Build: Option::Some(42)
4727        let callee = husk_ast::Expr {
4728            kind: HuskExprKind::Path {
4729                segments: vec![ident("Option", 0), ident("Some", 8)],
4730            },
4731            span: span(0, 12),
4732        };
4733
4734        let arg = husk_ast::Expr {
4735            kind: HuskExprKind::Literal(HuskLiteral {
4736                kind: HuskLiteralKind::Int(42),
4737                span: span(13, 15),
4738            }),
4739            span: span(13, 15),
4740        };
4741
4742        let call_expr = husk_ast::Expr {
4743            kind: HuskExprKind::Call {
4744                callee: Box::new(callee),
4745                type_args: vec![],
4746                args: vec![arg],
4747            },
4748            span: span(0, 16),
4749        };
4750
4751        let accessors = PropertyAccessors::default();
4752        let empty_resolution = HashMap::new();
4753        let empty_type_resolution = HashMap::new();
4754        let empty_variant_calls = HashMap::new();
4755        let empty_variant_patterns = HashMap::new();
4756        let ctx = CodegenContext::new(&accessors, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4757
4758        let js_expr = lower_expr(&call_expr, &ctx);
4759
4760        // Should be an object with tag and value fields
4761        if let JsExpr::Object(fields) = js_expr {
4762            assert_eq!(fields.len(), 2, "expected 2 fields (tag and value)");
4763            assert_eq!(fields[0].0, "tag");
4764            assert!(matches!(&fields[0].1, JsExpr::String(s) if s == "Some"));
4765            assert_eq!(fields[1].0, "value");
4766            assert!(matches!(&fields[1].1, JsExpr::Number(42)));
4767        } else {
4768            panic!("expected JsExpr::Object, got {:?}", js_expr);
4769        }
4770    }
4771
4772    #[test]
4773    fn lowers_enum_variant_with_multiple_args_to_indexed_object() {
4774        // Test that MyEnum::Pair(a, b) generates {tag: "Pair", "0": a, "1": b}
4775        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4776        let ident = |name: &str, s: usize| HuskIdent {
4777            name: name.to_string(),
4778            span: span(s, s + name.len()),
4779        };
4780
4781        // Build: MyEnum::Pair(1, 2)
4782        let callee = husk_ast::Expr {
4783            kind: HuskExprKind::Path {
4784                segments: vec![ident("MyEnum", 0), ident("Pair", 8)],
4785            },
4786            span: span(0, 12),
4787        };
4788
4789        let arg1 = husk_ast::Expr {
4790            kind: HuskExprKind::Literal(HuskLiteral {
4791                kind: HuskLiteralKind::Int(1),
4792                span: span(13, 14),
4793            }),
4794            span: span(13, 14),
4795        };
4796
4797        let arg2 = husk_ast::Expr {
4798            kind: HuskExprKind::Literal(HuskLiteral {
4799                kind: HuskLiteralKind::Int(2),
4800                span: span(16, 17),
4801            }),
4802            span: span(16, 17),
4803        };
4804
4805        let call_expr = husk_ast::Expr {
4806            kind: HuskExprKind::Call {
4807                callee: Box::new(callee),
4808                type_args: vec![],
4809                args: vec![arg1, arg2],
4810            },
4811            span: span(0, 18),
4812        };
4813
4814        let accessors = PropertyAccessors::default();
4815        let empty_resolution = HashMap::new();
4816        let empty_type_resolution = HashMap::new();
4817        let empty_variant_calls = HashMap::new();
4818        let empty_variant_patterns = HashMap::new();
4819        let ctx = CodegenContext::new(&accessors, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4820
4821        let js_expr = lower_expr(&call_expr, &ctx);
4822
4823        // Should be an object with tag and indexed fields
4824        if let JsExpr::Object(fields) = js_expr {
4825            assert_eq!(fields.len(), 3, "expected 3 fields (tag, 0, 1)");
4826            assert_eq!(fields[0].0, "tag");
4827            assert!(matches!(&fields[0].1, JsExpr::String(s) if s == "Pair"));
4828            assert_eq!(fields[1].0, "0");
4829            assert!(matches!(&fields[1].1, JsExpr::Number(1)));
4830            assert_eq!(fields[2].0, "1");
4831            assert!(matches!(&fields[2].1, JsExpr::Number(2)));
4832        } else {
4833            panic!("expected JsExpr::Object, got {:?}", js_expr);
4834        }
4835    }
4836
4837    #[test]
4838    fn lowers_unit_enum_variant_to_tagged_object() {
4839        // Test that Option::None generates {tag: "None"}
4840        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4841        let ident = |name: &str, s: usize| HuskIdent {
4842            name: name.to_string(),
4843            span: span(s, s + name.len()),
4844        };
4845
4846        // Build: Option::None (as a Path expression, not a Call)
4847        let path_expr = husk_ast::Expr {
4848            kind: HuskExprKind::Path {
4849                segments: vec![ident("Option", 0), ident("None", 8)],
4850            },
4851            span: span(0, 12),
4852        };
4853
4854        let accessors = PropertyAccessors::default();
4855        let empty_resolution = HashMap::new();
4856        let empty_type_resolution = HashMap::new();
4857        let empty_variant_calls = HashMap::new();
4858        let empty_variant_patterns = HashMap::new();
4859        let ctx = CodegenContext::new(&accessors, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4860
4861        let js_expr = lower_expr(&path_expr, &ctx);
4862
4863        // Should be an object with only a tag field
4864        if let JsExpr::Object(fields) = js_expr {
4865            assert_eq!(fields.len(), 1, "expected 1 field (tag only)");
4866            assert_eq!(fields[0].0, "tag");
4867            assert!(matches!(&fields[0].1, JsExpr::String(s) if s == "None"));
4868        } else {
4869            panic!("expected JsExpr::Object, got {:?}", js_expr);
4870        }
4871    }
4872
4873    #[test]
4874    fn lower_match_stmt_with_break() {
4875        // Test that match with break generates if/else with break
4876        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4877        let ident = |name: &str, s: usize| HuskIdent {
4878            name: name.to_string(),
4879            span: span(s, s + name.len()),
4880        };
4881
4882        // Build: match x { Option::Some(v) => break, Option::None => {} }
4883        let scrutinee = husk_ast::Expr {
4884            kind: HuskExprKind::Ident(ident("x", 0)),
4885            span: span(0, 1),
4886        };
4887
4888        let some_arm = husk_ast::MatchArm {
4889            pattern: husk_ast::Pattern {
4890                kind: HuskPatternKind::EnumTuple {
4891                    path: vec![ident("Option", 0), ident("Some", 7)],
4892                    fields: vec![husk_ast::Pattern {
4893                        kind: HuskPatternKind::Binding(ident("v", 12)),
4894                        span: span(12, 13),
4895                    }],
4896                },
4897                span: span(0, 14),
4898            },
4899            expr: husk_ast::Expr {
4900                kind: HuskExprKind::Block(husk_ast::Block {
4901                    stmts: vec![husk_ast::Stmt {
4902                        kind: husk_ast::StmtKind::Break,
4903                        span: span(18, 23),
4904                    }],
4905                    span: span(17, 24),
4906                }),
4907                span: span(17, 24),
4908            },
4909        };
4910
4911        let none_arm = husk_ast::MatchArm {
4912            pattern: husk_ast::Pattern {
4913                kind: HuskPatternKind::EnumUnit {
4914                    path: vec![ident("Option", 26), ident("None", 33)],
4915                },
4916                span: span(26, 38),
4917            },
4918            expr: husk_ast::Expr {
4919                kind: HuskExprKind::Block(husk_ast::Block {
4920                    stmts: vec![],
4921                    span: span(42, 44),
4922                }),
4923                span: span(42, 44),
4924            },
4925        };
4926
4927        let accessors = PropertyAccessors::default();
4928        let empty_resolution = HashMap::new();
4929        let empty_type_resolution = HashMap::new();
4930        let empty_variant_calls = HashMap::new();
4931        let empty_variant_patterns = HashMap::new();
4932        let ctx = CodegenContext::new(&accessors, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
4933
4934        let js_stmt = lower_match_stmt(&scrutinee, &[some_arm, none_arm], &ctx);
4935
4936        // Should generate: if (x.tag == "Some") { let v = x.value; break; } else if (x.tag == "None") { }
4937        if let JsStmt::If {
4938            cond,
4939            then_block,
4940            else_block,
4941        } = js_stmt
4942        {
4943            // Check the condition is x.tag == "Some"
4944            if let JsExpr::Binary { op, left, right } = cond {
4945                assert!(matches!(op, JsBinaryOp::EqEq));
4946                if let JsExpr::Member { property, .. } = *left {
4947                    assert_eq!(property, "tag");
4948                } else {
4949                    panic!("expected Member access for tag");
4950                }
4951                assert!(matches!(*right, JsExpr::String(s) if s == "Some"));
4952            } else {
4953                panic!("expected Binary comparison");
4954            }
4955
4956            // Check the then block has let v = x.value; break;
4957            assert_eq!(then_block.len(), 2, "expected 2 statements (let + break)");
4958            assert!(matches!(&then_block[0], JsStmt::Let { name, .. } if name == "v"));
4959            assert!(matches!(&then_block[1], JsStmt::Break));
4960
4961            // Check there is an else block
4962            assert!(else_block.is_some());
4963        } else {
4964            panic!("expected JsStmt::If, got {:?}", js_stmt);
4965        }
4966    }
4967
4968    #[test]
4969    fn lower_match_stmt_with_continue() {
4970        // Test that match with continue generates if/else with continue
4971        let span = |s: usize, e: usize| HuskSpan { range: s..e };
4972        let ident = |name: &str, s: usize| HuskIdent {
4973            name: name.to_string(),
4974            span: span(s, s + name.len()),
4975        };
4976
4977        // Build: match x { _ => continue }
4978        let scrutinee = husk_ast::Expr {
4979            kind: HuskExprKind::Ident(ident("x", 0)),
4980            span: span(0, 1),
4981        };
4982
4983        let wildcard_arm = husk_ast::MatchArm {
4984            pattern: husk_ast::Pattern {
4985                kind: HuskPatternKind::Wildcard,
4986                span: span(0, 1),
4987            },
4988            expr: husk_ast::Expr {
4989                kind: HuskExprKind::Block(husk_ast::Block {
4990                    stmts: vec![husk_ast::Stmt {
4991                        kind: husk_ast::StmtKind::Continue,
4992                        span: span(5, 13),
4993                    }],
4994                    span: span(4, 14),
4995                }),
4996                span: span(4, 14),
4997            },
4998        };
4999
5000        let accessors = PropertyAccessors::default();
5001        let empty_resolution = HashMap::new();
5002        let empty_type_resolution = HashMap::new();
5003        let empty_variant_calls = HashMap::new();
5004        let empty_variant_patterns = HashMap::new();
5005        let ctx = CodegenContext::new(&accessors, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
5006
5007        let js_stmt = lower_match_stmt(&scrutinee, &[wildcard_arm], &ctx);
5008
5009        // Wildcard match should just be the body statements
5010        assert!(
5011            matches!(&js_stmt, JsStmt::Continue),
5012            "expected Continue, got {:?}",
5013            js_stmt
5014        );
5015    }
5016
5017    #[test]
5018    fn lower_match_stmt_generates_if_else_chain() {
5019        // Test that match generates if/else chain, not IIFE with ternary
5020        let span = |s: usize, e: usize| HuskSpan { range: s..e };
5021        let ident = |name: &str, s: usize| HuskIdent {
5022            name: name.to_string(),
5023            span: span(s, s + name.len()),
5024        };
5025
5026        // Build: match x { Color::Red => 1, Color::Blue => 2 }
5027        let scrutinee = husk_ast::Expr {
5028            kind: HuskExprKind::Ident(ident("x", 0)),
5029            span: span(0, 1),
5030        };
5031
5032        let red_arm = husk_ast::MatchArm {
5033            pattern: husk_ast::Pattern {
5034                kind: HuskPatternKind::EnumUnit {
5035                    path: vec![ident("Color", 0), ident("Red", 7)],
5036                },
5037                span: span(0, 10),
5038            },
5039            expr: husk_ast::Expr {
5040                kind: HuskExprKind::Literal(husk_ast::Literal {
5041                    kind: husk_ast::LiteralKind::Int(1),
5042                    span: span(14, 15),
5043                }),
5044                span: span(14, 15),
5045            },
5046        };
5047
5048        let blue_arm = husk_ast::MatchArm {
5049            pattern: husk_ast::Pattern {
5050                kind: HuskPatternKind::EnumUnit {
5051                    path: vec![ident("Color", 17), ident("Blue", 24)],
5052                },
5053                span: span(17, 28),
5054            },
5055            expr: husk_ast::Expr {
5056                kind: HuskExprKind::Literal(husk_ast::Literal {
5057                    kind: husk_ast::LiteralKind::Int(2),
5058                    span: span(32, 33),
5059                }),
5060                span: span(32, 33),
5061            },
5062        };
5063
5064        let accessors = PropertyAccessors::default();
5065        let empty_resolution = HashMap::new();
5066        let empty_type_resolution = HashMap::new();
5067        let empty_variant_calls = HashMap::new();
5068        let empty_variant_patterns = HashMap::new();
5069        let ctx = CodegenContext::new(&accessors, &empty_resolution, &empty_type_resolution, &empty_variant_calls, &empty_variant_patterns);
5070
5071        let js_stmt = lower_match_stmt(&scrutinee, &[red_arm, blue_arm], &ctx);
5072
5073        // Should generate if/else chain
5074        if let JsStmt::If {
5075            cond: _,
5076            then_block,
5077            else_block,
5078        } = js_stmt
5079        {
5080            // First arm body should be an expression 1
5081            assert_eq!(then_block.len(), 1);
5082            if let JsStmt::Expr(JsExpr::Number(n)) = &then_block[0] {
5083                assert_eq!(*n, 1);
5084            } else {
5085                panic!("expected Expr(Number(1)), got {:?}", then_block[0]);
5086            }
5087
5088            // Should have an else block with the second arm
5089            assert!(else_block.is_some());
5090            let else_stmts = else_block.unwrap();
5091            assert_eq!(else_stmts.len(), 1);
5092            if let JsStmt::If { then_block, .. } = &else_stmts[0] {
5093                assert_eq!(then_block.len(), 1);
5094                if let JsStmt::Expr(JsExpr::Number(n)) = &then_block[0] {
5095                    assert_eq!(*n, 2);
5096                } else {
5097                    panic!("expected Expr(Number(2)), got {:?}", then_block[0]);
5098                }
5099            } else {
5100                panic!("expected nested If for second arm");
5101            }
5102        } else {
5103            panic!("expected JsStmt::If, got {:?}", js_stmt);
5104        }
5105    }
5106
5107    #[test]
5108    fn lowers_imported_unit_variant() {
5109        // Test: use Option::*; let x = None;
5110        // Should generate: {tag: "None"}
5111        let span = |s: usize, e: usize| HuskSpan { range: s..e };
5112        let ident = |name: &str, s: usize| HuskIdent {
5113            name: name.to_string(),
5114            span: span(s, s + name.len()),
5115        };
5116
5117        let none_ident = ident("None", 0);
5118        let none_expr = husk_ast::Expr {
5119            kind: HuskExprKind::Ident(none_ident.clone()),
5120            span: none_ident.span.clone(),
5121        };
5122
5123        let accessors = PropertyAccessors::default();
5124        let empty_resolution = HashMap::new();
5125        let empty_type_resolution = HashMap::new();
5126        let mut variant_calls = HashMap::new();
5127        variant_calls.insert((0, 4), ("Option".to_string(), "None".to_string()));
5128        let empty_variant_patterns = HashMap::new();
5129
5130        let ctx = CodegenContext::new(
5131            &accessors,
5132            &empty_resolution,
5133            &empty_type_resolution,
5134            &variant_calls,
5135            &empty_variant_patterns,
5136        );
5137
5138        let js_expr = lower_expr(&none_expr, &ctx);
5139
5140        if let JsExpr::Object(fields) = js_expr {
5141            assert_eq!(fields.len(), 1);
5142            assert_eq!(fields[0].0, "tag");
5143            assert!(matches!(&fields[0].1, JsExpr::String(s) if s == "None"));
5144        } else {
5145            panic!("expected JsExpr::Object, got {:?}", js_expr);
5146        }
5147    }
5148
5149    #[test]
5150    fn lowers_imported_variant_constructor() {
5151        // Test: use Option::*; let x = Some(42);
5152        // Should generate: {tag: "Some", value: 42}
5153        let span = |s: usize, e: usize| HuskSpan { range: s..e };
5154        let ident = |name: &str, s: usize| HuskIdent {
5155            name: name.to_string(),
5156            span: span(s, s + name.len()),
5157        };
5158
5159        // Build: Some(42) where Some is an imported variant
5160        let some_ident = ident("Some", 0);
5161        let callee = husk_ast::Expr {
5162            kind: HuskExprKind::Ident(some_ident.clone()),
5163            span: some_ident.span.clone(),
5164        };
5165
5166        let arg = husk_ast::Expr {
5167            kind: HuskExprKind::Literal(HuskLiteral {
5168                kind: HuskLiteralKind::Int(42),
5169                span: span(5, 7),
5170            }),
5171            span: span(5, 7),
5172        };
5173
5174        let call_expr = husk_ast::Expr {
5175            kind: HuskExprKind::Call {
5176                callee: Box::new(callee),
5177                type_args: vec![],
5178                args: vec![arg],
5179            },
5180            span: span(0, 8),
5181        };
5182
5183        let accessors = PropertyAccessors::default();
5184        let empty_resolution = HashMap::new();
5185        let empty_type_resolution = HashMap::new();
5186        let mut variant_calls = HashMap::new();
5187        // Register the call span as an imported variant
5188        variant_calls.insert((0, 8), ("Option".to_string(), "Some".to_string()));
5189        let empty_variant_patterns = HashMap::new();
5190
5191        let ctx = CodegenContext::new(
5192            &accessors,
5193            &empty_resolution,
5194            &empty_type_resolution,
5195            &variant_calls,
5196            &empty_variant_patterns,
5197        );
5198
5199        let js_expr = lower_expr(&call_expr, &ctx);
5200
5201        if let JsExpr::Object(fields) = js_expr {
5202            assert_eq!(fields.len(), 2, "expected 2 fields (tag and value)");
5203            assert_eq!(fields[0].0, "tag");
5204            assert!(matches!(&fields[0].1, JsExpr::String(s) if s == "Some"));
5205            assert_eq!(fields[1].0, "value");
5206            assert!(matches!(&fields[1].1, JsExpr::Number(42)));
5207        } else {
5208            panic!("expected JsExpr::Object, got {:?}", js_expr);
5209        }
5210    }
5211
5212    #[test]
5213    fn nested_tuple_destructuring_generates_nested_array_pattern() {
5214        // Test that let ((a, b), c) = expr; generates let [[a, b], c] = expr;
5215        use DestructurePattern::*;
5216
5217        let pattern = vec![
5218            Array(vec![Binding("a".to_string()), Binding("b".to_string())]),
5219            Binding("c".to_string()),
5220        ];
5221
5222        let stmt = JsStmt::LetDestructure {
5223            pattern,
5224            init: Some(JsExpr::Ident("expr".to_string())),
5225        };
5226
5227        let mut out = String::new();
5228        write_stmt(&stmt, 0, &mut out);
5229
5230        assert_eq!(out, "let [[a, b], c] = expr;");
5231    }
5232
5233    #[test]
5234    fn deeply_nested_tuple_destructuring() {
5235        // Test that let (((a, b), c), d) = expr; generates let [[[a, b], c], d] = expr;
5236        use DestructurePattern::*;
5237
5238        let pattern = vec![
5239            Array(vec![
5240                Array(vec![Binding("a".to_string()), Binding("b".to_string())]),
5241                Binding("c".to_string()),
5242            ]),
5243            Binding("d".to_string()),
5244        ];
5245
5246        let stmt = JsStmt::LetDestructure {
5247            pattern,
5248            init: Some(JsExpr::Ident("expr".to_string())),
5249        };
5250
5251        let mut out = String::new();
5252        write_stmt(&stmt, 0, &mut out);
5253
5254        assert_eq!(out, "let [[[a, b], c], d] = expr;");
5255    }
5256
5257    #[test]
5258    fn tuple_destructuring_with_wildcards() {
5259        // Test that let (a, _, c) = expr; generates let [a, _, c] = expr;
5260        use DestructurePattern::*;
5261
5262        let pattern = vec![
5263            Binding("a".to_string()),
5264            Wildcard,
5265            Binding("c".to_string()),
5266        ];
5267
5268        let stmt = JsStmt::LetDestructure {
5269            pattern,
5270            init: Some(JsExpr::Ident("expr".to_string())),
5271        };
5272
5273        let mut out = String::new();
5274        write_stmt(&stmt, 0, &mut out);
5275
5276        assert_eq!(out, "let [a, _, c] = expr;");
5277    }
5278}