mdbook_lint_core/rules/standard/
md038.rs

1use crate::Document;
2use crate::error::Result;
3use crate::rule::{Rule, RuleCategory, RuleMetadata};
4use crate::violation::{Severity, Violation};
5
6/// MD038 - Spaces inside code span elements
7pub struct MD038;
8
9impl MD038 {
10    fn find_code_span_violations(&self, line: &str, line_number: usize) -> Vec<Violation> {
11        let mut violations = Vec::new();
12        let chars: Vec<char> = line.chars().collect();
13        let len = chars.len();
14
15        let mut i = 0;
16        while i < len {
17            if chars[i] == '`' {
18                // Count consecutive backticks
19                let mut backtick_count = 0;
20                let start = i;
21                while i < len && chars[i] == '`' {
22                    backtick_count += 1;
23                    i += 1;
24                }
25
26                // Find the closing backticks
27                if let Some(end_start) = self.find_closing_backticks(&chars, i, backtick_count) {
28                    let content_start = start + backtick_count;
29                    let content_end = end_start;
30
31                    if content_start < content_end {
32                        let content = &chars[content_start..content_end];
33
34                        // Check for violations
35                        if self.has_unnecessary_spaces(content) {
36                            violations.push(self.create_violation(
37                                "Spaces inside code span elements".to_string(),
38                                line_number,
39                                start + 1, // Convert to 1-based column
40                                Severity::Warning,
41                            ));
42                        }
43                    }
44
45                    i = end_start + backtick_count;
46                } else {
47                    // No matching closing backticks found, move on
48                    break;
49                }
50            } else {
51                i += 1;
52            }
53        }
54
55        violations
56    }
57
58    fn find_closing_backticks(&self, chars: &[char], start: usize, count: usize) -> Option<usize> {
59        let mut i = start;
60        while i + count <= chars.len() {
61            if chars[i] == '`' {
62                let mut consecutive = 0;
63                let mut j = i;
64                while j < chars.len() && chars[j] == '`' {
65                    consecutive += 1;
66                    j += 1;
67                }
68
69                if consecutive == count {
70                    return Some(i);
71                }
72
73                i = j;
74            } else {
75                i += 1;
76            }
77        }
78        None
79    }
80
81    fn has_unnecessary_spaces(&self, content: &[char]) -> bool {
82        if content.is_empty() {
83            return false;
84        }
85
86        // Check for spaces-only content (this is allowed)
87        if content.iter().all(|&c| c.is_whitespace()) {
88            return false;
89        }
90
91        // Check for special case: content that contains backticks
92        // In this case, single leading and trailing spaces are allowed and required
93        let content_str: String = content.iter().collect();
94        if content_str.contains('`') {
95            // For backtick-containing content, spaces are required and allowed
96            return false;
97        }
98
99        // Check for unnecessary leading space
100        let has_leading_space = content[0].is_whitespace();
101
102        // Check for unnecessary trailing space
103        let has_trailing_space = content[content.len() - 1].is_whitespace();
104
105        // If there are multiple leading or trailing spaces, that's definitely wrong
106        if content.len() >= 2 {
107            let has_multiple_leading = has_leading_space && content[1].is_whitespace();
108            let has_multiple_trailing =
109                has_trailing_space && content[content.len() - 2].is_whitespace();
110
111            if has_multiple_leading || has_multiple_trailing {
112                return true;
113            }
114        }
115
116        // For normal content, any leading or trailing space is unnecessary
117        has_leading_space || has_trailing_space
118    }
119
120    /// Get code block ranges to exclude from checking
121    fn get_code_block_ranges(&self, lines: &[&str]) -> Vec<bool> {
122        let mut in_code_block = vec![false; lines.len()];
123        let mut in_fenced_block = false;
124
125        for (i, line) in lines.iter().enumerate() {
126            let trimmed = line.trim();
127
128            // Check for fenced code blocks
129            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
130                in_fenced_block = !in_fenced_block;
131                in_code_block[i] = true;
132                continue;
133            }
134
135            if in_fenced_block {
136                in_code_block[i] = true;
137                continue;
138            }
139        }
140
141        in_code_block
142    }
143}
144
145impl Rule for MD038 {
146    fn id(&self) -> &'static str {
147        "MD038"
148    }
149
150    fn name(&self) -> &'static str {
151        "no-space-in-code"
152    }
153
154    fn description(&self) -> &'static str {
155        "Spaces inside code span elements"
156    }
157
158    fn metadata(&self) -> RuleMetadata {
159        RuleMetadata::stable(RuleCategory::Formatting)
160    }
161
162    fn check_with_ast<'a>(
163        &self,
164        document: &Document,
165        _ast: Option<&'a comrak::nodes::AstNode<'a>>,
166    ) -> Result<Vec<Violation>> {
167        let mut violations = Vec::new();
168        let lines: Vec<&str> = document.content.lines().collect();
169        let in_code_block = self.get_code_block_ranges(&lines);
170
171        for (line_number, line) in lines.iter().enumerate() {
172            let line_number = line_number + 1;
173
174            // Skip lines inside code blocks
175            if in_code_block[line_number - 1] {
176                continue;
177            }
178
179            violations.extend(self.find_code_span_violations(line, line_number));
180        }
181
182        Ok(violations)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::Document;
190    use std::path::PathBuf;
191
192    #[test]
193    fn test_md038_no_violations() {
194        let content = r#"Here is some `code` text.
195
196More text with `another code span` here.
197
198Complex code: `some.method()` works.
199
200Multiple backticks: ``code with `backticks` inside``.
201"#;
202
203        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
204        let rule = MD038;
205        let violations = rule.check(&document).unwrap();
206        assert_eq!(violations.len(), 0);
207    }
208
209    #[test]
210    fn test_md038_leading_space() {
211        let content = r#"Here is some ` code` with leading space.
212
213Another example: ` another` here.
214"#;
215
216        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
217        let rule = MD038;
218        let violations = rule.check(&document).unwrap();
219        assert_eq!(violations.len(), 2);
220        assert_eq!(violations[0].line, 1);
221        assert_eq!(violations[1].line, 3);
222    }
223
224    #[test]
225    fn test_md038_trailing_space() {
226        let content = r#"Here is some `code ` with trailing space.
227
228Another example: `another ` here.
229"#;
230
231        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
232        let rule = MD038;
233        let violations = rule.check(&document).unwrap();
234        assert_eq!(violations.len(), 2);
235        assert_eq!(violations[0].line, 1);
236        assert_eq!(violations[1].line, 3);
237    }
238
239    #[test]
240    fn test_md038_both_spaces() {
241        let content = r#"Here is some ` code ` with both spaces.
242
243Multiple spaces: `   code   ` is also wrong.
244"#;
245
246        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
247        let rule = MD038;
248        let violations = rule.check(&document).unwrap();
249        assert_eq!(violations.len(), 2);
250        assert_eq!(violations[0].line, 1);
251        assert_eq!(violations[1].line, 3);
252    }
253
254    #[test]
255    fn test_md038_backtick_escaping_allowed() {
256        let content = r#"To show a backtick: `` ` ``.
257
258To show backticks: `` `backticks` ``.
259
260Another way: `` backtick` ``.
261"#;
262
263        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
264        let rule = MD038;
265        let violations = rule.check(&document).unwrap();
266        assert_eq!(violations.len(), 0); // These should be allowed
267    }
268
269    #[test]
270    fn test_md038_spaces_only_allowed() {
271        let content = r#"Single space: ` `.
272
273Multiple spaces: `   `.
274
275Tab character: `	`.
276"#;
277
278        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
279        let rule = MD038;
280        let violations = rule.check(&document).unwrap();
281        assert_eq!(violations.len(), 0); // Spaces-only content is allowed
282    }
283
284    #[test]
285    fn test_md038_multiple_code_spans() {
286        let content = r#"Good: `code1` and `code2` and `code3`.
287
288Bad: ` code1` and `code2 ` and ` code3 `.
289
290Mixed: `good` and ` bad` and `also good`.
291"#;
292
293        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
294        let rule = MD038;
295        let violations = rule.check(&document).unwrap();
296        assert_eq!(violations.len(), 4);
297        assert_eq!(violations[0].line, 3); // ` code1`
298        assert_eq!(violations[1].line, 3); // `code2 `
299        assert_eq!(violations[2].line, 3); // ` code3 `
300        assert_eq!(violations[3].line, 5); // ` bad`
301    }
302
303    #[test]
304    fn test_md038_triple_backticks_ignored() {
305        let content = r#"```
306This is a code block, not a code span.
307` spaces here` should not be flagged.
308```
309
310But this `code span ` should be flagged.
311"#;
312
313        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
314        let rule = MD038;
315        let violations = rule.check(&document).unwrap();
316        assert_eq!(violations.len(), 1);
317        assert_eq!(violations[0].line, 6);
318    }
319
320    #[test]
321    fn test_md038_unmatched_backticks() {
322        let content = r#"This line has ` unmatched backtick.
323
324This line has normal `code` and then ` another unmatched.
325
326Normal content here.
327"#;
328
329        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
330        let rule = MD038;
331        let violations = rule.check(&document).unwrap();
332        assert_eq!(violations.len(), 0); // The `code` span has no spaces, so no violations
333    }
334
335    #[test]
336    fn test_md038_empty_code_spans() {
337        let content = r#"Empty code span: ``.
338
339Another empty: ``.
340
341With spaces only: ` `.
342"#;
343
344        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
345        let rule = MD038;
346        let violations = rule.check(&document).unwrap();
347        assert_eq!(violations.len(), 0); // Empty spans are not violations
348    }
349}