Skip to main content

quillmark_core/
normalize.rs

1//! # Document Normalization
2//!
3//! Post-parse normalization of a [`Document`](crate::document::Document): payload
4//! field names to Unicode NFC (so composed `"café"` and decomposed `"cafe\u{0301}"`
5//! compare equal). YAML field *values* pass through verbatim.
6//!
7//! Card bodies are **not** normalized here: a body is already a normalized
8//! [`Content`](quillmark_content::Content) content, established once at import
9//! (`import::from_markdown` runs `normalize_markdown` (line endings, bidi strip,
10//! HTML-comment fence repair) before parsing). This pass only touches field
11//! names and carries each body through unchanged.
12
13use crate::document::Card;
14use unicode_normalization::UnicodeNormalization;
15
16/// Normalize field name to Unicode NFC, so visually identical keys
17/// (e.g., composed `"café"` vs decomposed `"cafe\u{0301}"`) are treated as equal.
18pub fn normalize_field_name(name: &str) -> String {
19    name.nfc().collect()
20}
21
22/// Normalize a [`crate::document::Document`] after parsing: per card, payload
23/// field names → Unicode NFC.
24///
25/// Card bodies are already-normalized content (import-time); they carry through
26/// unchanged. YAML field *values* pass through verbatim. Idempotent.
27pub fn normalize_document(
28    doc: crate::document::Document,
29) -> Result<crate::document::Document, crate::error::ParseError> {
30    use crate::document::Document;
31
32    let main = normalize_card(doc.main());
33    let normalized_cards: Vec<Card> = doc.cards().iter().map(normalize_card).collect();
34
35    Ok(Document::from_main_and_cards(main, normalized_cards))
36}
37
38/// Build a new `Card` with NFC-normalized field names, carrying the (already
39/// normalized) body content through unchanged.
40fn normalize_card(card: &Card) -> Card {
41    use crate::document::PayloadItem;
42    let mut payload = card.payload().clone();
43    for item in payload.items_mut() {
44        if let PayloadItem::Field { key, .. } = item {
45            let normalized = normalize_field_name(key);
46            if normalized != *key {
47                *key = normalized;
48            }
49        }
50    }
51    Card::from_parts(payload, card.body().clone())
52}
53
54#[cfg(test)]
55mod tests {
56
57    #[test]
58    fn test_normalize_document_basic() {
59        use crate::document::Document;
60
61        let doc = Document::parse(
62            "~~~card-yaml\n$quill: test\n$kind: main\ntitle: <<placeholder>>\n~~~\n\n<<content>> \u{202D}**bold**",
63        )
64        .unwrap()
65        .document;
66        let normalized = super::normalize_document(doc).unwrap();
67
68        assert_eq!(
69            normalized
70                .main()
71                .payload()
72                .get("title")
73                .unwrap()
74                .as_str()
75                .unwrap(),
76            "<<placeholder>>"
77        );
78
79        assert_eq!(normalized.main().body_markdown(), "\\<> **bold**");
80    }
81
82    #[test]
83    fn test_normalize_document_preserves_quill_tag() {
84        use crate::document::Document;
85
86        let doc = Document::parse("~~~card-yaml\n$quill: custom_quill\n$kind: main\n~~~\n")
87            .unwrap()
88            .document;
89        let normalized = super::normalize_document(doc).unwrap();
90
91        assert_eq!(normalized.quill_reference().name, "custom_quill");
92    }
93
94    #[test]
95    fn test_normalize_document_idempotent() {
96        use crate::document::Document;
97
98        let doc =
99            Document::parse("~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n<<content>>")
100                .unwrap()
101                .document;
102        let normalized_once = super::normalize_document(doc).unwrap();
103        let normalized_twice = super::normalize_document(normalized_once.clone()).unwrap();
104
105        assert_eq!(
106            normalized_once.main().body_markdown(),
107            normalized_twice.main().body_markdown()
108        );
109    }
110
111    #[test]
112    fn test_normalize_document_yaml_field_bidi_preserved() {
113        use crate::document::Document;
114
115        let doc = Document::parse(
116            "~~~card-yaml\n$quill: test\n$kind: main\ntitle: a\u{202D}b\n~~~\n",
117        )
118        .unwrap()
119        .document;
120        let normalized = super::normalize_document(doc).unwrap();
121        assert_eq!(
122            normalized
123                .main()
124                .payload()
125                .get("title")
126                .unwrap()
127                .as_str()
128                .unwrap(),
129            "a\u{202D}b"
130        );
131    }
132
133    #[test]
134    fn test_normalize_document_card_body_bidi_stripped() {
135        use crate::document::Document;
136
137        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\nbody\n\n~~~card-yaml\n$kind: note\n~~~\ncard\u{202D}body\n";
138        let doc = Document::parse(md).unwrap().document;
139        assert_eq!(doc.cards().len(), 1, "expected 1 card");
140        let normalized = super::normalize_document(doc).unwrap();
141        assert_eq!(normalized.cards()[0].body_markdown(), "cardbody");
142    }
143
144    #[test]
145    fn test_normalize_document_card_field_bidi_preserved() {
146        use crate::document::Document;
147
148        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\nbody\n\n~~~card-yaml\n$kind: note\nname: Ali\u{202D}ce\n~~~\n";
149        let doc = Document::parse(md).unwrap().document;
150        assert_eq!(doc.cards().len(), 1, "expected 1 card");
151        let normalized = super::normalize_document(doc).unwrap();
152        assert_eq!(
153            normalized.cards()[0]
154                .payload()
155                .get("name")
156                .unwrap()
157                .as_str()
158                .unwrap(),
159            "Ali\u{202D}ce"
160        );
161    }
162
163    #[test]
164    fn test_normalize_document_card_body_html_comment_repair() {
165        use crate::document::Document;
166
167        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n~~~card-yaml\n$kind: note\n~~~\n<!-- comment -->Trailing text\n";
168        let doc = Document::parse(md).unwrap().document;
169        let normalized = super::normalize_document(doc).unwrap();
170        assert_eq!(normalized.cards()[0].body_markdown(), "Trailing text");
171    }
172
173    #[test]
174    fn test_normalize_document_toplevel_body_html_comment_repair() {
175        use crate::document::Document;
176
177        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n<!-- note -->Content here";
178        let doc = Document::parse(md).unwrap().document;
179        let normalized = super::normalize_document(doc).unwrap();
180        assert_eq!(normalized.main().body_markdown(), "Content here");
181    }
182}