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            struct_layouts: std::collections::HashMap::new(),
40            interface_methods: std::collections::HashMap::new(),
41            loop_stack: Vec::new(),
42            handler_depth: 0,
43            finally_bodies: Vec::new(),
44            temp_counter: 0,
45            scope_depth: 0,
46            type_aliases: std::collections::HashMap::new(),
47            type_scopes: vec![std::collections::HashMap::new()],
48            monomorphic_bindings: std::collections::HashSet::new(),
49            string_constants: std::collections::HashMap::new(),
50            local_scopes: vec![std::collections::HashMap::new()],
51            module_level: true,
52            captured_idents: std::collections::HashSet::new(),
53        }
54    }
55
56    /// Compiler instance for a nested function-like body (fn, closure,
57    /// tool, parallel arm, etc.). Differs from `new()` only in that
58    /// `module_level` starts false — `try*` is allowed inside.
59    pub(super) fn for_nested_body(options: CompilerOptions) -> Self {
60        let mut c = Self::with_options(options);
61        c.module_level = false;
62        c
63    }
64
65    pub(super) fn nested_body(&self) -> Self {
66        Self::for_nested_body(self.options)
67    }
68
69    pub(super) fn nominal_type_names(&self) -> Vec<String> {
70        let mut names: Vec<String> = self
71            .struct_layouts
72            .keys()
73            .chain(self.enum_names.iter())
74            .cloned()
75            .collect();
76        names.sort();
77        names.dedup();
78        names
79    }
80
81    pub(super) fn string_constant(&mut self, value: &str) -> u16 {
82        if let Some(idx) = self.string_constants.get(value) {
83            return *idx;
84        }
85        let owned = value.to_string();
86        let idx = self.chunk.add_constant(Constant::String(owned.clone()));
87        self.string_constants.insert(owned, idx);
88        idx
89    }
90
91    pub(super) fn owned_string_constant(&mut self, value: String) -> u16 {
92        if let Some(idx) = self.string_constants.get(value.as_str()) {
93            return *idx;
94        }
95        let idx = self.chunk.add_constant(Constant::String(value.clone()));
96        self.string_constants.insert(value, idx);
97        idx
98    }
99
100    /// Populate `type_aliases` from a program's top-level `type T = ...`
101    /// declarations so later lowerings can resolve alias names to their
102    /// canonical `TypeExpr`.
103    pub(crate) fn collect_type_aliases(&mut self, program: &[SNode]) {
104        for sn in program {
105            if let Node::TypeDecl {
106                name,
107                type_expr,
108                type_params: _,
109                is_pub: _,
110            } = peel_node(sn)
111            {
112                self.type_aliases.insert(name.clone(), type_expr.clone());
113            }
114        }
115    }
116
117    /// Fully expand alias references, inlining every `Named(T)` whose `T` is a
118    /// known alias with the alias's body. A `visiting` set breaks recursive
119    /// aliases (`type Tree = {value: int, children: [Tree]}`): once an alias is
120    /// already being expanded on the current path, the self-reference is left
121    /// as an unexpanded `Named(T)` instead of recursing forever. This mirrors
122    /// the typechecker's `resolve_alias` cycle guard so both sides agree, and
123    /// keeps schema lowering (`type_expr_to_schema_value`) finite — a
124    /// cycle-broken `Named(T)` lowers to no runtime constraint at that nested
125    /// position rather than overflowing the stack.
126    pub(crate) fn expand_alias(&self, ty: &TypeExpr) -> TypeExpr {
127        let mut visiting = std::collections::HashSet::new();
128        self.expand_alias_inner(ty, &mut visiting)
129    }
130
131    fn expand_alias_inner(
132        &self,
133        ty: &TypeExpr,
134        visiting: &mut std::collections::HashSet<String>,
135    ) -> TypeExpr {
136        match ty {
137            TypeExpr::Named(name) => {
138                if let Some(target) = self.type_aliases.get(name) {
139                    if !visiting.insert(name.clone()) {
140                        return TypeExpr::Named(name.clone());
141                    }
142                    let resolved = self.expand_alias_inner(target, visiting);
143                    visiting.remove(name);
144                    resolved
145                } else {
146                    TypeExpr::Named(name.clone())
147                }
148            }
149            TypeExpr::Union(types) => TypeExpr::Union(
150                types
151                    .iter()
152                    .map(|t| self.expand_alias_inner(t, visiting))
153                    .collect(),
154            ),
155            TypeExpr::Intersection(types) => TypeExpr::Intersection(
156                types
157                    .iter()
158                    .map(|t| self.expand_alias_inner(t, visiting))
159                    .collect(),
160            ),
161            TypeExpr::Shape(fields) => TypeExpr::Shape(
162                fields
163                    .iter()
164                    .map(|field| ShapeField {
165                        name: field.name.clone(),
166                        type_expr: self.expand_alias_inner(&field.type_expr, visiting),
167                        optional: field.optional,
168                    })
169                    .collect(),
170            ),
171            TypeExpr::OpenShape { fields, rests } => TypeExpr::OpenShape {
172                fields: fields
173                    .iter()
174                    .map(|field| ShapeField {
175                        name: field.name.clone(),
176                        type_expr: self.expand_alias_inner(&field.type_expr, visiting),
177                        optional: field.optional,
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_enum_names(program, &mut self.enum_names);
293        self.enum_names.insert("Result".to_string());
294        Self::collect_enum_variant_owners(program, &mut self.enum_variant_owners);
295        Self::seed_builtin_variant_owners(&mut self.enum_variant_owners);
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_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                if let Some(parent_name) = extends {
330                    self.compile_parent_pipeline(program, parent_name)?;
331                }
332                let saved = std::mem::replace(&mut self.module_level, false);
333                self.compile_block(body)?;
334                self.module_level = saved;
335                pipeline_emits_value = true;
336            }
337        } else {
338            // Script mode: no pipeline found, treat top-level as implicit entry.
339            let top_level: Vec<&SNode> = program
340                .iter()
341                .filter(|sn| {
342                    !matches!(
343                        &sn.node,
344                        Node::ImportDecl { .. } | Node::SelectiveImport { .. }
345                    )
346                })
347                .collect();
348            for sn in &top_level {
349                self.compile_discarded_stmt(sn)?;
350            }
351            // E4.1 entrypoint convention: a top-level `fn main(harness: Harness)`
352            // is invoked automatically with the runtime-provided `harness`
353            // global. The typechecker rejects every other signature with
354            // HARN-NAM-101 so we don't need to re-validate the shape here.
355            if Self::has_top_level_fn_main(program) {
356                let harness_name = self.string_constant("harness");
357                self.chunk.emit_u16(Op::GetVar, harness_name, self.line);
358                self.emit_named_call("main", 1);
359                pipeline_emits_value = true;
360            }
361        }
362
363        self.drain_finallys_to_floor(0)?;
364        if !pipeline_emits_value {
365            self.chunk.emit(Op::Nil, self.line);
366        }
367        self.chunk.emit(Op::Return, self.line);
368        super::ensure_chunk_addressable(&self.chunk, "the program body", self.line)?;
369        Ok(self.chunk)
370    }
371
372    /// True when the program declares a top-level `fn main(...)`. Drives the
373    /// auto-call wired by `compile()` for the new `main(harness: Harness)`
374    /// entrypoint convention.
375    fn has_top_level_fn_main(program: &[SNode]) -> bool {
376        program
377            .iter()
378            .any(|sn| matches!(peel_node(sn), Node::FnDecl { name, .. } if name == "main"))
379    }
380
381    /// Compile a specific named pipeline (for test runners).
382    pub fn compile_named(
383        self,
384        program: &[SNode],
385        pipeline_name: &str,
386    ) -> Result<Chunk, CompileError> {
387        self.compile_named_inner(program, pipeline_name, false)
388    }
389
390    /// Compile a named pipeline and materialize its parameters from VM globals.
391    ///
392    /// This is for hosts that supply one binding set out-of-band, such as the
393    /// CLI test runner's `@test(cases: ...)` rows. Plain `compile_named` keeps
394    /// the historical behavior where unused pipeline parameters do not require
395    /// ambient globals.
396    pub fn compile_named_with_param_globals(
397        self,
398        program: &[SNode],
399        pipeline_name: &str,
400    ) -> Result<Chunk, CompileError> {
401        self.compile_named_inner(program, pipeline_name, true)
402    }
403
404    fn compile_named_inner(
405        mut self,
406        program: &[SNode],
407        pipeline_name: &str,
408        bind_params_from_globals: bool,
409    ) -> Result<Chunk, CompileError> {
410        Self::collect_enum_names(program, &mut self.enum_names);
411        self.enum_names.insert("Result".to_string());
412        Self::collect_enum_variant_owners(program, &mut self.enum_variant_owners);
413        Self::seed_builtin_variant_owners(&mut self.enum_variant_owners);
414        Self::collect_struct_layouts(program, &mut self.struct_layouts);
415        Self::collect_interface_methods(program, &mut self.interface_methods);
416        self.collect_type_aliases(program);
417        // Box module-level mutable `let`s that a top-level or pipeline-body
418        // closure captures (harn#4479). Nested `fn`/closure/`tool` bodies reseed
419        // their own capture set when compiled, so this only governs the
420        // module-level bindings emitted by `self`.
421        self.seed_captured_idents(program);
422
423        for sn in program {
424            if matches!(
425                &sn.node,
426                Node::ImportDecl { .. } | Node::SelectiveImport { .. }
427            ) {
428                self.compile_node(sn)?;
429            }
430        }
431        let target = program.iter().find(
432            |sn| matches!(peel_node(sn), Node::Pipeline { name, .. } if name == pipeline_name),
433        );
434
435        if let Some(sn) = target {
436            self.compile_top_level_declarations(program)?;
437            if let Node::Pipeline {
438                body,
439                extends,
440                params,
441                ..
442            } = peel_node(sn)
443            {
444                if let Some(parent_name) = extends {
445                    self.compile_parent_pipeline(program, parent_name)?;
446                }
447                let saved = std::mem::replace(&mut self.module_level, false);
448                if bind_params_from_globals {
449                    for param in params {
450                        self.define_local_slot(param, false);
451                        let idx = self.string_constant(param);
452                        self.chunk.emit_u16(Op::GetVar, idx, self.line);
453                        self.emit_init_or_define_binding(param, false);
454                    }
455                }
456                self.compile_block(body)?;
457                self.module_level = saved;
458            }
459        }
460
461        self.drain_finallys_to_floor(0)?;
462        self.chunk.emit(Op::Nil, self.line);
463        self.chunk.emit(Op::Return, self.line);
464        super::ensure_chunk_addressable(&self.chunk, "the pipeline body", self.line)?;
465        Ok(self.chunk)
466    }
467
468    /// Recursively compile parent pipeline bodies (for extends).
469    pub(super) fn compile_parent_pipeline(
470        &mut self,
471        program: &[SNode],
472        parent_name: &str,
473    ) -> Result<(), CompileError> {
474        let parent = program
475            .iter()
476            .find(|sn| matches!(&sn.node, Node::Pipeline { name, .. } if name == parent_name));
477        if let Some(sn) = parent {
478            if let Node::Pipeline { body, extends, .. } = &sn.node {
479                if let Some(grandparent) = extends {
480                    self.compile_parent_pipeline(program, grandparent)?;
481                }
482                for stmt in body {
483                    self.compile_discarded_stmt(stmt)?;
484                }
485            }
486        }
487        Ok(())
488    }
489
490    /// Emit bytecode preamble for default parameter values.
491    /// For each param with a default at index i, emits:
492    ///   GetArgc; PushInt (i+1); GreaterEqual; JumpIfTrue <skip>;
493    ///   [compile default expr]; DefLet param_name; <skip>:
494    pub(super) fn emit_default_preamble(
495        &mut self,
496        params: &[TypedParam],
497    ) -> Result<(), CompileError> {
498        for (i, param) in params.iter().enumerate() {
499            if let Some(default_expr) = &param.default_value {
500                self.chunk.emit(Op::GetArgc, self.line);
501                let threshold_idx = self.chunk.add_constant(Constant::Int((i + 1) as i64));
502                self.chunk.emit_u16(Op::Constant, threshold_idx, self.line);
503                self.chunk.emit(Op::GreaterEqual, self.line);
504                let skip_jump = self.chunk.emit_jump(Op::JumpIfTrue, self.line);
505                // JumpIfTrue doesn't pop its boolean operand.
506                self.chunk.emit(Op::Pop, self.line);
507                // Compile the default with this param and all *later* params
508                // hidden from local resolution. A default is evaluated left to
509                // right at call time: it may reference an earlier parameter,
510                // but a mention of its own name (or a later, not-yet-bound
511                // parameter) must resolve to the enclosing scope — e.g.
512                // `let n = 7; fn f(n = n * 2)` reads the outer `n`. Without the
513                // mask, `n` bound to the param's own unset slot and threw at
514                // runtime. Earlier params stay visible.
515                let masked = self.mask_param_names(&params[i..]);
516                let result = self.compile_node(default_expr);
517                self.restore_param_names(masked);
518                result?;
519                self.emit_init_or_define_binding(&param.name, false);
520                let end_jump = self.chunk.emit_jump(Op::Jump, self.line);
521                self.chunk.patch_jump(skip_jump);
522                self.chunk.emit(Op::Pop, self.line);
523                self.chunk.patch_jump(end_jump);
524            }
525        }
526        Ok(())
527    }
528
529    /// Emit body-local type checks that call-site validation cannot cover.
530    /// Ordinary supplied arguments are validated by precomputed
531    /// [`crate::chunk::ParamSlot`] guards before the frame is entered. The
532    /// bytecode preamble still checks interface parameters, because interface
533    /// satisfaction depends on compiler-collected method metadata, and checks
534    /// defaulted schema parameters only when the caller omitted that argument.
535    pub(super) fn emit_type_checks(&mut self, params: &[TypedParam]) {
536        for (param_index, param) in params.iter().enumerate() {
537            if let Some(type_expr) = &param.type_expr {
538                let check_type = if param.rest {
539                    harn_parser::TypeExpr::List(Box::new(type_expr.clone()))
540                } else {
541                    type_expr.clone()
542                };
543
544                if let harn_parser::TypeExpr::Named(name) = &check_type {
545                    if let Some(methods) = self.interface_methods.get(name).cloned() {
546                        let fn_idx = self.string_constant("__assert_interface");
547                        self.chunk.emit_u16(Op::Constant, fn_idx, self.line);
548                        self.emit_get_binding(&param.name);
549                        let name_idx = self.string_constant(&param.name);
550                        self.chunk.emit_u16(Op::Constant, name_idx, self.line);
551                        let iface_idx = self.string_constant(name);
552                        self.chunk.emit_u16(Op::Constant, iface_idx, self.line);
553                        let methods_str = methods.join(",");
554                        let methods_idx = self.owned_string_constant(methods_str);
555                        self.chunk.emit_u16(Op::Constant, methods_idx, self.line);
556                        self.chunk.emit_u8(Op::Call, 4, self.line);
557                        self.chunk.emit(Op::Pop, self.line);
558                        continue;
559                    }
560                }
561
562                if param.default_value.is_some() {
563                    if let Some(schema) = Self::type_expr_to_schema_value(&check_type) {
564                        self.emit_default_param_schema_check(param_index, param, &schema);
565                    }
566                }
567            }
568        }
569    }
570
571    fn emit_default_param_schema_check(
572        &mut self,
573        param_index: usize,
574        param: &TypedParam,
575        schema: &VmValue,
576    ) {
577        self.chunk.emit(Op::GetArgc, self.line);
578        let threshold_idx = self
579            .chunk
580            .add_constant(Constant::Int((param_index + 1) as i64));
581        self.chunk.emit_u16(Op::Constant, threshold_idx, self.line);
582        self.chunk.emit(Op::GreaterEqual, self.line);
583        let supplied_jump = self.chunk.emit_jump(Op::JumpIfTrue, self.line);
584        self.chunk.emit(Op::Pop, self.line);
585        self.emit_schema_assert_call(param, schema);
586        let end_jump = self.chunk.emit_jump(Op::Jump, self.line);
587        self.chunk.patch_jump(supplied_jump);
588        self.chunk.emit(Op::Pop, self.line);
589        self.chunk.patch_jump(end_jump);
590    }
591
592    fn emit_schema_assert_call(&mut self, param: &TypedParam, schema: &VmValue) {
593        let fn_idx = self.string_constant("__assert_schema");
594        self.chunk.emit_u16(Op::Constant, fn_idx, self.line);
595        self.emit_get_binding(&param.name);
596        let name_idx = self.string_constant(&param.name);
597        self.chunk.emit_u16(Op::Constant, name_idx, self.line);
598        self.emit_vm_value_literal(schema);
599        self.chunk.emit_u8(Op::Call, 3, self.line);
600        self.chunk.emit(Op::Pop, self.line);
601    }
602
603    pub(crate) fn type_expr_to_schema_value(type_expr: &harn_parser::TypeExpr) -> Option<VmValue> {
604        match type_expr {
605            harn_parser::TypeExpr::Named(name) => match name.as_str() {
606                "int" | "float" | "string" | "bool" | "list" | "dict" | "set" | "nil"
607                | "closure" | "bytes" => Some(VmValue::dict(BTreeMap::from([(
608                    "type".to_string(),
609                    VmValue::String(arcstr::ArcStr::from(name.as_str())),
610                )]))),
611                _ => None,
612            },
613            harn_parser::TypeExpr::Shape(fields)
614            | harn_parser::TypeExpr::OpenShape { fields, .. } => {
615                let mut properties = BTreeMap::new();
616                let mut required = Vec::new();
617                for field in fields {
618                    let field_schema = Self::type_expr_to_schema_value(&field.type_expr)?;
619                    properties.insert(field.name.clone(), field_schema);
620                    if !field.optional {
621                        required.push(VmValue::String(arcstr::ArcStr::from(field.name.as_str())));
622                    }
623                }
624                let mut out = BTreeMap::new();
625                out.put_str("type", "dict");
626                out.insert("properties".to_string(), VmValue::dict(properties));
627                if !required.is_empty() {
628                    out.insert(
629                        "required".to_string(),
630                        VmValue::List(std::sync::Arc::new(required)),
631                    );
632                }
633                Some(VmValue::dict(out))
634            }
635            harn_parser::TypeExpr::List(inner) => {
636                let mut out = BTreeMap::new();
637                out.put_str("type", "list");
638                if let Some(item_schema) = Self::type_expr_to_schema_value(inner) {
639                    out.insert("items".to_string(), item_schema);
640                }
641                Some(VmValue::dict(out))
642            }
643            harn_parser::TypeExpr::DictType(key, value) => {
644                let mut out = BTreeMap::new();
645                out.put_str("type", "dict");
646                if matches!(key.as_ref(), harn_parser::TypeExpr::Named(name) if name == "string") {
647                    if let Some(value_schema) = Self::type_expr_to_schema_value(value) {
648                        out.insert("additional_properties".to_string(), value_schema);
649                    }
650                }
651                Some(VmValue::dict(out))
652            }
653            harn_parser::TypeExpr::Union(members) => {
654                // Special-case unions of literals: emit as `enum: [...]`
655                // so the schema round-trips as canonical JSON Schema and
656                // is ACP-/OpenAPI-compatible. Mixed unions fall back to
657                // the `union:` key that validators recognize.
658                if !members.is_empty()
659                    && members
660                        .iter()
661                        .all(|m| matches!(m, harn_parser::TypeExpr::LitString(_)))
662                {
663                    let values = members
664                        .iter()
665                        .map(|m| match m {
666                            harn_parser::TypeExpr::LitString(s) => {
667                                VmValue::String(arcstr::ArcStr::from(s.as_str()))
668                            }
669                            _ => unreachable!(),
670                        })
671                        .collect::<Vec<_>>();
672                    return Some(VmValue::dict(BTreeMap::from([
673                        (
674                            "type".to_string(),
675                            VmValue::String(arcstr::ArcStr::from("string")),
676                        ),
677                        (
678                            "enum".to_string(),
679                            VmValue::List(std::sync::Arc::new(values)),
680                        ),
681                    ])));
682                }
683                if !members.is_empty()
684                    && members
685                        .iter()
686                        .all(|m| matches!(m, harn_parser::TypeExpr::LitInt(_)))
687                {
688                    let values = members
689                        .iter()
690                        .map(|m| match m {
691                            harn_parser::TypeExpr::LitInt(v) => VmValue::Int(*v),
692                            _ => unreachable!(),
693                        })
694                        .collect::<Vec<_>>();
695                    return Some(VmValue::dict(BTreeMap::from([
696                        (
697                            "type".to_string(),
698                            VmValue::String(arcstr::ArcStr::from("int")),
699                        ),
700                        (
701                            "enum".to_string(),
702                            VmValue::List(std::sync::Arc::new(values)),
703                        ),
704                    ])));
705                }
706                let branches = members
707                    .iter()
708                    .map(Self::type_expr_to_schema_value)
709                    .collect::<Option<Vec<_>>>()?;
710                if branches.is_empty() {
711                    None
712                } else {
713                    Some(VmValue::dict(BTreeMap::from([(
714                        "union".to_string(),
715                        VmValue::List(std::sync::Arc::new(branches)),
716                    )])))
717                }
718            }
719            harn_parser::TypeExpr::Intersection(members) => {
720                // Encode `A & B` as JSON-Schema `allOf` (the runtime
721                // accepts the snake_case `all_of` key directly). The
722                // value must validate against every branch.
723                let branches = members
724                    .iter()
725                    .map(Self::type_expr_to_schema_value)
726                    .collect::<Option<Vec<_>>>()?;
727                if branches.is_empty() {
728                    None
729                } else {
730                    Some(VmValue::dict(BTreeMap::from([(
731                        "all_of".to_string(),
732                        VmValue::List(std::sync::Arc::new(branches)),
733                    )])))
734                }
735            }
736            harn_parser::TypeExpr::FnType { .. } => Some(VmValue::dict(BTreeMap::from([(
737                "type".to_string(),
738                VmValue::String(arcstr::ArcStr::from("closure")),
739            )]))),
740            harn_parser::TypeExpr::Applied { .. } => None,
741            harn_parser::TypeExpr::Iter(_)
742            | harn_parser::TypeExpr::Generator(_)
743            | harn_parser::TypeExpr::Stream(_) => None,
744            harn_parser::TypeExpr::Never => None,
745            harn_parser::TypeExpr::LitString(s) => Some(VmValue::dict(BTreeMap::from([
746                (
747                    "type".to_string(),
748                    VmValue::String(arcstr::ArcStr::from("string")),
749                ),
750                (
751                    "const".to_string(),
752                    VmValue::String(arcstr::ArcStr::from(s.as_str())),
753                ),
754            ]))),
755            harn_parser::TypeExpr::LitInt(v) => Some(VmValue::dict(BTreeMap::from([
756                (
757                    "type".to_string(),
758                    VmValue::String(arcstr::ArcStr::from("int")),
759                ),
760                ("const".to_string(), VmValue::Int(*v)),
761            ]))),
762            harn_parser::TypeExpr::Owned(inner) => Self::type_expr_to_schema_value(inner),
763        }
764    }
765
766    pub(super) fn emit_vm_value_literal(&mut self, value: &VmValue) {
767        match value {
768            VmValue::String(text) => {
769                let idx = self.string_constant(text);
770                self.chunk.emit_u16(Op::Constant, idx, self.line);
771            }
772            VmValue::Int(number) => {
773                let idx = self.chunk.add_constant(Constant::Int(*number));
774                self.chunk.emit_u16(Op::Constant, idx, self.line);
775            }
776            VmValue::Float(number) => {
777                let idx = self.chunk.add_constant(Constant::Float(*number));
778                self.chunk.emit_u16(Op::Constant, idx, self.line);
779            }
780            VmValue::Bool(value) => {
781                let idx = self.chunk.add_constant(Constant::Bool(*value));
782                self.chunk.emit_u16(Op::Constant, idx, self.line);
783            }
784            VmValue::Nil => self.chunk.emit(Op::Nil, self.line),
785            VmValue::List(items) => {
786                for item in items.iter() {
787                    self.emit_vm_value_literal(item);
788                }
789                self.chunk
790                    .emit_u16(Op::BuildList, items.len() as u16, self.line);
791            }
792            VmValue::Dict(entries) => {
793                for (key, item) in entries.iter() {
794                    let key_idx = self.string_constant(key);
795                    self.chunk.emit_u16(Op::Constant, key_idx, self.line);
796                    self.emit_vm_value_literal(item);
797                }
798                self.chunk
799                    .emit_u16(Op::BuildDict, entries.len() as u16, self.line);
800            }
801            _ => {}
802        }
803    }
804
805    /// Emit the extra u16 type name index after a TryCatchSetup jump.
806    pub(super) fn emit_type_name_extra(&mut self, type_name_idx: u16) {
807        let hi = (type_name_idx >> 8) as u8;
808        let lo = type_name_idx as u8;
809        self.chunk.code.push(hi);
810        self.chunk.code.push(lo);
811        self.chunk.lines.push(self.line);
812        self.chunk.columns.push(self.column);
813        self.chunk.lines.push(self.line);
814        self.chunk.columns.push(self.column);
815    }
816
817    /// Compile a try/catch body block (produces a value on the stack).
818    pub(super) fn compile_try_body(&mut self, body: &[SNode]) -> Result<(), CompileError> {
819        if body.is_empty() {
820            self.chunk.emit(Op::Nil, self.line);
821        } else {
822            self.compile_scoped_block(body)?;
823        }
824        Ok(())
825    }
826
827    /// Compile catch error binding (error value is on stack from handler).
828    pub(super) fn compile_catch_binding(
829        &mut self,
830        error_var: &Option<String>,
831    ) -> Result<(), CompileError> {
832        if let Some(var_name) = error_var {
833            self.emit_define_binding(var_name, false);
834        } else {
835            self.chunk.emit(Op::Pop, self.line);
836        }
837        Ok(())
838    }
839
840    /// Compile finally body inline, discarding its result value.
841    /// `compile_scoped_block` always leaves exactly one value on the stack
842    /// (Nil for non-value tail statements), so the trailing Pop is
843    /// unconditional — otherwise a finally ending in e.g. `x = x + 1`
844    /// would leave a stray Nil that corrupts the surrounding expression
845    /// when the enclosing try/finally is used in expression position.
846    pub(super) fn compile_finally_inline(
847        &mut self,
848        finally_body: &[SNode],
849    ) -> Result<(), CompileError> {
850        if !finally_body.is_empty() {
851            self.compile_scoped_block(finally_body)?;
852            self.chunk.emit(Op::Pop, self.line);
853        }
854        Ok(())
855    }
856
857    /// Collect pending finally bodies from the top of the stack down to
858    /// (but not including) the innermost `CatchBarrier`. Used by `throw`
859    /// lowering: throws caught locally don't unwind past the catch, so
860    /// finallys behind the barrier aren't on the throw's exit path.
861    pub(super) fn pending_finallys_until_barrier(&self) -> Vec<Vec<SNode>> {
862        let mut out = Vec::new();
863        for entry in self.finally_bodies.iter().rev() {
864            match entry {
865                FinallyEntry::CatchBarrier => break,
866                FinallyEntry::Finally(body) => out.push(body.clone()),
867            }
868        }
869        out
870    }
871
872    /// True if there are any pending finally bodies (not just barriers).
873    pub(super) fn has_pending_finally(&self) -> bool {
874        self.finally_bodies
875            .iter()
876            .any(|e| matches!(e, FinallyEntry::Finally(_)))
877    }
878
879    /// Save a thrown value to a temp and rethrow without running finally.
880    ///
881    /// Historically this helper also invoked `compile_finally_inline` on the
882    /// thrown path, but that produced observable double-runs: the
883    /// `Node::ThrowStmt` lowering (below) already iterates `finally_bodies`
884    /// and runs each pending finally inline *before* emitting `Op::Throw`, so
885    /// a second run here fired the same side effects twice. Finally now runs
886    /// exactly once — via the throw-emit path during unwinding.
887    pub(super) fn compile_plain_rethrow(&mut self) -> Result<(), CompileError> {
888        self.temp_counter += 1;
889        let temp_name = format!("__finally_err_{}__", self.temp_counter);
890        self.emit_define_binding(&temp_name, true);
891        self.emit_get_binding(&temp_name);
892        self.chunk.emit(Op::Throw, self.line);
893        Ok(())
894    }
895
896    pub(super) fn declare_param_slots(&mut self, params: &[TypedParam]) {
897        for param in params {
898            self.define_local_slot(&param.name, false);
899        }
900    }
901
902    /// Temporarily remove the given parameters' names from the innermost local
903    /// scope so that, while compiling a default-value expression, references to
904    /// them resolve to the enclosing scope instead of their not-yet-bound param
905    /// slots. Returns the removed bindings so [`Self::restore_param_names`] can
906    /// reinstate them afterward. See [`Self::emit_default_preamble`].
907    fn mask_param_names(&mut self, params: &[TypedParam]) -> Vec<(String, super::LocalBinding)> {
908        let mut removed = Vec::new();
909        if let Some(scope) = self.local_scopes.last_mut() {
910            for param in params {
911                if let Some(binding) = scope.remove(&param.name) {
912                    removed.push((param.name.clone(), binding));
913                }
914            }
915        }
916        removed
917    }
918
919    /// Reinstate parameter names removed by [`Self::mask_param_names`].
920    fn restore_param_names(&mut self, removed: Vec<(String, super::LocalBinding)>) {
921        if let Some(scope) = self.local_scopes.last_mut() {
922            for (name, binding) in removed {
923                scope.insert(name, binding);
924            }
925        }
926    }
927
928    /// Seed [`Compiler::captured_idents`] for the function-like body about to be
929    /// compiled: every identifier that appears anywhere inside a nested closure
930    /// literal in `body`. A mutable local whose name lands here is captured by a
931    /// closure and must be boxed into a shared cell (see [`Self::is_boxed_capture`]).
932    /// Called once per body — `fn`/closure/`tool` bodies, pipeline bodies, and
933    /// the module top level — each with its own set.
934    pub(super) fn seed_captured_idents(&mut self, body: &[SNode]) {
935        let mut set = std::collections::HashSet::new();
936        for sn in body {
937            collect_closure_capture_idents(sn, &mut set);
938        }
939        self.captured_idents = set;
940    }
941
942    /// Whether a binding named `name` declared `mutable` here must be boxed into
943    /// a shared cell because a nested closure captures it. Only mutable (`let`)
944    /// bindings qualify: `const` locals and params are immutable in Harn (they
945    /// can be neither rebound nor mutated in place), so a by-value snapshot of
946    /// them is already indistinguishable from a shared reference.
947    #[inline]
948    fn is_boxed_capture(&self, name: &str, mutable: bool) -> bool {
949        mutable && self.captured_idents.contains(name)
950    }
951
952    fn define_local_slot(&mut self, name: &str, mutable: bool) -> Option<u16> {
953        if self.module_level
954            || harn_parser::is_discard_name(name)
955            || self.is_boxed_capture(name, mutable)
956        {
957            // A boxed capture lives in the env behind a shared cell, never in a
958            // by-value local slot, so its reads/writes route through the
959            // cell-aware env path (`GetVar`/`SetVar`) shared with the closure.
960            return None;
961        }
962        let current = self.local_scopes.last_mut()?;
963        if let Some(existing) = current.get_mut(name) {
964            if existing.mutable || mutable {
965                if mutable {
966                    existing.mutable = true;
967                    if let Some(info) = self.chunk.local_slots.get_mut(existing.slot as usize) {
968                        info.mutable = true;
969                    }
970                }
971                return Some(existing.slot);
972            }
973            return None;
974        }
975        let slot = self
976            .chunk
977            .add_local_slot(name.to_string(), mutable, self.scope_depth);
978        current.insert(name.to_string(), super::LocalBinding { slot, mutable });
979        Some(slot)
980    }
981
982    pub(super) fn resolve_local_slot(&self, name: &str) -> Option<super::LocalBinding> {
983        if self.module_level {
984            return None;
985        }
986        self.local_scopes
987            .iter()
988            .rev()
989            .find_map(|scope| scope.get(name).copied())
990    }
991
992    pub(super) fn emit_get_binding(&mut self, name: &str) {
993        if let Some(binding) = self.resolve_local_slot(name) {
994            self.chunk
995                .emit_u16(Op::GetLocalSlot, binding.slot, self.line);
996        } else {
997            let idx = self.string_constant(name);
998            self.chunk.emit_u16(Op::GetVar, idx, self.line);
999        }
1000    }
1001
1002    pub(super) fn emit_define_binding(&mut self, name: &str, mutable: bool) {
1003        if self.is_boxed_capture(name, mutable) {
1004            // Box a closure-captured mutable local into a shared cell. Runs
1005            // regardless of `module_level`: a captured top-level `let` needs the
1006            // same shared cell so a top-level closure observes its writes.
1007            let idx = self.string_constant(name);
1008            self.chunk.emit_u16(Op::DefCell, idx, self.line);
1009        } else if let Some(slot) = self.define_local_slot(name, mutable) {
1010            self.chunk.emit_u16(Op::DefLocalSlot, slot, self.line);
1011        } else {
1012            let idx = self.string_constant(name);
1013            let op = if mutable { Op::DefVar } else { Op::DefLet };
1014            self.chunk.emit_u16(op, idx, self.line);
1015        }
1016    }
1017
1018    pub(super) fn emit_init_or_define_binding(&mut self, name: &str, mutable: bool) {
1019        if let Some(binding) = self.resolve_local_slot(name) {
1020            self.chunk
1021                .emit_u16(Op::DefLocalSlot, binding.slot, self.line);
1022        } else {
1023            self.emit_define_binding(name, mutable);
1024        }
1025    }
1026
1027    pub(super) fn emit_set_binding(&mut self, name: &str) {
1028        if let Some(binding) = self.resolve_local_slot(name) {
1029            let _ = binding.mutable;
1030            self.chunk
1031                .emit_u16(Op::SetLocalSlot, binding.slot, self.line);
1032        } else {
1033            let idx = self.string_constant(name);
1034            self.chunk.emit_u16(Op::SetVar, idx, self.line);
1035        }
1036    }
1037
1038    pub(super) fn begin_scope(&mut self) {
1039        self.chunk.emit(Op::PushScope, self.line);
1040        self.scope_depth += 1;
1041        self.type_scopes.push(std::collections::HashMap::new());
1042        self.local_scopes.push(std::collections::HashMap::new());
1043    }
1044
1045    pub(super) fn end_scope(&mut self) {
1046        if self.scope_depth > 0 {
1047            self.chunk.emit(Op::PopScope, self.line);
1048            self.scope_depth -= 1;
1049            self.type_scopes.pop();
1050            self.local_scopes.pop();
1051        }
1052    }
1053
1054    /// Emit cleanup for an abrupt control-flow path without changing the
1055    /// compiler's lexical scope stacks for the source path that follows it.
1056    pub(super) fn emit_scope_unwind_to(&mut self, target_depth: usize) {
1057        for _ in target_depth..self.scope_depth {
1058            self.chunk.emit(Op::PopScope, self.line);
1059        }
1060    }
1061
1062    pub(super) fn compile_scoped_block(&mut self, stmts: &[SNode]) -> Result<(), CompileError> {
1063        self.begin_scope();
1064        let finally_floor = self.finally_bodies.len();
1065        if stmts.is_empty() {
1066            self.chunk.emit(Op::Nil, self.line);
1067        } else {
1068            self.compile_block(stmts)?;
1069        }
1070        self.drain_finallys_to_floor(finally_floor)?;
1071        self.end_scope();
1072        Ok(())
1073    }
1074
1075    pub(super) fn compile_scoped_statements(
1076        &mut self,
1077        stmts: &[SNode],
1078    ) -> Result<(), CompileError> {
1079        self.begin_scope();
1080        self.record_monomorphic_var_bindings(stmts);
1081        let finally_floor = self.finally_bodies.len();
1082        for sn in stmts {
1083            self.compile_discarded_stmt(sn)?;
1084        }
1085        self.drain_finallys_to_floor(finally_floor)?;
1086        self.end_scope();
1087        Ok(())
1088    }
1089
1090    /// Drain pending `defer` bodies down to a saved floor and run each inline
1091    /// in LIFO order. Each defer body is popped *before* its code is emitted so
1092    /// any `return` / `break` lowering inside the body sees the remaining
1093    /// pending defers (not itself).
1094    pub(super) fn drain_finallys_to_floor(&mut self, floor: usize) -> Result<(), CompileError> {
1095        while self.finally_bodies.len() > floor {
1096            let entry = self.finally_bodies.pop().expect("non-empty by guard");
1097            if let FinallyEntry::Finally(body) = entry {
1098                self.compile_finally_inline(&body)?;
1099            }
1100        }
1101        Ok(())
1102    }
1103
1104    /// Run the pending finally/defer bodies a non-local transfer (`return`,
1105    /// `break`, `continue`) crosses on its way down to `floor`, innermost
1106    /// first, then restore the pending stack.
1107    ///
1108    /// Like [`Self::drain_finallys_to_floor`] each body is removed from the
1109    /// stack *before* it is inlined, so a `return`/`break`/`continue` inside a
1110    /// finally body runs only the finallys *outside* it instead of re-running
1111    /// the one it is in — which otherwise recursed forever at compile time and
1112    /// aborted the process with a stack overflow. Unlike that helper (used at
1113    /// scope exit), the stack is restored afterward because a transfer is a
1114    /// branch: the code the compiler emits after it still needs the pending
1115    /// finallys for the fall-through and sibling paths.
1116    pub(super) fn run_pending_finallys_for_transfer(
1117        &mut self,
1118        floor: usize,
1119    ) -> Result<(), CompileError> {
1120        if self.finally_bodies.len() <= floor {
1121            return Ok(());
1122        }
1123        let saved = self.finally_bodies[floor..].to_vec();
1124        let result = self.drain_finallys_to_floor(floor);
1125        self.finally_bodies.extend(saved);
1126        result
1127    }
1128
1129    /// Like [`Self::run_pending_finallys_for_transfer`] but for a `throw`: run
1130    /// only the finallys between here and the innermost `CatchBarrier` (the
1131    /// ones the unwind actually crosses before a local `catch` halts it),
1132    /// masking each while it is inlined and restoring the stack afterward.
1133    pub(super) fn run_pending_finallys_until_barrier(&mut self) -> Result<(), CompileError> {
1134        let floor = self
1135            .finally_bodies
1136            .iter()
1137            .rposition(|e| matches!(e, FinallyEntry::CatchBarrier))
1138            .map(|i| i + 1)
1139            .unwrap_or(0);
1140        self.run_pending_finallys_for_transfer(floor)
1141    }
1142
1143    /// Register an auto-drop defer for an `owned<T>` binding. The drop runs
1144    /// at scope exit alongside any user-written `defer { ... }` blocks (LIFO
1145    /// order) and on `return` / `break` / `continue` / `throw` via the
1146    /// existing finally-unwinding machinery.
1147    pub(super) fn maybe_register_owned_drop(
1148        &mut self,
1149        pattern: &harn_parser::BindingPattern,
1150        type_ann: Option<&TypeExpr>,
1151        span: harn_lexer::Span,
1152    ) {
1153        // Auto-drop only fires when the user explicitly opted in via
1154        // `owned<T>` on a single-identifier binding. Destructured patterns
1155        // (`{a, b}`, `[a, b]`, pairs) aren't auto-dropped: ownership of a
1156        // composite isn't well-defined, and users can wrap individual fields
1157        // with `owned<T>` and bind them separately if needed.
1158        let Some(ty) = type_ann else {
1159            return;
1160        };
1161        if !matches!(ty, TypeExpr::Owned(_)) {
1162            return;
1163        }
1164        let harn_parser::BindingPattern::Identifier(name) = pattern else {
1165            return;
1166        };
1167        if harn_parser::is_discard_name(name) {
1168            return;
1169        }
1170        let call = harn_parser::spanned(
1171            Node::FunctionCall {
1172                name: "drop".to_string(),
1173                args: vec![harn_parser::spanned(Node::Identifier(name.clone()), span)],
1174                type_args: Vec::new(),
1175            },
1176            span,
1177        );
1178        self.finally_bodies.push(FinallyEntry::Finally(vec![call]));
1179    }
1180
1181    /// Compile a statement that appears in a value-discarding sequence —
1182    /// the script-mode module body, an inherited pipeline body, and block
1183    /// interiors — then pop its value when `produces_value` says it left
1184    /// one.
1185    ///
1186    /// In debug builds this also asserts the operand stack stayed balanced
1187    /// across the statement: a straight-line statement must net exactly one
1188    /// value when `produces_value` is true and zero otherwise. That turns a
1189    /// `produces_value` misclassification — like the attributed-decl gap
1190    /// fixed in #2610, where the loop popped against an empty stack — from a
1191    /// latent runtime "Stack underflow" (often masked further by the
1192    /// bytecode cache, #2621) into a loud compile-time failure in tests/CI.
1193    /// Statements containing branches or other non-linearly-modeled opcodes
1194    /// can't be summed by the lightweight model, so the assertion skips them
1195    /// (see [`Chunk::balance_delta_since`]).
1196    pub(super) fn compile_discarded_stmt(&mut self, sn: &SNode) -> Result<(), CompileError> {
1197        #[cfg(debug_assertions)]
1198        let probe = self.chunk.balance_probe();
1199        self.compile_node(sn)?;
1200        #[allow(unused_mut)]
1201        let mut produces = Self::produces_value(&sn.node);
1202        // Test-only hook: deliberately miswire the classification to prove
1203        // the balance assertion below trips on a `produces_value` gap (the
1204        // #2622 verification). No-op in non-test builds.
1205        #[cfg(test)]
1206        if let Some(forced) = FORCE_DISCARDED_PRODUCES_VALUE.with(std::cell::Cell::get) {
1207            produces = forced;
1208        }
1209        #[cfg(debug_assertions)]
1210        if let Some(delta) = self.chunk.balance_delta_since(probe) {
1211            let expected = i32::from(produces);
1212            debug_assert_eq!(
1213                delta, expected,
1214                "operand-stack imbalance at line {}: produces_value={produces} but the \
1215                 node's emitted bytecode netted {delta} (expected {expected}). A \
1216                 `produces_value` arm is out of sync with this node's codegen — see #2622.\n\
1217                 node: {:?}",
1218                self.line, sn.node,
1219            );
1220        }
1221        if produces {
1222            self.chunk.emit(Op::Pop, self.line);
1223        }
1224        Ok(())
1225    }
1226
1227    pub(super) fn compile_block(&mut self, stmts: &[SNode]) -> Result<(), CompileError> {
1228        self.record_monomorphic_var_bindings(stmts);
1229        for (i, snode) in stmts.iter().enumerate() {
1230            if i == stmts.len() - 1 {
1231                // The block's value is its last statement's. Backfill a `Nil`
1232                // when that statement produced none, so the block always
1233                // leaves exactly one value on the stack.
1234                self.compile_node(snode)?;
1235                if !Self::produces_value(&snode.node) {
1236                    self.chunk.emit(Op::Nil, self.line);
1237                }
1238            } else {
1239                self.compile_discarded_stmt(snode)?;
1240            }
1241        }
1242        Ok(())
1243    }
1244
1245    /// Compile a match arm body, ensuring it always pushes exactly one value.
1246    pub(super) fn compile_match_body(&mut self, body: &[SNode]) -> Result<(), CompileError> {
1247        self.begin_scope();
1248        let finally_floor = self.finally_bodies.len();
1249        if body.is_empty() {
1250            self.chunk.emit(Op::Nil, self.line);
1251        } else {
1252            self.compile_block(body)?;
1253            if !Self::produces_value(&body.last().unwrap().node) {
1254                self.chunk.emit(Op::Nil, self.line);
1255            }
1256        }
1257        self.drain_finallys_to_floor(finally_floor)?;
1258        self.end_scope();
1259        Ok(())
1260    }
1261
1262    /// Emit the binary op instruction for a compound assignment operator.
1263    pub(super) fn emit_compound_op(&mut self, op: &str) -> Result<(), CompileError> {
1264        match op {
1265            "+" => self.chunk.emit(Op::Add, self.line),
1266            "-" => self.chunk.emit(Op::Sub, self.line),
1267            "*" => self.chunk.emit(Op::Mul, self.line),
1268            "/" => self.chunk.emit(Op::Div, self.line),
1269            "%" => self.chunk.emit(Op::Mod, self.line),
1270            _ => {
1271                return Err(CompileError {
1272                    message: format!("Unknown compound operator: {op}"),
1273                    line: self.line,
1274                })
1275            }
1276        }
1277        Ok(())
1278    }
1279
1280    pub(super) fn compile_top_level_declarations(
1281        &mut self,
1282        program: &[SNode],
1283    ) -> Result<(), CompileError> {
1284        // Phase 1: execute module-level *statements* in source order —
1285        // bindings, assignments, expression statements, control flow. Running
1286        // bindings before phase 2 ensures function closures compiled there
1287        // capture these names in their env snapshot via `Op::Closure` —
1288        // fixing the "Undefined variable: FOO" surprise where a top-level
1289        // `let FOO = "..."` was silently dropped because it wasn't compiled
1290        // at all. Non-binding statements used to be silently dropped in
1291        // pipeline mode (`n = 2` or `log(...)` between a binding and a
1292        // pipeline simply never ran); they now execute exactly like script
1293        // mode. Keep in step with the import-time init path in
1294        // `crates/harn-vm/src/vm/imports.rs` (`module_state` construction).
1295        for sn in program {
1296            let handled_elsewhere = matches!(
1297                peel_node(sn),
1298                Node::Pipeline { .. }
1299                    | Node::ImportDecl { .. }
1300                    | Node::SelectiveImport { .. }
1301                    | Node::OverrideDecl { .. }
1302                    | Node::EvalPackDecl { .. }
1303                    | Node::FnDecl { .. }
1304                    | Node::ToolDecl { .. }
1305                    | Node::SkillDecl { .. }
1306                    | Node::ImplBlock { .. }
1307                    | Node::StructDecl { .. }
1308                    | Node::EnumDecl { .. }
1309                    | Node::InterfaceDecl { .. }
1310                    | Node::TypeDecl { .. }
1311            );
1312            if !handled_elsewhere {
1313                self.compile_discarded_stmt(sn)?;
1314            }
1315        }
1316        // Phase 2: compile function-like declarations. Function closures
1317        // created here capture the current env which now includes module-level
1318        // bindings from phase 1. Attributed declarations are compiled here too
1319        // — the AttributedDecl arm in compile_node dispatches to the inner
1320        // declaration's compile path.
1321        for sn in program {
1322            let inner_kind = match &sn.node {
1323                Node::AttributedDecl { inner, .. } => &inner.node,
1324                other => other,
1325            };
1326            match inner_kind {
1327                Node::EvalPackDecl {
1328                    binding_name,
1329                    pack_id,
1330                    fields,
1331                    body,
1332                    summarize,
1333                    ..
1334                } => {
1335                    self.compile_eval_pack_decl(
1336                        binding_name,
1337                        pack_id,
1338                        fields,
1339                        body,
1340                        summarize,
1341                        false,
1342                    )?;
1343                }
1344                Node::FnDecl { .. }
1345                | Node::ToolDecl { .. }
1346                | Node::SkillDecl { .. }
1347                | Node::ImplBlock { .. }
1348                | Node::StructDecl { .. }
1349                | Node::EnumDecl { .. }
1350                | Node::InterfaceDecl { .. } => {
1351                    self.compile_node(sn)?;
1352                }
1353                Node::TypeDecl { .. } => {}
1354                _ => {}
1355            }
1356        }
1357        Ok(())
1358    }
1359
1360    /// Recursively collect all enum type names from the AST.
1361    pub(super) fn collect_enum_names(
1362        nodes: &[SNode],
1363        names: &mut std::collections::HashSet<String>,
1364    ) {
1365        for sn in nodes {
1366            match &sn.node {
1367                Node::EnumDecl { name, .. } => {
1368                    names.insert(name.clone());
1369                }
1370                Node::Pipeline { body, .. } => {
1371                    Self::collect_enum_names(body, names);
1372                }
1373                Node::FnDecl { body, .. } | Node::ToolDecl { body, .. } => {
1374                    Self::collect_enum_names(body, names);
1375                }
1376                Node::SkillDecl { fields, .. } => {
1377                    for (_k, v) in fields {
1378                        Self::collect_enum_names(std::slice::from_ref(v), names);
1379                    }
1380                }
1381                Node::EvalPackDecl {
1382                    fields,
1383                    body,
1384                    summarize,
1385                    ..
1386                } => {
1387                    for (_k, v) in fields {
1388                        Self::collect_enum_names(std::slice::from_ref(v), names);
1389                    }
1390                    Self::collect_enum_names(body, names);
1391                    if let Some(summary_body) = summarize {
1392                        Self::collect_enum_names(summary_body, names);
1393                    }
1394                }
1395                Node::Block(stmts) => {
1396                    Self::collect_enum_names(stmts, names);
1397                }
1398                Node::AttributedDecl { inner, .. } => {
1399                    Self::collect_enum_names(std::slice::from_ref(inner), names);
1400                }
1401                _ => {}
1402            }
1403        }
1404    }
1405
1406    /// Collect variant name → owning enum names across the whole program
1407    /// (including nested declarations). Powers bare call-shaped match
1408    /// patterns (`Ok(v)` without the `Result.` qualifier): a pattern
1409    /// resolves only when exactly one visible enum owns the variant name.
1410    pub(super) fn collect_enum_variant_owners(
1411        nodes: &[SNode],
1412        owners: &mut std::collections::HashMap<String, Vec<String>>,
1413    ) {
1414        harn_parser::visit::walk_program(nodes, &mut |sn| {
1415            if let Node::EnumDecl { name, variants, .. } = &sn.node {
1416                for variant in variants {
1417                    let entry = owners.entry(variant.name.clone()).or_default();
1418                    if !entry.contains(name) {
1419                        entry.push(name.clone());
1420                    }
1421                }
1422            }
1423        });
1424    }
1425
1426    /// Seed the built-in `Result` enum's variants into the owner map (the
1427    /// same special-casing `compile`/`compile_named` apply to `enum_names`).
1428    pub(super) fn seed_builtin_variant_owners(
1429        owners: &mut std::collections::HashMap<String, Vec<String>>,
1430    ) {
1431        for variant in ["Ok", "Err"] {
1432            let entry = owners.entry(variant.to_string()).or_default();
1433            if !entry.contains(&"Result".to_string()) {
1434                entry.push("Result".to_string());
1435            }
1436        }
1437    }
1438
1439    pub(super) fn collect_struct_layouts(
1440        nodes: &[SNode],
1441        layouts: &mut std::collections::HashMap<String, Vec<String>>,
1442    ) {
1443        for sn in nodes {
1444            match &sn.node {
1445                Node::StructDecl { name, fields, .. } => {
1446                    layouts.insert(
1447                        name.clone(),
1448                        fields.iter().map(|field| field.name.clone()).collect(),
1449                    );
1450                }
1451                Node::Pipeline { body, .. }
1452                | Node::FnDecl { body, .. }
1453                | Node::ToolDecl { body, .. } => {
1454                    Self::collect_struct_layouts(body, layouts);
1455                }
1456                Node::SkillDecl { fields, .. } => {
1457                    for (_k, v) in fields {
1458                        Self::collect_struct_layouts(std::slice::from_ref(v), layouts);
1459                    }
1460                }
1461                Node::EvalPackDecl {
1462                    fields,
1463                    body,
1464                    summarize,
1465                    ..
1466                } => {
1467                    for (_k, v) in fields {
1468                        Self::collect_struct_layouts(std::slice::from_ref(v), layouts);
1469                    }
1470                    Self::collect_struct_layouts(body, layouts);
1471                    if let Some(summary_body) = summarize {
1472                        Self::collect_struct_layouts(summary_body, layouts);
1473                    }
1474                }
1475                Node::Block(stmts) => {
1476                    Self::collect_struct_layouts(stmts, layouts);
1477                }
1478                Node::AttributedDecl { inner, .. } => {
1479                    Self::collect_struct_layouts(std::slice::from_ref(inner), layouts);
1480                }
1481                _ => {}
1482            }
1483        }
1484    }
1485
1486    pub(super) fn collect_interface_methods(
1487        nodes: &[SNode],
1488        interfaces: &mut std::collections::HashMap<String, Vec<String>>,
1489    ) {
1490        for sn in nodes {
1491            match &sn.node {
1492                Node::InterfaceDecl { name, methods, .. } => {
1493                    let method_names: Vec<String> =
1494                        methods.iter().map(|m| m.name.clone()).collect();
1495                    interfaces.insert(name.clone(), method_names);
1496                }
1497                Node::Pipeline { body, .. }
1498                | Node::FnDecl { body, .. }
1499                | Node::ToolDecl { body, .. } => {
1500                    Self::collect_interface_methods(body, interfaces);
1501                }
1502                Node::SkillDecl { fields, .. } => {
1503                    for (_k, v) in fields {
1504                        Self::collect_interface_methods(std::slice::from_ref(v), interfaces);
1505                    }
1506                }
1507                Node::EvalPackDecl {
1508                    fields,
1509                    body,
1510                    summarize,
1511                    ..
1512                } => {
1513                    for (_k, v) in fields {
1514                        Self::collect_interface_methods(std::slice::from_ref(v), interfaces);
1515                    }
1516                    Self::collect_interface_methods(body, interfaces);
1517                    if let Some(summary_body) = summarize {
1518                        Self::collect_interface_methods(summary_body, interfaces);
1519                    }
1520                }
1521                Node::Block(stmts) => {
1522                    Self::collect_interface_methods(stmts, interfaces);
1523                }
1524                Node::AttributedDecl { inner, .. } => {
1525                    Self::collect_interface_methods(std::slice::from_ref(inner), interfaces);
1526                }
1527                _ => {}
1528            }
1529        }
1530    }
1531
1532    /// Compile a function body into a CompiledFunction (for import support).
1533    ///
1534    /// This path is used when a module is imported and its top-level `fn`
1535    /// declarations are loaded into the importer's environment. It MUST emit
1536    /// the same function preamble as the in-file `Node::FnDecl` path, or
1537    /// imported functions will behave differently from locally-defined ones —
1538    /// in particular, default parameter values would never be set and typed
1539    /// parameters would not be runtime-checked.
1540    ///
1541    /// `source_file`, when provided, tags the resulting chunk so runtime
1542    /// errors can attribute frames to the imported file rather than the
1543    /// entry-point pipeline.
1544    pub fn compile_fn_body(
1545        &mut self,
1546        type_params: &[harn_parser::TypeParam],
1547        params: &[TypedParam],
1548        body: &[SNode],
1549        source_file: Option<String>,
1550    ) -> Result<CompiledFunction, CompileError> {
1551        let mut fn_compiler = self.nested_body();
1552        fn_compiler.enum_names = self.enum_names.clone();
1553        fn_compiler.enum_variant_owners = self.enum_variant_owners.clone();
1554        fn_compiler.interface_methods = self.interface_methods.clone();
1555        fn_compiler.type_aliases = self.type_aliases.clone();
1556        fn_compiler.struct_layouts = self.struct_layouts.clone();
1557        fn_compiler.declare_param_slots(params);
1558        fn_compiler.record_param_types(params);
1559        fn_compiler.emit_default_preamble(params)?;
1560        fn_compiler.emit_type_checks(params);
1561        let is_gen = body_contains_yield(body);
1562        fn_compiler.seed_captured_idents(body);
1563        fn_compiler.compile_block(body)?;
1564        fn_compiler.chunk.emit(Op::Nil, 0);
1565        fn_compiler.chunk.emit(Op::Return, 0);
1566        fn_compiler.chunk.source_file = source_file;
1567        let param_slots = crate::chunk::ParamSlot::vec_from_typed(params);
1568        let has_runtime_type_checks =
1569            CompiledFunction::has_runtime_type_checks_for_params(&param_slots);
1570        super::ensure_chunk_addressable(&fn_compiler.chunk, "function body", self.line)?;
1571        Ok(CompiledFunction {
1572            name: String::new(),
1573            type_params: type_params.iter().map(|param| param.name.clone()).collect(),
1574            nominal_type_names: fn_compiler.nominal_type_names(),
1575            params: param_slots,
1576            default_start: TypedParam::default_start(params),
1577            chunk: Arc::new(fn_compiler.chunk),
1578            is_generator: is_gen,
1579            is_stream: false,
1580            has_rest_param: false,
1581            has_runtime_type_checks,
1582        })
1583    }
1584
1585    /// Check if a node produces a value on the stack that needs to be popped.
1586    pub(super) fn produces_value(node: &Node) -> bool {
1587        match node {
1588            // An attribute decorates a declaration (fn/struct/enum/…), never
1589            // an expression — so an attributed top-level item is a statement
1590            // that leaves nothing on the operand stack, exactly like its bare
1591            // inner declaration. Classifying by the inner node prevents the
1592            // script-mode top-level loop from emitting a spurious `Pop` (which
1593            // underflows the stack) after compiling, e.g., a `@route pub fn`.
1594            Node::AttributedDecl { inner, .. } => Self::produces_value(&inner.node),
1595            Node::LetBinding { .. }
1596            | Node::ConstBinding { .. }
1597            | Node::Assignment { .. }
1598            | Node::ReturnStmt { .. }
1599            | Node::FnDecl { .. }
1600            | Node::ToolDecl { .. }
1601            | Node::SkillDecl { .. }
1602            | Node::EvalPackDecl { .. }
1603            | Node::ImplBlock { .. }
1604            | Node::StructDecl { .. }
1605            | Node::EnumDecl { .. }
1606            | Node::InterfaceDecl { .. }
1607            | Node::TypeDecl { .. }
1608            // Metadata-only declarations that emit no bytecode — see the
1609            // matching arm in `compile_node`.
1610            | Node::OverrideDecl { .. }
1611            | Node::Pipeline { .. }
1612            | Node::ThrowStmt { .. }
1613            | Node::BreakStmt
1614            | Node::ContinueStmt
1615            | Node::RequireStmt { .. }
1616            | Node::DeferStmt { .. } => false,
1617            Node::TryCatch { has_catch: _, .. }
1618            | Node::TryExpr { .. }
1619            | Node::Retry { .. }
1620            | Node::GuardStmt { .. }
1621            | Node::DeadlineBlock { .. }
1622            | Node::MutexBlock { .. }
1623            | Node::Spread(_) => true,
1624            _ => true,
1625        }
1626    }
1627}
1628
1629impl Default for Compiler {
1630    fn default() -> Self {
1631        Self::new()
1632    }
1633}
1634
1635/// Collect into `set` every identifier that appears inside a nested closure
1636/// literal reachable from `node` (without descending past `node` itself if it
1637/// is not a closure). A `Node::Closure`/`FnDecl`/`ToolDecl` body has *all* of
1638/// its identifiers harvested (via [`collect_all_idents`], which recurses
1639/// through its own nested closures too, so a name captured across several
1640/// closure levels is still recorded); any other node is only traversed to find
1641/// the closures within it. The current body's own top-level identifiers are
1642/// therefore never added unless they also appear inside a closure — which is
1643/// exactly the "is this local captured?" question.
1644fn collect_closure_capture_idents(node: &SNode, set: &mut std::collections::HashSet<String>) {
1645    match super::peel_node(node) {
1646        Node::Closure { body, .. } | Node::FnDecl { body, .. } | Node::ToolDecl { body, .. } => {
1647            for sn in body {
1648                collect_all_idents(sn, set);
1649            }
1650        }
1651        // `parallel`/`spawn` bodies are lowered (in `compile_parallel` /
1652        // `compile_spawn_expr`) into nested closures that capture the enclosing
1653        // environment exactly like a closure literal — each concurrent branch
1654        // runs `closure.clone()`, sharing the captured `Cell`s by `Arc`. A
1655        // mutable local mutated inside such a body must therefore be boxed too;
1656        // otherwise its write lands in the branch's private env copy and is
1657        // silently lost — the very by-value regression this cutover removes,
1658        // but re-introduced *only* for concurrent code. The driving
1659        // `expr`/options run in the enclosing scope, so we still descend into
1660        // `expr` to find any closure literals nested there.
1661        Node::Parallel { expr, body, .. } => {
1662            for sn in body {
1663                collect_all_idents(sn, set);
1664            }
1665            collect_closure_capture_idents(expr, set);
1666        }
1667        Node::SpawnExpr { body } => {
1668            for sn in body {
1669                collect_all_idents(sn, set);
1670            }
1671        }
1672        _ => {
1673            for child in harn_parser::visit::immediate_children(node) {
1674                collect_closure_capture_idents(child, set);
1675            }
1676        }
1677    }
1678}
1679
1680/// Add every `Node::Identifier` name anywhere under `node` (inclusive) to `set`.
1681fn collect_all_idents(node: &SNode, set: &mut std::collections::HashSet<String>) {
1682    if let Node::Identifier(name) = super::peel_node(node) {
1683        set.insert(name.clone());
1684    }
1685    for child in harn_parser::visit::immediate_children(node) {
1686        collect_all_idents(child, set);
1687    }
1688}