1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3#[derive(Debug, Clone)]
28pub struct MD038NoSpaceInCode {
29 pub enabled: bool,
30 pub allow_intentional_spaces: bool,
32 pub allow_single_char_spaces: bool,
34 pub allow_command_spaces: bool,
36}
37
38impl Default for MD038NoSpaceInCode {
39 fn default() -> Self {
40 Self::new()
41 }
42}
43
44impl MD038NoSpaceInCode {
45 pub fn new() -> Self {
46 Self {
47 enabled: true,
48 allow_intentional_spaces: true, allow_single_char_spaces: true,
50 allow_command_spaces: true,
51 }
52 }
53
54 pub fn strict() -> Self {
55 Self {
56 enabled: true,
57 allow_intentional_spaces: false,
58 allow_single_char_spaces: false,
59 allow_command_spaces: false,
60 }
61 }
62
63 fn should_allow_spaces(&self, code_content: &str, trimmed: &str) -> bool {
65 if self.allow_intentional_spaces {
67 if self.allow_single_char_spaces && trimmed.len() == 1 {
69 return true;
70 }
71
72 if self.allow_command_spaces && self.looks_like_command(trimmed) {
74 return true;
75 }
76
77 if self.looks_like_variable_or_pattern(trimmed) {
79 return true;
80 }
81
82 if self.spaces_improve_readability(code_content, trimmed) {
84 return true;
85 }
86 }
87
88 false
89 }
90
91 fn looks_like_command(&self, content: &str) -> bool {
93 const COMMAND_PREFIXES: &[&str] = &[
95 "git ", "npm ", "cargo ", "docker ", "kubectl ", "pip ", "yarn ", "sudo ", "chmod ", "chown ", "ls ",
96 "cd ", "mkdir ", "rm ", "cp ", "mv ", "cat ", "grep ", "find ", "awk ", "sed ", "rumdl ",
97 ];
98
99 let needs_lowercase_check = COMMAND_PREFIXES.iter().any(|&cmd| {
102 content.len() >= cmd.len() && content.as_bytes()[..cmd.len()].eq_ignore_ascii_case(cmd.as_bytes())
103 });
104
105 needs_lowercase_check
106 || content.contains(" -") || content.contains(" --") }
109
110 fn looks_like_variable_or_pattern(&self, content: &str) -> bool {
112 content.starts_with('$')
114 || content.starts_with('%') && content.ends_with('%')
115 || (content.contains("*") && content.len() > 3) || (content.contains("?") && content.len() > 3 && content.contains("."))
117 }
119
120 fn spaces_improve_readability(&self, _code_content: &str, trimmed: &str) -> bool {
122 trimmed.len() >= 20 || trimmed.contains("://") || trimmed.contains("->") || trimmed.contains("=>") || trimmed.contains("&&") || trimmed.contains("||") || (trimmed.chars().filter(|c| c.is_ascii_punctuation()).count() as f64 / trimmed.len() as f64) > 0.4
129 }
131
132 fn is_likely_nested_backticks(&self, ctx: &crate::lint_context::LintContext, span_index: usize) -> bool {
134 let code_spans = ctx.code_spans();
137 let current_span = &code_spans[span_index];
138 let current_line = current_span.line;
139
140 let same_line_spans: Vec<_> = code_spans
142 .iter()
143 .enumerate()
144 .filter(|(i, s)| s.line == current_line && *i != span_index)
145 .collect();
146
147 if same_line_spans.is_empty() {
148 return false;
149 }
150
151 let line_idx = current_line - 1; if line_idx >= ctx.lines.len() {
155 return false;
156 }
157
158 let line_content = &ctx.lines[line_idx].content;
159
160 for (_, other_span) in &same_line_spans {
162 let start = current_span.end_col.min(other_span.end_col);
163 let end = current_span.start_col.max(other_span.start_col);
164
165 if start < end && end <= line_content.len() {
166 let between = &line_content[start..end];
167 if between.contains("code") || between.contains("backtick") {
170 return true;
171 }
172 }
173 }
174
175 false
176 }
177}
178
179impl Rule for MD038NoSpaceInCode {
180 fn name(&self) -> &'static str {
181 "MD038"
182 }
183
184 fn description(&self) -> &'static str {
185 "Spaces inside code span elements"
186 }
187
188 fn category(&self) -> RuleCategory {
189 RuleCategory::Other
190 }
191
192 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
193 if !self.enabled {
194 return Ok(vec![]);
195 }
196
197 let mut warnings = Vec::new();
198
199 let code_spans = ctx.code_spans();
201 for (i, code_span) in code_spans.iter().enumerate() {
202 let code_content = &code_span.content;
203
204 if code_content.is_empty() {
206 continue;
207 }
208
209 let has_leading_space = code_content.chars().next().is_some_and(|c| c.is_whitespace());
211 let has_trailing_space = code_content.chars().last().is_some_and(|c| c.is_whitespace());
212
213 if !has_leading_space && !has_trailing_space {
214 continue;
215 }
216
217 let trimmed = code_content.trim();
218
219 if code_content != trimmed {
221 if trimmed.contains('`') {
224 continue;
225 }
226
227 if self.is_likely_nested_backticks(ctx, i) {
230 continue;
231 }
232
233 if self.should_allow_spaces(code_content, trimmed) {
235 continue;
236 }
237
238 warnings.push(LintWarning {
239 rule_name: Some(self.name()),
240 line: code_span.line,
241 column: code_span.start_col + 1, end_line: code_span.line,
243 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
245 severity: Severity::Warning,
246 fix: Some(Fix {
247 range: code_span.byte_offset..code_span.byte_end,
248 replacement: format!(
249 "{}{}{}",
250 "`".repeat(code_span.backtick_count),
251 trimmed,
252 "`".repeat(code_span.backtick_count)
253 ),
254 }),
255 });
256 }
257 }
258
259 Ok(warnings)
260 }
261
262 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
263 let content = ctx.content;
264 if !self.enabled {
265 return Ok(content.to_string());
266 }
267
268 if !content.contains('`') {
270 return Ok(content.to_string());
271 }
272
273 let warnings = self.check(ctx)?;
275 if warnings.is_empty() {
276 return Ok(content.to_string());
277 }
278
279 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
281 .into_iter()
282 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
283 .collect();
284
285 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
286
287 let mut result = content.to_string();
289 for (range, replacement) in fixes {
290 result.replace_range(range, &replacement);
291 }
292
293 Ok(result)
294 }
295
296 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
298 !ctx.likely_has_code()
299 }
300
301 fn as_any(&self) -> &dyn std::any::Any {
302 self
303 }
304
305 fn default_config_section(&self) -> Option<(String, toml::Value)> {
306 let mut map = toml::map::Map::new();
307 map.insert(
308 "allow_intentional_spaces".to_string(),
309 toml::Value::Boolean(self.allow_intentional_spaces),
310 );
311 map.insert(
312 "allow_single_char_spaces".to_string(),
313 toml::Value::Boolean(self.allow_single_char_spaces),
314 );
315 map.insert(
316 "allow_command_spaces".to_string(),
317 toml::Value::Boolean(self.allow_command_spaces),
318 );
319 Some((self.name().to_string(), toml::Value::Table(map)))
320 }
321
322 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
323 where
324 Self: Sized,
325 {
326 let allow_intentional_spaces =
327 crate::config::get_rule_config_value::<bool>(config, "MD038", "allow_intentional_spaces").unwrap_or(true); let allow_single_char_spaces =
330 crate::config::get_rule_config_value::<bool>(config, "MD038", "allow_single_char_spaces").unwrap_or(true);
331
332 let allow_command_spaces =
333 crate::config::get_rule_config_value::<bool>(config, "MD038", "allow_command_spaces").unwrap_or(true);
334
335 Box::new(MD038NoSpaceInCode {
336 enabled: true,
337 allow_intentional_spaces,
338 allow_single_char_spaces,
339 allow_command_spaces,
340 })
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn test_md038_readme_false_positives() {
350 let rule = MD038NoSpaceInCode::new();
352 let valid_cases = vec![
353 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
354 "#### Effective Configuration (`rumdl config`)",
355 "- Blue: `.rumdl.toml`",
356 "### Defaults Only (`rumdl config --defaults`)",
357 ];
358
359 for case in valid_cases {
360 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
361 let result = rule.check(&ctx).unwrap();
362 assert!(
363 result.is_empty(),
364 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
365 case,
366 result.len()
367 );
368 }
369 }
370
371 #[test]
372 fn test_md038_valid() {
373 let rule = MD038NoSpaceInCode::new();
374 let valid_cases = vec![
375 "This is `code` in a sentence.",
376 "This is a `longer code span` in a sentence.",
377 "This is `code with internal spaces` which is fine.",
378 "This is`` code with double backticks`` which is also fine.",
379 "Code span at `end of line`",
380 "`Start of line` code span",
381 "Multiple `code spans` in `one line` are fine",
382 "Code span with `symbols: !@#$%^&*()`",
383 "Empty code span `` is technically valid",
384 "Type ` y ` to confirm.", "Use ` git commit -m \"message\" ` to commit.", "The variable ` $HOME ` contains home path.", "The pattern ` *.txt ` matches text files.", "URL example ` https://example.com/very/long/path?query=value&more=params ` here.", ];
391 for case in valid_cases {
392 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
393 let result = rule.check(&ctx).unwrap();
394 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
395 }
396 }
397
398 #[test]
399 fn test_md038_invalid() {
400 let rule = MD038NoSpaceInCode::new();
401 let invalid_cases = vec![
403 "This is ` random word ` with unnecessary spaces.", "Text with ` plain text ` should be flagged.", "Code with ` just code ` here.", "Multiple ` word ` spans with ` text ` in one line.", ];
408 for case in invalid_cases {
409 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
410 let result = rule.check(&ctx).unwrap();
411 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
412 }
413 }
414
415 #[test]
416 fn test_md038_strict_mode() {
417 let rule = MD038NoSpaceInCode::strict();
418 let invalid_cases = vec![
420 "Type ` y ` to confirm.", "Use ` git commit -m \"message\" ` to commit.", "The variable ` $HOME ` contains home path.", "The pattern ` *.txt ` matches text files.", "This is ` code` with leading space.",
425 "This is `code ` with trailing space.",
426 "This is ` code ` with both leading and trailing space.",
427 ];
428 for case in invalid_cases {
429 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
430 let result = rule.check(&ctx).unwrap();
431 assert!(!result.is_empty(), "Strict mode should flag all spaces: {case}");
432 }
433 }
434
435 #[test]
436 fn test_md038_fix() {
437 let rule = MD038NoSpaceInCode::new();
438 let test_cases = vec![
439 (
440 "This is ` code` with leading space.",
441 "This is `code` with leading space.",
442 ),
443 (
444 "This is `code ` with trailing space.",
445 "This is `code` with trailing space.",
446 ),
447 ("This is ` code ` with both spaces.", "This is `code` with both spaces."),
448 (
449 "Multiple ` code ` and `spans ` to fix.",
450 "Multiple `code` and `spans` to fix.",
451 ),
452 ];
453 for (input, expected) in test_cases {
454 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard);
455 let result = rule.fix(&ctx).unwrap();
456 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
457 }
458 }
459
460 #[test]
461 fn test_check_invalid_leading_space() {
462 let rule = MD038NoSpaceInCode::new();
463 let input = "This has a ` leading space` in code";
464 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard);
465 let result = rule.check(&ctx).unwrap();
466 assert_eq!(result.len(), 1);
467 assert_eq!(result[0].line, 1);
468 assert!(result[0].fix.is_some());
469 }
470
471 #[test]
472 fn test_code_span_parsing_nested_backticks() {
473 let content = "Code with ` nested `code` example ` should preserve backticks";
474 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
475
476 println!("Content: {content}");
477 println!("Code spans found:");
478 let code_spans = ctx.code_spans();
479 for (i, span) in code_spans.iter().enumerate() {
480 println!(
481 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
482 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
483 );
484 }
485
486 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
488 }
489
490 #[test]
491 fn test_nested_backtick_detection() {
492 let rule = MD038NoSpaceInCode::strict();
493
494 assert!(!rule.should_allow_spaces(" plain text ", "plain text"));
497
498 let lenient_rule = MD038NoSpaceInCode::new();
500 assert!(lenient_rule.should_allow_spaces(" y ", "y")); assert!(!lenient_rule.should_allow_spaces(" plain text ", "plain text"));
502 }
503}