use crate::filtered_lines::FilteredLinesExt;
use crate::lint_context::LintContext;
use crate::lint_context::types::HeadingStyle;
use crate::utils::LineIndex;
use crate::utils::range_utils::calculate_line_range;
use std::collections::HashSet;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
mod md012_config;
use md012_config::MD012Config;
#[derive(Debug, Clone)]
pub struct MD012NoMultipleBlanks {
config: MD012Config,
heading_blanks_above: usize,
heading_blanks_below: usize,
}
impl Default for MD012NoMultipleBlanks {
fn default() -> Self {
Self {
config: MD012Config::default(),
heading_blanks_above: 1,
heading_blanks_below: 1,
}
}
}
impl MD012NoMultipleBlanks {
pub fn new(maximum: usize) -> Self {
use crate::types::PositiveUsize;
Self {
config: MD012Config {
maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
},
heading_blanks_above: 1,
heading_blanks_below: 1,
}
}
pub const fn from_config_struct(config: MD012Config) -> Self {
Self {
config,
heading_blanks_above: 1,
heading_blanks_below: 1,
}
}
pub fn with_heading_limits(mut self, above: usize, below: usize) -> Self {
self.heading_blanks_above = above;
self.heading_blanks_below = below;
self
}
fn effective_max_above(&self) -> usize {
self.config.maximum.get().max(self.heading_blanks_above)
}
fn effective_max_below(&self) -> usize {
self.config.maximum.get().max(self.heading_blanks_below)
}
fn generate_excess_warnings(
&self,
blank_start: usize,
blank_count: usize,
effective_max: usize,
lines: &[&str],
lines_to_check: &HashSet<usize>,
line_index: &LineIndex,
) -> Vec<LintWarning> {
let mut warnings = Vec::new();
let location = if blank_start == 0 {
"at start of file"
} else {
"between content"
};
for i in effective_max..blank_count {
let excess_line_num = blank_start + i;
if lines_to_check.contains(&excess_line_num) {
let excess_line = excess_line_num + 1;
let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
severity: Severity::Warning,
message: format!("Multiple consecutive blank lines {location}"),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
fix: Some(Fix {
range: {
let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
let line_end = line_index
.get_line_start_byte(excess_line + 1)
.unwrap_or(line_start + 1);
line_start..line_end
},
replacement: String::new(),
}),
});
}
}
warnings
}
}
fn is_heading_context(ctx: &LintContext, line_idx: usize) -> bool {
if ctx.lines.get(line_idx).is_some_and(|li| li.heading.is_some()) {
return true;
}
if line_idx > 0
&& let Some(prev_info) = ctx.lines.get(line_idx - 1)
&& let Some(ref heading) = prev_info.heading
&& matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2)
{
return true;
}
false
}
fn max_heading_limit(
level_config: &crate::rules::md022_blanks_around_headings::md022_config::HeadingLevelConfig,
) -> usize {
let mut max_val: usize = 0;
for level in 1..=6 {
match level_config.get_for_level(level).required_count() {
None => return usize::MAX, Some(count) => max_val = max_val.max(count),
}
}
max_val
}
impl Rule for MD012NoMultipleBlanks {
fn name(&self) -> &'static str {
"MD012"
}
fn description(&self) -> &'static str {
"Multiple consecutive blank lines"
}
fn category(&self) -> RuleCategory {
RuleCategory::Whitespace
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let content = ctx.content;
if content.is_empty() {
return Ok(Vec::new());
}
let lines = ctx.raw_lines();
let has_potential_blanks = lines
.windows(2)
.any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
let ends_with_multiple_newlines = content.ends_with("\n\n");
if !has_potential_blanks && !ends_with_multiple_newlines {
return Ok(Vec::new());
}
let line_index = &ctx.line_index;
let mut warnings = Vec::new();
let mut blank_count = 0;
let mut blank_start = 0;
let mut last_line_num: Option<usize> = None;
let mut prev_content_line_num: Option<usize> = None;
let mut lines_to_check: HashSet<usize> = HashSet::new();
for filtered_line in ctx
.filtered_lines()
.skip_front_matter()
.skip_code_blocks()
.skip_quarto_divs()
.skip_math_blocks()
.skip_obsidian_comments()
.skip_pymdown_blocks()
{
let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
if let Some(last) = last_line_num
&& line_num > last + 1
{
let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
self.effective_max_below()
} else {
self.config.maximum.get()
};
if blank_count > effective_max {
warnings.extend(self.generate_excess_warnings(
blank_start,
blank_count,
effective_max,
lines,
&lines_to_check,
line_index,
));
}
blank_count = 0;
lines_to_check.clear();
prev_content_line_num = None;
}
last_line_num = Some(line_num);
if line.trim().is_empty() {
if blank_count == 0 {
blank_start = line_num;
}
blank_count += 1;
if blank_count > self.config.maximum.get() {
lines_to_check.insert(line_num);
}
} else {
let heading_below = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
let heading_above = blank_start > 0 && is_heading_context(ctx, line_num);
let effective_max = if heading_below && heading_above {
self.effective_max_above().max(self.effective_max_below())
} else if heading_below {
self.effective_max_below()
} else if heading_above {
self.effective_max_above()
} else {
self.config.maximum.get()
};
if blank_count > effective_max {
warnings.extend(self.generate_excess_warnings(
blank_start,
blank_count,
effective_max,
lines,
&lines_to_check,
line_index,
));
}
blank_count = 0;
lines_to_check.clear();
prev_content_line_num = Some(line_num);
}
}
let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
if blank_count > 0 && last_line_is_blank {
let location = "at end of file";
let report_line = lines.len();
let fix_start = line_index
.get_line_start_byte(report_line - blank_count + 1)
.unwrap_or(0);
let fix_end = content.len();
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
severity: Severity::Warning,
message: format!("Multiple consecutive blank lines {location}"),
line: report_line,
column: 1,
end_line: report_line,
end_column: 1,
fix: Some(Fix {
range: fix_start..fix_end,
replacement: String::new(),
}),
});
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let content = ctx.content;
let mut result = Vec::new();
let mut blank_count = 0;
let mut in_code_block = false;
let mut code_block_blanks = Vec::new();
let mut in_front_matter = false;
let mut last_content_is_heading: bool = false;
let mut has_seen_content: bool = false;
for filtered_line in ctx.filtered_lines() {
let line = filtered_line.content;
let line_num = filtered_line.line_num;
let line_idx = line_num - 1;
if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
result.push(line);
continue;
}
if filtered_line.line_info.in_front_matter {
if !in_front_matter {
let allowed_blanks = blank_count.min(self.config.maximum.get());
if allowed_blanks > 0 {
result.extend(vec![""; allowed_blanks]);
}
blank_count = 0;
in_front_matter = true;
last_content_is_heading = false;
}
result.push(line);
continue;
} else if in_front_matter {
in_front_matter = false;
last_content_is_heading = false;
}
if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
if !in_code_block {
let effective_max = if last_content_is_heading {
self.effective_max_below()
} else {
self.config.maximum.get()
};
let allowed_blanks = blank_count.min(effective_max);
if allowed_blanks > 0 {
result.extend(vec![""; allowed_blanks]);
}
blank_count = 0;
last_content_is_heading = false;
} else {
result.append(&mut code_block_blanks);
}
in_code_block = !in_code_block;
result.push(line);
continue;
}
if in_code_block {
if line.trim().is_empty() {
code_block_blanks.push(line);
} else {
result.append(&mut code_block_blanks);
result.push(line);
}
} else if line.trim().is_empty() {
blank_count += 1;
} else {
let heading_below = last_content_is_heading;
let heading_above = has_seen_content && is_heading_context(ctx, line_idx);
let effective_max = if heading_below && heading_above {
self.effective_max_above().max(self.effective_max_below())
} else if heading_below {
self.effective_max_below()
} else if heading_above {
self.effective_max_above()
} else {
self.config.maximum.get()
};
let allowed_blanks = blank_count.min(effective_max);
if allowed_blanks > 0 {
result.extend(vec![""; allowed_blanks]);
}
blank_count = 0;
last_content_is_heading = is_heading_context(ctx, line_idx);
has_seen_content = true;
result.push(line);
}
}
let mut output = result.join("\n");
if content.ends_with('\n') {
output.push('\n');
}
Ok(output)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || !ctx.has_char('\n')
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let default_config = MD012Config::default();
let json_value = serde_json::to_value(&default_config).ok()?;
let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
if let toml::Value::Table(table) = toml_value {
if !table.is_empty() {
Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
} else {
None
}
} else {
None
}
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
|| config.global.extend_disable.iter().any(|r| r == "MD022");
let (heading_above, heading_below) = if md022_disabled {
(rule_config.maximum.get(), rule_config.maximum.get())
} else {
let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
(
max_heading_limit(&md022_config.lines_above),
max_heading_limit(&md022_config.lines_below),
)
};
Box::new(Self {
config: rule_config,
heading_blanks_above: heading_above,
heading_blanks_below: heading_below,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_single_blank_line_allowed() {
let rule = MD012NoMultipleBlanks::default();
let content = "Line 1\n\nLine 2\n\nLine 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_multiple_blank_lines_flagged() {
let rule = MD012NoMultipleBlanks::default();
let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
assert_eq!(result[1].line, 6);
assert_eq!(result[2].line, 7);
}
#[test]
fn test_custom_maximum() {
let rule = MD012NoMultipleBlanks::new(2);
let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
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, 7);
}
#[test]
fn test_fix_multiple_blank_lines() {
let rule = MD012NoMultipleBlanks::default();
let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
}
#[test]
fn test_blank_lines_in_code_block() {
let rule = MD012NoMultipleBlanks::default();
let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty()); }
#[test]
fn test_fix_preserves_code_block_blanks() {
let rule = MD012NoMultipleBlanks::default();
let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
}
#[test]
fn test_blank_lines_in_front_matter() {
let rule = MD012NoMultipleBlanks::default();
let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty()); }
#[test]
fn test_blank_lines_at_start() {
let rule = MD012NoMultipleBlanks::default();
let content = "\n\n\nContent";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert!(result[0].message.contains("at start of file"));
}
#[test]
fn test_blank_lines_at_end() {
let rule = MD012NoMultipleBlanks::default();
let content = "Content\n\n\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("at end of file"));
}
#[test]
fn test_single_blank_at_eof_flagged() {
let rule = MD012NoMultipleBlanks::default();
let content = "Content\n\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("at end of file"));
}
#[test]
fn test_whitespace_only_lines() {
let rule = MD012NoMultipleBlanks::default();
let content = "Line 1\n \n\t\nLine 2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1); }
#[test]
fn test_indented_code_blocks() {
let rule = MD012NoMultipleBlanks::default();
let content = "Text\n\n code\n \n \n more code\n\nText";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
}
#[test]
fn test_blanks_in_indented_code_block() {
let content = " code line 1\n\n\n code line 2\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let rule = MD012NoMultipleBlanks::default();
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty(), "Should not flag blanks in indented code");
}
#[test]
fn test_blanks_in_indented_code_block_with_heading() {
let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let rule = MD012NoMultipleBlanks::default();
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Should not flag blanks in indented code after heading"
);
}
#[test]
fn test_blanks_after_indented_code_block_flagged() {
let content = "# Heading\n\n code line\n\n\n\nMore text\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let rule = MD012NoMultipleBlanks::default();
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
}
#[test]
fn test_fix_with_final_newline() {
let rule = MD012NoMultipleBlanks::default();
let content = "Line 1\n\n\nLine 2\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Line 1\n\nLine 2\n");
assert!(fixed.ends_with('\n'));
}
#[test]
fn test_empty_content() {
let rule = MD012NoMultipleBlanks::default();
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_nested_code_blocks() {
let rule = MD012NoMultipleBlanks::default();
let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_unclosed_code_block() {
let rule = MD012NoMultipleBlanks::default();
let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty()); }
#[test]
fn test_mixed_fence_styles() {
let rule = MD012NoMultipleBlanks::default();
let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty()); }
#[test]
fn test_config_from_toml() {
let mut config = crate::config::Config::default();
let mut rule_config = crate::config::RuleConfig::default();
rule_config
.values
.insert("maximum".to_string(), toml::Value::Integer(3));
config.rules.insert("MD012".to_string(), rule_config);
let rule = MD012NoMultipleBlanks::from_config(&config);
let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty()); }
#[test]
fn test_blank_lines_between_sections() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"2 blanks above heading allowed with heading_blanks_above=2"
);
}
#[test]
fn test_fix_preserves_indented_code() {
let rule = MD012NoMultipleBlanks::default();
let content = "Text\n\n\n code\n \n more code\n\n\nText";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
}
#[test]
fn test_edge_case_only_blanks() {
let rule = MD012NoMultipleBlanks::default();
let content = "\n\n\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("at end of file"));
}
#[test]
fn test_blanks_after_fenced_code_block_mid_document() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"2 blanks before heading allowed with heading_blanks_above=2"
);
}
#[test]
fn test_blanks_after_code_block_at_eof() {
let rule = MD012NoMultipleBlanks::default();
let content = "# Heading\n\n```\ncode\n```\n\n\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
assert!(result[0].message.contains("at end of file"));
}
#[test]
fn test_single_blank_after_code_block_allowed() {
let rule = MD012NoMultipleBlanks::default();
let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Single blank after code block should be allowed");
}
#[test]
fn test_multiple_code_blocks_with_blanks() {
let rule = MD012NoMultipleBlanks::default();
let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
}
#[test]
fn test_whitespace_only_lines_after_code_block_at_eof() {
let rule = MD012NoMultipleBlanks::default();
let content = "```\ncode\n```\n \n \n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
assert!(result[0].message.contains("at end of file"));
}
#[test]
fn test_warning_fix_removes_single_trailing_blank() {
let rule = MD012NoMultipleBlanks::default();
let content = "hello foobar hello.\n\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
let fix = warnings[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement, "", "Replacement should be empty");
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
}
#[test]
fn test_warning_fix_removes_multiple_trailing_blanks() {
let rule = MD012NoMultipleBlanks::default();
let content = "content\n\n\n\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].fix.is_some());
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
assert_eq!(fixed, "content\n", "Should end with single newline");
}
#[test]
fn test_warning_fix_preserves_content_newline() {
let rule = MD012NoMultipleBlanks::default();
let content = "line1\nline2\n\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
}
#[test]
fn test_warning_fix_mid_document_blanks() {
let rule = MD012NoMultipleBlanks::default();
let content = "# Heading\n\n\n\nParagraph\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
2,
"Excess heading-adjacent blanks flagged with default limits"
);
}
#[test]
fn test_heading_aware_blanks_below_with_higher_limit() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
let content = "# Heading\n\n\nParagraph\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"2 blanks below heading allowed with heading_blanks_below=2"
);
}
#[test]
fn test_heading_aware_blanks_above_with_higher_limit() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
let content = "Paragraph\n\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"2 blanks above heading allowed with heading_blanks_above=2"
);
}
#[test]
fn test_heading_aware_blanks_between_headings() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
let content = "# Heading 1\n\n\n## Heading 2\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
}
#[test]
fn test_heading_aware_excess_still_flagged() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
}
#[test]
fn test_heading_aware_setext_blanks_below() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
let content = "Heading\n=======\n\n\nParagraph\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
}
#[test]
fn test_heading_aware_setext_blanks_above() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
let content = "Paragraph\n\n\nHeading\n=======\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
}
#[test]
fn test_heading_aware_single_blank_allowed() {
let rule = MD012NoMultipleBlanks::default();
let content = "# Heading\n\nParagraph\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Single blank near heading should be allowed");
}
#[test]
fn test_heading_aware_non_heading_blanks_still_flagged() {
let rule = MD012NoMultipleBlanks::default();
let content = "Paragraph 1\n\n\nParagraph 2\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
}
#[test]
fn test_heading_aware_fix_caps_heading_blanks() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# Heading\n\n\nParagraph\n",
"Fix caps heading-adjacent blanks at effective max (2)"
);
}
#[test]
fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# Heading\n\n\n\nParagraph\n",
"Fix preserves blanks within the heading limit"
);
}
#[test]
fn test_heading_aware_fix_reduces_non_heading_blanks() {
let rule = MD012NoMultipleBlanks::default();
let content = "Paragraph 1\n\n\n\nParagraph 2\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "Paragraph 1\n\nParagraph 2\n",
"Fix should reduce non-heading blanks"
);
}
#[test]
fn test_heading_aware_mixed_heading_and_non_heading() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
}
#[test]
fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
let content = "\n\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Start-of-file blanks should be flagged even before heading"
);
assert!(result[0].message.contains("at start of file"));
}
#[test]
fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
let rule = MD012NoMultipleBlanks::default();
let content = "# Heading\n\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
assert!(result[0].message.contains("at end of file"));
}
#[test]
fn test_heading_aware_unlimited_heading_blanks() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Unlimited heading limits means MD012 never flags near headings"
);
}
#[test]
fn test_heading_aware_blanks_after_code_then_heading() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
}
#[test]
fn test_heading_aware_fix_mixed_document() {
let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
}
#[test]
fn test_heading_aware_from_config_reads_md022() {
let mut config = crate::config::Config::default();
let mut md022_config = crate::config::RuleConfig::default();
md022_config
.values
.insert("lines-above".to_string(), toml::Value::Integer(2));
md022_config
.values
.insert("lines-below".to_string(), toml::Value::Integer(3));
config.rules.insert("MD022".to_string(), md022_config);
let rule = MD012NoMultipleBlanks::from_config(&config);
let content = "Paragraph\n\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"2 blanks above heading allowed when MD022 lines-above=2"
);
}
#[test]
fn test_heading_aware_from_config_md022_disabled() {
let mut config = crate::config::Config::default();
config.global.disable.push("MD022".to_string());
let mut md022_config = crate::config::RuleConfig::default();
md022_config
.values
.insert("lines-above".to_string(), toml::Value::Integer(3));
config.rules.insert("MD022".to_string(), md022_config);
let rule = MD012NoMultipleBlanks::from_config(&config);
let content = "Paragraph\n\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"With MD022 disabled, heading-adjacent blanks are flagged"
);
}
#[test]
fn test_heading_aware_from_config_md022_unlimited() {
let mut config = crate::config::Config::default();
let mut md022_config = crate::config::RuleConfig::default();
md022_config
.values
.insert("lines-above".to_string(), toml::Value::Integer(-1));
config.rules.insert("MD022".to_string(), md022_config);
let rule = MD012NoMultipleBlanks::from_config(&config);
let content = "Paragraph\n\n\n\n\n# Heading\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Unlimited MD022 lines-above means MD012 never flags above headings"
);
}
#[test]
fn test_heading_aware_from_config_per_level() {
let mut config = crate::config::Config::default();
let mut md022_config = crate::config::RuleConfig::default();
md022_config.values.insert(
"lines-above".to_string(),
toml::Value::Array(vec![
toml::Value::Integer(2),
toml::Value::Integer(1),
toml::Value::Integer(1),
toml::Value::Integer(1),
toml::Value::Integer(1),
toml::Value::Integer(1),
]),
);
config.rules.insert("MD022".to_string(), md022_config);
let rule = MD012NoMultipleBlanks::from_config(&config);
let content = "Paragraph\n\n\n## H2 Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
let content = "Paragraph\n\n\n\n## H2 Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
}
#[test]
fn test_issue_449_reproduction() {
let rule = MD012NoMultipleBlanks::default();
let content = "\
# Heading
Some introductory text.
## Heading level 2
Some text for this section.
Some more text for this section.
## Another heading level 2
Some text for this section.
Some more text for this section.
";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Issue #449: excess blanks around headings should be flagged with default settings"
);
let fixed = rule.fix(&ctx).unwrap();
let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
let recheck = rule.check(&fixed_ctx).unwrap();
assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
assert!(
fixed.contains("text.\n\n## Heading level 2"),
"1 blank above second heading"
);
}
#[test]
fn test_blank_lines_in_quarto_callout() {
let rule = MD012NoMultipleBlanks::default();
let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
}
#[test]
fn test_blank_lines_in_quarto_div() {
let rule = MD012NoMultipleBlanks::default();
let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
}
#[test]
fn test_blank_lines_outside_quarto_div_flagged() {
let rule = MD012NoMultipleBlanks::default();
let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
}
#[test]
fn test_quarto_divs_ignored_in_standard_flavor() {
let rule = MD012NoMultipleBlanks::default();
let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
}
}