Skip to main content

markdown_prose_hooks/
scan.rs

1//! Structural line matchers: what a line *is*, before anything decides what to
2//! do with it.
3//!
4//! Each function ports one compiled pattern or one small helper from
5//! `src/markdown_prose_hooks/unwrap.py`, and the pattern it answers to is named
6//! in its documentation. Nothing here holds state or reads more than the line it
7//! is given.
8//!
9//! Every expected value in the tests below was produced by running the Python
10//! rather than by reading the pattern. Reading a regex and writing down what it
11//! ought to do reproduces the reader's misunderstanding in a second language,
12//! which is the one failure a second implementation is supposed to catch.
13
14/// Python's `str.isspace()`, `str.strip()`, and `\s` on a `str` pattern.
15///
16/// One set of 29 code points, verified identical across all three on 3.10 and
17/// 3.13: the Unicode `White_Space` property plus the four C0 separators
18/// `U+001C`-`U+001F`. Rust's `char::is_whitespace` is `White_Space` alone, 25
19/// points, so `str::trim` is not `str.strip()` and is never used to port it.
20///
21/// Written out rather than delegated to `char::is_whitespace` plus the four,
22/// because this predicate decides parity and should not depend on which Unicode
23/// version the compiler shipped with. `python_whitespace_matches_rusts_view`
24/// below is the drift detector for that choice.
25#[must_use]
26pub fn is_python_space(c: char) -> bool {
27    matches!(c,
28        '\u{9}'..='\u{d}'        // tab, newline, vertical tab, form feed, return
29        | '\u{1c}'..='\u{1f}'    // file, group, record, unit separator
30        | '\u{20}'               // space
31        | '\u{85}'               // next line
32        | '\u{a0}'               // no-break space
33        | '\u{1680}'             // ogham space mark
34        | '\u{2000}'..='\u{200a}'
35        | '\u{2028}'             // line separator
36        | '\u{2029}'             // paragraph separator
37        | '\u{202f}'             // narrow no-break space
38        | '\u{205f}'             // medium mathematical space
39        | '\u{3000}'             // ideographic space
40    )
41}
42
43/// Python's `str.strip()`.
44#[must_use]
45pub fn py_trim(s: &str) -> &str {
46    s.trim_matches(is_python_space)
47}
48
49/// Python's `str.lstrip()`.
50#[must_use]
51pub fn py_trim_start(s: &str) -> &str {
52    s.trim_start_matches(is_python_space)
53}
54
55/// Python's `str.rstrip()`.
56#[must_use]
57pub fn py_trim_end(s: &str) -> &str {
58    s.trim_end_matches(is_python_space)
59}
60
61/// `_split_eol`: return `(body, eol)` where `eol` is `\r\n`, `\n`, `\r` or empty.
62#[must_use]
63pub fn split_eol(line: &str) -> (&str, &str) {
64    // `\r\n` first, or a CRLF line reports a `\n` terminator and keeps the `\r`
65    // as the last byte of its body.
66    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/// `_split_lines(keepends=True)`: split on `\r\n`, `\n` and `\r`, and nothing else.
75///
76/// Deliberately not `str::lines` and deliberately not `str.splitlines`. Task 3
77/// narrowed the specification to these three boundaries in both languages; a
78/// vertical tab is content, and joining across one deleted it.
79#[must_use]
80pub fn py_splitlines_keepends(text: &str) -> Vec<&str> {
81    // Scanning bytes is safe for exactly these two: `\r` and `\n` are ASCII, so
82    // they never occur inside a multi-byte UTF-8 sequence and every index this
83    // slices at is a character boundary.
84    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/// `_has_hard_break`: a trailing backslash or two trailing spaces.
108#[must_use]
109pub fn has_hard_break(body: &str) -> bool {
110    body.ends_with('\\') || body.ends_with("  ")
111}
112
113/// `_starts_front_matter`: `---` on line one with a reachable closer below it.
114///
115/// A bare `---` is ambiguous between front matter and a thematic break, so it
116/// only counts when a closing `---` or `...` exists somewhere later. The scan is
117/// unbounded on purpose: a bounded one corrupts a long YAML header.
118#[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    // `removeprefix` leaves the string alone when the prefix is absent.
125    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/// `_MATCH_FENCE`: return `(fence_char, fence_len)` for a fenced code opener.
134#[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/// `_is_closing_fence`: does `body` close a fence of this character and length?
154#[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    // Python asks whether the set of remaining characters is a subset of
168    // `{fence_char}`, and the empty set satisfies that -- as does an all-empty
169    // iterator here.
170    py_trim(rest).chars().all(|c| c == fence_char)
171}
172
173/// `_MATCH_BLOCKQUOTE`: return `(prefix, rest)` for one blockquote level.
174#[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/// `_MATCH_BLOCKQUOTE_PREFIX`: the byte length of the whole marker stack.
181#[must_use]
182pub fn match_blockquote_prefix(body: &str) -> Option<usize> {
183    let mut end = 0;
184    // Each level consumes at least the `>`, so this always makes progress.
185    while let Some(step) = match_blockquote_once(&body[end..]) {
186        end += step;
187    }
188    (end > 0).then_some(end)
189}
190
191/// `_SUB_BLOCKQUOTE_PREFIX('', body)`: peel every blockquote level, or none.
192///
193/// The Python calls `re.sub`, whose default is replace-all, but the pattern is
194/// anchored with `^` and compiled without `re.MULTILINE`, so it can only fire at
195/// offset 0. A line further down a multi-line string keeps its marker.
196#[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/// `_MATCH_LIST_MARKER`: return `(prefix, content_col, rest)`.
205///
206/// `content_col` is a byte offset, and since the narrowing to ASCII digits the
207/// whole prefix is ASCII -- a bounded run of spaces, the marker, then one or
208/// more spaces -- so it is also the character offset the Python reports. That
209/// was not true while the marker could be a Devanagari digit.
210///
211/// The trailing run here is **ASCII spaces only**, where `is_list_line` takes
212/// any Python whitespace. They look like one predicate and are not: a tab after
213/// the marker gives `None` here and `true` there.
214#[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/// `_MATCH_LIST`: a marker at column zero followed by any Python whitespace.
233#[must_use]
234pub fn is_list_line(body: &str) -> bool {
235    let bytes = body.as_bytes();
236    // No indent is accepted at all, unlike `match_list_marker`: callers hand
237    // this one an already-stripped line.
238    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/// `_MATCH_ALPHA_LIST`: `a.` / `b)` sub-enumerators, which CommonMark does not
248/// treat as list markers but which are still load-bearing layout.
249#[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    // Exactly one whitespace character is required, not one or more -- the
259    // pattern ends `\s` rather than `\s+`. With nothing after it in the pattern
260    // that is the same acceptance, but it is worth not "fixing".
261    body[2..].chars().next().is_some_and(is_python_space)
262}
263
264/// `_MATCH_SETEXT`: a run of `=` or a run of `-`, then whitespace to the end.
265#[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    // The alternation is `=+|-+`, so the run may not mix: `=-=` matches neither
274    // branch. Trimming the first character's own run reproduces that.
275    body.trim_start_matches(first).chars().all(is_python_space)
276}
277
278/// `_MATCH_THEMATIC`: three or more `-`, `*` or `_`, whitespace permitted between.
279///
280/// Mixing the three is accepted, which CommonMark does not do; that is the
281/// Python's shape and this is a port, not a correction.
282#[must_use]
283pub fn is_thematic_break(body: &str) -> bool {
284    // Each repetition is marker-then-whitespace, so the line cannot start with
285    // whitespace however much of it follows a marker.
286    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/// `_MATCH_LINK_REFERENCE`: `[label]:` with a non-empty label.
301#[must_use]
302pub fn is_link_reference(body: &str) -> bool {
303    let Some(rest) = body.strip_prefix('[') else {
304        return false;
305    };
306    // `[^\]]+` is greedy but cannot cross a `]`, so it always ends at the first
307    // one; backtracking never reaches a later `]`.
308    match rest.find(']') {
309        None | Some(0) => false,
310        Some(index) => rest[index + 1..].starts_with(':'),
311    }
312}
313
314/// `_MATCH_HTML_TAG_NAME`: the tag name of an opening tag, case preserved.
315#[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/// `match_opening_html_block`: the tag name of a block that stays open.
330///
331/// Two different lowercasings, and they are not interchangeable. The tag *name*
332/// comes from `[a-zA-Z][a-zA-Z0-9-]*` and is ASCII by construction, so
333/// `to_ascii_lowercase` is exact and cheap. The *line* is lowercased with
334/// Python's full-Unicode `str.lower()`, which can change length and can fold
335/// non-ASCII onto ASCII, so it needs `to_lowercase`.
336#[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    // `-->` cannot reach here past the check above; it is carried because the
343    // Python carries it, and a port that drops a redundant guard invites the
344    // next reader to wonder which one of them was wrong.
345    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/// `_match_opening_html_literal_terminator`: what would close a raw HTML literal.
361#[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    // A declaration such as `<!DOCTYPE html`. Python asks for `isascii() and
372    // isupper()` on the third character, which together are exactly A-Z.
373    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/// `_MATCH_GFM_ALERT`: `[!NOTE]`, `[!TIP]-`, and the rest of the alert syntax.
382#[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    // Python's `$` outside MULTILINE matches at the end of the string or just
400    // before a newline that *is* the last character -- and only `\n`. A
401    // trailing `\r` therefore fails where a trailing `\n` passes.
402    tail.is_empty() || tail == "\n"
403}
404
405/// `_RAW_HTML_TAGS`: tags whose content is literal and must not be reflowed.
406#[must_use]
407pub fn is_raw_html_tag(name: &str) -> bool {
408    matches!(name, "pre" | "script" | "style" | "textarea")
409}
410
411/// Count leading ASCII spaces, stopping at four so the caller can reject `> 3`.
412fn leading_spaces(bytes: &[u8]) -> usize {
413    bytes.iter().take(4).take_while(|b| **b == b' ').count()
414}
415
416/// One blockquote level at `body`'s start: the byte length it occupies.
417fn 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    // ` ?`, at most one, which is why `>  a` yields a rest of ` a`.
425    if bytes.get(end) == Some(&b' ') {
426        end += 1;
427    }
428    Some(end)
429}
430
431/// The marker itself -- `[-+*]` or a run of ASCII digits then `.` or `)`.
432///
433/// Shared by `match_list_marker` and `is_list_line` because the marker half of
434/// the two patterns is identical. What follows it is not, and each caller spells
435/// its own trailing rule rather than taking one from here.
436fn 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    // `[0-9]+` is greedy and backtracking cannot help: giving a digit back only
448    // exposes another digit, never the `.` or `)` the pattern needs next.
449    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        // The drift detector for writing the set out by hand. If a future Rust
462        // ships a changed `White_Space`, this fails here rather than silently
463        // changing what the tool considers a blank line.
464        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        // A no-break space is whitespace to Python and to Rust alike.
488        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        // The specification narrowed to `[0-9]`; `\d` was 650 code points on
496        // 3.10 and 680 on 3.13, so there was no one Python behavior to port.
497        assert!(is_list_line("1. x"));
498        assert!(!is_list_line("\u{661}. x")); // ARABIC-INDIC ONE
499        assert!(!is_list_line("\u{967}. x")); // DEVANAGARI ONE
500        assert!(match_list_marker("\u{661}. x").is_none());
501    }
502
503    #[test]
504    fn a_list_marker_reports_its_content_column() {
505        // ASCII throughout since the narrowing, so this index is the same
506        // number in characters and in bytes. It was not before.
507        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        // And any Unicode whitespace, not merely a tab.
522        assert!(is_list_line("*\u{a0}x"));
523        // `is_list_line` accepts no indent at all.
524        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        // Task 3 narrowed both implementations to three boundaries, so a
575        // vertical tab and a line separator are both content.
576        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        // A trailing space on the opener is not `---`.
604        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        // Anchored and not MULTILINE, so despite re.sub's replace-all default
621        // it cannot fire past offset 0.
622        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        // `\s*` absorbs a lone carriage return and `$` then matches at the end.
642        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        // Mixing is accepted here where CommonMark rejects it; this is a port.
656        assert!(is_thematic_break("-*_"));
657        // The separator is Python whitespace, so a no-break space counts.
658        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        // A backslash is not an escape to this pattern; the `]` still ends it.
669        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        // No closing bracket is required: the pattern reads a name, not a tag.
694        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        // Both lowercasings have to happen, in either direction of mismatch.
703        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        // The third character must be ASCII uppercase, so a lowercase one fails.
737        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        // Python's `$` accepts one trailing `\n` and only `\n`.
749        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}