use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::fix_utils::apply_warning_fixes;
use std::collections::HashSet;
#[derive(Clone, Default)]
pub struct MD065BlanksAroundHorizontalRules;
struct RuleLines {
setext_underlines: HashSet<usize>,
item_openers: HashSet<usize>,
}
impl RuleLines {
fn new(ctx: &crate::lint_context::LintContext) -> Self {
let line_of = |offset: usize| ctx.offset_to_line_col(offset).0 - 1;
let mut setext_underlines = HashSet::new();
let mut item_openers = HashSet::new();
let mut item_line = None;
use crate::lint_context::document_parser::blocks::BlockKind;
for (kind, range) in &ctx.parser_blocks().events {
let opened = item_line.take();
match kind {
BlockKind::ItemStart => item_line = Some(line_of(range.start)),
BlockKind::Rule => {
let line = line_of(range.start);
if opened.is_some_and(|marker_line| line > marker_line) {
item_openers.insert(line);
}
}
BlockKind::HeadingStart => {
let last = line_of(range.end.saturating_sub(1).max(range.start));
if last > line_of(range.start) {
setext_underlines.insert(last);
}
}
_ => {}
}
}
Self {
setext_underlines,
item_openers,
}
}
}
impl MD065BlanksAroundHorizontalRules {
fn is_blank_line(line: &str) -> bool {
crate::utils::regex_cache::is_blank_in_blockquote_context(line)
}
fn is_setext_heading_marker(lines: &[&str], line_index: usize) -> bool {
if line_index == 0 {
return false;
}
let line = lines[line_index].trim();
let prev_line = lines[line_index - 1].trim();
if prev_line.is_empty() {
return false;
}
if crate::lint_context::is_horizontal_rule_line(prev_line) {
return false;
}
let has_hyphen = line.contains('-');
let has_equals = line.contains('=');
if has_hyphen == has_equals {
return false; }
let marker = if has_hyphen { '-' } else { '=' };
let trimmed = line.trim();
trimmed.chars().all(|c| c == marker)
}
fn count_blank_lines_before(lines: &[&str], line_index: usize) -> usize {
let mut count = 0;
let mut i = line_index;
while i > 0 {
i -= 1;
if Self::is_blank_line(lines[i]) {
count += 1;
} else {
break;
}
}
count
}
fn count_blank_lines_after(lines: &[&str], line_index: usize) -> usize {
let mut count = 0;
let mut i = line_index + 1;
while i < lines.len() {
if Self::is_blank_line(lines[i]) {
count += 1;
i += 1;
} else {
break;
}
}
count
}
fn fix_with(content: &str, warnings: &[LintWarning]) -> Result<String, LintError> {
apply_warning_fixes(content, warnings).map_err(LintError::FixFailed)
}
}
impl Rule for MD065BlanksAroundHorizontalRules {
fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(65))
}
fn name(&self) -> &'static str {
"MD065"
}
fn description(&self) -> &'static str {
"Horizontal rules should be surrounded by blank lines"
}
fn category(&self) -> RuleCategory {
RuleCategory::Whitespace
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.is_horizontal_rule)
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let content = ctx.content;
let mut warnings = Vec::new();
if content.is_empty() {
return Ok(Vec::new());
}
let lines = ctx.raw_lines();
let rule_lines = RuleLines::new(ctx);
for (i, line_info) in ctx.lines.iter().enumerate() {
if !line_info.is_horizontal_rule {
continue;
}
if rule_lines.setext_underlines.contains(&i)
|| ctx.heading_on_line(i).is_some_and(|heading| heading.is_setext())
|| Self::is_setext_heading_marker(lines, i)
{
continue;
}
if i > 0 && !rule_lines.item_openers.contains(&i) && Self::count_blank_lines_before(lines, i) == 0 {
let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message: "Missing blank line before horizontal rule".to_string(),
line: i + 1,
column: 1,
end_line: i + 1,
end_column: 2,
severity: Severity::Warning,
fix: Some(Fix::new(ctx.line_column_byte_range(i + 1, 1), format!("{bq_prefix}\n"))),
});
}
if i < lines.len() - 1 && Self::count_blank_lines_after(lines, i) == 0 {
let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message: "Missing blank line after horizontal rule".to_string(),
line: i + 1,
column: lines[i].chars().count() + 1,
end_line: i + 1,
end_column: lines[i].chars().count() + 2,
severity: Severity::Warning,
fix: Some(Fix::new(
ctx.line_column_byte_range(i + 1, lines[i].chars().count() + 1),
format!("\n{bq_prefix}"),
)),
});
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let content = ctx.content;
let warnings = self.check(ctx)?;
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
if warnings.is_empty() {
return Ok(content.to_string());
}
Self::fix_with(content, &warnings)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(MD065BlanksAroundHorizontalRules)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_hr_with_blanks() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text before.
---
Some text after.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_hr_missing_blank_before() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text before.
***
Some text after.";
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);
assert!(result[0].message.contains("before horizontal rule"));
}
#[test]
fn test_hr_missing_blank_after() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text before.
***
Some text after.";
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, 3);
assert!(result[0].message.contains("after horizontal rule"));
}
#[test]
fn test_hr_missing_both_blanks() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text before.
***
Some text after.";
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("before horizontal rule"));
assert!(result[1].message.contains("after horizontal rule"));
}
#[test]
fn test_hr_at_start_of_document() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "---
Some text after.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_hr_at_end_of_document() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text before.
---";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_multiple_hrs() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text before.
***
Middle text.
___
Text after.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 4);
}
#[test]
fn test_hr_asterisks() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text.
***
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_hr_underscores() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text.
___
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_hr_with_spaces() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text.
* * *
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_hr_long() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text.
**********
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_setext_heading_not_hr() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Heading
---
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_setext_heading_equals() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Heading
===
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_blockquote_setext_underline_is_not_hr() {
let rule = MD065BlanksAroundHorizontalRules;
for content in [
"> Title\n> ---\n\nText.\n",
"> > Deep\n> > ---\n\nText.\n",
"> Title\n> ---\n\nText.\n",
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "{content:?} is a heading: {result:?}");
assert_eq!(rule.fix(&ctx).unwrap(), content);
}
}
#[test]
fn test_blockquote_hr_below_blank_line_is_checked() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "> Text\n>\n> ---\n> More\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "{result:?}");
assert_eq!(result[0].line, 3);
assert_eq!(result[0].message, "Missing blank line after horizontal rule");
}
#[test]
fn test_hr_in_code_block() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text.
```
---
```
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_fix_missing_blanks() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text before.
***
Text after.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "Text before.
***
Text after.";
assert_eq!(fixed, expected);
}
#[test]
fn test_fix_multiple_hrs() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Start
***
Middle
___
End";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "Start
***
Middle
___
End";
assert_eq!(fixed, expected);
}
#[test]
fn test_empty_content() {
let rule = MD065BlanksAroundHorizontalRules;
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_no_hrs() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Just regular text.
No horizontal rules here.
Only paragraphs.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_is_horizontal_rule() {
use crate::lint_context::is_horizontal_rule_line;
assert!(is_horizontal_rule_line("---"));
assert!(is_horizontal_rule_line("----"));
assert!(is_horizontal_rule_line("***"));
assert!(is_horizontal_rule_line("****"));
assert!(is_horizontal_rule_line("___"));
assert!(is_horizontal_rule_line("____"));
assert!(is_horizontal_rule_line("- - -"));
assert!(is_horizontal_rule_line("* * *"));
assert!(is_horizontal_rule_line("_ _ _"));
assert!(is_horizontal_rule_line(" --- "));
assert!(!is_horizontal_rule_line("--"));
assert!(!is_horizontal_rule_line("**"));
assert!(!is_horizontal_rule_line("__"));
assert!(!is_horizontal_rule_line("- -"));
assert!(!is_horizontal_rule_line("text"));
assert!(!is_horizontal_rule_line(""));
assert!(!is_horizontal_rule_line("==="));
}
#[test]
fn test_consecutive_hrs_with_blanks() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text.
---
***
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_hr_after_heading() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "# Heading
***
Text.";
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("before horizontal rule"));
}
#[test]
fn test_hr_before_heading() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text.
***
# Heading";
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("after horizontal rule"));
}
#[test]
fn test_setext_heading_hyphen_not_flagged() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Heading Text
---
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_hr_with_blank_before_hyphen() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text.
---
More text.";
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("after horizontal rule"));
}
#[test]
fn test_frontmatter_not_flagged() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "---
title: Test Document
date: 2024-01-01
---
# Heading
Content here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_hr_after_frontmatter() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "---
title: Test
---
Content.
***
More content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_hr_in_indented_code_block() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text.
---
code here
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_hr_with_leading_spaces() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text.
***
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_hr_in_html_comment() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text.
<!--
---
-->
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_hr_in_blockquote() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text.
> Quote text
> ***
> More quote
After quote.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.len() <= 2); }
#[test]
fn test_hr_after_list() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "* Item one
* Item two
***
More text.";
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("before horizontal rule"));
}
#[test]
fn test_mixed_marker_with_many_spaces() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text.
- - - -
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_only_hr_in_document() {
let rule = MD065BlanksAroundHorizontalRules;
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_multiple_blank_lines_already_present() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text.
---
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_hr_at_both_start_and_end() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "---
Content in the middle.
---";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_consecutive_hrs_without_blanks() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text.
***
---
___
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.len() >= 2);
}
#[test]
fn test_hr_after_hr_not_setext() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "***\n---\n# ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
let fixed2 = rule.fix(&ctx2).unwrap();
assert_eq!(fixed, fixed2, "MD065 fix should be idempotent for consecutive HRs");
}
#[test]
fn test_fix_idempotency() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text before.
***
Text after.";
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);
}
#[test]
fn test_setext_heading_long_underline() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Heading Text
----------
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_hr_with_trailing_whitespace() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text.
***
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_hr_in_html_block() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text.
<div>
---
</div>
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_spaced_hyphens_are_hr_not_setext() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Heading
- - -
More text.";
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("before horizontal rule"));
}
#[test]
fn test_not_setext_if_prev_line_blank() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some paragraph.
---
Text after.";
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("after horizontal rule"));
}
#[test]
fn test_asterisk_cannot_be_setext() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text
***
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_underscore_cannot_be_setext() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Some text
___
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_fix_preserves_content() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "First paragraph with **bold** and *italic*.
***
Second paragraph with [link](url) and `code`.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("**bold**"));
assert!(fixed.contains("*italic*"));
assert!(fixed.contains("[link](url)"));
assert!(fixed.contains("`code`"));
assert!(fixed.contains("***"));
}
#[test]
fn test_fix_only_adds_needed_blanks() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text.
***
More text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "Text.
***
More text.";
assert_eq!(fixed, expected);
}
#[test]
fn test_hr_detection_edge_cases() {
use crate::lint_context::is_horizontal_rule_line;
assert!(is_horizontal_rule_line(" ---"));
assert!(is_horizontal_rule_line("--- "));
assert!(is_horizontal_rule_line(" --- "));
assert!(is_horizontal_rule_line("* * *"));
assert!(is_horizontal_rule_line("_ _ _"));
assert!(!is_horizontal_rule_line("--a"));
assert!(!is_horizontal_rule_line("**a"));
assert!(!is_horizontal_rule_line("-*-"));
assert!(!is_horizontal_rule_line("- * _"));
assert!(!is_horizontal_rule_line(" "));
assert!(!is_horizontal_rule_line("\t---")); }
#[test]
fn test_warning_line_numbers_accurate() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Line 1
Line 2
***
Line 4";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].line, 3); assert_eq!(result[1].line, 3);
}
#[test]
fn test_complex_document_structure() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "# Main Title
Introduction paragraph.
## Section One
Content here.
***
## Section Two
More content.
---
Final thoughts.";
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_blockquote_prefix_before_hr() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "> Text before
> ***
> Text after";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "> Text before
>
> ***
>
> Text after";
assert_eq!(
fixed, expected,
"Fix should insert '>' blank lines around HR, not plain blank lines"
);
}
#[test]
fn test_fix_preserves_nested_blockquote_prefix_for_hr() {
let rule = MD065BlanksAroundHorizontalRules;
let content = ">> Nested quote
>> ***
>> More text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = ">> Nested quote
>>
>> ***
>>
>> More text";
assert_eq!(fixed, expected, "Fix should preserve nested blockquote prefix '>>'");
}
#[test]
fn test_fix_preserves_blockquote_prefix_after_hr() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "> ---
> Text after";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "> ---
>
> Text after";
assert_eq!(
fixed, expected,
"Fix should insert '>' blank line after HR, not plain blank line"
);
}
#[test]
fn test_fix_preserves_triple_nested_blockquote_prefix_for_hr() {
let rule = MD065BlanksAroundHorizontalRules;
let content = ">>> Triple nested
>>> ***
>>> More text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = ">>> Triple nested
>>>
>>> ***
>>>
>>> More text";
assert_eq!(
fixed, expected,
"Fix should preserve triple-nested blockquote prefix '>>>'"
);
}
#[test]
fn test_fix_preserves_trailing_newline() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text\n***\nMore text\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
assert_eq!(fixed, "Text\n\n***\n\nMore text\n");
}
#[test]
fn spaced_thematic_break_is_not_skipped() {
let rule = MD065BlanksAroundHorizontalRules;
for content in [
"Text\n* * *\nMore text\n",
"Text\n- - -\nMore text\n",
"Text\n_ _ _\nMore text\n",
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(!rule.should_skip(&ctx), "the whole rule was skipped for {content:?}");
assert_eq!(
rule.check(&ctx).unwrap().len(),
2,
"both missing blank lines should be reported for {content:?}"
);
}
}
#[test]
fn a_document_with_no_thematic_break_is_still_skipped() {
let rule = MD065BlanksAroundHorizontalRules;
for content in ["Text\nMore text\n", "- item\n- item\n", "```\n---\n```\n"] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
rule.should_skip(&ctx),
"the rule should have been skipped for {content:?}"
);
}
}
#[test]
fn markers_inside_a_hidden_block_are_not_spaced_out() {
let rule = MD065BlanksAroundHorizontalRules;
for content in [
"Text.\n\n<!--\n***\n-->\n\nMore.\n",
"Text.\n\n$$\na = b\n***\nc = d\n$$\n\nMore.\n",
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(
rule.check(&ctx).unwrap().is_empty(),
"the hidden markers were reported in {content:?}"
);
assert_eq!(
rule.fix(&ctx).unwrap(),
content,
"the block was rewritten in {content:?}"
);
}
}
#[test]
fn test_fix_preserves_no_trailing_newline() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "Text\n***\nMore text";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
!fixed.ends_with('\n'),
"Fix should not add trailing newline if original didn't have one"
);
assert_eq!(fixed, "Text\n\n***\n\nMore text");
}
#[test]
fn test_indented_lazy_line_in_blockquote_is_not_a_horizontal_rule() {
let rule = MD065BlanksAroundHorizontalRules;
for content in [
"> o\n> ---\n",
"> o\n> ***\n",
">o\n> ***\n",
"> o\n> - - -\n",
"> > o\n> > ***\n",
"- > o\n > ***\n",
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
}
}
#[test]
fn test_real_horizontal_rule_in_blockquote_still_needs_blank_lines() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "> a\n>\n> ***\n> b\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
assert_eq!(rule.fix(&ctx).unwrap(), "> a\n>\n> ***\n>\n> b\n");
}
fn messages(content: &str) -> Vec<String> {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let rule = MD065BlanksAroundHorizontalRules;
rule.check(&ctx).unwrap().into_iter().map(|w| w.message).collect()
}
#[test]
fn a_rule_opening_a_bare_marker_item_has_nothing_before_it() {
let rule = MD065BlanksAroundHorizontalRules;
for content in [
"-\n ***\n",
"x\n\n-\n ***\n",
"1.\n ***\n",
"-\n ___\n",
"-\n * * *\n",
"-\n - - -\n",
"> -\n> ***\n",
"-\n -\n ***\n",
"- a\n -\n ***\n",
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
}
}
#[test]
fn a_rule_opening_a_bare_marker_item_still_needs_a_blank_after() {
let rule = MD065BlanksAroundHorizontalRules;
for (content, expected) in [
("-\n ***\n text\n", "-\n ***\n\n text\n"),
("1.\n ***\n text\n", "1.\n ***\n\n text\n"),
("> -\n> ***\n> text\n", "> -\n> ***\n>\n> text\n"),
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
assert_eq!(warnings[0].message, "Missing blank line after horizontal rule");
assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
}
}
#[test]
fn a_rule_after_item_content_still_needs_a_blank_before() {
let rule = MD065BlanksAroundHorizontalRules;
for (content, expected) in [
("- a\n ***\n", "- a\n\n ***\n"),
("-\n a\n ***\n", "-\n a\n\n ***\n"),
("1. a\n ***\n", "1. a\n\n ***\n"),
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
messages(content),
["Missing blank line before horizontal rule"],
"{content:?}"
);
assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
}
}
#[test]
fn a_rule_outside_the_list_after_a_bare_marker_is_unchanged() {
assert!(messages("-\n\n ***\n").is_empty());
assert!(messages("-\n\n***\n").is_empty());
}
#[test]
fn a_lazy_quoted_setext_underline_is_not_a_rule() {
let rule = MD065BlanksAroundHorizontalRules;
for content in [
"> a\nb\n> ---\n",
"> a\nb\n> ---\n",
"> > a\n> b\n> > ---\n",
"> a\nb\nc\n> ---\n",
"> - a\nb\n> ---\n",
"- a\nb\n ---\n",
"a\nb\n---\n",
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
}
}
#[test]
fn a_lazy_line_before_a_real_rule_is_still_separated() {
let rule = MD065BlanksAroundHorizontalRules;
for (content, expected) in [
("> a\nb\n> ***\n", "> a\nb\n>\n> ***\n"),
("> a\nb\n> - - -\n", "> a\nb\n>\n> - - -\n"),
("> a\nb\n> ***\n> c\n", "> a\nb\n>\n> ***\n>\n> c\n"),
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert!(!rule.check(&ctx).unwrap().is_empty(), "{content:?}");
assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
}
}
#[test]
fn the_fix_values_apply_to_the_same_text_as_fix() {
let rule = MD065BlanksAroundHorizontalRules;
for content in [
"Text\n***\nMore text\n",
"Text\n***\nMore text",
"> a\n> ***\n> b\n",
"> > a\n> > ***\n> > b\n",
"> a\nb\n> ***\nc\n",
"- a\n ***\n b\n",
"-\n ***\n b\n",
"a\n***\n***\nb\n",
] {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(!warnings.is_empty(), "{content:?}");
let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
assert_eq!(applied, rule.fix(&ctx).unwrap(), "{content:?}");
}
}
#[test]
fn the_fix_is_not_written_for_a_warning_without_a_fix() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "a\n***\nb\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let mut warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2);
warnings[0].fix = None; assert_eq!(
MD065BlanksAroundHorizontalRules::fix_with(content, &warnings).unwrap(),
"a\n***\n\nb\n"
);
}
#[test]
fn loosening_a_list_keeps_its_fixes_in_every_flavor() {
let rule = MD065BlanksAroundHorizontalRules;
for flavor in [
crate::config::MarkdownFlavor::Standard,
crate::config::MarkdownFlavor::MDX,
] {
let content = "- a\n ***\n b\n";
let ctx = LintContext::new(content, flavor, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2, "{flavor:?}");
assert!(warnings.iter().all(|w| w.fix.is_some()), "{flavor:?}: {warnings:?}");
assert_eq!(rule.fix(&ctx).unwrap(), "- a\n\n ***\n\n b\n", "{flavor:?}");
}
}
#[test]
fn mdx_reads_no_indented_code_so_its_fixes_stay() {
let rule = MD065BlanksAroundHorizontalRules;
let content = "> o\n> ***\n> after\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(warnings.iter().all(|w| w.fix.is_some()), "{warnings:?}");
}
}
#[cfg(test)]
mod block_adjacency_regressions {
#[test]
fn paragraph_between_item_start_and_rule_clears_opener() {
let ctx =
crate::lint_context::LintContext::new("- para\n\n ---\n", crate::config::MarkdownFlavor::Standard, None);
assert!(super::RuleLines::new(&ctx).item_openers.is_empty());
}
}