1use crate::lint_context::SourceCodeSpan;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::mkdocs_extensions::is_inline_hilite_content;
4
5fn is_code_span_space(c: char) -> bool {
12 matches!(c, ' ' | '\n' | '\r')
13}
14
15pub(crate) struct CodeSpanEdgeSpaces<'a> {
16 trimmed: &'a str,
17 leading: &'a str,
18 trailing: &'a str,
19}
20
21const NESTING_WORDS: [&str; 2] = ["code", "backtick"];
23
24const LINE_ENDINGS: [char; 2] = ['\n', '\r'];
30
31#[derive(Default)]
33struct NestedBacktickState {
34 runs: Option<Vec<(usize, usize)>>,
36 line: Option<LineNesting>,
38}
39
40struct LineNesting {
47 line: usize,
49 char_offsets: Vec<usize>,
51 len: usize,
53 word_end_after_first: Option<usize>,
55 word_start_before_last: Option<usize>,
57}
58
59impl LineNesting {
60 fn new(line_content: &str, line: usize, first: &SourceCodeSpan, last: &SourceCodeSpan) -> Self {
61 let char_offsets = if line_content.is_ascii() {
62 Vec::new()
63 } else {
64 line_content.char_indices().map(|(offset, _)| offset).collect()
65 };
66 let mut nesting = Self {
67 line,
68 char_offsets,
69 len: line_content.len(),
70 word_end_after_first: None,
71 word_start_before_last: None,
72 };
73
74 let after_first = nesting.char_offset(first.end_col);
75 let before_last = nesting.char_offset(last.start_col).unwrap_or(nesting.len);
76
77 for word in NESTING_WORDS {
78 for (start, matched) in line_content.match_indices(word) {
79 let end = start + matched.len();
80 if after_first.is_some_and(|bound| start >= bound) {
81 nesting.word_end_after_first = Some(nesting.word_end_after_first.map_or(end, |e| e.min(end)));
82 }
83 if end <= before_last {
84 nesting.word_start_before_last =
85 Some(nesting.word_start_before_last.map_or(start, |s| s.max(start)));
86 }
87 }
88 }
89
90 nesting
91 }
92
93 fn char_offset(&self, char_index: usize) -> Option<usize> {
95 if self.char_offsets.is_empty() {
96 (char_index < self.len).then_some(char_index)
97 } else {
98 self.char_offsets.get(char_index).copied()
99 }
100 }
101
102 fn names_backticks_before(&self, span: &SourceCodeSpan) -> bool {
104 let Some(word_end) = self.word_end_after_first else {
105 return false;
106 };
107 word_end <= self.char_offset(span.start_col).unwrap_or(self.len)
108 }
109
110 fn names_backticks_after(&self, span: &SourceCodeSpan, last: &SourceCodeSpan) -> bool {
112 let Some(word_start) = self.word_start_before_last else {
113 return false;
114 };
115 let Some(span_end) = self.char_offset(span.end_col.min(last.end_col)) else {
116 return false;
117 };
118 word_start >= span_end
119 }
120
121 fn names_backticks_between(
123 &self,
124 line_content: &str,
125 current_span: &SourceCodeSpan,
126 other_span: &SourceCodeSpan,
127 ) -> bool {
128 let start_char = current_span.end_col.min(other_span.end_col);
129 let end_char = current_span.start_col.max(other_span.start_col);
130 if start_char >= end_char {
131 return false;
132 }
133
134 let Some(start_byte) = self.char_offset(start_char) else {
136 return false;
137 };
138 let end_byte = self.char_offset(end_char).unwrap_or(self.len);
139 if start_byte >= end_byte {
140 return false;
141 }
142
143 let between = &line_content[start_byte..end_byte];
144 NESTING_WORDS.iter().any(|word| between.contains(word))
145 }
146}
147
148#[derive(Debug, Clone, Default)]
173pub struct MD038NoSpaceInCode {
174 pub enabled: bool,
175}
176
177impl MD038NoSpaceInCode {
178 pub(crate) fn code_span_edge_spaces(content: &str) -> Option<CodeSpanEdgeSpaces<'_>> {
181 if !content.starts_with(is_code_span_space) && !content.ends_with(is_code_span_space) {
182 return None;
183 }
184 let without_leading = content.trim_start_matches(is_code_span_space);
185 let trimmed = without_leading.trim_end_matches(is_code_span_space);
186 if trimmed.is_empty() {
187 return None;
188 }
189 let leading_len = content.len() - without_leading.len();
190 let trailing_start = leading_len + trimmed.len();
191 if leading_len == 1 && content.len() - trailing_start == 1 {
192 return None;
193 }
194 Some(CodeSpanEdgeSpaces {
195 trimmed,
196 leading: &content[..leading_len],
197 trailing: &content[trailing_start..],
198 })
199 }
200
201 pub(crate) fn editable_code_span_edges<'a>(
205 source: &str,
206 content: &'a str,
207 body_end: usize,
208 ) -> Option<CodeSpanEdgeSpaces<'a>> {
209 let edges = Self::code_span_edge_spaces(content)?;
210 let trailing_run_start = body_end - edges.trailing.len();
238 let leading_is_structural = edges.leading.contains(LINE_ENDINGS);
239 let trailing_is_structural = !edges.trailing.is_empty()
240 && (edges.trailing.contains(LINE_ENDINGS) || Self::only_layout_before(source, trailing_run_start));
241 if leading_is_structural || trailing_is_structural || edges.trimmed.contains('`') {
242 return None;
243 }
244 Some(edges)
245 }
246
247 pub fn new() -> Self {
248 Self { enabled: true }
249 }
250
251 fn is_hugo_template_syntax(&self, ctx: &crate::lint_context::LintContext, code_span: &SourceCodeSpan) -> bool {
267 let start_line_idx = code_span.line.saturating_sub(1);
268 let Some(start_line) = ctx.lines.get(start_line_idx) else {
269 return false;
270 };
271
272 let start_line_content = start_line.content(ctx.content);
273
274 let Some(span_start) = code_span
276 .byte_offset
277 .checked_sub(start_line.byte_offset)
278 .filter(|offset| *offset <= start_line_content.len())
279 else {
280 return false;
281 };
282
283 if span_start >= 3 {
288 let before_span = &start_line_content[..span_start];
291
292 let char_at_span_start = start_line_content[span_start..].chars().next().unwrap_or(' ');
296
297 let is_hugo_start =
305 (before_span.ends_with("{{raw ") && char_at_span_start == '`')
307 || (before_span.starts_with("{{<") && before_span.ends_with(' ') && char_at_span_start == '`')
309 || (before_span.ends_with("{{% ") && char_at_span_start == '`')
311 || (before_span.ends_with("{{ ") && char_at_span_start == '`');
313
314 if is_hugo_start {
315 let end_line_idx = code_span.end_line.saturating_sub(1);
318 if let Some(end_line) = ctx.lines.get(end_line_idx) {
319 let end_line_content = end_line.content(ctx.content);
320 let span_end = code_span
321 .byte_end
322 .checked_sub(end_line.byte_offset)
323 .unwrap_or(end_line_content.len())
324 .min(end_line_content.len());
325
326 if span_end < end_line_content.len() {
328 let after_span = &end_line_content[span_end..];
329 if after_span.trim_start().starts_with("}}") {
330 return true;
331 }
332 }
333
334 let next_line_idx = code_span.end_line;
336 if next_line_idx < ctx.lines.len() {
337 let next_line = ctx.lines[next_line_idx].content(ctx.content);
338 if next_line.trim_start().starts_with("}}") {
339 return true;
340 }
341 }
342 }
343 }
344 }
345
346 false
347 }
348
349 fn is_dataview_expression(content: &str) -> bool {
365 content.starts_with("= ") || content.starts_with("$= ")
368 }
369
370 fn same_line_runs(code_spans: &[SourceCodeSpan]) -> Vec<(usize, usize)> {
376 let mut runs = vec![(0, 0); code_spans.len()];
377 let mut run_start = 0;
378
379 for index in 1..=code_spans.len() {
380 if index == code_spans.len() || code_spans[index].line != code_spans[run_start].line {
381 runs[run_start..index].fill((run_start, index - 1));
382 run_start = index;
383 }
384 }
385
386 runs
387 }
388
389 fn is_likely_nested_backticks(
391 &self,
392 ctx: &crate::lint_context::LintContext,
393 code_spans: &[SourceCodeSpan],
394 span_index: usize,
395 state: &mut NestedBacktickState,
396 ) -> bool {
397 let current_span = &code_spans[span_index];
400 let (first, last) = {
401 let runs = state.runs.get_or_insert_with(|| Self::same_line_runs(code_spans));
402 runs[span_index]
403 };
404
405 if first == last {
407 return false;
408 }
409
410 let line_idx = current_span.line - 1; if line_idx >= ctx.lines.len() {
414 return false;
415 }
416
417 let line_content = ctx.lines[line_idx].content(ctx.content);
418 let line = match &mut state.line {
419 Some(cached) if cached.line == current_span.line => cached,
420 slot => slot.insert(LineNesting::new(
421 line_content,
422 current_span.line,
423 &code_spans[first],
424 &code_spans[last],
425 )),
426 };
427
428 if current_span.end_line != current_span.line {
433 return line.names_backticks_between(line_content, current_span, &code_spans[first]);
434 }
435
436 line.names_backticks_before(current_span) || line.names_backticks_after(current_span, &code_spans[last])
437 }
438
439 fn has_attached_nested_backtick_boundary(
446 &self,
447 ctx: &crate::lint_context::LintContext,
448 code_span: &crate::lint_context::SourceCodeSpan,
449 ) -> bool {
450 let content = code_span.content(ctx.content);
451
452 let next_char = ctx.content[code_span.byte_end..].chars().next();
453 let prev_char = ctx.content[..code_span.byte_offset].chars().next_back();
454
455 let trailing_neighbor_is_pandoc_attr =
459 ctx.flavor.is_pandoc_compatible() && ctx.is_in_inline_code_attr(code_span.byte_end);
460
461 (content.ends_with(is_code_span_space)
462 && next_char.is_some_and(|c| !is_code_span_space(c) && c != '\t')
463 && !trailing_neighbor_is_pandoc_attr)
464 || (content.starts_with(is_code_span_space)
465 && prev_char.is_some_and(|c| !is_code_span_space(c) && c != '\t'))
466 }
467
468 fn only_layout_before(content: &str, offset: usize) -> bool {
476 let line_start = content[..offset].rfind(LINE_ENDINGS).map_or(0, |i| i + 1);
477 content[line_start..offset]
478 .chars()
479 .all(|c| c == ' ' || c == '\t' || c == '>')
480 }
481}
482
483impl Rule for MD038NoSpaceInCode {
484 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
485 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(
486 if self.enabled { 38 } else { 0 },
487 ))
488 }
489
490 fn name(&self) -> &'static str {
491 "MD038"
492 }
493
494 fn description(&self) -> &'static str {
495 "Spaces inside code span elements"
496 }
497
498 fn category(&self) -> RuleCategory {
499 RuleCategory::Other
500 }
501
502 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
503 if !self.enabled {
504 return Ok(vec![]);
505 }
506
507 let mut warnings = Vec::new();
508
509 let code_spans = ctx.source_code_spans();
511 let mut nesting = NestedBacktickState::default();
514 for (i, code_span) in code_spans.iter().enumerate() {
515 if let Some(line_info) = ctx.lines.get(code_span.line - 1) {
516 if line_info.in_code_block
519 || line_info.in_front_matter
520 || line_info.in_math_block
521 || line_info.in_html_block
522 || line_info.in_html_comment
523 || line_info.in_mkdocstrings
524 || line_info.in_esm_block
525 {
526 continue;
527 }
528 if (line_info.in_mkdocs_container() || line_info.in_pymdown_block)
532 && code_span.content(ctx.content).contains('\n')
533 {
534 continue;
535 }
536 }
537
538 let code_content = code_span.content(ctx.content);
539
540 if let Some(edges) =
541 Self::editable_code_span_edges(ctx.content, code_content, code_span.byte_end - code_span.backtick_count)
542 {
543 let trimmed = edges.trimmed;
544
545 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
550 && trimmed.starts_with('r')
551 && trimmed.len() > 1
552 && trimmed.chars().nth(1).is_some_and(char::is_whitespace)
553 {
554 continue;
555 }
556
557 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
560 continue;
561 }
562
563 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && Self::is_dataview_expression(code_content) {
567 continue;
568 }
569
570 if ctx.flavor.supports_myst_roles() && ctx.is_in_myst_role(code_span.byte_offset) {
573 continue;
574 }
575
576 if ctx.is_in_jinja_string(code_span.byte_offset)
580 || ctx.is_in_jinja_string(code_span.byte_end - code_span.backtick_count)
581 {
582 continue;
583 }
584
585 if self.is_hugo_template_syntax(ctx, code_span) {
588 continue;
589 }
590
591 if ctx.is_in_shortcode(code_span.byte_offset) {
601 continue;
602 }
603
604 if self.is_likely_nested_backticks(ctx, code_spans, i, &mut nesting) {
607 continue;
608 }
609
610 if self.has_attached_nested_backtick_boundary(ctx, code_span) {
611 continue;
612 }
613
614 warnings.push(LintWarning {
615 rule_name: Some(self.name().to_string()),
616 line: code_span.line,
617 column: code_span.start_col + 1, end_line: code_span.end_line,
621 end_column: code_span.end_col + 1,
625 message: "Spaces inside code span elements".to_string(),
626 severity: Severity::Warning,
627 fix: Some(Fix::new(
628 code_span.byte_offset..code_span.byte_end,
629 format!(
630 "{}{}{}",
631 "`".repeat(code_span.backtick_count),
632 trimmed,
633 "`".repeat(code_span.backtick_count)
634 ),
635 )),
636 });
637 }
638 }
639
640 Ok(warnings)
641 }
642
643 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
644 let content = ctx.content;
645 if !self.enabled {
646 return Ok(content.to_string());
647 }
648
649 if !content.contains('`') {
651 return Ok(content.to_string());
652 }
653
654 let warnings = self.check(ctx)?;
656 let warnings =
657 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
658 if warnings.is_empty() {
659 return Ok(content.to_string());
660 }
661
662 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
664 .into_iter()
665 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
666 .collect();
667
668 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
669
670 let mut result = content.to_string();
672 for (range, replacement) in fixes {
673 result.replace_range(range, &replacement);
674 }
675
676 Ok(result)
677 }
678
679 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
681 !ctx.likely_has_code()
682 }
683
684 fn as_any(&self) -> &dyn std::any::Any {
685 self
686 }
687
688 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
689 where
690 Self: Sized,
691 {
692 Box::new(MD038NoSpaceInCode { enabled: true })
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn test_jinja_quoted_backticks_preserve_template_values() {
702 let rule = MD038NoSpaceInCode::new();
703 for template in [
704 "{{ '`hi ` text' }}",
705 r#"{{ "before ` hi` after" }}"#,
706 "{% set text = '`hi ` text' %}{{ text }}",
707 "{{\n '`hi ` text'\n}}",
708 r#"{{ {"text": "`hi ` text"}["text"] }}"#,
709 "{{ '内容 `hi ` text' }}",
710 "{{ '`hi' }} tail `",
711 ] {
712 let source = format!("Before {template}\n");
713 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
714 assert!(
715 rule.check(&ctx).unwrap().is_empty(),
716 "template code is literal: {source}"
717 );
718 assert_eq!(rule.fix(&ctx).unwrap(), source);
719 }
720 }
721
722 #[test]
723 fn test_jinja_quoted_backticks_do_not_hide_adjacent_code_spacing() {
724 let rule = MD038NoSpaceInCode::new();
725 let source = "Before {{ '`hi ` text' }} and ` visible`\n";
726 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
727 let warnings = rule.check(&ctx).unwrap();
728 assert_eq!(warnings.len(), 1);
729 assert_eq!(rule.fix(&ctx).unwrap(), "Before {{ '`hi ` text' }} and `visible`\n");
730 }
731
732 #[test]
733 fn test_jinja_unquoted_lookalikes_retain_code_spacing_checks() {
734 let rule = MD038NoSpaceInCode::new();
735 let source = "Before {{ note `hi ` text }}\n";
736 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
737 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
738 assert_eq!(rule.fix(&ctx).unwrap(), "Before {{ note `hi` text }}\n");
739 }
740
741 #[test]
742 fn test_md038_readme_false_positives() {
743 let rule = MD038NoSpaceInCode::new();
745 let valid_cases = vec![
746 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
747 "#### Effective Configuration (`rumdl config`)",
748 "- Blue: `.rumdl.toml`",
749 "### Defaults Only (`rumdl config --defaults`)",
750 ];
751
752 for case in valid_cases {
753 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
754 let result = rule.check(&ctx).unwrap();
755 assert!(
756 result.is_empty(),
757 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
758 case,
759 result.len()
760 );
761 }
762 }
763
764 #[test]
765 fn test_md038_front_matter() {
766 let rule = MD038NoSpaceInCode::new();
767 let content = "---\ntitle: \"` code `\"\n---\n` code `";
768 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770 assert_eq!(result.len(), 1);
772 assert_eq!(result[0].line, 4);
773 }
774
775 #[test]
776 fn test_md038_math_block() {
777 let rule = MD038NoSpaceInCode::new();
778 let content = "$$\n` code `\n$$\n` code `";
779 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 let result = rule.check(&ctx).unwrap();
781 assert_eq!(result.len(), 1);
783 assert_eq!(result[0].line, 4);
784 }
785
786 #[test]
787 fn test_md038_html_comment() {
788 let rule = MD038NoSpaceInCode::new();
789 let content = "<!--\n` code `\n-->\n` code `";
790 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
791 let result = rule.check(&ctx).unwrap();
792 assert_eq!(result.len(), 1);
794 assert_eq!(result[0].line, 4);
795 }
796
797 #[test]
798 fn test_md038_valid() {
799 let rule = MD038NoSpaceInCode::new();
800 let valid_cases = vec![
801 "This is `code` in a sentence.",
802 "This is a `longer code span` in a sentence.",
803 "This is `code with internal spaces` which is fine.",
804 "Code span at `end of line`",
805 "`Start of line` code span",
806 "Multiple `code spans` in `one line` are fine",
807 "Code span with `symbols: !@#$%^&*()`",
808 "Empty code span `` is technically valid",
809 ];
810 for case in valid_cases {
811 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
812 let result = rule.check(&ctx).unwrap();
813 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
814 }
815 }
816
817 #[test]
818 fn test_md038_invalid() {
819 let rule = MD038NoSpaceInCode::new();
820 let invalid_cases = vec![
825 "This is ` code` with leading space.",
827 "This is `code ` with trailing space.",
829 "This is ` code ` with double leading space.",
831 "This is ` code ` with double trailing space.",
833 "This is ` code ` with double spaces both sides.",
835 ];
836 for case in invalid_cases {
837 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
838 let result = rule.check(&ctx).unwrap();
839 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
840 }
841 }
842
843 #[test]
844 fn test_md038_valid_commonmark_stripping() {
845 let rule = MD038NoSpaceInCode::new();
846 let valid_cases = vec![
850 "Type ` y ` to confirm.",
851 "Use ` git commit -m \"message\" ` to commit.",
852 "The variable ` $HOME ` contains home path.",
853 "The pattern ` *.txt ` matches text files.",
854 "This is ` random word ` with unnecessary spaces.",
855 "Text with ` plain text ` is valid.",
856 "Code with ` just code ` here.",
857 "Multiple ` word ` spans with ` text ` in one line.",
858 "This is ` code ` with both leading and trailing single space.",
859 "Use ` - ` as separator.",
860 ];
861 for case in valid_cases {
862 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
863 let result = rule.check(&ctx).unwrap();
864 assert!(
865 result.is_empty(),
866 "Single space on each side should not be flagged (CommonMark strips them): {case}"
867 );
868 }
869 }
870
871 #[test]
872 fn test_md038_whitespace_only_span_not_flagged() {
873 let rule = MD038NoSpaceInCode::new();
879 let whitespace_only_cases = vec![
880 "A single-space span `\u{0020}` is intentional.",
881 "A two-space span `\u{0020}\u{0020}` is intentional.",
882 "A three-space span `\u{0020}\u{0020}\u{0020}` is intentional.",
883 "A tab span `\t` is intentional.",
884 "Just the span: ` `",
885 ];
886 for case in whitespace_only_cases {
887 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
888 let result = rule.check(&ctx).unwrap();
889 assert!(
890 result.is_empty(),
891 "Whitespace-only code span should not be flagged (kept verbatim per CommonMark): {case}"
892 );
893 }
894 }
895
896 #[test]
897 fn test_md038_whitespace_only_span_fix_preserves_verbatim() {
898 let rule = MD038NoSpaceInCode::new();
901 let unchanged_cases = vec![
902 "A single-space span `\u{0020}` is intentional.",
903 "A two-space span `\u{0020}\u{0020}` is intentional.",
904 "Just the span: ` `",
905 ];
906 for case in unchanged_cases {
907 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
908 let result = rule.fix(&ctx).unwrap();
909 assert_eq!(
910 result, case,
911 "Whitespace-only code span must be left verbatim by fix, not collapsed to ``"
912 );
913 }
914 }
915
916 #[test]
917 fn test_md038_fix() {
918 let rule = MD038NoSpaceInCode::new();
919 let test_cases = vec![
921 (
923 "This is ` code` with leading space.",
924 "This is `code` with leading space.",
925 ),
926 (
928 "This is `code ` with trailing space.",
929 "This is `code` with trailing space.",
930 ),
931 (
933 "This is ` code ` with both spaces.",
934 "This is ` code ` with both spaces.", ),
936 (
938 "This is ` code ` with double leading space.",
939 "This is `code` with double leading space.",
940 ),
941 (
943 "Multiple ` code ` and `spans ` to fix.",
944 "Multiple ` code ` and `spans` to fix.", ),
946 ];
947 for (input, expected) in test_cases {
948 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.fix(&ctx).unwrap();
950 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
951 }
952 }
953
954 #[test]
955 fn test_check_invalid_leading_space() {
956 let rule = MD038NoSpaceInCode::new();
957 let input = "This has a ` leading space` in code";
958 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
959 let result = rule.check(&ctx).unwrap();
960 assert_eq!(result.len(), 1);
961 assert_eq!(result[0].line, 1);
962 assert!(result[0].fix.is_some());
963 }
964
965 #[test]
966 fn test_code_span_parsing_nested_backticks() {
967 let content = "Code with ` nested `code` example ` should preserve backticks";
968 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969
970 println!("Content: {content}");
971 println!("Code spans found:");
972 let code_spans = ctx.code_spans();
973 for (i, span) in code_spans.iter().enumerate() {
974 println!(
975 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
976 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
977 );
978 }
979
980 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
982 }
983
984 #[test]
985 fn test_nested_backtick_detection() {
986 let rule = MD038NoSpaceInCode::new();
987
988 let content = "Code with `` `backticks` inside `` should not be flagged";
990 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
991 let result = rule.check(&ctx).unwrap();
992 assert!(result.is_empty(), "Code spans with backticks should be skipped");
993 }
994
995 #[test]
996 fn test_quarto_inline_r_code() {
997 let rule = MD038NoSpaceInCode::new();
999
1000 let content = r#"The result is `r nchar("test")` which equals 4."#;
1003
1004 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1006 let result_quarto = rule.check(&ctx_quarto).unwrap();
1007 assert!(
1008 result_quarto.is_empty(),
1009 "Quarto inline R code should not trigger warnings. Got {} warnings",
1010 result_quarto.len()
1011 );
1012
1013 let content_other = "This has `plain text ` with trailing space.";
1016 let ctx_other =
1017 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
1018 let result_other = rule.check(&ctx_other).unwrap();
1019 assert_eq!(
1020 result_other.len(),
1021 1,
1022 "Quarto should still flag non-R code spans with improper spaces"
1023 );
1024 }
1025
1026 #[test]
1032 fn test_hugo_template_syntax_comprehensive() {
1033 let rule = MD038NoSpaceInCode::new();
1034
1035 let valid_hugo_cases = vec![
1039 (
1041 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
1042 "Multi-line raw shortcode",
1043 ),
1044 (
1045 "Some text {{raw ` code `}} more text",
1046 "Inline raw shortcode with spaces",
1047 ),
1048 ("{{raw `code`}}", "Raw shortcode without spaces"),
1049 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
1051 ("{{< `code` >}}", "Partial shortcode without spaces"),
1052 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
1054 ("{{% `code` %}}", "Percent shortcode without spaces"),
1055 ("{{ ` code ` }}", "Generic shortcode with spaces"),
1057 ("{{ `code` }}", "Generic shortcode without spaces"),
1058 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
1060 ("{{< code `go list` >}}", "Shortcode with code parameter"),
1061 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
1063 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
1064 (
1066 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
1067 "Nested Go template syntax",
1068 ),
1069 ("{{raw `code`}}", "Hugo template at line start"),
1071 ("Text {{raw `code`}}", "Hugo template at end of line"),
1073 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
1075 ];
1076
1077 for (case, description) in valid_hugo_cases {
1078 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1079 let result = rule.check(&ctx).unwrap();
1080 assert!(
1081 result.is_empty(),
1082 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
1083 );
1084 }
1085
1086 let should_be_flagged = vec![
1091 ("This is ` code` with leading space.", "Leading space only"),
1092 ("This is `code ` with trailing space.", "Trailing space only"),
1093 ("Text ` code ` here", "Extra leading space (asymmetric)"),
1094 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
1095 ("Text ` code` here", "Double leading, no trailing"),
1096 ("Text `code ` here", "No leading, double trailing"),
1097 ];
1098
1099 for (case, description) in should_be_flagged {
1100 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1101 let result = rule.check(&ctx).unwrap();
1102 assert!(
1103 !result.is_empty(),
1104 "Should flag asymmetric space code spans: {description} - {case}"
1105 );
1106 }
1107
1108 let symmetric_single_space = vec![
1114 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
1115 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
1116 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
1117 ];
1118
1119 for (case, description) in symmetric_single_space {
1120 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1121 let result = rule.check(&ctx).unwrap();
1122 assert!(
1123 result.is_empty(),
1124 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
1125 );
1126 }
1127
1128 let unicode_cases = vec![
1131 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
1132 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
1133 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
1134 (
1135 "{{raw `\n\tcode with 'single quotes'\n`}}",
1136 "Single quotes in Hugo template",
1137 ),
1138 ];
1139
1140 for (case, description) in unicode_cases {
1141 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1142 let result = rule.check(&ctx).unwrap();
1143 assert!(
1144 result.is_empty(),
1145 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
1146 );
1147 }
1148
1149 assert!(
1153 rule.check(&crate::lint_context::LintContext::new(
1154 "{{ ` ` }}",
1155 crate::config::MarkdownFlavor::Standard,
1156 None
1157 ))
1158 .unwrap()
1159 .is_empty(),
1160 "Minimum Hugo pattern should be valid"
1161 );
1162
1163 assert!(
1165 rule.check(&crate::lint_context::LintContext::new(
1166 "{{raw `\n\t\n`}}",
1167 crate::config::MarkdownFlavor::Standard,
1168 None
1169 ))
1170 .unwrap()
1171 .is_empty(),
1172 "Hugo template with only whitespace should be valid"
1173 );
1174 }
1175
1176 #[test]
1179 fn test_hugo_template_after_multibyte_text() {
1180 let rule = MD038NoSpaceInCode::new();
1181
1182 let exempt = [
1185 "日本語 {{raw `a ` }}",
1186 "café {{% `a ` }}",
1187 "{{< 日本語 `a ` }}",
1188 "日本語 {{ `a `\n}}",
1189 "日本語 {{raw `a\nb ` }}",
1190 ];
1191 for case in exempt {
1192 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1193 assert!(
1194 rule.check(&ctx).unwrap().is_empty(),
1195 "Hugo template behind multibyte text should not trigger MD038: {case}"
1196 );
1197 }
1198
1199 let flagged = [
1201 "日本語 {{raw`a ` }}",
1202 "café {{ `a ` and",
1203 "{{< 日本語`a ` }}",
1204 "日本語 {{raw`a\nb ` }}",
1205 ];
1206 for case in flagged {
1207 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1208 assert_eq!(
1209 rule.check(&ctx).unwrap().len(),
1210 1,
1211 "Near miss behind multibyte text should still be reported: {case}"
1212 );
1213 }
1214 }
1215
1216 #[test]
1218 fn test_hugo_template_with_other_markdown() {
1219 let rule = MD038NoSpaceInCode::new();
1220
1221 let content = r#"1. First item
12232. Second item with {{raw `code`}} template
12243. Third item"#;
1225 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1226 let result = rule.check(&ctx).unwrap();
1227 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
1228
1229 let content = r#"> Quote with {{raw `code`}} template"#;
1231 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1232 let result = rule.check(&ctx).unwrap();
1233 assert!(
1234 result.is_empty(),
1235 "Hugo template in blockquote should not trigger warnings"
1236 );
1237
1238 let content = r#"{{raw `code`}} and ` bad code` here"#;
1240 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1241 let result = rule.check(&ctx).unwrap();
1242 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
1243 }
1244
1245 #[test]
1247 fn test_hugo_template_performance() {
1248 let rule = MD038NoSpaceInCode::new();
1249
1250 let mut content = String::new();
1252 for i in 0..100 {
1253 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
1254 }
1255
1256 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1257 let start = std::time::Instant::now();
1258 let result = rule.check(&ctx).unwrap();
1259 let duration = start.elapsed();
1260
1261 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
1262 assert!(
1263 duration.as_millis() < 1000,
1264 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
1265 );
1266 }
1267
1268 #[test]
1269 fn test_mkdocs_inline_hilite_not_flagged() {
1270 let rule = MD038NoSpaceInCode::new();
1273
1274 let valid_cases = vec![
1275 "`#!python print('hello')`",
1276 "`#!js alert('hi')`",
1277 "`#!c++ cout << x;`",
1278 "Use `#!python import os` to import modules",
1279 "`#!bash echo $HOME`",
1280 ];
1281
1282 for case in valid_cases {
1283 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
1284 let result = rule.check(&ctx).unwrap();
1285 assert!(
1286 result.is_empty(),
1287 "InlineHilite syntax should not be flagged in MkDocs: {case}"
1288 );
1289 }
1290
1291 let content = "`#!python print('hello')`";
1293 let ctx_standard =
1294 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295 let result_standard = rule.check(&ctx_standard).unwrap();
1296 assert!(
1299 result_standard.is_empty(),
1300 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
1301 );
1302 }
1303
1304 #[test]
1305 fn test_multibyte_utf8_no_panic() {
1306 let rule = MD038NoSpaceInCode::new();
1310
1311 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
1313 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
1314 let result = rule.check(&ctx);
1315 assert!(result.is_ok(), "Greek text should not panic");
1316
1317 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
1319 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
1320 let result = rule.check(&ctx);
1321 assert!(result.is_ok(), "Chinese text should not panic");
1322
1323 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
1325 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
1326 let result = rule.check(&ctx);
1327 assert!(result.is_ok(), "Cyrillic text should not panic");
1328
1329 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
1331 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
1332 let result = rule.check(&ctx);
1333 assert!(
1334 result.is_ok(),
1335 "Mixed Chinese text with multiple code spans should not panic"
1336 );
1337 }
1338
1339 #[test]
1343 fn test_obsidian_dataview_inline_dql_not_flagged() {
1344 let rule = MD038NoSpaceInCode::new();
1345
1346 let valid_dql_cases = vec![
1348 "`= this.file.name`",
1349 "`= date(today)`",
1350 "`= [[Page]].field`",
1351 "`= choice(condition, \"yes\", \"no\")`",
1352 "`= this.file.mtime`",
1353 "`= this.file.ctime`",
1354 "`= this.file.path`",
1355 "`= this.file.folder`",
1356 "`= this.file.size`",
1357 "`= this.file.ext`",
1358 "`= this.file.link`",
1359 "`= this.file.outlinks`",
1360 "`= this.file.inlinks`",
1361 "`= this.file.tags`",
1362 ];
1363
1364 for case in valid_dql_cases {
1365 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1366 let result = rule.check(&ctx).unwrap();
1367 assert!(
1368 result.is_empty(),
1369 "Dataview DQL expression should not be flagged in Obsidian: {case}"
1370 );
1371 }
1372 }
1373
1374 #[test]
1376 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
1377 let rule = MD038NoSpaceInCode::new();
1378
1379 let valid_dvjs_cases = vec![
1381 "`$= dv.current().file.mtime`",
1382 "`$= dv.pages().length`",
1383 "`$= dv.current()`",
1384 "`$= dv.pages('#tag').length`",
1385 "`$= dv.pages('\"folder\"').length`",
1386 "`$= dv.current().file.name`",
1387 "`$= dv.current().file.path`",
1388 "`$= dv.current().file.folder`",
1389 "`$= dv.current().file.link`",
1390 ];
1391
1392 for case in valid_dvjs_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 "Dataview JS expression should not be flagged in Obsidian: {case}"
1398 );
1399 }
1400 }
1401
1402 #[test]
1404 fn test_obsidian_dataview_complex_expressions() {
1405 let rule = MD038NoSpaceInCode::new();
1406
1407 let complex_cases = vec![
1408 "`= sum(filter(pages, (p) => p.done))`",
1410 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
1411 "`= choice(x > 5, \"big\", \"small\")`",
1413 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
1414 "`= date(today) - dur(7 days)`",
1416 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
1417 "`= sum(rows.amount)`",
1419 "`= round(average(rows.score), 2)`",
1420 "`= min(rows.priority)`",
1421 "`= max(rows.priority)`",
1422 "`= join(this.file.tags, \", \")`",
1424 "`= replace(this.title, \"-\", \" \")`",
1425 "`= lower(this.file.name)`",
1426 "`= upper(this.file.name)`",
1427 "`= length(this.file.outlinks)`",
1429 "`= contains(this.file.tags, \"important\")`",
1430 "`= [[Page Name]].field`",
1432 "`= [[Folder/Subfolder/Page]].nested.field`",
1433 "`= default(this.status, \"unknown\")`",
1435 "`= coalesce(this.priority, this.importance, 0)`",
1436 ];
1437
1438 for case in complex_cases {
1439 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1440 let result = rule.check(&ctx).unwrap();
1441 assert!(
1442 result.is_empty(),
1443 "Complex Dataview expression should not be flagged in Obsidian: {case}"
1444 );
1445 }
1446 }
1447
1448 #[test]
1450 fn test_obsidian_dataviewjs_method_chains() {
1451 let rule = MD038NoSpaceInCode::new();
1452
1453 let method_chain_cases = vec![
1454 "`$= dv.pages().where(p => p.status).length`",
1455 "`$= dv.pages('#project').where(p => !p.done).length`",
1456 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
1457 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
1458 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
1459 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
1460 "`$= dv.page('Index').children.map(p => p.title)`",
1461 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1462 ];
1463
1464 for case in method_chain_cases {
1465 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1466 let result = rule.check(&ctx).unwrap();
1467 assert!(
1468 result.is_empty(),
1469 "DataviewJS method chain should not be flagged in Obsidian: {case}"
1470 );
1471 }
1472 }
1473
1474 #[test]
1483 fn test_standard_flavor_vs_obsidian_dataview() {
1484 let rule = MD038NoSpaceInCode::new();
1485
1486 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1489
1490 for case in no_issue_cases {
1491 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1493 let result_std = rule.check(&ctx_std).unwrap();
1494 assert!(
1495 result_std.is_empty(),
1496 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1497 );
1498
1499 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1501 let result_obs = rule.check(&ctx_obs).unwrap();
1502 assert!(
1503 result_obs.is_empty(),
1504 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1505 );
1506 }
1507
1508 let space_issues = vec![
1511 "` code`", "`code `", ];
1514
1515 for case in space_issues {
1516 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1518 let result_std = rule.check(&ctx_std).unwrap();
1519 assert!(
1520 !result_std.is_empty(),
1521 "Code with spacing issue should be flagged in Standard: {case}"
1522 );
1523
1524 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1526 let result_obs = rule.check(&ctx_obs).unwrap();
1527 assert!(
1528 !result_obs.is_empty(),
1529 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1530 );
1531 }
1532 }
1533
1534 #[test]
1536 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1537 let rule = MD038NoSpaceInCode::new();
1538
1539 let invalid_cases = [
1542 "` regular code`", "`code `", "` code `", "` code`", ];
1547
1548 let expected_flags = [
1550 true, true, false, true, ];
1555
1556 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1557 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1558 let result = rule.check(&ctx).unwrap();
1559 if *should_flag {
1560 assert!(
1561 !result.is_empty(),
1562 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1563 );
1564 } else {
1565 assert!(
1566 result.is_empty(),
1567 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1568 );
1569 }
1570 }
1571 }
1572
1573 #[test]
1575 fn test_obsidian_dataview_edge_cases() {
1576 let rule = MD038NoSpaceInCode::new();
1577
1578 let valid_cases = vec![
1580 ("`= 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), ];
1596
1597 for (case, should_be_valid) in valid_cases {
1598 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1599 let result = rule.check(&ctx).unwrap();
1600 if should_be_valid {
1601 assert!(
1602 result.is_empty(),
1603 "Valid Dataview expression should not be flagged: {case}"
1604 );
1605 } else {
1606 let _ = result;
1609 }
1610 }
1611 }
1612
1613 #[test]
1615 fn test_obsidian_dataview_in_context() {
1616 let rule = MD038NoSpaceInCode::new();
1617
1618 let content = r#"# My Note
1620
1621The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1622
1623Regular code: `println!("hello")` and `let x = 5;`
1624
1625DataviewJS count: `$= dv.pages('#project').length` projects found.
1626
1627More regular code with issue: ` bad code` should be flagged.
1628"#;
1629
1630 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1631 let result = rule.check(&ctx).unwrap();
1632
1633 assert_eq!(
1635 result.len(),
1636 1,
1637 "Should only flag the regular code span with leading space, not Dataview expressions"
1638 );
1639 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1640 }
1641
1642 #[test]
1644 fn test_obsidian_dataview_in_code_blocks() {
1645 let rule = MD038NoSpaceInCode::new();
1646
1647 let content = r#"# Example
1650
1651```
1652`= this.file.name`
1653`$= dv.current()`
1654```
1655
1656Regular paragraph with `= this.file.name` Dataview.
1657"#;
1658
1659 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1660 let result = rule.check(&ctx).unwrap();
1661
1662 assert!(
1664 result.is_empty(),
1665 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1666 );
1667 }
1668
1669 #[test]
1671 fn test_obsidian_dataview_unicode() {
1672 let rule = MD038NoSpaceInCode::new();
1673
1674 let unicode_cases = vec![
1675 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1682
1683 for case in unicode_cases {
1684 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1685 let result = rule.check(&ctx).unwrap();
1686 assert!(
1687 result.is_empty(),
1688 "Unicode Dataview expression should not be flagged: {case}"
1689 );
1690 }
1691 }
1692
1693 #[test]
1695 fn test_obsidian_regular_equals_still_works() {
1696 let rule = MD038NoSpaceInCode::new();
1697
1698 let valid_regular_cases = vec![
1700 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1706
1707 for case in valid_regular_cases {
1708 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1709 let result = rule.check(&ctx).unwrap();
1710 assert!(
1711 result.is_empty(),
1712 "Regular code with equals should not be flagged: {case}"
1713 );
1714 }
1715 }
1716
1717 #[test]
1719 fn test_obsidian_dataview_fix_preserves_expressions() {
1720 let rule = MD038NoSpaceInCode::new();
1721
1722 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1724 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1725 let fixed = rule.fix(&ctx).unwrap();
1726
1727 assert!(
1729 fixed.contains("`= this.file.name`"),
1730 "Dataview expression should be preserved after fix"
1731 );
1732 assert!(
1733 fixed.contains("`fixme`"),
1734 "Regular code span should be fixed (space removed)"
1735 );
1736 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1737 }
1738
1739 #[test]
1741 fn test_obsidian_multiple_dataview_same_line() {
1742 let rule = MD038NoSpaceInCode::new();
1743
1744 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1745 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1746 let result = rule.check(&ctx).unwrap();
1747
1748 assert!(
1749 result.is_empty(),
1750 "Multiple Dataview expressions on same line should all be valid"
1751 );
1752 }
1753
1754 #[test]
1756 fn test_obsidian_dataview_performance() {
1757 let rule = MD038NoSpaceInCode::new();
1758
1759 let mut content = String::new();
1761 for i in 0..100 {
1762 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1763 }
1764
1765 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1766 let start = std::time::Instant::now();
1767 let result = rule.check(&ctx).unwrap();
1768 let duration = start.elapsed();
1769
1770 assert!(result.is_empty(), "All Dataview expressions should be valid");
1771 assert!(
1772 duration.as_millis() < 1000,
1773 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1774 );
1775 }
1776
1777 #[test]
1779 fn test_is_dataview_expression_helper() {
1780 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1782 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1783 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1784 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1785 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1786 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1787
1788 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")); }
1799
1800 #[test]
1802 fn test_obsidian_dataview_with_tags() {
1803 let rule = MD038NoSpaceInCode::new();
1804
1805 let content = r#"# Project Status
1807
1808Tags: #project #active
1809
1810Status: `= this.status`
1811Count: `$= dv.pages('#project').length`
1812
1813Regular code: `function test() {}`
1814"#;
1815
1816 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1817 let result = rule.check(&ctx).unwrap();
1818
1819 assert!(
1821 result.is_empty(),
1822 "Dataview expressions and regular code should work together"
1823 );
1824 }
1825
1826 #[test]
1827 fn test_unicode_between_code_spans_no_panic() {
1828 let rule = MD038NoSpaceInCode::new();
1831
1832 let content = "Use `one` \u{00DC}nited `two` for backtick examples.";
1834 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1835 let result = rule.check(&ctx);
1836 assert!(result.is_ok(), "Should not panic with Unicode between code spans");
1838
1839 let content_cjk = "Use `one` \u{4E16}\u{754C} `two` for examples.";
1841 let ctx_cjk = crate::lint_context::LintContext::new(content_cjk, crate::config::MarkdownFlavor::Standard, None);
1842 let result_cjk = rule.check(&ctx_cjk);
1843 assert!(result_cjk.is_ok(), "Should not panic with CJK between code spans");
1844 }
1845
1846 #[test]
1847 fn test_pandoc_inline_r_code_not_exempt() {
1848 let rule = MD038NoSpaceInCode::new();
1854 let content = "See `r foo ` for details.\n";
1857
1858 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1860 let result_quarto = rule.check(&ctx_quarto).unwrap();
1861 assert!(
1862 result_quarto.is_empty(),
1863 "MD038 should suppress trailing-space warning for `r expression` under Quarto: {result_quarto:?}"
1864 );
1865
1866 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1868 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1869 assert!(
1870 !result_pandoc.is_empty(),
1871 "MD038 should flag trailing space in `r expression` under Pandoc flavor (not Quarto/RMarkdown syntax): {result_pandoc:?}"
1872 );
1873 }
1874
1875 #[test]
1880 fn test_pandoc_inline_code_attr_does_not_suppress_leading_space() {
1881 let rule = MD038NoSpaceInCode::new();
1882 let content = "Use ` print()`{.python} for output.\n";
1883 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1884 let result = rule.check(&ctx).unwrap();
1885 assert!(
1886 !result.is_empty(),
1887 "MD038 must flag leading space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1888 );
1889 }
1890
1891 #[test]
1895 fn test_pandoc_inline_code_attr_does_not_suppress_trailing_space() {
1896 let rule = MD038NoSpaceInCode::new();
1897 let content = "Use `print() `{.python} for output.\n";
1898 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1899 let result = rule.check(&ctx).unwrap();
1900 assert!(
1901 !result.is_empty(),
1902 "MD038 must flag trailing space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1903 );
1904 }
1905
1906 #[test]
1908 fn test_standard_still_flags_leading_space_with_attr_syntax() {
1909 let rule = MD038NoSpaceInCode::new();
1910 let content = "Use ` print()`{.python} for output.\n";
1911 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1912 let result = rule.check(&ctx).unwrap();
1913 assert!(
1914 !result.is_empty(),
1915 "MD038 should flag leading space in code span under Standard flavor: {result:?}"
1916 );
1917 }
1918
1919 #[test]
1922 fn test_pandoc_inline_code_attr_clean_span_not_flagged() {
1923 let rule = MD038NoSpaceInCode::new();
1924 let content = "Use `print()`{.python} for output.\n";
1925 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1926 let result = rule.check(&ctx).unwrap();
1927 assert!(
1928 result.is_empty(),
1929 "MD038 must not flag a clean attributed code span under Pandoc: {result:?}"
1930 );
1931 }
1932
1933 #[test]
1937 fn test_trailing_line_ending_is_not_removed() {
1938 let rule = MD038NoSpaceInCode::new();
1939 let content = "Text `a\n` tail\n";
1940 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1941 let result = rule.check(&ctx).unwrap();
1942 assert!(
1943 result.is_empty(),
1944 "MD038 must not flag whitespace it can only remove by deleting a line: {result:?}"
1945 );
1946 assert_eq!(rule.fix(&ctx).unwrap(), content);
1947 }
1948
1949 #[test]
1951 fn test_leading_line_ending_is_not_removed() {
1952 let rule = MD038NoSpaceInCode::new();
1953 let content = "Text ` \na` tail\n";
1954 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1955 let result = rule.check(&ctx).unwrap();
1956 assert!(
1957 result.is_empty(),
1958 "MD038 must not flag a leading whitespace run holding a line ending: {result:?}"
1959 );
1960 assert_eq!(rule.fix(&ctx).unwrap(), content);
1961 }
1962
1963 #[test]
1969 fn test_quarto_callout_with_indented_chunk_is_left_alone() {
1970 let rule = MD038NoSpaceInCode::new();
1971 let content = "::: callout-note\n ```{r}\n x <- 1\n ```\n:::\n";
1972 for flavor in [
1973 crate::config::MarkdownFlavor::Quarto,
1974 crate::config::MarkdownFlavor::Pandoc,
1975 crate::config::MarkdownFlavor::Standard,
1976 ] {
1977 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1978 assert_eq!(
1979 rule.fix(&ctx).unwrap(),
1980 content,
1981 "MD038 rewrote an indented code chunk inside a div under {flavor:?}"
1982 );
1983 }
1984 }
1985
1986 #[test]
1990 fn test_blockquoted_indented_fence_keeps_its_indentation() {
1991 let rule = MD038NoSpaceInCode::new();
1992 for content in [
1993 "> text\n> ```\n> x\n> ```\n",
1994 ">> text\n>> ```\n>> x\n>> ```\n",
1995 "text\n ```\n x\n ```\n",
1996 ] {
1997 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998 assert!(
1999 rule.check(&ctx).unwrap().is_empty(),
2000 "MD038 flagged a line's own indentation in {content:?}"
2001 );
2002 assert_eq!(rule.fix(&ctx).unwrap(), content);
2003 }
2004 }
2005
2006 #[test]
2014 fn test_quoted_line_indentation_is_not_removed() {
2015 let rule = MD038NoSpaceInCode::new();
2016 for content in [
2017 "> Text `a\n> ` tail\n",
2018 "> Text `a\n> ` tail\n",
2019 ">> Text `a\n>> ` tail\n",
2020 "> Text `a\n> b\n> ` tail\n",
2021 "> Text `a\r> ` tail\r",
2024 ] {
2025 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2026 assert!(
2027 rule.check(&ctx).unwrap().is_empty(),
2028 "MD038 flagged a quoted line's own indentation in {content:?}"
2029 );
2030 assert_eq!(rule.fix(&ctx).unwrap(), content);
2031 }
2032 }
2033
2034 #[test]
2041 fn test_a_span_with_no_trailing_run_is_still_trimmed_at_the_front() {
2042 let rule = MD038NoSpaceInCode::new();
2043 let content = "Text ` a\n >` tail\n";
2044 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2045 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "no warning for {content:?}");
2046 assert_eq!(rule.fix(&ctx).unwrap(), "Text `a\n >` tail\n");
2047 }
2048
2049 #[test]
2053 fn test_container_multiline_span_still_trims_a_trailing_space() {
2054 let rule = MD038NoSpaceInCode::new();
2055 for (content, expected) in [
2056 ("> text `a\n> b ` tail\n", "> text `a\n> b` tail\n"),
2057 ("- text `a\n b ` tail\n", "- text `a\n b` tail\n"),
2058 ] {
2059 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2060 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "no warning for {content:?}");
2061 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2062 }
2063 }
2064
2065 #[test]
2069 fn test_multiline_span_still_trims_a_trailing_space() {
2070 let rule = MD038NoSpaceInCode::new();
2071 let content = "Text `a\nb ` tail\n";
2072 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2073 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2074 assert_eq!(rule.fix(&ctx).unwrap(), "Text `a\nb` tail\n");
2075 }
2076
2077 #[test]
2085 fn test_a_span_with_one_untrimmable_end_is_left_alone() {
2086 let rule = MD038NoSpaceInCode::new();
2087 for content in ["Text `\na ` tail\n", "Text ` a\n` tail\n"] {
2088 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2089 assert!(
2090 rule.check(&ctx).unwrap().is_empty(),
2091 "MD038 offered a partial fix that only moves the rendered space: {content:?}"
2092 );
2093 assert_eq!(rule.fix(&ctx).unwrap(), content);
2094 }
2095 }
2096
2097 #[test]
2101 fn test_multiline_span_reports_the_line_it_ends_on() {
2102 let rule = MD038NoSpaceInCode::new();
2103 let content = "Text `a\nb ` tail\n";
2104 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2105 let result = rule.check(&ctx).unwrap();
2106 assert_eq!(result.len(), 1);
2107 assert_eq!(result[0].line, 1);
2108 assert_eq!(result[0].end_line, 2, "warning: {:?}", result[0]);
2109 }
2110
2111 #[test]
2116 fn test_fix_never_changes_the_line_count() {
2117 let rule = MD038NoSpaceInCode::new();
2118 let cases = [
2119 "Text `a\n` tail\n",
2120 "Text `a\n ` tail\n",
2121 "Text ` \na` tail\n",
2122 "::: callout-note\n ```{r}\n x <- 1\n ```\n:::\n",
2123 "Some text.\n ```{r}\n x <- 1\n ```\n",
2124 "> text\n> ```\n> x\n> ```\n",
2125 "Text `a\r\n` tail\r\n",
2126 "Text `a\r` tail\r",
2127 "> text\r> ```\r> x\r> ```\r",
2128 "Text `a\nb ` tail\n",
2129 "Text `a\rb ` tail\r",
2130 ];
2131 let line_endings = |s: &str| s.matches('\n').count() + s.matches('\r').count() - s.matches("\r\n").count();
2135 let mut rewritten = 0;
2136 for content in cases {
2137 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2138 let fixed = rule.fix(&ctx).unwrap();
2139 assert_eq!(
2140 line_endings(&fixed),
2141 line_endings(content),
2142 "MD038 changed the line count of {content:?} -> {fixed:?}"
2143 );
2144 if fixed != content {
2145 rewritten += 1;
2146 }
2147 }
2148 assert_eq!(rewritten, 2, "both positive controls must still be rewritten");
2149 }
2150
2151 #[test]
2155 fn test_carriage_return_is_a_line_ending() {
2156 let rule = MD038NoSpaceInCode::new();
2157 for content in [
2158 "Text `a\r` tail\r",
2159 "Text ` \ra` tail\r",
2160 "text\r ```\r x\r ```\r",
2161 "> text\r> ```\r> x\r> ```\r",
2162 ] {
2163 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2164 assert!(
2165 rule.check(&ctx).unwrap().is_empty(),
2166 "MD038 flagged a carriage return it can only remove by joining lines: {content:?}"
2167 );
2168 assert_eq!(rule.fix(&ctx).unwrap(), content);
2169 }
2170
2171 let content = "Text `a\rb ` tail\r";
2174 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2175 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2176 assert_eq!(rule.fix(&ctx).unwrap(), "Text `a\rb` tail\r");
2177 }
2178
2179 #[test]
2183 fn test_non_space_whitespace_at_the_edge_is_code_content() {
2184 let rule = MD038NoSpaceInCode::new();
2185 for ws in ['\u{a0}', '\u{2003}', '\u{3000}', '\t'] {
2186 for content in [
2187 format!("`b{ws}`\n"),
2188 format!("`{ws}b`\n"),
2189 format!("`{ws}b{ws}`\n"),
2190 format!("text `{ws}b` tail\n"),
2191 ] {
2192 let ctx =
2193 crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2194 assert!(
2195 rule.check(&ctx).unwrap().is_empty(),
2196 "MD038 flagged code content: {content:?}"
2197 );
2198 assert_eq!(rule.fix(&ctx).unwrap(), content);
2199 }
2200 }
2201 }
2202
2203 #[test]
2206 fn test_ascii_space_beside_non_space_whitespace_is_still_fixed() {
2207 let rule = MD038NoSpaceInCode::new();
2208 let content = "`b\u{a0} `\n";
2209 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2210 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2211 assert_eq!(rule.fix(&ctx).unwrap(), "`b\u{a0}`\n");
2212 let content = "` \u{a0}b`\n";
2213 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2214 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2215 assert_eq!(rule.fix(&ctx).unwrap(), "`\u{a0}b`\n");
2216 }
2217}