Skip to main content

harn_vm/
chunk.rs

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