quickmark_core/rules/
md042.rs

1use std::rc::Rc;
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use tree_sitter::Node;
6
7use crate::{
8    linter::{range_from_tree_sitter, RuleViolation},
9    rules::{Context, Rule, RuleLinter, RuleType},
10};
11
12// Regular inline links: [text](url) - but NOT images ![text](url)
13static RE_INLINE_LINK: Lazy<Regex> =
14    Lazy::new(|| Regex::new(r"(?:^|[^!])\[([^\]]*)\]\(([^)]*)\)").unwrap());
15
16/// MD042 - No empty links
17///
18/// This rule checks for links that have no destination or only a fragment identifier.
19pub(crate) struct MD042Linter {
20    context: Rc<Context>,
21    violations: Vec<RuleViolation>,
22}
23
24impl RuleLinter for MD042Linter {
25    fn feed(&mut self, node: &Node) {
26        // Process different possible link node types
27        match node.kind() {
28            "link" => self.check_link_for_empty_destination(node),
29            "inline" => self.check_inline_for_links(node),
30            _ => {}
31        }
32    }
33
34    fn finalize(&mut self) -> Vec<RuleViolation> {
35        std::mem::take(&mut self.violations)
36    }
37}
38
39impl MD042Linter {
40    pub fn new(context: Rc<Context>) -> Self {
41        Self {
42            context,
43            violations: Vec::new(),
44        }
45    }
46
47    fn check_inline_for_links(&mut self, inline_node: &Node) {
48        let link_text = {
49            let document_content = self.context.document_content.borrow();
50            inline_node
51                .utf8_text(document_content.as_bytes())
52                .unwrap_or_default()
53                .to_string()
54        };
55        self.check_text_for_link_patterns(&link_text, inline_node);
56    }
57
58    fn check_text_for_link_patterns(&mut self, text: &str, node: &Node) {
59        // Check inline links: [text](url)
60        for caps in RE_INLINE_LINK.captures_iter(text) {
61            if let Some(url_match) = caps.get(2) {
62                if self.is_empty_link_destination(url_match.as_str()) {
63                    self.create_empty_link_violation(node);
64                }
65            }
66        }
67    }
68
69    fn check_link_for_empty_destination(&mut self, link_node: &Node) {
70        let link_text = {
71            let document_content = self.context.document_content.borrow();
72            link_node
73                .utf8_text(document_content.as_bytes())
74                .unwrap_or_default()
75                .to_string()
76        };
77        // Use the same regex-based checker for robustness and consistency
78        self.check_text_for_link_patterns(&link_text, link_node);
79    }
80
81    fn is_empty_link_destination(&self, url: &str) -> bool {
82        let trimmed = url.trim();
83        trimmed.is_empty() || trimmed == "#"
84    }
85
86    fn create_empty_link_violation(&mut self, node: &Node) {
87        self.violations.push(RuleViolation::new(
88            &MD042,
89            MD042.description.to_string(),
90            self.context.file_path.clone(),
91            range_from_tree_sitter(&node.range()),
92        ));
93    }
94}
95
96pub const MD042: Rule = Rule {
97    id: "MD042",
98    alias: "no-empty-links",
99    tags: &["links"],
100    description: "No empty links",
101    rule_type: RuleType::Token,
102    required_nodes: &["link", "inline"], // We need link nodes and inline nodes that might contain links
103    new_linter: |context| Box::new(MD042Linter::new(context)),
104};
105
106#[cfg(test)]
107mod test {
108    use std::path::PathBuf;
109
110    use crate::config::RuleSeverity;
111    use crate::linter::MultiRuleLinter;
112    use crate::test_utils::test_helpers::test_config_with_rules;
113
114    fn test_config() -> crate::config::QuickmarkConfig {
115        test_config_with_rules(vec![
116            ("no-empty-links", RuleSeverity::Error),
117            ("heading-style", RuleSeverity::Off),
118            ("heading-increment", RuleSeverity::Off),
119            ("line-length", RuleSeverity::Off),
120        ])
121    }
122
123    #[test]
124    fn test_valid_link() {
125        let input = "[link text](https://example.com)";
126
127        let config = test_config();
128        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
129        let violations = linter.analyze();
130
131        assert_eq!(0, violations.len());
132    }
133
134    #[test]
135    fn test_empty_link_url() {
136        let input = "[empty link]()";
137
138        let config = test_config();
139        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
140        let violations = linter.analyze();
141
142        assert_eq!(1, violations.len());
143        let violation = &violations[0];
144        assert_eq!("MD042", violation.rule().id);
145        assert_eq!("No empty links", violation.message());
146    }
147
148    #[test]
149    fn test_fragment_only_link() {
150        let input = "[fragment only](#)";
151
152        let config = test_config();
153        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
154        let violations = linter.analyze();
155
156        assert_eq!(1, violations.len());
157        let violation = &violations[0];
158        assert_eq!("MD042", violation.rule().id);
159        assert_eq!("No empty links", violation.message());
160    }
161
162    #[test]
163    fn test_valid_fragment_link() {
164        let input = "[section link](#section)";
165
166        let config = test_config();
167        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
168        let violations = linter.analyze();
169
170        assert_eq!(0, violations.len());
171    }
172
173    #[test]
174    fn test_empty_reference_link() {
175        let input = "[link text][]";
176
177        let config = test_config();
178        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
179        let violations = linter.analyze();
180
181        // Reference links would need document-level analysis to verify if the reference exists
182        // For now, we don't flag collapsed reference links as empty
183        assert_eq!(0, violations.len());
184    }
185
186    #[test]
187    fn test_image_not_affected() {
188        let input = "![image alt]()";
189
190        let config = test_config();
191        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
192        let violations = linter.analyze();
193
194        // Images should not be affected by this rule
195        assert_eq!(0, violations.len());
196    }
197
198    #[test]
199    fn test_multiple_links_with_one_empty() {
200        let input = "[good link](https://example.com) and [empty link]() and [another good](https://other.com)";
201
202        let config = test_config();
203        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
204        let violations = linter.analyze();
205
206        // Should only detect the empty link
207        assert_eq!(1, violations.len());
208        let violation = &violations[0];
209        assert_eq!("MD042", violation.rule().id);
210    }
211
212    #[test]
213    fn test_mixed_empty_links() {
214        let input = "[empty1]() and [fragment](#) and [valid](https://example.com)";
215
216        let config = test_config();
217        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
218        let violations = linter.analyze();
219
220        // Should detect both empty links
221        assert_eq!(2, violations.len());
222        for violation in &violations {
223            assert_eq!("MD042", violation.rule().id);
224        }
225    }
226
227    #[test]
228    fn test_sequential_links_bug_prevention() {
229        // This test is based on issue #308 - ensure that after finding an empty link,
230        // subsequent valid links are not incorrectly flagged as empty
231        let input = "[link1](https://example.com)\n[link2]()\n[link3](https://example.com)\n[link4](https://example.com)";
232
233        let config = test_config();
234        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
235        let violations = linter.analyze();
236
237        // Should only detect link2 as empty, not link3 or link4
238        assert_eq!(1, violations.len());
239        let violation = &violations[0];
240        assert_eq!("MD042", violation.rule().id);
241    }
242
243    #[test]
244    fn test_footnote_style_empty_links() {
245        // Test case from issue #370 - footnote-style links with empty destinations
246        let input = "[^gh-md]: <> \"Like here on GitHub.\"";
247
248        let config = test_config();
249        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
250        let violations = linter.analyze();
251
252        // This is a complex case - for now we may or may not detect this
253        // The original issue suggests this might be valid footnote syntax
254        // Let's see what our current implementation does
255        println!("Footnote test violations: {}", violations.len());
256        for violation in &violations {
257            println!("  {}: {}", violation.rule().id, violation.message());
258        }
259    }
260
261    #[test]
262    fn test_empty_link_with_title() {
263        // Test links with empty URL but with title attribute
264        let input = "[link text]( \"title\")";
265
266        let config = test_config();
267        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
268        let violations = linter.analyze();
269
270        // According to original markdownlint behavior, title-only URLs are NOT considered empty
271        assert_eq!(0, violations.len());
272    }
273
274    #[test]
275    fn test_fragment_with_content() {
276        // Test that fragments with actual content are not flagged
277        let input = "[section](#introduction)";
278
279        let config = test_config();
280        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
281        let violations = linter.analyze();
282
283        // Should not be flagged as empty
284        assert_eq!(0, violations.len());
285    }
286
287    #[test]
288    fn test_whitespace_only_urls() {
289        // Test URLs that are only whitespace
290        let input = "[empty]( ) and [tabs](\t) and [newline](\n)";
291
292        let config = test_config();
293        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
294        let violations = linter.analyze();
295
296        // Should detect all three whitespace-only URLs as empty
297        assert_eq!(3, violations.len());
298        for violation in &violations {
299            assert_eq!("MD042", violation.rule().id);
300        }
301    }
302}