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 side index. Derived from [`Chunk::constants`]
314    /// and intentionally omitted from [`CachedChunk`]; bytecode-cache loads
315    /// rebuild it from the serialized constant vector.
316    constant_index: HashMap<ConstantKey, u16>,
317    /// Source line numbers for each instruction (for error reporting).
318    pub lines: Vec<u32>,
319    /// Source column numbers for each instruction (for error reporting).
320    /// Parallel to `lines`; 0 means no column info available.
321    pub columns: Vec<u32>,
322    /// Source file that this chunk was compiled from, when known. Set for
323    /// chunks compiled from imported modules so runtime errors can report
324    /// the correct file path for each frame instead of always pointing at
325    /// the entry-point pipeline.
326    pub source_file: Option<String>,
327    /// Current column to use when emitting instructions (set by compiler).
328    current_col: u32,
329    /// Compiled function bodies (for closures).
330    pub functions: Vec<CompiledFunctionRef>,
331    /// Instruction offset to inline-cache slot. Slots are assigned at emit time
332    /// for cacheable instructions while bytecode bytes remain immutable.
333    /// Preserved as the serialization-stable representation that round-trips
334    /// through [`CachedChunk`]; the runtime hot path reads
335    /// [`Chunk::inline_cache_index`] instead.
336    inline_cache_slots: BTreeMap<usize, usize>,
337    /// Flat side-table indexed by code offset that returns the inline-cache
338    /// slot index (or [`NO_INLINE_CACHE_SLOT`] for "no slot at this offset").
339    /// Built alongside [`Chunk::inline_cache_slots`] at emit/load time so the
340    /// per-dispatch lookup that fires on every adaptive binary op, `Op::Call`,
341    /// `Op::MethodCall`, and `Op::GetProperty` is one cache-friendly `Vec`
342    /// index instead of a `BTreeMap::get` (O(1) vs O(log n) with the
343    /// associated pointer chasing). Derived; intentionally not serialized.
344    inline_cache_index: Vec<u32>,
345    /// Test/bench scratch entries for validating inline-cache transitions.
346    /// Runtime execution keeps live cache entries on each `Vm` isolate so
347    /// parallel workers do not contend on shared compiled chunks.
348    inline_caches: Arc<Mutex<Vec<InlineCacheEntry>>>,
349    /// Lazily-materialized shared string cache for `Constant::String` entries,
350    /// parallel to `constants`. String constants are materialized once per
351    /// unique constant; subsequent pushes are a [`HarnStr`] refcount bump.
352    constant_strings: Arc<Mutex<Vec<Option<crate::value::HarnStr>>>>,
353    /// Source-name metadata for slot-indexed locals in this chunk.
354    pub(crate) local_slots: Vec<LocalSlotInfo>,
355    /// True when this chunk's bytecode emits an opcode that resolves a
356    /// name through the runtime env (`GetVar`, `SetVar`, `CallBuiltin`,
357    /// `CallBuiltinSpread`, `CheckType`). The closure-call hot path uses
358    /// this as a cheap static guard: if a closure body never reads
359    /// outer names by name, the caller-scope late-bind walks in
360    /// [`Vm::closure_call_env`] and
361    /// [`Vm::closure_call_env_for_current_frame`] are pure overhead and
362    /// can be skipped, leaving the closure's captured env as-is.
363    ///
364    /// Walks exist to inject late-bound closure-typed names — typically
365    /// for self/mutually-recursive local fns and for fns whose captured
366    /// env predates a sibling definition. Inline arithmetic / comparison
367    /// callbacks (the `.map(x -> x * 2)` / `.filter(x -> x % 2 == 0)`
368    /// shape) emit none of the flagged opcodes, so the walk is wasted
369    /// work on every invocation.
370    pub(crate) references_outer_names: bool,
371    /// Compile-time operand-stack-depth tracking for the debug-build
372    /// balance assertion (issue #2622). `balance_depth` is the running net
373    /// effect of every *linearly-modeled* opcode emitted so far;
374    /// `balance_nonlinear` counts emits whose effect can't be tracked by a
375    /// straight-line sum (jumps, `return`, async/handler ops, variadic ops
376    /// whose count isn't an emit argument). A statement is "balance-exact"
377    /// only when `balance_nonlinear` is unchanged across its compilation,
378    /// at which point `balance_depth`'s delta is its true net stack effect.
379    /// Transient compile-time state: reset by [`Chunk::new`], never
380    /// serialized into [`CachedChunk`], and read only by debug assertions —
381    /// so a wrong absolute value (which a non-exact statement can leave
382    /// behind) is harmless; only per-statement *deltas over exact spans*
383    /// are ever trusted.
384    #[cfg(debug_assertions)]
385    balance_depth: i32,
386    #[cfg(debug_assertions)]
387    balance_nonlinear: u32,
388}
389
390pub type ChunkRef = Arc<Chunk>;
391pub type CompiledFunctionRef = Arc<CompiledFunction>;
392
393impl Clone for Chunk {
394    fn clone(&self) -> Self {
395        Self {
396            cache_id: self.cache_id,
397            code: self.code.clone(),
398            constants: self.constants.clone(),
399            constant_index: self.constant_index.clone(),
400            lines: self.lines.clone(),
401            columns: self.columns.clone(),
402            source_file: self.source_file.clone(),
403            current_col: self.current_col,
404            functions: self.functions.clone(),
405            inline_cache_slots: self.inline_cache_slots.clone(),
406            inline_cache_index: self.inline_cache_index.clone(),
407            inline_caches: Arc::new(Mutex::new(vec![
408                InlineCacheEntry::Empty;
409                self.inline_cache_slot_count()
410            ])),
411            constant_strings: Arc::new(Mutex::new(vec![None; self.constants.len()])),
412            local_slots: self.local_slots.clone(),
413            references_outer_names: self.references_outer_names,
414            #[cfg(debug_assertions)]
415            balance_depth: self.balance_depth,
416            #[cfg(debug_assertions)]
417            balance_nonlinear: self.balance_nonlinear,
418        }
419    }
420}
421
422/// Serializable snapshot of a [`Chunk`] suitable for the on-disk bytecode
423/// cache and for in-memory stdlib artifact caches. Inline-cache state is
424/// dropped at freeze time because it warms at runtime per VM isolate; the
425/// rest of the chunk round-trips byte-identically.
426#[derive(Debug, Serialize, Deserialize)]
427pub struct CachedChunk {
428    pub(crate) code: Vec<u8>,
429    pub(crate) constants: Vec<Constant>,
430    pub(crate) lines: Vec<u32>,
431    pub(crate) columns: Vec<u32>,
432    pub(crate) source_file: Option<String>,
433    pub(crate) current_col: u32,
434    pub(crate) functions: Vec<CachedCompiledFunction>,
435    pub(crate) inline_cache_slots: BTreeMap<usize, usize>,
436    pub(crate) local_slots: Vec<LocalSlotInfo>,
437    #[serde(default)]
438    pub(crate) references_outer_names: bool,
439}
440
441#[derive(Debug, Serialize, Deserialize)]
442pub struct CachedCompiledFunction {
443    pub(crate) name: String,
444    pub(crate) type_params: Vec<String>,
445    pub(crate) nominal_type_names: Vec<String>,
446    pub(crate) params: Vec<CachedParamSlot>,
447    pub(crate) default_start: Option<usize>,
448    pub(crate) chunk: CachedChunk,
449    pub(crate) is_generator: bool,
450    pub(crate) is_stream: bool,
451    pub(crate) has_rest_param: bool,
452    pub(crate) has_runtime_type_checks: bool,
453}
454
455#[derive(Debug, Serialize, Deserialize)]
456pub(crate) struct CachedParamSlot {
457    pub(crate) name: String,
458    pub(crate) type_expr: Option<TypeExpr>,
459    pub(crate) has_default: bool,
460}
461
462impl CachedParamSlot {
463    fn thaw(self) -> ParamSlot {
464        let runtime_guard = self
465            .type_expr
466            .as_ref()
467            .map(RuntimeParamGuard::from_type_expr);
468        ParamSlot {
469            name: self.name,
470            type_expr: self.type_expr,
471            runtime_guard,
472            has_default: self.has_default,
473        }
474    }
475}
476
477/// One parameter slot of a compiled user-defined function. Carries the
478/// declared name, the (optional) declared type expression, and a flag
479/// for whether a default value was provided. The runtime consults the
480/// type expression in `bind_param_slots` to enforce declared types
481/// against the values supplied at the call site.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct ParamSlot {
484    pub name: String,
485    /// Declared parameter type. `None` for untyped parameters (gradual
486    /// typing); the runtime skips type assertion when absent.
487    pub type_expr: Option<TypeExpr>,
488    /// Precomputed runtime validation metadata derived from `type_expr`.
489    /// Bytecode-cache artifacts omit this field and rebuild it at load time.
490    #[serde(skip)]
491    pub(crate) runtime_guard: Option<RuntimeParamGuard>,
492    /// True when the parameter has a default-value clause. Diagnostic
493    /// only — the canonical authority for arity ranges is
494    /// [`CompiledFunction::default_start`].
495    pub has_default: bool,
496}
497
498impl ParamSlot {
499    /// Build a [`ParamSlot`] from a parser-side [`harn_parser::TypedParam`].
500    /// Centralizes the conversion so every compile path stays in lockstep.
501    pub fn from_typed_param(param: &harn_parser::TypedParam) -> Self {
502        Self::from_typed_param_with_type(param, param.type_expr.clone())
503    }
504
505    pub(crate) fn from_typed_param_with_type(
506        param: &harn_parser::TypedParam,
507        type_expr: Option<TypeExpr>,
508    ) -> Self {
509        let runtime_guard = type_expr.as_ref().map(RuntimeParamGuard::from_type_expr);
510        Self {
511            name: param.name.clone(),
512            type_expr,
513            runtime_guard,
514            has_default: param.default_value.is_some(),
515        }
516    }
517
518    fn freeze_for_cache(&self) -> CachedParamSlot {
519        CachedParamSlot {
520            name: self.name.clone(),
521            type_expr: self.type_expr.clone(),
522            has_default: self.has_default,
523        }
524    }
525
526    /// Build a `Vec<ParamSlot>` from a slice of parser-side typed
527    /// parameters. Used pervasively at compile sites instead of
528    /// `TypedParam::names` (which discarded the type info we now need
529    /// at runtime).
530    pub fn vec_from_typed(params: &[harn_parser::TypedParam]) -> Vec<Self> {
531        params.iter().map(Self::from_typed_param).collect()
532    }
533}
534
535/// A compiled function (closure body).
536#[derive(Debug, Clone)]
537pub struct CompiledFunction {
538    pub name: String,
539    /// Generic type parameters declared by this function. Runtime
540    /// validation treats these as static-only constraints because the VM
541    /// does not monomorphize function bodies.
542    pub type_params: Vec<String>,
543    /// User-defined struct and enum names visible when this function was
544    /// compiled. These are the only non-primitive named types with runtime
545    /// nominal identity; aliases and interfaces remain static-only.
546    pub nominal_type_names: Vec<String>,
547    pub params: Vec<ParamSlot>,
548    /// Index of the first parameter with a default value, or None if all required.
549    pub default_start: Option<usize>,
550    pub chunk: ChunkRef,
551    /// True if the function body contains `yield` expressions (generator function).
552    pub is_generator: bool,
553    /// True if the function was declared as `gen fn` and should return Stream.
554    pub is_stream: bool,
555    /// True if the last parameter is a rest parameter (`...name`).
556    pub has_rest_param: bool,
557    /// True when at least one parameter has a runtime-visible type
558    /// assertion. Untyped closures dominate collection callback hot paths,
559    /// so this lets the VM skip the per-argument metadata walk after the
560    /// arity check.
561    pub has_runtime_type_checks: bool,
562}
563
564impl CompiledFunction {
565    pub(crate) fn has_runtime_type_checks_for_params(params: &[ParamSlot]) -> bool {
566        params.iter().any(|param| param.type_expr.is_some())
567    }
568
569    /// Returns just the parameter names — convenience for code paths that
570    /// don't care about types or defaults.
571    pub fn param_names(&self) -> impl Iterator<Item = &str> {
572        self.params.iter().map(|p| p.name.as_str())
573    }
574
575    /// Number of required parameters (those before `default_start`).
576    pub fn required_param_count(&self) -> usize {
577        self.default_start.unwrap_or(self.params.len())
578    }
579
580    /// Minimum number of caller-supplied arguments needed to enter the function.
581    pub(crate) fn minimum_arg_count(&self) -> usize {
582        if self.has_rest_param {
583            self.required_param_count()
584                .min(self.params.len().saturating_sub(1))
585        } else {
586            self.required_param_count()
587        }
588    }
589
590    /// Argument count visible to callee bytecode via `GetArgc`.
591    pub(crate) fn callee_arg_count(&self, supplied: usize) -> usize {
592        if self.has_rest_param {
593            supplied
594        } else {
595            supplied.min(self.params.len())
596        }
597    }
598
599    pub fn declares_type_param(&self, name: &str) -> bool {
600        self.type_params.iter().any(|param| param == name)
601    }
602
603    pub fn has_nominal_type(&self, name: &str) -> bool {
604        self.nominal_type_names.iter().any(|ty| ty == name)
605    }
606
607    pub(crate) fn freeze_for_cache(&self) -> CachedCompiledFunction {
608        CachedCompiledFunction {
609            name: self.name.clone(),
610            type_params: self.type_params.clone(),
611            nominal_type_names: self.nominal_type_names.clone(),
612            params: self
613                .params
614                .iter()
615                .map(ParamSlot::freeze_for_cache)
616                .collect(),
617            default_start: self.default_start,
618            chunk: self.chunk.freeze_for_cache(),
619            is_generator: self.is_generator,
620            is_stream: self.is_stream,
621            has_rest_param: self.has_rest_param,
622            has_runtime_type_checks: self.has_runtime_type_checks,
623        }
624    }
625
626    pub(crate) fn from_cached(cached: CachedCompiledFunction) -> Self {
627        Self {
628            name: cached.name,
629            type_params: cached.type_params,
630            nominal_type_names: cached.nominal_type_names,
631            params: cached
632                .params
633                .into_iter()
634                .map(CachedParamSlot::thaw)
635                .collect(),
636            default_start: cached.default_start,
637            chunk: Arc::new(Chunk::from_cached(cached.chunk)),
638            is_generator: cached.is_generator,
639            is_stream: cached.is_stream,
640            has_rest_param: cached.has_rest_param,
641            has_runtime_type_checks: cached.has_runtime_type_checks,
642        }
643    }
644}
645
646/// A snapshot of [`Chunk`]'s compile-time balance model, returned by
647/// [`Chunk::balance_probe`] and consumed by [`Chunk::balance_delta_since`].
648#[cfg(debug_assertions)]
649#[derive(Clone, Copy)]
650pub(crate) struct BalanceProbe {
651    depth: i32,
652    nonlinear: u32,
653}
654
655/// Net operand-stack effect (`pushes - pops`) of one emitted opcode, for
656/// the debug-build balance assertion (issue #2622). `count` is the opcode's
657/// variadic arity when that arity is the emit-call argument (`BuildList`
658/// length, `Call` argc, …) and `0` otherwise.
659///
660/// `Some(delta)` means the effect is exactly modeled. `None` marks an
661/// opcode a straight-line running sum can't track — control flow that
662/// branches or terminates (`Jump*`, `Return`, `Throw`, `TailCall`),
663/// async/handler ops, and variadic ops whose arity rides in a raw operand
664/// byte rather than the emit argument (`BuildEnum`, `MatchEnum`). Such an
665/// opcode taints its enclosing statement as non-exact, so the assertion
666/// skips it instead of risking a false trip.
667///
668/// The `match` is intentionally exhaustive with no `_` arm: adding an
669/// opcode forces a classification here (a compile error otherwise), so the
670/// balance model can't silently drift out of sync with the instruction set.
671#[cfg(debug_assertions)]
672fn op_stack_delta(op: Op, count: u16) -> Option<i32> {
673    use Op::*;
674    let count = count as i32;
675    Some(match op {
676        // Push one value.
677        Constant | Nil | True | False | GetVar | GetArgc | GetLocalSlot | Closure | Dup => 1,
678        // Consume one value (into a binding / property / discard). `SetVar`,
679        // `SetProperty` and the local-slot stores read their target by name
680        // or slot index, so they only pop the value being stored.
681        DefLet | DefVar | DefCell | SetVar | DefLocalSlot | SetLocalSlot | SetProperty
682        | SetLocalSlotProperty | ConcatAssignLocal | Pop => -1,
683        // Value-preserving: unary ops, by-name lookups/checks, and scope /
684        // iterator / exception-handler bookkeeping (the last three touch
685        // side stacks, not the operand stack).
686        Negate | Not | GetProperty | GetPropertyOpt | CheckType | TryUnwrap | TryWrapOk | Swap
687        | PushScope | PopScope | PopIterator | PopHandler => 0,
688        // Pop two, push one.
689        Add | Sub | Mul | Div | Mod | Pow | AddInt | SubInt | MulInt | DivInt | ModInt
690        | AddFloat | SubFloat | MulFloat | DivFloat | ModFloat | Equal | NotEqual | Less
691        | Greater | LessEqual | GreaterEqual | EqualInt | NotEqualInt | LessInt | GreaterInt
692        | LessEqualInt | GreaterEqualInt | EqualFloat | NotEqualFloat | LessFloat
693        | GreaterFloat | LessEqualFloat | GreaterEqualFloat | EqualBool | NotEqualBool
694        | EqualString | NotEqualString | Contains | Subscript | SubscriptOpt => -1,
695        // `IterInit` consumes the iterable and pushes nothing (the iterator
696        // lives on a side stack).
697        IterInit => -1,
698        // Net -2: `Slice` pops object/start/end and pushes one value;
699        // subscript stores pop value/index and read the target from bytecode.
700        Slice | SetSubscript | SetLocalSlotSubscript => -2,
701        // Variadic whose arity is the emit argument: pop `count`, push one.
702        BuildList | Concat | CallBuiltin => 1 - count,
703        BuildDict => 1 - 2 * count,
704        // Calls also pop the callee/receiver beneath the args.
705        Call | MethodCall | MethodCallOpt => -count,
706        // Non-linear (see doc comment): branches, terminators, async/handler
707        // ops, and variadic ops whose arity isn't the emit argument.
708        Jump | JumpIfFalse | JumpIfTrue | IterNext | Return | TailCall | Throw | TryCatchSetup
709        | Spawn | Pipe | Parallel | ParallelMap | ParallelMapStream | ParallelSettle
710        | SyncMutexEnter | SyncMutexEnterKeyed | TaskScopeEnter | TaskScopeExit | Import
711        | SelectiveImport | DeadlineSetup | DeadlineEnd | BuildEnum | MatchEnum | Yield
712        | CallSpread | CallBuiltinSpread | MethodCallSpread => return None,
713    })
714}
715
716impl Chunk {
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: 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            references_outer_names: false,
734            #[cfg(debug_assertions)]
735            balance_depth: 0,
736            #[cfg(debug_assertions)]
737            balance_nonlinear: 0,
738        }
739    }
740
741    /// Set the current column for subsequent emit calls.
742    pub fn set_column(&mut self, col: u32) {
743        self.current_col = col;
744    }
745
746    /// Add a constant and return its index.
747    pub fn add_constant(&mut self, constant: Constant) -> u16 {
748        debug_assert!(
749            self.constant_index.len() <= self.constants.len(),
750            "constant side index cannot outgrow the constant pool"
751        );
752        let key = ConstantKey::from(&constant);
753        if let Some(index) = self.constant_index.get(&key) {
754            debug_assert!(
755                self.constants
756                    .get(*index as usize)
757                    .is_some_and(|existing| constants_identical(existing, &constant)),
758                "constant side index drifted from the constant pool"
759            );
760            return *index;
761        }
762        let idx = self.constants.len();
763        let idx = u16::try_from(idx).expect("constant pool exceeded u16 operand space");
764        self.constants.push(constant);
765        self.constant_index.insert(key, idx);
766        idx
767    }
768
769    /// Emit a single-byte instruction.
770    pub fn emit(&mut self, op: Op, line: u32) {
771        #[cfg(debug_assertions)]
772        self.note_balance(op, 0);
773        let col = self.current_col;
774        let op_offset = self.code.len();
775        self.code.push(op as u8);
776        self.lines.push(line);
777        self.columns.push(col);
778        if is_adaptive_binary_op(op) {
779            self.register_inline_cache(op_offset);
780        }
781        if op_reads_outer_name(op) {
782            self.references_outer_names = true;
783        }
784    }
785
786    /// Emit an instruction with a u16 argument.
787    pub fn emit_u16(&mut self, op: Op, arg: u16, line: u32) {
788        #[cfg(debug_assertions)]
789        self.note_balance(op, arg);
790        let col = self.current_col;
791        let op_offset = self.code.len();
792        self.code.push(op as u8);
793        self.code.push((arg >> 8) as u8);
794        self.code.push((arg & 0xFF) as u8);
795        self.lines.push(line);
796        self.lines.push(line);
797        self.lines.push(line);
798        self.columns.push(col);
799        self.columns.push(col);
800        self.columns.push(col);
801        if matches!(
802            op,
803            Op::GetProperty | Op::GetPropertyOpt | Op::MethodCallSpread | Op::ConcatAssignLocal
804        ) {
805            self.register_inline_cache(op_offset);
806        }
807        if op_reads_outer_name(op) {
808            self.references_outer_names = true;
809        }
810    }
811
812    /// Emit a local-slot property assignment:
813    /// opcode + u16 property constant index + u16 local slot index.
814    pub fn emit_set_local_slot_property(&mut self, prop_idx: u16, slot: u16, line: u32) {
815        #[cfg(debug_assertions)]
816        self.note_balance(Op::SetLocalSlotProperty, 0);
817        let col = self.current_col;
818        self.code.push(Op::SetLocalSlotProperty as u8);
819        self.code.push((prop_idx >> 8) as u8);
820        self.code.push((prop_idx & 0xFF) as u8);
821        self.code.push((slot >> 8) as u8);
822        self.code.push((slot & 0xFF) as u8);
823        for _ in 0..5 {
824            self.lines.push(line);
825            self.columns.push(col);
826        }
827    }
828
829    /// Emit an instruction with a u8 argument.
830    pub fn emit_u8(&mut self, op: Op, arg: u8, line: u32) {
831        #[cfg(debug_assertions)]
832        self.note_balance(op, arg as u16);
833        let col = self.current_col;
834        let op_offset = self.code.len();
835        self.code.push(op as u8);
836        self.code.push(arg);
837        self.lines.push(line);
838        self.lines.push(line);
839        self.columns.push(col);
840        self.columns.push(col);
841        if matches!(op, Op::Call) {
842            self.register_inline_cache(op_offset);
843        }
844        if op_reads_outer_name(op) {
845            self.references_outer_names = true;
846        }
847    }
848
849    /// Emit a direct builtin call.
850    pub fn emit_call_builtin(
851        &mut self,
852        id: crate::BuiltinId,
853        name_idx: u16,
854        arg_count: u8,
855        line: u32,
856    ) {
857        #[cfg(debug_assertions)]
858        self.note_balance(Op::CallBuiltin, arg_count as u16);
859        let col = self.current_col;
860        let op_offset = self.code.len();
861        self.code.push(Op::CallBuiltin as u8);
862        self.code.extend_from_slice(&id.raw().to_be_bytes());
863        self.code.push((name_idx >> 8) as u8);
864        self.code.push((name_idx & 0xFF) as u8);
865        self.code.push(arg_count);
866        for _ in 0..12 {
867            self.lines.push(line);
868            self.columns.push(col);
869        }
870        self.register_inline_cache(op_offset);
871        self.references_outer_names = true;
872    }
873
874    /// Emit a direct builtin spread call.
875    pub fn emit_call_builtin_spread(&mut self, id: crate::BuiltinId, name_idx: u16, line: u32) {
876        #[cfg(debug_assertions)]
877        self.note_balance(Op::CallBuiltinSpread, 0);
878        let col = self.current_col;
879        self.code.push(Op::CallBuiltinSpread as u8);
880        self.code.extend_from_slice(&id.raw().to_be_bytes());
881        self.code.push((name_idx >> 8) as u8);
882        self.code.push((name_idx & 0xFF) as u8);
883        for _ in 0..11 {
884            self.lines.push(line);
885            self.columns.push(col);
886        }
887        self.references_outer_names = true;
888    }
889
890    /// Emit a method call: op + u16 (method name) + u8 (arg count).
891    pub fn emit_method_call(&mut self, name_idx: u16, arg_count: u8, line: u32) {
892        self.emit_method_call_inner(Op::MethodCall, name_idx, arg_count, line);
893    }
894
895    /// Emit an optional method call (?.) — returns nil if receiver is nil.
896    pub fn emit_method_call_opt(&mut self, name_idx: u16, arg_count: u8, line: u32) {
897        self.emit_method_call_inner(Op::MethodCallOpt, name_idx, arg_count, line);
898    }
899
900    fn emit_method_call_inner(&mut self, op: Op, name_idx: u16, arg_count: u8, line: u32) {
901        #[cfg(debug_assertions)]
902        self.note_balance(op, arg_count as u16);
903        let col = self.current_col;
904        let op_offset = self.code.len();
905        self.code.push(op as u8);
906        self.code.push((name_idx >> 8) as u8);
907        self.code.push((name_idx & 0xFF) as u8);
908        self.code.push(arg_count);
909        self.lines.push(line);
910        self.lines.push(line);
911        self.lines.push(line);
912        self.lines.push(line);
913        self.columns.push(col);
914        self.columns.push(col);
915        self.columns.push(col);
916        self.columns.push(col);
917        self.register_inline_cache(op_offset);
918    }
919
920    /// Current code offset (for jump patching).
921    pub fn current_offset(&self) -> usize {
922        self.code.len()
923    }
924
925    /// Emit a jump instruction with a placeholder offset. Returns the position to patch.
926    pub fn emit_jump(&mut self, op: Op, line: u32) -> usize {
927        #[cfg(debug_assertions)]
928        self.note_balance(op, 0);
929        let col = self.current_col;
930        self.code.push(op as u8);
931        let patch_pos = self.code.len();
932        self.code.push(0xFF);
933        self.code.push(0xFF);
934        self.lines.push(line);
935        self.lines.push(line);
936        self.lines.push(line);
937        self.columns.push(col);
938        self.columns.push(col);
939        self.columns.push(col);
940        patch_pos
941    }
942
943    /// Patch a jump instruction at the given position to jump to the current offset.
944    pub fn patch_jump(&mut self, patch_pos: usize) {
945        let target = self.code.len() as u16;
946        self.code[patch_pos] = (target >> 8) as u8;
947        self.code[patch_pos + 1] = (target & 0xFF) as u8;
948    }
949
950    /// Patch a jump to a specific target position.
951    pub fn patch_jump_to(&mut self, patch_pos: usize, target: usize) {
952        let target = target as u16;
953        self.code[patch_pos] = (target >> 8) as u8;
954        self.code[patch_pos + 1] = (target & 0xFF) as u8;
955    }
956
957    /// Read a u16 argument at the given position.
958    pub fn read_u16(&self, pos: usize) -> u16 {
959        ((self.code[pos] as u16) << 8) | (self.code[pos + 1] as u16)
960    }
961
962    /// Fold one just-emitted opcode into the compile-time operand-stack
963    /// balance model (issue #2622). See [`op_stack_delta`] for the
964    /// linear-vs-non-linear classification.
965    #[cfg(debug_assertions)]
966    fn note_balance(&mut self, op: Op, count: u16) {
967        match op_stack_delta(op, count) {
968            Some(delta) => self.balance_depth += delta,
969            None => self.balance_nonlinear += 1,
970        }
971    }
972
973    /// Snapshot the balance model before compiling a statement; pair with
974    /// [`Chunk::balance_delta_since`].
975    #[cfg(debug_assertions)]
976    pub(crate) fn balance_probe(&self) -> BalanceProbe {
977        BalanceProbe {
978            depth: self.balance_depth,
979            nonlinear: self.balance_nonlinear,
980        }
981    }
982
983    /// Net operand-stack effect emitted since `probe`, or `None` when any
984    /// non-linearly-modeled opcode was emitted in that span (which makes
985    /// the running sum untrustworthy, so callers must not assert on it).
986    /// The absolute `balance_depth` may be meaningless after a non-exact
987    /// span — only deltas over a fully-exact span are valid.
988    #[cfg(debug_assertions)]
989    pub(crate) fn balance_delta_since(&self, probe: BalanceProbe) -> Option<i32> {
990        if self.balance_nonlinear == probe.nonlinear {
991            Some(self.balance_depth - probe.depth)
992        } else {
993            None
994        }
995    }
996
997    fn register_inline_cache(&mut self, op_offset: usize) {
998        if self.inline_cache_slots.contains_key(&op_offset) {
999            return;
1000        }
1001        let mut entries = self.inline_caches.lock();
1002        let slot = entries.len();
1003        entries.push(InlineCacheEntry::Empty);
1004        self.inline_cache_slots.insert(op_offset, slot);
1005        Self::write_inline_cache_index(&mut self.inline_cache_index, op_offset, slot);
1006    }
1007
1008    /// Fast-path side-table writer. Pulled out as an associated fn so both
1009    /// the live emit path and [`Chunk::from_cached`] share the same growth
1010    /// strategy. Cache slots fit comfortably in `u32` because the slot count
1011    /// is bounded by the cacheable-opcode count in `code`.
1012    fn write_inline_cache_index(index: &mut Vec<u32>, op_offset: usize, slot: usize) {
1013        if op_offset >= index.len() {
1014            index.resize(op_offset + 1, NO_INLINE_CACHE_SLOT);
1015        }
1016        index[op_offset] = slot as u32;
1017    }
1018
1019    /// Look up the inline-cache slot for the opcode at `op_offset`. This is
1020    /// called on every dispatch of an adaptive binary op (Add/Sub/Mul/Div/
1021    /// Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq), `Op::Call`, `Op::MethodCall`
1022    /// (and `MethodCallOpt`/`MethodCallSpread`), and `Op::GetProperty`
1023    /// (`GetPropertyOpt`). Backed by [`Chunk::inline_cache_index`] — a flat
1024    /// `Vec<u32>` indexed by code offset — so the lookup is a single bounds-
1025    /// checked array read instead of the prior `BTreeMap::get` which walked
1026    /// internal nodes for every dispatched op.
1027    #[inline]
1028    pub(crate) fn inline_cache_slot(&self, op_offset: usize) -> Option<usize> {
1029        match self.inline_cache_index.get(op_offset).copied() {
1030            None | Some(NO_INLINE_CACHE_SLOT) => None,
1031            Some(slot) => Some(slot as usize),
1032        }
1033    }
1034
1035    pub(crate) fn inline_cache_slot_count(&self) -> usize {
1036        self.inline_cache_slots.len()
1037    }
1038
1039    pub(crate) fn cache_id(&self) -> u64 {
1040        self.cache_id
1041    }
1042
1043    /// Pre-optimization control path: the `BTreeMap`-backed lookup the
1044    /// dispatcher used before the flat `Vec<u32>` side-table. Exposed
1045    /// only behind the `vm-bench-internals` feature so the criterion
1046    /// microbench can A/B the two paths inside one binary on identical
1047    /// hardware. The production hot path must keep using
1048    /// [`Chunk::inline_cache_slot`].
1049    #[cfg(feature = "vm-bench-internals")]
1050    pub fn inline_cache_slot_via_btreemap_for_bench(&self, op_offset: usize) -> Option<usize> {
1051        self.inline_cache_slots.get(&op_offset).copied()
1052    }
1053
1054    /// Returns a shared string for a `Constant::String` at the given pool
1055    /// index, materializing it on first access and caching for reuse.
1056    /// Returns `None` when the constant at `idx` is not a string (the
1057    /// caller should fall back to the regular `Constant` match).
1058    pub(crate) fn constant_string_rc(&self, idx: usize) -> Option<crate::value::HarnStr> {
1059        // Borrow the side table mutably so we can lazily extend / fill
1060        // entries. The borrow is scope-confined to this function; the
1061        // VM never re-enters constant_string_rc for the same chunk
1062        // during a single materialization, so no nested-borrow risk.
1063        let mut entries = self.constant_strings.lock();
1064        if entries.len() < self.constants.len() {
1065            entries.resize(self.constants.len(), None);
1066        }
1067        if let Some(Some(existing)) = entries.get(idx) {
1068            return Some(existing.clone());
1069        }
1070        let materialized = match self.constants.get(idx)? {
1071            Constant::String(s) => crate::value::HarnStr::from(s.as_str()),
1072            _ => return None,
1073        };
1074        entries[idx] = Some(materialized.clone());
1075        Some(materialized)
1076    }
1077
1078    /// Test helper for the chunk-local scratch inline cache. Production
1079    /// dispatch reads VM-local cache sets through `Vm`.
1080    #[inline]
1081    #[cfg(test)]
1082    pub(crate) fn peek_adaptive_binary_cache(
1083        &self,
1084        slot: usize,
1085    ) -> Option<(AdaptiveBinaryOp, AdaptiveBinaryState)> {
1086        match self.inline_caches.lock().get(slot)? {
1087            &InlineCacheEntry::AdaptiveBinary { op, state } => Some((op, state)),
1088            _ => None,
1089        }
1090    }
1091
1092    /// Test helper for the chunk-local scratch inline cache. Production
1093    /// dispatch reads VM-local cache sets through `Vm`.
1094    #[inline]
1095    #[cfg(test)]
1096    pub(crate) fn peek_method_cache(&self, slot: usize) -> Option<(u16, usize, MethodCacheTarget)> {
1097        match self.inline_caches.lock().get(slot)? {
1098            &InlineCacheEntry::Method {
1099                name_idx,
1100                argc,
1101                target,
1102            } => Some((name_idx, argc, target)),
1103            _ => None,
1104        }
1105    }
1106
1107    /// Test helper for the chunk-local scratch inline cache. Production
1108    /// dispatch reads VM-local cache sets through `Vm`.
1109    #[inline]
1110    #[cfg(test)]
1111    pub(crate) fn peek_property_cache(&self, slot: usize) -> Option<(u16, PropertyCacheTarget)> {
1112        match self.inline_caches.lock().get(slot)? {
1113            InlineCacheEntry::Property { name_idx, target } => Some((*name_idx, target.clone())),
1114            _ => None,
1115        }
1116    }
1117
1118    /// Test helper for the chunk-local scratch inline cache. Production
1119    /// dispatch reads VM-local cache sets through `Vm`.
1120    #[inline]
1121    #[cfg(test)]
1122    pub(crate) fn peek_direct_call_state(&self, slot: usize) -> Option<DirectCallState> {
1123        match self.inline_caches.lock().get(slot)? {
1124            InlineCacheEntry::DirectCall { state } => Some(state.clone()),
1125            _ => None,
1126        }
1127    }
1128
1129    #[cfg(test)]
1130    pub(crate) fn set_inline_cache_entry(&self, slot: usize, entry: InlineCacheEntry) {
1131        if let Some(existing) = self.inline_caches.lock().get_mut(slot) {
1132            *existing = entry;
1133        }
1134    }
1135
1136    pub fn freeze_for_cache(&self) -> CachedChunk {
1137        CachedChunk {
1138            code: self.code.clone(),
1139            constants: self.constants.clone(),
1140            lines: self.lines.clone(),
1141            columns: self.columns.clone(),
1142            source_file: self.source_file.clone(),
1143            current_col: self.current_col,
1144            functions: self
1145                .functions
1146                .iter()
1147                .map(|function| function.freeze_for_cache())
1148                .collect(),
1149            inline_cache_slots: self.inline_cache_slots.clone(),
1150            local_slots: self.local_slots.clone(),
1151            references_outer_names: self.references_outer_names,
1152        }
1153    }
1154
1155    pub fn from_cached(cached: CachedChunk) -> Self {
1156        let CachedChunk {
1157            code,
1158            constants,
1159            lines,
1160            columns,
1161            source_file,
1162            current_col,
1163            functions,
1164            inline_cache_slots,
1165            local_slots,
1166            references_outer_names,
1167        } = cached;
1168        let inline_cache_count = inline_cache_slots.len();
1169        let constants_count = constants.len();
1170        // Project the cached `BTreeMap<op_offset, slot>` into the flat
1171        // dispatch-side lookup table. Sized to `code.len()` so the hottest
1172        // hot opcodes (binary ops at the end of a long chunk) still hit the
1173        // fast-path bounds check rather than falling through to the
1174        // none-found branch. The size is bounded by code length, so the
1175        // memory footprint is tiny — a few KB for typical chunks.
1176        let mut inline_cache_index = Vec::new();
1177        inline_cache_index.resize(code.len(), NO_INLINE_CACHE_SLOT);
1178        for (&op_offset, &slot) in &inline_cache_slots {
1179            if op_offset < inline_cache_index.len() {
1180                inline_cache_index[op_offset] = slot as u32;
1181            }
1182        }
1183        let constant_index = build_constant_index(&constants);
1184        Self {
1185            cache_id: next_chunk_cache_id(),
1186            code,
1187            constants,
1188            constant_index,
1189            lines,
1190            columns,
1191            source_file,
1192            current_col,
1193            functions: functions
1194                .into_iter()
1195                .map(|function| Arc::new(CompiledFunction::from_cached(function)))
1196                .collect(),
1197            inline_cache_slots,
1198            inline_cache_index,
1199            inline_caches: Arc::new(Mutex::new(vec![
1200                InlineCacheEntry::Empty;
1201                inline_cache_count
1202            ])),
1203            constant_strings: Arc::new(Mutex::new(vec![None; constants_count])),
1204            local_slots,
1205            references_outer_names,
1206            #[cfg(debug_assertions)]
1207            balance_depth: 0,
1208            #[cfg(debug_assertions)]
1209            balance_nonlinear: 0,
1210        }
1211    }
1212
1213    pub(crate) fn add_local_slot(
1214        &mut self,
1215        name: String,
1216        mutable: bool,
1217        scope_depth: usize,
1218    ) -> u16 {
1219        let idx = self.local_slots.len();
1220        self.local_slots.push(LocalSlotInfo {
1221            name,
1222            mutable,
1223            scope_depth,
1224        });
1225        idx as u16
1226    }
1227
1228    /// Read a u64 argument at the given position.
1229    pub fn read_u64(&self, pos: usize) -> u64 {
1230        u64::from_be_bytes([
1231            self.code[pos],
1232            self.code[pos + 1],
1233            self.code[pos + 2],
1234            self.code[pos + 3],
1235            self.code[pos + 4],
1236            self.code[pos + 5],
1237            self.code[pos + 6],
1238            self.code[pos + 7],
1239        ])
1240    }
1241
1242    /// Disassemble the chunk for debugging. The per-opcode rendering is
1243    /// macro-generated alongside the dispatch tables in
1244    /// `crate::vm::ops` — see [`Self::disassemble_op`].
1245    pub fn disassemble(&self, name: &str) -> String {
1246        let mut out = format!("== {name} ==\n");
1247        let mut ip = 0;
1248        while ip < self.code.len() {
1249            let op_byte = self.code[ip];
1250            let line = self.lines.get(ip).copied().unwrap_or(0);
1251            out.push_str(&format!("{ip:04} [{line:>4}] "));
1252            ip += 1;
1253
1254            if let Some(op) = Op::from_byte(op_byte) {
1255                self.disassemble_op(op, &mut ip, &mut out);
1256            } else {
1257                out.push_str(&format!("UNKNOWN(0x{op_byte:02x})\n"));
1258            }
1259        }
1260        out
1261    }
1262}
1263
1264/// Disassembly helpers consumed by the macro-generated
1265/// [`Chunk::disassemble_op`]. Each helper takes the current code position
1266/// (already advanced past the opcode byte), advances it over the operand
1267/// bytes the opcode carries, and renders one human-readable line without
1268/// a trailing newline (the dispatcher appends it).
1269///
1270/// Defining one helper per operand layout — and not one per opcode —
1271/// keeps adding an opcode a one-line edit in the `define_opcodes!` table
1272/// rather than a paired edit here. New layouts live with the helpers;
1273/// new opcodes live with the dispatch.
1274pub(crate) fn disasm_bare(_chunk: &Chunk, _ip: &mut usize, label: &str) -> String {
1275    label.to_string()
1276}
1277
1278pub(crate) fn disasm_u8(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1279    let arg = chunk.code[*ip];
1280    *ip += 1;
1281    format!("{label} {arg:>4}")
1282}
1283
1284pub(crate) fn disasm_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1285    let arg = chunk.read_u16(*ip);
1286    *ip += 2;
1287    format!("{label} {arg:>4}")
1288}
1289
1290pub(crate) fn disasm_try_catch_setup(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1291    let catch_offset = chunk.read_u16(*ip);
1292    *ip += 2;
1293    let type_idx = chunk.read_u16(*ip);
1294    *ip += 2;
1295    if let Some(type_name) = chunk.constants.get(type_idx as usize) {
1296        format!("{label} {catch_offset:>4} type {type_idx:>4} ({type_name})")
1297    } else {
1298        format!("{label} {catch_offset:>4} type {type_idx:>4}")
1299    }
1300}
1301
1302pub(crate) fn disasm_const_pool_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1303    let idx = chunk.read_u16(*ip);
1304    *ip += 2;
1305    format!("{label} {idx:>4} ({})", chunk.constants[idx as usize])
1306}
1307
1308pub(crate) fn disasm_local_slot_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1309    let slot = chunk.read_u16(*ip);
1310    *ip += 2;
1311    let mut out = format!("{label} {slot:>4}");
1312    if let Some(info) = chunk.local_slots.get(slot as usize) {
1313        out.push_str(&format!(" ({})", info.name));
1314    }
1315    out
1316}
1317
1318pub(crate) fn disasm_const_pool_local_slot(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1319    let prop = chunk.read_u16(*ip);
1320    *ip += 2;
1321    let slot = chunk.read_u16(*ip);
1322    *ip += 2;
1323    let mut out = format!(
1324        "{label} prop {prop:>4} ({}) slot {slot:>4}",
1325        chunk.constants[prop as usize]
1326    );
1327    if let Some(info) = chunk.local_slots.get(slot as usize) {
1328        out.push_str(&format!(" ({})", info.name));
1329    }
1330    out
1331}
1332
1333pub(crate) fn disasm_method_call(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1334    let idx = chunk.read_u16(*ip);
1335    *ip += 2;
1336    let argc = chunk.code[*ip];
1337    *ip += 1;
1338    format!(
1339        "{label} {idx:>4} ({}) argc={argc}",
1340        chunk.constants[idx as usize]
1341    )
1342}
1343
1344pub(crate) fn disasm_match_enum(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1345    let enum_idx = chunk.read_u16(*ip);
1346    *ip += 2;
1347    let var_idx = chunk.read_u16(*ip);
1348    *ip += 2;
1349    format!(
1350        "{label} {enum_idx:>4} ({}) {var_idx:>4} ({})",
1351        chunk.constants[enum_idx as usize], chunk.constants[var_idx as usize],
1352    )
1353}
1354
1355pub(crate) fn disasm_build_enum(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1356    let enum_idx = chunk.read_u16(*ip);
1357    *ip += 2;
1358    let var_idx = chunk.read_u16(*ip);
1359    *ip += 2;
1360    let field_count = chunk.read_u16(*ip);
1361    *ip += 2;
1362    format!(
1363        "{label} {enum_idx:>4} ({}) {var_idx:>4} ({}) fields={field_count}",
1364        chunk.constants[enum_idx as usize], chunk.constants[var_idx as usize],
1365    )
1366}
1367
1368pub(crate) fn disasm_selective_import(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1369    let path_idx = chunk.read_u16(*ip);
1370    *ip += 2;
1371    let names_idx = chunk.read_u16(*ip);
1372    *ip += 2;
1373    format!(
1374        "{label} {path_idx:>4} ({}) names: {names_idx:>4} ({})",
1375        chunk.constants[path_idx as usize], chunk.constants[names_idx as usize],
1376    )
1377}
1378
1379pub(crate) fn disasm_check_type(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1380    let var_idx = chunk.read_u16(*ip);
1381    *ip += 2;
1382    let type_idx = chunk.read_u16(*ip);
1383    *ip += 2;
1384    format!(
1385        "{label} {var_idx:>4} ({}) -> {type_idx:>4} ({})",
1386        chunk.constants[var_idx as usize], chunk.constants[type_idx as usize],
1387    )
1388}
1389
1390pub(crate) fn disasm_call_builtin(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1391    let id = chunk.read_u64(*ip);
1392    *ip += 8;
1393    let idx = chunk.read_u16(*ip);
1394    *ip += 2;
1395    let argc = chunk.code[*ip];
1396    *ip += 1;
1397    format!(
1398        "{label} {id:#018x} {idx:>4} ({}) argc={argc}",
1399        chunk.constants[idx as usize],
1400    )
1401}
1402
1403pub(crate) fn disasm_call_builtin_spread(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1404    let id = chunk.read_u64(*ip);
1405    *ip += 8;
1406    let idx = chunk.read_u16(*ip);
1407    *ip += 2;
1408    format!(
1409        "{label} {id:#018x} {idx:>4} ({})",
1410        chunk.constants[idx as usize],
1411    )
1412}
1413
1414pub(crate) fn disasm_method_call_spread(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1415    // emit_u16(Op::MethodCallSpread, name_idx, ...) writes opcode + 2
1416    // bytes of u16 name_idx, so the operand is read at *ip with the
1417    // usual `read_u16`. The previous hand-written disasm read at
1418    // `ip + 1`, which displayed the wrong constant index — silently
1419    // corrupting any disassembly that hit a `MethodCallSpread` opcode.
1420    let idx = chunk.read_u16(*ip);
1421    *ip += 2;
1422    format!("{label} {idx:>4} ({})", chunk.constants[idx as usize])
1423}
1424
1425impl Default for Chunk {
1426    fn default() -> Self {
1427        Self::new()
1428    }
1429}
1430
1431#[cfg(test)]
1432#[path = "chunk_tests.rs"]
1433mod tests;