Skip to main content

quillmark_content/
normalize.rs

1//! Markdown-string input normalization — the boundary preprocessing content
2//! import runs before parsing. Converts line endings to `\n`, strips invisible
3//! Unicode bidi controls (which sit adjacent to `**`/`_` and defeat delimiter
4//! recognition), and repairs `<!-- ... -->` HTML-comment fences that would
5//! otherwise swallow trailing text.
6//!
7//! The pure string primitive [`from_markdown`](crate::import::from_markdown)
8//! applies at its boundary. It carries no dependency on the document engine, so
9//! this crate is a leaf `quillmark-core` depends on.
10
11#[inline]
12pub(crate) fn is_bidi_char(c: char) -> bool {
13    matches!(
14        c,
15        '\u{061C}' // ARABIC LETTER MARK (ALM)
16        | '\u{200E}' // LEFT-TO-RIGHT MARK (LRM)
17        | '\u{200F}' // RIGHT-TO-LEFT MARK (RLM)
18        | '\u{202A}' // LEFT-TO-RIGHT EMBEDDING (LRE)
19        | '\u{202B}' // RIGHT-TO-LEFT EMBEDDING (RLE)
20        | '\u{202C}' // POP DIRECTIONAL FORMATTING (PDF)
21        | '\u{202D}' // LEFT-TO-RIGHT OVERRIDE (LRO)
22        | '\u{202E}' // RIGHT-TO-LEFT OVERRIDE (RLO)
23        | '\u{2066}' // LEFT-TO-RIGHT ISOLATE (LRI)
24        | '\u{2067}' // RIGHT-TO-LEFT ISOLATE (RLI)
25        | '\u{2068}' // FIRST STRONG ISOLATE (FSI)
26        | '\u{2069}' // POP DIRECTIONAL ISOLATE (PDI)
27    )
28}
29
30/// Strips Unicode bidirectional formatting characters that can interfere with markdown parsing.
31///
32/// Removes all of ALM (U+061C), LRM/RLM (U+200E/F), LRE/RLE/PDF/LRO/RLO
33/// (U+202A–202E), and LRI/RLI/FSI/PDI (U+2066–2069).
34pub fn strip_bidi_formatting(s: &str) -> String {
35    if !s.chars().any(is_bidi_char) {
36        return s.to_string();
37    }
38
39    s.chars().filter(|c| !is_bidi_char(*c)).collect()
40}
41
42/// Inserts a newline after `-->` when followed by non-whitespace content.
43///
44/// CommonMark HTML block type 2 ends with the line containing `-->`, so any
45/// text on the same line after `-->` would be swallowed. This function is
46/// context-aware: only closing fences inside a `<!-- ... -->` pair are fixed;
47/// bare `-->` outside a comment is left untouched.
48pub fn fix_html_comment_fences(s: &str) -> String {
49    if !s.contains("-->") {
50        return s.to_string();
51    }
52
53    let mut result = String::with_capacity(s.len() + 16);
54    let mut current_pos = 0;
55
56    while let Some(open_idx) = s[current_pos..].find("<!--") {
57        let abs_open = current_pos + open_idx;
58
59        if let Some(close_idx) = s[abs_open..].find("-->") {
60            let abs_close = abs_open + close_idx;
61            let mut after_fence = abs_close + 3;
62
63            // Handle `<!--- ... --->` style fences: the extra hyphen is part of
64            // the fence, not leaked trailing text.
65            let opener_has_extra_hyphen = s
66                .get(abs_open + 4..)
67                .is_some_and(|rest| rest.starts_with('-'));
68            if opener_has_extra_hyphen
69                && s.get(after_fence..)
70                    .is_some_and(|rest| rest.starts_with('-'))
71            {
72                after_fence += 1;
73            }
74
75            result.push_str(&s[current_pos..after_fence]);
76
77            let after_content = &s[after_fence..];
78
79            let needs_newline = if after_content.is_empty()
80                || after_content.starts_with('\n')
81                || after_content.starts_with("\r\n")
82            {
83                false
84            } else {
85                let next_newline = after_content.find('\n');
86                let until_newline = match next_newline {
87                    Some(pos) => &after_content[..pos],
88                    None => after_content,
89                };
90                !until_newline.trim().is_empty()
91            };
92
93            if needs_newline {
94                result.push('\n');
95            }
96
97            current_pos = after_fence;
98        } else {
99            // Unclosed comment — append the rest and stop.
100            result.push_str(&s[current_pos..]);
101            current_pos = s.len();
102            break;
103        }
104    }
105
106    if current_pos < s.len() {
107        result.push_str(&s[current_pos..]);
108    }
109
110    result
111}
112
113/// Applies all markdown normalizations in order: CRLF → LF, bidi strip,
114/// HTML comment fence repair.
115pub fn normalize_markdown(markdown: &str) -> String {
116    let cleaned = normalize_line_endings(markdown);
117    let cleaned = strip_bidi_formatting(&cleaned);
118    fix_html_comment_fences(&cleaned)
119}
120
121/// Convert CRLF (`\r\n`) and bare CR (`\r`) line endings to LF (`\n`).
122///
123/// Applied only to the Markdown body (spec §7); YAML scalars are unaffected.
124/// Necessary because YAML parsing normalizes its own scalars but passes the
125/// body verbatim, and some Windows/clipboard sources leave bare `\r` bytes.
126fn normalize_line_endings(s: &str) -> String {
127    if !s.contains('\r') {
128        return s.to_string();
129    }
130    let mut out = String::with_capacity(s.len());
131    let mut chars = s.chars().peekable();
132    while let Some(c) = chars.next() {
133        if c == '\r' {
134            if chars.peek() == Some(&'\n') {
135                chars.next();
136            }
137            out.push('\n');
138        } else {
139            out.push(c);
140        }
141    }
142    out
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_strip_bidi_formatting_cases() {
151        let cases: &[(&str, &str)] = &[
152            ("hello world", "hello world"),
153            ("", ""),
154            ("**bold** text", "**bold** text"),
155            ("he\u{202D}llo", "hello"),
156            ("**asdf** or \u{202D}**(1234**", "**asdf** or **(1234**"),
157            ("a\u{200E}b\u{200F}c", "abc"),
158            ("\u{202A}text\u{202B}more\u{202C}", "textmore"),
159            ("\u{2066}a\u{2067}b\u{2068}c\u{2069}", "abc"),
160            (
161                "\u{061C}\u{200E}\u{200F}\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}\u{2066}\u{2067}\u{2068}\u{2069}",
162                "",
163            ),
164            ("hello\u{061C}world", "helloworld"),
165            ("\u{061C}**bold**", "**bold**"),
166            ("你好世界", "你好世界"),
167            ("مرحبا", "مرحبا"),
168            ("🎉", "🎉"),
169        ];
170
171        for (input, expected) in cases {
172            assert_eq!(strip_bidi_formatting(input), *expected, "input: {:?}", input);
173        }
174    }
175
176    #[test]
177    fn test_normalize_markdown_basic() {
178        assert_eq!(normalize_markdown("hello"), "hello");
179        assert_eq!(
180            normalize_markdown("**bold** \u{202D}**more**"),
181            "**bold** **more**"
182        );
183    }
184
185    #[test]
186    fn test_normalize_markdown_html_comment() {
187        assert_eq!(
188            normalize_markdown("<!-- comment -->Some text"),
189            "<!-- comment -->\nSome text"
190        );
191    }
192
193    #[test]
194    fn test_fix_html_comment_fences_cases() {
195        let cases: &[(&str, &str)] = &[
196            ("hello world", "hello world"),
197            ("**bold** text", "**bold** text"),
198            ("", ""),
199            (
200                "<!-- comment -->Same line text",
201                "<!-- comment -->\nSame line text",
202            ),
203            (
204                "<!-- comment -->\nNext line text",
205                "<!-- comment -->\nNext line text",
206            ),
207            (
208                "<!-- comment -->   \nSome text",
209                "<!-- comment -->   \nSome text",
210            ),
211            (
212                "<!--\nmultiline\ncomment\n-->Trailing text",
213                "<!--\nmultiline\ncomment\n-->\nTrailing text",
214            ),
215            (
216                "<!--\nmultiline\n-->\n\nParagraph text",
217                "<!--\nmultiline\n-->\n\nParagraph text",
218            ),
219            (
220                "<!-- first -->Text\n\n<!-- second -->More text",
221                "<!-- first -->\nText\n\n<!-- second -->\nMore text",
222            ),
223            (
224                "Some text before <!-- comment -->",
225                "Some text before <!-- comment -->",
226            ),
227            ("-->some text", "-->some text"),
228            // The first <!-- opens, the first --> closes; inner <!-- is just text.
229            ("<!-- <!-- -->Trailing", "<!-- <!-- -->\nTrailing"),
230            (
231                "<!-- valid -->FixMe\ntext --> Ignore\n<!-- valid2 -->FixMe2",
232                "<!-- valid -->\nFixMe\ntext --> Ignore\n<!-- valid2 -->\nFixMe2",
233            ),
234            (
235                "<!-- comment -->\r\nSome text",
236                "<!-- comment -->\r\nSome text",
237            ),
238            (
239                "<!--- comment --->Trailing text",
240                "<!--- comment --->\nTrailing text",
241            ),
242        ];
243
244        for (input, expected) in cases {
245            assert_eq!(
246                fix_html_comment_fences(input),
247                *expected,
248                "input: {:?}",
249                input
250            );
251        }
252    }
253}