use crate::lint::rule::Rule;
use crate::markdown::MarkdownParser;
use crate::types::Violation;
use pulldown_cmark::{Event, HeadingLevel, Tag};
use serde_json::Value;
use std::collections::HashMap;
pub struct MD024;
impl Rule for MD024 {
fn name(&self) -> &str {
"MD024"
}
fn description(&self) -> &str {
"Multiple headings with the same content"
}
fn tags(&self) -> &[&str] {
&["headings"]
}
fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
let siblings_only = config
.and_then(|c| c.get("siblings_only"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut violations = Vec::new();
let mut heading_texts: HashMap<String, (usize, HeadingLevel)> = HashMap::new();
let mut sibling_headings: HashMap<(HeadingLevel, String), usize> = HashMap::new();
let mut last_heading_level: Option<HeadingLevel> = None;
let mut in_heading = false;
let mut current_heading_text = String::new();
let mut current_heading_line = 0;
let mut current_heading_level = HeadingLevel::H1;
for (event, range) in parser.parse_with_offsets() {
match event {
Event::Start(Tag::Heading(level, _, _)) => {
in_heading = true;
current_heading_text.clear();
current_heading_line = parser.offset_to_line(range.start);
current_heading_level = level;
}
Event::Text(text) if in_heading => {
current_heading_text.push_str(&text);
}
Event::End(Tag::Heading(_, _, _)) if in_heading => {
let text = current_heading_text.trim().to_string();
if siblings_only {
if let Some(&prev_level) = last_heading_level.as_ref()
&& prev_level != current_heading_level
{
sibling_headings.clear();
}
if let Some(&first_line) =
sibling_headings.get(&(current_heading_level, text.clone()))
{
violations.push(Violation {
line: current_heading_line,
column: Some(1),
rule: self.name().to_string(),
message: format!(
"Multiple sibling headings with the same content: \"{}\" (first at line {})",
text, first_line
),
fix: None,
});
} else {
sibling_headings.insert(
(current_heading_level, text.clone()),
current_heading_line,
);
}
} else {
if let Some(&(first_line, _first_level)) = heading_texts.get(&text) {
violations.push(Violation {
line: current_heading_line,
column: Some(1),
rule: self.name().to_string(),
message: format!(
"Multiple headings with the same content: \"{}\" (first at line {})",
text, first_line
),
fix: None,
});
} else {
heading_texts
.insert(text, (current_heading_line, current_heading_level));
}
}
last_heading_level = Some(current_heading_level);
in_heading = false;
}
_ => {}
}
}
violations
}
fn fixable(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unique_headings() {
let content = "# Heading 1\n## Heading 2\n### Heading 3";
let parser = MarkdownParser::new(content);
let rule = MD024;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
#[test]
fn test_duplicate_headings() {
let content = "# Heading\n## Content\n# Heading";
let parser = MarkdownParser::new(content);
let rule = MD024;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("Heading"));
}
#[test]
fn test_siblings_only_different_levels() {
let content = "# Heading\n## Heading\n### Heading";
let parser = MarkdownParser::new(content);
let rule = MD024;
let config = serde_json::json!({ "siblings_only": true });
let violations = rule.check(&parser, Some(&config));
assert_eq!(violations.len(), 0); }
#[test]
fn test_siblings_only_same_level() {
let content = "## Heading\n## Content\n## Heading";
let parser = MarkdownParser::new(content);
let rule = MD024;
let config = serde_json::json!({ "siblings_only": true });
let violations = rule.check(&parser, Some(&config));
assert_eq!(violations.len(), 1);
}
}