use crate::config::MarkdownFlavor;
use crate::lint_context::LineInfo;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::range_utils::calculate_line_range;
const GFM_ALERT_TYPES: &[&str] = &["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"];
#[derive(Clone)]
pub struct MD028NoBlanksBlockquote;
impl MD028NoBlanksBlockquote {
#[inline]
fn is_blockquote_line(line: &str) -> bool {
if !line.as_bytes().contains(&b'>') {
return false;
}
line.trim_start().starts_with('>')
}
fn get_blockquote_info(line: &str) -> (usize, usize) {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
let whitespace_end = i;
let mut level = 0;
while i < bytes.len() {
if bytes[i] == b'>' {
level += 1;
i += 1;
} else if bytes[i] == b' ' || bytes[i] == b'\t' {
i += 1;
} else {
break;
}
}
(level, whitespace_end)
}
#[inline]
fn is_in_skip_context(line_infos: &[LineInfo], idx: usize) -> bool {
if let Some(li) = line_infos.get(idx) {
li.in_html_comment || li.in_code_block || li.in_html_block || li.in_front_matter
} else {
false
}
}
fn has_content_between(lines: &[&str], line_infos: &[LineInfo], start: usize, end: usize) -> bool {
for (offset, line) in lines[start..end].iter().enumerate() {
let idx = start + offset;
if Self::is_in_skip_context(line_infos, idx) {
if !line.trim().is_empty() {
return true;
}
continue;
}
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('>') {
return true;
}
}
false
}
#[inline]
fn is_gfm_alert_line(line: &str) -> bool {
if !line.contains("[!") {
return false;
}
let trimmed = line.trim_start();
if !trimmed.starts_with('>') {
return false;
}
let content = trimmed
.trim_start_matches('>')
.trim_start_matches([' ', '\t'])
.trim_start_matches('>')
.trim_start();
if !content.starts_with("[!") {
return false;
}
if let Some(end_bracket) = content.find(']') {
let alert_type = &content[2..end_bracket];
return GFM_ALERT_TYPES.iter().any(|&t| t.eq_ignore_ascii_case(alert_type));
}
false
}
#[inline]
fn is_obsidian_callout_line(line: &str) -> bool {
if !line.contains("[!") {
return false;
}
let trimmed = line.trim_start();
if !trimmed.starts_with('>') {
return false;
}
let content = trimmed
.trim_start_matches('>')
.trim_start_matches([' ', '\t'])
.trim_start_matches('>')
.trim_start();
if !content.starts_with("[!") {
return false;
}
if let Some(end_bracket) = content.find(']') {
if end_bracket > 2 {
let alert_type = &content[2..end_bracket];
return !alert_type.is_empty()
&& alert_type.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_');
}
}
false
}
#[inline]
fn is_callout_line(line: &str, flavor: MarkdownFlavor) -> bool {
match flavor {
MarkdownFlavor::Obsidian => Self::is_obsidian_callout_line(line),
_ => Self::is_gfm_alert_line(line),
}
}
fn find_blockquote_start(lines: &[&str], line_infos: &[LineInfo], from_idx: usize) -> Option<usize> {
if from_idx >= lines.len() {
return None;
}
let mut start_idx = from_idx;
for i in (0..=from_idx).rev() {
if Self::is_in_skip_context(line_infos, i) {
continue;
}
let line = lines[i];
if Self::is_blockquote_line(line) {
start_idx = i;
} else if line.trim().is_empty() {
if start_idx == from_idx && !Self::is_blockquote_line(lines[from_idx]) {
continue;
}
break;
} else {
break;
}
}
if Self::is_blockquote_line(lines[start_idx]) && !Self::is_in_skip_context(line_infos, start_idx) {
Some(start_idx)
} else {
None
}
}
fn is_callout_block(
lines: &[&str],
line_infos: &[LineInfo],
blockquote_line_idx: usize,
flavor: MarkdownFlavor,
) -> bool {
if let Some(start_idx) = Self::find_blockquote_start(lines, line_infos, blockquote_line_idx) {
return Self::is_callout_line(lines[start_idx], flavor);
}
false
}
fn are_likely_same_blockquote(
lines: &[&str],
line_infos: &[LineInfo],
blank_idx: usize,
flavor: MarkdownFlavor,
) -> bool {
let mut prev_quote_idx = None;
let mut next_quote_idx = None;
for i in (0..blank_idx).rev() {
if Self::is_in_skip_context(line_infos, i) {
continue;
}
let line = lines[i];
if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
prev_quote_idx = Some(i);
break;
}
}
for (i, line) in lines.iter().enumerate().skip(blank_idx + 1) {
if Self::is_in_skip_context(line_infos, i) {
continue;
}
if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
next_quote_idx = Some(i);
break;
}
}
let (prev_idx, next_idx) = match (prev_quote_idx, next_quote_idx) {
(Some(p), Some(n)) => (p, n),
_ => return false,
};
let prev_is_callout = Self::is_callout_block(lines, line_infos, prev_idx, flavor);
let next_is_callout = Self::is_callout_block(lines, line_infos, next_idx, flavor);
if prev_is_callout || next_is_callout {
return false;
}
if Self::has_content_between(lines, line_infos, prev_idx + 1, next_idx) {
return false;
}
let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
if next_level < prev_level {
return false;
}
let prev_line = lines[prev_idx];
let next_line = lines[next_idx];
let prev_indent = &prev_line[..prev_whitespace_end];
let next_indent = &next_line[..next_whitespace_end];
prev_indent == next_indent
}
fn is_problematic_blank_line(
lines: &[&str],
line_infos: &[LineInfo],
index: usize,
flavor: MarkdownFlavor,
) -> Option<(usize, String)> {
let current_line = lines[index];
if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
return None;
}
if !Self::are_likely_same_blockquote(lines, line_infos, index, flavor) {
return None;
}
for i in (0..index).rev() {
if Self::is_in_skip_context(line_infos, i) {
continue;
}
let line = lines[i];
if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
let (level, whitespace_end) = Self::get_blockquote_info(line);
let indent = &line[..whitespace_end];
let mut fix = String::with_capacity(indent.len() + level);
fix.push_str(indent);
for _ in 0..level {
fix.push('>');
}
return Some((level, fix));
}
}
None
}
}
impl Default for MD028NoBlanksBlockquote {
fn default() -> Self {
Self
}
}
impl Rule for MD028NoBlanksBlockquote {
fn name(&self) -> &'static str {
"MD028"
}
fn description(&self) -> &'static str {
"Blank line inside blockquote"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
if !ctx.content.contains('>') {
return Ok(Vec::new());
}
let mut warnings = Vec::new();
let lines = ctx.raw_lines();
let mut blank_line_indices = Vec::new();
let mut has_blockquotes = false;
for (line_idx, line) in lines.iter().enumerate() {
if line_idx < ctx.lines.len() {
let li = &ctx.lines[line_idx];
if li.in_code_block || li.in_html_comment || li.in_html_block || li.in_front_matter {
continue;
}
}
if line.trim().is_empty() {
blank_line_indices.push(line_idx);
} else if Self::is_blockquote_line(line) {
has_blockquotes = true;
}
}
if !has_blockquotes {
return Ok(Vec::new());
}
for &line_idx in &blank_line_indices {
let line_num = line_idx + 1;
if let Some((level, fix_content)) = Self::is_problematic_blank_line(lines, &ctx.lines, line_idx, ctx.flavor)
{
let line = lines[line_idx];
let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message: format!("Blank line inside blockquote (level {level})"),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
severity: Severity::Warning,
fix: Some(Fix {
range: ctx
.line_index
.line_col_to_byte_range_with_length(line_num, 1, line.len()),
replacement: fix_content,
}),
});
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let mut result = Vec::with_capacity(ctx.lines.len());
let lines = ctx.raw_lines();
for (line_idx, line) in lines.iter().enumerate() {
if ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1) {
result.push(line.to_string());
continue;
}
if line_idx < ctx.lines.len() {
let li = &ctx.lines[line_idx];
if li.in_code_block || li.in_html_comment || li.in_html_block || li.in_front_matter {
result.push(line.to_string());
continue;
}
}
if let Some((_, fix_content)) = Self::is_problematic_blank_line(lines, &ctx.lines, line_idx, ctx.flavor) {
result.push(fix_content);
} else {
result.push(line.to_string());
}
}
Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
}
fn category(&self) -> RuleCategory {
RuleCategory::Blockquote
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
!ctx.likely_has_blockquotes()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(MD028NoBlanksBlockquote)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_no_blockquotes() {
let rule = MD028NoBlanksBlockquote;
let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag content without blockquotes");
}
#[test]
fn test_valid_blockquote_no_blanks() {
let rule = MD028NoBlanksBlockquote;
let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
}
#[test]
fn test_blockquote_with_empty_line_marker() {
let rule = MD028NoBlanksBlockquote;
let content = "> First line\n>\n> Third line";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag lines with just > marker");
}
#[test]
fn test_blockquote_with_empty_line_marker_and_space() {
let rule = MD028NoBlanksBlockquote;
let content = "> First line\n> \n> Third line";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag lines with > and space");
}
#[test]
fn test_blank_line_in_blockquote() {
let rule = MD028NoBlanksBlockquote;
let content = "> First line\n\n> Third line";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
assert_eq!(result[0].line, 2);
assert!(result[0].message.contains("Blank line inside blockquote"));
}
#[test]
fn test_multiple_blank_lines() {
let rule = MD028NoBlanksBlockquote;
let content = "> First\n\n\n> Fourth";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
assert_eq!(result[0].line, 2);
assert_eq!(result[1].line, 3);
}
#[test]
fn test_nested_blockquote_blank() {
let rule = MD028NoBlanksBlockquote;
let content = ">> Nested quote\n\n>> More nested";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 2);
}
#[test]
fn test_nested_blockquote_with_marker() {
let rule = MD028NoBlanksBlockquote;
let content = ">> Nested quote\n>>\n>> More nested";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag lines with >> marker");
}
#[test]
fn test_fix_single_blank() {
let rule = MD028NoBlanksBlockquote;
let content = "> First\n\n> Third";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "> First\n>\n> Third");
}
#[test]
fn test_fix_nested_blank() {
let rule = MD028NoBlanksBlockquote;
let content = ">> Nested\n\n>> More";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, ">> Nested\n>>\n>> More");
}
#[test]
fn test_fix_with_indentation() {
let rule = MD028NoBlanksBlockquote;
let content = " > Indented quote\n\n > More";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, " > Indented quote\n >\n > More");
}
#[test]
fn test_mixed_levels() {
let rule = MD028NoBlanksBlockquote;
let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 2);
}
#[test]
fn test_blockquote_with_code_block() {
let rule = MD028NoBlanksBlockquote;
let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag line with > marker");
}
#[test]
fn test_category() {
let rule = MD028NoBlanksBlockquote;
assert_eq!(rule.category(), RuleCategory::Blockquote);
}
#[test]
fn test_should_skip() {
let rule = MD028NoBlanksBlockquote;
let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
assert!(rule.should_skip(&ctx1));
let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
assert!(!rule.should_skip(&ctx2));
}
#[test]
fn test_empty_content() {
let rule = MD028NoBlanksBlockquote;
let content = "";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_blank_after_blockquote() {
let rule = MD028NoBlanksBlockquote;
let content = "> Quote\n\nNot a quote";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Blank line after blockquote ends is valid");
}
#[test]
fn test_blank_before_blockquote() {
let rule = MD028NoBlanksBlockquote;
let content = "Not a quote\n\n> Quote";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Blank line before blockquote starts is valid");
}
#[test]
fn test_preserve_trailing_newline() {
let rule = MD028NoBlanksBlockquote;
let content = "> Quote\n\n> More\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.ends_with('\n'));
let content_no_newline = "> Quote\n\n> More";
let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
let fixed2 = rule.fix(&ctx2).unwrap();
assert!(!fixed2.ends_with('\n'));
}
#[test]
fn test_document_structure_extension() {
let rule = MD028NoBlanksBlockquote;
let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag valid blockquote");
let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
}
#[test]
fn test_deeply_nested_blank() {
let rule = MD028NoBlanksBlockquote;
let content = ">>> Deep nest\n\n>>> More deep";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
}
#[test]
fn test_deeply_nested_with_marker() {
let rule = MD028NoBlanksBlockquote;
let content = ">>> Deep nest\n>>>\n>>> More deep";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag lines with >>> marker");
}
#[test]
fn test_complex_blockquote_structure() {
let rule = MD028NoBlanksBlockquote;
let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag line with > marker");
}
#[test]
fn test_complex_with_blank() {
let rule = MD028NoBlanksBlockquote;
let content = "> Level 1\n> > Nested\n\n> Back to level 1";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Blank between different nesting levels is not inside blockquote"
);
}
#[test]
fn test_gfm_alert_detection_note() {
assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); }
#[test]
fn test_gfm_alert_detection_all_types() {
assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
}
#[test]
fn test_gfm_alert_detection_not_alert() {
assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("Regular text [!NOTE]")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> ")); }
#[test]
fn test_gfm_alerts_separated_by_blank_line() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
}
#[test]
fn test_gfm_alerts_all_five_types_separated() {
let rule = MD028NoBlanksBlockquote;
let content = r#"> [!NOTE]
> Note content
> [!TIP]
> Tip content
> [!IMPORTANT]
> Important content
> [!WARNING]
> Warning content
> [!CAUTION]
> Caution content"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag blank lines between any GFM alert types"
);
}
#[test]
fn test_gfm_alert_with_multiple_lines() {
let rule = MD028NoBlanksBlockquote;
let content = r#"> [!WARNING]
> This is a warning
> with multiple lines
> of content
> [!NOTE]
> This is a note"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag blank line between multi-line GFM alerts"
);
}
#[test]
fn test_gfm_alert_followed_by_regular_blockquote() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag blank line after GFM alert");
}
#[test]
fn test_regular_blockquote_followed_by_gfm_alert() {
let rule = MD028NoBlanksBlockquote;
let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag blank line before GFM alert");
}
#[test]
fn test_regular_blockquotes_still_flagged() {
let rule = MD028NoBlanksBlockquote;
let content = "> First blockquote\n\n> Second blockquote";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should still flag blank line between regular blockquotes"
);
}
#[test]
fn test_gfm_alert_blank_line_within_same_alert() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"GFM alert status propagates to subsequent blockquote lines"
);
}
#[test]
fn test_gfm_alert_case_insensitive() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "GFM alert detection should be case insensitive");
}
#[test]
fn test_gfm_alert_with_nested_blockquote() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag blank between alerts even with nested content"
);
}
#[test]
fn test_gfm_alert_indented() {
let rule = MD028NoBlanksBlockquote;
let content = " > [!NOTE]\n > Indented note\n\n > [!TIP]\n > Indented tip";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
}
#[test]
fn test_gfm_alert_mixed_with_regular_content() {
let rule = MD028NoBlanksBlockquote;
let content = r#"# Heading
Some paragraph.
> [!NOTE]
> Important note
More paragraph text.
> [!WARNING]
> Be careful!
Final text."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"GFM alerts in mixed document should not trigger warnings"
);
}
#[test]
fn test_gfm_alert_fix_not_applied() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
}
#[test]
fn test_gfm_alert_multiple_blank_lines_between() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag multiple blank lines between GFM alerts"
);
}
#[test]
fn test_obsidian_callout_detection() {
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
}
#[test]
fn test_obsidian_callout_custom_types() {
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
}
#[test]
fn test_obsidian_callout_foldable() {
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
"> [!NOTE]- Collapsed"
));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
}
#[test]
fn test_obsidian_callout_with_title() {
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
"> [!NOTE] Custom Title"
));
assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
"> [!WARNING]+ Be Careful!"
));
}
#[test]
fn test_obsidian_callout_invalid() {
assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
"> Regular blockquote"
));
assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
"Regular text [!NOTE]"
)); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); }
#[test]
fn test_obsidian_callouts_separated_by_blank_line() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag blank line between Obsidian callouts"
);
}
#[test]
fn test_obsidian_custom_callouts_separated() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag blank line between custom Obsidian callouts"
);
}
#[test]
fn test_obsidian_foldable_callouts_separated() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag blank line between foldable Obsidian callouts"
);
}
#[test]
fn test_obsidian_custom_not_recognized_in_standard_flavor() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Custom callout types should be flagged in Standard flavor"
);
}
#[test]
fn test_obsidian_gfm_alerts_work_in_both_flavors() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
let result_standard = rule.check(&ctx_standard).unwrap();
assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result_obsidian = rule.check(&ctx_obsidian).unwrap();
assert!(
result_obsidian.is_empty(),
"GFM alerts should also work in Obsidian flavor"
);
}
#[test]
fn test_obsidian_callout_all_builtin_types() {
let rule = MD028NoBlanksBlockquote;
let content = r#"> [!note]
> Note
> [!abstract]
> Abstract
> [!summary]
> Summary
> [!info]
> Info
> [!todo]
> Todo
> [!tip]
> Tip
> [!success]
> Success
> [!question]
> Question
> [!warning]
> Warning
> [!failure]
> Failure
> [!danger]
> Danger
> [!bug]
> Bug
> [!example]
> Example
> [!quote]
> Quote"#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "All Obsidian callout types should be recognized");
}
#[test]
fn test_obsidian_fix_not_applied_to_callouts() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"Fix should not modify blank lines between Obsidian callouts"
);
}
#[test]
fn test_obsidian_regular_blockquotes_still_flagged() {
let rule = MD028NoBlanksBlockquote;
let content = "> First blockquote\n\n> Second blockquote";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Regular blockquotes should still be flagged in Obsidian flavor"
);
}
#[test]
fn test_obsidian_callout_mixed_with_regular_blockquote() {
let rule = MD028NoBlanksBlockquote;
let content = "> [!note]\n> Note content\n\n> Regular blockquote";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag blank after callout even if followed by regular blockquote"
);
}
#[test]
fn test_html_comment_blockquotes_not_flagged() {
let rule = MD028NoBlanksBlockquote;
let content = "## Responses\n\n<!--\n> First response text here.\n> <br>— Person One\n\n> Second response text here.\n> <br>— Person Two\n-->\n\nThe above responses are currently disabled.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag blank lines inside HTML comments, got: {result:?}"
);
}
#[test]
fn test_fix_preserves_html_comment_content() {
let rule = MD028NoBlanksBlockquote;
let content = "<!--\n> First quote\n\n> Second quote\n-->\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Fix should not modify content inside HTML comments");
}
#[test]
fn test_multiline_html_comment_with_blockquotes() {
let rule = MD028NoBlanksBlockquote;
let content = "# Title\n\n<!--\n> Quote A\n> Line 2\n\n> Quote B\n> Line 2\n\n> Quote C\n-->\n\nSome text\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag any blank lines inside HTML comments, got: {result:?}"
);
}
#[test]
fn test_blockquotes_outside_html_comment_still_flagged() {
let rule = MD028NoBlanksBlockquote;
let content = "> First quote\n\n> Second quote\n\n<!--\n> Commented quote A\n\n> Commented quote B\n-->\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
for w in &result {
assert!(
w.line < 5,
"Warning at line {} should not be inside HTML comment",
w.line
);
}
assert!(
!result.is_empty(),
"Should still flag blank line between blockquotes outside HTML comment"
);
}
#[test]
fn test_frontmatter_blockquote_like_content_not_flagged() {
let rule = MD028NoBlanksBlockquote;
let content = "---\n> not a real blockquote\n\n> also not real\n---\n\n# Title\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag content inside frontmatter, got: {result:?}"
);
}
#[test]
fn test_comment_boundary_does_not_leak_into_adjacent_blockquotes() {
let rule = MD028NoBlanksBlockquote;
let content = "> real quote\n\n<!--\n> commented quote\n-->\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not match blockquotes across HTML comment boundaries, got: {result:?}"
);
}
#[test]
fn test_blockquote_after_comment_boundary_not_matched() {
let rule = MD028NoBlanksBlockquote;
let content = "<!--\n> commented quote\n-->\n\n> real quote\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not match blockquotes across HTML comment boundaries, got: {result:?}"
);
}
#[test]
fn test_fix_preserves_comment_boundary_content() {
let rule = MD028NoBlanksBlockquote;
let content = "> real quote\n\n<!--\n> commented quote A\n\n> commented quote B\n-->\n\n> another real quote\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"Fix should not modify content when blockquotes are separated by comment boundaries"
);
}
#[test]
fn test_inline_html_comment_does_not_suppress_warning() {
let rule = MD028NoBlanksBlockquote;
let content = "> quote with <!-- inline comment -->\n\n> continuation\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should still flag blank lines between blockquotes with inline HTML comments"
);
}
#[test]
fn test_comment_with_blockquote_markers_on_delimiters() {
let rule = MD028NoBlanksBlockquote;
let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should only warn about blank between real quotes, got: {result:?}"
);
assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
}
#[test]
fn test_commented_blockquote_between_real_blockquotes() {
let rule = MD028NoBlanksBlockquote;
let content = "> real A\n\n<!-- > commented -->\n\n> real B";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should NOT warn when non-blockquote content (HTML comment) separates blockquotes, got: {result:?}"
);
}
#[test]
fn test_code_block_with_blockquote_markers_between_real_blockquotes() {
let rule = MD028NoBlanksBlockquote;
let content = "> real A\n\n```\n> not a blockquote\n```\n\n> real B";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should NOT warn when code block with > markers separates blockquotes, got: {result:?}"
);
}
#[test]
fn test_frontmatter_with_blockquote_markers_does_not_cause_false_positive() {
let rule = MD028NoBlanksBlockquote;
let content = "---\n> frontmatter value\n---\n\n> real quote A\n\n> real quote B";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should only flag the blank between real quotes, got: {result:?}"
);
assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
}
#[test]
fn test_fix_does_not_modify_comment_separated_blockquotes() {
let rule = MD028NoBlanksBlockquote;
let content = "> real A\n\n<!-- > commented -->\n\n> real B";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"Fix should not modify content when blockquotes are separated by HTML comment"
);
}
#[test]
fn test_fix_works_correctly_with_comment_before_real_blockquotes() {
let rule = MD028NoBlanksBlockquote;
let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("> real quote A\n>\n> real quote B"),
"Fix should add > marker between real quotes, got: {fixed}"
);
assert!(
fixed.contains("<!-- > not a real blockquote"),
"Fix should not modify comment content"
);
}
#[test]
fn test_html_block_with_angle_brackets_not_flagged() {
let rule = MD028NoBlanksBlockquote;
let content = "<div>\n> not a real blockquote\n\n> also not real\n</div>";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Lines inside HTML blocks should not trigger MD028. Got: {result:?}"
);
}
#[test]
fn test_html_block_does_not_leak_into_adjacent_blockquotes() {
let rule = MD028NoBlanksBlockquote;
let content =
"<details>\n<summary>Click</summary>\n> inside html block\n</details>\n\n> real quote A\n\n> real quote B";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Expected 1 warning for blank between real blockquotes after HTML block. Got: {result:?}"
);
}
}