sim-lib-pattern 0.2.0

Shape-based pattern matching and destructuring for SIM runtime values.
Documentation
use crate::{
    TextLimits, TextMatch, TextOp, compile_glob_pattern, compile_lua_pattern, run_text_pattern,
};
use std::sync::Arc;

use sim_kernel::{Cx, Datum, DefaultFactory, NoopEvalPolicy, Ref, Symbol};
use sim_lib_standard_core::{
    BoundedLane, CanonicalObservation, CanonicalOutcome, CharacterizationCapture, ScenarioLimits,
    ScenarioObservationLane, ScenarioSpec, publish_characterization_capture,
};

fn case(name: &str, fields: &[(&str, &str)]) -> Datum {
    Datum::Node {
        tag: Symbol::qualified("pattern-characterization", "case/v1"),
        fields: std::iter::once((Symbol::new("name"), Datum::String(name.to_owned())))
            .chain(
                fields
                    .iter()
                    .map(|(key, value)| (Symbol::new(*key), Datum::String((*value).to_owned()))),
            )
            .collect(),
    }
}

fn capture(name: &str, cases: Vec<Datum>) -> (ScenarioSpec, CharacterizationCapture) {
    let scenario = ScenarioSpec::new(
        Symbol::qualified("pattern-characterization", name),
        Symbol::qualified("pattern-characterization", "current/v1"),
    )
    .with_limits(ScenarioLimits::new(0, cases.len()))
    .observing(ScenarioObservationLane::ValueOrFailure);
    let capture = CharacterizationCapture::new(
        Symbol::qualified("pattern-characterization", "dialect-cases/v1"),
        CanonicalObservation {
            outcome: Some(CanonicalOutcome::Success(Datum::Vector(cases))),
            events: BoundedLane::Absent,
            receipts: BoundedLane::Absent,
            browse: BoundedLane::Absent,
        },
    );
    (scenario, capture)
}

fn test_cx() -> Cx {
    Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory))
}

fn assert_stable_capture(name: &str, cases: Vec<Datum>) {
    let (scenario, capture) = capture(name, cases);
    let first = publish_characterization_capture(&mut test_cx(), &scenario, &capture).unwrap();
    let replay = publish_characterization_capture(&mut test_cx(), &scenario, &capture).unwrap();
    assert!(matches!(first, Ref::Content(_)));
    assert_eq!(first, replay);
}

fn text_match(ops: &[TextOp], subject: &str) -> Option<TextMatch> {
    run_text_pattern(
        ops,
        subject,
        0,
        TextLimits {
            max_steps: 20_000,
            ..TextLimits::default()
        },
    )
}

fn text_span(ops: &[TextOp], subject: &str) -> Option<(usize, usize)> {
    text_match(ops, subject).map(|matched| (matched.start, matched.end))
}

