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 self.is_hugo_template_syntax(ctx, code_span) {
578 continue;
579 }
580
581 if ctx.is_in_shortcode(code_span.byte_offset) {
591 continue;
592 }
593
594 if self.is_likely_nested_backticks(ctx, &code_spans, i, &mut nesting) {
597 continue;
598 }
599
600 if self.has_attached_nested_backtick_boundary(ctx, code_span) {
601 continue;
602 }
603
604 warnings.push(LintWarning {
605 rule_name: Some(self.name().to_string()),
606 line: code_span.line,
607 column: code_span.start_col + 1, end_line: code_span.end_line,
611 end_column: code_span.end_col + 1,
615 message: "Spaces inside code span elements".to_string(),
616 severity: Severity::Warning,
617 fix: Some(Fix::new(
618 code_span.byte_offset..code_span.byte_end,
619 format!(
620 "{}{}{}",
621 "`".repeat(code_span.backtick_count),
622 trimmed,
623 "`".repeat(code_span.backtick_count)
624 ),
625 )),
626 });
627 }
628 }
629
630 Ok(warnings)
631 }
632
633 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
634 let content = ctx.content;
635 if !self.enabled {
636 return Ok(content.to_string());
637 }
638
639 if !content.contains('`') {
641 return Ok(content.to_string());
642 }
643
644 let warnings = self.check(ctx)?;
646 let warnings =
647 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
648 if warnings.is_empty() {
649 return Ok(content.to_string());
650 }
651
652 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
654 .into_iter()
655 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
656 .collect();
657
658 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
659
660 let mut result = content.to_string();
662 for (range, replacement) in fixes {
663 result.replace_range(range, &replacement);
664 }
665
666 Ok(result)
667 }
668
669 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
671 !ctx.likely_has_code()
672 }
673
674 fn as_any(&self) -> &dyn std::any::Any {
675 self
676 }
677
678 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
679 where
680 Self: Sized,
681 {
682 Box::new(MD038NoSpaceInCode { enabled: true })
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689
690 #[test]
691 fn test_md038_readme_false_positives() {
692 let rule = MD038NoSpaceInCode::new();
694 let valid_cases = vec![
695 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
696 "#### Effective Configuration (`rumdl config`)",
697 "- Blue: `.rumdl.toml`",
698 "### Defaults Only (`rumdl config --defaults`)",
699 ];
700
701 for case in valid_cases {
702 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
703 let result = rule.check(&ctx).unwrap();
704 assert!(
705 result.is_empty(),
706 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
707 case,
708 result.len()
709 );
710 }
711 }
712
713 #[test]
714 fn test_md038_front_matter() {
715 let rule = MD038NoSpaceInCode::new();
716 let content = "---\ntitle: \"` code `\"\n---\n` code `";
717 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718 let result = rule.check(&ctx).unwrap();
719 assert_eq!(result.len(), 1);
721 assert_eq!(result[0].line, 4);
722 }
723
724 #[test]
725 fn test_md038_math_block() {
726 let rule = MD038NoSpaceInCode::new();
727 let content = "$$\n` code `\n$$\n` code `";
728 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.check(&ctx).unwrap();
730 assert_eq!(result.len(), 1);
732 assert_eq!(result[0].line, 4);
733 }
734
735 #[test]
736 fn test_md038_html_comment() {
737 let rule = MD038NoSpaceInCode::new();
738 let content = "<!--\n` code `\n-->\n` code `";
739 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx).unwrap();
741 assert_eq!(result.len(), 1);
743 assert_eq!(result[0].line, 4);
744 }
745
746 #[test]
747 fn test_md038_valid() {
748 let rule = MD038NoSpaceInCode::new();
749 let valid_cases = vec![
750 "This is `code` in a sentence.",
751 "This is a `longer code span` in a sentence.",
752 "This is `code with internal spaces` which is fine.",
753 "Code span at `end of line`",
754 "`Start of line` code span",
755 "Multiple `code spans` in `one line` are fine",
756 "Code span with `symbols: !@#$%^&*()`",
757 "Empty code span `` is technically valid",
758 ];
759 for case in valid_cases {
760 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
761 let result = rule.check(&ctx).unwrap();
762 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
763 }
764 }
765
766 #[test]
767 fn test_md038_invalid() {
768 let rule = MD038NoSpaceInCode::new();
769 let invalid_cases = vec![
774 "This is ` code` with leading space.",
776 "This is `code ` with trailing space.",
778 "This is ` code ` with double leading space.",
780 "This is ` code ` with double trailing space.",
782 "This is ` code ` with double spaces both sides.",
784 ];
785 for case in invalid_cases {
786 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
787 let result = rule.check(&ctx).unwrap();
788 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
789 }
790 }
791
792 #[test]
793 fn test_md038_valid_commonmark_stripping() {
794 let rule = MD038NoSpaceInCode::new();
795 let valid_cases = vec![
799 "Type ` y ` to confirm.",
800 "Use ` git commit -m \"message\" ` to commit.",
801 "The variable ` $HOME ` contains home path.",
802 "The pattern ` *.txt ` matches text files.",
803 "This is ` random word ` with unnecessary spaces.",
804 "Text with ` plain text ` is valid.",
805 "Code with ` just code ` here.",
806 "Multiple ` word ` spans with ` text ` in one line.",
807 "This is ` code ` with both leading and trailing single space.",
808 "Use ` - ` as separator.",
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!(
814 result.is_empty(),
815 "Single space on each side should not be flagged (CommonMark strips them): {case}"
816 );
817 }
818 }
819
820 #[test]
821 fn test_md038_whitespace_only_span_not_flagged() {
822 let rule = MD038NoSpaceInCode::new();
828 let whitespace_only_cases = vec![
829 "A single-space span `\u{0020}` is intentional.",
830 "A two-space span `\u{0020}\u{0020}` is intentional.",
831 "A three-space span `\u{0020}\u{0020}\u{0020}` is intentional.",
832 "A tab span `\t` is intentional.",
833 "Just the span: ` `",
834 ];
835 for case in whitespace_only_cases {
836 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert!(
839 result.is_empty(),
840 "Whitespace-only code span should not be flagged (kept verbatim per CommonMark): {case}"
841 );
842 }
843 }
844
845 #[test]
846 fn test_md038_whitespace_only_span_fix_preserves_verbatim() {
847 let rule = MD038NoSpaceInCode::new();
850 let unchanged_cases = vec![
851 "A single-space span `\u{0020}` is intentional.",
852 "A two-space span `\u{0020}\u{0020}` is intentional.",
853 "Just the span: ` `",
854 ];
855 for case in unchanged_cases {
856 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.fix(&ctx).unwrap();
858 assert_eq!(
859 result, case,
860 "Whitespace-only code span must be left verbatim by fix, not collapsed to ``"
861 );
862 }
863 }
864
865 #[test]
866 fn test_md038_fix() {
867 let rule = MD038NoSpaceInCode::new();
868 let test_cases = vec![
870 (
872 "This is ` code` with leading space.",
873 "This is `code` with leading space.",
874 ),
875 (
877 "This is `code ` with trailing space.",
878 "This is `code` with trailing space.",
879 ),
880 (
882 "This is ` code ` with both spaces.",
883 "This is ` code ` with both spaces.", ),
885 (
887 "This is ` code ` with double leading space.",
888 "This is `code` with double leading space.",
889 ),
890 (
892 "Multiple ` code ` and `spans ` to fix.",
893 "Multiple ` code ` and `spans` to fix.", ),
895 ];
896 for (input, expected) in test_cases {
897 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.fix(&ctx).unwrap();
899 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
900 }
901 }
902
903 #[test]
904 fn test_check_invalid_leading_space() {
905 let rule = MD038NoSpaceInCode::new();
906 let input = "This has a ` leading space` in code";
907 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
908 let result = rule.check(&ctx).unwrap();
909 assert_eq!(result.len(), 1);
910 assert_eq!(result[0].line, 1);
911 assert!(result[0].fix.is_some());
912 }
913
914 #[test]
915 fn test_code_span_parsing_nested_backticks() {
916 let content = "Code with ` nested `code` example ` should preserve backticks";
917 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918
919 println!("Content: {content}");
920 println!("Code spans found:");
921 let code_spans = ctx.code_spans();
922 for (i, span) in code_spans.iter().enumerate() {
923 println!(
924 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
925 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
926 );
927 }
928
929 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
931 }
932
933 #[test]
934 fn test_nested_backtick_detection() {
935 let rule = MD038NoSpaceInCode::new();
936
937 let content = "Code with `` `backticks` inside `` should not be flagged";
939 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let result = rule.check(&ctx).unwrap();
941 assert!(result.is_empty(), "Code spans with backticks should be skipped");
942 }
943
944 #[test]
945 fn test_quarto_inline_r_code() {
946 let rule = MD038NoSpaceInCode::new();
948
949 let content = r#"The result is `r nchar("test")` which equals 4."#;
952
953 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
955 let result_quarto = rule.check(&ctx_quarto).unwrap();
956 assert!(
957 result_quarto.is_empty(),
958 "Quarto inline R code should not trigger warnings. Got {} warnings",
959 result_quarto.len()
960 );
961
962 let content_other = "This has `plain text ` with trailing space.";
965 let ctx_other =
966 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
967 let result_other = rule.check(&ctx_other).unwrap();
968 assert_eq!(
969 result_other.len(),
970 1,
971 "Quarto should still flag non-R code spans with improper spaces"
972 );
973 }
974
975 #[test]
981 fn test_hugo_template_syntax_comprehensive() {
982 let rule = MD038NoSpaceInCode::new();
983
984 let valid_hugo_cases = vec![
988 (
990 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
991 "Multi-line raw shortcode",
992 ),
993 (
994 "Some text {{raw ` code `}} more text",
995 "Inline raw shortcode with spaces",
996 ),
997 ("{{raw `code`}}", "Raw shortcode without spaces"),
998 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
1000 ("{{< `code` >}}", "Partial shortcode without spaces"),
1001 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
1003 ("{{% `code` %}}", "Percent shortcode without spaces"),
1004 ("{{ ` code ` }}", "Generic shortcode with spaces"),
1006 ("{{ `code` }}", "Generic shortcode without spaces"),
1007 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
1009 ("{{< code `go list` >}}", "Shortcode with code parameter"),
1010 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
1012 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
1013 (
1015 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
1016 "Nested Go template syntax",
1017 ),
1018 ("{{raw `code`}}", "Hugo template at line start"),
1020 ("Text {{raw `code`}}", "Hugo template at end of line"),
1022 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
1024 ];
1025
1026 for (case, description) in valid_hugo_cases {
1027 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1028 let result = rule.check(&ctx).unwrap();
1029 assert!(
1030 result.is_empty(),
1031 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
1032 );
1033 }
1034
1035 let should_be_flagged = vec![
1040 ("This is ` code` with leading space.", "Leading space only"),
1041 ("This is `code ` with trailing space.", "Trailing space only"),
1042 ("Text ` code ` here", "Extra leading space (asymmetric)"),
1043 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
1044 ("Text ` code` here", "Double leading, no trailing"),
1045 ("Text `code ` here", "No leading, double trailing"),
1046 ];
1047
1048 for (case, description) in should_be_flagged {
1049 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1050 let result = rule.check(&ctx).unwrap();
1051 assert!(
1052 !result.is_empty(),
1053 "Should flag asymmetric space code spans: {description} - {case}"
1054 );
1055 }
1056
1057 let symmetric_single_space = vec![
1063 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
1064 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
1065 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
1066 ];
1067
1068 for (case, description) in symmetric_single_space {
1069 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1070 let result = rule.check(&ctx).unwrap();
1071 assert!(
1072 result.is_empty(),
1073 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
1074 );
1075 }
1076
1077 let unicode_cases = vec![
1080 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
1081 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
1082 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
1083 (
1084 "{{raw `\n\tcode with 'single quotes'\n`}}",
1085 "Single quotes in Hugo template",
1086 ),
1087 ];
1088
1089 for (case, description) in unicode_cases {
1090 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1091 let result = rule.check(&ctx).unwrap();
1092 assert!(
1093 result.is_empty(),
1094 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
1095 );
1096 }
1097
1098 assert!(
1102 rule.check(&crate::lint_context::LintContext::new(
1103 "{{ ` ` }}",
1104 crate::config::MarkdownFlavor::Standard,
1105 None
1106 ))
1107 .unwrap()
1108 .is_empty(),
1109 "Minimum Hugo pattern should be valid"
1110 );
1111
1112 assert!(
1114 rule.check(&crate::lint_context::LintContext::new(
1115 "{{raw `\n\t\n`}}",
1116 crate::config::MarkdownFlavor::Standard,
1117 None
1118 ))
1119 .unwrap()
1120 .is_empty(),
1121 "Hugo template with only whitespace should be valid"
1122 );
1123 }
1124
1125 #[test]
1128 fn test_hugo_template_after_multibyte_text() {
1129 let rule = MD038NoSpaceInCode::new();
1130
1131 let exempt = [
1134 "日本語 {{raw `a ` }}",
1135 "café {{% `a ` }}",
1136 "{{< 日本語 `a ` }}",
1137 "日本語 {{ `a `\n}}",
1138 "日本語 {{raw `a\nb ` }}",
1139 ];
1140 for case in exempt {
1141 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1142 assert!(
1143 rule.check(&ctx).unwrap().is_empty(),
1144 "Hugo template behind multibyte text should not trigger MD038: {case}"
1145 );
1146 }
1147
1148 let flagged = [
1150 "日本語 {{raw`a ` }}",
1151 "café {{ `a ` and",
1152 "{{< 日本語`a ` }}",
1153 "日本語 {{raw`a\nb ` }}",
1154 ];
1155 for case in flagged {
1156 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1157 assert_eq!(
1158 rule.check(&ctx).unwrap().len(),
1159 1,
1160 "Near miss behind multibyte text should still be reported: {case}"
1161 );
1162 }
1163 }
1164
1165 #[test]
1167 fn test_hugo_template_with_other_markdown() {
1168 let rule = MD038NoSpaceInCode::new();
1169
1170 let content = r#"1. First item
11722. Second item with {{raw `code`}} template
11733. Third item"#;
1174 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175 let result = rule.check(&ctx).unwrap();
1176 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
1177
1178 let content = r#"> Quote with {{raw `code`}} template"#;
1180 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181 let result = rule.check(&ctx).unwrap();
1182 assert!(
1183 result.is_empty(),
1184 "Hugo template in blockquote should not trigger warnings"
1185 );
1186
1187 let content = r#"{{raw `code`}} and ` bad code` here"#;
1189 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1190 let result = rule.check(&ctx).unwrap();
1191 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
1192 }
1193
1194 #[test]
1196 fn test_hugo_template_performance() {
1197 let rule = MD038NoSpaceInCode::new();
1198
1199 let mut content = String::new();
1201 for i in 0..100 {
1202 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
1203 }
1204
1205 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1206 let start = std::time::Instant::now();
1207 let result = rule.check(&ctx).unwrap();
1208 let duration = start.elapsed();
1209
1210 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
1211 assert!(
1212 duration.as_millis() < 1000,
1213 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
1214 );
1215 }
1216
1217 #[test]
1218 fn test_mkdocs_inline_hilite_not_flagged() {
1219 let rule = MD038NoSpaceInCode::new();
1222
1223 let valid_cases = vec![
1224 "`#!python print('hello')`",
1225 "`#!js alert('hi')`",
1226 "`#!c++ cout << x;`",
1227 "Use `#!python import os` to import modules",
1228 "`#!bash echo $HOME`",
1229 ];
1230
1231 for case in valid_cases {
1232 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
1233 let result = rule.check(&ctx).unwrap();
1234 assert!(
1235 result.is_empty(),
1236 "InlineHilite syntax should not be flagged in MkDocs: {case}"
1237 );
1238 }
1239
1240 let content = "`#!python print('hello')`";
1242 let ctx_standard =
1243 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244 let result_standard = rule.check(&ctx_standard).unwrap();
1245 assert!(
1248 result_standard.is_empty(),
1249 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
1250 );
1251 }
1252
1253 #[test]
1254 fn test_multibyte_utf8_no_panic() {
1255 let rule = MD038NoSpaceInCode::new();
1259
1260 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
1262 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
1263 let result = rule.check(&ctx);
1264 assert!(result.is_ok(), "Greek text should not panic");
1265
1266 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
1268 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
1269 let result = rule.check(&ctx);
1270 assert!(result.is_ok(), "Chinese text should not panic");
1271
1272 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
1274 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
1275 let result = rule.check(&ctx);
1276 assert!(result.is_ok(), "Cyrillic text should not panic");
1277
1278 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
1280 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
1281 let result = rule.check(&ctx);
1282 assert!(
1283 result.is_ok(),
1284 "Mixed Chinese text with multiple code spans should not panic"
1285 );
1286 }
1287
1288 #[test]
1292 fn test_obsidian_dataview_inline_dql_not_flagged() {
1293 let rule = MD038NoSpaceInCode::new();
1294
1295 let valid_dql_cases = vec![
1297 "`= this.file.name`",
1298 "`= date(today)`",
1299 "`= [[Page]].field`",
1300 "`= choice(condition, \"yes\", \"no\")`",
1301 "`= this.file.mtime`",
1302 "`= this.file.ctime`",
1303 "`= this.file.path`",
1304 "`= this.file.folder`",
1305 "`= this.file.size`",
1306 "`= this.file.ext`",
1307 "`= this.file.link`",
1308 "`= this.file.outlinks`",
1309 "`= this.file.inlinks`",
1310 "`= this.file.tags`",
1311 ];
1312
1313 for case in valid_dql_cases {
1314 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1315 let result = rule.check(&ctx).unwrap();
1316 assert!(
1317 result.is_empty(),
1318 "Dataview DQL expression should not be flagged in Obsidian: {case}"
1319 );
1320 }
1321 }
1322
1323 #[test]
1325 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
1326 let rule = MD038NoSpaceInCode::new();
1327
1328 let valid_dvjs_cases = vec![
1330 "`$= dv.current().file.mtime`",
1331 "`$= dv.pages().length`",
1332 "`$= dv.current()`",
1333 "`$= dv.pages('#tag').length`",
1334 "`$= dv.pages('\"folder\"').length`",
1335 "`$= dv.current().file.name`",
1336 "`$= dv.current().file.path`",
1337 "`$= dv.current().file.folder`",
1338 "`$= dv.current().file.link`",
1339 ];
1340
1341 for case in valid_dvjs_cases {
1342 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1343 let result = rule.check(&ctx).unwrap();
1344 assert!(
1345 result.is_empty(),
1346 "Dataview JS expression should not be flagged in Obsidian: {case}"
1347 );
1348 }
1349 }
1350
1351 #[test]
1353 fn test_obsidian_dataview_complex_expressions() {
1354 let rule = MD038NoSpaceInCode::new();
1355
1356 let complex_cases = vec![
1357 "`= sum(filter(pages, (p) => p.done))`",
1359 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
1360 "`= choice(x > 5, \"big\", \"small\")`",
1362 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
1363 "`= date(today) - dur(7 days)`",
1365 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
1366 "`= sum(rows.amount)`",
1368 "`= round(average(rows.score), 2)`",
1369 "`= min(rows.priority)`",
1370 "`= max(rows.priority)`",
1371 "`= join(this.file.tags, \", \")`",
1373 "`= replace(this.title, \"-\", \" \")`",
1374 "`= lower(this.file.name)`",
1375 "`= upper(this.file.name)`",
1376 "`= length(this.file.outlinks)`",
1378 "`= contains(this.file.tags, \"important\")`",
1379 "`= [[Page Name]].field`",
1381 "`= [[Folder/Subfolder/Page]].nested.field`",
1382 "`= default(this.status, \"unknown\")`",
1384 "`= coalesce(this.priority, this.importance, 0)`",
1385 ];
1386
1387 for case in complex_cases {
1388 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1389 let result = rule.check(&ctx).unwrap();
1390 assert!(
1391 result.is_empty(),
1392 "Complex Dataview expression should not be flagged in Obsidian: {case}"
1393 );
1394 }
1395 }
1396
1397 #[test]
1399 fn test_obsidian_dataviewjs_method_chains() {
1400 let rule = MD038NoSpaceInCode::new();
1401
1402 let method_chain_cases = vec![
1403 "`$= dv.pages().where(p => p.status).length`",
1404 "`$= dv.pages('#project').where(p => !p.done).length`",
1405 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
1406 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
1407 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
1408 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
1409 "`$= dv.page('Index').children.map(p => p.title)`",
1410 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1411 ];
1412
1413 for case in method_chain_cases {
1414 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1415 let result = rule.check(&ctx).unwrap();
1416 assert!(
1417 result.is_empty(),
1418 "DataviewJS method chain should not be flagged in Obsidian: {case}"
1419 );
1420 }
1421 }
1422
1423 #[test]
1432 fn test_standard_flavor_vs_obsidian_dataview() {
1433 let rule = MD038NoSpaceInCode::new();
1434
1435 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1438
1439 for case in no_issue_cases {
1440 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1442 let result_std = rule.check(&ctx_std).unwrap();
1443 assert!(
1444 result_std.is_empty(),
1445 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1446 );
1447
1448 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1450 let result_obs = rule.check(&ctx_obs).unwrap();
1451 assert!(
1452 result_obs.is_empty(),
1453 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1454 );
1455 }
1456
1457 let space_issues = vec![
1460 "` code`", "`code `", ];
1463
1464 for case in space_issues {
1465 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1467 let result_std = rule.check(&ctx_std).unwrap();
1468 assert!(
1469 !result_std.is_empty(),
1470 "Code with spacing issue should be flagged in Standard: {case}"
1471 );
1472
1473 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1475 let result_obs = rule.check(&ctx_obs).unwrap();
1476 assert!(
1477 !result_obs.is_empty(),
1478 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1479 );
1480 }
1481 }
1482
1483 #[test]
1485 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1486 let rule = MD038NoSpaceInCode::new();
1487
1488 let invalid_cases = [
1491 "` regular code`", "`code `", "` code `", "` code`", ];
1496
1497 let expected_flags = [
1499 true, true, false, true, ];
1504
1505 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1506 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1507 let result = rule.check(&ctx).unwrap();
1508 if *should_flag {
1509 assert!(
1510 !result.is_empty(),
1511 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1512 );
1513 } else {
1514 assert!(
1515 result.is_empty(),
1516 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1517 );
1518 }
1519 }
1520 }
1521
1522 #[test]
1524 fn test_obsidian_dataview_edge_cases() {
1525 let rule = MD038NoSpaceInCode::new();
1526
1527 let valid_cases = vec![
1529 ("`= 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), ];
1545
1546 for (case, should_be_valid) in valid_cases {
1547 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1548 let result = rule.check(&ctx).unwrap();
1549 if should_be_valid {
1550 assert!(
1551 result.is_empty(),
1552 "Valid Dataview expression should not be flagged: {case}"
1553 );
1554 } else {
1555 let _ = result;
1558 }
1559 }
1560 }
1561
1562 #[test]
1564 fn test_obsidian_dataview_in_context() {
1565 let rule = MD038NoSpaceInCode::new();
1566
1567 let content = r#"# My Note
1569
1570The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1571
1572Regular code: `println!("hello")` and `let x = 5;`
1573
1574DataviewJS count: `$= dv.pages('#project').length` projects found.
1575
1576More regular code with issue: ` bad code` should be flagged.
1577"#;
1578
1579 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1580 let result = rule.check(&ctx).unwrap();
1581
1582 assert_eq!(
1584 result.len(),
1585 1,
1586 "Should only flag the regular code span with leading space, not Dataview expressions"
1587 );
1588 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1589 }
1590
1591 #[test]
1593 fn test_obsidian_dataview_in_code_blocks() {
1594 let rule = MD038NoSpaceInCode::new();
1595
1596 let content = r#"# Example
1599
1600```
1601`= this.file.name`
1602`$= dv.current()`
1603```
1604
1605Regular paragraph with `= this.file.name` Dataview.
1606"#;
1607
1608 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1609 let result = rule.check(&ctx).unwrap();
1610
1611 assert!(
1613 result.is_empty(),
1614 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1615 );
1616 }
1617
1618 #[test]
1620 fn test_obsidian_dataview_unicode() {
1621 let rule = MD038NoSpaceInCode::new();
1622
1623 let unicode_cases = vec![
1624 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1631
1632 for case in unicode_cases {
1633 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1634 let result = rule.check(&ctx).unwrap();
1635 assert!(
1636 result.is_empty(),
1637 "Unicode Dataview expression should not be flagged: {case}"
1638 );
1639 }
1640 }
1641
1642 #[test]
1644 fn test_obsidian_regular_equals_still_works() {
1645 let rule = MD038NoSpaceInCode::new();
1646
1647 let valid_regular_cases = vec![
1649 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1655
1656 for case in valid_regular_cases {
1657 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1658 let result = rule.check(&ctx).unwrap();
1659 assert!(
1660 result.is_empty(),
1661 "Regular code with equals should not be flagged: {case}"
1662 );
1663 }
1664 }
1665
1666 #[test]
1668 fn test_obsidian_dataview_fix_preserves_expressions() {
1669 let rule = MD038NoSpaceInCode::new();
1670
1671 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1673 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1674 let fixed = rule.fix(&ctx).unwrap();
1675
1676 assert!(
1678 fixed.contains("`= this.file.name`"),
1679 "Dataview expression should be preserved after fix"
1680 );
1681 assert!(
1682 fixed.contains("`fixme`"),
1683 "Regular code span should be fixed (space removed)"
1684 );
1685 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1686 }
1687
1688 #[test]
1690 fn test_obsidian_multiple_dataview_same_line() {
1691 let rule = MD038NoSpaceInCode::new();
1692
1693 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1694 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1695 let result = rule.check(&ctx).unwrap();
1696
1697 assert!(
1698 result.is_empty(),
1699 "Multiple Dataview expressions on same line should all be valid"
1700 );
1701 }
1702
1703 #[test]
1705 fn test_obsidian_dataview_performance() {
1706 let rule = MD038NoSpaceInCode::new();
1707
1708 let mut content = String::new();
1710 for i in 0..100 {
1711 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1712 }
1713
1714 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1715 let start = std::time::Instant::now();
1716 let result = rule.check(&ctx).unwrap();
1717 let duration = start.elapsed();
1718
1719 assert!(result.is_empty(), "All Dataview expressions should be valid");
1720 assert!(
1721 duration.as_millis() < 1000,
1722 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1723 );
1724 }
1725
1726 #[test]
1728 fn test_is_dataview_expression_helper() {
1729 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1731 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1732 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1733 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1734 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1735 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1736
1737 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")); }
1748
1749 #[test]
1751 fn test_obsidian_dataview_with_tags() {
1752 let rule = MD038NoSpaceInCode::new();
1753
1754 let content = r#"# Project Status
1756
1757Tags: #project #active
1758
1759Status: `= this.status`
1760Count: `$= dv.pages('#project').length`
1761
1762Regular code: `function test() {}`
1763"#;
1764
1765 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1766 let result = rule.check(&ctx).unwrap();
1767
1768 assert!(
1770 result.is_empty(),
1771 "Dataview expressions and regular code should work together"
1772 );
1773 }
1774
1775 #[test]
1776 fn test_unicode_between_code_spans_no_panic() {
1777 let rule = MD038NoSpaceInCode::new();
1780
1781 let content = "Use `one` \u{00DC}nited `two` for backtick examples.";
1783 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1784 let result = rule.check(&ctx);
1785 assert!(result.is_ok(), "Should not panic with Unicode between code spans");
1787
1788 let content_cjk = "Use `one` \u{4E16}\u{754C} `two` for examples.";
1790 let ctx_cjk = crate::lint_context::LintContext::new(content_cjk, crate::config::MarkdownFlavor::Standard, None);
1791 let result_cjk = rule.check(&ctx_cjk);
1792 assert!(result_cjk.is_ok(), "Should not panic with CJK between code spans");
1793 }
1794
1795 #[test]
1796 fn test_pandoc_inline_r_code_not_exempt() {
1797 let rule = MD038NoSpaceInCode::new();
1803 let content = "See `r foo ` for details.\n";
1806
1807 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1809 let result_quarto = rule.check(&ctx_quarto).unwrap();
1810 assert!(
1811 result_quarto.is_empty(),
1812 "MD038 should suppress trailing-space warning for `r expression` under Quarto: {result_quarto:?}"
1813 );
1814
1815 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1817 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1818 assert!(
1819 !result_pandoc.is_empty(),
1820 "MD038 should flag trailing space in `r expression` under Pandoc flavor (not Quarto/RMarkdown syntax): {result_pandoc:?}"
1821 );
1822 }
1823
1824 #[test]
1829 fn test_pandoc_inline_code_attr_does_not_suppress_leading_space() {
1830 let rule = MD038NoSpaceInCode::new();
1831 let content = "Use ` print()`{.python} for output.\n";
1832 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1833 let result = rule.check(&ctx).unwrap();
1834 assert!(
1835 !result.is_empty(),
1836 "MD038 must flag leading space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1837 );
1838 }
1839
1840 #[test]
1844 fn test_pandoc_inline_code_attr_does_not_suppress_trailing_space() {
1845 let rule = MD038NoSpaceInCode::new();
1846 let content = "Use `print() `{.python} for output.\n";
1847 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1848 let result = rule.check(&ctx).unwrap();
1849 assert!(
1850 !result.is_empty(),
1851 "MD038 must flag trailing space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1852 );
1853 }
1854
1855 #[test]
1857 fn test_standard_still_flags_leading_space_with_attr_syntax() {
1858 let rule = MD038NoSpaceInCode::new();
1859 let content = "Use ` print()`{.python} for output.\n";
1860 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1861 let result = rule.check(&ctx).unwrap();
1862 assert!(
1863 !result.is_empty(),
1864 "MD038 should flag leading space in code span under Standard flavor: {result:?}"
1865 );
1866 }
1867
1868 #[test]
1871 fn test_pandoc_inline_code_attr_clean_span_not_flagged() {
1872 let rule = MD038NoSpaceInCode::new();
1873 let content = "Use `print()`{.python} for output.\n";
1874 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1875 let result = rule.check(&ctx).unwrap();
1876 assert!(
1877 result.is_empty(),
1878 "MD038 must not flag a clean attributed code span under Pandoc: {result:?}"
1879 );
1880 }
1881
1882 #[test]
1886 fn test_trailing_line_ending_is_not_removed() {
1887 let rule = MD038NoSpaceInCode::new();
1888 let content = "Text `a\n` tail\n";
1889 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 let result = rule.check(&ctx).unwrap();
1891 assert!(
1892 result.is_empty(),
1893 "MD038 must not flag whitespace it can only remove by deleting a line: {result:?}"
1894 );
1895 assert_eq!(rule.fix(&ctx).unwrap(), content);
1896 }
1897
1898 #[test]
1900 fn test_leading_line_ending_is_not_removed() {
1901 let rule = MD038NoSpaceInCode::new();
1902 let content = "Text ` \na` tail\n";
1903 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1904 let result = rule.check(&ctx).unwrap();
1905 assert!(
1906 result.is_empty(),
1907 "MD038 must not flag a leading whitespace run holding a line ending: {result:?}"
1908 );
1909 assert_eq!(rule.fix(&ctx).unwrap(), content);
1910 }
1911
1912 #[test]
1918 fn test_quarto_callout_with_indented_chunk_is_left_alone() {
1919 let rule = MD038NoSpaceInCode::new();
1920 let content = "::: callout-note\n ```{r}\n x <- 1\n ```\n:::\n";
1921 for flavor in [
1922 crate::config::MarkdownFlavor::Quarto,
1923 crate::config::MarkdownFlavor::Pandoc,
1924 crate::config::MarkdownFlavor::Standard,
1925 ] {
1926 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1927 assert_eq!(
1928 rule.fix(&ctx).unwrap(),
1929 content,
1930 "MD038 rewrote an indented code chunk inside a div under {flavor:?}"
1931 );
1932 }
1933 }
1934
1935 #[test]
1939 fn test_blockquoted_indented_fence_keeps_its_indentation() {
1940 let rule = MD038NoSpaceInCode::new();
1941 for content in [
1942 "> text\n> ```\n> x\n> ```\n",
1943 ">> text\n>> ```\n>> x\n>> ```\n",
1944 "text\n ```\n x\n ```\n",
1945 ] {
1946 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1947 assert!(
1948 rule.check(&ctx).unwrap().is_empty(),
1949 "MD038 flagged a line's own indentation in {content:?}"
1950 );
1951 assert_eq!(rule.fix(&ctx).unwrap(), content);
1952 }
1953 }
1954
1955 #[test]
1963 fn test_quoted_line_indentation_is_not_removed() {
1964 let rule = MD038NoSpaceInCode::new();
1965 for content in [
1966 "> Text `a\n> ` tail\n",
1967 "> Text `a\n> ` tail\n",
1968 ">> Text `a\n>> ` tail\n",
1969 "> Text `a\n> b\n> ` tail\n",
1970 "> Text `a\r> ` tail\r",
1973 ] {
1974 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1975 assert!(
1976 rule.check(&ctx).unwrap().is_empty(),
1977 "MD038 flagged a quoted line's own indentation in {content:?}"
1978 );
1979 assert_eq!(rule.fix(&ctx).unwrap(), content);
1980 }
1981 }
1982
1983 #[test]
1990 fn test_a_span_with_no_trailing_run_is_still_trimmed_at_the_front() {
1991 let rule = MD038NoSpaceInCode::new();
1992 let content = "Text ` a\n >` tail\n";
1993 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1994 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "no warning for {content:?}");
1995 assert_eq!(rule.fix(&ctx).unwrap(), "Text `a\n >` tail\n");
1996 }
1997
1998 #[test]
2002 fn test_container_multiline_span_still_trims_a_trailing_space() {
2003 let rule = MD038NoSpaceInCode::new();
2004 for (content, expected) in [
2005 ("> text `a\n> b ` tail\n", "> text `a\n> b` tail\n"),
2006 ("- text `a\n b ` tail\n", "- text `a\n b` tail\n"),
2007 ] {
2008 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2009 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "no warning for {content:?}");
2010 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2011 }
2012 }
2013
2014 #[test]
2018 fn test_multiline_span_still_trims_a_trailing_space() {
2019 let rule = MD038NoSpaceInCode::new();
2020 let content = "Text `a\nb ` tail\n";
2021 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2022 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2023 assert_eq!(rule.fix(&ctx).unwrap(), "Text `a\nb` tail\n");
2024 }
2025
2026 #[test]
2034 fn test_a_span_with_one_untrimmable_end_is_left_alone() {
2035 let rule = MD038NoSpaceInCode::new();
2036 for content in ["Text `\na ` tail\n", "Text ` a\n` tail\n"] {
2037 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2038 assert!(
2039 rule.check(&ctx).unwrap().is_empty(),
2040 "MD038 offered a partial fix that only moves the rendered space: {content:?}"
2041 );
2042 assert_eq!(rule.fix(&ctx).unwrap(), content);
2043 }
2044 }
2045
2046 #[test]
2050 fn test_multiline_span_reports_the_line_it_ends_on() {
2051 let rule = MD038NoSpaceInCode::new();
2052 let content = "Text `a\nb ` tail\n";
2053 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054 let result = rule.check(&ctx).unwrap();
2055 assert_eq!(result.len(), 1);
2056 assert_eq!(result[0].line, 1);
2057 assert_eq!(result[0].end_line, 2, "warning: {:?}", result[0]);
2058 }
2059
2060 #[test]
2065 fn test_fix_never_changes_the_line_count() {
2066 let rule = MD038NoSpaceInCode::new();
2067 let cases = [
2068 "Text `a\n` tail\n",
2069 "Text `a\n ` tail\n",
2070 "Text ` \na` tail\n",
2071 "::: callout-note\n ```{r}\n x <- 1\n ```\n:::\n",
2072 "Some text.\n ```{r}\n x <- 1\n ```\n",
2073 "> text\n> ```\n> x\n> ```\n",
2074 "Text `a\r\n` tail\r\n",
2075 "Text `a\r` tail\r",
2076 "> text\r> ```\r> x\r> ```\r",
2077 "Text `a\nb ` tail\n",
2078 "Text `a\rb ` tail\r",
2079 ];
2080 let line_endings = |s: &str| s.matches('\n').count() + s.matches('\r').count() - s.matches("\r\n").count();
2084 let mut rewritten = 0;
2085 for content in cases {
2086 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2087 let fixed = rule.fix(&ctx).unwrap();
2088 assert_eq!(
2089 line_endings(&fixed),
2090 line_endings(content),
2091 "MD038 changed the line count of {content:?} -> {fixed:?}"
2092 );
2093 if fixed != content {
2094 rewritten += 1;
2095 }
2096 }
2097 assert_eq!(rewritten, 2, "both positive controls must still be rewritten");
2098 }
2099
2100 #[test]
2104 fn test_carriage_return_is_a_line_ending() {
2105 let rule = MD038NoSpaceInCode::new();
2106 for content in [
2107 "Text `a\r` tail\r",
2108 "Text ` \ra` tail\r",
2109 "text\r ```\r x\r ```\r",
2110 "> text\r> ```\r> x\r> ```\r",
2111 ] {
2112 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2113 assert!(
2114 rule.check(&ctx).unwrap().is_empty(),
2115 "MD038 flagged a carriage return it can only remove by joining lines: {content:?}"
2116 );
2117 assert_eq!(rule.fix(&ctx).unwrap(), content);
2118 }
2119
2120 let content = "Text `a\rb ` tail\r";
2123 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2124 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2125 assert_eq!(rule.fix(&ctx).unwrap(), "Text `a\rb` tail\r");
2126 }
2127}