chappi-syntax 0.1.0

Canonical S-expression parser and serializer for the Chappi cognitive-state language
Documentation
use chappi_syntax::{
    Atom, Document, Form, FormKind, Span, atom_is_canonical, canonical_hash, canonicalize,
    is_canonical_decimal, is_canonical_integer, is_canonical_symbol, is_canonical_text,
    parse_document,
};

fn test_span() -> Span {
    Span {
        start: 0,
        end: 0,
        start_line: 1,
        start_column: 1,
        end_line: 1,
        end_column: 1,
    }
}

fn form(kind: FormKind) -> Form {
    Form {
        span: test_span(),
        kind,
    }
}

#[test]
fn canonicalization_normalizes_text_numbers_order_and_line_endings() {
    let document = parse_document(
        "(unknown z)\r\n(focus main 10)\r\n(rel z a depends)\r\n(entity z \"e\\u{301}\")\r\n(chappi 0001)\r\n(claim a 01.2300)\r\n(rel a z depends)\r\n(focus main 2)\r\n",
    )
    .expect("non-canonical source remains parseable");

    let canonical = String::from_utf8(canonicalize(&document)).expect("canonical bytes are UTF-8");

    assert_eq!(
        canonical,
        "(chappi 1)\n(claim a 1.23)\n(entity z \"é\")\n(rel a z depends)\n(rel z a depends)\n(focus main 2)\n(focus main 10)\n(unknown z)\n"
    );
}

#[test]
fn canonicalization_uses_unambiguous_string_quoting() {
    let document = parse_document("(entity n \"quote: \\\" slash: \\\\ tab: \\t newline: \\n\")\n")
        .expect("escaped string should parse");

    let canonical = String::from_utf8(canonicalize(&document)).expect("canonical bytes are UTF-8");

    assert_eq!(
        canonical,
        "(entity n \"quote: \\\" slash: \\\\ tab: \\t newline: \\n\")\n"
    );
}

#[test]
fn canonical_hash_is_sha256_of_canonical_bytes() {
    let document = parse_document("(chappi 1)\n").expect("header should parse");

    assert_eq!(
        canonical_hash(&document),
        [
            0x41, 0xc6, 0x9b, 0xec, 0xeb, 0x48, 0x58, 0x39, 0x28, 0xf3, 0x2a, 0x11, 0x37, 0xa0,
            0x5d, 0xd3, 0x72, 0x3b, 0xd2, 0xbc, 0x0b, 0xd3, 0x81, 0x79, 0xb7, 0x1a, 0xae, 0x86,
            0xaa, 0x85, 0x44, 0xae,
        ]
    );
}

#[test]
fn canonicalization_is_total_for_hostile_public_forms() {
    let document = Document {
        forms: vec![
            form(FormKind::Atom(Atom::Symbol("bad\nsymbol".into()))),
            form(FormKind::Atom(Atom::Decimal("not-a-decimal".into()))),
            form(FormKind::List(vec![
                form(FormKind::Atom(Atom::Symbol("entity".into()))),
                form(FormKind::Atom(Atom::Symbol("line\nid".into()))),
                form(FormKind::Atom(Atom::Decimal("1.\n".into()))),
            ])),
        ],
    };

    let bytes = std::panic::catch_unwind(|| canonicalize(&document))
        .expect("canonicalization must not panic for public syntax values");
    let source = std::str::from_utf8(&bytes).expect("canonical output must be UTF-8");
    let reparsed = parse_document(source).expect("canonical output must remain parseable");

    assert_eq!(reparsed.forms.len(), 3);
    assert_eq!(source.lines().count(), reparsed.forms.len());
    assert!(source.lines().all(|line| !line.contains('\r')));
}

#[test]
fn revisions_sort_by_node_id_after_nodes_and_before_relations() {
    let document = parse_document(
        "(rel about z a)\n(revision z 2)\n(entity z z persistent 1 person (z))\n(revision a 4)\n(chappi 1)\n(claim a z working 1 likely (a))\n",
    )
    .expect("fixture should parse");

    assert_eq!(
        String::from_utf8(canonicalize(&document)).expect("canonical source is UTF-8"),
        "(chappi 1)\n(claim a z working 1 likely (a))\n(entity z z persistent 1 person (z))\n(revision a 4)\n(revision z 2)\n(rel about z a)\n"
    );
}

