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
12static RE_INLINE_LINK: Lazy<Regex> =
14 Lazy::new(|| Regex::new(r"(?:^|[^!])\[([^\]]*)\]\(([^)]*)\)").unwrap());
15
16pub(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 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 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 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"], 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 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 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 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 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 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 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 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 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 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 assert_eq!(0, violations.len());
272 }
273
274 #[test]
275 fn test_fragment_with_content() {
276 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 assert_eq!(0, violations.len());
285 }
286
287 #[test]
288 fn test_whitespace_only_urls() {
289 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 assert_eq!(3, violations.len());
298 for violation in &violations {
299 assert_eq!("MD042", violation.rule().id);
300 }
301 }
302}