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