mdbook_lint_core/rules/standard/
md039.rs

1//! MD039: Spaces inside link text
2//!
3//! This rule checks for unnecessary spaces at the beginning or end of link text.
4
5use crate::error::Result;
6use crate::rule::{Rule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11
12/// Rule to check for spaces inside link text
13pub struct MD039;
14
15impl MD039 {
16    /// Find link violations in a line
17    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                // Skip if this is an image (preceded by !)
25                if i > 0 && chars[i - 1] == '!' {
26                    i += 1;
27                    continue;
28                }
29
30                // Look for closing bracket
31                if let Some(end_bracket) = self.find_closing_bracket(&chars, i + 1) {
32                    let link_text = &chars[i + 1..end_bracket];
33
34                    // Check if this is followed by a link URL or reference
35                    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, // Convert to 1-based column
46                            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    /// Find the closing bracket for a link
63    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                    // Skip escaped character
73                    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    /// Check if link text has unnecessary leading or trailing spaces
89    fn has_unnecessary_spaces(&self, link_text: &[char]) -> bool {
90        if link_text.is_empty() {
91            return false;
92        }
93
94        // Check for leading space
95        let has_leading_space = link_text[0].is_whitespace();
96
97        // Check for trailing space
98        let has_trailing_space = link_text[link_text.len() - 1].is_whitespace();
99
100        has_leading_space || has_trailing_space
101    }
102
103    /// Get code block ranges to exclude from checking
104    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            // Check for fenced code blocks
112            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            // Skip lines inside code blocks
158            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); // Not links, so no violations
290    }
291
292    #[test]
293    fn test_md039_images_ignored() {
294        let content = r#"This has ![ spaced alt text](image.png) which is an image.
295
296And ![normal alt](image.png) 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); // Images are not checked by this rule
303    }
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}