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