1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mkdocs_extensions::is_inline_hilite_content;
3
4#[derive(Debug, Clone, Default)]
29pub struct MD038NoSpaceInCode {
30 pub enabled: bool,
31}
32
33impl MD038NoSpaceInCode {
34 pub fn new() -> Self {
35 Self { enabled: true }
36 }
37
38 fn is_hugo_template_syntax(
54 &self,
55 ctx: &crate::lint_context::LintContext,
56 code_span: &crate::lint_context::CodeSpan,
57 ) -> bool {
58 let start_line_idx = code_span.line.saturating_sub(1);
59 if start_line_idx >= ctx.lines.len() {
60 return false;
61 }
62
63 let start_line_content = ctx.lines[start_line_idx].content(ctx.content);
64
65 let span_start_col = code_span.start_col;
67
68 if span_start_col >= 3 {
74 let before_span: String = start_line_content.chars().take(span_start_col).collect();
77
78 let char_at_span_start = start_line_content.chars().nth(span_start_col).unwrap_or(' ');
82
83 let is_hugo_start =
91 (before_span.ends_with("{{raw ") && char_at_span_start == '`')
93 || (before_span.starts_with("{{<") && before_span.ends_with(' ') && char_at_span_start == '`')
95 || (before_span.ends_with("{{% ") && char_at_span_start == '`')
97 || (before_span.ends_with("{{ ") && char_at_span_start == '`');
99
100 if is_hugo_start {
101 let end_line_idx = code_span.end_line.saturating_sub(1);
104 if end_line_idx < ctx.lines.len() {
105 let end_line_content = ctx.lines[end_line_idx].content(ctx.content);
106 let end_line_char_count = end_line_content.chars().count();
107 let span_end_col = code_span.end_col.min(end_line_char_count);
108
109 if span_end_col < end_line_char_count {
111 let after_span: String = end_line_content.chars().skip(span_end_col).collect();
112 if after_span.trim_start().starts_with("}}") {
113 return true;
114 }
115 }
116
117 let next_line_idx = code_span.end_line;
119 if next_line_idx < ctx.lines.len() {
120 let next_line = ctx.lines[next_line_idx].content(ctx.content);
121 if next_line.trim_start().starts_with("}}") {
122 return true;
123 }
124 }
125 }
126 }
127 }
128
129 false
130 }
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(ctx.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 if let Some(between) = line_content.get(start..end) {
168 if between.contains("code") || between.contains("backtick") {
171 return true;
172 }
173 }
174 }
175 }
176
177 false
178 }
179}
180
181impl Rule for MD038NoSpaceInCode {
182 fn name(&self) -> &'static str {
183 "MD038"
184 }
185
186 fn description(&self) -> &'static str {
187 "Spaces inside code span elements"
188 }
189
190 fn category(&self) -> RuleCategory {
191 RuleCategory::Other
192 }
193
194 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
195 if !self.enabled {
196 return Ok(vec![]);
197 }
198
199 let mut warnings = Vec::new();
200
201 let code_spans = ctx.code_spans();
203 for (i, code_span) in code_spans.iter().enumerate() {
204 let code_content = &code_span.content;
205
206 if code_content.is_empty() {
208 continue;
209 }
210
211 let has_leading_space = code_content.chars().next().is_some_and(|c| c.is_whitespace());
213 let has_trailing_space = code_content.chars().last().is_some_and(|c| c.is_whitespace());
214
215 if !has_leading_space && !has_trailing_space {
216 continue;
217 }
218
219 let trimmed = code_content.trim();
220
221 if code_content != trimmed {
223 if has_leading_space && has_trailing_space && !trimmed.is_empty() {
235 let leading_spaces = code_content.len() - code_content.trim_start().len();
236 let trailing_spaces = code_content.len() - code_content.trim_end().len();
237
238 if leading_spaces == 1 && trailing_spaces == 1 {
240 continue;
241 }
242 }
243 if trimmed.contains('`') {
246 continue;
247 }
248
249 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
252 && trimmed.starts_with('r')
253 && trimmed.len() > 1
254 && trimmed.chars().nth(1).is_some_and(|c| c.is_whitespace())
255 {
256 continue;
257 }
258
259 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
262 continue;
263 }
264
265 if self.is_hugo_template_syntax(ctx, code_span) {
268 continue;
269 }
270
271 if self.is_likely_nested_backticks(ctx, i) {
274 continue;
275 }
276
277 warnings.push(LintWarning {
278 rule_name: Some(self.name().to_string()),
279 line: code_span.line,
280 column: code_span.start_col + 1, end_line: code_span.line,
282 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
284 severity: Severity::Warning,
285 fix: Some(Fix {
286 range: code_span.byte_offset..code_span.byte_end,
287 replacement: format!(
288 "{}{}{}",
289 "`".repeat(code_span.backtick_count),
290 trimmed,
291 "`".repeat(code_span.backtick_count)
292 ),
293 }),
294 });
295 }
296 }
297
298 Ok(warnings)
299 }
300
301 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
302 let content = ctx.content;
303 if !self.enabled {
304 return Ok(content.to_string());
305 }
306
307 if !content.contains('`') {
309 return Ok(content.to_string());
310 }
311
312 let warnings = self.check(ctx)?;
314 if warnings.is_empty() {
315 return Ok(content.to_string());
316 }
317
318 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
320 .into_iter()
321 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
322 .collect();
323
324 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
325
326 let mut result = content.to_string();
328 for (range, replacement) in fixes {
329 result.replace_range(range, &replacement);
330 }
331
332 Ok(result)
333 }
334
335 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
337 !ctx.likely_has_code()
338 }
339
340 fn as_any(&self) -> &dyn std::any::Any {
341 self
342 }
343
344 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
345 where
346 Self: Sized,
347 {
348 Box::new(MD038NoSpaceInCode { enabled: true })
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn test_md038_readme_false_positives() {
358 let rule = MD038NoSpaceInCode::new();
360 let valid_cases = vec![
361 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
362 "#### Effective Configuration (`rumdl config`)",
363 "- Blue: `.rumdl.toml`",
364 "### Defaults Only (`rumdl config --defaults`)",
365 ];
366
367 for case in valid_cases {
368 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
369 let result = rule.check(&ctx).unwrap();
370 assert!(
371 result.is_empty(),
372 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
373 case,
374 result.len()
375 );
376 }
377 }
378
379 #[test]
380 fn test_md038_valid() {
381 let rule = MD038NoSpaceInCode::new();
382 let valid_cases = vec![
383 "This is `code` in a sentence.",
384 "This is a `longer code span` in a sentence.",
385 "This is `code with internal spaces` which is fine.",
386 "Code span at `end of line`",
387 "`Start of line` code span",
388 "Multiple `code spans` in `one line` are fine",
389 "Code span with `symbols: !@#$%^&*()`",
390 "Empty code span `` is technically valid",
391 ];
392 for case in valid_cases {
393 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
394 let result = rule.check(&ctx).unwrap();
395 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
396 }
397 }
398
399 #[test]
400 fn test_md038_invalid() {
401 let rule = MD038NoSpaceInCode::new();
402 let invalid_cases = vec![
407 "This is ` code` with leading space.",
409 "This is `code ` with trailing space.",
411 "This is ` code ` with double leading space.",
413 "This is ` code ` with double trailing space.",
415 "This is ` code ` with double spaces both sides.",
417 ];
418 for case in invalid_cases {
419 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
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_valid_commonmark_stripping() {
427 let rule = MD038NoSpaceInCode::new();
428 let valid_cases = vec![
432 "Type ` y ` to confirm.",
433 "Use ` git commit -m \"message\" ` to commit.",
434 "The variable ` $HOME ` contains home path.",
435 "The pattern ` *.txt ` matches text files.",
436 "This is ` random word ` with unnecessary spaces.",
437 "Text with ` plain text ` is valid.",
438 "Code with ` just code ` here.",
439 "Multiple ` word ` spans with ` text ` in one line.",
440 "This is ` code ` with both leading and trailing single space.",
441 "Use ` - ` as separator.",
442 ];
443 for case in valid_cases {
444 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
445 let result = rule.check(&ctx).unwrap();
446 assert!(
447 result.is_empty(),
448 "Single space on each side should not be flagged (CommonMark strips them): {case}"
449 );
450 }
451 }
452
453 #[test]
454 fn test_md038_fix() {
455 let rule = MD038NoSpaceInCode::new();
456 let test_cases = vec![
458 (
460 "This is ` code` with leading space.",
461 "This is `code` with leading space.",
462 ),
463 (
465 "This is `code ` with trailing space.",
466 "This is `code` with trailing space.",
467 ),
468 (
470 "This is ` code ` with both spaces.",
471 "This is ` code ` with both spaces.", ),
473 (
475 "This is ` code ` with double leading space.",
476 "This is `code` with double leading space.",
477 ),
478 (
480 "Multiple ` code ` and `spans ` to fix.",
481 "Multiple ` code ` and `spans` to fix.", ),
483 ];
484 for (input, expected) in test_cases {
485 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
486 let result = rule.fix(&ctx).unwrap();
487 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
488 }
489 }
490
491 #[test]
492 fn test_check_invalid_leading_space() {
493 let rule = MD038NoSpaceInCode::new();
494 let input = "This has a ` leading space` in code";
495 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
496 let result = rule.check(&ctx).unwrap();
497 assert_eq!(result.len(), 1);
498 assert_eq!(result[0].line, 1);
499 assert!(result[0].fix.is_some());
500 }
501
502 #[test]
503 fn test_code_span_parsing_nested_backticks() {
504 let content = "Code with ` nested `code` example ` should preserve backticks";
505 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506
507 println!("Content: {content}");
508 println!("Code spans found:");
509 let code_spans = ctx.code_spans();
510 for (i, span) in code_spans.iter().enumerate() {
511 println!(
512 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
513 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
514 );
515 }
516
517 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
519 }
520
521 #[test]
522 fn test_nested_backtick_detection() {
523 let rule = MD038NoSpaceInCode::new();
524
525 let content = "Code with `` `backticks` inside `` should not be flagged";
527 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528 let result = rule.check(&ctx).unwrap();
529 assert!(result.is_empty(), "Code spans with backticks should be skipped");
530 }
531
532 #[test]
533 fn test_quarto_inline_r_code() {
534 let rule = MD038NoSpaceInCode::new();
536
537 let content = r#"The result is `r nchar("test")` which equals 4."#;
540
541 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
543 let result_quarto = rule.check(&ctx_quarto).unwrap();
544 assert!(
545 result_quarto.is_empty(),
546 "Quarto inline R code should not trigger warnings. Got {} warnings",
547 result_quarto.len()
548 );
549
550 let content_other = "This has `plain text ` with trailing space.";
553 let ctx_other =
554 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
555 let result_other = rule.check(&ctx_other).unwrap();
556 assert_eq!(
557 result_other.len(),
558 1,
559 "Quarto should still flag non-R code spans with improper spaces"
560 );
561 }
562
563 #[test]
569 fn test_hugo_template_syntax_comprehensive() {
570 let rule = MD038NoSpaceInCode::new();
571
572 let valid_hugo_cases = vec![
576 (
578 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
579 "Multi-line raw shortcode",
580 ),
581 (
582 "Some text {{raw ` code `}} more text",
583 "Inline raw shortcode with spaces",
584 ),
585 ("{{raw `code`}}", "Raw shortcode without spaces"),
586 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
588 ("{{< `code` >}}", "Partial shortcode without spaces"),
589 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
591 ("{{% `code` %}}", "Percent shortcode without spaces"),
592 ("{{ ` code ` }}", "Generic shortcode with spaces"),
594 ("{{ `code` }}", "Generic shortcode without spaces"),
595 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
597 ("{{< code `go list` >}}", "Shortcode with code parameter"),
598 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
600 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
601 (
603 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
604 "Nested Go template syntax",
605 ),
606 ("{{raw `code`}}", "Hugo template at line start"),
608 ("Text {{raw `code`}}", "Hugo template at end of line"),
610 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
612 ];
613
614 for (case, description) in valid_hugo_cases {
615 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
616 let result = rule.check(&ctx).unwrap();
617 assert!(
618 result.is_empty(),
619 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
620 );
621 }
622
623 let should_be_flagged = vec![
628 ("This is ` code` with leading space.", "Leading space only"),
629 ("This is `code ` with trailing space.", "Trailing space only"),
630 ("Text ` code ` here", "Extra leading space (asymmetric)"),
631 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
632 ("Text ` code` here", "Double leading, no trailing"),
633 ("Text `code ` here", "No leading, double trailing"),
634 ];
635
636 for (case, description) in should_be_flagged {
637 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
638 let result = rule.check(&ctx).unwrap();
639 assert!(
640 !result.is_empty(),
641 "Should flag asymmetric space code spans: {description} - {case}"
642 );
643 }
644
645 let symmetric_single_space = vec![
651 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
652 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
653 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
654 ];
655
656 for (case, description) in symmetric_single_space {
657 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
658 let result = rule.check(&ctx).unwrap();
659 assert!(
660 result.is_empty(),
661 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
662 );
663 }
664
665 let unicode_cases = vec![
668 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
669 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
670 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
671 (
672 "{{raw `\n\tcode with 'single quotes'\n`}}",
673 "Single quotes in Hugo template",
674 ),
675 ];
676
677 for (case, description) in unicode_cases {
678 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.check(&ctx).unwrap();
680 assert!(
681 result.is_empty(),
682 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
683 );
684 }
685
686 assert!(
690 rule.check(&crate::lint_context::LintContext::new(
691 "{{ ` ` }}",
692 crate::config::MarkdownFlavor::Standard,
693 None
694 ))
695 .unwrap()
696 .is_empty(),
697 "Minimum Hugo pattern should be valid"
698 );
699
700 assert!(
702 rule.check(&crate::lint_context::LintContext::new(
703 "{{raw `\n\t\n`}}",
704 crate::config::MarkdownFlavor::Standard,
705 None
706 ))
707 .unwrap()
708 .is_empty(),
709 "Hugo template with only whitespace should be valid"
710 );
711 }
712
713 #[test]
715 fn test_hugo_template_with_other_markdown() {
716 let rule = MD038NoSpaceInCode::new();
717
718 let content = r#"1. First item
7202. Second item with {{raw `code`}} template
7213. Third item"#;
722 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let result = rule.check(&ctx).unwrap();
724 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
725
726 let content = r#"> Quote with {{raw `code`}} template"#;
728 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.check(&ctx).unwrap();
730 assert!(
731 result.is_empty(),
732 "Hugo template in blockquote should not trigger warnings"
733 );
734
735 let content = r#"{{raw `code`}} and ` bad code` here"#;
737 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
740 }
741
742 #[test]
744 fn test_hugo_template_performance() {
745 let rule = MD038NoSpaceInCode::new();
746
747 let mut content = String::new();
749 for i in 0..100 {
750 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
751 }
752
753 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
754 let start = std::time::Instant::now();
755 let result = rule.check(&ctx).unwrap();
756 let duration = start.elapsed();
757
758 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
759 assert!(
760 duration.as_millis() < 1000,
761 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
762 );
763 }
764
765 #[test]
766 fn test_mkdocs_inline_hilite_not_flagged() {
767 let rule = MD038NoSpaceInCode::new();
770
771 let valid_cases = vec![
772 "`#!python print('hello')`",
773 "`#!js alert('hi')`",
774 "`#!c++ cout << x;`",
775 "Use `#!python import os` to import modules",
776 "`#!bash echo $HOME`",
777 ];
778
779 for case in valid_cases {
780 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
781 let result = rule.check(&ctx).unwrap();
782 assert!(
783 result.is_empty(),
784 "InlineHilite syntax should not be flagged in MkDocs: {case}"
785 );
786 }
787
788 let content = "`#!python print('hello')`";
790 let ctx_standard =
791 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
792 let result_standard = rule.check(&ctx_standard).unwrap();
793 assert!(
796 result_standard.is_empty(),
797 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
798 );
799 }
800
801 #[test]
802 fn test_multibyte_utf8_no_panic() {
803 let rule = MD038NoSpaceInCode::new();
807
808 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
810 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
811 let result = rule.check(&ctx);
812 assert!(result.is_ok(), "Greek text should not panic");
813
814 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
816 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.check(&ctx);
818 assert!(result.is_ok(), "Chinese text should not panic");
819
820 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
822 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
823 let result = rule.check(&ctx);
824 assert!(result.is_ok(), "Cyrillic text should not panic");
825
826 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
828 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
829 let result = rule.check(&ctx);
830 assert!(
831 result.is_ok(),
832 "Mixed Chinese text with multiple code spans should not panic"
833 );
834 }
835}