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.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[repr(u8)]
23pub enum Op {
24    /// Push a constant from the constant pool onto the stack.
25    Constant, // arg: u16 constant index
26    /// Push nil onto the stack.
27    Nil,
28    /// Push true onto the stack.
29    True,
30    /// Push false onto the stack.
31    False,
32
33    // --- Variable operations ---
34    /// Get a variable by name (from constant pool).
35    GetVar, // arg: u16 constant index (name)
36    /// Define a new immutable variable. Pops value from stack.
37    DefLet, // arg: u16 constant index (name)
38    /// Define a new mutable variable. Pops value from stack.
39    DefVar, // arg: u16 constant index (name)
40    /// Assign to an existing mutable variable. Pops value from stack.
41    SetVar, // arg: u16 constant index (name)
42    /// Push a new lexical scope onto the environment stack.
43    PushScope,
44    /// Pop the current lexical scope from the environment stack.
45    PopScope,
46
47    // --- Arithmetic ---
48    Add,
49    Sub,
50    Mul,
51    Div,
52    Mod,
53    Pow,
54    Negate,
55
56    // --- Comparison ---
57    Equal,
58    NotEqual,
59    Less,
60    Greater,
61    LessEqual,
62    GreaterEqual,
63
64    // --- Logical ---
65    Not,
66
67    // --- Control flow ---
68    /// Jump unconditionally. arg: u16 offset.
69    Jump,
70    /// Jump if top of stack is falsy. Does not pop. arg: u16 offset.
71    JumpIfFalse,
72    /// Jump if top of stack is truthy. Does not pop. arg: u16 offset.
73    JumpIfTrue,
74    /// Pop top of stack (discard).
75    Pop,
76
77    // --- Functions ---
78    /// Call a function/builtin. arg: u8 = arg count. Name is on stack below args.
79    Call,
80    /// Tail call: like Call, but replaces the current frame instead of pushing
81    /// a new one. Used for `return f(x)` to enable tail call optimization.
82    /// For builtins, behaves like a regular Call (no frame to replace).
83    TailCall,
84    /// Return from current function. Pops return value.
85    Return,
86    /// Create a closure. arg: u16 = chunk index in function table.
87    Closure,
88
89    // --- Collections ---
90    /// Build a list. arg: u16 = element count. Elements are on stack.
91    BuildList,
92    /// Build a dict. arg: u16 = entry count. Key-value pairs on stack.
93    BuildDict,
94    /// Subscript access: stack has [object, index]. Pushes result.
95    Subscript,
96    /// Optional subscript (`obj?[index]`). Like `Subscript` but pushes nil
97    /// instead of indexing when the object is nil.
98    SubscriptOpt,
99    /// Slice access: stack has [object, start_or_nil, end_or_nil]. Pushes sublist/substring.
100    Slice,
101
102    // --- Object operations ---
103    /// Property access. arg: u16 = constant index (property name).
104    GetProperty,
105    /// Optional property access (?.). Like GetProperty but returns nil
106    /// instead of erroring when the object is nil. arg: u16 = constant index.
107    GetPropertyOpt,
108    /// Property assignment. arg: u16 = constant index (property name).
109    /// Stack: [value] → assigns to the named variable's property.
110    SetProperty,
111    /// Subscript assignment. arg: u16 = constant index (variable name).
112    /// Stack: [index, value] → assigns to variable[index] = value.
113    SetSubscript,
114    /// Method call. arg1: u16 = constant index (method name), arg2: u8 = arg count.
115    MethodCall,
116    /// Optional method call (?.). Like MethodCall but returns nil if the
117    /// receiver is nil instead of dispatching. arg1: u16, arg2: u8.
118    MethodCallOpt,
119
120    // --- String ---
121    /// String concatenation of N parts. arg: u16 = part count.
122    Concat,
123
124    // --- Iteration ---
125    /// Set up a for-in loop. Expects iterable on stack. Pushes iterator state.
126    IterInit,
127    /// Advance iterator. If exhausted, jumps. arg: u16 = jump offset.
128    /// Pushes next value and the variable name is set via DefVar before the loop.
129    IterNext,
130
131    // --- Pipe ---
132    /// Pipe: pops [value, callable], invokes callable(value).
133    Pipe,
134
135    // --- Error handling ---
136    /// Pop value, raise as error.
137    Throw,
138    /// Push exception handler. arg: u16 = offset to catch handler.
139    TryCatchSetup,
140    /// Remove top exception handler (end of try body).
141    PopHandler,
142
143    // --- Concurrency ---
144    /// Execute closure N times sequentially, push results as list.
145    /// Stack: count, closure → result_list
146    Parallel,
147    /// Execute closure for each item in list, push results as list.
148    /// Stack: list, closure → result_list
149    ParallelMap,
150    /// Execute closure for each item in list, push a stream that emits in completion order.
151    /// Stack: list, closure → stream
152    ParallelMapStream,
153    /// Like ParallelMap but wraps each result in Result.Ok/Err, never fails.
154    /// Stack: list, closure → {results: [Result], succeeded: int, failed: int}
155    ParallelSettle,
156    /// Store closure for deferred execution, push TaskHandle.
157    /// Stack: closure → TaskHandle
158    Spawn,
159    /// Acquire a process-local mutex for the current lexical scope.
160    /// arg: u16 constant index (key string).
161    SyncMutexEnter,
162
163    // --- Imports ---
164    /// Import a file. arg: u16 = constant index (path string).
165    Import,
166    /// Selective import. arg1: u16 = path string, arg2: u16 = names list constant.
167    SelectiveImport,
168
169    // --- Deadline ---
170    /// Pop duration value, push deadline onto internal deadline stack.
171    DeadlineSetup,
172    /// Pop deadline from internal deadline stack.
173    DeadlineEnd,
174
175    // --- Enum ---
176    /// Build an enum variant value.
177    /// arg1: u16 = constant index (enum name), arg2: u16 = constant index (variant name),
178    /// arg3: u16 = field count. Fields are on stack.
179    BuildEnum,
180
181    // --- Match ---
182    /// Match an enum pattern. Checks enum_name + variant on the top of stack (dup'd match value).
183    /// arg1: u16 = constant index (enum name), arg2: u16 = constant index (variant name).
184    /// If match succeeds, pushes true; else pushes false.
185    MatchEnum,
186
187    // --- Loop control ---
188    /// Pop the top iterator from the iterator stack (cleanup on break from for-in).
189    PopIterator,
190
191    // --- Defaults ---
192    /// Push the number of arguments passed to the current function call.
193    GetArgc,
194
195    // --- Type checking ---
196    /// Runtime type check on a variable.
197    /// arg1: u16 = constant index (variable name),
198    /// arg2: u16 = constant index (expected type name).
199    /// Throws a TypeError if the variable's type doesn't match.
200    CheckType,
201
202    // --- Result try operator ---
203    /// Try-unwrap: if top is Result.Ok(v), replace with v. If Result.Err(e), return it.
204    TryUnwrap,
205    /// Wrap top of stack in Result.Ok unless it is already a Result.
206    TryWrapOk,
207
208    // --- Spread call ---
209    /// Call with spread arguments. Stack: [callee, args_list] -> result.
210    CallSpread,
211    /// Direct builtin call. Followed by u64 builtin ID, u16 name constant, u8 arg count.
212    /// Runtime still checks closure shadowing before using the ID.
213    CallBuiltin,
214    /// Direct builtin spread call. Followed by u64 builtin ID and u16 name constant.
215    /// Stack: [args_list] -> result.
216    CallBuiltinSpread,
217    /// Method call with spread arguments. Stack: [object, args_list] -> result.
218    /// Followed by 2 bytes for method name constant index.
219    MethodCallSpread,
220
221    // --- Misc ---
222    /// Duplicate top of stack.
223    Dup,
224    /// Swap top two stack values.
225    Swap,
226    /// Membership test: stack has [item, collection]. Pushes bool.
227    /// Works for lists (item in list), dicts (key in dict), strings (substr in string), and sets.
228    Contains,
229
230    // --- Typed arithmetic/comparison fast paths ---
231    AddInt,
232    SubInt,
233    MulInt,
234    DivInt,
235    ModInt,
236    AddFloat,
237    SubFloat,
238    MulFloat,
239    DivFloat,
240    ModFloat,
241    EqualInt,
242    NotEqualInt,
243    LessInt,
244    GreaterInt,
245    LessEqualInt,
246    GreaterEqualInt,
247    EqualFloat,
248    NotEqualFloat,
249    LessFloat,
250    GreaterFloat,
251    LessEqualFloat,
252    GreaterEqualFloat,
253    EqualBool,
254    NotEqualBool,
255    EqualString,
256    NotEqualString,
257
258    /// Yield a value from a generator. Pops value, sends through channel, suspends.
259    Yield,
260
261    // --- Slot-indexed locals ---
262    /// Get a frame-local slot. arg: u16 slot index.
263    GetLocalSlot,
264    /// Define or initialize a frame-local slot. Pops value from stack.
265    DefLocalSlot,
266    /// Assign an existing frame-local slot. Pops value from stack.
267    SetLocalSlot,
268}
269
270impl Op {
271    pub(crate) const ALL: &'static [Self] = &[
272        Op::Constant,
273        Op::Nil,
274        Op::True,
275        Op::False,
276        Op::GetVar,
277        Op::DefLet,
278        Op::DefVar,
279        Op::SetVar,
280        Op::PushScope,
281        Op::PopScope,
282        Op::Add,
283        Op::Sub,
284        Op::Mul,
285        Op::Div,
286        Op::Mod,
287        Op::Pow,
288        Op::Negate,
289        Op::Equal,
290        Op::NotEqual,
291        Op::Less,
292        Op::Greater,
293        Op::LessEqual,
294        Op::GreaterEqual,
295        Op::Not,
296        Op::Jump,
297        Op::JumpIfFalse,
298        Op::JumpIfTrue,
299        Op::Pop,
300        Op::Call,
301        Op::TailCall,
302        Op::Return,
303        Op::Closure,
304        Op::BuildList,
305        Op::BuildDict,
306        Op::Subscript,
307        Op::SubscriptOpt,
308        Op::Slice,
309        Op::GetProperty,
310        Op::GetPropertyOpt,
311        Op::SetProperty,
312        Op::SetSubscript,
313        Op::MethodCall,
314        Op::MethodCallOpt,
315        Op::Concat,
316        Op::IterInit,
317        Op::IterNext,
318        Op::Pipe,
319        Op::Throw,
320        Op::TryCatchSetup,
321        Op::PopHandler,
322        Op::Parallel,
323        Op::ParallelMap,
324        Op::ParallelMapStream,
325        Op::ParallelSettle,
326        Op::Spawn,
327        Op::SyncMutexEnter,
328        Op::Import,
329        Op::SelectiveImport,
330        Op::DeadlineSetup,
331        Op::DeadlineEnd,
332        Op::BuildEnum,
333        Op::MatchEnum,
334        Op::PopIterator,
335        Op::GetArgc,
336        Op::CheckType,
337        Op::TryUnwrap,
338        Op::TryWrapOk,
339        Op::CallSpread,
340        Op::CallBuiltin,
341        Op::CallBuiltinSpread,
342        Op::MethodCallSpread,
343        Op::Dup,
344        Op::Swap,
345        Op::Contains,
346        Op::AddInt,
347        Op::SubInt,
348        Op::MulInt,
349        Op::DivInt,
350        Op::ModInt,
351        Op::AddFloat,
352        Op::SubFloat,
353        Op::MulFloat,
354        Op::DivFloat,
355        Op::ModFloat,
356        Op::EqualInt,
357        Op::NotEqualInt,
358        Op::LessInt,
359        Op::GreaterInt,
360        Op::LessEqualInt,
361        Op::GreaterEqualInt,
362        Op::EqualFloat,
363        Op::NotEqualFloat,
364        Op::LessFloat,
365        Op::GreaterFloat,
366        Op::LessEqualFloat,
367        Op::GreaterEqualFloat,
368        Op::EqualBool,
369        Op::NotEqualBool,
370        Op::EqualString,
371        Op::NotEqualString,
372        Op::Yield,
373        Op::GetLocalSlot,
374        Op::DefLocalSlot,
375        Op::SetLocalSlot,
376    ];
377
378    pub(crate) fn from_byte(byte: u8) -> Option<Self> {
379        Self::ALL.get(byte as usize).copied()
380    }
381}
382
383/// A constant value in the constant pool.
384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
385pub enum Constant {
386    Int(i64),
387    Float(f64),
388    String(String),
389    Bool(bool),
390    Nil,
391    Duration(i64),
392}
393
394/// Runtime-only inline-cache state for bytecode instructions that repeatedly
395/// see the same dynamic shape. Lookup caches stay monomorphic on a name and
396/// receiver shape. Adaptive caches warm on a stable operand or call target,
397/// then fall back through the generic opcode and replace or reset state when
398/// the observed shape changes.
399///
400/// This vector is intentionally excluded from [`CachedChunk`]: bytecode cache
401/// artifacts keep the slot layout but start with empty runtime feedback in each
402/// process.
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub(crate) enum InlineCacheEntry {
405    Empty,
406    Property {
407        name_idx: u16,
408        target: PropertyCacheTarget,
409    },
410    Method {
411        name_idx: u16,
412        argc: usize,
413        target: MethodCacheTarget,
414    },
415    AdaptiveBinary {
416        op: AdaptiveBinaryOp,
417        state: AdaptiveBinaryState,
418    },
419    DirectCall {
420        state: DirectCallState,
421    },
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425pub(crate) enum AdaptiveBinaryOp {
426    Add,
427    Sub,
428    Mul,
429    Div,
430    Mod,
431    Equal,
432    NotEqual,
433    Less,
434    Greater,
435    LessEqual,
436    GreaterEqual,
437}
438
439/// Adaptive-binary IC state. All fields are scalar `Copy` (shape is a
440/// `Copy` enum, hit/miss counters are integers), so the struct as a whole
441/// is `Copy`. This lets `execute_adaptive_binary` extract the cached state
442/// by value for the specialization check without cloning the wrapping
443/// `InlineCacheEntry` on every dispatch — the previous shape held
444/// `Clone-only` state via the outer enum and forced a 24-32B memcpy on
445/// every Add/Sub/Mul/Div/Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq op,
446/// which is the hottest opcode class in the dispatch loop.
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub(crate) enum AdaptiveBinaryState {
449    Warmup {
450        shape: BinaryShape,
451        hits: u8,
452    },
453    Specialized {
454        shape: BinaryShape,
455        hits: u64,
456        misses: u64,
457    },
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461pub(crate) enum BinaryShape {
462    Int,
463    Float,
464    Bool,
465    String,
466}
467
468#[derive(Debug, Clone)]
469pub(crate) enum DirectCallState {
470    Warmup {
471        argc: usize,
472        target: DirectCallTarget,
473        hits: u8,
474    },
475    Specialized {
476        argc: usize,
477        target: DirectCallTarget,
478        hits: u64,
479        misses: u64,
480    },
481}
482
483#[derive(Debug, Clone)]
484pub(crate) enum DirectCallTarget {
485    Closure(Rc<crate::value::VmClosure>),
486}
487
488impl PartialEq for DirectCallTarget {
489    fn eq(&self, other: &Self) -> bool {
490        match (self, other) {
491            (Self::Closure(left), Self::Closure(right)) => Rc::ptr_eq(left, right),
492        }
493    }
494}
495
496impl Eq for DirectCallTarget {}
497
498impl PartialEq for DirectCallState {
499    fn eq(&self, other: &Self) -> bool {
500        match (self, other) {
501            (
502                Self::Warmup {
503                    argc: left_argc,
504                    target: left_target,
505                    hits: left_hits,
506                },
507                Self::Warmup {
508                    argc: right_argc,
509                    target: right_target,
510                    hits: right_hits,
511                },
512            ) => left_argc == right_argc && left_target == right_target && left_hits == right_hits,
513            (
514                Self::Specialized {
515                    argc: left_argc,
516                    target: left_target,
517                    hits: left_hits,
518                    misses: left_misses,
519                },
520                Self::Specialized {
521                    argc: right_argc,
522                    target: right_target,
523                    hits: right_hits,
524                    misses: right_misses,
525                },
526            ) => {
527                left_argc == right_argc
528                    && left_target == right_target
529                    && left_hits == right_hits
530                    && left_misses == right_misses
531            }
532            _ => false,
533        }
534    }
535}
536
537impl Eq for DirectCallState {}
538
539#[derive(Debug, Clone, PartialEq, Eq)]
540pub(crate) enum PropertyCacheTarget {
541    DictField(Rc<str>),
542    StructField { field_name: Rc<str>, index: usize },
543    ListCount,
544    ListEmpty,
545    ListFirst,
546    ListLast,
547    StringCount,
548    StringEmpty,
549    PairFirst,
550    PairSecond,
551    EnumVariant,
552    EnumFields,
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq)]
556pub(crate) enum MethodCacheTarget {
557    ListCount,
558    ListEmpty,
559    ListContains,
560    StringCount,
561    StringEmpty,
562    StringContains,
563    DictCount,
564    DictHas,
565    RangeCount,
566    RangeLen,
567    RangeEmpty,
568    RangeFirst,
569    RangeLast,
570    SetCount,
571    SetLen,
572    SetEmpty,
573    SetContains,
574}
575
576/// Debug metadata for a slot-indexed local in a compiled chunk.
577#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
578pub struct LocalSlotInfo {
579    pub name: String,
580    pub mutable: bool,
581    pub scope_depth: usize,
582}
583
584impl fmt::Display for Constant {
585    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
586        match self {
587            Constant::Int(n) => write!(f, "{n}"),
588            Constant::Float(n) => write!(f, "{n}"),
589            Constant::String(s) => write!(f, "\"{s}\""),
590            Constant::Bool(b) => write!(f, "{b}"),
591            Constant::Nil => write!(f, "nil"),
592            Constant::Duration(ms) => write!(f, "{ms}ms"),
593        }
594    }
595}
596
597/// A compiled chunk of bytecode.
598#[derive(Debug, Clone)]
599pub struct Chunk {
600    /// The bytecode instructions.
601    pub code: Vec<u8>,
602    /// Constant pool.
603    pub constants: Vec<Constant>,
604    /// Source line numbers for each instruction (for error reporting).
605    pub lines: Vec<u32>,
606    /// Source column numbers for each instruction (for error reporting).
607    /// Parallel to `lines`; 0 means no column info available.
608    pub columns: Vec<u32>,
609    /// Source file that this chunk was compiled from, when known. Set for
610    /// chunks compiled from imported modules so runtime errors can report
611    /// the correct file path for each frame instead of always pointing at
612    /// the entry-point pipeline.
613    pub source_file: Option<String>,
614    /// Current column to use when emitting instructions (set by compiler).
615    current_col: u32,
616    /// Compiled function bodies (for closures).
617    pub functions: Vec<CompiledFunctionRef>,
618    /// Instruction offset to inline-cache slot. Slots are assigned at emit time
619    /// for cacheable instructions while bytecode bytes remain immutable.
620    /// Preserved as the serialization-stable representation that round-trips
621    /// through [`CachedChunk`]; the runtime hot path reads
622    /// [`Chunk::inline_cache_index`] instead.
623    inline_cache_slots: BTreeMap<usize, usize>,
624    /// Flat side-table indexed by code offset that returns the inline-cache
625    /// slot index (or [`NO_INLINE_CACHE_SLOT`] for "no slot at this offset").
626    /// Built alongside [`Chunk::inline_cache_slots`] at emit/load time so the
627    /// per-dispatch lookup that fires on every adaptive binary op, `Op::Call`,
628    /// `Op::MethodCall`, and `Op::GetProperty` is one cache-friendly `Vec`
629    /// index instead of a `BTreeMap::get` (O(1) vs O(log n) with the
630    /// associated pointer chasing). Derived; intentionally not serialized.
631    inline_cache_index: Vec<u32>,
632    /// Shared cache entries so cloned chunks in call frames warm the same side
633    /// table as the compiled chunk used by tests/debugging.
634    inline_caches: Rc<RefCell<Vec<InlineCacheEntry>>>,
635    /// Lazily-materialized `Rc<str>` cache for `Constant::String` entries,
636    /// parallel to `constants`. `Op::Constant` for a string used to run
637    /// `Rc::from(s.as_str())` on every execution, allocating a fresh
638    /// `Rc<str>` per push — death by a thousand allocations for
639    /// string-interpolation-heavy hot paths. With this side table the
640    /// allocation happens once per unique constant; subsequent pushes
641    /// are an Rc refcount bump.
642    constant_strings: Rc<RefCell<Vec<Option<Rc<str>>>>>,
643    /// Source-name metadata for slot-indexed locals in this chunk.
644    pub(crate) local_slots: Vec<LocalSlotInfo>,
645    /// True when this chunk's bytecode emits an opcode that resolves a
646    /// name through the runtime env (`GetVar`, `SetVar`, `CallBuiltin`,
647    /// `CallBuiltinSpread`, `CheckType`). The closure-call hot path uses
648    /// this as a cheap static guard: if a closure body never reads
649    /// outer names by name, the caller-scope late-bind walks in
650    /// [`Vm::closure_call_env`] and
651    /// [`Vm::closure_call_env_for_current_frame`] are pure overhead and
652    /// can be skipped, leaving the closure's captured env as-is.
653    ///
654    /// Walks exist to inject late-bound closure-typed names — typically
655    /// for self/mutually-recursive local fns and for fns whose captured
656    /// env predates a sibling definition. Inline arithmetic / comparison
657    /// callbacks (the `.map(x -> x * 2)` / `.filter(x -> x % 2 == 0)`
658    /// shape) emit none of the flagged opcodes, so the walk is wasted
659    /// work on every invocation.
660    pub(crate) references_outer_names: bool,
661}
662
663pub type ChunkRef = Rc<Chunk>;
664pub type CompiledFunctionRef = Rc<CompiledFunction>;
665
666/// Serializable snapshot of a [`Chunk`] suitable for the on-disk bytecode
667/// cache and for in-memory stdlib artifact caches. Inline-cache state is
668/// dropped at freeze time because it warms at runtime per-process; the
669/// rest of the chunk round-trips byte-identically.
670#[derive(Debug, Clone, Serialize, Deserialize)]
671pub struct CachedChunk {
672    pub(crate) code: Vec<u8>,
673    pub(crate) constants: Vec<Constant>,
674    pub(crate) lines: Vec<u32>,
675    pub(crate) columns: Vec<u32>,
676    pub(crate) source_file: Option<String>,
677    pub(crate) current_col: u32,
678    pub(crate) functions: Vec<CachedCompiledFunction>,
679    pub(crate) inline_cache_slots: BTreeMap<usize, usize>,
680    pub(crate) local_slots: Vec<LocalSlotInfo>,
681    #[serde(default)]
682    pub(crate) references_outer_names: bool,
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize)]
686pub struct CachedCompiledFunction {
687    pub(crate) name: String,
688    pub(crate) type_params: Vec<String>,
689    pub(crate) nominal_type_names: Vec<String>,
690    pub(crate) params: Vec<CachedParamSlot>,
691    pub(crate) default_start: Option<usize>,
692    pub(crate) chunk: CachedChunk,
693    pub(crate) is_generator: bool,
694    pub(crate) is_stream: bool,
695    pub(crate) has_rest_param: bool,
696    pub(crate) has_runtime_type_checks: bool,
697}
698
699#[derive(Debug, Clone, Serialize, Deserialize)]
700pub(crate) struct CachedParamSlot {
701    pub(crate) name: String,
702    pub(crate) type_expr: Option<TypeExpr>,
703    pub(crate) has_default: bool,
704}
705
706impl CachedParamSlot {
707    fn thaw(&self) -> ParamSlot {
708        ParamSlot {
709            name: self.name.clone(),
710            type_expr: self.type_expr.clone(),
711            runtime_guard: self
712                .type_expr
713                .as_ref()
714                .map(RuntimeParamGuard::from_type_expr),
715            has_default: self.has_default,
716        }
717    }
718}
719
720/// One parameter slot of a compiled user-defined function. Carries the
721/// declared name, the (optional) declared type expression, and a flag
722/// for whether a default value was provided. The runtime consults the
723/// type expression in `bind_param_slots` to enforce declared types
724/// against the values supplied at the call site.
725#[derive(Debug, Clone, Serialize, Deserialize)]
726pub struct ParamSlot {
727    pub name: String,
728    /// Declared parameter type. `None` for untyped parameters (gradual
729    /// typing); the runtime skips type assertion when absent.
730    pub type_expr: Option<TypeExpr>,
731    /// Precomputed runtime validation metadata derived from `type_expr`.
732    /// Bytecode-cache artifacts omit this field and rebuild it at load time.
733    #[serde(skip)]
734    pub(crate) runtime_guard: Option<RuntimeParamGuard>,
735    /// True when the parameter has a default-value clause. Diagnostic
736    /// only — the canonical authority for arity ranges is
737    /// [`CompiledFunction::default_start`].
738    pub has_default: bool,
739}
740
741impl ParamSlot {
742    /// Build a [`ParamSlot`] from a parser-side [`harn_parser::TypedParam`].
743    /// Centralizes the conversion so every compile path stays in lockstep.
744    pub fn from_typed_param(param: &harn_parser::TypedParam) -> Self {
745        Self {
746            name: param.name.clone(),
747            type_expr: param.type_expr.clone(),
748            runtime_guard: param
749                .type_expr
750                .as_ref()
751                .map(RuntimeParamGuard::from_type_expr),
752            has_default: param.default_value.is_some(),
753        }
754    }
755
756    fn freeze_for_cache(&self) -> CachedParamSlot {
757        CachedParamSlot {
758            name: self.name.clone(),
759            type_expr: self.type_expr.clone(),
760            has_default: self.has_default,
761        }
762    }
763
764    /// Build a `Vec<ParamSlot>` from a slice of parser-side typed
765    /// parameters. Used pervasively at compile sites instead of
766    /// `TypedParam::names` (which discarded the type info we now need
767    /// at runtime).
768    pub fn vec_from_typed(params: &[harn_parser::TypedParam]) -> Vec<Self> {
769        params.iter().map(Self::from_typed_param).collect()
770    }
771}
772
773/// A compiled function (closure body).
774#[derive(Debug, Clone)]
775pub struct CompiledFunction {
776    pub name: String,
777    /// Generic type parameters declared by this function. Runtime
778    /// validation treats these as static-only constraints because the VM
779    /// does not monomorphize function bodies.
780    pub type_params: Vec<String>,
781    /// User-defined struct and enum names visible when this function was
782    /// compiled. These are the only non-primitive named types with runtime
783    /// nominal identity; aliases and interfaces remain static-only.
784    pub nominal_type_names: Vec<String>,
785    pub params: Vec<ParamSlot>,
786    /// Index of the first parameter with a default value, or None if all required.
787    pub default_start: Option<usize>,
788    pub chunk: ChunkRef,
789    /// True if the function body contains `yield` expressions (generator function).
790    pub is_generator: bool,
791    /// True if the function was declared as `gen fn` and should return Stream.
792    pub is_stream: bool,
793    /// True if the last parameter is a rest parameter (`...name`).
794    pub has_rest_param: bool,
795    /// True when at least one parameter has a runtime-visible type
796    /// assertion. Untyped closures dominate collection callback hot paths,
797    /// so this lets the VM skip the per-argument metadata walk after the
798    /// arity check.
799    pub has_runtime_type_checks: bool,
800}
801
802impl CompiledFunction {
803    pub(crate) fn has_runtime_type_checks_for_params(params: &[ParamSlot]) -> bool {
804        params.iter().any(|param| param.type_expr.is_some())
805    }
806
807    /// Returns just the parameter names — convenience for code paths that
808    /// don't care about types or defaults.
809    pub fn param_names(&self) -> impl Iterator<Item = &str> {
810        self.params.iter().map(|p| p.name.as_str())
811    }
812
813    /// Number of required parameters (those before `default_start`).
814    pub fn required_param_count(&self) -> usize {
815        self.default_start.unwrap_or(self.params.len())
816    }
817
818    pub fn declares_type_param(&self, name: &str) -> bool {
819        self.type_params.iter().any(|param| param == name)
820    }
821
822    pub fn has_nominal_type(&self, name: &str) -> bool {
823        self.nominal_type_names.iter().any(|ty| ty == name)
824    }
825
826    pub(crate) fn freeze_for_cache(&self) -> CachedCompiledFunction {
827        CachedCompiledFunction {
828            name: self.name.clone(),
829            type_params: self.type_params.clone(),
830            nominal_type_names: self.nominal_type_names.clone(),
831            params: self
832                .params
833                .iter()
834                .map(ParamSlot::freeze_for_cache)
835                .collect(),
836            default_start: self.default_start,
837            chunk: self.chunk.freeze_for_cache(),
838            is_generator: self.is_generator,
839            is_stream: self.is_stream,
840            has_rest_param: self.has_rest_param,
841            has_runtime_type_checks: self.has_runtime_type_checks,
842        }
843    }
844
845    pub(crate) fn from_cached(cached: &CachedCompiledFunction) -> Self {
846        Self {
847            name: cached.name.clone(),
848            type_params: cached.type_params.clone(),
849            nominal_type_names: cached.nominal_type_names.clone(),
850            params: cached.params.iter().map(CachedParamSlot::thaw).collect(),
851            default_start: cached.default_start,
852            chunk: Rc::new(Chunk::from_cached(&cached.chunk)),
853            is_generator: cached.is_generator,
854            is_stream: cached.is_stream,
855            has_rest_param: cached.has_rest_param,
856            has_runtime_type_checks: cached.has_runtime_type_checks,
857        }
858    }
859}
860
861impl Chunk {
862    pub fn new() -> Self {
863        Self {
864            code: Vec::new(),
865            constants: Vec::new(),
866            lines: Vec::new(),
867            columns: Vec::new(),
868            source_file: None,
869            current_col: 0,
870            functions: Vec::new(),
871            inline_cache_slots: BTreeMap::new(),
872            inline_cache_index: Vec::new(),
873            inline_caches: Rc::new(RefCell::new(Vec::new())),
874            constant_strings: Rc::new(RefCell::new(Vec::new())),
875            local_slots: Vec::new(),
876            references_outer_names: false,
877        }
878    }
879
880    /// Opcodes that perform a runtime env-based name lookup or
881    /// assignment. Emitting any of these marks the chunk as needing the
882    /// caller-scope late-bind walk in [`Vm::closure_call_env`].
883    ///
884    /// `Op::Call` / `Op::TailCall` / `Op::Pipe` make the list because
885    /// the compiler emits `Op::Constant("name") + Op::TailCall` for
886    /// `return fn_name(...)` (see `compile_return` in
887    /// `compiler/statements.rs`) — the callee is materialized on the
888    /// stack as a String and resolved through
889    /// [`Vm::resolve_named_closure`] at dispatch time, which is exactly
890    /// the path the walk feeds. Excluding them would silently break
891    /// mutual recursion across a tail-call boundary.
892    #[inline]
893    pub(crate) fn op_reads_outer_name(op: Op) -> bool {
894        matches!(
895            op,
896            Op::GetVar
897                | Op::SetVar
898                | Op::CallBuiltin
899                | Op::CallBuiltinSpread
900                | Op::CallSpread
901                | Op::Call
902                | Op::TailCall
903                | Op::Pipe
904                | Op::CheckType
905        )
906    }
907
908    /// Set the current column for subsequent emit calls.
909    pub fn set_column(&mut self, col: u32) {
910        self.current_col = col;
911    }
912
913    /// Add a constant and return its index.
914    pub fn add_constant(&mut self, constant: Constant) -> u16 {
915        for (i, c) in self.constants.iter().enumerate() {
916            if c == &constant {
917                return i as u16;
918            }
919        }
920        let idx = self.constants.len();
921        self.constants.push(constant);
922        idx as u16
923    }
924
925    /// Emit a single-byte instruction.
926    pub fn emit(&mut self, op: Op, line: u32) {
927        let col = self.current_col;
928        let op_offset = self.code.len();
929        self.code.push(op as u8);
930        self.lines.push(line);
931        self.columns.push(col);
932        if is_adaptive_binary_op(op) {
933            self.register_inline_cache(op_offset);
934        }
935        if Self::op_reads_outer_name(op) {
936            self.references_outer_names = true;
937        }
938    }
939
940    /// Emit an instruction with a u16 argument.
941    pub fn emit_u16(&mut self, op: Op, arg: u16, line: u32) {
942        let col = self.current_col;
943        let op_offset = self.code.len();
944        self.code.push(op as u8);
945        self.code.push((arg >> 8) as u8);
946        self.code.push((arg & 0xFF) as u8);
947        self.lines.push(line);
948        self.lines.push(line);
949        self.lines.push(line);
950        self.columns.push(col);
951        self.columns.push(col);
952        self.columns.push(col);
953        if matches!(
954            op,
955            Op::GetProperty | Op::GetPropertyOpt | Op::MethodCallSpread
956        ) {
957            self.register_inline_cache(op_offset);
958        }
959        if Self::op_reads_outer_name(op) {
960            self.references_outer_names = true;
961        }
962    }
963
964    /// Emit an instruction with a u8 argument.
965    pub fn emit_u8(&mut self, op: Op, arg: u8, line: u32) {
966        let col = self.current_col;
967        let op_offset = self.code.len();
968        self.code.push(op as u8);
969        self.code.push(arg);
970        self.lines.push(line);
971        self.lines.push(line);
972        self.columns.push(col);
973        self.columns.push(col);
974        if matches!(op, Op::Call) {
975            self.register_inline_cache(op_offset);
976        }
977        if Self::op_reads_outer_name(op) {
978            self.references_outer_names = true;
979        }
980    }
981
982    /// Emit a direct builtin call.
983    pub fn emit_call_builtin(
984        &mut self,
985        id: crate::BuiltinId,
986        name_idx: u16,
987        arg_count: u8,
988        line: u32,
989    ) {
990        let col = self.current_col;
991        let op_offset = self.code.len();
992        self.code.push(Op::CallBuiltin as u8);
993        self.code.extend_from_slice(&id.raw().to_be_bytes());
994        self.code.push((name_idx >> 8) as u8);
995        self.code.push((name_idx & 0xFF) as u8);
996        self.code.push(arg_count);
997        for _ in 0..12 {
998            self.lines.push(line);
999            self.columns.push(col);
1000        }
1001        self.register_inline_cache(op_offset);
1002        self.references_outer_names = true;
1003    }
1004
1005    /// Emit a direct builtin spread call.
1006    pub fn emit_call_builtin_spread(&mut self, id: crate::BuiltinId, name_idx: u16, line: u32) {
1007        let col = self.current_col;
1008        self.code.push(Op::CallBuiltinSpread as u8);
1009        self.code.extend_from_slice(&id.raw().to_be_bytes());
1010        self.code.push((name_idx >> 8) as u8);
1011        self.code.push((name_idx & 0xFF) as u8);
1012        for _ in 0..11 {
1013            self.lines.push(line);
1014            self.columns.push(col);
1015        }
1016        self.references_outer_names = true;
1017    }
1018
1019    /// Emit a method call: op + u16 (method name) + u8 (arg count).
1020    pub fn emit_method_call(&mut self, name_idx: u16, arg_count: u8, line: u32) {
1021        self.emit_method_call_inner(Op::MethodCall, name_idx, arg_count, line);
1022    }
1023
1024    /// Emit an optional method call (?.) — returns nil if receiver is nil.
1025    pub fn emit_method_call_opt(&mut self, name_idx: u16, arg_count: u8, line: u32) {
1026        self.emit_method_call_inner(Op::MethodCallOpt, name_idx, arg_count, line);
1027    }
1028
1029    fn emit_method_call_inner(&mut self, op: Op, name_idx: u16, arg_count: u8, line: u32) {
1030        let col = self.current_col;
1031        let op_offset = self.code.len();
1032        self.code.push(op as u8);
1033        self.code.push((name_idx >> 8) as u8);
1034        self.code.push((name_idx & 0xFF) as u8);
1035        self.code.push(arg_count);
1036        self.lines.push(line);
1037        self.lines.push(line);
1038        self.lines.push(line);
1039        self.lines.push(line);
1040        self.columns.push(col);
1041        self.columns.push(col);
1042        self.columns.push(col);
1043        self.columns.push(col);
1044        self.register_inline_cache(op_offset);
1045    }
1046
1047    /// Current code offset (for jump patching).
1048    pub fn current_offset(&self) -> usize {
1049        self.code.len()
1050    }
1051
1052    /// Emit a jump instruction with a placeholder offset. Returns the position to patch.
1053    pub fn emit_jump(&mut self, op: Op, line: u32) -> usize {
1054        let col = self.current_col;
1055        self.code.push(op as u8);
1056        let patch_pos = self.code.len();
1057        self.code.push(0xFF);
1058        self.code.push(0xFF);
1059        self.lines.push(line);
1060        self.lines.push(line);
1061        self.lines.push(line);
1062        self.columns.push(col);
1063        self.columns.push(col);
1064        self.columns.push(col);
1065        patch_pos
1066    }
1067
1068    /// Patch a jump instruction at the given position to jump to the current offset.
1069    pub fn patch_jump(&mut self, patch_pos: usize) {
1070        let target = self.code.len() as u16;
1071        self.code[patch_pos] = (target >> 8) as u8;
1072        self.code[patch_pos + 1] = (target & 0xFF) as u8;
1073    }
1074
1075    /// Patch a jump to a specific target position.
1076    pub fn patch_jump_to(&mut self, patch_pos: usize, target: usize) {
1077        let target = target as u16;
1078        self.code[patch_pos] = (target >> 8) as u8;
1079        self.code[patch_pos + 1] = (target & 0xFF) as u8;
1080    }
1081
1082    /// Read a u16 argument at the given position.
1083    pub fn read_u16(&self, pos: usize) -> u16 {
1084        ((self.code[pos] as u16) << 8) | (self.code[pos + 1] as u16)
1085    }
1086
1087    fn register_inline_cache(&mut self, op_offset: usize) {
1088        if self.inline_cache_slots.contains_key(&op_offset) {
1089            return;
1090        }
1091        let mut entries = self.inline_caches.borrow_mut();
1092        let slot = entries.len();
1093        entries.push(InlineCacheEntry::Empty);
1094        self.inline_cache_slots.insert(op_offset, slot);
1095        Self::write_inline_cache_index(&mut self.inline_cache_index, op_offset, slot);
1096    }
1097
1098    /// Fast-path side-table writer. Pulled out as an associated fn so both
1099    /// the live emit path and [`Chunk::from_cached`] share the same growth
1100    /// strategy. Cache slots fit comfortably in `u32` because the slot count
1101    /// is bounded by the cacheable-opcode count in `code`.
1102    fn write_inline_cache_index(index: &mut Vec<u32>, op_offset: usize, slot: usize) {
1103        if op_offset >= index.len() {
1104            index.resize(op_offset + 1, NO_INLINE_CACHE_SLOT);
1105        }
1106        index[op_offset] = slot as u32;
1107    }
1108
1109    /// Look up the inline-cache slot for the opcode at `op_offset`. This is
1110    /// called on every dispatch of an adaptive binary op (Add/Sub/Mul/Div/
1111    /// Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq), `Op::Call`, `Op::MethodCall`
1112    /// (and `MethodCallOpt`/`MethodCallSpread`), and `Op::GetProperty`
1113    /// (`GetPropertyOpt`). Backed by [`Chunk::inline_cache_index`] — a flat
1114    /// `Vec<u32>` indexed by code offset — so the lookup is a single bounds-
1115    /// checked array read instead of the prior `BTreeMap::get` which walked
1116    /// internal nodes for every dispatched op.
1117    #[inline]
1118    pub(crate) fn inline_cache_slot(&self, op_offset: usize) -> Option<usize> {
1119        match self.inline_cache_index.get(op_offset).copied() {
1120            None | Some(NO_INLINE_CACHE_SLOT) => None,
1121            Some(slot) => Some(slot as usize),
1122        }
1123    }
1124
1125    /// Pre-optimization control path: the `BTreeMap`-backed lookup the
1126    /// dispatcher used before the flat `Vec<u32>` side-table. Exposed
1127    /// only behind the `vm-bench-internals` feature so the criterion
1128    /// microbench can A/B the two paths inside one binary on identical
1129    /// hardware. The production hot path must keep using
1130    /// [`Chunk::inline_cache_slot`].
1131    #[cfg(feature = "vm-bench-internals")]
1132    pub fn inline_cache_slot_via_btreemap_for_bench(&self, op_offset: usize) -> Option<usize> {
1133        self.inline_cache_slots.get(&op_offset).copied()
1134    }
1135
1136    /// Returns an `Rc<str>` for a `Constant::String` at the given pool
1137    /// index, materializing it on first access and caching for reuse.
1138    /// Returns `None` when the constant at `idx` is not a string (the
1139    /// caller should fall back to the regular `Constant` match).
1140    pub(crate) fn constant_string_rc(&self, idx: usize) -> Option<Rc<str>> {
1141        // Borrow the side table mutably so we can lazily extend / fill
1142        // entries. The borrow is scope-confined to this function; the
1143        // VM never re-enters constant_string_rc for the same chunk
1144        // during a single materialization, so no nested-borrow risk.
1145        let mut entries = self.constant_strings.borrow_mut();
1146        if entries.len() < self.constants.len() {
1147            entries.resize(self.constants.len(), None);
1148        }
1149        if let Some(Some(existing)) = entries.get(idx) {
1150            return Some(Rc::clone(existing));
1151        }
1152        let materialized = match self.constants.get(idx)? {
1153            Constant::String(s) => Rc::<str>::from(s.as_str()),
1154            _ => return None,
1155        };
1156        entries[idx] = Some(Rc::clone(&materialized));
1157        Some(materialized)
1158    }
1159
1160    #[cfg(feature = "vm-bench-internals")]
1161    pub(crate) fn inline_cache_entry(&self, slot: usize) -> InlineCacheEntry {
1162        self.inline_caches
1163            .borrow()
1164            .get(slot)
1165            .cloned()
1166            .unwrap_or(InlineCacheEntry::Empty)
1167    }
1168
1169    /// Adaptive-binary fast path read. Returns the cached
1170    /// `(op, state)` pair by value (both `Copy`) when slot holds an
1171    /// `AdaptiveBinary` entry, else `None`. Skips the
1172    /// `InlineCacheEntry::clone` that `inline_cache_entry` performs:
1173    /// since `AdaptiveBinaryState: Copy`, the read does a single
1174    /// scalar move out of the cache instead of a 24-32B memcpy of the
1175    /// wrapping enum (which the variant-checking match destructures
1176    /// and throws away anyway). Fires on every Add/Sub/Mul/Div/Mod/Eq/
1177    /// Neq/Less/Greater/LessEq/GreaterEq dispatch, so the per-op
1178    /// savings compound across the millions of dispatches a typical
1179    /// loop body issues.
1180    #[inline]
1181    pub(crate) fn peek_adaptive_binary_cache(
1182        &self,
1183        slot: usize,
1184    ) -> Option<(AdaptiveBinaryOp, AdaptiveBinaryState)> {
1185        match self.inline_caches.borrow().get(slot)? {
1186            &InlineCacheEntry::AdaptiveBinary { op, state } => Some((op, state)),
1187            _ => None,
1188        }
1189    }
1190
1191    /// Method-cache fast path read. Returns the cached `(name_idx, argc,
1192    /// target)` triple by value (all three are `Copy`) when `slot` holds a
1193    /// `Method` entry, else `None`. Skips the full `InlineCacheEntry::clone`
1194    /// that `inline_cache_entry` performs on every `Op::MethodCall`,
1195    /// `Op::MethodCallOpt`, and `Op::MethodCallSpread` dispatch: the
1196    /// variant-checking `let-else` in `try_cached_method` destructures and
1197    /// throws the wrapping enum away anyway, so reading the payload by `Copy`
1198    /// avoids the 32-48B enum memcpy. Method-call dispatch is the second-
1199    /// hottest IC-keyed opcode class after the adaptive binary ops, so the
1200    /// per-dispatch savings compound across the millions of method calls a
1201    /// typical pipeline (`xs.filter(...).map(...).count()`) issues.
1202    #[inline]
1203    pub(crate) fn peek_method_cache(&self, slot: usize) -> Option<(u16, usize, MethodCacheTarget)> {
1204        match self.inline_caches.borrow().get(slot)? {
1205            &InlineCacheEntry::Method {
1206                name_idx,
1207                argc,
1208                target,
1209            } => Some((name_idx, argc, target)),
1210            _ => None,
1211        }
1212    }
1213
1214    /// Property-cache fast path read. Returns the cached `(name_idx, target)`
1215    /// pair by value when `slot` holds a `Property` entry, else `None`. The
1216    /// outer `InlineCacheEntry` is the worst-case-sized variant (DirectCall
1217    /// at ~48 bytes including padding); cloning it just to discard four other
1218    /// variants in `try_cached_property`'s variant-check is wasted work. The
1219    /// peek returns just the `Property` payload (`u16` + `PropertyCacheTarget`),
1220    /// skipping the outer enum tag init and the padding-to-largest-variant
1221    /// memcpy. Fires on every `Op::GetProperty` / `Op::GetPropertyOpt`
1222    /// dispatch, which is the dominant opcode for any field-read-heavy code.
1223    #[inline]
1224    pub(crate) fn peek_property_cache(&self, slot: usize) -> Option<(u16, PropertyCacheTarget)> {
1225        match self.inline_caches.borrow().get(slot)? {
1226            InlineCacheEntry::Property { name_idx, target } => Some((*name_idx, target.clone())),
1227            _ => None,
1228        }
1229    }
1230
1231    /// Direct-call cache state read. Returns just the inner `DirectCallState`
1232    /// by value when `slot` holds a `DirectCall` entry, else `None`. Used by
1233    /// both `try_cached_direct_call(_)` (steady-state Specialized hit check)
1234    /// and `next_direct_call_entry` (Warmup → Specialized state-machine
1235    /// transition). Peeking the inner state directly skips the outer
1236    /// `InlineCacheEntry` discriminant check and tag init that the dispatcher
1237    /// otherwise pays on every `Op::Call` (closure callee) and the named-fn
1238    /// fast path inside `Op::CallBuiltin`. Single peek per dispatch covers
1239    /// both the read check and the write-back computation.
1240    #[inline]
1241    pub(crate) fn peek_direct_call_state(&self, slot: usize) -> Option<DirectCallState> {
1242        match self.inline_caches.borrow().get(slot)? {
1243            InlineCacheEntry::DirectCall { state } => Some(state.clone()),
1244            _ => None,
1245        }
1246    }
1247
1248    pub(crate) fn set_inline_cache_entry(&self, slot: usize, entry: InlineCacheEntry) {
1249        if let Some(existing) = self.inline_caches.borrow_mut().get_mut(slot) {
1250            *existing = entry;
1251        }
1252    }
1253
1254    pub fn freeze_for_cache(&self) -> CachedChunk {
1255        CachedChunk {
1256            code: self.code.clone(),
1257            constants: self.constants.clone(),
1258            lines: self.lines.clone(),
1259            columns: self.columns.clone(),
1260            source_file: self.source_file.clone(),
1261            current_col: self.current_col,
1262            functions: self
1263                .functions
1264                .iter()
1265                .map(|function| function.freeze_for_cache())
1266                .collect(),
1267            inline_cache_slots: self.inline_cache_slots.clone(),
1268            local_slots: self.local_slots.clone(),
1269            references_outer_names: self.references_outer_names,
1270        }
1271    }
1272
1273    pub fn from_cached(cached: &CachedChunk) -> Self {
1274        let inline_cache_count = cached.inline_cache_slots.len();
1275        let constants_count = cached.constants.len();
1276        // Project the cached `BTreeMap<op_offset, slot>` into the flat
1277        // dispatch-side lookup table. Sized to `code.len()` so the hottest
1278        // hot opcodes (binary ops at the end of a long chunk) still hit the
1279        // fast-path bounds check rather than falling through to the
1280        // none-found branch. The size is bounded by code length, so the
1281        // memory footprint is tiny — a few KB for typical chunks.
1282        let mut inline_cache_index = Vec::new();
1283        inline_cache_index.resize(cached.code.len(), NO_INLINE_CACHE_SLOT);
1284        for (&op_offset, &slot) in cached.inline_cache_slots.iter() {
1285            if op_offset < inline_cache_index.len() {
1286                inline_cache_index[op_offset] = slot as u32;
1287            }
1288        }
1289        Self {
1290            code: cached.code.clone(),
1291            constants: cached.constants.clone(),
1292            lines: cached.lines.clone(),
1293            columns: cached.columns.clone(),
1294            source_file: cached.source_file.clone(),
1295            current_col: cached.current_col,
1296            functions: cached
1297                .functions
1298                .iter()
1299                .map(|function| Rc::new(CompiledFunction::from_cached(function)))
1300                .collect(),
1301            inline_cache_slots: cached.inline_cache_slots.clone(),
1302            inline_cache_index,
1303            inline_caches: Rc::new(RefCell::new(vec![
1304                InlineCacheEntry::Empty;
1305                inline_cache_count
1306            ])),
1307            constant_strings: Rc::new(RefCell::new(vec![None; constants_count])),
1308            local_slots: cached.local_slots.clone(),
1309            references_outer_names: cached.references_outer_names,
1310        }
1311    }
1312
1313    pub(crate) fn add_local_slot(
1314        &mut self,
1315        name: String,
1316        mutable: bool,
1317        scope_depth: usize,
1318    ) -> u16 {
1319        let idx = self.local_slots.len();
1320        self.local_slots.push(LocalSlotInfo {
1321            name,
1322            mutable,
1323            scope_depth,
1324        });
1325        idx as u16
1326    }
1327
1328    #[cfg(test)]
1329    pub(crate) fn inline_cache_entries(&self) -> Vec<InlineCacheEntry> {
1330        self.inline_caches.borrow().clone()
1331    }
1332
1333    /// Read a u64 argument at the given position.
1334    pub fn read_u64(&self, pos: usize) -> u64 {
1335        u64::from_be_bytes([
1336            self.code[pos],
1337            self.code[pos + 1],
1338            self.code[pos + 2],
1339            self.code[pos + 3],
1340            self.code[pos + 4],
1341            self.code[pos + 5],
1342            self.code[pos + 6],
1343            self.code[pos + 7],
1344        ])
1345    }
1346
1347    /// Disassemble for debugging.
1348    pub fn disassemble(&self, name: &str) -> String {
1349        let mut out = format!("== {name} ==\n");
1350        let mut ip = 0;
1351        while ip < self.code.len() {
1352            let op = self.code[ip];
1353            let line = self.lines.get(ip).copied().unwrap_or(0);
1354            out.push_str(&format!("{ip:04} [{line:>4}] "));
1355            ip += 1;
1356
1357            match op {
1358                x if x == Op::Constant as u8 => {
1359                    let idx = self.read_u16(ip);
1360                    ip += 2;
1361                    let val = &self.constants[idx as usize];
1362                    out.push_str(&format!("CONSTANT {idx:>4} ({val})\n"));
1363                }
1364                x if x == Op::Nil as u8 => out.push_str("NIL\n"),
1365                x if x == Op::True as u8 => out.push_str("TRUE\n"),
1366                x if x == Op::False as u8 => out.push_str("FALSE\n"),
1367                x if x == Op::GetVar as u8 => {
1368                    let idx = self.read_u16(ip);
1369                    ip += 2;
1370                    out.push_str(&format!(
1371                        "GET_VAR {:>4} ({})\n",
1372                        idx, self.constants[idx as usize]
1373                    ));
1374                }
1375                x if x == Op::DefLet as u8 => {
1376                    let idx = self.read_u16(ip);
1377                    ip += 2;
1378                    out.push_str(&format!(
1379                        "DEF_LET {:>4} ({})\n",
1380                        idx, self.constants[idx as usize]
1381                    ));
1382                }
1383                x if x == Op::DefVar as u8 => {
1384                    let idx = self.read_u16(ip);
1385                    ip += 2;
1386                    out.push_str(&format!(
1387                        "DEF_VAR {:>4} ({})\n",
1388                        idx, self.constants[idx as usize]
1389                    ));
1390                }
1391                x if x == Op::SetVar as u8 => {
1392                    let idx = self.read_u16(ip);
1393                    ip += 2;
1394                    out.push_str(&format!(
1395                        "SET_VAR {:>4} ({})\n",
1396                        idx, self.constants[idx as usize]
1397                    ));
1398                }
1399                x if x == Op::GetLocalSlot as u8 => {
1400                    let slot = self.read_u16(ip);
1401                    ip += 2;
1402                    out.push_str(&format!("GET_LOCAL_SLOT {slot:>4}"));
1403                    if let Some(info) = self.local_slots.get(slot as usize) {
1404                        out.push_str(&format!(" ({})", info.name));
1405                    }
1406                    out.push('\n');
1407                }
1408                x if x == Op::DefLocalSlot as u8 => {
1409                    let slot = self.read_u16(ip);
1410                    ip += 2;
1411                    out.push_str(&format!("DEF_LOCAL_SLOT {slot:>4}"));
1412                    if let Some(info) = self.local_slots.get(slot as usize) {
1413                        out.push_str(&format!(" ({})", info.name));
1414                    }
1415                    out.push('\n');
1416                }
1417                x if x == Op::SetLocalSlot as u8 => {
1418                    let slot = self.read_u16(ip);
1419                    ip += 2;
1420                    out.push_str(&format!("SET_LOCAL_SLOT {slot:>4}"));
1421                    if let Some(info) = self.local_slots.get(slot as usize) {
1422                        out.push_str(&format!(" ({})", info.name));
1423                    }
1424                    out.push('\n');
1425                }
1426                x if x == Op::PushScope as u8 => out.push_str("PUSH_SCOPE\n"),
1427                x if x == Op::PopScope as u8 => out.push_str("POP_SCOPE\n"),
1428                x if x == Op::Add as u8 => out.push_str("ADD\n"),
1429                x if x == Op::Sub as u8 => out.push_str("SUB\n"),
1430                x if x == Op::Mul as u8 => out.push_str("MUL\n"),
1431                x if x == Op::Div as u8 => out.push_str("DIV\n"),
1432                x if x == Op::Mod as u8 => out.push_str("MOD\n"),
1433                x if x == Op::Pow as u8 => out.push_str("POW\n"),
1434                x if x == Op::Negate as u8 => out.push_str("NEGATE\n"),
1435                x if x == Op::Equal as u8 => out.push_str("EQUAL\n"),
1436                x if x == Op::NotEqual as u8 => out.push_str("NOT_EQUAL\n"),
1437                x if x == Op::Less as u8 => out.push_str("LESS\n"),
1438                x if x == Op::Greater as u8 => out.push_str("GREATER\n"),
1439                x if x == Op::LessEqual as u8 => out.push_str("LESS_EQUAL\n"),
1440                x if x == Op::GreaterEqual as u8 => out.push_str("GREATER_EQUAL\n"),
1441                x if x == Op::Contains as u8 => out.push_str("CONTAINS\n"),
1442                x if x == Op::Not as u8 => out.push_str("NOT\n"),
1443                x if x == Op::Jump as u8 => {
1444                    let target = self.read_u16(ip);
1445                    ip += 2;
1446                    out.push_str(&format!("JUMP {target:>4}\n"));
1447                }
1448                x if x == Op::JumpIfFalse as u8 => {
1449                    let target = self.read_u16(ip);
1450                    ip += 2;
1451                    out.push_str(&format!("JUMP_IF_FALSE {target:>4}\n"));
1452                }
1453                x if x == Op::JumpIfTrue as u8 => {
1454                    let target = self.read_u16(ip);
1455                    ip += 2;
1456                    out.push_str(&format!("JUMP_IF_TRUE {target:>4}\n"));
1457                }
1458                x if x == Op::Pop as u8 => out.push_str("POP\n"),
1459                x if x == Op::Call as u8 => {
1460                    let argc = self.code[ip];
1461                    ip += 1;
1462                    out.push_str(&format!("CALL {argc:>4}\n"));
1463                }
1464                x if x == Op::TailCall as u8 => {
1465                    let argc = self.code[ip];
1466                    ip += 1;
1467                    out.push_str(&format!("TAIL_CALL {argc:>4}\n"));
1468                }
1469                x if x == Op::Return as u8 => out.push_str("RETURN\n"),
1470                x if x == Op::Closure as u8 => {
1471                    let idx = self.read_u16(ip);
1472                    ip += 2;
1473                    out.push_str(&format!("CLOSURE {idx:>4}\n"));
1474                }
1475                x if x == Op::BuildList as u8 => {
1476                    let count = self.read_u16(ip);
1477                    ip += 2;
1478                    out.push_str(&format!("BUILD_LIST {count:>4}\n"));
1479                }
1480                x if x == Op::BuildDict as u8 => {
1481                    let count = self.read_u16(ip);
1482                    ip += 2;
1483                    out.push_str(&format!("BUILD_DICT {count:>4}\n"));
1484                }
1485                x if x == Op::Subscript as u8 => out.push_str("SUBSCRIPT\n"),
1486                x if x == Op::SubscriptOpt as u8 => out.push_str("SUBSCRIPT_OPT\n"),
1487                x if x == Op::Slice as u8 => out.push_str("SLICE\n"),
1488                x if x == Op::GetProperty as u8 => {
1489                    let idx = self.read_u16(ip);
1490                    ip += 2;
1491                    out.push_str(&format!(
1492                        "GET_PROPERTY {:>4} ({})\n",
1493                        idx, self.constants[idx as usize]
1494                    ));
1495                }
1496                x if x == Op::GetPropertyOpt as u8 => {
1497                    let idx = self.read_u16(ip);
1498                    ip += 2;
1499                    out.push_str(&format!(
1500                        "GET_PROPERTY_OPT {:>4} ({})\n",
1501                        idx, self.constants[idx as usize]
1502                    ));
1503                }
1504                x if x == Op::SetProperty as u8 => {
1505                    let idx = self.read_u16(ip);
1506                    ip += 2;
1507                    out.push_str(&format!(
1508                        "SET_PROPERTY {:>4} ({})\n",
1509                        idx, self.constants[idx as usize]
1510                    ));
1511                }
1512                x if x == Op::SetSubscript as u8 => {
1513                    let idx = self.read_u16(ip);
1514                    ip += 2;
1515                    out.push_str(&format!(
1516                        "SET_SUBSCRIPT {:>4} ({})\n",
1517                        idx, self.constants[idx as usize]
1518                    ));
1519                }
1520                x if x == Op::MethodCall as u8 => {
1521                    let idx = self.read_u16(ip);
1522                    ip += 2;
1523                    let argc = self.code[ip];
1524                    ip += 1;
1525                    out.push_str(&format!(
1526                        "METHOD_CALL {:>4} ({}) argc={}\n",
1527                        idx, self.constants[idx as usize], argc
1528                    ));
1529                }
1530                x if x == Op::MethodCallOpt as u8 => {
1531                    let idx = self.read_u16(ip);
1532                    ip += 2;
1533                    let argc = self.code[ip];
1534                    ip += 1;
1535                    out.push_str(&format!(
1536                        "METHOD_CALL_OPT {:>4} ({}) argc={}\n",
1537                        idx, self.constants[idx as usize], argc
1538                    ));
1539                }
1540                x if x == Op::Concat as u8 => {
1541                    let count = self.read_u16(ip);
1542                    ip += 2;
1543                    out.push_str(&format!("CONCAT {count:>4}\n"));
1544                }
1545                x if x == Op::IterInit as u8 => out.push_str("ITER_INIT\n"),
1546                x if x == Op::IterNext as u8 => {
1547                    let target = self.read_u16(ip);
1548                    ip += 2;
1549                    out.push_str(&format!("ITER_NEXT {target:>4}\n"));
1550                }
1551                x if x == Op::Throw as u8 => out.push_str("THROW\n"),
1552                x if x == Op::TryCatchSetup as u8 => {
1553                    let target = self.read_u16(ip);
1554                    ip += 2;
1555                    out.push_str(&format!("TRY_CATCH_SETUP {target:>4}\n"));
1556                }
1557                x if x == Op::PopHandler as u8 => out.push_str("POP_HANDLER\n"),
1558                x if x == Op::Pipe as u8 => out.push_str("PIPE\n"),
1559                x if x == Op::Parallel as u8 => out.push_str("PARALLEL\n"),
1560                x if x == Op::ParallelMap as u8 => out.push_str("PARALLEL_MAP\n"),
1561                x if x == Op::ParallelMapStream as u8 => out.push_str("PARALLEL_MAP_STREAM\n"),
1562                x if x == Op::ParallelSettle as u8 => out.push_str("PARALLEL_SETTLE\n"),
1563                x if x == Op::Spawn as u8 => out.push_str("SPAWN\n"),
1564                x if x == Op::Import as u8 => {
1565                    let idx = self.read_u16(ip);
1566                    ip += 2;
1567                    out.push_str(&format!(
1568                        "IMPORT {:>4} ({})\n",
1569                        idx, self.constants[idx as usize]
1570                    ));
1571                }
1572                x if x == Op::SelectiveImport as u8 => {
1573                    let path_idx = self.read_u16(ip);
1574                    ip += 2;
1575                    let names_idx = self.read_u16(ip);
1576                    ip += 2;
1577                    out.push_str(&format!(
1578                        "SELECTIVE_IMPORT {:>4} ({}) names: {:>4} ({})\n",
1579                        path_idx,
1580                        self.constants[path_idx as usize],
1581                        names_idx,
1582                        self.constants[names_idx as usize]
1583                    ));
1584                }
1585                x if x == Op::SyncMutexEnter as u8 => {
1586                    let idx = self.read_u16(ip);
1587                    ip += 2;
1588                    out.push_str(&format!(
1589                        "SYNC_MUTEX_ENTER {:>4} ({})\n",
1590                        idx, self.constants[idx as usize]
1591                    ));
1592                }
1593                x if x == Op::DeadlineSetup as u8 => out.push_str("DEADLINE_SETUP\n"),
1594                x if x == Op::DeadlineEnd as u8 => out.push_str("DEADLINE_END\n"),
1595                x if x == Op::BuildEnum as u8 => {
1596                    let enum_idx = self.read_u16(ip);
1597                    ip += 2;
1598                    let variant_idx = self.read_u16(ip);
1599                    ip += 2;
1600                    let field_count = self.read_u16(ip);
1601                    ip += 2;
1602                    out.push_str(&format!(
1603                        "BUILD_ENUM {:>4} ({}) {:>4} ({}) fields={}\n",
1604                        enum_idx,
1605                        self.constants[enum_idx as usize],
1606                        variant_idx,
1607                        self.constants[variant_idx as usize],
1608                        field_count
1609                    ));
1610                }
1611                x if x == Op::MatchEnum as u8 => {
1612                    let enum_idx = self.read_u16(ip);
1613                    ip += 2;
1614                    let variant_idx = self.read_u16(ip);
1615                    ip += 2;
1616                    out.push_str(&format!(
1617                        "MATCH_ENUM {:>4} ({}) {:>4} ({})\n",
1618                        enum_idx,
1619                        self.constants[enum_idx as usize],
1620                        variant_idx,
1621                        self.constants[variant_idx as usize]
1622                    ));
1623                }
1624                x if x == Op::PopIterator as u8 => out.push_str("POP_ITERATOR\n"),
1625                x if x == Op::TryUnwrap as u8 => out.push_str("TRY_UNWRAP\n"),
1626                x if x == Op::TryWrapOk as u8 => out.push_str("TRY_WRAP_OK\n"),
1627                x if x == Op::CallSpread as u8 => out.push_str("CALL_SPREAD\n"),
1628                x if x == Op::CallBuiltin as u8 => {
1629                    let id = self.read_u64(ip);
1630                    ip += 8;
1631                    let idx = self.read_u16(ip);
1632                    ip += 2;
1633                    let argc = self.code[ip];
1634                    ip += 1;
1635                    out.push_str(&format!(
1636                        "CALL_BUILTIN {id:#018x} {:>4} ({}) argc={}\n",
1637                        idx, self.constants[idx as usize], argc
1638                    ));
1639                }
1640                x if x == Op::CallBuiltinSpread as u8 => {
1641                    let id = self.read_u64(ip);
1642                    ip += 8;
1643                    let idx = self.read_u16(ip);
1644                    ip += 2;
1645                    out.push_str(&format!(
1646                        "CALL_BUILTIN_SPREAD {id:#018x} {:>4} ({})\n",
1647                        idx, self.constants[idx as usize]
1648                    ));
1649                }
1650                x if x == Op::MethodCallSpread as u8 => {
1651                    let idx = self.read_u16(ip + 1);
1652                    ip += 2;
1653                    out.push_str(&format!("METHOD_CALL_SPREAD {idx}\n"));
1654                }
1655                x if x == Op::Dup as u8 => out.push_str("DUP\n"),
1656                x if x == Op::Swap as u8 => out.push_str("SWAP\n"),
1657                x if x == Op::AddInt as u8 => out.push_str("ADD_INT\n"),
1658                x if x == Op::SubInt as u8 => out.push_str("SUB_INT\n"),
1659                x if x == Op::MulInt as u8 => out.push_str("MUL_INT\n"),
1660                x if x == Op::DivInt as u8 => out.push_str("DIV_INT\n"),
1661                x if x == Op::ModInt as u8 => out.push_str("MOD_INT\n"),
1662                x if x == Op::AddFloat as u8 => out.push_str("ADD_FLOAT\n"),
1663                x if x == Op::SubFloat as u8 => out.push_str("SUB_FLOAT\n"),
1664                x if x == Op::MulFloat as u8 => out.push_str("MUL_FLOAT\n"),
1665                x if x == Op::DivFloat as u8 => out.push_str("DIV_FLOAT\n"),
1666                x if x == Op::ModFloat as u8 => out.push_str("MOD_FLOAT\n"),
1667                x if x == Op::EqualInt as u8 => out.push_str("EQUAL_INT\n"),
1668                x if x == Op::NotEqualInt as u8 => out.push_str("NOT_EQUAL_INT\n"),
1669                x if x == Op::LessInt as u8 => out.push_str("LESS_INT\n"),
1670                x if x == Op::GreaterInt as u8 => out.push_str("GREATER_INT\n"),
1671                x if x == Op::LessEqualInt as u8 => out.push_str("LESS_EQUAL_INT\n"),
1672                x if x == Op::GreaterEqualInt as u8 => out.push_str("GREATER_EQUAL_INT\n"),
1673                x if x == Op::EqualFloat as u8 => out.push_str("EQUAL_FLOAT\n"),
1674                x if x == Op::NotEqualFloat as u8 => out.push_str("NOT_EQUAL_FLOAT\n"),
1675                x if x == Op::LessFloat as u8 => out.push_str("LESS_FLOAT\n"),
1676                x if x == Op::GreaterFloat as u8 => out.push_str("GREATER_FLOAT\n"),
1677                x if x == Op::LessEqualFloat as u8 => out.push_str("LESS_EQUAL_FLOAT\n"),
1678                x if x == Op::GreaterEqualFloat as u8 => out.push_str("GREATER_EQUAL_FLOAT\n"),
1679                x if x == Op::EqualBool as u8 => out.push_str("EQUAL_BOOL\n"),
1680                x if x == Op::NotEqualBool as u8 => out.push_str("NOT_EQUAL_BOOL\n"),
1681                x if x == Op::EqualString as u8 => out.push_str("EQUAL_STRING\n"),
1682                x if x == Op::NotEqualString as u8 => out.push_str("NOT_EQUAL_STRING\n"),
1683                x if x == Op::Yield as u8 => out.push_str("YIELD\n"),
1684                _ => {
1685                    out.push_str(&format!("UNKNOWN(0x{op:02x})\n"));
1686                }
1687            }
1688        }
1689        out
1690    }
1691}
1692
1693fn is_adaptive_binary_op(op: Op) -> bool {
1694    matches!(
1695        op,
1696        Op::Add
1697            | Op::Sub
1698            | Op::Mul
1699            | Op::Div
1700            | Op::Mod
1701            | Op::Equal
1702            | Op::NotEqual
1703            | Op::Less
1704            | Op::Greater
1705            | Op::LessEqual
1706            | Op::GreaterEqual
1707    )
1708}
1709
1710impl Default for Chunk {
1711    fn default() -> Self {
1712        Self::new()
1713    }
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718    use std::rc::Rc;
1719
1720    use super::{
1721        Chunk, DirectCallState, DirectCallTarget, InlineCacheEntry, MethodCacheTarget, Op,
1722        PropertyCacheTarget,
1723    };
1724    use crate::BuiltinId;
1725
1726    #[test]
1727    fn op_from_byte_matches_repr_order() {
1728        for (byte, op) in Op::ALL.iter().copied().enumerate() {
1729            assert_eq!(byte as u8, op as u8);
1730            assert_eq!(Op::from_byte(byte as u8), Some(op));
1731        }
1732        assert_eq!(Op::from_byte(Op::ALL.len() as u8), None);
1733    }
1734
1735    // --- references_outer_names tracking ---
1736    //
1737    // Drives the compile-time guard used in `Vm::closure_call_env`
1738    // and `Vm::closure_call_env_for_current_frame` to skip the
1739    // per-invocation caller-scope late-bind walks. Coverage parity
1740    // matters because false negatives would regress recursive /
1741    // mutually-recursive fns.
1742
1743    #[test]
1744    fn empty_chunk_does_not_reference_outer_names() {
1745        let chunk = Chunk::new();
1746        assert!(!chunk.references_outer_names);
1747    }
1748
1749    #[test]
1750    fn arithmetic_only_chunk_does_not_reference_outer_names() {
1751        // The hot `.map(x -> x * 2)` / `.filter(x -> x % 2 == 0)`
1752        // shape: pure stack/arithmetic ops and slot locals, no env
1753        // reads. Must NOT flag — that's the whole point of the
1754        // optimization.
1755        let mut chunk = Chunk::new();
1756        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1757        chunk.emit_u16(Op::Constant, 0, 1);
1758        chunk.emit(Op::MulInt, 1);
1759        chunk.emit(Op::Pop, 1);
1760        chunk.emit(Op::Return, 1);
1761        assert!(!chunk.references_outer_names);
1762    }
1763
1764    #[test]
1765    fn slot_only_chunk_does_not_reference_outer_names() {
1766        // Compiler-resolved locals never need env-based late-bind.
1767        let mut chunk = Chunk::new();
1768        chunk.emit_u16(Op::DefLocalSlot, 0, 1);
1769        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1770        chunk.emit_u16(Op::SetLocalSlot, 0, 1);
1771        assert!(!chunk.references_outer_names);
1772    }
1773
1774    #[test]
1775    fn get_var_flags_outer_name_reference() {
1776        let mut chunk = Chunk::new();
1777        chunk.emit_u16(Op::GetVar, 0, 1);
1778        assert!(chunk.references_outer_names);
1779    }
1780
1781    #[test]
1782    fn set_var_flags_outer_name_reference() {
1783        let mut chunk = Chunk::new();
1784        chunk.emit_u16(Op::SetVar, 0, 1);
1785        assert!(chunk.references_outer_names);
1786    }
1787
1788    #[test]
1789    fn check_type_flags_outer_name_reference() {
1790        let mut chunk = Chunk::new();
1791        chunk.emit_u16(Op::CheckType, 0, 1);
1792        assert!(chunk.references_outer_names);
1793    }
1794
1795    #[test]
1796    fn call_builtin_flags_outer_name_reference() {
1797        let mut chunk = Chunk::new();
1798        chunk.emit_call_builtin(BuiltinId::from_name("any_name"), 0, 1, 1);
1799        assert!(chunk.references_outer_names);
1800    }
1801
1802    #[test]
1803    fn call_builtin_spread_flags_outer_name_reference() {
1804        let mut chunk = Chunk::new();
1805        chunk.emit_call_builtin_spread(BuiltinId::from_name("any_name"), 0, 1);
1806        assert!(chunk.references_outer_names);
1807    }
1808
1809    #[test]
1810    fn tail_call_flags_outer_name_reference() {
1811        // `return fn_name(...)` compiles to Constant + TailCall —
1812        // TailCall does a runtime name lookup, so it has to flag.
1813        let mut chunk = Chunk::new();
1814        chunk.emit_u8(Op::TailCall, 1, 1);
1815        assert!(chunk.references_outer_names);
1816    }
1817
1818    #[test]
1819    fn call_flags_outer_name_reference() {
1820        // Op::Call can receive a String callee from the stack (the
1821        // by-name dispatch shape), so it has to flag too.
1822        let mut chunk = Chunk::new();
1823        chunk.emit_u8(Op::Call, 1, 1);
1824        assert!(chunk.references_outer_names);
1825    }
1826
1827    #[test]
1828    fn pipe_flags_outer_name_reference() {
1829        // `x |> name` resolves `name` through env when the value on
1830        // the stack is a String / BuiltinRef.
1831        let mut chunk = Chunk::new();
1832        chunk.emit(Op::Pipe, 1);
1833        assert!(chunk.references_outer_names);
1834    }
1835
1836    #[test]
1837    fn method_call_does_not_flag_outer_name_reference() {
1838        // Method receivers come off the operand stack, not the env;
1839        // emitting MethodCall alone must not force the walk.
1840        let mut chunk = Chunk::new();
1841        chunk.emit_method_call(0, 1, 1);
1842        chunk.emit_method_call_opt(0, 1, 1);
1843        assert!(!chunk.references_outer_names);
1844    }
1845
1846    #[test]
1847    fn jump_and_control_flow_do_not_flag_outer_name_reference() {
1848        // Jumps, returns, pops — control flow stays inside the
1849        // frame and never touches env lookups.
1850        let mut chunk = Chunk::new();
1851        chunk.emit_u16(Op::Constant, 0, 1);
1852        chunk.emit(Op::JumpIfFalse, 1);
1853        chunk.emit(Op::Jump, 1);
1854        chunk.emit(Op::Return, 1);
1855        chunk.emit(Op::Pop, 1);
1856        assert!(!chunk.references_outer_names);
1857    }
1858
1859    #[test]
1860    fn references_outer_names_is_monotonic() {
1861        // Once flagged, subsequent non-flagging emits must not
1862        // clear the bit — flags are sticky.
1863        let mut chunk = Chunk::new();
1864        chunk.emit_u16(Op::GetVar, 0, 1);
1865        assert!(chunk.references_outer_names);
1866        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1867        chunk.emit(Op::MulInt, 1);
1868        assert!(chunk.references_outer_names);
1869    }
1870
1871    #[test]
1872    fn freeze_thaw_round_trips_references_outer_names() {
1873        // Bytecode-cache hits must observe the same flag as a
1874        // fresh compile — otherwise the first call after a cache
1875        // hit would either over- or under-skip the walk.
1876        let mut chunk = Chunk::new();
1877        chunk.emit_u16(Op::GetVar, 0, 1);
1878        assert!(chunk.references_outer_names);
1879        let frozen = chunk.freeze_for_cache();
1880        let thawed = Chunk::from_cached(&frozen);
1881        assert!(thawed.references_outer_names);
1882
1883        let plain = Chunk::new();
1884        assert!(!plain.references_outer_names);
1885        let frozen_plain = plain.freeze_for_cache();
1886        let thawed_plain = Chunk::from_cached(&frozen_plain);
1887        assert!(!thawed_plain.references_outer_names);
1888    }
1889
1890    // --- inline_cache_slot flat-index parity ---
1891    //
1892    // Slot lookups fire on every dispatch of an adaptive binary op
1893    // (Add/Sub/Mul/Div/Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq),
1894    // every `Op::Call`, every `Op::MethodCall(Opt)`, and every
1895    // `Op::GetProperty(Opt)`. The flat `Vec<u32>` index has to stay
1896    // perfectly in sync with the serialization-stable BTreeMap or
1897    // a cached call site would either skip its inline cache (slow
1898    // path with no learning) or read a stale slot (silently
1899    // mis-specialized arithmetic). These tests pin the contract.
1900
1901    #[test]
1902    fn inline_cache_slot_returns_none_for_non_cacheable_offsets() {
1903        // GetLocalSlot is a sync-fast-path opcode with no inline
1904        // cache; the index must report no slot.
1905        let mut chunk = Chunk::new();
1906        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
1907        chunk.emit(Op::Pop, 1);
1908        chunk.emit(Op::Return, 1);
1909        assert!(chunk.inline_cache_slot(0).is_none());
1910        assert!(chunk.inline_cache_slot(3).is_none());
1911        assert!(chunk.inline_cache_slot(4).is_none());
1912    }
1913
1914    #[test]
1915    fn inline_cache_slot_registered_for_adaptive_binary_op() {
1916        // Pure-arithmetic ops use the adaptive-binary IC for shape
1917        // specialization. The slot has to be 0 because the chunk is
1918        // otherwise empty.
1919        let mut chunk = Chunk::new();
1920        chunk.emit(Op::Add, 1);
1921        assert_eq!(chunk.inline_cache_slot(0), Some(0));
1922    }
1923
1924    #[test]
1925    fn inline_cache_slot_distinct_for_sequential_adaptive_binary_ops() {
1926        // Three back-to-back Adds must get three distinct slots so
1927        // each instruction's shape feedback evolves independently
1928        // (otherwise the same call site would clobber a neighbor's
1929        // learning every dispatch).
1930        let mut chunk = Chunk::new();
1931        chunk.emit(Op::Add, 1);
1932        chunk.emit(Op::Sub, 1);
1933        chunk.emit(Op::Mul, 1);
1934        let s0 = chunk.inline_cache_slot(0).expect("Add slot");
1935        let s1 = chunk.inline_cache_slot(1).expect("Sub slot");
1936        let s2 = chunk.inline_cache_slot(2).expect("Mul slot");
1937        assert_ne!(s0, s1);
1938        assert_ne!(s1, s2);
1939        assert_ne!(s0, s2);
1940    }
1941
1942    #[test]
1943    fn inline_cache_slot_returns_none_for_out_of_bounds_offset() {
1944        // The dispatcher derives `op_offset` from `ip - 1`; an
1945        // out-of-bounds query must return None rather than panic.
1946        let mut chunk = Chunk::new();
1947        chunk.emit(Op::Add, 1);
1948        assert!(chunk.inline_cache_slot(usize::MAX).is_none());
1949        assert!(chunk.inline_cache_slot(chunk.code.len()).is_none());
1950        assert!(chunk.inline_cache_slot(chunk.code.len() + 16).is_none());
1951    }
1952
1953    #[test]
1954    fn inline_cache_slot_for_get_property_and_method_call() {
1955        // GetProperty(Opt) and MethodCall(Opt) both register an IC
1956        // slot at emit time — adaptive method-call dispatch and
1957        // monomorphic property-cache learning depend on it.
1958        let mut chunk = Chunk::new();
1959        chunk.emit_u16(Op::GetProperty, 0, 1); // offset 0..3
1960        chunk.emit_method_call(0, 1, 1); // offset 3..7
1961        chunk.emit_method_call_opt(0, 1, 1); // offset 7..11
1962        chunk.emit_u16(Op::GetPropertyOpt, 0, 1); // offset 11..14
1963        assert!(chunk.inline_cache_slot(0).is_some(), "GetProperty");
1964        assert!(chunk.inline_cache_slot(3).is_some(), "MethodCall");
1965        assert!(chunk.inline_cache_slot(7).is_some(), "MethodCallOpt");
1966        assert!(chunk.inline_cache_slot(11).is_some(), "GetPropertyOpt");
1967    }
1968
1969    #[test]
1970    fn inline_cache_slot_for_call_and_call_builtin() {
1971        // Both `Op::Call` (closure / by-name callee) and
1972        // `emit_call_builtin` register IC slots. The latter is the
1973        // adaptive-call fast path used for every direct user-fn
1974        // invocation.
1975        let mut chunk = Chunk::new();
1976        chunk.emit_u8(Op::Call, 1, 1); // offset 0..2
1977        let call_builtin_offset = chunk.code.len();
1978        chunk.emit_call_builtin(BuiltinId::from_name("any"), 0, 1, 1);
1979        assert!(chunk.inline_cache_slot(0).is_some(), "Op::Call IC slot");
1980        assert!(
1981            chunk.inline_cache_slot(call_builtin_offset).is_some(),
1982            "Op::CallBuiltin IC slot"
1983        );
1984    }
1985
1986    #[test]
1987    fn inline_cache_slot_register_is_idempotent_for_same_offset() {
1988        // The compile path uses `BTreeMap::contains_key` to dedup
1989        // re-registration at the same offset (eg. when a helper
1990        // re-emits into a still-live position). The flat index has
1991        // to honor the same semantics — never silently overwriting
1992        // an existing slot with a fresh one.
1993        let mut chunk = Chunk::new();
1994        chunk.emit(Op::Add, 1);
1995        let slot_before = chunk.inline_cache_slot(0).expect("first registration");
1996        // Manually re-register the same offset to confirm dedup.
1997        chunk.register_inline_cache(0);
1998        let slot_after = chunk.inline_cache_slot(0).expect("re-registration");
1999        assert_eq!(slot_before, slot_after);
2000    }
2001
2002    #[test]
2003    fn inline_cache_index_round_trips_through_cached_chunk() {
2004        // The cache freeze drops the flat index (it's derived from
2005        // the BTreeMap that *is* serialized). On thaw, the flat
2006        // index must be rebuilt so the first hot dispatch of a
2007        // cached chunk doesn't fall off the IC-slot cliff (which
2008        // would silently disable shape specialization until the
2009        // chunk is recompiled from source).
2010        let mut chunk = Chunk::new();
2011        chunk.emit_u16(Op::GetLocalSlot, 0, 1);
2012        chunk.emit_u16(Op::Constant, 0, 1);
2013        chunk.emit(Op::Add, 1);
2014        chunk.emit(Op::Sub, 1);
2015        chunk.emit_method_call(0, 1, 1);
2016        chunk.emit_u8(Op::Call, 1, 1);
2017        let live_slots: Vec<(usize, Option<usize>)> = (0..chunk.code.len())
2018            .map(|o| (o, chunk.inline_cache_slot(o)))
2019            .collect();
2020        let frozen = chunk.freeze_for_cache();
2021        let thawed = Chunk::from_cached(&frozen);
2022        let thawed_slots: Vec<(usize, Option<usize>)> = (0..thawed.code.len())
2023            .map(|o| (o, thawed.inline_cache_slot(o)))
2024            .collect();
2025        assert_eq!(live_slots, thawed_slots);
2026    }
2027
2028    #[test]
2029    fn inline_cache_index_agrees_with_btreemap_view() {
2030        // Authoritative parity check: for every code offset, the
2031        // flat-index `inline_cache_slot` must return exactly what
2032        // the underlying BTreeMap would (mod the `Option` boxing).
2033        // Catches any future emit path that grows `inline_cache_slots`
2034        // without going through `register_inline_cache`.
2035        let mut chunk = Chunk::new();
2036        chunk.emit(Op::Add, 1);
2037        chunk.emit_u16(Op::GetVar, 0, 1);
2038        chunk.emit(Op::LessInt, 1);
2039        chunk.emit_u8(Op::Call, 2, 1);
2040        chunk.emit(Op::Equal, 1);
2041        chunk.emit_u16(Op::GetProperty, 0, 1);
2042        chunk.emit_method_call_opt(0, 0, 1);
2043        for offset in 0..chunk.code.len() {
2044            let from_map = chunk.inline_cache_slots.get(&offset).copied();
2045            let from_index = chunk.inline_cache_slot(offset);
2046            assert_eq!(from_index, from_map, "parity broken at offset {offset}");
2047        }
2048    }
2049
2050    // --- peek_adaptive_binary_cache contract ---
2051    //
2052    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
2053    // hottest opcode class (Add / Sub / Mul / Div / Mod / Eq / Neq /
2054    // Less / Greater / LessEq / GreaterEq). It must return None for
2055    // unrelated IC variants — silently mis-extracting a `Property` /
2056    // `DirectCall` / `Method` slot as `AdaptiveBinary` would feed
2057    // garbage into `try_specialized_binary` and either spec-mis-fire or
2058    // crash. These tests pin the variant gate.
2059
2060    #[test]
2061    fn peek_adaptive_binary_returns_none_for_empty_slot() {
2062        let mut chunk = Chunk::new();
2063        chunk.emit(Op::Add, 1);
2064        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
2065        // Default state of a freshly-emitted slot is Empty.
2066        assert!(chunk.peek_adaptive_binary_cache(slot).is_none());
2067    }
2068
2069    #[test]
2070    fn peek_adaptive_binary_returns_op_and_state_after_warmup() {
2071        use super::{AdaptiveBinaryOp, AdaptiveBinaryState, BinaryShape, InlineCacheEntry};
2072        let mut chunk = Chunk::new();
2073        chunk.emit(Op::Add, 1);
2074        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
2075        chunk.set_inline_cache_entry(
2076            slot,
2077            InlineCacheEntry::AdaptiveBinary {
2078                op: AdaptiveBinaryOp::Add,
2079                state: AdaptiveBinaryState::Warmup {
2080                    shape: BinaryShape::Int,
2081                    hits: 2,
2082                },
2083            },
2084        );
2085        let (op, state) = chunk
2086            .peek_adaptive_binary_cache(slot)
2087            .expect("warmed slot peek");
2088        assert_eq!(op, AdaptiveBinaryOp::Add);
2089        assert!(matches!(
2090            state,
2091            AdaptiveBinaryState::Warmup {
2092                shape: BinaryShape::Int,
2093                hits: 2
2094            }
2095        ));
2096    }
2097
2098    #[test]
2099    fn peek_adaptive_binary_returns_none_for_non_binary_variants() {
2100        // The cache slot may legitimately hold a `Property`, `Method`,
2101        // or `DirectCall` entry (eg. a Property slot at the offset
2102        // sequence happens to alias an Add slot during a code rewrite —
2103        // currently this cannot happen, but the peek must defensively
2104        // refuse non-AdaptiveBinary variants regardless).
2105        use super::{InlineCacheEntry, PropertyCacheTarget};
2106        let mut chunk = Chunk::new();
2107        chunk.emit(Op::Add, 1);
2108        let slot = chunk.inline_cache_slot(0).expect("Add registers a slot");
2109        chunk.set_inline_cache_entry(
2110            slot,
2111            InlineCacheEntry::Property {
2112                name_idx: 0,
2113                target: PropertyCacheTarget::ListCount,
2114            },
2115        );
2116        assert!(chunk.peek_adaptive_binary_cache(slot).is_none());
2117    }
2118
2119    #[test]
2120    fn peek_adaptive_binary_returns_none_for_out_of_bounds_slot() {
2121        // Defensive: `execute_adaptive_binary` filters its `slot`
2122        // through `inline_cache_slot` first, but
2123        // `peek_adaptive_binary_cache` should still return None for an
2124        // unmapped slot rather than panicking.
2125        let chunk = Chunk::new();
2126        assert!(chunk.peek_adaptive_binary_cache(0).is_none());
2127        assert!(chunk.peek_adaptive_binary_cache(usize::MAX).is_none());
2128    }
2129
2130    #[test]
2131    fn peek_adaptive_binary_state_is_copy() {
2132        // Compile-time assertion: `AdaptiveBinaryState: Copy` is the
2133        // whole point of this optimization — if a future variant adds
2134        // a non-Copy field, the static check below will fail at compile
2135        // time before the dispatcher silently regresses to the heavy
2136        // `InlineCacheEntry::clone` path.
2137        fn assert_copy<T: Copy>() {}
2138        assert_copy::<super::AdaptiveBinaryState>();
2139        assert_copy::<super::AdaptiveBinaryOp>();
2140        assert_copy::<super::BinaryShape>();
2141    }
2142
2143    // --- peek_method_cache contract ---
2144    //
2145    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
2146    // method-call dispatch sites (`Op::MethodCall`, `Op::MethodCallOpt`,
2147    // `Op::MethodCallSpread`). It must return None for unrelated IC variants
2148    // — silently mis-extracting a `Property` / `DirectCall` / `AdaptiveBinary`
2149    // slot as `Method` would feed garbage into `try_cached_method` and either
2150    // spec-mis-fire (wrong target/argc) or skip the cache entirely on a real
2151    // hit. These tests pin the variant gate.
2152
2153    #[test]
2154    fn peek_method_cache_returns_none_for_empty_slot() {
2155        let mut chunk = Chunk::new();
2156        chunk.emit_method_call(0, 0, 1);
2157        let slot = chunk
2158            .inline_cache_slot(0)
2159            .expect("MethodCall registers a slot");
2160        assert!(chunk.peek_method_cache(slot).is_none());
2161    }
2162
2163    #[test]
2164    fn peek_method_cache_returns_triple_after_warmup() {
2165        let mut chunk = Chunk::new();
2166        chunk.emit_method_call(7, 2, 1);
2167        let slot = chunk
2168            .inline_cache_slot(0)
2169            .expect("MethodCall registers a slot");
2170        chunk.set_inline_cache_entry(
2171            slot,
2172            InlineCacheEntry::Method {
2173                name_idx: 7,
2174                argc: 2,
2175                target: MethodCacheTarget::ListContains,
2176            },
2177        );
2178        let (name_idx, argc, target) = chunk.peek_method_cache(slot).expect("warmed slot peek");
2179        assert_eq!(name_idx, 7);
2180        assert_eq!(argc, 2);
2181        assert_eq!(target, MethodCacheTarget::ListContains);
2182    }
2183
2184    #[test]
2185    fn peek_method_cache_returns_none_for_non_method_variants() {
2186        // The cache slot may legitimately hold an `AdaptiveBinary`,
2187        // `Property`, or `DirectCall` entry. The peek must defensively
2188        // refuse non-Method variants regardless.
2189        let mut chunk = Chunk::new();
2190        chunk.emit_method_call(0, 0, 1);
2191        let slot = chunk
2192            .inline_cache_slot(0)
2193            .expect("MethodCall registers a slot");
2194
2195        chunk.set_inline_cache_entry(
2196            slot,
2197            InlineCacheEntry::Property {
2198                name_idx: 0,
2199                target: PropertyCacheTarget::ListCount,
2200            },
2201        );
2202        assert!(chunk.peek_method_cache(slot).is_none());
2203    }
2204
2205    #[test]
2206    fn peek_method_cache_returns_none_for_out_of_bounds_slot() {
2207        let chunk = Chunk::new();
2208        assert!(chunk.peek_method_cache(0).is_none());
2209        assert!(chunk.peek_method_cache(usize::MAX).is_none());
2210    }
2211
2212    #[test]
2213    fn peek_method_cache_target_is_copy() {
2214        // Compile-time assertion: `MethodCacheTarget: Copy` is the whole
2215        // point of this peek path — if a future variant adds a non-Copy
2216        // field (eg. an `Rc<str>` for a dynamic method name), the static
2217        // check below will fail at compile time before the dispatcher
2218        // silently regresses to the heavy `InlineCacheEntry::clone` path.
2219        fn assert_copy<T: Copy>() {}
2220        assert_copy::<super::MethodCacheTarget>();
2221    }
2222
2223    // --- peek_property_cache contract ---
2224    //
2225    // The peek replaces the per-dispatch `InlineCacheEntry::clone` on the
2226    // property-read path (`Op::GetProperty` / `Op::GetPropertyOpt`). It
2227    // must return None for unrelated IC variants — silently mis-extracting
2228    // a `Method` / `DirectCall` / `AdaptiveBinary` slot as `Property` would
2229    // feed garbage into `try_cached_property` (wrong target match, possibly
2230    // a panic on the field-name lookup). These tests pin the variant gate.
2231
2232    #[test]
2233    fn peek_property_cache_returns_none_for_empty_slot() {
2234        let mut chunk = Chunk::new();
2235        chunk.emit_u16(Op::GetProperty, 0, 1);
2236        let slot = chunk
2237            .inline_cache_slot(0)
2238            .expect("GetProperty registers a slot");
2239        assert!(chunk.peek_property_cache(slot).is_none());
2240    }
2241
2242    #[test]
2243    fn peek_property_cache_returns_pair_after_warmup_for_dict_field() {
2244        let mut chunk = Chunk::new();
2245        chunk.emit_u16(Op::GetProperty, 0, 1);
2246        let slot = chunk
2247            .inline_cache_slot(0)
2248            .expect("GetProperty registers a slot");
2249        chunk.set_inline_cache_entry(
2250            slot,
2251            InlineCacheEntry::Property {
2252                name_idx: 11,
2253                target: PropertyCacheTarget::DictField(Rc::from("count")),
2254            },
2255        );
2256        let (name_idx, target) = chunk
2257            .peek_property_cache(slot)
2258            .expect("warmed property slot peek");
2259        assert_eq!(name_idx, 11);
2260        match target {
2261            PropertyCacheTarget::DictField(field) => assert_eq!(field.as_ref(), "count"),
2262            other => panic!("expected DictField, got {other:?}"),
2263        }
2264    }
2265
2266    #[test]
2267    fn peek_property_cache_returns_pair_for_unit_target() {
2268        // Unit targets (eg. ListCount, ListEmpty, PairFirst) carry no Rc,
2269        // so the cloned PropertyCacheTarget is a pure scalar move at the
2270        // peek boundary. The hottest case in practice.
2271        let mut chunk = Chunk::new();
2272        chunk.emit_u16(Op::GetProperty, 0, 1);
2273        let slot = chunk
2274            .inline_cache_slot(0)
2275            .expect("GetProperty registers a slot");
2276        chunk.set_inline_cache_entry(
2277            slot,
2278            InlineCacheEntry::Property {
2279                name_idx: 3,
2280                target: PropertyCacheTarget::ListCount,
2281            },
2282        );
2283        let (name_idx, target) = chunk
2284            .peek_property_cache(slot)
2285            .expect("warmed property slot peek");
2286        assert_eq!(name_idx, 3);
2287        assert_eq!(target, PropertyCacheTarget::ListCount);
2288    }
2289
2290    #[test]
2291    fn peek_property_cache_returns_none_for_non_property_variants() {
2292        let mut chunk = Chunk::new();
2293        chunk.emit_u16(Op::GetProperty, 0, 1);
2294        let slot = chunk
2295            .inline_cache_slot(0)
2296            .expect("GetProperty registers a slot");
2297        chunk.set_inline_cache_entry(
2298            slot,
2299            InlineCacheEntry::Method {
2300                name_idx: 0,
2301                argc: 0,
2302                target: MethodCacheTarget::ListCount,
2303            },
2304        );
2305        assert!(chunk.peek_property_cache(slot).is_none());
2306    }
2307
2308    #[test]
2309    fn peek_property_cache_returns_none_for_out_of_bounds_slot() {
2310        let chunk = Chunk::new();
2311        assert!(chunk.peek_property_cache(0).is_none());
2312        assert!(chunk.peek_property_cache(usize::MAX).is_none());
2313    }
2314
2315    // --- peek_direct_call_state contract ---
2316    //
2317    // Used on both the hot Specialized-hit check path (`try_cached_direct_call`
2318    // / `try_cached_named_direct_call`) and the state-machine write-back
2319    // (`next_direct_call_entry`). Returning None for the non-DirectCall slot
2320    // shapes is critical: a mis-extracted Method/Property/AdaptiveBinary slot
2321    // would have the dispatcher attempt a closure call with the wrong argc
2322    // or Rc::ptr_eq against an unrelated closure.
2323
2324    #[test]
2325    fn peek_direct_call_state_returns_none_for_empty_slot() {
2326        let mut chunk = Chunk::new();
2327        chunk.emit_u8(Op::Call, 0, 1);
2328        let slot = chunk
2329            .inline_cache_slot(0)
2330            .expect("Op::Call registers a slot");
2331        assert!(chunk.peek_direct_call_state(slot).is_none());
2332    }
2333
2334    #[test]
2335    fn peek_direct_call_state_returns_warmup_state() {
2336        let mut chunk = Chunk::new();
2337        chunk.emit_u8(Op::Call, 0, 1);
2338        let slot = chunk
2339            .inline_cache_slot(0)
2340            .expect("Op::Call registers a slot");
2341        let target = synthetic_direct_call_target();
2342        chunk.set_inline_cache_entry(
2343            slot,
2344            InlineCacheEntry::DirectCall {
2345                state: DirectCallState::Warmup {
2346                    argc: 2,
2347                    target: target.clone(),
2348                    hits: 1,
2349                },
2350            },
2351        );
2352        let state = chunk
2353            .peek_direct_call_state(slot)
2354            .expect("warmed direct-call slot peek");
2355        match state {
2356            DirectCallState::Warmup {
2357                argc,
2358                target: peeked_target,
2359                hits,
2360            } => {
2361                assert_eq!(argc, 2);
2362                assert_eq!(hits, 1);
2363                assert_eq!(peeked_target, target);
2364            }
2365            other => panic!("expected Warmup, got {other:?}"),
2366        }
2367    }
2368
2369    #[test]
2370    fn peek_direct_call_state_returns_specialized_state() {
2371        let mut chunk = Chunk::new();
2372        chunk.emit_u8(Op::Call, 0, 1);
2373        let slot = chunk
2374            .inline_cache_slot(0)
2375            .expect("Op::Call registers a slot");
2376        let target = synthetic_direct_call_target();
2377        chunk.set_inline_cache_entry(
2378            slot,
2379            InlineCacheEntry::DirectCall {
2380                state: DirectCallState::Specialized {
2381                    argc: 3,
2382                    target: target.clone(),
2383                    hits: 100,
2384                    misses: 0,
2385                },
2386            },
2387        );
2388        let state = chunk
2389            .peek_direct_call_state(slot)
2390            .expect("warmed direct-call slot peek");
2391        match state {
2392            DirectCallState::Specialized {
2393                argc,
2394                target: peeked_target,
2395                hits,
2396                misses,
2397            } => {
2398                assert_eq!(argc, 3);
2399                assert_eq!(hits, 100);
2400                assert_eq!(misses, 0);
2401                assert_eq!(peeked_target, target);
2402            }
2403            other => panic!("expected Specialized, got {other:?}"),
2404        }
2405    }
2406
2407    #[test]
2408    fn peek_direct_call_state_returns_none_for_non_direct_call_variants() {
2409        let mut chunk = Chunk::new();
2410        chunk.emit_u8(Op::Call, 0, 1);
2411        let slot = chunk
2412            .inline_cache_slot(0)
2413            .expect("Op::Call registers a slot");
2414
2415        chunk.set_inline_cache_entry(
2416            slot,
2417            InlineCacheEntry::Property {
2418                name_idx: 0,
2419                target: PropertyCacheTarget::ListCount,
2420            },
2421        );
2422        assert!(chunk.peek_direct_call_state(slot).is_none());
2423    }
2424
2425    #[test]
2426    fn peek_direct_call_state_returns_none_for_out_of_bounds_slot() {
2427        let chunk = Chunk::new();
2428        assert!(chunk.peek_direct_call_state(0).is_none());
2429        assert!(chunk.peek_direct_call_state(usize::MAX).is_none());
2430    }
2431
2432    /// Build a synthetic `DirectCallTarget::Closure` for direct-call peek
2433    /// tests. The closure has an empty body — the IC peek only inspects
2434    /// the wrapping `Rc`, not the closure internals.
2435    fn synthetic_direct_call_target() -> DirectCallTarget {
2436        use crate::value::VmClosure;
2437        use crate::{CompiledFunction, VmEnv};
2438        let func = CompiledFunction {
2439            name: "synthetic".to_string(),
2440            type_params: Vec::new(),
2441            nominal_type_names: Vec::new(),
2442            params: Vec::new(),
2443            default_start: None,
2444            chunk: Rc::new(Chunk::new()),
2445            is_generator: false,
2446            is_stream: false,
2447            has_rest_param: false,
2448            has_runtime_type_checks: false,
2449        };
2450        DirectCallTarget::Closure(Rc::new(VmClosure {
2451            func: Rc::new(func),
2452            env: VmEnv::new(),
2453            source_dir: None,
2454            module_functions: None,
2455            module_state: None,
2456        }))
2457    }
2458}