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| param.type_expr.is_some())
456    }
457
458    /// Returns just the parameter names — convenience for code paths that
459    /// don't care about types or defaults.
460    pub fn param_names(&self) -> impl Iterator<Item = &str> {
461        self.params.iter().map(|p| p.name.as_str())
462    }
463
464    /// Number of required parameters (those before `default_start`).
465    pub fn required_param_count(&self) -> usize {
466        self.default_start.unwrap_or(self.params.len())
467    }
468
469    /// Minimum number of caller-supplied arguments needed to enter the function.
470    pub(crate) fn minimum_arg_count(&self) -> usize {
471        if self.has_rest_param {
472            self.required_param_count()
473                .min(self.params.len().saturating_sub(1))
474        } else {
475            self.required_param_count()
476        }
477    }
478
479    /// Argument count visible to callee bytecode via `GetArgc`.
480    pub(crate) fn callee_arg_count(&self, supplied: usize) -> usize {
481        if self.has_rest_param {
482            supplied
483        } else {
484            supplied.min(self.params.len())
485        }
486    }
487
488    pub fn declares_type_param(&self, name: &str) -> bool {
489        self.type_params.iter().any(|param| param == name)
490    }
491
492    pub fn has_nominal_type(&self, name: &str) -> bool {
493        self.nominal_type_names.iter().any(|ty| ty == name)
494    }
495
496    pub(crate) fn freeze_for_cache(&self) -> CachedCompiledFunction {
497        CachedCompiledFunction {
498            name: self.name.to_string(),
499            type_params: self.type_params.clone(),
500            nominal_type_names: self.nominal_type_names.clone(),
501            params: self
502                .params
503                .iter()
504                .map(ParamSlot::freeze_for_cache)
505                .collect(),
506            default_start: self.default_start,
507            chunk: self.chunk.freeze_for_cache(),
508            is_generator: self.is_generator,
509            is_stream: self.is_stream,
510            has_rest_param: self.has_rest_param,
511            has_runtime_type_checks: self.has_runtime_type_checks,
512        }
513    }
514
515    pub(crate) fn from_cached(cached: CachedCompiledFunction) -> Self {
516        Self {
517            name: crate::value::HarnStr::from(cached.name),
518            type_params: cached.type_params,
519            nominal_type_names: cached.nominal_type_names,
520            params: cached
521                .params
522                .into_iter()
523                .map(CachedParamSlot::thaw)
524                .collect(),
525            default_start: cached.default_start,
526            chunk: Arc::new(Chunk::from_cached(cached.chunk)),
527            is_generator: cached.is_generator,
528            is_stream: cached.is_stream,
529            has_rest_param: cached.has_rest_param,
530            has_runtime_type_checks: cached.has_runtime_type_checks,
531        }
532    }
533}
534
535/// Net operand-stack effect (`pushes - pops`) of one emitted opcode, for
536/// the debug-build balance assertion (issue #2622). `count` is the opcode's
537/// variadic arity when that arity is the emit-call argument (`BuildList`
538/// length, `Call` argc, …) and `0` otherwise.
539///
540/// `Some(delta)` means the effect is exactly modeled. `None` marks an
541/// opcode a straight-line running sum can't track — control flow that
542/// branches or terminates (`Jump*`, `Return`, `Throw`, `TailCall`),
543/// async/handler ops, and variadic ops whose arity rides in a raw operand
544/// byte rather than the emit argument (`BuildEnum`, `MatchEnum`). Such an
545/// opcode taints its enclosing statement as non-exact, so the assertion
546/// skips it instead of risking a false trip.
547///
548/// The `match` is intentionally exhaustive with no `_` arm: adding an
549/// opcode forces a classification here (a compile error otherwise), so the
550/// balance model can't silently drift out of sync with the instruction set.
551#[cfg(debug_assertions)]
552fn op_stack_delta(op: Op, count: u16) -> Option<i32> {
553    use Op::*;
554    let count = count as i32;
555    Some(match op {
556        // Push one value.
557        Constant | Nil | True | False | RootHarness | GetVar | GetArgc | GetLocalSlot | Closure
558        | Dup => 1,
559        // Consume one value (into a binding / property / discard). `SetVar`,
560        // `SetProperty` and the local-slot stores read their target by name
561        // or slot index, so they only pop the value being stored.
562        DefLet | DefVar | DefCell | SetVar | DefLocalSlot | SetLocalSlot | SetProperty
563        | SetLocalSlotProperty | ConcatAssignLocal | Pop => -1,
564        // Value-preserving: unary ops, by-name lookups/checks, and scope /
565        // iterator / exception-handler bookkeeping (the last three touch
566        // side stacks, not the operand stack).
567        AssertBindingType => 0,
568        Negate | Not | GetProperty | GetPropertyOpt | CheckType | TryUnwrap | TryWrapOk | Swap
569        | PushScope | PopScope | PopIterator | PopHandler => 0,
570        // Pop two, push one.
571        Add | Sub | Mul | Div | Mod | Pow | AddInt | SubInt | MulInt | DivInt | ModInt
572        | AddFloat | SubFloat | MulFloat | DivFloat | ModFloat | Equal | NotEqual | Less
573        | Greater | LessEqual | GreaterEqual | EqualInt | NotEqualInt | LessInt | GreaterInt
574        | LessEqualInt | GreaterEqualInt | EqualFloat | NotEqualFloat | LessFloat
575        | GreaterFloat | LessEqualFloat | GreaterEqualFloat | EqualBool | NotEqualBool
576        | EqualString | NotEqualString | Contains | Subscript | SubscriptOpt => -1,
577        // `IterInit` consumes the iterable and pushes nothing (the iterator
578        // lives on a side stack).
579        IterInit => -1,
580        // Net -2: `Slice` pops object/start/end and pushes one value;
581        // subscript stores pop value/index and read the target from bytecode.
582        Slice | SetSubscript | SetLocalSlotSubscript => -2,
583        // Variadic whose arity is the emit argument: pop `count`, push one.
584        BuildList | Concat | CallBuiltin => 1 - count,
585        BuildDict => 1 - 2 * count,
586        // Calls also pop the callee/receiver beneath the args.
587        Call | MethodCall | MethodCallOpt => -count,
588        // Non-linear (see doc comment): branches, terminators, async/handler
589        // ops, and variadic ops whose arity isn't the emit argument.
590        Jump
591        | JumpIfFalse
592        | JumpIfTrue
593        | IterNext
594        | Return
595        | TailCall
596        | Throw
597        | TryCatchSetup
598        | Spawn
599        | Pipe
600        | Parallel
601        | ParallelMap
602        | ParallelMapStream
603        | ParallelSettle
604        | SyncMutexEnter
605        | SyncMutexEnterKeyed
606        | TaskScopeEnter
607        | TaskScopeExit
608        | Import
609        | SelectiveImport
610        | NamespaceImport
611        | NamespaceImportMembers
612        | DeadlineSetup
613        | DeadlineEnd
614        | BuildEnum
615        | MatchEnum
616        | Yield
617        | CallSpread
618        | CallBuiltinSpread
619        | MethodCallSpread => return None,
620    })
621}
622
623impl Chunk {
624    /// Attach native-only caches and runtime guards to a portable program
625    /// image. The compiler never constructs these process-local structures.
626    pub fn from_portable(portable: harn_kernel::Chunk) -> Self {
627        let mut inline_cache_slots = BTreeMap::new();
628        let mut offset = 0usize;
629        while offset < portable.code.len() {
630            let Some(op) = Op::from_byte(portable.code[offset]) else {
631                break;
632            };
633            if is_adaptive_binary_op(op)
634                || matches!(
635                    op,
636                    Op::GetProperty
637                        | Op::GetPropertyOpt
638                        | Op::MethodCall
639                        | Op::MethodCallOpt
640                        | Op::MethodCallSpread
641                        | Op::ConcatAssignLocal
642                        | Op::Call
643                        | Op::CallBuiltin
644                )
645            {
646                let slot = inline_cache_slots.len();
647                inline_cache_slots.insert(offset, slot);
648            }
649            let Some(width) = harn_kernel::program::instruction_len(op, &portable.code[offset..])
650            else {
651                break;
652            };
653            offset = offset.saturating_add(width);
654        }
655
656        let code = portable.code;
657        let constants = portable
658            .constants
659            .into_iter()
660            .map(|constant| match constant {
661                harn_kernel::Constant::Int(value) => Constant::Int(value),
662                harn_kernel::Constant::Float(value) => Constant::Float(value),
663                harn_kernel::Constant::String(value) => Constant::String(value),
664                harn_kernel::Constant::Bool(value) => Constant::Bool(value),
665                harn_kernel::Constant::Nil => Constant::Nil,
666                harn_kernel::Constant::Duration(value) => Constant::Duration(value),
667            })
668            .collect::<Vec<_>>();
669        let constant_count = constants.len();
670        let inline_cache_count = inline_cache_slots.len();
671        let mut inline_cache_index = vec![NO_INLINE_CACHE_SLOT; code.len()];
672        for (&op_offset, &slot) in &inline_cache_slots {
673            inline_cache_index[op_offset] = slot as u32;
674        }
675
676        Self {
677            cache_id: next_chunk_cache_id(),
678            code,
679            constants,
680            constant_index: None,
681            lines: portable.lines,
682            columns: portable.columns,
683            source_file: portable.source_file,
684            current_col: portable.current_col,
685            functions: portable
686                .functions
687                .into_iter()
688                .map(|function| {
689                    Arc::new(CompiledFunction::from_portable(function.as_ref().clone()))
690                })
691                .collect(),
692            inline_cache_slots,
693            inline_cache_index,
694            inline_caches: Arc::new(Mutex::new(vec![
695                InlineCacheEntry::Empty;
696                inline_cache_count
697            ])),
698            constant_strings: Arc::new(
699                (0..constant_count)
700                    .map(|_| std::sync::OnceLock::new())
701                    .collect(),
702            ),
703            local_slots: portable
704                .local_slots
705                .into_iter()
706                .map(|slot| LocalSlotInfo {
707                    name: slot.name,
708                    mutable: slot.mutable,
709                    scope_depth: slot.scope_depth,
710                })
711                .collect(),
712            binding_types: portable
713                .binding_types
714                .into_iter()
715                .map(|slot| BindingTypeSlot {
716                    name: slot.name,
717                    type_expr: slot.type_expr,
718                    nominal_type_names: slot.nominal_type_names,
719                })
720                .collect(),
721            references_outer_names: portable.references_outer_names,
722            #[cfg(debug_assertions)]
723            balance_depth: 0,
724            #[cfg(debug_assertions)]
725            balance_nonlinear: 0,
726        }
727    }
728
729    pub fn new() -> Self {
730        Self {
731            cache_id: next_chunk_cache_id(),
732            code: Vec::new(),
733            constants: Vec::new(),
734            constant_index: Some(HashMap::new()),
735            lines: Vec::new(),
736            columns: Vec::new(),
737            source_file: None,
738            current_col: 0,
739            functions: Vec::new(),
740            inline_cache_slots: BTreeMap::new(),
741            inline_cache_index: Vec::new(),
742            inline_caches: Arc::new(Mutex::new(Vec::new())),
743            constant_strings: Arc::new(Vec::new()),
744            local_slots: Vec::new(),
745            binding_types: Vec::new(),
746            references_outer_names: false,
747            #[cfg(debug_assertions)]
748            balance_depth: 0,
749            #[cfg(debug_assertions)]
750            balance_nonlinear: 0,
751        }
752    }
753
754    /// Set the current column for subsequent emit calls.
755    pub fn set_column(&mut self, col: u32) {
756        self.current_col = col;
757    }
758
759    /// Add a constant and return its index.
760    pub fn add_constant(&mut self, constant: Constant) -> u16 {
761        if self.constant_index.is_none() {
762            self.constant_index = Some(build_constant_index(&self.constants));
763        }
764        let index_map = self
765            .constant_index
766            .as_mut()
767            .expect("constant side index was just derived");
768        debug_assert!(
769            index_map.len() <= self.constants.len(),
770            "constant side index cannot outgrow the constant pool"
771        );
772        let key = ConstantKey::from(&constant);
773        if let Some(index) = index_map.get(&key) {
774            debug_assert!(
775                self.constants
776                    .get(*index as usize)
777                    .is_some_and(|existing| constants_identical(existing, &constant)),
778                "constant side index drifted from the constant pool"
779            );
780            return *index;
781        }
782        let idx = self.constants.len();
783        let idx = u16::try_from(idx).expect("constant pool exceeded u16 operand space");
784        index_map.insert(key, idx);
785        self.constants.push(constant);
786        idx
787    }
788
789    /// Emit a single-byte instruction.
790    pub fn emit(&mut self, op: Op, line: u32) {
791        #[cfg(debug_assertions)]
792        self.note_balance(op, 0);
793        let col = self.current_col;
794        let op_offset = self.code.len();
795        self.code.push(op as u8);
796        self.lines.push(line);
797        self.columns.push(col);
798        if is_adaptive_binary_op(op) {
799            self.register_inline_cache(op_offset);
800        }
801        if op_reads_outer_name(op) {
802            self.references_outer_names = true;
803        }
804    }
805
806    /// Emit an instruction with a u16 argument.
807    pub fn emit_u16(&mut self, op: Op, arg: u16, line: u32) {
808        #[cfg(debug_assertions)]
809        self.note_balance(op, arg);
810        let col = self.current_col;
811        let op_offset = self.code.len();
812        self.code.push(op as u8);
813        self.code.push((arg >> 8) as u8);
814        self.code.push((arg & 0xFF) as u8);
815        self.lines.push(line);
816        self.lines.push(line);
817        self.lines.push(line);
818        self.columns.push(col);
819        self.columns.push(col);
820        self.columns.push(col);
821        if matches!(
822            op,
823            Op::GetProperty | Op::GetPropertyOpt | Op::MethodCallSpread | Op::ConcatAssignLocal
824        ) {
825            self.register_inline_cache(op_offset);
826        }
827        if op_reads_outer_name(op) {
828            self.references_outer_names = true;
829        }
830    }
831
832    /// Emit a local-slot property assignment:
833    /// opcode + u16 property constant index + u16 local slot index.
834    pub fn emit_set_local_slot_property(&mut self, prop_idx: u16, slot: u16, line: u32) {
835        #[cfg(debug_assertions)]
836        self.note_balance(Op::SetLocalSlotProperty, 0);
837        let col = self.current_col;
838        self.code.push(Op::SetLocalSlotProperty as u8);
839        self.code.push((prop_idx >> 8) as u8);
840        self.code.push((prop_idx & 0xFF) as u8);
841        self.code.push((slot >> 8) as u8);
842        self.code.push((slot & 0xFF) as u8);
843        for _ in 0..5 {
844            self.lines.push(line);
845            self.columns.push(col);
846        }
847    }
848
849    /// Emit an instruction with a u8 argument.
850    pub fn emit_u8(&mut self, op: Op, arg: u8, line: u32) {
851        #[cfg(debug_assertions)]
852        self.note_balance(op, arg as u16);
853        let col = self.current_col;
854        let op_offset = self.code.len();
855        self.code.push(op as u8);
856        self.code.push(arg);
857        self.lines.push(line);
858        self.lines.push(line);
859        self.columns.push(col);
860        self.columns.push(col);
861        if matches!(op, Op::Call) {
862            self.register_inline_cache(op_offset);
863        }
864        if op_reads_outer_name(op) {
865            self.references_outer_names = true;
866        }
867    }
868
869    /// Emit a direct builtin call.
870    pub fn emit_call_builtin(
871        &mut self,
872        id: crate::BuiltinId,
873        name_idx: u16,
874        arg_count: u8,
875        line: u32,
876    ) {
877        #[cfg(debug_assertions)]
878        self.note_balance(Op::CallBuiltin, arg_count as u16);
879        let col = self.current_col;
880        let op_offset = self.code.len();
881        self.code.push(Op::CallBuiltin as u8);
882        self.code.extend_from_slice(&id.raw().to_be_bytes());
883        self.code.push((name_idx >> 8) as u8);
884        self.code.push((name_idx & 0xFF) as u8);
885        self.code.push(arg_count);
886        for _ in 0..12 {
887            self.lines.push(line);
888            self.columns.push(col);
889        }
890        self.register_inline_cache(op_offset);
891        self.references_outer_names = true;
892    }
893
894    /// Emit a direct builtin spread call.
895    pub fn emit_call_builtin_spread(&mut self, id: crate::BuiltinId, name_idx: u16, line: u32) {
896        #[cfg(debug_assertions)]
897        self.note_balance(Op::CallBuiltinSpread, 0);
898        let col = self.current_col;
899        self.code.push(Op::CallBuiltinSpread as u8);
900        self.code.extend_from_slice(&id.raw().to_be_bytes());
901        self.code.push((name_idx >> 8) as u8);
902        self.code.push((name_idx & 0xFF) as u8);
903        for _ in 0..11 {
904            self.lines.push(line);
905            self.columns.push(col);
906        }
907        self.references_outer_names = true;
908    }
909
910    /// Emit a method call: op + u16 (method name) + u8 (arg count).
911    pub fn emit_method_call(&mut self, name_idx: u16, arg_count: u8, line: u32) {
912        self.emit_method_call_inner(Op::MethodCall, name_idx, arg_count, line);
913    }
914
915    /// Emit an optional method call (?.) — returns nil if receiver is nil.
916    pub fn emit_method_call_opt(&mut self, name_idx: u16, arg_count: u8, line: u32) {
917        self.emit_method_call_inner(Op::MethodCallOpt, name_idx, arg_count, line);
918    }
919
920    fn emit_method_call_inner(&mut self, op: Op, name_idx: u16, arg_count: u8, line: u32) {
921        #[cfg(debug_assertions)]
922        self.note_balance(op, arg_count as u16);
923        let col = self.current_col;
924        let op_offset = self.code.len();
925        self.code.push(op as u8);
926        self.code.push((name_idx >> 8) as u8);
927        self.code.push((name_idx & 0xFF) as u8);
928        self.code.push(arg_count);
929        self.lines.push(line);
930        self.lines.push(line);
931        self.lines.push(line);
932        self.lines.push(line);
933        self.columns.push(col);
934        self.columns.push(col);
935        self.columns.push(col);
936        self.columns.push(col);
937        self.register_inline_cache(op_offset);
938    }
939
940    /// Current code offset (for jump patching).
941    pub fn current_offset(&self) -> usize {
942        self.code.len()
943    }
944
945    /// Emit a jump instruction with a placeholder offset. Returns the position to patch.
946    pub fn emit_jump(&mut self, op: Op, line: u32) -> usize {
947        #[cfg(debug_assertions)]
948        self.note_balance(op, 0);
949        let col = self.current_col;
950        self.code.push(op as u8);
951        let patch_pos = self.code.len();
952        self.code.push(0xFF);
953        self.code.push(0xFF);
954        self.lines.push(line);
955        self.lines.push(line);
956        self.lines.push(line);
957        self.columns.push(col);
958        self.columns.push(col);
959        self.columns.push(col);
960        patch_pos
961    }
962
963    /// Patch a jump instruction at the given position to jump to the current offset.
964    pub fn patch_jump(&mut self, patch_pos: usize) {
965        let target = self.code.len() as u16;
966        self.code[patch_pos] = (target >> 8) as u8;
967        self.code[patch_pos + 1] = (target & 0xFF) as u8;
968    }
969
970    /// Patch a jump to a specific target position.
971    pub fn patch_jump_to(&mut self, patch_pos: usize, target: usize) {
972        let target = target as u16;
973        self.code[patch_pos] = (target >> 8) as u8;
974        self.code[patch_pos + 1] = (target & 0xFF) as u8;
975    }
976
977    /// Read a u16 argument at the given position.
978    pub fn read_u16(&self, pos: usize) -> u16 {
979        ((self.code[pos] as u16) << 8) | (self.code[pos + 1] as u16)
980    }
981
982    /// Fold one just-emitted opcode into the compile-time operand-stack
983    /// balance model (issue #2622). See [`op_stack_delta`] for the
984    /// linear-vs-non-linear classification.
985    #[cfg(debug_assertions)]
986    fn note_balance(&mut self, op: Op, count: u16) {
987        match op_stack_delta(op, count) {
988            Some(delta) => self.balance_depth += delta,
989            None => self.balance_nonlinear += 1,
990        }
991    }
992
993    fn register_inline_cache(&mut self, op_offset: usize) {
994        if self.inline_cache_slots.contains_key(&op_offset) {
995            return;
996        }
997        let mut entries = self.inline_caches.lock();
998        let slot = entries.len();
999        entries.push(InlineCacheEntry::Empty);
1000        self.inline_cache_slots.insert(op_offset, slot);
1001        Self::write_inline_cache_index(&mut self.inline_cache_index, op_offset, slot);
1002    }
1003
1004    /// Fast-path side-table writer. Pulled out as an associated fn so both
1005    /// the live emit path and [`Chunk::from_cached`] share the same growth
1006    /// strategy. Cache slots fit comfortably in `u32` because the slot count
1007    /// is bounded by the cacheable-opcode count in `code`.
1008    fn write_inline_cache_index(index: &mut Vec<u32>, op_offset: usize, slot: usize) {
1009        if op_offset >= index.len() {
1010            index.resize(op_offset + 1, NO_INLINE_CACHE_SLOT);
1011        }
1012        index[op_offset] = slot as u32;
1013    }
1014
1015    /// Look up the inline-cache slot for the opcode at `op_offset`. This is
1016    /// called on every dispatch of an adaptive binary op (Add/Sub/Mul/Div/
1017    /// Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq), `Op::Call`, `Op::MethodCall`
1018    /// (and `MethodCallOpt`/`MethodCallSpread`), and `Op::GetProperty`
1019    /// (`GetPropertyOpt`). Backed by [`Chunk::inline_cache_index`] — a flat
1020    /// `Vec<u32>` indexed by code offset — so the lookup is a single bounds-
1021    /// checked array read instead of the prior `BTreeMap::get` which walked
1022    /// internal nodes for every dispatched op.
1023    #[inline]
1024    pub(crate) fn inline_cache_slot(&self, op_offset: usize) -> Option<usize> {
1025        match self.inline_cache_index.get(op_offset).copied() {
1026            None | Some(NO_INLINE_CACHE_SLOT) => None,
1027            Some(slot) => Some(slot as usize),
1028        }
1029    }
1030
1031    pub(crate) fn inline_cache_slot_count(&self) -> usize {
1032        self.inline_cache_slots.len()
1033    }
1034
1035    pub(crate) fn cache_id(&self) -> u64 {
1036        self.cache_id
1037    }
1038
1039    /// Pre-optimization control path: the `BTreeMap`-backed lookup the
1040    /// dispatcher used before the flat `Vec<u32>` side-table. Exposed
1041    /// only behind the `vm-bench-internals` feature so the criterion
1042    /// microbench can A/B the two paths inside one binary on identical
1043    /// hardware. The production hot path must keep using
1044    /// [`Chunk::inline_cache_slot`].
1045    #[cfg(feature = "vm-bench-internals")]
1046    pub fn inline_cache_slot_via_btreemap_for_bench(&self, op_offset: usize) -> Option<usize> {
1047        self.inline_cache_slots.get(&op_offset).copied()
1048    }
1049
1050    /// Returns a shared string for a `Constant::String` at the given pool
1051    /// index, materializing it on first access and caching for reuse.
1052    /// Returns `None` when the constant at `idx` is not a string (the
1053    /// caller should fall back to the regular `Constant` match).
1054    pub(crate) fn constant_string_rc(&self, idx: usize) -> Option<crate::value::HarnStr> {
1055        let slot = match self.constant_strings.get(idx) {
1056            Some(slot) => slot,
1057            // Constant appended after the cache was sized (a chunk still
1058            // being emitted into, e.g. tests executing hand-built chunks):
1059            // correct but uncached.
1060            None => {
1061                return match self.constants.get(idx)? {
1062                    Constant::String(s) => Some(crate::value::HarnStr::from(s.as_str())),
1063                    _ => None,
1064                }
1065            }
1066        };
1067        if let Some(existing) = slot.get() {
1068            return Some(existing.clone());
1069        }
1070        let materialized = match self.constants.get(idx)? {
1071            Constant::String(s) => crate::value::HarnStr::from(s.as_str()),
1072            _ => return None,
1073        };
1074        // A concurrent initializer stored an identical value; either wins.
1075        let _ = slot.set(materialized.clone());
1076        Some(materialized)
1077    }
1078
1079    /// Test helper for the chunk-local scratch inline cache. Production
1080    /// dispatch reads VM-local cache sets through `Vm`.
1081    #[inline]
1082    #[cfg(test)]
1083    pub(crate) fn peek_adaptive_binary_cache(
1084        &self,
1085        slot: usize,
1086    ) -> Option<(AdaptiveBinaryOp, AdaptiveBinaryState)> {
1087        match self.inline_caches.lock().get(slot)? {
1088            &InlineCacheEntry::AdaptiveBinary { op, state } => Some((op, state)),
1089            _ => None,
1090        }
1091    }
1092
1093    /// Test helper for the chunk-local scratch inline cache. Production
1094    /// dispatch reads VM-local cache sets through `Vm`.
1095    #[inline]
1096    #[cfg(test)]
1097    pub(crate) fn peek_method_cache(&self, slot: usize) -> Option<(u16, usize, MethodCacheTarget)> {
1098        match self.inline_caches.lock().get(slot)? {
1099            &InlineCacheEntry::Method {
1100                name_idx,
1101                argc,
1102                target,
1103            } => Some((name_idx, argc, target)),
1104            _ => None,
1105        }
1106    }
1107
1108    /// Test helper for the chunk-local scratch inline cache. Production
1109    /// dispatch reads VM-local cache sets through `Vm`.
1110    #[inline]
1111    #[cfg(test)]
1112    pub(crate) fn peek_property_cache(&self, slot: usize) -> Option<(u16, PropertyCacheTarget)> {
1113        match self.inline_caches.lock().get(slot)? {
1114            InlineCacheEntry::Property { name_idx, target } => Some((*name_idx, target.clone())),
1115            _ => None,
1116        }
1117    }
1118
1119    /// Test helper for the chunk-local scratch inline cache. Production
1120    /// dispatch reads VM-local cache sets through `Vm`.
1121    #[inline]
1122    #[cfg(test)]
1123    pub(crate) fn peek_direct_call_state(&self, slot: usize) -> Option<DirectCallState> {
1124        match self.inline_caches.lock().get(slot)? {
1125            InlineCacheEntry::DirectCall { state } => Some(state.clone()),
1126            _ => None,
1127        }
1128    }
1129
1130    #[cfg(test)]
1131    pub(crate) fn set_inline_cache_entry(&self, slot: usize, entry: InlineCacheEntry) {
1132        if let Some(existing) = self.inline_caches.lock().get_mut(slot) {
1133            *existing = entry;
1134        }
1135    }
1136
1137    pub fn freeze_for_cache(&self) -> CachedChunk {
1138        CachedChunk {
1139            code: self.code.clone(),
1140            constants: self.constants.clone(),
1141            lines: self.lines.clone(),
1142            columns: self.columns.clone(),
1143            source_file: self.source_file.clone(),
1144            current_col: self.current_col,
1145            functions: self
1146                .functions
1147                .iter()
1148                .map(|function| function.freeze_for_cache())
1149                .collect(),
1150            inline_cache_slots: self.inline_cache_slots.clone(),
1151            local_slots: self.local_slots.clone(),
1152            binding_types: self.binding_types.clone(),
1153            references_outer_names: self.references_outer_names,
1154        }
1155    }
1156
1157    pub fn from_cached(cached: CachedChunk) -> Self {
1158        let CachedChunk {
1159            code,
1160            constants,
1161            lines,
1162            columns,
1163            source_file,
1164            current_col,
1165            functions,
1166            inline_cache_slots,
1167            local_slots,
1168            binding_types,
1169            references_outer_names,
1170        } = cached;
1171        let inline_cache_count = inline_cache_slots.len();
1172        let constants_count = constants.len();
1173        // Project the cached `BTreeMap<op_offset, slot>` into the flat
1174        // dispatch-side lookup table. Sized to `code.len()` so the hottest
1175        // hot opcodes (binary ops at the end of a long chunk) still hit the
1176        // fast-path bounds check rather than falling through to the
1177        // none-found branch. The size is bounded by code length, so the
1178        // memory footprint is tiny — a few KB for typical chunks.
1179        let mut inline_cache_index = Vec::new();
1180        inline_cache_index.resize(code.len(), NO_INLINE_CACHE_SLOT);
1181        for (&op_offset, &slot) in &inline_cache_slots {
1182            if op_offset < inline_cache_index.len() {
1183                inline_cache_index[op_offset] = slot as u32;
1184            }
1185        }
1186        Self {
1187            cache_id: next_chunk_cache_id(),
1188            code,
1189            constants,
1190            // Derived on demand: a cache-loaded chunk is executed, not appended to.
1191            constant_index: None,
1192            lines,
1193            columns,
1194            source_file,
1195            current_col,
1196            functions: functions
1197                .into_iter()
1198                .map(|function| Arc::new(CompiledFunction::from_cached(function)))
1199                .collect(),
1200            inline_cache_slots,
1201            inline_cache_index,
1202            inline_caches: Arc::new(Mutex::new(vec![
1203                InlineCacheEntry::Empty;
1204                inline_cache_count
1205            ])),
1206            constant_strings: Arc::new(
1207                (0..constants_count)
1208                    .map(|_| std::sync::OnceLock::new())
1209                    .collect(),
1210            ),
1211            local_slots,
1212            binding_types,
1213            references_outer_names,
1214            #[cfg(debug_assertions)]
1215            balance_depth: 0,
1216            #[cfg(debug_assertions)]
1217            balance_nonlinear: 0,
1218        }
1219    }
1220
1221    #[cfg(test)]
1222    pub(crate) fn add_local_slot(
1223        &mut self,
1224        name: String,
1225        mutable: bool,
1226        scope_depth: usize,
1227    ) -> u16 {
1228        let idx = self.local_slots.len();
1229        self.local_slots.push(LocalSlotInfo {
1230            name,
1231            mutable,
1232            scope_depth,
1233        });
1234        idx as u16
1235    }
1236
1237    /// Read a u64 argument at the given position.
1238    pub fn read_u64(&self, pos: usize) -> u64 {
1239        u64::from_be_bytes([
1240            self.code[pos],
1241            self.code[pos + 1],
1242            self.code[pos + 2],
1243            self.code[pos + 3],
1244            self.code[pos + 4],
1245            self.code[pos + 5],
1246            self.code[pos + 6],
1247            self.code[pos + 7],
1248        ])
1249    }
1250
1251    /// Disassemble the chunk for debugging. The per-opcode rendering is
1252    /// macro-generated alongside the dispatch tables in
1253    /// `crate::vm::ops` — see [`Self::disassemble_op`].
1254    pub fn disassemble(&self, name: &str) -> String {
1255        let mut out = format!("== {name} ==\n");
1256        let mut ip = 0;
1257        while ip < self.code.len() {
1258            let op_start = ip;
1259            let op_byte = self.code[ip];
1260            let line = self.lines.get(ip).copied().unwrap_or(0);
1261            out.push_str(&format!("{ip:04} [{line:>4}] "));
1262            ip += 1;
1263
1264            if let Some(op) = Op::from_byte(op_byte) {
1265                self.disassemble_op(op, &mut ip, &mut out);
1266                debug_assert_eq!(
1267                    ip,
1268                    op_start + op.instruction_len(),
1269                    "disassembler operand width drifted for {}",
1270                    op.name(),
1271                );
1272            } else {
1273                out.push_str(&format!("UNKNOWN(0x{op_byte:02x})\n"));
1274            }
1275        }
1276        out
1277    }
1278}
1279
1280impl Default for Chunk {
1281    fn default() -> Self {
1282        Self::new()
1283    }
1284}
1285
1286#[cfg(test)]
1287#[path = "chunk_tests.rs"]
1288mod tests;