1#[must_use]
26pub fn is_python_space(c: char) -> bool {
27 matches!(c,
28 '\u{9}'..='\u{d}' | '\u{1c}'..='\u{1f}' | '\u{20}' | '\u{85}' | '\u{a0}' | '\u{1680}' | '\u{2000}'..='\u{200a}'
35 | '\u{2028}' | '\u{2029}' | '\u{202f}' | '\u{205f}' | '\u{3000}' )
41}
42
43#[must_use]
45pub fn py_trim(s: &str) -> &str {
46 s.trim_matches(is_python_space)
47}
48
49#[must_use]
51pub fn py_trim_start(s: &str) -> &str {
52 s.trim_start_matches(is_python_space)
53}
54
55#[must_use]
57pub fn py_trim_end(s: &str) -> &str {
58 s.trim_end_matches(is_python_space)
59}
60
61#[must_use]
63pub fn split_eol(line: &str) -> (&str, &str) {
64 for eol in ["\r\n", "\n", "\r"] {
67 if let Some(body) = line.strip_suffix(eol) {
68 return (body, eol);
69 }
70 }
71 (line, "")
72}
73
74#[must_use]
80pub fn py_splitlines_keepends(text: &str) -> Vec<&str> {
81 let bytes = text.as_bytes();
85 let mut lines = Vec::new();
86 let mut start = 0;
87 let mut index = 0;
88 while index < bytes.len() {
89 let end = match bytes[index] {
90 b'\r' if bytes.get(index + 1) == Some(&b'\n') => index + 2,
91 b'\r' | b'\n' => index + 1,
92 _ => {
93 index += 1;
94 continue;
95 }
96 };
97 lines.push(&text[start..end]);
98 index = end;
99 start = end;
100 }
101 if start < bytes.len() {
102 lines.push(&text[start..]);
103 }
104 lines
105}
106
107#[must_use]
109pub fn has_hard_break(body: &str) -> bool {
110 body.ends_with('\\') || body.ends_with(" ")
111}
112
113#[must_use]
119pub fn starts_front_matter(lines: &[&str]) -> bool {
120 let Some(first) = lines.first() else {
121 return false;
122 };
123 let opener = split_eol(first).0;
124 if opener.strip_prefix('\u{feff}').unwrap_or(opener) != "---" {
126 return false;
127 }
128 lines[1..]
129 .iter()
130 .any(|line| matches!(split_eol(line).0, "---" | "..."))
131}
132
133#[must_use]
135pub fn match_opening_fence(body: &str) -> Option<(char, usize)> {
136 let bytes = body.as_bytes();
137 let indent = leading_spaces(bytes);
138 if indent > 3 {
139 return None;
140 }
141 let fence_char = match bytes.get(indent) {
142 Some(b'`') => '`',
143 Some(b'~') => '~',
144 _ => return None,
145 };
146 let run = bytes[indent..]
147 .iter()
148 .take_while(|b| **b == fence_char as u8)
149 .count();
150 (run >= 3).then_some((fence_char, run))
151}
152
153#[must_use]
155pub fn is_closing_fence(body: &str, fence_char: char, fence_len: usize) -> bool {
156 let stripped = body.trim_start_matches(' ');
157 if body.len() - stripped.len() > 3 {
158 return false;
159 }
160 let mut rest = stripped;
161 for _ in 0..fence_len {
162 match rest.strip_prefix(fence_char) {
163 Some(shorter) => rest = shorter,
164 None => return false,
165 }
166 }
167 py_trim(rest).chars().all(|c| c == fence_char)
171}
172
173#[must_use]
175pub fn match_blockquote(body: &str) -> Option<(&str, &str)> {
176 let end = match_blockquote_once(body)?;
177 Some((&body[..end], &body[end..]))
178}
179
180#[must_use]
182pub fn match_blockquote_prefix(body: &str) -> Option<usize> {
183 let mut end = 0;
184 while let Some(step) = match_blockquote_once(&body[end..]) {
186 end += step;
187 }
188 (end > 0).then_some(end)
189}
190
191#[must_use]
197pub fn strip_blockquote_prefix(body: &str) -> &str {
198 match match_blockquote_prefix(body) {
199 Some(end) => &body[end..],
200 None => body,
201 }
202}
203
204#[must_use]
215pub fn match_list_marker(body: &str) -> Option<(&str, usize, &str)> {
216 let bytes = body.as_bytes();
217 let indent = leading_spaces(bytes);
218 if indent > 3 {
219 return None;
220 }
221 let after_marker = match_marker(bytes, indent)?;
222 let mut cursor = after_marker;
223 while bytes.get(cursor) == Some(&b' ') {
224 cursor += 1;
225 }
226 if cursor == after_marker {
227 return None;
228 }
229 Some((&body[..cursor], cursor, &body[cursor..]))
230}
231
232#[must_use]
234pub fn is_list_line(body: &str) -> bool {
235 let bytes = body.as_bytes();
236 let Some(after_marker) = match_marker(bytes, 0) else {
239 return false;
240 };
241 body[after_marker..]
242 .chars()
243 .next()
244 .is_some_and(is_python_space)
245}
246
247#[must_use]
250pub fn is_alpha_list_line(body: &str) -> bool {
251 let bytes = body.as_bytes();
252 if !bytes.first().is_some_and(u8::is_ascii_alphabetic) {
253 return false;
254 }
255 if !matches!(bytes.get(1), Some(b'.' | b')')) {
256 return false;
257 }
258 body[2..].chars().next().is_some_and(is_python_space)
262}
263
264#[must_use]
266pub fn is_setext_line(body: &str) -> bool {
267 let Some(first) = body.chars().next() else {
268 return false;
269 };
270 if first != '=' && first != '-' {
271 return false;
272 }
273 body.trim_start_matches(first).chars().all(is_python_space)
276}
277
278#[must_use]
283pub fn is_thematic_break(body: &str) -> bool {
284 if !body.starts_with(['-', '*', '_']) {
287 return false;
288 }
289 let mut markers = 0usize;
290 for c in body.chars() {
291 if matches!(c, '-' | '*' | '_') {
292 markers += 1;
293 } else if !is_python_space(c) {
294 return false;
295 }
296 }
297 markers >= 3
298}
299
300#[must_use]
302pub fn is_link_reference(body: &str) -> bool {
303 let Some(rest) = body.strip_prefix('[') else {
304 return false;
305 };
306 match rest.find(']') {
309 None | Some(0) => false,
310 Some(index) => rest[index + 1..].starts_with(':'),
311 }
312}
313
314#[must_use]
316pub fn match_html_tag_name(body: &str) -> Option<&str> {
317 let rest = body.strip_prefix('<')?;
318 if !rest.as_bytes().first()?.is_ascii_alphabetic() {
319 return None;
320 }
321 let end = rest
322 .as_bytes()
323 .iter()
324 .position(|b| !(b.is_ascii_alphanumeric() || *b == b'-'))
325 .unwrap_or(rest.len());
326 Some(&rest[..end])
327}
328
329#[must_use]
337pub fn match_opening_html_block(body: &str) -> Option<String> {
338 let stripped = py_trim(body);
339 if !stripped.starts_with('<') {
340 return None;
341 }
342 for prefix in ["<!--", "-->", "<?", "<![", "<!", "</"] {
346 if stripped.starts_with(prefix) {
347 return None;
348 }
349 }
350 if stripped.ends_with("/>") {
351 return None;
352 }
353 let name = match_html_tag_name(stripped)?.to_ascii_lowercase();
354 if stripped.to_lowercase().contains(&format!("</{name}>")) {
355 return None;
356 }
357 Some(name)
358}
359
360#[must_use]
362pub fn match_opening_html_literal_terminator(body: &str) -> Option<&'static str> {
363 let stripped = py_trim_start(body);
364 for (opener, terminator) in [("<!--", "-->"), ("<?", "?>"), ("<![CDATA[", "]]>")] {
365 if let Some(tail) = stripped.strip_prefix(opener) {
366 if !tail.contains(terminator) {
367 return Some(terminator);
368 }
369 }
370 }
371 let mut chars = stripped.chars();
374 if chars.next() != Some('<') || chars.next() != Some('!') {
375 return None;
376 }
377 let third = chars.next()?;
378 (third.is_ascii_uppercase() && !chars.as_str().contains('>')).then_some(">")
379}
380
381#[must_use]
383pub fn is_gfm_alert(body: &str) -> bool {
384 let Some(rest) = body.strip_prefix("[!") else {
385 return false;
386 };
387 let bytes = rest.as_bytes();
388 if !bytes.first().is_some_and(u8::is_ascii_uppercase) {
389 return false;
390 }
391 let end = bytes
392 .iter()
393 .position(|b| !(b.is_ascii_uppercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-')))
394 .unwrap_or(bytes.len());
395 let Some(tail) = rest[end..].strip_prefix(']') else {
396 return false;
397 };
398 let tail = tail.strip_prefix(['+', '-']).unwrap_or(tail);
399 tail.is_empty() || tail == "\n"
403}
404
405#[must_use]
407pub fn is_raw_html_tag(name: &str) -> bool {
408 matches!(name, "pre" | "script" | "style" | "textarea")
409}
410
411fn leading_spaces(bytes: &[u8]) -> usize {
413 bytes.iter().take(4).take_while(|b| **b == b' ').count()
414}
415
416fn match_blockquote_once(body: &str) -> Option<usize> {
418 let bytes = body.as_bytes();
419 let indent = leading_spaces(bytes);
420 if indent > 3 || bytes.get(indent) != Some(&b'>') {
421 return None;
422 }
423 let mut end = indent + 1;
424 if bytes.get(end) == Some(&b' ') {
426 end += 1;
427 }
428 Some(end)
429}
430
431fn match_marker(bytes: &[u8], start: usize) -> Option<usize> {
437 let mut cursor = start;
438 match bytes.get(cursor)? {
439 b'-' | b'+' | b'*' => return Some(cursor + 1),
440 b'0'..=b'9' => {
441 while matches!(bytes.get(cursor), Some(b'0'..=b'9')) {
442 cursor += 1;
443 }
444 }
445 _ => return None,
446 }
447 match bytes.get(cursor) {
450 Some(b'.' | b')') => Some(cursor + 1),
451 _ => None,
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn python_whitespace_matches_rusts_view_plus_four() {
461 for cp in 0..=0x10_FFFFu32 {
465 let Some(c) = char::from_u32(cp) else {
466 continue;
467 };
468 let expected = c.is_whitespace() || matches!(c, '\u{1c}'..='\u{1f}');
469 assert_eq!(is_python_space(c), expected, "disagreed on U+{cp:04X}");
470 }
471 assert_eq!(
472 (0..=0x10_FFFFu32)
473 .filter_map(char::from_u32)
474 .filter(|c| is_python_space(*c))
475 .count(),
476 29
477 );
478 }
479
480 #[test]
481 fn python_whitespace_includes_the_c0_separators() {
482 assert!(is_python_space('\u{1c}'));
483 assert!(is_python_space('\u{1f}'));
484 assert!(!'\u{1c}'.is_whitespace());
485 assert_eq!(py_trim("\u{1c}a\u{1e}"), "a");
486 assert_eq!("\u{1c}a\u{1e}".trim(), "\u{1c}a\u{1e}");
487 assert_eq!(py_trim("\u{a0}a\u{a0}"), "a");
489 assert_eq!(py_trim_start("\u{1c}a\u{1e}"), "a\u{1e}");
490 assert_eq!(py_trim_end("\u{1c}a\u{1e}"), "\u{1c}a");
491 }
492
493 #[test]
494 fn an_ordered_list_marker_is_ascii_digits_only() {
495 assert!(is_list_line("1. x"));
498 assert!(!is_list_line("\u{661}. x")); assert!(!is_list_line("\u{967}. x")); assert!(match_list_marker("\u{661}. x").is_none());
501 }
502
503 #[test]
504 fn a_list_marker_reports_its_content_column() {
505 assert_eq!(match_list_marker("12. x"), Some(("12. ", 4, "x")));
508 assert_eq!(match_list_marker("- x"), Some(("- ", 2, "x")));
509 assert_eq!(match_list_marker("- x"), Some(("- ", 3, "x")));
510 assert_eq!(match_list_marker(" - x"), Some((" - ", 5, "x")));
511 assert_eq!(match_list_marker(" - x"), None);
512 assert_eq!(match_list_marker("-x"), None);
513 assert_eq!(match_list_marker("1.x"), None);
514 assert_eq!(match_list_marker("1a. x"), None);
515 }
516
517 #[test]
518 fn list_marker_needs_a_space_where_list_line_takes_any_whitespace() {
519 assert!(match_list_marker("-\tx").is_none());
520 assert!(is_list_line("-\tx"));
521 assert!(is_list_line("*\u{a0}x"));
523 assert!(!is_list_line(" - x"));
525 }
526
527 #[test]
528 fn an_alpha_enumerator_needs_one_whitespace() {
529 assert!(is_alpha_list_line("a. x"));
530 assert!(is_alpha_list_line("a) x"));
531 assert!(is_alpha_list_line("a.\tx"));
532 assert!(is_alpha_list_line("A. x"));
533 assert!(!is_alpha_list_line("a.x"));
534 assert!(!is_alpha_list_line("ab. x"));
535 assert!(!is_alpha_list_line("a."));
536 }
537
538 #[test]
539 fn a_closing_fence_tolerates_python_whitespace_after_it() {
540 assert!(is_closing_fence("```\u{1c}", '`', 3));
541 assert!(is_closing_fence("```", '`', 3));
542 assert!(is_closing_fence(" ```", '`', 3));
543 assert!(is_closing_fence("``` ```", '`', 3));
544 assert!(is_closing_fence("````", '`', 3));
545 assert!(!is_closing_fence(" ```", '`', 3));
546 assert!(!is_closing_fence("```x", '`', 3));
547 assert!(!is_closing_fence("``", '`', 3));
548 }
549
550 #[test]
551 fn an_opening_fence_reports_its_character_and_length() {
552 assert_eq!(match_opening_fence("```"), Some(('`', 3)));
553 assert_eq!(match_opening_fence(" ```"), Some(('`', 3)));
554 assert_eq!(match_opening_fence("~~~~"), Some(('~', 4)));
555 assert_eq!(match_opening_fence("```rust"), Some(('`', 3)));
556 assert_eq!(match_opening_fence(" ~~~ "), Some(('~', 3)));
557 assert_eq!(match_opening_fence(" ```"), None);
558 assert_eq!(match_opening_fence("``"), None);
559 assert_eq!(match_opening_fence("`~`"), None);
560 }
561
562 #[test]
563 fn split_eol_recognizes_the_three_boundaries() {
564 assert_eq!(split_eol("a\r\n"), ("a", "\r\n"));
565 assert_eq!(split_eol("a\n"), ("a", "\n"));
566 assert_eq!(split_eol("a\r"), ("a", "\r"));
567 assert_eq!(split_eol("a"), ("a", ""));
568 assert_eq!(split_eol("\r\n"), ("", "\r\n"));
569 assert_eq!(split_eol(""), ("", ""));
570 }
571
572 #[test]
573 fn splitlines_is_narrow_per_the_specification() {
574 assert_eq!(py_splitlines_keepends("a\u{b}b\n"), vec!["a\u{b}b\n"]);
577 assert_eq!(py_splitlines_keepends("a\u{2028}b\n"), vec!["a\u{2028}b\n"]);
578 assert_eq!(py_splitlines_keepends("a\r\nb\n"), vec!["a\r\n", "b\n"]);
579 assert_eq!(py_splitlines_keepends("a\rb"), vec!["a\r", "b"]);
580 assert_eq!(py_splitlines_keepends("a\n\n"), vec!["a\n", "\n"]);
581 assert!(py_splitlines_keepends("").is_empty());
582 assert_eq!(py_splitlines_keepends("a"), vec!["a"]);
583 }
584
585 #[test]
586 fn a_hard_break_is_a_backslash_or_two_spaces() {
587 assert!(has_hard_break("a "));
588 assert!(has_hard_break("a\\"));
589 assert!(has_hard_break(" "));
590 assert!(!has_hard_break("a "));
591 assert!(!has_hard_break("a"));
592 assert!(!has_hard_break(""));
593 }
594
595 #[test]
596 fn front_matter_needs_a_reachable_closer_and_tolerates_a_bom() {
597 assert!(starts_front_matter(&["---\n", "a: 1\n", "---\n"]));
598 assert!(starts_front_matter(&["\u{feff}---\n", "---\n"]));
599 assert!(starts_front_matter(&["---\n", "...\n"]));
600 assert!(starts_front_matter(&["---\r\n", "---\r\n"]));
601 assert!(!starts_front_matter(&["---\n", "a: 1\n"]));
602 assert!(!starts_front_matter(&[]));
603 assert!(!starts_front_matter(&["--- \n", "---\n"]));
605 }
606
607 #[test]
608 fn a_blockquote_takes_one_level_and_at_most_one_space() {
609 assert_eq!(match_blockquote("> a"), Some(("> ", "a")));
610 assert_eq!(match_blockquote(">a"), Some((">", "a")));
611 assert_eq!(match_blockquote(" > a"), Some((" > ", "a")));
612 assert_eq!(match_blockquote(">"), Some((">", "")));
613 assert_eq!(match_blockquote("> a"), Some(("> ", " a")));
614 assert_eq!(match_blockquote(" > a"), None);
615 assert_eq!(match_blockquote("a"), None);
616 }
617
618 #[test]
619 fn a_blockquote_prefix_strip_fires_at_most_once() {
620 assert_eq!(strip_blockquote_prefix("> > a"), "a");
623 assert_eq!(strip_blockquote_prefix("a\n> b"), "a\n> b");
624 assert_eq!(strip_blockquote_prefix(">>a"), "a");
625 assert_eq!(strip_blockquote_prefix(" > > a"), "a");
626 assert_eq!(strip_blockquote_prefix("> "), "");
627 assert_eq!(strip_blockquote_prefix(" > a"), " > a");
628 assert_eq!(match_blockquote_prefix("> > a"), Some(4));
629 assert_eq!(match_blockquote_prefix(" > > a"), Some(9));
630 assert_eq!(match_blockquote_prefix("no marker"), None);
631 }
632
633 #[test]
634 fn a_setext_run_may_not_mix_its_character() {
635 assert!(is_setext_line("==="));
636 assert!(is_setext_line("---"));
637 assert!(is_setext_line("=== "));
638 assert!(is_setext_line("===\n"));
639 assert!(is_setext_line("===\r\n"));
640 assert!(is_setext_line("===\n\n"));
641 assert!(is_setext_line("===\r"));
643 assert!(!is_setext_line("=-="));
644 assert!(!is_setext_line("= ="));
645 assert!(!is_setext_line(""));
646 }
647
648 #[test]
649 fn a_thematic_break_counts_three_markers_and_may_mix_them() {
650 assert!(is_thematic_break("---"));
651 assert!(is_thematic_break("***"));
652 assert!(is_thematic_break("___"));
653 assert!(is_thematic_break("- - -"));
654 assert!(is_thematic_break("---\n"));
655 assert!(is_thematic_break("-*_"));
657 assert!(is_thematic_break("-\u{a0}-\u{a0}-"));
659 assert!(!is_thematic_break("--"));
660 assert!(!is_thematic_break(" ---"));
661 assert!(!is_thematic_break("---x"));
662 }
663
664 #[test]
665 fn a_link_reference_needs_a_non_empty_label() {
666 assert!(is_link_reference("[a]: b"));
667 assert!(is_link_reference("[a]:"));
668 assert!(is_link_reference("[a\\]: b"));
670 assert!(!is_link_reference("[]: b"));
671 assert!(!is_link_reference("[a] b"));
672 assert!(!is_link_reference("a]: b"));
673 }
674
675 #[test]
676 fn a_tag_name_keeps_its_case_and_starts_with_a_letter() {
677 assert_eq!(match_html_tag_name("<div>"), Some("div"));
678 assert_eq!(match_html_tag_name("<my-tag x>"), Some("my-tag"));
679 assert_eq!(match_html_tag_name("<DIV>"), Some("DIV"));
680 assert_eq!(match_html_tag_name("<a"), Some("a"));
681 assert_eq!(match_html_tag_name("<1div>"), None);
682 assert_eq!(match_html_tag_name("< div>"), None);
683 assert_eq!(match_html_tag_name("div"), None);
684 }
685
686 #[test]
687 fn an_html_block_opener_rejects_what_closes_on_its_own_line() {
688 assert_eq!(match_opening_html_block("<div>").as_deref(), Some("div"));
689 assert_eq!(
690 match_opening_html_block(" <div> ").as_deref(),
691 Some("div")
692 );
693 assert_eq!(match_opening_html_block("<div").as_deref(), Some("div"));
695 assert_eq!(match_opening_html_block("<div>x</div>"), None);
696 assert_eq!(match_opening_html_block("<br/>"), None);
697 assert_eq!(match_opening_html_block("<!-- c -->"), None);
698 assert_eq!(match_opening_html_block("</div>"), None);
699 assert_eq!(match_opening_html_block("<?php"), None);
700 assert_eq!(match_opening_html_block("<![CDATA["), None);
701 assert_eq!(match_opening_html_block("<!DOCTYPE html>"), None);
702 assert_eq!(match_opening_html_block("<DIV>x</div>"), None);
704 assert_eq!(match_opening_html_block("<div>x</DIV>"), None);
705 }
706
707 #[test]
708 fn a_literal_terminator_is_reported_only_while_it_is_still_open() {
709 assert_eq!(
710 match_opening_html_literal_terminator("<!-- open"),
711 Some("-->")
712 );
713 assert_eq!(
714 match_opening_html_literal_terminator(" <!-- open"),
715 Some("-->")
716 );
717 assert_eq!(match_opening_html_literal_terminator("<?php"), Some("?>"));
718 assert_eq!(
719 match_opening_html_literal_terminator("<![CDATA[x"),
720 Some("]]>")
721 );
722 assert_eq!(
723 match_opening_html_literal_terminator("<!DOCTYPE html"),
724 Some(">")
725 );
726 assert_eq!(
727 match_opening_html_literal_terminator("<!-- closed -->"),
728 None
729 );
730 assert_eq!(match_opening_html_literal_terminator("<?php ?>"), None);
731 assert_eq!(match_opening_html_literal_terminator("<![CDATA[x]]>"), None);
732 assert_eq!(
733 match_opening_html_literal_terminator("<!DOCTYPE html>"),
734 None
735 );
736 assert_eq!(match_opening_html_literal_terminator("<!x"), None);
738 assert_eq!(match_opening_html_literal_terminator("<!"), None);
739 }
740
741 #[test]
742 fn a_gfm_alert_ends_at_the_end_or_before_one_trailing_newline() {
743 assert!(is_gfm_alert("[!NOTE]"));
744 assert!(is_gfm_alert("[!NOTE]+"));
745 assert!(is_gfm_alert("[!NOTE]-"));
746 assert!(is_gfm_alert("[!NOTE]\n"));
747 assert!(is_gfm_alert("[!N0-T_E]"));
748 assert!(!is_gfm_alert("[!NOTE]\r"));
750 assert!(!is_gfm_alert("[!NOTE]\n\n"));
751 assert!(!is_gfm_alert("[!note]"));
752 assert!(!is_gfm_alert("[!]"));
753 assert!(!is_gfm_alert("[NOTE]"));
754 assert!(!is_gfm_alert("[!NOTE]x"));
755 }
756
757 #[test]
758 fn the_raw_html_tags_are_the_four_that_hold_literal_text() {
759 for name in ["pre", "script", "style", "textarea"] {
760 assert!(is_raw_html_tag(name));
761 }
762 assert!(!is_raw_html_tag("div"));
763 assert!(!is_raw_html_tag("PRE"));
764 }
765}