quillmark_core/
normalize.rs1use crate::document::Card;
24use unicode_normalization::UnicodeNormalization;
25
26#[inline]
27fn is_bidi_char(c: char) -> bool {
28 matches!(
29 c,
30 '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}' | '\u{202B}' | '\u{202C}' | '\u{202D}' | '\u{202E}' | '\u{2066}' | '\u{2067}' | '\u{2068}' | '\u{2069}' )
43}
44
45pub fn strip_bidi_formatting(s: &str) -> String {
50 if !s.chars().any(is_bidi_char) {
51 return s.to_string();
52 }
53
54 s.chars().filter(|c| !is_bidi_char(*c)).collect()
55}
56
57pub fn fix_html_comment_fences(s: &str) -> String {
64 if !s.contains("-->") {
65 return s.to_string();
66 }
67
68 let mut result = String::with_capacity(s.len() + 16);
69 let mut current_pos = 0;
70
71 while let Some(open_idx) = s[current_pos..].find("<!--") {
72 let abs_open = current_pos + open_idx;
73
74 if let Some(close_idx) = s[abs_open..].find("-->") {
75 let abs_close = abs_open + close_idx;
76 let mut after_fence = abs_close + 3;
77
78 let opener_has_extra_hyphen = s
81 .get(abs_open + 4..)
82 .is_some_and(|rest| rest.starts_with('-'));
83 if opener_has_extra_hyphen
84 && s.get(after_fence..)
85 .is_some_and(|rest| rest.starts_with('-'))
86 {
87 after_fence += 1;
88 }
89
90 result.push_str(&s[current_pos..after_fence]);
91
92 let after_content = &s[after_fence..];
93
94 let needs_newline = if after_content.is_empty()
95 || after_content.starts_with('\n')
96 || after_content.starts_with("\r\n")
97 {
98 false
99 } else {
100 let next_newline = after_content.find('\n');
101 let until_newline = match next_newline {
102 Some(pos) => &after_content[..pos],
103 None => after_content,
104 };
105 !until_newline.trim().is_empty()
106 };
107
108 if needs_newline {
109 result.push('\n');
110 }
111
112 current_pos = after_fence;
113 } else {
114 result.push_str(&s[current_pos..]);
116 current_pos = s.len();
117 break;
118 }
119 }
120
121 if current_pos < s.len() {
122 result.push_str(&s[current_pos..]);
123 }
124
125 result
126}
127
128pub fn normalize_markdown(markdown: &str) -> String {
131 let cleaned = normalize_line_endings(markdown);
132 let cleaned = strip_bidi_formatting(&cleaned);
133 fix_html_comment_fences(&cleaned)
134}
135
136fn normalize_line_endings(s: &str) -> String {
142 if !s.contains('\r') {
143 return s.to_string();
144 }
145 let mut out = String::with_capacity(s.len());
146 let mut chars = s.chars().peekable();
147 while let Some(c) = chars.next() {
148 if c == '\r' {
149 if chars.peek() == Some(&'\n') {
150 chars.next();
151 }
152 out.push('\n');
153 } else {
154 out.push(c);
155 }
156 }
157 out
158}
159
160pub fn normalize_field_name(name: &str) -> String {
163 name.nfc().collect()
164}
165
166pub fn normalize_document(
175 doc: crate::document::Document,
176) -> Result<crate::document::Document, crate::error::ParseError> {
177 use crate::document::Document;
178
179 let main = normalize_card(doc.main());
180 let normalized_cards: Vec<Card> = doc.cards().iter().map(normalize_card).collect();
181
182 Ok(Document::from_main_and_cards(
183 main,
184 normalized_cards,
185 doc.warnings().to_vec(),
186 ))
187}
188
189fn normalize_card(card: &Card) -> Card {
191 use crate::document::PayloadItem;
192 let mut payload = card.payload().clone();
193 for item in payload.items_mut() {
194 if let PayloadItem::Field { key, .. } = item {
195 let normalized = normalize_field_name(key);
196 if normalized != *key {
197 *key = normalized;
198 }
199 }
200 }
201 Card::from_parts(payload, normalize_markdown(card.body()))
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn test_strip_bidi_no_change() {
210 assert_eq!(strip_bidi_formatting("hello world"), "hello world");
211 assert_eq!(strip_bidi_formatting(""), "");
212 assert_eq!(strip_bidi_formatting("**bold** text"), "**bold** text");
213 }
214
215 #[test]
216 fn test_strip_bidi_lro() {
217 assert_eq!(strip_bidi_formatting("he\u{202D}llo"), "hello");
218 assert_eq!(
219 strip_bidi_formatting("**asdf** or \u{202D}**(1234**"),
220 "**asdf** or **(1234**"
221 );
222 }
223
224 #[test]
225 fn test_strip_bidi_rlo() {
226 assert_eq!(strip_bidi_formatting("he\u{202E}llo"), "hello");
227 }
228
229 #[test]
230 fn test_strip_bidi_marks() {
231 assert_eq!(strip_bidi_formatting("a\u{200E}b\u{200F}c"), "abc");
232 }
233
234 #[test]
235 fn test_strip_bidi_embeddings() {
236 assert_eq!(
237 strip_bidi_formatting("\u{202A}text\u{202B}more\u{202C}"),
238 "textmore"
239 );
240 }
241
242 #[test]
243 fn test_strip_bidi_isolates() {
244 assert_eq!(
245 strip_bidi_formatting("\u{2066}a\u{2067}b\u{2068}c\u{2069}"),
246 "abc"
247 );
248 }
249
250 #[test]
251 fn test_strip_bidi_all_chars() {
252 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}";
253 assert_eq!(strip_bidi_formatting(all_bidi), "");
254 }
255
256 #[test]
257 fn test_strip_bidi_arabic_letter_mark() {
258 assert_eq!(strip_bidi_formatting("hello\u{061C}world"), "helloworld");
259 assert_eq!(strip_bidi_formatting("\u{061C}**bold**"), "**bold**");
260 }
261
262 #[test]
263 fn test_strip_bidi_unicode_preserved() {
264 assert_eq!(strip_bidi_formatting("你好世界"), "你好世界");
265 assert_eq!(strip_bidi_formatting("مرحبا"), "مرحبا");
266 assert_eq!(strip_bidi_formatting("🎉"), "🎉");
267 }
268
269 #[test]
270 fn test_normalize_markdown_basic() {
271 assert_eq!(normalize_markdown("hello"), "hello");
272 assert_eq!(
273 normalize_markdown("**bold** \u{202D}**more**"),
274 "**bold** **more**"
275 );
276 }
277
278 #[test]
279 fn test_normalize_markdown_html_comment() {
280 assert_eq!(
281 normalize_markdown("<!-- comment -->Some text"),
282 "<!-- comment -->\nSome text"
283 );
284 }
285
286 #[test]
287 fn test_fix_html_comment_no_comment() {
288 assert_eq!(fix_html_comment_fences("hello world"), "hello world");
289 assert_eq!(fix_html_comment_fences("**bold** text"), "**bold** text");
290 assert_eq!(fix_html_comment_fences(""), "");
291 }
292
293 #[test]
294 fn test_fix_html_comment_single_line_trailing_text() {
295 assert_eq!(
296 fix_html_comment_fences("<!-- comment -->Same line text"),
297 "<!-- comment -->\nSame line text"
298 );
299 }
300
301 #[test]
302 fn test_fix_html_comment_already_newline() {
303 assert_eq!(
304 fix_html_comment_fences("<!-- comment -->\nNext line text"),
305 "<!-- comment -->\nNext line text"
306 );
307 }
308
309 #[test]
310 fn test_fix_html_comment_only_whitespace_after() {
311 assert_eq!(
312 fix_html_comment_fences("<!-- comment --> \nSome text"),
313 "<!-- comment --> \nSome text"
314 );
315 }
316
317 #[test]
318 fn test_fix_html_comment_multiline_trailing_text() {
319 assert_eq!(
320 fix_html_comment_fences("<!--\nmultiline\ncomment\n-->Trailing text"),
321 "<!--\nmultiline\ncomment\n-->\nTrailing text"
322 );
323 }
324
325 #[test]
326 fn test_fix_html_comment_multiline_proper() {
327 assert_eq!(
328 fix_html_comment_fences("<!--\nmultiline\n-->\n\nParagraph text"),
329 "<!--\nmultiline\n-->\n\nParagraph text"
330 );
331 }
332
333 #[test]
334 fn test_fix_html_comment_multiple_comments() {
335 assert_eq!(
336 fix_html_comment_fences("<!-- first -->Text\n\n<!-- second -->More text"),
337 "<!-- first -->\nText\n\n<!-- second -->\nMore text"
338 );
339 }
340
341 #[test]
342 fn test_fix_html_comment_end_of_string() {
343 assert_eq!(
344 fix_html_comment_fences("Some text before <!-- comment -->"),
345 "Some text before <!-- comment -->"
346 );
347 }
348
349 #[test]
350 fn test_fix_html_comment_only_comment() {
351 assert_eq!(
352 fix_html_comment_fences("<!-- comment -->"),
353 "<!-- comment -->"
354 );
355 }
356
357 #[test]
358 fn test_fix_html_comment_arrow_not_comment() {
359 assert_eq!(fix_html_comment_fences("-->some text"), "-->some text");
360 }
361
362 #[test]
363 fn test_fix_html_comment_nested_opener() {
364 assert_eq!(
366 fix_html_comment_fences("<!-- <!-- -->Trailing"),
367 "<!-- <!-- -->\nTrailing"
368 );
369 }
370
371 #[test]
372 fn test_fix_html_comment_unmatched_closer() {
373 assert_eq!(
374 fix_html_comment_fences("text --> more text"),
375 "text --> more text"
376 );
377 }
378
379 #[test]
380 fn test_fix_html_comment_multiple_valid_invalid() {
381 let input = "<!-- valid -->FixMe\ntext --> Ignore\n<!-- valid2 -->FixMe2";
382 let expected = "<!-- valid -->\nFixMe\ntext --> Ignore\n<!-- valid2 -->\nFixMe2";
383 assert_eq!(fix_html_comment_fences(input), expected);
384 }
385
386 #[test]
387 fn test_fix_html_comment_crlf() {
388 assert_eq!(
389 fix_html_comment_fences("<!-- comment -->\r\nSome text"),
390 "<!-- comment -->\r\nSome text"
391 );
392 }
393
394 #[test]
395 fn test_fix_html_comment_triple_hyphen_single_line() {
396 assert_eq!(
397 fix_html_comment_fences("<!--- comment --->Trailing text"),
398 "<!--- comment --->\nTrailing text"
399 );
400 }
401
402 #[test]
403 fn test_fix_html_comment_triple_hyphen_multiline() {
404 assert_eq!(
405 fix_html_comment_fences("<!---\ncomment\n--->Trailing text"),
406 "<!---\ncomment\n--->\nTrailing text"
407 );
408 }
409
410 #[test]
411 fn test_normalize_document_basic() {
412 use crate::document::Document;
413
414 let doc = Document::from_markdown(
415 "~~~card-yaml\n$quill: test\n$kind: main\ntitle: <<placeholder>>\n~~~\n\n<<content>> \u{202D}**bold**",
416 )
417 .unwrap();
418 let normalized = super::normalize_document(doc).unwrap();
419
420 assert_eq!(
421 normalized
422 .main()
423 .payload()
424 .get("title")
425 .unwrap()
426 .as_str()
427 .unwrap(),
428 "<<placeholder>>"
429 );
430
431 assert_eq!(normalized.main().body(), "\n<<content>> **bold**");
432 }
433
434 #[test]
435 fn test_normalize_document_preserves_quill_tag() {
436 use crate::document::Document;
437
438 let doc = Document::from_markdown("~~~card-yaml\n$quill: custom_quill\n$kind: main\n~~~\n")
439 .unwrap();
440 let normalized = super::normalize_document(doc).unwrap();
441
442 assert_eq!(normalized.quill_reference().name, "custom_quill");
443 }
444
445 #[test]
446 fn test_normalize_document_idempotent() {
447 use crate::document::Document;
448
449 let doc =
450 Document::from_markdown("~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n<<content>>")
451 .unwrap();
452 let normalized_once = super::normalize_document(doc).unwrap();
453 let normalized_twice = super::normalize_document(normalized_once.clone()).unwrap();
454
455 assert_eq!(
456 normalized_once.main().body(),
457 normalized_twice.main().body()
458 );
459 }
460
461 #[test]
462 fn test_normalize_document_body_bidi_stripped() {
463 use crate::document::Document;
464
465 let doc = Document::from_markdown(
466 "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\nhello\u{202D}world",
467 )
468 .unwrap();
469 let normalized = super::normalize_document(doc).unwrap();
470 assert_eq!(normalized.main().body(), "\nhelloworld");
471 }
472
473 #[test]
474 fn test_normalize_document_yaml_field_bidi_preserved() {
475 use crate::document::Document;
476
477 let doc = Document::from_markdown(
478 "~~~card-yaml\n$quill: test\n$kind: main\ntitle: a\u{202D}b\n~~~\n",
479 )
480 .unwrap();
481 let normalized = super::normalize_document(doc).unwrap();
482 assert_eq!(
483 normalized
484 .main()
485 .payload()
486 .get("title")
487 .unwrap()
488 .as_str()
489 .unwrap(),
490 "a\u{202D}b"
491 );
492 }
493
494 #[test]
495 fn test_normalize_document_card_body_bidi_stripped() {
496 use crate::document::Document;
497
498 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";
499 let doc = Document::from_markdown(md).unwrap();
500 assert_eq!(doc.cards().len(), 1, "expected 1 card");
501 let normalized = super::normalize_document(doc).unwrap();
502 assert_eq!(normalized.cards()[0].body(), "cardbody\n");
503 }
504
505 #[test]
506 fn test_normalize_document_card_field_bidi_preserved() {
507 use crate::document::Document;
508
509 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";
510 let doc = Document::from_markdown(md).unwrap();
511 assert_eq!(doc.cards().len(), 1, "expected 1 card");
512 let normalized = super::normalize_document(doc).unwrap();
513 assert_eq!(
514 normalized.cards()[0]
515 .payload()
516 .get("name")
517 .unwrap()
518 .as_str()
519 .unwrap(),
520 "Ali\u{202D}ce"
521 );
522 }
523
524 #[test]
525 fn test_normalize_document_card_body_html_comment_repair() {
526 use crate::document::Document;
527
528 let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n~~~card-yaml\n$kind: note\n~~~\n<!-- comment -->Trailing text\n";
529 let doc = Document::from_markdown(md).unwrap();
530 let normalized = super::normalize_document(doc).unwrap();
531 assert_eq!(
532 normalized.cards()[0].body(),
533 "<!-- comment -->\nTrailing text\n"
534 );
535 }
536
537 #[test]
538 fn test_normalize_document_toplevel_body_html_comment_repair() {
539 use crate::document::Document;
540
541 let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n<!-- note -->Content here";
542 let doc = Document::from_markdown(md).unwrap();
543 let normalized = super::normalize_document(doc).unwrap();
544 assert_eq!(normalized.main().body(), "\n<!-- note -->\nContent here");
545 }
546}