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_dataview_expression(content: &str) -> bool {
148 content.starts_with("= ") || content.starts_with("$= ")
151 }
152
153 fn is_likely_nested_backticks(&self, ctx: &crate::lint_context::LintContext, span_index: usize) -> bool {
155 let code_spans = ctx.code_spans();
158 let current_span = &code_spans[span_index];
159 let current_line = current_span.line;
160
161 let same_line_spans: Vec<_> = code_spans
163 .iter()
164 .enumerate()
165 .filter(|(i, s)| s.line == current_line && *i != span_index)
166 .collect();
167
168 if same_line_spans.is_empty() {
169 return false;
170 }
171
172 let line_idx = current_line - 1; if line_idx >= ctx.lines.len() {
176 return false;
177 }
178
179 let line_content = &ctx.lines[line_idx].content(ctx.content);
180
181 for (_, other_span) in &same_line_spans {
183 let start_char = current_span.end_col.min(other_span.end_col);
184 let end_char = current_span.start_col.max(other_span.start_col);
185
186 if start_char < end_char {
187 let char_indices: Vec<(usize, char)> = line_content.char_indices().collect();
189 let start_byte = char_indices.get(start_char).map(|(i, _)| *i);
190 let end_byte = char_indices.get(end_char).map_or(line_content.len(), |(i, _)| *i);
191
192 if let Some(start_byte) = start_byte
193 && start_byte < end_byte
194 && end_byte <= line_content.len()
195 {
196 let between = &line_content[start_byte..end_byte];
197 if between.contains("code") || between.contains("backtick") {
200 return true;
201 }
202 }
203 }
204 }
205
206 false
207 }
208
209 fn has_attached_nested_backtick_boundary(
216 &self,
217 ctx: &crate::lint_context::LintContext,
218 code_span: &crate::lint_context::CodeSpan,
219 ) -> bool {
220 let content = code_span.content.as_str();
221
222 let next_char = ctx.content[code_span.byte_end..].chars().next();
223 let prev_char = ctx.content[..code_span.byte_offset].chars().next_back();
224
225 let trailing_neighbor_is_pandoc_attr =
229 ctx.flavor.is_pandoc_compatible() && ctx.is_in_inline_code_attr(code_span.byte_end);
230
231 (content.ends_with(char::is_whitespace)
232 && next_char.is_some_and(|c| !c.is_whitespace())
233 && !trailing_neighbor_is_pandoc_attr)
234 || (content.starts_with(char::is_whitespace) && prev_char.is_some_and(|c| !c.is_whitespace()))
235 }
236}
237
238impl Rule for MD038NoSpaceInCode {
239 fn name(&self) -> &'static str {
240 "MD038"
241 }
242
243 fn description(&self) -> &'static str {
244 "Spaces inside code span elements"
245 }
246
247 fn category(&self) -> RuleCategory {
248 RuleCategory::Other
249 }
250
251 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
252 if !self.enabled {
253 return Ok(vec![]);
254 }
255
256 let mut warnings = Vec::new();
257
258 let code_spans = ctx.code_spans();
260 for (i, code_span) in code_spans.iter().enumerate() {
261 if let Some(line_info) = ctx.lines.get(code_span.line - 1) {
262 if line_info.in_code_block
265 || line_info.in_front_matter
266 || line_info.in_math_block
267 || line_info.in_html_block
268 || line_info.in_html_comment
269 || line_info.in_mkdocstrings
270 || line_info.in_esm_block
271 {
272 continue;
273 }
274 if (line_info.in_mkdocs_container() || line_info.in_pymdown_block) && code_span.content.contains('\n') {
278 continue;
279 }
280 }
281
282 let code_content = &code_span.content;
283
284 if code_content.is_empty() {
286 continue;
287 }
288
289 let has_leading_space = code_content.chars().next().is_some_and(char::is_whitespace);
291 let has_trailing_space = code_content.chars().last().is_some_and(char::is_whitespace);
292
293 if !has_leading_space && !has_trailing_space {
294 continue;
295 }
296
297 let trimmed = code_content.trim();
298
299 if trimmed.is_empty() {
305 continue;
306 }
307
308 if code_content != trimmed {
310 if has_leading_space && has_trailing_space {
324 let leading_spaces = code_content.len() - code_content.trim_start().len();
325 let trailing_spaces = code_content.len() - code_content.trim_end().len();
326
327 if leading_spaces == 1 && trailing_spaces == 1 {
329 continue;
330 }
331 }
332 if trimmed.contains('`') {
335 continue;
336 }
337
338 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
343 && trimmed.starts_with('r')
344 && trimmed.len() > 1
345 && trimmed.chars().nth(1).is_some_and(char::is_whitespace)
346 {
347 continue;
348 }
349
350 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
353 continue;
354 }
355
356 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && Self::is_dataview_expression(code_content) {
360 continue;
361 }
362
363 if ctx.flavor.supports_myst_roles() && ctx.is_in_myst_role(code_span.byte_offset) {
366 continue;
367 }
368
369 if self.is_hugo_template_syntax(ctx, code_span) {
372 continue;
373 }
374
375 if self.is_likely_nested_backticks(ctx, i) {
378 continue;
379 }
380
381 if self.has_attached_nested_backtick_boundary(ctx, code_span) {
382 continue;
383 }
384
385 warnings.push(LintWarning {
386 rule_name: Some(self.name().to_string()),
387 line: code_span.line,
388 column: code_span.start_col + 1, end_line: code_span.line,
390 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
392 severity: Severity::Warning,
393 fix: Some(Fix::new(
394 code_span.byte_offset..code_span.byte_end,
395 format!(
396 "{}{}{}",
397 "`".repeat(code_span.backtick_count),
398 trimmed,
399 "`".repeat(code_span.backtick_count)
400 ),
401 )),
402 });
403 }
404 }
405
406 Ok(warnings)
407 }
408
409 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
410 let content = ctx.content;
411 if !self.enabled {
412 return Ok(content.to_string());
413 }
414
415 if !content.contains('`') {
417 return Ok(content.to_string());
418 }
419
420 let warnings = self.check(ctx)?;
422 let warnings =
423 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
424 if warnings.is_empty() {
425 return Ok(content.to_string());
426 }
427
428 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
430 .into_iter()
431 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
432 .collect();
433
434 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
435
436 let mut result = content.to_string();
438 for (range, replacement) in fixes {
439 result.replace_range(range, &replacement);
440 }
441
442 Ok(result)
443 }
444
445 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
447 !ctx.likely_has_code()
448 }
449
450 fn as_any(&self) -> &dyn std::any::Any {
451 self
452 }
453
454 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
455 where
456 Self: Sized,
457 {
458 Box::new(MD038NoSpaceInCode { enabled: true })
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn test_md038_readme_false_positives() {
468 let rule = MD038NoSpaceInCode::new();
470 let valid_cases = vec![
471 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
472 "#### Effective Configuration (`rumdl config`)",
473 "- Blue: `.rumdl.toml`",
474 "### Defaults Only (`rumdl config --defaults`)",
475 ];
476
477 for case in valid_cases {
478 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
479 let result = rule.check(&ctx).unwrap();
480 assert!(
481 result.is_empty(),
482 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
483 case,
484 result.len()
485 );
486 }
487 }
488
489 #[test]
490 fn test_md038_front_matter() {
491 let rule = MD038NoSpaceInCode::new();
492 let content = "---\ntitle: \"` code `\"\n---\n` code `";
493 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let result = rule.check(&ctx).unwrap();
495 assert_eq!(result.len(), 1);
497 assert_eq!(result[0].line, 4);
498 }
499
500 #[test]
501 fn test_md038_math_block() {
502 let rule = MD038NoSpaceInCode::new();
503 let content = "$$\n` code `\n$$\n` code `";
504 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
505 let result = rule.check(&ctx).unwrap();
506 assert_eq!(result.len(), 1);
508 assert_eq!(result[0].line, 4);
509 }
510
511 #[test]
512 fn test_md038_html_comment() {
513 let rule = MD038NoSpaceInCode::new();
514 let content = "<!--\n` code `\n-->\n` code `";
515 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516 let result = rule.check(&ctx).unwrap();
517 assert_eq!(result.len(), 1);
519 assert_eq!(result[0].line, 4);
520 }
521
522 #[test]
523 fn test_md038_valid() {
524 let rule = MD038NoSpaceInCode::new();
525 let valid_cases = vec![
526 "This is `code` in a sentence.",
527 "This is a `longer code span` in a sentence.",
528 "This is `code with internal spaces` which is fine.",
529 "Code span at `end of line`",
530 "`Start of line` code span",
531 "Multiple `code spans` in `one line` are fine",
532 "Code span with `symbols: !@#$%^&*()`",
533 "Empty code span `` is technically valid",
534 ];
535 for case in valid_cases {
536 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
537 let result = rule.check(&ctx).unwrap();
538 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
539 }
540 }
541
542 #[test]
543 fn test_md038_invalid() {
544 let rule = MD038NoSpaceInCode::new();
545 let invalid_cases = vec![
550 "This is ` code` with leading space.",
552 "This is `code ` with trailing space.",
554 "This is ` code ` with double leading space.",
556 "This is ` code ` with double trailing space.",
558 "This is ` code ` with double spaces both sides.",
560 ];
561 for case in invalid_cases {
562 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
563 let result = rule.check(&ctx).unwrap();
564 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
565 }
566 }
567
568 #[test]
569 fn test_md038_valid_commonmark_stripping() {
570 let rule = MD038NoSpaceInCode::new();
571 let valid_cases = vec![
575 "Type ` y ` to confirm.",
576 "Use ` git commit -m \"message\" ` to commit.",
577 "The variable ` $HOME ` contains home path.",
578 "The pattern ` *.txt ` matches text files.",
579 "This is ` random word ` with unnecessary spaces.",
580 "Text with ` plain text ` is valid.",
581 "Code with ` just code ` here.",
582 "Multiple ` word ` spans with ` text ` in one line.",
583 "This is ` code ` with both leading and trailing single space.",
584 "Use ` - ` as separator.",
585 ];
586 for case in valid_cases {
587 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
588 let result = rule.check(&ctx).unwrap();
589 assert!(
590 result.is_empty(),
591 "Single space on each side should not be flagged (CommonMark strips them): {case}"
592 );
593 }
594 }
595
596 #[test]
597 fn test_md038_whitespace_only_span_not_flagged() {
598 let rule = MD038NoSpaceInCode::new();
604 let whitespace_only_cases = vec![
605 "A single-space span `\u{0020}` is intentional.",
606 "A two-space span `\u{0020}\u{0020}` is intentional.",
607 "A three-space span `\u{0020}\u{0020}\u{0020}` is intentional.",
608 "A tab span `\t` is intentional.",
609 "Just the span: ` `",
610 ];
611 for case in whitespace_only_cases {
612 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
613 let result = rule.check(&ctx).unwrap();
614 assert!(
615 result.is_empty(),
616 "Whitespace-only code span should not be flagged (kept verbatim per CommonMark): {case}"
617 );
618 }
619 }
620
621 #[test]
622 fn test_md038_whitespace_only_span_fix_preserves_verbatim() {
623 let rule = MD038NoSpaceInCode::new();
626 let unchanged_cases = vec![
627 "A single-space span `\u{0020}` is intentional.",
628 "A two-space span `\u{0020}\u{0020}` is intentional.",
629 "Just the span: ` `",
630 ];
631 for case in unchanged_cases {
632 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.fix(&ctx).unwrap();
634 assert_eq!(
635 result, case,
636 "Whitespace-only code span must be left verbatim by fix, not collapsed to ``"
637 );
638 }
639 }
640
641 #[test]
642 fn test_md038_fix() {
643 let rule = MD038NoSpaceInCode::new();
644 let test_cases = vec![
646 (
648 "This is ` code` with leading space.",
649 "This is `code` with leading space.",
650 ),
651 (
653 "This is `code ` with trailing space.",
654 "This is `code` with trailing space.",
655 ),
656 (
658 "This is ` code ` with both spaces.",
659 "This is ` code ` with both spaces.", ),
661 (
663 "This is ` code ` with double leading space.",
664 "This is `code` with double leading space.",
665 ),
666 (
668 "Multiple ` code ` and `spans ` to fix.",
669 "Multiple ` code ` and `spans` to fix.", ),
671 ];
672 for (input, expected) in test_cases {
673 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
674 let result = rule.fix(&ctx).unwrap();
675 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
676 }
677 }
678
679 #[test]
680 fn test_check_invalid_leading_space() {
681 let rule = MD038NoSpaceInCode::new();
682 let input = "This has a ` leading space` in code";
683 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
684 let result = rule.check(&ctx).unwrap();
685 assert_eq!(result.len(), 1);
686 assert_eq!(result[0].line, 1);
687 assert!(result[0].fix.is_some());
688 }
689
690 #[test]
691 fn test_code_span_parsing_nested_backticks() {
692 let content = "Code with ` nested `code` example ` should preserve backticks";
693 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694
695 println!("Content: {content}");
696 println!("Code spans found:");
697 let code_spans = ctx.code_spans();
698 for (i, span) in code_spans.iter().enumerate() {
699 println!(
700 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
701 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
702 );
703 }
704
705 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
707 }
708
709 #[test]
710 fn test_nested_backtick_detection() {
711 let rule = MD038NoSpaceInCode::new();
712
713 let content = "Code with `` `backticks` inside `` should not be flagged";
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(), "Code spans with backticks should be skipped");
718 }
719
720 #[test]
721 fn test_quarto_inline_r_code() {
722 let rule = MD038NoSpaceInCode::new();
724
725 let content = r#"The result is `r nchar("test")` which equals 4."#;
728
729 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
731 let result_quarto = rule.check(&ctx_quarto).unwrap();
732 assert!(
733 result_quarto.is_empty(),
734 "Quarto inline R code should not trigger warnings. Got {} warnings",
735 result_quarto.len()
736 );
737
738 let content_other = "This has `plain text ` with trailing space.";
741 let ctx_other =
742 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
743 let result_other = rule.check(&ctx_other).unwrap();
744 assert_eq!(
745 result_other.len(),
746 1,
747 "Quarto should still flag non-R code spans with improper spaces"
748 );
749 }
750
751 #[test]
757 fn test_hugo_template_syntax_comprehensive() {
758 let rule = MD038NoSpaceInCode::new();
759
760 let valid_hugo_cases = vec![
764 (
766 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
767 "Multi-line raw shortcode",
768 ),
769 (
770 "Some text {{raw ` code `}} more text",
771 "Inline raw shortcode with spaces",
772 ),
773 ("{{raw `code`}}", "Raw shortcode without spaces"),
774 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
776 ("{{< `code` >}}", "Partial shortcode without spaces"),
777 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
779 ("{{% `code` %}}", "Percent shortcode without spaces"),
780 ("{{ ` code ` }}", "Generic shortcode with spaces"),
782 ("{{ `code` }}", "Generic shortcode without spaces"),
783 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
785 ("{{< code `go list` >}}", "Shortcode with code parameter"),
786 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
788 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
789 (
791 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
792 "Nested Go template syntax",
793 ),
794 ("{{raw `code`}}", "Hugo template at line start"),
796 ("Text {{raw `code`}}", "Hugo template at end of line"),
798 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
800 ];
801
802 for (case, description) in valid_hugo_cases {
803 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
804 let result = rule.check(&ctx).unwrap();
805 assert!(
806 result.is_empty(),
807 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
808 );
809 }
810
811 let should_be_flagged = vec![
816 ("This is ` code` with leading space.", "Leading space only"),
817 ("This is `code ` with trailing space.", "Trailing space only"),
818 ("Text ` code ` here", "Extra leading space (asymmetric)"),
819 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
820 ("Text ` code` here", "Double leading, no trailing"),
821 ("Text `code ` here", "No leading, double trailing"),
822 ];
823
824 for (case, description) in should_be_flagged {
825 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
826 let result = rule.check(&ctx).unwrap();
827 assert!(
828 !result.is_empty(),
829 "Should flag asymmetric space code spans: {description} - {case}"
830 );
831 }
832
833 let symmetric_single_space = vec![
839 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
840 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
841 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
842 ];
843
844 for (case, description) in symmetric_single_space {
845 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847 assert!(
848 result.is_empty(),
849 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
850 );
851 }
852
853 let unicode_cases = vec![
856 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
857 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
858 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
859 (
860 "{{raw `\n\tcode with 'single quotes'\n`}}",
861 "Single quotes in Hugo template",
862 ),
863 ];
864
865 for (case, description) in unicode_cases {
866 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
867 let result = rule.check(&ctx).unwrap();
868 assert!(
869 result.is_empty(),
870 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
871 );
872 }
873
874 assert!(
878 rule.check(&crate::lint_context::LintContext::new(
879 "{{ ` ` }}",
880 crate::config::MarkdownFlavor::Standard,
881 None
882 ))
883 .unwrap()
884 .is_empty(),
885 "Minimum Hugo pattern should be valid"
886 );
887
888 assert!(
890 rule.check(&crate::lint_context::LintContext::new(
891 "{{raw `\n\t\n`}}",
892 crate::config::MarkdownFlavor::Standard,
893 None
894 ))
895 .unwrap()
896 .is_empty(),
897 "Hugo template with only whitespace should be valid"
898 );
899 }
900
901 #[test]
903 fn test_hugo_template_with_other_markdown() {
904 let rule = MD038NoSpaceInCode::new();
905
906 let content = r#"1. First item
9082. Second item with {{raw `code`}} template
9093. Third item"#;
910 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let result = rule.check(&ctx).unwrap();
912 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
913
914 let content = r#"> Quote with {{raw `code`}} template"#;
916 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.check(&ctx).unwrap();
918 assert!(
919 result.is_empty(),
920 "Hugo template in blockquote should not trigger warnings"
921 );
922
923 let content = r#"{{raw `code`}} and ` bad code` here"#;
925 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let result = rule.check(&ctx).unwrap();
927 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
928 }
929
930 #[test]
932 fn test_hugo_template_performance() {
933 let rule = MD038NoSpaceInCode::new();
934
935 let mut content = String::new();
937 for i in 0..100 {
938 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
939 }
940
941 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
942 let start = std::time::Instant::now();
943 let result = rule.check(&ctx).unwrap();
944 let duration = start.elapsed();
945
946 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
947 assert!(
948 duration.as_millis() < 1000,
949 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
950 );
951 }
952
953 #[test]
954 fn test_mkdocs_inline_hilite_not_flagged() {
955 let rule = MD038NoSpaceInCode::new();
958
959 let valid_cases = vec![
960 "`#!python print('hello')`",
961 "`#!js alert('hi')`",
962 "`#!c++ cout << x;`",
963 "Use `#!python import os` to import modules",
964 "`#!bash echo $HOME`",
965 ];
966
967 for case in valid_cases {
968 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
969 let result = rule.check(&ctx).unwrap();
970 assert!(
971 result.is_empty(),
972 "InlineHilite syntax should not be flagged in MkDocs: {case}"
973 );
974 }
975
976 let content = "`#!python print('hello')`";
978 let ctx_standard =
979 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
980 let result_standard = rule.check(&ctx_standard).unwrap();
981 assert!(
984 result_standard.is_empty(),
985 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
986 );
987 }
988
989 #[test]
990 fn test_multibyte_utf8_no_panic() {
991 let rule = MD038NoSpaceInCode::new();
995
996 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
998 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
999 let result = rule.check(&ctx);
1000 assert!(result.is_ok(), "Greek text should not panic");
1001
1002 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
1004 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
1005 let result = rule.check(&ctx);
1006 assert!(result.is_ok(), "Chinese text should not panic");
1007
1008 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
1010 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
1011 let result = rule.check(&ctx);
1012 assert!(result.is_ok(), "Cyrillic text should not panic");
1013
1014 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
1016 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
1017 let result = rule.check(&ctx);
1018 assert!(
1019 result.is_ok(),
1020 "Mixed Chinese text with multiple code spans should not panic"
1021 );
1022 }
1023
1024 #[test]
1028 fn test_obsidian_dataview_inline_dql_not_flagged() {
1029 let rule = MD038NoSpaceInCode::new();
1030
1031 let valid_dql_cases = vec![
1033 "`= this.file.name`",
1034 "`= date(today)`",
1035 "`= [[Page]].field`",
1036 "`= choice(condition, \"yes\", \"no\")`",
1037 "`= this.file.mtime`",
1038 "`= this.file.ctime`",
1039 "`= this.file.path`",
1040 "`= this.file.folder`",
1041 "`= this.file.size`",
1042 "`= this.file.ext`",
1043 "`= this.file.link`",
1044 "`= this.file.outlinks`",
1045 "`= this.file.inlinks`",
1046 "`= this.file.tags`",
1047 ];
1048
1049 for case in valid_dql_cases {
1050 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1051 let result = rule.check(&ctx).unwrap();
1052 assert!(
1053 result.is_empty(),
1054 "Dataview DQL expression should not be flagged in Obsidian: {case}"
1055 );
1056 }
1057 }
1058
1059 #[test]
1061 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
1062 let rule = MD038NoSpaceInCode::new();
1063
1064 let valid_dvjs_cases = vec![
1066 "`$= dv.current().file.mtime`",
1067 "`$= dv.pages().length`",
1068 "`$= dv.current()`",
1069 "`$= dv.pages('#tag').length`",
1070 "`$= dv.pages('\"folder\"').length`",
1071 "`$= dv.current().file.name`",
1072 "`$= dv.current().file.path`",
1073 "`$= dv.current().file.folder`",
1074 "`$= dv.current().file.link`",
1075 ];
1076
1077 for case in valid_dvjs_cases {
1078 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1079 let result = rule.check(&ctx).unwrap();
1080 assert!(
1081 result.is_empty(),
1082 "Dataview JS expression should not be flagged in Obsidian: {case}"
1083 );
1084 }
1085 }
1086
1087 #[test]
1089 fn test_obsidian_dataview_complex_expressions() {
1090 let rule = MD038NoSpaceInCode::new();
1091
1092 let complex_cases = vec![
1093 "`= sum(filter(pages, (p) => p.done))`",
1095 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
1096 "`= choice(x > 5, \"big\", \"small\")`",
1098 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
1099 "`= date(today) - dur(7 days)`",
1101 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
1102 "`= sum(rows.amount)`",
1104 "`= round(average(rows.score), 2)`",
1105 "`= min(rows.priority)`",
1106 "`= max(rows.priority)`",
1107 "`= join(this.file.tags, \", \")`",
1109 "`= replace(this.title, \"-\", \" \")`",
1110 "`= lower(this.file.name)`",
1111 "`= upper(this.file.name)`",
1112 "`= length(this.file.outlinks)`",
1114 "`= contains(this.file.tags, \"important\")`",
1115 "`= [[Page Name]].field`",
1117 "`= [[Folder/Subfolder/Page]].nested.field`",
1118 "`= default(this.status, \"unknown\")`",
1120 "`= coalesce(this.priority, this.importance, 0)`",
1121 ];
1122
1123 for case in complex_cases {
1124 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1125 let result = rule.check(&ctx).unwrap();
1126 assert!(
1127 result.is_empty(),
1128 "Complex Dataview expression should not be flagged in Obsidian: {case}"
1129 );
1130 }
1131 }
1132
1133 #[test]
1135 fn test_obsidian_dataviewjs_method_chains() {
1136 let rule = MD038NoSpaceInCode::new();
1137
1138 let method_chain_cases = vec![
1139 "`$= dv.pages().where(p => p.status).length`",
1140 "`$= dv.pages('#project').where(p => !p.done).length`",
1141 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
1142 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
1143 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
1144 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
1145 "`$= dv.page('Index').children.map(p => p.title)`",
1146 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1147 ];
1148
1149 for case in method_chain_cases {
1150 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1151 let result = rule.check(&ctx).unwrap();
1152 assert!(
1153 result.is_empty(),
1154 "DataviewJS method chain should not be flagged in Obsidian: {case}"
1155 );
1156 }
1157 }
1158
1159 #[test]
1168 fn test_standard_flavor_vs_obsidian_dataview() {
1169 let rule = MD038NoSpaceInCode::new();
1170
1171 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1174
1175 for case in no_issue_cases {
1176 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1178 let result_std = rule.check(&ctx_std).unwrap();
1179 assert!(
1180 result_std.is_empty(),
1181 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1182 );
1183
1184 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1186 let result_obs = rule.check(&ctx_obs).unwrap();
1187 assert!(
1188 result_obs.is_empty(),
1189 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1190 );
1191 }
1192
1193 let space_issues = vec![
1196 "` code`", "`code `", ];
1199
1200 for case in space_issues {
1201 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1203 let result_std = rule.check(&ctx_std).unwrap();
1204 assert!(
1205 !result_std.is_empty(),
1206 "Code with spacing issue should be flagged in Standard: {case}"
1207 );
1208
1209 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1211 let result_obs = rule.check(&ctx_obs).unwrap();
1212 assert!(
1213 !result_obs.is_empty(),
1214 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1215 );
1216 }
1217 }
1218
1219 #[test]
1221 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1222 let rule = MD038NoSpaceInCode::new();
1223
1224 let invalid_cases = [
1227 "` regular code`", "`code `", "` code `", "` code`", ];
1232
1233 let expected_flags = [
1235 true, true, false, true, ];
1240
1241 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1242 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1243 let result = rule.check(&ctx).unwrap();
1244 if *should_flag {
1245 assert!(
1246 !result.is_empty(),
1247 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1248 );
1249 } else {
1250 assert!(
1251 result.is_empty(),
1252 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1253 );
1254 }
1255 }
1256 }
1257
1258 #[test]
1260 fn test_obsidian_dataview_edge_cases() {
1261 let rule = MD038NoSpaceInCode::new();
1262
1263 let valid_cases = vec![
1265 ("`= x`", true), ("`$= x`", true), ("`= `", true), ("`$= `", true), ("`=x`", false), ("`$=x`", false), ("`= [[Link]]`", true), ("`= this`", true), ("`$= dv`", true), ("`= 1 + 2`", true), ("`$= 1 + 2`", true), ("`= \"string\"`", true), ("`$= 'string'`", true), ("`= this.field ?? \"default\"`", true), ("`$= dv?.pages()`", true), ];
1281
1282 for (case, should_be_valid) in valid_cases {
1283 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1284 let result = rule.check(&ctx).unwrap();
1285 if should_be_valid {
1286 assert!(
1287 result.is_empty(),
1288 "Valid Dataview expression should not be flagged: {case}"
1289 );
1290 } else {
1291 let _ = result;
1294 }
1295 }
1296 }
1297
1298 #[test]
1300 fn test_obsidian_dataview_in_context() {
1301 let rule = MD038NoSpaceInCode::new();
1302
1303 let content = r#"# My Note
1305
1306The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1307
1308Regular code: `println!("hello")` and `let x = 5;`
1309
1310DataviewJS count: `$= dv.pages('#project').length` projects found.
1311
1312More regular code with issue: ` bad code` should be flagged.
1313"#;
1314
1315 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1316 let result = rule.check(&ctx).unwrap();
1317
1318 assert_eq!(
1320 result.len(),
1321 1,
1322 "Should only flag the regular code span with leading space, not Dataview expressions"
1323 );
1324 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1325 }
1326
1327 #[test]
1329 fn test_obsidian_dataview_in_code_blocks() {
1330 let rule = MD038NoSpaceInCode::new();
1331
1332 let content = r#"# Example
1335
1336```
1337`= this.file.name`
1338`$= dv.current()`
1339```
1340
1341Regular paragraph with `= this.file.name` Dataview.
1342"#;
1343
1344 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1345 let result = rule.check(&ctx).unwrap();
1346
1347 assert!(
1349 result.is_empty(),
1350 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1351 );
1352 }
1353
1354 #[test]
1356 fn test_obsidian_dataview_unicode() {
1357 let rule = MD038NoSpaceInCode::new();
1358
1359 let unicode_cases = vec![
1360 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1367
1368 for case in unicode_cases {
1369 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1370 let result = rule.check(&ctx).unwrap();
1371 assert!(
1372 result.is_empty(),
1373 "Unicode Dataview expression should not be flagged: {case}"
1374 );
1375 }
1376 }
1377
1378 #[test]
1380 fn test_obsidian_regular_equals_still_works() {
1381 let rule = MD038NoSpaceInCode::new();
1382
1383 let valid_regular_cases = vec![
1385 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1391
1392 for case in valid_regular_cases {
1393 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1394 let result = rule.check(&ctx).unwrap();
1395 assert!(
1396 result.is_empty(),
1397 "Regular code with equals should not be flagged: {case}"
1398 );
1399 }
1400 }
1401
1402 #[test]
1404 fn test_obsidian_dataview_fix_preserves_expressions() {
1405 let rule = MD038NoSpaceInCode::new();
1406
1407 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1409 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1410 let fixed = rule.fix(&ctx).unwrap();
1411
1412 assert!(
1414 fixed.contains("`= this.file.name`"),
1415 "Dataview expression should be preserved after fix"
1416 );
1417 assert!(
1418 fixed.contains("`fixme`"),
1419 "Regular code span should be fixed (space removed)"
1420 );
1421 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1422 }
1423
1424 #[test]
1426 fn test_obsidian_multiple_dataview_same_line() {
1427 let rule = MD038NoSpaceInCode::new();
1428
1429 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1430 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1431 let result = rule.check(&ctx).unwrap();
1432
1433 assert!(
1434 result.is_empty(),
1435 "Multiple Dataview expressions on same line should all be valid"
1436 );
1437 }
1438
1439 #[test]
1441 fn test_obsidian_dataview_performance() {
1442 let rule = MD038NoSpaceInCode::new();
1443
1444 let mut content = String::new();
1446 for i in 0..100 {
1447 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1448 }
1449
1450 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1451 let start = std::time::Instant::now();
1452 let result = rule.check(&ctx).unwrap();
1453 let duration = start.elapsed();
1454
1455 assert!(result.is_empty(), "All Dataview expressions should be valid");
1456 assert!(
1457 duration.as_millis() < 1000,
1458 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1459 );
1460 }
1461
1462 #[test]
1464 fn test_is_dataview_expression_helper() {
1465 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1467 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1468 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1469 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1470 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1471 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1472
1473 assert!(!MD038NoSpaceInCode::is_dataview_expression("=")); assert!(!MD038NoSpaceInCode::is_dataview_expression("$=")); assert!(!MD038NoSpaceInCode::is_dataview_expression("=x")); assert!(!MD038NoSpaceInCode::is_dataview_expression("$=x")); assert!(!MD038NoSpaceInCode::is_dataview_expression(" = x")); assert!(!MD038NoSpaceInCode::is_dataview_expression("x = 5")); assert!(!MD038NoSpaceInCode::is_dataview_expression("== x")); assert!(!MD038NoSpaceInCode::is_dataview_expression("")); assert!(!MD038NoSpaceInCode::is_dataview_expression("regular")); }
1484
1485 #[test]
1487 fn test_obsidian_dataview_with_tags() {
1488 let rule = MD038NoSpaceInCode::new();
1489
1490 let content = r#"# Project Status
1492
1493Tags: #project #active
1494
1495Status: `= this.status`
1496Count: `$= dv.pages('#project').length`
1497
1498Regular code: `function test() {}`
1499"#;
1500
1501 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1502 let result = rule.check(&ctx).unwrap();
1503
1504 assert!(
1506 result.is_empty(),
1507 "Dataview expressions and regular code should work together"
1508 );
1509 }
1510
1511 #[test]
1512 fn test_unicode_between_code_spans_no_panic() {
1513 let rule = MD038NoSpaceInCode::new();
1516
1517 let content = "Use `one` \u{00DC}nited `two` for backtick examples.";
1519 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1520 let result = rule.check(&ctx);
1521 assert!(result.is_ok(), "Should not panic with Unicode between code spans");
1523
1524 let content_cjk = "Use `one` \u{4E16}\u{754C} `two` for examples.";
1526 let ctx_cjk = crate::lint_context::LintContext::new(content_cjk, crate::config::MarkdownFlavor::Standard, None);
1527 let result_cjk = rule.check(&ctx_cjk);
1528 assert!(result_cjk.is_ok(), "Should not panic with CJK between code spans");
1529 }
1530
1531 #[test]
1532 fn test_pandoc_inline_r_code_not_exempt() {
1533 let rule = MD038NoSpaceInCode::new();
1539 let content = "See `r foo ` for details.\n";
1542
1543 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1545 let result_quarto = rule.check(&ctx_quarto).unwrap();
1546 assert!(
1547 result_quarto.is_empty(),
1548 "MD038 should suppress trailing-space warning for `r expression` under Quarto: {result_quarto:?}"
1549 );
1550
1551 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1553 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1554 assert!(
1555 !result_pandoc.is_empty(),
1556 "MD038 should flag trailing space in `r expression` under Pandoc flavor (not Quarto/RMarkdown syntax): {result_pandoc:?}"
1557 );
1558 }
1559
1560 #[test]
1565 fn test_pandoc_inline_code_attr_does_not_suppress_leading_space() {
1566 let rule = MD038NoSpaceInCode::new();
1567 let content = "Use ` print()`{.python} for output.\n";
1568 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1569 let result = rule.check(&ctx).unwrap();
1570 assert!(
1571 !result.is_empty(),
1572 "MD038 must flag leading space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1573 );
1574 }
1575
1576 #[test]
1580 fn test_pandoc_inline_code_attr_does_not_suppress_trailing_space() {
1581 let rule = MD038NoSpaceInCode::new();
1582 let content = "Use `print() `{.python} for output.\n";
1583 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1584 let result = rule.check(&ctx).unwrap();
1585 assert!(
1586 !result.is_empty(),
1587 "MD038 must flag trailing space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1588 );
1589 }
1590
1591 #[test]
1593 fn test_standard_still_flags_leading_space_with_attr_syntax() {
1594 let rule = MD038NoSpaceInCode::new();
1595 let content = "Use ` print()`{.python} for output.\n";
1596 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597 let result = rule.check(&ctx).unwrap();
1598 assert!(
1599 !result.is_empty(),
1600 "MD038 should flag leading space in code span under Standard flavor: {result:?}"
1601 );
1602 }
1603
1604 #[test]
1607 fn test_pandoc_inline_code_attr_clean_span_not_flagged() {
1608 let rule = MD038NoSpaceInCode::new();
1609 let content = "Use `print()`{.python} for output.\n";
1610 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1611 let result = rule.check(&ctx).unwrap();
1612 assert!(
1613 result.is_empty(),
1614 "MD038 must not flag a clean attributed code span under Pandoc: {result:?}"
1615 );
1616 }
1617}