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