ilo 26.5.0

ilo - the token-minimal programming language AI agents write
Documentation
// Regression tests for native `map` HOF dispatch on every engine
// (PR 2 of the VM/Cranelift HOF dispatch chain).
//
// Background: PR 1 (#274) landed FnRef NaN-tagging so a function
// reference could survive the NanVal round-trip on VM and Cranelift.
// PR 2 puts that plumbing to work: the compiler emits a native bytecode
// loop for `map fn xs` that calls back into the FnRef via OP_CALL_DYN,
// and Cranelift lowers OP_CALL_DYN to a `jit_call_dyn` helper which
// dispatches user-fns by re-entering the VM and builtins by routing
// through the tree-bridge. Every engine now runs `map` end-to-end.
//
// The tests below pin the value-level behaviour across `--run-tree`,
// `--vm` and `--jit`. They cover the common shapes that
// were previously gated with `engine-skip: vm / cranelift`:
//   - user-function callback (`map sq xs`)
//   - builtin callback (`map abs xs`)
//   - empty list (early-exit before the first OP_CALL_DYN)
//   - single-element list (one trip through the loop body)
//   - composition with the result of another `map` (chained HOFs)
//   - tail-call shape where `map` is the function's return value
//
// Any divergence between engines should fail one of these tests rather
// than silently producing different output.

use std::process::Command;

fn ilo() -> Command {
    Command::new(env!("CARGO_BIN_EXE_ilo"))
}

fn write_src(name: &str, src: &str) -> std::path::PathBuf {
    use std::sync::atomic::{AtomicU64, Ordering};
    static COUNTER: AtomicU64 = AtomicU64::new(0);
    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
    let mut path = std::env::temp_dir();
    path.push(format!("ilo_hof_map_{name}_{}_{n}.ilo", std::process::id()));
    std::fs::write(&path, src).expect("write src");
    path
}

