mdbook_lint_core/rules/standard/
md039.rs1use crate::error::Result;
6use crate::rule::{Rule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11
12pub struct MD039;
14
15impl MD039 {
16 fn check_line_links(&self, line: &str, line_number: usize) -> Vec<Violation> {
18 let mut violations = Vec::new();
19 let chars: Vec<char> = line.chars().collect();
20 let mut i = 0;
21
22 while i < chars.len() {
23 if chars[i] == '[' {
24 if i > 0 && chars[i - 1] == '!' {
26 i += 1;
27 continue;
28 }
29
30 if let Some(end_bracket) = self.find_closing_bracket(&chars, i + 1) {
32 let link_text = &chars[i + 1..end_bracket];
33
34 let is_link = if end_bracket + 1 < chars.len() {
36 chars[end_bracket + 1] == '(' || chars[end_bracket + 1] == '['
37 } else {
38 false
39 };
40
41 if is_link && self.has_unnecessary_spaces(link_text) {
42 violations.push(self.create_violation(
43 "Spaces inside link text".to_string(),
44 line_number,
45 i + 1, Severity::Warning,
47 ));
48 }
49
50 i = end_bracket + 1;
51 } else {
52 i += 1;
53 }
54 } else {
55 i += 1;
56 }
57 }
58
59 violations
60 }
61
62 fn find_closing_bracket(&self, chars: &[char], start: usize) -> Option<usize> {
64 let mut bracket_count = 1;
65 let mut i = start;
66
67 while i < chars.len() && bracket_count > 0 {
68 match chars[i] {
69 '[' => bracket_count += 1,
70 ']' => bracket_count -= 1,
71 '\\' => {
72 i += 1;
74 }
75 _ => {}
76 }
77
78 if bracket_count == 0 {
79 return Some(i);
80 }
81
82 i += 1;
83 }
84
85 None
86 }
87
88 fn has_unnecessary_spaces(&self, link_text: &[char]) -> bool {
90 if link_text.is_empty() {
91 return false;
92 }
93
94 let has_leading_space = link_text[0].is_whitespace();
96
97 let has_trailing_space = link_text[link_text.len() - 1].is_whitespace();
99
100 has_leading_space || has_trailing_space
101 }
102
103 fn get_code_block_ranges(&self, lines: &[&str]) -> Vec<bool> {
105 let mut in_code_block = vec![false; lines.len()];
106 let mut in_fenced_block = false;
107
108 for (i, line) in lines.iter().enumerate() {
109 let trimmed = line.trim();
110
111 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
113 in_fenced_block = !in_fenced_block;
114 in_code_block[i] = true;
115 continue;
116 }
117
118 if in_fenced_block {
119 in_code_block[i] = true;
120 continue;
121 }
122 }
123
124 in_code_block
125 }
126}
127
128impl Rule for MD039 {
129 fn id(&self) -> &'static str {
130 "MD039"
131 }
132
133 fn name(&self) -> &'static str {
134 "no-space-in-links"
135 }
136
137 fn description(&self) -> &'static str {
138 "Spaces inside link text"
139 }
140
141 fn metadata(&self) -> RuleMetadata {
142 RuleMetadata::stable(RuleCategory::Content).introduced_in("mdbook-lint v0.1.0")
143 }
144
145 fn check_with_ast<'a>(
146 &self,
147 document: &Document,
148 _ast: Option<&'a comrak::nodes::AstNode<'a>>,
149 ) -> Result<Vec<Violation>> {
150 let mut violations = Vec::new();
151 let lines: Vec<&str> = document.content.lines().collect();
152 let in_code_block = self.get_code_block_ranges(&lines);
153
154 for (line_number, line) in lines.iter().enumerate() {
155 let line_number = line_number + 1;
156
157 if in_code_block[line_number - 1] {
159 continue;
160 }
161
162 violations.extend(self.check_line_links(line, line_number));
163 }
164
165 Ok(violations)
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::rule::Rule;
173 use std::path::PathBuf;
174
175 fn create_test_document(content: &str) -> Document {
176 Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
177 }
178
179 #[test]
180 fn test_md039_normal_links_valid() {
181 let content = r#"Here is a [normal link](http://example.com).
182
183Another [link with text](http://example.com) works fine.
184
185Reference link [with text][ref] is also okay.
186
187[ref]: http://example.com
188"#;
189
190 let document = create_test_document(content);
191 let rule = MD039;
192 let violations = rule.check(&document).unwrap();
193 assert_eq!(violations.len(), 0);
194 }
195
196 #[test]
197 fn test_md039_leading_space_violation() {
198 let content = r#"Here is a [ leading space](http://example.com) link.
199
200Another [ spaced link](http://example.com) here.
201"#;
202
203 let document = create_test_document(content);
204 let rule = MD039;
205 let violations = rule.check(&document).unwrap();
206 assert_eq!(violations.len(), 2);
207 assert_eq!(violations[0].rule_id, "MD039");
208 assert_eq!(violations[0].line, 1);
209 assert_eq!(violations[1].line, 3);
210 }
211
212 #[test]
213 fn test_md039_trailing_space_violation() {
214 let content = r#"Here is a [trailing space ](http://example.com) link.
215
216Another [spaced link ](http://example.com) here.
217"#;
218
219 let document = create_test_document(content);
220 let rule = MD039;
221 let violations = rule.check(&document).unwrap();
222 assert_eq!(violations.len(), 2);
223 assert_eq!(violations[0].line, 1);
224 assert_eq!(violations[1].line, 3);
225 }
226
227 #[test]
228 fn test_md039_both_spaces_violation() {
229 let content = r#"Here is a [ both spaces ](http://example.com) link.
230
231Multiple [ spaced ](http://example.com) spaces.
232"#;
233
234 let document = create_test_document(content);
235 let rule = MD039;
236 let violations = rule.check(&document).unwrap();
237 assert_eq!(violations.len(), 2);
238 assert_eq!(violations[0].line, 1);
239 assert_eq!(violations[1].line, 3);
240 }
241
242 #[test]
243 fn test_md039_reference_links() {
244 let content = r#"Good [reference link][good] here.
245
246Bad [ spaced reference][bad] link.
247
248Another [reference with space ][also-bad] here.
249
250[good]: http://example.com
251[bad]: http://example.com
252[also-bad]: http://example.com
253"#;
254
255 let document = create_test_document(content);
256 let rule = MD039;
257 let violations = rule.check(&document).unwrap();
258 assert_eq!(violations.len(), 2);
259 assert_eq!(violations[0].line, 3);
260 assert_eq!(violations[1].line, 5);
261 }
262
263 #[test]
264 fn test_md039_nested_brackets() {
265 let content = r#"This has [link with [nested] brackets](http://example.com).
266
267This has [ link with [nested] and space](http://example.com).
268"#;
269
270 let document = create_test_document(content);
271 let rule = MD039;
272 let violations = rule.check(&document).unwrap();
273 assert_eq!(violations.len(), 1);
274 assert_eq!(violations[0].line, 3);
275 }
276
277 #[test]
278 fn test_md039_not_links() {
279 let content = r#"This has [brackets] but no link.
280
281This has [ spaced brackets] but no link.
282
283This has [reference] but no definition.
284"#;
285
286 let document = create_test_document(content);
287 let rule = MD039;
288 let violations = rule.check(&document).unwrap();
289 assert_eq!(violations.len(), 0); }
291
292 #[test]
293 fn test_md039_images_ignored() {
294 let content = r#"This has  which is an image.
295
296And  text.
297"#;
298
299 let document = create_test_document(content);
300 let rule = MD039;
301 let violations = rule.check(&document).unwrap();
302 assert_eq!(violations.len(), 0); }
304
305 #[test]
306 fn test_md039_code_blocks_ignored() {
307 let content = r#"This has [normal link](http://example.com).
308
309```
310This has [ spaced link](http://example.com) in code.
311```
312
313This has [ spaced link](http://example.com) that should be flagged.
314"#;
315
316 let document = create_test_document(content);
317 let rule = MD039;
318 let violations = rule.check(&document).unwrap();
319 assert_eq!(violations.len(), 1);
320 assert_eq!(violations[0].line, 7);
321 }
322
323 #[test]
324 fn test_md039_escaped_brackets() {
325 let content = r#"This has [link with \] escaped bracket](http://example.com).
326
327This has [ link with \] and space](http://example.com).
328"#;
329
330 let document = create_test_document(content);
331 let rule = MD039;
332 let violations = rule.check(&document).unwrap();
333 assert_eq!(violations.len(), 1);
334 assert_eq!(violations[0].line, 3);
335 }
336
337 #[test]
338 fn test_md039_autolinks() {
339 let content = r#"Autolinks like <http://example.com> are not checked.
340
341Email autolinks <user@example.com> are also not checked.
342
343Regular [normal link](http://example.com) is fine.
344
345Bad [ spaced link](http://example.com) is flagged.
346"#;
347
348 let document = create_test_document(content);
349 let rule = MD039;
350 let violations = rule.check(&document).unwrap();
351 assert_eq!(violations.len(), 1);
352 assert_eq!(violations[0].line, 7);
353 }
354
355 #[test]
356 fn test_md039_empty_link_text() {
357 let content = r#"Empty link [](http://example.com) is not flagged for spaces.
358
359Link with just space [ ](http://example.com) is flagged.
360"#;
361
362 let document = create_test_document(content);
363 let rule = MD039;
364 let violations = rule.check(&document).unwrap();
365 assert_eq!(violations.len(), 1);
366 assert_eq!(violations[0].line, 3);
367 }
368
369 #[test]
370 fn test_md039_multiple_links_per_line() {
371 let content = r#"Multiple [good link](http://example.com) and [ bad link](http://example.com) on same line.
372
373More [good](http://example.com) and [also good](http://example.com) links.
374"#;
375
376 let document = create_test_document(content);
377 let rule = MD039;
378 let violations = rule.check(&document).unwrap();
379 assert_eq!(violations.len(), 1);
380 assert_eq!(violations[0].line, 1);
381 }
382}