Skip to main content

harn_vm/compiler/
state.rs

1use crate::value::VmDictExt;
2use std::collections::BTreeMap;
3use std::sync::Arc;
4
5use harn_parser::{substitute_type_expr, Node, SNode, ShapeField, TypeExpr, TypedParam};
6
7use crate::chunk::{Chunk, CompiledFunction, Constant, Op};
8use crate::value::VmValue;
9
10use super::error::CompileError;
11use super::yield_scan::body_contains_yield;
12use super::{peel_node, Compiler, CompilerOptions, FinallyEntry};
13
14#[cfg(test)]
15thread_local! {
16    /// Test-only override for the value-discarding classification used by
17    /// [`Compiler::compile_discarded_stmt`]. Setting it forces a
18    /// `produces_value` answer regardless of the node, letting tests
19    /// deliberately miswire the classification and prove the #2622 balance
20    /// assertion fires (see
21    /// `compiler::tests::miswired_produces_value_trips_balance_assertion`).
22    pub(super) static FORCE_DISCARDED_PRODUCES_VALUE: std::cell::Cell<Option<bool>> =
23        const { std::cell::Cell::new(None) };
24}
25
26impl Compiler {
27    pub fn new() -> Self {
28        Self::with_options(CompilerOptions::from_env())
29    }
30
31    /// Seed syntax-sensitive import metadata before compiling a source file.
32    ///
33    /// The parser intentionally keeps `Color.Ready(value)` ambiguous: it can
34    /// be a method call or an enum constructor. The module graph resolves
35    /// that ambiguity for imported enums, including wildcard imports, so
36    /// callers that compile a file outside the module-artifact path can pass
37    /// the same public export contract here.
38    pub fn with_imported_enum_candidates(
39        mut self,
40        candidates: impl IntoIterator<Item = String>,
41    ) -> Self {
42        self.add_imported_enum_candidates(candidates);
43        self
44    }
45
46    /// Populate every module-level compiler catalog needed by declarations
47    /// compiled outside the entry pipeline. Module artifacts use this same
48    /// preparation as ordinary program compilation so imported functions see
49    /// the enum, struct, interface, and type-alias context of their source
50    /// module.
51    pub(crate) fn prepare_module_context(&mut self, program: &[SNode]) {
52        self.collect_module_enum_catalog(program);
53        if self.enum_names.insert("Result".to_string()) {
54            Self::seed_builtin_variant_owners(&mut self.enum_variant_owners);
55        }
56        Self::collect_struct_layouts(program, &mut self.struct_layouts);
57        Self::collect_interface_methods(program, &mut self.interface_methods);
58        self.collect_type_aliases(program);
59        self.collect_imported_enum_candidates(program);
60        // Box module-level mutable `let`s that a top-level or pipeline-body
61        // closure captures (harn#4479). Nested function-like bodies reseed
62        // their own capture set when compiled.
63        self.seed_module_captured_idents(program);
64    }
65
66    pub(crate) fn add_imported_enum_candidates(
67        &mut self,
68        candidates: impl IntoIterator<Item = String>,
69    ) {
70        self.imported_enum_candidates_authoritative = true;
71        self.imported_enum_candidates.extend(candidates);
72    }
73
74    /// Compile only the declarations that form a module's initialization
75    /// chunk, using the complete source program for compiler context. The
76    /// caller supplies a filtered list so function and pipeline closures are
77    /// materialized exactly once by the artifact's function table.
78    pub(crate) fn compile_module_init(
79        mut self,
80        context: &[SNode],
81        init_nodes: &[SNode],
82        imported_enum_candidates: &[String],
83    ) -> Result<Chunk, CompileError> {
84        self.add_imported_enum_candidates(imported_enum_candidates.iter().cloned());
85        self.prepare_module_context(context);
86        self.compile_top_level_declarations(init_nodes)?;
87        self.chunk.emit(Op::Nil, self.line);
88        self.chunk.emit(Op::Return, self.line);
89        super::ensure_chunk_addressable(&self.chunk, "the module initialization body", self.line)?;
90        Ok(self.chunk)
91    }
92
93    pub fn with_options(options: CompilerOptions) -> Self {
94        Self {
95            options,
96            chunk: Chunk::new(),
97            line: 1,
98            column: 1,
99            enum_names: std::collections::HashSet::new(),
100            enum_variant_owners: std::collections::HashMap::new(),
101            imported_enum_candidates: std::collections::HashSet::new(),
102            imported_enum_candidates_authoritative: false,
103            predeclared_enum_declarations: std::collections::HashSet::new(),
104            enum_catalog_scopes: Vec::new(),
105            struct_layouts: std::collections::HashMap::new(),
106            interface_methods: std::collections::HashMap::new(),
107            loop_stack: Vec::new(),
108            handler_depth: 0,
109            finally_bodies: Vec::new(),
110            temp_counter: 0,
111            scope_depth: 0,
112            type_aliases: std::collections::HashMap::new(),
113            type_scopes: vec![std::collections::HashMap::new()],
114            monomorphic_bindings: std::collections::HashSet::new(),
115            string_constants: std::collections::HashMap::new(),
116            local_scopes: vec![std::collections::HashMap::new()],
117            module_level: true,
118            captured_bindings: std::collections::HashSet::new(),
119        }
120    }
121
122    /// Compiler instance for a nested function-like body (fn, closure,
123    /// tool, parallel arm, etc.). Differs from `new()` only in that
124    /// `module_level` starts false — `try*` is allowed inside.
125    pub(super) fn for_nested_body(options: CompilerOptions) -> Self {
126        let mut c = Self::with_options(options);
127        c.module_level = false;
128        c
129    }
130
131    pub(super) fn nested_body(&self) -> Self {
132        Self::for_nested_body(self.options)
133    }
134
135    pub(super) fn nominal_type_names(&self) -> Vec<String> {
136        let mut names: Vec<String> = self
137            .struct_layouts
138            .keys()
139            .chain(self.enum_names.iter())
140            .cloned()
141            .collect();
142        names.sort();
143        names.dedup();
144        names
145    }
146
147    pub(super) fn string_constant(&mut self, value: &str) -> u16 {
148        if let Some(idx) = self.string_constants.get(value) {
149            return *idx;
150        }
151        let owned = value.to_string();
152        let idx = self.chunk.add_constant(Constant::String(owned.clone()));
153        self.string_constants.insert(owned, idx);
154        idx
155    }
156
157    pub(super) fn owned_string_constant(&mut self, value: String) -> u16 {
158        if let Some(idx) = self.string_constants.get(value.as_str()) {
159            return *idx;
160        }
161        let idx = self.chunk.add_constant(Constant::String(value.clone()));
162        self.string_constants.insert(value, idx);
163        idx
164    }
165
166    /// Populate `type_aliases` from a program's top-level `type T = ...`
167    /// declarations so later lowerings can resolve alias names to their
168    /// canonical `TypeExpr`.
169    pub(crate) fn collect_type_aliases(&mut self, program: &[SNode]) {
170        for sn in program {
171            match peel_node(sn) {
172                Node::SelectiveImport { names, .. } => {
173                    for name in names {
174                        self.type_aliases.entry(name.clone()).or_insert_with(|| {
175                            super::TypeAliasDefinition {
176                                type_params: Vec::new(),
177                                body: None,
178                            }
179                        });
180                    }
181                }
182                Node::TypeDecl {
183                    name,
184                    type_expr,
185                    type_params,
186                    is_pub: _,
187                } => {
188                    self.type_aliases.insert(
189                        name.clone(),
190                        super::TypeAliasDefinition {
191                            type_params: type_params.clone(),
192                            body: Some(type_expr.clone()),
193                        },
194                    );
195                }
196                _ => {}
197            }
198        }
199    }
200
201    /// Fully expand alias references, inlining every `Named(T)` whose `T` is a
202    /// known alias with the alias's body. A `visiting` set breaks recursive
203    /// aliases (`type Tree = {value: int, children: [Tree]}`): once an alias is
204    /// already being expanded on the current path, the self-reference is left
205    /// as an unexpanded `Named(T)` instead of recursing forever. This mirrors
206    /// the typechecker's `resolve_alias` cycle guard so both sides agree, and
207    /// keeps schema lowering (`type_expr_to_schema_value`) finite — a
208    /// cycle-broken `Named(T)` lowers to no runtime constraint at that nested
209    /// position rather than overflowing the stack.
210    pub(crate) fn expand_alias(&self, ty: &TypeExpr) -> TypeExpr {
211        let mut visiting = std::collections::HashSet::new();
212        self.expand_alias_inner(ty, &mut visiting)
213    }
214
215    fn expand_alias_inner(
216        &self,
217        ty: &TypeExpr,
218        visiting: &mut std::collections::HashSet<String>,
219    ) -> TypeExpr {
220        match ty {
221            TypeExpr::Named(name) => {
222                if let Some(target) = self
223                    .type_aliases
224                    .get(name)
225                    .filter(|alias| alias.type_params.is_empty() && alias.body.is_some())
226                {
227                    if !visiting.insert(name.clone()) {
228                        return TypeExpr::Named(name.clone());
229                    }
230                    let resolved = self.expand_alias_inner(target.body.as_ref().unwrap(), visiting);
231                    visiting.remove(name);
232                    resolved
233                } else {
234                    TypeExpr::Named(name.clone())
235                }
236            }
237            TypeExpr::Union(types) => TypeExpr::Union(
238                types
239                    .iter()
240                    .map(|t| self.expand_alias_inner(t, visiting))
241                    .collect(),
242            ),
243            TypeExpr::Intersection(types) => TypeExpr::Intersection(
244                types
245                    .iter()
246                    .map(|t| self.expand_alias_inner(t, visiting))
247                    .collect(),
248            ),
249            TypeExpr::Shape(fields) => TypeExpr::Shape(
250                fields
251                    .iter()
252                    .map(|field| ShapeField {
253                        type_expr: self.expand_alias_inner(&field.type_expr, visiting),
254                        ..field.clone()
255                    })
256                    .collect(),
257            ),
258            TypeExpr::OpenShape { fields, rests } => TypeExpr::OpenShape {
259                fields: fields
260                    .iter()
261                    .map(|field| ShapeField {
262                        type_expr: self.expand_alias_inner(&field.type_expr, visiting),
263                        ..field.clone()
264                    })
265                    .collect(),
266                rests: rests
267                    .iter()
268                    .map(|r| self.expand_alias_inner(r, visiting))
269                    .collect(),
270            },
271            TypeExpr::List(inner) => {
272                TypeExpr::List(Box::new(self.expand_alias_inner(inner, visiting)))
273            }
274            TypeExpr::Tuple(elements) => TypeExpr::Tuple(
275                elements
276                    .iter()
277                    .map(|element| self.expand_alias_inner(element, visiting))
278                    .collect(),
279            ),
280            TypeExpr::Iter(inner) => {
281                TypeExpr::Iter(Box::new(self.expand_alias_inner(inner, visiting)))
282            }
283            TypeExpr::Generator(inner) => {
284                TypeExpr::Generator(Box::new(self.expand_alias_inner(inner, visiting)))
285            }
286            TypeExpr::Stream(inner) => {
287                TypeExpr::Stream(Box::new(self.expand_alias_inner(inner, visiting)))
288            }
289            TypeExpr::DictType(k, v) => TypeExpr::DictType(
290                Box::new(self.expand_alias_inner(k, visiting)),
291                Box::new(self.expand_alias_inner(v, visiting)),
292            ),
293            TypeExpr::FnType {
294                params,
295                return_type,
296            } => TypeExpr::FnType {
297                params: params
298                    .iter()
299                    .map(|p| self.expand_alias_inner(p, visiting))
300                    .collect(),
301                return_type: Box::new(self.expand_alias_inner(return_type, visiting)),
302            },
303            TypeExpr::Applied { name, args } => {
304                let args = args
305                    .iter()
306                    .map(|arg| self.expand_alias_inner(arg, visiting))
307                    .collect::<Vec<_>>();
308                let Some(alias) = self.type_aliases.get(name) else {
309                    return TypeExpr::Applied {
310                        name: name.clone(),
311                        args,
312                    };
313                };
314                let Some(body) = alias.body.as_ref() else {
315                    return TypeExpr::Applied {
316                        name: name.clone(),
317                        args,
318                    };
319                };
320                if alias.type_params.len() != args.len() || !visiting.insert(name.clone()) {
321                    return TypeExpr::Applied {
322                        name: name.clone(),
323                        args,
324                    };
325                }
326                let bindings = alias
327                    .type_params
328                    .iter()
329                    .zip(args.iter().cloned())
330                    .map(|(param, arg)| (param.name.clone(), arg))
331                    .collect();
332                let instantiated = substitute_type_expr(body, &bindings);
333                let resolved = self.expand_alias_inner(&instantiated, visiting);
334                visiting.remove(name);
335                resolved
336            }
337            TypeExpr::Never => TypeExpr::Never,
338            TypeExpr::LitString(s) => TypeExpr::LitString(s.clone()),
339            TypeExpr::LitInt(v) => TypeExpr::LitInt(*v),
340            TypeExpr::Owned(inner) => {
341                TypeExpr::Owned(Box::new(self.expand_alias_inner(inner, visiting)))
342            }
343        }
344    }
345
346    /// Compile exported type schemas into a tiny module initializer. Running
347    /// this after imports makes referenced imported schemas ordinary lexical
348    /// inputs while keeping the cached artifact immutable and relocatable.
349    pub fn compile_public_type_schema_initializers(
350        program: &[SNode],
351        source_file: Option<String>,
352    ) -> Result<Option<Chunk>, CompileError> {
353        let mut compiler = Compiler::new();
354        compiler.collect_type_aliases(program);
355        compiler.chunk.source_file = source_file;
356        let mut emitted = false;
357        for sn in program {
358            let Node::TypeDecl {
359                name, is_pub: true, ..
360            } = peel_node(sn)
361            else {
362                continue;
363            };
364            if compiler.emit_schema_for_alias(name) {
365                compiler.emit_define_binding(name, false);
366                emitted = true;
367            }
368        }
369        if !emitted {
370            return Ok(None);
371        }
372        compiler.chunk.emit(Op::Nil, compiler.line);
373        compiler.chunk.emit(Op::Return, compiler.line);
374        super::ensure_chunk_addressable(
375            &compiler.chunk,
376            "the module type-schema initializer",
377            compiler.line,
378        )?;
379        Ok(Some(compiler.chunk))
380    }
381
382    /// Schema-guard builtins that accept a schema as their second argument.
383    /// When callers pass a type-alias identifier here, the compiler lowers
384    /// it to the alias's JSON-Schema dict constant.
385    pub(super) fn is_schema_guard(name: &str) -> bool {
386        matches!(
387            name,
388            "schema_is"
389                | "schema_expect"
390                | "schema_parse"
391                | "schema_check"
392                | "schema_report"
393                | "is_type"
394                | "json_validate"
395        )
396    }
397
398    /// Check whether a dict-literal key node matches the given keyword
399    /// (identifier or string literal form).
400    pub(super) fn entry_key_is(key: &SNode, keyword: &str) -> bool {
401        matches!(
402            &key.node,
403            Node::Identifier(name) | Node::StringLiteral(name) | Node::RawStringLiteral(name)
404                if name == keyword
405        )
406    }
407
408    /// Compile a program (list of top-level nodes) into a Chunk.
409    /// Finds the entry pipeline and compiles its body, including inherited bodies.
410    pub fn compile(mut self, program: &[SNode]) -> Result<Chunk, CompileError> {
411        // Pre-scan so we can recognize EnumName.Variant as enum construction
412        // even when the enum is declared inside a pipeline.
413        self.prepare_module_context(program);
414
415        for sn in program {
416            match &sn.node {
417                Node::ImportDecl { .. }
418                | Node::SelectiveImport { .. }
419                | Node::NamespaceImport { .. } => {
420                    self.compile_node(sn)?;
421                }
422                _ => {}
423            }
424        }
425        let main = program
426            .iter()
427            .find(|sn| matches!(peel_node(sn), Node::Pipeline { name, .. } if name == "default"))
428            .or_else(|| {
429                program
430                    .iter()
431                    .find(|sn| matches!(peel_node(sn), Node::Pipeline { .. }))
432            });
433
434        // When a pipeline body produces a final value, that value flows
435        // out of `vm.execute()` so the CLI can map it to a process exit
436        // code (int → exit n, Result::Err(msg) → stderr+exit 1).
437        let mut pipeline_emits_value = false;
438        if let Some(sn) = main {
439            self.compile_top_level_declarations(program)?;
440            if let Node::Pipeline { body, extends, .. } = peel_node(sn) {
441                self.compile_with_pipeline_captures(
442                    program,
443                    body,
444                    extends.as_deref(),
445                    |compiler| {
446                        if let Some(parent_name) = extends {
447                            compiler.compile_parent_pipeline(program, parent_name)?;
448                        }
449                        let saved = std::mem::replace(&mut compiler.module_level, false);
450                        let result = compiler.compile_block(body);
451                        compiler.module_level = saved;
452                        result
453                    },
454                )?;
455                pipeline_emits_value = true;
456            }
457        } else {
458            // Script mode: no pipeline found, treat top-level as implicit entry.
459            let top_level: Vec<&SNode> = program
460                .iter()
461                .filter(|sn| {
462                    !matches!(
463                        &sn.node,
464                        Node::ImportDecl { .. }
465                            | Node::SelectiveImport { .. }
466                            | Node::NamespaceImport { .. }
467                    )
468                })
469                .collect();
470            for sn in &top_level {
471                self.compile_discarded_stmt(sn)?;
472            }
473            // E4.1 entrypoint convention: a top-level `fn main(harness: Harness)`
474            // is invoked automatically with the runtime-provided `harness`
475            // global. The typechecker rejects every other signature with
476            // HARN-NAM-101 so we don't need to re-validate the shape here.
477            if Self::has_top_level_fn_main(program) {
478                let harness_name = self.string_constant("harness");
479                self.chunk.emit_u16(Op::GetVar, harness_name, self.line);
480                self.emit_named_call("main", 1);
481                pipeline_emits_value = true;
482            }
483        }
484
485        self.drain_finallys_to_floor(0)?;
486        if !pipeline_emits_value {
487            self.chunk.emit(Op::Nil, self.line);
488        }
489        self.chunk.emit(Op::Return, self.line);
490        super::ensure_chunk_addressable(&self.chunk, "the program body", self.line)?;
491        Ok(self.chunk)
492    }
493
494    /// True when the program declares a top-level `fn main(...)`. Drives the
495    /// auto-call wired by `compile()` for the new `main(harness: Harness)`
496    /// entrypoint convention.
497    fn has_top_level_fn_main(program: &[SNode]) -> bool {
498        program
499            .iter()
500            .any(|sn| matches!(peel_node(sn), Node::FnDecl { name, .. } if name == "main"))
501    }
502
503    /// Compile a specific named pipeline (for test runners).
504    pub fn compile_named(
505        self,
506        program: &[SNode],
507        pipeline_name: &str,
508    ) -> Result<Chunk, CompileError> {
509        self.compile_named_inner(program, pipeline_name)
510    }
511
512    fn compile_named_inner(
513        mut self,
514        program: &[SNode],
515        pipeline_name: &str,
516    ) -> Result<Chunk, CompileError> {
517        self.prepare_module_context(program);
518
519        for sn in program {
520            if matches!(
521                &sn.node,
522                Node::ImportDecl { .. }
523                    | Node::SelectiveImport { .. }
524                    | Node::NamespaceImport { .. }
525            ) {
526                self.compile_node(sn)?;
527            }
528        }
529        let target = program.iter().find(
530            |sn| matches!(peel_node(sn), Node::Pipeline { name, .. } if name == pipeline_name),
531        );
532
533        if let Some(sn) = target {
534            self.compile_top_level_declarations(program)?;
535            if let Node::Pipeline { body, extends, .. } = peel_node(sn) {
536                self.compile_with_pipeline_captures(
537                    program,
538                    body,
539                    extends.as_deref(),
540                    |compiler| {
541                        if let Some(parent_name) = extends {
542                            compiler.compile_parent_pipeline(program, parent_name)?;
543                        }
544                        let saved = std::mem::replace(&mut compiler.module_level, false);
545                        let result = compiler.compile_block(body);
546                        compiler.module_level = saved;
547                        result
548                    },
549                )?;
550            }
551        }
552
553        self.drain_finallys_to_floor(0)?;
554        self.chunk.emit(Op::Nil, self.line);
555        self.chunk.emit(Op::Return, self.line);
556        super::ensure_chunk_addressable(&self.chunk, "the pipeline body", self.line)?;
557        Ok(self.chunk)
558    }
559
560    /// Emit bytecode preamble for default parameter values.
561    /// For each param with a default at index i, emits:
562    ///   GetArgc; PushInt (i+1); GreaterEqual; JumpIfTrue <skip>;
563    ///   [compile default expr]; DefLet param_name; <skip>:
564    pub(super) fn emit_default_preamble(
565        &mut self,
566        params: &[TypedParam],
567    ) -> Result<(), CompileError> {
568        for (i, param) in params.iter().enumerate() {
569            if let Some(default_expr) = &param.default_value {
570                self.chunk.emit(Op::GetArgc, self.line);
571                let threshold_idx = self.chunk.add_constant(Constant::Int((i + 1) as i64));
572                self.chunk.emit_u16(Op::Constant, threshold_idx, self.line);
573                self.chunk.emit(Op::GreaterEqual, self.line);
574                let skip_jump = self.chunk.emit_jump(Op::JumpIfTrue, self.line);
575                // JumpIfTrue doesn't pop its boolean operand.
576                self.chunk.emit(Op::Pop, self.line);
577                // Compile the default with this param and all *later* params
578                // hidden from local resolution. A default is evaluated left to
579                // right at call time: it may reference an earlier parameter,
580                // but a mention of its own name (or a later, not-yet-bound
581                // parameter) must resolve to the enclosing scope — e.g.
582                // `let n = 7; fn f(n = n * 2)` reads the outer `n`. Without the
583                // mask, `n` bound to the param's own unset slot and threw at
584                // runtime. Earlier params stay visible.
585                let masked = self.mask_param_names(&params[i..]);
586                let result = self.compile_node(default_expr);
587                self.restore_param_names(masked);
588                result?;
589                self.emit_init_or_define_binding(&param.name, false);
590                let end_jump = self.chunk.emit_jump(Op::Jump, self.line);
591                self.chunk.patch_jump(skip_jump);
592                self.chunk.emit(Op::Pop, self.line);
593                self.chunk.patch_jump(end_jump);
594            }
595        }
596        Ok(())
597    }
598
599    /// Emit body-local type checks that call-site validation cannot cover.
600    /// Ordinary supplied arguments are validated by precomputed
601    /// [`crate::chunk::ParamSlot`] guards before the frame is entered. The
602    /// bytecode preamble still checks interface parameters, because interface
603    /// satisfaction depends on compiler-collected method metadata, and checks
604    /// defaulted schema parameters only when the caller omitted that argument.
605    pub(super) fn emit_type_checks(&mut self, params: &[TypedParam]) {
606        for (param_index, param) in params.iter().enumerate() {
607            if let Some(type_expr) = &param.type_expr {
608                let check_type = if param.rest {
609                    harn_parser::TypeExpr::List(Box::new(type_expr.clone()))
610                } else {
611                    type_expr.clone()
612                };
613
614                if let harn_parser::TypeExpr::Named(name) = &check_type {
615                    if let Some(methods) = self.interface_methods.get(name).cloned() {
616                        let fn_idx = self.string_constant("__assert_interface");
617                        self.chunk.emit_u16(Op::Constant, fn_idx, self.line);
618                        self.emit_get_binding(&param.name);
619                        let name_idx = self.string_constant(&param.name);
620                        self.chunk.emit_u16(Op::Constant, name_idx, self.line);
621                        let iface_idx = self.string_constant(name);
622                        self.chunk.emit_u16(Op::Constant, iface_idx, self.line);
623                        let methods_str = methods.join(",");
624                        let methods_idx = self.owned_string_constant(methods_str);
625                        self.chunk.emit_u16(Op::Constant, methods_idx, self.line);
626                        self.chunk.emit_u8(Op::Call, 4, self.line);
627                        self.chunk.emit(Op::Pop, self.line);
628                        continue;
629                    }
630                }
631
632                if param.default_value.is_some() {
633                    if let Some(schema) = Self::type_expr_to_schema_value(&check_type) {
634                        self.emit_default_param_schema_check(param_index, param, &schema);
635                    }
636                }
637            }
638        }
639    }
640
641    fn emit_default_param_schema_check(
642        &mut self,
643        param_index: usize,
644        param: &TypedParam,
645        schema: &VmValue,
646    ) {
647        self.chunk.emit(Op::GetArgc, self.line);
648        let threshold_idx = self
649            .chunk
650            .add_constant(Constant::Int((param_index + 1) as i64));
651        self.chunk.emit_u16(Op::Constant, threshold_idx, self.line);
652        self.chunk.emit(Op::GreaterEqual, self.line);
653        let supplied_jump = self.chunk.emit_jump(Op::JumpIfTrue, self.line);
654        self.chunk.emit(Op::Pop, self.line);
655        self.emit_schema_assert_call(param, schema);
656        let end_jump = self.chunk.emit_jump(Op::Jump, self.line);
657        self.chunk.patch_jump(supplied_jump);
658        self.chunk.emit(Op::Pop, self.line);
659        self.chunk.patch_jump(end_jump);
660    }
661
662    fn emit_schema_assert_call(&mut self, param: &TypedParam, schema: &VmValue) {
663        let fn_idx = self.string_constant("__assert_schema");
664        self.chunk.emit_u16(Op::Constant, fn_idx, self.line);
665        self.emit_get_binding(&param.name);
666        let name_idx = self.string_constant(&param.name);
667        self.chunk.emit_u16(Op::Constant, name_idx, self.line);
668        self.emit_vm_value_literal(schema);
669        self.chunk.emit_u8(Op::Call, 3, self.line);
670        self.chunk.emit(Op::Pop, self.line);
671    }
672
673    pub(crate) fn type_expr_to_schema_value(type_expr: &harn_parser::TypeExpr) -> Option<VmValue> {
674        match type_expr {
675            harn_parser::TypeExpr::Named(name) => match name.as_str() {
676                "any" | "unknown" => Some(VmValue::dict(BTreeMap::<String, VmValue>::new())),
677                "int" | "float" | "string" | "bool" | "list" | "dict" | "set" | "nil"
678                | "closure" | "bytes" => Some(VmValue::dict(BTreeMap::from([(
679                    "type".to_string(),
680                    VmValue::String(arcstr::ArcStr::from(name.as_str())),
681                )]))),
682                _ => None,
683            },
684            harn_parser::TypeExpr::Shape(fields) => {
685                let mut properties = BTreeMap::new();
686                let mut required = Vec::new();
687                for field in fields {
688                    let mut field_schema = Self::type_expr_to_schema_value(&field.type_expr)?;
689                    if field.optional {
690                        field_schema = VmValue::dict(BTreeMap::from([(
691                            "union".to_string(),
692                            VmValue::List(std::sync::Arc::new(vec![
693                                field_schema,
694                                VmValue::dict(BTreeMap::from([(
695                                    "type".to_string(),
696                                    VmValue::String(arcstr::ArcStr::from("nil")),
697                                )])),
698                            ])),
699                        )]));
700                    }
701                    properties.insert(field.name.clone(), field_schema);
702                    if !field.optional {
703                        required.push(VmValue::String(arcstr::ArcStr::from(field.name.as_str())));
704                    }
705                }
706                let mut out = BTreeMap::new();
707                out.put_str("type", "dict");
708                out.insert("properties".to_string(), VmValue::dict(properties));
709                if !required.is_empty() {
710                    out.insert(
711                        "required".to_string(),
712                        VmValue::List(std::sync::Arc::new(required)),
713                    );
714                }
715                Some(VmValue::dict(out))
716            }
717            harn_parser::TypeExpr::OpenShape { .. } => None,
718            harn_parser::TypeExpr::List(inner) => {
719                let mut out = BTreeMap::new();
720                out.put_str("type", "list");
721                let item_schema = Self::type_expr_to_schema_value(inner)?;
722                out.insert("items".to_string(), item_schema);
723                Some(VmValue::dict(out))
724            }
725            // The canonical Harn schema vocabulary currently has homogeneous
726            // `items` but no positional-items contract. Returning `None`
727            // deliberately selects the compiled TypeExpr runtime guard, which
728            // preserves exact arity and slot types instead of weakening a
729            // tuple to a homogeneous list schema.
730            harn_parser::TypeExpr::Tuple(_) => None,
731            harn_parser::TypeExpr::DictType(key, value) => {
732                let mut out = BTreeMap::new();
733                out.put_str("type", "dict");
734                if matches!(key.as_ref(), harn_parser::TypeExpr::Named(name) if name == "string") {
735                    let value_schema = Self::type_expr_to_schema_value(value)?;
736                    out.insert("additional_properties".to_string(), value_schema);
737                }
738                Some(VmValue::dict(out))
739            }
740            harn_parser::TypeExpr::Union(members) => {
741                // Special-case unions of literals: emit as `enum: [...]`
742                // so the schema round-trips as canonical JSON Schema and
743                // is ACP-/OpenAPI-compatible. Mixed unions fall back to
744                // the `union:` key that validators recognize.
745                if !members.is_empty()
746                    && members
747                        .iter()
748                        .all(|m| matches!(m, harn_parser::TypeExpr::LitString(_)))
749                {
750                    let values = members
751                        .iter()
752                        .map(|m| match m {
753                            harn_parser::TypeExpr::LitString(s) => {
754                                VmValue::String(arcstr::ArcStr::from(s.as_str()))
755                            }
756                            _ => unreachable!(),
757                        })
758                        .collect::<Vec<_>>();
759                    return Some(VmValue::dict(BTreeMap::from([
760                        (
761                            "type".to_string(),
762                            VmValue::String(arcstr::ArcStr::from("string")),
763                        ),
764                        (
765                            "enum".to_string(),
766                            VmValue::List(std::sync::Arc::new(values)),
767                        ),
768                    ])));
769                }
770                if !members.is_empty()
771                    && members
772                        .iter()
773                        .all(|m| matches!(m, harn_parser::TypeExpr::LitInt(_)))
774                {
775                    let values = members
776                        .iter()
777                        .map(|m| match m {
778                            harn_parser::TypeExpr::LitInt(v) => VmValue::Int(*v),
779                            _ => unreachable!(),
780                        })
781                        .collect::<Vec<_>>();
782                    return Some(VmValue::dict(BTreeMap::from([
783                        (
784                            "type".to_string(),
785                            VmValue::String(arcstr::ArcStr::from("int")),
786                        ),
787                        (
788                            "enum".to_string(),
789                            VmValue::List(std::sync::Arc::new(values)),
790                        ),
791                    ])));
792                }
793                let branches = members
794                    .iter()
795                    .map(Self::type_expr_to_schema_value)
796                    .collect::<Option<Vec<_>>>()?;
797                if branches.is_empty() {
798                    None
799                } else {
800                    Some(VmValue::dict(BTreeMap::from([(
801                        "union".to_string(),
802                        VmValue::List(std::sync::Arc::new(branches)),
803                    )])))
804                }
805            }
806            harn_parser::TypeExpr::Intersection(members) => {
807                // Encode `A & B` as JSON-Schema `allOf` (the runtime
808                // accepts the snake_case `all_of` key directly). The
809                // value must validate against every branch.
810                let branches = members
811                    .iter()
812                    .map(Self::type_expr_to_schema_value)
813                    .collect::<Option<Vec<_>>>()?;
814                if branches.is_empty() {
815                    None
816                } else {
817                    Some(VmValue::dict(BTreeMap::from([(
818                        "all_of".to_string(),
819                        VmValue::List(std::sync::Arc::new(branches)),
820                    )])))
821                }
822            }
823            harn_parser::TypeExpr::FnType { .. } => Some(VmValue::dict(BTreeMap::from([(
824                "type".to_string(),
825                VmValue::String(arcstr::ArcStr::from("closure")),
826            )]))),
827            harn_parser::TypeExpr::Applied { .. } => None,
828            harn_parser::TypeExpr::Iter(_)
829            | harn_parser::TypeExpr::Generator(_)
830            | harn_parser::TypeExpr::Stream(_) => None,
831            harn_parser::TypeExpr::Never => None,
832            harn_parser::TypeExpr::LitString(s) => Some(VmValue::dict(BTreeMap::from([
833                (
834                    "type".to_string(),
835                    VmValue::String(arcstr::ArcStr::from("string")),
836                ),
837                (
838                    "const".to_string(),
839                    VmValue::String(arcstr::ArcStr::from(s.as_str())),
840                ),
841            ]))),
842            harn_parser::TypeExpr::LitInt(v) => Some(VmValue::dict(BTreeMap::from([
843                (
844                    "type".to_string(),
845                    VmValue::String(arcstr::ArcStr::from("int")),
846                ),
847                ("const".to_string(), VmValue::Int(*v)),
848            ]))),
849            harn_parser::TypeExpr::Owned(inner) => Self::type_expr_to_schema_value(inner),
850        }
851    }
852
853    pub(super) fn emit_vm_value_literal(&mut self, value: &VmValue) {
854        match value {
855            VmValue::String(text) => {
856                let idx = self.string_constant(text);
857                self.chunk.emit_u16(Op::Constant, idx, self.line);
858            }
859            VmValue::Int(number) => {
860                let idx = self.chunk.add_constant(Constant::Int(*number));
861                self.chunk.emit_u16(Op::Constant, idx, self.line);
862            }
863            VmValue::Float(number) => {
864                let idx = self.chunk.add_constant(Constant::Float(*number));
865                self.chunk.emit_u16(Op::Constant, idx, self.line);
866            }
867            VmValue::Bool(value) => {
868                let idx = self.chunk.add_constant(Constant::Bool(*value));
869                self.chunk.emit_u16(Op::Constant, idx, self.line);
870            }
871            VmValue::Nil => self.chunk.emit(Op::Nil, self.line),
872            VmValue::List(items) => {
873                for item in items.iter() {
874                    self.emit_vm_value_literal(item);
875                }
876                self.chunk
877                    .emit_u16(Op::BuildList, items.len() as u16, self.line);
878            }
879            VmValue::Dict(entries) => {
880                for (key, item) in entries.iter() {
881                    let key_idx = self.string_constant(key);
882                    self.chunk.emit_u16(Op::Constant, key_idx, self.line);
883                    self.emit_vm_value_literal(item);
884                }
885                self.chunk
886                    .emit_u16(Op::BuildDict, entries.len() as u16, self.line);
887            }
888            _ => {}
889        }
890    }
891
892    /// Emit the extra u16 type name index after a TryCatchSetup jump.
893    pub(super) fn emit_type_name_extra(&mut self, type_name_idx: u16) {
894        let hi = (type_name_idx >> 8) as u8;
895        let lo = type_name_idx as u8;
896        self.chunk.code.push(hi);
897        self.chunk.code.push(lo);
898        self.chunk.lines.push(self.line);
899        self.chunk.columns.push(self.column);
900        self.chunk.lines.push(self.line);
901        self.chunk.columns.push(self.column);
902    }
903
904    /// Compile a try/catch body block (produces a value on the stack).
905    pub(super) fn compile_try_body(&mut self, body: &[SNode]) -> Result<(), CompileError> {
906        if body.is_empty() {
907            self.chunk.emit(Op::Nil, self.line);
908        } else {
909            self.compile_scoped_block(body)?;
910        }
911        Ok(())
912    }
913
914    /// Compile catch error binding (error value is on stack from handler).
915    pub(super) fn compile_catch_binding(
916        &mut self,
917        error_var: &Option<String>,
918    ) -> Result<(), CompileError> {
919        if let Some(var_name) = error_var {
920            self.emit_define_binding(var_name, false);
921        } else {
922            self.chunk.emit(Op::Pop, self.line);
923        }
924        Ok(())
925    }
926
927    /// Compile finally body inline, discarding its result value.
928    /// `compile_scoped_block` always leaves exactly one value on the stack
929    /// (Nil for non-value tail statements), so the trailing Pop is
930    /// unconditional — otherwise a finally ending in e.g. `x = x + 1`
931    /// would leave a stray Nil that corrupts the surrounding expression
932    /// when the enclosing try/finally is used in expression position.
933    pub(super) fn compile_finally_inline(
934        &mut self,
935        finally_body: &[SNode],
936    ) -> Result<(), CompileError> {
937        if !finally_body.is_empty() {
938            self.compile_scoped_block(finally_body)?;
939            self.chunk.emit(Op::Pop, self.line);
940        }
941        Ok(())
942    }
943
944    /// Whether a pending finally lies above the innermost `CatchBarrier`.
945    /// A locally caught throw stops at the barrier, so cleanup entries below
946    /// it are not part of that throw's exit path.
947    pub(super) fn has_pending_finally_until_barrier(&self) -> bool {
948        self.finally_bodies
949            .iter()
950            .rev()
951            .take_while(|entry| !matches!(entry, FinallyEntry::CatchBarrier))
952            .any(|entry| matches!(entry, FinallyEntry::Finally(_)))
953    }
954
955    /// True if there are any pending finally bodies (not just barriers).
956    pub(super) fn has_pending_finally(&self) -> bool {
957        self.finally_bodies
958            .iter()
959            .any(|e| matches!(e, FinallyEntry::Finally(_)))
960    }
961
962    /// Save a thrown value to a temp and rethrow without running finally.
963    ///
964    /// Historically this helper also invoked `compile_finally_inline` on the
965    /// thrown path, but that produced observable double-runs: the
966    /// `Node::ThrowStmt` lowering (below) already iterates `finally_bodies`
967    /// and runs each pending finally inline *before* emitting `Op::Throw`, so
968    /// a second run here fired the same side effects twice. Finally now runs
969    /// exactly once — via the throw-emit path during unwinding.
970    pub(super) fn compile_plain_rethrow(&mut self) -> Result<(), CompileError> {
971        self.temp_counter += 1;
972        let temp_name = format!("__finally_err_{}__", self.temp_counter);
973        self.emit_define_binding(&temp_name, true);
974        self.emit_get_binding(&temp_name);
975        self.chunk.emit(Op::Throw, self.line);
976        Ok(())
977    }
978
979    pub(super) fn declare_param_slots(&mut self, params: &[TypedParam]) {
980        for param in params {
981            self.define_local_slot(&param.name, false);
982        }
983    }
984
985    /// Temporarily remove the given parameters' names from the innermost local
986    /// scope so that, while compiling a default-value expression, references to
987    /// them resolve to the enclosing scope instead of their not-yet-bound param
988    /// slots. Returns the removed bindings so [`Self::restore_param_names`] can
989    /// reinstate them afterward. See [`Self::emit_default_preamble`].
990    fn mask_param_names(&mut self, params: &[TypedParam]) -> Vec<(String, super::LocalBinding)> {
991        let mut removed = Vec::new();
992        if let Some(scope) = self.local_scopes.last_mut() {
993            for param in params {
994                if let Some(binding) = scope.remove(&param.name) {
995                    removed.push((param.name.clone(), binding));
996                }
997            }
998        }
999        removed
1000    }
1001
1002    /// Reinstate parameter names removed by [`Self::mask_param_names`].
1003    fn restore_param_names(&mut self, removed: Vec<(String, super::LocalBinding)>) {
1004        if let Some(scope) = self.local_scopes.last_mut() {
1005            for (name, binding) in removed {
1006                scope.insert(name, binding);
1007            }
1008        }
1009    }
1010
1011    /// Seed exact source bindings captured by nested callables in the body
1012    /// about to be compiled. Parser-owned lexical analysis accounts for
1013    /// parameters, patterns, blocks, loops, catches, selects, and nested
1014    /// callable boundaries before the VM decides whether to use `DefCell`.
1015    pub(super) fn seed_captured_idents(&mut self, body: &[SNode]) {
1016        let match_patterns = self.lexical_match_pattern_catalog();
1017        self.captured_bindings =
1018            harn_parser::lexical::captured_bindings_in_nested_callables(body, &match_patterns);
1019    }
1020
1021    fn seed_module_captured_idents(&mut self, body: &[SNode]) {
1022        let match_patterns = self.lexical_match_pattern_catalog();
1023        self.captured_bindings =
1024            harn_parser::lexical::captured_bindings_in_compiled_module(body, &match_patterns);
1025    }
1026
1027    pub(super) fn lexical_match_pattern_catalog(
1028        &self,
1029    ) -> harn_parser::lexical::MatchPatternCatalog {
1030        if self.imported_enum_candidates.is_empty() {
1031            return harn_parser::lexical::MatchPatternCatalog::new(
1032                &self.enum_names,
1033                &self.enum_variant_owners,
1034            );
1035        }
1036        let mut enum_names = self.enum_names.clone();
1037        enum_names.extend(self.imported_enum_candidates.iter().cloned());
1038        harn_parser::lexical::MatchPatternCatalog::new(&enum_names, &self.enum_variant_owners)
1039    }
1040
1041    pub(super) fn begin_scope(&mut self) {
1042        self.chunk.emit(Op::PushScope, self.line);
1043        self.scope_depth += 1;
1044        let enum_catalog = self.enum_catalog_snapshot();
1045        self.enum_catalog_scopes.push(enum_catalog);
1046        self.type_scopes.push(std::collections::HashMap::new());
1047        self.local_scopes.push(std::collections::HashMap::new());
1048    }
1049
1050    pub(super) fn end_scope(&mut self) {
1051        if self.scope_depth > 0 {
1052            self.chunk.emit(Op::PopScope, self.line);
1053            self.scope_depth -= 1;
1054            if let Some(snapshot) = self.enum_catalog_scopes.pop() {
1055                self.restore_enum_catalog(snapshot);
1056            }
1057            self.type_scopes.pop();
1058            self.local_scopes.pop();
1059        }
1060    }
1061
1062    /// Emit cleanup for an abrupt control-flow path without changing the
1063    /// compiler's lexical scope stacks for the source path that follows it.
1064    pub(super) fn emit_scope_unwind_to(&mut self, target_depth: usize) {
1065        for _ in target_depth..self.scope_depth {
1066            self.chunk.emit(Op::PopScope, self.line);
1067        }
1068    }
1069
1070    pub(super) fn compile_scoped_block(&mut self, stmts: &[SNode]) -> Result<(), CompileError> {
1071        self.begin_scope();
1072        let finally_floor = self.finally_bodies.len();
1073        if stmts.is_empty() {
1074            self.chunk.emit(Op::Nil, self.line);
1075        } else {
1076            self.compile_block(stmts)?;
1077        }
1078        self.drain_finallys_to_floor(finally_floor)?;
1079        self.end_scope();
1080        Ok(())
1081    }
1082
1083    pub(super) fn compile_scoped_statements(
1084        &mut self,
1085        stmts: &[SNode],
1086    ) -> Result<(), CompileError> {
1087        self.begin_scope();
1088        self.record_monomorphic_var_bindings(stmts);
1089        let finally_floor = self.finally_bodies.len();
1090        for sn in stmts {
1091            self.compile_discarded_stmt(sn)?;
1092        }
1093        self.drain_finallys_to_floor(finally_floor)?;
1094        self.end_scope();
1095        Ok(())
1096    }
1097
1098    /// Drain pending `defer` bodies down to a saved floor and run each inline
1099    /// in LIFO order. Each defer body is popped *before* its code is emitted so
1100    /// any `return` / `break` lowering inside the body sees the remaining
1101    /// pending defers (not itself).
1102    pub(super) fn drain_finallys_to_floor(&mut self, floor: usize) -> Result<(), CompileError> {
1103        while self.finally_bodies.len() > floor {
1104            let entry = self.finally_bodies.pop().expect("non-empty by guard");
1105            if let FinallyEntry::Finally(body) = entry {
1106                self.compile_finally_inline(&body)?;
1107            }
1108        }
1109        Ok(())
1110    }
1111
1112    /// Run the pending finally/defer bodies a non-local transfer (`return`,
1113    /// `break`, `continue`) crosses on its way down to `floor`, innermost
1114    /// first, then restore the pending stack.
1115    ///
1116    /// Like [`Self::drain_finallys_to_floor`] each body is removed from the
1117    /// stack *before* it is inlined, so a `return`/`break`/`continue` inside a
1118    /// finally body runs only the finallys *outside* it instead of re-running
1119    /// the one it is in — which otherwise recursed forever at compile time and
1120    /// aborted the process with a stack overflow. Unlike that helper (used at
1121    /// scope exit), the stack is restored afterward because a transfer is a
1122    /// branch: the code the compiler emits after it still needs the pending
1123    /// finallys for the fall-through and sibling paths.
1124    pub(super) fn run_pending_finallys_for_transfer(
1125        &mut self,
1126        floor: usize,
1127    ) -> Result<(), CompileError> {
1128        if self.finally_bodies.len() <= floor {
1129            return Ok(());
1130        }
1131        let saved = self.finally_bodies[floor..].to_vec();
1132        let result = self.drain_finallys_to_floor(floor);
1133        self.finally_bodies.extend(saved);
1134        result
1135    }
1136
1137    /// Like [`Self::run_pending_finallys_for_transfer`] but for a `throw`: run
1138    /// only the finallys between here and the innermost `CatchBarrier` (the
1139    /// ones the unwind actually crosses before a local `catch` halts it),
1140    /// masking each while it is inlined and restoring the stack afterward.
1141    pub(super) fn run_pending_finallys_until_barrier(&mut self) -> Result<(), CompileError> {
1142        let floor = self
1143            .finally_bodies
1144            .iter()
1145            .rposition(|e| matches!(e, FinallyEntry::CatchBarrier))
1146            .map(|i| i + 1)
1147            .unwrap_or(0);
1148        self.run_pending_finallys_for_transfer(floor)
1149    }
1150
1151    /// Register an auto-drop defer for an `owned<T>` binding. The drop runs
1152    /// at scope exit alongside any user-written `defer { ... }` blocks (LIFO
1153    /// order) and on `return` / `break` / `continue` / `throw` via the
1154    /// existing finally-unwinding machinery.
1155    pub(super) fn maybe_register_owned_drop(
1156        &mut self,
1157        pattern: &harn_parser::BindingPattern,
1158        type_ann: Option<&TypeExpr>,
1159        span: harn_lexer::Span,
1160    ) {
1161        // Auto-drop only fires when the user explicitly opted in via
1162        // `owned<T>` on a single-identifier binding. Destructured patterns
1163        // (`{a, b}`, `[a, b]`, pairs) aren't auto-dropped: ownership of a
1164        // composite isn't well-defined, and users can wrap individual fields
1165        // with `owned<T>` and bind them separately if needed.
1166        let Some(ty) = type_ann else {
1167            return;
1168        };
1169        if !matches!(ty, TypeExpr::Owned(_)) {
1170            return;
1171        }
1172        let harn_parser::BindingPattern::Identifier(name) = pattern else {
1173            return;
1174        };
1175        if harn_parser::is_discard_name(name) {
1176            return;
1177        }
1178        let call = harn_parser::spanned(
1179            Node::FunctionCall {
1180                name: "drop".to_string(),
1181                args: vec![harn_parser::spanned(Node::Identifier(name.clone()), span)],
1182                type_args: Vec::new(),
1183            },
1184            span,
1185        );
1186        self.finally_bodies.push(FinallyEntry::Finally(vec![call]));
1187    }
1188
1189    /// Compile a statement that appears in a value-discarding sequence —
1190    /// the script-mode module body, an inherited pipeline body, and block
1191    /// interiors — then pop its value when `produces_value` says it left
1192    /// one.
1193    ///
1194    /// In debug builds this also asserts the operand stack stayed balanced
1195    /// across the statement: a straight-line statement must net exactly one
1196    /// value when `produces_value` is true and zero otherwise. That turns a
1197    /// `produces_value` misclassification — like the attributed-decl gap
1198    /// fixed in #2610, where the loop popped against an empty stack — from a
1199    /// latent runtime "Stack underflow" (often masked further by the
1200    /// bytecode cache, #2621) into a loud compile-time failure in tests/CI.
1201    /// Statements containing branches or other non-linearly-modeled opcodes
1202    /// can't be summed by the lightweight model, so the assertion skips them
1203    /// (see [`Chunk::balance_delta_since`]).
1204    pub(super) fn compile_discarded_stmt(&mut self, sn: &SNode) -> Result<(), CompileError> {
1205        #[cfg(debug_assertions)]
1206        let probe = self.chunk.balance_probe();
1207        self.compile_node(sn)?;
1208        #[allow(unused_mut)]
1209        let mut produces = Self::produces_value(&sn.node);
1210        // Test-only hook: deliberately miswire the classification to prove
1211        // the balance assertion below trips on a `produces_value` gap (the
1212        // #2622 verification). No-op in non-test builds.
1213        #[cfg(test)]
1214        if let Some(forced) = FORCE_DISCARDED_PRODUCES_VALUE.with(std::cell::Cell::get) {
1215            produces = forced;
1216        }
1217        #[cfg(debug_assertions)]
1218        if let Some(delta) = self.chunk.balance_delta_since(probe) {
1219            let expected = i32::from(produces);
1220            debug_assert_eq!(
1221                delta, expected,
1222                "operand-stack imbalance at line {}: produces_value={produces} but the \
1223                 node's emitted bytecode netted {delta} (expected {expected}). A \
1224                 `produces_value` arm is out of sync with this node's codegen — see #2622.\n\
1225                 node: {:?}",
1226                self.line, sn.node,
1227            );
1228        }
1229        if produces {
1230            self.chunk.emit(Op::Pop, self.line);
1231        }
1232        Ok(())
1233    }
1234
1235    pub(super) fn compile_block(&mut self, stmts: &[SNode]) -> Result<(), CompileError> {
1236        self.record_monomorphic_var_bindings(stmts);
1237        for (i, snode) in stmts.iter().enumerate() {
1238            if i == stmts.len() - 1 {
1239                // The block's value is its last statement's. Backfill a `Nil`
1240                // when that statement produced none, so the block always
1241                // leaves exactly one value on the stack.
1242                self.compile_node(snode)?;
1243                if !Self::produces_value(&snode.node) {
1244                    self.chunk.emit(Op::Nil, self.line);
1245                }
1246            } else {
1247                self.compile_discarded_stmt(snode)?;
1248            }
1249        }
1250        Ok(())
1251    }
1252
1253    /// Compile a match arm body, ensuring it always pushes exactly one value.
1254    pub(super) fn compile_match_body(&mut self, body: &[SNode]) -> Result<(), CompileError> {
1255        self.begin_scope();
1256        let finally_floor = self.finally_bodies.len();
1257        if body.is_empty() {
1258            self.chunk.emit(Op::Nil, self.line);
1259        } else {
1260            self.compile_block(body)?;
1261            if !Self::produces_value(&body.last().unwrap().node) {
1262                self.chunk.emit(Op::Nil, self.line);
1263            }
1264        }
1265        self.drain_finallys_to_floor(finally_floor)?;
1266        self.end_scope();
1267        Ok(())
1268    }
1269
1270    /// Emit the binary op instruction for a compound assignment operator.
1271    pub(super) fn emit_compound_op(&mut self, op: &str) -> Result<(), CompileError> {
1272        match op {
1273            "+" => self.chunk.emit(Op::Add, self.line),
1274            "-" => self.chunk.emit(Op::Sub, self.line),
1275            "*" => self.chunk.emit(Op::Mul, self.line),
1276            "/" => self.chunk.emit(Op::Div, self.line),
1277            "%" => self.chunk.emit(Op::Mod, self.line),
1278            _ => {
1279                return Err(CompileError {
1280                    message: format!("Unknown compound operator: {op}"),
1281                    line: self.line,
1282                })
1283            }
1284        }
1285        Ok(())
1286    }
1287
1288    pub(super) fn compile_top_level_declarations(
1289        &mut self,
1290        program: &[SNode],
1291    ) -> Result<(), CompileError> {
1292        // Phase 1: execute module-level *statements* in source order —
1293        // bindings, assignments, expression statements, control flow. Running
1294        // bindings before phase 2 ensures function closures compiled there
1295        // capture these names in their env snapshot via `Op::Closure` —
1296        // fixing the "Undefined variable: FOO" surprise where a top-level
1297        // `let FOO = "..."` was silently dropped because it wasn't compiled
1298        // at all. Non-binding statements used to be silently dropped in
1299        // pipeline mode (`n = 2` or `log(...)` between a binding and a
1300        // pipeline simply never ran); they now execute exactly like script
1301        // mode. Keep in step with the import-time init path in
1302        // `crates/harn-vm/src/vm/imports.rs` (`module_state` construction).
1303        for sn in program {
1304            if !harn_parser::lexical::is_deferred_module_declaration(sn) {
1305                self.compile_discarded_stmt(sn)?;
1306            }
1307        }
1308        // Phase 2: compile function-like declarations. Function closures
1309        // created here capture the current env which now includes module-level
1310        // bindings from phase 1. Attributed declarations are compiled here too
1311        // — the AttributedDecl arm in compile_node dispatches to the inner
1312        // declaration's compile path.
1313        for sn in program {
1314            let inner_kind = match &sn.node {
1315                Node::AttributedDecl { inner, .. } => &inner.node,
1316                other => other,
1317            };
1318            match inner_kind {
1319                Node::EvalPackDecl {
1320                    binding_name,
1321                    pack_id,
1322                    fields,
1323                    body,
1324                    summarize,
1325                    ..
1326                } => {
1327                    self.compile_eval_pack_decl(
1328                        binding_name,
1329                        pack_id,
1330                        fields,
1331                        body,
1332                        summarize,
1333                        false,
1334                    )?;
1335                }
1336                Node::FnDecl { .. }
1337                | Node::ToolDecl { .. }
1338                | Node::SkillDecl { .. }
1339                | Node::ImplBlock { .. }
1340                | Node::StructDecl { .. }
1341                | Node::EnumDecl { .. }
1342                | Node::InterfaceDecl { .. } => {
1343                    self.compile_node(sn)?;
1344                }
1345                Node::TypeDecl { .. } => {}
1346                _ => {}
1347            }
1348        }
1349        Ok(())
1350    }
1351
1352    /// Compile a function body into a CompiledFunction (for import support).
1353    ///
1354    /// This path is used when a module is imported and its top-level `fn`
1355    /// declarations are loaded into the importer's environment. It MUST emit
1356    /// the same function preamble as the in-file `Node::FnDecl` path, or
1357    /// imported functions will behave differently from locally-defined ones —
1358    /// in particular, default parameter values would never be set and typed
1359    /// parameters would not be runtime-checked.
1360    ///
1361    /// `source_file`, when provided, tags the resulting chunk so runtime
1362    /// errors can attribute frames to the imported file rather than the
1363    /// entry-point pipeline.
1364    pub fn compile_fn_body(
1365        &mut self,
1366        type_params: &[harn_parser::TypeParam],
1367        params: &[TypedParam],
1368        body: &[SNode],
1369        source_file: Option<String>,
1370    ) -> Result<CompiledFunction, CompileError> {
1371        let mut fn_compiler = self.nested_body();
1372        fn_compiler.enum_names = self.enum_names.clone();
1373        fn_compiler.enum_variant_owners = self.enum_variant_owners.clone();
1374        fn_compiler.imported_enum_candidates = self.imported_enum_candidates.clone();
1375        fn_compiler.imported_enum_candidates_authoritative =
1376            self.imported_enum_candidates_authoritative;
1377        fn_compiler.interface_methods = self.interface_methods.clone();
1378        fn_compiler.type_aliases = self.type_aliases.clone();
1379        fn_compiler.struct_layouts = self.struct_layouts.clone();
1380        fn_compiler.declare_param_slots(params);
1381        fn_compiler.record_param_types(params);
1382        fn_compiler.emit_default_preamble(params)?;
1383        fn_compiler.emit_type_checks(params);
1384        let is_gen = body_contains_yield(body);
1385        fn_compiler.seed_captured_idents(body);
1386        fn_compiler.compile_block(body)?;
1387        fn_compiler.chunk.emit(Op::Nil, 0);
1388        fn_compiler.chunk.emit(Op::Return, 0);
1389        fn_compiler.chunk.source_file = source_file;
1390        let param_slots = fn_compiler.compile_param_slots(params);
1391        let has_runtime_type_checks =
1392            CompiledFunction::has_runtime_type_checks_for_params(&param_slots);
1393        super::ensure_chunk_addressable(&fn_compiler.chunk, "function body", self.line)?;
1394        Ok(CompiledFunction {
1395            name: String::new(),
1396            type_params: type_params.iter().map(|param| param.name.clone()).collect(),
1397            nominal_type_names: fn_compiler.nominal_type_names(),
1398            params: param_slots,
1399            default_start: TypedParam::default_start(params),
1400            chunk: Arc::new(fn_compiler.chunk),
1401            is_generator: is_gen,
1402            is_stream: false,
1403            has_rest_param: false,
1404            has_runtime_type_checks,
1405        })
1406    }
1407
1408    /// Check if a node produces a value on the stack that needs to be popped.
1409    pub(super) fn produces_value(node: &Node) -> bool {
1410        harn_parser::node_produces_value(node)
1411    }
1412}
1413
1414impl Default for Compiler {
1415    fn default() -> Self {
1416        Self::new()
1417    }
1418}