quillmark_core/
normalize.rs1use 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}' | '\u{200E}' | '\u{200F}' | '\u{202A}' | '\u{202B}' | '\u{202C}' | '\u{202D}' | '\u{202E}' | '\u{2066}' | '\u{2067}' | '\u{2068}' | '\u{2069}' )
49}
50
51pub 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
63pub 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 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 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
134pub 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
142fn 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
166pub fn normalize_field_name(name: &str) -> String {
169 name.nfc().collect()
170}
171
172pub 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
195fn 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 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}