const MAX_BLOCK_INDENT: usize = 3;
const FENCE_MIN_RUN: usize = 3;
const MAX_ORDERED_LIST_DIGITS: usize = 9;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct OpenerShape {
pub fence: bool,
pub setext_underline: bool,
pub indented_code: bool,
pub html_block: bool,
pub list_marker: bool,
pub blockquote: bool,
}
pub(crate) fn opener_shape(line: &str) -> OpenerShape {
OpenerShape {
fence: is_fence_marker(line),
setext_underline: is_setext_underline(line),
indented_code: is_indented_code(line),
html_block: is_html_block_opener(line),
list_marker: is_list_marker(line),
blockquote: is_blockquote_marker(line),
}
}
fn split_indent(line: &str) -> (usize, &str) {
let trimmed = line.trim_start_matches(' ');
(line.len() - trimmed.len(), trimmed)
}
fn is_fence_marker(line: &str) -> bool {
let (indent, trimmed) = split_indent(line);
if indent > MAX_BLOCK_INDENT {
return false;
}
let run = |c: char| {
trimmed.starts_with(c) && trimmed.chars().take_while(|&x| x == c).count() >= FENCE_MIN_RUN
};
run('`') || run('~')
}
fn is_setext_underline(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && (trimmed.chars().all(|c| c == '=') || trimmed.chars().all(|c| c == '-'))
}
fn is_indented_code(line: &str) -> bool {
if let Some(rest) = line.strip_prefix('\t') {
return !rest.trim().is_empty();
}
let leading_spaces = line.chars().take_while(|c| *c == ' ').count();
if leading_spaces <= MAX_BLOCK_INDENT {
return false;
}
!line[leading_spaces..].trim().is_empty()
}
fn is_html_block_opener(line: &str) -> bool {
let (indent, trimmed) = split_indent(line);
indent <= MAX_BLOCK_INDENT && trimmed.starts_with('<')
}
fn is_blockquote_marker(line: &str) -> bool {
let (indent, trimmed) = split_indent(line);
indent <= MAX_BLOCK_INDENT && trimmed.starts_with('>')
}
fn is_list_marker(line: &str) -> bool {
let (indent, trimmed) = split_indent(line);
if indent > MAX_BLOCK_INDENT {
return false;
}
let mut chars = trimmed.chars();
let Some(first) = chars.next() else {
return false;
};
if matches!(first, '-' | '*' | '+') {
if !matches!(chars.next(), Some(' ' | '\t')) {
return false;
}
return chars.any(|c| !c.is_whitespace());
}
if first.is_ascii_digit() {
let mut digits = 1usize;
let mut next = chars.next();
while let Some(c) = next
&& c.is_ascii_digit()
{
digits += 1;
if digits > MAX_ORDERED_LIST_DIGITS {
return false;
}
next = chars.next();
}
if !matches!(next, Some('.' | ')')) {
return false;
}
if !matches!(chars.next(), Some(' ' | '\t')) {
return false;
}
return chars.any(|c| !c.is_whitespace());
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn indented_code_detects_tab_and_4_spaces() {
assert!(opener_shape("\tcode").indented_code);
assert!(opener_shape(" code").indented_code);
assert!(opener_shape(" code").indented_code);
assert!(!opener_shape(" code").indented_code);
assert!(!opener_shape("code").indented_code);
}
#[test]
fn indented_code_rejects_whitespace_only() {
assert!(!opener_shape(" ").indented_code);
assert!(!opener_shape(" ").indented_code);
assert!(!opener_shape("\t").indented_code);
assert!(!opener_shape("\t ").indented_code);
assert!(opener_shape(" x").indented_code);
assert!(opener_shape("\tx").indented_code);
}
#[test]
fn list_marker_recognizes_pulldown_pattern() {
assert!(opener_shape("* a").list_marker);
assert!(opener_shape("- a").list_marker);
assert!(opener_shape("+ a").list_marker);
assert!(opener_shape("1. a").list_marker);
assert!(opener_shape("12) a").list_marker);
assert!(opener_shape(" * a").list_marker);
assert!(!opener_shape("*").list_marker);
assert!(!opener_shape("* ").list_marker);
assert!(!opener_shape("* ").list_marker);
assert!(!opener_shape("1.").list_marker);
assert!(!opener_shape("1. ").list_marker);
assert!(!opener_shape(" * a").list_marker); assert!(!opener_shape("1234567890. a").list_marker); }
#[test]
fn blockquote_marker_recognizes_leading_gt() {
assert!(opener_shape(">").blockquote);
assert!(opener_shape("> a").blockquote);
assert!(opener_shape(" > a").blockquote);
assert!(!opener_shape(" > a").blockquote); assert!(!opener_shape("a > b").blockquote);
assert!(!opener_shape("").blockquote);
}
#[test]
fn html_block_opener_detects_leading_lt() {
assert!(opener_shape("<div>").html_block);
assert!(opener_shape(" <div>").html_block);
assert!(opener_shape(" <table>").html_block);
assert!(!opener_shape(" <div>").html_block); assert!(!opener_shape("text <span>").html_block);
}
#[test]
fn fence_marker_requires_three_run() {
assert!(opener_shape("```").fence);
assert!(opener_shape("~~~rust").fence);
assert!(opener_shape(" ```").fence);
assert!(!opener_shape("``").fence);
assert!(!opener_shape(" ```").fence); assert!(!opener_shape("``~").fence); }
#[test]
fn setext_underline_all_same_char() {
assert!(opener_shape("===").setext_underline);
assert!(opener_shape("---").setext_underline);
assert!(opener_shape(" == ").setext_underline);
assert!(!opener_shape("=-=").setext_underline);
assert!(!opener_shape("").setext_underline);
}
#[test]
fn plain_prose_has_no_shape() {
assert_eq!(opener_shape("just some words"), OpenerShape::default());
assert_eq!(opener_shape(""), OpenerShape::default());
}
#[test]
fn equality_detects_any_flip() {
assert_ne!(opener_shape("x"), opener_shape("> x")); assert_ne!(opener_shape("x"), opener_shape("- x")); assert_ne!(opener_shape("text"), opener_shape("```")); assert_eq!(opener_shape("- a"), opener_shape("- ab"));
}
}