mdbook_lint_core/rules/standard/
md042.rs

1//! MD042: No empty links
2//!
3//! This rule checks for links that have no text content.
4
5use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13/// Rule to check for empty links
14pub struct MD042;
15
16impl MD042 {
17    /// Check if a link node is empty (has no text content)
18    fn is_empty_link<'a>(&self, node: &'a AstNode<'a>) -> bool {
19        // Get all text content from the link's children
20        let text_content = Self::extract_text_content(node);
21        text_content.trim().is_empty()
22    }
23
24    /// Extract all text content from a node and its children
25    fn extract_text_content<'a>(node: &'a AstNode<'a>) -> String {
26        let mut content = String::new();
27
28        match &node.data.borrow().value {
29            NodeValue::Text(text) => {
30                content.push_str(text);
31            }
32            NodeValue::Code(code) => {
33                content.push_str(&code.literal);
34            }
35            _ => {}
36        }
37
38        // Recursively extract text from children
39        for child in node.children() {
40            content.push_str(&Self::extract_text_content(child));
41        }
42
43        content
44    }
45
46    /// Get line and column position for a node
47    fn get_position<'a>(&self, node: &'a AstNode<'a>) -> (usize, usize) {
48        let data = node.data.borrow();
49        let pos = data.sourcepos;
50        (pos.start.line, pos.start.column)
51    }
52
53    /// Walk AST and find all link violations
54    fn check_node<'a>(&self, node: &'a AstNode<'a>, violations: &mut Vec<Violation>) {
55        match &node.data.borrow().value {
56            NodeValue::Link(_) => {
57                if self.is_empty_link(node) {
58                    let (line, column) = self.get_position(node);
59                    violations.push(self.create_violation(
60                        "Found empty link".to_string(),
61                        line,
62                        column,
63                        Severity::Warning,
64                    ));
65                }
66            }
67            NodeValue::Image(_) => {
68                // Also check images for empty alt text
69                if self.is_empty_link(node) {
70                    let (line, column) = self.get_position(node);
71                    violations.push(self.create_violation(
72                        "Found image with empty alt text".to_string(),
73                        line,
74                        column,
75                        Severity::Warning,
76                    ));
77                }
78            }
79            _ => {}
80        }
81
82        // Recursively check children
83        for child in node.children() {
84            self.check_node(child, violations);
85        }
86    }
87}
88
89impl AstRule for MD042 {
90    fn id(&self) -> &'static str {
91        "MD042"
92    }
93
94    fn name(&self) -> &'static str {
95        "no-empty-links"
96    }
97
98    fn description(&self) -> &'static str {
99        "No empty links"
100    }
101
102    fn metadata(&self) -> RuleMetadata {
103        RuleMetadata::stable(RuleCategory::Content).introduced_in("mdbook-lint v0.1.0")
104    }
105
106    fn check_ast<'a>(&self, _document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
107        let mut violations = Vec::new();
108        self.check_node(ast, &mut violations);
109        Ok(violations)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::rule::Rule;
117    use std::path::PathBuf;
118
119    fn create_test_document(content: &str) -> Document {
120        Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
121    }
122
123    #[test]
124    fn test_md042_normal_links_valid() {
125        let content = r#"Here is a [normal link](http://example.com).
126
127Another [link with text](http://example.com) works fine.
128
129Reference link [with text][ref] is also okay.
130
131[ref]: http://example.com
132"#;
133
134        let document = create_test_document(content);
135        let rule = MD042;
136        let violations = rule.check(&document).unwrap();
137        assert_eq!(violations.len(), 0);
138    }
139
140    #[test]
141    fn test_md042_empty_inline_link() {
142        let content = r#"Here is an [](http://example.com) empty link.
143
144This is normal text with a problem [](http://bad.com) link.
145"#;
146
147        let document = create_test_document(content);
148        let rule = MD042;
149        let violations = rule.check(&document).unwrap();
150        assert_eq!(violations.len(), 2);
151        assert_eq!(violations[0].rule_id, "MD042");
152        assert!(violations[0].message.contains("Found empty link"));
153        assert_eq!(violations[0].line, 1);
154        assert_eq!(violations[1].line, 3);
155    }
156
157    #[test]
158    fn test_md042_empty_reference_link() {
159        let content = r#"Here is an [][ref] empty reference link.
160
161[ref]: http://example.com
162"#;
163
164        let document = create_test_document(content);
165        let rule = MD042;
166        let violations = rule.check(&document).unwrap();
167        assert_eq!(violations.len(), 1);
168        assert_eq!(violations[0].line, 1);
169    }
170
171    #[test]
172    fn test_md042_whitespace_only_link() {
173        let content = r#"Here is a [   ](http://example.com) whitespace-only link.
174
175Another [	](http://example.com) tab-only link.
176"#;
177
178        let document = create_test_document(content);
179        let rule = MD042;
180        let violations = rule.check(&document).unwrap();
181        assert_eq!(violations.len(), 2);
182        assert_eq!(violations[0].line, 1);
183        assert_eq!(violations[1].line, 3);
184    }
185
186    #[test]
187    fn test_md042_link_with_code_valid() {
188        let content = r#"Here is a [`code`](http://example.com) link with code.
189
190Another [normal text](http://example.com) link.
191"#;
192
193        let document = create_test_document(content);
194        let rule = MD042;
195        let violations = rule.check(&document).unwrap();
196        assert_eq!(violations.len(), 0);
197    }
198
199    #[test]
200    fn test_md042_link_with_emphasis_valid() {
201        let content = r#"Here is a [*emphasized*](http://example.com) link.
202
203Another [**strong**](http://example.com) link.
204
205And [_underlined_](http://example.com) text.
206"#;
207
208        let document = create_test_document(content);
209        let rule = MD042;
210        let violations = rule.check(&document).unwrap();
211        assert_eq!(violations.len(), 0);
212    }
213
214    #[test]
215    fn test_md042_empty_image_alt_text() {
216        let content = r#"Here is an ![](image.png) image with no alt text.
217
218This ![good alt text](image.png) is fine.
219
220But this ![](bad.png) is not.
221"#;
222
223        let document = create_test_document(content);
224        let rule = MD042;
225        let violations = rule.check(&document).unwrap();
226        assert_eq!(violations.len(), 2);
227        assert!(violations[0].message.contains("empty alt text"));
228        assert_eq!(violations[0].line, 1);
229        assert_eq!(violations[1].line, 5);
230    }
231
232    #[test]
233    fn test_md042_mixed_valid_and_invalid() {
234        let content = r#"Good [link](http://example.com) here.
235
236Bad [](http://example.com) link here.
237
238Another good [link text](http://example.com).
239
240Another bad [](http://bad.com) link.
241
242![good alt](image.png) image.
243
244![](bad-image.png) bad image.
245"#;
246
247        let document = create_test_document(content);
248        let rule = MD042;
249        let violations = rule.check(&document).unwrap();
250        assert_eq!(violations.len(), 3); // 2 empty links + 1 empty alt text
251
252        // Check that we get both link violations and image violation
253        let link_violations: Vec<_> = violations
254            .iter()
255            .filter(|v| v.message.contains("Found empty link"))
256            .collect();
257        let image_violations: Vec<_> = violations
258            .iter()
259            .filter(|v| v.message.contains("empty alt text"))
260            .collect();
261
262        assert_eq!(link_violations.len(), 2);
263        assert_eq!(image_violations.len(), 1);
264    }
265
266    #[test]
267    fn test_md042_autolinks_valid() {
268        let content = r#"Autolinks like <http://example.com> are fine.
269
270Email autolinks <user@example.com> are also okay.
271
272Regular [text links](http://example.com) work too.
273"#;
274
275        let document = create_test_document(content);
276        let rule = MD042;
277        let violations = rule.check(&document).unwrap();
278        assert_eq!(violations.len(), 0);
279    }
280
281    #[test]
282    fn test_md042_nested_formatting_valid() {
283        let content = r#"Complex [**bold _and italic_**](http://example.com) link.
284
285With [`code` and *emphasis*](http://example.com) mixed.
286"#;
287
288        let document = create_test_document(content);
289        let rule = MD042;
290        let violations = rule.check(&document).unwrap();
291        assert_eq!(violations.len(), 0);
292    }
293
294    #[test]
295    fn test_md042_reference_style_links() {
296        let content = r#"Good [reference link][good] here.
297
298Bad [][bad] reference link.
299
300[good]: http://example.com
301[bad]: http://example.com
302"#;
303
304        let document = create_test_document(content);
305        let rule = MD042;
306        let violations = rule.check(&document).unwrap();
307        assert_eq!(violations.len(), 1);
308        assert_eq!(violations[0].line, 3);
309    }
310}