1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3#[derive(Debug, Clone, Default)]
28pub struct MD038NoSpaceInCode {
29 pub enabled: bool,
30}
31
32impl MD038NoSpaceInCode {
33 pub fn new() -> Self {
34 Self { enabled: true }
35 }
36
37 fn is_hugo_template_syntax(
53 &self,
54 ctx: &crate::lint_context::LintContext,
55 code_span: &crate::lint_context::CodeSpan,
56 ) -> bool {
57 let start_line_idx = code_span.line.saturating_sub(1);
58 if start_line_idx >= ctx.lines.len() {
59 return false;
60 }
61
62 let start_line_content = ctx.lines[start_line_idx].content(ctx.content);
63
64 let span_start_col = code_span.start_col;
66
67 if span_start_col >= 3 {
73 let before_span: String = start_line_content.chars().take(span_start_col).collect();
76
77 let char_at_span_start = start_line_content.chars().nth(span_start_col).unwrap_or(' ');
81
82 let is_hugo_start =
90 (before_span.ends_with("{{raw ") && char_at_span_start == '`')
92 || (before_span.starts_with("{{<") && before_span.ends_with(' ') && char_at_span_start == '`')
94 || (before_span.ends_with("{{% ") && char_at_span_start == '`')
96 || (before_span.ends_with("{{ ") && char_at_span_start == '`');
98
99 if is_hugo_start {
100 let end_line_idx = code_span.end_line.saturating_sub(1);
103 if end_line_idx < ctx.lines.len() {
104 let end_line_content = ctx.lines[end_line_idx].content(ctx.content);
105 let end_line_char_count = end_line_content.chars().count();
106 let span_end_col = code_span.end_col.min(end_line_char_count);
107
108 if span_end_col < end_line_char_count {
110 let after_span: String = end_line_content.chars().skip(span_end_col).collect();
111 if after_span.trim_start().starts_with("}}") {
112 return true;
113 }
114 }
115
116 let next_line_idx = code_span.end_line;
118 if next_line_idx < ctx.lines.len() {
119 let next_line = ctx.lines[next_line_idx].content(ctx.content);
120 if next_line.trim_start().starts_with("}}") {
121 return true;
122 }
123 }
124 }
125 }
126 }
127
128 false
129 }
130
131 fn is_likely_nested_backticks(&self, ctx: &crate::lint_context::LintContext, span_index: usize) -> bool {
133 let code_spans = ctx.code_spans();
136 let current_span = &code_spans[span_index];
137 let current_line = current_span.line;
138
139 let same_line_spans: Vec<_> = code_spans
141 .iter()
142 .enumerate()
143 .filter(|(i, s)| s.line == current_line && *i != span_index)
144 .collect();
145
146 if same_line_spans.is_empty() {
147 return false;
148 }
149
150 let line_idx = current_line - 1; if line_idx >= ctx.lines.len() {
154 return false;
155 }
156
157 let line_content = &ctx.lines[line_idx].content(ctx.content);
158
159 for (_, other_span) in &same_line_spans {
161 let start = current_span.end_col.min(other_span.end_col);
162 let end = current_span.start_col.max(other_span.start_col);
163
164 if start < end && end <= line_content.len() {
165 if let Some(between) = line_content.get(start..end) {
167 if between.contains("code") || between.contains("backtick") {
170 return true;
171 }
172 }
173 }
174 }
175
176 false
177 }
178}
179
180impl Rule for MD038NoSpaceInCode {
181 fn name(&self) -> &'static str {
182 "MD038"
183 }
184
185 fn description(&self) -> &'static str {
186 "Spaces inside code span elements"
187 }
188
189 fn category(&self) -> RuleCategory {
190 RuleCategory::Other
191 }
192
193 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
194 if !self.enabled {
195 return Ok(vec![]);
196 }
197
198 let mut warnings = Vec::new();
199
200 let code_spans = ctx.code_spans();
202 for (i, code_span) in code_spans.iter().enumerate() {
203 let code_content = &code_span.content;
204
205 if code_content.is_empty() {
207 continue;
208 }
209
210 let has_leading_space = code_content.chars().next().is_some_and(|c| c.is_whitespace());
212 let has_trailing_space = code_content.chars().last().is_some_and(|c| c.is_whitespace());
213
214 if !has_leading_space && !has_trailing_space {
215 continue;
216 }
217
218 let trimmed = code_content.trim();
219
220 if code_content != trimmed {
222 if has_leading_space && has_trailing_space && !trimmed.is_empty() {
234 let leading_spaces = code_content.len() - code_content.trim_start().len();
235 let trailing_spaces = code_content.len() - code_content.trim_end().len();
236
237 if leading_spaces == 1 && trailing_spaces == 1 {
239 continue;
240 }
241 }
242 if trimmed.contains('`') {
245 continue;
246 }
247
248 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
251 && trimmed.starts_with('r')
252 && trimmed.len() > 1
253 && trimmed.chars().nth(1).is_some_and(|c| c.is_whitespace())
254 {
255 continue;
256 }
257
258 if self.is_hugo_template_syntax(ctx, code_span) {
261 continue;
262 }
263
264 if self.is_likely_nested_backticks(ctx, i) {
267 continue;
268 }
269
270 warnings.push(LintWarning {
271 rule_name: Some(self.name().to_string()),
272 line: code_span.line,
273 column: code_span.start_col + 1, end_line: code_span.line,
275 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
277 severity: Severity::Warning,
278 fix: Some(Fix {
279 range: code_span.byte_offset..code_span.byte_end,
280 replacement: format!(
281 "{}{}{}",
282 "`".repeat(code_span.backtick_count),
283 trimmed,
284 "`".repeat(code_span.backtick_count)
285 ),
286 }),
287 });
288 }
289 }
290
291 Ok(warnings)
292 }
293
294 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
295 let content = ctx.content;
296 if !self.enabled {
297 return Ok(content.to_string());
298 }
299
300 if !content.contains('`') {
302 return Ok(content.to_string());
303 }
304
305 let warnings = self.check(ctx)?;
307 if warnings.is_empty() {
308 return Ok(content.to_string());
309 }
310
311 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
313 .into_iter()
314 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
315 .collect();
316
317 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
318
319 let mut result = content.to_string();
321 for (range, replacement) in fixes {
322 result.replace_range(range, &replacement);
323 }
324
325 Ok(result)
326 }
327
328 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
330 !ctx.likely_has_code()
331 }
332
333 fn as_any(&self) -> &dyn std::any::Any {
334 self
335 }
336
337 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
338 where
339 Self: Sized,
340 {
341 Box::new(MD038NoSpaceInCode { enabled: true })
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn test_md038_readme_false_positives() {
351 let rule = MD038NoSpaceInCode::new();
353 let valid_cases = vec![
354 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
355 "#### Effective Configuration (`rumdl config`)",
356 "- Blue: `.rumdl.toml`",
357 "### Defaults Only (`rumdl config --defaults`)",
358 ];
359
360 for case in valid_cases {
361 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
362 let result = rule.check(&ctx).unwrap();
363 assert!(
364 result.is_empty(),
365 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
366 case,
367 result.len()
368 );
369 }
370 }
371
372 #[test]
373 fn test_md038_valid() {
374 let rule = MD038NoSpaceInCode::new();
375 let valid_cases = vec![
376 "This is `code` in a sentence.",
377 "This is a `longer code span` in a sentence.",
378 "This is `code with internal spaces` which is 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 ];
385 for case in valid_cases {
386 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
387 let result = rule.check(&ctx).unwrap();
388 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
389 }
390 }
391
392 #[test]
393 fn test_md038_invalid() {
394 let rule = MD038NoSpaceInCode::new();
395 let invalid_cases = vec![
400 "This is ` code` with leading space.",
402 "This is `code ` with trailing space.",
404 "This is ` code ` with double leading space.",
406 "This is ` code ` with double trailing space.",
408 "This is ` code ` with double spaces both sides.",
410 ];
411 for case in invalid_cases {
412 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
413 let result = rule.check(&ctx).unwrap();
414 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
415 }
416 }
417
418 #[test]
419 fn test_md038_valid_commonmark_stripping() {
420 let rule = MD038NoSpaceInCode::new();
421 let valid_cases = vec![
425 "Type ` y ` to confirm.",
426 "Use ` git commit -m \"message\" ` to commit.",
427 "The variable ` $HOME ` contains home path.",
428 "The pattern ` *.txt ` matches text files.",
429 "This is ` random word ` with unnecessary spaces.",
430 "Text with ` plain text ` is valid.",
431 "Code with ` just code ` here.",
432 "Multiple ` word ` spans with ` text ` in one line.",
433 "This is ` code ` with both leading and trailing single space.",
434 "Use ` - ` as separator.",
435 ];
436 for case in valid_cases {
437 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
438 let result = rule.check(&ctx).unwrap();
439 assert!(
440 result.is_empty(),
441 "Single space on each side should not be flagged (CommonMark strips them): {case}"
442 );
443 }
444 }
445
446 #[test]
447 fn test_md038_fix() {
448 let rule = MD038NoSpaceInCode::new();
449 let test_cases = vec![
451 (
453 "This is ` code` with leading space.",
454 "This is `code` with leading space.",
455 ),
456 (
458 "This is `code ` with trailing space.",
459 "This is `code` with trailing space.",
460 ),
461 (
463 "This is ` code ` with both spaces.",
464 "This is ` code ` with both spaces.", ),
466 (
468 "This is ` code ` with double leading space.",
469 "This is `code` with double leading space.",
470 ),
471 (
473 "Multiple ` code ` and `spans ` to fix.",
474 "Multiple ` code ` and `spans` to fix.", ),
476 ];
477 for (input, expected) in test_cases {
478 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
479 let result = rule.fix(&ctx).unwrap();
480 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
481 }
482 }
483
484 #[test]
485 fn test_check_invalid_leading_space() {
486 let rule = MD038NoSpaceInCode::new();
487 let input = "This has a ` leading space` in code";
488 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
489 let result = rule.check(&ctx).unwrap();
490 assert_eq!(result.len(), 1);
491 assert_eq!(result[0].line, 1);
492 assert!(result[0].fix.is_some());
493 }
494
495 #[test]
496 fn test_code_span_parsing_nested_backticks() {
497 let content = "Code with ` nested `code` example ` should preserve backticks";
498 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499
500 println!("Content: {content}");
501 println!("Code spans found:");
502 let code_spans = ctx.code_spans();
503 for (i, span) in code_spans.iter().enumerate() {
504 println!(
505 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
506 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
507 );
508 }
509
510 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
512 }
513
514 #[test]
515 fn test_nested_backtick_detection() {
516 let rule = MD038NoSpaceInCode::new();
517
518 let content = "Code with `` `backticks` inside `` should not be flagged";
520 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert!(result.is_empty(), "Code spans with backticks should be skipped");
523 }
524
525 #[test]
526 fn test_quarto_inline_r_code() {
527 let rule = MD038NoSpaceInCode::new();
529
530 let content = r#"The result is `r nchar("test")` which equals 4."#;
533
534 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
536 let result_quarto = rule.check(&ctx_quarto).unwrap();
537 assert!(
538 result_quarto.is_empty(),
539 "Quarto inline R code should not trigger warnings. Got {} warnings",
540 result_quarto.len()
541 );
542
543 let content_other = "This has `plain text ` with trailing space.";
546 let ctx_other =
547 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
548 let result_other = rule.check(&ctx_other).unwrap();
549 assert_eq!(
550 result_other.len(),
551 1,
552 "Quarto should still flag non-R code spans with improper spaces"
553 );
554 }
555
556 #[test]
562 fn test_hugo_template_syntax_comprehensive() {
563 let rule = MD038NoSpaceInCode::new();
564
565 let valid_hugo_cases = vec![
569 (
571 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
572 "Multi-line raw shortcode",
573 ),
574 (
575 "Some text {{raw ` code `}} more text",
576 "Inline raw shortcode with spaces",
577 ),
578 ("{{raw `code`}}", "Raw shortcode without spaces"),
579 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
581 ("{{< `code` >}}", "Partial shortcode without spaces"),
582 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
584 ("{{% `code` %}}", "Percent shortcode without spaces"),
585 ("{{ ` code ` }}", "Generic shortcode with spaces"),
587 ("{{ `code` }}", "Generic shortcode without spaces"),
588 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
590 ("{{< code `go list` >}}", "Shortcode with code parameter"),
591 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
593 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
594 (
596 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
597 "Nested Go template syntax",
598 ),
599 ("{{raw `code`}}", "Hugo template at line start"),
601 ("Text {{raw `code`}}", "Hugo template at end of line"),
603 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
605 ];
606
607 for (case, description) in valid_hugo_cases {
608 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
609 let result = rule.check(&ctx).unwrap();
610 assert!(
611 result.is_empty(),
612 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
613 );
614 }
615
616 let should_be_flagged = vec![
621 ("This is ` code` with leading space.", "Leading space only"),
622 ("This is `code ` with trailing space.", "Trailing space only"),
623 ("Text ` code ` here", "Extra leading space (asymmetric)"),
624 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
625 ("Text ` code` here", "Double leading, no trailing"),
626 ("Text `code ` here", "No leading, double trailing"),
627 ];
628
629 for (case, description) in should_be_flagged {
630 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
631 let result = rule.check(&ctx).unwrap();
632 assert!(
633 !result.is_empty(),
634 "Should flag asymmetric space code spans: {description} - {case}"
635 );
636 }
637
638 let symmetric_single_space = vec![
644 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
645 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
646 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
647 ];
648
649 for (case, description) in symmetric_single_space {
650 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
651 let result = rule.check(&ctx).unwrap();
652 assert!(
653 result.is_empty(),
654 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
655 );
656 }
657
658 let unicode_cases = vec![
661 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
662 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
663 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
664 (
665 "{{raw `\n\tcode with 'single quotes'\n`}}",
666 "Single quotes in Hugo template",
667 ),
668 ];
669
670 for (case, description) in unicode_cases {
671 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
672 let result = rule.check(&ctx).unwrap();
673 assert!(
674 result.is_empty(),
675 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
676 );
677 }
678
679 assert!(
683 rule.check(&crate::lint_context::LintContext::new(
684 "{{ ` ` }}",
685 crate::config::MarkdownFlavor::Standard,
686 None
687 ))
688 .unwrap()
689 .is_empty(),
690 "Minimum Hugo pattern should be valid"
691 );
692
693 assert!(
695 rule.check(&crate::lint_context::LintContext::new(
696 "{{raw `\n\t\n`}}",
697 crate::config::MarkdownFlavor::Standard,
698 None
699 ))
700 .unwrap()
701 .is_empty(),
702 "Hugo template with only whitespace should be valid"
703 );
704 }
705
706 #[test]
708 fn test_hugo_template_with_other_markdown() {
709 let rule = MD038NoSpaceInCode::new();
710
711 let content = r#"1. First item
7132. Second item with {{raw `code`}} template
7143. Third item"#;
715 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716 let result = rule.check(&ctx).unwrap();
717 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
718
719 let content = r#"> Quote with {{raw `code`}} template"#;
721 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let result = rule.check(&ctx).unwrap();
723 assert!(
724 result.is_empty(),
725 "Hugo template in blockquote should not trigger warnings"
726 );
727
728 let content = r#"{{raw `code`}} and ` bad code` here"#;
730 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
731 let result = rule.check(&ctx).unwrap();
732 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
733 }
734
735 #[test]
737 fn test_hugo_template_performance() {
738 let rule = MD038NoSpaceInCode::new();
739
740 let mut content = String::new();
742 for i in 0..100 {
743 content.push_str(&format!("{{raw `code{i}\n`}}\n"));
744 }
745
746 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
747 let start = std::time::Instant::now();
748 let result = rule.check(&ctx).unwrap();
749 let duration = start.elapsed();
750
751 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
752 assert!(
753 duration.as_millis() < 1000,
754 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
755 );
756 }
757
758 #[test]
759 fn test_multibyte_utf8_no_panic() {
760 let rule = MD038NoSpaceInCode::new();
764
765 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
767 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
768 let result = rule.check(&ctx);
769 assert!(result.is_ok(), "Greek text should not panic");
770
771 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
773 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
774 let result = rule.check(&ctx);
775 assert!(result.is_ok(), "Chinese text should not panic");
776
777 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
779 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
780 let result = rule.check(&ctx);
781 assert!(result.is_ok(), "Cyrillic text should not panic");
782
783 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
785 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx);
787 assert!(
788 result.is_ok(),
789 "Mixed Chinese text with multiple code spans should not panic"
790 );
791 }
792}