Skip to main content

quillmark_core/
normalize.rs

1//! # Input Normalization
2//!
3//! Preprocessing for markdown content before parsing. Handles invisible Unicode
4//! control characters (especially from copy-paste) that interfere with delimiter
5//! recognition, and HTML comment fences that would silently drop trailing text.
6//!
7//! Double chevrons (`<<` and `>>`) are passed through unchanged.
8//!
9//! ## Why normalize bidi characters?
10//!
11//! Unicode bidirectional formatting characters (LRO, RLO, LRE, RLE, etc.) are invisible
12//! and when placed adjacent to markdown delimiters like `**` prevent parsers from
13//! recognizing them:
14//!
15//! ```text
16//! **bold** or <U+202D>**(1234**
17//!             ^^^^^^^^ invisible LRO prevents second ** from being recognized as bold
18//! ```
19//!
20//! These appear commonly when copying from web pages with mixed LTR/RTL content,
21//! PDFs, and word processors.
22
23use crate::document::Card;
24use unicode_normalization::UnicodeNormalization;
25
26#[derive(Debug, thiserror::Error)]
27pub enum NormalizationError {
28    #[error("JSON nesting too deep: {depth} levels (max: {max} levels)")]
29    NestingTooDeep { depth: usize, max: usize },
30}
31
32#[inline]
33fn is_bidi_char(c: char) -> bool {
34    matches!(
35        c,
36        '\u{061C}' // ARABIC LETTER MARK (ALM)
37        | '\u{200E}' // LEFT-TO-RIGHT MARK (LRM)
38        | '\u{200F}' // RIGHT-TO-LEFT MARK (RLM)
39        | '\u{202A}' // LEFT-TO-RIGHT EMBEDDING (LRE)
40        | '\u{202B}' // RIGHT-TO-LEFT EMBEDDING (RLE)
41        | '\u{202C}' // POP DIRECTIONAL FORMATTING (PDF)
42        | '\u{202D}' // LEFT-TO-RIGHT OVERRIDE (LRO)
43        | '\u{202E}' // RIGHT-TO-LEFT OVERRIDE (RLO)
44        | '\u{2066}' // LEFT-TO-RIGHT ISOLATE (LRI)
45        | '\u{2067}' // RIGHT-TO-LEFT ISOLATE (RLI)
46        | '\u{2068}' // FIRST STRONG ISOLATE (FSI)
47        | '\u{2069}' // POP DIRECTIONAL ISOLATE (PDI)
48    )
49}
50
51/// Strips Unicode bidirectional formatting characters that can interfere with markdown parsing.
52///
53/// Removes all of ALM (U+061C), LRM/RLM (U+200E/F), LRE/RLE/PDF/LRO/RLO
54/// (U+202A–202E), and LRI/RLI/FSI/PDI (U+2066–2069).
55pub fn strip_bidi_formatting(s: &str) -> String {
56    if !s.chars().any(is_bidi_char) {
57        return s.to_string();
58    }
59
60    s.chars().filter(|c| !is_bidi_char(*c)).collect()
61}
62
63/// Inserts a newline after `-->` when followed by non-whitespace content.
64///
65/// CommonMark HTML block type 2 ends with the line containing `-->`, so any
66/// text on the same line after `-->` would be swallowed. This function is
67/// context-aware: only closing fences inside a `<!-- ... -->` pair are fixed;
68/// bare `-->` outside a comment is left untouched.
69pub fn fix_html_comment_fences(s: &str) -> String {
70    if !s.contains("-->") {
71        return s.to_string();
72    }
73
74    let mut result = String::with_capacity(s.len() + 16);
75    let mut current_pos = 0;
76
77    while let Some(open_idx) = s[current_pos..].find("<!--") {
78        let abs_open = current_pos + open_idx;
79
80        if let Some(close_idx) = s[abs_open..].find("-->") {
81            let abs_close = abs_open + close_idx;
82            let mut after_fence = abs_close + 3;
83
84            // Handle `<!--- ... --->` style fences: the extra hyphen is part of
85            // the fence, not leaked trailing text.
86            let opener_has_extra_hyphen = s
87                .get(abs_open + 4..)
88                .is_some_and(|rest| rest.starts_with('-'));
89            if opener_has_extra_hyphen
90                && s.get(after_fence..)
91                    .is_some_and(|rest| rest.starts_with('-'))
92            {
93                after_fence += 1;
94            }
95
96            result.push_str(&s[current_pos..after_fence]);
97
98            let after_content = &s[after_fence..];
99
100            let needs_newline = if after_content.is_empty()
101                || after_content.starts_with('\n')
102                || after_content.starts_with("\r\n")
103            {
104                false
105            } else {
106                let next_newline = after_content.find('\n');
107                let until_newline = match next_newline {
108                    Some(pos) => &after_content[..pos],
109                    None => after_content,
110                };
111                !until_newline.trim().is_empty()
112            };
113
114            if needs_newline {
115                result.push('\n');
116            }
117
118            current_pos = after_fence;
119        } else {
120            // Unclosed comment — append the rest and stop.
121            result.push_str(&s[current_pos..]);
122            current_pos = s.len();
123            break;
124        }
125    }
126
127    if current_pos < s.len() {
128        result.push_str(&s[current_pos..]);
129    }
130
131    result
132}
133
134/// Applies all markdown normalizations in order: CRLF → LF, bidi strip,
135/// HTML comment fence repair.
136pub fn normalize_markdown(markdown: &str) -> String {
137    let cleaned = normalize_line_endings(markdown);
138    let cleaned = strip_bidi_formatting(&cleaned);
139    fix_html_comment_fences(&cleaned)
140}
141
142/// Convert CRLF (`\r\n`) and bare CR (`\r`) line endings to LF (`\n`).
143///
144/// Applied only to the Markdown body (spec §7); YAML scalars are unaffected.
145/// Necessary because YAML parsing normalizes its own scalars but passes the
146/// body verbatim, and some Windows/clipboard sources leave bare `\r` bytes.
147fn normalize_line_endings(s: &str) -> String {
148    if !s.contains('\r') {
149        return s.to_string();
150    }
151    let mut out = String::with_capacity(s.len());
152    let mut chars = s.chars().peekable();
153    while let Some(c) = chars.next() {
154        if c == '\r' {
155            if chars.peek() == Some(&'\n') {
156                chars.next();
157            }
158            out.push('\n');
159        } else {
160            out.push(c);
161        }
162    }
163    out
164}
165
166/// Normalize field name to Unicode NFC, so visually identical keys
167/// (e.g., composed `"café"` vs decomposed `"cafe\u{0301}"`) are treated as equal.
168pub fn normalize_field_name(name: &str) -> String {
169    name.nfc().collect()
170}
171
172/// Primary entry point for normalizing a [`crate::document::Document`] after parsing.
173///
174/// Per-card normalization:
175/// 1. Payload field names → Unicode NFC.
176/// 2. Card body → bidi-stripped + HTML comment fence repair (spec §7).
177///    YAML field *values* pass through verbatim.
178///
179/// Idempotent — calling multiple times produces the same result.
180pub fn normalize_document(
181    doc: crate::document::Document,
182) -> Result<crate::document::Document, crate::error::ParseError> {
183    use crate::document::Document;
184
185    let main = normalize_card(doc.main());
186    let normalized_cards: Vec<Card> = doc.cards().iter().map(normalize_card).collect();
187
188    Ok(Document::from_main_and_cards(
189        main,
190        normalized_cards,
191        doc.warnings().to_vec(),
192    ))
193}
194
195/// Build a new `Card` with NFC-normalized field names and a normalized body.
196fn normalize_card(card: &Card) -> Card {
197    use crate::document::PayloadItem;
198    let mut payload = card.payload().clone();
199    for item in payload.items_mut() {
200        if let PayloadItem::Field { key, .. } = item {
201            let normalized = normalize_field_name(key);
202            if normalized != *key {
203                *key = normalized;
204            }
205        }
206    }
207    Card::from_parts(payload, normalize_markdown(card.body()))
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_strip_bidi_no_change() {
216        assert_eq!(strip_bidi_formatting("hello world"), "hello world");
217        assert_eq!(strip_bidi_formatting(""), "");
218        assert_eq!(strip_bidi_formatting("**bold** text"), "**bold** text");
219    }
220
221    #[test]
222    fn test_strip_bidi_lro() {
223        assert_eq!(strip_bidi_formatting("he\u{202D}llo"), "hello");
224        assert_eq!(
225            strip_bidi_formatting("**asdf** or \u{202D}**(1234**"),
226            "**asdf** or **(1234**"
227        );
228    }
229
230    #[test]
231    fn test_strip_bidi_rlo() {
232        assert_eq!(strip_bidi_formatting("he\u{202E}llo"), "hello");
233    }
234
235    #[test]
236    fn test_strip_bidi_marks() {
237        assert_eq!(strip_bidi_formatting("a\u{200E}b\u{200F}c"), "abc");
238    }
239
240    #[test]
241    fn test_strip_bidi_embeddings() {
242        assert_eq!(
243            strip_bidi_formatting("\u{202A}text\u{202B}more\u{202C}"),
244            "textmore"
245        );
246    }
247
248    #[test]
249    fn test_strip_bidi_isolates() {
250        assert_eq!(
251            strip_bidi_formatting("\u{2066}a\u{2067}b\u{2068}c\u{2069}"),
252            "abc"
253        );
254    }
255
256    #[test]
257    fn test_strip_bidi_all_chars() {
258        let all_bidi = "\u{061C}\u{200E}\u{200F}\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}\u{2066}\u{2067}\u{2068}\u{2069}";
259        assert_eq!(strip_bidi_formatting(all_bidi), "");
260    }
261
262    #[test]
263    fn test_strip_bidi_arabic_letter_mark() {
264        assert_eq!(strip_bidi_formatting("hello\u{061C}world"), "helloworld");
265        assert_eq!(strip_bidi_formatting("\u{061C}**bold**"), "**bold**");
266    }
267
268    #[test]
269    fn test_strip_bidi_unicode_preserved() {
270        assert_eq!(strip_bidi_formatting("你好世界"), "你好世界");
271        assert_eq!(strip_bidi_formatting("مرحبا"), "مرحبا");
272        assert_eq!(strip_bidi_formatting("🎉"), "🎉");
273    }
274
275    #[test]
276    fn test_normalize_markdown_basic() {
277        assert_eq!(normalize_markdown("hello"), "hello");
278        assert_eq!(
279            normalize_markdown("**bold** \u{202D}**more**"),
280            "**bold** **more**"
281        );
282    }
283
284    #[test]
285    fn test_normalize_markdown_html_comment() {
286        assert_eq!(
287            normalize_markdown("<!-- comment -->Some text"),
288            "<!-- comment -->\nSome text"
289        );
290    }
291
292    #[test]
293    fn test_fix_html_comment_no_comment() {
294        assert_eq!(fix_html_comment_fences("hello world"), "hello world");
295        assert_eq!(fix_html_comment_fences("**bold** text"), "**bold** text");
296        assert_eq!(fix_html_comment_fences(""), "");
297    }
298
299    #[test]
300    fn test_fix_html_comment_single_line_trailing_text() {
301        assert_eq!(
302            fix_html_comment_fences("<!-- comment -->Same line text"),
303            "<!-- comment -->\nSame line text"
304        );
305    }
306
307    #[test]
308    fn test_fix_html_comment_already_newline() {
309        assert_eq!(
310            fix_html_comment_fences("<!-- comment -->\nNext line text"),
311            "<!-- comment -->\nNext line text"
312        );
313    }
314
315    #[test]
316    fn test_fix_html_comment_only_whitespace_after() {
317        assert_eq!(
318            fix_html_comment_fences("<!-- comment -->   \nSome text"),
319            "<!-- comment -->   \nSome text"
320        );
321    }
322
323    #[test]
324    fn test_fix_html_comment_multiline_trailing_text() {
325        assert_eq!(
326            fix_html_comment_fences("<!--\nmultiline\ncomment\n-->Trailing text"),
327            "<!--\nmultiline\ncomment\n-->\nTrailing text"
328        );
329    }
330
331    #[test]
332    fn test_fix_html_comment_multiline_proper() {
333        assert_eq!(
334            fix_html_comment_fences("<!--\nmultiline\n-->\n\nParagraph text"),
335            "<!--\nmultiline\n-->\n\nParagraph text"
336        );
337    }
338
339    #[test]
340    fn test_fix_html_comment_multiple_comments() {
341        assert_eq!(
342            fix_html_comment_fences("<!-- first -->Text\n\n<!-- second -->More text"),
343            "<!-- first -->\nText\n\n<!-- second -->\nMore text"
344        );
345    }
346
347    #[test]
348    fn test_fix_html_comment_end_of_string() {
349        assert_eq!(
350            fix_html_comment_fences("Some text before <!-- comment -->"),
351            "Some text before <!-- comment -->"
352        );
353    }
354
355    #[test]
356    fn test_fix_html_comment_only_comment() {
357        assert_eq!(
358            fix_html_comment_fences("<!-- comment -->"),
359            "<!-- comment -->"
360        );
361    }
362
363    #[test]
364    fn test_fix_html_comment_arrow_not_comment() {
365        assert_eq!(fix_html_comment_fences("-->some text"), "-->some text");
366    }
367
368    #[test]
369    fn test_fix_html_comment_nested_opener() {
370        // The first <!-- opens, the first --> closes; inner <!-- is just text.
371        assert_eq!(
372            fix_html_comment_fences("<!-- <!-- -->Trailing"),
373            "<!-- <!-- -->\nTrailing"
374        );
375    }
376
377    #[test]
378    fn test_fix_html_comment_unmatched_closer() {
379        assert_eq!(
380            fix_html_comment_fences("text --> more text"),
381            "text --> more text"
382        );
383    }
384
385    #[test]
386    fn test_fix_html_comment_multiple_valid_invalid() {
387        let input = "<!-- valid -->FixMe\ntext --> Ignore\n<!-- valid2 -->FixMe2";
388        let expected = "<!-- valid -->\nFixMe\ntext --> Ignore\n<!-- valid2 -->\nFixMe2";
389        assert_eq!(fix_html_comment_fences(input), expected);
390    }
391
392    #[test]
393    fn test_fix_html_comment_crlf() {
394        assert_eq!(
395            fix_html_comment_fences("<!-- comment -->\r\nSome text"),
396            "<!-- comment -->\r\nSome text"
397        );
398    }
399
400    #[test]
401    fn test_fix_html_comment_triple_hyphen_single_line() {
402        assert_eq!(
403            fix_html_comment_fences("<!--- comment --->Trailing text"),
404            "<!--- comment --->\nTrailing text"
405        );
406    }
407
408    #[test]
409    fn test_fix_html_comment_triple_hyphen_multiline() {
410        assert_eq!(
411            fix_html_comment_fences("<!---\ncomment\n--->Trailing text"),
412            "<!---\ncomment\n--->\nTrailing text"
413        );
414    }
415
416    #[test]
417    fn test_normalize_document_basic() {
418        use crate::document::Document;
419
420        let doc = Document::from_markdown(
421            "~~~card-yaml\n$quill: test\n$kind: main\ntitle: <<placeholder>>\n~~~\n\n<<content>> \u{202D}**bold**",
422        )
423        .unwrap();
424        let normalized = super::normalize_document(doc).unwrap();
425
426        assert_eq!(
427            normalized
428                .main()
429                .payload()
430                .get("title")
431                .unwrap()
432                .as_str()
433                .unwrap(),
434            "<<placeholder>>"
435        );
436
437        assert_eq!(normalized.main().body(), "\n<<content>> **bold**");
438    }
439
440    #[test]
441    fn test_normalize_document_preserves_quill_tag() {
442        use crate::document::Document;
443
444        let doc = Document::from_markdown("~~~card-yaml\n$quill: custom_quill\n$kind: main\n~~~\n")
445            .unwrap();
446        let normalized = super::normalize_document(doc).unwrap();
447
448        assert_eq!(normalized.quill_reference().name, "custom_quill");
449    }
450
451    #[test]
452    fn test_normalize_document_idempotent() {
453        use crate::document::Document;
454
455        let doc =
456            Document::from_markdown("~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n<<content>>")
457                .unwrap();
458        let normalized_once = super::normalize_document(doc).unwrap();
459        let normalized_twice = super::normalize_document(normalized_once.clone()).unwrap();
460
461        assert_eq!(
462            normalized_once.main().body(),
463            normalized_twice.main().body()
464        );
465    }
466
467    #[test]
468    fn test_normalize_document_body_bidi_stripped() {
469        use crate::document::Document;
470
471        let doc = Document::from_markdown(
472            "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\nhello\u{202D}world",
473        )
474        .unwrap();
475        let normalized = super::normalize_document(doc).unwrap();
476        assert_eq!(normalized.main().body(), "\nhelloworld");
477    }
478
479    #[test]
480    fn test_normalize_document_yaml_field_bidi_preserved() {
481        use crate::document::Document;
482
483        let doc = Document::from_markdown(
484            "~~~card-yaml\n$quill: test\n$kind: main\ntitle: a\u{202D}b\n~~~\n",
485        )
486        .unwrap();
487        let normalized = super::normalize_document(doc).unwrap();
488        assert_eq!(
489            normalized
490                .main()
491                .payload()
492                .get("title")
493                .unwrap()
494                .as_str()
495                .unwrap(),
496            "a\u{202D}b"
497        );
498    }
499
500    #[test]
501    fn test_normalize_document_card_body_bidi_stripped() {
502        use crate::document::Document;
503
504        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";
505        let doc = Document::from_markdown(md).unwrap();
506        assert_eq!(doc.cards().len(), 1, "expected 1 card");
507        let normalized = super::normalize_document(doc).unwrap();
508        assert_eq!(normalized.cards()[0].body(), "cardbody\n");
509    }
510
511    #[test]
512    fn test_normalize_document_card_field_bidi_preserved() {
513        use crate::document::Document;
514
515        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";
516        let doc = Document::from_markdown(md).unwrap();
517        assert_eq!(doc.cards().len(), 1, "expected 1 card");
518        let normalized = super::normalize_document(doc).unwrap();
519        assert_eq!(
520            normalized.cards()[0]
521                .payload()
522                .get("name")
523                .unwrap()
524                .as_str()
525                .unwrap(),
526            "Ali\u{202D}ce"
527        );
528    }
529
530    #[test]
531    fn test_normalize_document_card_body_html_comment_repair() {
532        use crate::document::Document;
533
534        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n~~~card-yaml\n$kind: note\n~~~\n<!-- comment -->Trailing text\n";
535        let doc = Document::from_markdown(md).unwrap();
536        let normalized = super::normalize_document(doc).unwrap();
537        assert_eq!(
538            normalized.cards()[0].body(),
539            "<!-- comment -->\nTrailing text\n"
540        );
541    }
542
543    #[test]
544    fn test_normalize_document_toplevel_body_html_comment_repair() {
545        use crate::document::Document;
546
547        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n<!-- note -->Content here";
548        let doc = Document::from_markdown(md).unwrap();
549        let normalized = super::normalize_document(doc).unwrap();
550        assert_eq!(normalized.main().body(), "\n<!-- note -->\nContent here");
551    }
552}