Skip to main content

harn_vm/
chunk.rs

1use std::collections::{BTreeMap, HashMap};
2use std::fmt;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5
6use harn_parser::TypeExpr;
7use parking_lot::Mutex;
8use serde::{Deserialize, Serialize};
9
10use crate::runtime_guards::RuntimeParamGuard;
11
12/// Sentinel value stored in [`Chunk::inline_cache_index`] for code offsets
13/// that have no inline-cache slot registered. Chosen as `u32::MAX` so the
14/// hot dispatch path can treat the side-table as a flat `Vec<u32>` without
15/// an `Option` wrapper — the comparison against the sentinel collapses to a
16/// single integer compare. The compile-time max useful slot count is bounded
17/// by code length (one slot per cacheable opcode), so `u32::MAX` is safely
18/// out of the addressable slot range.
19pub(crate) const NO_INLINE_CACHE_SLOT: u32 = u32::MAX;
20static NEXT_CHUNK_CACHE_ID: AtomicU64 = AtomicU64::new(1);
21
22fn next_chunk_cache_id() -> u64 {
23    NEXT_CHUNK_CACHE_ID.fetch_add(1, Ordering::Relaxed)
24}
25
26/// Bytecode opcodes for the Harn VM. The enum, the byte-to-variant
27/// mapping, the sync and async dispatch tables, the disassembly
28/// renderer, and the per-opcode classification helpers are all emitted
29/// by `harn_opcode_macros::define_opcodes!` in [`crate::vm::ops`].
30/// Re-exported here so callers that import `crate::chunk::Op` need no
31/// awareness of the macro layout.
32pub use crate::vm::ops::Op;
33pub(crate) use crate::vm::ops::{is_adaptive_binary_op, op_reads_outer_name};
34
35mod disassembly;
36pub(crate) use disassembly::*;
37mod inline_cache;
38pub(crate) use inline_cache::*;
39
40/// One annotated `let` / `const` binding site of a chunk, indexed by
41/// `Op::AssertBindingType`.
42///
43/// The native mirror of `harn_kernel::BindingTypeSlot`. Both execution targets
44/// validate this slot through `harn_kernel::type_contract::matches_type`, so a
45/// binding assertion is one of the few checks that is bit-for-bit the same
46/// decision natively and in the portable kernel.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct BindingTypeSlot {
49    pub name: String,
50    pub type_expr: TypeExpr,
51    /// Struct and enum names mentioned by `type_expr`. Every other named type
52    /// is unconstrained at runtime, matching parameter semantics.
53    pub nominal_type_names: Vec<String>,
54}
55
56/// A constant value in the constant pool.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub enum Constant {
59    Int(i64),
60    Float(f64),
61    String(String),
62    Bool(bool),
63    Nil,
64    Duration(i64),
65}
66
67/// Identity used for constant-pool deduplication.
68///
69/// This is stricter than `PartialEq` for floats: it compares `Constant::Float`
70/// operands by their raw bits, so `+0.0` and `-0.0` (which are `==` under IEEE
71/// 754) get distinct pool slots, and each distinct NaN bit-pattern is preserved.
72/// Collapsing `+0.0`/`-0.0` onto one slot makes signed zero — and therefore the
73/// sign of `1.0 / 0.0` vs `1.0 / -0.0` — depend on which literal happened to be
74/// interned first. The derived `PartialEq` is left intact for all other uses.
75fn constants_identical(a: &Constant, b: &Constant) -> bool {
76    match (a, b) {
77        (Constant::Float(x), Constant::Float(y)) => x.to_bits() == y.to_bits(),
78        _ => a == b,
79    }
80}
81
82/// Hashable identity for constant-pool deduplication.
83///
84/// Mirrors [`constants_identical`] exactly, including bitwise float identity,
85/// so the compiler can replace the previous linear scan with an amortized O(1)
86/// side index without changing bytecode-visible constant slots.
87#[derive(Debug, Clone, PartialEq, Eq, Hash)]
88enum ConstantKey {
89    Int(i64),
90    Float(u64),
91    String(String),
92    Bool(bool),
93    Nil,
94    Duration(i64),
95}
96
97impl From<&Constant> for ConstantKey {
98    fn from(constant: &Constant) -> Self {
99        match constant {
100            Constant::Int(value) => Self::Int(*value),
101            Constant::Float(value) => Self::Float(value.to_bits()),
102            Constant::String(value) => Self::String(value.clone()),
103            Constant::Bool(value) => Self::Bool(*value),
104            Constant::Nil => Self::Nil,
105            Constant::Duration(value) => Self::Duration(*value),
106        }
107    }
108}
109
110fn build_constant_index(constants: &[Constant]) -> HashMap<ConstantKey, u16> {
111    let mut index = HashMap::with_capacity(constants.len());
112    for (slot, constant) in constants.iter().enumerate() {
113        if let Ok(slot) = u16::try_from(slot) {
114            index.entry(ConstantKey::from(constant)).or_insert(slot);
115        }
116    }
117    index
118}
119
120/// Debug metadata for a slot-indexed local in a compiled chunk.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct LocalSlotInfo {
123    pub name: String,
124    pub mutable: bool,
125    pub scope_depth: usize,
126}
127
128impl fmt::Display for Constant {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        match self {
131            Constant::Int(n) => write!(f, "{n}"),
132            Constant::Float(n) => write!(f, "{n}"),
133            Constant::String(s) => write!(f, "\"{s}\""),
134            Constant::Bool(b) => write!(f, "{b}"),
135            Constant::Nil => write!(f, "nil"),
136            Constant::Duration(ms) => write!(f, "{ms}ms"),
137        }
138    }
139}
140
141/// A compiled chunk of bytecode.
142#[derive(Debug)]
143pub struct Chunk {
144    /// Runtime-only identity for VM-local inline cache storage. It is not
145    /// serialized; freshly compiled or loaded chunks get new ids, while clones
146    /// keep the same id because they represent the same bytecode object.
147    cache_id: u64,
148    /// The bytecode instructions.
149    pub code: Vec<u8>,
150    /// Constant pool.
151    pub constants: Vec<Constant>,
152    /// Compile-time constant-pool dedup index, derived from
153    /// [`Chunk::constants`] and intentionally omitted from [`CachedChunk`].
154    ///
155    /// Only [`Chunk::add_constant`] reads it, so only a chunk the compiler is
156    /// still emitting into needs it. Building it eagerly on a cache load would
157    /// hash every constant of every chunk of every module on a path that never
158    /// appends another constant. `None` means "not built yet"; it is derived on
159    /// the first append.
160    constant_index: Option<HashMap<ConstantKey, u16>>,
161    /// Source line numbers for each instruction (for error reporting).
162    pub lines: Vec<u32>,
163    /// Source column numbers for each instruction (for error reporting).
164    /// Parallel to `lines`; 0 means no column info available.
165    pub columns: Vec<u32>,
166    /// Source file that this chunk was compiled from, when known. Set for
167    /// chunks compiled from imported modules so runtime errors can report
168    /// the correct file path for each frame instead of always pointing at
169    /// the entry-point pipeline.
170    pub source_file: Option<String>,
171    /// Current column to use when emitting instructions (set by compiler).
172    current_col: u32,
173    /// Compiled function bodies (for closures).
174    pub functions: Vec<CompiledFunctionRef>,
175    /// Instruction offset to inline-cache slot. Slots are assigned at emit time
176    /// for cacheable instructions while bytecode bytes remain immutable.
177    /// Preserved as the serialization-stable representation that round-trips
178    /// through [`CachedChunk`]; the runtime hot path reads
179    /// [`Chunk::inline_cache_index`] instead.
180    inline_cache_slots: BTreeMap<usize, usize>,
181    /// Flat side-table indexed by code offset that returns the inline-cache
182    /// slot index (or [`NO_INLINE_CACHE_SLOT`] for "no slot at this offset").
183    /// Built alongside [`Chunk::inline_cache_slots`] at emit/load time so the
184    /// per-dispatch lookup that fires on every adaptive binary op, `Op::Call`,
185    /// `Op::MethodCall`, and `Op::GetProperty` is one cache-friendly `Vec`
186    /// index instead of a `BTreeMap::get` (O(1) vs O(log n) with the
187    /// associated pointer chasing). Derived; intentionally not serialized.
188    inline_cache_index: Vec<u32>,
189    /// Test/bench scratch entries for validating inline-cache transitions.
190    /// Runtime execution keeps live cache entries on each `Vm` isolate so
191    /// parallel workers do not contend on shared compiled chunks.
192    inline_caches: Arc<Mutex<Vec<InlineCacheEntry>>>,
193    /// Lazily-materialized shared string cache for `Constant::String` entries,
194    /// parallel to `constants`. String constants are materialized once per
195    /// unique constant; subsequent pushes are a [`HarnStr`] refcount bump.
196    /// Per-slot `OnceLock`s keep the steady-state read lock-free — the
197    /// previous `Mutex<Vec<..>>` paid an uncontended lock round-trip on every
198    /// string-constant push. Sized to the constant pool at construction;
199    /// constants appended afterwards (compile-time emission) fall back to
200    /// uncached materialization in [`Chunk::constant_string_rc`].
201    constant_strings: Arc<Vec<std::sync::OnceLock<crate::value::HarnStr>>>,
202    /// Source-name metadata for slot-indexed locals in this chunk.
203    pub(crate) local_slots: Vec<LocalSlotInfo>,
204    /// Declared types for annotated `let` / `const` sites in this chunk,
205    /// indexed by the `AssertBindingType` operand.
206    pub(crate) binding_types: Vec<BindingTypeSlot>,
207    /// True when this chunk's bytecode emits an opcode that resolves a
208    /// name through the runtime env (`GetVar`, `SetVar`, `CallBuiltin`,
209    /// `CallBuiltinSpread`, `CheckType`). The closure-call hot path uses
210    /// this as a cheap static guard: if a closure body never reads
211    /// outer names by name, the caller-scope late-bind walks in
212    /// [`Vm::closure_call_env`] and
213    /// [`Vm::closure_call_env_for_current_frame`] are pure overhead and
214    /// can be skipped, leaving the closure's captured env as-is.
215    ///
216    /// Walks exist to inject late-bound closure-typed names — typically
217    /// for self/mutually-recursive local fns and for fns whose captured
218    /// env predates a sibling definition. Inline arithmetic / comparison
219    /// callbacks (the `.map(x -> x * 2)` / `.filter(x -> x % 2 == 0)`
220    /// shape) emit none of the flagged opcodes, so the walk is wasted
221    /// work on every invocation.
222    pub(crate) references_outer_names: bool,
223    /// Compile-time operand-stack-depth tracking for the debug-build
224    /// balance assertion (issue #2622). `balance_depth` is the running net
225    /// effect of every *linearly-modeled* opcode emitted so far;
226    /// `balance_nonlinear` counts emits whose effect can't be tracked by a
227    /// straight-line sum (jumps, `return`, async/handler ops, variadic ops
228    /// whose count isn't an emit argument). A statement is "balance-exact"
229    /// only when `balance_nonlinear` is unchanged across its compilation,
230    /// at which point `balance_depth`'s delta is its true net stack effect.
231    /// Transient compile-time state: reset by [`Chunk::new`], never
232    /// serialized into [`CachedChunk`], and read only by debug assertions —
233    /// so a wrong absolute value (which a non-exact statement can leave
234    /// behind) is harmless; only per-statement *deltas over exact spans*
235    /// are ever trusted.
236    #[cfg(debug_assertions)]
237    balance_depth: i32,
238    #[cfg(debug_assertions)]
239    balance_nonlinear: u32,
240}
241
242pub type ChunkRef = Arc<Chunk>;
243pub type CompiledFunctionRef = Arc<CompiledFunction>;
244
245impl Clone for Chunk {
246    fn clone(&self) -> Self {
247        Self {
248            cache_id: self.cache_id,
249            code: self.code.clone(),
250            constants: self.constants.clone(),
251            constant_index: self.constant_index.clone(),
252            lines: self.lines.clone(),
253            columns: self.columns.clone(),
254            source_file: self.source_file.clone(),
255            current_col: self.current_col,
256            functions: self.functions.clone(),
257            inline_cache_slots: self.inline_cache_slots.clone(),
258            inline_cache_index: self.inline_cache_index.clone(),
259            inline_caches: Arc::new(Mutex::new(vec![
260                InlineCacheEntry::Empty;
261                self.inline_cache_slot_count()
262            ])),
263            // Same immutable constants — share the materialized-string cache.
264            constant_strings: Arc::clone(&self.constant_strings),
265            local_slots: self.local_slots.clone(),
266            binding_types: self.binding_types.clone(),
267            references_outer_names: self.references_outer_names,
268            #[cfg(debug_assertions)]
269            balance_depth: self.balance_depth,
270            #[cfg(debug_assertions)]
271            balance_nonlinear: self.balance_nonlinear,
272        }
273    }
274}
275
276/// Serializable snapshot of a [`Chunk`] suitable for the on-disk bytecode
277/// cache and for in-memory stdlib artifact caches. Inline-cache state is
278/// dropped at freeze time because it warms at runtime per VM isolate; the
279/// rest of the chunk round-trips byte-identically.
280#[derive(Clone, Debug, Serialize, Deserialize)]
281pub struct CachedChunk {
282    pub(crate) code: Vec<u8>,
283    pub(crate) constants: Vec<Constant>,
284    pub(crate) lines: Vec<u32>,
285    pub(crate) columns: Vec<u32>,
286    pub(crate) source_file: Option<String>,
287    pub(crate) current_col: u32,
288    pub(crate) functions: Vec<CachedCompiledFunction>,
289    pub(crate) inline_cache_slots: BTreeMap<usize, usize>,
290    pub(crate) local_slots: Vec<LocalSlotInfo>,
291    #[serde(default)]
292    pub(crate) binding_types: Vec<BindingTypeSlot>,
293    #[serde(default)]
294    pub(crate) references_outer_names: bool,
295}
296
297#[derive(Clone, Debug, Serialize, Deserialize)]
298pub struct CachedCompiledFunction {
299    pub(crate) name: String,
300    pub(crate) type_params: Vec<String>,
301    pub(crate) nominal_type_names: Vec<String>,
302    pub(crate) params: Vec<CachedParamSlot>,
303    pub(crate) default_start: Option<usize>,
304    pub(crate) chunk: CachedChunk,
305    pub(crate) is_generator: bool,
306    pub(crate) is_stream: bool,
307    pub(crate) has_rest_param: bool,
308    pub(crate) has_runtime_type_checks: bool,
309}
310
311#[derive(Clone, Debug, Serialize, Deserialize)]
312pub(crate) struct CachedParamSlot {
313    pub(crate) name: String,
314    pub(crate) type_expr: Option<TypeExpr>,
315    pub(crate) has_default: bool,
316}
317
318impl CachedParamSlot {
319    fn thaw(self) -> ParamSlot {
320        let runtime_guard = self
321            .type_expr
322            .as_ref()
323            .map(RuntimeParamGuard::from_type_expr);
324        ParamSlot {
325            name: self.name,
326            type_expr: self.type_expr,
327            runtime_guard,
328            has_default: self.has_default,
329        }
330    }
331}
332
333/// One parameter slot of a compiled user-defined function. Carries the
334/// declared name, the (optional) declared type expression, and a flag
335/// for whether a default value was provided. The runtime consults the
336/// type expression in `bind_param_slots` to enforce declared types
337/// against the values supplied at the call site.
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct ParamSlot {
340    pub name: String,
341    /// Declared parameter type. `None` for untyped parameters (gradual
342    /// typing); the runtime skips type assertion when absent.
343    pub type_expr: Option<TypeExpr>,
344    /// Precomputed runtime validation metadata derived from `type_expr`.
345    /// Bytecode-cache artifacts omit this field and rebuild it at load time.
346    #[serde(skip)]
347    pub(crate) runtime_guard: Option<RuntimeParamGuard>,
348    /// True when the parameter has a default-value clause. Diagnostic
349    /// only — the canonical authority for arity ranges is
350    /// [`CompiledFunction::default_start`].
351    pub has_default: bool,
352}
353
354impl ParamSlot {
355    /// Build a [`ParamSlot`] from a parser-side [`harn_parser::TypedParam`].
356    /// Centralizes the conversion so every compile path stays in lockstep.
357    pub fn from_typed_param(param: &harn_parser::TypedParam) -> Self {
358        Self::from_typed_param_with_type(param, param.type_expr.clone())
359    }
360
361    pub(crate) fn from_typed_param_with_type(
362        param: &harn_parser::TypedParam,
363        type_expr: Option<TypeExpr>,
364    ) -> Self {
365        let runtime_guard = type_expr.as_ref().map(RuntimeParamGuard::from_type_expr);
366        Self {
367            name: param.name.clone(),
368            type_expr,
369            runtime_guard,
370            has_default: param.default_value.is_some(),
371        }
372    }
373
374    fn freeze_for_cache(&self) -> CachedParamSlot {
375        CachedParamSlot {
376            name: self.name.clone(),
377            type_expr: self.type_expr.clone(),
378            has_default: self.has_default,
379        }
380    }
381
382    /// Build a `Vec<ParamSlot>` from a slice of parser-side typed
383    /// parameters. Used pervasively at compile sites instead of
384    /// `TypedParam::names` (which discarded the type info we now need
385    /// at runtime).
386    pub fn vec_from_typed(params: &[harn_parser::TypedParam]) -> Vec<Self> {
387        params.iter().map(Self::from_typed_param).collect()
388    }
389}
390
391/// A compiled function (closure body).
392#[derive(Debug, Clone)]
393pub struct CompiledFunction {
394    /// Shared so per-call consumers (the call frame, stack traces) take a
395    /// refcount bump instead of a heap-allocating `String` clone.
396    pub name: crate::value::HarnStr,
397    /// Generic type parameters declared by this function. Runtime
398    /// validation treats these as static-only constraints because the VM
399    /// does not monomorphize function bodies.
400    pub type_params: Vec<String>,
401    /// User-defined struct and enum names visible when this function was
402    /// compiled. These are the only non-primitive named types with runtime
403    /// nominal identity; aliases and interfaces remain static-only.
404    pub nominal_type_names: Vec<String>,
405    pub params: Vec<ParamSlot>,
406    /// Index of the first parameter with a default value, or None if all required.
407    pub default_start: Option<usize>,
408    pub chunk: ChunkRef,
409    /// True if the function body contains `yield` expressions (generator function).
410    pub is_generator: bool,
411    /// True if the function was declared as `gen fn` and should return Stream.
412    pub is_stream: bool,
413    /// True if the last parameter is a rest parameter (`...name`).
414    pub has_rest_param: bool,
415    /// True when at least one parameter has a runtime-visible type
416    /// assertion. Untyped closures dominate collection callback hot paths,
417    /// so this lets the VM skip the per-argument metadata walk after the
418    /// arity check.
419    pub has_runtime_type_checks: bool,
420}
421
422impl CompiledFunction {
423    pub(crate) fn from_portable(portable: harn_kernel::CompiledFunction) -> Self {
424        Self {
425            name: crate::value::HarnStr::from(portable.name),
426            type_params: portable.type_params,
427            nominal_type_names: portable.nominal_type_names,
428            params: portable
429                .params
430                .into_iter()
431                .map(|param| {
432                    let runtime_guard = param
433                        .type_expr
434                        .as_ref()
435                        .map(RuntimeParamGuard::from_type_expr);
436                    ParamSlot {
437                        name: param.name,
438                        type_expr: param.type_expr,
439                        runtime_guard,
440                        has_default: param.has_default,
441                    }
442                })
443                .collect(),
444            default_start: portable.default_start,
445            chunk: Arc::new(Chunk::from_portable((*portable.chunk).clone())),
446            is_generator: portable.is_generator,
447            is_stream: portable.is_stream,
448            has_rest_param: portable.has_rest_param,
449            has_runtime_type_checks: portable.has_runtime_type_checks,
450        }
451    }
452
453    #[cfg(test)]
454    pub(crate) fn has_runtime_type_checks_for_params(params: &[ParamSlot]) -> bool {
455        params.iter().any(|param| {
456            param
457                .type_expr
458                .as_ref()
459                .is_some_and(harn_kernel::type_contract::requires_runtime_type_check)
460        })
461    }
462
463    /// Returns just the parameter names — convenience for code paths that
464    /// don't care about types or defaults.
465    pub fn param_names(&self) -> impl Iterator<Item = &str> {
466        self.params.iter().map(|p| p.name.as_str())
467    }
468
469    /// Number of required parameters (those before `default_start`).
470    pub fn required_param_count(&self) -> usize {
471        self.default_start.unwrap_or(self.params.len())
472    }
473
474    /// Minimum number of caller-supplied arguments needed to enter the function.
475    pub(crate) fn minimum_arg_count(&self) -> usize {
476        if self.has_rest_param {
477            self.required_param_count()
478                .min(self.params.len().saturating_sub(1))
479        } else {
480            self.required_param_count()
481        }
482    }
483
484    /// Argument count visible to callee bytecode via `GetArgc`.
485    pub(crate) fn callee_arg_count(&self, supplied: usize) -> usize {
486        if self.has_rest_param {
487            supplied
488        } else {
489            supplied.min(self.params.len())
490        }
491    }
492
493    pub fn declares_type_param(&self, name: &str) -> bool {
494        self.type_params.iter().any(|param| param == name)
495    }
496
497    pub fn has_nominal_type(&self, name: &str) -> bool {
498        self.nominal_type_names.iter().any(|ty| ty == name)
499    }
500
501    pub(crate) fn freeze_for_cache(&self) -> CachedCompiledFunction {
502        CachedCompiledFunction {
503            name: self.name.to_string(),
504            type_params: self.type_params.clone(),
505            nominal_type_names: self.nominal_type_names.clone(),
506            params: self
507                .params
508                .iter()
509                .map(ParamSlot::freeze_for_cache)
510                .collect(),
511            default_start: self.default_start,
512            chunk: self.chunk.freeze_for_cache(),
513            is_generator: self.is_generator,
514            is_stream: self.is_stream,
515            has_rest_param: self.has_rest_param,
516            has_runtime_type_checks: self.has_runtime_type_checks,
517        }
518    }
519
520    pub(crate) fn from_cached(cached: CachedCompiledFunction) -> Self {
521        Self {
522            name: crate::value::HarnStr::from(cached.name),
523            type_params: cached.type_params,
524            nominal_type_names: cached.nominal_type_names,
525            params: cached
526                .params
527                .into_iter()
528                .map(CachedParamSlot::thaw)
529                .collect(),
530            default_start: cached.default_start,
531            chunk: Arc::new(Chunk::from_cached(cached.chunk)),
532            is_generator: cached.is_generator,
533            is_stream: cached.is_stream,
534            has_rest_param: cached.has_rest_param,
535            has_runtime_type_checks: cached.has_runtime_type_checks,
536        }
537    }
538}
539
540/// Net operand-stack effect (`pushes - pops`) of one emitted opcode, for
541/// the debug-build balance assertion (issue #2622). `count` is the opcode's
542/// variadic arity when that arity is the emit-call argument (`BuildList`
543/// length, `Call` argc, …) and `0` otherwise.
544///
545/// `Some(delta)` means the effect is exactly modeled. `None` marks an
546/// opcode a straight-line running sum can't track — control flow that
547/// branches or terminates (`Jump*`, `Return`, `Throw`, `TailCall`),
548/// async/handler ops, and variadic ops whose arity rides in a raw operand
549/// byte rather than the emit argument (`BuildEnum`, `MatchEnum`). Such an
550/// opcode taints its enclosing statement as non-exact, so the assertion
551/// skips it instead of risking a false trip.
552///
553/// The `match` is intentionally exhaustive with no `_` arm: adding an
554/// opcode forces a classification here (a compile error otherwise), so the
555/// balance model can't silently drift out of sync with the instruction set.
556#[cfg(debug_assertions)]
557fn op_stack_delta(op: Op, count: u16) -> Option<i32> {
558    use Op::*;
559    let count = count as i32;
560    Some(match op {
561        // Push one value.
562        Constant | Nil | True | False | RootHarness | GetVar | GetArgc | GetLocalSlot | Closure
563        | Dup => 1,
564        // Consume one value (into a binding / property / discard). `SetVar`,
565        // `SetProperty` and the local-slot stores read their target by name
566        // or slot index, so they only pop the value being stored.
567        DefLet | DefVar | DefCell | SetVar | DefLocalSlot | SetLocalSlot | SetProperty
568        | SetLocalSlotProperty | ConcatAssignLocal | Pop => -1,
569        // Value-preserving: unary ops, by-name lookups/checks, and scope /
570        // iterator / exception-handler bookkeeping (the last three touch
571        // side stacks, not the operand stack).
572        AssertBindingType => 0,
573        Negate | Not | GetProperty | GetPropertyOpt | CheckType | TryUnwrap | TryWrapOk | Swap
574        | PushScope | PopScope | PopIterator | PopHandler => 0,
575        // Pop two, push one.
576        Add | Sub | Mul | Div | Mod | Pow | AddInt | SubInt | MulInt | DivInt | ModInt
577        | AddFloat | SubFloat | MulFloat | DivFloat | ModFloat | Equal | NotEqual | Less
578        | Greater | LessEqual | GreaterEqual | EqualInt | NotEqualInt | LessInt | GreaterInt
579        | LessEqualInt | GreaterEqualInt | EqualFloat | NotEqualFloat | LessFloat
580        | GreaterFloat | LessEqualFloat | GreaterEqualFloat | EqualBool | NotEqualBool
581        | EqualString | NotEqualString | Contains | Subscript | SubscriptOpt => -1,
582        // `IterInit` consumes the iterable and pushes nothing (the iterator
583        // lives on a side stack).
584        IterInit => -1,
585        // Net -2: `Slice` pops object/start/end and pushes one value;
586        // subscript stores pop value/index and read the target from bytecode.
587        Slice | SetSubscript | SetLocalSlotSubscript => -2,
588        // Variadic whose arity is the emit argument: pop `count`, push one.
589        BuildList | Concat | CallBuiltin => 1 - count,
590        BuildDict => 1 - 2 * count,
591        // Calls also pop the callee/receiver beneath the args.
592        Call | MethodCall | MethodCallOpt => -count,
593        // Non-linear (see doc comment): branches, terminators, async/handler
594        // ops, and variadic ops whose arity isn't the emit argument.
595        Jump
596        | JumpIfFalse
597        | JumpIfTrue
598        | IterNext
599        | Return
600        | TailCall
601        | Throw
602        | TryCatchSetup
603        | Spawn
604        | Pipe
605        | Parallel
606        | ParallelMap
607        | ParallelMapStream
608        | ParallelSettle
609        | SyncMutexEnter
610        | SyncMutexEnterKeyed
611        | TaskScopeEnter
612        | TaskScopeExit
613        | Import
614        | SelectiveImport
615        | NamespaceImport
616        | NamespaceImportMembers
617        | DeadlineSetup
618        | DeadlineEnd
619        | BuildEnum
620        | MatchEnum
621        | Yield
622        | CallSpread
623        | CallBuiltinSpread
624        | MethodCallSpread => return None,
625    })
626}
627
628impl Chunk {
629    /// Attach native-only caches and runtime guards to a portable program
630    /// image. The compiler never constructs these process-local structures.
631    pub fn from_portable(portable: harn_kernel::Chunk) -> Self {
632        let mut inline_cache_slots = BTreeMap::new();
633        let mut offset = 0usize;
634        while offset < portable.code.len() {
635            let Some(op) = Op::from_byte(portable.code[offset]) else {
636                break;
637            };
638            if is_adaptive_binary_op(op)
639                || matches!(
640                    op,
641                    Op::GetProperty
642                        | Op::GetPropertyOpt
643                        | Op::MethodCall
644                        | Op::MethodCallOpt
645                        | Op::MethodCallSpread
646                        | Op::ConcatAssignLocal
647                        | Op::Call
648                        | Op::CallBuiltin
649                )
650            {
651                let slot = inline_cache_slots.len();
652                inline_cache_slots.insert(offset, slot);
653            }
654            let Some(width) = harn_kernel::program::instruction_len(op, &portable.code[offset..])
655            else {
656                break;
657            };
658            offset = offset.saturating_add(width);
659        }
660
661        let code = portable.code;
662        let constants = portable
663            .constants
664            .into_iter()
665            .map(|constant| match constant {
666                harn_kernel::Constant::Int(value) => Constant::Int(value),
667                harn_kernel::Constant::Float(value) => Constant::Float(value),
668                harn_kernel::Constant::String(value) => Constant::String(value),
669                harn_kernel::Constant::Bool(value) => Constant::Bool(value),
670                harn_kernel::Constant::Nil => Constant::Nil,
671                harn_kernel::Constant::Duration(value) => Constant::Duration(value),
672            })
673            .collect::<Vec<_>>();
674        let constant_count = constants.len();
675        let inline_cache_count = inline_cache_slots.len();
676        let mut inline_cache_index = vec![NO_INLINE_CACHE_SLOT; code.len()];
677        for (&op_offset, &slot) in &inline_cache_slots {
678            inline_cache_index[op_offset] = slot as u32;
679        }
680
681        Self {
682            cache_id: next_chunk_cache_id(),
683            code,
684            constants,
685            constant_index: None,
686            lines: portable.lines,
687            columns: portable.columns,
688            source_file: portable.source_file,
689            current_col: portable.current_col,
690            functions: portable
691                .functions
692                .into_iter()
693                .map(|function| {
694                    Arc::new(CompiledFunction::from_portable(function.as_ref().clone()))
695                })
696                .collect(),
697            inline_cache_slots,
698            inline_cache_index,
699            inline_caches: Arc::new(Mutex::new(vec![
700                InlineCacheEntry::Empty;
701                inline_cache_count
702            ])),
703            constant_strings: Arc::new(
704                (0..constant_count)
705                    .map(|_| std::sync::OnceLock::new())
706                    .collect(),
707            ),
708            local_slots: portable
709                .local_slots
710                .into_iter()
711                .map(|slot| LocalSlotInfo {
712                    name: slot.name,
713                    mutable: slot.mutable,
714                    scope_depth: slot.scope_depth,
715                })
716                .collect(),
717            binding_types: portable
718                .binding_types
719                .into_iter()
720                .map(|slot| BindingTypeSlot {
721                    name: slot.name,
722                    type_expr: slot.type_expr,
723                    nominal_type_names: slot.nominal_type_names,
724                })
725                .collect(),
726            references_outer_names: portable.references_outer_names,
727            #[cfg(debug_assertions)]
728            balance_depth: 0,
729            #[cfg(debug_assertions)]
730            balance_nonlinear: 0,
731        }
732    }
733
734    pub fn new() -> Self {
735        Self {
736            cache_id: next_chunk_cache_id(),
737            code: Vec::new(),
738            constants: Vec::new(),
739            constant_index: Some(HashMap::new()),
740            lines: Vec::new(),
741            columns: Vec::new(),
742            source_file: None,
743            current_col: 0,
744            functions: Vec::new(),
745            inline_cache_slots: BTreeMap::new(),
746            inline_cache_index: Vec::new(),
747            inline_caches: Arc::new(Mutex::new(Vec::new())),
748            constant_strings: Arc::new(Vec::new()),
749            local_slots: Vec::new(),
750            binding_types: Vec::new(),
751            references_outer_names: false,
752            #[cfg(debug_assertions)]
753            balance_depth: 0,
754            #[cfg(debug_assertions)]
755            balance_nonlinear: 0,
756        }
757    }
758
759    /// Set the current column for subsequent emit calls.
760    pub fn set_column(&mut self, col: u32) {
761        self.current_col = col;
762    }
763
764    /// Add a constant and return its index.
765    pub fn add_constant(&mut self, constant: Constant) -> u16 {
766        if self.constant_index.is_none() {
767            self.constant_index = Some(build_constant_index(&self.constants));
768        }
769        let index_map = self
770            .constant_index
771            .as_mut()
772            .expect("constant side index was just derived");
773        debug_assert!(
774            index_map.len() <= self.constants.len(),
775            "constant side index cannot outgrow the constant pool"
776        );
777        let key = ConstantKey::from(&constant);
778        if let Some(index) = index_map.get(&key) {
779            debug_assert!(
780                self.constants
781                    .get(*index as usize)
782                    .is_some_and(|existing| constants_identical(existing, &constant)),
783                "constant side index drifted from the constant pool"
784            );
785            return *index;
786        }
787        let idx = self.constants.len();
788        let idx = u16::try_from(idx).expect("constant pool exceeded u16 operand space");
789        index_map.insert(key, idx);
790        self.constants.push(constant);
791        idx
792    }
793
794    /// Emit a single-byte instruction.
795    pub fn emit(&mut self, op: Op, line: u32) {
796        #[cfg(debug_assertions)]
797        self.note_balance(op, 0);
798        let col = self.current_col;
799        let op_offset = self.code.len();
800        self.code.push(op as u8);
801        self.lines.push(line);
802        self.columns.push(col);
803        if is_adaptive_binary_op(op) {
804            self.register_inline_cache(op_offset);
805        }
806        if op_reads_outer_name(op) {
807            self.references_outer_names = true;
808        }
809    }
810
811    /// Emit an instruction with a u16 argument.
812    pub fn emit_u16(&mut self, op: Op, arg: u16, line: u32) {
813        #[cfg(debug_assertions)]
814        self.note_balance(op, arg);
815        let col = self.current_col;
816        let op_offset = self.code.len();
817        self.code.push(op as u8);
818        self.code.push((arg >> 8) as u8);
819        self.code.push((arg & 0xFF) as u8);
820        self.lines.push(line);
821        self.lines.push(line);
822        self.lines.push(line);
823        self.columns.push(col);
824        self.columns.push(col);
825        self.columns.push(col);
826        if matches!(
827            op,
828            Op::GetProperty | Op::GetPropertyOpt | Op::MethodCallSpread | Op::ConcatAssignLocal
829        ) {
830            self.register_inline_cache(op_offset);
831        }
832        if op_reads_outer_name(op) {
833            self.references_outer_names = true;
834        }
835    }
836
837    /// Emit a local-slot property assignment:
838    /// opcode + u16 property constant index + u16 local slot index.
839    pub fn emit_set_local_slot_property(&mut self, prop_idx: u16, slot: u16, line: u32) {
840        #[cfg(debug_assertions)]
841        self.note_balance(Op::SetLocalSlotProperty, 0);
842        let col = self.current_col;
843        self.code.push(Op::SetLocalSlotProperty as u8);
844        self.code.push((prop_idx >> 8) as u8);
845        self.code.push((prop_idx & 0xFF) as u8);
846        self.code.push((slot >> 8) as u8);
847        self.code.push((slot & 0xFF) as u8);
848        for _ in 0..5 {
849            self.lines.push(line);
850            self.columns.push(col);
851        }
852    }
853
854    /// Emit an instruction with a u8 argument.
855    pub fn emit_u8(&mut self, op: Op, arg: u8, line: u32) {
856        #[cfg(debug_assertions)]
857        self.note_balance(op, arg as u16);
858        let col = self.current_col;
859        let op_offset = self.code.len();
860        self.code.push(op as u8);
861        self.code.push(arg);
862        self.lines.push(line);
863        self.lines.push(line);
864        self.columns.push(col);
865        self.columns.push(col);
866        if matches!(op, Op::Call) {
867            self.register_inline_cache(op_offset);
868        }
869        if op_reads_outer_name(op) {
870            self.references_outer_names = true;
871        }
872    }
873
874    /// Emit a direct builtin call.
875    pub fn emit_call_builtin(
876        &mut self,
877        id: crate::BuiltinId,
878        name_idx: u16,
879        arg_count: u8,
880        line: u32,
881    ) {
882        #[cfg(debug_assertions)]
883        self.note_balance(Op::CallBuiltin, arg_count as u16);
884        let col = self.current_col;
885        let op_offset = self.code.len();
886        self.code.push(Op::CallBuiltin as u8);
887        self.code.extend_from_slice(&id.raw().to_be_bytes());
888        self.code.push((name_idx >> 8) as u8);
889        self.code.push((name_idx & 0xFF) as u8);
890        self.code.push(arg_count);
891        for _ in 0..12 {
892            self.lines.push(line);
893            self.columns.push(col);
894        }
895        self.register_inline_cache(op_offset);
896        self.references_outer_names = true;
897    }
898
899    /// Emit a direct builtin spread call.
900    pub fn emit_call_builtin_spread(&mut self, id: crate::BuiltinId, name_idx: u16, line: u32) {
901        #[cfg(debug_assertions)]
902        self.note_balance(Op::CallBuiltinSpread, 0);
903        let col = self.current_col;
904        self.code.push(Op::CallBuiltinSpread as u8);
905        self.code.extend_from_slice(&id.raw().to_be_bytes());
906        self.code.push((name_idx >> 8) as u8);
907        self.code.push((name_idx & 0xFF) as u8);
908        for _ in 0..11 {
909            self.lines.push(line);
910            self.columns.push(col);
911        }
912        self.references_outer_names = true;
913    }
914
915    /// Emit a method call: op + u16 (method name) + u8 (arg count).
916    pub fn emit_method_call(&mut self, name_idx: u16, arg_count: u8, line: u32) {
917        self.emit_method_call_inner(Op::MethodCall, name_idx, arg_count, line);
918    }
919
920    /// Emit an optional method call (?.) — returns nil if receiver is nil.
921    pub fn emit_method_call_opt(&mut self, name_idx: u16, arg_count: u8, line: u32) {
922        self.emit_method_call_inner(Op::MethodCallOpt, name_idx, arg_count, line);
923    }
924
925    fn emit_method_call_inner(&mut self, op: Op, name_idx: u16, arg_count: u8, line: u32) {
926        #[cfg(debug_assertions)]
927        self.note_balance(op, arg_count as u16);
928        let col = self.current_col;
929        let op_offset = self.code.len();
930        self.code.push(op as u8);
931        self.code.push((name_idx >> 8) as u8);
932        self.code.push((name_idx & 0xFF) as u8);
933        self.code.push(arg_count);
934        self.lines.push(line);
935        self.lines.push(line);
936        self.lines.push(line);
937        self.lines.push(line);
938        self.columns.push(col);
939        self.columns.push(col);
940        self.columns.push(col);
941        self.columns.push(col);
942        self.register_inline_cache(op_offset);
943    }
944
945    /// Current code offset (for jump patching).
946    pub fn current_offset(&self) -> usize {
947        self.code.len()
948    }
949
950    /// Emit a jump instruction with a placeholder offset. Returns the position to patch.
951    pub fn emit_jump(&mut self, op: Op, line: u32) -> usize {
952        #[cfg(debug_assertions)]
953        self.note_balance(op, 0);
954        let col = self.current_col;
955        self.code.push(op as u8);
956        let patch_pos = self.code.len();
957        self.code.push(0xFF);
958        self.code.push(0xFF);
959        self.lines.push(line);
960        self.lines.push(line);
961        self.lines.push(line);
962        self.columns.push(col);
963        self.columns.push(col);
964        self.columns.push(col);
965        patch_pos
966    }
967
968    /// Patch a jump instruction at the given position to jump to the current offset.
969    pub fn patch_jump(&mut self, patch_pos: usize) {
970        let target = self.code.len() as u16;
971        self.code[patch_pos] = (target >> 8) as u8;
972        self.code[patch_pos + 1] = (target & 0xFF) as u8;
973    }
974
975    /// Patch a jump to a specific target position.
976    pub fn patch_jump_to(&mut self, patch_pos: usize, target: usize) {
977        let target = target as u16;
978        self.code[patch_pos] = (target >> 8) as u8;
979        self.code[patch_pos + 1] = (target & 0xFF) as u8;
980    }
981
982    /// Read a u16 argument at the given position.
983    pub fn read_u16(&self, pos: usize) -> u16 {
984        ((self.code[pos] as u16) << 8) | (self.code[pos + 1] as u16)
985    }
986
987    /// Fold one just-emitted opcode into the compile-time operand-stack
988    /// balance model (issue #2622). See [`op_stack_delta`] for the
989    /// linear-vs-non-linear classification.
990    #[cfg(debug_assertions)]
991    fn note_balance(&mut self, op: Op, count: u16) {
992        match op_stack_delta(op, count) {
993            Some(delta) => self.balance_depth += delta,
994            None => self.balance_nonlinear += 1,
995        }
996    }
997
998    fn register_inline_cache(&mut self, op_offset: usize) {
999        if self.inline_cache_slots.contains_key(&op_offset) {
1000            return;
1001        }
1002        let mut entries = self.inline_caches.lock();
1003        let slot = entries.len();
1004        entries.push(InlineCacheEntry::Empty);
1005        self.inline_cache_slots.insert(op_offset, slot);
1006        Self::write_inline_cache_index(&mut self.inline_cache_index, op_offset, slot);
1007    }
1008
1009    /// Fast-path side-table writer. Pulled out as an associated fn so both
1010    /// the live emit path and [`Chunk::from_cached`] share the same growth
1011    /// strategy. Cache slots fit comfortably in `u32` because the slot count
1012    /// is bounded by the cacheable-opcode count in `code`.
1013    fn write_inline_cache_index(index: &mut Vec<u32>, op_offset: usize, slot: usize) {
1014        if op_offset >= index.len() {
1015            index.resize(op_offset + 1, NO_INLINE_CACHE_SLOT);
1016        }
1017        index[op_offset] = slot as u32;
1018    }
1019
1020    /// Look up the inline-cache slot for the opcode at `op_offset`. This is
1021    /// called on every dispatch of an adaptive binary op (Add/Sub/Mul/Div/
1022    /// Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq), `Op::Call`, `Op::MethodCall`
1023    /// (and `MethodCallOpt`/`MethodCallSpread`), and `Op::GetProperty`
1024    /// (`GetPropertyOpt`). Backed by [`Chunk::inline_cache_index`] — a flat
1025    /// `Vec<u32>` indexed by code offset — so the lookup is a single bounds-
1026    /// checked array read instead of the prior `BTreeMap::get` which walked
1027    /// internal nodes for every dispatched op.
1028    #[inline]
1029    pub(crate) fn inline_cache_slot(&self, op_offset: usize) -> Option<usize> {
1030        match self.inline_cache_index.get(op_offset).copied() {
1031            None | Some(NO_INLINE_CACHE_SLOT) => None,
1032            Some(slot) => Some(slot as usize),
1033        }
1034    }
1035
1036    pub(crate) fn inline_cache_slot_count(&self) -> usize {
1037        self.inline_cache_slots.len()
1038    }
1039
1040    pub(crate) fn cache_id(&self) -> u64 {
1041        self.cache_id
1042    }
1043
1044    /// Pre-optimization control path: the `BTreeMap`-backed lookup the
1045    /// dispatcher used before the flat `Vec<u32>` side-table. Exposed
1046    /// only behind the `vm-bench-internals` feature so the criterion
1047    /// microbench can A/B the two paths inside one binary on identical
1048    /// hardware. The production hot path must keep using
1049    /// [`Chunk::inline_cache_slot`].
1050    #[cfg(feature = "vm-bench-internals")]
1051    pub fn inline_cache_slot_via_btreemap_for_bench(&self, op_offset: usize) -> Option<usize> {
1052        self.inline_cache_slots.get(&op_offset).copied()
1053    }
1054
1055    /// Returns a shared string for a `Constant::String` at the given pool
1056    /// index, materializing it on first access and caching for reuse.
1057    /// Returns `None` when the constant at `idx` is not a string (the
1058    /// caller should fall back to the regular `Constant` match).
1059    pub(crate) fn constant_string_rc(&self, idx: usize) -> Option<crate::value::HarnStr> {
1060        let slot = match self.constant_strings.get(idx) {
1061            Some(slot) => slot,
1062            // Constant appended after the cache was sized (a chunk still
1063            // being emitted into, e.g. tests executing hand-built chunks):
1064            // correct but uncached.
1065            None => {
1066                return match self.constants.get(idx)? {
1067                    Constant::String(s) => Some(crate::value::HarnStr::from(s.as_str())),
1068                    _ => None,
1069                }
1070            }
1071        };
1072        if let Some(existing) = slot.get() {
1073            return Some(existing.clone());
1074        }
1075        let materialized = match self.constants.get(idx)? {
1076            Constant::String(s) => crate::value::HarnStr::from(s.as_str()),
1077            _ => return None,
1078        };
1079        // A concurrent initializer stored an identical value; either wins.
1080        let _ = slot.set(materialized.clone());
1081        Some(materialized)
1082    }
1083
1084    /// Test helper for the chunk-local scratch inline cache. Production
1085    /// dispatch reads VM-local cache sets through `Vm`.
1086    #[inline]
1087    #[cfg(test)]
1088    pub(crate) fn peek_adaptive_binary_cache(
1089        &self,
1090        slot: usize,
1091    ) -> Option<(AdaptiveBinaryOp, AdaptiveBinaryState)> {
1092        match self.inline_caches.lock().get(slot)? {
1093            &InlineCacheEntry::AdaptiveBinary { op, state } => Some((op, state)),
1094            _ => None,
1095        }
1096    }
1097
1098    /// Test helper for the chunk-local scratch inline cache. Production
1099    /// dispatch reads VM-local cache sets through `Vm`.
1100    #[inline]
1101    #[cfg(test)]
1102    pub(crate) fn peek_method_cache(&self, slot: usize) -> Option<(u16, usize, MethodCacheTarget)> {
1103        match self.inline_caches.lock().get(slot)? {
1104            &InlineCacheEntry::Method {
1105                name_idx,
1106                argc,
1107                target,
1108            } => Some((name_idx, argc, target)),
1109            _ => None,
1110        }
1111    }
1112
1113    /// Test helper for the chunk-local scratch inline cache. Production
1114    /// dispatch reads VM-local cache sets through `Vm`.
1115    #[inline]
1116    #[cfg(test)]
1117    pub(crate) fn peek_property_cache(&self, slot: usize) -> Option<(u16, PropertyCacheTarget)> {
1118        match self.inline_caches.lock().get(slot)? {
1119            InlineCacheEntry::Property { name_idx, target } => Some((*name_idx, target.clone())),
1120            _ => None,
1121        }
1122    }
1123
1124    /// Test helper for the chunk-local scratch inline cache. Production
1125    /// dispatch reads VM-local cache sets through `Vm`.
1126    #[inline]
1127    #[cfg(test)]
1128    pub(crate) fn peek_direct_call_state(&self, slot: usize) -> Option<DirectCallState> {
1129        match self.inline_caches.lock().get(slot)? {
1130            InlineCacheEntry::DirectCall { state } => Some(state.clone()),
1131            _ => None,
1132        }
1133    }
1134
1135    #[cfg(test)]
1136    pub(crate) fn set_inline_cache_entry(&self, slot: usize, entry: InlineCacheEntry) {
1137        if let Some(existing) = self.inline_caches.lock().get_mut(slot) {
1138            *existing = entry;
1139        }
1140    }
1141
1142    pub fn freeze_for_cache(&self) -> CachedChunk {
1143        CachedChunk {
1144            code: self.code.clone(),
1145            constants: self.constants.clone(),
1146            lines: self.lines.clone(),
1147            columns: self.columns.clone(),
1148            source_file: self.source_file.clone(),
1149            current_col: self.current_col,
1150            functions: self
1151                .functions
1152                .iter()
1153                .map(|function| function.freeze_for_cache())
1154                .collect(),
1155            inline_cache_slots: self.inline_cache_slots.clone(),
1156            local_slots: self.local_slots.clone(),
1157            binding_types: self.binding_types.clone(),
1158            references_outer_names: self.references_outer_names,
1159        }
1160    }
1161
1162    pub fn from_cached(cached: CachedChunk) -> Self {
1163        let CachedChunk {
1164            code,
1165            constants,
1166            lines,
1167            columns,
1168            source_file,
1169            current_col,
1170            functions,
1171            inline_cache_slots,
1172            local_slots,
1173            binding_types,
1174            references_outer_names,
1175        } = cached;
1176        let inline_cache_count = inline_cache_slots.len();
1177        let constants_count = constants.len();
1178        // Project the cached `BTreeMap<op_offset, slot>` into the flat
1179        // dispatch-side lookup table. Sized to `code.len()` so the hottest
1180        // hot opcodes (binary ops at the end of a long chunk) still hit the
1181        // fast-path bounds check rather than falling through to the
1182        // none-found branch. The size is bounded by code length, so the
1183        // memory footprint is tiny — a few KB for typical chunks.
1184        let mut inline_cache_index = Vec::new();
1185        inline_cache_index.resize(code.len(), NO_INLINE_CACHE_SLOT);
1186        for (&op_offset, &slot) in &inline_cache_slots {
1187            if op_offset < inline_cache_index.len() {
1188                inline_cache_index[op_offset] = slot as u32;
1189            }
1190        }
1191        Self {
1192            cache_id: next_chunk_cache_id(),
1193            code,
1194            constants,
1195            // Derived on demand: a cache-loaded chunk is executed, not appended to.
1196            constant_index: None,
1197            lines,
1198            columns,
1199            source_file,
1200            current_col,
1201            functions: functions
1202                .into_iter()
1203                .map(|function| Arc::new(CompiledFunction::from_cached(function)))
1204                .collect(),
1205            inline_cache_slots,
1206            inline_cache_index,
1207            inline_caches: Arc::new(Mutex::new(vec![
1208                InlineCacheEntry::Empty;
1209                inline_cache_count
1210            ])),
1211            constant_strings: Arc::new(
1212                (0..constants_count)
1213                    .map(|_| std::sync::OnceLock::new())
1214                    .collect(),
1215            ),
1216            local_slots,
1217            binding_types,
1218            references_outer_names,
1219            #[cfg(debug_assertions)]
1220            balance_depth: 0,
1221            #[cfg(debug_assertions)]
1222            balance_nonlinear: 0,
1223        }
1224    }
1225
1226    #[cfg(test)]
1227    pub(crate) fn add_local_slot(
1228        &mut self,
1229        name: String,
1230        mutable: bool,
1231        scope_depth: usize,
1232    ) -> u16 {
1233        let idx = self.local_slots.len();
1234        self.local_slots.push(LocalSlotInfo {
1235            name,
1236            mutable,
1237            scope_depth,
1238        });
1239        idx as u16
1240    }
1241
1242    /// Read a u64 argument at the given position.
1243    pub fn read_u64(&self, pos: usize) -> u64 {
1244        u64::from_be_bytes([
1245            self.code[pos],
1246            self.code[pos + 1],
1247            self.code[pos + 2],
1248            self.code[pos + 3],
1249            self.code[pos + 4],
1250            self.code[pos + 5],
1251            self.code[pos + 6],
1252            self.code[pos + 7],
1253        ])
1254    }
1255
1256    /// Disassemble the chunk for debugging. The per-opcode rendering is
1257    /// macro-generated alongside the dispatch tables in
1258    /// `crate::vm::ops` — see [`Self::disassemble_op`].
1259    pub fn disassemble(&self, name: &str) -> String {
1260        let mut out = format!("== {name} ==\n");
1261        let mut ip = 0;
1262        while ip < self.code.len() {
1263            let op_start = ip;
1264            let op_byte = self.code[ip];
1265            let line = self.lines.get(ip).copied().unwrap_or(0);
1266            out.push_str(&format!("{ip:04} [{line:>4}] "));
1267            ip += 1;
1268
1269            if let Some(op) = Op::from_byte(op_byte) {
1270                self.disassemble_op(op, &mut ip, &mut out);
1271                debug_assert_eq!(
1272                    ip,
1273                    op_start + op.instruction_len(),
1274                    "disassembler operand width drifted for {}",
1275                    op.name(),
1276                );
1277            } else {
1278                out.push_str(&format!("UNKNOWN(0x{op_byte:02x})\n"));
1279            }
1280        }
1281        out
1282    }
1283}
1284
1285impl Default for Chunk {
1286    fn default() -> Self {
1287        Self::new()
1288    }
1289}
1290
1291#[cfg(test)]
1292#[path = "chunk_tests.rs"]
1293mod tests;