#[test]
fn lua_dialect_runs_over_shared_text_vm_table() {
    let cases = [
        ("abc", "abc", Some((0, 3))),
        ("abc", "xxabczz", Some((2, 5))),
        ("^abc", "abczz", Some((0, 3))),
        ("^abc", "xxabc", None),
        ("abc$", "xxabc", Some((2, 5))),
        ("abc$", "abcxx", None),
        (".", "x", Some((0, 1))),
        ("a.c", "abc", Some((0, 3))),
        ("%a+", "abc123", Some((0, 3))),
        ("%d+", "abc123", Some((3, 6))),
        ("%l+", "abcDEF", Some((0, 3))),
        ("%u+", "abcDEF", Some((3, 6))),
        ("%w+", "ab12!!", Some((0, 4))),
        ("%s+", "xx \t yy", Some((2, 5))),
        ("%p+", "abc!?z", Some((3, 5))),
        ("%x+", "g1afz", Some((1, 4))),
        ("%A+", "123abc", Some((0, 3))),
        ("%D+", "abc123", Some((0, 3))),
        ("%L+", "ABCabc", Some((0, 3))),
        ("%U+", "abcABC", Some((0, 3))),
        ("%W+", "!!abc", Some((0, 2))),
        ("%S+", "ab cd", Some((0, 2))),
        ("%P+", "ab!cd", Some((0, 2))),
        ("%X+", "zzaf", Some((0, 2))),
        ("[abc]+", "zzcab", Some((2, 5))),
        ("[^abc]+", "abc123", Some((3, 6))),
        ("[a-c]+", "xxabc", Some((2, 5))),
        ("[%d]+", "aa123", Some((2, 5))),
        ("a*", "aaab", Some((0, 3))),
        ("a+", "baaac", Some((1, 4))),
        ("a?b", "ab", Some((0, 2))),
        ("a?b", "b", Some((0, 1))),
        ("a-b", "aaab", Some((0, 4))),
        ("a-", "aaa", Some((0, 0))),
        ("%b()", "x(a(b)c)y", Some((1, 8))),
        ("%f[%a]cat", "1cat", Some((1, 4))),
        ("%f[%d]%d+", "ab123", Some((2, 5))),
        ("%f[^%a]123", "abc123", Some((3, 6))),
        ("%%", "a%b", Some((1, 2))),
        ("%.", "a.b", Some((1, 2))),
        ("%z", "a\0b", Some((1, 2))),
        ("()abc()", "abc", Some((0, 3))),
        ("(a+)", "aa", Some((0, 2))),
        ("a^b", "a^b", Some((0, 3))),
        ("$x", "$x", Some((0, 2))),
        ("^$", "", Some((0, 0))),
        ("%a*%d", "abc1", Some((0, 4))),
        ("%a-%d", "abc1", Some((0, 4))),
        ("colou?r", "color", Some((0, 5))),
        ("colou?r", "colour", Some((0, 6))),
    ];

    assert!(cases.len() >= 40);
    for (pattern, subject, expected) in cases {
        let ops = compile_lua_pattern(pattern).unwrap();
        assert_eq!(
            text_span(&ops, subject),
            expected,
            "pattern {pattern:?} subject {subject:?}"
        );
    }
}

#[test]
fn lua_dialect_preserves_captures_and_budget_limits() {
    let ops = compile_lua_pattern("(%a+)%s+(%d+)").unwrap();
    let matched = text_match(&ops, "id 42").unwrap();
    assert_eq!((matched.start, matched.end), (0, 5));
    assert_eq!(matched.captures, vec![(0, 2), (3, 5)]);

    let empty = compile_lua_pattern("()abc()").unwrap();
    let matched = text_match(&empty, "abc").unwrap();
    assert_eq!(matched.captures, vec![(0, 0), (3, 3)]);

    let bounded = compile_lua_pattern("a*b").unwrap();
    assert!(
        run_text_pattern(
            &bounded,
            "aaab",
            0,
            TextLimits {
                max_steps: 1,
                ..TextLimits::default()
            }
        )
        .is_none()
    );
    assert_eq!(text_span(&bounded, "aaab"), Some((0, 4)));
}

#[test]
fn glob_dialect_reuses_the_same_text_vm() {
    let cases = [
        ("*.rs", "lib.rs", true),
        ("*.rs", "lib.py", false),
        ("src/?ain.rs", "src/main.rs", true),
        ("src/?ain.rs", "src/plain.rs", false),
        ("file[0-9].txt", "file7.txt", true),
        ("file[!0-9].txt", "filex.txt", true),
        ("file[!0-9].txt", "file7.txt", false),
        ("literal\\*.txt", "literal*.txt", true),
        ("a[bc]d", "acd", true),
        ("a[bc]d", "aed", false),
    ];

    for (pattern, subject, expected) in cases {
        let ops = compile_glob_pattern(pattern).unwrap();
        assert_eq!(
            text_match(&ops, subject).is_some(),
            expected,
            "glob {pattern:?} subject {subject:?}"
        );
    }
}

#[test]
fn text_pattern_dialects_fail_closed_on_malformed_patterns() {
    assert!(compile_lua_pattern("*").is_err());
    assert!(compile_lua_pattern("[abc").is_err());
    assert!(compile_lua_pattern("%").is_err());
    assert!(compile_glob_pattern("[abc").is_err());
}

