quillmark_content/
normalize.rs1#[inline]
12pub(crate) fn is_bidi_char(c: char) -> bool {
13 matches!(
14 c,
15 '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}' | '\u{202B}' | '\u{202C}' | '\u{202D}' | '\u{202E}' | '\u{2066}' | '\u{2067}' | '\u{2068}' | '\u{2069}' )
28}
29
30pub 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
42pub 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 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 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
113pub 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
121fn 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 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}