use regex::Regex;
pub(crate) fn leading_indent(line: &str) -> (usize, usize) {
let mut width = 0;
let mut bytes = 0;
for &b in line.as_bytes() {
match b {
b' ' => {
width += 1;
bytes += 1;
}
0x09 => {
width += 4;
bytes += 1;
}
_ => break,
}
}
(width, bytes)
}
pub(super) static BULLET_RE: std::sync::LazyLock<Regex> = lazy_regex!(
r"^(\s*(?:[-*+]|\d+[.)])\s+(?:\[\s*(?:[xX]|\s)\s*\]\s*)?)(.*)",
"bullet pattern regex should compile",
);
pub(super) static FOOTNOTE_RE: std::sync::LazyLock<Regex> = lazy_regex!(
r"^(\s*)(\[\^[^]]+\]:\s*)(.*)$",
"footnote pattern regex should compile",
);
pub(super) static BLOCKQUOTE_RE: std::sync::LazyLock<Regex> = lazy_regex!(
r"^(\s*(?:>\s*)+)(.*)$",
"blockquote pattern regex should compile",
);
pub(super) static MARKDOWNLINT_DIRECTIVE_RE: std::sync::LazyLock<Regex> = lazy_regex!(
r"(?i)^\s*<!--\s*markdownlint-(?:disable|enable|disable-line|disable-next-line)(?:\s+[A-Za-z0-9_\-/]+)*\s*-->\s*$",
"markdownlint directive regex should compile",
);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BlockKind {
Heading,
Bullet,
Blockquote,
FootnoteDefinition,
LinkReferenceDefinition,
MarkdownlintDirective,
DigitPrefix,
}
pub(crate) fn classify_block(
line: &str,
link_matcher: super::link_reference::LinkReferenceMatcher,
) -> Option<BlockKind> {
let (indent_width, indent_bytes) = leading_indent(line);
let trimmed = line[indent_bytes..].trim_start();
if indent_width < 4 && trimmed.starts_with('#') {
return Some(BlockKind::Heading);
}
if indent_width < 4 && BULLET_RE.is_match(line) {
return Some(BlockKind::Bullet);
}
if indent_width < 4 && BLOCKQUOTE_RE.is_match(line) {
return Some(BlockKind::Blockquote);
}
if indent_width < 4 && FOOTNOTE_RE.is_match(line) {
return Some(BlockKind::FootnoteDefinition);
}
if indent_width < 4
&& (link_matcher.is_definition(line) || link_matcher.is_bare_label_only(line))
{
return Some(BlockKind::LinkReferenceDefinition);
}
if indent_width < 4 && is_markdownlint_directive(line) {
return Some(BlockKind::MarkdownlintDirective);
}
if indent_width < 4 && trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Some(BlockKind::DigitPrefix);
}
None
}
pub(super) fn is_markdownlint_directive(line: &str) -> bool {
MARKDOWNLINT_DIRECTIVE_RE.is_match(line)
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
use crate::wrap::LinkReferenceMatcher;
#[rstest(
line,
expected,
case("# Heading", Some(BlockKind::Heading)),
case(" # Heading", Some(BlockKind::Heading)),
case(" # Code block", None),
case(" # Heading", None),
case("- item", Some(BlockKind::Bullet)),
case("1. item", Some(BlockKind::Bullet)),
case("> quote", Some(BlockKind::Blockquote)),
case("[^1]: footnote", Some(BlockKind::FootnoteDefinition)),
case(
"[ansible]: <https://docs.ansible.com/>",
Some(BlockKind::LinkReferenceDefinition)
),
case(
"[label]: https://example.com",
Some(BlockKind::LinkReferenceDefinition)
),
case(
"[label]: https://example.com \"Optional title\"",
Some(BlockKind::LinkReferenceDefinition)
),
case("[label]:", Some(BlockKind::LinkReferenceDefinition)),
case(" [label]:", Some(BlockKind::LinkReferenceDefinition)),
case(" [label]: https://example.com", None),
case(" [label]:", None),
case(
"<!-- markdownlint-disable -->",
Some(BlockKind::MarkdownlintDirective)
),
case("2024 revenue", Some(BlockKind::DigitPrefix)),
case("plain paragraph", None),
case("| a | b |", None),
case("#123", Some(BlockKind::Heading)),
case("1) list", Some(BlockKind::Bullet)),
case(" 2024", Some(BlockKind::DigitPrefix)),
case(" 1. code", None)
)]
fn classify_block_identifies_prefixes(line: &str, expected: Option<BlockKind>) {
let matcher = LinkReferenceMatcher::production();
assert_eq!(classify_block(line, matcher), expected);
}
#[rstest]
#[case("<!-- markdownlint-disable -->", true)]
#[case("<!-- markdownlint-disable-next-line MD013 -->", true)]
#[case("<!-- markdownlint-enable -->", true)]
#[case("<!-- markdownlint enable -->", false)]
#[case("<!-- just a comment -->", false)]
fn detects_markdownlint_directives(#[case] line: &str, #[case] expected: bool) {
assert_eq!(is_markdownlint_directive(line), expected);
}
}