mdbook_lint_core/rules/standard/
md011.rs

1//! MD011: Reversed link syntax
2//!
3//! This rule checks for reversed link syntax: (text)\[url\] instead of \[text\](url).
4
5use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11use comrak::nodes::AstNode;
12
13/// Rule to check for reversed link syntax
14pub struct MD011;
15
16impl AstRule for MD011 {
17    fn id(&self) -> &'static str {
18        "MD011"
19    }
20
21    fn name(&self) -> &'static str {
22        "no-reversed-links"
23    }
24
25    fn description(&self) -> &'static str {
26        "Reversed link syntax"
27    }
28
29    fn metadata(&self) -> RuleMetadata {
30        RuleMetadata::stable(RuleCategory::Content).introduced_in("mdbook-lint v0.1.0")
31    }
32
33    fn check_ast<'a>(&self, document: &Document, _ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
34        let mut violations = Vec::new();
35        let mut in_code_block = false;
36
37        for (line_number, line) in document.lines.iter().enumerate() {
38            // Track code block state
39            if line.trim_start().starts_with("```") {
40                in_code_block = !in_code_block;
41                continue;
42            }
43
44            // Skip lines inside code blocks
45            if in_code_block {
46                continue;
47            }
48
49            // Parse the line character by character looking for (text)[url] pattern
50            // but skip content inside inline code spans
51            let chars: Vec<char> = line.chars().collect();
52            let mut i = 0;
53
54            while i < chars.len() {
55                // Skip inline code spans
56                if chars[i] == '`' {
57                    i += 1;
58                    // Find the closing backtick
59                    while i < chars.len() && chars[i] != '`' {
60                        i += 1;
61                    }
62                    if i < chars.len() {
63                        i += 1; // Skip closing backtick
64                    }
65                    continue;
66                }
67
68                if chars[i] == '(' {
69                    // Found opening parenthesis, look for the pattern (text)[url]
70                    if let Some((text, url, start_pos, end_pos)) =
71                        self.parse_reversed_link(&chars, i)
72                    {
73                        violations.push(self.create_violation(
74                            format!(
75                                "Reversed link syntax: ({text})[{url}]. Should be: [{text}]({url})"
76                            ),
77                            line_number + 1, // 1-based line numbers
78                            start_pos + 1,   // 1-based column
79                            Severity::Error,
80                        ));
81                        i = end_pos;
82                    } else {
83                        i += 1;
84                    }
85                } else {
86                    i += 1;
87                }
88            }
89        }
90
91        Ok(violations)
92    }
93}
94
95impl MD011 {
96    /// Parse a potential reversed link starting at position `start`
97    /// Returns (text, url, start_pos, end_pos) if a reversed link is found
98    fn parse_reversed_link(
99        &self,
100        chars: &[char],
101        start: usize,
102    ) -> Option<(String, String, usize, usize)> {
103        if start >= chars.len() || chars[start] != '(' {
104            return None;
105        }
106
107        let mut i = start + 1;
108        let mut text = String::new();
109
110        // Parse text inside parentheses
111        while i < chars.len() && chars[i] != ')' {
112            text.push(chars[i]);
113            i += 1;
114        }
115
116        // Must find closing parenthesis
117        if i >= chars.len() || chars[i] != ')' {
118            return None;
119        }
120        i += 1; // Skip ')'
121
122        // Must find opening bracket
123        if i >= chars.len() || chars[i] != '[' {
124            return None;
125        }
126        i += 1; // Skip '['
127
128        let mut url = String::new();
129
130        // Parse URL inside brackets
131        while i < chars.len() && chars[i] != ']' {
132            url.push(chars[i]);
133            i += 1;
134        }
135
136        // Must find closing bracket
137        if i >= chars.len() || chars[i] != ']' {
138            return None;
139        }
140
141        Some((text, url, start, i))
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::Document;
149    use crate::rule::Rule;
150    use std::path::PathBuf;
151
152    #[test]
153    fn test_md011_no_violations() {
154        let content = r#"# Valid Links
155
156Here's a [valid link](https://example.com) that works correctly.
157
158Another [good link](./relative/path.md) here.
159
160[Email link](mailto:test@example.com) is also fine.
161"#;
162        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
163        let rule = MD011;
164        let violations = rule.check(&document).unwrap();
165
166        assert_eq!(violations.len(), 0);
167    }
168
169    #[test]
170    fn test_md011_reversed_link_violation() {
171        let content = r#"# Document with Reversed Link
172
173This has (reversed link)[https://example.com] syntax.
174
175Some content here.
176"#;
177        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
178        let rule = MD011;
179        let violations = rule.check(&document).unwrap();
180
181        assert_eq!(violations.len(), 1);
182        assert!(violations[0].message.contains("Reversed link syntax"));
183        assert!(
184            violations[0]
185                .message
186                .contains("(reversed link)[https://example.com]")
187        );
188        assert!(
189            violations[0]
190                .message
191                .contains("Should be: [reversed link](https://example.com)")
192        );
193        assert_eq!(violations[0].line, 3);
194    }
195
196    #[test]
197    fn test_md011_multiple_reversed_links() {
198        let content = r#"# Multiple Issues
199
200First (bad link)[url1] here.
201
202Second (another bad)[url2] there.
203
204And a (third one)[url3] at the end.
205"#;
206        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
207        let rule = MD011;
208        let violations = rule.check(&document).unwrap();
209
210        assert_eq!(violations.len(), 3);
211
212        assert_eq!(violations[0].line, 3);
213        assert!(violations[0].message.contains("bad link"));
214        assert!(violations[0].message.contains("url1"));
215
216        assert_eq!(violations[1].line, 5);
217        assert!(violations[1].message.contains("another bad"));
218        assert!(violations[1].message.contains("url2"));
219
220        assert_eq!(violations[2].line, 7);
221        assert!(violations[2].message.contains("third one"));
222        assert!(violations[2].message.contains("url3"));
223    }
224
225    #[test]
226    fn test_md011_mixed_valid_and_invalid() {
227        let content = r#"# Mixed Links
228
229This [valid link](https://good.com) is fine.
230
231But this (bad link)[https://bad.com] is not.
232
233Another [good one](./path.md) here.
234
235And another (problem)[./bad-path.md] there.
236"#;
237        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
238        let rule = MD011;
239        let violations = rule.check(&document).unwrap();
240
241        assert_eq!(violations.len(), 2);
242        assert_eq!(violations[0].line, 5);
243        assert_eq!(violations[1].line, 9);
244    }
245
246    #[test]
247    fn test_md011_code_blocks_ignored() {
248        let content = r#"# Code Examples
249
250This (bad link)[url] should be detected.
251
252```
253This (code example)[url] should be ignored.
254```
255
256`This (inline code)[url] should be ignored.`
257
258Another (bad link)[url2] should be detected.
259"#;
260        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
261        let rule = MD011;
262        let violations = rule.check(&document).unwrap();
263
264        assert_eq!(violations.len(), 2);
265        assert_eq!(violations[0].line, 3);
266        assert_eq!(violations[1].line, 11);
267    }
268
269    #[test]
270    fn test_md011_empty_text_and_url() {
271        let content = r#"# Edge Cases
272
273This ()[empty text] has empty parts.
274
275This ()[url] has empty text.
276
277This (text)[] has empty URL.
278"#;
279        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
280        let rule = MD011;
281        let violations = rule.check(&document).unwrap();
282
283        assert_eq!(violations.len(), 3);
284        assert!(violations[0].message.contains("Should be: [](empty text)"));
285        assert!(violations[1].message.contains("Should be: [](url)"));
286        assert!(violations[2].message.contains("Should be: [text]()"));
287    }
288
289    #[test]
290    fn test_md011_complex_urls() {
291        let content = r#"# Complex URLs
292
293This (complex link)[https://example.com/path?param=value&other=test#anchor] is wrong.
294
295This (relative link)[../parent/file.md#section] is also wrong.
296"#;
297        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
298        let rule = MD011;
299        let violations = rule.check(&document).unwrap();
300
301        assert_eq!(violations.len(), 2);
302        assert!(violations[0].message.contains("complex link"));
303        assert!(
304            violations[0]
305                .message
306                .contains("https://example.com/path?param=value&other=test#anchor")
307        );
308        assert!(violations[1].message.contains("relative link"));
309        assert!(violations[1].message.contains("../parent/file.md#section"));
310    }
311}