lex-trace 0.11.24

Run-time trace tree + replay for Lex programs.
Documentation
//! M7 acceptance per spec §10.4.

use indexmap::IndexMap;
use lex_ast::canonicalize_program;
use lex_bytecode::{compile_program, vm::Vm, Value};
use lex_runtime::{DefaultHandler, Policy};
use lex_syntax::parse_source;
use lex_trace::{diff_runs, Recorder, TraceNodeKind, TraceTree};
use std::collections::BTreeSet;

fn compile(src: &str) -> lex_bytecode::Program {
    let prog = parse_source(src).unwrap();
    let stages = canonicalize_program(&prog);
    compile_program(&stages)
}

fn allow(effects: &[&str]) -> Policy {
    let mut p = Policy::pure();
    p.allow_effects = effects.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
    p
}

fn run_with_recorder(
    src: &str,
    func: &str,
    args: Vec<Value>,
    overrides: Option<IndexMap<String, serde_json::Value>>,
    policy: Policy,
) -> (TraceTree, Result<Value, lex_bytecode::vm::VmError>) {
    let prog = compile(src);
    let mut recorder = Recorder::new();
    if let Some(o) = overrides { recorder = recorder.with_overrides(o); }
    let handle = recorder.handle();
    let started = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
    let handler = DefaultHandler::new(policy);
    let mut vm = Vm::with_handler(&prog, Box::new(handler));
    vm.set_tracer(Box::new(recorder));
    let result = vm.call(func, args);
    let ended = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
    let (root_out, root_err) = match &result {
        Ok(v) => (Some(value_to_json(v)), None),
        Err(e) => (None, Some(format!("{e}"))),
    };
    let tree = handle.finalize(func, serde_json::Value::Null, root_out, root_err, started, ended);
    (tree, result)
}

fn value_to_json(v: &Value) -> serde_json::Value {
    use serde_json::Value as J;
    match v {
        Value::Int(n) => J::from(*n),
        Value::Float(f) => J::from(*f),
        Value::Bool(b) => J::Bool(*b),
        Value::Str(s) => J::String(s.to_string()),
        Value::Bytes(b) => J::String(b.iter().map(|b| format!("{:02x}", b)).collect()),
        Value::Unit => J::Null,
        Value::List(items) => J::Array(items.iter().map(value_to_json).collect()),
        Value::Tuple(items) => J::Array(items.iter().map(value_to_json).collect()),
        Value::Record { fields, .. } => {
            let mut m = serde_json::Map::new();
            for (k, v) in fields.iter() { m.insert(k.to_string(), value_to_json(v)); }
            J::Object(m)
        }
        Value::Variant { name, args } => {
            let mut m = serde_json::Map::new();
            m.insert("$variant".into(), J::String(name.clone()));
            m.insert("args".into(), J::Array(args.iter().map(value_to_json).collect()));
            J::Object(m)
        }
        Value::Closure { body_hash, .. } => {
            let prefix: String = body_hash.iter().take(4)
                .map(|b| format!("{b:02x}")).collect();
            J::String(format!("<closure {prefix}>"))
        }
        Value::F64Array { rows, cols, data } => {
            let mut m = serde_json::Map::new();
            m.insert("$f64_array".into(), J::Bool(true));
            m.insert("rows".into(), J::from(*rows));
            m.insert("cols".into(), J::from(*cols));
            m.insert("data".into(), J::Array(data.iter().map(|f| J::from(*f)).collect()));
            J::Object(m)
        }
        Value::Map(_) | Value::Set(_) | Value::Deque(_) | Value::Actor(_)
        | Value::Ticker(_) | Value::ArrowTable(_)
        | Value::StackRecord { .. } | Value::StackTuple { .. }
        | Value::ArenaRecord { .. } | Value::ArenaTuple { .. } => {
            // Tests in this file don't exercise these container values;
            // render as a placeholder so the match is exhaustive.
            J::String("<container>".into())
        }
    }
}

const FACTORIAL: &str = "fn factorial(n :: Int) -> Int { match n { 0 => 1, _ => n * factorial(n - 1) } }\nfn driver(n :: Int) -> Int { factorial(n) }\n";

#[test]
fn pure_run_records_call_tree() {
    let (tree, r) = run_with_recorder(FACTORIAL, "driver", vec![Value::Int(3)], None, Policy::pure());
    assert_eq!(r.unwrap(), Value::Int(6));
    // driver → factorial(3) → factorial(2) → factorial(1) → factorial(0)
    assert_eq!(tree.nodes.len(), 1, "single top-level call");
    let factorial_call = &tree.nodes[0];
    assert!(matches!(factorial_call.kind, TraceNodeKind::Call));
    assert_eq!(factorial_call.target, "factorial");
    assert!(factorial_call.output.is_some());
}

#[test]
fn effect_call_appears_in_trace() {
    let src = r#"
import "std.io" as io
fn say(line :: Str) -> [io] Nil { io.print(line) }
"#;
    let (tree, r) = run_with_recorder(src, "say", vec![Value::Str("hello".into())], None, allow(&["io"]));
    assert!(r.is_ok());
    // Find the io.print effect node.
    fn find_effect(n: &lex_trace::TraceNode) -> Option<&lex_trace::TraceNode> {
        if matches!(n.kind, TraceNodeKind::Effect) { return Some(n); }
        for c in &n.children {
            if let Some(f) = find_effect(c) { return Some(f); }
        }
        None
    }
    let effect = tree.nodes.iter().find_map(find_effect)
        .expect("io.print effect should appear in trace");
    assert_eq!(effect.target, "io.print");
}

