mdbook_lint_core/rules/standard/
md011.rs1use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11use comrak::nodes::AstNode;
12
13pub 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 if line.trim_start().starts_with("```") {
40 in_code_block = !in_code_block;
41 continue;
42 }
43
44 if in_code_block {
46 continue;
47 }
48
49 let chars: Vec<char> = line.chars().collect();
52 let mut i = 0;
53
54 while i < chars.len() {
55 if chars[i] == '`' {
57 i += 1;
58 while i < chars.len() && chars[i] != '`' {
60 i += 1;
61 }
62 if i < chars.len() {
63 i += 1; }
65 continue;
66 }
67
68 if chars[i] == '(' {
69 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, start_pos + 1, 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 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 while i < chars.len() && chars[i] != ')' {
112 text.push(chars[i]);
113 i += 1;
114 }
115
116 if i >= chars.len() || chars[i] != ')' {
118 return None;
119 }
120 i += 1; if i >= chars.len() || chars[i] != '[' {
124 return None;
125 }
126 i += 1; let mut url = String::new();
129
130 while i < chars.len() && chars[i] != ']' {
132 url.push(chars[i]);
133 i += 1;
134 }
135
136 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}