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 | Import | SelectiveImport | DeadlineSetup | DeadlineEnd | BuildEnum
622        | MatchEnum | Yield | CallSpread | CallBuiltinSpread | MethodCallSpread => return None,
623    })
624}
625
626impl Chunk {
627    pub fn new() -> Self {
628        Self {
629            cache_id: next_chunk_cache_id(),
630            code: Vec::new(),
631            constants: Vec::new(),
632            lines: Vec::new(),
633            columns: Vec::new(),
634            source_file: None,
635            current_col: 0,
636            functions: Vec::new(),
637            inline_cache_slots: BTreeMap::new(),
638            inline_cache_index: Vec::new(),
639            inline_caches: Arc::new(Mutex::new(Vec::new())),
640            constant_strings: Arc::new(Mutex::new(Vec::new())),
641            local_slots: Vec::new(),
642            references_outer_names: false,
643            #[cfg(debug_assertions)]
644            balance_depth: 0,
645            #[cfg(debug_assertions)]
646            balance_nonlinear: 0,
647        }
648    }
649
650    /// Set the current column for subsequent emit calls.
651    pub fn set_column(&mut self, col: u32) {
652        self.current_col = col;
653    }
654
655    /// Add a constant and return its index.
656    pub fn add_constant(&mut self, constant: Constant) -> u16 {
657        for (i, c) in self.constants.iter().enumerate() {
658            if c == &constant {
659                return i as u16;
660            }
661        }
662        let idx = self.constants.len();
663        self.constants.push(constant);
664        idx as u16
665    }
666
667    /// Emit a single-byte instruction.
668    pub fn emit(&mut self, op: Op, line: u32) {
669        #[cfg(debug_assertions)]
670        self.note_balance(op, 0);
671        let col = self.current_col;
672        let op_offset = self.code.len();
673        self.code.push(op as u8);
674        self.lines.push(line);
675        self.columns.push(col);
676        if is_adaptive_binary_op(op) {
677            self.register_inline_cache(op_offset);
678        }
679        if op_reads_outer_name(op) {
680            self.references_outer_names = true;
681        }
682    }
683
684    /// Emit an instruction with a u16 argument.
685    pub fn emit_u16(&mut self, op: Op, arg: u16, line: u32) {
686        #[cfg(debug_assertions)]
687        self.note_balance(op, arg);
688        let col = self.current_col;
689        let op_offset = self.code.len();
690        self.code.push(op as u8);
691        self.code.push((arg >> 8) as u8);
692        self.code.push((arg & 0xFF) as u8);
693        self.lines.push(line);
694        self.lines.push(line);
695        self.lines.push(line);
696        self.columns.push(col);
697        self.columns.push(col);
698        self.columns.push(col);
699        if matches!(
700            op,
701            Op::GetProperty | Op::GetPropertyOpt | Op::MethodCallSpread
702        ) {
703            self.register_inline_cache(op_offset);
704        }
705        if op_reads_outer_name(op) {
706            self.references_outer_names = true;
707        }
708    }
709
710    /// Emit an instruction with a u8 argument.
711    pub fn emit_u8(&mut self, op: Op, arg: u8, line: u32) {
712        #[cfg(debug_assertions)]
713        self.note_balance(op, arg as u16);
714        let col = self.current_col;
715        let op_offset = self.code.len();
716        self.code.push(op as u8);
717        self.code.push(arg);
718        self.lines.push(line);
719        self.lines.push(line);
720        self.columns.push(col);
721        self.columns.push(col);
722        if matches!(op, Op::Call) {
723            self.register_inline_cache(op_offset);
724        }
725        if op_reads_outer_name(op) {
726            self.references_outer_names = true;
727        }
728    }
729
730    /// Emit a direct builtin call.
731    pub fn emit_call_builtin(
732        &mut self,
733        id: crate::BuiltinId,
734        name_idx: u16,
735        arg_count: u8,
736        line: u32,
737    ) {
738        #[cfg(debug_assertions)]
739        self.note_balance(Op::CallBuiltin, arg_count as u16);
740        let col = self.current_col;
741        let op_offset = self.code.len();
742        self.code.push(Op::CallBuiltin as u8);
743        self.code.extend_from_slice(&id.raw().to_be_bytes());
744        self.code.push((name_idx >> 8) as u8);
745        self.code.push((name_idx & 0xFF) as u8);
746        self.code.push(arg_count);
747        for _ in 0..12 {
748            self.lines.push(line);
749            self.columns.push(col);
750        }
751        self.register_inline_cache(op_offset);
752        self.references_outer_names = true;
753    }
754
755    /// Emit a direct builtin spread call.
756    pub fn emit_call_builtin_spread(&mut self, id: crate::BuiltinId, name_idx: u16, line: u32) {
757        #[cfg(debug_assertions)]
758        self.note_balance(Op::CallBuiltinSpread, 0);
759        let col = self.current_col;
760        self.code.push(Op::CallBuiltinSpread as u8);
761        self.code.extend_from_slice(&id.raw().to_be_bytes());
762        self.code.push((name_idx >> 8) as u8);
763        self.code.push((name_idx & 0xFF) as u8);
764        for _ in 0..11 {
765            self.lines.push(line);
766            self.columns.push(col);
767        }
768        self.references_outer_names = true;
769    }
770
771    /// Emit a method call: op + u16 (method name) + u8 (arg count).
772    pub fn emit_method_call(&mut self, name_idx: u16, arg_count: u8, line: u32) {
773        self.emit_method_call_inner(Op::MethodCall, name_idx, arg_count, line);
774    }
775
776    /// Emit an optional method call (?.) — returns nil if receiver is nil.
777    pub fn emit_method_call_opt(&mut self, name_idx: u16, arg_count: u8, line: u32) {
778        self.emit_method_call_inner(Op::MethodCallOpt, name_idx, arg_count, line);
779    }
780
781    fn emit_method_call_inner(&mut self, op: Op, name_idx: u16, arg_count: u8, line: u32) {
782        #[cfg(debug_assertions)]
783        self.note_balance(op, arg_count as u16);
784        let col = self.current_col;
785        let op_offset = self.code.len();
786        self.code.push(op as u8);
787        self.code.push((name_idx >> 8) as u8);
788        self.code.push((name_idx & 0xFF) as u8);
789        self.code.push(arg_count);
790        self.lines.push(line);
791        self.lines.push(line);
792        self.lines.push(line);
793        self.lines.push(line);
794        self.columns.push(col);
795        self.columns.push(col);
796        self.columns.push(col);
797        self.columns.push(col);
798        self.register_inline_cache(op_offset);
799    }
800
801    /// Current code offset (for jump patching).
802    pub fn current_offset(&self) -> usize {
803        self.code.len()
804    }
805
806    /// Emit a jump instruction with a placeholder offset. Returns the position to patch.
807    pub fn emit_jump(&mut self, op: Op, line: u32) -> usize {
808        #[cfg(debug_assertions)]
809        self.note_balance(op, 0);
810        let col = self.current_col;
811        self.code.push(op as u8);
812        let patch_pos = self.code.len();
813        self.code.push(0xFF);
814        self.code.push(0xFF);
815        self.lines.push(line);
816        self.lines.push(line);
817        self.lines.push(line);
818        self.columns.push(col);
819        self.columns.push(col);
820        self.columns.push(col);
821        patch_pos
822    }
823
824    /// Patch a jump instruction at the given position to jump to the current offset.
825    pub fn patch_jump(&mut self, patch_pos: usize) {
826        let target = self.code.len() as u16;
827        self.code[patch_pos] = (target >> 8) as u8;
828        self.code[patch_pos + 1] = (target & 0xFF) as u8;
829    }
830
831    /// Patch a jump to a specific target position.
832    pub fn patch_jump_to(&mut self, patch_pos: usize, target: usize) {
833        let target = target as u16;
834        self.code[patch_pos] = (target >> 8) as u8;
835        self.code[patch_pos + 1] = (target & 0xFF) as u8;
836    }
837
838    /// Read a u16 argument at the given position.
839    pub fn read_u16(&self, pos: usize) -> u16 {
840        ((self.code[pos] as u16) << 8) | (self.code[pos + 1] as u16)
841    }
842
843    /// Fold one just-emitted opcode into the compile-time operand-stack
844    /// balance model (issue #2622). See [`op_stack_delta`] for the
845    /// linear-vs-non-linear classification.
846    #[cfg(debug_assertions)]
847    fn note_balance(&mut self, op: Op, count: u16) {
848        match op_stack_delta(op, count) {
849            Some(delta) => self.balance_depth += delta,
850            None => self.balance_nonlinear += 1,
851        }
852    }
853
854    /// Snapshot the balance model before compiling a statement; pair with
855    /// [`Chunk::balance_delta_since`].
856    #[cfg(debug_assertions)]
857    pub(crate) fn balance_probe(&self) -> BalanceProbe {
858        BalanceProbe {
859            depth: self.balance_depth,
860            nonlinear: self.balance_nonlinear,
861        }
862    }
863
864    /// Net operand-stack effect emitted since `probe`, or `None` when any
865    /// non-linearly-modeled opcode was emitted in that span (which makes
866    /// the running sum untrustworthy, so callers must not assert on it).
867    /// The absolute `balance_depth` may be meaningless after a non-exact
868    /// span — only deltas over a fully-exact span are valid.
869    #[cfg(debug_assertions)]
870    pub(crate) fn balance_delta_since(&self, probe: BalanceProbe) -> Option<i32> {
871        if self.balance_nonlinear == probe.nonlinear {
872            Some(self.balance_depth - probe.depth)
873        } else {
874            None
875        }
876    }
877
878    fn register_inline_cache(&mut self, op_offset: usize) {
879        if self.inline_cache_slots.contains_key(&op_offset) {
880            return;
881        }
882        let mut entries = self.inline_caches.lock();
883        let slot = entries.len();
884        entries.push(InlineCacheEntry::Empty);
885        self.inline_cache_slots.insert(op_offset, slot);
886        Self::write_inline_cache_index(&mut self.inline_cache_index, op_offset, slot);
887    }
888
889    /// Fast-path side-table writer. Pulled out as an associated fn so both
890    /// the live emit path and [`Chunk::from_cached`] share the same growth
891    /// strategy. Cache slots fit comfortably in `u32` because the slot count
892    /// is bounded by the cacheable-opcode count in `code`.
893    fn write_inline_cache_index(index: &mut Vec<u32>, op_offset: usize, slot: usize) {
894        if op_offset >= index.len() {
895            index.resize(op_offset + 1, NO_INLINE_CACHE_SLOT);
896        }
897        index[op_offset] = slot as u32;
898    }
899
900    /// Look up the inline-cache slot for the opcode at `op_offset`. This is
901    /// called on every dispatch of an adaptive binary op (Add/Sub/Mul/Div/
902    /// Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq), `Op::Call`, `Op::MethodCall`
903    /// (and `MethodCallOpt`/`MethodCallSpread`), and `Op::GetProperty`
904    /// (`GetPropertyOpt`). Backed by [`Chunk::inline_cache_index`] — a flat
905    /// `Vec<u32>` indexed by code offset — so the lookup is a single bounds-
906    /// checked array read instead of the prior `BTreeMap::get` which walked
907    /// internal nodes for every dispatched op.
908    #[inline]
909    pub(crate) fn inline_cache_slot(&self, op_offset: usize) -> Option<usize> {
910        match self.inline_cache_index.get(op_offset).copied() {
911            None | Some(NO_INLINE_CACHE_SLOT) => None,
912            Some(slot) => Some(slot as usize),
913        }
914    }
915
916    pub(crate) fn inline_cache_slot_count(&self) -> usize {
917        self.inline_cache_slots.len()
918    }
919
920    pub(crate) fn cache_id(&self) -> u64 {
921        self.cache_id
922    }
923
924    /// Pre-optimization control path: the `BTreeMap`-backed lookup the
925    /// dispatcher used before the flat `Vec<u32>` side-table. Exposed
926    /// only behind the `vm-bench-internals` feature so the criterion
927    /// microbench can A/B the two paths inside one binary on identical
928    /// hardware. The production hot path must keep using
929    /// [`Chunk::inline_cache_slot`].
930    #[cfg(feature = "vm-bench-internals")]
931    pub fn inline_cache_slot_via_btreemap_for_bench(&self, op_offset: usize) -> Option<usize> {
932        self.inline_cache_slots.get(&op_offset).copied()
933    }
934
935    /// Returns a shared string for a `Constant::String` at the given pool
936    /// index, materializing it on first access and caching for reuse.
937    /// Returns `None` when the constant at `idx` is not a string (the
938    /// caller should fall back to the regular `Constant` match).
939    pub(crate) fn constant_string_rc(&self, idx: usize) -> Option<Arc<str>> {
940        // Borrow the side table mutably so we can lazily extend / fill
941        // entries. The borrow is scope-confined to this function; the
942        // VM never re-enters constant_string_rc for the same chunk
943        // during a single materialization, so no nested-borrow risk.
944        let mut entries = self.constant_strings.lock();
945        if entries.len() < self.constants.len() {
946            entries.resize(self.constants.len(), None);
947        }
948        if let Some(Some(existing)) = entries.get(idx) {
949            return Some(Arc::clone(existing));
950        }
951        let materialized = match self.constants.get(idx)? {
952            Constant::String(s) => Arc::<str>::from(s.as_str()),
953            _ => return None,
954        };
955        entries[idx] = Some(Arc::clone(&materialized));
956        Some(materialized)
957    }
958
959    #[cfg(feature = "vm-bench-internals")]
960    pub(crate) fn inline_cache_entry(&self, slot: usize) -> InlineCacheEntry {
961        self.inline_caches
962            .lock()
963            .get(slot)
964            .cloned()
965            .unwrap_or(InlineCacheEntry::Empty)
966    }
967
968    /// Adaptive-binary fast path read. Returns the cached
969    /// `(op, state)` pair by value (both `Copy`) when slot holds an
970    /// `AdaptiveBinary` entry, else `None`. Skips the
971    /// `InlineCacheEntry::clone` that `inline_cache_entry` performs:
972    /// since `AdaptiveBinaryState: Copy`, the read does a single
973    /// scalar move out of the cache instead of a 24-32B memcpy of the
974    /// wrapping enum (which the variant-checking match destructures
975    /// and throws away anyway). Fires on every Add/Sub/Mul/Div/Mod/Eq/
976    /// Neq/Less/Greater/LessEq/GreaterEq dispatch, so the per-op
977    /// savings compound across the millions of dispatches a typical
978    /// loop body issues.
979    #[inline]
980    #[cfg(any(test, feature = "vm-bench-internals"))]
981    pub(crate) fn peek_adaptive_binary_cache(
982        &self,
983        slot: usize,
984    ) -> Option<(AdaptiveBinaryOp, AdaptiveBinaryState)> {
985        match self.inline_caches.lock().get(slot)? {
986            &InlineCacheEntry::AdaptiveBinary { op, state } => Some((op, state)),
987            _ => None,
988        }
989    }
990
991    /// Method-cache fast path read. Returns the cached `(name_idx, argc,
992    /// target)` triple by value (all three are `Copy`) when `slot` holds a
993    /// `Method` entry, else `None`. Skips the full `InlineCacheEntry::clone`
994    /// that `inline_cache_entry` performs on every `Op::MethodCall`,
995    /// `Op::MethodCallOpt`, and `Op::MethodCallSpread` dispatch: the
996    /// variant-checking `let-else` in `try_cached_method` destructures and
997    /// throws the wrapping enum away anyway, so reading the payload by `Copy`
998    /// avoids the 32-48B enum memcpy. Method-call dispatch is the second-
999    /// hottest IC-keyed opcode class after the adaptive binary ops, so the
1000    /// per-dispatch savings compound across the millions of method calls a
1001    /// typical pipeline (`xs.filter(...).map(...).count()`) issues.
1002    #[inline]
1003    #[cfg(any(test, feature = "vm-bench-internals"))]
1004    pub(crate) fn peek_method_cache(&self, slot: usize) -> Option<(u16, usize, MethodCacheTarget)> {
1005        match self.inline_caches.lock().get(slot)? {
1006            &InlineCacheEntry::Method {
1007                name_idx,
1008                argc,
1009                target,
1010            } => Some((name_idx, argc, target)),
1011            _ => None,
1012        }
1013    }
1014
1015    /// Property-cache fast path read. Returns the cached `(name_idx, target)`
1016    /// pair by value when `slot` holds a `Property` entry, else `None`. The
1017    /// outer `InlineCacheEntry` is the worst-case-sized variant (DirectCall
1018    /// at ~48 bytes including padding); cloning it just to discard four other
1019    /// variants in `try_cached_property`'s variant-check is wasted work. The
1020    /// peek returns just the `Property` payload (`u16` + `PropertyCacheTarget`),
1021    /// skipping the outer enum tag init and the padding-to-largest-variant
1022    /// memcpy. Fires on every `Op::GetProperty` / `Op::GetPropertyOpt`
1023    /// dispatch, which is the dominant opcode for any field-read-heavy code.
1024    #[inline]
1025    #[cfg(any(test, feature = "vm-bench-internals"))]
1026    pub(crate) fn peek_property_cache(&self, slot: usize) -> Option<(u16, PropertyCacheTarget)> {
1027        match self.inline_caches.lock().get(slot)? {
1028            InlineCacheEntry::Property { name_idx, target } => Some((*name_idx, target.clone())),
1029            _ => None,
1030        }
1031    }
1032
1033    /// Direct-call cache state read. Returns just the inner `DirectCallState`
1034    /// by value when `slot` holds a `DirectCall` entry, else `None`. Used by
1035    /// both `try_cached_direct_call(_)` (steady-state Specialized hit check)
1036    /// and `next_direct_call_entry` (Warmup → Specialized state-machine
1037    /// transition). Peeking the inner state directly skips the outer
1038    /// `InlineCacheEntry` discriminant check and tag init that the dispatcher
1039    /// otherwise pays on every `Op::Call` (closure callee) and the named-fn
1040    /// fast path inside `Op::CallBuiltin`. Single peek per dispatch covers
1041    /// both the read check and the write-back computation.
1042    #[inline]
1043    #[cfg(any(test, feature = "vm-bench-internals"))]
1044    pub(crate) fn peek_direct_call_state(&self, slot: usize) -> Option<DirectCallState> {
1045        match self.inline_caches.lock().get(slot)? {
1046            InlineCacheEntry::DirectCall { state } => Some(state.clone()),
1047            _ => None,
1048        }
1049    }
1050
1051    #[cfg(any(test, feature = "vm-bench-internals"))]
1052    pub(crate) fn set_inline_cache_entry(&self, slot: usize, entry: InlineCacheEntry) {
1053        if let Some(existing) = self.inline_caches.lock().get_mut(slot) {
1054            *existing = entry;
1055        }
1056    }
1057
1058    pub fn freeze_for_cache(&self) -> CachedChunk {
1059        CachedChunk {
1060            code: self.code.clone(),
1061            constants: self.constants.clone(),
1062            lines: self.lines.clone(),
1063            columns: self.columns.clone(),
1064            source_file: self.source_file.clone(),
1065            current_col: self.current_col,
1066            functions: self
1067                .functions
1068                .iter()
1069                .map(|function| function.freeze_for_cache())
1070                .collect(),
1071            inline_cache_slots: self.inline_cache_slots.clone(),
1072            local_slots: self.local_slots.clone(),
1073            references_outer_names: self.references_outer_names,
1074        }
1075    }
1076
1077    pub fn from_cached(cached: &CachedChunk) -> Self {
1078        let inline_cache_count = cached.inline_cache_slots.len();
1079        let constants_count = cached.constants.len();
1080        // Project the cached `BTreeMap<op_offset, slot>` into the flat
1081        // dispatch-side lookup table. Sized to `code.len()` so the hottest
1082        // hot opcodes (binary ops at the end of a long chunk) still hit the
1083        // fast-path bounds check rather than falling through to the
1084        // none-found branch. The size is bounded by code length, so the
1085        // memory footprint is tiny — a few KB for typical chunks.
1086        let mut inline_cache_index = Vec::new();
1087        inline_cache_index.resize(cached.code.len(), NO_INLINE_CACHE_SLOT);
1088        for (&op_offset, &slot) in cached.inline_cache_slots.iter() {
1089            if op_offset < inline_cache_index.len() {
1090                inline_cache_index[op_offset] = slot as u32;
1091            }
1092        }
1093        Self {
1094            cache_id: next_chunk_cache_id(),
1095            code: cached.code.clone(),
1096            constants: cached.constants.clone(),
1097            lines: cached.lines.clone(),
1098            columns: cached.columns.clone(),
1099            source_file: cached.source_file.clone(),
1100            current_col: cached.current_col,
1101            functions: cached
1102                .functions
1103                .iter()
1104                .map(|function| Arc::new(CompiledFunction::from_cached(function)))
1105                .collect(),
1106            inline_cache_slots: cached.inline_cache_slots.clone(),
1107            inline_cache_index,
1108            inline_caches: Arc::new(Mutex::new(vec![
1109                InlineCacheEntry::Empty;
1110                inline_cache_count
1111            ])),
1112            constant_strings: Arc::new(Mutex::new(vec![None; constants_count])),
1113            local_slots: cached.local_slots.clone(),
1114            references_outer_names: cached.references_outer_names,
1115            #[cfg(debug_assertions)]
1116            balance_depth: 0,
1117            #[cfg(debug_assertions)]
1118            balance_nonlinear: 0,
1119        }
1120    }
1121
1122    pub(crate) fn add_local_slot(
1123        &mut self,
1124        name: String,
1125        mutable: bool,
1126        scope_depth: usize,
1127    ) -> u16 {
1128        let idx = self.local_slots.len();
1129        self.local_slots.push(LocalSlotInfo {
1130            name,
1131            mutable,
1132            scope_depth,
1133        });
1134        idx as u16
1135    }
1136
1137    /// Read a u64 argument at the given position.
1138    pub fn read_u64(&self, pos: usize) -> u64 {
1139        u64::from_be_bytes([
1140            self.code[pos],
1141            self.code[pos + 1],
1142            self.code[pos + 2],
1143            self.code[pos + 3],
1144            self.code[pos + 4],
1145            self.code[pos + 5],
1146            self.code[pos + 6],
1147            self.code[pos + 7],
1148        ])
1149    }
1150
1151    /// Disassemble the chunk for debugging. The per-opcode rendering is
1152    /// macro-generated alongside the dispatch tables in
1153    /// `crate::vm::ops` — see [`Self::disassemble_op`].
1154    pub fn disassemble(&self, name: &str) -> String {
1155        let mut out = format!("== {name} ==\n");
1156        let mut ip = 0;
1157        while ip < self.code.len() {
1158            let op_byte = self.code[ip];
1159            let line = self.lines.get(ip).copied().unwrap_or(0);
1160            out.push_str(&format!("{ip:04} [{line:>4}] "));
1161            ip += 1;
1162
1163            if let Some(op) = Op::from_byte(op_byte) {
1164                self.disassemble_op(op, &mut ip, &mut out);
1165            } else {
1166                out.push_str(&format!("UNKNOWN(0x{op_byte:02x})\n"));
1167            }
1168        }
1169        out
1170    }
1171}
1172
1173/// Disassembly helpers consumed by the macro-generated
1174/// [`Chunk::disassemble_op`]. Each helper takes the current code position
1175/// (already advanced past the opcode byte), advances it over the operand
1176/// bytes the opcode carries, and renders one human-readable line without
1177/// a trailing newline (the dispatcher appends it).
1178///
1179/// Defining one helper per operand layout — and not one per opcode —
1180/// keeps adding an opcode a one-line edit in the `define_opcodes!` table
1181/// rather than a paired edit here. New layouts live with the helpers;
1182/// new opcodes live with the dispatch.
1183pub(crate) fn disasm_bare(_chunk: &Chunk, _ip: &mut usize, label: &str) -> String {
1184    label.to_string()
1185}
1186
1187pub(crate) fn disasm_u8(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1188    let arg = chunk.code[*ip];
1189    *ip += 1;
1190    format!("{label} {arg:>4}")
1191}
1192
1193pub(crate) fn disasm_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1194    let arg = chunk.read_u16(*ip);
1195    *ip += 2;
1196    format!("{label} {arg:>4}")
1197}
1198
1199pub(crate) fn disasm_const_pool_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1200    let idx = chunk.read_u16(*ip);
1201    *ip += 2;
1202    format!("{label} {idx:>4} ({})", chunk.constants[idx as usize])
1203}
1204
1205pub(crate) fn disasm_local_slot_u16(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1206    let slot = chunk.read_u16(*ip);
1207    *ip += 2;
1208    let mut out = format!("{label} {slot:>4}");
1209    if let Some(info) = chunk.local_slots.get(slot as usize) {
1210        out.push_str(&format!(" ({})", info.name));
1211    }
1212    out
1213}
1214
1215pub(crate) fn disasm_method_call(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1216    let idx = chunk.read_u16(*ip);
1217    *ip += 2;
1218    let argc = chunk.code[*ip];
1219    *ip += 1;
1220    format!(
1221        "{label} {idx:>4} ({}) argc={argc}",
1222        chunk.constants[idx as usize]
1223    )
1224}
1225
1226pub(crate) fn disasm_match_enum(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1227    let enum_idx = chunk.read_u16(*ip);
1228    *ip += 2;
1229    let var_idx = chunk.read_u16(*ip);
1230    *ip += 2;
1231    format!(
1232        "{label} {enum_idx:>4} ({}) {var_idx:>4} ({})",
1233        chunk.constants[enum_idx as usize], chunk.constants[var_idx as usize],
1234    )
1235}
1236
1237pub(crate) fn disasm_build_enum(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1238    let enum_idx = chunk.read_u16(*ip);
1239    *ip += 2;
1240    let var_idx = chunk.read_u16(*ip);
1241    *ip += 2;
1242    let field_count = chunk.read_u16(*ip);
1243    *ip += 2;
1244    format!(
1245        "{label} {enum_idx:>4} ({}) {var_idx:>4} ({}) fields={field_count}",
1246        chunk.constants[enum_idx as usize], chunk.constants[var_idx as usize],
1247    )
1248}
1249
1250pub(crate) fn disasm_selective_import(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1251    let path_idx = chunk.read_u16(*ip);
1252    *ip += 2;
1253    let names_idx = chunk.read_u16(*ip);
1254    *ip += 2;
1255    format!(
1256        "{label} {path_idx:>4} ({}) names: {names_idx:>4} ({})",
1257        chunk.constants[path_idx as usize], chunk.constants[names_idx as usize],
1258    )
1259}
1260
1261pub(crate) fn disasm_check_type(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1262    let var_idx = chunk.read_u16(*ip);
1263    *ip += 2;
1264    let type_idx = chunk.read_u16(*ip);
1265    *ip += 2;
1266    format!(
1267        "{label} {var_idx:>4} ({}) -> {type_idx:>4} ({})",
1268        chunk.constants[var_idx as usize], chunk.constants[type_idx as usize],
1269    )
1270}
1271
1272pub(crate) fn disasm_call_builtin(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1273    let id = chunk.read_u64(*ip);
1274    *ip += 8;
1275    let idx = chunk.read_u16(*ip);
1276    *ip += 2;
1277    let argc = chunk.code[*ip];
1278    *ip += 1;
1279    format!(
1280        "{label} {id:#018x} {idx:>4} ({}) argc={argc}",
1281        chunk.constants[idx as usize],
1282    )
1283}
1284
1285pub(crate) fn disasm_call_builtin_spread(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    format!(
1291        "{label} {id:#018x} {idx:>4} ({})",
1292        chunk.constants[idx as usize],
1293    )
1294}
1295
1296pub(crate) fn disasm_method_call_spread(chunk: &Chunk, ip: &mut usize, label: &str) -> String {
1297    // emit_u16(Op::MethodCallSpread, name_idx, ...) writes opcode + 2
1298    // bytes of u16 name_idx, so the operand is read at *ip with the
1299    // usual `read_u16`. The previous hand-written disasm read at
1300    // `ip + 1`, which displayed the wrong constant index — silently
1301    // corrupting any disassembly that hit a `MethodCallSpread` opcode.
1302    let idx = chunk.read_u16(*ip);
1303    *ip += 2;
1304    format!("{label} {idx:>4} ({})", chunk.constants[idx as usize])
1305}
1306
1307impl Default for Chunk {
1308    fn default() -> Self {
1309        Self::new()
1310    }
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315    use std::sync::Arc;
1316
1317    use super::{
1318        Chunk, DirectCallState, DirectCallTarget, InlineCacheEntry, MethodCacheTarget, Op,
1319        PropertyCacheTarget,
1320    };
1321    use crate::BuiltinId;
1322
1323    #[test]
1324    fn op_from_byte_matches_repr_order() {
1325        for (byte, op) in Op::ALL.iter().copied().enumerate() {
1326            assert_eq!(byte as u8, op as u8);
1327            assert_eq!(Op::from_byte(byte as u8), Some(op));
1328        }
1329        assert_eq!(Op::from_byte(Op::ALL.len() as u8), None);
1330        assert_eq!(Op::COUNT, Op::ALL.len());
1331    }
1332
1333    #[test]
1334    fn disassemble_covers_every_opcode_variant() {
1335        // The macro-generated `disassemble_op` match is exhaustive on
1336        // `Op`, so this is a compile-time guarantee. The runtime check
1337        // pins that no helper falls through to `UNKNOWN(...)` for a
1338        // valid opcode byte — catching any future macro refactor that
1339        // silently drops a helper arm. Each opcode is exercised in
1340        // isolation against a hand-built chunk so the test logic does
1341        // not depend on operand sizes (and so a single short opcode
1342        // does not bleed into reading trailing padding as a follow-on
1343        // opcode in the chunk-level loop).
1344        for op in Op::ALL.iter().copied() {
1345            let mut chunk = Chunk::new();
1346            chunk.add_constant(super::Constant::String("__probe__".to_string()));
1347            // Pad to the worst-case operand width (CallBuiltin: u64 +
1348            // u16 + u8 = 11 bytes) so any helper has well-formed bytes
1349            // to consume regardless of its layout.
1350            for _ in 0..16 {
1351                chunk.code.push(0);
1352            }
1353            let mut ip: usize = 0;
1354            let mut out = String::new();
1355            chunk.disassemble_op(op, &mut ip, &mut out);
1356            assert!(
1357                !out.contains("UNKNOWN"),
1358                "disasm emitted UNKNOWN for {op:?}: {out}",
1359            );
1360            assert!(!out.is_empty(), "disasm produced no output for {op:?}");
1361        }
1362    }
1363
1364    // --- references_outer_names tracking ---
1365    //
1366    // Drives the compile-time guard used in `Vm::closure_call_env`
1367    // and `Vm::closure_call_env_for_current_frame` to skip the
1368    // per-invocation caller-scope late-bind walks. Coverage parity
1369    // matters because false negatives would regress recursive /
1370    // mutually-recursive fns.
1371
1372    #[test]
1373    fn empty_chunk_does_not_reference_outer_names() {
1374        let chunk = Chunk::new();
1375        assert!(!chunk.references_outer_names);
1376    }
1377
1378    #[test]
1379    fn arithmetic_only_chunk_does_not_reference_outer_names() {
1380        // The hot `.map(x -> x * 2)` / `.filter(x -> x % 2 == 0)`
1381        // shape: pure stack/arithmetic ops and slot locals, no env
1382        // reads. Must NOT flag — that's the whole point of the
1383        // optimization.
1384        let mut chunk = Chunk::new();
1385        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1386        chunk.emit_u16(Op::Constant, 0, 1);
1387        chunk.emit(Op::MulInt, 1);
1388        chunk.emit(Op::Pop, 1);
1389        chunk.emit(Op::Return, 1);
1390        assert!(!chunk.references_outer_names);
1391    }
1392
1393    #[test]
1394    fn slot_only_chunk_does_not_reference_outer_names() {
1395        // Compiler-resolved locals never need env-based late-bind.
1396        let mut chunk = Chunk::new();
1397        chunk.emit_u16(Op::DefLocalSlot, 0, 1);
1398        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1399        chunk.emit_u16(Op::SetLocalSlot, 0, 1);
1400        assert!(!chunk.references_outer_names);
1401    }
1402
1403    #[test]
1404    fn get_var_flags_outer_name_reference() {
1405        let mut chunk = Chunk::new();
1406        chunk.emit_u16(Op::GetVar, 0, 1);
1407        assert!(chunk.references_outer_names);
1408    }
1409
1410    #[test]
1411    fn set_var_flags_outer_name_reference() {
1412        let mut chunk = Chunk::new();
1413        chunk.emit_u16(Op::SetVar, 0, 1);
1414        assert!(chunk.references_outer_names);
1415    }
1416
1417    #[test]
1418    fn check_type_flags_outer_name_reference() {
1419        let mut chunk = Chunk::new();
1420        chunk.emit_u16(Op::CheckType, 0, 1);
1421        assert!(chunk.references_outer_names);
1422    }
1423
1424    #[test]
1425    fn call_builtin_flags_outer_name_reference() {
1426        let mut chunk = Chunk::new();
1427        chunk.emit_call_builtin(BuiltinId::from_name("any_name"), 0, 1, 1);
1428        assert!(chunk.references_outer_names);
1429    }
1430
1431    #[test]
1432    fn call_builtin_spread_flags_outer_name_reference() {
1433        let mut chunk = Chunk::new();
1434        chunk.emit_call_builtin_spread(BuiltinId::from_name("any_name"), 0, 1);
1435        assert!(chunk.references_outer_names);
1436    }
1437
1438    #[test]
1439    fn tail_call_flags_outer_name_reference() {
1440        // `return fn_name(...)` compiles to Constant + TailCall —
1441        // TailCall does a runtime name lookup, so it has to flag.
1442        let mut chunk = Chunk::new();
1443        chunk.emit_u8(Op::TailCall, 1, 1);
1444        assert!(chunk.references_outer_names);
1445    }
1446
1447    #[test]
1448    fn call_flags_outer_name_reference() {
1449        // Op::Call can receive a String callee from the stack (the
1450        // by-name dispatch shape), so it has to flag too.
1451        let mut chunk = Chunk::new();
1452        chunk.emit_u8(Op::Call, 1, 1);
1453        assert!(chunk.references_outer_names);
1454    }
1455
1456    #[test]
1457    fn pipe_flags_outer_name_reference() {
1458        // `x |> name` resolves `name` through env when the value on
1459        // the stack is a String / BuiltinRef.
1460        let mut chunk = Chunk::new();
1461        chunk.emit(Op::Pipe, 1);
1462        assert!(chunk.references_outer_names);
1463    }
1464
1465    #[test]
1466    fn method_call_does_not_flag_outer_name_reference() {
1467        // Method receivers come off the operand stack, not the env;
1468        // emitting MethodCall alone must not force the walk.
1469        let mut chunk = Chunk::new();
1470        chunk.emit_method_call(0, 1, 1);
1471        chunk.emit_method_call_opt(0, 1, 1);
1472        assert!(!chunk.references_outer_names);
1473    }
1474
1475    #[test]
1476    fn jump_and_control_flow_do_not_flag_outer_name_reference() {
1477        // Jumps, returns, pops — control flow stays inside the
1478        // frame and never touches env lookups.
1479        let mut chunk = Chunk::new();
1480        chunk.emit_u16(Op::Constant, 0, 1);
1481        chunk.emit(Op::JumpIfFalse, 1);
1482        chunk.emit(Op::Jump, 1);
1483        chunk.emit(Op::Return, 1);
1484        chunk.emit(Op::Pop, 1);
1485        assert!(!chunk.references_outer_names);
1486    }
1487
1488    #[test]
1489    fn references_outer_names_is_monotonic() {
1490        // Once flagged, subsequent non-flagging emits must not
1491        // clear the bit — flags are sticky.
1492        let mut chunk = Chunk::new();
1493        chunk.emit_u16(Op::GetVar, 0, 1);
1494        assert!(chunk.references_outer_names);
1495        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1496        chunk.emit(Op::MulInt, 1);
1497        assert!(chunk.references_outer_names);
1498    }
1499
1500    #[test]
1501    fn freeze_thaw_round_trips_references_outer_names() {
1502        // Bytecode-cache hits must observe the same flag as a
1503        // fresh compile — otherwise the first call after a cache
1504        // hit would either over- or under-skip the walk.
1505        let mut chunk = Chunk::new();
1506        chunk.emit_u16(Op::GetVar, 0, 1);
1507        assert!(chunk.references_outer_names);
1508        let frozen = chunk.freeze_for_cache();
1509        let thawed = Chunk::from_cached(&frozen);
1510        assert!(thawed.references_outer_names);
1511
1512        let plain = Chunk::new();
1513        assert!(!plain.references_outer_names);
1514        let frozen_plain = plain.freeze_for_cache();
1515        let thawed_plain = Chunk::from_cached(&frozen_plain);
1516        assert!(!thawed_plain.references_outer_names);
1517    }
1518
1519    // --- inline_cache_slot flat-index parity ---
1520    //
1521    // Slot lookups fire on every dispatch of an adaptive binary op
1522    // (Add/Sub/Mul/Div/Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq),
1523    // every `Op::Call`, every `Op::MethodCall(Opt)`, and every
1524    // `Op::GetProperty(Opt)`. The flat `Vec<u32>` index has to stay
1525    // perfectly in sync with the serialization-stable BTreeMap or
1526    // a cached call site would either skip its inline cache (slow
1527    // path with no learning) or read a stale slot (silently
1528    // mis-specialized arithmetic). These tests pin the contract.
1529
1530    #[test]
1531    fn inline_cache_slot_returns_none_for_non_cacheable_offsets() {
1532        // GetLocalSlot is a sync-fast-path opcode with no inline
1533        // cache; the index must report no slot.
1534        let mut chunk = Chunk::new();
1535        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1536        chunk.emit(Op::Pop, 1);
1537        chunk.emit(Op::Return, 1);
1538        assert!(chunk.inline_cache_slot(0).is_none());
1539        assert!(chunk.inline_cache_slot(3).is_none());
1540        assert!(chunk.inline_cache_slot(4).is_none());
1541    }
1542
1543    #[test]
1544    fn inline_cache_slot_registered_for_adaptive_binary_op() {
1545        // Pure-arithmetic ops use the adaptive-binary IC for shape
1546        // specialization. The slot has to be 0 because the chunk is
1547        // otherwise empty.
1548        let mut chunk = Chunk::new();
1549        chunk.emit(Op::Add, 1);
1550        assert_eq!(chunk.inline_cache_slot(0), Some(0));
1551    }
1552
1553    #[test]
1554    fn inline_cache_slot_distinct_for_sequential_adaptive_binary_ops() {
1555        // Three back-to-back Adds must get three distinct slots so
1556        // each instruction's shape feedback evolves independently
1557        // (otherwise the same call site would clobber a neighbor's
1558        // learning every dispatch).
1559        let mut chunk = Chunk::new();
1560        chunk.emit(Op::Add, 1);
1561        chunk.emit(Op::Sub, 1);
1562        chunk.emit(Op::Mul, 1);
1563        let s0 = chunk.inline_cache_slot(0).expect("Add slot");
1564        let s1 = chunk.inline_cache_slot(1).expect("Sub slot");
1565        let s2 = chunk.inline_cache_slot(2).expect("Mul slot");
1566        assert_ne!(s0, s1);
1567        assert_ne!(s1, s2);
1568        assert_ne!(s0, s2);
1569    }
1570
1571    #[test]
1572    fn inline_cache_slot_returns_none_for_out_of_bounds_offset() {
1573        // The dispatcher derives `op_offset` from `ip - 1`; an
1574        // out-of-bounds query must return None rather than panic.
1575        let mut chunk = Chunk::new();
1576        chunk.emit(Op::Add, 1);
1577        assert!(chunk.inline_cache_slot(usize::MAX).is_none());
1578        assert!(chunk.inline_cache_slot(chunk.code.len()).is_none());
1579        assert!(chunk.inline_cache_slot(chunk.code.len() + 16).is_none());
1580    }
1581
1582    #[test]
1583    fn inline_cache_slot_for_get_property_and_method_call() {
1584        // GetProperty(Opt) and MethodCall(Opt) both register an IC
1585        // slot at emit time — adaptive method-call dispatch and
1586        // monomorphic property-cache learning depend on it.
1587        let mut chunk = Chunk::new();
1588        chunk.emit_u16(Op::GetProperty, 0, 1); // offset 0..3
1589        chunk.emit_method_call(0, 1, 1); // offset 3..7
1590        chunk.emit_method_call_opt(0, 1, 1); // offset 7..11
1591        chunk.emit_u16(Op::GetPropertyOpt, 0, 1); // offset 11..14
1592        assert!(chunk.inline_cache_slot(0).is_some(), "GetProperty");
1593        assert!(chunk.inline_cache_slot(3).is_some(), "MethodCall");
1594        assert!(chunk.inline_cache_slot(7).is_some(), "MethodCallOpt");
1595        assert!(chunk.inline_cache_slot(11).is_some(), "GetPropertyOpt");
1596    }
1597
1598    #[test]
1599    fn inline_cache_slot_for_call_and_call_builtin() {
1600        // Both `Op::Call` (closure / by-name callee) and
1601        // `emit_call_builtin` register IC slots. The latter is the
1602        // adaptive-call fast path used for every direct user-fn
1603        // invocation.
1604        let mut chunk = Chunk::new();
1605        chunk.emit_u8(Op::Call, 1, 1); // offset 0..2
1606        let call_builtin_offset = chunk.code.len();
1607        chunk.emit_call_builtin(BuiltinId::from_name("any"), 0, 1, 1);
1608        assert!(chunk.inline_cache_slot(0).is_some(), "Op::Call IC slot");
1609        assert!(
1610            chunk.inline_cache_slot(call_builtin_offset).is_some(),
1611            "Op::CallBuiltin IC slot"
1612        );
1613    }
1614
1615    #[test]
1616    fn inline_cache_slot_register_is_idempotent_for_same_offset() {
1617        // The compile path uses `BTreeMap::contains_key` to dedup
1618        // re-registration at the same offset (eg. when a helper
1619        // re-emits into a still-live position). The flat index has
1620        // to honor the same semantics — never silently overwriting
1621        // an existing slot with a fresh one.
1622        let mut chunk = Chunk::new();
1623        chunk.emit(Op::Add, 1);
1624        let slot_before = chunk.inline_cache_slot(0).expect("first registration");
1625        // Manually re-register the same offset to confirm dedup.
1626        chunk.register_inline_cache(0);
1627        let slot_after = chunk.inline_cache_slot(0).expect("re-registration");
1628        assert_eq!(slot_before, slot_after);
1629    }
1630
1631    #[test]
1632    fn inline_cache_index_round_trips_through_cached_chunk() {
1633        // The cache freeze drops the flat index (it's derived from
1634        // the BTreeMap that *is* serialized). On thaw, the flat
1635        // index must be rebuilt so the first hot dispatch of a
1636        // cached chunk doesn't fall off the IC-slot cliff (which
1637        // would silently disable shape specialization until the
1638        // chunk is recompiled from source).
1639        let mut chunk = Chunk::new();
1640        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1641        chunk.emit_u16(Op::Constant, 0, 1);
1642        chunk.emit(Op::Add, 1);
1643        chunk.emit(Op::Sub, 1);
1644        chunk.emit_method_call(0, 1, 1);
1645        chunk.emit_u8(Op::Call, 1, 1);
1646        let live_slots: Vec<(usize, Option<usize>)> = (0..chunk.code.len())
1647            .map(|o| (o, chunk.inline_cache_slot(o)))
1648            .collect();
1649        let frozen = chunk.freeze_for_cache();
1650        let thawed = Chunk::from_cached(&frozen);
1651        let thawed_slots: Vec<(usize, Option<usize>)> = (0..thawed.code.len())
1652            .map(|o| (o, thawed.inline_cache_slot(o)))
1653            .collect();
1654        assert_eq!(live_slots, thawed_slots);
1655    }
1656
1657    #[test]
1658    fn inline_cache_index_agrees_with_btreemap_view() {
1659        // Authoritative parity check: for every code offset, the
1660        // flat-index `inline_cache_slot` must return exactly what
1661        // the underlying BTreeMap would (mod the `Option` boxing).
1662        // Catches any future emit path that grows `inline_cache_slots`
1663        // without going through `register_inline_cache`.
1664        let mut chunk = Chunk::new();
1665        chunk.emit(Op::Add, 1);
1666        chunk.emit_u16(Op::GetVar, 0, 1);
1667        chunk.emit(Op::LessInt, 1);
1668        chunk.emit_u8(Op::Call, 2, 1);
1669        chunk.emit(Op::Equal, 1);
1670        chunk.emit_u16(Op::GetProperty, 0, 1);
1671        chunk.emit_method_call_opt(0, 0, 1);
1672        for offset in 0..chunk.code.len() {
1673            let from_map = chunk.inline_cache_slots.get(&offset).copied();
1674            let from_index = chunk.inline_cache_slot(offset);
1675            assert_eq!(from_index, from_map, "parity broken at offset {offset}");
1676        }
1677    }
1678
1679    // --- peek_adaptive_binary_cache contract ---
1680    //
1681    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1682    // hottest opcode class (Add / Sub / Mul / Div / Mod / Eq / Neq /
1683    // Less / Greater / LessEq / GreaterEq). It must return None for
1684    // unrelated IC variants — silently mis-extracting a `Property` /
1685    // `DirectCall` / `Method` slot as `AdaptiveBinary` would feed
1686    // garbage into `try_specialized_binary` and either spec-mis-fire or
1687    // crash. These tests pin the variant gate.
1688
1689    #[test]
1690    fn peek_adaptive_binary_returns_none_for_empty_slot() {
1691        let mut chunk = Chunk::new();
1692        chunk.emit(Op::Add, 1);
1693        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
1694        // Default state of a freshly-emitted slot is Empty.
1695        assert!(chunk.peek_adaptive_binary_cache(slot).is_none());
1696    }
1697
1698    #[test]
1699    fn peek_adaptive_binary_returns_op_and_state_after_warmup() {
1700        use super::{AdaptiveBinaryOp, AdaptiveBinaryState, BinaryShape, InlineCacheEntry};
1701        let mut chunk = Chunk::new();
1702        chunk.emit(Op::Add, 1);
1703        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
1704        chunk.set_inline_cache_entry(
1705            slot,
1706            InlineCacheEntry::AdaptiveBinary {
1707                op: AdaptiveBinaryOp::Add,
1708                state: AdaptiveBinaryState::Warmup {
1709                    shape: BinaryShape::Int,
1710                    hits: 2,
1711                },
1712            },
1713        );
1714        let (op, state) = chunk
1715            .peek_adaptive_binary_cache(slot)
1716            .expect("warmed slot peek");
1717        assert_eq!(op, AdaptiveBinaryOp::Add);
1718        assert!(matches!(
1719            state,
1720            AdaptiveBinaryState::Warmup {
1721                shape: BinaryShape::Int,
1722                hits: 2
1723            }
1724        ));
1725    }
1726
1727    #[test]
1728    fn peek_adaptive_binary_returns_none_for_non_binary_variants() {
1729        // The cache slot may legitimately hold a `Property`, `Method`,
1730        // or `DirectCall` entry (eg. a Property slot at the offset
1731        // sequence happens to alias an Add slot during a code rewrite —
1732        // currently this cannot happen, but the peek must defensively
1733        // refuse non-AdaptiveBinary variants regardless).
1734        use super::{InlineCacheEntry, PropertyCacheTarget};
1735        let mut chunk = Chunk::new();
1736        chunk.emit(Op::Add, 1);
1737        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
1738        chunk.set_inline_cache_entry(
1739            slot,
1740            InlineCacheEntry::Property {
1741                name_idx: 0,
1742                target: PropertyCacheTarget::ListCount,
1743            },
1744        );
1745        assert!(chunk.peek_adaptive_binary_cache(slot).is_none());
1746    }
1747
1748    #[test]
1749    fn peek_adaptive_binary_returns_none_for_out_of_bounds_slot() {
1750        // Defensive: `execute_adaptive_binary` filters its `slot`
1751        // through `inline_cache_slot` first, but
1752        // `peek_adaptive_binary_cache` should still return None for an
1753        // unmapped slot rather than panicking.
1754        let chunk = Chunk::new();
1755        assert!(chunk.peek_adaptive_binary_cache(0).is_none());
1756        assert!(chunk.peek_adaptive_binary_cache(usize::MAX).is_none());
1757    }
1758
1759    #[test]
1760    fn peek_adaptive_binary_state_is_copy() {
1761        // Compile-time assertion: `AdaptiveBinaryState: Copy` is the
1762        // whole point of this optimization — if a future variant adds
1763        // a non-Copy field, the static check below will fail at compile
1764        // time before the dispatcher silently regresses to the heavy
1765        // `InlineCacheEntry::clone` path.
1766        fn assert_copy<T: Copy>() {}
1767        assert_copy::<super::AdaptiveBinaryState>();
1768        assert_copy::<super::AdaptiveBinaryOp>();
1769        assert_copy::<super::BinaryShape>();
1770    }
1771
1772    // --- peek_method_cache contract ---
1773    //
1774    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1775    // method-call dispatch sites (`Op::MethodCall`, `Op::MethodCallOpt`,
1776    // `Op::MethodCallSpread`). It must return None for unrelated IC variants
1777    // — silently mis-extracting a `Property` / `DirectCall` / `AdaptiveBinary`
1778    // slot as `Method` would feed garbage into `try_cached_method` and either
1779    // spec-mis-fire (wrong target/argc) or skip the cache entirely on a real
1780    // hit. These tests pin the variant gate.
1781
1782    #[test]
1783    fn peek_method_cache_returns_none_for_empty_slot() {
1784        let mut chunk = Chunk::new();
1785        chunk.emit_method_call(0, 0, 1);
1786        let slot = chunk
1787            .inline_cache_slot(0)
1788            .expect("MethodCall registers a slot");
1789        assert!(chunk.peek_method_cache(slot).is_none());
1790    }
1791
1792    #[test]
1793    fn peek_method_cache_returns_triple_after_warmup() {
1794        let mut chunk = Chunk::new();
1795        chunk.emit_method_call(7, 2, 1);
1796        let slot = chunk
1797            .inline_cache_slot(0)
1798            .expect("MethodCall registers a slot");
1799        chunk.set_inline_cache_entry(
1800            slot,
1801            InlineCacheEntry::Method {
1802                name_idx: 7,
1803                argc: 2,
1804                target: MethodCacheTarget::ListContains,
1805            },
1806        );
1807        let (name_idx, argc, target) = chunk.peek_method_cache(slot).expect("warmed slot peek");
1808        assert_eq!(name_idx, 7);
1809        assert_eq!(argc, 2);
1810        assert_eq!(target, MethodCacheTarget::ListContains);
1811    }
1812
1813    #[test]
1814    fn peek_method_cache_returns_none_for_non_method_variants() {
1815        // The cache slot may legitimately hold an `AdaptiveBinary`,
1816        // `Property`, or `DirectCall` entry. The peek must defensively
1817        // refuse non-Method variants regardless.
1818        let mut chunk = Chunk::new();
1819        chunk.emit_method_call(0, 0, 1);
1820        let slot = chunk
1821            .inline_cache_slot(0)
1822            .expect("MethodCall registers a slot");
1823
1824        chunk.set_inline_cache_entry(
1825            slot,
1826            InlineCacheEntry::Property {
1827                name_idx: 0,
1828                target: PropertyCacheTarget::ListCount,
1829            },
1830        );
1831        assert!(chunk.peek_method_cache(slot).is_none());
1832    }
1833
1834    #[test]
1835    fn peek_method_cache_returns_none_for_out_of_bounds_slot() {
1836        let chunk = Chunk::new();
1837        assert!(chunk.peek_method_cache(0).is_none());
1838        assert!(chunk.peek_method_cache(usize::MAX).is_none());
1839    }
1840
1841    #[test]
1842    fn peek_method_cache_target_is_copy() {
1843        // Compile-time assertion: `MethodCacheTarget: Copy` is the whole
1844        // point of this peek path — if a future variant adds a non-Copy
1845        // field (eg. an `Arc<str>` for a dynamic method name), the static
1846        // check below will fail at compile time before the dispatcher
1847        // silently regresses to the heavy `InlineCacheEntry::clone` path.
1848        fn assert_copy<T: Copy>() {}
1849        assert_copy::<super::MethodCacheTarget>();
1850    }
1851
1852    // --- peek_property_cache contract ---
1853    //
1854    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
1855    // property-read path (`Op::GetProperty` / `Op::GetPropertyOpt`). It
1856    // must return None for unrelated IC variants — silently mis-extracting
1857    // a `Method` / `DirectCall` / `AdaptiveBinary` slot as `Property` would
1858    // feed garbage into `try_cached_property` (wrong target match, possibly
1859    // a panic on the field-name lookup). These tests pin the variant gate.
1860
1861    #[test]
1862    fn peek_property_cache_returns_none_for_empty_slot() {
1863        let mut chunk = Chunk::new();
1864        chunk.emit_u16(Op::GetProperty, 0, 1);
1865        let slot = chunk
1866            .inline_cache_slot(0)
1867            .expect("GetProperty registers a slot");
1868        assert!(chunk.peek_property_cache(slot).is_none());
1869    }
1870
1871    #[test]
1872    fn peek_property_cache_returns_pair_after_warmup_for_dict_field() {
1873        let mut chunk = Chunk::new();
1874        chunk.emit_u16(Op::GetProperty, 0, 1);
1875        let slot = chunk
1876            .inline_cache_slot(0)
1877            .expect("GetProperty registers a slot");
1878        chunk.set_inline_cache_entry(
1879            slot,
1880            InlineCacheEntry::Property {
1881                name_idx: 11,
1882                target: PropertyCacheTarget::DictField(Arc::from("count")),
1883            },
1884        );
1885        let (name_idx, target) = chunk
1886            .peek_property_cache(slot)
1887            .expect("warmed property slot peek");
1888        assert_eq!(name_idx, 11);
1889        match target {
1890            PropertyCacheTarget::DictField(field) => assert_eq!(field.as_ref(), "count"),
1891            other => panic!("expected DictField, got {other:?}"),
1892        }
1893    }
1894
1895    #[test]
1896    fn peek_property_cache_returns_pair_for_unit_target() {
1897        // Unit targets (eg. ListCount, ListEmpty, PairFirst) carry no Arc,
1898        // so the cloned PropertyCacheTarget is a pure scalar move at the
1899        // peek boundary. The hottest case in practice.
1900        let mut chunk = Chunk::new();
1901        chunk.emit_u16(Op::GetProperty, 0, 1);
1902        let slot = chunk
1903            .inline_cache_slot(0)
1904            .expect("GetProperty registers a slot");
1905        chunk.set_inline_cache_entry(
1906            slot,
1907            InlineCacheEntry::Property {
1908                name_idx: 3,
1909                target: PropertyCacheTarget::ListCount,
1910            },
1911        );
1912        let (name_idx, target) = chunk
1913            .peek_property_cache(slot)
1914            .expect("warmed property slot peek");
1915        assert_eq!(name_idx, 3);
1916        assert_eq!(target, PropertyCacheTarget::ListCount);
1917    }
1918
1919    #[test]
1920    fn peek_property_cache_returns_none_for_non_property_variants() {
1921        let mut chunk = Chunk::new();
1922        chunk.emit_u16(Op::GetProperty, 0, 1);
1923        let slot = chunk
1924            .inline_cache_slot(0)
1925            .expect("GetProperty registers a slot");
1926        chunk.set_inline_cache_entry(
1927            slot,
1928            InlineCacheEntry::Method {
1929                name_idx: 0,
1930                argc: 0,
1931                target: MethodCacheTarget::ListCount,
1932            },
1933        );
1934        assert!(chunk.peek_property_cache(slot).is_none());
1935    }
1936
1937    #[test]
1938    fn peek_property_cache_returns_none_for_out_of_bounds_slot() {
1939        let chunk = Chunk::new();
1940        assert!(chunk.peek_property_cache(0).is_none());
1941        assert!(chunk.peek_property_cache(usize::MAX).is_none());
1942    }
1943
1944    // --- peek_direct_call_state contract ---
1945    //
1946    // Used on both the hot Specialized-hit check path (`try_cached_direct_call`
1947    // / `try_cached_named_direct_call`) and the state-machine write-back
1948    // (`next_direct_call_entry`). Returning None for the non-DirectCall slot
1949    // shapes is critical: a mis-extracted Method/Property/AdaptiveBinary slot
1950    // would have the dispatcher attempt a closure call with the wrong argc
1951    // or Arc::ptr_eq against an unrelated closure.
1952
1953    #[test]
1954    fn peek_direct_call_state_returns_none_for_empty_slot() {
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        assert!(chunk.peek_direct_call_state(slot).is_none());
1961    }
1962
1963    #[test]
1964    fn peek_direct_call_state_returns_warmup_state() {
1965        let mut chunk = Chunk::new();
1966        chunk.emit_u8(Op::Call, 0, 1);
1967        let slot = chunk
1968            .inline_cache_slot(0)
1969            .expect("Op::Call registers a slot");
1970        let target = synthetic_direct_call_target();
1971        chunk.set_inline_cache_entry(
1972            slot,
1973            InlineCacheEntry::DirectCall {
1974                state: DirectCallState::Warmup {
1975                    argc: 2,
1976                    target: target.clone(),
1977                    hits: 1,
1978                },
1979            },
1980        );
1981        let state = chunk
1982            .peek_direct_call_state(slot)
1983            .expect("warmed direct-call slot peek");
1984        match state {
1985            DirectCallState::Warmup {
1986                argc,
1987                target: peeked_target,
1988                hits,
1989            } => {
1990                assert_eq!(argc, 2);
1991                assert_eq!(hits, 1);
1992                assert_eq!(peeked_target, target);
1993            }
1994            other => panic!("expected Warmup, got {other:?}"),
1995        }
1996    }
1997
1998    #[test]
1999    fn peek_direct_call_state_returns_specialized_state() {
2000        let mut chunk = Chunk::new();
2001        chunk.emit_u8(Op::Call, 0, 1);
2002        let slot = chunk
2003            .inline_cache_slot(0)
2004            .expect("Op::Call registers a slot");
2005        let target = synthetic_direct_call_target();
2006        chunk.set_inline_cache_entry(
2007            slot,
2008            InlineCacheEntry::DirectCall {
2009                state: DirectCallState::Specialized {
2010                    argc: 3,
2011                    target: target.clone(),
2012                    hits: 100,
2013                    misses: 0,
2014                },
2015            },
2016        );
2017        let state = chunk
2018            .peek_direct_call_state(slot)
2019            .expect("warmed direct-call slot peek");
2020        match state {
2021            DirectCallState::Specialized {
2022                argc,
2023                target: peeked_target,
2024                hits,
2025                misses,
2026            } => {
2027                assert_eq!(argc, 3);
2028                assert_eq!(hits, 100);
2029                assert_eq!(misses, 0);
2030                assert_eq!(peeked_target, target);
2031            }
2032            other => panic!("expected Specialized, got {other:?}"),
2033        }
2034    }
2035
2036    #[test]
2037    fn peek_direct_call_state_returns_none_for_non_direct_call_variants() {
2038        let mut chunk = Chunk::new();
2039        chunk.emit_u8(Op::Call, 0, 1);
2040        let slot = chunk
2041            .inline_cache_slot(0)
2042            .expect("Op::Call registers a slot");
2043
2044        chunk.set_inline_cache_entry(
2045            slot,
2046            InlineCacheEntry::Property {
2047                name_idx: 0,
2048                target: PropertyCacheTarget::ListCount,
2049            },
2050        );
2051        assert!(chunk.peek_direct_call_state(slot).is_none());
2052    }
2053
2054    #[test]
2055    fn peek_direct_call_state_returns_none_for_out_of_bounds_slot() {
2056        let chunk = Chunk::new();
2057        assert!(chunk.peek_direct_call_state(0).is_none());
2058        assert!(chunk.peek_direct_call_state(usize::MAX).is_none());
2059    }
2060
2061    /// Build a synthetic `DirectCallTarget::Closure` for direct-call peek
2062    /// tests. The closure has an empty body — the IC peek only inspects
2063    /// the wrapping `Arc`, not the closure internals.
2064    fn synthetic_direct_call_target() -> DirectCallTarget {
2065        use crate::value::VmClosure;
2066        use crate::{CompiledFunction, VmEnv};
2067        let func = CompiledFunction {
2068            name: "synthetic".to_string(),
2069            type_params: Vec::new(),
2070            nominal_type_names: Vec::new(),
2071            params: Vec::new(),
2072            default_start: None,
2073            chunk: Arc::new(Chunk::new()),
2074            is_generator: false,
2075            is_stream: false,
2076            has_rest_param: false,
2077            has_runtime_type_checks: false,
2078        };
2079        DirectCallTarget::Closure(Arc::new(VmClosure {
2080            func: Arc::new(func),
2081            env: VmEnv::new(),
2082            source_dir: None,
2083            module_functions: None,
2084            module_state: None,
2085        }))
2086    }
2087}