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