#[must_use]
pub fn is_python_space(c: char) -> bool {
matches!(c,
'\u{9}'..='\u{d}' | '\u{1c}'..='\u{1f}' | '\u{20}' | '\u{85}' | '\u{a0}' | '\u{1680}' | '\u{2000}'..='\u{200a}'
| '\u{2028}' | '\u{2029}' | '\u{202f}' | '\u{205f}' | '\u{3000}' )
}
#[must_use]
pub fn py_trim(s: &str) -> &str {
s.trim_matches(is_python_space)
}
#[must_use]
pub fn py_trim_start(s: &str) -> &str {
s.trim_start_matches(is_python_space)
}
#[must_use]
pub fn py_trim_end(s: &str) -> &str {
s.trim_end_matches(is_python_space)
}
#[must_use]
pub fn split_eol(line: &str) -> (&str, &str) {
for eol in ["\r\n", "\n", "\r"] {
if let Some(body) = line.strip_suffix(eol) {
return (body, eol);
}
}
(line, "")
}
#[must_use]
pub fn py_splitlines_keepends(text: &str) -> Vec<&str> {
let bytes = text.as_bytes();
let mut lines = Vec::new();
let mut start = 0;
let mut index = 0;
while index < bytes.len() {
let end = match bytes[index] {
b'\r' if bytes.get(index + 1) == Some(&b'\n') => index + 2,
b'\r' | b'\n' => index + 1,
_ => {
index += 1;
continue;
}
};
lines.push(&text[start..end]);
index = end;
start = end;
}
if start < bytes.len() {
lines.push(&text[start..]);
}
lines
}
#[must_use]
pub fn has_hard_break(body: &str) -> bool {
body.ends_with('\\') || body.ends_with(" ")
}
#[must_use]
pub fn starts_front_matter(lines: &[&str]) -> bool {
let Some(first) = lines.first() else {
return false;
};
let opener = split_eol(first).0;
if opener.strip_prefix('\u{feff}').unwrap_or(opener) != "---" {
return false;
}
lines[1..]
.iter()
.any(|line| matches!(split_eol(line).0, "---" | "..."))
}
#[must_use]
pub fn match_opening_fence(body: &str) -> Option<(char, usize)> {
let bytes = body.as_bytes();
let indent = leading_spaces(bytes);
if indent > 3 {
return None;
}
let fence_char = match bytes.get(indent) {
Some(b'`') => '`',
Some(b'~') => '~',
_ => return None,
};
let run = bytes[indent..]
.iter()
.take_while(|b| **b == fence_char as u8)
.count();
(run >= 3).then_some((fence_char, run))
}
#[must_use]
pub fn is_closing_fence(body: &str, fence_char: char, fence_len: usize) -> bool {
let stripped = body.trim_start_matches(' ');
if body.len() - stripped.len() > 3 {
return false;
}
let mut rest = stripped;
for _ in 0..fence_len {
match rest.strip_prefix(fence_char) {
Some(shorter) => rest = shorter,
None => return false,
}
}
py_trim(rest).chars().all(|c| c == fence_char)
}
#[must_use]
pub fn match_blockquote(body: &str) -> Option<(&str, &str)> {
let end = match_blockquote_once(body)?;
Some((&body[..end], &body[end..]))
}
#[must_use]
pub fn match_blockquote_prefix(body: &str) -> Option<usize> {
let mut end = 0;
while let Some(step) = match_blockquote_once(&body[end..]) {
end += step;
}
(end > 0).then_some(end)
}
#[must_use]
pub fn strip_blockquote_prefix(body: &str) -> &str {
match match_blockquote_prefix(body) {
Some(end) => &body[end..],
None => body,
}
}
#[must_use]
pub fn match_list_marker(body: &str) -> Option<(&str, usize, &str)> {
let bytes = body.as_bytes();
let indent = leading_spaces(bytes);
if indent > 3 {
return None;
}
let after_marker = match_marker(bytes, indent)?;
let mut cursor = after_marker;
while bytes.get(cursor) == Some(&b' ') {
cursor += 1;
}
if cursor == after_marker {
return None;
}
Some((&body[..cursor], cursor, &body[cursor..]))
}
#[must_use]
pub fn is_list_line(body: &str) -> bool {
let bytes = body.as_bytes();
let Some(after_marker) = match_marker(bytes, 0) else {
return false;
};
body[after_marker..]
.chars()
.next()
.is_some_and(is_python_space)
}
#[must_use]
pub fn is_alpha_list_line(body: &str) -> bool {
let bytes = body.as_bytes();
if !bytes.first().is_some_and(u8::is_ascii_alphabetic) {
return false;
}
if !matches!(bytes.get(1), Some(b'.' | b')')) {
return false;
}
body[2..].chars().next().is_some_and(is_python_space)
}
#[must_use]
pub fn is_setext_line(body: &str) -> bool {
let Some(first) = body.chars().next() else {
return false;
};
if first != '=' && first != '-' {
return false;
}
body.trim_start_matches(first).chars().all(is_python_space)
}
#[must_use]
pub fn is_thematic_break(body: &str) -> bool {
if !body.starts_with(['-', '*', '_']) {
return false;
}
let mut markers = 0usize;
for c in body.chars() {
if matches!(c, '-' | '*' | '_') {
markers += 1;
} else if !is_python_space(c) {
return false;
}
}
markers >= 3
}
#[must_use]
pub fn is_link_reference(body: &str) -> bool {
let Some(rest) = body.strip_prefix('[') else {
return false;
};
match rest.find(']') {
None | Some(0) => false,
Some(index) => rest[index + 1..].starts_with(':'),
}
}
#[must_use]
pub fn match_html_tag_name(body: &str) -> Option<&str> {
let rest = body.strip_prefix('<')?;
if !rest.as_bytes().first()?.is_ascii_alphabetic() {
return None;
}
let end = rest
.as_bytes()
.iter()
.position(|b| !(b.is_ascii_alphanumeric() || *b == b'-'))
.unwrap_or(rest.len());
Some(&rest[..end])
}
#[must_use]
pub fn match_opening_html_block(body: &str) -> Option<String> {
let stripped = py_trim(body);
if !stripped.starts_with('<') {
return None;
}
for prefix in ["<!--", "-->", "<?", "<![", "<!", "</"] {
if stripped.starts_with(prefix) {
return None;
}
}
if stripped.ends_with("/>") {
return None;
}
let name = match_html_tag_name(stripped)?.to_ascii_lowercase();
if stripped.to_lowercase().contains(&format!("</{name}>")) {
return None;
}
Some(name)
}
#[must_use]
pub fn match_opening_html_literal_terminator(body: &str) -> Option<&'static str> {
let stripped = py_trim_start(body);
for (opener, terminator) in [("<!--", "-->"), ("<?", "?>"), ("<![CDATA[", "]]>")] {
if let Some(tail) = stripped.strip_prefix(opener) {
if !tail.contains(terminator) {
return Some(terminator);
}
}
}
let mut chars = stripped.chars();
if chars.next() != Some('<') || chars.next() != Some('!') {
return None;
}
let third = chars.next()?;
(third.is_ascii_uppercase() && !chars.as_str().contains('>')).then_some(">")
}
#[must_use]
pub fn is_gfm_alert(body: &str) -> bool {
let Some(rest) = body.strip_prefix("[!") else {
return false;
};
let bytes = rest.as_bytes();
if !bytes.first().is_some_and(u8::is_ascii_uppercase) {
return false;
}
let end = bytes
.iter()
.position(|b| !(b.is_ascii_uppercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-')))
.unwrap_or(bytes.len());
let Some(tail) = rest[end..].strip_prefix(']') else {
return false;
};
let tail = tail.strip_prefix(['+', '-']).unwrap_or(tail);
tail.is_empty() || tail == "\n"
}
#[must_use]
pub fn is_raw_html_tag(name: &str) -> bool {
matches!(name, "pre" | "script" | "style" | "textarea")
}
fn leading_spaces(bytes: &[u8]) -> usize {
bytes.iter().take(4).take_while(|b| **b == b' ').count()
}
fn match_blockquote_once(body: &str) -> Option<usize> {
let bytes = body.as_bytes();
let indent = leading_spaces(bytes);
if indent > 3 || bytes.get(indent) != Some(&b'>') {
return None;
}
let mut end = indent + 1;
if bytes.get(end) == Some(&b' ') {
end += 1;
}
Some(end)
}
fn match_marker(bytes: &[u8], start: usize) -> Option<usize> {
let mut cursor = start;
match bytes.get(cursor)? {
b'-' | b'+' | b'*' => return Some(cursor + 1),
b'0'..=b'9' => {
while matches!(bytes.get(cursor), Some(b'0'..=b'9')) {
cursor += 1;
}
}
_ => return None,
}
match bytes.get(cursor) {
Some(b'.' | b')') => Some(cursor + 1),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn python_whitespace_matches_rusts_view_plus_four() {
for cp in 0..=0x10_FFFFu32 {
let Some(c) = char::from_u32(cp) else {
continue;
};
let expected = c.is_whitespace() || matches!(c, '\u{1c}'..='\u{1f}');
assert_eq!(is_python_space(c), expected, "disagreed on U+{cp:04X}");
}
assert_eq!(
(0..=0x10_FFFFu32)
.filter_map(char::from_u32)
.filter(|c| is_python_space(*c))
.count(),
29
);
}
#[test]
fn python_whitespace_includes_the_c0_separators() {
assert!(is_python_space('\u{1c}'));
assert!(is_python_space('\u{1f}'));
assert!(!'\u{1c}'.is_whitespace());
assert_eq!(py_trim("\u{1c}a\u{1e}"), "a");
assert_eq!("\u{1c}a\u{1e}".trim(), "\u{1c}a\u{1e}");
assert_eq!(py_trim("\u{a0}a\u{a0}"), "a");
assert_eq!(py_trim_start("\u{1c}a\u{1e}"), "a\u{1e}");
assert_eq!(py_trim_end("\u{1c}a\u{1e}"), "\u{1c}a");
}
#[test]
fn an_ordered_list_marker_is_ascii_digits_only() {
assert!(is_list_line("1. x"));
assert!(!is_list_line("\u{661}. x")); assert!(!is_list_line("\u{967}. x")); assert!(match_list_marker("\u{661}. x").is_none());
}
#[test]
fn a_list_marker_reports_its_content_column() {
assert_eq!(match_list_marker("12. x"), Some(("12. ", 4, "x")));
assert_eq!(match_list_marker("- x"), Some(("- ", 2, "x")));
assert_eq!(match_list_marker("- x"), Some(("- ", 3, "x")));
assert_eq!(match_list_marker(" - x"), Some((" - ", 5, "x")));
assert_eq!(match_list_marker(" - x"), None);
assert_eq!(match_list_marker("-x"), None);
assert_eq!(match_list_marker("1.x"), None);
assert_eq!(match_list_marker("1a. x"), None);
}
#[test]
fn list_marker_needs_a_space_where_list_line_takes_any_whitespace() {
assert!(match_list_marker("-\tx").is_none());
assert!(is_list_line("-\tx"));
assert!(is_list_line("*\u{a0}x"));
assert!(!is_list_line(" - x"));
}
#[test]
fn an_alpha_enumerator_needs_one_whitespace() {
assert!(is_alpha_list_line("a. x"));
assert!(is_alpha_list_line("a) x"));
assert!(is_alpha_list_line("a.\tx"));
assert!(is_alpha_list_line("A. x"));
assert!(!is_alpha_list_line("a.x"));
assert!(!is_alpha_list_line("ab. x"));
assert!(!is_alpha_list_line("a."));
}
#[test]
fn a_closing_fence_tolerates_python_whitespace_after_it() {
assert!(is_closing_fence("```\u{1c}", '`', 3));
assert!(is_closing_fence("```", '`', 3));
assert!(is_closing_fence(" ```", '`', 3));
assert!(is_closing_fence("``` ```", '`', 3));
assert!(is_closing_fence("````", '`', 3));
assert!(!is_closing_fence(" ```", '`', 3));
assert!(!is_closing_fence("```x", '`', 3));
assert!(!is_closing_fence("``", '`', 3));
}
#[test]
fn an_opening_fence_reports_its_character_and_length() {
assert_eq!(match_opening_fence("```"), Some(('`', 3)));
assert_eq!(match_opening_fence(" ```"), Some(('`', 3)));
assert_eq!(match_opening_fence("~~~~"), Some(('~', 4)));
assert_eq!(match_opening_fence("```rust"), Some(('`', 3)));
assert_eq!(match_opening_fence(" ~~~ "), Some(('~', 3)));
assert_eq!(match_opening_fence(" ```"), None);
assert_eq!(match_opening_fence("``"), None);
assert_eq!(match_opening_fence("`~`"), None);
}
#[test]
fn split_eol_recognizes_the_three_boundaries() {
assert_eq!(split_eol("a\r\n"), ("a", "\r\n"));
assert_eq!(split_eol("a\n"), ("a", "\n"));
assert_eq!(split_eol("a\r"), ("a", "\r"));
assert_eq!(split_eol("a"), ("a", ""));
assert_eq!(split_eol("\r\n"), ("", "\r\n"));
assert_eq!(split_eol(""), ("", ""));
}
#[test]
fn splitlines_is_narrow_per_the_specification() {
assert_eq!(py_splitlines_keepends("a\u{b}b\n"), vec!["a\u{b}b\n"]);
assert_eq!(py_splitlines_keepends("a\u{2028}b\n"), vec!["a\u{2028}b\n"]);
assert_eq!(py_splitlines_keepends("a\r\nb\n"), vec!["a\r\n", "b\n"]);
assert_eq!(py_splitlines_keepends("a\rb"), vec!["a\r", "b"]);
assert_eq!(py_splitlines_keepends("a\n\n"), vec!["a\n", "\n"]);
assert!(py_splitlines_keepends("").is_empty());
assert_eq!(py_splitlines_keepends("a"), vec!["a"]);
}
#[test]
fn a_hard_break_is_a_backslash_or_two_spaces() {
assert!(has_hard_break("a "));
assert!(has_hard_break("a\\"));
assert!(has_hard_break(" "));
assert!(!has_hard_break("a "));
assert!(!has_hard_break("a"));
assert!(!has_hard_break(""));
}
#[test]
fn front_matter_needs_a_reachable_closer_and_tolerates_a_bom() {
assert!(starts_front_matter(&["---\n", "a: 1\n", "---\n"]));
assert!(starts_front_matter(&["\u{feff}---\n", "---\n"]));
assert!(starts_front_matter(&["---\n", "...\n"]));
assert!(starts_front_matter(&["---\r\n", "---\r\n"]));
assert!(!starts_front_matter(&["---\n", "a: 1\n"]));
assert!(!starts_front_matter(&[]));
assert!(!starts_front_matter(&["--- \n", "---\n"]));
}
#[test]
fn a_blockquote_takes_one_level_and_at_most_one_space() {
assert_eq!(match_blockquote("> a"), Some(("> ", "a")));
assert_eq!(match_blockquote(">a"), Some((">", "a")));
assert_eq!(match_blockquote(" > a"), Some((" > ", "a")));
assert_eq!(match_blockquote(">"), Some((">", "")));
assert_eq!(match_blockquote("> a"), Some(("> ", " a")));
assert_eq!(match_blockquote(" > a"), None);
assert_eq!(match_blockquote("a"), None);
}
#[test]
fn a_blockquote_prefix_strip_fires_at_most_once() {
assert_eq!(strip_blockquote_prefix("> > a"), "a");
assert_eq!(strip_blockquote_prefix("a\n> b"), "a\n> b");
assert_eq!(strip_blockquote_prefix(">>a"), "a");
assert_eq!(strip_blockquote_prefix(" > > a"), "a");
assert_eq!(strip_blockquote_prefix("> "), "");
assert_eq!(strip_blockquote_prefix(" > a"), " > a");
assert_eq!(match_blockquote_prefix("> > a"), Some(4));
assert_eq!(match_blockquote_prefix(" > > a"), Some(9));
assert_eq!(match_blockquote_prefix("no marker"), None);
}
#[test]
fn a_setext_run_may_not_mix_its_character() {
assert!(is_setext_line("==="));
assert!(is_setext_line("---"));
assert!(is_setext_line("=== "));
assert!(is_setext_line("===\n"));
assert!(is_setext_line("===\r\n"));
assert!(is_setext_line("===\n\n"));
assert!(is_setext_line("===\r"));
assert!(!is_setext_line("=-="));
assert!(!is_setext_line("= ="));
assert!(!is_setext_line(""));
}
#[test]
fn a_thematic_break_counts_three_markers_and_may_mix_them() {
assert!(is_thematic_break("---"));
assert!(is_thematic_break("***"));
assert!(is_thematic_break("___"));
assert!(is_thematic_break("- - -"));
assert!(is_thematic_break("---\n"));
assert!(is_thematic_break("-*_"));
assert!(is_thematic_break("-\u{a0}-\u{a0}-"));
assert!(!is_thematic_break("--"));
assert!(!is_thematic_break(" ---"));
assert!(!is_thematic_break("---x"));
}
#[test]
fn a_link_reference_needs_a_non_empty_label() {
assert!(is_link_reference("[a]: b"));
assert!(is_link_reference("[a]:"));
assert!(is_link_reference("[a\\]: b"));
assert!(!is_link_reference("[]: b"));
assert!(!is_link_reference("[a] b"));
assert!(!is_link_reference("a]: b"));
}
#[test]
fn a_tag_name_keeps_its_case_and_starts_with_a_letter() {
assert_eq!(match_html_tag_name("<div>"), Some("div"));
assert_eq!(match_html_tag_name("<my-tag x>"), Some("my-tag"));
assert_eq!(match_html_tag_name("<DIV>"), Some("DIV"));
assert_eq!(match_html_tag_name("<a"), Some("a"));
assert_eq!(match_html_tag_name("<1div>"), None);
assert_eq!(match_html_tag_name("< div>"), None);
assert_eq!(match_html_tag_name("div"), None);
}
#[test]
fn an_html_block_opener_rejects_what_closes_on_its_own_line() {
assert_eq!(match_opening_html_block("<div>").as_deref(), Some("div"));
assert_eq!(
match_opening_html_block(" <div> ").as_deref(),
Some("div")
);
assert_eq!(match_opening_html_block("<div").as_deref(), Some("div"));
assert_eq!(match_opening_html_block("<div>x</div>"), None);
assert_eq!(match_opening_html_block("<br/>"), None);
assert_eq!(match_opening_html_block("<!-- c -->"), None);
assert_eq!(match_opening_html_block("</div>"), None);
assert_eq!(match_opening_html_block("<?php"), None);
assert_eq!(match_opening_html_block("<![CDATA["), None);
assert_eq!(match_opening_html_block("<!DOCTYPE html>"), None);
assert_eq!(match_opening_html_block("<DIV>x</div>"), None);
assert_eq!(match_opening_html_block("<div>x</DIV>"), None);
}
#[test]
fn a_literal_terminator_is_reported_only_while_it_is_still_open() {
assert_eq!(
match_opening_html_literal_terminator("<!-- open"),
Some("-->")
);
assert_eq!(
match_opening_html_literal_terminator(" <!-- open"),
Some("-->")
);
assert_eq!(match_opening_html_literal_terminator("<?php"), Some("?>"));
assert_eq!(
match_opening_html_literal_terminator("<![CDATA[x"),
Some("]]>")
);
assert_eq!(
match_opening_html_literal_terminator("<!DOCTYPE html"),
Some(">")
);
assert_eq!(
match_opening_html_literal_terminator("<!-- closed -->"),
None
);
assert_eq!(match_opening_html_literal_terminator("<?php ?>"), None);
assert_eq!(match_opening_html_literal_terminator("<![CDATA[x]]>"), None);
assert_eq!(
match_opening_html_literal_terminator("<!DOCTYPE html>"),
None
);
assert_eq!(match_opening_html_literal_terminator("<!x"), None);
assert_eq!(match_opening_html_literal_terminator("<!"), None);
}
#[test]
fn a_gfm_alert_ends_at_the_end_or_before_one_trailing_newline() {
assert!(is_gfm_alert("[!NOTE]"));
assert!(is_gfm_alert("[!NOTE]+"));
assert!(is_gfm_alert("[!NOTE]-"));
assert!(is_gfm_alert("[!NOTE]\n"));
assert!(is_gfm_alert("[!N0-T_E]"));
assert!(!is_gfm_alert("[!NOTE]\r"));
assert!(!is_gfm_alert("[!NOTE]\n\n"));
assert!(!is_gfm_alert("[!note]"));
assert!(!is_gfm_alert("[!]"));
assert!(!is_gfm_alert("[NOTE]"));
assert!(!is_gfm_alert("[!NOTE]x"));
}
#[test]
fn the_raw_html_tags_are_the_four_that_hold_literal_text() {
for name in ["pre", "script", "style", "textarea"] {
assert!(is_raw_html_tag(name));
}
assert!(!is_raw_html_tag("div"));
assert!(!is_raw_html_tag("PRE"));
}
}