#[test]
fn failed_run_identifies_failing_node() {
    // Effect handler will fail because we don't allow `io`.
    let src = r#"
import "std.io" as io
fn say(line :: Str) -> [io] Nil { io.print(line) }
"#;
    // Allow `io` statically (so policy passes) but use a handler that
    // refuses to dispatch — emulating a failing call.
    struct FailHandler;
    impl lex_bytecode::vm::EffectHandler for FailHandler {
        fn dispatch(&mut self, _: &str, _: &str, _: Vec<Value>) -> Result<Value, String> {
            Err("simulated failure".into())
        }
    }
    let prog = compile(src);
    let recorder = Recorder::new();
    let handle = recorder.handle();
    let mut vm = Vm::with_handler(&prog, Box::new(FailHandler));
    vm.set_tracer(Box::new(recorder));
    let result = vm.call("say", vec![Value::Str("x".into())]);
    let tree = handle.finalize("say", serde_json::Value::Null, None,
        result.as_ref().err().map(|e| format!("{e}")),
        0, 0);
    assert!(result.is_err());
    // Walk to find an Effect node with an `error` field.
    fn find_err_node(n: &lex_trace::TraceNode) -> Option<&lex_trace::TraceNode> {
        if n.error.is_some() { return Some(n); }
        for c in &n.children {
            if let Some(f) = find_err_node(c) { return Some(f); }
        }
        None
    }
    let bad = tree.nodes.iter().find_map(find_err_node)
        .expect("trace should record the failing effect node");
    assert_eq!(bad.target, "io.print");
    assert!(bad.error.as_deref().unwrap_or("").contains("simulated"));
    // §10.4: failing run identifies the node.
    assert!(!bad.node_id.is_empty(), "failing node must carry its NodeId");
}

#[test]
fn replay_with_override_substitutes_effect_output() {
    // Function calls io.read, then continues. We override io.read's output
    // for replay so we don't need the real fs.
    let src = r#"
import "std.io" as io
fn read_then_concat(path :: Str) -> [io] Result[Str, Str] {
  match io.read(path) {
    Ok(s) => Ok(s),
    Err(e) => Err(e),
  }
}
"#;
    // First, do a "real" run that we expect to fail (path doesn't exist).
    let (tree1, r1) = run_with_recorder(src, "read_then_concat",
        vec![Value::Str("/no/such/path".into())], None, allow(&["io"]));
    // io.read returns `Err(...)` from the runtime when the file's missing,
    // so the function returns Err — *value* level. That's still Ok at the
    // VM/result level. Useful: we can read tree1's NodeId for io.read.
    assert!(r1.is_ok(), "io.read returns Err(...) as a value");
    fn find_effect(n: &lex_trace::TraceNode) -> Option<&lex_trace::TraceNode> {
        if matches!(n.kind, TraceNodeKind::Effect) { return Some(n); }
        for c in &n.children {
            if let Some(f) = find_effect(c) { return Some(f); }
        }
        None
    }
    let effect_node = tree1.nodes.iter().find_map(find_effect).expect("effect");
    let effect_node_id = effect_node.node_id.clone();

    // Now replay with an override that injects Ok("REPLACED").
    let mut overrides = IndexMap::new();
    let injected = serde_json::json!({"$variant": "Ok", "args": ["REPLACED"]});
    overrides.insert(effect_node_id.clone(), injected);

    let (tree2, r2) = run_with_recorder(src, "read_then_concat",
        vec![Value::Str("/no/such/path".into())], Some(overrides), allow(&["io"]));
    let v2 = r2.unwrap();
    // Expect Ok("REPLACED") — value-level.
    let expected = Value::Variant { name: "Ok".into(), args: vec![Value::Str("REPLACED".into())] };
    assert_eq!(v2, expected, "override must propagate through pattern match");

    // Diff: the io.read effect node's output differs between tree1 and tree2.
    let div = diff_runs(&tree1, &tree2).expect("traces should diverge");
    assert_eq!(div.node_id, effect_node_id);
}

#[test]
fn diff_returns_first_divergence() {
    let src = r#"
import "std.io" as io
fn pipe(line :: Str) -> [io] Nil { io.print(line) }
"#;
    let (a, _) = run_with_recorder(src, "pipe", vec![Value::Str("one".into())], None, allow(&["io"]));
    let (b, _) = run_with_recorder(src, "pipe", vec![Value::Str("one".into())], None, allow(&["io"]));
    // Two identical runs. Args identical, output identical → no divergence.
    assert!(diff_runs(&a, &b).is_none(), "identical runs should not diverge");

    // Now run with different argument.
    let (c, _) = run_with_recorder(src, "pipe", vec![Value::Str("two".into())], None, allow(&["io"]));
    let div = diff_runs(&a, &c).expect("different args ⇒ divergence");
    // The first diverging node is the io.print (its input args differ → output is Unit in both,
    // but the *node* differs because input differs). Our diff currently catches output
    // mismatches; print returns Unit on both, so input mismatch is reflected only at the
    // top-level call. This still satisfies §10.4 (returns *some* diverging node id).
    assert!(!div.node_id.is_empty());
}