chappi-syntax 0.1.0

Canonical S-expression parser and serializer for the Chappi cognitive-state language
Documentation
use chappi_syntax::{Atom, FormKind, canonicalize, parse_document};
use proptest::prelude::*;

#[test]
fn reports_each_malformed_top_level_line_with_its_source_span() {
    let errors = parse_document("(broken \"\\q\")\n)\n(claim good)\n")
        .expect_err("malformed lines must be rejected");

    assert_eq!(errors.len(), 2);
    assert_eq!(errors[0].code, "invalid-escape");
    assert_eq!(errors[0].span.start_line, 1);
    assert_eq!(errors[1].code, "unexpected-close-paren");
    assert_eq!(errors[1].span.start_line, 2);
}

#[test]
fn raw_string_line_break_does_not_discard_the_next_top_level_line() {
    let errors = parse_document("(broken \"line one\n)\n")
        .expect_err("raw line breaks in strings must be rejected");

    assert_eq!(errors.len(), 2);
    assert_eq!(errors[0].code, "unclosed-string");
    assert_eq!(errors[0].span.start_line, 1);
    assert_eq!(errors[1].code, "unexpected-close-paren");
    assert_eq!(errors[1].span.start_line, 2);
}

#[test]
fn unterminated_unicode_escape_does_not_discard_the_next_top_level_line() {
    let errors = parse_document("(broken \"\\u{1\n)\n")
        .expect_err("unterminated Unicode escapes must be rejected");

    assert_eq!(errors.len(), 2);
    assert_eq!(errors[0].code, "invalid-unicode-escape");
    assert_eq!(errors[0].span.start_line, 1);
    assert_eq!(errors[1].code, "unexpected-close-paren");
    assert_eq!(errors[1].span.start_line, 2);
}

#[test]
fn excessive_nesting_is_rejected_with_a_stable_diagnostic_and_line_recovery() {
    let source = format!("{}x{}\n)\n", "(".repeat(50_000), ")".repeat(50_000));
    let errors = parse_document(&source).expect_err("excessive nesting must be rejected");

    assert_eq!(errors.len(), 2);
    assert_eq!(errors[0].code, "nesting-depth-exceeded");
    assert_eq!(errors[0].severity, chappi_syntax::Severity::Error);
    assert_eq!(errors[0].span.start, 256);
    assert_eq!(errors[0].span.end, 257);
    assert_eq!(errors[0].span.start_line, 1);
    assert_eq!(errors[0].span.start_column, 257);
    assert!(errors[0].semantic_path.is_empty());
    assert_eq!(
        errors[0].message,
        "S-expression nesting exceeds the Chappi v1 limit of 256 lists"
    );
    assert_eq!(
        errors[0].hint.as_deref(),
        Some("Reduce list nesting to at most 256 levels.")
    );
    assert_eq!(errors[1].code, "unexpected-close-paren");
    assert_eq!(errors[1].span.start_line, 2);
}

#[test]
fn nesting_at_the_v1_limit_remains_valid() {
    let source = format!("{}x{}\n", "(".repeat(256), ")".repeat(256));
    let document = parse_document(&source).expect("256 nested lists are valid in Chappi v1");

    assert_eq!(document.forms.len(), 1);
}

#[test]
fn parses_comments_and_all_atom_kinds_with_spans() {
    let document = parse_document("; before\n(item symbol -002 03.40 \"a\\n\\u{301}\") ; after\n")
        .expect("comments and atoms should parse");
    let FormKind::List(items) = &document.forms[0].kind else {
        panic!("top-level form must be a list");
    };

    assert!(matches!(items[1].kind, FormKind::Atom(Atom::Symbol(ref value)) if value == "symbol"));
    assert!(matches!(items[2].kind, FormKind::Atom(Atom::Integer(ref value)) if value == "-002"));
    assert!(matches!(items[3].kind, FormKind::Atom(Atom::Decimal(ref value)) if value == "03.40"));
    assert!(
        matches!(items[4].kind, FormKind::Atom(Atom::String(ref value)) if value == "a\n\u{301}")
    );
    assert_eq!(document.forms[0].span.start_line, 2);
    assert_eq!(document.forms[0].span.start_column, 1);
}

fn escaped_source_string(value: &str) -> String {
    value
        .chars()
        .flat_map(|character| match character {
            '"' => "\\\"".chars().collect::<Vec<_>>(),
            '\\' => "\\\\".chars().collect::<Vec<_>>(),
            '\n' => "\\n".chars().collect::<Vec<_>>(),
            '\r' => "\\r".chars().collect::<Vec<_>>(),
            '\t' => "\\t".chars().collect::<Vec<_>>(),
            _ => vec![character],
        })
        .collect()
}

proptest! {
    #[test]
    fn canonical_output_round_trips_for_valid_generic_forms(
        symbol in "[a-z][a-z0-9_-]{0,8}",
        value in "[A-Za-z0-9 .,!?\\\"\\\\\\n\\t]{0,24}",
    ) {
        let source = format!("(entity {symbol} \"{}\")\n", escaped_source_string(&value));
        let document = parse_document(&source).expect("generated source is valid");
        let canonical = canonicalize(&document);
        let reparsed = parse_document(std::str::from_utf8(&canonical).expect("canonical source is UTF-8"))
            .expect("canonical output is valid source");

        prop_assert_eq!(canonical, canonicalize(&reparsed));
    }
}