Skip to main content

harn_vm/
chunk.rs

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