use crate::lint_context::LintContext;
use pulldown_cmark::LinkType;
pub(crate) fn is_unwrappable_line(line: &str) -> bool {
let mut last_ws_end: Option<usize> = None;
for (idx, ch) in line.char_indices() {
if ch == '#' || ch == '>' {
} else if ch.is_whitespace() {
last_ws_end = Some(idx + ch.len_utf8());
} else {
break;
}
}
let rest_start = last_ws_end.unwrap_or(0);
line[rest_start..].chars().all(|c| !c.is_whitespace())
}
pub(crate) fn has_hard_break(line: &str) -> bool {
let line = line.strip_suffix('\r').unwrap_or(line);
line.ends_with(" ") || line.ends_with('\\')
}
pub(crate) fn trim_preserving_hard_break(s: &str) -> String {
let s = s.strip_suffix('\r').unwrap_or(s);
if s.ends_with('\\') {
return s.to_string();
}
if s.ends_with(" ") {
let content_end = s.trim_end().len();
if content_end == 0 {
return String::new();
}
format!("{} ", &s[..content_end])
} else {
s.trim_end().to_string()
}
}
pub(crate) fn split_into_segments(para_lines: &[(String, usize)]) -> Vec<Vec<(String, usize)>> {
let mut segments: Vec<Vec<(String, usize)>> = Vec::new();
let mut current_segment: Vec<(String, usize)> = Vec::new();
for (line, line_num) in para_lines {
current_segment.push((line.clone(), *line_num));
if has_hard_break(line) {
segments.push(current_segment.clone());
current_segment.clear();
}
}
if !current_segment.is_empty() {
segments.push(current_segment);
}
segments
}
const TASK_CHECKBOXES: [&str; 3] = ["[ ] ", "[x] ", "[X] "];
const MARKER_PADDING: [char; 2] = [' ', '\t'];
fn strip_task_checkbox(after_marker: &str) -> Option<(&'static str, &str)> {
let content = after_marker.trim_start_matches(MARKER_PADDING);
TASK_CHECKBOXES
.iter()
.find_map(|checkbox| content.strip_prefix(checkbox).map(|text| (*checkbox, text)))
}
fn display_width(s: &str) -> usize {
s.chars()
.fold(0, |col, c| if c == '\t' { col + 4 - col % 4 } else { col + 1 })
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct SourceMarker {
pub text: String,
pub content_col: usize,
}
pub(crate) fn source_list_marker(line: &str) -> Option<SourceMarker> {
let indent_len = line.len() - line.trim_start().len();
let trimmed = &line[indent_len..];
let after_marker = if let Some(rest) = trimmed.strip_prefix(['-', '*', '+']) {
rest
} else {
let digits = trimmed.find('.')?;
if digits == 0 || !trimmed[..digits].chars().all(|c| c.is_ascii_digit()) {
return None;
}
&trimmed[digits + 1..]
};
if !after_marker.starts_with(MARKER_PADDING) {
return None;
}
let after_padding = after_marker.trim_start_matches(MARKER_PADDING);
let consumed = TASK_CHECKBOXES
.iter()
.find_map(|checkbox| after_padding.strip_prefix(checkbox).map(|_| checkbox.len()))
.unwrap_or(0);
let marker_end = line.len() - after_padding.len() + consumed;
let text = line[..marker_end].to_string();
Some(SourceMarker {
content_col: display_width(&text),
text,
})
}
pub(crate) fn extract_list_marker_and_content(line: &str) -> (String, String) {
let indent_len = line.len() - line.trim_start().len();
let indent = &line[..indent_len];
let trimmed = &line[indent_len..];
for bullet in ['-', '*', '+'] {
let Some(after_bullet) = trimmed.strip_prefix(bullet) else {
continue;
};
let mut padding = after_bullet.chars();
if !padding.next().is_some_and(|c| MARKER_PADDING.contains(&c)) {
continue;
}
if let Some((checkbox, content)) = strip_task_checkbox(after_bullet) {
return (
format!("{indent}{bullet} {checkbox}"),
trim_preserving_hard_break(content),
);
}
return (
format!("{indent}{bullet} "),
trim_preserving_hard_break(padding.as_str()),
);
}
let mut chars = trimmed.chars();
let mut marker_content = String::new();
while let Some(c) = chars.next() {
marker_content.push(c);
if c == '.' {
if let Some(next) = chars.next()
&& MARKER_PADDING.contains(&next)
{
marker_content.push(' ');
let rest = chars.as_str();
if let Some((checkbox, content)) = strip_task_checkbox(rest) {
return (
format!("{indent}{marker_content}{checkbox}"),
trim_preserving_hard_break(content),
);
}
let content = trim_preserving_hard_break(rest);
return (format!("{indent}{marker_content}"), content);
}
break;
}
}
(String::new(), line.to_string())
}
pub(crate) fn is_horizontal_rule(line: &str) -> bool {
crate::utils::thematic_break::is_thematic_break(line)
}
pub(crate) fn is_setext_heading_text_line(ctx: &LintContext, line_num: usize) -> bool {
ctx.line_info(line_num).is_some_and(|info| {
info.heading.as_ref().is_some_and(|h| {
matches!(
h.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
)
})
})
}
pub(crate) fn is_setext_underline_content(content: &str) -> bool {
let trimmed = content.trim();
let mut chars = trimmed.chars();
let Some(marker) = chars.next() else {
return false;
};
if marker != '=' && marker != '-' {
return false;
}
chars.all(|c| c == marker)
}
pub(crate) fn is_numbered_list_item(line: &str) -> bool {
let mut chars = line.chars();
if !chars.next().is_some_and(char::is_numeric) {
return false;
}
while let Some(c) = chars.next() {
if c == '.' {
return chars.next().is_some_and(|c| MARKER_PADDING.contains(&c));
}
if !c.is_numeric() {
return false;
}
}
false
}
pub(crate) fn is_list_item(line: &str) -> bool {
if (line.starts_with('-') || line.starts_with('*') || line.starts_with('+'))
&& line.chars().nth(1).is_some_and(|c| MARKER_PADDING.contains(&c))
{
return true;
}
is_numbered_list_item(line)
}
pub(crate) fn is_github_alert_marker(trimmed: &str) -> bool {
if !trimmed.starts_with("[!") {
return false;
}
let rest = &trimmed[2..];
let end = rest.find(|c: char| !c.is_ascii_uppercase()).unwrap_or(rest.len());
end > 0 && rest[end..].starts_with(']')
}
fn strip_structural_prefixes_slice(line: &str) -> (&str, usize) {
let mut s = line;
let mut offset = 0;
loop {
let prev_len = s.len();
let trimmed = s.trim_start();
let trim_len = s.len() - trimmed.len();
if let Some(rest) = trimmed.strip_prefix('>') {
s = rest;
offset += trim_len + 1;
continue;
}
if let Some(marker) = source_list_marker(trimmed) {
s = &trimmed[marker.text.len()..];
offset += trim_len + marker.text.len();
continue;
}
if s.len() == prev_len {
break;
}
}
(s, offset)
}
pub(crate) fn is_standalone_link_or_image_line(ctx: &LintContext, line_num: usize) -> bool {
let Some(line_info) = ctx.lines.get(line_num - 1) else {
return false;
};
let line = line_info.content(ctx.content);
let (stripped, offset) = strip_structural_prefixes_slice(line);
is_link_with_optional_emphasis(ctx, stripped, line_info.byte_offset + offset)
}
pub(crate) fn is_html_only_line(line: &str) -> bool {
let (stripped, _) = strip_structural_prefixes_slice(line);
is_html_only_content(stripped)
}
fn is_html_only_content(s: &str) -> bool {
let s = s.trim();
if s.is_empty() || !s.starts_with('<') {
return false;
}
if is_content_all_html_tags(s) {
return true;
}
if s.ends_with('>') && (s.contains("href=") || s.contains("src=") || s.contains("srcset=") || s.contains("poster="))
{
return true;
}
false
}
fn is_content_all_html_tags(s: &str) -> bool {
let s = s.trim();
if s.is_empty() || !s.starts_with('<') {
return false;
}
let mut in_tag = false;
let mut quote_char: Option<char> = None;
let mut found_complete_tag = false;
for c in s.chars() {
if let Some(q) = quote_char {
if c == q {
quote_char = None;
}
} else if in_tag {
match c {
'"' | '\'' => quote_char = Some(c),
'>' => {
in_tag = false;
found_complete_tag = true;
}
_ => {}
}
} else if c == '<' {
in_tag = true;
} else if !c.is_whitespace() {
return false;
}
}
found_complete_tag
}
fn is_matching_wrapper(s: &str, first: char, last: char) -> bool {
match (first, last) {
(f, l) if f == l && (f == '*' || f == '_' || f == '"' || f == '\'') => true,
('(', ')') => s.chars().filter(|&c| c == '(').count() == s.chars().filter(|&c| c == ')').count(),
('[', ']') => s.chars().filter(|&c| c == '[').count() == s.chars().filter(|&c| c == ']').count(),
('{', '}')
| ('\u{201C}', '\u{201D}') | ('\u{2018}', '\u{2019}') | ('(', ')')
| ('{', '}')
| ('【', '】')
| ('「', '」')
| ('『', '』') => true,
_ => false,
}
}
fn is_safe_leading(c: char) -> bool {
matches!(c, '(' | '{' | '(' | '{' | '【' | '「' | '『' | '[' | '*' | '_')
|| crate::utils::sentence_utils::is_opening_quote(c)
}
fn is_safe_trailing(c: char) -> bool {
matches!(
c,
'.' | ','
| ';'
| ':'
| '!'
| '?'
| '}'
| ')'
| '}'
| '】'
| '』'
| '」'
| ']'
| '、'
| ','
| ';'
| ':'
| '\\'
) || crate::utils::sentence_utils::is_closing_quote(c)
|| crate::utils::sentence_utils::is_cjk_sentence_ending(c)
}
fn has_matching_link_or_image(ctx: &LintContext, start: usize, end: usize) -> bool {
ctx.link_starting_at(start)
.is_some_and(|link| link.byte_end == end && !(link.link_type == LinkType::Shortcut && link.url.is_empty()))
|| ctx.image_starting_at(start).is_some_and(|image| image.byte_end == end)
}
fn is_link_with_optional_emphasis(ctx: &LintContext, s: &str, s_offset: usize) -> bool {
let mut s = s;
let mut s_start = s_offset;
let trimmed_start = s.len() - s.trim_start().len();
s_start += trimmed_start;
s = s.trim_start();
s = s.trim_end();
let mut s_end = s_start + s.len();
if s.is_empty() {
return false;
}
loop {
if has_matching_link_or_image(ctx, s_start, s_end) {
return true;
}
if let Some(l) = ctx.link_starting_at(s_start)
&& l.byte_end < s_end
&& l.link_type == LinkType::Shortcut
&& l.url.is_empty()
{
let remaining = &s[l.byte_end - s_start..];
if is_destination_group(remaining) {
return true;
}
}
if let Some(i) = ctx.image_starting_at(s_start)
&& i.byte_end < s_end
&& i.link_type == LinkType::Shortcut
&& i.url.is_empty()
{
let remaining = &s[i.byte_end - s_start..];
if is_destination_group(remaining) {
return true;
}
}
let prev_len = s.len();
if prev_len < 2 {
break;
}
let first = s.chars().next().unwrap();
let last = s.chars().next_back().unwrap();
if is_matching_wrapper(s, first, last) {
s = &s[first.len_utf8()..s.len() - last.len_utf8()];
s_start += first.len_utf8();
s_end -= last.len_utf8();
let ts = s.len() - s.trim_start().len();
s_start += ts;
s = s.trim_start();
let te = s.len() - s.trim_end().len();
s_end -= te;
s = s.trim_end();
continue;
}
if is_safe_leading(first) {
s = &s[first.len_utf8()..];
s_start += first.len_utf8();
let ts = s.len() - s.trim_start().len();
s_start += ts;
s = s.trim_start();
continue;
}
if is_safe_trailing(last) || last == '*' || last == '_' {
s = &s[..s.len() - last.len_utf8()];
s_end -= last.len_utf8();
let te = s.len() - s.trim_end().len();
s_end -= te;
s = s.trim_end();
continue;
}
if first == '[' && s.chars().filter(|&c| c == '[').count() > s.chars().filter(|&c| c == ']').count() {
s = &s[first.len_utf8()..];
s_start += first.len_utf8();
let ts = s.len() - s.trim_start().len();
s_start += ts;
s = s.trim_start();
continue;
}
if last == ')' && s.chars().filter(|&c| c == ')').count() > s.chars().filter(|&c| c == '(').count() {
s = &s[..s.len() - last.len_utf8()];
s_end -= last.len_utf8();
let te = s.len() - s.trim_end().len();
s_end -= te;
s = s.trim_end();
continue;
}
if last == ']' && s.chars().filter(|&c| c == ']').count() > s.chars().filter(|&c| c == '[').count() {
s = &s[..s.len() - last.len_utf8()];
s_end -= last.len_utf8();
let te = s.len() - s.trim_end().len();
s_end -= te;
s = s.trim_end();
continue;
}
if s.len() == prev_len {
break;
}
}
false
}
fn is_destination_group(s: &str) -> bool {
let s = s.trim_end();
if !s.starts_with('(') || !s.ends_with(')') {
return false;
}
let mut balance = 0;
let mut char_iter = s.chars().peekable();
while let Some(c) = char_iter.next() {
if c == '(' {
balance += 1;
} else if c == ')' {
balance -= 1;
if balance == 0 {
return char_iter.peek().is_none();
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MarkdownFlavor;
fn check_standalone(content: &str) -> bool {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
is_standalone_link_or_image_line(&ctx, 1)
}
#[test]
fn test_numbered_list_item_requires_space_after_period() {
assert!(is_numbered_list_item("1. Item"));
assert!(is_numbered_list_item("10. Item"));
assert!(is_numbered_list_item("99. Long number"));
assert!(is_numbered_list_item("123. Triple digits"));
assert!(!is_numbered_list_item("2019."));
assert!(!is_numbered_list_item("1999."));
assert!(!is_numbered_list_item("2023."));
assert!(!is_numbered_list_item("1."));
assert!(!is_numbered_list_item("a. Item"));
assert!(!is_numbered_list_item(". Item"));
assert!(!is_numbered_list_item("Item"));
assert!(!is_numbered_list_item("1 Item"));
assert!(!is_numbered_list_item("123"));
}
#[test]
fn test_extract_list_marker_task_checkboxes() {
assert_eq!(
extract_list_marker_and_content("- [ ] some content"),
("- [ ] ".to_string(), "some content".to_string())
);
assert_eq!(
extract_list_marker_and_content("- [x] done item"),
("- [x] ".to_string(), "done item".to_string())
);
assert_eq!(
extract_list_marker_and_content("- [X] also done"),
("- [X] ".to_string(), "also done".to_string())
);
assert_eq!(
extract_list_marker_and_content("* [ ] star task"),
("* [ ] ".to_string(), "star task".to_string())
);
assert_eq!(
extract_list_marker_and_content("+ [ ] plus task"),
("+ [ ] ".to_string(), "plus task".to_string())
);
assert_eq!(
extract_list_marker_and_content(" - [ ] indented task"),
(" - [ ] ".to_string(), "indented task".to_string())
);
assert_eq!(
extract_list_marker_and_content("- regular item"),
("- ".to_string(), "regular item".to_string())
);
assert_eq!(
extract_list_marker_and_content("1. [ ] unchecked ordered"),
("1. [ ] ".to_string(), "unchecked ordered".to_string())
);
assert_eq!(
extract_list_marker_and_content("1. [x] checked ordered"),
("1. [x] ".to_string(), "checked ordered".to_string())
);
assert_eq!(
extract_list_marker_and_content("1. [X] checked upper ordered"),
("1. [X] ".to_string(), "checked upper ordered".to_string())
);
assert_eq!(
extract_list_marker_and_content("99. [x] multi-digit ordered"),
("99. [x] ".to_string(), "multi-digit ordered".to_string())
);
}
#[test]
fn test_extract_list_marker_task_checkbox_with_wide_marker_spacing() {
assert_eq!(
extract_list_marker_and_content("- [ ] wide unchecked"),
("- [ ] ".to_string(), "wide unchecked".to_string())
);
assert_eq!(
extract_list_marker_and_content("* [x] wide checked"),
("* [x] ".to_string(), "wide checked".to_string())
);
assert_eq!(
extract_list_marker_and_content("+ [X] wide checked upper"),
("+ [X] ".to_string(), "wide checked upper".to_string())
);
assert_eq!(
extract_list_marker_and_content(" - [ ] indented and wide"),
(" - [ ] ".to_string(), "indented and wide".to_string())
);
assert_eq!(
extract_list_marker_and_content("1. [ ] wide ordered"),
("1. [ ] ".to_string(), "wide ordered".to_string())
);
assert_eq!(
extract_list_marker_and_content("99. [x] wide multi-digit"),
("99. [x] ".to_string(), "wide multi-digit".to_string())
);
assert_eq!(
extract_list_marker_and_content("- \t[ ] tab padded"),
("- [ ] ".to_string(), "tab padded".to_string())
);
assert_eq!(
extract_list_marker_and_content("-\t[ ] tab only"),
("- [ ] ".to_string(), "tab only".to_string())
);
assert_eq!(
extract_list_marker_and_content("1.\t[x] ordered tab only"),
("1. [x] ".to_string(), "ordered tab only".to_string())
);
assert_eq!(
extract_list_marker_and_content("-\tplain tab item"),
("- ".to_string(), "plain tab item".to_string())
);
assert_eq!(
extract_list_marker_and_content("1.\tplain ordered tab item"),
("1. ".to_string(), "plain ordered tab item".to_string())
);
assert_eq!(
extract_list_marker_and_content("- plain wide item"),
("- ".to_string(), " plain wide item".to_string())
);
assert_eq!(
extract_list_marker_and_content("- [link] text"),
("- ".to_string(), " [link] text".to_string())
);
}
#[test]
fn test_is_horizontal_rule_commonmark_indent() {
assert!(is_horizontal_rule("---"));
assert!(is_horizontal_rule(" ---"));
assert!(is_horizontal_rule(" ---"));
assert!(is_horizontal_rule(" ---"));
assert!(is_horizontal_rule(" ***"));
assert!(is_horizontal_rule(" - - -"));
assert!(!is_horizontal_rule(" ---"));
assert!(!is_horizontal_rule(" ---"));
assert!(!is_horizontal_rule(" ***"));
assert!(!is_horizontal_rule(" - - -"));
assert!(is_horizontal_rule("--- "));
assert!(is_horizontal_rule(" --- "));
assert!(is_horizontal_rule("----"));
assert!(is_horizontal_rule("***"));
assert!(is_horizontal_rule("___"));
assert!(is_horizontal_rule("- - -"));
assert!(!is_horizontal_rule("--"));
assert!(!is_horizontal_rule("text"));
assert!(!is_horizontal_rule(""));
}
#[test]
fn test_is_list_item_bullet_and_numbered() {
assert!(is_list_item("- Item"));
assert!(is_list_item("* Item"));
assert!(is_list_item("+ Item"));
assert!(!is_list_item("-Item"));
assert!(!is_list_item("*Item"));
assert!(is_list_item("1. Item"));
assert!(is_list_item("99. Item"));
assert!(!is_list_item("2019."));
}
#[test]
fn test_is_github_alert_marker() {
assert!(is_github_alert_marker("[!NOTE]"));
assert!(is_github_alert_marker("[!TIP]"));
assert!(is_github_alert_marker("[!WARNING]"));
assert!(is_github_alert_marker("[!CAUTION]"));
assert!(is_github_alert_marker("[!IMPORTANT]"));
assert!(is_github_alert_marker("[!NOTE] Some inline content here"));
assert!(is_github_alert_marker("[!WARNING] Do not do this"));
assert!(is_github_alert_marker("[!CUSTOM]"));
assert!(!is_github_alert_marker("[!note]")); assert!(!is_github_alert_marker("[Note]")); assert!(!is_github_alert_marker("[!]")); assert!(!is_github_alert_marker("[!NOTE")); assert!(!is_github_alert_marker("NOTE")); assert!(!is_github_alert_marker("[link]: url")); assert!(!is_github_alert_marker("Some text [!NOTE]")); }
#[test]
fn test_standalone_link_bare() {
assert!(check_standalone("[text](https://example.com)"));
assert!(check_standalone("[long title here](https://example.com/path)"));
assert!(check_standalone(" [text](https://example.com)"));
assert!(check_standalone(
"[Rust](https://en.wikipedia.org/wiki/Rust_(programming_language))"
));
assert!(check_standalone("[A](https://example.com/A_(B)_C)"));
}
#[test]
fn test_emphasis_only_line_is_not_a_standalone_link() {
for s in ["*", "**", "***", "_", "__", "___", "*_*", "**_", "_**"] {
assert!(!check_standalone(s), "{s:?} is not a standalone link");
}
assert!(check_standalone("*[text](https://example.com)*"));
assert!(check_standalone("**[text](https://example.com)**"));
}
#[test]
fn test_standalone_image() {
assert!(check_standalone(""));
assert!(check_standalone(" "));
assert!(check_standalone(""));
}
#[test]
fn test_standalone_link_in_list() {
assert!(check_standalone("- [text](url)"));
assert!(check_standalone("* [text](url)"));
assert!(check_standalone("+ [text](url)"));
assert!(check_standalone("1. [text](url)"));
assert!(check_standalone("99. [text](url)"));
assert!(check_standalone(" - [text](url)"));
assert!(check_standalone("- [ ] [text](url)"));
assert!(check_standalone("- [x] [text](url)"));
assert!(check_standalone("1. [x] [text](url)"));
assert!(check_standalone("1. [ ] [text](url)"));
assert!(check_standalone(
"* [Front Matter Defaults]({{ '...' | relative_url }})"
));
}
#[test]
fn test_standalone_link_in_blockquote() {
assert!(check_standalone("> [text](url)"));
assert!(check_standalone(">> [text](url)"));
assert!(check_standalone("> > [text](url)"));
}
#[test]
fn test_standalone_link_with_emphasis() {
assert!(check_standalone("**[text](url)**"));
assert!(check_standalone("*[text](url)*"));
assert!(check_standalone("__[text](url)__"));
assert!(check_standalone("_[text](url)_"));
assert!(check_standalone("***[text](url)***"));
assert!(check_standalone("- **[text](url)**"));
}
#[test]
fn test_standalone_link_reference_style() {
assert!(check_standalone("[text][]\n\n[text]: url"));
assert!(check_standalone("[text][ref]"));
assert!(check_standalone("![alt][ref]"));
assert!(check_standalone("- [text][ref]"));
assert!(check_standalone("> [text][ref]"));
assert!(check_standalone("[text][]"));
assert!(check_standalone("- [text][]"));
}
#[test]
fn test_standalone_link_with_trailing_punctuation() {
assert!(check_standalone("[text](url),"));
assert!(check_standalone("[text](url)."));
assert!(check_standalone("[text](url);"));
assert!(check_standalone("[text](url):"));
assert!(check_standalone("[text](url)?"));
assert!(check_standalone("[text](url)!"));
assert!(check_standalone("[text](url)\""));
assert!(check_standalone("[text](url)'"));
assert!(check_standalone("[text](url)”"));
assert!(check_standalone("[text](url)’"));
assert!(check_standalone("([text](url))"));
assert!(check_standalone("{[text](url)}"));
assert!(check_standalone("\"[text](url)\""));
assert!(check_standalone("'[text](url)'"));
assert!(check_standalone("“[text](url)”"));
assert!(check_standalone("‘[text](url)’"));
assert!(check_standalone("([text](url))"));
assert!(check_standalone("{[text](url)}"));
assert!(check_standalone("【[text](url)】"));
assert!(check_standalone("「[text](url)」"));
assert!(check_standalone("『[text](url)』"));
assert!(check_standalone("[text](url)。"));
assert!(check_standalone("[text](url),"));
assert!(check_standalone("[text](url);"));
assert!(check_standalone("[text](url):"));
assert!(check_standalone("[text](url)!"));
assert!(check_standalone("[text](url)?"));
assert!(check_standalone("[text](url)、"));
assert!(check_standalone("[text](url)..."));
assert!(check_standalone("[text](url)?!"));
assert!(check_standalone("**[text](url)**,"));
assert!(check_standalone("*[text](url)*."));
assert!(check_standalone("***[text](url)***!"));
assert!(check_standalone("**[text](url),**"));
assert!(check_standalone("*[text](url).*"));
assert!(check_standalone("***[text](url)!***"));
assert!(check_standalone("- [text](url),"));
assert!(check_standalone("> [text](url)."));
assert!(check_standalone(" - **[text](url)**;"));
assert!(check_standalone("[text][ref],"));
assert!(check_standalone("![alt][ref]."));
assert!(check_standalone("- ***[text][ref]***!"));
assert!(check_standalone(","));
assert!(check_standalone("**_***[text](url).***._.**"));
}
#[test]
fn test_not_standalone_link() {
assert!(!check_standalone("Some text [link](url)"));
assert!(!check_standalone("See [link](url) for details"));
assert!(!check_standalone("Just some long text"));
assert!(!check_standalone("[text]"));
assert!(!check_standalone(""));
assert!(!check_standalone(" "));
assert!(!check_standalone("[link1](url1) [link2](url2)"));
assert!(!check_standalone("[link](url) extra text"));
}
#[test]
fn test_link_followed_by_parenthetical_is_not_standalone() {
assert!(!check_standalone(
"- [ripgrep](https://github.com/BurntSushi/ripgrep) (a line-oriented search tool)"
));
assert!(!check_standalone(
"[the docs](https://example.com/d) (updated for 2026, including the new guide)"
));
assert!(!check_standalone(
" (captured on a retina display with the sidebar hidden)"
));
assert!(!check_standalone(
"[NOTE] (this applies only when the feature flag is enabled)"
));
assert!(!check_standalone(
"[the docs](https://example.com/d)(a parenthetical stuck onto the link)"
));
assert!(check_standalone(
""
));
assert!(check_standalone(
"* [Front Matter Defaults]({{ '/assets/img/very-long-image-name.png' | relative_url }})"
));
}
#[test]
fn test_html_only_badge_line() {
assert!(is_html_only_line(
r#"<a href="https://dotfyle.com/plugins/chrisgrieser/nvim-rulebook"><img alt="badge" src="https://dotfyle.com/plugins/chrisgrieser/nvim-rulebook/shield"/></a>"#
));
}
#[test]
fn test_html_only_self_closing_tags() {
assert!(is_html_only_line(
r#"<img src="https://example.com/image.png" alt="screenshot" width="800" height="600"/>"#
));
assert!(is_html_only_line(r#"<br/>"#));
assert!(is_html_only_line(r#"<hr />"#));
}
#[test]
fn test_html_only_multiple_tags() {
assert!(is_html_only_line(r#"<img src="a.png"/><img src="b.png"/>"#));
assert!(is_html_only_line(r#"<br/><br/><br/>"#));
}
#[test]
fn test_html_only_empty_element() {
assert!(is_html_only_line(r#"<video src="long-url.mp4" controls></video>"#));
assert!(is_html_only_line(r#"<div></div>"#));
}
#[test]
fn test_html_only_with_whitespace_between_tags() {
assert!(is_html_only_line(r#"<img src="a.png"/> <img src="b.png"/>"#));
}
#[test]
fn test_html_only_quoted_angle_brackets() {
assert!(is_html_only_line(r#"<img alt="a > b" src="test.png"/>"#));
assert!(is_html_only_line(r#"<img alt='a > b' src="test.png"/>"#));
}
#[test]
fn test_html_only_in_blockquote() {
assert!(is_html_only_line(r#"> <img src="long-url.png" alt="screenshot"/>"#));
assert!(is_html_only_line(r#">> <a href="url"><img src="img"/></a>"#));
}
#[test]
fn test_html_only_in_list() {
assert!(is_html_only_line(r#"- <img src="long-url.png" alt="screenshot"/>"#));
assert!(is_html_only_line(r#"1. <a href="url"><img src="img"/></a>"#));
assert!(is_html_only_line(r#" - <img src="long-url.png"/>"#));
}
#[test]
fn test_html_only_link_with_text_and_url() {
assert!(is_html_only_line(
r#"<a href="https://example.com/very-long-path">Click here for details</a>"#
));
assert!(is_html_only_line(
r#"<a href="https://example.com/very-long-path" target="_blank">Click here for details</a>"#
));
assert!(is_html_only_line(
r#"<a href="https://example.com/path"><img src="https://example.com/badge.svg" alt="status"/></a>"#
));
}
#[test]
fn test_not_html_only_text_before_tags() {
assert!(!is_html_only_line(r#"Click here: <a href="url">link</a>"#));
assert!(!is_html_only_line(r#"See <img src="url"/> for details"#));
}
#[test]
fn test_not_html_only_text_after_tags() {
assert!(!is_html_only_line(r#"<a href="url">link</a> - click above"#));
assert!(!is_html_only_line(r#"<img src="url"/> is an image"#));
}
#[test]
fn test_not_html_only_formatting_tags_without_urls() {
assert!(!is_html_only_line(
r#"<b>This is very long bold text that exceeds the line length limit</b>"#
));
assert!(!is_html_only_line(
r#"<p>This is a very long paragraph written in HTML tags for some reason</p>"#
));
assert!(!is_html_only_line(
r#"<span style="color:red">Some styled text that is quite long</span>"#
));
assert!(!is_html_only_line(
r#"<em>Emphasized text that goes on and on and on</em>"#
));
assert!(!is_html_only_line(r#"<b>bold</b> and <i>italic</i>"#));
}
#[test]
fn test_not_html_only_plain_text() {
assert!(!is_html_only_line("Just some long text without any HTML"));
assert!(!is_html_only_line(""));
assert!(!is_html_only_line(" "));
}
#[test]
fn test_not_html_only_incomplete_tag() {
assert!(!is_html_only_line("<unclosed"));
assert!(!is_html_only_line(r#"<a href="url">text"#));
}
#[test]
fn test_html_only_comment() {
assert!(is_html_only_line(
"<!-- this is a long HTML comment that spans many characters -->"
));
}
#[test]
fn test_html_only_media_elements() {
assert!(is_html_only_line(
r#"<video src="https://example.com/very-long-path/video.mp4" poster="https://example.com/thumb.jpg" controls></video>"#
));
assert!(is_html_only_line(
r#"<audio src="https://example.com/very-long-path/audio.mp3" controls></audio>"#
));
assert!(is_html_only_line(
r#"<source srcset="https://example.com/image-large.webp" media="(min-width: 800px)"/>"#
));
assert!(is_html_only_line(
r#"<picture><source srcset="large.webp"/><img src="fallback.png"/></picture>"#
));
}
#[test]
fn test_html_only_in_list_with_url_text() {
assert!(is_html_only_line(
r#"- <a href="https://example.com/very-long-path">documentation link</a>"#
));
}
#[test]
fn test_html_only_in_blockquote_with_url_text() {
assert!(is_html_only_line(
r#"> <a href="https://example.com/very-long-path">documentation link</a>"#
));
}
#[test]
fn setext_underline_content_accepts_either_marker_at_any_width() {
for content in ["=", "===", "-", "--", "---", " === ", "\t---\t"] {
assert!(
is_setext_underline_content(content),
"{content:?} is a setext underline"
);
}
}
#[test]
fn setext_underline_content_rejects_internal_spaces_and_mixed_markers() {
for content in ["= = =", "- - -", "=-=", "--=", "= x", "", " ", "prose", "-> arrow"] {
assert!(
!is_setext_underline_content(content),
"{content:?} is not a setext underline"
);
}
}
#[test]
fn setext_heading_lookup_follows_the_parser() {
let ctx = LintContext::new("Setup\n=====\n\nSubhead\n---\n", MarkdownFlavor::Standard, None);
assert!(is_setext_heading_text_line(&ctx, 1));
assert!(!is_setext_heading_text_line(&ctx, 2));
assert!(is_setext_heading_text_line(&ctx, 4));
assert!(!is_setext_heading_text_line(&ctx, 5));
assert!(!is_setext_heading_text_line(&ctx, 3));
assert!(!is_setext_heading_text_line(&ctx, 0));
}
#[test]
fn setext_heading_lookup_inherits_the_parsers_disqualifiers() {
let ctx = LintContext::new("- item\n---\n", MarkdownFlavor::Standard, None);
assert!(!is_setext_heading_text_line(&ctx, 1));
let ctx = LintContext::new("```\nSetup\n=====\n```\n", MarkdownFlavor::Standard, None);
assert!(!is_setext_heading_text_line(&ctx, 2));
let ctx = LintContext::new("> Setup\n> =====\n", MarkdownFlavor::Standard, None);
assert!(!is_setext_heading_text_line(&ctx, 1));
}
}