use crate::lint::rule::Rule;
use crate::markdown::MarkdownParser;
use crate::types::Violation;
use pulldown_cmark::{Event, Tag, TagEnd};
use serde_json::Value;
use std::collections::HashMap;
pub struct MD051;
impl Rule for MD051 {
fn name(&self) -> &str {
"MD051"
}
fn description(&self) -> &str {
"Link fragments should be valid"
}
fn tags(&self) -> &[&str] {
&["links"]
}
fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
let mut violations = Vec::new();
let mut heading_ids: HashMap<String, usize> = HashMap::new();
let mut in_heading = false;
let mut current_heading_text = String::new();
for (event, _range) in parser.parse_with_offsets() {
match event {
Event::Start(Tag::Heading { .. }) => {
in_heading = true;
current_heading_text.clear();
}
Event::Text(text) if in_heading => {
current_heading_text.push_str(&text);
}
Event::End(TagEnd::Heading(_)) if in_heading => {
let heading_id = heading_to_id(¤t_heading_text);
let count = heading_ids.entry(heading_id.clone()).or_insert(0);
*count += 1;
in_heading = false;
}
_ => {}
}
}
let mut in_link = false;
let mut link_url = String::new();
let mut link_line = 0;
for (event, range) in parser.parse_with_offsets() {
match event {
Event::Start(Tag::Link { dest_url, .. }) => {
in_link = true;
link_url = dest_url.to_string();
link_line = parser.offset_to_line(range.start);
}
Event::End(TagEnd::Link) if in_link => {
if let Some(fragment) = link_url.strip_prefix('#') {
let fragment_id = fragment.to_string();
if !heading_ids.contains_key(&fragment_id) {
violations.push(Violation {
line: link_line,
column: Some(1),
rule: self.name().to_string(),
message: format!(
"Link fragment '{}' does not match any heading",
fragment
),
fix: None,
});
}
} else if let Some(pos) = link_url.find('#') {
if !link_url.starts_with("http://") && !link_url.starts_with("https://") {
let fragment = &link_url[pos + 1..];
let fragment_id = fragment.to_string();
if !heading_ids.contains_key(&fragment_id) {
violations.push(Violation {
line: link_line,
column: Some(1),
rule: self.name().to_string(),
message: format!(
"Link fragment '{}' does not match any heading",
fragment
),
fix: None,
});
}
}
}
in_link = false;
}
_ => {}
}
}
violations
}
fn fixable(&self) -> bool {
false
}
}
fn heading_to_id(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else if c.is_whitespace() {
'-'
} else {
'\0'
}
})
.filter(|&c| c != '\0')
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_fragment() {
let content = "# Introduction\n\nSee [intro](#introduction) for more.";
let parser = MarkdownParser::new(content);
let rule = MD051;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
#[test]
fn test_invalid_fragment() {
let content = "# Introduction\n\nSee [wrong](#nonexistent) for more.";
let parser = MarkdownParser::new(content);
let rule = MD051;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("nonexistent"));
}
#[test]
fn test_multiple_headings() {
let content = "# One\n## Two\n### Three\n\n[Link](#two)";
let parser = MarkdownParser::new(content);
let rule = MD051;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
#[test]
fn test_heading_with_spaces() {
let content = "# Hello World\n\n[Link](#hello-world)";
let parser = MarkdownParser::new(content);
let rule = MD051;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
#[test]
fn test_external_links_ignored() {
let content = "# Section\n\n[External](https://example.com#anything)";
let parser = MarkdownParser::new(content);
let rule = MD051;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
}