lambda-cat 0.1.0

Untyped lambda calculus interpreter built on comp-cat-rs. Lex, parse, and tree-walk evaluation expressed as Io effects with static dispatch and no panics.
Documentation
//! End-to-end interpreter tests.
//!
//! Each test returns `Result<(), TestFailure>`.  Library errors propagate via
//! `?` from the `From<Error>` impl; assertion failures construct a
//! [`TestFailure::Assertion`] variant so the cargo-test reporter
//! prints actionable diagnostics without panics.

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

/// A test-local error type so tests never need to panic to report failure.
#[derive(Debug)]
enum TestFailure {
    /// A library-side error reached the test boundary.
    Interpreter(Error),
    /// A test assertion did not hold.
    Assertion {
        /// Name of the property being checked.
        what: &'static str,
        /// What the test computed.
        actual: String,
        /// What the test expected.
        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 identity_via_let() -> Result<(), TestFailure> {
    let value = run(r"let id = \x. x in id").run()?;
    check_displays(&value, "\\x. x", "identity binding")
}

#[test]
fn k_combinator_first() -> Result<(), TestFailure> {
    let source = r"
        let id  = \x. x in
        let one = \x. x x in
        (\x. \y. x) id one
    ";
    let value = run(source).run()?;
    check_displays(&value, "\\x. x", "K picks first argument")
}

#[test]
fn k_combinator_second() -> Result<(), TestFailure> {
    let source = r"
        let id  = \x. x in
        let two = \z. z in
        (\x. \y. y) id two
    ";
    let value = run(source).run()?;
    check_displays(&value, "\\z. z", "K-second picks second argument")
}

#[test]
fn lexical_scoping_shadowing() -> Result<(), TestFailure> {
    let source = r"
        let x = \a. a in
        let y = \b. b in
        (\x. x) y
    ";
    let value = run(source).run()?;
    check_displays(&value, "\\b. b", "inner binding shadows outer")
}

#[test]
fn nested_lambda_capture() -> Result<(), TestFailure> {
    let source = r"
        let outer = \x. \y. x in
        let inner_a = \u. u in
        let inner_b = \v. v in
        outer inner_a inner_b
    ";
    let value = run(source).run()?;
    check_displays(&value, "\\u. u", "outer binding survives inner argument")
}

#[test]
fn church_true_picks_first() -> Result<(), TestFailure> {
    let source = r"
        let tru = \t. \f. t in
        let yes = \a. a in
        let no  = \b. b in
        tru yes no
    ";
    let value = run(source).run()?;
    check_displays(&value, "\\a. a", "Church true selects then-branch")
}

#[test]
fn church_false_picks_second() -> Result<(), TestFailure> {
    let source = r"
        let fls = \t. \f. f in
        let yes = \a. a in
        let no  = \b. b in
        fls yes no
    ";
    let value = run(source).run()?;
    check_displays(&value, "\\b. b", "Church false selects else-branch")
}

#[test]
fn fix_without_recursion_terminates() -> Result<(), TestFailure> {
    let source = r"(fix f. \n. n) (\x. x)";
    let value = run(source).run()?;
    check_displays(
        &value,
        "\\x. x",
        "fix whose body ignores f reduces normally",
    )
}

#[test]
fn fix_with_let_inside() -> Result<(), TestFailure> {
    let source = r"
        let const = fix self. \x. \y. x in
        const (\u. u) (\v. v)
    ";
    let value = run(source).run()?;
    check_displays(
        &value,
        "\\u. u",
        "fix value behaves as a normal closure when self is unused",
    )
}

#[test]
fn unbound_variable_errors() -> Result<(), TestFailure> {
    // The body of an unapplied lambda is not reduced, so we must force evaluation
    // by applying it.  Once the body is entered, the free `y` surfaces.
    let result = run(r"(\x. y) (\u. u)").run();
    check_error_matches(
        result,
        |e| matches!(e, Error::UnboundVariable { .. }),
        "free variable surfaces as UnboundVariable",
    )
}

#[test]
fn omega_diverges_into_fuel_exhaustion() -> Result<(), TestFailure> {
    let result = run_with_fuel(r"(\x. x x) (\x. x x)", Fuel::new(64)).run();
    check_error_matches(
        result,
        |e| matches!(e, Error::FuelExhausted { .. }),
        "omega combinator exhausts a bounded fuel budget",
    )
}

#[test]
fn parse_error_propagates() -> Result<(), TestFailure> {
    let result = run(r"\x").run();
    check_error_matches(
        result,
        |e| {
            matches!(
                e,
                Error::UnexpectedEnd { .. } | Error::UnexpectedToken { .. }
            )
        },
        "incomplete lambda surfaces as a parse error",
    )
}

#[test]
fn default_fuel_handles_small_programs() -> Result<(), TestFailure> {
    let source = r"
        let id = \x. x in
        let k  = \x. \y. x in
        let s  = \x. \y. \z. x z (y z) in
        s k k (\q. q)
    ";
    let value = run(source).run()?;
    // S K K reduces to identity: (S K K) v = K v (K v) = v
    check_displays(&value, "\\q. q", "SKK identity equivalence")?;
    // Sanity-check that the constant is what callers see.
    (DEFAULT_FUEL == 10_000)
        .then_some(())
        .ok_or(TestFailure::Assertion {
            what: "DEFAULT_FUEL constant",
            actual: DEFAULT_FUEL.to_string(),
            expected: "10000".to_owned(),
        })
}