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_no_change() {
151        assert_eq!(strip_bidi_formatting("hello world"), "hello world");
152        assert_eq!(strip_bidi_formatting(""), "");
153        assert_eq!(strip_bidi_formatting("**bold** text"), "**bold** text");
154    }
155
156    #[test]
157    fn test_strip_bidi_lro() {
158        assert_eq!(strip_bidi_formatting("he\u{202D}llo"), "hello");
159        assert_eq!(
160            strip_bidi_formatting("**asdf** or \u{202D}**(1234**"),
161            "**asdf** or **(1234**"
162        );
163    }
164
165    #[test]
166    fn test_strip_bidi_marks() {
167        assert_eq!(strip_bidi_formatting("a\u{200E}b\u{200F}c"), "abc");
168    }
169
170    #[test]
171    fn test_strip_bidi_embeddings() {
172        assert_eq!(
173            strip_bidi_formatting("\u{202A}text\u{202B}more\u{202C}"),
174            "textmore"
175        );
176    }
177
178    #[test]
179    fn test_strip_bidi_isolates() {
180        assert_eq!(
181            strip_bidi_formatting("\u{2066}a\u{2067}b\u{2068}c\u{2069}"),
182            "abc"
183        );
184    }
185
186    #[test]
187    fn test_strip_bidi_all_chars() {
188        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}";
189        assert_eq!(strip_bidi_formatting(all_bidi), "");
190    }
191
192    #[test]
193    fn test_strip_bidi_arabic_letter_mark() {
194        assert_eq!(strip_bidi_formatting("hello\u{061C}world"), "helloworld");
195        assert_eq!(strip_bidi_formatting("\u{061C}**bold**"), "**bold**");
196    }
197
198    #[test]
199    fn test_strip_bidi_unicode_preserved() {
200        assert_eq!(strip_bidi_formatting("你好世界"), "你好世界");
201        assert_eq!(strip_bidi_formatting("مرحبا"), "مرحبا");
202        assert_eq!(strip_bidi_formatting("🎉"), "🎉");
203    }
204
205    #[test]
206    fn test_normalize_markdown_basic() {
207        assert_eq!(normalize_markdown("hello"), "hello");
208        assert_eq!(
209            normalize_markdown("**bold** \u{202D}**more**"),
210            "**bold** **more**"
211        );
212    }
213
214    #[test]
215    fn test_normalize_markdown_html_comment() {
216        assert_eq!(
217            normalize_markdown("<!-- comment -->Some text"),
218            "<!-- comment -->\nSome text"
219        );
220    }
221
222    #[test]
223    fn test_fix_html_comment_no_comment() {
224        assert_eq!(fix_html_comment_fences("hello world"), "hello world");
225        assert_eq!(fix_html_comment_fences("**bold** text"), "**bold** text");
226        assert_eq!(fix_html_comment_fences(""), "");
227    }
228
229    #[test]
230    fn test_fix_html_comment_single_line_trailing_text() {
231        assert_eq!(
232            fix_html_comment_fences("<!-- comment -->Same line text"),
233            "<!-- comment -->\nSame line text"
234        );
235    }
236
237    #[test]
238    fn test_fix_html_comment_already_newline() {
239        assert_eq!(
240            fix_html_comment_fences("<!-- comment -->\nNext line text"),
241            "<!-- comment -->\nNext line text"
242        );
243    }
244
245    #[test]
246    fn test_fix_html_comment_only_whitespace_after() {
247        assert_eq!(
248            fix_html_comment_fences("<!-- comment -->   \nSome text"),
249            "<!-- comment -->   \nSome text"
250        );
251    }
252
253    #[test]
254    fn test_fix_html_comment_multiline_trailing_text() {
255        assert_eq!(
256            fix_html_comment_fences("<!--\nmultiline\ncomment\n-->Trailing text"),
257            "<!--\nmultiline\ncomment\n-->\nTrailing text"
258        );
259    }
260
261    #[test]
262    fn test_fix_html_comment_multiline_proper() {
263        assert_eq!(
264            fix_html_comment_fences("<!--\nmultiline\n-->\n\nParagraph text"),
265            "<!--\nmultiline\n-->\n\nParagraph text"
266        );
267    }
268
269    #[test]
270    fn test_fix_html_comment_multiple_comments() {
271        assert_eq!(
272            fix_html_comment_fences("<!-- first -->Text\n\n<!-- second -->More text"),
273            "<!-- first -->\nText\n\n<!-- second -->\nMore text"
274        );
275    }
276
277    #[test]
278    fn test_fix_html_comment_end_of_string() {
279        assert_eq!(
280            fix_html_comment_fences("Some text before <!-- comment -->"),
281            "Some text before <!-- comment -->"
282        );
283    }
284
285    #[test]
286    fn test_fix_html_comment_arrow_not_comment() {
287        assert_eq!(fix_html_comment_fences("-->some text"), "-->some text");
288    }
289
290    #[test]
291    fn test_fix_html_comment_nested_opener() {
292        // The first <!-- opens, the first --> closes; inner <!-- is just text.
293        assert_eq!(
294            fix_html_comment_fences("<!-- <!-- -->Trailing"),
295            "<!-- <!-- -->\nTrailing"
296        );
297    }
298
299    #[test]
300    fn test_fix_html_comment_multiple_valid_invalid() {
301        let input = "<!-- valid -->FixMe\ntext --> Ignore\n<!-- valid2 -->FixMe2";
302        let expected = "<!-- valid -->\nFixMe\ntext --> Ignore\n<!-- valid2 -->\nFixMe2";
303        assert_eq!(fix_html_comment_fences(input), expected);
304    }
305
306    #[test]
307    fn test_fix_html_comment_crlf() {
308        assert_eq!(
309            fix_html_comment_fences("<!-- comment -->\r\nSome text"),
310            "<!-- comment -->\r\nSome text"
311        );
312    }
313
314    #[test]
315    fn test_fix_html_comment_triple_hyphen_single_line() {
316        assert_eq!(
317            fix_html_comment_fences("<!--- comment --->Trailing text"),
318            "<!--- comment --->\nTrailing text"
319        );
320    }
321
322}