fusevm 0.14.6

Language-agnostic bytecode VM with fused superinstructions and a 3-tier Cranelift JIT (linear/block/tracing)
Documentation
//! Strict numeric mode (`VM::set_numeric_hook`) and the block-JIT float result.
//!
//! Both exist for frontends whose language is not awk: elisp signals on a
//! non-numeric operand and promotes integer overflow to a bignum, where the
//! default policy coerces (`"a"` → `0.0`) and wraps. The tests below pin:
//!
//! 1. a chunk whose result is a float still returns a float once the block-JIT
//!    cache is warm (it used to truncate to an integer from the second run on);
//! 2. the default policy still coerces and wraps — zshrs/awkrs/stryke semantics
//!    are untouched by the hook's existence;
//! 3. with a hook installed, a non-numeric operand and an overflowing integer
//!    op both reach the host, *including* after the JIT has compiled the chunk,
//!    which is the case native code would otherwise silently get wrong.

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

use fusevm::{Chunk, ChunkBuilder, NumOp, Op, VMResult, Value, VM};

/// Mirrors how elisprs drives fusevm: a fresh VM per chunk, tracing JIT on,
/// every chunk run on the same thread (so the JIT caches are shared).
fn run(chunk: Chunk, hook: Option<fusevm::NumericHook>) -> Result<Value, String> {
    let mut vm = VM::new(chunk);
    vm.enable_tracing_jit();
    if let Some(h) = hook {
        vm.set_numeric_hook(h);
    }
    match vm.run() {
        VMResult::Ok(v) => Ok(v),
        VMResult::Halted => Ok(vm.stack.last().cloned().unwrap_or(Value::Undef)),
        VMResult::Error(e) => Err(e),
    }
}

fn float_chunk(f: f64) -> Chunk {
    let mut b = ChunkBuilder::new();
    b.emit(Op::LoadFloat(f), 0);
    b.build()
}

/// `a OP b` as a two-constant chunk.
fn binop_chunk(a: Value, b: Value, op: Op) -> Chunk {
    let mut bd = ChunkBuilder::new();
    match a {
        Value::Int(n) => bd.emit(Op::LoadInt(n), 0),
        Value::Float(f) => bd.emit(Op::LoadFloat(f), 0),
        other => {
            let idx = bd.add_constant(other);
            bd.emit(Op::LoadConst(idx), 0)
        }
    };
    match b {
        Value::Int(n) => bd.emit(Op::LoadInt(n), 0),
        Value::Float(f) => bd.emit(Op::LoadFloat(f), 0),
        other => {
            let idx = bd.add_constant(other);
            bd.emit(Op::LoadConst(idx), 0)
        }
    };
    bd.emit(op, 0);
    bd.build()
}

/// A float chunk must keep returning a float no matter how many times it runs.
///
/// Regression: `VM::run` decoded the block tier's `i64` return register as
/// `Value::Int` unconditionally, so once the block cache warmed up (run 2), a
/// chunk loading `2.5` returned `Int(2)`. elisprs hit this as `(eval 2.5 t)`
/// evaluating to `2` the second time the same form was evaluated.
#[test]
fn block_jit_preserves_a_float_chunk_result_across_runs() {
    for i in 1..=12 {
        let v = run(float_chunk(2.5), None).expect("float chunk runs");
        assert_eq!(v, Value::Float(2.5), "run {i} lost the float");
    }
    // A different float, to prove the cache is keyed and decoded per chunk.
    assert_eq!(run(float_chunk(-0.5), None).unwrap(), Value::Float(-0.5));
}

/// No hook installed → the awk/shell contract is exactly as before: a string
/// operand coerces through `to_float`, and integer overflow wraps.
#[test]
fn default_policy_still_coerces_and_wraps() {
    let v = run(
        binop_chunk(Value::Int(1), Value::str("a".to_string()), Op::Add),
        None,
    )
    .expect("coercing add of a string does not error");
    assert_eq!(v, Value::Float(1.0));

    let v = run(
        binop_chunk(Value::Int(i64::MAX), Value::Int(1), Op::Add),
        None,
    )
    .expect("coercing add does not error on overflow");
    assert_eq!(v, Value::Int(i64::MIN), "overflow must wrap, not trap");
}

/// With a hook installed, a non-numeric operand is the host's decision.
#[test]
fn strict_mode_delegates_a_non_numeric_operand() {
    let hook: fusevm::NumericHook = Arc::new(|_op, a, b| {
        let bad = if matches!(a, Value::Int(_) | Value::Float(_)) {
            b
        } else {
            a
        };
        Err(format!("wrong-type-argument: number-or-marker-p {bad:?}"))
    });
    let err = run(
        binop_chunk(Value::Int(1), Value::str("a".to_string()), Op::Add),
        Some(hook),
    )
    .expect_err("strict add of a string must signal");
    assert!(
        err.starts_with("wrong-type-argument"),
        "hook's error must propagate verbatim, got: {err}"
    );
}