#[test]
fn lua_current_behavior_is_a_stable_characterization_capture() {
    let unicode = compile_lua_pattern("(\u{1f600}+)").unwrap();
    let unicode_match = text_match(&unicode, "x\u{1f600}\u{1f600}y").unwrap();
    let greedy = text_span(&compile_lua_pattern("a*a").unwrap(), "aaa");
    let lazy = text_span(&compile_lua_pattern("a-a").unwrap(), "aaa");
    let empty = text_span(&compile_lua_pattern("a-").unwrap(), "aaa");
    let bounded = run_text_pattern(
        &compile_lua_pattern("a*b").unwrap(),
        "aaab",
        0,
        TextLimits {
            max_steps: 1,
            ..TextLimits::default()
        },
    );
    let refusals = [
        ("quantifier-without-atom", "*"),
        ("unterminated-character-set", "[abc"),
        ("dangling-percent-escape", "%"),
    ];
    let mut cases = vec![
        case(
            "unicode-byte-offsets",
            &[
                (
                    "span",
                    &format!("{}..{}", unicode_match.start, unicode_match.end),
                ),
                (
                    "capture",
                    &format!(
                        "{}..{}",
                        unicode_match.captures[0].0, unicode_match.captures[0].1
                    ),
                ),
            ],
        ),
        case("greedy-repetition", &[("span", &format!("{greedy:?}"))]),
        case("lazy-repetition", &[("span", &format!("{lazy:?}"))]),
        case("empty-match", &[("span", &format!("{empty:?}"))]),
        case(
            "limit-exhaustion",
            &[
                (
                    "outcome",
                    if bounded.is_none() {
                        "refused"
                    } else {
                        "matched"
                    },
                ),
                ("clause", "maximum VM steps"),
            ],
        ),
    ];
    for (clause, pattern) in refusals {
        let diagnostic = compile_lua_pattern(pattern).unwrap_err().to_string();
        assert!(
            diagnostic.contains(clause.replace('-', " ").as_str())
                || diagnostic.contains("quantifier without atom")
        );
        assert!(!diagnostic.contains("PATTERN"));
        cases.push(case(
            "malformed-program",
            &[("clause", clause), ("diagnostic", &diagnostic)],
        ));
    }
    assert_stable_capture("lua/v1", cases);
}

#[test]
fn glob_current_behavior_is_a_stable_characterization_capture() {
    let unicode = text_span(
        &compile_glob_pattern("?\u{1f600}*").unwrap(),
        "\u{e5}\u{1f600}x",
    );
    let empty = text_span(&compile_glob_pattern("*").unwrap(), "");
    let rejected = text_span(&compile_glob_pattern("*.rs").unwrap(), "lib.py");
    let limited = run_text_pattern(
        &compile_glob_pattern("*x").unwrap(),
        "abcx",
        0,
        TextLimits {
            max_steps: 1,
            ..TextLimits::default()
        },
    );
    let diagnostics = [
        (
            "unterminated-character-set",
            "unterminated glob character set",
            compile_glob_pattern("[abc").unwrap_err().to_string(),
        ),
        (
            "dangling-escape",
            "dangling escape",
            compile_glob_pattern("\\").unwrap_err().to_string(),
        ),
    ];
    let mut cases = vec![
        case("unicode-byte-offsets", &[("span", &format!("{unicode:?}"))]),
        case("empty-match", &[("span", &format!("{empty:?}"))]),
        case(
            "ordinary-rejection",
            &[(
                "outcome",
                if rejected.is_none() {
                    "no-match"
                } else {
                    "matched"
                },
            )],
        ),
        case(
            "limit-exhaustion",
            &[
                (
                    "outcome",
                    if limited.is_none() {
                        "refused"
                    } else {
                        "matched"
                    },
                ),
                ("clause", "maximum VM steps"),
            ],
        ),
    ];
    for (clause, expected_detail, diagnostic) in diagnostics {
        assert!(diagnostic.contains(expected_detail));
        assert!(!diagnostic.contains("PATTERN"));
        cases.push(case(
            "malformed-program",
            &[("clause", clause), ("diagnostic", &diagnostic)],
        ));
    }
    assert_stable_capture("glob/v1", cases);
}