fn run_ok(engine: &str, src: &str, entry: &str, args: &[&str]) -> String {
    let path = write_src(entry, src);
    let mut cmd = ilo();
    cmd.arg(&path).arg(engine).arg(entry);
    for a in args {
        cmd.arg(a);
    }
    let out = cmd.output().expect("failed to run ilo");
    let _ = std::fs::remove_file(&path);
    assert!(
        out.status.success(),
        "ilo {engine} failed for `{src}`: stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

fn run_all(src: &str, entry: &str, args: &[&str], expected: &str) {
    for engine in ["--vm", "--jit"] {
        let actual = run_ok(engine, src, entry, args);
        assert_eq!(
            actual, expected,
            "engine {engine} produced {actual:?}, expected {expected:?} for src `{src}`"
        );
    }
}

/// Compile `src` with `ilo compile`, run the binary with `args`, return
/// trimmed stdout.  Used by AOT-coverage tests that need to pin all three
/// backends (VM + JIT + AOT) byte-for-byte.
#[cfg(feature = "cranelift")]
fn run_aot_ok(src: &str, args: &[&str]) -> String {
    let path = write_src("aot", src);
    let bin = {
        let mut p = path.clone();
        p.set_extension("bin");
        p
    };
    let compile = ilo()
        .arg("compile")
        .arg(&path)
        .arg("-o")
        .arg(&bin)
        .output()
        .expect("ilo compile failed to spawn");
    assert!(
        compile.status.success(),
        "ilo compile failed for `{src}`: stderr={}",
        String::from_utf8_lossy(&compile.stderr)
    );
    let out = std::process::Command::new(&bin)
        .args(args)
        .output()
        .expect("AOT binary failed to spawn");
    let _ = std::fs::remove_file(&path);
    let _ = std::fs::remove_file(&bin);
    assert!(
        out.status.success(),
        "AOT binary exited non-zero for `{src}`: stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

/// Like `run_all`, but also exercises the AOT pipeline (`ilo compile` +
/// run binary).  Per CLAUDE.md, multi-backend code must exercise ALL
/// backends.  Used for the record-field regression — AOT round-trips
/// records through the same NanVal conversion path the fix corrects.
#[cfg(feature = "cranelift")]
fn run_all_with_aot(src: &str, entry: &str, args: &[&str], expected: &str) {
    run_all(src, entry, args, expected);
    let actual = run_aot_ok(src, args);
    assert_eq!(
        actual, expected,
        "engine AOT produced {actual:?}, expected {expected:?} for src `{src}`"
    );
}

// ── User-fn callback ────────────────────────────────────────────────────

const MAP_USER_SQ: &str = "sq x:n>n;*x x\nmain xs:L n>L n;map sq xs";

#[test]
fn map_user_fn_squares_tree_vm_cranelift() {
    run_all(MAP_USER_SQ, "main", &["[1,2,3,4,5]"], "[1, 4, 9, 16, 25]");
}

#[test]
fn map_user_fn_empty_list() {
    // Empty list short-circuits at OP_FOREACHPREP: the first JMP exits
    // before any OP_CALL_DYN fires. Pins the dispatcher's "no callback
    // for empty input" path on every engine.
    run_all(MAP_USER_SQ, "main", &["[]"], "[]");
}

#[test]
fn map_user_fn_single_element() {
    // One iteration only. Worth pinning separately because it exercises
    // OP_FOREACHPREP-stay-in-loop, then exactly one OP_CALL_DYN, then
    // FOREACHNEXT-fallthrough-to-exit. Past HOF stubs failed here
    // because the OP_LISTAPPEND in the body left acc_reg in a bad state.
    run_all(MAP_USER_SQ, "main", &["[7]"], "[49]");
}

// ── Builtin callback ────────────────────────────────────────────────────

// `abs` is a pure builtin promoted to F by the verifier (#165); the
// Cranelift path goes through `jit_call_dyn` → tree-bridge for builtins.
const MAP_BUILTIN_ABS: &str = "main xs:L n>L n;map abs xs";

#[test]
fn map_builtin_abs_round_trip() {
    run_all(MAP_BUILTIN_ABS, "main", &["[-3, 0, 4, -7]"], "[3, 0, 4, 7]");
}

// ── Chained map (composition) ───────────────────────────────────────────

// Two HOF calls in series: the result of the first feeds the second.
// This pins that `map`'s result is a valid List value (not a smuggled
// FnRef or stale heap pointer) when it flows back into another HOF.
const MAP_CHAINED: &str = "sq x:n>n;*x x\nadd1 x:n>n;+x 1\nmain xs:L n>L n;map add1 (map sq xs)";

#[test]
fn map_chained_user_fns() {
    run_all(MAP_CHAINED, "main", &["[1,2,3]"], "[2, 5, 10]");
}

// ── Tail-position map (result is the function's return) ─────────────────

// Most function bodies in ilo return the last expression. With `map` as
// the tail, the result register must survive OP_RET's RC accounting on
// every engine. Pins that we don't drop the result list early.
const MAP_TAIL: &str = "dbl x:n>n;*x 2\nmain xs:L n>L n;map dbl xs";

#[test]
fn map_tail_position_user_fn() {
    run_all(MAP_TAIL, "main", &["[5, 10, 15]"], "[10, 20, 30]");
}

// ── Record-field access through map ─────────────────────────────────────

// Regression: JIT `map fn xs` where fn reads a typed record field returned
// wrong (non-text) values. Root cause: Value::Record (HashMap) was
// converted back to a heap NanVal in non-deterministic key order, so
// OP_RECFLD's positional index resolved to the wrong slot.
//
// Test `map get-nm` and `map get-off` on a 3-element typed record list,
// covering both text and number field types.  Needs to pass on every engine.

// Programs use semicolons to chain statements within the function body
// so the Rust string literal works without indentation.
const MAP_RECORD_FIELD: &str = concat!(
    "type prs{nm:t;off:n}\n",
    "get-nm p:prs>t;p.nm\n",
    "get-off p:prs>n;p.off\n",
    "main>t\n",
    "  ps=[prs nm:\"London\" off:1 prs nm:\"NewYork\" off:-4 prs nm:\"Tokyo\" off:9]\n",
    "  nms=map get-nm ps\n",
    "  offs=map get-off ps\n",
    "  fmt \"{} {}\" (cat nms \",\") (cat (map str offs) \",\")",
);

#[test]
fn map_record_field_text_jit_vm_parity() {
    // Pins that map get-nm returns text field values (not numerics or garbage)
    // on both VM and JIT.  Before the fix this was non-deterministic on JIT.
    // Skips AOT only because the mixed numeric-list interpolation in this
    // particular shape hits a separate, pre-existing AOT divergence
    // (numeric list rendering); the text-field path itself is covered by
    // `map_record_field_text_only_all_backends` below.
    run_all(MAP_RECORD_FIELD, "main", &[], "London,NewYork,Tokyo 1,-4,9");
}

#[test]
fn map_record_field_single_element() {
    // Single-element variant — one trip through the OP_CALL_DYN loop.
    const SINGLE: &str = concat!(
        "type prs{nm:t;off:n}\n",
        "get-nm p:prs>t;p.nm\n",
        "main>t\n",
        "  ps=[prs nm:\"London\" off:1]\n",
        "  nms=map get-nm ps\n",
        "  cat nms \",\"",
    );
    run_all(SINGLE, "main", &[], "London");
}

#[test]
fn map_record_field_number_field() {
    // Ensure numeric field access also lands in the correct slot on JIT.
    // AOT-skipped for the same numeric-list rendering quirk noted above;
    // the dispatch / OP_RECFLD path itself is identical across engines.
    const NUM_FIELD: &str = concat!(
        "type prs{nm:t;off:n}\n",
        "get-off p:prs>n;p.off\n",
        "main>t\n",
        "  ps=[prs nm:\"London\" off:1 prs nm:\"NewYork\" off:-4]\n",
        "  offs=map get-off ps\n",
        "  cat (map str offs) \",\"",
    );
    run_all(NUM_FIELD, "main", &[], "1,-4");
}

// AOT-coverage variant: exercise the full VM + JIT + AOT triangle on the
// text-field path.  Per CLAUDE.md, multi-backend regressions must touch
// every backend — this is the cross-cutting pin that catches AOT-specific
// divergence in the Value::Record -> NanVal conversion.
#[cfg(feature = "cranelift")]
#[test]
fn map_record_field_text_only_all_backends() {
    const TEXT_ONLY: &str = concat!(
        "type prs{nm:t;off:n}\n",
        "get-nm p:prs>t;p.nm\n",
        "main>t\n",
        "  ps=[prs nm:\"London\" off:1 prs nm:\"NewYork\" off:-4 prs nm:\"Tokyo\" off:9]\n",
        "  nms=map get-nm ps\n",
        "  cat nms \",\"",
    );
    run_all_with_aot(TEXT_ONLY, "main", &[], "London,NewYork,Tokyo");
}