Skip to main content

fusevm/
vm.rs

1//! The fusevm execution engine — stack-based bytecode dispatch loop.
2//!
3//! This is the hot path. Every cycle counts. The dispatch loop uses
4//! a flat `match` on `Op` variants — Rust compiles this to a jump table.
5//!
6//! Frontends register extension handlers via `ExtensionHandler` for
7//! language-specific opcodes (`Op::Extended`, `Op::ExtendedWide`).
8//!
9//! ## Optimizations
10//!
11//! - **Type-specialized integer fast paths**: Add, Sub, Mul, Mod, comparisons
12//!   check for `Int×Int` first and skip `to_float()` coercion entirely.
13//! - **Zero-clone dispatch**: ops are borrowed from the chunk, not cloned per cycle.
14//!   `LoadConst` copies scalars (Int/Float/Bool) without touching Arc refcounts.
15//! - **In-place container mutation**: array/hash ops (Push, Pop, Shift, Set,
16//!   HashSet, HashDelete) mutate globals directly — no clone-modify-writeback.
17//! - **`Cow<str>` string coercion**: `as_str_cow()` borrows `Str` variants without
18//!   allocation. Used in string comparisons, Concat, Print, hash key lookup.
19//! - **Inline builtin cache**: `CallBuiltin` dispatches through a pre-registered
20//!   function pointer table — no name lookup at runtime.
21//! - **Fused superinstructions**: hot loop patterns run as single ops
22//!   (AccumSumLoop, SlotIncLtIntJumpBack, etc.)
23//! - **Pre-allocated collections**: Range, MakeHash, HashKeys/Values use exact
24//!   or estimated capacity. ConcatConstLoop pre-sizes the string buffer.
25
26use crate::awk_host::AwkHost;
27use crate::chunk::Chunk;
28use crate::host::ShellHost;
29#[cfg(feature = "jit")]
30use crate::jit::{DeoptInfo, JitCompiler, SlotKind, TraceLookup};
31use crate::op::Op;
32use crate::value::Value;
33
34// Tracing-JIT thresholds previously sourced from `vm.rs` constants are
35// now read through `JitCompiler::get_config()` so callers can override
36// per-thread via `JitCompiler::set_config(...)`. The defaults match the
37// historical phase-by-phase constants:
38//   trace_threshold      = 50  (backedges before recording arms)
39//   max_side_exits       = 50  (side-exits before main-trace blacklist)
40//   max_inline_recursion = 4   (self-recursive call depth cap)
41//   max_trace_chain      = 4   (chained side-trace dispatch depth cap)
42//   max_trace_len        = 256 (recorded ops cap)
43
44/// In-progress trace recording state.
45///
46/// The recorder is armed when `trace_lookup` returns `StartRecording`.
47/// While armed, every dispatched op is appended to `ops` before the op's
48/// effect is applied. The recording closes when the interpreter takes a
49/// backward jump that lands at `close_anchor_ip`.
50///
51/// Phase 9 split: `record_anchor_ip` is where the recording STARTED (the
52/// trace cache key); `close_anchor_ip` is where the recording is expected
53/// to LAND on close. For main traces the two are identical (a loop header
54/// is both the recording start and the closing-branch target). For side
55/// traces (recordings armed at a hot side-exit), `record_anchor_ip` is the
56/// side-exit IP the recorder started from, while `close_anchor_ip` remains
57/// the enclosing loop's header — so the trace closes correctly when the
58/// loop's backward branch fires.
59///
60/// `entered_ips` simulates the inlined frame stack so the recorder can
61/// (a) bound self-recursion to `MAX_INLINE_RECURSION` levels, and
62/// (b) reject unbalanced Returns. The values pushed are bytecode entry IPs
63/// resolved from `Op::Call(name_idx, _)`.
64#[cfg(feature = "jit")]
65struct TraceRecorder {
66    /// Phase 9: IP recording started from. Used as the trace cache key
67    /// `(chunk.op_hash, record_anchor_ip)`.
68    record_anchor_ip: usize,
69    /// Phase 9: IP the closing backward branch is expected to land at.
70    /// For main traces this equals `record_anchor_ip`; for side traces
71    /// it's the enclosing loop's header.
72    close_anchor_ip: usize,
73    /// IP just past the closing branch (where the interpreter resumes on
74    /// normal loop exit).
75    fallthrough_ip: usize,
76    /// Recorded op sequence (body + closing branch as final op).
77    ops: Vec<Op>,
78    /// Original bytecode IP each recorded op was dispatched from. Parallel to
79    /// `ops`. Used at compile time to infer direction taken at conditional
80    /// branches: for op at index `i`, if `recorded_ips[i+1]` equals the op's
81    /// jump target, the jump was taken; otherwise the fallthrough was.
82    recorded_ips: Vec<usize>,
83    /// Slot type snapshot at recording start; installed as the entry guard.
84    slot_kinds_at_anchor: Vec<SlotKind>,
85    /// Stack of bytecode entry IPs for currently inlined callees. Empty in
86    /// the caller frame; pushed on Op::Call, popped on Op::Return /
87    /// Op::ReturnValue. Used for recursion detection.
88    entered_ips: Vec<usize>,
89    /// True if any condition aborted the recording. Causes cleanup-only on
90    /// next jump dispatch.
91    aborted: bool,
92}
93
94/// Call frame on the frame stack.
95#[derive(Debug, Clone)]
96pub struct Frame {
97    /// Return address (ip to resume after call)
98    pub return_ip: usize,
99    /// Base pointer into the value stack (locals start here)
100    pub stack_base: usize,
101    /// Local variable slots (indexed by `GetSlot`/`SetSlot`)
102    pub slots: Vec<Value>,
103}
104
105/// Extension handler for language-specific opcodes.
106/// Frontends register this at VM init.
107pub type ExtensionHandler = Box<dyn FnMut(&mut VM, u16, u8) + Send>;
108/// Wide extension handler (usize payload).
109pub type ExtensionWideHandler = Box<dyn FnMut(&mut VM, u16, usize) + Send>;
110/// Builtin function handler: (vm, argc) → Value
111pub type BuiltinHandler = fn(&mut VM, u8) -> Value;
112
113/// The virtual machine.
114pub struct VM {
115    /// Value stack
116    pub stack: Vec<Value>,
117    /// Call frame stack
118    pub frames: Vec<Frame>,
119    /// Global variables (name pool index → value)
120    pub globals: Vec<Value>,
121    /// Instruction pointer
122    pub ip: usize,
123    /// Current chunk being executed
124    pub chunk: Chunk,
125    /// Last exit status ($?)
126    pub last_status: i32,
127    /// Optional stdout sink for `Op::Print`/`Op::PrintLn`; see [`OutputSink`].
128    /// `None` writes to `std::io::stdout()`. Installed by web-worker frontends.
129    output_sink: Option<OutputSink>,
130    /// Optional stdin source for `Op::ReadLine`; see [`InputSource`]. `None`
131    /// reads `std::io::stdin()`. Installed by web-worker frontends.
132    input_source: Option<InputSource>,
133    /// Host callback for arithmetic fusevm cannot complete natively (non-numeric
134    /// operand, or integer overflow). `None` — the default — keeps the
135    /// coerce-and-wrap semantics zshrs/awkrs/stryke rely on. See [`NumericHook`].
136    numeric_hook: Option<NumericHook>,
137    /// The inclusive range the VM may keep as a native `Value::Int`. `None` (the
138    /// default) is the whole `i64`. A Lisp with tagged fixnums has a narrower one
139    /// — Emacs's is ±2^61 — and every result outside it is a bignum, so strict
140    /// mode delegates those to the [`NumericHook`] exactly as it delegates an
141    /// `i64` overflow. Only consulted in strict mode.
142    fixnum_range: Option<(i64, i64)>,
143    /// Whether every slot of the current frame held an `Int`/`Float`/`Bool` at
144    /// the last `refresh_slot_buffers`. In strict numeric mode the block JIT is
145    /// skipped when this is false: a slot holding a string or an object handle is
146    /// passed to native code as the integer `0`, which would silently coerce
147    /// exactly the operand the `NumericHook` exists to reject.
148    #[cfg(feature = "jit")]
149    slots_all_numeric: bool,
150    /// Extension handler for `Op::Extended`
151    ext_handler: Option<ExtensionHandler>,
152    /// Extension handler for `Op::ExtendedWide`
153    ext_wide_handler: Option<ExtensionWideHandler>,
154    /// Inline builtin cache: builtin_id → function pointer (no lookup at dispatch)
155    builtin_table: Vec<Option<BuiltinHandler>>,
156    /// Frontend-supplied shell host (glob/expand/redirect/pipeline/etc).
157    /// When `None`, shell ops fall back to minimal stub behavior.
158    pub host: Option<Box<dyn ShellHost>>,
159    /// Frontend-supplied AWK host (fields/record/print/getline/string builtins).
160    /// The VM routes the reserved AWK op range (`Op::ExtendedWide` with
161    /// `id >= awk_builtins::AWK_OP_BASE`) here. When `None`, AWK ops are inert
162    /// stubs and the universal ops still execute normally.
163    pub awk_host: Option<Box<dyn AwkHost>>,
164    /// AWK PRNG seed for native `rand`/`srand` (glibc LCG, gawk-compatible).
165    /// Execution-intrinsic VM state (like a register), not part of AWK's data
166    /// model, so it lives here and is handled VM-side in both dispatch paths.
167    /// Initialized to 1 to match awkrs's default seed sequence.
168    awk_rand_seed: u64,
169    /// AWK control-flow signal raised by `Op::AwkSignal(code)` (the chunk halts
170    /// and the frontend driver reads this after `run()`): `next`/`nextfile`/
171    /// `exit` and range-pattern flow that has no `fusevm::Value` representation.
172    /// `None` unless an awk frontend emitted `Op::AwkSignal`; zshrs/stryke never
173    /// do, so for them this stays `None` and `Halted` behaves exactly as before.
174    awk_signal: Option<u8>,
175    /// Cooperative-concurrency scheduling request raised by a goroutine/channel
176    /// op (`Op::Go`/`ChanMake`/`ChanSend`/`ChanRecv`/`ChanClose`): the op stores
177    /// the request here and halts the chunk, and a [`crate::sched::Scheduler`]
178    /// driver reads it via [`VM::take_sched`] after `run()` returns, then resumes
179    /// this VM (or another goroutine). `None` unless a frontend emits those ops,
180    /// so zshrs/stryke/etc. behave exactly as before.
181    sched: Option<crate::sched::SchedReq>,
182    /// Halted flag
183    halted: bool,
184    /// Tracing JIT enabled. When true, backward branches consult the trace
185    /// cache and may invoke compiled traces or arm the recorder.
186    #[cfg(feature = "jit")]
187    tracing_jit: bool,
188    /// JIT compiler instance — stateless wrapper over the thread-local cache.
189    #[cfg(feature = "jit")]
190    jit: JitCompiler,
191    /// Active trace recording, if any.
192    #[cfg(feature = "jit")]
193    recorder: Option<TraceRecorder>,
194    /// Reusable scratch i64 buffer of slot values, passed to compiled traces.
195    #[cfg(feature = "jit")]
196    slot_buf: Vec<i64>,
197    /// Reusable scratch slot-kind snapshot for the trace entry guard.
198    #[cfg(feature = "jit")]
199    slot_kinds_buf: Vec<SlotKind>,
200    /// Reusable scratch buffer the trace fn populates on every invocation
201    /// with the resume IP and (on callee-frame side-exits) inlined-frame
202    /// materialization records the VM uses to reshape `vm.frames`.
203    /// Stored inline (~888 bytes) to avoid heap indirection on the hot
204    /// trace path; the size cost is paid once per VM and the access
205    /// savings hit every invocation.
206    #[cfg(feature = "jit")]
207    deopt_info: DeoptInfo,
208    /// Cached block-JIT eligibility for `self.chunk`. `None` until first
209    /// `VM::run` call evaluates it; reused across subsequent runs since
210    /// `Chunk` is immutable for the VM's lifetime. Saves the TLS HashMap
211    /// lookup that `JitCompiler::is_block_eligible` would otherwise
212    /// perform on every run.
213    #[cfg(feature = "jit")]
214    block_eligible_cached: Option<bool>,
215    /// Result captured by the AOT closed-world driver (`fusevm::aot`). The
216    /// native entry function stores the terminating [`VMResult`] here via
217    /// [`VM::aot_finish`]; the caller takes it after the driver returns.
218    #[cfg(feature = "aot")]
219    aot_result: Option<VMResult>,
220    /// Value arena for natively-lowered heap values. The native fast path keeps
221    /// scalars in registers, but a boxed value (string/array/…) can't fit one,
222    /// so it lives here and the register holds an `i64` *handle* (an index). See
223    /// [`VM::aot_box`] / [`VM::aot_unbox`].
224    #[cfg(feature = "aot")]
225    aot_arena: Vec<Value>,
226    /// Freelist of reusable [`VM::aot_arena`] slots. Native heap handles are
227    /// owned (one live owner each): consuming a handle (`aot_unbox`, result,
228    /// pop) frees its slot here so a loop that rebuilds a value each iteration
229    /// reuses arena slots instead of growing without bound.
230    #[cfg(feature = "aot")]
231    aot_free: Vec<u32>,
232}
233
234/// Result of VM execution
235#[derive(Debug)]
236pub enum VMResult {
237    Ok(Value),
238    /// Halted (no more instructions)
239    Halted,
240    /// Runtime error
241    Error(String),
242}
243
244/// Outcome of executing one op via [`VM::exec_op`]. `Cont` means the dispatch
245/// loop proceeds to the next op (running its recorder-finalize step); `Ret`
246/// means the op terminated the run and the loop returns the wrapped
247/// [`VMResult`]. This is the control-flow contract shared by the interpreter
248/// loop and the AOT closed-world compiler (`fusevm::aot`).
249pub(crate) enum ExecFlow {
250    Cont,
251    Ret(VMResult),
252}
253
254/// The arithmetic or comparison op that delegated to a [`NumericHook`].
255///
256/// `Neg` is unary: its handler receives the operand as `a` and `Value::Undef`
257/// as `b`.
258#[derive(Clone, Copy, Debug, PartialEq, Eq)]
259pub enum NumOp {
260    Add,
261    Sub,
262    Mul,
263    Div,
264    Mod,
265    Pow,
266    Neg,
267    Lt,
268    Gt,
269    Le,
270    Ge,
271    Eq,
272    Ne,
273}
274
275/// Host callback for arithmetic fusevm cannot complete natively.
276///
277/// fusevm's numeric ops are awk/shell-flavoured by default: a non-numeric
278/// operand is coerced (`to_float`, so `"a"` becomes `0.0`) and integer overflow
279/// wraps. That is correct for zshrs, awkrs and stryke, and wrong for a frontend
280/// whose language signals on non-numbers or promotes on overflow.
281///
282/// Installing a hook with [`VM::set_numeric_hook`] switches the VM to *strict*
283/// numeric mode, where an op that cannot be computed exactly in `i64`/`f64`
284/// hands off to the host instead of guessing:
285///
286/// - an operand that is not `Int` or `Float` (a string, a `Value::Obj` handle,
287///   a bool, `Undef`) — the host decides whether that is an error (elisp:
288///   `(wrong-type-argument number-or-marker-p "a")`) or a value it knows how to
289///   add (elisp: a marker, or a bignum living in its own object heap);
290/// - integer `Add`/`Sub`/`Mul`/`Neg` that overflows `i64` — the host returns the
291///   exact result (elisp: a bignum), rather than the wrapped one.
292///
293/// `Err` raises a VM error carrying the message. Mixed int/float and
294/// float/float arithmetic never delegates: it is exact in `f64` and stays on
295/// the fast path.
296pub type NumericHook =
297    std::sync::Arc<dyn Fn(NumOp, &Value, &Value) -> Result<Value, String> + Send + Sync>;
298
299/// Sink for VM stdout (`Op::Print` / `Op::PrintLn`).
300///
301/// `None` — the default — writes to `std::io::stdout()`, byte-for-byte as
302/// before. A frontend running fusevm in a browser web worker installs a sink,
303/// because wasm has no real stdout: typically a closure appending to an
304/// `Arc<Mutex<String>>` the frontend drains after `run()` and forwards to the
305/// JS host via `postMessage`. The `Send` bound keeps [`VM`] `Send` for
306/// [`VMPool`]; a worker sink stays `Send` by writing to an `Arc<Mutex<_>>`
307/// rather than capturing a JS handle directly.
308pub type OutputSink = Box<dyn FnMut(&str) + Send>;
309
310/// Source for VM stdin (`Op::ReadLine`).
311///
312/// `None` — the default — reads a line from `std::io::stdin()`. When installed,
313/// the closure returns one line per call (newline already trimmed) or `None` at
314/// end of input, which `Op::ReadLine` pushes as `Value::Undef`. Blocking
315/// interactive stdin inside a worker needs `SharedArrayBuffer` + `Atomics.wait`
316/// and is the frontend's concern; this hook covers the common pre-loaded-input
317/// case.
318pub type InputSource = Box<dyn FnMut() -> Option<String> + Send>;
319
320/// Whether a value is one fusevm's numeric ops can compute on natively.
321#[inline(always)]
322fn is_native_num(v: &Value) -> bool {
323    matches!(v, Value::Int(_) | Value::Float(_))
324}
325
326impl VM {
327    /// Construct a fresh VM bound to the given chunk. Allocates the
328    /// per-name slot vector, seeds the call-frame stack with a root
329    /// frame, and zeros every per-thread counter (cycle / deopt /
330    /// trace stats). The chunk's `op_hash` is preserved verbatim so
331    /// subsequent JIT-cache lookups can short-circuit recompilation.
332    pub fn new(chunk: Chunk) -> Self {
333        let num_names = chunk.names.len();
334        let mut frames = Vec::with_capacity(32);
335        frames.push(Frame {
336            return_ip: 0,
337            stack_base: 0,
338            slots: Vec::with_capacity(16),
339        });
340        Self {
341            stack: Vec::with_capacity(256),
342            frames,
343            globals: vec![Value::Undef; num_names],
344            ip: 0,
345            chunk,
346            last_status: 0,
347            output_sink: None,
348            input_source: None,
349            numeric_hook: None,
350            fixnum_range: None,
351            #[cfg(feature = "jit")]
352            slots_all_numeric: true,
353            ext_handler: None,
354            ext_wide_handler: None,
355            builtin_table: Vec::new(),
356            host: None,
357            awk_host: None,
358            awk_rand_seed: 1,
359            awk_signal: None,
360            sched: None,
361            halted: false,
362            #[cfg(feature = "jit")]
363            tracing_jit: false,
364            #[cfg(feature = "jit")]
365            jit: JitCompiler::new(),
366            #[cfg(feature = "jit")]
367            recorder: None,
368            #[cfg(feature = "jit")]
369            slot_buf: Vec::new(),
370            #[cfg(feature = "jit")]
371            slot_kinds_buf: Vec::new(),
372            #[cfg(feature = "jit")]
373            deopt_info: DeoptInfo::zeroed(),
374            #[cfg(feature = "jit")]
375            block_eligible_cached: None,
376            #[cfg(feature = "aot")]
377            aot_result: None,
378            #[cfg(feature = "aot")]
379            aot_arena: Vec::new(),
380            #[cfg(feature = "aot")]
381            aot_free: Vec::new(),
382        }
383    }
384
385    /// Install a [`NumericHook`], switching this VM to strict numeric mode:
386    /// arithmetic that cannot be computed exactly in `i64`/`f64` — a
387    /// non-numeric operand, or integer overflow — is handed to `hook` instead
388    /// of being coerced or wrapped.
389    ///
390    /// Strict mode also constrains the JIT, which otherwise coerces and wraps
391    /// in native code exactly like the default interpreter: the block tier is
392    /// skipped whenever a live slot holds a non-numeric value, and JIT-compiled
393    /// integer `Add`/`Sub`/`Mul` are emitted with overflow checks that bail back
394    /// to the interpreter (where the hook runs). Native code compiled in strict
395    /// mode is cached separately from the coercing kind, so the two never mix.
396    pub fn set_numeric_hook(&mut self, hook: NumericHook) {
397        self.numeric_hook = Some(hook);
398        #[cfg(feature = "jit")]
399        {
400            // Eligibility is cached per chunk hash and is policy-dependent.
401            self.block_eligible_cached = None;
402        }
403    }
404
405    /// Install an [`OutputSink`] so `Op::Print`/`Op::PrintLn` route through
406    /// `sink` instead of `std::io::stdout()`. Frontends running fusevm in a
407    /// browser web worker use this to capture output and bridge it to the JS
408    /// host (wasm has no real stdout). With no sink installed, output is
409    /// byte-for-byte identical to the previous direct-stdout behaviour.
410    pub fn set_output_sink(&mut self, sink: OutputSink) {
411        self.output_sink = Some(sink);
412    }
413
414    /// Install an [`InputSource`] so `Op::ReadLine` pulls from `source` instead
415    /// of `std::io::stdin()`. The closure returns one line per call (newline
416    /// trimmed) or `None` at end of input (pushed as `Value::Undef`).
417    pub fn set_input_source(&mut self, source: InputSource) {
418        self.input_source = Some(source);
419    }
420
421    /// Write `s` to the installed [`OutputSink`], or to `std::io::stdout()` when
422    /// none is set. Callers buffer the print args into one `s` first so the sink
423    /// borrow stays disjoint from the value stack.
424    fn emit_output(&mut self, s: &str) {
425        if let Some(sink) = self.output_sink.as_mut() {
426            sink(s);
427        } else {
428            use std::io::Write;
429            let stdout = std::io::stdout();
430            let mut lock = stdout.lock();
431            let _ = lock.write_all(s.as_bytes());
432        }
433    }
434
435    /// Whether a [`NumericHook`] is installed (strict numeric mode).
436    pub fn is_strict_numeric(&self) -> bool {
437        self.numeric_hook.is_some()
438    }
439
440    /// Narrow the range the VM keeps as a native `Value::Int` (strict mode only).
441    ///
442    /// A Lisp whose integers are tagged has fewer than 64 bits for a fixnum —
443    /// Emacs gets 62, so `most-positive-fixnum` is 2^61-1 — and an arithmetic
444    /// result outside that range is a bignum, *even though it still fits an
445    /// `i64`*. Setting the range makes strict mode delegate those results to the
446    /// [`NumericHook`] alongside true `i64` overflow, so the host can widen them.
447    /// JIT-compiled code carries the same bounds check (two ALU ops folded into
448    /// the overflow accumulator — still no branch on the hot path).
449    ///
450    /// Without this, the host would see only `i64` overflow and integers in the
451    /// 2^61..2^63 band would masquerade as fixnums.
452    pub fn set_fixnum_range(&mut self, lo: i64, hi: i64) {
453        self.fixnum_range = Some((lo, hi));
454        #[cfg(feature = "jit")]
455        {
456            self.block_eligible_cached = None;
457        }
458    }
459
460    /// Whether `n` is representable as a native fixnum under the current range.
461    #[inline(always)]
462    fn in_fixnum_range(&self, n: i64) -> bool {
463        match self.fixnum_range {
464            Some((lo, hi)) => n >= lo && n <= hi,
465            None => true,
466        }
467    }
468
469    /// Enable the tracing JIT for this VM. After this call, hot loops
470    /// (loops crossing the backedge threshold) will be recorded and JIT-
471    /// compiled at runtime; subsequent iterations dispatch through the
472    /// compiled trace.
473    ///
474    /// Phase 1 limits: only int-slot loops with a single backward branch and
475    /// no internal jumps are traceable. Loops outside that envelope continue
476    /// to run in the interpreter.
477    #[cfg(feature = "jit")]
478    pub fn enable_tracing_jit(&mut self) {
479        self.tracing_jit = true;
480    }
481
482    /// Disable the tracing JIT. Existing compiled traces remain in the
483    /// thread-local cache but are no longer consulted from this VM.
484    #[cfg(feature = "jit")]
485    pub fn disable_tracing_jit(&mut self) {
486        self.tracing_jit = false;
487        self.recorder = None;
488    }
489
490    /// Reset the VM for re-use with a new chunk, preserving internal
491    /// `Vec` allocations to avoid the construction cost of `VM::new`.
492    ///
493    /// State that's cleared:
494    /// - Value stack (truncated, capacity preserved)
495    /// - Frame stack (rebuilt with one entry pointing at the new chunk)
496    /// - Globals (resized to match the new chunk's name pool)
497    /// - Instruction pointer, halted flag, exit status
498    /// - Tracing JIT recorder / slot buffers / deopt info
499    /// - Cached block-JIT eligibility (the new chunk has a different hash)
500    ///
501    /// State that's preserved:
502    /// - Tracing JIT enabled flag
503    /// - Extension handlers (`ext_handler`, `ext_wide_handler`)
504    /// - Builtin table
505    /// - Shell host
506    ///
507    /// This pairs with [`VMPool`] for hot-path callers that run many
508    /// chunks back-to-back and want to skip the per-call allocation cost
509    /// of `VM::new`.
510    pub fn reset(&mut self, chunk: Chunk) {
511        self.stack.clear();
512        self.frames.clear();
513        let num_names = chunk.names.len();
514        self.globals.clear();
515        self.globals.resize(num_names, Value::Undef);
516        self.frames.push(Frame {
517            return_ip: 0,
518            stack_base: 0,
519            slots: Vec::with_capacity(16),
520        });
521        self.ip = 0;
522        self.last_status = 0;
523        self.halted = false;
524        self.awk_rand_seed = 1;
525        self.chunk = chunk;
526        #[cfg(feature = "jit")]
527        {
528            self.recorder = None;
529            self.slot_buf.clear();
530            self.slot_kinds_buf.clear();
531            self.deopt_info = DeoptInfo::zeroed();
532            self.block_eligible_cached = None;
533        }
534    }
535
536    /// Register the frontend shell host. Replaces any prior host.
537    pub fn set_shell_host(&mut self, host: Box<dyn ShellHost>) {
538        self.host = Some(host);
539    }
540
541    /// Register the frontend AWK host. Replaces any prior host. The VM then
542    /// routes the reserved AWK op range to it (see [`crate::awk_host::AwkHost`]).
543    pub fn set_awk_host(&mut self, host: Box<dyn AwkHost>) {
544        self.awk_host = Some(host);
545    }
546
547    /// AWK control-flow signal raised by the most recent `run()`, if any. An
548    /// awk frontend reads this after `run()` returns to map `Op::AwkSignal`
549    /// codes (`awk_builtins::signal::{NEXT,NEXTFILE,EXIT}`) onto its own
550    /// record/file/exit control flow. `None` when no signal was raised (always
551    /// the case for zshrs/stryke, which never emit `Op::AwkSignal`).
552    pub fn awk_signal(&self) -> Option<u8> {
553        self.awk_signal
554    }
555
556    /// Take the pending cooperative-concurrency scheduling request, if any. The
557    /// [`crate::sched::Scheduler`] driver calls this after `run()` returns: `Some`
558    /// means a goroutine/channel op halted the VM to request scheduling; `None`
559    /// means the VM finished (or halted for another reason). Clears the slot.
560    pub fn take_sched(&mut self) -> Option<crate::sched::SchedReq> {
561        self.sched.take()
562    }
563
564    /// Clear the halt flag so a parked goroutine VM resumes on the next `run()`
565    /// (which continues from the current `ip`, past the op that parked it). Used
566    /// by [`crate::sched::Scheduler`]; a plain schedulerless `run()` never needs it.
567    pub fn clear_halt(&mut self) {
568        self.halted = false;
569    }
570
571    /// Register a handler for `Op::Extended(id, arg)` opcodes.
572    pub fn set_extension_handler(&mut self, handler: ExtensionHandler) {
573        self.ext_handler = Some(handler);
574    }
575
576    /// Register a handler for `Op::ExtendedWide(id, payload)` opcodes.
577    pub fn set_extension_wide_handler(&mut self, handler: ExtensionWideHandler) {
578        self.ext_wide_handler = Some(handler);
579    }
580
581    /// Register a builtin function by ID. `CallBuiltin(id, argc)` dispatches
582    /// directly through the function pointer — no name lookup at runtime.
583    pub fn register_builtin(&mut self, id: u16, handler: BuiltinHandler) {
584        let idx = id as usize;
585        if idx >= self.builtin_table.len() {
586            self.builtin_table.resize(idx + 1, None);
587        }
588        self.builtin_table[idx] = Some(handler);
589    }
590
591    /// Run a shell builtin by **name** at run time — the runtime analog of the
592    /// compile-time [`Op::CallBuiltin`] opcode. The compiler resolves a literal
593    /// command name to a builtin id and emits `CallBuiltin`, so builtins only
594    /// dispatch for names known at compile time. A host that resolves a name
595    /// only at run time — `builtin NAME`, `$var` command indirection, `eval`
596    /// of a computed name — needs this to reach the same builtins.
597    ///
598    /// Resolves `name` via [`crate::shell_builtins::builtin_id`], pushes `args`
599    /// as string values in argument order (identical to what the compiler emits
600    /// ahead of `CallBuiltin`, which the handler's arg-pop reverses back), then
601    /// invokes the registered handler and returns its status value. Returns
602    /// `None` when `name` is not a known builtin or has no registered handler,
603    /// so the caller falls through to function / external lookup.
604    pub fn run_builtin_by_name(&mut self, name: &str, args: &[String]) -> Option<Value> {
605        let id = crate::shell_builtins::builtin_id(name)?;
606        let handler = match self.builtin_table.get(id as usize) {
607            Some(Some(h)) => *h,
608            _ => return None,
609        };
610        // `CallBuiltin`'s argc is a u8, so builtins take at most 255 args;
611        // push exactly that many so the handler's arg-pop stays balanced.
612        let argc = args.len().min(u8::MAX as usize);
613        for a in &args[..argc] {
614            self.push(Value::Str(std::sync::Arc::new(a.clone())));
615        }
616        Some(handler(self, argc as u8))
617    }
618
619    /// Externally request the VM to halt after the current op finishes.
620    /// Used by host-side shell semantics like `set -e` post-command checks
621    /// and `exit` from inside builtins to stop dispatch at a safe point.
622    pub fn request_halt(&mut self) {
623        self.halted = true;
624    }
625
626    // ── Tracing JIT integration helpers ──
627
628    /// Snapshot the current frame's slots into the i64 + slot-kind buffers.
629    ///
630    /// Slots that don't fit cleanly into i64 (Array/Hash/String/etc) are
631    /// reported as `SlotKind::Int` with i64 value 0 — they will fail the
632    /// trace's entry guard if the recorded trace expected Int there, which
633    /// is the desired behavior (skip the trace, fall back to interpreter).
634    ///
635    /// Specialized fast paths for 0-slot and 1-slot frames (the common
636    /// case for tight inner loops) — these skip Vec resize bookkeeping
637    /// and the iterator loop, saving ~20-50 ns per `vm.run()` invocation.
638    #[cfg(feature = "jit")]
639    #[inline]
640    fn refresh_slot_buffers(&mut self) {
641        let frame = match self.frames.last() {
642            Some(f) => f,
643            None => return,
644        };
645        let n = frame.slots.len();
646        let mut all_num = true;
647        match n {
648            0 => {
649                self.slot_buf.clear();
650                self.slot_kinds_buf.clear();
651            }
652            1 => {
653                let (i, kind) = match &frame.slots[0] {
654                    Value::Int(v) => (*v, SlotKind::Int),
655                    Value::Float(f) => (f.to_bits() as i64, SlotKind::Float),
656                    Value::Bool(b) => (*b as i64, SlotKind::Int),
657                    _ => {
658                        all_num = false;
659                        (0, SlotKind::Int)
660                    }
661                };
662                if self.slot_buf.is_empty() {
663                    self.slot_buf.push(i);
664                    self.slot_kinds_buf.push(kind);
665                } else {
666                    self.slot_buf.truncate(1);
667                    self.slot_buf[0] = i;
668                    self.slot_kinds_buf.truncate(1);
669                    self.slot_kinds_buf[0] = kind;
670                }
671            }
672            _ => {
673                self.slot_buf.clear();
674                self.slot_kinds_buf.clear();
675                self.slot_buf.reserve(n);
676                self.slot_kinds_buf.reserve(n);
677                for v in &frame.slots {
678                    let (i, kind) = match v {
679                        Value::Int(n) => (*n, SlotKind::Int),
680                        Value::Float(f) => (f.to_bits() as i64, SlotKind::Float),
681                        Value::Bool(b) => (*b as i64, SlotKind::Int),
682                        _ => {
683                            all_num = false;
684                            (0, SlotKind::Int)
685                        }
686                    };
687                    self.slot_buf.push(i);
688                    self.slot_kinds_buf.push(kind);
689                }
690            }
691        }
692        self.slots_all_numeric = all_num;
693    }
694
695    /// Copy the i64 slot buffer back into the current frame's slots,
696    /// materializing Int and Float kinds. Float slots are stored as i64
697    /// bit patterns in the buffer; recover via `f64::from_bits`. Slots of
698    /// other kinds (Array, Hash, etc.) are left untouched — those slots
699    /// would have prevented trace install if referenced.
700    ///
701    /// Specialized for 0/1-slot frames (common case).
702    #[cfg(feature = "jit")]
703    #[inline]
704    fn write_slots_back(&mut self) {
705        let frame = match self.frames.last_mut() {
706            Some(f) => f,
707            None => return,
708        };
709        let n = frame.slots.len().min(self.slot_buf.len());
710        match n {
711            0 => {}
712            1 => match self.slot_kinds_buf.first() {
713                Some(SlotKind::Int) => frame.slots[0] = Value::Int(self.slot_buf[0]),
714                Some(SlotKind::Float) => {
715                    frame.slots[0] = Value::Float(f64::from_bits(self.slot_buf[0] as u64));
716                }
717                None => {}
718            },
719            _ => {
720                for i in 0..n {
721                    match self.slot_kinds_buf.get(i) {
722                        Some(SlotKind::Int) => {
723                            frame.slots[i] = Value::Int(self.slot_buf[i]);
724                        }
725                        Some(SlotKind::Float) => {
726                            frame.slots[i] = Value::Float(f64::from_bits(self.slot_buf[i] as u64));
727                        }
728                        None => {}
729                    }
730                }
731            }
732        }
733    }
734
735    /// Consult the trace cache at a backward-branch site and return the IP
736    /// the interpreter should resume at. If a compiled trace runs, slot state
737    /// is copied back from the trace's i64 buffer into the frame, and any
738    /// inlined callee frames the trace recorded at a side-exit are
739    /// materialized as synthetic `Frame`s on `self.frames` so the
740    /// interpreter can resume mid-callee with a correctly shaped call stack.
741    #[cfg(feature = "jit")]
742    fn lookup_trace_for_backward(&mut self, anchor_ip: usize, fallthrough_ip: usize) -> usize {
743        self.refresh_slot_buffers();
744        let lookup = self.jit.trace_lookup(
745            &self.chunk,
746            anchor_ip,
747            &mut self.slot_buf,
748            &self.slot_kinds_buf,
749            &mut self.deopt_info,
750        );
751        match lookup {
752            TraceLookup::Ran { resume_ip } => {
753                self.write_slots_back();
754                self.materialize_deopt_frames();
755                // Phase 9: if the trace deopted (returned non-fallthrough),
756                // try to chain into a side trace at the resume IP.
757                self.chain_side_traces(anchor_ip, fallthrough_ip, resume_ip)
758            }
759            TraceLookup::StartRecording => {
760                // Main-trace path: record_anchor_ip == close_anchor_ip
761                // (the loop header). Side-trace recording is armed via the
762                // chained-dispatch path below.
763                self.recorder = Some(TraceRecorder {
764                    record_anchor_ip: anchor_ip,
765                    close_anchor_ip: anchor_ip,
766                    fallthrough_ip,
767                    ops: Vec::new(),
768                    recorded_ips: Vec::new(),
769                    slot_kinds_at_anchor: self.slot_kinds_buf.clone(),
770                    entered_ips: Vec::new(),
771                    aborted: false,
772                });
773                anchor_ip
774            }
775            TraceLookup::NotHot | TraceLookup::GuardMismatch | TraceLookup::Skip => anchor_ip,
776        }
777    }
778
779    /// Phase 9: chained dispatch through linked traces.
780    ///
781    /// When the main trace's `compiled.invoke` returns a non-fallthrough
782    /// resume IP (a brif guard fired and we side-exited), this method
783    /// attempts to dispatch a side trace registered at that resume IP.
784    /// Iterates up to `MAX_TRACE_CHAIN` times so a sequence of linked
785    /// side-exits can resolve through their respective side traces.
786    ///
787    /// Side-trace recording is armed when a side-exit IP becomes hot (the
788    /// `StartRecording` branch). The recorder is set up with
789    /// `close_anchor_ip = main_anchor`, so the side trace closes correctly
790    /// when the enclosing loop's backward branch fires.
791    ///
792    /// The main trace's `side_exit_count` is incremented only when the
793    /// chain bottoms out without finding a side trace — exits that are
794    /// being absorbed productively shouldn't push the main trace toward
795    /// blacklisting.
796    #[cfg(feature = "jit")]
797    fn chain_side_traces(
798        &mut self,
799        main_anchor: usize,
800        main_fallthrough: usize,
801        first_resume: usize,
802    ) -> usize {
803        let mut current = first_resume;
804        if current == main_fallthrough {
805            return current;
806        }
807        let max_chain = self.jit.get_config().max_trace_chain;
808        for _ in 0..max_chain {
809            // The chained trace at `current` may itself have a different
810            // fallthrough; we re-fetch on each iteration.
811            let chained_fallthrough = self
812                .jit
813                .trace_loop_anchors(&self.chunk, current)
814                .map(|(_, fallthrough)| fallthrough);
815
816            self.refresh_slot_buffers();
817            let lookup = self.jit.trace_lookup(
818                &self.chunk,
819                current,
820                &mut self.slot_buf,
821                &self.slot_kinds_buf,
822                &mut self.deopt_info,
823            );
824            match lookup {
825                TraceLookup::Ran { resume_ip } => {
826                    self.write_slots_back();
827                    self.materialize_deopt_frames();
828                    current = resume_ip;
829                    if Some(current) == chained_fallthrough {
830                        return current;
831                    }
832                }
833                TraceLookup::StartRecording => {
834                    // Arm side-trace recording. The side trace's close
835                    // anchor is the main loop's header; its fallthrough is
836                    // the main loop's post-loop IP. Slot-kind snapshot is
837                    // taken at THIS moment (post-deopt state).
838                    self.recorder = Some(TraceRecorder {
839                        record_anchor_ip: current,
840                        close_anchor_ip: main_anchor,
841                        fallthrough_ip: main_fallthrough,
842                        ops: Vec::new(),
843                        recorded_ips: Vec::new(),
844                        slot_kinds_at_anchor: self.slot_kinds_buf.clone(),
845                        entered_ips: Vec::new(),
846                        aborted: false,
847                    });
848                    return current;
849                }
850                _ => {
851                    // No side trace available; count toward main trace's
852                    // blacklist budget.
853                    self.jit.trace_bump_side_exit(&self.chunk, main_anchor);
854                    return current;
855                }
856            }
857        }
858        current
859    }
860
861    /// Push synthetic `Frame`s onto `self.frames` for each inlined callee
862    /// frame the trace recorded at side-exit, then push any remaining
863    /// abstract-stack values from the trace onto `self.stack` as
864    /// `Value::Int`. Order matters: stack values are pushed BEFORE the
865    /// frames are pushed, because each frame's `stack_base` snapshots
866    /// `self.stack.len()` AFTER the stack values are placed — that way
867    /// when the synthetic frame eventually returns and truncates to
868    /// `stack_base`, those values are correctly retained. Phase 5+.
869    #[cfg(feature = "jit")]
870    fn materialize_deopt_frames(&mut self) {
871        // 1. Push the abstract stack (in trace order; entry [0] ends up at
872        //    the bottom, [N-1] at the top). Float entries are bit-cast back
873        //    via `f64::from_bits`; everything else is treated as `i64`.
874        let stack_count = self.deopt_info.stack_count;
875        for i in 0..stack_count {
876            let raw = self.deopt_info.stack_buf[i];
877            let v = match self.deopt_info.stack_kinds[i] {
878                crate::jit::STACK_KIND_FLOAT => Value::Float(f64::from_bits(raw as u64)),
879                _ => Value::Int(raw),
880            };
881            self.stack.push(v);
882        }
883        // 2. Materialize inlined frames.
884        let count = self.deopt_info.frame_count;
885        if count == 0 {
886            return;
887        }
888        for i in 0..count {
889            let df = &self.deopt_info.frames[i];
890            let mut slots: Vec<Value> = Vec::with_capacity(df.slot_count);
891            for j in 0..df.slot_count {
892                slots.push(Value::Int(df.slots[j]));
893            }
894            self.frames.push(Frame {
895                return_ip: df.return_ip,
896                stack_base: self.stack.len(),
897                slots,
898            });
899        }
900    }
901
902    /// Finalize the active recording: either close (install) when the just-
903    /// dispatched jump landed back at the anchor and the trace is eligible,
904    /// or abort and discard. Safe to call only when `self.recorder.is_some()`.
905    #[cfg(feature = "jit")]
906    fn finalize_recorder(&mut self) {
907        let Some(rec) = self.recorder.as_ref() else {
908            return;
909        };
910        let aborted = rec.aborted;
911        let close_anchor = rec.close_anchor_ip;
912        let record_anchor = rec.record_anchor_ip;
913        // Trace closes when the just-dispatched jump lands at the recorded
914        // close anchor. For main traces this is the loop header; for side
915        // traces it's the enclosing loop's header (the side trace
916        // started at a side-exit IP but still closes when the loop iterates).
917        if !aborted && self.ip == close_anchor {
918            let r = self.recorder.take().unwrap();
919            if self.jit.is_trace_eligible(&r.ops, r.close_anchor_ip) {
920                // Phase 9: when record != close (side trace), install via
921                // the kinded variant so the IR's "continuation" branch
922                // exits rather than looping back.
923                let installed = self.jit.trace_install_with_kind(
924                    &self.chunk,
925                    r.record_anchor_ip,
926                    r.close_anchor_ip,
927                    r.fallthrough_ip,
928                    &r.ops,
929                    &r.recorded_ips,
930                    &r.slot_kinds_at_anchor,
931                );
932                if !installed {
933                    self.jit.trace_abort(&self.chunk, r.record_anchor_ip);
934                }
935            } else {
936                self.jit.trace_abort(&self.chunk, r.record_anchor_ip);
937            }
938        } else {
939            // Trace dispatch landed somewhere unexpected — abort. The
940            // record_anchor_ip captured before take() is the cache key.
941            let _ = self.recorder.take();
942            self.jit.trace_abort(&self.chunk, record_anchor);
943        }
944    }
945
946    // ── Stack operations ──
947
948    /// Push `val` onto the value stack. Inlined for hot-path callers
949    /// (extension handlers, builtin shims) that bypass the dispatch
950    /// loop's own push.
951    #[inline(always)]
952    pub fn push(&mut self, val: Value) {
953        self.stack.push(val);
954    }
955
956    /// Pop the top of the value stack, returning `Value::Undef` if the
957    /// stack is empty. Returning Undef rather than panicking matches
958    /// Perl's "underflow is undef" semantic and lets extension/builtin
959    /// handlers stay panic-free under malformed bytecode.
960    #[inline(always)]
961    pub fn pop(&mut self) -> Value {
962        self.stack.pop().unwrap_or(Value::Undef)
963    }
964
965    /// Borrow the top of the value stack without popping. Returns a
966    /// reference to `Value::Undef` when the stack is empty.
967    #[inline(always)]
968    pub fn peek(&self) -> &Value {
969        self.stack.last().unwrap_or(&Value::Undef)
970    }
971
972    // ── Type-specialized helpers (avoid to_float coercion on hot paths) ──
973
974    /// Pop two values; if both Int, apply int_op. Otherwise promote to float.
975    ///
976    /// `ck_op` is the checked form of `int_op` and is consulted only in strict
977    /// numeric mode (a [`NumericHook`] is installed), where an integer result
978    /// that does not fit `i64` — and any operand that is not a number — goes to
979    /// the host instead of being wrapped or coerced. Returns the error message
980    /// if the hook signalled.
981    #[inline(always)]
982    fn arith_int_fast(
983        &mut self,
984        op: NumOp,
985        int_op: fn(i64, i64) -> i64,
986        ck_op: fn(i64, i64) -> Option<i64>,
987        float_op: fn(f64, f64) -> f64,
988    ) -> Option<String> {
989        let len = self.stack.len();
990        if len < 2 {
991            return None;
992        }
993        // Borrow both slots without popping (avoid branch + unwrap_or)
994        let b = &self.stack[len - 1];
995        let a = &self.stack[len - 2];
996        let result = match (a, b, &self.numeric_hook) {
997            // Fast path, both policies: two fixnums.
998            (Value::Int(x), Value::Int(y), None) => Value::Int(int_op(*x, *y)),
999            (Value::Int(x), Value::Int(y), Some(hook)) => match ck_op(*x, *y) {
1000                // Exact, and inside the host's fixnum range.
1001                Some(r) if self.in_fixnum_range(r) => Value::Int(r),
1002                // Overflowed `i64`, or left the host's fixnum range: either way
1003                // only the host can represent it (a bignum).
1004                _ => match hook(op, a, b) {
1005                    Ok(v) => v,
1006                    Err(e) => return Some(e),
1007                },
1008            },
1009            // Mixed int/float and float/float are exact in f64 under either
1010            // policy — never delegate.
1011            (a, b, _) if is_native_num(a) && is_native_num(b) => {
1012                Value::Float(float_op(a.to_float(), b.to_float()))
1013            }
1014            // A non-number. Coerce (awk/shell) or delegate (strict).
1015            (a, b, None) => Value::Float(float_op(a.to_float(), b.to_float())),
1016            (a, b, Some(hook)) => match hook(op, a, b) {
1017                Ok(v) => v,
1018                Err(e) => return Some(e),
1019            },
1020        };
1021        self.stack.truncate(len - 2);
1022        self.stack.push(result);
1023        None
1024    }
1025
1026    /// Pop two values; compare as int if both Int, otherwise float.
1027    /// Push Bool(true/false).
1028    ///
1029    /// Comparison cannot overflow, so in strict numeric mode only a non-numeric
1030    /// operand delegates to the [`NumericHook`].
1031    #[inline(always)]
1032    fn cmp_int_fast(
1033        &mut self,
1034        op: NumOp,
1035        int_cmp: fn(i64, i64) -> bool,
1036        float_cmp: fn(f64, f64) -> bool,
1037    ) -> Option<String> {
1038        let len = self.stack.len();
1039        if len < 2 {
1040            return None;
1041        }
1042        let b = &self.stack[len - 1];
1043        let a = &self.stack[len - 2];
1044        let result = match (a, b, &self.numeric_hook) {
1045            (Value::Int(x), Value::Int(y), _) => Value::Bool(int_cmp(*x, *y)),
1046            (a, b, _) if is_native_num(a) && is_native_num(b) => {
1047                Value::Bool(float_cmp(a.to_float(), b.to_float()))
1048            }
1049            (a, b, None) => Value::Bool(float_cmp(a.to_float(), b.to_float())),
1050            (a, b, Some(hook)) => match hook(op, a, b) {
1051                Ok(v) => v,
1052                Err(e) => return Some(e),
1053            },
1054        };
1055        self.stack.truncate(len - 2);
1056        self.stack.push(result);
1057        None
1058    }
1059
1060    /// Pop two operands and hand them to the [`NumericHook`]. Only called in
1061    /// strict mode, for the ops (`Div`, `Pow`) whose native path is float-only
1062    /// and therefore has no overflow case to check — just a type case.
1063    #[inline(always)]
1064    fn delegate_binary(&mut self, op: NumOp) -> Option<String> {
1065        let b = self.pop();
1066        let a = self.pop();
1067        let hook = match &self.numeric_hook {
1068            Some(h) => h,
1069            None => return None,
1070        };
1071        match hook(op, &a, &b) {
1072            Ok(v) => {
1073                self.push(v);
1074                None
1075            }
1076            Err(e) => Some(e),
1077        }
1078    }
1079
1080    /// Unary negate, strict-aware: `i64::MIN` has no positive counterpart and a
1081    /// non-number is not negatable, so both go to the [`NumericHook`].
1082    #[inline(always)]
1083    fn negate_strict(&mut self) -> Option<String> {
1084        let val = self.pop();
1085        let result = match (&val, &self.numeric_hook) {
1086            (Value::Int(n), None) => Value::Int(n.wrapping_neg()),
1087            (Value::Int(n), Some(hook)) => match n.checked_neg() {
1088                Some(r) if self.in_fixnum_range(r) => Value::Int(r),
1089                _ => match hook(NumOp::Neg, &val, &Value::Undef) {
1090                    Ok(v) => v,
1091                    Err(e) => return Some(e),
1092                },
1093            },
1094            (Value::Float(f), _) => Value::Float(-f),
1095            (v, None) => Value::Float(-v.to_float()),
1096            (v, Some(hook)) => match hook(NumOp::Neg, v, &Value::Undef) {
1097                Ok(v) => v,
1098                Err(e) => return Some(e),
1099            },
1100        };
1101        self.push(result);
1102        None
1103    }
1104
1105    // ── Main dispatch loop ──
1106
1107    /// Execute the loaded chunk until completion or error.
1108    ///
1109    /// Phase 10: tiered auto-dispatch. When `tracing_jit` is enabled the
1110    /// VM consults all three Cranelift tiers in priority order:
1111    ///
1112    /// 1. **Block JIT** — if the entire chunk is block-eligible, the
1113    ///    block-JIT cache returns `Some(result)` after its own warmup
1114    ///    threshold and the whole chunk runs in native code with zero
1115    ///    interpreter dispatch.
1116    /// 2. **Tracing JIT** — when block JIT doesn't apply, the dispatch
1117    ///    loop runs with the recorder armed at backward branches; hot
1118    ///    loops compile to traces that take over subsequent iterations.
1119    /// 3. **Interpreter** — fallback for cold code and chunks neither
1120    ///    tier handles.
1121    ///
1122    /// Block JIT is tried first because, when it applies, it has zero
1123    /// VM-side overhead (direct fn-ptr through the slot pointer). For
1124    /// chunks block JIT can't take, control falls through to the
1125    /// interpreter with tracing JIT integrated. The two tiers don't
1126    /// compete on the same chunk: block-eligible chunks short-circuit
1127    /// before tracing JIT records anything.
1128    pub fn run(&mut self) -> VMResult {
1129        // A fresh execution: clear any AWK signal raised by a prior run on a
1130        // reused VM. (zshrs/stryke never emit `Op::AwkSignal`, so this stays
1131        // `None` for them.)
1132        self.awk_signal = None;
1133        // A prior park request is consumed by the scheduler before it resumes
1134        // this VM; clear any stragglers so a plain (schedulerless) run is unaffected.
1135        self.sched = None;
1136        // Phase 10: try block JIT first for fully-eligible chunks. The
1137        // block-JIT cache has its own threshold (10 invocations); the
1138        // call returns None until it warms up, at which point the whole
1139        // chunk runs in native code.
1140        //
1141        // VM-side eligibility cache (`block_eligible_cached`) saves the
1142        // TLS HashMap lookup `JitCompiler::is_block_eligible` would
1143        // otherwise do on every run. The slot buffer must be valid on
1144        // every invocation because `try_run_block` may compile + invoke
1145        // the same call (on threshold cross), so we can't skip the
1146        // refresh based on warmup state.
1147        // Publish this VM's numeric policy to the thread's JIT: it decides op
1148        // eligibility, salts every cache key, and switches integer arithmetic to
1149        // its overflow-checked lowering.
1150        #[cfg(feature = "jit")]
1151        crate::jit::set_strict_numeric(self.numeric_hook.is_some(), self.fixnum_range);
1152
1153        #[cfg(feature = "jit")]
1154        if self.tracing_jit && self.frames.len() == 1 && self.ip == 0 {
1155            let eligible = match self.block_eligible_cached {
1156                Some(v) => v,
1157                None => {
1158                    let v = self.jit.is_block_eligible(&self.chunk);
1159                    self.block_eligible_cached = Some(v);
1160                    v
1161                }
1162            };
1163            if eligible {
1164                self.refresh_slot_buffers();
1165                // Strict numeric mode: a non-numeric slot reaches native code as
1166                // the integer 0 (`refresh_slot_buffers` has no richer encoding),
1167                // which is precisely the silent coercion the `NumericHook` exists
1168                // to reject. Falling out of this block runs the chunk in the
1169                // interpreter, where the hook sees the real value.
1170                let strict_blocked = self.numeric_hook.is_some() && !self.slots_all_numeric;
1171                if !strict_blocked {
1172                    if let Some(result) = self.jit.try_run_block_typed_kinded(
1173                        &self.chunk,
1174                        &mut self.slot_buf,
1175                        &self.slot_kinds_buf,
1176                    ) {
1177                        // Strict numeric mode: an integer op in the compiled block
1178                        // overflowed i64. The native result is the wrapped (wrong)
1179                        // value, so discard it *and* skip the slot writeback — the
1180                        // block's ops are pure, so nothing has escaped — and let the
1181                        // interpreter re-run the chunk, where the `NumericHook`
1182                        // produces the exact result (elisp: a bignum).
1183                        if crate::jit::take_num_overflow_trap() {
1184                            self.ip = 0;
1185                        } else {
1186                            // A JIT-compiled AwkDivJit/AwkModJit may have hit a zero
1187                            // divisor and set the thread-local trap code before
1188                            // returning. Honor it as the awk fatal, discarding the
1189                            // (garbage) block result and slot writeback.
1190                            match crate::jit::take_awk_div_trap() {
1191                                1 => {
1192                                    return VMResult::Error(
1193                                        "division by zero attempted".to_string(),
1194                                    )
1195                                }
1196                                2 => {
1197                                    return VMResult::Error(
1198                                        "division by zero attempted in `%'".to_string(),
1199                                    )
1200                                }
1201                                3 => {
1202                                    return VMResult::Error(
1203                                        "lshift: negative values are not allowed".to_string(),
1204                                    )
1205                                }
1206                                4 => {
1207                                    return VMResult::Error(
1208                                        "rshift: negative values are not allowed".to_string(),
1209                                    )
1210                                }
1211                                5 => {
1212                                    return VMResult::Error(
1213                                        "compl: negative value is not allowed".to_string(),
1214                                    )
1215                                }
1216                                _ => {}
1217                            }
1218                            self.write_slots_back();
1219                            self.halted = true;
1220                            // The block tier returns its result in an i64 register, with a
1221                            // float riding back as its raw bit pattern. Decode through
1222                            // `BlockNum` — wrapping the register in `Value::Int`
1223                            // unconditionally (as this did before) truncated every float
1224                            // chunk result to an integer on the second and later runs of
1225                            // a chunk, once the block cache was warm: `LoadFloat(2.5)`
1226                            // returned `Int(2)`.
1227                            return VMResult::Ok(match result {
1228                                crate::jit::BlockNum::Int(n) => Value::Int(n),
1229                                crate::jit::BlockNum::Float(f) => Value::Float(f),
1230                            });
1231                        }
1232                    }
1233                }
1234            }
1235        }
1236
1237        let ops = &self.chunk.ops as *const Vec<Op>;
1238        // SAFETY: self.chunk.ops is not mutated during execution.
1239        // We take a pointer to avoid borrow checker issues with &self.chunk.ops
1240        // while mutating self.stack/frames/globals.
1241        let ops = unsafe { &*ops };
1242
1243        while self.ip < ops.len() && !self.halted {
1244            // Zero-clone: borrow the op instead of cloning
1245            let ip = self.ip;
1246            self.ip += 1;
1247
1248            // Tracing JIT: capture this op into the active recording, if any.
1249            // Push happens before dispatch so the closing branch is included.
1250            // Track whether the recorder was armed BEFORE this dispatch step —
1251            // a recorder armed inside this step (via `StartRecording`) must not
1252            // finalize until the NEXT iteration, otherwise the very dispatch
1253            // step that armed it would also close it with an empty op list.
1254            #[cfg(feature = "jit")]
1255            let recorder_was_armed = self.recorder.is_some();
1256            #[cfg(not(feature = "jit"))]
1257            let recorder_was_armed = false;
1258            #[cfg(feature = "jit")]
1259            if self.recorder.is_some() {
1260                let cfg = self.jit.get_config();
1261                let max_len = cfg.max_trace_len;
1262                let max_inline_recursion = cfg.max_inline_recursion;
1263                let cur_op = ops[ip].clone();
1264                // Resolve sub-entry up front (immutable chunk borrow) so the
1265                // mutable recorder borrow below doesn't collide.
1266                let resolved_entry = if let Op::Call(name_idx, _) = &cur_op {
1267                    self.chunk.find_sub(*name_idx)
1268                } else {
1269                    None
1270                };
1271                let rec = self.recorder.as_mut().unwrap();
1272                if !rec.aborted {
1273                    rec.ops.push(cur_op.clone());
1274                    rec.recorded_ips.push(ip);
1275                    if rec.ops.len() > max_len {
1276                        rec.aborted = true;
1277                    } else {
1278                        // Maintain inlined-frame stack and abort on patterns
1279                        // the trace JIT can't represent in phase 2.
1280                        match cur_op {
1281                            Op::Call(_, _) => match resolved_entry {
1282                                Some(entry_ip) => {
1283                                    // Phase 8: bounded recursion. Allow a
1284                                    // self-call to be inlined up to
1285                                    // `MAX_INLINE_RECURSION` levels deep
1286                                    // before aborting. Each push is one
1287                                    // level — the depth limit naturally
1288                                    // bounds tail-recursive helpers without
1289                                    // explicit base-case detection.
1290                                    let occurrences = rec
1291                                        .entered_ips
1292                                        .iter()
1293                                        .filter(|&&ip| ip == entry_ip)
1294                                        .count();
1295                                    if occurrences >= max_inline_recursion {
1296                                        rec.aborted = true;
1297                                    } else {
1298                                        rec.entered_ips.push(entry_ip);
1299                                    }
1300                                }
1301                                None => rec.aborted = true, // unknown sub
1302                            },
1303                            Op::Return | Op::ReturnValue => {
1304                                if rec.entered_ips.is_empty() {
1305                                    rec.aborted = true; // unbalanced — would
1306                                                        // pop the caller frame
1307                                } else {
1308                                    rec.entered_ips.pop();
1309                                }
1310                            }
1311                            Op::CallBuiltin(_, _) => rec.aborted = true,
1312                            // Most AWK ops are host calls (like CallBuiltin) and
1313                            // can't appear in a compiled trace — abort. Pure ops
1314                            // with native CLIF codegen in `emit_data_op`
1315                            // (e.g. AwkInt → trunc) are omitted here so they can
1316                            // be recorded and compiled.
1317                            Op::AwkFieldGet
1318                            | Op::AwkFieldSet
1319                            | Op::AwkNf
1320                            | Op::AwkSetRecord
1321                            | Op::AwkSpecialGet(_)
1322                            | Op::AwkSpecialSet(_)
1323                            | Op::AwkPrint(_)
1324                            | Op::AwkPrintf(_)
1325                            | Op::AwkSprintf(_)
1326                            | Op::AwkGetline(_)
1327                            | Op::AwkLength(_)
1328                            | Op::AwkSubstr(_)
1329                            | Op::AwkIndex
1330                            | Op::AwkSplit(_)
1331                            | Op::AwkSub(_)
1332                            | Op::AwkGsub(_)
1333                            | Op::AwkGensub(_)
1334                            | Op::AwkMatch
1335                            | Op::AwkToLower
1336                            | Op::AwkToUpper
1337                            | Op::AwkSqrt
1338                            | Op::AwkSin
1339                            | Op::AwkCos
1340                            | Op::AwkExp
1341                            | Op::AwkLog
1342                            | Op::AwkAtan2
1343                            | Op::AwkDiv
1344                            | Op::AwkMod
1345                            | Op::AwkDivJit
1346                            | Op::AwkModJit
1347                            | Op::AwkSqrtJit
1348                            | Op::AwkLogJit
1349                            | Op::AwkLshiftJit
1350                            | Op::AwkRshiftJit
1351                            | Op::AwkComplJit
1352                            | Op::AwkAnd(_)
1353                            | Op::AwkOr(_)
1354                            | Op::AwkXor(_)
1355                            | Op::AwkCompl
1356                            | Op::AwkLshift
1357                            | Op::AwkRshift
1358                            | Op::AwkStrtonum
1359                            | Op::AwkSystime
1360                            | Op::AwkRand
1361                            | Op::AwkSrand(_)
1362                            | Op::AwkStrftime(_)
1363                            | Op::AwkMktime(_)
1364                            | Op::AwkOrd
1365                            | Op::AwkChr
1366                            | Op::AwkMkbool
1367                            | Op::AwkIntdiv
1368                            | Op::AwkIntdiv0
1369                            | Op::AwkArrayGet(_)
1370                            | Op::AwkArraySet(_)
1371                            | Op::AwkArrayExists(_)
1372                            | Op::AwkArrayDelete(_)
1373                            | Op::AwkArrayClear(_)
1374                            | Op::AwkArrayLen(_) => rec.aborted = true,
1375                            Op::AwkSignal(_) => rec.aborted = true,
1376                            // Concurrency ops halt for the scheduler; never trace.
1377                            Op::Go(_, _)
1378                            | Op::ChanMake
1379                            | Op::ChanSend
1380                            | Op::ChanRecv
1381                            | Op::ChanClose
1382                            | Op::Select(_, _)
1383                            | Op::CallDynamic(_) => rec.aborted = true,
1384                            _ => {}
1385                        }
1386                    }
1387                }
1388            }
1389
1390            match self.exec_op(ops, ip, recorder_was_armed) {
1391                ExecFlow::Cont => {}
1392                ExecFlow::Ret(r) => return r,
1393            }
1394        }
1395
1396        if let Some(val) = self.stack.pop() {
1397            VMResult::Ok(val)
1398        } else {
1399            VMResult::Halted
1400        }
1401    }
1402
1403    /// Execute the single op at `ops[ip]` — the **single source of truth** for op
1404    /// semantics, shared by the interpreter loop ([`VM::run`]) and the AOT
1405    /// closed-world compiler (`fusevm::aot`), which emits native code that calls
1406    /// this for every op it does not specialize. Returns [`ExecFlow::Ret`] when
1407    /// an op terminates the run, else [`ExecFlow::Cont`]. Arms that previously
1408    /// `continue`d the dispatch loop now `return ExecFlow::Cont`, which skips the
1409    /// trailing recorder-finalize step exactly as `continue` did.
1410    #[cfg_attr(not(feature = "jit"), allow(unused_variables))]
1411    pub(crate) fn exec_op(&mut self, ops: &[Op], ip: usize, recorder_was_armed: bool) -> ExecFlow {
1412        use crate::awk_builtins as ab;
1413        match &ops[ip] {
1414            Op::Nop => {}
1415
1416            // ── Constants ──
1417            Op::LoadInt(n) => self.push(Value::Int(*n)),
1418            Op::LoadFloat(f) => self.push(Value::Float(*f)),
1419            Op::LoadConst(idx) => {
1420                let val = match self.chunk.constants.get(*idx as usize) {
1421                    Some(Value::Int(n)) => Value::Int(*n),
1422                    Some(Value::Float(f)) => Value::Float(*f),
1423                    Some(Value::Bool(b)) => Value::Bool(*b),
1424                    Some(other) => other.clone(),
1425                    None => Value::Undef,
1426                };
1427                self.push(val);
1428            }
1429            Op::LoadTrue => self.push(Value::Bool(true)),
1430            Op::LoadFalse => self.push(Value::Bool(false)),
1431            Op::LoadUndef => self.push(Value::Undef),
1432
1433            // ── Stack ──
1434            Op::Pop => {
1435                self.pop();
1436            }
1437            Op::Dup => {
1438                let val = self.peek().clone();
1439                self.push(val);
1440            }
1441            Op::Dup2 => {
1442                let len = self.stack.len();
1443                if len >= 2 {
1444                    let a = self.stack[len - 2].clone();
1445                    let b = self.stack[len - 1].clone();
1446                    self.push(a);
1447                    self.push(b);
1448                }
1449            }
1450            Op::Swap => {
1451                let len = self.stack.len();
1452                if len >= 2 {
1453                    self.stack.swap(len - 1, len - 2);
1454                }
1455            }
1456            Op::Rot => {
1457                let len = self.stack.len();
1458                if len >= 3 {
1459                    // [a, b, c] → [b, c, a] via two swaps instead of O(n) remove
1460                    self.stack.swap(len - 3, len - 2);
1461                    self.stack.swap(len - 2, len - 1);
1462                }
1463            }
1464
1465            // ── Variables ──
1466            Op::GetVar(idx) => {
1467                let val = self.get_var(*idx);
1468                self.push(val);
1469            }
1470            Op::SetVar(idx) => {
1471                let val = self.pop();
1472                self.set_var(*idx, val);
1473            }
1474            Op::DeclareVar(idx) => {
1475                let val = self.pop();
1476                self.set_var(*idx, val);
1477            }
1478            Op::GetSlot(slot) => {
1479                let val = self.get_slot(*slot);
1480                self.push(val);
1481            }
1482            Op::SetSlot(slot) => {
1483                let val = self.pop();
1484                self.set_slot(*slot, val);
1485            }
1486            Op::SlotArrayGet(slot) => {
1487                let index = self.pop().to_int() as usize;
1488                let val = self.get_slot(*slot);
1489                let result = if let Value::Array(ref arr) = val {
1490                    arr.get(index).cloned().unwrap_or(Value::Undef)
1491                } else {
1492                    Value::Undef
1493                };
1494                self.push(result);
1495            }
1496            Op::SlotArraySet(slot) => {
1497                let index = self.pop().to_int() as usize;
1498                let val = self.pop();
1499                let arr_val = self.get_slot(*slot);
1500                if let Value::Array(mut arr) = arr_val {
1501                    if index >= arr.len() {
1502                        arr.resize(index + 1, Value::Undef);
1503                    }
1504                    arr[index] = val;
1505                    self.set_slot(*slot, Value::Array(arr));
1506                }
1507            }
1508
1509            // ── Arithmetic (type-specialized: Int×Int avoids to_float) ──
1510            Op::Add => {
1511                if let Some(e) =
1512                    self.arith_int_fast(NumOp::Add, i64::wrapping_add, i64::checked_add, |a, b| {
1513                        a + b
1514                    })
1515                {
1516                    return ExecFlow::Ret(VMResult::Error(e));
1517                }
1518            }
1519            Op::Sub => {
1520                if let Some(e) =
1521                    self.arith_int_fast(NumOp::Sub, i64::wrapping_sub, i64::checked_sub, |a, b| {
1522                        a - b
1523                    })
1524                {
1525                    return ExecFlow::Ret(VMResult::Error(e));
1526                }
1527            }
1528            Op::Mul => {
1529                if let Some(e) =
1530                    self.arith_int_fast(NumOp::Mul, i64::wrapping_mul, i64::checked_mul, |a, b| {
1531                        a * b
1532                    })
1533                {
1534                    return ExecFlow::Ret(VMResult::Error(e));
1535                }
1536            }
1537            Op::Div => {
1538                // Strict mode delegates a non-numeric operand; the zero-divisor
1539                // and always-float results are unchanged (a frontend whose `/`
1540                // has other semantics — elisp's integer division — implements it
1541                // as a builtin rather than lowering to this op).
1542                if self.numeric_hook.is_some() {
1543                    let len = self.stack.len();
1544                    if len >= 2
1545                        && !(is_native_num(&self.stack[len - 1])
1546                            && is_native_num(&self.stack[len - 2]))
1547                    {
1548                        if let Some(e) = self.delegate_binary(NumOp::Div) {
1549                            return ExecFlow::Ret(VMResult::Error(e));
1550                        }
1551                        return ExecFlow::Cont;
1552                    }
1553                }
1554                let b = self.pop();
1555                let a = self.pop();
1556                let divisor = b.to_float();
1557                self.push(if divisor == 0.0 {
1558                    Value::Undef
1559                } else {
1560                    Value::Float(a.to_float() / divisor)
1561                });
1562            }
1563            Op::Mod => {
1564                if let Some(e) = self.arith_int_fast(
1565                    NumOp::Mod,
1566                    |x, y| if y != 0 { x % y } else { 0 },
1567                    |x, y| if y != 0 { x.checked_rem(y) } else { Some(0) },
1568                    |a, b| a % b,
1569                ) {
1570                    return ExecFlow::Ret(VMResult::Error(e));
1571                }
1572            }
1573            Op::Pow => {
1574                if self.numeric_hook.is_some() {
1575                    let len = self.stack.len();
1576                    if len >= 2
1577                        && !(is_native_num(&self.stack[len - 1])
1578                            && is_native_num(&self.stack[len - 2]))
1579                    {
1580                        if let Some(e) = self.delegate_binary(NumOp::Pow) {
1581                            return ExecFlow::Ret(VMResult::Error(e));
1582                        }
1583                        return ExecFlow::Cont;
1584                    }
1585                }
1586                let b = self.pop();
1587                let a = self.pop();
1588                self.push(Value::Float(a.to_float().powf(b.to_float())));
1589            }
1590            Op::Negate => {
1591                if let Some(e) = self.negate_strict() {
1592                    return ExecFlow::Ret(VMResult::Error(e));
1593                }
1594            }
1595            Op::Inc => {
1596                let val = self.pop();
1597                let result = match (&val, &self.numeric_hook) {
1598                    (Value::Int(n), None) => Value::Int(n.wrapping_add(1)),
1599                    (Value::Int(n), Some(hook)) => match n.checked_add(1) {
1600                        Some(r) if self.in_fixnum_range(r) => Value::Int(r),
1601                        _ => match hook(NumOp::Add, &val, &Value::Int(1)) {
1602                            Ok(v) => v,
1603                            Err(e) => return ExecFlow::Ret(VMResult::Error(e)),
1604                        },
1605                    },
1606                    (v, None) => Value::Int(v.to_int().wrapping_add(1)),
1607                    (v, Some(hook)) => match hook(NumOp::Add, v, &Value::Int(1)) {
1608                        Ok(v) => v,
1609                        Err(e) => return ExecFlow::Ret(VMResult::Error(e)),
1610                    },
1611                };
1612                self.push(result);
1613            }
1614            Op::Dec => {
1615                let val = self.pop();
1616                let result = match (&val, &self.numeric_hook) {
1617                    (Value::Int(n), None) => Value::Int(n.wrapping_sub(1)),
1618                    (Value::Int(n), Some(hook)) => match n.checked_sub(1) {
1619                        Some(r) if self.in_fixnum_range(r) => Value::Int(r),
1620                        _ => match hook(NumOp::Sub, &val, &Value::Int(1)) {
1621                            Ok(v) => v,
1622                            Err(e) => return ExecFlow::Ret(VMResult::Error(e)),
1623                        },
1624                    },
1625                    (v, None) => Value::Int(v.to_int().wrapping_sub(1)),
1626                    (v, Some(hook)) => match hook(NumOp::Sub, v, &Value::Int(1)) {
1627                        Ok(v) => v,
1628                        Err(e) => return ExecFlow::Ret(VMResult::Error(e)),
1629                    },
1630                };
1631                self.push(result);
1632            }
1633
1634            // ── String ──
1635            Op::Concat => {
1636                let b = self.pop();
1637                let a = self.pop();
1638                let a_s = a.as_str_cow();
1639                let b_s = b.as_str_cow();
1640                let mut s = String::with_capacity(a_s.len() + b_s.len());
1641                s.push_str(&a_s);
1642                s.push_str(&b_s);
1643                self.push(Value::str(s));
1644            }
1645            Op::StringRepeat => {
1646                let count = self.pop().to_int();
1647                let s = self.pop().to_str();
1648                self.push(Value::str(s.repeat(count.max(0) as usize)));
1649            }
1650            Op::StringLen => {
1651                let s = self.pop();
1652                self.push(Value::Int(s.len() as i64));
1653            }
1654
1655            // ── Comparison (type-specialized: Int×Int avoids to_float) ──
1656            Op::NumEq => {
1657                if let Some(e) = self.cmp_int_fast(NumOp::Eq, |x, y| x == y, |a, b| a == b) {
1658                    return ExecFlow::Ret(VMResult::Error(e));
1659                }
1660            }
1661            Op::NumNe => {
1662                if let Some(e) = self.cmp_int_fast(NumOp::Ne, |x, y| x != y, |a, b| a != b) {
1663                    return ExecFlow::Ret(VMResult::Error(e));
1664                }
1665            }
1666            Op::NumLt => {
1667                if let Some(e) = self.cmp_int_fast(NumOp::Lt, |x, y| x < y, |a, b| a < b) {
1668                    return ExecFlow::Ret(VMResult::Error(e));
1669                }
1670            }
1671            Op::NumGt => {
1672                if let Some(e) = self.cmp_int_fast(NumOp::Gt, |x, y| x > y, |a, b| a > b) {
1673                    return ExecFlow::Ret(VMResult::Error(e));
1674                }
1675            }
1676            Op::NumLe => {
1677                if let Some(e) = self.cmp_int_fast(NumOp::Le, |x, y| x <= y, |a, b| a <= b) {
1678                    return ExecFlow::Ret(VMResult::Error(e));
1679                }
1680            }
1681            Op::NumGe => {
1682                if let Some(e) = self.cmp_int_fast(NumOp::Ge, |x, y| x >= y, |a, b| a >= b) {
1683                    return ExecFlow::Ret(VMResult::Error(e));
1684                }
1685            }
1686            Op::Spaceship => {
1687                let len = self.stack.len();
1688                if len >= 2 {
1689                    let b = &self.stack[len - 1];
1690                    let a = &self.stack[len - 2];
1691                    let result = match (a, b) {
1692                        (Value::Int(x), Value::Int(y)) => x.cmp(y) as i64,
1693                        _ => {
1694                            let af = a.to_float();
1695                            let bf = b.to_float();
1696                            if af < bf {
1697                                -1
1698                            } else if af > bf {
1699                                1
1700                            } else {
1701                                0
1702                            }
1703                        }
1704                    };
1705                    self.stack.truncate(len - 2);
1706                    self.stack.push(Value::Int(result));
1707                }
1708            }
1709
1710            // ── Comparison (string — borrow via Cow to avoid allocation) ──
1711            Op::StrEq => {
1712                let b = self.pop();
1713                let a = self.pop();
1714                self.push(Value::Bool(a.as_str_cow() == b.as_str_cow()));
1715            }
1716            Op::StrNe => {
1717                let b = self.pop();
1718                let a = self.pop();
1719                self.push(Value::Bool(a.as_str_cow() != b.as_str_cow()));
1720            }
1721            Op::StrLt => {
1722                let b = self.pop();
1723                let a = self.pop();
1724                self.push(Value::Bool(a.as_str_cow() < b.as_str_cow()));
1725            }
1726            Op::StrGt => {
1727                let b = self.pop();
1728                let a = self.pop();
1729                self.push(Value::Bool(a.as_str_cow() > b.as_str_cow()));
1730            }
1731            Op::StrLe => {
1732                let b = self.pop();
1733                let a = self.pop();
1734                self.push(Value::Bool(a.as_str_cow() <= b.as_str_cow()));
1735            }
1736            Op::StrGe => {
1737                let b = self.pop();
1738                let a = self.pop();
1739                self.push(Value::Bool(a.as_str_cow() >= b.as_str_cow()));
1740            }
1741            Op::StrCmp => {
1742                let b = self.pop();
1743                let a = self.pop();
1744                self.push(Value::Int(match a.as_str_cow().cmp(&b.as_str_cow()) {
1745                    std::cmp::Ordering::Less => -1,
1746                    std::cmp::Ordering::Equal => 0,
1747                    std::cmp::Ordering::Greater => 1,
1748                }));
1749            }
1750
1751            // ── Logical / Bitwise ──
1752            Op::LogNot => {
1753                let val = self.pop();
1754                self.push(Value::Bool(!val.is_truthy()));
1755            }
1756            Op::LogAnd => {
1757                let b = self.pop();
1758                let a = self.pop();
1759                self.push(Value::Bool(a.is_truthy() && b.is_truthy()));
1760            }
1761            Op::LogOr => {
1762                let b = self.pop();
1763                let a = self.pop();
1764                self.push(Value::Bool(a.is_truthy() || b.is_truthy()));
1765            }
1766            Op::BitAnd => {
1767                let b = self.pop();
1768                let a = self.pop();
1769                self.push(Value::Int(a.to_int() & b.to_int()));
1770            }
1771            Op::BitOr => {
1772                let b = self.pop();
1773                let a = self.pop();
1774                self.push(Value::Int(a.to_int() | b.to_int()));
1775            }
1776            Op::BitXor => {
1777                let b = self.pop();
1778                let a = self.pop();
1779                self.push(Value::Int(a.to_int() ^ b.to_int()));
1780            }
1781            Op::BitNot => {
1782                let val = self.pop();
1783                self.push(Value::Int(!val.to_int()));
1784            }
1785            Op::Shl => {
1786                let b = self.pop();
1787                let a = self.pop();
1788                self.push(Value::Int(a.to_int() << (b.to_int() as u32 & 63)));
1789            }
1790            Op::Shr => {
1791                let b = self.pop();
1792                let a = self.pop();
1793                self.push(Value::Int(a.to_int() >> (b.to_int() as u32 & 63)));
1794            }
1795
1796            // ── Control flow ──
1797            Op::Jump(target) => {
1798                let target = *target;
1799                #[cfg(feature = "jit")]
1800                if self.tracing_jit && self.recorder.is_none() && target <= ip {
1801                    self.ip = self.lookup_trace_for_backward(target, ip + 1);
1802                } else {
1803                    self.ip = target;
1804                }
1805                #[cfg(not(feature = "jit"))]
1806                {
1807                    self.ip = target;
1808                }
1809            }
1810            Op::JumpIfTrue(target) => {
1811                let target = *target;
1812                if self.pop().is_truthy() {
1813                    #[cfg(feature = "jit")]
1814                    if self.tracing_jit && self.recorder.is_none() && target <= ip {
1815                        self.ip = self.lookup_trace_for_backward(target, ip + 1);
1816                    } else {
1817                        self.ip = target;
1818                    }
1819                    #[cfg(not(feature = "jit"))]
1820                    {
1821                        self.ip = target;
1822                    }
1823                }
1824            }
1825            Op::JumpIfFalse(target) => {
1826                let target = *target;
1827                if !self.pop().is_truthy() {
1828                    #[cfg(feature = "jit")]
1829                    if self.tracing_jit && self.recorder.is_none() && target <= ip {
1830                        self.ip = self.lookup_trace_for_backward(target, ip + 1);
1831                    } else {
1832                        self.ip = target;
1833                    }
1834                    #[cfg(not(feature = "jit"))]
1835                    {
1836                        self.ip = target;
1837                    }
1838                }
1839            }
1840            Op::JumpIfTrueKeep(target) => {
1841                let target = *target;
1842                if self.peek().is_truthy() {
1843                    #[cfg(feature = "jit")]
1844                    if self.tracing_jit && self.recorder.is_none() && target <= ip {
1845                        self.ip = self.lookup_trace_for_backward(target, ip + 1);
1846                    } else {
1847                        self.ip = target;
1848                    }
1849                    #[cfg(not(feature = "jit"))]
1850                    {
1851                        self.ip = target;
1852                    }
1853                }
1854            }
1855            Op::JumpIfFalseKeep(target) => {
1856                let target = *target;
1857                if !self.peek().is_truthy() {
1858                    #[cfg(feature = "jit")]
1859                    if self.tracing_jit && self.recorder.is_none() && target <= ip {
1860                        self.ip = self.lookup_trace_for_backward(target, ip + 1);
1861                    } else {
1862                        self.ip = target;
1863                    }
1864                    #[cfg(not(feature = "jit"))]
1865                    {
1866                        self.ip = target;
1867                    }
1868                }
1869            }
1870
1871            // ── Functions ──
1872            Op::Call(name_idx, argc) => {
1873                if let Some(entry_ip) = self.chunk.find_sub(*name_idx) {
1874                    self.frames.push(Frame {
1875                        return_ip: self.ip,
1876                        stack_base: self.stack.len() - *argc as usize,
1877                        slots: Vec::new(),
1878                    });
1879                    self.ip = entry_ip;
1880                } else {
1881                    return ExecFlow::Ret(VMResult::Error(format!(
1882                        "undefined function: {}",
1883                        self.chunk
1884                            .names
1885                            .get(*name_idx as usize)
1886                            .map(|s| s.as_str())
1887                            .unwrap_or("?")
1888                    )));
1889                }
1890            }
1891            Op::Return => {
1892                if let Some(frame) = self.frames.pop() {
1893                    self.stack.truncate(frame.stack_base);
1894                    self.ip = frame.return_ip;
1895                } else {
1896                    self.halted = true;
1897                }
1898            }
1899            Op::ReturnValue => {
1900                let val = self.pop();
1901                if let Some(frame) = self.frames.pop() {
1902                    self.stack.truncate(frame.stack_base);
1903                    self.ip = frame.return_ip;
1904                    self.push(val);
1905                } else {
1906                    self.halted = true;
1907                    return ExecFlow::Ret(VMResult::Ok(val));
1908                }
1909            }
1910
1911            // ── Scope ──
1912            Op::PushFrame => {
1913                self.frames.push(Frame {
1914                    return_ip: self.ip,
1915                    stack_base: self.stack.len(),
1916                    slots: Vec::new(),
1917                });
1918            }
1919            Op::PopFrame => {
1920                if let Some(frame) = self.frames.pop() {
1921                    self.stack.truncate(frame.stack_base);
1922                }
1923            }
1924
1925            // ── I/O (write directly, no intermediate Vec) ──
1926            Op::Print(n) => {
1927                let n = *n;
1928                let start = self.stack.len().saturating_sub(n as usize);
1929                let mut out = String::new();
1930                for v in &self.stack[start..] {
1931                    out.push_str(&v.as_str_cow());
1932                }
1933                self.stack.truncate(start);
1934                self.emit_output(&out);
1935            }
1936            Op::PrintLn(n) => {
1937                let n = *n;
1938                let start = self.stack.len().saturating_sub(n as usize);
1939                let mut out = String::new();
1940                for v in &self.stack[start..] {
1941                    out.push_str(&v.as_str_cow());
1942                }
1943                out.push('\n');
1944                self.stack.truncate(start);
1945                self.emit_output(&out);
1946            }
1947            Op::ReadLine => {
1948                if let Some(src) = self.input_source.as_mut() {
1949                    match src() {
1950                        Some(l) => self.push(Value::str(l)),
1951                        None => self.push(Value::Undef),
1952                    }
1953                } else {
1954                    let mut line = String::new();
1955                    let _ = std::io::stdin().read_line(&mut line);
1956                    self.push(Value::str(line.trim_end_matches('\n')));
1957                }
1958            }
1959
1960            // ── Fused superinstructions ──
1961            Op::PreIncSlot(slot) => {
1962                let val = self.get_slot(*slot).to_int() + 1;
1963                self.set_slot(*slot, Value::Int(val));
1964                self.push(Value::Int(val));
1965            }
1966            Op::PreIncSlotVoid(slot) => {
1967                let val = self.get_slot(*slot).to_int() + 1;
1968                self.set_slot(*slot, Value::Int(val));
1969            }
1970            Op::SlotLtIntJumpIfFalse(slot, limit, target) => {
1971                if self.get_slot(*slot).to_int() >= *limit as i64 {
1972                    self.ip = *target;
1973                }
1974            }
1975            Op::SlotIncLtIntJumpBack(slot, limit, target) => {
1976                let val = self.get_slot(*slot).to_int() + 1;
1977                self.set_slot(*slot, Value::Int(val));
1978                if val < *limit as i64 {
1979                    self.ip = *target;
1980                }
1981            }
1982            Op::AccumSumLoop(sum_slot, i_slot, limit) => {
1983                let mut sum = self.get_slot(*sum_slot).to_int();
1984                let mut i = self.get_slot(*i_slot).to_int();
1985                let lim = *limit as i64;
1986                while i < lim {
1987                    sum += i;
1988                    i += 1;
1989                }
1990                self.set_slot(*sum_slot, Value::Int(sum));
1991                self.set_slot(*i_slot, Value::Int(i));
1992            }
1993            Op::AddAssignSlotVoid(a, b) => {
1994                let sum = self.get_slot(*a).to_int() + self.get_slot(*b).to_int();
1995                self.set_slot(*a, Value::Int(sum));
1996            }
1997            Op::PreDecSlot(slot) => {
1998                let val = self.get_slot(*slot).to_int() - 1;
1999                self.set_slot(*slot, Value::Int(val));
2000                self.push(Value::Int(val));
2001            }
2002            Op::PostIncSlot(slot) => {
2003                let old = self.get_slot(*slot).to_int();
2004                self.set_slot(*slot, Value::Int(old + 1));
2005                self.push(Value::Int(old));
2006            }
2007            Op::PostDecSlot(slot) => {
2008                let old = self.get_slot(*slot).to_int();
2009                self.set_slot(*slot, Value::Int(old - 1));
2010                self.push(Value::Int(old));
2011            }
2012
2013            // ── Status ──
2014            Op::SetStatus => {
2015                self.last_status = self.pop().to_int() as i32;
2016            }
2017            Op::GetStatus => {
2018                self.push(Value::Status(self.last_status));
2019            }
2020
2021            // ── Extension dispatch ──
2022            Op::Extended(id, arg) => {
2023                let (id, arg) = (*id, *arg);
2024                if let Some(mut handler) = self.ext_handler.take() {
2025                    handler(self, id, arg);
2026                    self.ext_handler = Some(handler);
2027                }
2028            }
2029            Op::ExtendedWide(id, payload) => {
2030                let (id, payload) = (*id, *payload);
2031                if crate::awk_builtins::is_awk_op(id) {
2032                    self.dispatch_awk(id, payload);
2033                } else if let Some(mut handler) = self.ext_wide_handler.take() {
2034                    handler(self, id, payload);
2035                    self.ext_wide_handler = Some(handler);
2036                }
2037            }
2038
2039            // ── Arrays ──
2040            Op::GetArray(idx) => {
2041                let val = self.get_var(*idx);
2042                self.push(val);
2043            }
2044            Op::SetArray(idx) => {
2045                let val = self.pop();
2046                self.set_var(*idx, val);
2047            }
2048            Op::DeclareArray(idx) => {
2049                self.set_var(*idx, Value::Array(Vec::new()));
2050            }
2051            Op::ArrayGet(arr_idx) => {
2052                let index = self.pop().to_int() as usize;
2053                let idx = *arr_idx as usize;
2054                let val = if idx < self.globals.len() {
2055                    if let Value::Array(ref arr) = self.globals[idx] {
2056                        arr.get(index).cloned().unwrap_or(Value::Undef)
2057                    } else {
2058                        Value::Undef
2059                    }
2060                } else {
2061                    Value::Undef
2062                };
2063                self.push(val);
2064            }
2065            Op::ArraySet(arr_idx) => {
2066                let index = self.pop().to_int() as usize;
2067                let val = self.pop();
2068                let idx = *arr_idx as usize;
2069                if idx >= self.globals.len() {
2070                    self.globals.resize(idx + 1, Value::Undef);
2071                }
2072                if let Value::Array(ref mut vec) = self.globals[idx] {
2073                    if index >= vec.len() {
2074                        vec.resize(index + 1, Value::Undef);
2075                    }
2076                    vec[index] = val;
2077                }
2078            }
2079            Op::ArrayPush(arr_idx) => {
2080                let val = self.pop();
2081                let idx = *arr_idx as usize;
2082                if idx >= self.globals.len() {
2083                    self.globals.resize(idx + 1, Value::Undef);
2084                }
2085                if let Value::Array(ref mut vec) = self.globals[idx] {
2086                    vec.push(val);
2087                }
2088            }
2089            Op::ArrayPop(arr_idx) => {
2090                let idx = *arr_idx as usize;
2091                let val = if idx < self.globals.len() {
2092                    if let Value::Array(ref mut vec) = self.globals[idx] {
2093                        vec.pop().unwrap_or(Value::Undef)
2094                    } else {
2095                        Value::Undef
2096                    }
2097                } else {
2098                    Value::Undef
2099                };
2100                self.push(val);
2101            }
2102            Op::ArrayShift(arr_idx) => {
2103                let idx = *arr_idx as usize;
2104                let val = if idx < self.globals.len() {
2105                    if let Value::Array(ref mut vec) = self.globals[idx] {
2106                        if vec.is_empty() {
2107                            Value::Undef
2108                        } else {
2109                            vec.remove(0)
2110                        }
2111                    } else {
2112                        Value::Undef
2113                    }
2114                } else {
2115                    Value::Undef
2116                };
2117                self.push(val);
2118            }
2119            Op::ArrayLen(arr_idx) => {
2120                let idx = *arr_idx as usize;
2121                let len = if idx < self.globals.len() {
2122                    if let Value::Array(ref vec) = self.globals[idx] {
2123                        vec.len() as i64
2124                    } else {
2125                        0
2126                    }
2127                } else {
2128                    0
2129                };
2130                self.push(Value::Int(len));
2131            }
2132            Op::MakeArray(n) => {
2133                let n = *n;
2134                let start = self.stack.len().saturating_sub(n as usize);
2135                let elements: Vec<Value> = self.stack.drain(start..).collect();
2136                self.push(Value::Array(elements));
2137            }
2138
2139            // ── Hashes ──
2140            Op::GetHash(idx) => {
2141                let val = self.get_var(*idx);
2142                self.push(val);
2143            }
2144            Op::SetHash(idx) => {
2145                let val = self.pop();
2146                self.set_var(*idx, val);
2147            }
2148            Op::DeclareHash(idx) => {
2149                self.set_var(*idx, Value::Hash(std::collections::HashMap::new()));
2150            }
2151            Op::HashGet(hash_idx) => {
2152                let key_val = self.pop();
2153                let key = key_val.as_str_cow();
2154                let idx = *hash_idx as usize;
2155                let val = if idx < self.globals.len() {
2156                    if let Value::Hash(ref map) = self.globals[idx] {
2157                        map.get(key.as_ref()).cloned().unwrap_or(Value::Undef)
2158                    } else {
2159                        Value::Undef
2160                    }
2161                } else {
2162                    Value::Undef
2163                };
2164                self.push(val);
2165            }
2166            Op::HashSet(hash_idx) => {
2167                let key = self.pop().to_str();
2168                let val = self.pop();
2169                let idx = *hash_idx as usize;
2170                if idx >= self.globals.len() {
2171                    self.globals.resize(idx + 1, Value::Undef);
2172                }
2173                if let Value::Hash(ref mut map) = self.globals[idx] {
2174                    map.insert(key, val);
2175                }
2176            }
2177            Op::HashDelete(hash_idx) => {
2178                let key_val = self.pop();
2179                let key = key_val.as_str_cow();
2180                let idx = *hash_idx as usize;
2181                let val = if idx < self.globals.len() {
2182                    if let Value::Hash(ref mut map) = self.globals[idx] {
2183                        map.remove(key.as_ref()).unwrap_or(Value::Undef)
2184                    } else {
2185                        Value::Undef
2186                    }
2187                } else {
2188                    Value::Undef
2189                };
2190                self.push(val);
2191            }
2192            Op::HashExists(hash_idx) => {
2193                let key_val = self.pop();
2194                let key = key_val.as_str_cow();
2195                let idx = *hash_idx as usize;
2196                let val = if idx < self.globals.len() {
2197                    if let Value::Hash(ref map) = self.globals[idx] {
2198                        map.contains_key(key.as_ref())
2199                    } else {
2200                        false
2201                    }
2202                } else {
2203                    false
2204                };
2205                self.push(Value::Bool(val));
2206            }
2207            Op::HashKeys(hash_idx) => {
2208                let idx = *hash_idx as usize;
2209                let arr = if idx < self.globals.len() {
2210                    if let Value::Hash(ref map) = self.globals[idx] {
2211                        let mut keys = Vec::with_capacity(map.len());
2212                        keys.extend(map.keys().map(|k| Value::str(k.as_str())));
2213                        keys
2214                    } else {
2215                        Vec::new()
2216                    }
2217                } else {
2218                    Vec::new()
2219                };
2220                self.push(Value::Array(arr));
2221            }
2222            Op::HashValues(hash_idx) => {
2223                let idx = *hash_idx as usize;
2224                let arr = if idx < self.globals.len() {
2225                    if let Value::Hash(ref map) = self.globals[idx] {
2226                        let mut vals = Vec::with_capacity(map.len());
2227                        vals.extend(map.values().cloned());
2228                        vals
2229                    } else {
2230                        Vec::new()
2231                    }
2232                } else {
2233                    Vec::new()
2234                };
2235                self.push(Value::Array(arr));
2236            }
2237            Op::MakeHash(n) => {
2238                let n = *n;
2239                let start = self.stack.len().saturating_sub(n as usize);
2240                let pairs: Vec<Value> = self.stack.drain(start..).collect();
2241                let mut map = std::collections::HashMap::with_capacity(pairs.len() / 2);
2242                let mut iter = pairs.into_iter();
2243                while let Some(key) = iter.next() {
2244                    if let Some(val) = iter.next() {
2245                        map.insert(key.to_str(), val);
2246                    }
2247                }
2248                self.push(Value::Hash(map));
2249            }
2250
2251            // ── Range ──
2252            Op::Range => {
2253                let to = self.pop().to_int();
2254                let from = self.pop().to_int();
2255                let cap = (to - from + 1).max(0) as usize;
2256                let mut arr = Vec::with_capacity(cap);
2257                arr.extend((from..=to).map(Value::Int));
2258                self.push(Value::Array(arr));
2259            }
2260            Op::RangeStep => {
2261                let step = self.pop().to_int();
2262                let to = self.pop().to_int();
2263                let from = self.pop().to_int();
2264                let cap = if step > 0 {
2265                    ((to - from) / step + 1).max(0) as usize
2266                } else if step < 0 {
2267                    ((from - to) / (-step) + 1).max(0) as usize
2268                } else {
2269                    0
2270                };
2271                let mut arr = Vec::with_capacity(cap);
2272                if step > 0 {
2273                    let mut i = from;
2274                    while i <= to {
2275                        arr.push(Value::Int(i));
2276                        i += step;
2277                    }
2278                } else if step < 0 {
2279                    let mut i = from;
2280                    while i >= to {
2281                        arr.push(Value::Int(i));
2282                        i += step;
2283                    }
2284                }
2285                self.push(Value::Array(arr));
2286            }
2287
2288            // ── Shell ops ──
2289            Op::TestFile(test_type) => {
2290                let test_type = *test_type;
2291                let path = self.pop().to_str();
2292                let result = match test_type {
2293                    crate::op::file_test::EXISTS => std::path::Path::new(&path).exists(),
2294                    crate::op::file_test::IS_FILE => std::path::Path::new(&path).is_file(),
2295                    crate::op::file_test::IS_DIR => std::path::Path::new(&path).is_dir(),
2296                    crate::op::file_test::IS_SYMLINK => std::path::Path::new(&path).is_symlink(),
2297                    crate::op::file_test::IS_READABLE | crate::op::file_test::IS_WRITABLE => {
2298                        std::path::Path::new(&path).exists()
2299                    }
2300                    crate::op::file_test::IS_EXECUTABLE => {
2301                        #[cfg(unix)]
2302                        {
2303                            use std::os::unix::fs::PermissionsExt;
2304                            std::fs::metadata(&path)
2305                                .map(|m| m.permissions().mode() & 0o111 != 0)
2306                                .unwrap_or(false)
2307                        }
2308                        #[cfg(not(unix))]
2309                        {
2310                            std::path::Path::new(&path).exists()
2311                        }
2312                    }
2313                    crate::op::file_test::IS_NONEMPTY => std::fs::metadata(&path)
2314                        .map(|m| m.len() > 0)
2315                        .unwrap_or(false),
2316                    crate::op::file_test::IS_SOCKET => {
2317                        #[cfg(unix)]
2318                        {
2319                            use std::os::unix::fs::FileTypeExt;
2320                            std::fs::symlink_metadata(&path)
2321                                .map(|m| m.file_type().is_socket())
2322                                .unwrap_or(false)
2323                        }
2324                        #[cfg(not(unix))]
2325                        {
2326                            false
2327                        }
2328                    }
2329                    crate::op::file_test::IS_FIFO => {
2330                        #[cfg(unix)]
2331                        {
2332                            use std::os::unix::fs::FileTypeExt;
2333                            std::fs::symlink_metadata(&path)
2334                                .map(|m| m.file_type().is_fifo())
2335                                .unwrap_or(false)
2336                        }
2337                        #[cfg(not(unix))]
2338                        {
2339                            false
2340                        }
2341                    }
2342                    crate::op::file_test::IS_BLOCK_DEV => {
2343                        #[cfg(unix)]
2344                        {
2345                            use std::os::unix::fs::FileTypeExt;
2346                            std::fs::symlink_metadata(&path)
2347                                .map(|m| m.file_type().is_block_device())
2348                                .unwrap_or(false)
2349                        }
2350                        #[cfg(not(unix))]
2351                        {
2352                            false
2353                        }
2354                    }
2355                    crate::op::file_test::IS_CHAR_DEV => {
2356                        #[cfg(unix)]
2357                        {
2358                            use std::os::unix::fs::FileTypeExt;
2359                            std::fs::symlink_metadata(&path)
2360                                .map(|m| m.file_type().is_char_device())
2361                                .unwrap_or(false)
2362                        }
2363                        #[cfg(not(unix))]
2364                        {
2365                            false
2366                        }
2367                    }
2368                    _ => false,
2369                };
2370                self.push(Value::Bool(result));
2371            }
2372
2373            Op::Exec(argc) => {
2374                let argc = *argc;
2375                let start = self.stack.len().saturating_sub(argc as usize);
2376                // Flatten Value::Array entries into argv. Shell array splice
2377                // (`${arr[@]}`) pushes a single Array value at compile-time
2378                // even though it expands to N argv slots at runtime. Without
2379                // this flat_map, `cmd ${arr[@]}` would pass the whole array
2380                // as one space-joined arg instead of N separate args.
2381                let args: Vec<String> = self
2382                    .stack
2383                    .drain(start..)
2384                    .flat_map(|v| match v {
2385                        Value::Array(items) => {
2386                            items.into_iter().map(|i| i.to_str()).collect::<Vec<_>>()
2387                        }
2388                        other => vec![other.to_str()],
2389                    })
2390                    .collect();
2391                if let Some(cmd) = args.first() {
2392                    // Check if it's a shell function
2393                    let name_idx = self.chunk.names.iter().position(|n| n == cmd);
2394                    if let Some(name_idx) = name_idx {
2395                        if let Some(entry_ip) = self.chunk.find_sub(name_idx as u16) {
2396                            // Push arguments for the function (skip command name)
2397                            for arg in &args[1..] {
2398                                self.push(Value::str(arg));
2399                            }
2400                            // Push frame and call
2401                            self.frames.push(Frame {
2402                                return_ip: self.ip,
2403                                stack_base: self.stack.len() - (args.len() - 1),
2404                                slots: Vec::with_capacity(8),
2405                            });
2406                            self.ip = entry_ip;
2407                            return ExecFlow::Cont;
2408                        }
2409                    }
2410
2411                    match cmd.as_str() {
2412                        "true" => self.push(Value::Status(0)),
2413                        "false" => self.push(Value::Status(1)),
2414                        "echo" => {
2415                            println!("{}", args[1..].join(" "));
2416                            self.push(Value::Status(0));
2417                        }
2418                        "test" | "[" => {
2419                            self.push(Value::Status(0));
2420                        }
2421                        _ => {
2422                            // Route through the host's `exec` so frontends
2423                            // (zshrs) can apply intercepts/AOP advice/job
2424                            // tracking on dynamic command names like
2425                            // `cmd=ls; $cmd`. The default ShellHost::exec
2426                            // implementation falls back to Command::new,
2427                            // so behavior is identical when no host is
2428                            // wired. Without host, we keep the inline
2429                            // Command::new path so the VM still runs in
2430                            // host-less embeddings (tests, REPL stubs).
2431                            let status = if let Some(h) = self.host.as_mut() {
2432                                h.exec(args.clone())
2433                            } else {
2434                                #[cfg(not(target_arch = "wasm32"))]
2435                                {
2436                                    use std::process::{Command, Stdio};
2437                                    Command::new(cmd)
2438                                        .args(&args[1..])
2439                                        .stdout(Stdio::inherit())
2440                                        .stderr(Stdio::inherit())
2441                                        .status()
2442                                        .map(|s| s.code().unwrap_or(1))
2443                                        .unwrap_or(127)
2444                                }
2445                                // No process model in a browser worker; a
2446                                // host-less wasm embedding reports 127.
2447                                #[cfg(target_arch = "wasm32")]
2448                                {
2449                                    let _ = cmd;
2450                                    127
2451                                }
2452                            };
2453                            self.push(Value::Status(status));
2454                        }
2455                    }
2456                } else {
2457                    self.push(Value::Status(0));
2458                }
2459            }
2460            Op::ExecBg(argc) => {
2461                let argc = *argc;
2462                let start = self.stack.len().saturating_sub(argc as usize);
2463                // Same Array-flattening as Op::Exec — see comment there.
2464                let args: Vec<String> = self
2465                    .stack
2466                    .drain(start..)
2467                    .flat_map(|v| match v {
2468                        Value::Array(items) => {
2469                            items.into_iter().map(|i| i.to_str()).collect::<Vec<_>>()
2470                        }
2471                        other => vec![other.to_str()],
2472                    })
2473                    .collect();
2474                if let Some(cmd) = args.first() {
2475                    // Route bg exec through the host. Frontends override
2476                    // to register the spawned pid in their job table; the
2477                    // default impl spawns and detaches. We DON'T wait on
2478                    // the bg child here — that's the host's responsibility
2479                    // (zshrs uses BUILTIN_RUN_BG which forks before
2480                    // emitting Op::ExecBg, so this path is rare for
2481                    // shell-level bg). Without host, fall back to inline
2482                    // Command::new spawn for host-less embeddings.
2483                    if let Some(h) = self.host.as_mut() {
2484                        let _ = h.exec_bg(args.clone());
2485                    } else {
2486                        #[cfg(not(target_arch = "wasm32"))]
2487                        {
2488                            use std::process::{Command, Stdio};
2489                            let _ = Command::new(cmd)
2490                                .args(&args[1..])
2491                                .stdout(Stdio::null())
2492                                .stderr(Stdio::null())
2493                                .spawn();
2494                        }
2495                        // No process model in a browser worker; drop the spawn.
2496                        #[cfg(target_arch = "wasm32")]
2497                        {
2498                            let _ = cmd;
2499                        }
2500                    }
2501                }
2502                self.push(Value::Status(0));
2503            }
2504
2505            // ── Shell ops ── (route through host when set, fall back to stubs)
2506            Op::PipelineBegin(n) => {
2507                let n = *n;
2508                if let Some(h) = self.host.as_mut() {
2509                    h.pipeline_begin(n);
2510                }
2511            }
2512            Op::PipelineStage => {
2513                if let Some(h) = self.host.as_mut() {
2514                    h.pipeline_stage();
2515                }
2516            }
2517            Op::PipelineEnd => {
2518                let status = if let Some(h) = self.host.as_mut() {
2519                    h.pipeline_end()
2520                } else {
2521                    self.last_status
2522                };
2523                self.last_status = status;
2524                self.push(Value::Status(status));
2525            }
2526            Op::SubshellBegin => {
2527                if let Some(h) = self.host.as_mut() {
2528                    h.subshell_begin();
2529                }
2530            }
2531            Op::SubshellEnd => {
2532                if let Some(h) = self.host.as_mut() {
2533                    if let Some(status) = h.subshell_end() {
2534                        self.last_status = status;
2535                    }
2536                }
2537            }
2538            Op::Redirect(fd, op) => {
2539                let fd = *fd;
2540                let op = *op;
2541                let target = self.pop().to_str();
2542                if let Some(h) = self.host.as_mut() {
2543                    h.redirect(fd, op, &target);
2544                }
2545            }
2546            Op::HereDoc(idx) => {
2547                let content = self
2548                    .chunk
2549                    .constants
2550                    .get(*idx as usize)
2551                    .map(|v| v.to_str())
2552                    .unwrap_or_default();
2553                if let Some(h) = self.host.as_mut() {
2554                    h.heredoc(&content);
2555                }
2556            }
2557            Op::HereString => {
2558                let s = self.pop().to_str();
2559                if let Some(h) = self.host.as_mut() {
2560                    h.herestring(&s);
2561                }
2562            }
2563            Op::CmdSubst(idx) => {
2564                let result = match self.chunk.sub_chunks.get(*idx as usize) {
2565                    Some(sub) => {
2566                        // Split borrow: self.host and self.chunk are disjoint fields
2567                        let sub_ref: *const Chunk = sub;
2568                        // SAFETY: sub_chunks is not mutated during op dispatch
2569                        let sub_ref = unsafe { &*sub_ref };
2570                        if let Some(h) = self.host.as_mut() {
2571                            h.cmd_subst(sub_ref)
2572                        } else {
2573                            String::new()
2574                        }
2575                    }
2576                    None => String::new(),
2577                };
2578                self.push(Value::str(result));
2579            }
2580            Op::ProcessSubIn(idx) => {
2581                let result = match self.chunk.sub_chunks.get(*idx as usize) {
2582                    Some(sub) => {
2583                        let sub_ref: *const Chunk = sub;
2584                        let sub_ref = unsafe { &*sub_ref };
2585                        if let Some(h) = self.host.as_mut() {
2586                            h.process_sub_in(sub_ref)
2587                        } else {
2588                            String::new()
2589                        }
2590                    }
2591                    None => String::new(),
2592                };
2593                self.push(Value::str(result));
2594            }
2595            Op::ProcessSubOut(idx) => {
2596                let result = match self.chunk.sub_chunks.get(*idx as usize) {
2597                    Some(sub) => {
2598                        let sub_ref: *const Chunk = sub;
2599                        let sub_ref = unsafe { &*sub_ref };
2600                        if let Some(h) = self.host.as_mut() {
2601                            h.process_sub_out(sub_ref)
2602                        } else {
2603                            String::new()
2604                        }
2605                    }
2606                    None => String::new(),
2607                };
2608                self.push(Value::str(result));
2609            }
2610            Op::Glob | Op::GlobRecursive => {
2611                let recursive = matches!(&ops[ip], Op::GlobRecursive);
2612                let pat_val = self.pop();
2613                let pattern = pat_val.to_str();
2614                let matches: Vec<String> = if let Some(h) = self.host.as_mut() {
2615                    h.glob(&pattern, recursive)
2616                } else {
2617                    glob::glob(&pattern)
2618                        .into_iter()
2619                        .flat_map(|paths| paths.filter_map(|p| p.ok()))
2620                        .map(|p| p.to_string_lossy().into_owned())
2621                        .collect()
2622                };
2623                let arr: Vec<Value> = matches.into_iter().map(Value::str).collect();
2624                self.push(Value::Array(arr));
2625            }
2626            Op::TrapSet(idx) => {
2627                // stack: [signal_name]
2628                let sig = self.pop().to_str();
2629                if let Some(sub) = self.chunk.sub_chunks.get(*idx as usize) {
2630                    let sub_ref: *const Chunk = sub;
2631                    let sub_ref = unsafe { &*sub_ref };
2632                    if let Some(h) = self.host.as_mut() {
2633                        h.trap_set(&sig, sub_ref);
2634                    }
2635                }
2636            }
2637            Op::TrapCheck => {
2638                if let Some(h) = self.host.as_mut() {
2639                    h.trap_check();
2640                }
2641            }
2642            Op::TildeExpand => {
2643                let s = self.pop().to_str();
2644                let result = if let Some(h) = self.host.as_mut() {
2645                    h.tilde_expand(&s)
2646                } else {
2647                    s
2648                };
2649                self.push(Value::str(result));
2650            }
2651            Op::BraceExpand => {
2652                let s = self.pop().to_str();
2653                let result = if let Some(h) = self.host.as_mut() {
2654                    h.brace_expand(&s)
2655                } else {
2656                    vec![s]
2657                };
2658                let arr: Vec<Value> = result.into_iter().map(Value::str).collect();
2659                self.push(Value::Array(arr));
2660            }
2661            Op::WordSplit => {
2662                let s = self.pop().to_str();
2663                let result = if let Some(h) = self.host.as_mut() {
2664                    h.word_split(&s)
2665                } else {
2666                    s.split_whitespace().map(|w| w.to_string()).collect()
2667                };
2668                let arr: Vec<Value> = result.into_iter().map(Value::str).collect();
2669                self.push(Value::Array(arr));
2670            }
2671            Op::ExpandParam(modifier) => {
2672                // Stack layout per modifier:
2673                //   DEFAULT/ASSIGN/ERROR/ALTERNATE/STRIP*/RSTRIP*: [name, arg]
2674                //   SUBST_FIRST/SUBST_ALL: [name, pat, rep]
2675                //   SLICE: [name, off, len]
2676                //   LENGTH/UPPER/LOWER/UPPER_FIRST/LOWER_FIRST/INDIRECT/KEYS: [name]
2677                let m = *modifier;
2678                let argc = match m {
2679                    crate::op::param_mod::DEFAULT
2680                    | crate::op::param_mod::ASSIGN
2681                    | crate::op::param_mod::ERROR
2682                    | crate::op::param_mod::ALTERNATE
2683                    | crate::op::param_mod::STRIP_SHORT
2684                    | crate::op::param_mod::STRIP_LONG
2685                    | crate::op::param_mod::RSTRIP_SHORT
2686                    | crate::op::param_mod::RSTRIP_LONG => 1,
2687                    crate::op::param_mod::SUBST_FIRST
2688                    | crate::op::param_mod::SUBST_ALL
2689                    | crate::op::param_mod::SLICE => 2,
2690                    _ => 0,
2691                };
2692                let mut args: Vec<Value> = Vec::with_capacity(argc);
2693                for _ in 0..argc {
2694                    args.push(self.pop());
2695                }
2696                args.reverse();
2697                let name = self.pop().to_str();
2698                let result = if let Some(h) = self.host.as_mut() {
2699                    h.expand_param(&name, m, &args)
2700                } else {
2701                    Value::str("")
2702                };
2703                self.push(result);
2704            }
2705            Op::StrMatch => {
2706                let pat = self.pop().to_str();
2707                let s = self.pop().to_str();
2708                let result = if let Some(h) = self.host.as_mut() {
2709                    h.str_match(&s, &pat)
2710                } else {
2711                    s == pat
2712                };
2713                self.push(Value::Bool(result));
2714            }
2715            Op::RegexMatch => {
2716                let re = self.pop().to_str();
2717                let s = self.pop().to_str();
2718                let result = if let Some(h) = self.host.as_mut() {
2719                    h.regex_match(&s, &re)
2720                } else {
2721                    false
2722                };
2723                self.push(Value::Bool(result));
2724            }
2725            Op::WithRedirectsBegin(n) => {
2726                let n = *n;
2727                if let Some(h) = self.host.as_mut() {
2728                    h.with_redirects_begin(n);
2729                }
2730            }
2731            Op::WithRedirectsEnd => {
2732                if let Some(h) = self.host.as_mut() {
2733                    h.with_redirects_end();
2734                }
2735            }
2736            Op::CallFunction(name_idx, argc) => {
2737                let name = self
2738                    .chunk
2739                    .names
2740                    .get(*name_idx as usize)
2741                    .cloned()
2742                    .unwrap_or_default();
2743                let argc = *argc as usize;
2744                let start = self.stack.len().saturating_sub(argc);
2745                // Flatten arrays (see Op::Exec for rationale).
2746                let args: Vec<String> = self
2747                    .stack
2748                    .drain(start..)
2749                    .flat_map(|v| match v {
2750                        Value::Array(items) => {
2751                            items.into_iter().map(|i| i.to_str()).collect::<Vec<_>>()
2752                        }
2753                        other => vec![other.to_str()],
2754                    })
2755                    .collect();
2756                let status = if self.host.is_some() {
2757                    // alias/function/host-table builtin resolution.
2758                    let cf = self
2759                        .host
2760                        .as_mut()
2761                        .unwrap()
2762                        .call_function(&name, args.clone());
2763                    match cf {
2764                        Some(s) => s,
2765                        None => {
2766                            // Not a user function or a host-table builtin. Try a
2767                            // VM-registered builtin by NAME — the run-time analog
2768                            // of the `CallBuiltin` opcode — BEFORE external exec,
2769                            // matching the shell's function -> builtin -> external
2770                            // resolution order. Without this, a builtin only ever
2771                            // reached through its compile-time `CallBuiltin`
2772                            // (literal name) is unreachable when the command name
2773                            // is resolved at run time (`$var`, `eval`, indirection).
2774                            if let Some(v) = self.run_builtin_by_name(&name, &args) {
2775                                v.to_int() as i32
2776                            } else {
2777                                let mut full = Vec::with_capacity(args.len() + 1);
2778                                full.push(name.clone());
2779                                full.extend(args);
2780                                self.host.as_mut().unwrap().exec(full)
2781                            }
2782                        }
2783                    }
2784                } else {
2785                    // No host — fall back to in-chunk function lookup, then external exec
2786                    let nidx = *name_idx;
2787                    if let Some(entry_ip) = self.chunk.find_sub(nidx) {
2788                        for arg in &args {
2789                            self.push(Value::str(arg));
2790                        }
2791                        self.frames.push(Frame {
2792                            return_ip: self.ip,
2793                            stack_base: self.stack.len() - args.len(),
2794                            slots: Vec::with_capacity(8),
2795                        });
2796                        self.ip = entry_ip;
2797                        return ExecFlow::Cont;
2798                    }
2799                    let mut full = Vec::with_capacity(args.len() + 1);
2800                    full.push(name);
2801                    full.extend(args);
2802                    #[cfg(not(target_arch = "wasm32"))]
2803                    {
2804                        use std::process::Command;
2805                        Command::new(&full[0])
2806                            .args(&full[1..])
2807                            .status()
2808                            .map(|s| s.code().unwrap_or(1))
2809                            .unwrap_or(127)
2810                    }
2811                    // No process model in a browser worker; a host-less wasm
2812                    // embedding reports 127 (command not found).
2813                    #[cfg(target_arch = "wasm32")]
2814                    {
2815                        let _ = &full;
2816                        127
2817                    }
2818                };
2819                self.last_status = status;
2820                self.push(Value::Status(status));
2821            }
2822
2823            // ── Remaining fused ops ──
2824            Op::ConcatConstLoop(const_idx, s_slot, i_slot, limit) => {
2825                let c_str = self
2826                    .chunk
2827                    .constants
2828                    .get(*const_idx as usize)
2829                    .map(|v| v.as_str_cow())
2830                    .unwrap_or(std::borrow::Cow::Borrowed(""));
2831                let mut s = self.get_slot(*s_slot).to_str();
2832                let mut i = self.get_slot(*i_slot).to_int();
2833                let lim = *limit as i64;
2834                let iters = (lim - i).max(0) as usize;
2835                s.reserve(c_str.len() * iters);
2836                while i < lim {
2837                    s.push_str(&c_str);
2838                    i += 1;
2839                }
2840                self.set_slot(*s_slot, Value::str(s));
2841                self.set_slot(*i_slot, Value::Int(i));
2842            }
2843            Op::PushIntRangeLoop(arr_idx, i_slot, limit) => {
2844                let mut i = self.get_slot(*i_slot).to_int();
2845                let lim = *limit as i64;
2846                let arr = self.get_var(*arr_idx);
2847                let mut vec = if let Value::Array(v) = arr {
2848                    v
2849                } else {
2850                    Vec::new()
2851                };
2852                vec.reserve((lim - i).max(0) as usize);
2853                while i < lim {
2854                    vec.push(Value::Int(i));
2855                    i += 1;
2856                }
2857                self.set_var(*arr_idx, Value::Array(vec));
2858                self.set_slot(*i_slot, Value::Int(i));
2859            }
2860
2861            // ── Higher-order (stubs) ──
2862            Op::MapBlock(_)
2863            | Op::GrepBlock(_)
2864            | Op::SortBlock(_)
2865            | Op::SortDefault
2866            | Op::ForEachBlock(_) => {}
2867
2868            // ── Builtins (inline cache) ──
2869            Op::CallBuiltin(id, argc) => {
2870                let (id, argc) = (*id, *argc);
2871                if let Some(Some(handler)) = self.builtin_table.get(id as usize) {
2872                    let result = handler(self, argc);
2873                    self.push(result);
2874                }
2875            }
2876
2877            // ── AWK ops (first-class; dispatched to the AwkHost, same path
2878            //    as the reserved ExtendedWide AWK range) ──
2879            Op::AwkFieldGet => self.dispatch_awk(ab::AWK_FIELD_GET, 0),
2880            Op::AwkFieldSet => self.dispatch_awk(ab::AWK_FIELD_SET, 0),
2881            Op::AwkNf => self.dispatch_awk(ab::AWK_NF, 0),
2882            Op::AwkSetRecord => self.dispatch_awk(ab::AWK_SET_RECORD, 0),
2883            Op::AwkSpecialGet(n) => self.dispatch_awk(ab::AWK_SPECIAL_GET, *n as usize),
2884            Op::AwkSpecialSet(n) => self.dispatch_awk(ab::AWK_SPECIAL_SET, *n as usize),
2885            Op::AwkPrint(argc) => self.dispatch_awk(ab::AWK_PRINT, *argc as usize),
2886            Op::AwkPrintf(argc) => self.dispatch_awk(ab::AWK_PRINTF, *argc as usize),
2887            Op::AwkSprintf(argc) => self.dispatch_awk(ab::AWK_SPRINTF, *argc as usize),
2888            Op::AwkGetline(src) => self.dispatch_awk(ab::AWK_GETLINE, *src as usize),
2889            Op::AwkLength(argc) => self.dispatch_awk(ab::AWK_LENGTH, *argc as usize),
2890            Op::AwkSubstr(argc) => self.dispatch_awk(ab::AWK_SUBSTR, *argc as usize),
2891            Op::AwkIndex => self.dispatch_awk(ab::AWK_INDEX, 0),
2892            Op::AwkSplit(argc) => self.dispatch_awk(ab::AWK_SPLIT, *argc as usize),
2893            Op::AwkSub(argc) => self.dispatch_awk(ab::AWK_SUB, *argc as usize),
2894            Op::AwkGsub(argc) => self.dispatch_awk(ab::AWK_GSUB, *argc as usize),
2895            Op::AwkMatch => self.dispatch_awk(ab::AWK_MATCH, 0),
2896            Op::AwkToLower => self.dispatch_awk(ab::AWK_TOLOWER, 0),
2897            Op::AwkToUpper => self.dispatch_awk(ab::AWK_TOUPPER, 0),
2898            Op::AwkInt => self.dispatch_awk(ab::AWK_INT, 0),
2899            Op::AwkSqrt => self.dispatch_awk(ab::AWK_SQRT, 0),
2900            Op::AwkSin => self.dispatch_awk(ab::AWK_SIN, 0),
2901            Op::AwkCos => self.dispatch_awk(ab::AWK_COS, 0),
2902            Op::AwkExp => self.dispatch_awk(ab::AWK_EXP, 0),
2903            Op::AwkLog => self.dispatch_awk(ab::AWK_LOG, 0),
2904            Op::AwkAtan2 => self.dispatch_awk(ab::AWK_ATAN2, 0),
2905            // awk `a / b` and `a % b`: pop b then a (same order as Op::Div),
2906            // raise the POSIX fatal error on a zero divisor instead of
2907            // yielding Undef. Distinct from the shared shell-arithmetic ops.
2908            Op::AwkDiv => {
2909                let b = self.pop();
2910                let a = self.pop();
2911                let divisor = b.to_float();
2912                if divisor == 0.0 {
2913                    return ExecFlow::Ret(VMResult::Error(
2914                        "division by zero attempted".to_string(),
2915                    ));
2916                }
2917                self.push(Value::Float(a.to_float() / divisor));
2918            }
2919            Op::AwkMod => {
2920                let b = self.pop();
2921                let a = self.pop();
2922                let divisor = b.to_float();
2923                if divisor == 0.0 {
2924                    return ExecFlow::Ret(VMResult::Error(
2925                        "division by zero attempted in `%'".to_string(),
2926                    ));
2927                }
2928                self.push(Value::Float(a.to_float() % divisor));
2929            }
2930            // Block-JIT-eligible div/mod (see `Op::AwkDivJit`). The
2931            // interpreter behavior is byte-identical to AwkDiv/AwkMod; the
2932            // distinct opcode only changes JIT eligibility.
2933            Op::AwkDivJit => {
2934                let b = self.pop();
2935                let a = self.pop();
2936                let divisor = b.to_float();
2937                if divisor == 0.0 {
2938                    return ExecFlow::Ret(VMResult::Error(
2939                        "division by zero attempted".to_string(),
2940                    ));
2941                }
2942                self.push(Value::Float(a.to_float() / divisor));
2943            }
2944            Op::AwkModJit => {
2945                let b = self.pop();
2946                let a = self.pop();
2947                let divisor = b.to_float();
2948                if divisor == 0.0 {
2949                    return ExecFlow::Ret(VMResult::Error(
2950                        "division by zero attempted in `%'".to_string(),
2951                    ));
2952                }
2953                self.push(Value::Float(a.to_float() % divisor));
2954            }
2955            // awk sqrt(x) — interpreter path. On negative input, emit the
2956            // generic "awk: warning: sqrt: received negative argument <x>"
2957            // warning to stderr (the JIT-trapped path uses the same generic
2958            // format via the warn libcall, so the two tiers agree).
2959            Op::AwkSqrtJit => {
2960                let a = self.pop().to_float();
2961                if a < 0.0 {
2962                    eprintln!("awk: warning: sqrt: received negative argument {a}");
2963                    self.push(Value::Float(f64::NAN));
2964                } else {
2965                    self.push(Value::Float(a.sqrt()));
2966                }
2967            }
2968            // awk log(x) — interpreter path. Negative emits the generic warn
2969            // and pushes NaN; zero returns -inf naturally (no lint warn in
2970            // this tier — host frontends that want LINT=1 behavior must use
2971            // the existing `Op::AwkLog` host-dispatched variant).
2972            Op::AwkLogJit => {
2973                let a = self.pop().to_float();
2974                if a < 0.0 {
2975                    eprintln!("awk: warning: log: received negative argument {a}");
2976                    self.push(Value::Float(f64::NAN));
2977                } else {
2978                    self.push(Value::Float(a.ln()));
2979                }
2980            }
2981            // awk lshift(a, n) — fatal on negative operands. Stack [a, n]:
2982            // pop n then a (matches the awk evaluation order pushed by
2983            // frontends).
2984            Op::AwkLshiftJit => {
2985                let n = self.pop().to_float();
2986                let a = self.pop().to_float();
2987                if a < 0.0 || n < 0.0 {
2988                    return ExecFlow::Ret(VMResult::Error(
2989                        "lshift: negative values are not allowed".to_string(),
2990                    ));
2991                }
2992                let shifted = (a as i64).wrapping_shl((n as u32) & 0x3f);
2993                self.push(Value::Float(shifted as f64));
2994            }
2995            // awk rshift(a, n) — same guard as lshift but logical right.
2996            Op::AwkRshiftJit => {
2997                let n = self.pop().to_float();
2998                let a = self.pop().to_float();
2999                if a < 0.0 || n < 0.0 {
3000                    return ExecFlow::Ret(VMResult::Error(
3001                        "rshift: negative values are not allowed".to_string(),
3002                    ));
3003                }
3004                let shifted = ((a as i64) as u64).wrapping_shr((n as u32) & 0x3f);
3005                self.push(Value::Float(shifted as f64));
3006            }
3007            // awk compl(a) — fatal on negative. `!a` in u64 space then back
3008            // to f64 (the high bits saturate the f64 mantissa, matching
3009            // awkrs's `num_to_u64` semantics).
3010            Op::AwkComplJit => {
3011                let a = self.pop().to_float();
3012                if a < 0.0 {
3013                    return ExecFlow::Ret(VMResult::Error(
3014                        "compl: negative value is not allowed".to_string(),
3015                    ));
3016                }
3017                let v = !(a as i64);
3018                self.push(Value::Float(v as f64));
3019            }
3020            // awk `$N` numeric read — interpreter path. Calls the same
3021            // host-installed hook as the JIT-compiled variant so behavior
3022            // matches across tiers. Returns 0.0 when no hook is set, which
3023            // matches awk's "missing field" coercion.
3024            Op::AwkGetFieldNum(field_idx) => {
3025                let v = crate::jit::fusevm_jit_awk_get_field_num(*field_idx as i64);
3026                self.push(Value::Float(v));
3027            }
3028            Op::PowFloat => {
3029                let b = self.pop();
3030                let a = self.pop();
3031                self.push(Value::Float(a.to_float().powf(b.to_float())));
3032            }
3033            Op::SqrtFloat => {
3034                let a = self.pop();
3035                self.push(Value::Float(a.to_float().sqrt()));
3036            }
3037            Op::SinFloat => {
3038                let a = self.pop();
3039                self.push(Value::Float(a.to_float().sin()));
3040            }
3041            Op::CosFloat => {
3042                let a = self.pop();
3043                self.push(Value::Float(a.to_float().cos()));
3044            }
3045            Op::ExpFloat => {
3046                let a = self.pop();
3047                self.push(Value::Float(a.to_float().exp()));
3048            }
3049            Op::Atan2Float => {
3050                let x = self.pop();
3051                let y = self.pop();
3052                self.push(Value::Float(y.to_float().atan2(x.to_float())));
3053            }
3054            Op::LogFloat => {
3055                let a = self.pop();
3056                self.push(Value::Float(a.to_float().ln()));
3057            }
3058            Op::AbsFloat => {
3059                let a = self.pop();
3060                self.push(Value::Float(a.to_float().abs()));
3061            }
3062            Op::TruncInt => {
3063                let a = self.pop();
3064                self.push(Value::Int(a.to_int()));
3065            }
3066            Op::CeilFloat => {
3067                let a = self.pop();
3068                self.push(Value::Float(a.to_float().ceil()));
3069            }
3070            Op::FloorFloat => {
3071                let a = self.pop();
3072                self.push(Value::Float(a.to_float().floor()));
3073            }
3074            Op::TruncFloat => {
3075                let a = self.pop();
3076                self.push(Value::Float(a.to_float().trunc()));
3077            }
3078            Op::RoundFloat => {
3079                let a = self.pop();
3080                self.push(Value::Float(a.to_float().round_ties_even()));
3081            }
3082            Op::TanFloat => {
3083                let a = self.pop();
3084                self.push(Value::Float(a.to_float().tan()));
3085            }
3086            Op::AsinFloat => {
3087                let a = self.pop();
3088                self.push(Value::Float(a.to_float().asin()));
3089            }
3090            Op::AcosFloat => {
3091                let a = self.pop();
3092                self.push(Value::Float(a.to_float().acos()));
3093            }
3094            Op::AtanFloat => {
3095                let a = self.pop();
3096                self.push(Value::Float(a.to_float().atan()));
3097            }
3098            Op::SinhFloat => {
3099                let a = self.pop();
3100                self.push(Value::Float(a.to_float().sinh()));
3101            }
3102            Op::CoshFloat => {
3103                let a = self.pop();
3104                self.push(Value::Float(a.to_float().cosh()));
3105            }
3106            Op::TanhFloat => {
3107                let a = self.pop();
3108                self.push(Value::Float(a.to_float().tanh()));
3109            }
3110            Op::Log2Float => {
3111                let a = self.pop();
3112                self.push(Value::Float(a.to_float().log2()));
3113            }
3114            Op::Log10Float => {
3115                let a = self.pop();
3116                self.push(Value::Float(a.to_float().log10()));
3117            }
3118            Op::AbsInt => {
3119                let a = self.pop();
3120                self.push(Value::Int(a.to_int().wrapping_abs()));
3121            }
3122            Op::GcdInt => {
3123                let b = self.pop().to_int().unsigned_abs();
3124                let a = self.pop().to_int().unsigned_abs();
3125                let mut x = a;
3126                let mut y = b;
3127                while y != 0 {
3128                    let t = x % y;
3129                    x = y;
3130                    y = t;
3131                }
3132                self.push(Value::Int(x as i64));
3133            }
3134            Op::LcmInt => {
3135                let b = self.pop().to_int().unsigned_abs();
3136                let a = self.pop().to_int().unsigned_abs();
3137                if a == 0 || b == 0 {
3138                    self.push(Value::Int(0));
3139                } else {
3140                    let mut x = a;
3141                    let mut y = b;
3142                    while y != 0 {
3143                        let t = x % y;
3144                        x = y;
3145                        y = t;
3146                    }
3147                    let prod = (a / x).saturating_mul(b);
3148                    self.push(Value::Int(prod.min(i64::MAX as u64) as i64));
3149                }
3150            }
3151            Op::TimeInt => {
3152                self.push(Value::Int(crate::sysclock::unix_secs()));
3153            }
3154            Op::AwkArrayGet(n) => self.dispatch_awk(ab::AWK_ARRAY_GET, *n as usize),
3155            Op::AwkArraySet(n) => self.dispatch_awk(ab::AWK_ARRAY_SET, *n as usize),
3156            Op::AwkArrayExists(n) => self.dispatch_awk(ab::AWK_ARRAY_EXISTS, *n as usize),
3157            Op::AwkArrayDelete(n) => self.dispatch_awk(ab::AWK_ARRAY_DELETE, *n as usize),
3158            Op::AwkArrayClear(n) => self.dispatch_awk(ab::AWK_ARRAY_CLEAR, *n as usize),
3159            Op::AwkArrayLen(n) => self.dispatch_awk(ab::AWK_ARRAY_LEN, *n as usize),
3160            Op::AwkAnd(argc) => self.dispatch_awk(ab::AWK_AND, *argc as usize),
3161            Op::AwkOr(argc) => self.dispatch_awk(ab::AWK_OR, *argc as usize),
3162            Op::AwkXor(argc) => self.dispatch_awk(ab::AWK_XOR, *argc as usize),
3163            Op::AwkCompl => self.dispatch_awk(ab::AWK_COMPL, 0),
3164            Op::AwkLshift => self.dispatch_awk(ab::AWK_LSHIFT, 0),
3165            Op::AwkRshift => self.dispatch_awk(ab::AWK_RSHIFT, 0),
3166            Op::AwkStrtonum => self.dispatch_awk(ab::AWK_STRTONUM, 0),
3167            Op::AwkSystime => self.dispatch_awk(ab::AWK_SYSTIME, 0),
3168            Op::AwkRand => self.dispatch_awk(ab::AWK_RAND, 0),
3169            Op::AwkSrand(argc) => self.dispatch_awk(ab::AWK_SRAND, *argc as usize),
3170            Op::AwkStrftime(argc) => self.dispatch_awk(ab::AWK_STRFTIME, *argc as usize),
3171            Op::AwkMktime(argc) => self.dispatch_awk(ab::AWK_MKTIME, *argc as usize),
3172            Op::AwkOrd => self.dispatch_awk(ab::AWK_ORD, 1),
3173            Op::AwkChr => self.dispatch_awk(ab::AWK_CHR, 1),
3174            Op::AwkMkbool => self.dispatch_awk(ab::AWK_MKBOOL, 1),
3175            Op::AwkIntdiv => self.dispatch_awk(ab::AWK_INTDIV, 2),
3176            Op::AwkIntdiv0 => self.dispatch_awk(ab::AWK_INTDIV0, 2),
3177            Op::AwkGensub(argc) => self.dispatch_awk(ab::AWK_GENSUB, *argc as usize),
3178            Op::AwkSignal(code) => {
3179                // Raise the AWK control-flow signal and halt this chunk; the
3180                // frontend driver reads `self.awk_signal()` after `run()`.
3181                self.awk_signal = Some(*code);
3182                self.halted = true;
3183            }
3184
3185            // ── cooperative concurrency: raise a scheduling request + halt ──
3186            // The op has already advanced `self.ip`, so on resume `run()`
3187            // continues past it — the scheduler delivers any result (a channel
3188            // id, a received value) by pushing onto this VM's stack first.
3189            Op::Go(name_idx, argc) => {
3190                let n = *argc as usize;
3191                let mut args = Vec::with_capacity(n);
3192                for _ in 0..n {
3193                    args.push(self.pop());
3194                }
3195                args.reverse();
3196                self.sched = Some(crate::sched::SchedReq::Go {
3197                    name_idx: *name_idx,
3198                    args,
3199                });
3200                self.halted = true;
3201            }
3202            Op::ChanMake => {
3203                let cap = self.pop().to_int().max(0) as usize;
3204                self.sched = Some(crate::sched::SchedReq::Make { cap });
3205                self.halted = true;
3206            }
3207            Op::ChanSend => {
3208                let val = self.pop();
3209                let ch = self.pop().to_int();
3210                self.sched = Some(crate::sched::SchedReq::Send { ch, val });
3211                self.halted = true;
3212            }
3213            Op::ChanRecv => {
3214                let ch = self.pop().to_int();
3215                self.sched = Some(crate::sched::SchedReq::Recv { ch });
3216                self.halted = true;
3217            }
3218            Op::ChanClose => {
3219                let ch = self.pop().to_int();
3220                self.sched = Some(crate::sched::SchedReq::Close { ch });
3221                self.halted = true;
3222            }
3223            Op::CallDynamic(argc) => {
3224                // The subroutine name-index is on top; the `argc` args are below.
3225                let name_idx = self.pop().to_int() as u16;
3226                if let Some(entry_ip) = self.chunk.find_sub(name_idx) {
3227                    self.frames.push(Frame {
3228                        return_ip: self.ip,
3229                        stack_base: self.stack.len() - *argc as usize,
3230                        slots: Vec::new(),
3231                    });
3232                    self.ip = entry_ip;
3233                } else {
3234                    return ExecFlow::Ret(VMResult::Error(
3235                        "call of a nil or unknown function value".to_string(),
3236                    ));
3237                }
3238            }
3239            Op::Select(num_cases, has_default) => {
3240                let n = *num_cases as usize;
3241                let mut raw = Vec::with_capacity(n * 3);
3242                for _ in 0..n * 3 {
3243                    raw.push(self.pop());
3244                }
3245                raw.reverse();
3246                let cases = raw
3247                    .chunks_exact(3)
3248                    .map(|c| crate::sched::SelectCase {
3249                        ch: c[0].to_int(),
3250                        recv: c[1].to_int() != 0,
3251                        val: c[2].clone(),
3252                    })
3253                    .collect();
3254                self.sched = Some(crate::sched::SchedReq::Select {
3255                    cases,
3256                    has_default: *has_default != 0,
3257                });
3258                self.halted = true;
3259            }
3260        }
3261
3262        // Tracing JIT: finalize an active recording on either:
3263        //   (a) the recorder was marked aborted earlier (e.g. trace
3264        //       exceeded MAX_TRACE_LEN, observed CallBuiltin, etc.) —
3265        //       discard and clean up the cache entry, OR
3266        //   (b) the just-dispatched jump landed at the anchor IP —
3267        //       this is the loop-closing backward branch.
3268        // Internal mid-trace branches that DON'T land at the anchor
3269        // continue recording; their direction is captured in
3270        // `recorded_ips` for later compile-time guard emission.
3271        // Only run finalize if the recorder was armed *before* this step;
3272        // a recorder freshly armed inside this step starts recording on
3273        // the next iteration.
3274        #[cfg(feature = "jit")]
3275        if recorder_was_armed && self.recorder.is_some() {
3276            let aborted = self.recorder.as_ref().map_or(false, |r| r.aborted);
3277            // Phase 9: close on the recorded `close_anchor_ip` rather
3278            // than `record_anchor_ip` — for side traces these differ.
3279            let close_ip = self
3280                .recorder
3281                .as_ref()
3282                .map(|r| r.close_anchor_ip)
3283                .unwrap_or(0);
3284            let was_jump = matches!(
3285                &ops[ip],
3286                Op::Jump(_)
3287                    | Op::JumpIfTrue(_)
3288                    | Op::JumpIfFalse(_)
3289                    | Op::JumpIfTrueKeep(_)
3290                    | Op::JumpIfFalseKeep(_)
3291            );
3292            let landed_at_anchor = self.ip == close_ip;
3293            if aborted || (was_jump && landed_at_anchor) {
3294                self.finalize_recorder();
3295            }
3296        }
3297        ExecFlow::Cont
3298    }
3299
3300    /// AOT closed-world per-op step. Mirrors one iteration of the [`VM::run`]
3301    /// dispatch loop for the op at `ip`: advances `self.ip` to `ip + 1`, runs
3302    /// the op via [`VM::exec_op`], and returns the **next instruction index**
3303    /// for the native driver to branch to — or `-1` when the run terminates
3304    /// (an op returned [`ExecFlow::Ret`] or set the halted flag). On terminate,
3305    /// any explicit result is stashed in `self.aot_result`. The returned index
3306    /// is `ip + 1` for ordinary ops and the jump/call/return target for
3307    /// control-flow ops, so the native driver branches without ever reading the
3308    /// `VM` struct layout.
3309    #[cfg(feature = "aot")]
3310    pub(crate) fn aot_exec_op(&mut self, ip: usize) -> i64 {
3311        // SAFETY: chunk.ops is not mutated during execution; alias it past the
3312        // borrow checker exactly as VM::run does.
3313        let ops = &self.chunk.ops as *const Vec<Op>;
3314        let ops = unsafe { &*ops };
3315        self.ip = ip + 1;
3316        match self.exec_op(ops, ip, false) {
3317            ExecFlow::Ret(r) => {
3318                self.aot_result = Some(r);
3319                return -1;
3320            }
3321            ExecFlow::Cont => {}
3322        }
3323        if self.halted {
3324            return -1;
3325        }
3326        self.ip as i64
3327    }
3328
3329    /// Finalize an AOT run: if no op stored an explicit result, apply the same
3330    /// tail logic as [`VM::run`] (pop the stack top as the value, else
3331    /// `Halted`). Called once by the native driver's return path.
3332    #[cfg(feature = "aot")]
3333    pub(crate) fn aot_finish(&mut self) {
3334        if self.aot_result.is_none() {
3335            self.aot_result = Some(match self.stack.pop() {
3336                Some(v) => VMResult::Ok(v),
3337                None => VMResult::Halted,
3338            });
3339        }
3340    }
3341
3342    /// Store an explicit integer result computed by natively-lowered AOT code.
3343    /// The native fast path holds intermediate values in registers (never on
3344    /// `self.stack`), so it reports its final value through this hook instead of
3345    /// the stack-tail logic in [`VM::aot_finish`].
3346    #[cfg(feature = "aot")]
3347    pub(crate) fn aot_set_int_result(&mut self, n: i64) {
3348        self.aot_result = Some(VMResult::Ok(Value::Int(n)));
3349    }
3350
3351    /// Store an explicit float result computed by natively-lowered AOT code.
3352    /// The float analog of [`VM::aot_set_int_result`].
3353    #[cfg(feature = "aot")]
3354    pub(crate) fn aot_set_float_result(&mut self, f: f64) {
3355        self.aot_result = Some(VMResult::Ok(Value::Float(f)));
3356    }
3357
3358    /// Allocate an arena slot holding `v`, returning its handle. Reuses a freed
3359    /// slot when available so loops that rebuild values stay bounded.
3360    #[cfg(feature = "aot")]
3361    fn aot_alloc(&mut self, v: Value) -> i64 {
3362        if let Some(h) = self.aot_free.pop() {
3363            self.aot_arena[h as usize] = v;
3364            h as i64
3365        } else {
3366            let h = self.aot_arena.len() as i64;
3367            self.aot_arena.push(v);
3368            h
3369        }
3370    }
3371
3372    /// Take the value out of an arena slot, freeing the slot for reuse. A handle
3373    /// is owned by exactly one place, so consuming it returns its slot.
3374    #[cfg(feature = "aot")]
3375    fn aot_take(&mut self, handle: i64) -> Value {
3376        match self.aot_arena.get_mut(handle as usize) {
3377            Some(slot) => {
3378                let v = std::mem::replace(slot, Value::Undef);
3379                self.aot_free.push(handle as u32);
3380                v
3381            }
3382            None => Value::Undef,
3383        }
3384    }
3385
3386    /// Box the boxed-stack top into the value arena, returning its owning handle.
3387    /// A shimmed op leaves its (boxed) result on `self.stack`; the native code
3388    /// stashes it here and threads the handle through a register, the way scalars
3389    /// are threaded directly.
3390    #[cfg(feature = "aot")]
3391    pub(crate) fn aot_box(&mut self) -> i64 {
3392        let v = self.stack.pop().unwrap_or(Value::Undef);
3393        self.aot_alloc(v)
3394    }
3395
3396    /// Push the value for `handle` back onto the boxed stack so a shimmed op can
3397    /// consume it, *consuming* the handle (its slot is freed). The inverse of
3398    /// [`VM::aot_box`]; every owned handle is unboxed exactly once.
3399    #[cfg(feature = "aot")]
3400    pub(crate) fn aot_unbox(&mut self, handle: i64) {
3401        let v = self.aot_take(handle);
3402        self.stack.push(v);
3403    }
3404
3405    /// Clone the value behind `handle` into a fresh owned handle (used when a
3406    /// slot is *read*: the slot keeps its handle, the stack gets its own copy).
3407    #[cfg(feature = "aot")]
3408    pub(crate) fn aot_clone(&mut self, handle: i64) -> i64 {
3409        let v = self
3410            .aot_arena
3411            .get(handle as usize)
3412            .cloned()
3413            .unwrap_or(Value::Undef);
3414        self.aot_alloc(v)
3415    }
3416
3417    /// Free an owned handle without using its value (e.g. a slot overwritten, or
3418    /// an `Obj` popped). A negative handle is the "empty slot" sentinel: no-op.
3419    #[cfg(feature = "aot")]
3420    pub(crate) fn aot_free(&mut self, handle: i64) {
3421        if handle >= 0 && (handle as usize) < self.aot_arena.len() {
3422            self.aot_arena[handle as usize] = Value::Undef;
3423            self.aot_free.push(handle as u32);
3424        }
3425    }
3426
3427    /// Store an explicit boxed (heap) result from a register handle (consuming
3428    /// it). The arena analog of [`VM::aot_set_int_result`].
3429    #[cfg(feature = "aot")]
3430    pub(crate) fn aot_set_obj_result(&mut self, handle: i64) {
3431        let v = self.aot_take(handle);
3432        self.aot_result = Some(VMResult::Ok(v));
3433    }
3434
3435    /// Write a register-held `Obj` slot/global back to the VM on deopt: store a
3436    /// *clone* of the handle's value (the run ends in the interpreter afterward,
3437    /// so the arena is abandoned — cloning avoids disturbing other live handles).
3438    #[cfg(feature = "aot")]
3439    pub(crate) fn aot_store_slot_obj(&mut self, idx: u32, handle: i64) {
3440        let v = self
3441            .aot_arena
3442            .get(handle as usize)
3443            .cloned()
3444            .unwrap_or(Value::Undef);
3445        self.set_slot(idx as u16, v);
3446    }
3447
3448    /// Global analog of [`VM::aot_store_slot_obj`].
3449    #[cfg(feature = "aot")]
3450    pub(crate) fn aot_store_global_obj(&mut self, idx: u32, handle: i64) {
3451        let v = self
3452            .aot_arena
3453            .get(handle as usize)
3454            .cloned()
3455            .unwrap_or(Value::Undef);
3456        self.set_var(idx as u16, v);
3457    }
3458
3459    /// Spill a scalar from a native register onto the boxed operand stack, so a
3460    /// shimmed (non-lowered) op can consume it. Boxed by kind to match exactly
3461    /// what the interpreter would have on the stack.
3462    #[cfg(feature = "aot")]
3463    pub(crate) fn aot_push_int(&mut self, n: i64) {
3464        self.stack.push(Value::Int(n));
3465    }
3466
3467    /// Float analog of [`VM::aot_push_int`].
3468    #[cfg(feature = "aot")]
3469    pub(crate) fn aot_push_float(&mut self, f: f64) {
3470        self.stack.push(Value::Float(f));
3471    }
3472
3473    /// Bool analog of [`VM::aot_push_int`] (the register carries 0/1).
3474    #[cfg(feature = "aot")]
3475    pub(crate) fn aot_push_bool(&mut self, n: i64) {
3476        self.stack.push(Value::Bool(n != 0));
3477    }
3478
3479    /// Write a register-resident slot back to the VM frame on deopt, so the
3480    /// resumed interpreter sees the current value. (Native code caches slots in
3481    /// registers; the frame is otherwise stale.)
3482    #[cfg(feature = "aot")]
3483    pub(crate) fn aot_store_slot_int(&mut self, idx: u32, n: i64) {
3484        self.set_slot(idx as u16, Value::Int(n));
3485    }
3486
3487    /// Float analog of [`VM::aot_store_slot_int`].
3488    #[cfg(feature = "aot")]
3489    pub(crate) fn aot_store_slot_float(&mut self, idx: u32, f: f64) {
3490        self.set_slot(idx as u16, Value::Float(f));
3491    }
3492
3493    /// Global analog of [`VM::aot_store_slot_int`].
3494    #[cfg(feature = "aot")]
3495    pub(crate) fn aot_store_global_int(&mut self, idx: u32, n: i64) {
3496        self.set_var(idx as u16, Value::Int(n));
3497    }
3498
3499    /// Float global analog of [`VM::aot_store_slot_int`].
3500    #[cfg(feature = "aot")]
3501    pub(crate) fn aot_store_global_float(&mut self, idx: u32, f: f64) {
3502        self.set_var(idx as u16, Value::Float(f));
3503    }
3504
3505    /// Deopt exit: resume interpretation from `ip` with the VM state the native
3506    /// code just reconstructed (operand stack spilled, slots/globals written
3507    /// back), and capture the rest-of-chunk result. One-way — native code does
3508    /// not re-enter after this.
3509    #[cfg(feature = "aot")]
3510    pub(crate) fn aot_resume(&mut self, ip: u32) {
3511        self.ip = ip as usize;
3512        let r = self.run();
3513        self.aot_result = Some(r);
3514    }
3515
3516    /// Reload a float off the boxed operand stack into a native register — the
3517    /// reload half of the boundary, for source ops whose result kind is
3518    /// statically `Float` (so no runtime type guard is needed).
3519    #[cfg(feature = "aot")]
3520    pub(crate) fn aot_pop_float(&mut self) -> f64 {
3521        self.stack.pop().map(|v| v.to_float()).unwrap_or(0.0)
3522    }
3523
3524    /// Reload an integer (via `to_int`) off the boxed operand stack — used for
3525    /// `GetStatus`, whose `Value::Status` carries its code as the int.
3526    #[cfg(feature = "aot")]
3527    pub(crate) fn aot_pop_int(&mut self) -> i64 {
3528        self.stack.pop().map(|v| v.to_int()).unwrap_or(0)
3529    }
3530
3531    /// Spill a `Status` code from a register onto the boxed stack (deopt/sink).
3532    #[cfg(feature = "aot")]
3533    pub(crate) fn aot_push_status(&mut self, n: i64) {
3534        self.stack.push(Value::Status(n as i32));
3535    }
3536
3537    /// Store a `Status` result computed by natively-lowered AOT code.
3538    #[cfg(feature = "aot")]
3539    pub(crate) fn aot_set_status_result(&mut self, n: i64) {
3540        self.aot_result = Some(VMResult::Ok(Value::Status(n as i32)));
3541    }
3542
3543    /// Store an error result from natively-lowered AOT code, keyed by a small
3544    /// code so the native side passes an integer rather than a string. The
3545    /// messages match the interpreter's for the corresponding ops exactly.
3546    #[cfg(feature = "aot")]
3547    pub(crate) fn aot_set_error(&mut self, code: u32) {
3548        let msg = match code {
3549            0 => "division by zero attempted",
3550            1 => "division by zero attempted in `%'",
3551            2 => "lshift: negative values are not allowed",
3552            3 => "rshift: negative values are not allowed",
3553            4 => "compl: negative value is not allowed",
3554            _ => "aot: runtime error",
3555        };
3556        self.aot_result = Some(VMResult::Error(msg.to_string()));
3557    }
3558
3559    /// Take the result captured by the AOT driver, leaving `None` behind.
3560    #[cfg(feature = "aot")]
3561    pub(crate) fn take_aot_result(&mut self) -> VMResult {
3562        self.aot_result.take().unwrap_or(VMResult::Halted)
3563    }
3564
3565    // ── Helpers ──
3566
3567    /// Dispatch one AWK op (`Op::ExtendedWide(id, payload)` with `id` in the
3568    /// reserved AWK range) through the registered [`AwkHost`]. Value operands
3569    /// come from the stack (pushed in source order); `payload` carries the
3570    /// inline integer operand (field index / argument count / name-pool index).
3571    ///
3572    /// When no AWK host is registered, AWK ops are inert: ops that yield a value
3573    /// push a neutral default so the stack stays balanced, statement-form ops
3574    /// (`print`/`delete`/field-set) simply drop their operands.
3575    ///
3576    /// [`AwkHost`]: crate::awk_host::AwkHost
3577    fn dispatch_awk(&mut self, id: u16, payload: usize) {
3578        use crate::awk_builtins as ab;
3579        use crate::awk_host::AwkLvalue;
3580
3581        // Take the host out to satisfy the borrow checker (handlers reach back
3582        // into `self` via the stack); restore it afterwards. Mirrors the
3583        // `ext_handler` take/restore pattern used for `Op::Extended`.
3584        let mut host = match self.awk_host.take() {
3585            Some(h) => h,
3586            None => {
3587                self.dispatch_awk_stub(id, payload);
3588                return;
3589            }
3590        };
3591
3592        // Pop `n` value operands, returned in source (pushed) order.
3593        macro_rules! pop_n {
3594            ($n:expr) => {{
3595                let n = $n;
3596                let mut v: Vec<Value> = (0..n).map(|_| self.pop()).collect();
3597                v.reverse();
3598                v
3599            }};
3600        }
3601        let name_at = |vm: &Self, idx: usize| -> String {
3602            vm.chunk.names.get(idx).cloned().unwrap_or_default()
3603        };
3604
3605        match id {
3606            ab::AWK_FIELD_GET => {
3607                let i = self.pop().to_int();
3608                let v = host.field_get(i);
3609                self.push(v);
3610            }
3611            ab::AWK_FIELD_SET => {
3612                let i = self.pop().to_int();
3613                let v = self.pop();
3614                host.field_set(i, v);
3615            }
3616            ab::AWK_NF => {
3617                let n = host.nf();
3618                self.push(Value::Int(n));
3619            }
3620            ab::AWK_SET_RECORD => {
3621                let v = self.pop();
3622                host.set_record(v);
3623            }
3624            ab::AWK_SPECIAL_GET => {
3625                let name = name_at(self, payload);
3626                let v = host.special_get(&name);
3627                self.push(v);
3628            }
3629            ab::AWK_SPECIAL_SET => {
3630                let name = name_at(self, payload);
3631                let v = self.pop();
3632                host.special_set(&name, v);
3633            }
3634            ab::AWK_PRINT => {
3635                let args = pop_n!(payload);
3636                host.print(&args);
3637            }
3638            ab::AWK_PRINTF => {
3639                let mut args = pop_n!(payload);
3640                let fmt = if args.is_empty() {
3641                    String::new()
3642                } else {
3643                    args.remove(0).to_str()
3644                };
3645                host.printf(&fmt, &args);
3646            }
3647            ab::AWK_SPRINTF => {
3648                let mut args = pop_n!(payload);
3649                let fmt = if args.is_empty() {
3650                    String::new()
3651                } else {
3652                    args.remove(0).to_str()
3653                };
3654                let v = host.sprintf(&fmt, &args);
3655                self.push(v);
3656            }
3657            ab::AWK_GETLINE => {
3658                // For file/command sources the operand string is on the stack.
3659                let operand = match payload {
3660                    ab::getline_source::FILE
3661                    | ab::getline_source::FILE_VAR
3662                    | ab::getline_source::CMD
3663                    | ab::getline_source::CMD_VAR => Some(self.pop().to_str()),
3664                    _ => None,
3665                };
3666                let status = host.getline(payload, operand.as_deref(), None);
3667                self.push(Value::Int(status));
3668            }
3669            ab::AWK_LENGTH => {
3670                let arg = if payload == 0 { None } else { Some(self.pop()) };
3671                let n = host.length(arg.as_ref());
3672                self.push(Value::Int(n));
3673            }
3674            ab::AWK_SUBSTR => {
3675                let args = pop_n!(payload);
3676                let s = args.first().cloned().unwrap_or(Value::str(""));
3677                let m = args.get(1).map(|v| v.to_int()).unwrap_or(1);
3678                let n = args.get(2).map(|v| v.to_int());
3679                let v = host.substr(&s, m, n);
3680                self.push(v);
3681            }
3682            ab::AWK_INDEX => {
3683                let t = self.pop();
3684                let s = self.pop();
3685                let r = host.index(&s, &t);
3686                self.push(Value::Int(r));
3687            }
3688            ab::AWK_SPLIT => {
3689                let args = pop_n!(payload);
3690                let s = args.first().cloned().unwrap_or(Value::str(""));
3691                let arr = args.get(1).map(|v| v.to_str()).unwrap_or_default();
3692                let fs = args.get(2);
3693                let n = host.split(&s, &arr, fs);
3694                self.push(Value::Int(n));
3695            }
3696            ab::AWK_SUB | ab::AWK_GSUB => {
3697                // Stack: [re, repl, target_name]. The target name string lets
3698                // the host write back to the right lvalue (a var here; field /
3699                // array targets use their own dedicated emission).
3700                let args = pop_n!(payload);
3701                let re = args.first().cloned().unwrap_or(Value::str(""));
3702                let repl = args.get(1).cloned().unwrap_or(Value::str(""));
3703                let target = args
3704                    .get(2)
3705                    .map(|v| AwkLvalue::Var(v.to_str()))
3706                    .unwrap_or(AwkLvalue::Field(0));
3707                let n = if id == ab::AWK_SUB {
3708                    host.sub(&re, &repl, &target)
3709                } else {
3710                    host.gsub(&re, &repl, &target)
3711                };
3712                self.push(Value::Int(n));
3713            }
3714            ab::AWK_MATCH => {
3715                let re = self.pop();
3716                let s = self.pop();
3717                let r = host.match_re(&s, &re);
3718                self.push(Value::Int(r));
3719            }
3720            ab::AWK_GENSUB => {
3721                // Stack: [re, repl, how, target?] (payload = argc, 3..=4).
3722                let args = pop_n!(payload);
3723                let re = args.first().cloned().unwrap_or(Value::str(""));
3724                let repl = args.get(1).cloned().unwrap_or(Value::str(""));
3725                let how = args.get(2).cloned().unwrap_or(Value::str("g"));
3726                let target = args.get(3);
3727                let v = host.gensub(&re, &repl, &how, target);
3728                self.push(v);
3729            }
3730            ab::AWK_TOLOWER => {
3731                let s = self.pop();
3732                let v = host.tolower(&s);
3733                self.push(v);
3734            }
3735            ab::AWK_TOUPPER => {
3736                let s = self.pop();
3737                let v = host.toupper(&s);
3738                self.push(v);
3739            }
3740            ab::AWK_INT => {
3741                let x = self.pop();
3742                let v = host.int(&x);
3743                self.push(v);
3744            }
3745            ab::AWK_SQRT => {
3746                let x = self.pop();
3747                let v = host.sqrt(&x);
3748                self.push(v);
3749            }
3750            ab::AWK_SIN => {
3751                let x = self.pop();
3752                let v = host.sin(&x);
3753                self.push(v);
3754            }
3755            ab::AWK_COS => {
3756                let x = self.pop();
3757                let v = host.cos(&x);
3758                self.push(v);
3759            }
3760            ab::AWK_EXP => {
3761                let x = self.pop();
3762                let v = host.exp(&x);
3763                self.push(v);
3764            }
3765            ab::AWK_LOG => {
3766                let x = self.pop();
3767                let v = host.log(&x);
3768                self.push(v);
3769            }
3770            ab::AWK_ATAN2 => {
3771                let x = self.pop();
3772                let y = self.pop();
3773                let v = host.atan2(&y, &x);
3774                self.push(v);
3775            }
3776            ab::AWK_AND => {
3777                let args = pop_n!(payload);
3778                let v = host.and(&args);
3779                self.push(v);
3780            }
3781            ab::AWK_OR => {
3782                let args = pop_n!(payload);
3783                let v = host.or(&args);
3784                self.push(v);
3785            }
3786            ab::AWK_XOR => {
3787                let args = pop_n!(payload);
3788                let v = host.xor(&args);
3789                self.push(v);
3790            }
3791            ab::AWK_COMPL => {
3792                let v = self.pop();
3793                let r = host.compl(&v);
3794                self.push(r);
3795            }
3796            ab::AWK_LSHIFT => {
3797                let n = self.pop();
3798                let v = self.pop();
3799                let r = host.lshift(&v, &n);
3800                self.push(r);
3801            }
3802            ab::AWK_RSHIFT => {
3803                let n = self.pop();
3804                let v = self.pop();
3805                let r = host.rshift(&v, &n);
3806                self.push(r);
3807            }
3808            ab::AWK_STRTONUM => {
3809                let s = self.pop();
3810                let r = host.strtonum(&s);
3811                self.push(r);
3812            }
3813            ab::AWK_SYSTIME => {
3814                let r = host.systime();
3815                self.push(r);
3816            }
3817            ab::AWK_RAND => {
3818                let r = crate::awk_host::awk_rand(&mut self.awk_rand_seed);
3819                self.push(Value::Float(r));
3820            }
3821            ab::AWK_SRAND => {
3822                let n = if payload >= 1 {
3823                    Some(self.pop().to_float() as u32 as u64)
3824                } else {
3825                    None
3826                };
3827                let r = crate::awk_host::awk_srand(&mut self.awk_rand_seed, n);
3828                self.push(Value::Float(r));
3829            }
3830            ab::AWK_STRFTIME => {
3831                let args = pop_n!(payload);
3832                let r = host.strftime(&args);
3833                self.push(r);
3834            }
3835            ab::AWK_MKTIME => {
3836                let args = pop_n!(payload);
3837                let r = host.mktime(&args);
3838                self.push(r);
3839            }
3840            ab::AWK_ORD => {
3841                let a = self.pop();
3842                let r = host.ord(&a);
3843                self.push(r);
3844            }
3845            ab::AWK_CHR => {
3846                let a = self.pop();
3847                let r = host.chr(&a);
3848                self.push(r);
3849            }
3850            ab::AWK_MKBOOL => {
3851                let a = self.pop();
3852                let r = host.mkbool(&a);
3853                self.push(r);
3854            }
3855            ab::AWK_INTDIV => {
3856                let b = self.pop();
3857                let a = self.pop();
3858                let r = host.intdiv(&a, &b);
3859                self.push(r);
3860            }
3861            ab::AWK_INTDIV0 => {
3862                let b = self.pop();
3863                let a = self.pop();
3864                let r = host.intdiv0(&a, &b);
3865                self.push(r);
3866            }
3867            ab::AWK_ARRAY_GET => {
3868                let name = name_at(self, payload);
3869                let key = self.pop();
3870                let v = host.array_get(&name, &key);
3871                self.push(v);
3872            }
3873            ab::AWK_ARRAY_SET => {
3874                let name = name_at(self, payload);
3875                let key = self.pop();
3876                let v = self.pop();
3877                host.array_set(&name, &key, v);
3878            }
3879            ab::AWK_ARRAY_EXISTS => {
3880                let name = name_at(self, payload);
3881                let key = self.pop();
3882                let b = host.array_exists(&name, &key);
3883                self.push(Value::Bool(b));
3884            }
3885            ab::AWK_ARRAY_DELETE => {
3886                let name = name_at(self, payload);
3887                let key = self.pop();
3888                host.array_delete(&name, &key);
3889            }
3890            ab::AWK_ARRAY_CLEAR => {
3891                let name = name_at(self, payload);
3892                host.array_clear(&name);
3893            }
3894            ab::AWK_ARRAY_LEN => {
3895                let name = name_at(self, payload);
3896                let n = host.array_len(&name);
3897                self.push(Value::Int(n));
3898            }
3899            // Unknown AWK op id: drop nothing, push Undef to keep callers that
3900            // expect a value from glitching. (Reserved range, forward-compat.)
3901            _ => self.push(Value::Undef),
3902        }
3903
3904        self.awk_host = Some(host);
3905    }
3906
3907    /// Inert fallback for AWK ops when no [`AwkHost`] is registered. Keeps the
3908    /// stack balanced: value-producing ops push a neutral default; statement
3909    /// ops drop their operands.
3910    fn dispatch_awk_stub(&mut self, id: u16, payload: usize) {
3911        use crate::awk_builtins as ab;
3912        use crate::awk_host::{
3913            awk_canon_nan, awk_chr, awk_compl, awk_fold_and, awk_fold_or, awk_fold_xor, awk_index,
3914            awk_int, awk_intdiv, awk_intdiv0, awk_length, awk_lshift, awk_mkbool, awk_mktime,
3915            awk_ord, awk_rand, awk_rshift, awk_srand, awk_strftime, awk_strtonum, awk_substr,
3916            awk_systime, awk_tolower, awk_toupper,
3917        };
3918        match id {
3919            // value-producing: pop declared operands, push neutral default
3920            ab::AWK_FIELD_GET => {
3921                self.pop();
3922                self.push(Value::str(""));
3923            }
3924            // `length(s)` (scalar form, payload>0) is host-independent — compute
3925            // it natively. `length($0)` (payload==0) and `length(arr)` need the
3926            // host, so they still yield 0 here.
3927            ab::AWK_LENGTH if payload > 0 => {
3928                let s = self.pop();
3929                self.push(Value::Int(awk_length(Some(&s))));
3930            }
3931            ab::AWK_NF | ab::AWK_LENGTH | ab::AWK_ARRAY_LEN => {
3932                self.push(Value::Int(0));
3933            }
3934            ab::AWK_SPECIAL_GET => self.push(Value::Undef),
3935            ab::AWK_SPRINTF => {
3936                for _ in 0..payload {
3937                    self.pop();
3938                }
3939                self.push(Value::str(""));
3940            }
3941            // Host-independent string builtins: compute the real result so these
3942            // AWK ops execute natively even with no registered host. Operand pop
3943            // order mirrors the host path in `dispatch_awk`.
3944            ab::AWK_SUBSTR => {
3945                let mut args: Vec<Value> = (0..payload).map(|_| self.pop()).collect();
3946                args.reverse();
3947                let s = args.first().cloned().unwrap_or(Value::str(""));
3948                let m = args.get(1).map(|v| v.to_int()).unwrap_or(1);
3949                let n = args.get(2).map(|v| v.to_int());
3950                self.push(awk_substr(&s, m, n));
3951            }
3952            ab::AWK_TOLOWER => {
3953                let s = self.pop();
3954                self.push(awk_tolower(&s));
3955            }
3956            ab::AWK_TOUPPER => {
3957                let s = self.pop();
3958                self.push(awk_toupper(&s));
3959            }
3960            // Host-independent numeric builtins: pure f64 math, computed
3961            // natively even with no registered host.
3962            ab::AWK_INT => {
3963                let x = self.pop();
3964                self.push(awk_int(&x));
3965            }
3966            ab::AWK_SQRT => {
3967                let x = self.pop();
3968                self.push(Value::Float(x.to_float().sqrt()));
3969            }
3970            ab::AWK_SIN => {
3971                let x = self.pop();
3972                self.push(Value::Float(awk_canon_nan(x.to_float().sin())));
3973            }
3974            ab::AWK_COS => {
3975                let x = self.pop();
3976                self.push(Value::Float(awk_canon_nan(x.to_float().cos())));
3977            }
3978            ab::AWK_EXP => {
3979                let x = self.pop();
3980                self.push(Value::Float(awk_canon_nan(x.to_float().exp())));
3981            }
3982            ab::AWK_LOG => {
3983                let x = self.pop();
3984                self.push(Value::Float(x.to_float().ln()));
3985            }
3986            ab::AWK_ATAN2 => {
3987                let x = self.pop();
3988                let y = self.pop();
3989                self.push(Value::Float(awk_canon_nan(
3990                    y.to_float().atan2(x.to_float()),
3991                )));
3992            }
3993            // Host-independent bitwise builtins (gawk): pure integer math.
3994            ab::AWK_AND => {
3995                let args: Vec<Value> = {
3996                    let mut v: Vec<Value> = (0..payload).map(|_| self.pop()).collect();
3997                    v.reverse();
3998                    v
3999                };
4000                self.push(Value::Int(awk_fold_and(&args)));
4001            }
4002            ab::AWK_OR => {
4003                let args: Vec<Value> = {
4004                    let mut v: Vec<Value> = (0..payload).map(|_| self.pop()).collect();
4005                    v.reverse();
4006                    v
4007                };
4008                self.push(Value::Int(awk_fold_or(&args)));
4009            }
4010            ab::AWK_XOR => {
4011                let args: Vec<Value> = {
4012                    let mut v: Vec<Value> = (0..payload).map(|_| self.pop()).collect();
4013                    v.reverse();
4014                    v
4015                };
4016                self.push(Value::Int(awk_fold_xor(&args)));
4017            }
4018            ab::AWK_COMPL => {
4019                let v = self.pop();
4020                self.push(Value::Int(awk_compl(&v)));
4021            }
4022            ab::AWK_LSHIFT => {
4023                let n = self.pop();
4024                let v = self.pop();
4025                self.push(Value::Int(awk_lshift(&v, &n)));
4026            }
4027            ab::AWK_RSHIFT => {
4028                let n = self.pop();
4029                let v = self.pop();
4030                self.push(Value::Int(awk_rshift(&v, &n)));
4031            }
4032            ab::AWK_STRTONUM => {
4033                let s = self.pop();
4034                self.push(Value::Float(awk_strtonum(&s.to_str())));
4035            }
4036            ab::AWK_SYSTIME => {
4037                self.push(Value::Float(awk_systime()));
4038            }
4039            ab::AWK_RAND => {
4040                let r = awk_rand(&mut self.awk_rand_seed);
4041                self.push(Value::Float(r));
4042            }
4043            ab::AWK_SRAND => {
4044                let n = if payload >= 1 {
4045                    Some(self.pop().to_float() as u32 as u64)
4046                } else {
4047                    None
4048                };
4049                let r = awk_srand(&mut self.awk_rand_seed, n);
4050                self.push(Value::Float(r));
4051            }
4052            ab::AWK_STRFTIME => {
4053                let mut args: Vec<Value> = (0..payload).map(|_| self.pop()).collect();
4054                args.reverse();
4055                self.push(awk_strftime(&args));
4056            }
4057            ab::AWK_MKTIME => {
4058                let mut args: Vec<Value> = (0..payload).map(|_| self.pop()).collect();
4059                args.reverse();
4060                self.push(awk_mktime(&args));
4061            }
4062            ab::AWK_ORD => {
4063                let a = self.pop();
4064                self.push(awk_ord(&a));
4065            }
4066            ab::AWK_CHR => {
4067                let a = self.pop();
4068                self.push(awk_chr(&a));
4069            }
4070            ab::AWK_MKBOOL => {
4071                let a = self.pop();
4072                self.push(awk_mkbool(&a));
4073            }
4074            ab::AWK_INTDIV => {
4075                let b = self.pop();
4076                let a = self.pop();
4077                self.push(awk_intdiv(&a, &b));
4078            }
4079            ab::AWK_INTDIV0 => {
4080                let b = self.pop();
4081                let a = self.pop();
4082                self.push(awk_intdiv0(&a, &b));
4083            }
4084            ab::AWK_INDEX => {
4085                let t = self.pop();
4086                let s = self.pop();
4087                self.push(Value::Int(awk_index(&s, &t)));
4088            }
4089            ab::AWK_MATCH => {
4090                self.pop();
4091                self.pop();
4092                self.push(Value::Int(0));
4093            }
4094            ab::AWK_SPLIT | ab::AWK_SUB | ab::AWK_GSUB => {
4095                for _ in 0..payload {
4096                    self.pop();
4097                }
4098                self.push(Value::Int(0));
4099            }
4100            ab::AWK_GENSUB => {
4101                for _ in 0..payload {
4102                    self.pop();
4103                }
4104                self.push(Value::str(""));
4105            }
4106            ab::AWK_GETLINE => {
4107                if matches!(
4108                    payload,
4109                    ab::getline_source::FILE
4110                        | ab::getline_source::FILE_VAR
4111                        | ab::getline_source::CMD
4112                        | ab::getline_source::CMD_VAR
4113                ) {
4114                    self.pop();
4115                }
4116                self.push(Value::Int(0));
4117            }
4118            ab::AWK_ARRAY_GET => {
4119                self.pop();
4120                self.push(Value::str(""));
4121            }
4122            ab::AWK_ARRAY_EXISTS => {
4123                self.pop();
4124                self.push(Value::Bool(false));
4125            }
4126            // statement-form ops: drop operands, push nothing
4127            ab::AWK_FIELD_SET | ab::AWK_ARRAY_SET => {
4128                self.pop();
4129                self.pop();
4130            }
4131            ab::AWK_SET_RECORD | ab::AWK_SPECIAL_SET | ab::AWK_ARRAY_DELETE => {
4132                self.pop();
4133            }
4134            ab::AWK_PRINT | ab::AWK_PRINTF => {
4135                for _ in 0..payload {
4136                    self.pop();
4137                }
4138            }
4139            ab::AWK_ARRAY_CLEAR => {}
4140            _ => {}
4141        }
4142    }
4143
4144    fn get_var(&self, idx: u16) -> Value {
4145        self.globals
4146            .get(idx as usize)
4147            .cloned()
4148            .unwrap_or(Value::Undef)
4149    }
4150
4151    fn set_var(&mut self, idx: u16, val: Value) {
4152        let idx = idx as usize;
4153        if idx >= self.globals.len() {
4154            self.globals.resize(idx + 1, Value::Undef);
4155        }
4156        self.globals[idx] = val;
4157    }
4158
4159    /// Read a slot from the current (top) call frame.
4160    ///
4161    /// Returns `Value::Undef` when there is no active frame or the slot
4162    /// index is out of range. Public so frontend extension handlers
4163    /// (`set_extension_handler`) can read slot operands without reaching
4164    /// into `frames` directly.
4165    pub fn get_slot(&self, slot: u16) -> Value {
4166        self.frames
4167            .last()
4168            .and_then(|f| f.slots.get(slot as usize))
4169            .cloned()
4170            .unwrap_or(Value::Undef)
4171    }
4172
4173    /// Write a slot in the current (top) call frame, growing the frame's
4174    /// slot vector as needed. No-op when there is no active frame.
4175    ///
4176    /// Public so frontend extension handlers can write slot results back
4177    /// without reaching into `frames` directly.
4178    pub fn set_slot(&mut self, slot: u16, val: Value) {
4179        if let Some(frame) = self.frames.last_mut() {
4180            let idx = slot as usize;
4181            if idx >= frame.slots.len() {
4182                frame.slots.resize(idx + 1, Value::Undef);
4183            }
4184            frame.slots[idx] = val;
4185        }
4186    }
4187}
4188
4189/// Pool of reusable `VM` instances.
4190///
4191/// `VM::new` does ~3 `Vec` allocations (stack, frames, globals) at
4192/// construction. Callers that run many small chunks back-to-back —
4193/// REPL-style invocation, batch script execution, eval loops — pay
4194/// that cost on every call. `VMPool` recycles the allocations: the
4195/// first `acquire` allocates, subsequent acquires pop a previously-
4196/// released VM and reset it via `VM::reset`.
4197///
4198/// # Example
4199///
4200/// ```
4201/// use fusevm::{ChunkBuilder, Op, VMPool, VMResult, Value};
4202///
4203/// let mut pool = VMPool::new();
4204///
4205/// for _ in 0..1000 {
4206///     let mut b = ChunkBuilder::new();
4207///     b.emit(Op::LoadInt(40), 1);
4208///     b.emit(Op::LoadInt(2), 1);
4209///     b.emit(Op::Add, 1);
4210///
4211///     let mut vm = pool.acquire(b.build());
4212///     let result = vm.run();
4213///     assert!(matches!(result, VMResult::Ok(Value::Int(42))));
4214///     pool.release(vm);
4215/// }
4216/// ```
4217pub struct VMPool {
4218    pool: Vec<VM>,
4219}
4220
4221impl VMPool {
4222    /// Construct an empty pool.
4223    pub fn new() -> Self {
4224        Self { pool: Vec::new() }
4225    }
4226
4227    /// Construct with a pre-allocated capacity.
4228    pub fn with_capacity(cap: usize) -> Self {
4229        Self {
4230            pool: Vec::with_capacity(cap),
4231        }
4232    }
4233
4234    /// Acquire a VM ready to run `chunk`. Pops a recycled VM if
4235    /// available; otherwise constructs a fresh one. The returned VM
4236    /// inherits the pool's previously-released VMs' allocations
4237    /// (Vec capacities preserved).
4238    pub fn acquire(&mut self, chunk: Chunk) -> VM {
4239        if let Some(mut vm) = self.pool.pop() {
4240            vm.reset(chunk);
4241            vm
4242        } else {
4243            VM::new(chunk)
4244        }
4245    }
4246
4247    /// Return a VM to the pool for later reuse. The VM's allocations
4248    /// are kept; only state is cleared on the next `acquire`.
4249    pub fn release(&mut self, vm: VM) {
4250        self.pool.push(vm);
4251    }
4252
4253    /// Run a closure against an acquired VM, returning it to the pool
4254    /// after the closure finishes (RAII-style scope).
4255    pub fn with<F, T>(&mut self, chunk: Chunk, f: F) -> T
4256    where
4257        F: FnOnce(&mut VM) -> T,
4258    {
4259        let mut vm = self.acquire(chunk);
4260        let r = f(&mut vm);
4261        self.release(vm);
4262        r
4263    }
4264
4265    /// Number of VMs currently held in the pool (released, ready for
4266    /// reuse). Doesn't count VMs currently checked out via `acquire`.
4267    pub fn len(&self) -> usize {
4268        self.pool.len()
4269    }
4270
4271    /// Whether the pool is empty.
4272    pub fn is_empty(&self) -> bool {
4273        self.pool.is_empty()
4274    }
4275}
4276
4277impl Default for VMPool {
4278    fn default() -> Self {
4279        Self::new()
4280    }
4281}
4282
4283#[cfg(test)]
4284mod tests {
4285    use super::*;
4286    use crate::chunk::ChunkBuilder;
4287
4288    #[test]
4289    fn test_arithmetic() {
4290        let mut b = ChunkBuilder::new();
4291        b.emit(Op::LoadInt(10), 1);
4292        b.emit(Op::LoadInt(32), 1);
4293        b.emit(Op::Add, 1);
4294        let mut vm = VM::new(b.build());
4295        match vm.run() {
4296            VMResult::Ok(Value::Int(42)) => {}
4297            other => panic!("expected Int(42), got {:?}", other),
4298        }
4299    }
4300
4301    #[test]
4302    fn test_jump() {
4303        let mut b = ChunkBuilder::new();
4304        b.emit(Op::LoadInt(1), 1);
4305        b.emit(Op::Jump(3), 1);
4306        b.emit(Op::LoadInt(999), 1); // skipped
4307                                     // ip 3:
4308        b.emit(Op::LoadInt(2), 1);
4309        b.emit(Op::Add, 1);
4310        let mut vm = VM::new(b.build());
4311        match vm.run() {
4312            VMResult::Ok(Value::Int(3)) => {}
4313            other => panic!("expected Int(3), got {:?}", other),
4314        }
4315    }
4316
4317    #[test]
4318    fn test_fused_sum_loop() {
4319        // sum = 0; for i in 0..100 { sum += i }
4320        let mut b = ChunkBuilder::new();
4321        b.emit(Op::PushFrame, 1);
4322        b.emit(Op::LoadInt(0), 1);
4323        b.emit(Op::SetSlot(0), 1); // sum = 0
4324        b.emit(Op::LoadInt(0), 1);
4325        b.emit(Op::SetSlot(1), 1); // i = 0
4326        b.emit(Op::AccumSumLoop(0, 1, 100), 1);
4327        b.emit(Op::GetSlot(0), 1);
4328
4329        let mut vm = VM::new(b.build());
4330        match vm.run() {
4331            VMResult::Ok(Value::Int(4950)) => {}
4332            other => panic!("expected Int(4950), got {:?}", other),
4333        }
4334    }
4335
4336    #[test]
4337    fn test_call_dynamic() {
4338        let mut b = ChunkBuilder::new();
4339        let dbl = b.add_name("double");
4340        // main: push 21, push name_idx of "double", CallDynamic(1)
4341        b.emit(Op::LoadInt(21), 1);
4342        b.emit(Op::LoadInt(dbl as i64), 1);
4343        b.emit(Op::CallDynamic(1), 1);
4344        let end = b.emit(Op::Jump(0), 1);
4345        let ip = b.current_pos();
4346        b.add_sub_entry(dbl, ip);
4347        b.emit(Op::LoadInt(2), 2);
4348        b.emit(Op::Mul, 2);
4349        b.emit(Op::ReturnValue, 2);
4350        b.patch_jump(end, b.current_pos());
4351        let mut vm = VM::new(b.build());
4352        assert!(matches!(vm.run(), VMResult::Ok(Value::Int(42))));
4353    }
4354
4355    #[test]
4356    fn test_function_call() {
4357        let mut b = ChunkBuilder::new();
4358        let double_name = b.add_name("double");
4359
4360        // main: push 21, call double, result on stack
4361        b.emit(Op::LoadInt(21), 1);
4362        b.emit(Op::Call(double_name, 1), 1);
4363        let end_jump = b.emit(Op::Jump(0), 1); // jump past function body
4364
4365        // double: arg * 2
4366        let double_ip = b.current_pos();
4367        b.add_sub_entry(double_name, double_ip);
4368        b.emit(Op::LoadInt(2), 2);
4369        b.emit(Op::Mul, 2);
4370        b.emit(Op::ReturnValue, 2);
4371
4372        b.patch_jump(end_jump, b.current_pos());
4373
4374        let mut vm = VM::new(b.build());
4375        match vm.run() {
4376            VMResult::Ok(Value::Int(42)) => {}
4377            other => panic!("expected Int(42), got {:?}", other),
4378        }
4379    }
4380
4381    #[test]
4382    fn test_builtin_cache() {
4383        let mut b = ChunkBuilder::new();
4384        b.emit(Op::LoadInt(10), 1);
4385        b.emit(Op::CallBuiltin(0, 1), 1);
4386        let mut vm = VM::new(b.build());
4387        vm.register_builtin(0, |vm, _argc| {
4388            let val = vm.pop();
4389            Value::Int(val.to_int() * 2)
4390        });
4391        match vm.run() {
4392            VMResult::Ok(Value::Int(20)) => {}
4393            other => panic!("expected Int(20), got {:?}", other),
4394        }
4395    }
4396
4397    // ── helpers ──
4398
4399    fn run_one(ops: Vec<Op>) -> VMResult {
4400        let mut b = ChunkBuilder::new();
4401        for op in ops {
4402            b.emit(op, 1);
4403        }
4404        VM::new(b.build()).run()
4405    }
4406
4407    fn expect_int(ops: Vec<Op>, want: i64) {
4408        match run_one(ops) {
4409            VMResult::Ok(Value::Int(n)) => assert_eq!(n, want),
4410            other => panic!("expected Int({}), got {:?}", want, other),
4411        }
4412    }
4413
4414    fn expect_bool(ops: Vec<Op>, want: bool) {
4415        match run_one(ops) {
4416            VMResult::Ok(Value::Bool(b)) => assert_eq!(b, want),
4417            other => panic!("expected Bool({}), got {:?}", want, other),
4418        }
4419    }
4420
4421    // ── Arithmetic ──
4422
4423    #[test]
4424    fn arithmetic_sub_mul_div_mod() {
4425        expect_int(vec![Op::LoadInt(20), Op::LoadInt(8), Op::Sub], 12);
4426        expect_int(vec![Op::LoadInt(6), Op::LoadInt(7), Op::Mul], 42);
4427        expect_int(vec![Op::LoadInt(20), Op::LoadInt(3), Op::Mod], 2);
4428        // Div returns Float for int operands (no truncating int division).
4429        match run_one(vec![Op::LoadInt(20), Op::LoadInt(5), Op::Div]) {
4430            VMResult::Ok(Value::Float(f)) => assert!((f - 4.0).abs() < 1e-9),
4431            VMResult::Ok(Value::Int(4)) => {} // tolerate either impl
4432            other => panic!("got {:?}", other),
4433        }
4434    }
4435
4436    #[test]
4437    fn arithmetic_negate_and_inc_dec() {
4438        expect_int(vec![Op::LoadInt(5), Op::Negate], -5);
4439        expect_int(vec![Op::LoadInt(5), Op::Inc], 6);
4440        expect_int(vec![Op::LoadInt(5), Op::Dec], 4);
4441    }
4442
4443    #[test]
4444    fn arithmetic_negate_preserves_float_zero_kind_and_sign() {
4445        // Interpreter reference for the JIT float-kind tests: a zero-valued
4446        // float operand stays Float (with the correct sign), never Int(0).
4447        match run_one(vec![Op::LoadFloat(-0.0), Op::Negate]) {
4448            VMResult::Ok(Value::Float(f)) => assert_eq!(f.to_bits(), 0.0f64.to_bits()),
4449            other => panic!("expected Float(0.0), got {:?}", other),
4450        }
4451        match run_one(vec![Op::LoadFloat(0.0), Op::Negate]) {
4452            VMResult::Ok(Value::Float(f)) => assert_eq!(f.to_bits(), (-0.0f64).to_bits()),
4453            other => panic!("expected Float(-0.0), got {:?}", other),
4454        }
4455        match run_one(vec![Op::LoadFloat(-0.0), Op::LoadInt(0), Op::Sub]) {
4456            VMResult::Ok(Value::Float(f)) => assert_eq!(f.to_bits(), (-0.0f64).to_bits()),
4457            other => panic!("expected Float(-0.0), got {:?}", other),
4458        }
4459    }
4460
4461    #[test]
4462    fn arithmetic_pow_returns_float() {
4463        match run_one(vec![Op::LoadInt(3), Op::LoadInt(4), Op::Pow]) {
4464            VMResult::Ok(Value::Float(f)) => assert!((f - 81.0).abs() < 1e-9),
4465            VMResult::Ok(Value::Int(81)) => {} // tolerate either impl
4466            other => panic!("got {:?}", other),
4467        }
4468    }
4469
4470    // ── Comparison ──
4471
4472    #[test]
4473    fn num_comparisons_produce_booleans() {
4474        expect_bool(vec![Op::LoadInt(1), Op::LoadInt(1), Op::NumEq], true);
4475        expect_bool(vec![Op::LoadInt(1), Op::LoadInt(2), Op::NumEq], false);
4476        expect_bool(vec![Op::LoadInt(1), Op::LoadInt(2), Op::NumLt], true);
4477        expect_bool(vec![Op::LoadInt(1), Op::LoadInt(2), Op::NumGt], false);
4478        expect_bool(vec![Op::LoadInt(2), Op::LoadInt(2), Op::NumLe], true);
4479        expect_bool(vec![Op::LoadInt(2), Op::LoadInt(2), Op::NumGe], true);
4480        expect_bool(vec![Op::LoadInt(2), Op::LoadInt(2), Op::NumNe], false);
4481    }
4482
4483    #[test]
4484    fn spaceship_returns_neg_zero_pos() {
4485        expect_int(vec![Op::LoadInt(1), Op::LoadInt(2), Op::Spaceship], -1);
4486        expect_int(vec![Op::LoadInt(2), Op::LoadInt(2), Op::Spaceship], 0);
4487        expect_int(vec![Op::LoadInt(3), Op::LoadInt(2), Op::Spaceship], 1);
4488    }
4489
4490    #[test]
4491    fn string_comparisons() {
4492        let mut b = ChunkBuilder::new();
4493        let a = b.add_constant(Value::str("alpha"));
4494        let z = b.add_constant(Value::str("beta"));
4495        b.emit(Op::LoadConst(a), 1);
4496        b.emit(Op::LoadConst(z), 1);
4497        b.emit(Op::StrLt, 1);
4498        let mut vm = VM::new(b.build());
4499        assert!(matches!(vm.run(), VMResult::Ok(Value::Bool(true))));
4500    }
4501
4502    #[test]
4503    fn string_eq_and_ne() {
4504        let mut b = ChunkBuilder::new();
4505        let s1 = b.add_constant(Value::str("hi"));
4506        let s2 = b.add_constant(Value::str("hi"));
4507        b.emit(Op::LoadConst(s1), 1);
4508        b.emit(Op::LoadConst(s2), 1);
4509        b.emit(Op::StrEq, 1);
4510        assert!(matches!(
4511            VM::new(b.build()).run(),
4512            VMResult::Ok(Value::Bool(true))
4513        ));
4514    }
4515
4516    // ── Stack manipulation ──
4517
4518    #[test]
4519    fn pop_discards_top() {
4520        // push 1, push 2, pop, → result 1
4521        expect_int(vec![Op::LoadInt(1), Op::LoadInt(2), Op::Pop], 1);
4522    }
4523
4524    #[test]
4525    fn dup_duplicates_top() {
4526        // 5, dup, add → 10
4527        expect_int(vec![Op::LoadInt(5), Op::Dup, Op::Add], 10);
4528    }
4529
4530    #[test]
4531    fn swap_exchanges_top_two() {
4532        // 10, 3, swap, sub → 10 - 3 = 7 (after swap top is 10, next is 3 → 3 - 10 = -7?)
4533        // Sub semantics: pops b then a, returns a - b. After swap, top=10, below=3.
4534        // Pop b=10, a=3 → 3 - 10 = -7.
4535        expect_int(vec![Op::LoadInt(10), Op::LoadInt(3), Op::Swap, Op::Sub], -7);
4536    }
4537
4538    #[test]
4539    fn dup2_duplicates_top_two_values() {
4540        // Dup2 on [3,4] yields [3,4,3,4]. Two Adds collapse to 11 on top.
4541        expect_int(
4542            vec![Op::LoadInt(3), Op::LoadInt(4), Op::Dup2, Op::Add, Op::Add],
4543            11,
4544        );
4545    }
4546
4547    // ── Logical / Bitwise ──
4548
4549    #[test]
4550    fn log_not_inverts_truthiness() {
4551        expect_bool(vec![Op::LoadInt(0), Op::LogNot], true);
4552        expect_bool(vec![Op::LoadInt(1), Op::LogNot], false);
4553        expect_bool(vec![Op::LoadTrue, Op::LogNot], false);
4554        expect_bool(vec![Op::LoadFalse, Op::LogNot], true);
4555    }
4556
4557    #[test]
4558    fn bitwise_ops() {
4559        expect_int(
4560            vec![Op::LoadInt(0b1100), Op::LoadInt(0b1010), Op::BitAnd],
4561            0b1000,
4562        );
4563        expect_int(
4564            vec![Op::LoadInt(0b1100), Op::LoadInt(0b1010), Op::BitOr],
4565            0b1110,
4566        );
4567        expect_int(
4568            vec![Op::LoadInt(0b1100), Op::LoadInt(0b1010), Op::BitXor],
4569            0b0110,
4570        );
4571        expect_int(vec![Op::LoadInt(1), Op::LoadInt(4), Op::Shl], 16);
4572        expect_int(vec![Op::LoadInt(64), Op::LoadInt(2), Op::Shr], 16);
4573    }
4574
4575    #[test]
4576    fn bit_not_inverts_bits() {
4577        expect_int(vec![Op::LoadInt(0), Op::BitNot], -1);
4578    }
4579
4580    // ── Strings ──
4581
4582    #[test]
4583    fn concat_joins_strings() {
4584        let mut b = ChunkBuilder::new();
4585        let h = b.add_constant(Value::str("hello "));
4586        let w = b.add_constant(Value::str("world"));
4587        b.emit(Op::LoadConst(h), 1);
4588        b.emit(Op::LoadConst(w), 1);
4589        b.emit(Op::Concat, 1);
4590        match VM::new(b.build()).run() {
4591            VMResult::Ok(v) => assert_eq!(v.to_str(), "hello world"),
4592            other => panic!("got {:?}", other),
4593        }
4594    }
4595
4596    #[test]
4597    fn string_repeat_op() {
4598        let mut b = ChunkBuilder::new();
4599        let s = b.add_constant(Value::str("ab"));
4600        b.emit(Op::LoadConst(s), 1);
4601        b.emit(Op::LoadInt(3), 1);
4602        b.emit(Op::StringRepeat, 1);
4603        match VM::new(b.build()).run() {
4604            VMResult::Ok(v) => assert_eq!(v.to_str(), "ababab"),
4605            other => panic!("got {:?}", other),
4606        }
4607    }
4608
4609    #[test]
4610    fn string_len_returns_int() {
4611        let mut b = ChunkBuilder::new();
4612        let s = b.add_constant(Value::str("abcd"));
4613        b.emit(Op::LoadConst(s), 1);
4614        b.emit(Op::StringLen, 1);
4615        match VM::new(b.build()).run() {
4616            VMResult::Ok(Value::Int(4)) => {}
4617            other => panic!("got {:?}", other),
4618        }
4619    }
4620
4621    // ── Constants & literals ──
4622
4623    #[test]
4624    fn load_true_false_undef() {
4625        assert!(matches!(
4626            run_one(vec![Op::LoadTrue]),
4627            VMResult::Ok(Value::Bool(true))
4628        ));
4629        assert!(matches!(
4630            run_one(vec![Op::LoadFalse]),
4631            VMResult::Ok(Value::Bool(false))
4632        ));
4633        assert!(matches!(
4634            run_one(vec![Op::LoadUndef]),
4635            VMResult::Ok(Value::Undef)
4636        ));
4637    }
4638
4639    #[test]
4640    fn load_const_string() {
4641        let mut b = ChunkBuilder::new();
4642        let c = b.add_constant(Value::str("xyz"));
4643        b.emit(Op::LoadConst(c), 1);
4644        match VM::new(b.build()).run() {
4645            VMResult::Ok(v) => assert_eq!(v.to_str(), "xyz"),
4646            other => panic!("got {:?}", other),
4647        }
4648    }
4649
4650    // ── Control flow ──
4651
4652    #[test]
4653    fn jump_if_true_taken() {
4654        // load true; JumpIfTrue past "load 0"; load 1 → 1
4655        let mut b = ChunkBuilder::new();
4656        b.emit(Op::LoadTrue, 1);
4657        let j = b.emit(Op::JumpIfTrue(0), 1);
4658        b.emit(Op::LoadInt(0), 1);
4659        b.patch_jump(j, b.current_pos());
4660        b.emit(Op::LoadInt(1), 1);
4661        assert!(matches!(
4662            VM::new(b.build()).run(),
4663            VMResult::Ok(Value::Int(1))
4664        ));
4665    }
4666
4667    #[test]
4668    fn jump_if_false_not_taken_for_true() {
4669        let mut b = ChunkBuilder::new();
4670        b.emit(Op::LoadTrue, 1);
4671        let j = b.emit(Op::JumpIfFalse(0), 1);
4672        b.emit(Op::LoadInt(7), 1); // executed
4673        b.patch_jump(j, b.current_pos());
4674        match VM::new(b.build()).run() {
4675            VMResult::Ok(Value::Int(7)) => {}
4676            other => panic!("got {:?}", other),
4677        }
4678    }
4679
4680    // ── Frame / scope ──
4681
4682    #[test]
4683    fn push_pop_frame_with_slots() {
4684        // PushFrame creates a new frame with its own slot table; GetSlot reads
4685        // back what SetSlot wrote. We omit PopFrame to keep the result on the
4686        // stack at end-of-chunk.
4687        let mut b = ChunkBuilder::new();
4688        b.emit(Op::PushFrame, 1);
4689        b.emit(Op::LoadInt(99), 1);
4690        b.emit(Op::SetSlot(0), 1);
4691        b.emit(Op::GetSlot(0), 1);
4692        match VM::new(b.build()).run() {
4693            VMResult::Ok(Value::Int(99)) => {}
4694            other => panic!("got {:?}", other),
4695        }
4696    }
4697
4698    // ── Public VM helpers: push/pop/peek ──
4699
4700    #[test]
4701    fn vm_push_pop_peek_round_trip() {
4702        let chunk = ChunkBuilder::new().build();
4703        let mut vm = VM::new(chunk);
4704        vm.push(Value::Int(1));
4705        vm.push(Value::Int(2));
4706        assert_eq!(*vm.peek(), Value::Int(2));
4707        assert_eq!(vm.pop(), Value::Int(2));
4708        assert_eq!(vm.pop(), Value::Int(1));
4709    }
4710
4711    // ── register_builtin: overwrite + grow ──
4712
4713    #[test]
4714    fn register_builtin_overwrites_existing_handler() {
4715        let mut b = ChunkBuilder::new();
4716        b.emit(Op::LoadInt(1), 1);
4717        b.emit(Op::CallBuiltin(0, 1), 1);
4718        let mut vm = VM::new(b.build());
4719        vm.register_builtin(0, |vm, _| {
4720            vm.pop();
4721            Value::Int(111)
4722        });
4723        // Overwrite with a different handler before run.
4724        vm.register_builtin(0, |vm, _| {
4725            vm.pop();
4726            Value::Int(222)
4727        });
4728        assert!(matches!(vm.run(), VMResult::Ok(Value::Int(222))));
4729    }
4730
4731    #[test]
4732    fn run_builtin_by_name_dispatches_and_passes_args() {
4733        // A handler registered at a real builtin id must be reachable by NAME
4734        // at runtime, receiving its args in argument order.
4735        let id = crate::shell_builtins::builtin_id("true").expect("`true` is a builtin");
4736        let mut vm = VM::new(ChunkBuilder::new().build());
4737        vm.register_builtin(id, |vm, argc| {
4738            // args pushed in order → popped-and-reversed back to order.
4739            let mut got = Vec::new();
4740            for _ in 0..argc {
4741                got.push(vm.pop().to_str());
4742            }
4743            got.reverse();
4744            assert_eq!(got, vec!["x".to_string(), "y".to_string()]);
4745            Value::Int(7)
4746        });
4747        let out = vm.run_builtin_by_name("true", &["x".to_string(), "y".to_string()]);
4748        assert!(matches!(out, Some(Value::Int(7))));
4749        // Unknown / unregistered names return None (caller falls through).
4750        assert!(vm
4751            .run_builtin_by_name("definitely_not_a_builtin_xyz", &[])
4752            .is_none());
4753    }
4754
4755    #[test]
4756    fn register_builtin_grows_table_to_high_id() {
4757        // High id should expand the builtin_table to accommodate.
4758        let chunk = ChunkBuilder::new().build();
4759        let mut vm = VM::new(chunk);
4760        vm.register_builtin(500, |_, _| Value::Int(0));
4761        // Indirect proof: re-registering at lower id still works (no panic).
4762        vm.register_builtin(1, |_, _| Value::Int(0));
4763    }
4764
4765    // ── reset() ──
4766
4767    #[test]
4768    fn reset_clears_state_and_runs_new_chunk() {
4769        let mut b1 = ChunkBuilder::new();
4770        b1.emit(Op::LoadInt(1), 1);
4771        let mut vm = VM::new(b1.build());
4772        assert!(matches!(vm.run(), VMResult::Ok(Value::Int(1))));
4773
4774        let mut b2 = ChunkBuilder::new();
4775        b2.emit(Op::LoadInt(2), 1);
4776        b2.emit(Op::LoadInt(3), 1);
4777        b2.emit(Op::Add, 1);
4778        vm.reset(b2.build());
4779        assert!(matches!(vm.run(), VMResult::Ok(Value::Int(5))));
4780    }
4781
4782    // ── Extension handler ──
4783
4784    #[test]
4785    fn extension_handler_invoked_with_payload() {
4786        use std::sync::{Arc, Mutex};
4787        let captured: Arc<Mutex<Option<(u16, u8)>>> = Arc::new(Mutex::new(None));
4788        let captured_cl = Arc::clone(&captured);
4789
4790        let mut b = ChunkBuilder::new();
4791        b.emit(Op::Extended(7, 42), 1);
4792        let mut vm = VM::new(b.build());
4793        vm.set_extension_handler(Box::new(move |vm, id, arg| {
4794            *captured_cl.lock().unwrap() = Some((id, arg));
4795            vm.push(Value::Int(123));
4796        }));
4797        match vm.run() {
4798            VMResult::Ok(Value::Int(123)) => {}
4799            other => panic!("got {:?}", other),
4800        }
4801        assert_eq!(*captured.lock().unwrap(), Some((7, 42)));
4802    }
4803
4804    #[test]
4805    fn extension_wide_handler_invoked_with_payload() {
4806        use std::sync::{Arc, Mutex};
4807        let captured: Arc<Mutex<Option<(u16, usize)>>> = Arc::new(Mutex::new(None));
4808        let captured_cl = Arc::clone(&captured);
4809        let mut b = ChunkBuilder::new();
4810        b.emit(Op::ExtendedWide(9, 9999), 1);
4811        let mut vm = VM::new(b.build());
4812        vm.set_extension_wide_handler(Box::new(move |vm, id, payload| {
4813            *captured_cl.lock().unwrap() = Some((id, payload));
4814            vm.push(Value::Int(0));
4815        }));
4816        let _ = vm.run();
4817        assert_eq!(*captured.lock().unwrap(), Some((9, 9999)));
4818    }
4819
4820    // ── VMPool ──
4821
4822    #[test]
4823    fn vmpool_new_default_and_with_capacity_start_empty() {
4824        let p = VMPool::new();
4825        assert!(p.is_empty());
4826        assert_eq!(p.len(), 0);
4827        let p = VMPool::with_capacity(8);
4828        assert!(p.is_empty());
4829        let p: VMPool = Default::default();
4830        assert!(p.is_empty());
4831    }
4832
4833    #[test]
4834    fn vmpool_release_then_acquire_reuses_vm() {
4835        let mut pool = VMPool::new();
4836        let chunk1 = {
4837            let mut b = ChunkBuilder::new();
4838            b.emit(Op::LoadInt(1), 1);
4839            b.build()
4840        };
4841        let vm = pool.acquire(chunk1);
4842        assert_eq!(pool.len(), 0);
4843        pool.release(vm);
4844        assert_eq!(pool.len(), 1);
4845
4846        let chunk2 = {
4847            let mut b = ChunkBuilder::new();
4848            b.emit(Op::LoadInt(2), 1);
4849            b.build()
4850        };
4851        let mut vm = pool.acquire(chunk2);
4852        assert_eq!(pool.len(), 0);
4853        assert!(matches!(vm.run(), VMResult::Ok(Value::Int(2))));
4854    }
4855
4856    #[test]
4857    fn vmpool_with_returns_value_and_recycles_vm() {
4858        let mut pool = VMPool::new();
4859        let chunk = {
4860            let mut b = ChunkBuilder::new();
4861            b.emit(Op::LoadInt(10), 1);
4862            b.emit(Op::LoadInt(5), 1);
4863            b.emit(Op::Add, 1);
4864            b.build()
4865        };
4866        let result = pool.with(chunk, |vm| match vm.run() {
4867            VMResult::Ok(Value::Int(n)) => n,
4868            other => panic!("got {:?}", other),
4869        });
4870        assert_eq!(result, 15);
4871        assert_eq!(pool.len(), 1, "VM should be returned to pool after with()");
4872    }
4873
4874    // ── AWK host dispatch ──────────────────────────────────────────────────
4875
4876    /// Recording AWK host: captures every routed call so tests can assert the
4877    /// VM popped/pushed the right operands and dispatched to the right method.
4878    #[derive(Default)]
4879    struct RecordingAwkHost {
4880        record: String,
4881        fields: Vec<String>,
4882        printed: Vec<Vec<String>>,
4883        field_sets: Vec<(i64, String)>,
4884        special_sets: Vec<(String, String)>,
4885        array: std::collections::HashMap<String, String>,
4886    }
4887
4888    impl crate::awk_host::AwkHost for RecordingAwkHost {
4889        fn field_get(&mut self, i: i64) -> Value {
4890            Value::str(self.fields.get(i as usize).cloned().unwrap_or_default())
4891        }
4892        fn field_set(&mut self, i: i64, v: Value) {
4893            self.field_sets.push((i, v.to_str()));
4894        }
4895        fn nf(&mut self) -> i64 {
4896            self.fields.len() as i64
4897        }
4898        fn set_record(&mut self, v: Value) {
4899            self.record = v.to_str();
4900            self.fields = self.record.split(' ').map(|s| s.to_string()).collect();
4901        }
4902        fn special_get(&mut self, name: &str) -> Value {
4903            match name {
4904                "NR" => Value::Int(7),
4905                _ => Value::str(""),
4906            }
4907        }
4908        fn special_set(&mut self, name: &str, v: Value) {
4909            self.special_sets.push((name.to_string(), v.to_str()));
4910        }
4911        fn print(&mut self, args: &[Value]) {
4912            self.printed.push(args.iter().map(|v| v.to_str()).collect());
4913        }
4914        fn array_get(&mut self, _arr: &str, key: &Value) -> Value {
4915            Value::str(self.array.get(&key.to_str()).cloned().unwrap_or_default())
4916        }
4917        fn array_set(&mut self, _arr: &str, key: &Value, v: Value) {
4918            self.array.insert(key.to_str(), v.to_str());
4919        }
4920    }
4921
4922    fn awk_op(b: &mut ChunkBuilder, id: u16, payload: usize) {
4923        b.emit(Op::ExtendedWide(id, payload), 1);
4924    }
4925
4926    #[test]
4927    fn awk_field_get_routes_to_host() {
4928        use crate::awk_builtins::*;
4929        let chunk = {
4930            let mut b = ChunkBuilder::new();
4931            b.emit(Op::LoadInt(2), 1); // $2
4932            awk_op(&mut b, AWK_FIELD_GET, 0);
4933            b.build()
4934        };
4935        let mut vm = VM::new(chunk);
4936        let host = RecordingAwkHost {
4937            fields: vec!["a".into(), "b".into(), "c".into()],
4938            ..Default::default()
4939        };
4940        vm.set_awk_host(Box::new(host));
4941        match vm.run() {
4942            VMResult::Ok(v) => assert_eq!(v.to_str(), "c"),
4943            other => panic!("got {:?}", other),
4944        }
4945    }
4946
4947    #[test]
4948    fn awk_print_pops_args_in_source_order() {
4949        use crate::awk_builtins::*;
4950        let chunk = {
4951            let mut b = ChunkBuilder::new();
4952            let x = b.add_constant(Value::str("x"));
4953            let y = b.add_constant(Value::str("y"));
4954            b.emit(Op::LoadConst(x), 1);
4955            b.emit(Op::LoadConst(y), 1);
4956            awk_op(&mut b, AWK_PRINT, 2);
4957            b.build()
4958        };
4959        let mut vm = VM::new(chunk);
4960        // Use a shared host we can inspect after the run.
4961        struct H(std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>);
4962        impl crate::awk_host::AwkHost for H {
4963            fn print(&mut self, args: &[Value]) {
4964                self.0
4965                    .lock()
4966                    .unwrap()
4967                    .push(args.iter().map(|v| v.to_str()).collect());
4968            }
4969        }
4970        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
4971        vm.set_awk_host(Box::new(H(sink.clone())));
4972        let _ = vm.run();
4973        assert_eq!(
4974            sink.lock().unwrap().as_slice(),
4975            &[vec!["x".to_string(), "y".to_string()]]
4976        );
4977    }
4978
4979    #[test]
4980    fn awk_field_set_pops_value_and_index() {
4981        use crate::awk_builtins::*;
4982        let chunk = {
4983            let mut b = ChunkBuilder::new();
4984            let v = b.add_constant(Value::str("Z"));
4985            b.emit(Op::LoadConst(v), 1); // value
4986            b.emit(Op::LoadInt(3), 1); // index
4987            awk_op(&mut b, AWK_FIELD_SET, 0);
4988            b.build()
4989        };
4990        struct H(std::sync::Arc<std::sync::Mutex<Vec<(i64, String)>>>);
4991        impl crate::awk_host::AwkHost for H {
4992            fn field_set(&mut self, i: i64, v: Value) {
4993                self.0.lock().unwrap().push((i, v.to_str()));
4994            }
4995        }
4996        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
4997        let mut vm = VM::new(chunk);
4998        vm.set_awk_host(Box::new(H(sink.clone())));
4999        let _ = vm.run();
5000        assert_eq!(sink.lock().unwrap().as_slice(), &[(3i64, "Z".to_string())]);
5001    }
5002
5003    #[test]
5004    fn awk_special_get_and_array_roundtrip() {
5005        use crate::awk_builtins::*;
5006        let chunk = {
5007            let mut b = ChunkBuilder::new();
5008            let arr = b.add_name("counts");
5009            let k = b.add_constant(Value::str("k"));
5010            let val = b.add_constant(Value::str("42"));
5011            // counts["k"] = "42"
5012            b.emit(Op::LoadConst(val), 1);
5013            b.emit(Op::LoadConst(k), 1);
5014            awk_op(&mut b, AWK_ARRAY_SET, arr as usize);
5015            // push counts["k"] then NR
5016            b.emit(Op::LoadConst(k), 1);
5017            awk_op(&mut b, AWK_ARRAY_GET, arr as usize);
5018            b.build()
5019        };
5020        let mut vm = VM::new(chunk);
5021        vm.set_awk_host(Box::new(RecordingAwkHost::default()));
5022        match vm.run() {
5023            VMResult::Ok(v) => assert_eq!(v.to_str(), "42"),
5024            other => panic!("got {:?}", other),
5025        }
5026    }
5027
5028    #[test]
5029    fn awk_ops_are_inert_without_host_but_keep_stack_balanced() {
5030        use crate::awk_builtins::*;
5031        // $1 with no host → pushes "" (stack stays balanced, run returns it).
5032        let chunk = {
5033            let mut b = ChunkBuilder::new();
5034            b.emit(Op::LoadInt(1), 1);
5035            awk_op(&mut b, AWK_FIELD_GET, 0);
5036            b.build()
5037        };
5038        let mut vm = VM::new(chunk);
5039        match vm.run() {
5040            VMResult::Ok(v) => assert_eq!(v.to_str(), ""),
5041            other => panic!("got {:?}", other),
5042        }
5043    }
5044
5045    // ── First-class AWK ops (Op::Awk*) ──
5046    // These mirror the shell-ops design: named Op variants dispatched to the
5047    // same AwkHost path as the reserved ExtendedWide AWK range. The tests below
5048    // prove each first-class variant routes to the host identically to its
5049    // `ExtendedWide(AWK_*)` form.
5050
5051    #[test]
5052    fn first_class_awk_field_get_routes_to_host() {
5053        let chunk = {
5054            let mut b = ChunkBuilder::new();
5055            b.emit(Op::LoadInt(2), 1); // $2
5056            b.emit(Op::AwkFieldGet, 1);
5057            b.build()
5058        };
5059        let mut vm = VM::new(chunk);
5060        let host = RecordingAwkHost {
5061            fields: vec!["a".into(), "b".into(), "c".into()],
5062            ..Default::default()
5063        };
5064        vm.set_awk_host(Box::new(host));
5065        match vm.run() {
5066            VMResult::Ok(v) => assert_eq!(v.to_str(), "c"),
5067            other => panic!("got {:?}", other),
5068        }
5069    }
5070
5071    #[test]
5072    fn first_class_awk_print_pops_args_in_source_order() {
5073        struct H(std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>);
5074        impl crate::awk_host::AwkHost for H {
5075            fn print(&mut self, args: &[Value]) {
5076                self.0
5077                    .lock()
5078                    .unwrap()
5079                    .push(args.iter().map(|v| v.to_str()).collect());
5080            }
5081        }
5082        let chunk = {
5083            let mut b = ChunkBuilder::new();
5084            let x = b.add_constant(Value::str("x"));
5085            let y = b.add_constant(Value::str("y"));
5086            b.emit(Op::LoadConst(x), 1);
5087            b.emit(Op::LoadConst(y), 1);
5088            b.emit(Op::AwkPrint(2), 1);
5089            b.build()
5090        };
5091        let mut vm = VM::new(chunk);
5092        let sink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
5093        vm.set_awk_host(Box::new(H(sink.clone())));
5094        let _ = vm.run();
5095        assert_eq!(
5096            sink.lock().unwrap().as_slice(),
5097            &[vec!["x".to_string(), "y".to_string()]]
5098        );
5099    }
5100
5101    #[test]
5102    fn first_class_awk_array_roundtrip_matches_extendedwide() {
5103        // counts["k"]="42"; counts["k"] — once via Op::AwkArray*, once via the
5104        // ExtendedWide form; both must yield the same value.
5105        fn run_variant(first_class: bool) -> String {
5106            use crate::awk_builtins::*;
5107            let mut b = ChunkBuilder::new();
5108            let arr = b.add_name("counts");
5109            let k = b.add_constant(Value::str("k"));
5110            let val = b.add_constant(Value::str("42"));
5111            b.emit(Op::LoadConst(val), 1);
5112            b.emit(Op::LoadConst(k), 1);
5113            if first_class {
5114                b.emit(Op::AwkArraySet(arr), 1);
5115            } else {
5116                b.emit(Op::ExtendedWide(AWK_ARRAY_SET, arr as usize), 1);
5117            }
5118            b.emit(Op::LoadConst(k), 1);
5119            if first_class {
5120                b.emit(Op::AwkArrayGet(arr), 1);
5121            } else {
5122                b.emit(Op::ExtendedWide(AWK_ARRAY_GET, arr as usize), 1);
5123            }
5124            let mut vm = VM::new(b.build());
5125            vm.set_awk_host(Box::new(RecordingAwkHost::default()));
5126            match vm.run() {
5127                VMResult::Ok(v) => v.to_str(),
5128                other => panic!("got {:?}", other),
5129            }
5130        }
5131        assert_eq!(run_variant(true), "42");
5132        assert_eq!(run_variant(true), run_variant(false));
5133    }
5134
5135    #[test]
5136    fn first_class_awk_ops_inert_without_host() {
5137        // $1 (Op::AwkFieldGet) with no host → pushes "" and stays balanced.
5138        let chunk = {
5139            let mut b = ChunkBuilder::new();
5140            b.emit(Op::LoadInt(1), 1);
5141            b.emit(Op::AwkFieldGet, 1);
5142            b.build()
5143        };
5144        let mut vm = VM::new(chunk);
5145        match vm.run() {
5146            VMResult::Ok(v) => assert_eq!(v.to_str(), ""),
5147            other => panic!("got {:?}", other),
5148        }
5149    }
5150
5151    // ── Host-independent AWK string builtins execute natively (no host) ──
5152    // `substr`/`tolower`/`toupper`/`index`/`length(s)` need none of AWK's
5153    // host-side runtime state, so they compute real results even when no
5154    // `AwkHost` is registered (unlike field/array/print ops, which stay inert).
5155
5156    fn run_native(chunk: crate::chunk::Chunk) -> Value {
5157        let mut vm = VM::new(chunk);
5158        match vm.run() {
5159            VMResult::Ok(v) => v,
5160            other => panic!("got {:?}", other),
5161        }
5162    }
5163
5164    #[test]
5165    fn awk_substr_executes_natively_without_host() {
5166        // substr("hello", 2, 3) → "ell"
5167        let chunk = {
5168            let mut b = ChunkBuilder::new();
5169            let s = b.add_constant(Value::str("hello"));
5170            b.emit(Op::LoadConst(s), 1);
5171            b.emit(Op::LoadInt(2), 1);
5172            b.emit(Op::LoadInt(3), 1);
5173            b.emit(Op::AwkSubstr(3), 1);
5174            b.build()
5175        };
5176        assert_eq!(run_native(chunk).to_str(), "ell");
5177    }
5178
5179    #[test]
5180    fn awk_substr_two_arg_to_end_without_host() {
5181        // substr("hello", 2) → "ello"
5182        let chunk = {
5183            let mut b = ChunkBuilder::new();
5184            let s = b.add_constant(Value::str("hello"));
5185            b.emit(Op::LoadConst(s), 1);
5186            b.emit(Op::LoadInt(2), 1);
5187            b.emit(Op::AwkSubstr(2), 1);
5188            b.build()
5189        };
5190        assert_eq!(run_native(chunk).to_str(), "ello");
5191    }
5192
5193    #[test]
5194    fn awk_tolower_toupper_execute_natively_without_host() {
5195        let lower = {
5196            let mut b = ChunkBuilder::new();
5197            let s = b.add_constant(Value::str("MiXeD"));
5198            b.emit(Op::LoadConst(s), 1);
5199            b.emit(Op::AwkToLower, 1);
5200            b.build()
5201        };
5202        assert_eq!(run_native(lower).to_str(), "mixed");
5203        let upper = {
5204            let mut b = ChunkBuilder::new();
5205            let s = b.add_constant(Value::str("MiXeD"));
5206            b.emit(Op::LoadConst(s), 1);
5207            b.emit(Op::AwkToUpper, 1);
5208            b.build()
5209        };
5210        assert_eq!(run_native(upper).to_str(), "MIXED");
5211    }
5212
5213    #[test]
5214    fn awk_index_executes_natively_without_host() {
5215        // index("hello", "ll") → 3
5216        let chunk = {
5217            let mut b = ChunkBuilder::new();
5218            let s = b.add_constant(Value::str("hello"));
5219            let t = b.add_constant(Value::str("ll"));
5220            b.emit(Op::LoadConst(s), 1);
5221            b.emit(Op::LoadConst(t), 1);
5222            b.emit(Op::AwkIndex, 1);
5223            b.build()
5224        };
5225        assert_eq!(run_native(chunk).to_int(), 3);
5226    }
5227
5228    #[test]
5229    fn awk_length_scalar_executes_natively_without_host() {
5230        // length("héllo") → 5 chars (not bytes)
5231        let chunk = {
5232            let mut b = ChunkBuilder::new();
5233            let s = b.add_constant(Value::str("héllo"));
5234            b.emit(Op::LoadConst(s), 1);
5235            b.emit(Op::AwkLength(1), 1);
5236            b.build()
5237        };
5238        assert_eq!(run_native(chunk).to_int(), 5);
5239    }
5240
5241    #[test]
5242    fn awk_native_string_ops_match_host_path() {
5243        // The no-host native result must equal the DefaultAwkHost result.
5244        use crate::awk_host::{awk_index, awk_substr, awk_tolower};
5245        assert_eq!(awk_substr(&Value::str("hello"), 2, Some(3)).to_str(), "ell");
5246        assert_eq!(awk_index(&Value::str("hello"), &Value::str("z")), 0);
5247        assert_eq!(awk_tolower(&Value::str("ABC")).to_str(), "abc");
5248    }
5249
5250    // ── Host-independent AWK numeric builtins execute natively (no host) ──
5251    // int/sqrt/sin/cos/exp/log/atan2 are pure f64 math with no AWK runtime
5252    // state, so they compute real results with no `AwkHost` registered.
5253
5254    #[test]
5255    fn awk_int_truncates_toward_zero_without_host() {
5256        for (input, want) in [(3.7_f64, 3_i64), (-3.7, -3), (0.0, 0)] {
5257            let chunk = {
5258                let mut b = ChunkBuilder::new();
5259                let x = b.add_constant(Value::Float(input));
5260                b.emit(Op::LoadConst(x), 1);
5261                b.emit(Op::AwkInt, 1);
5262                b.build()
5263            };
5264            assert_eq!(run_native(chunk).to_int(), want, "int({input})");
5265        }
5266    }
5267
5268    #[test]
5269    fn awk_sqrt_exp_log_execute_natively_without_host() {
5270        let sqrt = {
5271            let mut b = ChunkBuilder::new();
5272            let x = b.add_constant(Value::Float(16.0));
5273            b.emit(Op::LoadConst(x), 1);
5274            b.emit(Op::AwkSqrt, 1);
5275            b.build()
5276        };
5277        assert_eq!(run_native(sqrt).to_float(), 4.0);
5278        let log = {
5279            let mut b = ChunkBuilder::new();
5280            let x = b.add_constant(Value::Float(std::f64::consts::E));
5281            b.emit(Op::LoadConst(x), 1);
5282            b.emit(Op::AwkLog, 1);
5283            b.build()
5284        };
5285        assert!((run_native(log).to_float() - 1.0).abs() < 1e-12);
5286        let exp = {
5287            let mut b = ChunkBuilder::new();
5288            let x = b.add_constant(Value::Float(0.0));
5289            b.emit(Op::LoadConst(x), 1);
5290            b.emit(Op::AwkExp, 1);
5291            b.build()
5292        };
5293        assert_eq!(run_native(exp).to_float(), 1.0);
5294    }
5295
5296    #[test]
5297    fn awk_sin_cos_execute_natively_without_host() {
5298        let sin = {
5299            let mut b = ChunkBuilder::new();
5300            let x = b.add_constant(Value::Float(0.0));
5301            b.emit(Op::LoadConst(x), 1);
5302            b.emit(Op::AwkSin, 1);
5303            b.build()
5304        };
5305        assert_eq!(run_native(sin).to_float(), 0.0);
5306        let cos = {
5307            let mut b = ChunkBuilder::new();
5308            let x = b.add_constant(Value::Float(0.0));
5309            b.emit(Op::LoadConst(x), 1);
5310            b.emit(Op::AwkCos, 1);
5311            b.build()
5312        };
5313        assert_eq!(run_native(cos).to_float(), 1.0);
5314    }
5315
5316    #[test]
5317    fn awk_atan2_pops_y_then_x_without_host() {
5318        // atan2(1, 1) == π/4. Stack order is [y, x] (y pushed first).
5319        let chunk = {
5320            let mut b = ChunkBuilder::new();
5321            let y = b.add_constant(Value::Float(1.0));
5322            let x = b.add_constant(Value::Float(1.0));
5323            b.emit(Op::LoadConst(y), 1);
5324            b.emit(Op::LoadConst(x), 1);
5325            b.emit(Op::AwkAtan2, 1);
5326            b.build()
5327        };
5328        assert!((run_native(chunk).to_float() - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
5329    }
5330
5331    // ── Host-independent AWK bitwise builtins execute natively (no host) ──
5332    // gawk and/or/xor/compl/lshift/rshift are pure integer math (operands
5333    // truncated to u64), ported faithfully from awkrs's f64 path.
5334
5335    fn run_native_int(chunk: crate::chunk::Chunk) -> i64 {
5336        run_native(chunk).to_int()
5337    }
5338
5339    #[test]
5340    fn awk_and_or_xor_execute_natively_without_host() {
5341        // and(12,10)=8, or(12,10)=14, xor(12,10)=6
5342        let mk = |op: Op| {
5343            let mut b = ChunkBuilder::new();
5344            b.emit(Op::LoadInt(12), 1);
5345            b.emit(Op::LoadInt(10), 1);
5346            b.emit(op, 1);
5347            b.build()
5348        };
5349        assert_eq!(run_native_int(mk(Op::AwkAnd(2))), 8);
5350        assert_eq!(run_native_int(mk(Op::AwkOr(2))), 14);
5351        assert_eq!(run_native_int(mk(Op::AwkXor(2))), 6);
5352    }
5353
5354    #[test]
5355    fn awk_and_is_variadic_without_host() {
5356        // and(15, 9, 5) → 15&9=9, 9&5=1
5357        let chunk = {
5358            let mut b = ChunkBuilder::new();
5359            b.emit(Op::LoadInt(15), 1);
5360            b.emit(Op::LoadInt(9), 1);
5361            b.emit(Op::LoadInt(5), 1);
5362            b.emit(Op::AwkAnd(3), 1);
5363            b.build()
5364        };
5365        assert_eq!(run_native_int(chunk), 1);
5366    }
5367
5368    #[test]
5369    fn awk_compl_matches_awkrs_i64_wrap_without_host() {
5370        // awkrs f64 path: compl(0) = (!0u64) as i64 = -1.
5371        let chunk = {
5372            let mut b = ChunkBuilder::new();
5373            b.emit(Op::LoadInt(0), 1);
5374            b.emit(Op::AwkCompl, 1);
5375            b.build()
5376        };
5377        assert_eq!(run_native_int(chunk), -1);
5378    }
5379
5380    #[test]
5381    fn awk_lshift_rshift_execute_natively_without_host() {
5382        // lshift(1,4)=16; rshift(256,4)=16. Stack order is [v, n].
5383        let lshift = {
5384            let mut b = ChunkBuilder::new();
5385            b.emit(Op::LoadInt(1), 1);
5386            b.emit(Op::LoadInt(4), 1);
5387            b.emit(Op::AwkLshift, 1);
5388            b.build()
5389        };
5390        assert_eq!(run_native_int(lshift), 16);
5391        let rshift = {
5392            let mut b = ChunkBuilder::new();
5393            b.emit(Op::LoadInt(256), 1);
5394            b.emit(Op::LoadInt(4), 1);
5395            b.emit(Op::AwkRshift, 1);
5396            b.build()
5397        };
5398        assert_eq!(run_native_int(rshift), 16);
5399    }
5400
5401    #[test]
5402    fn awk_bitwise_free_fns_match_gawk_semantics() {
5403        use crate::awk_host::{awk_compl, awk_fold_and, awk_fold_or, awk_fold_xor, awk_lshift};
5404        assert_eq!(awk_fold_and(&[Value::Int(12), Value::Int(10)]), 8);
5405        assert_eq!(awk_fold_or(&[Value::Int(12), Value::Int(10)]), 14);
5406        assert_eq!(awk_fold_xor(&[Value::Int(12), Value::Int(10)]), 6);
5407        assert_eq!(awk_compl(&Value::Int(0)), -1);
5408        // shift count is masked to low 6 bits (n & 0x3f)
5409        assert_eq!(awk_lshift(&Value::Int(1), &Value::Int(64)), 1);
5410    }
5411
5412    #[test]
5413    fn awk_mktime_utc_executes_natively_without_host() {
5414        // mktime("2020 01 01 00 00 00", 1) in UTC = 1577836800 epoch seconds.
5415        let chunk = {
5416            let mut b = ChunkBuilder::new();
5417            let s = b.add_constant(Value::str("2020 01 01 00 00 00"));
5418            b.emit(Op::LoadConst(s), 1);
5419            b.emit(Op::LoadInt(1), 1); // utc = true
5420            b.emit(Op::AwkMktime(2), 1);
5421            b.build()
5422        };
5423        assert_eq!(run_native(chunk).to_float(), 1_577_836_800.0);
5424    }
5425
5426    #[test]
5427    fn awk_mktime_bad_datespec_returns_minus_one_without_host() {
5428        // Fewer than 6 fields → -1.
5429        let chunk = {
5430            let mut b = ChunkBuilder::new();
5431            let s = b.add_constant(Value::str("2020 01 01"));
5432            b.emit(Op::LoadConst(s), 1);
5433            b.emit(Op::AwkMktime(1), 1);
5434            b.build()
5435        };
5436        assert_eq!(run_native(chunk).to_float(), -1.0);
5437    }
5438
5439    #[test]
5440    fn awk_strftime_utc_executes_natively_without_host() {
5441        // strftime("%Y-%m-%d", 0, 1) in UTC = "1970-01-01".
5442        let chunk = {
5443            let mut b = ChunkBuilder::new();
5444            let fmt = b.add_constant(Value::str("%Y-%m-%d"));
5445            b.emit(Op::LoadConst(fmt), 1);
5446            b.emit(Op::LoadInt(0), 1); // ts = epoch
5447            b.emit(Op::LoadInt(1), 1); // utc = true
5448            b.emit(Op::AwkStrftime(3), 1);
5449            b.build()
5450        };
5451        assert_eq!(run_native(chunk).to_str(), "1970-01-01");
5452    }
5453
5454    #[test]
5455    fn awk_strftime_mktime_free_fns_match_awkrs() {
5456        use crate::awk_host::{awk_mktime, awk_strftime};
5457        // UTC round trip and -1 sentinel.
5458        assert_eq!(
5459            awk_mktime(&[Value::str("2020 01 01 00 00 00"), Value::Int(1)]).to_float(),
5460            1_577_836_800.0
5461        );
5462        assert_eq!(awk_mktime(&[Value::str("garbage")]).to_float(), -1.0);
5463        assert_eq!(
5464            awk_strftime(&[Value::str("%H:%M:%S"), Value::Int(0), Value::Int(1)]).to_str(),
5465            "00:00:00"
5466        );
5467    }
5468
5469    #[test]
5470    fn awk_ord_executes_natively_without_host() {
5471        // ord("A") = 65; ord("") = 0.
5472        let chunk = {
5473            let mut b = ChunkBuilder::new();
5474            let s = b.add_constant(Value::str("ABC"));
5475            b.emit(Op::LoadConst(s), 1);
5476            b.emit(Op::AwkOrd, 1);
5477            b.build()
5478        };
5479        assert_eq!(run_native(chunk).to_float(), 65.0);
5480
5481        let empty = {
5482            let mut b = ChunkBuilder::new();
5483            let s = b.add_constant(Value::str(""));
5484            b.emit(Op::LoadConst(s), 1);
5485            b.emit(Op::AwkOrd, 1);
5486            b.build()
5487        };
5488        assert_eq!(run_native(empty).to_float(), 0.0);
5489    }
5490
5491    #[test]
5492    fn awk_chr_executes_natively_without_host() {
5493        // chr(65) = "A"; chr of an invalid scalar (surrogate) = "".
5494        let chunk = {
5495            let mut b = ChunkBuilder::new();
5496            b.emit(Op::LoadInt(65), 1);
5497            b.emit(Op::AwkChr, 1);
5498            b.build()
5499        };
5500        assert_eq!(run_native(chunk).to_str(), "A");
5501
5502        let bad = {
5503            let mut b = ChunkBuilder::new();
5504            b.emit(Op::LoadInt(0xD800), 1); // lone surrogate → invalid
5505            b.emit(Op::AwkChr, 1);
5506            b.build()
5507        };
5508        assert_eq!(run_native(bad).to_str(), "");
5509    }
5510
5511    #[test]
5512    fn awk_mkbool_executes_natively_without_host() {
5513        // mkbool(7) = 1; mkbool(0) = 0; mkbool("") = 0.
5514        let truthy = {
5515            let mut b = ChunkBuilder::new();
5516            b.emit(Op::LoadInt(7), 1);
5517            b.emit(Op::AwkMkbool, 1);
5518            b.build()
5519        };
5520        assert_eq!(run_native(truthy).to_float(), 1.0);
5521
5522        let zero = {
5523            let mut b = ChunkBuilder::new();
5524            b.emit(Op::LoadInt(0), 1);
5525            b.emit(Op::AwkMkbool, 1);
5526            b.build()
5527        };
5528        assert_eq!(run_native(zero).to_float(), 0.0);
5529    }
5530
5531    #[test]
5532    fn awk_intdiv_executes_natively_without_host() {
5533        // intdiv(17, 5) = 3 (truncating); intdiv(x, 0) = Undef.
5534        let chunk = {
5535            let mut b = ChunkBuilder::new();
5536            b.emit(Op::LoadInt(17), 1);
5537            b.emit(Op::LoadInt(5), 1);
5538            b.emit(Op::AwkIntdiv, 1);
5539            b.build()
5540        };
5541        assert_eq!(run_native(chunk).to_float(), 3.0);
5542
5543        let div0 = {
5544            let mut b = ChunkBuilder::new();
5545            b.emit(Op::LoadInt(17), 1);
5546            b.emit(Op::LoadInt(0), 1);
5547            b.emit(Op::AwkIntdiv, 1);
5548            b.build()
5549        };
5550        assert!(matches!(run_native(div0), Value::Undef));
5551    }
5552
5553    #[test]
5554    fn awk_intdiv0_executes_natively_without_host() {
5555        // intdiv0(17, 5) = 3; intdiv0(x, 0) = 0 (safe variant, never errors).
5556        let chunk = {
5557            let mut b = ChunkBuilder::new();
5558            b.emit(Op::LoadInt(17), 1);
5559            b.emit(Op::LoadInt(5), 1);
5560            b.emit(Op::AwkIntdiv0, 1);
5561            b.build()
5562        };
5563        assert_eq!(run_native(chunk).to_float(), 3.0);
5564
5565        let div0 = {
5566            let mut b = ChunkBuilder::new();
5567            b.emit(Op::LoadInt(17), 1);
5568            b.emit(Op::LoadInt(0), 1);
5569            b.emit(Op::AwkIntdiv0, 1);
5570            b.build()
5571        };
5572        assert_eq!(run_native(div0).to_float(), 0.0);
5573    }
5574
5575    #[test]
5576    fn awk_div_mod_compute_and_trap_on_zero() {
5577        // awk `a / b` and `a % b` compute the float result for a nonzero
5578        // divisor and raise the POSIX fatal runtime error on a zero divisor
5579        // (distinct from the shell-arithmetic Op::Div/Op::Mod which yield
5580        // Undef / 0). Pop order mirrors Op::Div: b is on top, a beneath.
5581        let div = {
5582            let mut b = ChunkBuilder::new();
5583            b.emit(Op::LoadFloat(7.0), 1);
5584            b.emit(Op::LoadFloat(2.0), 1);
5585            b.emit(Op::AwkDiv, 1);
5586            b.build()
5587        };
5588        assert_eq!(run_native(div).to_float(), 3.5);
5589
5590        let md = {
5591            let mut b = ChunkBuilder::new();
5592            b.emit(Op::LoadFloat(7.0), 1);
5593            b.emit(Op::LoadFloat(3.0), 1);
5594            b.emit(Op::AwkMod, 1);
5595            b.build()
5596        };
5597        assert_eq!(run_native(md).to_float(), 1.0);
5598
5599        let div0 = {
5600            let mut b = ChunkBuilder::new();
5601            b.emit(Op::LoadFloat(1.0), 1);
5602            b.emit(Op::LoadFloat(0.0), 1);
5603            b.emit(Op::AwkDiv, 1);
5604            b.build()
5605        };
5606        match VM::new(div0).run() {
5607            VMResult::Error(m) => assert_eq!(m, "division by zero attempted"),
5608            other => panic!("expected div-by-zero trap, got {:?}", other),
5609        }
5610
5611        let mod0 = {
5612            let mut b = ChunkBuilder::new();
5613            b.emit(Op::LoadFloat(1.0), 1);
5614            b.emit(Op::LoadFloat(0.0), 1);
5615            b.emit(Op::AwkMod, 1);
5616            b.build()
5617        };
5618        match VM::new(mod0).run() {
5619            VMResult::Error(m) => assert_eq!(m, "division by zero attempted in `%'"),
5620            other => panic!("expected mod-by-zero trap, got {:?}", other),
5621        }
5622    }
5623
5624    #[test]
5625    fn awk_signal_halts_chunk_and_records_code() {
5626        use crate::awk_builtins::signal;
5627        // `Op::AwkSignal(code)` halts the chunk immediately and stashes `code`
5628        // in the VM for the frontend driver to read; ops after it do not run.
5629        let chunk = {
5630            let mut b = ChunkBuilder::new();
5631            b.emit(Op::LoadInt(1), 1);
5632            b.emit(Op::AwkSignal(signal::NEXTFILE), 1);
5633            // Unreachable once the signal halts the chunk.
5634            b.emit(Op::LoadInt(99), 1);
5635            b.build()
5636        };
5637        let mut vm = VM::new(chunk);
5638        let r = vm.run();
5639        assert_eq!(vm.awk_signal(), Some(signal::NEXTFILE));
5640        // The pre-signal value remains on the stack; the post-signal LoadInt(99)
5641        // never executed (would have been the Ok value otherwise).
5642        match r {
5643            VMResult::Ok(v) => assert_eq!(v.to_float(), 1.0),
5644            other => panic!("expected Ok(1), got {:?}", other),
5645        }
5646
5647        // A signal-free chunk leaves awk_signal None (zshrs/stryke behavior).
5648        let plain = {
5649            let mut b = ChunkBuilder::new();
5650            b.emit(Op::LoadInt(5), 1);
5651            b.build()
5652        };
5653        let mut vm2 = VM::new(plain);
5654        let _ = vm2.run();
5655        assert_eq!(vm2.awk_signal(), None);
5656    }
5657
5658    #[test]
5659    fn awk_gensub_stub_is_stack_balanced_without_host() {
5660        // Host-bound op: with no AwkHost the stub pops all argc operands and
5661        // pushes a neutral empty string, keeping the stack balanced.
5662        let chunk = {
5663            let mut b = ChunkBuilder::new();
5664            let re = b.add_constant(Value::str("x"));
5665            let repl = b.add_constant(Value::str("y"));
5666            let how = b.add_constant(Value::str("g"));
5667            let target = b.add_constant(Value::str("xax"));
5668            b.emit(Op::LoadConst(re), 1);
5669            b.emit(Op::LoadConst(repl), 1);
5670            b.emit(Op::LoadConst(how), 1);
5671            b.emit(Op::LoadConst(target), 1);
5672            b.emit(Op::AwkGensub(4), 1);
5673            b.build()
5674        };
5675        assert_eq!(run_native(chunk).to_str(), "");
5676    }
5677
5678    #[test]
5679    fn awk_char_scalar_free_fns_match_awkrs() {
5680        use crate::awk_host::{awk_chr, awk_intdiv, awk_intdiv0, awk_mkbool, awk_ord};
5681        assert_eq!(awk_ord(&Value::str("z")).to_float(), 122.0);
5682        assert_eq!(awk_chr(&Value::Int(0x1F600)).to_str(), "😀");
5683        assert_eq!(awk_mkbool(&Value::str("0")).to_float(), 0.0);
5684        assert_eq!(awk_mkbool(&Value::str("x")).to_float(), 1.0);
5685        assert_eq!(awk_intdiv(&Value::Int(-7), &Value::Int(2)).to_float(), -3.0);
5686        assert!(matches!(
5687            awk_intdiv(&Value::Int(1), &Value::Int(0)),
5688            Value::Undef
5689        ));
5690        assert_eq!(
5691            awk_intdiv0(&Value::Int(-7), &Value::Int(2)).to_float(),
5692            -3.0
5693        );
5694        assert_eq!(awk_intdiv0(&Value::Int(1), &Value::Int(0)).to_float(), 0.0);
5695    }
5696
5697    #[test]
5698    fn awk_rand_executes_natively_without_host() {
5699        // Fresh VM seeds rand_seed=1; first rand() is deterministic.
5700        let chunk = {
5701            let mut b = ChunkBuilder::new();
5702            b.emit(Op::AwkRand, 1);
5703            b.build()
5704        };
5705        let v = run_native(chunk).to_float();
5706        assert!((0.0..1.0).contains(&v), "rand() = {v} out of [0,1)");
5707        assert_eq!(
5708            v, 0.51385498046875,
5709            "first rand() from seed=1 must be stable"
5710        );
5711    }
5712
5713    #[test]
5714    fn awk_srand_reseeds_and_returns_prev_seed_without_host() {
5715        // srand(42) on a fresh VM (seed=1) returns previous seed 1.0, then the
5716        // next rand() follows the seed-42 sequence.
5717        let chunk = {
5718            let mut b = ChunkBuilder::new();
5719            b.emit(Op::LoadInt(42), 1);
5720            b.emit(Op::AwkSrand(1), 1); // pushes prev seed (1.0)
5721            b.emit(Op::Pop, 1);
5722            b.emit(Op::AwkRand, 1);
5723            b.build()
5724        };
5725        assert_eq!(run_native(chunk).to_float(), 0.582305908203125);
5726    }
5727
5728    #[test]
5729    fn awk_srand_no_arg_returns_prev_seed_without_host() {
5730        // srand() with no arg reseeds from the clock but still returns prev seed
5731        // (1.0 on a fresh VM).
5732        let chunk = {
5733            let mut b = ChunkBuilder::new();
5734            b.emit(Op::AwkSrand(0), 1);
5735            b.build()
5736        };
5737        assert_eq!(run_native(chunk).to_float(), 1.0);
5738    }
5739
5740    #[test]
5741    fn awk_rand_srand_free_fns_match_awkrs_lcg() {
5742        use crate::awk_host::{awk_rand, awk_srand};
5743        let mut seed: u64 = 1;
5744        assert_eq!(awk_rand(&mut seed), 0.51385498046875);
5745        // srand(Some(42)) returns prev seed low-32 bits and reseeds to 42
5746        let prev = awk_srand(&mut seed, Some(42));
5747        assert_eq!(prev, 1103527590.0);
5748        assert_eq!(awk_rand(&mut seed), 0.582305908203125);
5749    }
5750
5751    #[test]
5752    fn awk_systime_executes_natively_without_host() {
5753        // systime() pushes seconds since the Unix epoch; must be a large positive
5754        // number (well past 2020-01-01 = 1577836800) with no host registered.
5755        let chunk = {
5756            let mut b = ChunkBuilder::new();
5757            b.emit(Op::AwkSystime, 1);
5758            b.build()
5759        };
5760        let v = run_native(chunk).to_float();
5761        assert!(v > 1_577_836_800.0, "systime() = {v} should be past 2020");
5762    }
5763
5764    #[test]
5765    fn awk_systime_free_fn_is_positive() {
5766        use crate::awk_host::awk_systime;
5767        assert!(awk_systime() > 1_577_836_800.0);
5768    }
5769
5770    #[test]
5771    fn awk_strtonum_executes_natively_without_host() {
5772        // strtonum("0x10") → 16 (hex), no host registered.
5773        let chunk = {
5774            let mut b = ChunkBuilder::new();
5775            let s = b.add_constant(Value::str("0x10"));
5776            b.emit(Op::LoadConst(s), 1);
5777            b.emit(Op::AwkStrtonum, 1);
5778            b.build()
5779        };
5780        assert_eq!(run_native(chunk).to_float(), 16.0);
5781    }
5782
5783    #[test]
5784    fn awk_strtonum_octal_and_decimal_prefix_without_host() {
5785        // strtonum("010") → 8 (octal); strtonum("42abc") → 42 (longest prefix).
5786        let octal = {
5787            let mut b = ChunkBuilder::new();
5788            let s = b.add_constant(Value::str("010"));
5789            b.emit(Op::LoadConst(s), 1);
5790            b.emit(Op::AwkStrtonum, 1);
5791            b.build()
5792        };
5793        assert_eq!(run_native(octal).to_float(), 8.0);
5794        let prefix = {
5795            let mut b = ChunkBuilder::new();
5796            let s = b.add_constant(Value::str("42abc"));
5797            b.emit(Op::LoadConst(s), 1);
5798            b.emit(Op::AwkStrtonum, 1);
5799            b.build()
5800        };
5801        assert_eq!(run_native(prefix).to_float(), 42.0);
5802    }
5803
5804    #[test]
5805    fn awk_strtonum_free_fn_matches_awkrs_semantics() {
5806        use crate::awk_host::awk_strtonum;
5807        assert_eq!(awk_strtonum(""), 0.0);
5808        assert_eq!(awk_strtonum("   "), 0.0);
5809        assert_eq!(awk_strtonum("0x10"), 16.0);
5810        assert_eq!(awk_strtonum("0Xff"), 255.0);
5811        assert_eq!(awk_strtonum("010"), 8.0);
5812        assert_eq!(awk_strtonum("3.5"), 3.5);
5813        // invalid hex → 0
5814        assert_eq!(awk_strtonum("0x"), 0.0);
5815        assert_eq!(awk_strtonum("0xzz"), 0.0);
5816        // signed disqualifies hex/octal form: "+0x10" parses leading "+0" → 0
5817        assert_eq!(awk_strtonum("+0x10"), 0.0);
5818        // bare nan/inf without sign → 0 (gawk number scan rejects)
5819        assert_eq!(awk_strtonum("nan"), 0.0);
5820        assert_eq!(awk_strtonum("inf"), 0.0);
5821    }
5822
5823    #[test]
5824    fn awk_builtin_substr_default_impl_is_posix() {
5825        use crate::awk_host::{AwkHost, DefaultAwkHost};
5826        let mut h = DefaultAwkHost;
5827        assert_eq!(h.substr(&Value::str("hello"), 2, Some(3)).to_str(), "ell");
5828        assert_eq!(h.substr(&Value::str("hello"), 2, None).to_str(), "ello");
5829        assert_eq!(h.substr(&Value::str("hello"), 0, Some(3)).to_str(), "he");
5830        assert_eq!(h.index(&Value::str("hello"), &Value::str("ll")), 3);
5831        assert_eq!(h.index(&Value::str("hello"), &Value::str("z")), 0);
5832    }
5833
5834    #[test]
5835    fn awk_op_range_is_disjoint_from_generic_extended_wide() {
5836        use crate::awk_builtins::*;
5837        assert!(!is_awk_op(0));
5838        assert!(!is_awk_op(AWK_OP_BASE - 1));
5839        assert!(is_awk_op(AWK_FIELD_GET));
5840        assert!(is_awk_op(AWK_ARRAY_LEN));
5841        assert!(!is_awk_op(AWK_OP_END));
5842    }
5843
5844    // ── Block-JIT-eligible builtins with awk negative-arg semantics ──
5845    // Interpreter-tier coverage for AwkSqrtJit / AwkLogJit (warn-then-NaN) and
5846    // AwkLshiftJit / AwkRshiftJit / AwkComplJit (fatal trap). Block-JIT codegen
5847    // is a separate follow-up — these stay block-JIT-ineligible for now and
5848    // run on the fusevm interpreter through the chunk dispatch.
5849
5850    fn build_unary(op: Op, x: f64) -> crate::Chunk {
5851        let mut b = crate::ChunkBuilder::new();
5852        b.emit(Op::PushFrame, 1);
5853        b.emit(Op::LoadFloat(x), 1);
5854        b.emit(op, 1);
5855        b.build()
5856    }
5857
5858    fn build_binary(op: Op, a: f64, n: f64) -> crate::Chunk {
5859        let mut b = crate::ChunkBuilder::new();
5860        b.emit(Op::PushFrame, 1);
5861        b.emit(Op::LoadFloat(a), 1);
5862        b.emit(Op::LoadFloat(n), 1);
5863        b.emit(op, 1);
5864        b.build()
5865    }
5866
5867    #[test]
5868    fn awk_sqrt_jit_negative_warns_returns_nan() {
5869        let chunk = build_unary(Op::AwkSqrtJit, -4.0);
5870        let mut vm = VM::new(chunk);
5871        match vm.run() {
5872            VMResult::Ok(v) => assert!(v.to_float().is_nan(), "expected NaN, got {v:?}"),
5873            other => panic!("expected Ok(NaN), got {other:?}"),
5874        }
5875    }
5876
5877    #[test]
5878    fn awk_sqrt_jit_positive_returns_sqrt() {
5879        let chunk = build_unary(Op::AwkSqrtJit, 16.0);
5880        let mut vm = VM::new(chunk);
5881        match vm.run() {
5882            VMResult::Ok(v) => assert_eq!(v.to_float(), 4.0),
5883            other => panic!("expected Ok(4.0), got {other:?}"),
5884        }
5885    }
5886
5887    #[test]
5888    fn awk_log_jit_positive_returns_ln() {
5889        let chunk = build_unary(Op::AwkLogJit, std::f64::consts::E);
5890        let mut vm = VM::new(chunk);
5891        match vm.run() {
5892            VMResult::Ok(v) => assert!((v.to_float() - 1.0).abs() < 1e-10),
5893            other => panic!("expected Ok(~1.0), got {other:?}"),
5894        }
5895    }
5896
5897    #[test]
5898    fn awk_log_jit_negative_warns_returns_nan() {
5899        let chunk = build_unary(Op::AwkLogJit, -1.0);
5900        let mut vm = VM::new(chunk);
5901        match vm.run() {
5902            VMResult::Ok(v) => assert!(v.to_float().is_nan()),
5903            other => panic!("expected Ok(NaN), got {other:?}"),
5904        }
5905    }
5906
5907    #[test]
5908    fn awk_lshift_jit_computes_left_shift() {
5909        let chunk = build_binary(Op::AwkLshiftJit, 1.0, 4.0);
5910        let mut vm = VM::new(chunk);
5911        match vm.run() {
5912            VMResult::Ok(v) => assert_eq!(v.to_float(), 16.0, "1 << 4 == 16"),
5913            other => panic!("expected Ok(16.0), got {other:?}"),
5914        }
5915    }
5916
5917    #[test]
5918    fn awk_lshift_jit_negative_amount_errors() {
5919        let chunk = build_binary(Op::AwkLshiftJit, 1.0, -1.0);
5920        let mut vm = VM::new(chunk);
5921        match vm.run() {
5922            VMResult::Error(msg) => assert!(msg.contains("lshift"), "msg = {msg:?}"),
5923            other => panic!("expected Error, got {other:?}"),
5924        }
5925    }
5926
5927    #[test]
5928    fn awk_rshift_jit_computes_right_shift() {
5929        let chunk = build_binary(Op::AwkRshiftJit, 16.0, 2.0);
5930        let mut vm = VM::new(chunk);
5931        match vm.run() {
5932            VMResult::Ok(v) => assert_eq!(v.to_float(), 4.0, "16 >> 2 == 4"),
5933            other => panic!("expected Ok(4.0), got {other:?}"),
5934        }
5935    }
5936
5937    #[test]
5938    fn awk_compl_jit_negates_bits() {
5939        // compl(15) ≈ !15 in i64 ≈ -16 ≈ as f64 ≈ -16.0
5940        let chunk = build_unary(Op::AwkComplJit, 15.0);
5941        let mut vm = VM::new(chunk);
5942        match vm.run() {
5943            VMResult::Ok(v) => assert_eq!(v.to_float(), -16.0, "compl(15) == -16"),
5944            other => panic!("expected Ok(-16.0), got {other:?}"),
5945        }
5946    }
5947
5948    #[test]
5949    fn awk_compl_jit_negative_errors() {
5950        let chunk = build_unary(Op::AwkComplJit, -1.0);
5951        let mut vm = VM::new(chunk);
5952        match vm.run() {
5953            VMResult::Error(msg) => assert!(msg.contains("compl"), "msg = {msg:?}"),
5954            other => panic!("expected Error, got {other:?}"),
5955        }
5956    }
5957
5958    // ─── I/O hooks (web-worker bridging) ──────────────────────────────
5959    // Target-independent: these exercise the OutputSink / InputSource paths
5960    // that a wasm32 web-worker frontend installs, but run on any host.
5961
5962    use std::sync::{Arc, Mutex};
5963
5964    #[test]
5965    fn output_sink_captures_print_and_println() {
5966        // `print "a"; println "b"` → sink sees "a" then "b\n".
5967        let mut b = ChunkBuilder::new();
5968        let a = b.add_constant(Value::str("a"));
5969        let bee = b.add_constant(Value::str("b"));
5970        b.emit(Op::LoadConst(a), 1);
5971        b.emit(Op::Print(1), 1);
5972        b.emit(Op::LoadConst(bee), 1);
5973        b.emit(Op::PrintLn(1), 1);
5974        let captured = Arc::new(Mutex::new(String::new()));
5975        let buf = Arc::clone(&captured);
5976        let mut vm = VM::new(b.build());
5977        vm.set_output_sink(Box::new(move |s: &str| buf.lock().unwrap().push_str(s)));
5978        vm.run();
5979        assert_eq!(*captured.lock().unwrap(), "ab\n");
5980    }
5981
5982    #[test]
5983    fn output_sink_receives_multi_arg_print_concatenated() {
5984        // `print "x", 1, "y"` with three args → one contiguous "x1y".
5985        let mut b = ChunkBuilder::new();
5986        let x = b.add_constant(Value::str("x"));
5987        let y = b.add_constant(Value::str("y"));
5988        b.emit(Op::LoadConst(x), 1);
5989        b.emit(Op::LoadInt(1), 1);
5990        b.emit(Op::LoadConst(y), 1);
5991        b.emit(Op::Print(3), 1);
5992        let captured = Arc::new(Mutex::new(String::new()));
5993        let buf = Arc::clone(&captured);
5994        let mut vm = VM::new(b.build());
5995        vm.set_output_sink(Box::new(move |s: &str| buf.lock().unwrap().push_str(s)));
5996        vm.run();
5997        assert_eq!(*captured.lock().unwrap(), "x1y");
5998    }
5999
6000    #[test]
6001    fn input_source_feeds_readline_then_undef_at_eof() {
6002        // Two ReadLines pull the source's two lines; a third past EOF is Undef.
6003        let mut b = ChunkBuilder::new();
6004        b.emit(Op::ReadLine, 1); // -> "first"  (popped last)
6005        b.emit(Op::Pop, 1);
6006        b.emit(Op::ReadLine, 1); // -> "second"
6007        b.emit(Op::Pop, 1);
6008        b.emit(Op::ReadLine, 1); // -> Undef (EOF)
6009        let mut pending = vec!["second".to_string(), "first".to_string()];
6010        let mut vm = VM::new(b.build());
6011        vm.set_input_source(Box::new(move || pending.pop()));
6012        match vm.run() {
6013            VMResult::Ok(Value::Undef) => {}
6014            other => panic!("expected Undef at EOF, got {other:?}"),
6015        }
6016    }
6017}