Skip to main content

harn_vm/
chunk.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::sync::Arc;
4
5use harn_parser::TypeExpr;
6use parking_lot::Mutex;
7use serde::{Deserialize, Serialize};
8
9use crate::runtime_guards::RuntimeParamGuard;
10
11/// Sentinel value stored in [`Chunk::inline_cache_index`] for code offsets
12/// that have no inline-cache slot registered. Chosen as `u32::MAX` so the
13/// hot dispatch path can treat the side-table as a flat `Vec<u32>` without
14/// an `Option` wrapper — the comparison against the sentinel collapses to a
15/// single integer compare. The compile-time max useful slot count is bounded
16/// by code length (one slot per cacheable opcode), so `u32::MAX` is safely
17/// out of the addressable slot range.
18pub(crate) const NO_INLINE_CACHE_SLOT: u32 = u32::MAX;
19
20/// Bytecode opcodes for the Harn VM. The enum, the byte-to-variant
21/// mapping, the sync and async dispatch tables, the disassembly
22/// renderer, and the per-opcode classification helpers are all emitted
23/// by `harn_opcode_macros::define_opcodes!` in [`crate::vm::ops`].
24/// Re-exported here so callers that import `crate::chunk::Op` need no
25/// awareness of the macro layout.
26pub use crate::vm::ops::Op;
27pub(crate) use crate::vm::ops::{is_adaptive_binary_op, op_reads_outer_name};
28
29/// A constant value in the constant pool.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub enum Constant {
32    Int(i64),
33    Float(f64),
34    String(String),
35    Bool(bool),
36    Nil,
37    Duration(i64),
38}
39
40/// Runtime-only inline-cache state for bytecode instructions that repeatedly
41/// see the same dynamic shape. Lookup caches stay monomorphic on a name and
42/// receiver shape. Adaptive caches warm on a stable operand or call target,
43/// then fall back through the generic opcode and replace or reset state when
44/// the observed shape changes.
45///
46/// This vector is intentionally excluded from [`CachedChunk`]: bytecode cache
47/// artifacts keep the slot layout but start with empty runtime feedback in each
48/// process.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub(crate) enum InlineCacheEntry {
51    Empty,
52    Property {
53        name_idx: u16,
54        target: PropertyCacheTarget,
55    },
56    Method {
57        name_idx: u16,
58        argc: usize,
59        target: MethodCacheTarget,
60    },
61    AdaptiveBinary {
62        op: AdaptiveBinaryOp,
63        state: AdaptiveBinaryState,
64    },
65    DirectCall {
66        state: DirectCallState,
67    },
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub(crate) enum AdaptiveBinaryOp {
72    Add,
73    Sub,
74    Mul,
75    Div,
76    Mod,
77    Equal,
78    NotEqual,
79    Less,
80    Greater,
81    LessEqual,
82    GreaterEqual,
83}
84
85/// Adaptive-binary IC state. All fields are scalar `Copy` (shape is a
86/// `Copy` enum, hit/miss counters are integers), so the struct as a whole
87/// is `Copy`. This lets `execute_adaptive_binary` extract the cached state
88/// by value for the specialization check without cloning the wrapping
89/// `InlineCacheEntry` on every dispatch.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub(crate) enum AdaptiveBinaryState {
92    Warmup {
93        shape: BinaryShape,
94        hits: u8,
95    },
96    Specialized {
97        shape: BinaryShape,
98        hits: u64,
99        misses: u64,
100    },
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub(crate) enum BinaryShape {
105    Int,
106    Float,
107    Bool,
108    String,
109}
110
111#[derive(Debug, Clone)]
112pub(crate) enum DirectCallState {
113    Warmup {
114        argc: usize,
115        target: DirectCallTarget,
116        hits: u8,
117    },
118    Specialized {
119        argc: usize,
120        target: DirectCallTarget,
121        hits: u64,
122        misses: u64,
123    },
124}
125
126#[derive(Debug, Clone)]
127pub(crate) enum DirectCallTarget {
128    Closure(Arc<crate::value::VmClosure>),
129}
130
131impl PartialEq for DirectCallTarget {
132    fn eq(&self, other: &Self) -> bool {
133        match (self, other) {
134            (Self::Closure(left), Self::Closure(right)) => Arc::ptr_eq(left, right),
135        }
136    }
137}
138
139impl Eq for DirectCallTarget {}
140
141impl PartialEq for DirectCallState {
142    fn eq(&self, other: &Self) -> bool {
143        match (self, other) {
144            (
145                Self::Warmup {
146                    argc: left_argc,
147                    target: left_target,
148                    hits: left_hits,
149                },
150                Self::Warmup {
151                    argc: right_argc,
152                    target: right_target,
153                    hits: right_hits,
154                },
155            ) => left_argc == right_argc && left_target == right_target && left_hits == right_hits,
156            (
157                Self::Specialized {
158                    argc: left_argc,
159                    target: left_target,
160                    hits: left_hits,
161                    misses: left_misses,
162                },
163                Self::Specialized {
164                    argc: right_argc,
165                    target: right_target,
166                    hits: right_hits,
167                    misses: right_misses,
168                },
169            ) => {
170                left_argc == right_argc
171                    && left_target == right_target
172                    && left_hits == right_hits
173                    && left_misses == right_misses
174            }
175            _ => false,
176        }
177    }
178}
179
180impl Eq for DirectCallState {}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub(crate) enum PropertyCacheTarget {
184    DictField(Arc<str>),
185    StructField { field_name: Arc<str>, index: usize },
186    ListCount,
187    ListEmpty,
188    ListFirst,
189    ListLast,
190    StringCount,
191    StringEmpty,
192    PairFirst,
193    PairSecond,
194    EnumVariant,
195    EnumFields,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub(crate) enum MethodCacheTarget {
200    ListCount,
201    ListEmpty,
202    ListContains,
203    StringCount,
204    StringEmpty,
205    StringContains,
206    DictCount,
207    DictHas,
208    RangeCount,
209    RangeLen,
210    RangeEmpty,
211    RangeFirst,
212    RangeLast,
213    SetCount,
214    SetLen,
215    SetEmpty,
216    SetContains,
217}
218
219/// Debug metadata for a slot-indexed local in a compiled chunk.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct LocalSlotInfo {
222    pub name: String,
223    pub mutable: bool,
224    pub scope_depth: usize,
225}
226
227impl fmt::Display for Constant {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        match self {
230            Constant::Int(n) => write!(f, "{n}"),
231            Constant::Float(n) => write!(f, "{n}"),
232            Constant::String(s) => write!(f, "\"{s}\""),
233            Constant::Bool(b) => write!(f, "{b}"),
234            Constant::Nil => write!(f, "nil"),
235            Constant::Duration(ms) => write!(f, "{ms}ms"),
236        }
237    }
238}
239
240/// A compiled chunk of bytecode.
241#[derive(Debug, Clone)]
242pub struct Chunk {
243    /// The bytecode instructions.
244    pub code: Vec<u8>,
245    /// Constant pool.
246    pub constants: Vec<Constant>,
247    /// Source line numbers for each instruction (for error reporting).
248    pub lines: Vec<u32>,
249    /// Source column numbers for each instruction (for error reporting).
250    /// Parallel to `lines`; 0 means no column info available.
251    pub columns: Vec<u32>,
252    /// Source file that this chunk was compiled from, when known. Set for
253    /// chunks compiled from imported modules so runtime errors can report
254    /// the correct file path for each frame instead of always pointing at
255    /// the entry-point pipeline.
256    pub source_file: Option<String>,
257    /// Current column to use when emitting instructions (set by compiler).
258    current_col: u32,
259    /// Compiled function bodies (for closures).
260    pub functions: Vec<CompiledFunctionRef>,
261    /// Instruction offset to inline-cache slot. Slots are assigned at emit time
262    /// for cacheable instructions while bytecode bytes remain immutable.
263    /// Preserved as the serialization-stable representation that round-trips
264    /// through [`CachedChunk`]; the runtime hot path reads
265    /// [`Chunk::inline_cache_index`] instead.
266    inline_cache_slots: BTreeMap<usize, usize>,
267    /// Flat side-table indexed by code offset that returns the inline-cache
268    /// slot index (or [`NO_INLINE_CACHE_SLOT`] for "no slot at this offset").
269    /// Built alongside [`Chunk::inline_cache_slots`] at emit/load time so the
270    /// per-dispatch lookup that fires on every adaptive binary op, `Op::Call`,
271    /// `Op::MethodCall`, and `Op::GetProperty` is one cache-friendly `Vec`
272    /// index instead of a `BTreeMap::get` (O(1) vs O(log n) with the
273    /// associated pointer chasing). Derived; intentionally not serialized.
274    inline_cache_index: Vec<u32>,
275    /// Shared cache entries so cloned chunks in call frames warm the same side
276    /// table as the compiled chunk used by tests/debugging.
277    inline_caches: Arc<Mutex<Vec<InlineCacheEntry>>>,
278    /// Lazily-materialized shared string cache for `Constant::String` entries,
279    /// parallel to `constants`. String constants are materialized once per
280    /// unique constant; subsequent pushes are an `Arc` refcount bump.
281    constant_strings: Arc<Mutex<Vec<Option<Arc<str>>>>>,
282    /// Source-name metadata for slot-indexed locals in this chunk.
283    pub(crate) local_slots: Vec<LocalSlotInfo>,
284    /// True when this chunk's bytecode emits an opcode that resolves a
285    /// name through the runtime env (`GetVar`, `SetVar`, `CallBuiltin`,
286    /// `CallBuiltinSpread`, `CheckType`). The closure-call hot path uses
287    /// this as a cheap static guard: if a closure body never reads
288    /// outer names by name, the caller-scope late-bind walks in
289    /// [`Vm::closure_call_env`] and
290    /// [`Vm::closure_call_env_for_current_frame`] are pure overhead and
291    /// can be skipped, leaving the closure's captured env as-is.
292    ///
293    /// Walks exist to inject late-bound closure-typed names — typically
294    /// for self/mutually-recursive local fns and for fns whose captured
295    /// env predates a sibling definition. Inline arithmetic / comparison
296    /// callbacks (the `.map(x -> x * 2)` / `.filter(x -> x % 2 == 0)`
297    /// shape) emit none of the flagged opcodes, so the walk is wasted
298    /// work on every invocation.
299    pub(crate) references_outer_names: bool,
300    /// Compile-time operand-stack-depth tracking for the debug-build
301    /// balance assertion (issue #2622). `balance_depth` is the running net
302    /// effect of every *linearly-modeled* opcode emitted so far;
303    /// `balance_nonlinear` counts emits whose effect can't be tracked by a
304    /// straight-line sum (jumps, `return`, async/handler ops, variadic ops
305    /// whose count isn't an emit argument). A statement is "balance-exact"
306    /// only when `balance_nonlinear` is unchanged across its compilation,
307    /// at which point `balance_depth`'s delta is its true net stack effect.
308    /// Transient compile-time state: reset by [`Chunk::new`], never
309    /// serialized into [`CachedChunk`], and read only by debug assertions —
310    /// so a wrong absolute value (which a non-exact statement can leave
311    /// behind) is harmless; only per-statement *deltas over exact spans*
312    /// are ever trusted.
313    #[cfg(debug_assertions)]
314    balance_depth: i32,
315    #[cfg(debug_assertions)]
316    balance_nonlinear: u32,
317}
318
319pub type ChunkRef = Arc<Chunk>;
320pub type CompiledFunctionRef = Arc<CompiledFunction>;
321
322/// Serializable snapshot of a [`Chunk`] suitable for the on-disk bytecode
323/// cache and for in-memory stdlib artifact caches. Inline-cache state is
324/// dropped at freeze time because it warms at runtime per-process; the
325/// rest of the chunk round-trips byte-identically.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct CachedChunk {
328    pub(crate) code: Vec<u8>,
329    pub(crate) constants: Vec<Constant>,
330    pub(crate) lines: Vec<u32>,
331    pub(crate) columns: Vec<u32>,
332    pub(crate) source_file: Option<String>,
333    pub(crate) current_col: u32,
334    pub(crate) functions: Vec<CachedCompiledFunction>,
335    pub(crate) inline_cache_slots: BTreeMap<usize, usize>,
336    pub(crate) local_slots: Vec<LocalSlotInfo>,
337    #[serde(default)]
338    pub(crate) references_outer_names: bool,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct CachedCompiledFunction {
343    pub(crate) name: String,
344    pub(crate) type_params: Vec<String>,
345    pub(crate) nominal_type_names: Vec<String>,
346    pub(crate) params: Vec<CachedParamSlot>,
347    pub(crate) default_start: Option<usize>,
348    pub(crate) chunk: CachedChunk,
349    pub(crate) is_generator: bool,
350    pub(crate) is_stream: bool,
351    pub(crate) has_rest_param: bool,
352    pub(crate) has_runtime_type_checks: bool,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub(crate) struct CachedParamSlot {
357    pub(crate) name: String,
358    pub(crate) type_expr: Option<TypeExpr>,
359    pub(crate) has_default: bool,
360}
361
362impl CachedParamSlot {
363    fn thaw(&self) -> ParamSlot {
364        ParamSlot {
365            name: self.name.clone(),
366            type_expr: self.type_expr.clone(),
367            runtime_guard: self
368                .type_expr
369                .as_ref()
370                .map(RuntimeParamGuard::from_type_expr),
371            has_default: self.has_default,
372        }
373    }
374}
375
376/// One parameter slot of a compiled user-defined function. Carries the
377/// declared name, the (optional) declared type expression, and a flag
378/// for whether a default value was provided. The runtime consults the
379/// type expression in `bind_param_slots` to enforce declared types
380/// against the values supplied at the call site.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct ParamSlot {
383    pub name: String,
384    /// Declared parameter type. `None` for untyped parameters (gradual
385    /// typing); the runtime skips type assertion when absent.
386    pub type_expr: Option<TypeExpr>,
387    /// Precomputed runtime validation metadata derived from `type_expr`.
388    /// Bytecode-cache artifacts omit this field and rebuild it at load time.
389    #[serde(skip)]
390    pub(crate) runtime_guard: Option<RuntimeParamGuard>,
391    /// True when the parameter has a default-value clause. Diagnostic
392    /// only — the canonical authority for arity ranges is
393    /// [`CompiledFunction::default_start`].
394    pub has_default: bool,
395}
396
397impl ParamSlot {
398    /// Build a [`ParamSlot`] from a parser-side [`harn_parser::TypedParam`].
399    /// Centralizes the conversion so every compile path stays in lockstep.
400    pub fn from_typed_param(param: &harn_parser::TypedParam) -> Self {
401        Self {
402            name: param.name.clone(),
403            type_expr: param.type_expr.clone(),
404            runtime_guard: param
405                .type_expr
406                .as_ref()
407                .map(RuntimeParamGuard::from_type_expr),
408            has_default: param.default_value.is_some(),
409        }
410    }
411
412    fn freeze_for_cache(&self) -> CachedParamSlot {
413        CachedParamSlot {
414            name: self.name.clone(),
415            type_expr: self.type_expr.clone(),
416            has_default: self.has_default,
417        }
418    }
419
420    /// Build a `Vec<ParamSlot>` from a slice of parser-side typed
421    /// parameters. Used pervasively at compile sites instead of
422    /// `TypedParam::names` (which discarded the type info we now need
423    /// at runtime).
424    pub fn vec_from_typed(params: &[harn_parser::TypedParam]) -> Vec<Self> {
425        params.iter().map(Self::from_typed_param).collect()
426    }
427}
428
429/// A compiled function (closure body).
430#[derive(Debug, Clone)]
431pub struct CompiledFunction {
432    pub name: String,
433    /// Generic type parameters declared by this function. Runtime
434    /// validation treats these as static-only constraints because the VM
435    /// does not monomorphize function bodies.
436    pub type_params: Vec<String>,
437    /// User-defined struct and enum names visible when this function was
438    /// compiled. These are the only non-primitive named types with runtime
439    /// nominal identity; aliases and interfaces remain static-only.
440    pub nominal_type_names: Vec<String>,
441    pub params: Vec<ParamSlot>,
442    /// Index of the first parameter with a default value, or None if all required.
443    pub default_start: Option<usize>,
444    pub chunk: ChunkRef,
445    /// True if the function body contains `yield` expressions (generator function).
446    pub is_generator: bool,
447    /// True if the function was declared as `gen fn` and should return Stream.
448    pub is_stream: bool,
449    /// True if the last parameter is a rest parameter (`...name`).
450    pub has_rest_param: bool,
451    /// True when at least one parameter has a runtime-visible type
452    /// assertion. Untyped closures dominate collection callback hot paths,
453    /// so this lets the VM skip the per-argument metadata walk after the
454    /// arity check.
455    pub has_runtime_type_checks: bool,
456}
457
458impl CompiledFunction {
459    pub(crate) fn has_runtime_type_checks_for_params(params: &[ParamSlot]) -> bool {
460        params.iter().any(|param| param.type_expr.is_some())
461    }
462
463    /// Returns just the parameter names — convenience for code paths that
464    /// don't care about types or defaults.
465    pub fn param_names(&self) -> impl Iterator<Item = &str> {
466        self.params.iter().map(|p| p.name.as_str())
467    }
468
469    /// Number of required parameters (those before `default_start`).
470    pub fn required_param_count(&self) -> usize {
471        self.default_start.unwrap_or(self.params.len())
472    }
473
474    pub fn declares_type_param(&self, name: &str) -> bool {
475        self.type_params.iter().any(|param| param == name)
476    }
477
478    pub fn has_nominal_type(&self, name: &str) -> bool {
479        self.nominal_type_names.iter().any(|ty| ty == name)
480    }
481
482    pub(crate) fn freeze_for_cache(&self) -> CachedCompiledFunction {
483        CachedCompiledFunction {
484            name: self.name.clone(),
485            type_params: self.type_params.clone(),
486            nominal_type_names: self.nominal_type_names.clone(),
487            params: self
488                .params
489                .iter()
490                .map(ParamSlot::freeze_for_cache)
491                .collect(),
492            default_start: self.default_start,
493            chunk: self.chunk.freeze_for_cache(),
494            is_generator: self.is_generator,
495            is_stream: self.is_stream,
496            has_rest_param: self.has_rest_param,
497            has_runtime_type_checks: self.has_runtime_type_checks,
498        }
499    }
500
501    pub(crate) fn from_cached(cached: &CachedCompiledFunction) -> Self {
502        Self {
503            name: cached.name.clone(),
504            type_params: cached.type_params.clone(),
505            nominal_type_names: cached.nominal_type_names.clone(),
506            params: cached.params.iter().map(CachedParamSlot::thaw).collect(),
507            default_start: cached.default_start,
508            chunk: Arc::new(Chunk::from_cached(&cached.chunk)),
509            is_generator: cached.is_generator,
510            is_stream: cached.is_stream,
511            has_rest_param: cached.has_rest_param,
512            has_runtime_type_checks: cached.has_runtime_type_checks,
513        }
514    }
515}
516
517/// A snapshot of [`Chunk`]'s compile-time balance model, returned by
518/// [`Chunk::balance_probe`] and consumed by [`Chunk::balance_delta_since`].
519#[cfg(debug_assertions)]
520#[derive(Clone, Copy)]
521pub(crate) struct BalanceProbe {
522    depth: i32,
523    nonlinear: u32,
524}
525
526/// Net operand-stack effect (`pushes - pops`) of one emitted opcode, for
527/// the debug-build balance assertion (issue #2622). `count` is the opcode's
528/// variadic arity when that arity is the emit-call argument (`BuildList`
529/// length, `Call` argc, …) and `0` otherwise.
530///
531/// `Some(delta)` means the effect is exactly modeled. `None` marks an
532/// opcode a straight-line running sum can't track — control flow that
533/// branches or terminates (`Jump*`, `Return`, `Throw`, `TailCall`),
534/// async/handler ops, and variadic ops whose arity rides in a raw operand
535/// byte rather than the emit argument (`BuildEnum`, `MatchEnum`). Such an
536/// opcode taints its enclosing statement as non-exact, so the assertion
537/// skips it instead of risking a false trip.
538///
539/// The `match` is intentionally exhaustive with no `_` arm: adding an
540/// opcode forces a classification here (a compile error otherwise), so the
541/// balance model can't silently drift out of sync with the instruction set.
542#[cfg(debug_assertions)]
543fn op_stack_delta(op: Op, count: u16) -> Option<i32> {
544    use Op::*;
545    let count = count as i32;
546    Some(match op {
547        // Push one value.
548        Constant | Nil | True | False | GetVar | GetArgc | GetLocalSlot | Closure | Dup => 1,
549        // Consume one value (into a binding / property / discard). `SetVar`,
550        // `SetProperty` and the local-slot stores read their target by name
551        // or slot index, so they only pop the value being stored.
552        DefLet | DefVar | SetVar | DefLocalSlot | SetLocalSlot | SetProperty | Pop => -1,
553        // Value-preserving: unary ops, by-name lookups/checks, and scope /
554        // iterator / exception-handler bookkeeping (the last three touch
555        // side stacks, not the operand stack).
556        Negate | Not | GetProperty | GetPropertyOpt | CheckType | TryUnwrap | TryWrapOk | Swap
557        | PushScope | PopScope | PopIterator | PopHandler => 0,
558        // Pop two, push one.
559        Add | Sub | Mul | Div | Mod | Pow | AddInt | SubInt | MulInt | DivInt | ModInt
560        | AddFloat | SubFloat | MulFloat | DivFloat | ModFloat | Equal | NotEqual | Less
561        | Greater | LessEqual | GreaterEqual | EqualInt | NotEqualInt | LessInt | GreaterInt
562        | LessEqualInt | GreaterEqualInt | EqualFloat | NotEqualFloat | LessFloat
563        | GreaterFloat | LessEqualFloat | GreaterEqualFloat | EqualBool | NotEqualBool
564        | EqualString | NotEqualString | Contains | Subscript | SubscriptOpt => -1,
565        // `IterInit` consumes the iterable and pushes nothing (the iterator
566        // lives on a side stack).
567        IterInit => -1,
568        // Pop three (or two values + a by-name target), push one.
569        Slice | SetSubscript => -2,
570        // Variadic whose arity is the emit argument: pop `count`, push one.
571        BuildList | Concat | CallBuiltin => 1 - count,
572        BuildDict => 1 - 2 * count,
573        // Calls also pop the callee/receiver beneath the args.
574        Call | MethodCall | MethodCallOpt => -count,
575        // Non-linear (see doc comment): branches, terminators, async/handler
576        // ops, and variadic ops whose arity isn't the emit argument.
577        Jump | JumpIfFalse | JumpIfTrue | IterNext | Return | TailCall | Throw | TryCatchSetup
578        | Spawn | Pipe | Parallel | ParallelMap | ParallelMapStream | ParallelSettle
579        | SyncMutexEnter | Import | SelectiveImport | DeadlineSetup | DeadlineEnd | BuildEnum
580        | MatchEnum | Yield | CallSpread | CallBuiltinSpread | MethodCallSpread => return None,
581    })
582}
583
584impl Chunk {
585    pub fn new() -> Self {
586        Self {
587            code: Vec::new(),
588            constants: Vec::new(),
589            lines: Vec::new(),
590            columns: Vec::new(),
591            source_file: None,
592            current_col: 0,
593            functions: Vec::new(),
594            inline_cache_slots: BTreeMap::new(),
595            inline_cache_index: Vec::new(),
596            inline_caches: Arc::new(Mutex::new(Vec::new())),
597            constant_strings: Arc::new(Mutex::new(Vec::new())),
598            local_slots: Vec::new(),
599            references_outer_names: false,
600            #[cfg(debug_assertions)]
601            balance_depth: 0,
602            #[cfg(debug_assertions)]
603            balance_nonlinear: 0,
604        }
605    }
606
607    /// Set the current column for subsequent emit calls.
608    pub fn set_column(&mut self, col: u32) {
609        self.current_col = col;
610    }
611
612    /// Add a constant and return its index.
613    pub fn add_constant(&mut self, constant: Constant) -> u16 {
614        for (i, c) in self.constants.iter().enumerate() {
615            if c == &constant {
616                return i as u16;
617            }
618        }
619        let idx = self.constants.len();
620        self.constants.push(constant);
621        idx as u16
622    }
623
624    /// Emit a single-byte instruction.
625    pub fn emit(&mut self, op: Op, line: u32) {
626        #[cfg(debug_assertions)]
627        self.note_balance(op, 0);
628        let col = self.current_col;
629        let op_offset = self.code.len();
630        self.code.push(op as u8);
631        self.lines.push(line);
632        self.columns.push(col);
633        if is_adaptive_binary_op(op) {
634            self.register_inline_cache(op_offset);
635        }
636        if op_reads_outer_name(op) {
637            self.references_outer_names = true;
638        }
639    }
640
641    /// Emit an instruction with a u16 argument.
642    pub fn emit_u16(&mut self, op: Op, arg: u16, line: u32) {
643        #[cfg(debug_assertions)]
644        self.note_balance(op, arg);
645        let col = self.current_col;
646        let op_offset = self.code.len();
647        self.code.push(op as u8);
648        self.code.push((arg >> 8) as u8);
649        self.code.push((arg & 0xFF) as u8);
650        self.lines.push(line);
651        self.lines.push(line);
652        self.lines.push(line);
653        self.columns.push(col);
654        self.columns.push(col);
655        self.columns.push(col);
656        if matches!(
657            op,
658            Op::GetProperty | Op::GetPropertyOpt | Op::MethodCallSpread
659        ) {
660            self.register_inline_cache(op_offset);
661        }
662        if op_reads_outer_name(op) {
663            self.references_outer_names = true;
664        }
665    }
666
667    /// Emit an instruction with a u8 argument.
668    pub fn emit_u8(&mut self, op: Op, arg: u8, line: u32) {
669        #[cfg(debug_assertions)]
670        self.note_balance(op, arg as u16);
671        let col = self.current_col;
672        let op_offset = self.code.len();
673        self.code.push(op as u8);
674        self.code.push(arg);
675        self.lines.push(line);
676        self.lines.push(line);
677        self.columns.push(col);
678        self.columns.push(col);
679        if matches!(op, Op::Call) {
680            self.register_inline_cache(op_offset);
681        }
682        if op_reads_outer_name(op) {
683            self.references_outer_names = true;
684        }
685    }
686
687    /// Emit a direct builtin call.
688    pub fn emit_call_builtin(
689        &mut self,
690        id: crate::BuiltinId,
691        name_idx: u16,
692        arg_count: u8,
693        line: u32,
694    ) {
695        #[cfg(debug_assertions)]
696        self.note_balance(Op::CallBuiltin, arg_count as u16);
697        let col = self.current_col;
698        let op_offset = self.code.len();
699        self.code.push(Op::CallBuiltin as u8);
700        self.code.extend_from_slice(&id.raw().to_be_bytes());
701        self.code.push((name_idx >> 8) as u8);
702        self.code.push((name_idx & 0xFF) as u8);
703        self.code.push(arg_count);
704        for _ in 0..12 {
705            self.lines.push(line);
706            self.columns.push(col);
707        }
708        self.register_inline_cache(op_offset);
709        self.references_outer_names = true;
710    }
711
712    /// Emit a direct builtin spread call.
713    pub fn emit_call_builtin_spread(&mut self, id: crate::BuiltinId, name_idx: u16, line: u32) {
714        #[cfg(debug_assertions)]
715        self.note_balance(Op::CallBuiltinSpread, 0);
716        let col = self.current_col;
717        self.code.push(Op::CallBuiltinSpread as u8);
718        self.code.extend_from_slice(&id.raw().to_be_bytes());
719        self.code.push((name_idx >> 8) as u8);
720        self.code.push((name_idx & 0xFF) as u8);
721        for _ in 0..11 {
722            self.lines.push(line);
723            self.columns.push(col);
724        }
725        self.references_outer_names = true;
726    }
727
728    /// Emit a method call: op + u16 (method name) + u8 (arg count).
729    pub fn emit_method_call(&mut self, name_idx: u16, arg_count: u8, line: u32) {
730        self.emit_method_call_inner(Op::MethodCall, name_idx, arg_count, line);
731    }
732
733    /// Emit an optional method call (?.) — returns nil if receiver is nil.
734    pub fn emit_method_call_opt(&mut self, name_idx: u16, arg_count: u8, line: u32) {
735        self.emit_method_call_inner(Op::MethodCallOpt, name_idx, arg_count, line);
736    }
737
738    fn emit_method_call_inner(&mut self, op: Op, name_idx: u16, arg_count: u8, line: u32) {
739        #[cfg(debug_assertions)]
740        self.note_balance(op, arg_count as u16);
741        let col = self.current_col;
742        let op_offset = self.code.len();
743        self.code.push(op as u8);
744        self.code.push((name_idx >> 8) as u8);
745        self.code.push((name_idx & 0xFF) as u8);
746        self.code.push(arg_count);
747        self.lines.push(line);
748        self.lines.push(line);
749        self.lines.push(line);
750        self.lines.push(line);
751        self.columns.push(col);
752        self.columns.push(col);
753        self.columns.push(col);
754        self.columns.push(col);
755        self.register_inline_cache(op_offset);
756    }
757
758    /// Current code offset (for jump patching).
759    pub fn current_offset(&self) -> usize {
760        self.code.len()
761    }
762
763    /// Emit a jump instruction with a placeholder offset. Returns the position to patch.
764    pub fn emit_jump(&mut self, op: Op, line: u32) -> usize {
765        #[cfg(debug_assertions)]
766        self.note_balance(op, 0);
767        let col = self.current_col;
768        self.code.push(op as u8);
769        let patch_pos = self.code.len();
770        self.code.push(0xFF);
771        self.code.push(0xFF);
772        self.lines.push(line);
773        self.lines.push(line);
774        self.lines.push(line);
775        self.columns.push(col);
776        self.columns.push(col);
777        self.columns.push(col);
778        patch_pos
779    }
780
781    /// Patch a jump instruction at the given position to jump to the current offset.
782    pub fn patch_jump(&mut self, patch_pos: usize) {
783        let target = self.code.len() as u16;
784        self.code[patch_pos] = (target >> 8) as u8;
785        self.code[patch_pos + 1] = (target & 0xFF) as u8;
786    }
787
788    /// Patch a jump to a specific target position.
789    pub fn patch_jump_to(&mut self, patch_pos: usize, target: usize) {
790        let target = target as u16;
791        self.code[patch_pos] = (target >> 8) as u8;
792        self.code[patch_pos + 1] = (target & 0xFF) as u8;
793    }
794
795    /// Read a u16 argument at the given position.
796    pub fn read_u16(&self, pos: usize) -> u16 {
797        ((self.code[pos] as u16) << 8) | (self.code[pos + 1] as u16)
798    }
799
800    /// Fold one just-emitted opcode into the compile-time operand-stack
801    /// balance model (issue #2622). See [`op_stack_delta`] for the
802    /// linear-vs-non-linear classification.
803    #[cfg(debug_assertions)]
804    fn note_balance(&mut self, op: Op, count: u16) {
805        match op_stack_delta(op, count) {
806            Some(delta) => self.balance_depth += delta,
807            None => self.balance_nonlinear += 1,
808        }
809    }
810
811    /// Snapshot the balance model before compiling a statement; pair with
812    /// [`Chunk::balance_delta_since`].
813    #[cfg(debug_assertions)]
814    pub(crate) fn balance_probe(&self) -> BalanceProbe {
815        BalanceProbe {
816            depth: self.balance_depth,
817            nonlinear: self.balance_nonlinear,
818        }
819    }
820
821    /// Net operand-stack effect emitted since `probe`, or `None` when any
822    /// non-linearly-modeled opcode was emitted in that span (which makes
823    /// the running sum untrustworthy, so callers must not assert on it).
824    /// The absolute `balance_depth` may be meaningless after a non-exact
825    /// span — only deltas over a fully-exact span are valid.
826    #[cfg(debug_assertions)]
827    pub(crate) fn balance_delta_since(&self, probe: BalanceProbe) -> Option<i32> {
828        if self.balance_nonlinear == probe.nonlinear {
829            Some(self.balance_depth - probe.depth)
830        } else {
831            None
832        }
833    }
834
835    fn register_inline_cache(&mut self, op_offset: usize) {
836        if self.inline_cache_slots.contains_key(&op_offset) {
837            return;
838        }
839        let mut entries = self.inline_caches.lock();
840        let slot = entries.len();
841        entries.push(InlineCacheEntry::Empty);
842        self.inline_cache_slots.insert(op_offset, slot);
843        Self::write_inline_cache_index(&mut self.inline_cache_index, op_offset, slot);
844    }
845
846    /// Fast-path side-table writer. Pulled out as an associated fn so both
847    /// the live emit path and [`Chunk::from_cached`] share the same growth
848    /// strategy. Cache slots fit comfortably in `u32` because the slot count
849    /// is bounded by the cacheable-opcode count in `code`.
850    fn write_inline_cache_index(index: &mut Vec<u32>, op_offset: usize, slot: usize) {
851        if op_offset >= index.len() {
852            index.resize(op_offset + 1, NO_INLINE_CACHE_SLOT);
853        }
854        index[op_offset] = slot as u32;
855    }
856
857    /// Look up the inline-cache slot for the opcode at `op_offset`. This is
858    /// called on every dispatch of an adaptive binary op (Add/Sub/Mul/Div/
859    /// Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq), `Op::Call`, `Op::MethodCall`
860    /// (and `MethodCallOpt`/`MethodCallSpread`), and `Op::GetProperty`
861    /// (`GetPropertyOpt`). Backed by [`Chunk::inline_cache_index`] — a flat
862    /// `Vec<u32>` indexed by code offset — so the lookup is a single bounds-
863    /// checked array read instead of the prior `BTreeMap::get` which walked
864    /// internal nodes for every dispatched op.
865    #[inline]
866    pub(crate) fn inline_cache_slot(&self, op_offset: usize) -> Option<usize> {
867        match self.inline_cache_index.get(op_offset).copied() {
868            None | Some(NO_INLINE_CACHE_SLOT) => None,
869            Some(slot) => Some(slot as usize),
870        }
871    }
872
873    /// Pre-optimization control path: the `BTreeMap`-backed lookup the
874    /// dispatcher used before the flat `Vec<u32>` side-table. Exposed
875    /// only behind the `vm-bench-internals` feature so the criterion
876    /// microbench can A/B the two paths inside one binary on identical
877    /// hardware. The production hot path must keep using
878    /// [`Chunk::inline_cache_slot`].
879    #[cfg(feature = "vm-bench-internals")]
880    pub fn inline_cache_slot_via_btreemap_for_bench(&self, op_offset: usize) -> Option<usize> {
881        self.inline_cache_slots.get(&op_offset).copied()
882    }
883
884    /// Returns a shared string for a `Constant::String` at the given pool
885    /// index, materializing it on first access and caching for reuse.
886    /// Returns `None` when the constant at `idx` is not a string (the
887    /// caller should fall back to the regular `Constant` match).
888    pub(crate) fn constant_string_rc(&self, idx: usize) -> Option<Arc<str>> {
889        // Borrow the side table mutably so we can lazily extend / fill
890        // entries. The borrow is scope-confined to this function; the
891        // VM never re-enters constant_string_rc for the same chunk
892        // during a single materialization, so no nested-borrow risk.
893        let mut entries = self.constant_strings.lock();
894        if entries.len() < self.constants.len() {
895            entries.resize(self.constants.len(), None);
896        }
897        if let Some(Some(existing)) = entries.get(idx) {
898            return Some(Arc::clone(existing));
899        }
900        let materialized = match self.constants.get(idx)? {
901            Constant::String(s) => Arc::<str>::from(s.as_str()),
902            _ => return None,
903        };
904        entries[idx] = Some(Arc::clone(&materialized));
905        Some(materialized)
906    }
907
908    #[cfg(feature = "vm-bench-internals")]
909    pub(crate) fn inline_cache_entry(&self, slot: usize) -> InlineCacheEntry {
910        self.inline_caches
911            .lock()
912            .get(slot)
913            .cloned()
914            .unwrap_or(InlineCacheEntry::Empty)
915    }
916
917    /// Adaptive-binary fast path read. Returns the cached
918    /// `(op, state)` pair by value (both `Copy`) when slot holds an
919    /// `AdaptiveBinary` entry, else `None`. Skips the
920    /// `InlineCacheEntry::clone` that `inline_cache_entry` performs:
921    /// since `AdaptiveBinaryState: Copy`, the read does a single
922    /// scalar move out of the cache instead of a 24-32B memcpy of the
923    /// wrapping enum (which the variant-checking match destructures
924    /// and throws away anyway). Fires on every Add/Sub/Mul/Div/Mod/Eq/
925    /// Neq/Less/Greater/LessEq/GreaterEq dispatch, so the per-op
926    /// savings compound across the millions of dispatches a typical
927    /// loop body issues.
928    #[inline]
929    pub(crate) fn peek_adaptive_binary_cache(
930        &self,
931        slot: usize,
932    ) -> Option<(AdaptiveBinaryOp, AdaptiveBinaryState)> {
933        match self.inline_caches.lock().get(slot)? {
934            &InlineCacheEntry::AdaptiveBinary { op, state } => Some((op, state)),
935            _ => None,
936        }
937    }
938
939    /// Method-cache fast path read. Returns the cached `(name_idx, argc,
940    /// target)` triple by value (all three are `Copy`) when `slot` holds a
941    /// `Method` entry, else `None`. Skips the full `InlineCacheEntry::clone`
942    /// that `inline_cache_entry` performs on every `Op::MethodCall`,
943    /// `Op::MethodCallOpt`, and `Op::MethodCallSpread` dispatch: the
944    /// variant-checking `let-else` in `try_cached_method` destructures and
945    /// throws the wrapping enum away anyway, so reading the payload by `Copy`
946    /// avoids the 32-48B enum memcpy. Method-call dispatch is the second-
947    /// hottest IC-keyed opcode class after the adaptive binary ops, so the
948    /// per-dispatch savings compound across the millions of method calls a
949    /// typical pipeline (`xs.filter(...).map(...).count()`) issues.
950    #[inline]
951    pub(crate) fn peek_method_cache(&self, slot: usize) -> Option<(u16, usize, MethodCacheTarget)> {
952        match self.inline_caches.lock().get(slot)? {
953            &InlineCacheEntry::Method {
954                name_idx,
955                argc,
956                target,
957            } => Some((name_idx, argc, target)),
958            _ => None,
959        }
960    }
961
962    /// Property-cache fast path read. Returns the cached `(name_idx, target)`
963    /// pair by value when `slot` holds a `Property` entry, else `None`. The
964    /// outer `InlineCacheEntry` is the worst-case-sized variant (DirectCall
965    /// at ~48 bytes including padding); cloning it just to discard four other
966    /// variants in `try_cached_property`'s variant-check is wasted work. The
967    /// peek returns just the `Property` payload (`u16` + `PropertyCacheTarget`),
968    /// skipping the outer enum tag init and the padding-to-largest-variant
969    /// memcpy. Fires on every `Op::GetProperty` / `Op::GetPropertyOpt`
970    /// dispatch, which is the dominant opcode for any field-read-heavy code.
971    #[inline]
972    pub(crate) fn peek_property_cache(&self, slot: usize) -> Option<(u16, PropertyCacheTarget)> {
973        match self.inline_caches.lock().get(slot)? {
974            InlineCacheEntry::Property { name_idx, target } => Some((*name_idx, target.clone())),
975            _ => None,
976        }
977    }
978
979    /// Direct-call cache state read. Returns just the inner `DirectCallState`
980    /// by value when `slot` holds a `DirectCall` entry, else `None`. Used by
981    /// both `try_cached_direct_call(_)` (steady-state Specialized hit check)
982    /// and `next_direct_call_entry` (Warmup → Specialized state-machine
983    /// transition). Peeking the inner state directly skips the outer
984    /// `InlineCacheEntry` discriminant check and tag init that the dispatcher
985    /// otherwise pays on every `Op::Call` (closure callee) and the named-fn
986    /// fast path inside `Op::CallBuiltin`. Single peek per dispatch covers
987    /// both the read check and the write-back computation.
988    #[inline]
989    pub(crate) fn peek_direct_call_state(&self, slot: usize) -> Option<DirectCallState> {
990        match self.inline_caches.lock().get(slot)? {
991            InlineCacheEntry::DirectCall { state } => Some(state.clone()),
992            _ => None,
993        }
994    }
995
996    pub(crate) fn set_inline_cache_entry(&self, slot: usize, entry: InlineCacheEntry) {
997        if let Some(existing) = self.inline_caches.lock().get_mut(slot) {
998            *existing = entry;
999        }
1000    }
1001
1002    pub fn freeze_for_cache(&self) -> CachedChunk {
1003        CachedChunk {
1004            code: self.code.clone(),
1005            constants: self.constants.clone(),
1006            lines: self.lines.clone(),
1007            columns: self.columns.clone(),
1008            source_file: self.source_file.clone(),
1009            current_col: self.current_col,
1010            functions: self
1011                .functions
1012                .iter()
1013                .map(|function| function.freeze_for_cache())
1014                .collect(),
1015            inline_cache_slots: self.inline_cache_slots.clone(),
1016            local_slots: self.local_slots.clone(),
1017            references_outer_names: self.references_outer_names,
1018        }
1019    }
1020
1021    pub fn from_cached(cached: &CachedChunk) -> Self {
1022        let inline_cache_count = cached.inline_cache_slots.len();
1023        let constants_count = cached.constants.len();
1024        // Project the cached `BTreeMap<op_offset, slot>` into the flat
1025        // dispatch-side lookup table. Sized to `code.len()` so the hottest
1026        // hot opcodes (binary ops at the end of a long chunk) still hit the
1027        // fast-path bounds check rather than falling through to the
1028        // none-found branch. The size is bounded by code length, so the
1029        // memory footprint is tiny — a few KB for typical chunks.
1030        let mut inline_cache_index = Vec::new();
1031        inline_cache_index.resize(cached.code.len(), NO_INLINE_CACHE_SLOT);
1032        for (&op_offset, &slot) in cached.inline_cache_slots.iter() {
1033            if op_offset < inline_cache_index.len() {
1034                inline_cache_index[op_offset] = slot as u32;
1035            }
1036        }
1037        Self {
1038            code: cached.code.clone(),
1039            constants: cached.constants.clone(),
1040            lines: cached.lines.clone(),
1041            columns: cached.columns.clone(),
1042            source_file: cached.source_file.clone(),
1043            current_col: cached.current_col,
1044            functions: cached
1045                .functions
1046                .iter()
1047                .map(|function| Arc::new(CompiledFunction::from_cached(function)))
1048                .collect(),
1049            inline_cache_slots: cached.inline_cache_slots.clone(),
1050            inline_cache_index,
1051            inline_caches: Arc::new(Mutex::new(vec![
1052                InlineCacheEntry::Empty;
1053                inline_cache_count
1054            ])),
1055            constant_strings: Arc::new(Mutex::new(vec![None; constants_count])),
1056            local_slots: cached.local_slots.clone(),
1057            references_outer_names: cached.references_outer_names,
1058            #[cfg(debug_assertions)]
1059            balance_depth: 0,
1060            #[cfg(debug_assertions)]
1061            balance_nonlinear: 0,
1062        }
1063    }
1064
1065    pub(crate) fn add_local_slot(
1066        &mut self,
1067        name: String,
1068        mutable: bool,
1069        scope_depth: usize,
1070    ) -> u16 {
1071        let idx = self.local_slots.len();
1072        self.local_slots.push(LocalSlotInfo {
1073            name,
1074            mutable,
1075            scope_depth,
1076        });
1077        idx as u16
1078    }
1079
1080    #[cfg(test)]
1081    pub(crate) fn inline_cache_entries(&self) -> Vec<InlineCacheEntry> {
1082        self.inline_caches.lock().clone()
1083    }
1084
1085    /// Read a u64 argument at the given position.
1086    pub fn read_u64(&self, pos: usize) -> u64 {
1087        u64::from_be_bytes([
1088            self.code[pos],
1089            self.code[pos + 1],
1090            self.code[pos + 2],
1091            self.code[pos + 3],
1092            self.code[pos + 4],
1093            self.code[pos + 5],
1094            self.code[pos + 6],
1095            self.code[pos + 7],
1096        ])
1097    }
1098
1099    /// Disassemble the chunk for debugging. The per-opcode rendering is
1100    /// macro-generated alongside the dispatch tables in
1101    /// `crate::vm::ops` — see [`Self::disassemble_op`].
1102    pub fn disassemble(&self, name: &str) -> String {
1103        let mut out = format!("== {name} ==\n");
1104        let mut ip = 0;
1105        while ip < self.code.len() {
1106            let op_byte = self.code[ip];
1107            let line = self.lines.get(ip).copied().unwrap_or(0);
1108            out.push_str(&format!("{ip:04} [{line:>4}] "));
1109            ip += 1;
1110
1111            if let Some(op) = Op::from_byte(op_byte) {
1112                self.disassemble_op(op, &mut ip, &mut out);
1113            } else {
1114                out.push_str(&format!("UNKNOWN(0x{op_byte:02x})\n"));
1115            }
1116        }
1117        out
1118    }
1119}
1120
1121/// Disassembly helpers consumed by the macro-generated
1122/// [`Chunk::disassemble_op`]. Each helper takes the current code position
1123/// (already advanced past the opcode byte), advances it over the operand
1124/// bytes the opcode carries, and renders one human-readable line without
1125/// a trailing newline (the dispatcher appends it).
1126///
1127/// Defining one helper per operand layout — and not one per opcode —
1128/// keeps adding an opcode a one-line edit in the `define_opcodes!` table
1129/// rather than a paired edit here. New layouts live with the helpers;
1130/// new opcodes live with the dispatch.
1131pub(crate) fn disasm_bare(_chunk: &Chunk, _ip: &mut usize, label: &str) -> String {
1132    label.to_string()
1133}
1134
1135pub(crate) fn disasm_u8(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1136    let arg = chunk.code[*ip];
1137    *ip += 1;
1138    format!("{label} {arg:>4}")
1139}
1140
1141pub(crate) fn disasm_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1142    let arg = chunk.read_u16(*ip);
1143    *ip += 2;
1144    format!("{label} {arg:>4}")
1145}
1146
1147pub(crate) fn disasm_const_pool_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1148    let idx = chunk.read_u16(*ip);
1149    *ip += 2;
1150    format!("{label} {idx:>4} ({})", chunk.constants[idx as usize])
1151}
1152
1153pub(crate) fn disasm_local_slot_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1154    let slot = chunk.read_u16(*ip);
1155    *ip += 2;
1156    let mut out = format!("{label} {slot:>4}");
1157    if let Some(info) = chunk.local_slots.get(slot as usize) {
1158        out.push_str(&format!(" ({})", info.name));
1159    }
1160    out
1161}
1162
1163pub(crate) fn disasm_method_call(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1164    let idx = chunk.read_u16(*ip);
1165    *ip += 2;
1166    let argc = chunk.code[*ip];
1167    *ip += 1;
1168    format!(
1169        "{label} {idx:>4} ({}) argc={argc}",
1170        chunk.constants[idx as usize]
1171    )
1172}
1173
1174pub(crate) fn disasm_match_enum(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1175    let enum_idx = chunk.read_u16(*ip);
1176    *ip += 2;
1177    let var_idx = chunk.read_u16(*ip);
1178    *ip += 2;
1179    format!(
1180        "{label} {enum_idx:>4} ({}) {var_idx:>4} ({})",
1181        chunk.constants[enum_idx as usize], chunk.constants[var_idx as usize],
1182    )
1183}
1184
1185pub(crate) fn disasm_build_enum(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1186    let enum_idx = chunk.read_u16(*ip);
1187    *ip += 2;
1188    let var_idx = chunk.read_u16(*ip);
1189    *ip += 2;
1190    let field_count = chunk.read_u16(*ip);
1191    *ip += 2;
1192    format!(
1193        "{label} {enum_idx:>4} ({}) {var_idx:>4} ({}) fields={field_count}",
1194        chunk.constants[enum_idx as usize], chunk.constants[var_idx as usize],
1195    )
1196}
1197
1198pub(crate) fn disasm_selective_import(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1199    let path_idx = chunk.read_u16(*ip);
1200    *ip += 2;
1201    let names_idx = chunk.read_u16(*ip);
1202    *ip += 2;
1203    format!(
1204        "{label} {path_idx:>4} ({}) names: {names_idx:>4} ({})",
1205        chunk.constants[path_idx as usize], chunk.constants[names_idx as usize],
1206    )
1207}
1208
1209pub(crate) fn disasm_check_type(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1210    let var_idx = chunk.read_u16(*ip);
1211    *ip += 2;
1212    let type_idx = chunk.read_u16(*ip);
1213    *ip += 2;
1214    format!(
1215        "{label} {var_idx:>4} ({}) -> {type_idx:>4} ({})",
1216        chunk.constants[var_idx as usize], chunk.constants[type_idx as usize],
1217    )
1218}
1219
1220pub(crate) fn disasm_call_builtin(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1221    let id = chunk.read_u64(*ip);
1222    *ip += 8;
1223    let idx = chunk.read_u16(*ip);
1224    *ip += 2;
1225    let argc = chunk.code[*ip];
1226    *ip += 1;
1227    format!(
1228        "{label} {id:#018x} {idx:>4} ({}) argc={argc}",
1229        chunk.constants[idx as usize],
1230    )
1231}
1232
1233pub(crate) fn disasm_call_builtin_spread(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1234    let id = chunk.read_u64(*ip);
1235    *ip += 8;
1236    let idx = chunk.read_u16(*ip);
1237    *ip += 2;
1238    format!(
1239        "{label} {id:#018x} {idx:>4} ({})",
1240        chunk.constants[idx as usize],
1241    )
1242}
1243
1244pub(crate) fn disasm_method_call_spread(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1245    // emit_u16(Op::MethodCallSpread, name_idx, ...) writes opcode + 2
1246    // bytes of u16 name_idx, so the operand is read at *ip with the
1247    // usual `read_u16`. The previous hand-written disasm read at
1248    // `ip + 1`, which displayed the wrong constant index — silently
1249    // corrupting any disassembly that hit a `MethodCallSpread` opcode.
1250    let idx = chunk.read_u16(*ip);
1251    *ip += 2;
1252    format!("{label} {idx:>4} ({})", chunk.constants[idx as usize])
1253}
1254
1255impl Default for Chunk {
1256    fn default() -> Self {
1257        Self::new()
1258    }
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263    use std::sync::Arc;
1264
1265    use super::{
1266        Chunk, DirectCallState, DirectCallTarget, InlineCacheEntry, MethodCacheTarget, Op,
1267        PropertyCacheTarget,
1268    };
1269    use crate::BuiltinId;
1270
1271    #[test]
1272    fn op_from_byte_matches_repr_order() {
1273        for (byte, op) in Op::ALL.iter().copied().enumerate() {
1274            assert_eq!(byte as u8, op as u8);
1275            assert_eq!(Op::from_byte(byte as u8), Some(op));
1276        }
1277        assert_eq!(Op::from_byte(Op::ALL.len() as u8), None);
1278        assert_eq!(Op::COUNT, Op::ALL.len());
1279    }
1280
1281    #[test]
1282    fn disassemble_covers_every_opcode_variant() {
1283        // The macro-generated `disassemble_op` match is exhaustive on
1284        // `Op`, so this is a compile-time guarantee. The runtime check
1285        // pins that no helper falls through to `UNKNOWN(...)` for a
1286        // valid opcode byte — catching any future macro refactor that
1287        // silently drops a helper arm. Each opcode is exercised in
1288        // isolation against a hand-built chunk so the test logic does
1289        // not depend on operand sizes (and so a single short opcode
1290        // does not bleed into reading trailing padding as a follow-on
1291        // opcode in the chunk-level loop).
1292        for op in Op::ALL.iter().copied() {
1293            let mut chunk = Chunk::new();
1294            chunk.add_constant(super::Constant::String("__probe__".to_string()));
1295            // Pad to the worst-case operand width (CallBuiltin: u64 +
1296            // u16 + u8 = 11 bytes) so any helper has well-formed bytes
1297            // to consume regardless of its layout.
1298            for _ in 0..16 {
1299                chunk.code.push(0);
1300            }
1301            let mut ip: usize = 0;
1302            let mut out = String::new();
1303            chunk.disassemble_op(op, &mut ip, &mut out);
1304            assert!(
1305                !out.contains("UNKNOWN"),
1306                "disasm emitted UNKNOWN for {op:?}: {out}",
1307            );
1308            assert!(!out.is_empty(), "disasm produced no output for {op:?}");
1309        }
1310    }
1311
1312    // --- references_outer_names tracking ---
1313    //
1314    // Drives the compile-time guard used in `Vm::closure_call_env`
1315    // and `Vm::closure_call_env_for_current_frame` to skip the
1316    // per-invocation caller-scope late-bind walks. Coverage parity
1317    // matters because false negatives would regress recursive /
1318    // mutually-recursive fns.
1319
1320    #[test]
1321    fn empty_chunk_does_not_reference_outer_names() {
1322        let chunk = Chunk::new();
1323        assert!(!chunk.references_outer_names);
1324    }
1325
1326    #[test]
1327    fn arithmetic_only_chunk_does_not_reference_outer_names() {
1328        // The hot `.map(x -> x * 2)` / `.filter(x -> x % 2 == 0)`
1329        // shape: pure stack/arithmetic ops and slot locals, no env
1330        // reads. Must NOT flag — that's the whole point of the
1331        // optimization.
1332        let mut chunk = Chunk::new();
1333        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1334        chunk.emit_u16(Op::Constant, 0, 1);
1335        chunk.emit(Op::MulInt, 1);
1336        chunk.emit(Op::Pop, 1);
1337        chunk.emit(Op::Return, 1);
1338        assert!(!chunk.references_outer_names);
1339    }
1340
1341    #[test]
1342    fn slot_only_chunk_does_not_reference_outer_names() {
1343        // Compiler-resolved locals never need env-based late-bind.
1344        let mut chunk = Chunk::new();
1345        chunk.emit_u16(Op::DefLocalSlot, 0, 1);
1346        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1347        chunk.emit_u16(Op::SetLocalSlot, 0, 1);
1348        assert!(!chunk.references_outer_names);
1349    }
1350
1351    #[test]
1352    fn get_var_flags_outer_name_reference() {
1353        let mut chunk = Chunk::new();
1354        chunk.emit_u16(Op::GetVar, 0, 1);
1355        assert!(chunk.references_outer_names);
1356    }
1357
1358    #[test]
1359    fn set_var_flags_outer_name_reference() {
1360        let mut chunk = Chunk::new();
1361        chunk.emit_u16(Op::SetVar, 0, 1);
1362        assert!(chunk.references_outer_names);
1363    }
1364
1365    #[test]
1366    fn check_type_flags_outer_name_reference() {
1367        let mut chunk = Chunk::new();
1368        chunk.emit_u16(Op::CheckType, 0, 1);
1369        assert!(chunk.references_outer_names);
1370    }
1371
1372    #[test]
1373    fn call_builtin_flags_outer_name_reference() {
1374        let mut chunk = Chunk::new();
1375        chunk.emit_call_builtin(BuiltinId::from_name("any_name"), 0, 1, 1);
1376        assert!(chunk.references_outer_names);
1377    }
1378
1379    #[test]
1380    fn call_builtin_spread_flags_outer_name_reference() {
1381        let mut chunk = Chunk::new();
1382        chunk.emit_call_builtin_spread(BuiltinId::from_name("any_name"), 0, 1);
1383        assert!(chunk.references_outer_names);
1384    }
1385
1386    #[test]
1387    fn tail_call_flags_outer_name_reference() {
1388        // `return fn_name(...)` compiles to Constant + TailCall —
1389        // TailCall does a runtime name lookup, so it has to flag.
1390        let mut chunk = Chunk::new();
1391        chunk.emit_u8(Op::TailCall, 1, 1);
1392        assert!(chunk.references_outer_names);
1393    }
1394
1395    #[test]
1396    fn call_flags_outer_name_reference() {
1397        // Op::Call can receive a String callee from the stack (the
1398        // by-name dispatch shape), so it has to flag too.
1399        let mut chunk = Chunk::new();
1400        chunk.emit_u8(Op::Call, 1, 1);
1401        assert!(chunk.references_outer_names);
1402    }
1403
1404    #[test]
1405    fn pipe_flags_outer_name_reference() {
1406        // `x |> name` resolves `name` through env when the value on
1407        // the stack is a String / BuiltinRef.
1408        let mut chunk = Chunk::new();
1409        chunk.emit(Op::Pipe, 1);
1410        assert!(chunk.references_outer_names);
1411    }
1412
1413    #[test]
1414    fn method_call_does_not_flag_outer_name_reference() {
1415        // Method receivers come off the operand stack, not the env;
1416        // emitting MethodCall alone must not force the walk.
1417        let mut chunk = Chunk::new();
1418        chunk.emit_method_call(0, 1, 1);
1419        chunk.emit_method_call_opt(0, 1, 1);
1420        assert!(!chunk.references_outer_names);
1421    }
1422
1423    #[test]
1424    fn jump_and_control_flow_do_not_flag_outer_name_reference() {
1425        // Jumps, returns, pops — control flow stays inside the
1426        // frame and never touches env lookups.
1427        let mut chunk = Chunk::new();
1428        chunk.emit_u16(Op::Constant, 0, 1);
1429        chunk.emit(Op::JumpIfFalse, 1);
1430        chunk.emit(Op::Jump, 1);
1431        chunk.emit(Op::Return, 1);
1432        chunk.emit(Op::Pop, 1);
1433        assert!(!chunk.references_outer_names);
1434    }
1435
1436    #[test]
1437    fn references_outer_names_is_monotonic() {
1438        // Once flagged, subsequent non-flagging emits must not
1439        // clear the bit — flags are sticky.
1440        let mut chunk = Chunk::new();
1441        chunk.emit_u16(Op::GetVar, 0, 1);
1442        assert!(chunk.references_outer_names);
1443        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1444        chunk.emit(Op::MulInt, 1);
1445        assert!(chunk.references_outer_names);
1446    }
1447
1448    #[test]
1449    fn freeze_thaw_round_trips_references_outer_names() {
1450        // Bytecode-cache hits must observe the same flag as a
1451        // fresh compile — otherwise the first call after a cache
1452        // hit would either over- or under-skip the walk.
1453        let mut chunk = Chunk::new();
1454        chunk.emit_u16(Op::GetVar, 0, 1);
1455        assert!(chunk.references_outer_names);
1456        let frozen = chunk.freeze_for_cache();
1457        let thawed = Chunk::from_cached(&frozen);
1458        assert!(thawed.references_outer_names);
1459
1460        let plain = Chunk::new();
1461        assert!(!plain.references_outer_names);
1462        let frozen_plain = plain.freeze_for_cache();
1463        let thawed_plain = Chunk::from_cached(&frozen_plain);
1464        assert!(!thawed_plain.references_outer_names);
1465    }
1466
1467    // --- inline_cache_slot flat-index parity ---
1468    //
1469    // Slot lookups fire on every dispatch of an adaptive binary op
1470    // (Add/Sub/Mul/Div/Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq),
1471    // every `Op::Call`, every `Op::MethodCall(Opt)`, and every
1472    // `Op::GetProperty(Opt)`. The flat `Vec<u32>` index has to stay
1473    // perfectly in sync with the serialization-stable BTreeMap or
1474    // a cached call site would either skip its inline cache (slow
1475    // path with no learning) or read a stale slot (silently
1476    // mis-specialized arithmetic). These tests pin the contract.
1477
1478    #[test]
1479    fn inline_cache_slot_returns_none_for_non_cacheable_offsets() {
1480        // GetLocalSlot is a sync-fast-path opcode with no inline
1481        // cache; the index must report no slot.
1482        let mut chunk = Chunk::new();
1483        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1484        chunk.emit(Op::Pop, 1);
1485        chunk.emit(Op::Return, 1);
1486        assert!(chunk.inline_cache_slot(0).is_none());
1487        assert!(chunk.inline_cache_slot(3).is_none());
1488        assert!(chunk.inline_cache_slot(4).is_none());
1489    }
1490
1491    #[test]
1492    fn inline_cache_slot_registered_for_adaptive_binary_op() {
1493        // Pure-arithmetic ops use the adaptive-binary IC for shape
1494        // specialization. The slot has to be 0 because the chunk is
1495        // otherwise empty.
1496        let mut chunk = Chunk::new();
1497        chunk.emit(Op::Add, 1);
1498        assert_eq!(chunk.inline_cache_slot(0), Some(0));
1499    }
1500
1501    #[test]
1502    fn inline_cache_slot_distinct_for_sequential_adaptive_binary_ops() {
1503        // Three back-to-back Adds must get three distinct slots so
1504        // each instruction's shape feedback evolves independently
1505        // (otherwise the same call site would clobber a neighbor's
1506        // learning every dispatch).
1507        let mut chunk = Chunk::new();
1508        chunk.emit(Op::Add, 1);
1509        chunk.emit(Op::Sub, 1);
1510        chunk.emit(Op::Mul, 1);
1511        let s0 = chunk.inline_cache_slot(0).expect("Add slot");
1512        let s1 = chunk.inline_cache_slot(1).expect("Sub slot");
1513        let s2 = chunk.inline_cache_slot(2).expect("Mul slot");
1514        assert_ne!(s0, s1);
1515        assert_ne!(s1, s2);
1516        assert_ne!(s0, s2);
1517    }
1518
1519    #[test]
1520    fn inline_cache_slot_returns_none_for_out_of_bounds_offset() {
1521        // The dispatcher derives `op_offset` from `ip - 1`; an
1522        // out-of-bounds query must return None rather than panic.
1523        let mut chunk = Chunk::new();
1524        chunk.emit(Op::Add, 1);
1525        assert!(chunk.inline_cache_slot(usize::MAX).is_none());
1526        assert!(chunk.inline_cache_slot(chunk.code.len()).is_none());
1527        assert!(chunk.inline_cache_slot(chunk.code.len() + 16).is_none());
1528    }
1529
1530    #[test]
1531    fn inline_cache_slot_for_get_property_and_method_call() {
1532        // GetProperty(Opt) and MethodCall(Opt) both register an IC
1533        // slot at emit time — adaptive method-call dispatch and
1534        // monomorphic property-cache learning depend on it.
1535        let mut chunk = Chunk::new();
1536        chunk.emit_u16(Op::GetProperty, 0, 1); // offset 0..3
1537        chunk.emit_method_call(0, 1, 1); // offset 3..7
1538        chunk.emit_method_call_opt(0, 1, 1); // offset 7..11
1539        chunk.emit_u16(Op::GetPropertyOpt, 0, 1); // offset 11..14
1540        assert!(chunk.inline_cache_slot(0).is_some(), "GetProperty");
1541        assert!(chunk.inline_cache_slot(3).is_some(), "MethodCall");
1542        assert!(chunk.inline_cache_slot(7).is_some(), "MethodCallOpt");
1543        assert!(chunk.inline_cache_slot(11).is_some(), "GetPropertyOpt");
1544    }
1545
1546    #[test]
1547    fn inline_cache_slot_for_call_and_call_builtin() {
1548        // Both `Op::Call` (closure / by-name callee) and
1549        // `emit_call_builtin` register IC slots. The latter is the
1550        // adaptive-call fast path used for every direct user-fn
1551        // invocation.
1552        let mut chunk = Chunk::new();
1553        chunk.emit_u8(Op::Call, 1, 1); // offset 0..2
1554        let call_builtin_offset = chunk.code.len();
1555        chunk.emit_call_builtin(BuiltinId::from_name("any"), 0, 1, 1);
1556        assert!(chunk.inline_cache_slot(0).is_some(), "Op::Call IC slot");
1557        assert!(
1558            chunk.inline_cache_slot(call_builtin_offset).is_some(),
1559            "Op::CallBuiltin IC slot"
1560        );
1561    }
1562
1563    #[test]
1564    fn inline_cache_slot_register_is_idempotent_for_same_offset() {
1565        // The compile path uses `BTreeMap::contains_key` to dedup
1566        // re-registration at the same offset (eg. when a helper
1567        // re-emits into a still-live position). The flat index has
1568        // to honor the same semantics — never silently overwriting
1569        // an existing slot with a fresh one.
1570        let mut chunk = Chunk::new();
1571        chunk.emit(Op::Add, 1);
1572        let slot_before = chunk.inline_cache_slot(0).expect("first registration");
1573        // Manually re-register the same offset to confirm dedup.
1574        chunk.register_inline_cache(0);
1575        let slot_after = chunk.inline_cache_slot(0).expect("re-registration");
1576        assert_eq!(slot_before, slot_after);
1577    }
1578
1579    #[test]
1580    fn inline_cache_index_round_trips_through_cached_chunk() {
1581        // The cache freeze drops the flat index (it's derived from
1582        // the BTreeMap that *is* serialized). On thaw, the flat
1583        // index must be rebuilt so the first hot dispatch of a
1584        // cached chunk doesn't fall off the IC-slot cliff (which
1585        // would silently disable shape specialization until the
1586        // chunk is recompiled from source).
1587        let mut chunk = Chunk::new();
1588        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1589        chunk.emit_u16(Op::Constant, 0, 1);
1590        chunk.emit(Op::Add, 1);
1591        chunk.emit(Op::Sub, 1);
1592        chunk.emit_method_call(0, 1, 1);
1593        chunk.emit_u8(Op::Call, 1, 1);
1594        let live_slots: Vec<(usize, Option<usize>)> = (0..chunk.code.len())
1595            .map(|o| (o, chunk.inline_cache_slot(o)))
1596            .collect();
1597        let frozen = chunk.freeze_for_cache();
1598        let thawed = Chunk::from_cached(&frozen);
1599        let thawed_slots: Vec<(usize, Option<usize>)> = (0..thawed.code.len())
1600            .map(|o| (o, thawed.inline_cache_slot(o)))
1601            .collect();
1602        assert_eq!(live_slots, thawed_slots);
1603    }
1604
1605    #[test]
1606    fn inline_cache_index_agrees_with_btreemap_view() {
1607        // Authoritative parity check: for every code offset, the
1608        // flat-index `inline_cache_slot` must return exactly what
1609        // the underlying BTreeMap would (mod the `Option` boxing).
1610        // Catches any future emit path that grows `inline_cache_slots`
1611        // without going through `register_inline_cache`.
1612        let mut chunk = Chunk::new();
1613        chunk.emit(Op::Add, 1);
1614        chunk.emit_u16(Op::GetVar, 0, 1);
1615        chunk.emit(Op::LessInt, 1);
1616        chunk.emit_u8(Op::Call, 2, 1);
1617        chunk.emit(Op::Equal, 1);
1618        chunk.emit_u16(Op::GetProperty, 0, 1);
1619        chunk.emit_method_call_opt(0, 0, 1);
1620        for offset in 0..chunk.code.len() {
1621            let from_map = chunk.inline_cache_slots.get(&offset).copied();
1622            let from_index = chunk.inline_cache_slot(offset);
1623            assert_eq!(from_index, from_map, "parity broken at offset {offset}");
1624        }
1625    }
1626
1627    // --- peek_adaptive_binary_cache contract ---
1628    //
1629    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1630    // hottest opcode class (Add / Sub / Mul / Div / Mod / Eq / Neq /
1631    // Less / Greater / LessEq / GreaterEq). It must return None for
1632    // unrelated IC variants — silently mis-extracting a `Property` /
1633    // `DirectCall` / `Method` slot as `AdaptiveBinary` would feed
1634    // garbage into `try_specialized_binary` and either spec-mis-fire or
1635    // crash. These tests pin the variant gate.
1636
1637    #[test]
1638    fn peek_adaptive_binary_returns_none_for_empty_slot() {
1639        let mut chunk = Chunk::new();
1640        chunk.emit(Op::Add, 1);
1641        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
1642        // Default state of a freshly-emitted slot is Empty.
1643        assert!(chunk.peek_adaptive_binary_cache(slot).is_none());
1644    }
1645
1646    #[test]
1647    fn peek_adaptive_binary_returns_op_and_state_after_warmup() {
1648        use super::{AdaptiveBinaryOp, AdaptiveBinaryState, BinaryShape, InlineCacheEntry};
1649        let mut chunk = Chunk::new();
1650        chunk.emit(Op::Add, 1);
1651        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
1652        chunk.set_inline_cache_entry(
1653            slot,
1654            InlineCacheEntry::AdaptiveBinary {
1655                op: AdaptiveBinaryOp::Add,
1656                state: AdaptiveBinaryState::Warmup {
1657                    shape: BinaryShape::Int,
1658                    hits: 2,
1659                },
1660            },
1661        );
1662        let (op, state) = chunk
1663            .peek_adaptive_binary_cache(slot)
1664            .expect("warmed slot peek");
1665        assert_eq!(op, AdaptiveBinaryOp::Add);
1666        assert!(matches!(
1667            state,
1668            AdaptiveBinaryState::Warmup {
1669                shape: BinaryShape::Int,
1670                hits: 2
1671            }
1672        ));
1673    }
1674
1675    #[test]
1676    fn peek_adaptive_binary_returns_none_for_non_binary_variants() {
1677        // The cache slot may legitimately hold a `Property`, `Method`,
1678        // or `DirectCall` entry (eg. a Property slot at the offset
1679        // sequence happens to alias an Add slot during a code rewrite —
1680        // currently this cannot happen, but the peek must defensively
1681        // refuse non-AdaptiveBinary variants regardless).
1682        use super::{InlineCacheEntry, PropertyCacheTarget};
1683        let mut chunk = Chunk::new();
1684        chunk.emit(Op::Add, 1);
1685        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
1686        chunk.set_inline_cache_entry(
1687            slot,
1688            InlineCacheEntry::Property {
1689                name_idx: 0,
1690                target: PropertyCacheTarget::ListCount,
1691            },
1692        );
1693        assert!(chunk.peek_adaptive_binary_cache(slot).is_none());
1694    }
1695
1696    #[test]
1697    fn peek_adaptive_binary_returns_none_for_out_of_bounds_slot() {
1698        // Defensive: `execute_adaptive_binary` filters its `slot`
1699        // through `inline_cache_slot` first, but
1700        // `peek_adaptive_binary_cache` should still return None for an
1701        // unmapped slot rather than panicking.
1702        let chunk = Chunk::new();
1703        assert!(chunk.peek_adaptive_binary_cache(0).is_none());
1704        assert!(chunk.peek_adaptive_binary_cache(usize::MAX).is_none());
1705    }
1706
1707    #[test]
1708    fn peek_adaptive_binary_state_is_copy() {
1709        // Compile-time assertion: `AdaptiveBinaryState: Copy` is the
1710        // whole point of this optimization — if a future variant adds
1711        // a non-Copy field, the static check below will fail at compile
1712        // time before the dispatcher silently regresses to the heavy
1713        // `InlineCacheEntry::clone` path.
1714        fn assert_copy<T: Copy>() {}
1715        assert_copy::<super::AdaptiveBinaryState>();
1716        assert_copy::<super::AdaptiveBinaryOp>();
1717        assert_copy::<super::BinaryShape>();
1718    }
1719
1720    // --- peek_method_cache contract ---
1721    //
1722    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1723    // method-call dispatch sites (`Op::MethodCall`, `Op::MethodCallOpt`,
1724    // `Op::MethodCallSpread`). It must return None for unrelated IC variants
1725    // — silently mis-extracting a `Property` / `DirectCall` / `AdaptiveBinary`
1726    // slot as `Method` would feed garbage into `try_cached_method` and either
1727    // spec-mis-fire (wrong target/argc) or skip the cache entirely on a real
1728    // hit. These tests pin the variant gate.
1729
1730    #[test]
1731    fn peek_method_cache_returns_none_for_empty_slot() {
1732        let mut chunk = Chunk::new();
1733        chunk.emit_method_call(0, 0, 1);
1734        let slot = chunk
1735            .inline_cache_slot(0)
1736            .expect("MethodCall registers a slot");
1737        assert!(chunk.peek_method_cache(slot).is_none());
1738    }
1739
1740    #[test]
1741    fn peek_method_cache_returns_triple_after_warmup() {
1742        let mut chunk = Chunk::new();
1743        chunk.emit_method_call(7, 2, 1);
1744        let slot = chunk
1745            .inline_cache_slot(0)
1746            .expect("MethodCall registers a slot");
1747        chunk.set_inline_cache_entry(
1748            slot,
1749            InlineCacheEntry::Method {
1750                name_idx: 7,
1751                argc: 2,
1752                target: MethodCacheTarget::ListContains,
1753            },
1754        );
1755        let (name_idx, argc, target) = chunk.peek_method_cache(slot).expect("warmed slot peek");
1756        assert_eq!(name_idx, 7);
1757        assert_eq!(argc, 2);
1758        assert_eq!(target, MethodCacheTarget::ListContains);
1759    }
1760
1761    #[test]
1762    fn peek_method_cache_returns_none_for_non_method_variants() {
1763        // The cache slot may legitimately hold an `AdaptiveBinary`,
1764        // `Property`, or `DirectCall` entry. The peek must defensively
1765        // refuse non-Method variants regardless.
1766        let mut chunk = Chunk::new();
1767        chunk.emit_method_call(0, 0, 1);
1768        let slot = chunk
1769            .inline_cache_slot(0)
1770            .expect("MethodCall registers a slot");
1771
1772        chunk.set_inline_cache_entry(
1773            slot,
1774            InlineCacheEntry::Property {
1775                name_idx: 0,
1776                target: PropertyCacheTarget::ListCount,
1777            },
1778        );
1779        assert!(chunk.peek_method_cache(slot).is_none());
1780    }
1781
1782    #[test]
1783    fn peek_method_cache_returns_none_for_out_of_bounds_slot() {
1784        let chunk = Chunk::new();
1785        assert!(chunk.peek_method_cache(0).is_none());
1786        assert!(chunk.peek_method_cache(usize::MAX).is_none());
1787    }
1788
1789    #[test]
1790    fn peek_method_cache_target_is_copy() {
1791        // Compile-time assertion: `MethodCacheTarget: Copy` is the whole
1792        // point of this peek path — if a future variant adds a non-Copy
1793        // field (eg. an `Arc<str>` for a dynamic method name), the static
1794        // check below will fail at compile time before the dispatcher
1795        // silently regresses to the heavy `InlineCacheEntry::clone` path.
1796        fn assert_copy<T: Copy>() {}
1797        assert_copy::<super::MethodCacheTarget>();
1798    }
1799
1800    // --- peek_property_cache contract ---
1801    //
1802    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1803    // property-read path (`Op::GetProperty` / `Op::GetPropertyOpt`). It
1804    // must return None for unrelated IC variants — silently mis-extracting
1805    // a `Method` / `DirectCall` / `AdaptiveBinary` slot as `Property` would
1806    // feed garbage into `try_cached_property` (wrong target match, possibly
1807    // a panic on the field-name lookup). These tests pin the variant gate.
1808
1809    #[test]
1810    fn peek_property_cache_returns_none_for_empty_slot() {
1811        let mut chunk = Chunk::new();
1812        chunk.emit_u16(Op::GetProperty, 0, 1);
1813        let slot = chunk
1814            .inline_cache_slot(0)
1815            .expect("GetProperty registers a slot");
1816        assert!(chunk.peek_property_cache(slot).is_none());
1817    }
1818
1819    #[test]
1820    fn peek_property_cache_returns_pair_after_warmup_for_dict_field() {
1821        let mut chunk = Chunk::new();
1822        chunk.emit_u16(Op::GetProperty, 0, 1);
1823        let slot = chunk
1824            .inline_cache_slot(0)
1825            .expect("GetProperty registers a slot");
1826        chunk.set_inline_cache_entry(
1827            slot,
1828            InlineCacheEntry::Property {
1829                name_idx: 11,
1830                target: PropertyCacheTarget::DictField(Arc::from("count")),
1831            },
1832        );
1833        let (name_idx, target) = chunk
1834            .peek_property_cache(slot)
1835            .expect("warmed property slot peek");
1836        assert_eq!(name_idx, 11);
1837        match target {
1838            PropertyCacheTarget::DictField(field) => assert_eq!(field.as_ref(), "count"),
1839            other => panic!("expected DictField, got {other:?}"),
1840        }
1841    }
1842
1843    #[test]
1844    fn peek_property_cache_returns_pair_for_unit_target() {
1845        // Unit targets (eg. ListCount, ListEmpty, PairFirst) carry no Arc,
1846        // so the cloned PropertyCacheTarget is a pure scalar move at the
1847        // peek boundary. The hottest case in practice.
1848        let mut chunk = Chunk::new();
1849        chunk.emit_u16(Op::GetProperty, 0, 1);
1850        let slot = chunk
1851            .inline_cache_slot(0)
1852            .expect("GetProperty registers a slot");
1853        chunk.set_inline_cache_entry(
1854            slot,
1855            InlineCacheEntry::Property {
1856                name_idx: 3,
1857                target: PropertyCacheTarget::ListCount,
1858            },
1859        );
1860        let (name_idx, target) = chunk
1861            .peek_property_cache(slot)
1862            .expect("warmed property slot peek");
1863        assert_eq!(name_idx, 3);
1864        assert_eq!(target, PropertyCacheTarget::ListCount);
1865    }
1866
1867    #[test]
1868    fn peek_property_cache_returns_none_for_non_property_variants() {
1869        let mut chunk = Chunk::new();
1870        chunk.emit_u16(Op::GetProperty, 0, 1);
1871        let slot = chunk
1872            .inline_cache_slot(0)
1873            .expect("GetProperty registers a slot");
1874        chunk.set_inline_cache_entry(
1875            slot,
1876            InlineCacheEntry::Method {
1877                name_idx: 0,
1878                argc: 0,
1879                target: MethodCacheTarget::ListCount,
1880            },
1881        );
1882        assert!(chunk.peek_property_cache(slot).is_none());
1883    }
1884
1885    #[test]
1886    fn peek_property_cache_returns_none_for_out_of_bounds_slot() {
1887        let chunk = Chunk::new();
1888        assert!(chunk.peek_property_cache(0).is_none());
1889        assert!(chunk.peek_property_cache(usize::MAX).is_none());
1890    }
1891
1892    // --- peek_direct_call_state contract ---
1893    //
1894    // Used on both the hot Specialized-hit check path (`try_cached_direct_call`
1895    // / `try_cached_named_direct_call`) and the state-machine write-back
1896    // (`next_direct_call_entry`). Returning None for the non-DirectCall slot
1897    // shapes is critical: a mis-extracted Method/Property/AdaptiveBinary slot
1898    // would have the dispatcher attempt a closure call with the wrong argc
1899    // or Arc::ptr_eq against an unrelated closure.
1900
1901    #[test]
1902    fn peek_direct_call_state_returns_none_for_empty_slot() {
1903        let mut chunk = Chunk::new();
1904        chunk.emit_u8(Op::Call, 0, 1);
1905        let slot = chunk
1906            .inline_cache_slot(0)
1907            .expect("Op::Call registers a slot");
1908        assert!(chunk.peek_direct_call_state(slot).is_none());
1909    }
1910
1911    #[test]
1912    fn peek_direct_call_state_returns_warmup_state() {
1913        let mut chunk = Chunk::new();
1914        chunk.emit_u8(Op::Call, 0, 1);
1915        let slot = chunk
1916            .inline_cache_slot(0)
1917            .expect("Op::Call registers a slot");
1918        let target = synthetic_direct_call_target();
1919        chunk.set_inline_cache_entry(
1920            slot,
1921            InlineCacheEntry::DirectCall {
1922                state: DirectCallState::Warmup {
1923                    argc: 2,
1924                    target: target.clone(),
1925                    hits: 1,
1926                },
1927            },
1928        );
1929        let state = chunk
1930            .peek_direct_call_state(slot)
1931            .expect("warmed direct-call slot peek");
1932        match state {
1933            DirectCallState::Warmup {
1934                argc,
1935                target: peeked_target,
1936                hits,
1937            } => {
1938                assert_eq!(argc, 2);
1939                assert_eq!(hits, 1);
1940                assert_eq!(peeked_target, target);
1941            }
1942            other => panic!("expected Warmup, got {other:?}"),
1943        }
1944    }
1945
1946    #[test]
1947    fn peek_direct_call_state_returns_specialized_state() {
1948        let mut chunk = Chunk::new();
1949        chunk.emit_u8(Op::Call, 0, 1);
1950        let slot = chunk
1951            .inline_cache_slot(0)
1952            .expect("Op::Call registers a slot");
1953        let target = synthetic_direct_call_target();
1954        chunk.set_inline_cache_entry(
1955            slot,
1956            InlineCacheEntry::DirectCall {
1957                state: DirectCallState::Specialized {
1958                    argc: 3,
1959                    target: target.clone(),
1960                    hits: 100,
1961                    misses: 0,
1962                },
1963            },
1964        );
1965        let state = chunk
1966            .peek_direct_call_state(slot)
1967            .expect("warmed direct-call slot peek");
1968        match state {
1969            DirectCallState::Specialized {
1970                argc,
1971                target: peeked_target,
1972                hits,
1973                misses,
1974            } => {
1975                assert_eq!(argc, 3);
1976                assert_eq!(hits, 100);
1977                assert_eq!(misses, 0);
1978                assert_eq!(peeked_target, target);
1979            }
1980            other => panic!("expected Specialized, got {other:?}"),
1981        }
1982    }
1983
1984    #[test]
1985    fn peek_direct_call_state_returns_none_for_non_direct_call_variants() {
1986        let mut chunk = Chunk::new();
1987        chunk.emit_u8(Op::Call, 0, 1);
1988        let slot = chunk
1989            .inline_cache_slot(0)
1990            .expect("Op::Call registers a slot");
1991
1992        chunk.set_inline_cache_entry(
1993            slot,
1994            InlineCacheEntry::Property {
1995                name_idx: 0,
1996                target: PropertyCacheTarget::ListCount,
1997            },
1998        );
1999        assert!(chunk.peek_direct_call_state(slot).is_none());
2000    }
2001
2002    #[test]
2003    fn peek_direct_call_state_returns_none_for_out_of_bounds_slot() {
2004        let chunk = Chunk::new();
2005        assert!(chunk.peek_direct_call_state(0).is_none());
2006        assert!(chunk.peek_direct_call_state(usize::MAX).is_none());
2007    }
2008
2009    /// Build a synthetic `DirectCallTarget::Closure` for direct-call peek
2010    /// tests. The closure has an empty body — the IC peek only inspects
2011    /// the wrapping `Arc`, not the closure internals.
2012    fn synthetic_direct_call_target() -> DirectCallTarget {
2013        use crate::value::VmClosure;
2014        use crate::{CompiledFunction, VmEnv};
2015        let func = CompiledFunction {
2016            name: "synthetic".to_string(),
2017            type_params: Vec::new(),
2018            nominal_type_names: Vec::new(),
2019            params: Vec::new(),
2020            default_start: None,
2021            chunk: Arc::new(Chunk::new()),
2022            is_generator: false,
2023            is_stream: false,
2024            has_rest_param: false,
2025            has_runtime_type_checks: false,
2026        };
2027        DirectCallTarget::Closure(Arc::new(VmClosure {
2028            func: Arc::new(func),
2029            env: VmEnv::new(),
2030            source_dir: None,
2031            module_functions: None,
2032            module_state: None,
2033        }))
2034    }
2035}