Skip to main content

harn_vm/
chunk.rs

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