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