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/// Primary entry point for normalizing a [`crate::document::Document`] after parsing.
23///
24/// Per-card normalization:
25/// 1. Payload field names → Unicode NFC.
26///
27/// Card bodies are already-normalized content (import-time); they carry through
28/// unchanged. YAML field *values* pass through verbatim. Idempotent.
29pub fn normalize_document(
30    doc: crate::document::Document,
31) -> Result<crate::document::Document, crate::error::ParseError> {
32    use crate::document::Document;
33
34    let main = normalize_card(doc.main());
35    let normalized_cards: Vec<Card> = doc.cards().iter().map(normalize_card).collect();
36
37    Ok(Document::from_main_and_cards(main, normalized_cards))
38}
39
40/// Build a new `Card` with NFC-normalized field names, carrying the (already
41/// normalized) body content through unchanged.
42fn normalize_card(card: &Card) -> Card {
43    use crate::document::PayloadItem;
44    let mut payload = card.payload().clone();
45    for item in payload.items_mut() {
46        if let PayloadItem::Field { key, .. } = item {
47            let normalized = normalize_field_name(key);
48            if normalized != *key {
49                *key = normalized;
50            }
51        }
52    }
53    Card::from_parts(payload, card.body().clone())
54}
55
56#[cfg(test)]
57mod tests {
58
59    #[test]
60    fn test_normalize_document_basic() {
61        use crate::document::Document;
62
63        let doc = Document::parse(
64            "~~~card-yaml\n$quill: test\n$kind: main\ntitle: <<placeholder>>\n~~~\n\n<<content>> \u{202D}**bold**",
65        )
66        .unwrap()
67        .document;
68        let normalized = super::normalize_document(doc).unwrap();
69
70        assert_eq!(
71            normalized
72                .main()
73                .payload()
74                .get("title")
75                .unwrap()
76                .as_str()
77                .unwrap(),
78            "<<placeholder>>"
79        );
80
81        assert_eq!(normalized.main().body_markdown(), "\\<> **bold**");
82    }
83
84    #[test]
85    fn test_normalize_document_preserves_quill_tag() {
86        use crate::document::Document;
87
88        let doc = Document::parse("~~~card-yaml\n$quill: custom_quill\n$kind: main\n~~~\n")
89            .unwrap()
90            .document;
91        let normalized = super::normalize_document(doc).unwrap();
92
93        assert_eq!(normalized.quill_reference().name, "custom_quill");
94    }
95
96    #[test]
97    fn test_normalize_document_idempotent() {
98        use crate::document::Document;
99
100        let doc =
101            Document::parse("~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n<<content>>")
102                .unwrap()
103                .document;
104        let normalized_once = super::normalize_document(doc).unwrap();
105        let normalized_twice = super::normalize_document(normalized_once.clone()).unwrap();
106
107        assert_eq!(
108            normalized_once.main().body_markdown(),
109            normalized_twice.main().body_markdown()
110        );
111    }
112
113    #[test]
114    fn test_normalize_document_yaml_field_bidi_preserved() {
115        use crate::document::Document;
116
117        let doc = Document::parse(
118            "~~~card-yaml\n$quill: test\n$kind: main\ntitle: a\u{202D}b\n~~~\n",
119        )
120        .unwrap()
121        .document;
122        let normalized = super::normalize_document(doc).unwrap();
123        assert_eq!(
124            normalized
125                .main()
126                .payload()
127                .get("title")
128                .unwrap()
129                .as_str()
130                .unwrap(),
131            "a\u{202D}b"
132        );
133    }
134
135    #[test]
136    fn test_normalize_document_card_body_bidi_stripped() {
137        use crate::document::Document;
138
139        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";
140        let doc = Document::parse(md).unwrap().document;
141        assert_eq!(doc.cards().len(), 1, "expected 1 card");
142        let normalized = super::normalize_document(doc).unwrap();
143        assert_eq!(normalized.cards()[0].body_markdown(), "cardbody");
144    }
145
146    #[test]
147    fn test_normalize_document_card_field_bidi_preserved() {
148        use crate::document::Document;
149
150        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";
151        let doc = Document::parse(md).unwrap().document;
152        assert_eq!(doc.cards().len(), 1, "expected 1 card");
153        let normalized = super::normalize_document(doc).unwrap();
154        assert_eq!(
155            normalized.cards()[0]
156                .payload()
157                .get("name")
158                .unwrap()
159                .as_str()
160                .unwrap(),
161            "Ali\u{202D}ce"
162        );
163    }
164
165    #[test]
166    fn test_normalize_document_card_body_html_comment_repair() {
167        use crate::document::Document;
168
169        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n~~~card-yaml\n$kind: note\n~~~\n<!-- comment -->Trailing text\n";
170        let doc = Document::parse(md).unwrap().document;
171        let normalized = super::normalize_document(doc).unwrap();
172        assert_eq!(normalized.cards()[0].body_markdown(), "Trailing text");
173    }
174
175    #[test]
176    fn test_normalize_document_toplevel_body_html_comment_repair() {
177        use crate::document::Document;
178
179        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n<!-- note -->Content here";
180        let doc = Document::parse(md).unwrap().document;
181        let normalized = super::normalize_document(doc).unwrap();
182        assert_eq!(normalized.main().body_markdown(), "Content here");
183    }
184}