Skip to main content

harn_vm/
chunk.rs

1use std::collections::{BTreeMap, HashMap};
2use std::fmt;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5
6use harn_parser::TypeExpr;
7use parking_lot::Mutex;
8use serde::{Deserialize, Serialize};
9
10use crate::runtime_guards::RuntimeParamGuard;
11
12/// Sentinel value stored in [`Chunk::inline_cache_index`] for code offsets
13/// that have no inline-cache slot registered. Chosen as `u32::MAX` so the
14/// hot dispatch path can treat the side-table as a flat `Vec<u32>` without
15/// an `Option` wrapper — the comparison against the sentinel collapses to a
16/// single integer compare. The compile-time max useful slot count is bounded
17/// by code length (one slot per cacheable opcode), so `u32::MAX` is safely
18/// out of the addressable slot range.
19pub(crate) const NO_INLINE_CACHE_SLOT: u32 = u32::MAX;
20static NEXT_CHUNK_CACHE_ID: AtomicU64 = AtomicU64::new(1);
21
22fn next_chunk_cache_id() -> u64 {
23    NEXT_CHUNK_CACHE_ID.fetch_add(1, Ordering::Relaxed)
24}
25
26/// Bytecode opcodes for the Harn VM. The enum, the byte-to-variant
27/// mapping, the sync and async dispatch tables, the disassembly
28/// renderer, and the per-opcode classification helpers are all emitted
29/// by `harn_opcode_macros::define_opcodes!` in [`crate::vm::ops`].
30/// Re-exported here so callers that import `crate::chunk::Op` need no
31/// awareness of the macro layout.
32pub use crate::vm::ops::Op;
33pub(crate) use crate::vm::ops::{is_adaptive_binary_op, op_reads_outer_name};
34
35mod disassembly;
36pub(crate) use disassembly::*;
37mod inline_cache;
38pub(crate) use inline_cache::*;
39
40/// A constant value in the constant pool.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub enum Constant {
43    Int(i64),
44    Float(f64),
45    String(String),
46    Bool(bool),
47    Nil,
48    Duration(i64),
49}
50
51/// Identity used for constant-pool deduplication.
52///
53/// This is stricter than `PartialEq` for floats: it compares `Constant::Float`
54/// operands by their raw bits, so `+0.0` and `-0.0` (which are `==` under IEEE
55/// 754) get distinct pool slots, and each distinct NaN bit-pattern is preserved.
56/// Collapsing `+0.0`/`-0.0` onto one slot makes signed zero — and therefore the
57/// sign of `1.0 / 0.0` vs `1.0 / -0.0` — depend on which literal happened to be
58/// interned first. The derived `PartialEq` is left intact for all other uses.
59fn constants_identical(a: &Constant, b: &Constant) -> bool {
60    match (a, b) {
61        (Constant::Float(x), Constant::Float(y)) => x.to_bits() == y.to_bits(),
62        _ => a == b,
63    }
64}
65
66/// Hashable identity for constant-pool deduplication.
67///
68/// Mirrors [`constants_identical`] exactly, including bitwise float identity,
69/// so the compiler can replace the previous linear scan with an amortized O(1)
70/// side index without changing bytecode-visible constant slots.
71#[derive(Debug, Clone, PartialEq, Eq, Hash)]
72enum ConstantKey {
73    Int(i64),
74    Float(u64),
75    String(String),
76    Bool(bool),
77    Nil,
78    Duration(i64),
79}
80
81impl From<&Constant> for ConstantKey {
82    fn from(constant: &Constant) -> Self {
83        match constant {
84            Constant::Int(value) => Self::Int(*value),
85            Constant::Float(value) => Self::Float(value.to_bits()),
86            Constant::String(value) => Self::String(value.clone()),
87            Constant::Bool(value) => Self::Bool(*value),
88            Constant::Nil => Self::Nil,
89            Constant::Duration(value) => Self::Duration(*value),
90        }
91    }
92}
93
94fn build_constant_index(constants: &[Constant]) -> HashMap<ConstantKey, u16> {
95    let mut index = HashMap::with_capacity(constants.len());
96    for (slot, constant) in constants.iter().enumerate() {
97        if let Ok(slot) = u16::try_from(slot) {
98            index.entry(ConstantKey::from(constant)).or_insert(slot);
99        }
100    }
101    index
102}
103
104/// Debug metadata for a slot-indexed local in a compiled chunk.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct LocalSlotInfo {
107    pub name: String,
108    pub mutable: bool,
109    pub scope_depth: usize,
110}
111
112impl fmt::Display for Constant {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        match self {
115            Constant::Int(n) => write!(f, "{n}"),
116            Constant::Float(n) => write!(f, "{n}"),
117            Constant::String(s) => write!(f, "\"{s}\""),
118            Constant::Bool(b) => write!(f, "{b}"),
119            Constant::Nil => write!(f, "nil"),
120            Constant::Duration(ms) => write!(f, "{ms}ms"),
121        }
122    }
123}
124
125/// A compiled chunk of bytecode.
126#[derive(Debug)]
127pub struct Chunk {
128    /// Runtime-only identity for VM-local inline cache storage. It is not
129    /// serialized; freshly compiled or loaded chunks get new ids, while clones
130    /// keep the same id because they represent the same bytecode object.
131    cache_id: u64,
132    /// The bytecode instructions.
133    pub code: Vec<u8>,
134    /// Constant pool.
135    pub constants: Vec<Constant>,
136    /// Compile-time constant-pool dedup index, derived from
137    /// [`Chunk::constants`] and intentionally omitted from [`CachedChunk`].
138    ///
139    /// Only [`Chunk::add_constant`] reads it, so only a chunk the compiler is
140    /// still emitting into needs it. Building it eagerly on a cache load would
141    /// hash every constant of every chunk of every module on a path that never
142    /// appends another constant. `None` means "not built yet"; it is derived on
143    /// the first append.
144    constant_index: Option<HashMap<ConstantKey, u16>>,
145    /// Source line numbers for each instruction (for error reporting).
146    pub lines: Vec<u32>,
147    /// Source column numbers for each instruction (for error reporting).
148    /// Parallel to `lines`; 0 means no column info available.
149    pub columns: Vec<u32>,
150    /// Source file that this chunk was compiled from, when known. Set for
151    /// chunks compiled from imported modules so runtime errors can report
152    /// the correct file path for each frame instead of always pointing at
153    /// the entry-point pipeline.
154    pub source_file: Option<String>,
155    /// Current column to use when emitting instructions (set by compiler).
156    current_col: u32,
157    /// Compiled function bodies (for closures).
158    pub functions: Vec<CompiledFunctionRef>,
159    /// Instruction offset to inline-cache slot. Slots are assigned at emit time
160    /// for cacheable instructions while bytecode bytes remain immutable.
161    /// Preserved as the serialization-stable representation that round-trips
162    /// through [`CachedChunk`]; the runtime hot path reads
163    /// [`Chunk::inline_cache_index`] instead.
164    inline_cache_slots: BTreeMap<usize, usize>,
165    /// Flat side-table indexed by code offset that returns the inline-cache
166    /// slot index (or [`NO_INLINE_CACHE_SLOT`] for "no slot at this offset").
167    /// Built alongside [`Chunk::inline_cache_slots`] at emit/load time so the
168    /// per-dispatch lookup that fires on every adaptive binary op, `Op::Call`,
169    /// `Op::MethodCall`, and `Op::GetProperty` is one cache-friendly `Vec`
170    /// index instead of a `BTreeMap::get` (O(1) vs O(log n) with the
171    /// associated pointer chasing). Derived; intentionally not serialized.
172    inline_cache_index: Vec<u32>,
173    /// Test/bench scratch entries for validating inline-cache transitions.
174    /// Runtime execution keeps live cache entries on each `Vm` isolate so
175    /// parallel workers do not contend on shared compiled chunks.
176    inline_caches: Arc<Mutex<Vec<InlineCacheEntry>>>,
177    /// Lazily-materialized shared string cache for `Constant::String` entries,
178    /// parallel to `constants`. String constants are materialized once per
179    /// unique constant; subsequent pushes are a [`HarnStr`] refcount bump.
180    constant_strings: Arc<Mutex<Vec<Option<crate::value::HarnStr>>>>,
181    /// Source-name metadata for slot-indexed locals in this chunk.
182    pub(crate) local_slots: Vec<LocalSlotInfo>,
183    /// True when this chunk's bytecode emits an opcode that resolves a
184    /// name through the runtime env (`GetVar`, `SetVar`, `CallBuiltin`,
185    /// `CallBuiltinSpread`, `CheckType`). The closure-call hot path uses
186    /// this as a cheap static guard: if a closure body never reads
187    /// outer names by name, the caller-scope late-bind walks in
188    /// [`Vm::closure_call_env`] and
189    /// [`Vm::closure_call_env_for_current_frame`] are pure overhead and
190    /// can be skipped, leaving the closure's captured env as-is.
191    ///
192    /// Walks exist to inject late-bound closure-typed names — typically
193    /// for self/mutually-recursive local fns and for fns whose captured
194    /// env predates a sibling definition. Inline arithmetic / comparison
195    /// callbacks (the `.map(x -> x * 2)` / `.filter(x -> x % 2 == 0)`
196    /// shape) emit none of the flagged opcodes, so the walk is wasted
197    /// work on every invocation.
198    pub(crate) references_outer_names: bool,
199    /// Compile-time operand-stack-depth tracking for the debug-build
200    /// balance assertion (issue #2622). `balance_depth` is the running net
201    /// effect of every *linearly-modeled* opcode emitted so far;
202    /// `balance_nonlinear` counts emits whose effect can't be tracked by a
203    /// straight-line sum (jumps, `return`, async/handler ops, variadic ops
204    /// whose count isn't an emit argument). A statement is "balance-exact"
205    /// only when `balance_nonlinear` is unchanged across its compilation,
206    /// at which point `balance_depth`'s delta is its true net stack effect.
207    /// Transient compile-time state: reset by [`Chunk::new`], never
208    /// serialized into [`CachedChunk`], and read only by debug assertions —
209    /// so a wrong absolute value (which a non-exact statement can leave
210    /// behind) is harmless; only per-statement *deltas over exact spans*
211    /// are ever trusted.
212    #[cfg(debug_assertions)]
213    balance_depth: i32,
214    #[cfg(debug_assertions)]
215    balance_nonlinear: u32,
216}
217
218pub type ChunkRef = Arc<Chunk>;
219pub type CompiledFunctionRef = Arc<CompiledFunction>;
220
221impl Clone for Chunk {
222    fn clone(&self) -> Self {
223        Self {
224            cache_id: self.cache_id,
225            code: self.code.clone(),
226            constants: self.constants.clone(),
227            constant_index: self.constant_index.clone(),
228            lines: self.lines.clone(),
229            columns: self.columns.clone(),
230            source_file: self.source_file.clone(),
231            current_col: self.current_col,
232            functions: self.functions.clone(),
233            inline_cache_slots: self.inline_cache_slots.clone(),
234            inline_cache_index: self.inline_cache_index.clone(),
235            inline_caches: Arc::new(Mutex::new(vec![
236                InlineCacheEntry::Empty;
237                self.inline_cache_slot_count()
238            ])),
239            constant_strings: Arc::new(Mutex::new(vec![None; self.constants.len()])),
240            local_slots: self.local_slots.clone(),
241            references_outer_names: self.references_outer_names,
242            #[cfg(debug_assertions)]
243            balance_depth: self.balance_depth,
244            #[cfg(debug_assertions)]
245            balance_nonlinear: self.balance_nonlinear,
246        }
247    }
248}
249
250/// Serializable snapshot of a [`Chunk`] suitable for the on-disk bytecode
251/// cache and for in-memory stdlib artifact caches. Inline-cache state is
252/// dropped at freeze time because it warms at runtime per VM isolate; the
253/// rest of the chunk round-trips byte-identically.
254#[derive(Debug, Serialize, Deserialize)]
255pub struct CachedChunk {
256    pub(crate) code: Vec<u8>,
257    pub(crate) constants: Vec<Constant>,
258    pub(crate) lines: Vec<u32>,
259    pub(crate) columns: Vec<u32>,
260    pub(crate) source_file: Option<String>,
261    pub(crate) current_col: u32,
262    pub(crate) functions: Vec<CachedCompiledFunction>,
263    pub(crate) inline_cache_slots: BTreeMap<usize, usize>,
264    pub(crate) local_slots: Vec<LocalSlotInfo>,
265    #[serde(default)]
266    pub(crate) references_outer_names: bool,
267}
268
269#[derive(Debug, Serialize, Deserialize)]
270pub struct CachedCompiledFunction {
271    pub(crate) name: String,
272    pub(crate) type_params: Vec<String>,
273    pub(crate) nominal_type_names: Vec<String>,
274    pub(crate) params: Vec<CachedParamSlot>,
275    pub(crate) default_start: Option<usize>,
276    pub(crate) chunk: CachedChunk,
277    pub(crate) is_generator: bool,
278    pub(crate) is_stream: bool,
279    pub(crate) has_rest_param: bool,
280    pub(crate) has_runtime_type_checks: bool,
281}
282
283#[derive(Debug, Serialize, Deserialize)]
284pub(crate) struct CachedParamSlot {
285    pub(crate) name: String,
286    pub(crate) type_expr: Option<TypeExpr>,
287    pub(crate) has_default: bool,
288}
289
290impl CachedParamSlot {
291    fn thaw(self) -> ParamSlot {
292        let runtime_guard = self
293            .type_expr
294            .as_ref()
295            .map(RuntimeParamGuard::from_type_expr);
296        ParamSlot {
297            name: self.name,
298            type_expr: self.type_expr,
299            runtime_guard,
300            has_default: self.has_default,
301        }
302    }
303}
304
305/// One parameter slot of a compiled user-defined function. Carries the
306/// declared name, the (optional) declared type expression, and a flag
307/// for whether a default value was provided. The runtime consults the
308/// type expression in `bind_param_slots` to enforce declared types
309/// against the values supplied at the call site.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct ParamSlot {
312    pub name: String,
313    /// Declared parameter type. `None` for untyped parameters (gradual
314    /// typing); the runtime skips type assertion when absent.
315    pub type_expr: Option<TypeExpr>,
316    /// Precomputed runtime validation metadata derived from `type_expr`.
317    /// Bytecode-cache artifacts omit this field and rebuild it at load time.
318    #[serde(skip)]
319    pub(crate) runtime_guard: Option<RuntimeParamGuard>,
320    /// True when the parameter has a default-value clause. Diagnostic
321    /// only — the canonical authority for arity ranges is
322    /// [`CompiledFunction::default_start`].
323    pub has_default: bool,
324}
325
326impl ParamSlot {
327    /// Build a [`ParamSlot`] from a parser-side [`harn_parser::TypedParam`].
328    /// Centralizes the conversion so every compile path stays in lockstep.
329    pub fn from_typed_param(param: &harn_parser::TypedParam) -> Self {
330        Self::from_typed_param_with_type(param, param.type_expr.clone())
331    }
332
333    pub(crate) fn from_typed_param_with_type(
334        param: &harn_parser::TypedParam,
335        type_expr: Option<TypeExpr>,
336    ) -> Self {
337        let runtime_guard = type_expr.as_ref().map(RuntimeParamGuard::from_type_expr);
338        Self {
339            name: param.name.clone(),
340            type_expr,
341            runtime_guard,
342            has_default: param.default_value.is_some(),
343        }
344    }
345
346    fn freeze_for_cache(&self) -> CachedParamSlot {
347        CachedParamSlot {
348            name: self.name.clone(),
349            type_expr: self.type_expr.clone(),
350            has_default: self.has_default,
351        }
352    }
353
354    /// Build a `Vec<ParamSlot>` from a slice of parser-side typed
355    /// parameters. Used pervasively at compile sites instead of
356    /// `TypedParam::names` (which discarded the type info we now need
357    /// at runtime).
358    pub fn vec_from_typed(params: &[harn_parser::TypedParam]) -> Vec<Self> {
359        params.iter().map(Self::from_typed_param).collect()
360    }
361}
362
363/// A compiled function (closure body).
364#[derive(Debug, Clone)]
365pub struct CompiledFunction {
366    pub name: String,
367    /// Generic type parameters declared by this function. Runtime
368    /// validation treats these as static-only constraints because the VM
369    /// does not monomorphize function bodies.
370    pub type_params: Vec<String>,
371    /// User-defined struct and enum names visible when this function was
372    /// compiled. These are the only non-primitive named types with runtime
373    /// nominal identity; aliases and interfaces remain static-only.
374    pub nominal_type_names: Vec<String>,
375    pub params: Vec<ParamSlot>,
376    /// Index of the first parameter with a default value, or None if all required.
377    pub default_start: Option<usize>,
378    pub chunk: ChunkRef,
379    /// True if the function body contains `yield` expressions (generator function).
380    pub is_generator: bool,
381    /// True if the function was declared as `gen fn` and should return Stream.
382    pub is_stream: bool,
383    /// True if the last parameter is a rest parameter (`...name`).
384    pub has_rest_param: bool,
385    /// True when at least one parameter has a runtime-visible type
386    /// assertion. Untyped closures dominate collection callback hot paths,
387    /// so this lets the VM skip the per-argument metadata walk after the
388    /// arity check.
389    pub has_runtime_type_checks: bool,
390}
391
392impl CompiledFunction {
393    pub(crate) fn from_portable(portable: harn_kernel::CompiledFunction) -> Self {
394        Self {
395            name: portable.name,
396            type_params: portable.type_params,
397            nominal_type_names: portable.nominal_type_names,
398            params: portable
399                .params
400                .into_iter()
401                .map(|param| {
402                    let runtime_guard = param
403                        .type_expr
404                        .as_ref()
405                        .map(RuntimeParamGuard::from_type_expr);
406                    ParamSlot {
407                        name: param.name,
408                        type_expr: param.type_expr,
409                        runtime_guard,
410                        has_default: param.has_default,
411                    }
412                })
413                .collect(),
414            default_start: portable.default_start,
415            chunk: Arc::new(Chunk::from_portable((*portable.chunk).clone())),
416            is_generator: portable.is_generator,
417            is_stream: portable.is_stream,
418            has_rest_param: portable.has_rest_param,
419            has_runtime_type_checks: portable.has_runtime_type_checks,
420        }
421    }
422
423    #[cfg(test)]
424    pub(crate) fn has_runtime_type_checks_for_params(params: &[ParamSlot]) -> bool {
425        params.iter().any(|param| param.type_expr.is_some())
426    }
427
428    /// Returns just the parameter names — convenience for code paths that
429    /// don't care about types or defaults.
430    pub fn param_names(&self) -> impl Iterator<Item = &str> {
431        self.params.iter().map(|p| p.name.as_str())
432    }
433
434    /// Number of required parameters (those before `default_start`).
435    pub fn required_param_count(&self) -> usize {
436        self.default_start.unwrap_or(self.params.len())
437    }
438
439    /// Minimum number of caller-supplied arguments needed to enter the function.
440    pub(crate) fn minimum_arg_count(&self) -> usize {
441        if self.has_rest_param {
442            self.required_param_count()
443                .min(self.params.len().saturating_sub(1))
444        } else {
445            self.required_param_count()
446        }
447    }
448
449    /// Argument count visible to callee bytecode via `GetArgc`.
450    pub(crate) fn callee_arg_count(&self, supplied: usize) -> usize {
451        if self.has_rest_param {
452            supplied
453        } else {
454            supplied.min(self.params.len())
455        }
456    }
457
458    pub fn declares_type_param(&self, name: &str) -> bool {
459        self.type_params.iter().any(|param| param == name)
460    }
461
462    pub fn has_nominal_type(&self, name: &str) -> bool {
463        self.nominal_type_names.iter().any(|ty| ty == name)
464    }
465
466    pub(crate) fn freeze_for_cache(&self) -> CachedCompiledFunction {
467        CachedCompiledFunction {
468            name: self.name.clone(),
469            type_params: self.type_params.clone(),
470            nominal_type_names: self.nominal_type_names.clone(),
471            params: self
472                .params
473                .iter()
474                .map(ParamSlot::freeze_for_cache)
475                .collect(),
476            default_start: self.default_start,
477            chunk: self.chunk.freeze_for_cache(),
478            is_generator: self.is_generator,
479            is_stream: self.is_stream,
480            has_rest_param: self.has_rest_param,
481            has_runtime_type_checks: self.has_runtime_type_checks,
482        }
483    }
484
485    pub(crate) fn from_cached(cached: CachedCompiledFunction) -> Self {
486        Self {
487            name: cached.name,
488            type_params: cached.type_params,
489            nominal_type_names: cached.nominal_type_names,
490            params: cached
491                .params
492                .into_iter()
493                .map(CachedParamSlot::thaw)
494                .collect(),
495            default_start: cached.default_start,
496            chunk: Arc::new(Chunk::from_cached(cached.chunk)),
497            is_generator: cached.is_generator,
498            is_stream: cached.is_stream,
499            has_rest_param: cached.has_rest_param,
500            has_runtime_type_checks: cached.has_runtime_type_checks,
501        }
502    }
503}
504
505/// Net operand-stack effect (`pushes - pops`) of one emitted opcode, for
506/// the debug-build balance assertion (issue #2622). `count` is the opcode's
507/// variadic arity when that arity is the emit-call argument (`BuildList`
508/// length, `Call` argc, …) and `0` otherwise.
509///
510/// `Some(delta)` means the effect is exactly modeled. `None` marks an
511/// opcode a straight-line running sum can't track — control flow that
512/// branches or terminates (`Jump*`, `Return`, `Throw`, `TailCall`),
513/// async/handler ops, and variadic ops whose arity rides in a raw operand
514/// byte rather than the emit argument (`BuildEnum`, `MatchEnum`). Such an
515/// opcode taints its enclosing statement as non-exact, so the assertion
516/// skips it instead of risking a false trip.
517///
518/// The `match` is intentionally exhaustive with no `_` arm: adding an
519/// opcode forces a classification here (a compile error otherwise), so the
520/// balance model can't silently drift out of sync with the instruction set.
521#[cfg(debug_assertions)]
522fn op_stack_delta(op: Op, count: u16) -> Option<i32> {
523    use Op::*;
524    let count = count as i32;
525    Some(match op {
526        // Push one value.
527        Constant | Nil | True | False | RootHarness | GetVar | GetArgc | GetLocalSlot | Closure
528        | Dup => 1,
529        // Consume one value (into a binding / property / discard). `SetVar`,
530        // `SetProperty` and the local-slot stores read their target by name
531        // or slot index, so they only pop the value being stored.
532        DefLet | DefVar | DefCell | SetVar | DefLocalSlot | SetLocalSlot | SetProperty
533        | SetLocalSlotProperty | ConcatAssignLocal | Pop => -1,
534        // Value-preserving: unary ops, by-name lookups/checks, and scope /
535        // iterator / exception-handler bookkeeping (the last three touch
536        // side stacks, not the operand stack).
537        Negate | Not | GetProperty | GetPropertyOpt | CheckType | TryUnwrap | TryWrapOk | Swap
538        | PushScope | PopScope | PopIterator | PopHandler => 0,
539        // Pop two, push one.
540        Add | Sub | Mul | Div | Mod | Pow | AddInt | SubInt | MulInt | DivInt | ModInt
541        | AddFloat | SubFloat | MulFloat | DivFloat | ModFloat | Equal | NotEqual | Less
542        | Greater | LessEqual | GreaterEqual | EqualInt | NotEqualInt | LessInt | GreaterInt
543        | LessEqualInt | GreaterEqualInt | EqualFloat | NotEqualFloat | LessFloat
544        | GreaterFloat | LessEqualFloat | GreaterEqualFloat | EqualBool | NotEqualBool
545        | EqualString | NotEqualString | Contains | Subscript | SubscriptOpt => -1,
546        // `IterInit` consumes the iterable and pushes nothing (the iterator
547        // lives on a side stack).
548        IterInit => -1,
549        // Net -2: `Slice` pops object/start/end and pushes one value;
550        // subscript stores pop value/index and read the target from bytecode.
551        Slice | SetSubscript | SetLocalSlotSubscript => -2,
552        // Variadic whose arity is the emit argument: pop `count`, push one.
553        BuildList | Concat | CallBuiltin => 1 - count,
554        BuildDict => 1 - 2 * count,
555        // Calls also pop the callee/receiver beneath the args.
556        Call | MethodCall | MethodCallOpt => -count,
557        // Non-linear (see doc comment): branches, terminators, async/handler
558        // ops, and variadic ops whose arity isn't the emit argument.
559        Jump | JumpIfFalse | JumpIfTrue | IterNext | Return | TailCall | Throw | TryCatchSetup
560        | Spawn | Pipe | Parallel | ParallelMap | ParallelMapStream | ParallelSettle
561        | SyncMutexEnter | SyncMutexEnterKeyed | TaskScopeEnter | TaskScopeExit | Import
562        | SelectiveImport | NamespaceImport | DeadlineSetup | DeadlineEnd | BuildEnum
563        | MatchEnum | Yield | CallSpread | CallBuiltinSpread | MethodCallSpread => return None,
564    })
565}
566
567impl Chunk {
568    /// Attach native-only caches and runtime guards to a portable program
569    /// image. The compiler never constructs these process-local structures.
570    pub fn from_portable(portable: harn_kernel::Chunk) -> Self {
571        let mut inline_cache_slots = BTreeMap::new();
572        let mut offset = 0usize;
573        while offset < portable.code.len() {
574            let Some(op) = Op::from_byte(portable.code[offset]) else {
575                break;
576            };
577            if is_adaptive_binary_op(op)
578                || matches!(
579                    op,
580                    Op::GetProperty
581                        | Op::GetPropertyOpt
582                        | Op::MethodCall
583                        | Op::MethodCallOpt
584                        | Op::MethodCallSpread
585                        | Op::ConcatAssignLocal
586                        | Op::Call
587                        | Op::CallBuiltin
588                )
589            {
590                let slot = inline_cache_slots.len();
591                inline_cache_slots.insert(offset, slot);
592            }
593            let Some(width) = harn_kernel::program::instruction_len(op, &portable.code[offset..])
594            else {
595                break;
596            };
597            offset = offset.saturating_add(width);
598        }
599
600        let code = portable.code;
601        let constants = portable
602            .constants
603            .into_iter()
604            .map(|constant| match constant {
605                harn_kernel::Constant::Int(value) => Constant::Int(value),
606                harn_kernel::Constant::Float(value) => Constant::Float(value),
607                harn_kernel::Constant::String(value) => Constant::String(value),
608                harn_kernel::Constant::Bool(value) => Constant::Bool(value),
609                harn_kernel::Constant::Nil => Constant::Nil,
610                harn_kernel::Constant::Duration(value) => Constant::Duration(value),
611            })
612            .collect::<Vec<_>>();
613        let constant_count = constants.len();
614        let inline_cache_count = inline_cache_slots.len();
615        let mut inline_cache_index = vec![NO_INLINE_CACHE_SLOT; code.len()];
616        for (&op_offset, &slot) in &inline_cache_slots {
617            inline_cache_index[op_offset] = slot as u32;
618        }
619
620        Self {
621            cache_id: next_chunk_cache_id(),
622            code,
623            constants,
624            constant_index: None,
625            lines: portable.lines,
626            columns: portable.columns,
627            source_file: portable.source_file,
628            current_col: portable.current_col,
629            functions: portable
630                .functions
631                .into_iter()
632                .map(|function| {
633                    Arc::new(CompiledFunction::from_portable(function.as_ref().clone()))
634                })
635                .collect(),
636            inline_cache_slots,
637            inline_cache_index,
638            inline_caches: Arc::new(Mutex::new(vec![
639                InlineCacheEntry::Empty;
640                inline_cache_count
641            ])),
642            constant_strings: Arc::new(Mutex::new(vec![None; constant_count])),
643            local_slots: portable
644                .local_slots
645                .into_iter()
646                .map(|slot| LocalSlotInfo {
647                    name: slot.name,
648                    mutable: slot.mutable,
649                    scope_depth: slot.scope_depth,
650                })
651                .collect(),
652            references_outer_names: portable.references_outer_names,
653            #[cfg(debug_assertions)]
654            balance_depth: 0,
655            #[cfg(debug_assertions)]
656            balance_nonlinear: 0,
657        }
658    }
659
660    pub fn new() -> Self {
661        Self {
662            cache_id: next_chunk_cache_id(),
663            code: Vec::new(),
664            constants: Vec::new(),
665            constant_index: Some(HashMap::new()),
666            lines: Vec::new(),
667            columns: Vec::new(),
668            source_file: None,
669            current_col: 0,
670            functions: Vec::new(),
671            inline_cache_slots: BTreeMap::new(),
672            inline_cache_index: Vec::new(),
673            inline_caches: Arc::new(Mutex::new(Vec::new())),
674            constant_strings: Arc::new(Mutex::new(Vec::new())),
675            local_slots: Vec::new(),
676            references_outer_names: false,
677            #[cfg(debug_assertions)]
678            balance_depth: 0,
679            #[cfg(debug_assertions)]
680            balance_nonlinear: 0,
681        }
682    }
683
684    /// Set the current column for subsequent emit calls.
685    pub fn set_column(&mut self, col: u32) {
686        self.current_col = col;
687    }
688
689    /// Add a constant and return its index.
690    pub fn add_constant(&mut self, constant: Constant) -> u16 {
691        if self.constant_index.is_none() {
692            self.constant_index = Some(build_constant_index(&self.constants));
693        }
694        let index_map = self
695            .constant_index
696            .as_mut()
697            .expect("constant side index was just derived");
698        debug_assert!(
699            index_map.len() <= self.constants.len(),
700            "constant side index cannot outgrow the constant pool"
701        );
702        let key = ConstantKey::from(&constant);
703        if let Some(index) = index_map.get(&key) {
704            debug_assert!(
705                self.constants
706                    .get(*index as usize)
707                    .is_some_and(|existing| constants_identical(existing, &constant)),
708                "constant side index drifted from the constant pool"
709            );
710            return *index;
711        }
712        let idx = self.constants.len();
713        let idx = u16::try_from(idx).expect("constant pool exceeded u16 operand space");
714        index_map.insert(key, idx);
715        self.constants.push(constant);
716        idx
717    }
718
719    /// Emit a single-byte instruction.
720    pub fn emit(&mut self, op: Op, line: u32) {
721        #[cfg(debug_assertions)]
722        self.note_balance(op, 0);
723        let col = self.current_col;
724        let op_offset = self.code.len();
725        self.code.push(op as u8);
726        self.lines.push(line);
727        self.columns.push(col);
728        if is_adaptive_binary_op(op) {
729            self.register_inline_cache(op_offset);
730        }
731        if op_reads_outer_name(op) {
732            self.references_outer_names = true;
733        }
734    }
735
736    /// Emit an instruction with a u16 argument.
737    pub fn emit_u16(&mut self, op: Op, arg: u16, line: u32) {
738        #[cfg(debug_assertions)]
739        self.note_balance(op, arg);
740        let col = self.current_col;
741        let op_offset = self.code.len();
742        self.code.push(op as u8);
743        self.code.push((arg >> 8) as u8);
744        self.code.push((arg & 0xFF) as u8);
745        self.lines.push(line);
746        self.lines.push(line);
747        self.lines.push(line);
748        self.columns.push(col);
749        self.columns.push(col);
750        self.columns.push(col);
751        if matches!(
752            op,
753            Op::GetProperty | Op::GetPropertyOpt | Op::MethodCallSpread | Op::ConcatAssignLocal
754        ) {
755            self.register_inline_cache(op_offset);
756        }
757        if op_reads_outer_name(op) {
758            self.references_outer_names = true;
759        }
760    }
761
762    /// Emit a local-slot property assignment:
763    /// opcode + u16 property constant index + u16 local slot index.
764    pub fn emit_set_local_slot_property(&mut self, prop_idx: u16, slot: u16, line: u32) {
765        #[cfg(debug_assertions)]
766        self.note_balance(Op::SetLocalSlotProperty, 0);
767        let col = self.current_col;
768        self.code.push(Op::SetLocalSlotProperty as u8);
769        self.code.push((prop_idx >> 8) as u8);
770        self.code.push((prop_idx & 0xFF) as u8);
771        self.code.push((slot >> 8) as u8);
772        self.code.push((slot & 0xFF) as u8);
773        for _ in 0..5 {
774            self.lines.push(line);
775            self.columns.push(col);
776        }
777    }
778
779    /// Emit an instruction with a u8 argument.
780    pub fn emit_u8(&mut self, op: Op, arg: u8, line: u32) {
781        #[cfg(debug_assertions)]
782        self.note_balance(op, arg as u16);
783        let col = self.current_col;
784        let op_offset = self.code.len();
785        self.code.push(op as u8);
786        self.code.push(arg);
787        self.lines.push(line);
788        self.lines.push(line);
789        self.columns.push(col);
790        self.columns.push(col);
791        if matches!(op, Op::Call) {
792            self.register_inline_cache(op_offset);
793        }
794        if op_reads_outer_name(op) {
795            self.references_outer_names = true;
796        }
797    }
798
799    /// Emit a direct builtin call.
800    pub fn emit_call_builtin(
801        &mut self,
802        id: crate::BuiltinId,
803        name_idx: u16,
804        arg_count: u8,
805        line: u32,
806    ) {
807        #[cfg(debug_assertions)]
808        self.note_balance(Op::CallBuiltin, arg_count as u16);
809        let col = self.current_col;
810        let op_offset = self.code.len();
811        self.code.push(Op::CallBuiltin as u8);
812        self.code.extend_from_slice(&id.raw().to_be_bytes());
813        self.code.push((name_idx >> 8) as u8);
814        self.code.push((name_idx & 0xFF) as u8);
815        self.code.push(arg_count);
816        for _ in 0..12 {
817            self.lines.push(line);
818            self.columns.push(col);
819        }
820        self.register_inline_cache(op_offset);
821        self.references_outer_names = true;
822    }
823
824    /// Emit a direct builtin spread call.
825    pub fn emit_call_builtin_spread(&mut self, id: crate::BuiltinId, name_idx: u16, line: u32) {
826        #[cfg(debug_assertions)]
827        self.note_balance(Op::CallBuiltinSpread, 0);
828        let col = self.current_col;
829        self.code.push(Op::CallBuiltinSpread as u8);
830        self.code.extend_from_slice(&id.raw().to_be_bytes());
831        self.code.push((name_idx >> 8) as u8);
832        self.code.push((name_idx & 0xFF) as u8);
833        for _ in 0..11 {
834            self.lines.push(line);
835            self.columns.push(col);
836        }
837        self.references_outer_names = true;
838    }
839
840    /// Emit a method call: op + u16 (method name) + u8 (arg count).
841    pub fn emit_method_call(&mut self, name_idx: u16, arg_count: u8, line: u32) {
842        self.emit_method_call_inner(Op::MethodCall, name_idx, arg_count, line);
843    }
844
845    /// Emit an optional method call (?.) — returns nil if receiver is nil.
846    pub fn emit_method_call_opt(&mut self, name_idx: u16, arg_count: u8, line: u32) {
847        self.emit_method_call_inner(Op::MethodCallOpt, name_idx, arg_count, line);
848    }
849
850    fn emit_method_call_inner(&mut self, op: Op, name_idx: u16, arg_count: u8, line: u32) {
851        #[cfg(debug_assertions)]
852        self.note_balance(op, arg_count as u16);
853        let col = self.current_col;
854        let op_offset = self.code.len();
855        self.code.push(op as u8);
856        self.code.push((name_idx >> 8) as u8);
857        self.code.push((name_idx & 0xFF) as u8);
858        self.code.push(arg_count);
859        self.lines.push(line);
860        self.lines.push(line);
861        self.lines.push(line);
862        self.lines.push(line);
863        self.columns.push(col);
864        self.columns.push(col);
865        self.columns.push(col);
866        self.columns.push(col);
867        self.register_inline_cache(op_offset);
868    }
869
870    /// Current code offset (for jump patching).
871    pub fn current_offset(&self) -> usize {
872        self.code.len()
873    }
874
875    /// Emit a jump instruction with a placeholder offset. Returns the position to patch.
876    pub fn emit_jump(&mut self, op: Op, line: u32) -> usize {
877        #[cfg(debug_assertions)]
878        self.note_balance(op, 0);
879        let col = self.current_col;
880        self.code.push(op as u8);
881        let patch_pos = self.code.len();
882        self.code.push(0xFF);
883        self.code.push(0xFF);
884        self.lines.push(line);
885        self.lines.push(line);
886        self.lines.push(line);
887        self.columns.push(col);
888        self.columns.push(col);
889        self.columns.push(col);
890        patch_pos
891    }
892
893    /// Patch a jump instruction at the given position to jump to the current offset.
894    pub fn patch_jump(&mut self, patch_pos: usize) {
895        let target = self.code.len() as u16;
896        self.code[patch_pos] = (target >> 8) as u8;
897        self.code[patch_pos + 1] = (target & 0xFF) as u8;
898    }
899
900    /// Patch a jump to a specific target position.
901    pub fn patch_jump_to(&mut self, patch_pos: usize, target: usize) {
902        let target = target as u16;
903        self.code[patch_pos] = (target >> 8) as u8;
904        self.code[patch_pos + 1] = (target & 0xFF) as u8;
905    }
906
907    /// Read a u16 argument at the given position.
908    pub fn read_u16(&self, pos: usize) -> u16 {
909        ((self.code[pos] as u16) << 8) | (self.code[pos + 1] as u16)
910    }
911
912    /// Fold one just-emitted opcode into the compile-time operand-stack
913    /// balance model (issue #2622). See [`op_stack_delta`] for the
914    /// linear-vs-non-linear classification.
915    #[cfg(debug_assertions)]
916    fn note_balance(&mut self, op: Op, count: u16) {
917        match op_stack_delta(op, count) {
918            Some(delta) => self.balance_depth += delta,
919            None => self.balance_nonlinear += 1,
920        }
921    }
922
923    fn register_inline_cache(&mut self, op_offset: usize) {
924        if self.inline_cache_slots.contains_key(&op_offset) {
925            return;
926        }
927        let mut entries = self.inline_caches.lock();
928        let slot = entries.len();
929        entries.push(InlineCacheEntry::Empty);
930        self.inline_cache_slots.insert(op_offset, slot);
931        Self::write_inline_cache_index(&mut self.inline_cache_index, op_offset, slot);
932    }
933
934    /// Fast-path side-table writer. Pulled out as an associated fn so both
935    /// the live emit path and [`Chunk::from_cached`] share the same growth
936    /// strategy. Cache slots fit comfortably in `u32` because the slot count
937    /// is bounded by the cacheable-opcode count in `code`.
938    fn write_inline_cache_index(index: &mut Vec<u32>, op_offset: usize, slot: usize) {
939        if op_offset >= index.len() {
940            index.resize(op_offset + 1, NO_INLINE_CACHE_SLOT);
941        }
942        index[op_offset] = slot as u32;
943    }
944
945    /// Look up the inline-cache slot for the opcode at `op_offset`. This is
946    /// called on every dispatch of an adaptive binary op (Add/Sub/Mul/Div/
947    /// Mod/Eq/Neq/Less/Greater/LessEq/GreaterEq), `Op::Call`, `Op::MethodCall`
948    /// (and `MethodCallOpt`/`MethodCallSpread`), and `Op::GetProperty`
949    /// (`GetPropertyOpt`). Backed by [`Chunk::inline_cache_index`] — a flat
950    /// `Vec<u32>` indexed by code offset — so the lookup is a single bounds-
951    /// checked array read instead of the prior `BTreeMap::get` which walked
952    /// internal nodes for every dispatched op.
953    #[inline]
954    pub(crate) fn inline_cache_slot(&self, op_offset: usize) -> Option<usize> {
955        match self.inline_cache_index.get(op_offset).copied() {
956            None | Some(NO_INLINE_CACHE_SLOT) => None,
957            Some(slot) => Some(slot as usize),
958        }
959    }
960
961    pub(crate) fn inline_cache_slot_count(&self) -> usize {
962        self.inline_cache_slots.len()
963    }
964
965    pub(crate) fn cache_id(&self) -> u64 {
966        self.cache_id
967    }
968
969    /// Pre-optimization control path: the `BTreeMap`-backed lookup the
970    /// dispatcher used before the flat `Vec<u32>` side-table. Exposed
971    /// only behind the `vm-bench-internals` feature so the criterion
972    /// microbench can A/B the two paths inside one binary on identical
973    /// hardware. The production hot path must keep using
974    /// [`Chunk::inline_cache_slot`].
975    #[cfg(feature = "vm-bench-internals")]
976    pub fn inline_cache_slot_via_btreemap_for_bench(&self, op_offset: usize) -> Option<usize> {
977        self.inline_cache_slots.get(&op_offset).copied()
978    }
979
980    /// Returns a shared string for a `Constant::String` at the given pool
981    /// index, materializing it on first access and caching for reuse.
982    /// Returns `None` when the constant at `idx` is not a string (the
983    /// caller should fall back to the regular `Constant` match).
984    pub(crate) fn constant_string_rc(&self, idx: usize) -> Option<crate::value::HarnStr> {
985        // Borrow the side table mutably so we can lazily extend / fill
986        // entries. The borrow is scope-confined to this function; the
987        // VM never re-enters constant_string_rc for the same chunk
988        // during a single materialization, so no nested-borrow risk.
989        let mut entries = self.constant_strings.lock();
990        if entries.len() < self.constants.len() {
991            entries.resize(self.constants.len(), None);
992        }
993        if let Some(Some(existing)) = entries.get(idx) {
994            return Some(existing.clone());
995        }
996        let materialized = match self.constants.get(idx)? {
997            Constant::String(s) => crate::value::HarnStr::from(s.as_str()),
998            _ => return None,
999        };
1000        entries[idx] = Some(materialized.clone());
1001        Some(materialized)
1002    }
1003
1004    /// Test helper for the chunk-local scratch inline cache. Production
1005    /// dispatch reads VM-local cache sets through `Vm`.
1006    #[inline]
1007    #[cfg(test)]
1008    pub(crate) fn peek_adaptive_binary_cache(
1009        &self,
1010        slot: usize,
1011    ) -> Option<(AdaptiveBinaryOp, AdaptiveBinaryState)> {
1012        match self.inline_caches.lock().get(slot)? {
1013            &InlineCacheEntry::AdaptiveBinary { op, state } => Some((op, state)),
1014            _ => None,
1015        }
1016    }
1017
1018    /// Test helper for the chunk-local scratch inline cache. Production
1019    /// dispatch reads VM-local cache sets through `Vm`.
1020    #[inline]
1021    #[cfg(test)]
1022    pub(crate) fn peek_method_cache(&self, slot: usize) -> Option<(u16, usize, MethodCacheTarget)> {
1023        match self.inline_caches.lock().get(slot)? {
1024            &InlineCacheEntry::Method {
1025                name_idx,
1026                argc,
1027                target,
1028            } => Some((name_idx, argc, target)),
1029            _ => None,
1030        }
1031    }
1032
1033    /// Test helper for the chunk-local scratch inline cache. Production
1034    /// dispatch reads VM-local cache sets through `Vm`.
1035    #[inline]
1036    #[cfg(test)]
1037    pub(crate) fn peek_property_cache(&self, slot: usize) -> Option<(u16, PropertyCacheTarget)> {
1038        match self.inline_caches.lock().get(slot)? {
1039            InlineCacheEntry::Property { name_idx, target } => Some((*name_idx, target.clone())),
1040            _ => None,
1041        }
1042    }
1043
1044    /// Test helper for the chunk-local scratch inline cache. Production
1045    /// dispatch reads VM-local cache sets through `Vm`.
1046    #[inline]
1047    #[cfg(test)]
1048    pub(crate) fn peek_direct_call_state(&self, slot: usize) -> Option<DirectCallState> {
1049        match self.inline_caches.lock().get(slot)? {
1050            InlineCacheEntry::DirectCall { state } => Some(state.clone()),
1051            _ => None,
1052        }
1053    }
1054
1055    #[cfg(test)]
1056    pub(crate) fn set_inline_cache_entry(&self, slot: usize, entry: InlineCacheEntry) {
1057        if let Some(existing) = self.inline_caches.lock().get_mut(slot) {
1058            *existing = entry;
1059        }
1060    }
1061
1062    pub fn freeze_for_cache(&self) -> CachedChunk {
1063        CachedChunk {
1064            code: self.code.clone(),
1065            constants: self.constants.clone(),
1066            lines: self.lines.clone(),
1067            columns: self.columns.clone(),
1068            source_file: self.source_file.clone(),
1069            current_col: self.current_col,
1070            functions: self
1071                .functions
1072                .iter()
1073                .map(|function| function.freeze_for_cache())
1074                .collect(),
1075            inline_cache_slots: self.inline_cache_slots.clone(),
1076            local_slots: self.local_slots.clone(),
1077            references_outer_names: self.references_outer_names,
1078        }
1079    }
1080
1081    pub fn from_cached(cached: CachedChunk) -> Self {
1082        let CachedChunk {
1083            code,
1084            constants,
1085            lines,
1086            columns,
1087            source_file,
1088            current_col,
1089            functions,
1090            inline_cache_slots,
1091            local_slots,
1092            references_outer_names,
1093        } = cached;
1094        let inline_cache_count = inline_cache_slots.len();
1095        let constants_count = constants.len();
1096        // Project the cached `BTreeMap<op_offset, slot>` into the flat
1097        // dispatch-side lookup table. Sized to `code.len()` so the hottest
1098        // hot opcodes (binary ops at the end of a long chunk) still hit the
1099        // fast-path bounds check rather than falling through to the
1100        // none-found branch. The size is bounded by code length, so the
1101        // memory footprint is tiny — a few KB for typical chunks.
1102        let mut inline_cache_index = Vec::new();
1103        inline_cache_index.resize(code.len(), NO_INLINE_CACHE_SLOT);
1104        for (&op_offset, &slot) in &inline_cache_slots {
1105            if op_offset < inline_cache_index.len() {
1106                inline_cache_index[op_offset] = slot as u32;
1107            }
1108        }
1109        Self {
1110            cache_id: next_chunk_cache_id(),
1111            code,
1112            constants,
1113            // Derived on demand: a cache-loaded chunk is executed, not appended to.
1114            constant_index: None,
1115            lines,
1116            columns,
1117            source_file,
1118            current_col,
1119            functions: functions
1120                .into_iter()
1121                .map(|function| Arc::new(CompiledFunction::from_cached(function)))
1122                .collect(),
1123            inline_cache_slots,
1124            inline_cache_index,
1125            inline_caches: Arc::new(Mutex::new(vec![
1126                InlineCacheEntry::Empty;
1127                inline_cache_count
1128            ])),
1129            constant_strings: Arc::new(Mutex::new(vec![None; constants_count])),
1130            local_slots,
1131            references_outer_names,
1132            #[cfg(debug_assertions)]
1133            balance_depth: 0,
1134            #[cfg(debug_assertions)]
1135            balance_nonlinear: 0,
1136        }
1137    }
1138
1139    #[cfg(test)]
1140    pub(crate) fn add_local_slot(
1141        &mut self,
1142        name: String,
1143        mutable: bool,
1144        scope_depth: usize,
1145    ) -> u16 {
1146        let idx = self.local_slots.len();
1147        self.local_slots.push(LocalSlotInfo {
1148            name,
1149            mutable,
1150            scope_depth,
1151        });
1152        idx as u16
1153    }
1154
1155    /// Read a u64 argument at the given position.
1156    pub fn read_u64(&self, pos: usize) -> u64 {
1157        u64::from_be_bytes([
1158            self.code[pos],
1159            self.code[pos + 1],
1160            self.code[pos + 2],
1161            self.code[pos + 3],
1162            self.code[pos + 4],
1163            self.code[pos + 5],
1164            self.code[pos + 6],
1165            self.code[pos + 7],
1166        ])
1167    }
1168
1169    /// Disassemble the chunk for debugging. The per-opcode rendering is
1170    /// macro-generated alongside the dispatch tables in
1171    /// `crate::vm::ops` — see [`Self::disassemble_op`].
1172    pub fn disassemble(&self, name: &str) -> String {
1173        let mut out = format!("== {name} ==\n");
1174        let mut ip = 0;
1175        while ip < self.code.len() {
1176            let op_byte = self.code[ip];
1177            let line = self.lines.get(ip).copied().unwrap_or(0);
1178            out.push_str(&format!("{ip:04} [{line:>4}] "));
1179            ip += 1;
1180
1181            if let Some(op) = Op::from_byte(op_byte) {
1182                self.disassemble_op(op, &mut ip, &mut out);
1183            } else {
1184                out.push_str(&format!("UNKNOWN(0x{op_byte:02x})\n"));
1185            }
1186        }
1187        out
1188    }
1189}
1190
1191impl Default for Chunk {
1192    fn default() -> Self {
1193        Self::new()
1194    }
1195}
1196
1197#[cfg(test)]
1198#[path = "chunk_tests.rs"]
1199mod tests;