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);
}
}
}
}