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 ctx.flavor == crate::config::MarkdownFlavor::Quarto
230 && trimmed.starts_with('r')
231 && trimmed.len() > 1
232 && trimmed.chars().nth(1).is_some_and(|c| c.is_whitespace())
233 {
234 continue;
235 }
236
237 if self.is_likely_nested_backticks(ctx, i) {
240 continue;
241 }
242
243 if self.should_allow_spaces(code_content, trimmed) {
245 continue;
246 }
247
248 warnings.push(LintWarning {
249 rule_name: Some(self.name().to_string()),
250 line: code_span.line,
251 column: code_span.start_col + 1, end_line: code_span.line,
253 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
255 severity: Severity::Warning,
256 fix: Some(Fix {
257 range: code_span.byte_offset..code_span.byte_end,
258 replacement: format!(
259 "{}{}{}",
260 "`".repeat(code_span.backtick_count),
261 trimmed,
262 "`".repeat(code_span.backtick_count)
263 ),
264 }),
265 });
266 }
267 }
268
269 Ok(warnings)
270 }
271
272 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
273 let content = ctx.content;
274 if !self.enabled {
275 return Ok(content.to_string());
276 }
277
278 if !content.contains('`') {
280 return Ok(content.to_string());
281 }
282
283 let warnings = self.check(ctx)?;
285 if warnings.is_empty() {
286 return Ok(content.to_string());
287 }
288
289 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
291 .into_iter()
292 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
293 .collect();
294
295 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
296
297 let mut result = content.to_string();
299 for (range, replacement) in fixes {
300 result.replace_range(range, &replacement);
301 }
302
303 Ok(result)
304 }
305
306 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
308 !ctx.likely_has_code()
309 }
310
311 fn as_any(&self) -> &dyn std::any::Any {
312 self
313 }
314
315 fn default_config_section(&self) -> Option<(String, toml::Value)> {
316 let mut map = toml::map::Map::new();
317 map.insert(
318 "allow_intentional_spaces".to_string(),
319 toml::Value::Boolean(self.allow_intentional_spaces),
320 );
321 map.insert(
322 "allow_single_char_spaces".to_string(),
323 toml::Value::Boolean(self.allow_single_char_spaces),
324 );
325 map.insert(
326 "allow_command_spaces".to_string(),
327 toml::Value::Boolean(self.allow_command_spaces),
328 );
329 Some((self.name().to_string(), toml::Value::Table(map)))
330 }
331
332 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
333 where
334 Self: Sized,
335 {
336 let allow_intentional_spaces =
337 crate::config::get_rule_config_value::<bool>(config, "MD038", "allow_intentional_spaces").unwrap_or(true); let allow_single_char_spaces =
340 crate::config::get_rule_config_value::<bool>(config, "MD038", "allow_single_char_spaces").unwrap_or(true);
341
342 let allow_command_spaces =
343 crate::config::get_rule_config_value::<bool>(config, "MD038", "allow_command_spaces").unwrap_or(true);
344
345 Box::new(MD038NoSpaceInCode {
346 enabled: true,
347 allow_intentional_spaces,
348 allow_single_char_spaces,
349 allow_command_spaces,
350 })
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn test_md038_readme_false_positives() {
360 let rule = MD038NoSpaceInCode::new();
362 let valid_cases = vec![
363 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
364 "#### Effective Configuration (`rumdl config`)",
365 "- Blue: `.rumdl.toml`",
366 "### Defaults Only (`rumdl config --defaults`)",
367 ];
368
369 for case in valid_cases {
370 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
371 let result = rule.check(&ctx).unwrap();
372 assert!(
373 result.is_empty(),
374 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
375 case,
376 result.len()
377 );
378 }
379 }
380
381 #[test]
382 fn test_md038_valid() {
383 let rule = MD038NoSpaceInCode::new();
384 let valid_cases = vec![
385 "This is `code` in a sentence.",
386 "This is a `longer code span` in a sentence.",
387 "This is `code with internal spaces` which is fine.",
388 "This is`` code with double backticks`` which is also fine.",
389 "Code span at `end of line`",
390 "`Start of line` code span",
391 "Multiple `code spans` in `one line` are fine",
392 "Code span with `symbols: !@#$%^&*()`",
393 "Empty code span `` is technically valid",
394 "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.", ];
401 for case in valid_cases {
402 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
403 let result = rule.check(&ctx).unwrap();
404 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
405 }
406 }
407
408 #[test]
409 fn test_md038_invalid() {
410 let rule = MD038NoSpaceInCode::new();
411 let invalid_cases = vec![
413 "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.", ];
418 for case in invalid_cases {
419 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
420 let result = rule.check(&ctx).unwrap();
421 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
422 }
423 }
424
425 #[test]
426 fn test_md038_strict_mode() {
427 let rule = MD038NoSpaceInCode::strict();
428 let invalid_cases = vec![
430 "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.",
435 "This is `code ` with trailing space.",
436 "This is ` code ` with both leading and trailing space.",
437 ];
438 for case in invalid_cases {
439 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
440 let result = rule.check(&ctx).unwrap();
441 assert!(!result.is_empty(), "Strict mode should flag all spaces: {case}");
442 }
443 }
444
445 #[test]
446 fn test_md038_fix() {
447 let rule = MD038NoSpaceInCode::new();
448 let test_cases = vec![
449 (
450 "This is ` code` with leading space.",
451 "This is `code` with leading space.",
452 ),
453 (
454 "This is `code ` with trailing space.",
455 "This is `code` with trailing space.",
456 ),
457 ("This is ` code ` with both spaces.", "This is `code` with both spaces."),
458 (
459 "Multiple ` code ` and `spans ` to fix.",
460 "Multiple `code` and `spans` to fix.",
461 ),
462 ];
463 for (input, expected) in test_cases {
464 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard);
465 let result = rule.fix(&ctx).unwrap();
466 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
467 }
468 }
469
470 #[test]
471 fn test_check_invalid_leading_space() {
472 let rule = MD038NoSpaceInCode::new();
473 let input = "This has a ` leading space` in code";
474 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard);
475 let result = rule.check(&ctx).unwrap();
476 assert_eq!(result.len(), 1);
477 assert_eq!(result[0].line, 1);
478 assert!(result[0].fix.is_some());
479 }
480
481 #[test]
482 fn test_code_span_parsing_nested_backticks() {
483 let content = "Code with ` nested `code` example ` should preserve backticks";
484 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
485
486 println!("Content: {content}");
487 println!("Code spans found:");
488 let code_spans = ctx.code_spans();
489 for (i, span) in code_spans.iter().enumerate() {
490 println!(
491 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
492 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
493 );
494 }
495
496 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
498 }
499
500 #[test]
501 fn test_nested_backtick_detection() {
502 let rule = MD038NoSpaceInCode::strict();
503
504 assert!(!rule.should_allow_spaces(" plain text ", "plain text"));
507
508 let lenient_rule = MD038NoSpaceInCode::new();
510 assert!(lenient_rule.should_allow_spaces(" y ", "y")); assert!(!lenient_rule.should_allow_spaces(" plain text ", "plain text"));
512 }
513
514 #[test]
515 fn test_quarto_inline_r_code() {
516 let rule_strict = MD038NoSpaceInCode::strict();
518
519 let content = r#"The result is `r nchar("test")` which equals 4."#;
522
523 let ctx_quarto_strict = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto);
525 let result_quarto_strict = rule_strict.check(&ctx_quarto_strict).unwrap();
526 assert!(
527 result_quarto_strict.is_empty(),
528 "Quarto inline R code should not trigger warnings even in strict mode. Got {} warnings",
529 result_quarto_strict.len()
530 );
531
532 let content_other = "This has ` plain text ` with spaces.";
534 let ctx_other = crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto);
535 let result_other = rule_strict.check(&ctx_other).unwrap();
536 assert_eq!(
537 result_other.len(),
538 1,
539 "Quarto strict mode should still flag non-R code spans with improper spaces"
540 );
541 }
542}