use crate::utils::range_utils::calculate_line_range;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use toml;
mod md035_config;
use md035_config::MD035Config;
#[derive(Clone, Default)]
pub struct MD035HRStyle {
config: MD035Config,
}
impl MD035HRStyle {
pub fn new(style: String) -> Self {
Self {
config: MD035Config { style },
}
}
pub fn from_config_struct(config: MD035Config) -> Self {
Self { config }
}
fn is_horizontal_rule(line: &str) -> bool {
crate::utils::thematic_break::is_thematic_break(line)
}
fn is_potential_setext_heading(lines: &[&str], i: usize) -> bool {
if i == 0 {
return false; }
let line = lines[i].trim();
let prev_line = lines[i - 1].trim();
let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
(is_dash_line || is_equals_line) && prev_line_has_content
}
fn most_prevalent_hr_style(lines: &[&str], ctx: &crate::lint_context::LintContext) -> Option<String> {
use std::collections::HashMap;
let mut counts: HashMap<&str, usize> = HashMap::new();
let mut order: Vec<&str> = Vec::new();
for (i, line) in lines.iter().enumerate() {
if let Some(line_info) = ctx.lines.get(i)
&& (line_info.in_front_matter || line_info.in_code_block || line_info.in_mkdocs_html_markdown)
{
continue;
}
if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(lines, i) {
let style = line.trim();
let counter = counts.entry(style).or_insert(0);
*counter += 1;
if *counter == 1 {
order.push(style);
}
}
}
counts
.iter()
.max_by_key(|&(style, count)| {
(
*count,
-(order.iter().position(|&s| s == *style).unwrap_or(usize::MAX) as isize),
)
})
.map(|(style, _)| style.to_string())
}
}
impl Rule for MD035HRStyle {
fn name(&self) -> &'static str {
"MD035"
}
fn description(&self) -> &'static str {
"Horizontal rule style"
}
fn category(&self) -> RuleCategory {
RuleCategory::Whitespace
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let _line_index = &ctx.line_index;
let mut warnings = Vec::new();
let lines = ctx.raw_lines();
let expected_style = if self.config.style.is_empty() || self.config.style == "consistent" {
Self::most_prevalent_hr_style(lines, ctx).unwrap_or_else(|| "---".to_string())
} else {
self.config.style.clone()
};
for (i, line) in lines.iter().enumerate() {
if let Some(line_info) = ctx.lines.get(i)
&& (line_info.in_front_matter || line_info.in_code_block || line_info.in_mkdocs_html_markdown)
{
continue;
}
if Self::is_potential_setext_heading(lines, i) {
continue;
}
if Self::is_horizontal_rule(line) {
let has_indentation = line.len() > line.trim_start().len();
let style_mismatch = line.trim() != expected_style;
if style_mismatch || has_indentation {
let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: if has_indentation {
"Horizontal rule should not be indented".to_string()
} else {
format!("Horizontal rule style should be \"{expected_style}\"")
},
severity: Severity::Warning,
fix: Some(Fix {
range: _line_index.line_col_to_byte_range_with_length(i + 1, 1, line.chars().count()),
replacement: expected_style.clone(),
}),
});
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let content = ctx.content;
let _line_index = &ctx.line_index;
let mut result = Vec::new();
let lines = ctx.raw_lines();
let expected_style = if self.config.style.is_empty() || self.config.style == "consistent" {
Self::most_prevalent_hr_style(lines, ctx).unwrap_or_else(|| "---".to_string())
} else {
self.config.style.clone()
};
for (i, line) in lines.iter().enumerate() {
if ctx.inline_config().is_rule_disabled(self.name(), i + 1) {
result.push(line.to_string());
continue;
}
if let Some(line_info) = ctx.lines.get(i)
&& (line_info.in_front_matter || line_info.in_code_block || line_info.in_mkdocs_html_markdown)
{
result.push(line.to_string());
continue;
}
if Self::is_potential_setext_heading(lines, i) {
result.push(line.to_string());
continue;
}
if Self::is_horizontal_rule(line) {
result.push(expected_style.clone());
} else {
result.push(line.to_string());
}
}
let mut fixed = result.join("\n");
if content.ends_with('\n') && !fixed.ends_with('\n') {
fixed.push('\n');
}
Ok(fixed)
}
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('-') && !ctx.has_char('*') && !ctx.has_char('_'))
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let mut map = toml::map::Map::new();
map.insert("style".to_string(), toml::Value::String(self.config.style.clone()));
Some((self.name().to_string(), toml::Value::Table(map)))
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let style = crate::config::get_rule_config_value::<String>(config, "MD035", "style")
.unwrap_or_else(|| "consistent".to_string());
Box::new(MD035HRStyle::new(style))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_is_horizontal_rule() {
assert!(MD035HRStyle::is_horizontal_rule("---"));
assert!(MD035HRStyle::is_horizontal_rule("----"));
assert!(MD035HRStyle::is_horizontal_rule("***"));
assert!(MD035HRStyle::is_horizontal_rule("****"));
assert!(MD035HRStyle::is_horizontal_rule("___"));
assert!(MD035HRStyle::is_horizontal_rule("____"));
assert!(MD035HRStyle::is_horizontal_rule("- - -"));
assert!(MD035HRStyle::is_horizontal_rule("* * *"));
assert!(MD035HRStyle::is_horizontal_rule("_ _ _"));
assert!(MD035HRStyle::is_horizontal_rule(" --- "));
assert!(!MD035HRStyle::is_horizontal_rule("--")); assert!(!MD035HRStyle::is_horizontal_rule("**"));
assert!(!MD035HRStyle::is_horizontal_rule("__"));
assert!(!MD035HRStyle::is_horizontal_rule("- -")); assert!(!MD035HRStyle::is_horizontal_rule("* *"));
assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
assert!(!MD035HRStyle::is_horizontal_rule("text"));
assert!(!MD035HRStyle::is_horizontal_rule(""));
}
#[test]
fn test_is_potential_setext_heading() {
let lines = vec!["Heading 1", "=========", "Content", "Heading 2", "---", "More content"];
assert!(MD035HRStyle::is_potential_setext_heading(&lines, 1)); assert!(MD035HRStyle::is_potential_setext_heading(&lines, 4));
assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 0)); assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 2));
let lines2 = vec!["", "---", "Content"];
assert!(!MD035HRStyle::is_potential_setext_heading(&lines2, 1));
let lines3 = vec!["***", "---"];
assert!(!MD035HRStyle::is_potential_setext_heading(&lines3, 1)); }
#[test]
fn test_most_prevalent_hr_style() {
let content = "Content\n\n---\n\nMore\n\n---\n\nText";
let lines: Vec<&str> = content.lines().collect();
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
Some("---".to_string())
);
let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
let lines: Vec<&str> = content.lines().collect();
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
Some("---".to_string())
);
let content = "Content\n\n***\n\nMore\n\n---\n\nText";
let lines: Vec<&str> = content.lines().collect();
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
Some("***".to_string())
);
let content = "Just\nRegular\nContent";
let lines: Vec<&str> = content.lines().collect();
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(MD035HRStyle::most_prevalent_hr_style(&lines, &ctx), None);
let content = "Heading\n---\nContent\n\n***";
let lines: Vec<&str> = content.lines().collect();
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
Some("***".to_string())
);
}
#[test]
fn test_consistent_style() {
let rule = MD035HRStyle::new("consistent".to_string());
let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\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, 7);
assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
}
#[test]
fn test_specific_style_dashes() {
let rule = MD035HRStyle::new("---".to_string());
let content = "Content\n\n***\n\nMore\n\n___\n\nText";
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, 7);
assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
}
#[test]
fn test_indented_horizontal_rule() {
let rule = MD035HRStyle::new("---".to_string());
let content = "Content\n\n ---\n\nMore";
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_eq!(result[0].message, "Horizontal rule should not be indented");
}
#[test]
fn test_setext_heading_not_flagged() {
let rule = MD035HRStyle::new("***".to_string());
let content = "Heading\n---\nContent\n***";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_fix_consistent_style() {
let rule = MD035HRStyle::new("consistent".to_string());
let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "Content\n\n---\n\nMore\n\n---\n\nText\n\n---";
assert_eq!(fixed, expected);
}
#[test]
fn test_fix_specific_style() {
let rule = MD035HRStyle::new("***".to_string());
let content = "Content\n\n---\n\nMore\n\n___\n\nText";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "Content\n\n***\n\nMore\n\n***\n\nText";
assert_eq!(fixed, expected);
}
#[test]
fn test_fix_preserves_setext_headings() {
let rule = MD035HRStyle::new("***".to_string());
let content = "Heading 1\n=========\nHeading 2\n---\nContent\n\n---";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "Heading 1\n=========\nHeading 2\n---\nContent\n\n***";
assert_eq!(fixed, expected);
}
#[test]
fn test_fix_removes_indentation() {
let rule = MD035HRStyle::new("---".to_string());
let content = "Content\n\n ***\n\nMore\n\n ___\n\nText";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let expected = "Content\n\n---\n\nMore\n\n---\n\nText";
assert_eq!(fixed, expected);
}
#[test]
fn test_spaced_styles() {
let rule = MD035HRStyle::new("* * *".to_string());
let content = "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText";
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("Horizontal rule style should be \"* * *\""));
}
#[test]
fn test_empty_style_uses_consistent() {
let rule = MD035HRStyle::new("".to_string());
let content = "Content\n\n---\n\nMore\n\n***\n\nText";
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_all_hr_styles_consistent() {
let rule = MD035HRStyle::new("consistent".to_string());
let content = "Content\n---\nMore\n---\nText\n---";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_no_horizontal_rules() {
let rule = MD035HRStyle::new("---".to_string());
let content = "Just regular content\nNo horizontal rules here";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_mixed_spaced_and_unspaced() {
let rule = MD035HRStyle::new("consistent".to_string());
let content = "Content\n\n---\n\nMore\n\n- - -\n\nText";
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_trailing_whitespace_in_hr() {
let rule = MD035HRStyle::new("---".to_string());
let content = "Content\n\n--- \n\nMore";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_hr_in_code_block_not_flagged() {
let rule = MD035HRStyle::new("---".to_string());
let content =
"Text\n\n```bash\n----------------------------------------------------------------------\n```\n\nMore";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_hr_in_code_span_not_flagged() {
let rule = MD035HRStyle::new("---".to_string());
let content = "Text with inline `---` code span";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_hr_with_extra_characters() {
let rule = MD035HRStyle::new("---".to_string());
let content = "Content\n-----\nMore\n--------\nText";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_default_config() {
let rule = MD035HRStyle::new("consistent".to_string());
let (name, config) = rule.default_config_section().unwrap();
assert_eq!(name, "MD035");
let table = config.as_table().unwrap();
assert_eq!(table.get("style").unwrap().as_str().unwrap(), "consistent");
}
#[test]
fn test_fix_skips_mkdocs_html_markdown() {
let rule = MD035HRStyle::new("***".to_string());
let content = "Some content\n\n***\n\n<div class=\"grid cards\" markdown>\n\n- Card 1 content\n\n ---\n\n Card 1 footer\n\n</div>\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let warnings = rule.check(&ctx).unwrap();
for w in &warnings {
assert_ne!(w.line, 9, "check() should not flag --- inside <div markdown> block");
}
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains(" ---"),
"fix() should preserve --- inside <div markdown> block, got: {fixed}"
);
}
#[test]
fn test_is_horizontal_rule_edge_cases() {
assert!(MD035HRStyle::is_horizontal_rule("----------"));
assert!(MD035HRStyle::is_horizontal_rule("**********"));
assert!(MD035HRStyle::is_horizontal_rule("__________"));
assert!(MD035HRStyle::is_horizontal_rule("- - - -"));
assert!(MD035HRStyle::is_horizontal_rule("* * * * *"));
assert!(MD035HRStyle::is_horizontal_rule("_ _ _ _ _ _"));
assert!(MD035HRStyle::is_horizontal_rule("* * *"));
assert!(MD035HRStyle::is_horizontal_rule("- - -"));
assert!(MD035HRStyle::is_horizontal_rule("_ _ _"));
assert!(MD035HRStyle::is_horizontal_rule("--- "));
assert!(MD035HRStyle::is_horizontal_rule("*** "));
assert!(MD035HRStyle::is_horizontal_rule("___ "));
assert!(MD035HRStyle::is_horizontal_rule("- - - "));
assert!(MD035HRStyle::is_horizontal_rule("* * * "));
assert!(!MD035HRStyle::is_horizontal_rule("-*-"));
assert!(!MD035HRStyle::is_horizontal_rule("- * -"));
assert!(!MD035HRStyle::is_horizontal_rule("_-_"));
assert!(!MD035HRStyle::is_horizontal_rule("*_*"));
assert!(!MD035HRStyle::is_horizontal_rule("---text"));
assert!(!MD035HRStyle::is_horizontal_rule("***text"));
assert!(!MD035HRStyle::is_horizontal_rule("- - - text"));
assert!(!MD035HRStyle::is_horizontal_rule("- -"));
assert!(!MD035HRStyle::is_horizontal_rule("* *"));
assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
assert!(!MD035HRStyle::is_horizontal_rule("-a-b-"));
assert!(!MD035HRStyle::is_horizontal_rule("*x*x*"));
assert!(!MD035HRStyle::is_horizontal_rule("-"));
assert!(!MD035HRStyle::is_horizontal_rule("*"));
assert!(!MD035HRStyle::is_horizontal_rule("_"));
assert!(MD035HRStyle::is_horizontal_rule("*\t*\t*"));
assert!(MD035HRStyle::is_horizontal_rule("-\t-\t-"));
let long_hr = "-".repeat(200);
assert!(MD035HRStyle::is_horizontal_rule(&long_hr));
}
#[test]
fn test_frontmatter_not_treated_as_hr() {
let rule = MD035HRStyle::new("***".to_string());
let content = "---\ntitle: Test\n---\n\n***\n\nContent";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_fix_skips_mkdocs_html_markdown_preserves_outside() {
let rule = MD035HRStyle::new("***".to_string());
let content = "Some content\n\n---\n\n<div class=\"grid cards\" markdown>\n\n- Card content\n\n ---\n\n Card footer\n\n</div>\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
assert_eq!(lines[2], "***", "fix() should change --- outside <div markdown> to ***");
assert!(
fixed.contains(" ---"),
"fix() should preserve --- inside <div markdown>"
);
}
}