/// With a hook installed, integer overflow reaches the host so it can widen —
/// and it keeps reaching the host after the block JIT has compiled the chunk,
/// which is the case the overflow-checked lowering exists for. Without the
/// checked lowering this test fails from the warmup threshold on (native `iadd`
/// wraps silently and never calls back).
#[test]
fn strict_mode_delegates_integer_overflow_even_once_jit_compiled() {
    let calls = Arc::new(AtomicUsize::new(0));
    let seen = calls.clone();
    let hook: fusevm::NumericHook = Arc::new(move |op, a, b| {
        seen.fetch_add(1, Ordering::Relaxed);
        assert_eq!(op, NumOp::Add);
        assert_eq!((a, b), (&Value::Int(i64::MAX), &Value::Int(1)));
        // Stand in for a bignum: the host returns a value fusevm never could.
        Ok(Value::str("BIGNUM".to_string()))
    });

    // Well past the block-JIT warmup threshold (10), so the later iterations run
    // as native code.
    for i in 1..=25 {
        let v = run(
            binop_chunk(Value::Int(i64::MAX), Value::Int(1), Op::Add),
            Some(hook.clone()),
        )
        .expect("overflow is delegated, not an error");
        assert_eq!(
            v,
            Value::str("BIGNUM".to_string()),
            "run {i}: overflow escaped the hook (wrapped in native code?)"
        );
    }
    assert_eq!(
        calls.load(Ordering::Relaxed),
        25,
        "every run must delegate, including the JIT-compiled ones"
    );
}

/// Multiplication and negation overflow the same way, and a non-overflowing
/// strict chunk still returns the plain fixnum result (the checked lowering must
/// not change results, only catch the cases i64 cannot represent).
#[test]
fn strict_mode_checked_ops_are_exact_when_they_fit() {
    let hook: fusevm::NumericHook = Arc::new(|_, _, _| Ok(Value::str("BIG".to_string())));

    for i in 1..=15 {
        let v = run(
            binop_chunk(Value::Int(6), Value::Int(7), Op::Mul),
            Some(hook.clone()),
        )
        .unwrap();
        assert_eq!(v, Value::Int(42), "run {i}: checked mul changed the result");
    }
    let v = run(
        binop_chunk(Value::Int(i64::MAX), Value::Int(2), Op::Mul),
        Some(hook.clone()),
    )
    .unwrap();
    assert_eq!(
        v,
        Value::str("BIG".to_string()),
        "mul overflow must delegate"
    );
}

/// A host with tagged fixnums (Emacs: 62-bit) must see results that still fit an
/// `i64` but leave its fixnum range — `most-positive-fixnum + 1` is a bignum in
/// Emacs even though 2^61 fits an i64 twice over. The bounds check rides in the
/// same accumulator as the overflow bit, so it must survive JIT compilation too.
#[test]
fn strict_mode_delegates_results_outside_a_narrowed_fixnum_range() {
    const MOST_POSITIVE_FIXNUM: i64 = 2_305_843_009_213_693_951; // 2^61 - 1
    const MOST_NEGATIVE_FIXNUM: i64 = -2_305_843_009_213_693_952;

    let calls = Arc::new(AtomicUsize::new(0));
    let seen = calls.clone();
    let hook: fusevm::NumericHook = Arc::new(move |_op, _a, _b| {
        seen.fetch_add(1, Ordering::Relaxed);
        Ok(Value::str("BIGNUM".to_string()))
    });

    let run_ranged = |chunk: Chunk| -> Value {
        let mut vm = VM::new(chunk);
        vm.enable_tracing_jit();
        vm.set_numeric_hook(hook.clone());
        vm.set_fixnum_range(MOST_NEGATIVE_FIXNUM, MOST_POSITIVE_FIXNUM);
        match vm.run() {
            VMResult::Ok(v) => v,
            VMResult::Halted => vm.stack.last().cloned().unwrap_or(Value::Undef),
            VMResult::Error(e) => panic!("vm error: {e}"),
        }
    };

    // Past the block-JIT threshold, so the tail of this loop is native code.
    for i in 1..=25 {
        let v = run_ranged(binop_chunk(
            Value::Int(MOST_POSITIVE_FIXNUM),
            Value::Int(1),
            Op::Add,
        ));
        assert_eq!(
            v,
            Value::str("BIGNUM".to_string()),
            "run {i}: 2^61 escaped as a fixnum"
        );
    }
    assert_eq!(calls.load(Ordering::Relaxed), 25);

    // In range: still an exact native fixnum, no delegation.
    let v = run_ranged(binop_chunk(
        Value::Int(MOST_POSITIVE_FIXNUM - 1),
        Value::Int(1),
        Op::Add,
    ));
    assert_eq!(v, Value::Int(MOST_POSITIVE_FIXNUM));
    assert_eq!(
        calls.load(Ordering::Relaxed),
        25,
        "in-range must not delegate"
    );
}