Skip to main content

harn_vm/
chunk.rs

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