use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
use crate::utils::kramdown_utils::is_kramdown_block_attribute;
use crate::utils::quarto_divs;
use crate::utils::range_utils::calculate_heading_range;
use toml;
pub(crate) mod md022_config;
use md022_config::MD022Config;
#[derive(Clone, Default)]
pub struct MD022BlanksAroundHeadings {
config: MD022Config,
}
impl MD022BlanksAroundHeadings {
pub fn new() -> Self {
Self {
config: MD022Config::default(),
}
}
pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
use md022_config::HeadingLevelConfig;
Self {
config: MD022Config {
lines_above: HeadingLevelConfig::scalar(lines_above),
lines_below: HeadingLevelConfig::scalar(lines_below),
allowed_at_start: true,
},
}
}
pub fn from_config_struct(config: MD022Config) -> Self {
Self { config }
}
fn _fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
let line_ending = "\n";
let had_trailing_newline = ctx.content.ends_with('\n');
let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
let mut result = Vec::new();
let mut skip_count: usize = 0;
let heading_at_start_idx = {
let mut found_non_transparent = false;
ctx.lines.iter().enumerate().find_map(|(i, line)| {
if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
Some(i)
} else {
if !line.is_blank && !line.in_html_comment {
let trimmed = line.content(ctx.content).trim();
if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
} else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
} else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed))
{
} else {
found_non_transparent = true;
}
}
None
}
})
};
for (i, line_info) in ctx.lines.iter().enumerate() {
if skip_count > 0 {
skip_count -= 1;
continue;
}
let line = line_info.content(ctx.content);
if line_info.in_code_block {
result.push(line.to_string());
continue;
}
if let Some(heading) = &line_info.heading {
if !heading.is_valid {
result.push(line.to_string());
continue;
}
let line_num = i + 1;
if ctx.inline_config().is_rule_disabled("MD022", line_num) {
result.push(line.to_string());
if matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
) && i + 1 < ctx.lines.len()
{
result.push(ctx.lines[i + 1].content(ctx.content).to_string());
skip_count += 1;
}
continue;
}
let is_first_heading = Some(i) == heading_at_start_idx;
let heading_level = heading.level as usize;
let mut blank_lines_above = 0;
let mut check_idx = result.len();
while check_idx > 0 {
let prev_line = &result[check_idx - 1];
let trimmed = prev_line.trim();
if trimmed.is_empty() {
blank_lines_above += 1;
check_idx -= 1;
} else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
check_idx -= 1;
} else if is_kramdown_block_attribute(trimmed) {
check_idx -= 1;
} else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed)) {
check_idx -= 1;
} else {
break;
}
}
let requirement_above = self.config.lines_above.get_for_level(heading_level);
let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
0
} else {
requirement_above.required_count().unwrap_or(0)
};
while blank_lines_above < needed_blanks_above {
result.push(String::new());
blank_lines_above += 1;
}
result.push(line.to_string());
let mut effective_end_idx = i;
if matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
) {
if i + 1 < ctx.lines.len() {
result.push(ctx.lines[i + 1].content(ctx.content).to_string());
skip_count += 1; effective_end_idx = i + 1;
}
}
let mut ial_count = 0;
while effective_end_idx + 1 < ctx.lines.len() {
let next_line = &ctx.lines[effective_end_idx + 1];
let next_trimmed = next_line.content(ctx.content).trim();
if is_kramdown_block_attribute(next_trimmed) {
result.push(next_trimmed.to_string());
effective_end_idx += 1;
ial_count += 1;
} else {
break;
}
}
let mut blank_lines_below = 0;
let mut next_content_line_idx = None;
for j in (effective_end_idx + 1)..ctx.lines.len() {
if ctx.lines[j].is_blank {
blank_lines_below += 1;
} else {
next_content_line_idx = Some(j);
break;
}
}
let next_is_special = if let Some(idx) = next_content_line_idx {
let next_line = &ctx.lines[idx];
next_line.list_item.is_some() || {
let trimmed = next_line.content(ctx.content).trim();
(trimmed.starts_with("```") || trimmed.starts_with("~~~"))
&& (trimmed.len() == 3
|| (trimmed.len() > 3
&& trimmed
.chars()
.nth(3)
.is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
}
} else {
false
};
let requirement_below = self.config.lines_below.get_for_level(heading_level);
let needed_blanks_below = if next_is_special {
0
} else {
requirement_below.required_count().unwrap_or(0)
};
if blank_lines_below < needed_blanks_below {
for _ in 0..(needed_blanks_below - blank_lines_below) {
result.push(String::new());
}
}
skip_count += ial_count;
} else {
result.push(line.to_string());
}
}
let joined = result.join(line_ending);
if had_trailing_newline && !joined.ends_with('\n') {
format!("{joined}{line_ending}")
} else if !had_trailing_newline && joined.ends_with('\n') {
joined[..joined.len() - 1].to_string()
} else {
joined
}
}
}
impl Rule for MD022BlanksAroundHeadings {
fn name(&self) -> &'static str {
"MD022"
}
fn description(&self) -> &'static str {
"Headings should be surrounded by blank lines"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut result = Vec::new();
if ctx.lines.is_empty() {
return Ok(result);
}
let line_ending = "\n";
let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
let heading_at_start_idx = {
let mut found_non_transparent = false;
ctx.lines.iter().enumerate().find_map(|(i, line)| {
if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
Some(i)
} else {
if !line.is_blank && !line.in_html_comment {
let trimmed = line.content(ctx.content).trim();
if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
} else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
} else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed))
{
} else {
found_non_transparent = true;
}
}
None
}
})
};
let mut heading_violations = Vec::new();
let mut processed_headings = std::collections::HashSet::new();
for (line_num, line_info) in ctx.lines.iter().enumerate() {
if processed_headings.contains(&line_num) || line_info.heading.is_none() {
continue;
}
if line_info.in_pymdown_block {
continue;
}
let heading = line_info.heading.as_ref().unwrap();
if !heading.is_valid {
continue;
}
let heading_level = heading.level as usize;
processed_headings.insert(line_num);
let is_first_heading = Some(line_num) == heading_at_start_idx;
let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
let should_check_above =
required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
if should_check_above {
let mut blank_lines_above = 0;
let mut hit_frontmatter_end = false;
for j in (0..line_num).rev() {
let line_content = ctx.lines[j].content(ctx.content);
let trimmed = line_content.trim();
if ctx.lines[j].is_blank {
blank_lines_above += 1;
} else if ctx.lines[j].in_html_comment || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
{
continue;
} else if is_kramdown_block_attribute(trimmed) {
continue;
} else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed)) {
continue;
} else if ctx.lines[j].in_front_matter {
hit_frontmatter_end = true;
break;
} else {
break;
}
}
let required = required_above_count.unwrap();
if !hit_frontmatter_end && blank_lines_above < required {
let needed_blanks = required - blank_lines_above;
heading_violations.push((line_num, "above", needed_blanks, heading_level));
}
}
let mut effective_last_line = if matches!(
heading.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
) {
line_num + 1 } else {
line_num
};
while effective_last_line + 1 < ctx.lines.len() {
let next_line = &ctx.lines[effective_last_line + 1];
let next_trimmed = next_line.content(ctx.content).trim();
if is_kramdown_block_attribute(next_trimmed) {
effective_last_line += 1;
} else {
break;
}
}
if effective_last_line < ctx.lines.len() - 1 {
let mut next_non_blank_idx = effective_last_line + 1;
while next_non_blank_idx < ctx.lines.len() {
let check_line = &ctx.lines[next_non_blank_idx];
let check_trimmed = check_line.content(ctx.content).trim();
if check_line.is_blank {
next_non_blank_idx += 1;
} else if check_line.in_html_comment
|| (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
{
next_non_blank_idx += 1;
} else if is_quarto
&& (quarto_divs::is_div_open(check_trimmed) || quarto_divs::is_div_close(check_trimmed))
{
next_non_blank_idx += 1;
} else {
break;
}
}
if next_non_blank_idx >= ctx.lines.len() {
continue;
}
let next_line_is_special = {
let next_line = &ctx.lines[next_non_blank_idx];
let next_trimmed = next_line.content(ctx.content).trim();
let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
&& (next_trimmed.len() == 3
|| (next_trimmed.len() > 3
&& next_trimmed
.chars()
.nth(3)
.is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
let is_list_item = next_line.list_item.is_some();
is_code_fence || is_list_item
};
if !next_line_is_special && let Some(required) = required_below_count {
let mut blank_lines_below = 0;
for k in (effective_last_line + 1)..next_non_blank_idx {
if ctx.lines[k].is_blank {
blank_lines_below += 1;
}
}
if blank_lines_below < required {
let needed_blanks = required - blank_lines_below;
heading_violations.push((line_num, "below", needed_blanks, heading_level));
}
}
}
}
for (heading_line, position, needed_blanks, heading_level) in heading_violations {
let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
let (start_line, start_col, end_line, end_col) =
calculate_heading_range(heading_display_line, line_info.content(ctx.content));
let required_above_count = self
.config
.lines_above
.get_for_level(heading_level)
.required_count()
.expect("Violations only generated for limited 'above' requirements");
let required_below_count = self
.config
.lines_below
.get_for_level(heading_level)
.required_count()
.expect("Violations only generated for limited 'below' requirements");
let (message, insertion_point) = match position {
"above" => (
format!(
"Expected {} blank {} above heading",
required_above_count,
if required_above_count == 1 { "line" } else { "lines" }
),
heading_line, ),
"below" => {
let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
matches!(
h.style,
crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
)
}) {
heading_line + 2
} else {
heading_line + 1
};
(
format!(
"Expected {} blank {} below heading",
required_below_count,
if required_below_count == 1 { "line" } else { "lines" }
),
insert_after,
)
}
_ => continue,
};
let byte_range = if insertion_point == 0 && position == "above" {
0..0
} else if position == "above" && insertion_point > 0 {
ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
} else if position == "below" && insertion_point - 1 < ctx.lines.len() {
let line_idx = insertion_point - 1;
let line_end_offset = if line_idx + 1 < ctx.lines.len() {
ctx.lines[line_idx + 1].byte_offset
} else {
ctx.content.len()
};
line_end_offset..line_end_offset
} else {
let content_len = ctx.content.len();
content_len..content_len
};
result.push(LintWarning {
rule_name: Some(self.name().to_string()),
message,
line: start_line,
column: start_col,
end_line,
end_column: end_col,
severity: Severity::Warning,
fix: Some(Fix {
range: byte_range,
replacement: line_ending.repeat(needed_blanks),
}),
});
}
Ok(result)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
if ctx.content.is_empty() {
return Ok(ctx.content.to_string());
}
let fixed = self._fix_content(ctx);
Ok(fixed)
}
fn category(&self) -> RuleCategory {
RuleCategory::Heading
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
if ctx.content.is_empty() || !ctx.likely_has_headings() {
return true;
}
ctx.lines.iter().all(|line| line.heading.is_none())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let default_config = MD022Config::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((MD022Config::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,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
Box::new(Self::from_config_struct(rule_config))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_valid_headings() {
let rule = MD022BlanksAroundHeadings::default();
let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_missing_blank_above() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("# Heading 1"));
assert!(fixed.contains("Some content."));
assert!(fixed.contains("## Heading 2"));
assert!(fixed.contains("More content."));
}
#[test]
fn test_missing_blank_below() {
let rule = MD022BlanksAroundHeadings::default();
let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
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);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("# Heading 1\n\nSome content"));
}
#[test]
fn test_missing_blank_above_and_below() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("# Heading 1\n\nSome content"));
assert!(fixed.contains("Some content.\n\n## Heading 2"));
assert!(fixed.contains("## Heading 2\n\nMore content"));
}
#[test]
fn test_fix_headings() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
assert_eq!(result, expected);
}
#[test]
fn test_consecutive_headings_pattern() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading 1\n## Heading 2\n### Heading 3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert!(!lines.is_empty());
let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
assert!(
h2_pos > h1_pos + 1,
"Should have at least one blank line after first heading"
);
assert!(
h3_pos > h2_pos + 1,
"Should have at least one blank line after second heading"
);
assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
}
#[test]
fn test_blanks_around_setext_headings() {
let rule = MD022BlanksAroundHeadings::default();
let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert!(result.contains("Heading 1"));
assert!(result.contains("========="));
assert!(result.contains("Some content."));
assert!(result.contains("Heading 2"));
assert!(result.contains("---------"));
assert!(result.contains("More content."));
let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
assert!(
some_content_idx > heading1_marker_idx + 1,
"Should have a blank line after the first heading"
);
let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
assert!(
more_content_idx > heading2_marker_idx + 1,
"Should have a blank line after the second heading"
);
let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
let fixed_warnings = rule.check(&fixed_ctx).unwrap();
assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
}
#[test]
fn test_fix_specific_blank_line_cases() {
let rule = MD022BlanksAroundHeadings::default();
let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
let result1 = rule.fix(&ctx1).unwrap();
assert!(result1.contains("# Heading 1"));
assert!(result1.contains("## Heading 2"));
assert!(result1.contains("### Heading 3"));
let lines: Vec<&str> = result1.lines().collect();
let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.fix(&ctx2).unwrap();
assert!(result2.contains("# Heading 1"));
assert!(result2.contains("Content under heading 1"));
assert!(result2.contains("## Heading 2"));
let lines2: Vec<&str> = result2.lines().collect();
let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
assert!(
lines2[h1_pos2 + 1].trim().is_empty(),
"Should have a blank line after heading 1"
);
let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
let result3 = rule.fix(&ctx3).unwrap();
assert!(result3.contains("# Heading 1"));
assert!(result3.contains("## Heading 2"));
assert!(result3.contains("### Heading 3"));
assert!(result3.contains("Content"));
}
#[test]
fn test_fix_preserves_existing_blank_lines() {
let rule = MD022BlanksAroundHeadings::new();
let content = "# Title
## Section 1
Content here.
## Section 2
More content.
### Missing Blank Above
Even more content.
## Section 3
Final content.";
let expected = "# Title
## Section 1
Content here.
## Section 2
More content.
### Missing Blank Above
Even more content.
## Section 3
Final content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule._fix_content(&ctx);
assert_eq!(
result, expected,
"Fix should only add missing blank lines, never remove existing ones"
);
}
#[test]
fn test_fix_preserves_trailing_newline() {
let rule = MD022BlanksAroundHeadings::new();
let content_with_newline = "# Title\nContent here.\n";
let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
assert!(result.ends_with('\n'), "Should preserve trailing newline");
let content_without_newline = "# Title\nContent here.";
let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
let result = rule.fix(&ctx).unwrap();
assert!(
!result.ends_with('\n'),
"Should not add trailing newline if original didn't have one"
);
}
#[test]
fn test_fix_does_not_add_blank_lines_before_lists() {
let rule = MD022BlanksAroundHeadings::new();
let content = "## Configuration\n\nThis rule has the following configuration options:\n\n- `option1`: Description of option 1.\n- `option2`: Description of option 2.\n\n## Another Section\n\nSome content here.";
let expected = "## Configuration\n\nThis rule has the following configuration options:\n\n- `option1`: Description of option 1.\n- `option2`: Description of option 2.\n\n## Another Section\n\nSome content here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule._fix_content(&ctx);
assert_eq!(result, expected, "Fix should not add blank lines before lists");
}
#[test]
fn test_per_level_configuration_no_blank_above_h1() {
use md022_config::HeadingLevelConfig;
let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
lines_below: HeadingLevelConfig::scalar(1),
allowed_at_start: false, });
let content = "Some text\n# Heading 1\n\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
let content = "Some text\n## Heading 2\n\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
assert!(warnings[0].message.contains("above"));
}
#[test]
fn test_per_level_configuration_different_requirements() {
use md022_config::HeadingLevelConfig;
let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
lines_below: HeadingLevelConfig::scalar(1),
allowed_at_start: false,
});
let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
0,
"All headings should satisfy level-specific requirements"
);
}
#[test]
fn test_per_level_configuration_violations() {
use md022_config::HeadingLevelConfig;
let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
lines_below: HeadingLevelConfig::scalar(1),
allowed_at_start: false,
});
let content = "Text\n\n#### Heading 4\n\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
assert!(warnings[0].message.contains("2 blank lines above"));
}
#[test]
fn test_per_level_fix_different_levels() {
use md022_config::HeadingLevelConfig;
let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
lines_below: HeadingLevelConfig::scalar(1),
allowed_at_start: false,
});
let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("Text\n# H1\n\nContent"));
assert!(fixed.contains("Content\n\n## H2\n\nContent"));
assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
}
#[test]
fn test_per_level_below_configuration() {
use md022_config::HeadingLevelConfig;
let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
lines_above: HeadingLevelConfig::scalar(1),
lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
});
let content = "# Heading 1\n\nSome text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"H1 with insufficient blanks below should trigger warning"
);
assert!(warnings[0].message.contains("2 blank lines below"));
}
#[test]
fn test_scalar_configuration_still_works() {
use md022_config::HeadingLevelConfig;
let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
lines_above: HeadingLevelConfig::scalar(2),
lines_below: HeadingLevelConfig::scalar(2),
allowed_at_start: false,
});
let content = "Text\n# H1\nContent\n## H2\nContent";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
}
#[test]
fn test_unlimited_configuration_skips_requirements() {
use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
lines_above: HeadingLevelConfig::per_level_requirements([
HeadingBlankRequirement::unlimited(),
HeadingBlankRequirement::limited(1),
HeadingBlankRequirement::limited(1),
HeadingBlankRequirement::limited(1),
HeadingBlankRequirement::limited(1),
HeadingBlankRequirement::limited(1),
]),
lines_below: HeadingLevelConfig::per_level_requirements([
HeadingBlankRequirement::unlimited(),
HeadingBlankRequirement::limited(1),
HeadingBlankRequirement::limited(1),
HeadingBlankRequirement::limited(1),
HeadingBlankRequirement::limited(1),
HeadingBlankRequirement::limited(1),
]),
allowed_at_start: false,
});
let content = "# H1\nParagraph\n## H2\nParagraph";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
assert!(
warnings.iter().all(|w| w.line >= 3),
"Warnings should target later headings"
);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.starts_with("# H1\nParagraph\n\n## H2"),
"H1 should remain unchanged"
);
}
#[test]
fn test_html_comment_transparency() {
let rule = MD022BlanksAroundHeadings::default();
let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"HTML comment is transparent - blank line above it counts for heading"
);
let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
let warnings_multiline = rule.check(&ctx_multiline).unwrap();
assert!(
warnings_multiline.is_empty(),
"Multi-line HTML comment is also transparent"
);
}
#[test]
fn test_frontmatter_transparency() {
let rule = MD022BlanksAroundHeadings::default();
let content = "---\ntitle: Test\n---\n# First heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Frontmatter is transparent - heading can appear immediately after"
);
let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
assert!(
warnings_with_blank.is_empty(),
"Heading with blank line after frontmatter should also be valid"
);
let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
let warnings_toml = rule.check(&ctx_toml).unwrap();
assert!(
warnings_toml.is_empty(),
"TOML frontmatter is also transparent for MD022"
);
}
#[test]
fn test_horizontal_rule_not_treated_as_frontmatter() {
let rule = MD022BlanksAroundHeadings::default();
let content = "Some content\n\n---\n# Heading after HR";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
!warnings.is_empty(),
"Heading after horizontal rule without blank line SHOULD trigger MD022"
);
assert!(
warnings.iter().any(|w| w.line == 4),
"Warning should be on line 4 (the heading line)"
);
let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
assert!(
warnings_with_blank.is_empty(),
"Heading with blank line after HR should not trigger MD022"
);
let content_hr_start = "---\n# Heading";
let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
assert!(
!warnings_hr_start.is_empty(),
"Heading after HR at document start SHOULD trigger MD022"
);
let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
assert!(
!warnings_multi_hr.is_empty(),
"Heading after multiple HRs without blank line SHOULD trigger MD022"
);
}
#[test]
fn test_all_hr_styles_require_blank_before_heading() {
let rule = MD022BlanksAroundHeadings::default();
let hr_styles = [
"---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
"- - -", " ---", " ---", ];
for hr in hr_styles {
let content = format!("Content\n\n{hr}\n# Heading");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
!warnings.is_empty(),
"HR style '{hr}' followed by heading should trigger MD022"
);
}
}
#[test]
fn test_setext_heading_after_hr() {
let rule = MD022BlanksAroundHeadings::default();
let content = "Content\n\n---\nHeading\n======";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
!warnings.is_empty(),
"Setext heading after HR without blank should trigger MD022"
);
let content_h2 = "Content\n\n---\nHeading\n------";
let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
let warnings_h2 = rule.check(&ctx_h2).unwrap();
assert!(
!warnings_h2.is_empty(),
"Setext h2 after HR without blank should trigger MD022"
);
let content_ok = "Content\n\n---\n\nHeading\n======";
let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
let warnings_ok = rule.check(&ctx_ok).unwrap();
assert!(
warnings_ok.is_empty(),
"Setext heading with blank after HR should not warn"
);
}
#[test]
fn test_hr_in_code_block_not_treated_as_hr() {
let rule = MD022BlanksAroundHeadings::default();
let content = "```\n---\n```\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
let content_ok = "```\n---\n```\n\n# Heading";
let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
let warnings_ok = rule.check(&ctx_ok).unwrap();
assert!(
warnings_ok.is_empty(),
"Heading with blank after code block should not warn"
);
}
#[test]
fn test_hr_in_html_comment_not_treated_as_hr() {
let rule = MD022BlanksAroundHeadings::default();
let content = "<!-- \n---\n -->\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"HR inside HTML comment should be ignored - heading after comment is OK"
);
}
#[test]
fn test_invalid_hr_not_triggering() {
let rule = MD022BlanksAroundHeadings::default();
let invalid_hrs = [
" ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
for invalid in invalid_hrs {
let content = format!("Content\n\n{invalid}\n# Heading");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let _ = rule.check(&ctx);
}
}
#[test]
fn test_frontmatter_vs_horizontal_rule_distinction() {
let rule = MD022BlanksAroundHeadings::default();
let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
!warnings.is_empty(),
"HR after frontmatter content should still require blank line before heading"
);
let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
let warnings_ok = rule.check(&ctx_ok).unwrap();
assert!(
warnings_ok.is_empty(),
"HR with blank line before heading should not warn"
);
}
#[test]
fn test_kramdown_ial_after_heading_no_warning() {
let rule = MD022BlanksAroundHeadings::default();
let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"IAL after heading should not require blank line between them: {warnings:?}"
);
}
#[test]
fn test_kramdown_ial_with_class() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:.highlight}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty(), "IAL with class should be part of heading");
}
#[test]
fn test_kramdown_ial_with_id() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:#custom-id}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty(), "IAL with id should be part of heading");
}
#[test]
fn test_kramdown_ial_with_multiple_attributes() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"IAL with multiple attributes should be part of heading"
);
}
#[test]
fn test_kramdown_ial_missing_blank_after() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:.class}\nContent without blank.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"Should warn about missing blank after IAL (part of heading)"
);
assert!(warnings[0].message.contains("below"));
}
#[test]
fn test_kramdown_ial_before_heading_transparent() {
let rule = MD022BlanksAroundHeadings::default();
let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"IAL before heading should be transparent for blank line count"
);
}
#[test]
fn test_kramdown_ial_setext_heading() {
let rule = MD022BlanksAroundHeadings::default();
let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"IAL after Setext heading should be part of heading"
);
}
#[test]
fn test_kramdown_ial_fix_preserves_ial() {
let rule = MD022BlanksAroundHeadings::default();
let content = "Content.\n# Heading\n{:.class}\nMore content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("# Heading\n{:.class}"),
"IAL should stay attached to heading"
);
assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
}
#[test]
fn test_kramdown_ial_fix_does_not_separate() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:.class}\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
!fixed.contains("# Heading\n\n{:.class}"),
"Should not add blank between heading and IAL"
);
assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
}
#[test]
fn test_kramdown_multiple_ial_lines() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Multiple consecutive IALs should be part of heading"
);
}
#[test]
fn test_kramdown_ial_with_blank_line_not_attached() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n\n{:.class}\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty(), "Blank line separates heading from IAL");
}
#[test]
fn test_not_kramdown_ial_regular_braces() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{not an ial}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"Non-IAL braces should be regular content requiring blank"
);
}
#[test]
fn test_kramdown_ial_at_document_end() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:.class}";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty(), "IAL at document end needs no blank after");
}
#[test]
fn test_kramdown_ial_followed_by_code_fence() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:.class}\n```\ncode\n```";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
}
#[test]
fn test_kramdown_ial_followed_by_list() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:.class}\n- List item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty(), "No blank needed between IAL and list");
}
#[test]
fn test_kramdown_ial_fix_idempotent() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:.class}\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed_once = rule.fix(&ctx).unwrap();
let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
let fixed_twice = rule.fix(&ctx2).unwrap();
assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
}
#[test]
fn test_kramdown_ial_whitespace_line_between_not_attached() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n \n{:.class}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Whitespace between heading and IAL means IAL is not attached"
);
}
#[test]
fn test_kramdown_ial_html_comment_between() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"IAL not attached when comment is between: {warnings:?}"
);
}
#[test]
fn test_kramdown_ial_generic_attribute() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
}
#[test]
fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
assert!(
fixed.contains("{:data-x=\"y\"}\n\nContent"),
"Blank line should be after all IALs"
);
}
#[test]
fn test_kramdown_ial_crlf_line_endings() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
}
#[test]
fn test_kramdown_ial_invalid_patterns_not_recognized() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{ :.class}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
let content2 = "# Heading\n{.class}\n\nContent.";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let warnings2 = rule.check(&ctx2).unwrap();
assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
let content3 = "# Heading\n{just text}\n\nContent.";
let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
let warnings3 = rule.check(&ctx3).unwrap();
assert_eq!(
warnings3.len(),
1,
"Text in braces is not IAL and should trigger warning"
);
}
#[test]
fn test_kramdown_ial_toc_marker() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n{:toc}\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
}
#[test]
fn test_kramdown_ial_mixed_headings_in_document() {
let rule = MD022BlanksAroundHeadings::default();
let content = r#"# ATX Heading
{:.atx-class}
Content after ATX.
Setext Heading
--------------
{:#setext-id}
Content after Setext.
## Another ATX
{:.another}
More content."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Mixed headings with IAL should all work: {warnings:?}"
);
}
#[test]
fn test_kramdown_extension_block_before_first_heading_is_document_start() {
let rule = MD022BlanksAroundHeadings::default();
let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Kramdown extension preamble should not require blank above first heading: {warnings:?}"
);
}
#[test]
fn test_kramdown_ial_before_first_heading_is_document_start() {
let rule = MD022BlanksAroundHeadings::default();
let content = "{:.doc-class}\n# Heading\n\nBody\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
);
}
#[test]
fn test_quarto_div_marker_transparent_above_heading() {
let rule = MD022BlanksAroundHeadings::default();
let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Quarto div marker should be transparent above heading: {warnings:?}"
);
}
#[test]
fn test_quarto_div_marker_transparent_below_heading() {
let rule = MD022BlanksAroundHeadings::default();
let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Quarto div marker should be transparent below heading: {warnings:?}"
);
}
#[test]
fn test_quarto_heading_inside_callout() {
let rule = MD022BlanksAroundHeadings::default();
let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Heading inside Quarto callout should have no warnings: {warnings:?}"
);
}
#[test]
fn test_quarto_heading_at_start_after_div_open() {
let rule = MD022BlanksAroundHeadings::default();
let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Heading at start after div open should pass: {warnings:?}"
);
}
#[test]
fn test_quarto_heading_before_div_close() {
let rule = MD022BlanksAroundHeadings::default();
let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Heading before div close should pass: {warnings:?}"
);
}
#[test]
fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
let rule = MD022BlanksAroundHeadings::default();
let content = "Content\n\n:::\n# Heading\n\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
!warnings.is_empty(),
"Standard flavor should not treat ::: as transparent: {warnings:?}"
);
}
#[test]
fn test_quarto_nested_divs_with_heading() {
let rule = MD022BlanksAroundHeadings::default();
let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Nested divs with heading should work: {warnings:?}"
);
}
#[test]
fn test_quarto_fix_preserves_div_markers() {
let rule = MD022BlanksAroundHeadings::default();
let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
assert!(fixed.contains(":::"), "Should preserve div closing");
assert!(fixed.contains("## Note"), "Should preserve heading");
}
#[test]
fn test_quarto_heading_needs_blank_without_div_transparency() {
let rule = MD022BlanksAroundHeadings::default();
let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
!warnings.is_empty(),
"Should still require blank line when not present: {warnings:?}"
);
}
}