Skip to main content

chappi_syntax/
canonical.rs

1use std::cmp::Ordering;
2
3use sha2::{Digest, Sha256};
4use unicode_normalization::UnicodeNormalization;
5
6use crate::{Atom, Document, Form, FormKind};
7
8/// Serializes a document as deterministic, UTF-8 canonical S-expressions.
9pub fn canonicalize(document: &Document) -> Vec<u8> {
10    let mut forms = document.forms.iter().collect::<Vec<_>>();
11    if forms
12        .iter()
13        .any(|form| head_symbol(form) == Some("chappi-patch"))
14    {
15        forms.sort_by_key(|form| u8::from(head_symbol(form) != Some("chappi-patch")));
16    } else {
17        forms.sort_by(|left, right| compare_forms(left, right));
18    }
19
20    let mut output = String::new();
21    for form in forms {
22        write_form(&mut output, form);
23        output.push('\n');
24    }
25    output.into_bytes()
26}
27
28/// Returns the SHA-256 digest of [`canonicalize`]'s bytes.
29pub fn canonical_hash(document: &Document) -> [u8; 32] {
30    Sha256::digest(canonicalize(document)).into()
31}
32
33/// Returns whether text is already in the NFC form used by canonical output.
34pub fn is_canonical_text(value: &str) -> bool {
35    value.nfc().eq(value.chars())
36}
37
38/// Returns whether a value canonically parses back as the same symbol atom.
39pub fn is_canonical_symbol(value: &str) -> bool {
40    is_canonical_text(value)
41        && is_symbol_literal(value)
42        && !is_integer_literal(value)
43        && !is_decimal_literal(value)
44}
45
46/// Returns whether an integer literal already has its canonical spelling.
47pub fn is_canonical_integer(value: &str) -> bool {
48    is_integer_literal(value) && normalize_integer(value) == value
49}
50
51/// Returns whether a decimal literal already has its canonical spelling.
52pub fn is_canonical_decimal(value: &str) -> bool {
53    is_decimal_literal(value) && normalize_decimal(value) == value
54}
55
56/// Returns whether canonical serialization and reparsing preserve this atom exactly.
57pub fn atom_is_canonical(atom: &Atom) -> bool {
58    match atom {
59        Atom::Symbol(value) => is_canonical_symbol(value),
60        Atom::String(value) => is_canonical_text(value),
61        Atom::Integer(value) => is_canonical_integer(value),
62        Atom::Decimal(value) => is_canonical_decimal(value),
63    }
64}
65
66fn compare_forms(left: &Form, right: &Form) -> Ordering {
67    let rank = form_rank(left).cmp(&form_rank(right));
68    if rank != Ordering::Equal {
69        return rank;
70    }
71
72    let specific = match form_rank(left) {
73        1 => canonical_child(left, 1).cmp(&canonical_child(right, 1)),
74        2 => canonical_child(left, 1).cmp(&canonical_child(right, 1)),
75        3 => canonical_form(left).cmp(&canonical_form(right)),
76        4 => compare_focus(left, right),
77        _ => Ordering::Equal,
78    };
79    if specific == Ordering::Equal {
80        canonical_form(left).cmp(&canonical_form(right))
81    } else {
82        specific
83    }
84}
85
86fn form_rank(form: &Form) -> u8 {
87    match head_symbol(form) {
88        Some("chappi") => 0,
89        Some(head) if is_node_head(head) => 1,
90        Some("revision") => 2,
91        Some("rel") => 3,
92        Some("focus") => 4,
93        _ => 5,
94    }
95}
96
97fn is_node_head(head: &str) -> bool {
98    matches!(
99        head,
100        "entity"
101            | "claim"
102            | "evidence"
103            | "goal"
104            | "constraint"
105            | "question"
106            | "relationship"
107            | "desire"
108            | "attention"
109            | "association"
110            | "tension"
111            | "prediction"
112            | "perspective"
113            | "affect"
114            | "metacognition"
115    )
116}
117
118fn head_symbol(form: &Form) -> Option<&str> {
119    let FormKind::List(items) = &form.kind else {
120        return None;
121    };
122    let Some(Form {
123        kind: FormKind::Atom(Atom::Symbol(symbol)),
124        ..
125    }) = items.first()
126    else {
127        return None;
128    };
129    Some(symbol)
130}
131
132fn canonical_child(form: &Form, index: usize) -> Vec<u8> {
133    match &form.kind {
134        FormKind::List(items) => items.get(index).map(canonical_form).unwrap_or_default(),
135        FormKind::Atom(_) => Vec::new(),
136    }
137}
138
139fn compare_focus(left: &Form, right: &Form) -> Ordering {
140    let stream = canonical_child(left, 1).cmp(&canonical_child(right, 1));
141    if stream != Ordering::Equal {
142        return stream;
143    }
144    let sequence = focus_sequence(left);
145    let other_sequence = focus_sequence(right);
146    compare_integer_literals(sequence.as_deref(), other_sequence.as_deref())
147}
148
149fn focus_sequence(form: &Form) -> Option<String> {
150    let FormKind::List(items) = &form.kind else {
151        return None;
152    };
153    match items.get(2).map(|form| &form.kind) {
154        Some(FormKind::Atom(Atom::Integer(value))) => Some(normalize_integer(value)),
155        _ => None,
156    }
157}
158
159fn compare_integer_literals(left: Option<&str>, right: Option<&str>) -> Ordering {
160    match (left, right) {
161        (Some(left), Some(right)) => compare_normalized_integers(left, right),
162        (Some(_), None) => Ordering::Less,
163        (None, Some(_)) => Ordering::Greater,
164        (None, None) => Ordering::Equal,
165    }
166}
167
168fn compare_normalized_integers(left: &str, right: &str) -> Ordering {
169    let left_negative = left.starts_with('-');
170    let right_negative = right.starts_with('-');
171    match (left_negative, right_negative) {
172        (true, false) => Ordering::Less,
173        (false, true) => Ordering::Greater,
174        _ => {
175            let left_digits = left.strip_prefix('-').unwrap_or(left);
176            let right_digits = right.strip_prefix('-').unwrap_or(right);
177            let order = left_digits
178                .len()
179                .cmp(&right_digits.len())
180                .then_with(|| left_digits.cmp(right_digits));
181            if left_negative {
182                order.reverse()
183            } else {
184                order
185            }
186        }
187    }
188}
189
190fn canonical_form(form: &Form) -> Vec<u8> {
191    let mut output = String::new();
192    write_form(&mut output, form);
193    output.into_bytes()
194}
195
196fn write_form(output: &mut String, form: &Form) {
197    enum WriteItem<'a> {
198        Form(&'a Form),
199        Space,
200        CloseList,
201    }
202
203    let mut pending = vec![WriteItem::Form(form)];
204    while let Some(item) = pending.pop() {
205        match item {
206            WriteItem::Form(form) => match &form.kind {
207                FormKind::Atom(atom) => write_atom(output, atom),
208                FormKind::List(items) => {
209                    output.push('(');
210                    pending.push(WriteItem::CloseList);
211                    for index in (0..items.len()).rev() {
212                        pending.push(WriteItem::Form(&items[index]));
213                        if index > 0 {
214                            pending.push(WriteItem::Space);
215                        }
216                    }
217                }
218            },
219            WriteItem::Space => output.push(' '),
220            WriteItem::CloseList => output.push(')'),
221        }
222    }
223}
224
225fn write_atom(output: &mut String, atom: &Atom) {
226    match atom {
227        Atom::Symbol(value) => write_symbol(output, value),
228        Atom::Integer(value) if is_integer_literal(value) => {
229            output.push_str(&normalize_integer(value));
230        }
231        Atom::Decimal(value) if is_decimal_literal(value) => {
232            output.push_str(&normalize_decimal(value));
233        }
234        Atom::Integer(value) | Atom::Decimal(value) => write_string(output, value),
235        Atom::String(value) => write_string(output, value),
236    }
237}
238
239fn write_symbol(output: &mut String, value: &str) {
240    let value = value.nfc().collect::<String>();
241    if is_symbol_literal(&value) {
242        output.push_str(&value);
243    } else {
244        write_string(output, &value);
245    }
246}
247
248fn is_symbol_literal(value: &str) -> bool {
249    !value.is_empty()
250        && value.chars().all(|character| {
251            !character.is_control() && !matches!(character, ' ' | '\t' | '(' | ')' | '"' | ';')
252        })
253}
254
255fn is_integer_literal(value: &str) -> bool {
256    let digits = value.strip_prefix('-').unwrap_or(value);
257    !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
258}
259
260fn is_decimal_literal(value: &str) -> bool {
261    let unsigned = value.strip_prefix('-').unwrap_or(value);
262    let Some((whole, fraction)) = unsigned.split_once('.') else {
263        return false;
264    };
265    !whole.is_empty()
266        && !fraction.is_empty()
267        && !fraction.contains('.')
268        && whole.bytes().all(|byte| byte.is_ascii_digit())
269        && fraction.bytes().all(|byte| byte.is_ascii_digit())
270}
271
272fn normalize_integer(value: &str) -> String {
273    let (negative, digits) = match value.strip_prefix('-') {
274        Some(digits) => (true, digits),
275        None => (false, value),
276    };
277    let digits = digits.trim_start_matches('0');
278    if digits.is_empty() {
279        "0".to_owned()
280    } else if negative {
281        format!("-{digits}")
282    } else {
283        digits.to_owned()
284    }
285}
286
287fn normalize_decimal(value: &str) -> String {
288    let (negative, unsigned) = match value.strip_prefix('-') {
289        Some(unsigned) => (true, unsigned),
290        None => (false, value),
291    };
292    let Some((whole, fraction)) = unsigned.split_once('.') else {
293        return value.to_owned();
294    };
295    let whole = whole.trim_start_matches('0');
296    let whole = if whole.is_empty() { "0" } else { whole };
297    let fraction = fraction.trim_end_matches('0');
298    if fraction.is_empty() {
299        return if negative && whole != "0" {
300            format!("-{whole}")
301        } else {
302            whole.to_owned()
303        };
304    }
305    let prefix = if negative { "-" } else { "" };
306    format!("{prefix}{whole}.{fraction}")
307}
308
309fn write_string(output: &mut String, value: &str) {
310    output.push('"');
311    for character in value.nfc() {
312        match character {
313            '"' => output.push_str("\\\""),
314            '\\' => output.push_str("\\\\"),
315            '\n' => output.push_str("\\n"),
316            '\r' => output.push_str("\\r"),
317            '\t' => output.push_str("\\t"),
318            character if character.is_control() => {
319                use std::fmt::Write as _;
320                write!(output, "\\u{{{:x}}}", character as u32)
321                    .expect("writing to String cannot fail");
322            }
323            character => output.push(character),
324        }
325    }
326    output.push('"');
327}