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) {
263 if line_info.in_code_block {
264 continue;
265 }
266 if (line_info.in_mkdocs_container() || line_info.in_pymdown_block) && code_span.content.contains('\n') {
270 continue;
271 }
272 }
273
274 let code_content = &code_span.content;
275
276 if code_content.is_empty() {
278 continue;
279 }
280
281 let has_leading_space = code_content.chars().next().is_some_and(char::is_whitespace);
283 let has_trailing_space = code_content.chars().last().is_some_and(char::is_whitespace);
284
285 if !has_leading_space && !has_trailing_space {
286 continue;
287 }
288
289 let trimmed = code_content.trim();
290
291 if trimmed.is_empty() {
297 continue;
298 }
299
300 if code_content != trimmed {
302 if has_leading_space && has_trailing_space {
316 let leading_spaces = code_content.len() - code_content.trim_start().len();
317 let trailing_spaces = code_content.len() - code_content.trim_end().len();
318
319 if leading_spaces == 1 && trailing_spaces == 1 {
321 continue;
322 }
323 }
324 if trimmed.contains('`') {
327 continue;
328 }
329
330 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
335 && trimmed.starts_with('r')
336 && trimmed.len() > 1
337 && trimmed.chars().nth(1).is_some_and(char::is_whitespace)
338 {
339 continue;
340 }
341
342 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
345 continue;
346 }
347
348 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && Self::is_dataview_expression(code_content) {
352 continue;
353 }
354
355 if ctx.flavor.supports_myst_roles() && ctx.is_in_myst_role(code_span.byte_offset) {
358 continue;
359 }
360
361 if self.is_hugo_template_syntax(ctx, code_span) {
364 continue;
365 }
366
367 if self.is_likely_nested_backticks(ctx, i) {
370 continue;
371 }
372
373 if self.has_attached_nested_backtick_boundary(ctx, code_span) {
374 continue;
375 }
376
377 warnings.push(LintWarning {
378 rule_name: Some(self.name().to_string()),
379 line: code_span.line,
380 column: code_span.start_col + 1, end_line: code_span.line,
382 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
384 severity: Severity::Warning,
385 fix: Some(Fix::new(
386 code_span.byte_offset..code_span.byte_end,
387 format!(
388 "{}{}{}",
389 "`".repeat(code_span.backtick_count),
390 trimmed,
391 "`".repeat(code_span.backtick_count)
392 ),
393 )),
394 });
395 }
396 }
397
398 Ok(warnings)
399 }
400
401 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
402 let content = ctx.content;
403 if !self.enabled {
404 return Ok(content.to_string());
405 }
406
407 if !content.contains('`') {
409 return Ok(content.to_string());
410 }
411
412 let warnings = self.check(ctx)?;
414 let warnings =
415 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
416 if warnings.is_empty() {
417 return Ok(content.to_string());
418 }
419
420 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
422 .into_iter()
423 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
424 .collect();
425
426 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
427
428 let mut result = content.to_string();
430 for (range, replacement) in fixes {
431 result.replace_range(range, &replacement);
432 }
433
434 Ok(result)
435 }
436
437 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
439 !ctx.likely_has_code()
440 }
441
442 fn as_any(&self) -> &dyn std::any::Any {
443 self
444 }
445
446 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
447 where
448 Self: Sized,
449 {
450 Box::new(MD038NoSpaceInCode { enabled: true })
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn test_md038_readme_false_positives() {
460 let rule = MD038NoSpaceInCode::new();
462 let valid_cases = vec![
463 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
464 "#### Effective Configuration (`rumdl config`)",
465 "- Blue: `.rumdl.toml`",
466 "### Defaults Only (`rumdl config --defaults`)",
467 ];
468
469 for case in valid_cases {
470 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
471 let result = rule.check(&ctx).unwrap();
472 assert!(
473 result.is_empty(),
474 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
475 case,
476 result.len()
477 );
478 }
479 }
480
481 #[test]
482 fn test_md038_valid() {
483 let rule = MD038NoSpaceInCode::new();
484 let valid_cases = vec![
485 "This is `code` in a sentence.",
486 "This is a `longer code span` in a sentence.",
487 "This is `code with internal spaces` which is fine.",
488 "Code span at `end of line`",
489 "`Start of line` code span",
490 "Multiple `code spans` in `one line` are fine",
491 "Code span with `symbols: !@#$%^&*()`",
492 "Empty code span `` is technically valid",
493 ];
494 for case in valid_cases {
495 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
496 let result = rule.check(&ctx).unwrap();
497 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
498 }
499 }
500
501 #[test]
502 fn test_md038_invalid() {
503 let rule = MD038NoSpaceInCode::new();
504 let invalid_cases = vec![
509 "This is ` code` with leading space.",
511 "This is `code ` with trailing space.",
513 "This is ` code ` with double leading space.",
515 "This is ` code ` with double trailing space.",
517 "This is ` code ` with double spaces both sides.",
519 ];
520 for case in invalid_cases {
521 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
522 let result = rule.check(&ctx).unwrap();
523 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
524 }
525 }
526
527 #[test]
528 fn test_md038_valid_commonmark_stripping() {
529 let rule = MD038NoSpaceInCode::new();
530 let valid_cases = vec![
534 "Type ` y ` to confirm.",
535 "Use ` git commit -m \"message\" ` to commit.",
536 "The variable ` $HOME ` contains home path.",
537 "The pattern ` *.txt ` matches text files.",
538 "This is ` random word ` with unnecessary spaces.",
539 "Text with ` plain text ` is valid.",
540 "Code with ` just code ` here.",
541 "Multiple ` word ` spans with ` text ` in one line.",
542 "This is ` code ` with both leading and trailing single space.",
543 "Use ` - ` as separator.",
544 ];
545 for case in valid_cases {
546 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
547 let result = rule.check(&ctx).unwrap();
548 assert!(
549 result.is_empty(),
550 "Single space on each side should not be flagged (CommonMark strips them): {case}"
551 );
552 }
553 }
554
555 #[test]
556 fn test_md038_whitespace_only_span_not_flagged() {
557 let rule = MD038NoSpaceInCode::new();
563 let whitespace_only_cases = vec![
564 "A single-space span `\u{0020}` is intentional.",
565 "A two-space span `\u{0020}\u{0020}` is intentional.",
566 "A three-space span `\u{0020}\u{0020}\u{0020}` is intentional.",
567 "A tab span `\t` is intentional.",
568 "Just the span: ` `",
569 ];
570 for case in whitespace_only_cases {
571 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
572 let result = rule.check(&ctx).unwrap();
573 assert!(
574 result.is_empty(),
575 "Whitespace-only code span should not be flagged (kept verbatim per CommonMark): {case}"
576 );
577 }
578 }
579
580 #[test]
581 fn test_md038_whitespace_only_span_fix_preserves_verbatim() {
582 let rule = MD038NoSpaceInCode::new();
585 let unchanged_cases = vec![
586 "A single-space span `\u{0020}` is intentional.",
587 "A two-space span `\u{0020}\u{0020}` is intentional.",
588 "Just the span: ` `",
589 ];
590 for case in unchanged_cases {
591 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
592 let result = rule.fix(&ctx).unwrap();
593 assert_eq!(
594 result, case,
595 "Whitespace-only code span must be left verbatim by fix, not collapsed to ``"
596 );
597 }
598 }
599
600 #[test]
601 fn test_md038_fix() {
602 let rule = MD038NoSpaceInCode::new();
603 let test_cases = vec![
605 (
607 "This is ` code` with leading space.",
608 "This is `code` with leading space.",
609 ),
610 (
612 "This is `code ` with trailing space.",
613 "This is `code` with trailing space.",
614 ),
615 (
617 "This is ` code ` with both spaces.",
618 "This is ` code ` with both spaces.", ),
620 (
622 "This is ` code ` with double leading space.",
623 "This is `code` with double leading space.",
624 ),
625 (
627 "Multiple ` code ` and `spans ` to fix.",
628 "Multiple ` code ` and `spans` to fix.", ),
630 ];
631 for (input, expected) in test_cases {
632 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.fix(&ctx).unwrap();
634 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
635 }
636 }
637
638 #[test]
639 fn test_check_invalid_leading_space() {
640 let rule = MD038NoSpaceInCode::new();
641 let input = "This has a ` leading space` in code";
642 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644 assert_eq!(result.len(), 1);
645 assert_eq!(result[0].line, 1);
646 assert!(result[0].fix.is_some());
647 }
648
649 #[test]
650 fn test_code_span_parsing_nested_backticks() {
651 let content = "Code with ` nested `code` example ` should preserve backticks";
652 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653
654 println!("Content: {content}");
655 println!("Code spans found:");
656 let code_spans = ctx.code_spans();
657 for (i, span) in code_spans.iter().enumerate() {
658 println!(
659 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
660 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
661 );
662 }
663
664 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
666 }
667
668 #[test]
669 fn test_nested_backtick_detection() {
670 let rule = MD038NoSpaceInCode::new();
671
672 let content = "Code with `` `backticks` inside `` should not be flagged";
674 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676 assert!(result.is_empty(), "Code spans with backticks should be skipped");
677 }
678
679 #[test]
680 fn test_quarto_inline_r_code() {
681 let rule = MD038NoSpaceInCode::new();
683
684 let content = r#"The result is `r nchar("test")` which equals 4."#;
687
688 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
690 let result_quarto = rule.check(&ctx_quarto).unwrap();
691 assert!(
692 result_quarto.is_empty(),
693 "Quarto inline R code should not trigger warnings. Got {} warnings",
694 result_quarto.len()
695 );
696
697 let content_other = "This has `plain text ` with trailing space.";
700 let ctx_other =
701 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
702 let result_other = rule.check(&ctx_other).unwrap();
703 assert_eq!(
704 result_other.len(),
705 1,
706 "Quarto should still flag non-R code spans with improper spaces"
707 );
708 }
709
710 #[test]
716 fn test_hugo_template_syntax_comprehensive() {
717 let rule = MD038NoSpaceInCode::new();
718
719 let valid_hugo_cases = vec![
723 (
725 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
726 "Multi-line raw shortcode",
727 ),
728 (
729 "Some text {{raw ` code `}} more text",
730 "Inline raw shortcode with spaces",
731 ),
732 ("{{raw `code`}}", "Raw shortcode without spaces"),
733 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
735 ("{{< `code` >}}", "Partial shortcode without spaces"),
736 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
738 ("{{% `code` %}}", "Percent shortcode without spaces"),
739 ("{{ ` code ` }}", "Generic shortcode with spaces"),
741 ("{{ `code` }}", "Generic shortcode without spaces"),
742 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
744 ("{{< code `go list` >}}", "Shortcode with code parameter"),
745 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
747 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
748 (
750 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
751 "Nested Go template syntax",
752 ),
753 ("{{raw `code`}}", "Hugo template at line start"),
755 ("Text {{raw `code`}}", "Hugo template at end of line"),
757 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
759 ];
760
761 for (case, description) in valid_hugo_cases {
762 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
763 let result = rule.check(&ctx).unwrap();
764 assert!(
765 result.is_empty(),
766 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
767 );
768 }
769
770 let should_be_flagged = vec![
775 ("This is ` code` with leading space.", "Leading space only"),
776 ("This is `code ` with trailing space.", "Trailing space only"),
777 ("Text ` code ` here", "Extra leading space (asymmetric)"),
778 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
779 ("Text ` code` here", "Double leading, no trailing"),
780 ("Text `code ` here", "No leading, double trailing"),
781 ];
782
783 for (case, description) in should_be_flagged {
784 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
785 let result = rule.check(&ctx).unwrap();
786 assert!(
787 !result.is_empty(),
788 "Should flag asymmetric space code spans: {description} - {case}"
789 );
790 }
791
792 let symmetric_single_space = vec![
798 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
799 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
800 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
801 ];
802
803 for (case, description) in symmetric_single_space {
804 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
805 let result = rule.check(&ctx).unwrap();
806 assert!(
807 result.is_empty(),
808 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
809 );
810 }
811
812 let unicode_cases = vec![
815 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
816 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
817 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
818 (
819 "{{raw `\n\tcode with 'single quotes'\n`}}",
820 "Single quotes in Hugo template",
821 ),
822 ];
823
824 for (case, description) in unicode_cases {
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 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
830 );
831 }
832
833 assert!(
837 rule.check(&crate::lint_context::LintContext::new(
838 "{{ ` ` }}",
839 crate::config::MarkdownFlavor::Standard,
840 None
841 ))
842 .unwrap()
843 .is_empty(),
844 "Minimum Hugo pattern should be valid"
845 );
846
847 assert!(
849 rule.check(&crate::lint_context::LintContext::new(
850 "{{raw `\n\t\n`}}",
851 crate::config::MarkdownFlavor::Standard,
852 None
853 ))
854 .unwrap()
855 .is_empty(),
856 "Hugo template with only whitespace should be valid"
857 );
858 }
859
860 #[test]
862 fn test_hugo_template_with_other_markdown() {
863 let rule = MD038NoSpaceInCode::new();
864
865 let content = r#"1. First item
8672. Second item with {{raw `code`}} template
8683. Third item"#;
869 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
870 let result = rule.check(&ctx).unwrap();
871 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
872
873 let content = r#"> Quote with {{raw `code`}} template"#;
875 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let result = rule.check(&ctx).unwrap();
877 assert!(
878 result.is_empty(),
879 "Hugo template in blockquote should not trigger warnings"
880 );
881
882 let content = r#"{{raw `code`}} and ` bad code` here"#;
884 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let result = rule.check(&ctx).unwrap();
886 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
887 }
888
889 #[test]
891 fn test_hugo_template_performance() {
892 let rule = MD038NoSpaceInCode::new();
893
894 let mut content = String::new();
896 for i in 0..100 {
897 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
898 }
899
900 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
901 let start = std::time::Instant::now();
902 let result = rule.check(&ctx).unwrap();
903 let duration = start.elapsed();
904
905 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
906 assert!(
907 duration.as_millis() < 1000,
908 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
909 );
910 }
911
912 #[test]
913 fn test_mkdocs_inline_hilite_not_flagged() {
914 let rule = MD038NoSpaceInCode::new();
917
918 let valid_cases = vec![
919 "`#!python print('hello')`",
920 "`#!js alert('hi')`",
921 "`#!c++ cout << x;`",
922 "Use `#!python import os` to import modules",
923 "`#!bash echo $HOME`",
924 ];
925
926 for case in valid_cases {
927 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
928 let result = rule.check(&ctx).unwrap();
929 assert!(
930 result.is_empty(),
931 "InlineHilite syntax should not be flagged in MkDocs: {case}"
932 );
933 }
934
935 let content = "`#!python print('hello')`";
937 let ctx_standard =
938 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
939 let result_standard = rule.check(&ctx_standard).unwrap();
940 assert!(
943 result_standard.is_empty(),
944 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
945 );
946 }
947
948 #[test]
949 fn test_multibyte_utf8_no_panic() {
950 let rule = MD038NoSpaceInCode::new();
954
955 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
957 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
958 let result = rule.check(&ctx);
959 assert!(result.is_ok(), "Greek text should not panic");
960
961 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
963 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
964 let result = rule.check(&ctx);
965 assert!(result.is_ok(), "Chinese text should not panic");
966
967 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
969 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
970 let result = rule.check(&ctx);
971 assert!(result.is_ok(), "Cyrillic text should not panic");
972
973 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
975 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx);
977 assert!(
978 result.is_ok(),
979 "Mixed Chinese text with multiple code spans should not panic"
980 );
981 }
982
983 #[test]
987 fn test_obsidian_dataview_inline_dql_not_flagged() {
988 let rule = MD038NoSpaceInCode::new();
989
990 let valid_dql_cases = vec![
992 "`= this.file.name`",
993 "`= date(today)`",
994 "`= [[Page]].field`",
995 "`= choice(condition, \"yes\", \"no\")`",
996 "`= this.file.mtime`",
997 "`= this.file.ctime`",
998 "`= this.file.path`",
999 "`= this.file.folder`",
1000 "`= this.file.size`",
1001 "`= this.file.ext`",
1002 "`= this.file.link`",
1003 "`= this.file.outlinks`",
1004 "`= this.file.inlinks`",
1005 "`= this.file.tags`",
1006 ];
1007
1008 for case in valid_dql_cases {
1009 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1010 let result = rule.check(&ctx).unwrap();
1011 assert!(
1012 result.is_empty(),
1013 "Dataview DQL expression should not be flagged in Obsidian: {case}"
1014 );
1015 }
1016 }
1017
1018 #[test]
1020 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
1021 let rule = MD038NoSpaceInCode::new();
1022
1023 let valid_dvjs_cases = vec![
1025 "`$= dv.current().file.mtime`",
1026 "`$= dv.pages().length`",
1027 "`$= dv.current()`",
1028 "`$= dv.pages('#tag').length`",
1029 "`$= dv.pages('\"folder\"').length`",
1030 "`$= dv.current().file.name`",
1031 "`$= dv.current().file.path`",
1032 "`$= dv.current().file.folder`",
1033 "`$= dv.current().file.link`",
1034 ];
1035
1036 for case in valid_dvjs_cases {
1037 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1038 let result = rule.check(&ctx).unwrap();
1039 assert!(
1040 result.is_empty(),
1041 "Dataview JS expression should not be flagged in Obsidian: {case}"
1042 );
1043 }
1044 }
1045
1046 #[test]
1048 fn test_obsidian_dataview_complex_expressions() {
1049 let rule = MD038NoSpaceInCode::new();
1050
1051 let complex_cases = vec![
1052 "`= sum(filter(pages, (p) => p.done))`",
1054 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
1055 "`= choice(x > 5, \"big\", \"small\")`",
1057 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
1058 "`= date(today) - dur(7 days)`",
1060 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
1061 "`= sum(rows.amount)`",
1063 "`= round(average(rows.score), 2)`",
1064 "`= min(rows.priority)`",
1065 "`= max(rows.priority)`",
1066 "`= join(this.file.tags, \", \")`",
1068 "`= replace(this.title, \"-\", \" \")`",
1069 "`= lower(this.file.name)`",
1070 "`= upper(this.file.name)`",
1071 "`= length(this.file.outlinks)`",
1073 "`= contains(this.file.tags, \"important\")`",
1074 "`= [[Page Name]].field`",
1076 "`= [[Folder/Subfolder/Page]].nested.field`",
1077 "`= default(this.status, \"unknown\")`",
1079 "`= coalesce(this.priority, this.importance, 0)`",
1080 ];
1081
1082 for case in complex_cases {
1083 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1084 let result = rule.check(&ctx).unwrap();
1085 assert!(
1086 result.is_empty(),
1087 "Complex Dataview expression should not be flagged in Obsidian: {case}"
1088 );
1089 }
1090 }
1091
1092 #[test]
1094 fn test_obsidian_dataviewjs_method_chains() {
1095 let rule = MD038NoSpaceInCode::new();
1096
1097 let method_chain_cases = vec![
1098 "`$= dv.pages().where(p => p.status).length`",
1099 "`$= dv.pages('#project').where(p => !p.done).length`",
1100 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
1101 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
1102 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
1103 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
1104 "`$= dv.page('Index').children.map(p => p.title)`",
1105 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1106 ];
1107
1108 for case in method_chain_cases {
1109 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1110 let result = rule.check(&ctx).unwrap();
1111 assert!(
1112 result.is_empty(),
1113 "DataviewJS method chain should not be flagged in Obsidian: {case}"
1114 );
1115 }
1116 }
1117
1118 #[test]
1127 fn test_standard_flavor_vs_obsidian_dataview() {
1128 let rule = MD038NoSpaceInCode::new();
1129
1130 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1133
1134 for case in no_issue_cases {
1135 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1137 let result_std = rule.check(&ctx_std).unwrap();
1138 assert!(
1139 result_std.is_empty(),
1140 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1141 );
1142
1143 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1145 let result_obs = rule.check(&ctx_obs).unwrap();
1146 assert!(
1147 result_obs.is_empty(),
1148 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1149 );
1150 }
1151
1152 let space_issues = vec![
1155 "` code`", "`code `", ];
1158
1159 for case in space_issues {
1160 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1162 let result_std = rule.check(&ctx_std).unwrap();
1163 assert!(
1164 !result_std.is_empty(),
1165 "Code with spacing issue should be flagged in Standard: {case}"
1166 );
1167
1168 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1170 let result_obs = rule.check(&ctx_obs).unwrap();
1171 assert!(
1172 !result_obs.is_empty(),
1173 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1174 );
1175 }
1176 }
1177
1178 #[test]
1180 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1181 let rule = MD038NoSpaceInCode::new();
1182
1183 let invalid_cases = [
1186 "` regular code`", "`code `", "` code `", "` code`", ];
1191
1192 let expected_flags = [
1194 true, true, false, true, ];
1199
1200 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1201 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1202 let result = rule.check(&ctx).unwrap();
1203 if *should_flag {
1204 assert!(
1205 !result.is_empty(),
1206 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1207 );
1208 } else {
1209 assert!(
1210 result.is_empty(),
1211 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1212 );
1213 }
1214 }
1215 }
1216
1217 #[test]
1219 fn test_obsidian_dataview_edge_cases() {
1220 let rule = MD038NoSpaceInCode::new();
1221
1222 let valid_cases = vec![
1224 ("`= 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), ];
1240
1241 for (case, should_be_valid) in valid_cases {
1242 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1243 let result = rule.check(&ctx).unwrap();
1244 if should_be_valid {
1245 assert!(
1246 result.is_empty(),
1247 "Valid Dataview expression should not be flagged: {case}"
1248 );
1249 } else {
1250 let _ = result;
1253 }
1254 }
1255 }
1256
1257 #[test]
1259 fn test_obsidian_dataview_in_context() {
1260 let rule = MD038NoSpaceInCode::new();
1261
1262 let content = r#"# My Note
1264
1265The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1266
1267Regular code: `println!("hello")` and `let x = 5;`
1268
1269DataviewJS count: `$= dv.pages('#project').length` projects found.
1270
1271More regular code with issue: ` bad code` should be flagged.
1272"#;
1273
1274 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1275 let result = rule.check(&ctx).unwrap();
1276
1277 assert_eq!(
1279 result.len(),
1280 1,
1281 "Should only flag the regular code span with leading space, not Dataview expressions"
1282 );
1283 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1284 }
1285
1286 #[test]
1288 fn test_obsidian_dataview_in_code_blocks() {
1289 let rule = MD038NoSpaceInCode::new();
1290
1291 let content = r#"# Example
1294
1295```
1296`= this.file.name`
1297`$= dv.current()`
1298```
1299
1300Regular paragraph with `= this.file.name` Dataview.
1301"#;
1302
1303 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1304 let result = rule.check(&ctx).unwrap();
1305
1306 assert!(
1308 result.is_empty(),
1309 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1310 );
1311 }
1312
1313 #[test]
1315 fn test_obsidian_dataview_unicode() {
1316 let rule = MD038NoSpaceInCode::new();
1317
1318 let unicode_cases = vec![
1319 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1326
1327 for case in unicode_cases {
1328 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1329 let result = rule.check(&ctx).unwrap();
1330 assert!(
1331 result.is_empty(),
1332 "Unicode Dataview expression should not be flagged: {case}"
1333 );
1334 }
1335 }
1336
1337 #[test]
1339 fn test_obsidian_regular_equals_still_works() {
1340 let rule = MD038NoSpaceInCode::new();
1341
1342 let valid_regular_cases = vec![
1344 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1350
1351 for case in valid_regular_cases {
1352 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1353 let result = rule.check(&ctx).unwrap();
1354 assert!(
1355 result.is_empty(),
1356 "Regular code with equals should not be flagged: {case}"
1357 );
1358 }
1359 }
1360
1361 #[test]
1363 fn test_obsidian_dataview_fix_preserves_expressions() {
1364 let rule = MD038NoSpaceInCode::new();
1365
1366 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1368 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1369 let fixed = rule.fix(&ctx).unwrap();
1370
1371 assert!(
1373 fixed.contains("`= this.file.name`"),
1374 "Dataview expression should be preserved after fix"
1375 );
1376 assert!(
1377 fixed.contains("`fixme`"),
1378 "Regular code span should be fixed (space removed)"
1379 );
1380 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1381 }
1382
1383 #[test]
1385 fn test_obsidian_multiple_dataview_same_line() {
1386 let rule = MD038NoSpaceInCode::new();
1387
1388 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1389 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1390 let result = rule.check(&ctx).unwrap();
1391
1392 assert!(
1393 result.is_empty(),
1394 "Multiple Dataview expressions on same line should all be valid"
1395 );
1396 }
1397
1398 #[test]
1400 fn test_obsidian_dataview_performance() {
1401 let rule = MD038NoSpaceInCode::new();
1402
1403 let mut content = String::new();
1405 for i in 0..100 {
1406 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1407 }
1408
1409 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1410 let start = std::time::Instant::now();
1411 let result = rule.check(&ctx).unwrap();
1412 let duration = start.elapsed();
1413
1414 assert!(result.is_empty(), "All Dataview expressions should be valid");
1415 assert!(
1416 duration.as_millis() < 1000,
1417 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1418 );
1419 }
1420
1421 #[test]
1423 fn test_is_dataview_expression_helper() {
1424 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1426 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1427 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1428 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1429 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1430 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1431
1432 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")); }
1443
1444 #[test]
1446 fn test_obsidian_dataview_with_tags() {
1447 let rule = MD038NoSpaceInCode::new();
1448
1449 let content = r#"# Project Status
1451
1452Tags: #project #active
1453
1454Status: `= this.status`
1455Count: `$= dv.pages('#project').length`
1456
1457Regular code: `function test() {}`
1458"#;
1459
1460 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1461 let result = rule.check(&ctx).unwrap();
1462
1463 assert!(
1465 result.is_empty(),
1466 "Dataview expressions and regular code should work together"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_unicode_between_code_spans_no_panic() {
1472 let rule = MD038NoSpaceInCode::new();
1475
1476 let content = "Use `one` \u{00DC}nited `two` for backtick examples.";
1478 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479 let result = rule.check(&ctx);
1480 assert!(result.is_ok(), "Should not panic with Unicode between code spans");
1482
1483 let content_cjk = "Use `one` \u{4E16}\u{754C} `two` for examples.";
1485 let ctx_cjk = crate::lint_context::LintContext::new(content_cjk, crate::config::MarkdownFlavor::Standard, None);
1486 let result_cjk = rule.check(&ctx_cjk);
1487 assert!(result_cjk.is_ok(), "Should not panic with CJK between code spans");
1488 }
1489
1490 #[test]
1491 fn test_pandoc_inline_r_code_not_exempt() {
1492 let rule = MD038NoSpaceInCode::new();
1498 let content = "See `r foo ` for details.\n";
1501
1502 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1504 let result_quarto = rule.check(&ctx_quarto).unwrap();
1505 assert!(
1506 result_quarto.is_empty(),
1507 "MD038 should suppress trailing-space warning for `r expression` under Quarto: {result_quarto:?}"
1508 );
1509
1510 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1512 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1513 assert!(
1514 !result_pandoc.is_empty(),
1515 "MD038 should flag trailing space in `r expression` under Pandoc flavor (not Quarto/RMarkdown syntax): {result_pandoc:?}"
1516 );
1517 }
1518
1519 #[test]
1524 fn test_pandoc_inline_code_attr_does_not_suppress_leading_space() {
1525 let rule = MD038NoSpaceInCode::new();
1526 let content = "Use ` print()`{.python} for output.\n";
1527 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1528 let result = rule.check(&ctx).unwrap();
1529 assert!(
1530 !result.is_empty(),
1531 "MD038 must flag leading space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1532 );
1533 }
1534
1535 #[test]
1539 fn test_pandoc_inline_code_attr_does_not_suppress_trailing_space() {
1540 let rule = MD038NoSpaceInCode::new();
1541 let content = "Use `print() `{.python} for output.\n";
1542 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1543 let result = rule.check(&ctx).unwrap();
1544 assert!(
1545 !result.is_empty(),
1546 "MD038 must flag trailing space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1547 );
1548 }
1549
1550 #[test]
1552 fn test_standard_still_flags_leading_space_with_attr_syntax() {
1553 let rule = MD038NoSpaceInCode::new();
1554 let content = "Use ` print()`{.python} for output.\n";
1555 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1556 let result = rule.check(&ctx).unwrap();
1557 assert!(
1558 !result.is_empty(),
1559 "MD038 should flag leading space in code span under Standard flavor: {result:?}"
1560 );
1561 }
1562
1563 #[test]
1566 fn test_pandoc_inline_code_attr_clean_span_not_flagged() {
1567 let rule = MD038NoSpaceInCode::new();
1568 let content = "Use `print()`{.python} for output.\n";
1569 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1570 let result = rule.check(&ctx).unwrap();
1571 assert!(
1572 result.is_empty(),
1573 "MD038 must not flag a clean attributed code span under Pandoc: {result:?}"
1574 );
1575 }
1576}