use crate::error::Result;
use crate::{
Document, Violation,
rule::{Rule, RuleCategory, RuleMetadata},
violation::Severity,
};
use comrak::nodes::AstNode;
use std::collections::{HashMap, HashSet};
pub struct MD053 {
ignored_definitions: Vec<String>,
}
impl Default for MD053 {
fn default() -> Self {
Self::new()
}
}
impl MD053 {
pub fn new() -> Self {
Self {
ignored_definitions: vec!["//".to_string()], }
}
#[allow(dead_code)]
pub fn ignored_definitions(mut self, definitions: Vec<String>) -> Self {
self.ignored_definitions = definitions;
self
}
fn collect_definitions(&self, document: &Document) -> Vec<(String, usize, usize)> {
let mut definitions = Vec::new();
for (line_num, line) in document.content.lines().enumerate() {
let line_number = line_num + 1;
if let Some((label, column)) = self.parse_reference_definition(line) {
definitions.push((label.to_lowercase(), line_number, column));
}
}
definitions
}
fn parse_reference_definition(&self, line: &str) -> Option<(String, usize)> {
let mut chars = line.char_indices().peekable();
let mut start_pos = 0;
while let Some((pos, ch)) = chars.peek() {
if ch.is_whitespace() {
start_pos = *pos + 1;
chars.next();
} else {
break;
}
}
if chars.next()?.1 != '[' {
return None;
}
let bracket_start = start_pos;
let mut label = String::new();
let mut found_closing_bracket = false;
for (_, ch) in chars.by_ref() {
if ch == ']' {
found_closing_bracket = true;
break;
}
label.push(ch);
}
if !found_closing_bracket || label.is_empty() {
return None;
}
if chars.next()?.1 != ':' {
return None;
}
if let Some((_, ch)) = chars.peek() {
if !ch.is_whitespace() {
return None;
}
}
Some((label, bracket_start + 1))
}
fn collect_used_labels(&self, document: &Document) -> HashSet<String> {
let mut used_labels = HashSet::new();
for line in document.content.lines() {
let mut chars = line.char_indices().peekable();
let mut in_backticks = false;
while let Some((i, ch)) = chars.next() {
match ch {
'`' => {
in_backticks = !in_backticks;
}
'[' if !in_backticks => {
if let Some(label) = self.parse_reference_usage(&line[i..]) {
used_labels.insert(label.to_lowercase());
while let Some((_, next_ch)) = chars.peek() {
if *next_ch == ']' {
chars.next();
break;
}
chars.next();
}
}
}
_ => {}
}
}
}
used_labels
}
fn parse_reference_usage(&self, text: &str) -> Option<String> {
if !text.starts_with('[') {
return None;
}
let mut chars = text.char_indices().skip(1);
let mut first_part = String::new();
let mut found_first_closing = false;
for (_, ch) in chars.by_ref() {
if ch == ']' {
found_first_closing = true;
break;
}
first_part.push(ch);
}
if !found_first_closing {
return None;
}
if let Some((_, next_ch)) = chars.next() {
if next_ch == '[' {
let mut second_part = String::new();
let mut found_second_closing = false;
for (_, ch) in chars {
if ch == ']' {
found_second_closing = true;
break;
}
second_part.push(ch);
}
if found_second_closing {
if second_part.is_empty() {
return Some(first_part);
} else {
return Some(second_part);
}
}
}
}
None
}
fn check_definitions(
&self,
definitions: Vec<(String, usize, usize)>,
used_labels: &HashSet<String>,
) -> Vec<Violation> {
let mut violations = Vec::new();
let mut seen_labels: HashMap<String, (usize, usize)> = HashMap::new();
for (label, line, column) in definitions {
if self.ignored_definitions.contains(&label) {
continue;
}
if let Some((first_line, _first_column)) = seen_labels.get(&label) {
violations.push(self.create_violation(
format!(
"Reference definition '{label}' is duplicated (first defined at line {first_line})"
),
line,
column,
Severity::Warning,
));
} else {
seen_labels.insert(label.clone(), (line, column));
if !used_labels.contains(&label) {
violations.push(self.create_violation(
format!("Reference definition '{label}' is unused"),
line,
column,
Severity::Warning,
));
}
}
}
violations
}
}
impl Rule for MD053 {
fn id(&self) -> &'static str {
"MD053"
}
fn name(&self) -> &'static str {
"link-image-reference-definitions"
}
fn description(&self) -> &'static str {
"Link and image reference definitions should be needed"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Links)
}
fn check_with_ast<'a>(
&self,
document: &Document,
_ast: Option<&'a AstNode<'a>>,
) -> Result<Vec<Violation>> {
let definitions = self.collect_definitions(document);
let used_labels = self.collect_used_labels(document);
let violations = self.check_definitions(definitions, &used_labels);
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{
assert_no_violations, assert_single_violation, assert_violation_count,
};
#[test]
fn test_used_definitions() {
let content = r#"[Link][label]
[label]: https://example.com
"#;
assert_no_violations(MD053::new(), content);
}
#[test]
fn test_unused_definition() {
let content = r#"[Link][used]
[used]: https://example.com
[unused]: https://example.com
"#;
let violation = assert_single_violation(MD053::new(), content);
assert_eq!(violation.line, 4);
assert!(violation.message.contains("unused"));
}
#[test]
fn test_duplicate_definitions() {
let content = r#"[Link][label]
[label]: https://example.com
[label]: https://duplicate.com
"#;
let violation = assert_single_violation(MD053::new(), content);
assert_eq!(violation.line, 4);
assert!(violation.message.contains("duplicated"));
assert!(violation.message.contains("first defined at line 3"));
}
#[test]
fn test_ignored_definitions() {
let content = r#"[//]: # (This is a comment)
"#;
assert_no_violations(MD053::new(), content); }
#[test]
fn test_case_insensitive_matching() {
let content = r#"[Link][LABEL]
[label]: https://example.com
"#;
assert_no_violations(MD053::new(), content);
}
#[test]
fn test_collapsed_reference() {
let content = r#"[Label][]
[label]: https://example.com
"#;
assert_no_violations(MD053::new(), content);
}
#[test]
fn test_unused_and_duplicate() {
let content = r#"[Link][used]
[used]: https://example.com
[unused]: https://example.com
[used]: https://duplicate.com
"#;
let violations = assert_violation_count(MD053::new(), content, 2);
let unused_violation = violations
.iter()
.find(|v| v.message.contains("unused"))
.unwrap();
assert_eq!(unused_violation.line, 4);
let duplicate_violation = violations
.iter()
.find(|v| v.message.contains("duplicated"))
.unwrap();
assert_eq!(duplicate_violation.line, 5);
}
}