Skip to main content

mdlint/lint/rules/
md052.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use pulldown_cmark::{Event, LinkType, Tag};
5use serde_json::Value;
6
7pub struct MD052;
8
9impl Rule for MD052 {
10    fn name(&self) -> &'static str {
11        "MD052"
12    }
13
14    fn description(&self) -> &'static str {
15        "Reference links and images should use a label that is defined"
16    }
17
18    fn tags(&self) -> &[&str] {
19        &["links"]
20    }
21
22    fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
23        let mut violations = Vec::new();
24
25        // A broken-link callback forces pulldown-cmark to still emit an event for
26        // reference-style links/images with no matching definition, tagging their
27        // `LinkType` as the `*Unknown` variant instead of silently treating them as
28        // plain text. This lets shortcut (`[label]`) and collapsed (`[label][]`)
29        // forms be checked alongside the full `[text][label]` form.
30        for (event, range) in parser.parse_with_broken_links() {
31            let (is_image, link_type, id) = match event {
32                Event::Start(Tag::Link { link_type, id, .. }) => (false, link_type, id),
33                Event::Start(Tag::Image { link_type, id, .. }) => (true, link_type, id),
34                _ => continue,
35            };
36
37            if matches!(
38                link_type,
39                LinkType::ReferenceUnknown | LinkType::CollapsedUnknown | LinkType::ShortcutUnknown
40            ) {
41                let item_type = if is_image { "image" } else { "link" };
42                violations.push(Violation {
43                    line: parser.offset_to_line(range.start),
44                    column: Some(1),
45                    rule: self.name().to_owned(),
46                    message: format!("Reference {item_type} label '{id}' is not defined"),
47                    fix: None,
48                });
49            }
50        }
51
52        violations
53    }
54
55    fn fixable(&self) -> bool {
56        false
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn test_defined_reference() {
66        let content = "[example]: https://example.com\n\n[Link][example]";
67        let parser = MarkdownParser::new(content);
68        let rule = MD052;
69        let violations = rule.check(&parser, None);
70
71        assert_eq!(violations.len(), 0);
72    }
73
74    #[test]
75    fn test_undefined_reference() {
76        let content = "[Link][undefined]";
77        let parser = MarkdownParser::new(content);
78        let rule = MD052;
79        let violations = rule.check(&parser, None);
80
81        assert_eq!(violations.len(), 1);
82        assert!(violations[0].message.contains("undefined"));
83    }
84
85    #[test]
86    fn test_image_reference() {
87        let content = "[img]: image.png\n\n![Alt][img]";
88        let parser = MarkdownParser::new(content);
89        let rule = MD052;
90        let violations = rule.check(&parser, None);
91
92        assert_eq!(violations.len(), 0);
93    }
94
95    #[test]
96    fn test_inline_links_ignored() {
97        let content = "[Link](https://example.com)";
98        let parser = MarkdownParser::new(content);
99        let rule = MD052;
100        let violations = rule.check(&parser, None);
101
102        // Inline links should not trigger violations
103        assert_eq!(violations.len(), 0);
104    }
105
106    #[test]
107    fn test_case_insensitive() {
108        let content = "[EXAMPLE]: https://example.com\n\n[Link][example]";
109        let parser = MarkdownParser::new(content);
110        let rule = MD052;
111        let violations = rule.check(&parser, None);
112
113        // Labels should be case-insensitive
114        assert_eq!(violations.len(), 0);
115    }
116
117    #[test]
118    fn test_shortcut_reference_undefined() {
119        // Regression test for https://github.com/swanysimon/mdlint/issues/53
120        let content = "Here a [reference] is unused.";
121        let parser = MarkdownParser::new(content);
122        let rule = MD052;
123        let violations = rule.check(&parser, None);
124
125        assert_eq!(violations.len(), 1);
126        assert!(violations[0].message.contains("reference"));
127    }
128
129    #[test]
130    fn test_shortcut_reference_defined() {
131        let content = "Here a [reference] is used.\n\n[reference]: https://example.com/";
132        let parser = MarkdownParser::new(content);
133        let rule = MD052;
134        let violations = rule.check(&parser, None);
135
136        assert_eq!(violations.len(), 0);
137    }
138
139    #[test]
140    fn test_collapsed_reference_undefined() {
141        let content = "[Link][]";
142        let parser = MarkdownParser::new(content);
143        let rule = MD052;
144        let violations = rule.check(&parser, None);
145
146        assert_eq!(violations.len(), 1);
147    }
148}