#[test]
fn patch_canonicalization_keeps_the_header_first_and_operation_order_stable() {
    let document = parse_document(
        "(> (focus work 0 self evoked none (fg n) (bg) (residue) (trigger start)))\n(chappi-patch 0001 \"0000000000000000000000000000000000000000000000000000000000000000\")\n(+ (claim n self working 050 likely (new)))\n",
    )
    .expect("fixture should parse");

    assert_eq!(
        String::from_utf8(canonicalize(&document)).expect("canonical source is UTF-8"),
        "(chappi-patch 1 \"0000000000000000000000000000000000000000000000000000000000000000\")\n(> (focus work 0 self evoked none (fg n) (bg) (residue) (trigger start)))\n(+ (claim n self working 50 likely (new)))\n"
    );
}

#[test]
fn public_canonical_atom_predicates_define_the_round_trip_domain() {
    assert!(is_canonical_text("é"));
    assert!(!is_canonical_text("e\u{301}"));

    for value in ["self", "two-words", "é"] {
        assert!(is_canonical_symbol(value), "{value}");
    }
    for value in ["", "123", "1.5", "two words", "e\u{301}"] {
        assert!(!is_canonical_symbol(value), "{value}");
    }

    for value in ["0", "12", "-12"] {
        assert!(is_canonical_integer(value), "{value}");
    }
    for value in ["00", "-0", "01", "+1", "one"] {
        assert!(!is_canonical_integer(value), "{value}");
    }

    for value in ["0.5", "12.25", "-12.25"] {
        assert!(is_canonical_decimal(value), "{value}");
    }
    for value in ["00.5", "1.0", "1.50", ".5", "one"] {
        assert!(!is_canonical_decimal(value), "{value}");
    }

    assert!(atom_is_canonical(&Atom::Symbol("safe".into())));
    assert!(atom_is_canonical(&Atom::String("é".into())));
    assert!(atom_is_canonical(&Atom::Integer("12".into())));
    assert!(atom_is_canonical(&Atom::Decimal("1.5".into())));
    assert!(!atom_is_canonical(&Atom::Symbol("123".into())));
    assert!(!atom_is_canonical(&Atom::String("e\u{301}".into())));
    assert!(!atom_is_canonical(&Atom::Integer("012".into())));
    assert!(!atom_is_canonical(&Atom::Decimal("1.50".into())));
}

#[test]
fn canonicalization_iterates_over_programmatically_constructed_deep_forms() {
    let mut nested = form(FormKind::Atom(Atom::Symbol("x".into())));
    for _ in 0..50_000 {
        nested = form(FormKind::List(vec![nested]));
    }
    let document = IterativelyDroppedDocument(Some(Document {
        forms: vec![nested],
    }));

    let canonical = canonicalize(document.as_ref());

    assert_eq!(canonical.len(), 100_002);
    assert!(canonical.starts_with(&vec![b'('; 50_000]));
    assert_eq!(canonical[50_000], b'x');
    assert!(canonical[50_001..100_001].iter().all(|byte| *byte == b')'));
    assert_eq!(canonical.last(), Some(&b'\n'));
}

struct IterativelyDroppedDocument(Option<Document>);

impl AsRef<Document> for IterativelyDroppedDocument {
    fn as_ref(&self) -> &Document {
        self.0.as_ref().expect("deep fixture is present")
    }
}

impl Drop for IterativelyDroppedDocument {
    fn drop(&mut self) {
        let mut pending = self.0.take().expect("deep fixture is present").forms;
        while let Some(mut form) = pending.pop() {
            if let FormKind::List(children) =
                std::mem::replace(&mut form.kind, FormKind::List(Vec::new()))
            {
                pending.extend(children);
            }
        }
    }
}