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 ",
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.content.contains('`')
299 }
300
301 fn as_any(&self) -> &dyn std::any::Any {
302 self
303 }
304
305 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
306 Some(self)
307 }
308
309 fn default_config_section(&self) -> Option<(String, toml::Value)> {
310 let mut map = toml::map::Map::new();
311 map.insert(
312 "allow_intentional_spaces".to_string(),
313 toml::Value::Boolean(self.allow_intentional_spaces),
314 );
315 map.insert(
316 "allow_single_char_spaces".to_string(),
317 toml::Value::Boolean(self.allow_single_char_spaces),
318 );
319 map.insert(
320 "allow_command_spaces".to_string(),
321 toml::Value::Boolean(self.allow_command_spaces),
322 );
323 Some((self.name().to_string(), toml::Value::Table(map)))
324 }
325
326 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
327 where
328 Self: Sized,
329 {
330 let allow_intentional_spaces =
331 crate::config::get_rule_config_value::<bool>(config, "MD038", "allow_intentional_spaces").unwrap_or(true); let allow_single_char_spaces =
334 crate::config::get_rule_config_value::<bool>(config, "MD038", "allow_single_char_spaces").unwrap_or(true);
335
336 let allow_command_spaces =
337 crate::config::get_rule_config_value::<bool>(config, "MD038", "allow_command_spaces").unwrap_or(true);
338
339 Box::new(MD038NoSpaceInCode {
340 enabled: true,
341 allow_intentional_spaces,
342 allow_single_char_spaces,
343 allow_command_spaces,
344 })
345 }
346}
347
348impl crate::utils::document_structure::DocumentStructureExtensions for MD038NoSpaceInCode {
349 fn has_relevant_elements(
350 &self,
351 ctx: &crate::lint_context::LintContext,
352 _doc_structure: &crate::utils::document_structure::DocumentStructure,
353 ) -> bool {
354 ctx.content.contains('`')
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn test_md038_readme_false_positives() {
366 let rule = MD038NoSpaceInCode::new();
368 let valid_cases = vec![
369 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
370 "#### Effective Configuration (`rumdl config`)",
371 "- Blue: `.rumdl.toml`",
372 "### Defaults Only (`rumdl config --defaults`)",
373 ];
374
375 for case in valid_cases {
376 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
377 let result = rule.check(&ctx).unwrap();
378 assert!(
379 result.is_empty(),
380 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
381 case,
382 result.len()
383 );
384 }
385 }
386
387 #[test]
388 fn test_md038_valid() {
389 let rule = MD038NoSpaceInCode::new();
390 let valid_cases = vec![
391 "This is `code` in a sentence.",
392 "This is a `longer code span` in a sentence.",
393 "This is `code with internal spaces` which is fine.",
394 "This is`` code with double backticks`` which is also fine.",
395 "Code span at `end of line`",
396 "`Start of line` code span",
397 "Multiple `code spans` in `one line` are fine",
398 "Code span with `symbols: !@#$%^&*()`",
399 "Empty code span `` is technically valid",
400 "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.", ];
407 for case in valid_cases {
408 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
409 let result = rule.check(&ctx).unwrap();
410 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
411 }
412 }
413
414 #[test]
415 fn test_md038_invalid() {
416 let rule = MD038NoSpaceInCode::new();
417 let invalid_cases = vec![
419 "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.", ];
424 for case in invalid_cases {
425 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
426 let result = rule.check(&ctx).unwrap();
427 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
428 }
429 }
430
431 #[test]
432 fn test_md038_strict_mode() {
433 let rule = MD038NoSpaceInCode::strict();
434 let invalid_cases = vec![
436 "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.",
441 "This is `code ` with trailing space.",
442 "This is ` code ` with both leading and trailing space.",
443 ];
444 for case in invalid_cases {
445 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
446 let result = rule.check(&ctx).unwrap();
447 assert!(!result.is_empty(), "Strict mode should flag all spaces: {case}");
448 }
449 }
450
451 #[test]
452 fn test_md038_fix() {
453 let rule = MD038NoSpaceInCode::new();
454 let test_cases = vec![
455 (
456 "This is ` code` with leading space.",
457 "This is `code` with leading space.",
458 ),
459 (
460 "This is `code ` with trailing space.",
461 "This is `code` with trailing space.",
462 ),
463 ("This is ` code ` with both spaces.", "This is `code` with both spaces."),
464 (
465 "Multiple ` code ` and `spans ` to fix.",
466 "Multiple `code` and `spans` to fix.",
467 ),
468 ];
469 for (input, expected) in test_cases {
470 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard);
471 let result = rule.fix(&ctx).unwrap();
472 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
473 }
474 }
475
476 #[test]
477 fn test_check_invalid_leading_space() {
478 let rule = MD038NoSpaceInCode::new();
479 let input = "This has a ` leading space` in code";
480 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard);
481 let result = rule.check(&ctx).unwrap();
482 assert_eq!(result.len(), 1);
483 assert_eq!(result[0].line, 1);
484 assert!(result[0].fix.is_some());
485 }
486
487 #[test]
488 fn test_code_span_parsing_nested_backticks() {
489 let content = "Code with ` nested `code` example ` should preserve backticks";
490 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
491
492 println!("Content: {content}");
493 println!("Code spans found:");
494 let code_spans = ctx.code_spans();
495 for (i, span) in code_spans.iter().enumerate() {
496 println!(
497 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
498 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
499 );
500 }
501
502 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
504 }
505
506 #[test]
507 fn test_nested_backtick_detection() {
508 let rule = MD038NoSpaceInCode::strict();
509
510 assert!(!rule.should_allow_spaces(" plain text ", "plain text"));
513
514 let lenient_rule = MD038NoSpaceInCode::new();
516 assert!(lenient_rule.should_allow_spaces(" y ", "y")); assert!(!lenient_rule.should_allow_spaces(" plain text ", "plain text"));
518 }
519}