Skip to main content

harn_kernel/compiler/
state.rs

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