lambda-throw-cat 0.1.0

Lambda calculus with records, prototype chains, ref cells, GC, and non-local control flow via throw/try/catch. Outcome::Normal/Thrown is threaded purely-functionally through every reduction. Spike 4 of a web-engine reformulation targeting Tauri.
Documentation
//! End-to-end tests for throw/try-catch exception flow.

use lambda_throw_cat::error::Error;
use lambda_throw_cat::eval::Fuel;
use lambda_throw_cat::value::Value;
use lambda_throw_cat::{DEFAULT_FUEL, run, run_with_fuel};

#[derive(Debug)]
enum TestFailure {
    Interpreter(Error),
    Assertion {
        what: &'static str,
        actual: String,
        expected: String,
    },
}

impl From<Error> for TestFailure {
    fn from(value: Error) -> Self {
        Self::Interpreter(value)
    }
}

impl std::fmt::Display for TestFailure {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Interpreter(e) => write!(f, "interpreter error: {e}"),
            Self::Assertion {
                what,
                actual,
                expected,
            } => write!(
                f,
                "assertion {what:?} failed: expected {expected:?}, got {actual:?}"
            ),
        }
    }
}

fn check_displays(value: &Value, expected: &str, what: &'static str) -> Result<(), TestFailure> {
    let actual = format!("{value}");
    (actual == expected)
        .then_some(())
        .ok_or(TestFailure::Assertion {
            what,
            actual,
            expected: expected.to_owned(),
        })
}

fn check_error_matches<F: FnOnce(&Error) -> bool>(
    result: Result<Value, Error>,
    predicate: F,
    what: &'static str,
) -> Result<(), TestFailure> {
    match result {
        Err(e) => predicate(&e).then_some(()).ok_or(TestFailure::Assertion {
            what,
            actual: format!("{e}"),
            expected: "matching error variant".to_owned(),
        }),
        Ok(v) => Err(TestFailure::Assertion {
            what,
            actual: format!("{v}"),
            expected: "an error".to_owned(),
        }),
    }
}

#[test]
fn try_with_no_throw_returns_body_value() -> Result<(), TestFailure> {
    let value = run(r"try (\x. x) catch e. e").run()?;
    check_displays(&value, "\\x. x", "try with no throw returns body")
}

#[test]
fn try_catches_immediate_throw() -> Result<(), TestFailure> {
    let value = run(r"try throw (\x. x) catch e. e").run()?;
    check_displays(&value, "\\x. x", "immediate throw caught and rebound")
}

#[test]
fn catch_can_transform_thrown_value() -> Result<(), TestFailure> {
    // The handler ignores e and returns its own value.
    let value = run(r"try throw (\x. x) catch _ignored. (\y. y)").run()?;
    check_displays(&value, "\\y. y", "catch arm yields handler value")
}

#[test]
fn nested_try_inner_catches() -> Result<(), TestFailure> {
    let source = r"
        try
            try throw (\a. a) catch e. e
        catch outer. (\never. never)
    ";
    let value = run(source).run()?;
    // The inner catch handles the throw and yields \a. a; the outer try sees
    // a normal value and propagates it.
    check_displays(&value, "\\a. a", "inner catch handles, outer untouched")
}

#[test]
fn nested_try_outer_catches_inner_handler_throw() -> Result<(), TestFailure> {
    // The inner catch throws again; the outer catches the re-thrown value.
    let source = r"
        try
            try throw (\inner. inner) catch e. throw (\outer. outer)
        catch o. o
    ";
    let value = run(source).run()?;
    check_displays(
        &value,
        "\\outer. outer",
        "outer catch handles inner-handler throw",
    )
}

#[test]
fn throw_inside_lambda_body_propagates_at_call_site() -> Result<(), TestFailure> {
    let source = r"
        let raiser = \dummy. throw (\thrown. thrown) in
        try raiser (\unused. unused) catch e. e
    ";
    let value = run(source).run()?;
    check_displays(
        &value,
        "\\thrown. thrown",
        "throw inside lambda body propagates",
    )
}

#[test]
fn throw_inside_seq_skips_remainder() -> Result<(), TestFailure> {
    // `throw v ; never_runs` evaluates throw, propagating before the next stmt.
    let source = r"
        try
            throw (\thrown. thrown) ; (\never. never)
        catch e. e
    ";
    let value = run(source).run()?;
    check_displays(
        &value,
        "\\thrown. thrown",
        "throw short-circuits the rest of the sequence",
    )
}

#[test]
fn heap_mutation_visible_after_catch() -> Result<(), TestFailure> {
    // Allocate a cell, store something, throw, then catch and read the cell.
    // The mutation made before the throw must remain visible in the handler.
    let source = r"
        let r = ref (\original. original) in
        try
            r := (\after_store. after_store) ;
            throw (\thrown. thrown)
        catch _ignored. !r
    ";
    let value = run(source).run()?;
    check_displays(
        &value,
        "\\after_store. after_store",
        "pre-throw mutation visible in catch",
    )
}

#[test]
fn uncaught_throw_at_top_surfaces_as_uncaught_exception() -> Result<(), TestFailure> {
    let result = run(r"throw (\x. x)").run();
    check_error_matches(
        result,
        |e| matches!(e, Error::UncaughtException { .. }),
        "uncaught throw becomes UncaughtException at the top",
    )
}

#[test]
fn other_errors_are_not_caught() -> Result<(), TestFailure> {
    // An unbound variable is an Error::UnboundVariable, not Error::Thrown, so
    // try/catch does not catch it.
    let result = run(r"try unbound_name catch e. e").run();
    check_error_matches(
        result,
        |e| matches!(e, Error::UnboundVariable { .. }),
        "try-catch does not swallow non-throw errors",
    )
}

#[test]
fn omega_still_exhausts_fuel_inside_try() -> Result<(), TestFailure> {
    let result = run_with_fuel(r"try (\x. x x) (\x. x x) catch e. e", Fuel::new(64)).run();
    check_error_matches(
        result,
        |e| matches!(e, Error::FuelExhausted { .. }),
        "fuel exhaustion is not catchable",
    )
}

#[test]
fn catch_can_rethrow_to_outer_catch() -> Result<(), TestFailure> {
    // The inner catch re-throws e; the outer catches it.
    let source = r"
        try
            try throw (\original. original) catch e. throw e
        catch caught. caught
    ";
    let value = run(source).run()?;
    check_displays(
        &value,
        "\\original. original",
        "rethrow forwards the same value",
    )
}

#[test]
fn default_fuel_constant_visible() -> Result<(), TestFailure> {
    (DEFAULT_FUEL == 10_000)
        .then_some(())
        .ok_or(TestFailure::Assertion {
            what: "DEFAULT_FUEL constant",
            actual: DEFAULT_FUEL.to_string(),
            expected: "10000".to_owned(),
        })
}