mdbook_lint_core/rules/standard/
md038.rs1use crate::Document;
2use crate::error::Result;
3use crate::rule::{Rule, RuleCategory, RuleMetadata};
4use crate::violation::{Severity, Violation};
5
6pub 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 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 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 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, Severity::Warning,
41 ));
42 }
43 }
44
45 i = end_start + backtick_count;
46 } else {
47 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 if content.iter().all(|&c| c.is_whitespace()) {
88 return false;
89 }
90
91 let content_str: String = content.iter().collect();
94 if content_str.contains('`') {
95 return false;
97 }
98
99 let has_leading_space = content[0].is_whitespace();
101
102 let has_trailing_space = content[content.len() - 1].is_whitespace();
104
105 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 has_leading_space || has_trailing_space
118 }
119
120 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 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 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); }
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); }
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); assert_eq!(violations[1].line, 3); assert_eq!(violations[2].line, 3); assert_eq!(violations[3].line, 5); }
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); }
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); }
349}