1use crate::lint_context::CodeSpan;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::mkdocs_extensions::is_inline_hilite_content;
4
5const NESTING_WORDS: [&str; 2] = ["code", "backtick"];
7
8const LINE_ENDINGS: [char; 2] = ['\n', '\r'];
14
15#[derive(Default)]
17struct NestedBacktickState {
18 runs: Option<Vec<(usize, usize)>>,
20 line: Option<LineNesting>,
22}
23
24struct LineNesting {
31 line: usize,
33 char_offsets: Vec<usize>,
35 len: usize,
37 word_end_after_first: Option<usize>,
39 word_start_before_last: Option<usize>,
41}
42
43impl LineNesting {
44 fn new(line_content: &str, line: usize, first: &CodeSpan, last: &CodeSpan) -> Self {
45 let char_offsets = if line_content.is_ascii() {
46 Vec::new()
47 } else {
48 line_content.char_indices().map(|(offset, _)| offset).collect()
49 };
50 let mut nesting = Self {
51 line,
52 char_offsets,
53 len: line_content.len(),
54 word_end_after_first: None,
55 word_start_before_last: None,
56 };
57
58 let after_first = nesting.char_offset(first.end_col);
59 let before_last = nesting.char_offset(last.start_col).unwrap_or(nesting.len);
60
61 for word in NESTING_WORDS {
62 for (start, matched) in line_content.match_indices(word) {
63 let end = start + matched.len();
64 if after_first.is_some_and(|bound| start >= bound) {
65 nesting.word_end_after_first = Some(nesting.word_end_after_first.map_or(end, |e| e.min(end)));
66 }
67 if end <= before_last {
68 nesting.word_start_before_last =
69 Some(nesting.word_start_before_last.map_or(start, |s| s.max(start)));
70 }
71 }
72 }
73
74 nesting
75 }
76
77 fn char_offset(&self, char_index: usize) -> Option<usize> {
79 if self.char_offsets.is_empty() {
80 (char_index < self.len).then_some(char_index)
81 } else {
82 self.char_offsets.get(char_index).copied()
83 }
84 }
85
86 fn names_backticks_before(&self, span: &CodeSpan) -> bool {
88 let Some(word_end) = self.word_end_after_first else {
89 return false;
90 };
91 word_end <= self.char_offset(span.start_col).unwrap_or(self.len)
92 }
93
94 fn names_backticks_after(&self, span: &CodeSpan, last: &CodeSpan) -> bool {
96 let Some(word_start) = self.word_start_before_last else {
97 return false;
98 };
99 let Some(span_end) = self.char_offset(span.end_col.min(last.end_col)) else {
100 return false;
101 };
102 word_start >= span_end
103 }
104
105 fn names_backticks_between(&self, line_content: &str, current_span: &CodeSpan, other_span: &CodeSpan) -> bool {
107 let start_char = current_span.end_col.min(other_span.end_col);
108 let end_char = current_span.start_col.max(other_span.start_col);
109 if start_char >= end_char {
110 return false;
111 }
112
113 let Some(start_byte) = self.char_offset(start_char) else {
115 return false;
116 };
117 let end_byte = self.char_offset(end_char).unwrap_or(self.len);
118 if start_byte >= end_byte {
119 return false;
120 }
121
122 let between = &line_content[start_byte..end_byte];
123 NESTING_WORDS.iter().any(|word| between.contains(word))
124 }
125}
126
127#[derive(Debug, Clone, Default)]
152pub struct MD038NoSpaceInCode {
153 pub enabled: bool,
154}
155
156impl MD038NoSpaceInCode {
157 pub fn new() -> Self {
158 Self { enabled: true }
159 }
160
161 fn is_hugo_template_syntax(&self, ctx: &crate::lint_context::LintContext, code_span: &CodeSpan) -> bool {
177 let start_line_idx = code_span.line.saturating_sub(1);
178 let Some(start_line) = ctx.lines.get(start_line_idx) else {
179 return false;
180 };
181
182 let start_line_content = start_line.content(ctx.content);
183
184 let Some(span_start) = code_span
186 .byte_offset
187 .checked_sub(start_line.byte_offset)
188 .filter(|offset| *offset <= start_line_content.len())
189 else {
190 return false;
191 };
192
193 if span_start >= 3 {
198 let before_span = &start_line_content[..span_start];
201
202 let char_at_span_start = start_line_content[span_start..].chars().next().unwrap_or(' ');
206
207 let is_hugo_start =
215 (before_span.ends_with("{{raw ") && char_at_span_start == '`')
217 || (before_span.starts_with("{{<") && before_span.ends_with(' ') && char_at_span_start == '`')
219 || (before_span.ends_with("{{% ") && char_at_span_start == '`')
221 || (before_span.ends_with("{{ ") && char_at_span_start == '`');
223
224 if is_hugo_start {
225 let end_line_idx = code_span.end_line.saturating_sub(1);
228 if let Some(end_line) = ctx.lines.get(end_line_idx) {
229 let end_line_content = end_line.content(ctx.content);
230 let span_end = code_span
231 .byte_end
232 .checked_sub(end_line.byte_offset)
233 .unwrap_or(end_line_content.len())
234 .min(end_line_content.len());
235
236 if span_end < end_line_content.len() {
238 let after_span = &end_line_content[span_end..];
239 if after_span.trim_start().starts_with("}}") {
240 return true;
241 }
242 }
243
244 let next_line_idx = code_span.end_line;
246 if next_line_idx < ctx.lines.len() {
247 let next_line = ctx.lines[next_line_idx].content(ctx.content);
248 if next_line.trim_start().starts_with("}}") {
249 return true;
250 }
251 }
252 }
253 }
254 }
255
256 false
257 }
258
259 fn is_dataview_expression(content: &str) -> bool {
275 content.starts_with("= ") || content.starts_with("$= ")
278 }
279
280 fn same_line_runs(code_spans: &[CodeSpan]) -> Vec<(usize, usize)> {
286 let mut runs = vec![(0, 0); code_spans.len()];
287 let mut run_start = 0;
288
289 for index in 1..=code_spans.len() {
290 if index == code_spans.len() || code_spans[index].line != code_spans[run_start].line {
291 runs[run_start..index].fill((run_start, index - 1));
292 run_start = index;
293 }
294 }
295
296 runs
297 }
298
299 fn is_likely_nested_backticks(
301 &self,
302 ctx: &crate::lint_context::LintContext,
303 code_spans: &[CodeSpan],
304 span_index: usize,
305 state: &mut NestedBacktickState,
306 ) -> bool {
307 let current_span = &code_spans[span_index];
310 let (first, last) = {
311 let runs = state.runs.get_or_insert_with(|| Self::same_line_runs(code_spans));
312 runs[span_index]
313 };
314
315 if first == last {
317 return false;
318 }
319
320 let line_idx = current_span.line - 1; if line_idx >= ctx.lines.len() {
324 return false;
325 }
326
327 let line_content = ctx.lines[line_idx].content(ctx.content);
328 let line = match &mut state.line {
329 Some(cached) if cached.line == current_span.line => cached,
330 slot => slot.insert(LineNesting::new(
331 line_content,
332 current_span.line,
333 &code_spans[first],
334 &code_spans[last],
335 )),
336 };
337
338 if current_span.end_line != current_span.line {
343 return line.names_backticks_between(line_content, current_span, &code_spans[first]);
344 }
345
346 line.names_backticks_before(current_span) || line.names_backticks_after(current_span, &code_spans[last])
347 }
348
349 fn has_attached_nested_backtick_boundary(
356 &self,
357 ctx: &crate::lint_context::LintContext,
358 code_span: &crate::lint_context::CodeSpan,
359 ) -> bool {
360 let content = code_span.content.as_str();
361
362 let next_char = ctx.content[code_span.byte_end..].chars().next();
363 let prev_char = ctx.content[..code_span.byte_offset].chars().next_back();
364
365 let trailing_neighbor_is_pandoc_attr =
369 ctx.flavor.is_pandoc_compatible() && ctx.is_in_inline_code_attr(code_span.byte_end);
370
371 (content.ends_with(char::is_whitespace)
372 && next_char.is_some_and(|c| !c.is_whitespace())
373 && !trailing_neighbor_is_pandoc_attr)
374 || (content.starts_with(char::is_whitespace) && prev_char.is_some_and(|c| !c.is_whitespace()))
375 }
376
377 fn only_layout_before(content: &str, offset: usize) -> bool {
385 let line_start = content[..offset].rfind(LINE_ENDINGS).map_or(0, |i| i + 1);
386 content[line_start..offset]
387 .chars()
388 .all(|c| c.is_whitespace() || c == '>')
389 }
390}
391
392impl Rule for MD038NoSpaceInCode {
393 fn name(&self) -> &'static str {
394 "MD038"
395 }
396
397 fn description(&self) -> &'static str {
398 "Spaces inside code span elements"
399 }
400
401 fn category(&self) -> RuleCategory {
402 RuleCategory::Other
403 }
404
405 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
406 if !self.enabled {
407 return Ok(vec![]);
408 }
409
410 let mut warnings = Vec::new();
411
412 let code_spans = ctx.code_spans();
414 let mut nesting = NestedBacktickState::default();
417 for (i, code_span) in code_spans.iter().enumerate() {
418 if let Some(line_info) = ctx.lines.get(code_span.line - 1) {
419 if line_info.in_code_block
422 || line_info.in_front_matter
423 || line_info.in_math_block
424 || line_info.in_html_block
425 || line_info.in_html_comment
426 || line_info.in_mkdocstrings
427 || line_info.in_esm_block
428 {
429 continue;
430 }
431 if (line_info.in_mkdocs_container() || line_info.in_pymdown_block) && code_span.content.contains('\n') {
435 continue;
436 }
437 }
438
439 let code_content = &code_span.content;
440
441 if code_content.is_empty() {
443 continue;
444 }
445
446 let has_leading_space = code_content.chars().next().is_some_and(char::is_whitespace);
448 let has_trailing_space = code_content.chars().last().is_some_and(char::is_whitespace);
449
450 if !has_leading_space && !has_trailing_space {
451 continue;
452 }
453
454 let trimmed = code_content.trim();
455
456 if trimmed.is_empty() {
462 continue;
463 }
464
465 if code_content != trimmed {
467 if has_leading_space && has_trailing_space {
481 let leading_spaces = code_content.len() - code_content.trim_start().len();
482 let trailing_spaces = code_content.len() - code_content.trim_end().len();
483
484 if leading_spaces == 1 && trailing_spaces == 1 {
486 continue;
487 }
488 }
489
490 let leading = &code_content[..code_content.len() - code_content.trim_start().len()];
518 let trailing = &code_content[code_content.trim_end().len()..];
519
520 let trailing_run_start = ctx.content[..code_span.byte_end - code_span.backtick_count]
523 .trim_end()
524 .len();
525
526 let leading_is_structural = leading.contains(LINE_ENDINGS);
531 let trailing_is_structural = !trailing.is_empty()
532 && (trailing.contains(LINE_ENDINGS) || Self::only_layout_before(ctx.content, trailing_run_start));
533
534 if leading_is_structural || trailing_is_structural {
535 continue;
536 }
537
538 if trimmed.contains('`') {
541 continue;
542 }
543
544 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
549 && trimmed.starts_with('r')
550 && trimmed.len() > 1
551 && trimmed.chars().nth(1).is_some_and(char::is_whitespace)
552 {
553 continue;
554 }
555
556 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
559 continue;
560 }
561
562 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && Self::is_dataview_expression(code_content) {
566 continue;
567 }
568
569 if ctx.flavor.supports_myst_roles() && ctx.is_in_myst_role(code_span.byte_offset) {
572 continue;
573 }
574
575 if ctx.is_in_jinja_string(code_span.byte_offset)
579 || ctx.is_in_jinja_string(code_span.byte_end - code_span.backtick_count)
580 {
581 continue;
582 }
583
584 if self.is_hugo_template_syntax(ctx, code_span) {
587 continue;
588 }
589
590 if ctx.is_in_shortcode(code_span.byte_offset) {
600 continue;
601 }
602
603 if self.is_likely_nested_backticks(ctx, &code_spans, i, &mut nesting) {
606 continue;
607 }
608
609 if self.has_attached_nested_backtick_boundary(ctx, code_span) {
610 continue;
611 }
612
613 warnings.push(LintWarning {
614 rule_name: Some(self.name().to_string()),
615 line: code_span.line,
616 column: code_span.start_col + 1, end_line: code_span.end_line,
620 end_column: code_span.end_col + 1,
624 message: "Spaces inside code span elements".to_string(),
625 severity: Severity::Warning,
626 fix: Some(Fix::new(
627 code_span.byte_offset..code_span.byte_end,
628 format!(
629 "{}{}{}",
630 "`".repeat(code_span.backtick_count),
631 trimmed,
632 "`".repeat(code_span.backtick_count)
633 ),
634 )),
635 });
636 }
637 }
638
639 Ok(warnings)
640 }
641
642 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
643 let content = ctx.content;
644 if !self.enabled {
645 return Ok(content.to_string());
646 }
647
648 if !content.contains('`') {
650 return Ok(content.to_string());
651 }
652
653 let warnings = self.check(ctx)?;
655 let warnings =
656 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
657 if warnings.is_empty() {
658 return Ok(content.to_string());
659 }
660
661 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
663 .into_iter()
664 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
665 .collect();
666
667 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
668
669 let mut result = content.to_string();
671 for (range, replacement) in fixes {
672 result.replace_range(range, &replacement);
673 }
674
675 Ok(result)
676 }
677
678 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
680 !ctx.likely_has_code()
681 }
682
683 fn as_any(&self) -> &dyn std::any::Any {
684 self
685 }
686
687 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
688 where
689 Self: Sized,
690 {
691 Box::new(MD038NoSpaceInCode { enabled: true })
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698
699 #[test]
700 fn test_jinja_quoted_backticks_preserve_template_values() {
701 let rule = MD038NoSpaceInCode::new();
702 for template in [
703 "{{ '`hi ` text' }}",
704 r#"{{ "before ` hi` after" }}"#,
705 "{% set text = '`hi ` text' %}{{ text }}",
706 "{{\n '`hi ` text'\n}}",
707 r#"{{ {"text": "`hi ` text"}["text"] }}"#,
708 "{{ '内容 `hi ` text' }}",
709 "{{ '`hi' }} tail `",
710 ] {
711 let source = format!("Before {template}\n");
712 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
713 assert!(
714 rule.check(&ctx).unwrap().is_empty(),
715 "template code is literal: {source}"
716 );
717 assert_eq!(rule.fix(&ctx).unwrap(), source);
718 }
719 }
720
721 #[test]
722 fn test_jinja_quoted_backticks_do_not_hide_adjacent_code_spacing() {
723 let rule = MD038NoSpaceInCode::new();
724 let source = "Before {{ '`hi ` text' }} and ` visible`\n";
725 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
726 let warnings = rule.check(&ctx).unwrap();
727 assert_eq!(warnings.len(), 1);
728 assert_eq!(rule.fix(&ctx).unwrap(), "Before {{ '`hi ` text' }} and `visible`\n");
729 }
730
731 #[test]
732 fn test_jinja_unquoted_lookalikes_retain_code_spacing_checks() {
733 let rule = MD038NoSpaceInCode::new();
734 let source = "Before {{ note `hi ` text }}\n";
735 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
736 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
737 assert_eq!(rule.fix(&ctx).unwrap(), "Before {{ note `hi` text }}\n");
738 }
739
740 #[test]
741 fn test_md038_readme_false_positives() {
742 let rule = MD038NoSpaceInCode::new();
744 let valid_cases = vec![
745 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
746 "#### Effective Configuration (`rumdl config`)",
747 "- Blue: `.rumdl.toml`",
748 "### Defaults Only (`rumdl config --defaults`)",
749 ];
750
751 for case in valid_cases {
752 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
753 let result = rule.check(&ctx).unwrap();
754 assert!(
755 result.is_empty(),
756 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
757 case,
758 result.len()
759 );
760 }
761 }
762
763 #[test]
764 fn test_md038_front_matter() {
765 let rule = MD038NoSpaceInCode::new();
766 let content = "---\ntitle: \"` code `\"\n---\n` code `";
767 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768 let result = rule.check(&ctx).unwrap();
769 assert_eq!(result.len(), 1);
771 assert_eq!(result[0].line, 4);
772 }
773
774 #[test]
775 fn test_md038_math_block() {
776 let rule = MD038NoSpaceInCode::new();
777 let content = "$$\n` code `\n$$\n` code `";
778 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779 let result = rule.check(&ctx).unwrap();
780 assert_eq!(result.len(), 1);
782 assert_eq!(result[0].line, 4);
783 }
784
785 #[test]
786 fn test_md038_html_comment() {
787 let rule = MD038NoSpaceInCode::new();
788 let content = "<!--\n` code `\n-->\n` code `";
789 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert_eq!(result.len(), 1);
793 assert_eq!(result[0].line, 4);
794 }
795
796 #[test]
797 fn test_md038_valid() {
798 let rule = MD038NoSpaceInCode::new();
799 let valid_cases = vec![
800 "This is `code` in a sentence.",
801 "This is a `longer code span` in a sentence.",
802 "This is `code with internal spaces` which is fine.",
803 "Code span at `end of line`",
804 "`Start of line` code span",
805 "Multiple `code spans` in `one line` are fine",
806 "Code span with `symbols: !@#$%^&*()`",
807 "Empty code span `` is technically valid",
808 ];
809 for case in valid_cases {
810 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
811 let result = rule.check(&ctx).unwrap();
812 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
813 }
814 }
815
816 #[test]
817 fn test_md038_invalid() {
818 let rule = MD038NoSpaceInCode::new();
819 let invalid_cases = vec![
824 "This is ` code` with leading space.",
826 "This is `code ` with trailing space.",
828 "This is ` code ` with double leading space.",
830 "This is ` code ` with double trailing space.",
832 "This is ` code ` with double spaces both sides.",
834 ];
835 for case in invalid_cases {
836 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
839 }
840 }
841
842 #[test]
843 fn test_md038_valid_commonmark_stripping() {
844 let rule = MD038NoSpaceInCode::new();
845 let valid_cases = vec![
849 "Type ` y ` to confirm.",
850 "Use ` git commit -m \"message\" ` to commit.",
851 "The variable ` $HOME ` contains home path.",
852 "The pattern ` *.txt ` matches text files.",
853 "This is ` random word ` with unnecessary spaces.",
854 "Text with ` plain text ` is valid.",
855 "Code with ` just code ` here.",
856 "Multiple ` word ` spans with ` text ` in one line.",
857 "This is ` code ` with both leading and trailing single space.",
858 "Use ` - ` as separator.",
859 ];
860 for case in valid_cases {
861 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
862 let result = rule.check(&ctx).unwrap();
863 assert!(
864 result.is_empty(),
865 "Single space on each side should not be flagged (CommonMark strips them): {case}"
866 );
867 }
868 }
869
870 #[test]
871 fn test_md038_whitespace_only_span_not_flagged() {
872 let rule = MD038NoSpaceInCode::new();
878 let whitespace_only_cases = vec![
879 "A single-space span `\u{0020}` is intentional.",
880 "A two-space span `\u{0020}\u{0020}` is intentional.",
881 "A three-space span `\u{0020}\u{0020}\u{0020}` is intentional.",
882 "A tab span `\t` is intentional.",
883 "Just the span: ` `",
884 ];
885 for case in whitespace_only_cases {
886 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
887 let result = rule.check(&ctx).unwrap();
888 assert!(
889 result.is_empty(),
890 "Whitespace-only code span should not be flagged (kept verbatim per CommonMark): {case}"
891 );
892 }
893 }
894
895 #[test]
896 fn test_md038_whitespace_only_span_fix_preserves_verbatim() {
897 let rule = MD038NoSpaceInCode::new();
900 let unchanged_cases = vec![
901 "A single-space span `\u{0020}` is intentional.",
902 "A two-space span `\u{0020}\u{0020}` is intentional.",
903 "Just the span: ` `",
904 ];
905 for case in unchanged_cases {
906 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
907 let result = rule.fix(&ctx).unwrap();
908 assert_eq!(
909 result, case,
910 "Whitespace-only code span must be left verbatim by fix, not collapsed to ``"
911 );
912 }
913 }
914
915 #[test]
916 fn test_md038_fix() {
917 let rule = MD038NoSpaceInCode::new();
918 let test_cases = vec![
920 (
922 "This is ` code` with leading space.",
923 "This is `code` with leading space.",
924 ),
925 (
927 "This is `code ` with trailing space.",
928 "This is `code` with trailing space.",
929 ),
930 (
932 "This is ` code ` with both spaces.",
933 "This is ` code ` with both spaces.", ),
935 (
937 "This is ` code ` with double leading space.",
938 "This is `code` with double leading space.",
939 ),
940 (
942 "Multiple ` code ` and `spans ` to fix.",
943 "Multiple ` code ` and `spans` to fix.", ),
945 ];
946 for (input, expected) in test_cases {
947 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
948 let result = rule.fix(&ctx).unwrap();
949 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
950 }
951 }
952
953 #[test]
954 fn test_check_invalid_leading_space() {
955 let rule = MD038NoSpaceInCode::new();
956 let input = "This has a ` leading space` in code";
957 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
958 let result = rule.check(&ctx).unwrap();
959 assert_eq!(result.len(), 1);
960 assert_eq!(result[0].line, 1);
961 assert!(result[0].fix.is_some());
962 }
963
964 #[test]
965 fn test_code_span_parsing_nested_backticks() {
966 let content = "Code with ` nested `code` example ` should preserve backticks";
967 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
968
969 println!("Content: {content}");
970 println!("Code spans found:");
971 let code_spans = ctx.code_spans();
972 for (i, span) in code_spans.iter().enumerate() {
973 println!(
974 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
975 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
976 );
977 }
978
979 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
981 }
982
983 #[test]
984 fn test_nested_backtick_detection() {
985 let rule = MD038NoSpaceInCode::new();
986
987 let content = "Code with `` `backticks` inside `` should not be flagged";
989 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990 let result = rule.check(&ctx).unwrap();
991 assert!(result.is_empty(), "Code spans with backticks should be skipped");
992 }
993
994 #[test]
995 fn test_quarto_inline_r_code() {
996 let rule = MD038NoSpaceInCode::new();
998
999 let content = r#"The result is `r nchar("test")` which equals 4."#;
1002
1003 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1005 let result_quarto = rule.check(&ctx_quarto).unwrap();
1006 assert!(
1007 result_quarto.is_empty(),
1008 "Quarto inline R code should not trigger warnings. Got {} warnings",
1009 result_quarto.len()
1010 );
1011
1012 let content_other = "This has `plain text ` with trailing space.";
1015 let ctx_other =
1016 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
1017 let result_other = rule.check(&ctx_other).unwrap();
1018 assert_eq!(
1019 result_other.len(),
1020 1,
1021 "Quarto should still flag non-R code spans with improper spaces"
1022 );
1023 }
1024
1025 #[test]
1031 fn test_hugo_template_syntax_comprehensive() {
1032 let rule = MD038NoSpaceInCode::new();
1033
1034 let valid_hugo_cases = vec![
1038 (
1040 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
1041 "Multi-line raw shortcode",
1042 ),
1043 (
1044 "Some text {{raw ` code `}} more text",
1045 "Inline raw shortcode with spaces",
1046 ),
1047 ("{{raw `code`}}", "Raw shortcode without spaces"),
1048 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
1050 ("{{< `code` >}}", "Partial shortcode without spaces"),
1051 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
1053 ("{{% `code` %}}", "Percent shortcode without spaces"),
1054 ("{{ ` code ` }}", "Generic shortcode with spaces"),
1056 ("{{ `code` }}", "Generic shortcode without spaces"),
1057 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
1059 ("{{< code `go list` >}}", "Shortcode with code parameter"),
1060 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
1062 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
1063 (
1065 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
1066 "Nested Go template syntax",
1067 ),
1068 ("{{raw `code`}}", "Hugo template at line start"),
1070 ("Text {{raw `code`}}", "Hugo template at end of line"),
1072 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
1074 ];
1075
1076 for (case, description) in valid_hugo_cases {
1077 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1078 let result = rule.check(&ctx).unwrap();
1079 assert!(
1080 result.is_empty(),
1081 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
1082 );
1083 }
1084
1085 let should_be_flagged = vec![
1090 ("This is ` code` with leading space.", "Leading space only"),
1091 ("This is `code ` with trailing space.", "Trailing space only"),
1092 ("Text ` code ` here", "Extra leading space (asymmetric)"),
1093 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
1094 ("Text ` code` here", "Double leading, no trailing"),
1095 ("Text `code ` here", "No leading, double trailing"),
1096 ];
1097
1098 for (case, description) in should_be_flagged {
1099 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101 assert!(
1102 !result.is_empty(),
1103 "Should flag asymmetric space code spans: {description} - {case}"
1104 );
1105 }
1106
1107 let symmetric_single_space = vec![
1113 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
1114 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
1115 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
1116 ];
1117
1118 for (case, description) in symmetric_single_space {
1119 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1120 let result = rule.check(&ctx).unwrap();
1121 assert!(
1122 result.is_empty(),
1123 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
1124 );
1125 }
1126
1127 let unicode_cases = vec![
1130 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
1131 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
1132 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
1133 (
1134 "{{raw `\n\tcode with 'single quotes'\n`}}",
1135 "Single quotes in Hugo template",
1136 ),
1137 ];
1138
1139 for (case, description) in unicode_cases {
1140 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.check(&ctx).unwrap();
1142 assert!(
1143 result.is_empty(),
1144 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
1145 );
1146 }
1147
1148 assert!(
1152 rule.check(&crate::lint_context::LintContext::new(
1153 "{{ ` ` }}",
1154 crate::config::MarkdownFlavor::Standard,
1155 None
1156 ))
1157 .unwrap()
1158 .is_empty(),
1159 "Minimum Hugo pattern should be valid"
1160 );
1161
1162 assert!(
1164 rule.check(&crate::lint_context::LintContext::new(
1165 "{{raw `\n\t\n`}}",
1166 crate::config::MarkdownFlavor::Standard,
1167 None
1168 ))
1169 .unwrap()
1170 .is_empty(),
1171 "Hugo template with only whitespace should be valid"
1172 );
1173 }
1174
1175 #[test]
1178 fn test_hugo_template_after_multibyte_text() {
1179 let rule = MD038NoSpaceInCode::new();
1180
1181 let exempt = [
1184 "日本語 {{raw `a ` }}",
1185 "café {{% `a ` }}",
1186 "{{< 日本語 `a ` }}",
1187 "日本語 {{ `a `\n}}",
1188 "日本語 {{raw `a\nb ` }}",
1189 ];
1190 for case in exempt {
1191 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1192 assert!(
1193 rule.check(&ctx).unwrap().is_empty(),
1194 "Hugo template behind multibyte text should not trigger MD038: {case}"
1195 );
1196 }
1197
1198 let flagged = [
1200 "日本語 {{raw`a ` }}",
1201 "café {{ `a ` and",
1202 "{{< 日本語`a ` }}",
1203 "日本語 {{raw`a\nb ` }}",
1204 ];
1205 for case in flagged {
1206 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1207 assert_eq!(
1208 rule.check(&ctx).unwrap().len(),
1209 1,
1210 "Near miss behind multibyte text should still be reported: {case}"
1211 );
1212 }
1213 }
1214
1215 #[test]
1217 fn test_hugo_template_with_other_markdown() {
1218 let rule = MD038NoSpaceInCode::new();
1219
1220 let content = r#"1. First item
12222. Second item with {{raw `code`}} template
12233. Third item"#;
1224 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
1227
1228 let content = r#"> Quote with {{raw `code`}} template"#;
1230 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1231 let result = rule.check(&ctx).unwrap();
1232 assert!(
1233 result.is_empty(),
1234 "Hugo template in blockquote should not trigger warnings"
1235 );
1236
1237 let content = r#"{{raw `code`}} and ` bad code` here"#;
1239 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let result = rule.check(&ctx).unwrap();
1241 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
1242 }
1243
1244 #[test]
1246 fn test_hugo_template_performance() {
1247 let rule = MD038NoSpaceInCode::new();
1248
1249 let mut content = String::new();
1251 for i in 0..100 {
1252 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
1253 }
1254
1255 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1256 let start = std::time::Instant::now();
1257 let result = rule.check(&ctx).unwrap();
1258 let duration = start.elapsed();
1259
1260 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
1261 assert!(
1262 duration.as_millis() < 1000,
1263 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
1264 );
1265 }
1266
1267 #[test]
1268 fn test_mkdocs_inline_hilite_not_flagged() {
1269 let rule = MD038NoSpaceInCode::new();
1272
1273 let valid_cases = vec![
1274 "`#!python print('hello')`",
1275 "`#!js alert('hi')`",
1276 "`#!c++ cout << x;`",
1277 "Use `#!python import os` to import modules",
1278 "`#!bash echo $HOME`",
1279 ];
1280
1281 for case in valid_cases {
1282 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
1283 let result = rule.check(&ctx).unwrap();
1284 assert!(
1285 result.is_empty(),
1286 "InlineHilite syntax should not be flagged in MkDocs: {case}"
1287 );
1288 }
1289
1290 let content = "`#!python print('hello')`";
1292 let ctx_standard =
1293 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let result_standard = rule.check(&ctx_standard).unwrap();
1295 assert!(
1298 result_standard.is_empty(),
1299 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
1300 );
1301 }
1302
1303 #[test]
1304 fn test_multibyte_utf8_no_panic() {
1305 let rule = MD038NoSpaceInCode::new();
1309
1310 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
1312 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
1313 let result = rule.check(&ctx);
1314 assert!(result.is_ok(), "Greek text should not panic");
1315
1316 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
1318 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
1319 let result = rule.check(&ctx);
1320 assert!(result.is_ok(), "Chinese text should not panic");
1321
1322 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
1324 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
1325 let result = rule.check(&ctx);
1326 assert!(result.is_ok(), "Cyrillic text should not panic");
1327
1328 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
1330 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
1331 let result = rule.check(&ctx);
1332 assert!(
1333 result.is_ok(),
1334 "Mixed Chinese text with multiple code spans should not panic"
1335 );
1336 }
1337
1338 #[test]
1342 fn test_obsidian_dataview_inline_dql_not_flagged() {
1343 let rule = MD038NoSpaceInCode::new();
1344
1345 let valid_dql_cases = vec![
1347 "`= this.file.name`",
1348 "`= date(today)`",
1349 "`= [[Page]].field`",
1350 "`= choice(condition, \"yes\", \"no\")`",
1351 "`= this.file.mtime`",
1352 "`= this.file.ctime`",
1353 "`= this.file.path`",
1354 "`= this.file.folder`",
1355 "`= this.file.size`",
1356 "`= this.file.ext`",
1357 "`= this.file.link`",
1358 "`= this.file.outlinks`",
1359 "`= this.file.inlinks`",
1360 "`= this.file.tags`",
1361 ];
1362
1363 for case in valid_dql_cases {
1364 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1365 let result = rule.check(&ctx).unwrap();
1366 assert!(
1367 result.is_empty(),
1368 "Dataview DQL expression should not be flagged in Obsidian: {case}"
1369 );
1370 }
1371 }
1372
1373 #[test]
1375 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
1376 let rule = MD038NoSpaceInCode::new();
1377
1378 let valid_dvjs_cases = vec![
1380 "`$= dv.current().file.mtime`",
1381 "`$= dv.pages().length`",
1382 "`$= dv.current()`",
1383 "`$= dv.pages('#tag').length`",
1384 "`$= dv.pages('\"folder\"').length`",
1385 "`$= dv.current().file.name`",
1386 "`$= dv.current().file.path`",
1387 "`$= dv.current().file.folder`",
1388 "`$= dv.current().file.link`",
1389 ];
1390
1391 for case in valid_dvjs_cases {
1392 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1393 let result = rule.check(&ctx).unwrap();
1394 assert!(
1395 result.is_empty(),
1396 "Dataview JS expression should not be flagged in Obsidian: {case}"
1397 );
1398 }
1399 }
1400
1401 #[test]
1403 fn test_obsidian_dataview_complex_expressions() {
1404 let rule = MD038NoSpaceInCode::new();
1405
1406 let complex_cases = vec![
1407 "`= sum(filter(pages, (p) => p.done))`",
1409 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
1410 "`= choice(x > 5, \"big\", \"small\")`",
1412 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
1413 "`= date(today) - dur(7 days)`",
1415 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
1416 "`= sum(rows.amount)`",
1418 "`= round(average(rows.score), 2)`",
1419 "`= min(rows.priority)`",
1420 "`= max(rows.priority)`",
1421 "`= join(this.file.tags, \", \")`",
1423 "`= replace(this.title, \"-\", \" \")`",
1424 "`= lower(this.file.name)`",
1425 "`= upper(this.file.name)`",
1426 "`= length(this.file.outlinks)`",
1428 "`= contains(this.file.tags, \"important\")`",
1429 "`= [[Page Name]].field`",
1431 "`= [[Folder/Subfolder/Page]].nested.field`",
1432 "`= default(this.status, \"unknown\")`",
1434 "`= coalesce(this.priority, this.importance, 0)`",
1435 ];
1436
1437 for case in complex_cases {
1438 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1439 let result = rule.check(&ctx).unwrap();
1440 assert!(
1441 result.is_empty(),
1442 "Complex Dataview expression should not be flagged in Obsidian: {case}"
1443 );
1444 }
1445 }
1446
1447 #[test]
1449 fn test_obsidian_dataviewjs_method_chains() {
1450 let rule = MD038NoSpaceInCode::new();
1451
1452 let method_chain_cases = vec![
1453 "`$= dv.pages().where(p => p.status).length`",
1454 "`$= dv.pages('#project').where(p => !p.done).length`",
1455 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
1456 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
1457 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
1458 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
1459 "`$= dv.page('Index').children.map(p => p.title)`",
1460 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1461 ];
1462
1463 for case in method_chain_cases {
1464 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1465 let result = rule.check(&ctx).unwrap();
1466 assert!(
1467 result.is_empty(),
1468 "DataviewJS method chain should not be flagged in Obsidian: {case}"
1469 );
1470 }
1471 }
1472
1473 #[test]
1482 fn test_standard_flavor_vs_obsidian_dataview() {
1483 let rule = MD038NoSpaceInCode::new();
1484
1485 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1488
1489 for case in no_issue_cases {
1490 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1492 let result_std = rule.check(&ctx_std).unwrap();
1493 assert!(
1494 result_std.is_empty(),
1495 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1496 );
1497
1498 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1500 let result_obs = rule.check(&ctx_obs).unwrap();
1501 assert!(
1502 result_obs.is_empty(),
1503 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1504 );
1505 }
1506
1507 let space_issues = vec![
1510 "` code`", "`code `", ];
1513
1514 for case in space_issues {
1515 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1517 let result_std = rule.check(&ctx_std).unwrap();
1518 assert!(
1519 !result_std.is_empty(),
1520 "Code with spacing issue should be flagged in Standard: {case}"
1521 );
1522
1523 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1525 let result_obs = rule.check(&ctx_obs).unwrap();
1526 assert!(
1527 !result_obs.is_empty(),
1528 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1529 );
1530 }
1531 }
1532
1533 #[test]
1535 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1536 let rule = MD038NoSpaceInCode::new();
1537
1538 let invalid_cases = [
1541 "` regular code`", "`code `", "` code `", "` code`", ];
1546
1547 let expected_flags = [
1549 true, true, false, true, ];
1554
1555 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1556 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1557 let result = rule.check(&ctx).unwrap();
1558 if *should_flag {
1559 assert!(
1560 !result.is_empty(),
1561 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1562 );
1563 } else {
1564 assert!(
1565 result.is_empty(),
1566 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1567 );
1568 }
1569 }
1570 }
1571
1572 #[test]
1574 fn test_obsidian_dataview_edge_cases() {
1575 let rule = MD038NoSpaceInCode::new();
1576
1577 let valid_cases = vec![
1579 ("`= 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), ];
1595
1596 for (case, should_be_valid) in valid_cases {
1597 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1598 let result = rule.check(&ctx).unwrap();
1599 if should_be_valid {
1600 assert!(
1601 result.is_empty(),
1602 "Valid Dataview expression should not be flagged: {case}"
1603 );
1604 } else {
1605 let _ = result;
1608 }
1609 }
1610 }
1611
1612 #[test]
1614 fn test_obsidian_dataview_in_context() {
1615 let rule = MD038NoSpaceInCode::new();
1616
1617 let content = r#"# My Note
1619
1620The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1621
1622Regular code: `println!("hello")` and `let x = 5;`
1623
1624DataviewJS count: `$= dv.pages('#project').length` projects found.
1625
1626More regular code with issue: ` bad code` should be flagged.
1627"#;
1628
1629 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1630 let result = rule.check(&ctx).unwrap();
1631
1632 assert_eq!(
1634 result.len(),
1635 1,
1636 "Should only flag the regular code span with leading space, not Dataview expressions"
1637 );
1638 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1639 }
1640
1641 #[test]
1643 fn test_obsidian_dataview_in_code_blocks() {
1644 let rule = MD038NoSpaceInCode::new();
1645
1646 let content = r#"# Example
1649
1650```
1651`= this.file.name`
1652`$= dv.current()`
1653```
1654
1655Regular paragraph with `= this.file.name` Dataview.
1656"#;
1657
1658 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1659 let result = rule.check(&ctx).unwrap();
1660
1661 assert!(
1663 result.is_empty(),
1664 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1665 );
1666 }
1667
1668 #[test]
1670 fn test_obsidian_dataview_unicode() {
1671 let rule = MD038NoSpaceInCode::new();
1672
1673 let unicode_cases = vec![
1674 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1681
1682 for case in unicode_cases {
1683 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1684 let result = rule.check(&ctx).unwrap();
1685 assert!(
1686 result.is_empty(),
1687 "Unicode Dataview expression should not be flagged: {case}"
1688 );
1689 }
1690 }
1691
1692 #[test]
1694 fn test_obsidian_regular_equals_still_works() {
1695 let rule = MD038NoSpaceInCode::new();
1696
1697 let valid_regular_cases = vec![
1699 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1705
1706 for case in valid_regular_cases {
1707 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1708 let result = rule.check(&ctx).unwrap();
1709 assert!(
1710 result.is_empty(),
1711 "Regular code with equals should not be flagged: {case}"
1712 );
1713 }
1714 }
1715
1716 #[test]
1718 fn test_obsidian_dataview_fix_preserves_expressions() {
1719 let rule = MD038NoSpaceInCode::new();
1720
1721 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1723 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1724 let fixed = rule.fix(&ctx).unwrap();
1725
1726 assert!(
1728 fixed.contains("`= this.file.name`"),
1729 "Dataview expression should be preserved after fix"
1730 );
1731 assert!(
1732 fixed.contains("`fixme`"),
1733 "Regular code span should be fixed (space removed)"
1734 );
1735 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1736 }
1737
1738 #[test]
1740 fn test_obsidian_multiple_dataview_same_line() {
1741 let rule = MD038NoSpaceInCode::new();
1742
1743 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1744 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1745 let result = rule.check(&ctx).unwrap();
1746
1747 assert!(
1748 result.is_empty(),
1749 "Multiple Dataview expressions on same line should all be valid"
1750 );
1751 }
1752
1753 #[test]
1755 fn test_obsidian_dataview_performance() {
1756 let rule = MD038NoSpaceInCode::new();
1757
1758 let mut content = String::new();
1760 for i in 0..100 {
1761 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1762 }
1763
1764 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1765 let start = std::time::Instant::now();
1766 let result = rule.check(&ctx).unwrap();
1767 let duration = start.elapsed();
1768
1769 assert!(result.is_empty(), "All Dataview expressions should be valid");
1770 assert!(
1771 duration.as_millis() < 1000,
1772 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1773 );
1774 }
1775
1776 #[test]
1778 fn test_is_dataview_expression_helper() {
1779 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1781 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1782 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1783 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1784 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1785 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1786
1787 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")); }
1798
1799 #[test]
1801 fn test_obsidian_dataview_with_tags() {
1802 let rule = MD038NoSpaceInCode::new();
1803
1804 let content = r#"# Project Status
1806
1807Tags: #project #active
1808
1809Status: `= this.status`
1810Count: `$= dv.pages('#project').length`
1811
1812Regular code: `function test() {}`
1813"#;
1814
1815 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1816 let result = rule.check(&ctx).unwrap();
1817
1818 assert!(
1820 result.is_empty(),
1821 "Dataview expressions and regular code should work together"
1822 );
1823 }
1824
1825 #[test]
1826 fn test_unicode_between_code_spans_no_panic() {
1827 let rule = MD038NoSpaceInCode::new();
1830
1831 let content = "Use `one` \u{00DC}nited `two` for backtick examples.";
1833 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1834 let result = rule.check(&ctx);
1835 assert!(result.is_ok(), "Should not panic with Unicode between code spans");
1837
1838 let content_cjk = "Use `one` \u{4E16}\u{754C} `two` for examples.";
1840 let ctx_cjk = crate::lint_context::LintContext::new(content_cjk, crate::config::MarkdownFlavor::Standard, None);
1841 let result_cjk = rule.check(&ctx_cjk);
1842 assert!(result_cjk.is_ok(), "Should not panic with CJK between code spans");
1843 }
1844
1845 #[test]
1846 fn test_pandoc_inline_r_code_not_exempt() {
1847 let rule = MD038NoSpaceInCode::new();
1853 let content = "See `r foo ` for details.\n";
1856
1857 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1859 let result_quarto = rule.check(&ctx_quarto).unwrap();
1860 assert!(
1861 result_quarto.is_empty(),
1862 "MD038 should suppress trailing-space warning for `r expression` under Quarto: {result_quarto:?}"
1863 );
1864
1865 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1867 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1868 assert!(
1869 !result_pandoc.is_empty(),
1870 "MD038 should flag trailing space in `r expression` under Pandoc flavor (not Quarto/RMarkdown syntax): {result_pandoc:?}"
1871 );
1872 }
1873
1874 #[test]
1879 fn test_pandoc_inline_code_attr_does_not_suppress_leading_space() {
1880 let rule = MD038NoSpaceInCode::new();
1881 let content = "Use ` print()`{.python} for output.\n";
1882 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1883 let result = rule.check(&ctx).unwrap();
1884 assert!(
1885 !result.is_empty(),
1886 "MD038 must flag leading space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1887 );
1888 }
1889
1890 #[test]
1894 fn test_pandoc_inline_code_attr_does_not_suppress_trailing_space() {
1895 let rule = MD038NoSpaceInCode::new();
1896 let content = "Use `print() `{.python} for output.\n";
1897 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1898 let result = rule.check(&ctx).unwrap();
1899 assert!(
1900 !result.is_empty(),
1901 "MD038 must flag trailing space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1902 );
1903 }
1904
1905 #[test]
1907 fn test_standard_still_flags_leading_space_with_attr_syntax() {
1908 let rule = MD038NoSpaceInCode::new();
1909 let content = "Use ` print()`{.python} for output.\n";
1910 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1911 let result = rule.check(&ctx).unwrap();
1912 assert!(
1913 !result.is_empty(),
1914 "MD038 should flag leading space in code span under Standard flavor: {result:?}"
1915 );
1916 }
1917
1918 #[test]
1921 fn test_pandoc_inline_code_attr_clean_span_not_flagged() {
1922 let rule = MD038NoSpaceInCode::new();
1923 let content = "Use `print()`{.python} for output.\n";
1924 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1925 let result = rule.check(&ctx).unwrap();
1926 assert!(
1927 result.is_empty(),
1928 "MD038 must not flag a clean attributed code span under Pandoc: {result:?}"
1929 );
1930 }
1931
1932 #[test]
1936 fn test_trailing_line_ending_is_not_removed() {
1937 let rule = MD038NoSpaceInCode::new();
1938 let content = "Text `a\n` tail\n";
1939 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1940 let result = rule.check(&ctx).unwrap();
1941 assert!(
1942 result.is_empty(),
1943 "MD038 must not flag whitespace it can only remove by deleting a line: {result:?}"
1944 );
1945 assert_eq!(rule.fix(&ctx).unwrap(), content);
1946 }
1947
1948 #[test]
1950 fn test_leading_line_ending_is_not_removed() {
1951 let rule = MD038NoSpaceInCode::new();
1952 let content = "Text ` \na` tail\n";
1953 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1954 let result = rule.check(&ctx).unwrap();
1955 assert!(
1956 result.is_empty(),
1957 "MD038 must not flag a leading whitespace run holding a line ending: {result:?}"
1958 );
1959 assert_eq!(rule.fix(&ctx).unwrap(), content);
1960 }
1961
1962 #[test]
1968 fn test_quarto_callout_with_indented_chunk_is_left_alone() {
1969 let rule = MD038NoSpaceInCode::new();
1970 let content = "::: callout-note\n ```{r}\n x <- 1\n ```\n:::\n";
1971 for flavor in [
1972 crate::config::MarkdownFlavor::Quarto,
1973 crate::config::MarkdownFlavor::Pandoc,
1974 crate::config::MarkdownFlavor::Standard,
1975 ] {
1976 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1977 assert_eq!(
1978 rule.fix(&ctx).unwrap(),
1979 content,
1980 "MD038 rewrote an indented code chunk inside a div under {flavor:?}"
1981 );
1982 }
1983 }
1984
1985 #[test]
1989 fn test_blockquoted_indented_fence_keeps_its_indentation() {
1990 let rule = MD038NoSpaceInCode::new();
1991 for content in [
1992 "> text\n> ```\n> x\n> ```\n",
1993 ">> text\n>> ```\n>> x\n>> ```\n",
1994 "text\n ```\n x\n ```\n",
1995 ] {
1996 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1997 assert!(
1998 rule.check(&ctx).unwrap().is_empty(),
1999 "MD038 flagged a line's own indentation in {content:?}"
2000 );
2001 assert_eq!(rule.fix(&ctx).unwrap(), content);
2002 }
2003 }
2004
2005 #[test]
2013 fn test_quoted_line_indentation_is_not_removed() {
2014 let rule = MD038NoSpaceInCode::new();
2015 for content in [
2016 "> Text `a\n> ` tail\n",
2017 "> Text `a\n> ` tail\n",
2018 ">> Text `a\n>> ` tail\n",
2019 "> Text `a\n> b\n> ` tail\n",
2020 "> Text `a\r> ` tail\r",
2023 ] {
2024 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2025 assert!(
2026 rule.check(&ctx).unwrap().is_empty(),
2027 "MD038 flagged a quoted line's own indentation in {content:?}"
2028 );
2029 assert_eq!(rule.fix(&ctx).unwrap(), content);
2030 }
2031 }
2032
2033 #[test]
2040 fn test_a_span_with_no_trailing_run_is_still_trimmed_at_the_front() {
2041 let rule = MD038NoSpaceInCode::new();
2042 let content = "Text ` a\n >` tail\n";
2043 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2044 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "no warning for {content:?}");
2045 assert_eq!(rule.fix(&ctx).unwrap(), "Text `a\n >` tail\n");
2046 }
2047
2048 #[test]
2052 fn test_container_multiline_span_still_trims_a_trailing_space() {
2053 let rule = MD038NoSpaceInCode::new();
2054 for (content, expected) in [
2055 ("> text `a\n> b ` tail\n", "> text `a\n> b` tail\n"),
2056 ("- text `a\n b ` tail\n", "- text `a\n b` tail\n"),
2057 ] {
2058 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2059 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "no warning for {content:?}");
2060 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2061 }
2062 }
2063
2064 #[test]
2068 fn test_multiline_span_still_trims_a_trailing_space() {
2069 let rule = MD038NoSpaceInCode::new();
2070 let content = "Text `a\nb ` tail\n";
2071 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2072 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2073 assert_eq!(rule.fix(&ctx).unwrap(), "Text `a\nb` tail\n");
2074 }
2075
2076 #[test]
2084 fn test_a_span_with_one_untrimmable_end_is_left_alone() {
2085 let rule = MD038NoSpaceInCode::new();
2086 for content in ["Text `\na ` tail\n", "Text ` a\n` tail\n"] {
2087 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2088 assert!(
2089 rule.check(&ctx).unwrap().is_empty(),
2090 "MD038 offered a partial fix that only moves the rendered space: {content:?}"
2091 );
2092 assert_eq!(rule.fix(&ctx).unwrap(), content);
2093 }
2094 }
2095
2096 #[test]
2100 fn test_multiline_span_reports_the_line_it_ends_on() {
2101 let rule = MD038NoSpaceInCode::new();
2102 let content = "Text `a\nb ` tail\n";
2103 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2104 let result = rule.check(&ctx).unwrap();
2105 assert_eq!(result.len(), 1);
2106 assert_eq!(result[0].line, 1);
2107 assert_eq!(result[0].end_line, 2, "warning: {:?}", result[0]);
2108 }
2109
2110 #[test]
2115 fn test_fix_never_changes_the_line_count() {
2116 let rule = MD038NoSpaceInCode::new();
2117 let cases = [
2118 "Text `a\n` tail\n",
2119 "Text `a\n ` tail\n",
2120 "Text ` \na` tail\n",
2121 "::: callout-note\n ```{r}\n x <- 1\n ```\n:::\n",
2122 "Some text.\n ```{r}\n x <- 1\n ```\n",
2123 "> text\n> ```\n> x\n> ```\n",
2124 "Text `a\r\n` tail\r\n",
2125 "Text `a\r` tail\r",
2126 "> text\r> ```\r> x\r> ```\r",
2127 "Text `a\nb ` tail\n",
2128 "Text `a\rb ` tail\r",
2129 ];
2130 let line_endings = |s: &str| s.matches('\n').count() + s.matches('\r').count() - s.matches("\r\n").count();
2134 let mut rewritten = 0;
2135 for content in cases {
2136 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2137 let fixed = rule.fix(&ctx).unwrap();
2138 assert_eq!(
2139 line_endings(&fixed),
2140 line_endings(content),
2141 "MD038 changed the line count of {content:?} -> {fixed:?}"
2142 );
2143 if fixed != content {
2144 rewritten += 1;
2145 }
2146 }
2147 assert_eq!(rewritten, 2, "both positive controls must still be rewritten");
2148 }
2149
2150 #[test]
2154 fn test_carriage_return_is_a_line_ending() {
2155 let rule = MD038NoSpaceInCode::new();
2156 for content in [
2157 "Text `a\r` tail\r",
2158 "Text ` \ra` tail\r",
2159 "text\r ```\r x\r ```\r",
2160 "> text\r> ```\r> x\r> ```\r",
2161 ] {
2162 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2163 assert!(
2164 rule.check(&ctx).unwrap().is_empty(),
2165 "MD038 flagged a carriage return it can only remove by joining lines: {content:?}"
2166 );
2167 assert_eq!(rule.fix(&ctx).unwrap(), content);
2168 }
2169
2170 let content = "Text `a\rb ` tail\r";
2173 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2174 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2175 assert_eq!(rule.fix(&ctx).unwrap(), "Text `a\rb` tail\r");
2176 }
2177}