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