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
8#[derive(Default)]
10struct NestedBacktickState {
11 runs: Option<Vec<(usize, usize)>>,
13 line: Option<LineNesting>,
15}
16
17struct LineNesting {
24 line: usize,
26 char_offsets: Vec<usize>,
28 len: usize,
30 word_end_after_first: Option<usize>,
32 word_start_before_last: Option<usize>,
34}
35
36impl LineNesting {
37 fn new(line_content: &str, line: usize, first: &CodeSpan, last: &CodeSpan) -> Self {
38 let char_offsets = if line_content.is_ascii() {
39 Vec::new()
40 } else {
41 line_content.char_indices().map(|(offset, _)| offset).collect()
42 };
43 let mut nesting = Self {
44 line,
45 char_offsets,
46 len: line_content.len(),
47 word_end_after_first: None,
48 word_start_before_last: None,
49 };
50
51 let after_first = nesting.char_offset(first.end_col);
52 let before_last = nesting.char_offset(last.start_col).unwrap_or(nesting.len);
53
54 for word in NESTING_WORDS {
55 for (start, matched) in line_content.match_indices(word) {
56 let end = start + matched.len();
57 if after_first.is_some_and(|bound| start >= bound) {
58 nesting.word_end_after_first = Some(nesting.word_end_after_first.map_or(end, |e| e.min(end)));
59 }
60 if end <= before_last {
61 nesting.word_start_before_last =
62 Some(nesting.word_start_before_last.map_or(start, |s| s.max(start)));
63 }
64 }
65 }
66
67 nesting
68 }
69
70 fn char_offset(&self, char_index: usize) -> Option<usize> {
72 if self.char_offsets.is_empty() {
73 (char_index < self.len).then_some(char_index)
74 } else {
75 self.char_offsets.get(char_index).copied()
76 }
77 }
78
79 fn names_backticks_before(&self, span: &CodeSpan) -> bool {
81 let Some(word_end) = self.word_end_after_first else {
82 return false;
83 };
84 word_end <= self.char_offset(span.start_col).unwrap_or(self.len)
85 }
86
87 fn names_backticks_after(&self, span: &CodeSpan, last: &CodeSpan) -> bool {
89 let Some(word_start) = self.word_start_before_last else {
90 return false;
91 };
92 let Some(span_end) = self.char_offset(span.end_col.min(last.end_col)) else {
93 return false;
94 };
95 word_start >= span_end
96 }
97
98 fn names_backticks_between(&self, line_content: &str, current_span: &CodeSpan, other_span: &CodeSpan) -> bool {
100 let start_char = current_span.end_col.min(other_span.end_col);
101 let end_char = current_span.start_col.max(other_span.start_col);
102 if start_char >= end_char {
103 return false;
104 }
105
106 let Some(start_byte) = self.char_offset(start_char) else {
108 return false;
109 };
110 let end_byte = self.char_offset(end_char).unwrap_or(self.len);
111 if start_byte >= end_byte {
112 return false;
113 }
114
115 let between = &line_content[start_byte..end_byte];
116 NESTING_WORDS.iter().any(|word| between.contains(word))
117 }
118}
119
120#[derive(Debug, Clone, Default)]
145pub struct MD038NoSpaceInCode {
146 pub enabled: bool,
147}
148
149impl MD038NoSpaceInCode {
150 pub fn new() -> Self {
151 Self { enabled: true }
152 }
153
154 fn is_hugo_template_syntax(&self, ctx: &crate::lint_context::LintContext, code_span: &CodeSpan) -> bool {
170 let start_line_idx = code_span.line.saturating_sub(1);
171 let Some(start_line) = ctx.lines.get(start_line_idx) else {
172 return false;
173 };
174
175 let start_line_content = start_line.content(ctx.content);
176
177 let Some(span_start) = code_span
179 .byte_offset
180 .checked_sub(start_line.byte_offset)
181 .filter(|offset| *offset <= start_line_content.len())
182 else {
183 return false;
184 };
185
186 if span_start >= 3 {
191 let before_span = &start_line_content[..span_start];
194
195 let char_at_span_start = start_line_content[span_start..].chars().next().unwrap_or(' ');
199
200 let is_hugo_start =
208 (before_span.ends_with("{{raw ") && char_at_span_start == '`')
210 || (before_span.starts_with("{{<") && before_span.ends_with(' ') && char_at_span_start == '`')
212 || (before_span.ends_with("{{% ") && char_at_span_start == '`')
214 || (before_span.ends_with("{{ ") && char_at_span_start == '`');
216
217 if is_hugo_start {
218 let end_line_idx = code_span.end_line.saturating_sub(1);
221 if let Some(end_line) = ctx.lines.get(end_line_idx) {
222 let end_line_content = end_line.content(ctx.content);
223 let span_end = code_span
224 .byte_end
225 .checked_sub(end_line.byte_offset)
226 .unwrap_or(end_line_content.len())
227 .min(end_line_content.len());
228
229 if span_end < end_line_content.len() {
231 let after_span = &end_line_content[span_end..];
232 if after_span.trim_start().starts_with("}}") {
233 return true;
234 }
235 }
236
237 let next_line_idx = code_span.end_line;
239 if next_line_idx < ctx.lines.len() {
240 let next_line = ctx.lines[next_line_idx].content(ctx.content);
241 if next_line.trim_start().starts_with("}}") {
242 return true;
243 }
244 }
245 }
246 }
247 }
248
249 false
250 }
251
252 fn is_dataview_expression(content: &str) -> bool {
268 content.starts_with("= ") || content.starts_with("$= ")
271 }
272
273 fn same_line_runs(code_spans: &[CodeSpan]) -> Vec<(usize, usize)> {
279 let mut runs = vec![(0, 0); code_spans.len()];
280 let mut run_start = 0;
281
282 for index in 1..=code_spans.len() {
283 if index == code_spans.len() || code_spans[index].line != code_spans[run_start].line {
284 runs[run_start..index].fill((run_start, index - 1));
285 run_start = index;
286 }
287 }
288
289 runs
290 }
291
292 fn is_likely_nested_backticks(
294 &self,
295 ctx: &crate::lint_context::LintContext,
296 code_spans: &[CodeSpan],
297 span_index: usize,
298 state: &mut NestedBacktickState,
299 ) -> bool {
300 let current_span = &code_spans[span_index];
303 let (first, last) = {
304 let runs = state.runs.get_or_insert_with(|| Self::same_line_runs(code_spans));
305 runs[span_index]
306 };
307
308 if first == last {
310 return false;
311 }
312
313 let line_idx = current_span.line - 1; if line_idx >= ctx.lines.len() {
317 return false;
318 }
319
320 let line_content = ctx.lines[line_idx].content(ctx.content);
321 let line = match &mut state.line {
322 Some(cached) if cached.line == current_span.line => cached,
323 slot => slot.insert(LineNesting::new(
324 line_content,
325 current_span.line,
326 &code_spans[first],
327 &code_spans[last],
328 )),
329 };
330
331 if current_span.end_line != current_span.line {
336 return line.names_backticks_between(line_content, current_span, &code_spans[first]);
337 }
338
339 line.names_backticks_before(current_span) || line.names_backticks_after(current_span, &code_spans[last])
340 }
341
342 fn has_attached_nested_backtick_boundary(
349 &self,
350 ctx: &crate::lint_context::LintContext,
351 code_span: &crate::lint_context::CodeSpan,
352 ) -> bool {
353 let content = code_span.content.as_str();
354
355 let next_char = ctx.content[code_span.byte_end..].chars().next();
356 let prev_char = ctx.content[..code_span.byte_offset].chars().next_back();
357
358 let trailing_neighbor_is_pandoc_attr =
362 ctx.flavor.is_pandoc_compatible() && ctx.is_in_inline_code_attr(code_span.byte_end);
363
364 (content.ends_with(char::is_whitespace)
365 && next_char.is_some_and(|c| !c.is_whitespace())
366 && !trailing_neighbor_is_pandoc_attr)
367 || (content.starts_with(char::is_whitespace) && prev_char.is_some_and(|c| !c.is_whitespace()))
368 }
369}
370
371impl Rule for MD038NoSpaceInCode {
372 fn name(&self) -> &'static str {
373 "MD038"
374 }
375
376 fn description(&self) -> &'static str {
377 "Spaces inside code span elements"
378 }
379
380 fn category(&self) -> RuleCategory {
381 RuleCategory::Other
382 }
383
384 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
385 if !self.enabled {
386 return Ok(vec![]);
387 }
388
389 let mut warnings = Vec::new();
390
391 let code_spans = ctx.code_spans();
393 let mut nesting = NestedBacktickState::default();
396 for (i, code_span) in code_spans.iter().enumerate() {
397 if let Some(line_info) = ctx.lines.get(code_span.line - 1) {
398 if line_info.in_code_block
401 || line_info.in_front_matter
402 || line_info.in_math_block
403 || line_info.in_html_block
404 || line_info.in_html_comment
405 || line_info.in_mkdocstrings
406 || line_info.in_esm_block
407 {
408 continue;
409 }
410 if (line_info.in_mkdocs_container() || line_info.in_pymdown_block) && code_span.content.contains('\n') {
414 continue;
415 }
416 }
417
418 let code_content = &code_span.content;
419
420 if code_content.is_empty() {
422 continue;
423 }
424
425 let has_leading_space = code_content.chars().next().is_some_and(char::is_whitespace);
427 let has_trailing_space = code_content.chars().last().is_some_and(char::is_whitespace);
428
429 if !has_leading_space && !has_trailing_space {
430 continue;
431 }
432
433 let trimmed = code_content.trim();
434
435 if trimmed.is_empty() {
441 continue;
442 }
443
444 if code_content != trimmed {
446 if has_leading_space && has_trailing_space {
460 let leading_spaces = code_content.len() - code_content.trim_start().len();
461 let trailing_spaces = code_content.len() - code_content.trim_end().len();
462
463 if leading_spaces == 1 && trailing_spaces == 1 {
465 continue;
466 }
467 }
468 if trimmed.contains('`') {
471 continue;
472 }
473
474 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
479 && trimmed.starts_with('r')
480 && trimmed.len() > 1
481 && trimmed.chars().nth(1).is_some_and(char::is_whitespace)
482 {
483 continue;
484 }
485
486 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
489 continue;
490 }
491
492 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && Self::is_dataview_expression(code_content) {
496 continue;
497 }
498
499 if ctx.flavor.supports_myst_roles() && ctx.is_in_myst_role(code_span.byte_offset) {
502 continue;
503 }
504
505 if self.is_hugo_template_syntax(ctx, code_span) {
508 continue;
509 }
510
511 if ctx.is_in_shortcode(code_span.byte_offset) {
521 continue;
522 }
523
524 if self.is_likely_nested_backticks(ctx, &code_spans, i, &mut nesting) {
527 continue;
528 }
529
530 if self.has_attached_nested_backtick_boundary(ctx, code_span) {
531 continue;
532 }
533
534 warnings.push(LintWarning {
535 rule_name: Some(self.name().to_string()),
536 line: code_span.line,
537 column: code_span.start_col + 1, end_line: code_span.line,
539 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
541 severity: Severity::Warning,
542 fix: Some(Fix::new(
543 code_span.byte_offset..code_span.byte_end,
544 format!(
545 "{}{}{}",
546 "`".repeat(code_span.backtick_count),
547 trimmed,
548 "`".repeat(code_span.backtick_count)
549 ),
550 )),
551 });
552 }
553 }
554
555 Ok(warnings)
556 }
557
558 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
559 let content = ctx.content;
560 if !self.enabled {
561 return Ok(content.to_string());
562 }
563
564 if !content.contains('`') {
566 return Ok(content.to_string());
567 }
568
569 let warnings = self.check(ctx)?;
571 let warnings =
572 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
573 if warnings.is_empty() {
574 return Ok(content.to_string());
575 }
576
577 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
579 .into_iter()
580 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
581 .collect();
582
583 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
584
585 let mut result = content.to_string();
587 for (range, replacement) in fixes {
588 result.replace_range(range, &replacement);
589 }
590
591 Ok(result)
592 }
593
594 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
596 !ctx.likely_has_code()
597 }
598
599 fn as_any(&self) -> &dyn std::any::Any {
600 self
601 }
602
603 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
604 where
605 Self: Sized,
606 {
607 Box::new(MD038NoSpaceInCode { enabled: true })
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
616 fn test_md038_readme_false_positives() {
617 let rule = MD038NoSpaceInCode::new();
619 let valid_cases = vec![
620 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
621 "#### Effective Configuration (`rumdl config`)",
622 "- Blue: `.rumdl.toml`",
623 "### Defaults Only (`rumdl config --defaults`)",
624 ];
625
626 for case in valid_cases {
627 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629 assert!(
630 result.is_empty(),
631 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
632 case,
633 result.len()
634 );
635 }
636 }
637
638 #[test]
639 fn test_md038_front_matter() {
640 let rule = MD038NoSpaceInCode::new();
641 let content = "---\ntitle: \"` code `\"\n---\n` code `";
642 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644 assert_eq!(result.len(), 1);
646 assert_eq!(result[0].line, 4);
647 }
648
649 #[test]
650 fn test_md038_math_block() {
651 let rule = MD038NoSpaceInCode::new();
652 let content = "$$\n` code `\n$$\n` code `";
653 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let result = rule.check(&ctx).unwrap();
655 assert_eq!(result.len(), 1);
657 assert_eq!(result[0].line, 4);
658 }
659
660 #[test]
661 fn test_md038_html_comment() {
662 let rule = MD038NoSpaceInCode::new();
663 let content = "<!--\n` code `\n-->\n` code `";
664 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
665 let result = rule.check(&ctx).unwrap();
666 assert_eq!(result.len(), 1);
668 assert_eq!(result[0].line, 4);
669 }
670
671 #[test]
672 fn test_md038_valid() {
673 let rule = MD038NoSpaceInCode::new();
674 let valid_cases = vec![
675 "This is `code` in a sentence.",
676 "This is a `longer code span` in a sentence.",
677 "This is `code with internal spaces` which is fine.",
678 "Code span at `end of line`",
679 "`Start of line` code span",
680 "Multiple `code spans` in `one line` are fine",
681 "Code span with `symbols: !@#$%^&*()`",
682 "Empty code span `` is technically valid",
683 ];
684 for case in valid_cases {
685 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
686 let result = rule.check(&ctx).unwrap();
687 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
688 }
689 }
690
691 #[test]
692 fn test_md038_invalid() {
693 let rule = MD038NoSpaceInCode::new();
694 let invalid_cases = vec![
699 "This is ` code` with leading space.",
701 "This is `code ` with trailing space.",
703 "This is ` code ` with double leading space.",
705 "This is ` code ` with double trailing space.",
707 "This is ` code ` with double spaces both sides.",
709 ];
710 for case in invalid_cases {
711 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
714 }
715 }
716
717 #[test]
718 fn test_md038_valid_commonmark_stripping() {
719 let rule = MD038NoSpaceInCode::new();
720 let valid_cases = vec![
724 "Type ` y ` to confirm.",
725 "Use ` git commit -m \"message\" ` to commit.",
726 "The variable ` $HOME ` contains home path.",
727 "The pattern ` *.txt ` matches text files.",
728 "This is ` random word ` with unnecessary spaces.",
729 "Text with ` plain text ` is valid.",
730 "Code with ` just code ` here.",
731 "Multiple ` word ` spans with ` text ` in one line.",
732 "This is ` code ` with both leading and trailing single space.",
733 "Use ` - ` as separator.",
734 ];
735 for case in valid_cases {
736 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
737 let result = rule.check(&ctx).unwrap();
738 assert!(
739 result.is_empty(),
740 "Single space on each side should not be flagged (CommonMark strips them): {case}"
741 );
742 }
743 }
744
745 #[test]
746 fn test_md038_whitespace_only_span_not_flagged() {
747 let rule = MD038NoSpaceInCode::new();
753 let whitespace_only_cases = vec![
754 "A single-space span `\u{0020}` is intentional.",
755 "A two-space span `\u{0020}\u{0020}` is intentional.",
756 "A three-space span `\u{0020}\u{0020}\u{0020}` is intentional.",
757 "A tab span `\t` is intentional.",
758 "Just the span: ` `",
759 ];
760 for case in whitespace_only_cases {
761 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763 assert!(
764 result.is_empty(),
765 "Whitespace-only code span should not be flagged (kept verbatim per CommonMark): {case}"
766 );
767 }
768 }
769
770 #[test]
771 fn test_md038_whitespace_only_span_fix_preserves_verbatim() {
772 let rule = MD038NoSpaceInCode::new();
775 let unchanged_cases = vec![
776 "A single-space span `\u{0020}` is intentional.",
777 "A two-space span `\u{0020}\u{0020}` is intentional.",
778 "Just the span: ` `",
779 ];
780 for case in unchanged_cases {
781 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
782 let result = rule.fix(&ctx).unwrap();
783 assert_eq!(
784 result, case,
785 "Whitespace-only code span must be left verbatim by fix, not collapsed to ``"
786 );
787 }
788 }
789
790 #[test]
791 fn test_md038_fix() {
792 let rule = MD038NoSpaceInCode::new();
793 let test_cases = vec![
795 (
797 "This is ` code` with leading space.",
798 "This is `code` with leading space.",
799 ),
800 (
802 "This is `code ` with trailing space.",
803 "This is `code` with trailing space.",
804 ),
805 (
807 "This is ` code ` with both spaces.",
808 "This is ` code ` with both spaces.", ),
810 (
812 "This is ` code ` with double leading space.",
813 "This is `code` with double leading space.",
814 ),
815 (
817 "Multiple ` code ` and `spans ` to fix.",
818 "Multiple ` code ` and `spans` to fix.", ),
820 ];
821 for (input, expected) in test_cases {
822 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
823 let result = rule.fix(&ctx).unwrap();
824 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
825 }
826 }
827
828 #[test]
829 fn test_check_invalid_leading_space() {
830 let rule = MD038NoSpaceInCode::new();
831 let input = "This has a ` leading space` in code";
832 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
833 let result = rule.check(&ctx).unwrap();
834 assert_eq!(result.len(), 1);
835 assert_eq!(result[0].line, 1);
836 assert!(result[0].fix.is_some());
837 }
838
839 #[test]
840 fn test_code_span_parsing_nested_backticks() {
841 let content = "Code with ` nested `code` example ` should preserve backticks";
842 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843
844 println!("Content: {content}");
845 println!("Code spans found:");
846 let code_spans = ctx.code_spans();
847 for (i, span) in code_spans.iter().enumerate() {
848 println!(
849 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
850 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
851 );
852 }
853
854 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
856 }
857
858 #[test]
859 fn test_nested_backtick_detection() {
860 let rule = MD038NoSpaceInCode::new();
861
862 let content = "Code with `` `backticks` inside `` should not be flagged";
864 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx).unwrap();
866 assert!(result.is_empty(), "Code spans with backticks should be skipped");
867 }
868
869 #[test]
870 fn test_quarto_inline_r_code() {
871 let rule = MD038NoSpaceInCode::new();
873
874 let content = r#"The result is `r nchar("test")` which equals 4."#;
877
878 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
880 let result_quarto = rule.check(&ctx_quarto).unwrap();
881 assert!(
882 result_quarto.is_empty(),
883 "Quarto inline R code should not trigger warnings. Got {} warnings",
884 result_quarto.len()
885 );
886
887 let content_other = "This has `plain text ` with trailing space.";
890 let ctx_other =
891 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
892 let result_other = rule.check(&ctx_other).unwrap();
893 assert_eq!(
894 result_other.len(),
895 1,
896 "Quarto should still flag non-R code spans with improper spaces"
897 );
898 }
899
900 #[test]
906 fn test_hugo_template_syntax_comprehensive() {
907 let rule = MD038NoSpaceInCode::new();
908
909 let valid_hugo_cases = vec![
913 (
915 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
916 "Multi-line raw shortcode",
917 ),
918 (
919 "Some text {{raw ` code `}} more text",
920 "Inline raw shortcode with spaces",
921 ),
922 ("{{raw `code`}}", "Raw shortcode without spaces"),
923 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
925 ("{{< `code` >}}", "Partial shortcode without spaces"),
926 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
928 ("{{% `code` %}}", "Percent shortcode without spaces"),
929 ("{{ ` code ` }}", "Generic shortcode with spaces"),
931 ("{{ `code` }}", "Generic shortcode without spaces"),
932 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
934 ("{{< code `go list` >}}", "Shortcode with code parameter"),
935 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
937 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
938 (
940 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
941 "Nested Go template syntax",
942 ),
943 ("{{raw `code`}}", "Hugo template at line start"),
945 ("Text {{raw `code`}}", "Hugo template at end of line"),
947 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
949 ];
950
951 for (case, description) in valid_hugo_cases {
952 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.check(&ctx).unwrap();
954 assert!(
955 result.is_empty(),
956 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
957 );
958 }
959
960 let should_be_flagged = vec![
965 ("This is ` code` with leading space.", "Leading space only"),
966 ("This is `code ` with trailing space.", "Trailing space only"),
967 ("Text ` code ` here", "Extra leading space (asymmetric)"),
968 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
969 ("Text ` code` here", "Double leading, no trailing"),
970 ("Text `code ` here", "No leading, double trailing"),
971 ];
972
973 for (case, description) in should_be_flagged {
974 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
975 let result = rule.check(&ctx).unwrap();
976 assert!(
977 !result.is_empty(),
978 "Should flag asymmetric space code spans: {description} - {case}"
979 );
980 }
981
982 let symmetric_single_space = vec![
988 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
989 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
990 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
991 ];
992
993 for (case, description) in symmetric_single_space {
994 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996 assert!(
997 result.is_empty(),
998 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
999 );
1000 }
1001
1002 let unicode_cases = vec![
1005 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
1006 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
1007 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
1008 (
1009 "{{raw `\n\tcode with 'single quotes'\n`}}",
1010 "Single quotes in Hugo template",
1011 ),
1012 ];
1013
1014 for (case, description) in unicode_cases {
1015 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1016 let result = rule.check(&ctx).unwrap();
1017 assert!(
1018 result.is_empty(),
1019 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
1020 );
1021 }
1022
1023 assert!(
1027 rule.check(&crate::lint_context::LintContext::new(
1028 "{{ ` ` }}",
1029 crate::config::MarkdownFlavor::Standard,
1030 None
1031 ))
1032 .unwrap()
1033 .is_empty(),
1034 "Minimum Hugo pattern should be valid"
1035 );
1036
1037 assert!(
1039 rule.check(&crate::lint_context::LintContext::new(
1040 "{{raw `\n\t\n`}}",
1041 crate::config::MarkdownFlavor::Standard,
1042 None
1043 ))
1044 .unwrap()
1045 .is_empty(),
1046 "Hugo template with only whitespace should be valid"
1047 );
1048 }
1049
1050 #[test]
1053 fn test_hugo_template_after_multibyte_text() {
1054 let rule = MD038NoSpaceInCode::new();
1055
1056 let exempt = [
1059 "日本語 {{raw `a ` }}",
1060 "café {{% `a ` }}",
1061 "{{< 日本語 `a ` }}",
1062 "日本語 {{ `a `\n}}",
1063 "日本語 {{raw `a\nb ` }}",
1064 ];
1065 for case in exempt {
1066 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1067 assert!(
1068 rule.check(&ctx).unwrap().is_empty(),
1069 "Hugo template behind multibyte text should not trigger MD038: {case}"
1070 );
1071 }
1072
1073 let flagged = [
1075 "日本語 {{raw`a ` }}",
1076 "café {{ `a ` and",
1077 "{{< 日本語`a ` }}",
1078 "日本語 {{raw`a\nb ` }}",
1079 ];
1080 for case in flagged {
1081 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1082 assert_eq!(
1083 rule.check(&ctx).unwrap().len(),
1084 1,
1085 "Near miss behind multibyte text should still be reported: {case}"
1086 );
1087 }
1088 }
1089
1090 #[test]
1092 fn test_hugo_template_with_other_markdown() {
1093 let rule = MD038NoSpaceInCode::new();
1094
1095 let content = r#"1. First item
10972. Second item with {{raw `code`}} template
10983. Third item"#;
1099 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
1102
1103 let content = r#"> Quote with {{raw `code`}} template"#;
1105 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let result = rule.check(&ctx).unwrap();
1107 assert!(
1108 result.is_empty(),
1109 "Hugo template in blockquote should not trigger warnings"
1110 );
1111
1112 let content = r#"{{raw `code`}} and ` bad code` here"#;
1114 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1115 let result = rule.check(&ctx).unwrap();
1116 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
1117 }
1118
1119 #[test]
1121 fn test_hugo_template_performance() {
1122 let rule = MD038NoSpaceInCode::new();
1123
1124 let mut content = String::new();
1126 for i in 0..100 {
1127 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
1128 }
1129
1130 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1131 let start = std::time::Instant::now();
1132 let result = rule.check(&ctx).unwrap();
1133 let duration = start.elapsed();
1134
1135 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
1136 assert!(
1137 duration.as_millis() < 1000,
1138 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
1139 );
1140 }
1141
1142 #[test]
1143 fn test_mkdocs_inline_hilite_not_flagged() {
1144 let rule = MD038NoSpaceInCode::new();
1147
1148 let valid_cases = vec![
1149 "`#!python print('hello')`",
1150 "`#!js alert('hi')`",
1151 "`#!c++ cout << x;`",
1152 "Use `#!python import os` to import modules",
1153 "`#!bash echo $HOME`",
1154 ];
1155
1156 for case in valid_cases {
1157 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
1158 let result = rule.check(&ctx).unwrap();
1159 assert!(
1160 result.is_empty(),
1161 "InlineHilite syntax should not be flagged in MkDocs: {case}"
1162 );
1163 }
1164
1165 let content = "`#!python print('hello')`";
1167 let ctx_standard =
1168 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169 let result_standard = rule.check(&ctx_standard).unwrap();
1170 assert!(
1173 result_standard.is_empty(),
1174 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
1175 );
1176 }
1177
1178 #[test]
1179 fn test_multibyte_utf8_no_panic() {
1180 let rule = MD038NoSpaceInCode::new();
1184
1185 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
1187 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
1188 let result = rule.check(&ctx);
1189 assert!(result.is_ok(), "Greek text should not panic");
1190
1191 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
1193 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
1194 let result = rule.check(&ctx);
1195 assert!(result.is_ok(), "Chinese text should not panic");
1196
1197 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
1199 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
1200 let result = rule.check(&ctx);
1201 assert!(result.is_ok(), "Cyrillic text should not panic");
1202
1203 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
1205 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
1206 let result = rule.check(&ctx);
1207 assert!(
1208 result.is_ok(),
1209 "Mixed Chinese text with multiple code spans should not panic"
1210 );
1211 }
1212
1213 #[test]
1217 fn test_obsidian_dataview_inline_dql_not_flagged() {
1218 let rule = MD038NoSpaceInCode::new();
1219
1220 let valid_dql_cases = vec![
1222 "`= this.file.name`",
1223 "`= date(today)`",
1224 "`= [[Page]].field`",
1225 "`= choice(condition, \"yes\", \"no\")`",
1226 "`= this.file.mtime`",
1227 "`= this.file.ctime`",
1228 "`= this.file.path`",
1229 "`= this.file.folder`",
1230 "`= this.file.size`",
1231 "`= this.file.ext`",
1232 "`= this.file.link`",
1233 "`= this.file.outlinks`",
1234 "`= this.file.inlinks`",
1235 "`= this.file.tags`",
1236 ];
1237
1238 for case in valid_dql_cases {
1239 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1240 let result = rule.check(&ctx).unwrap();
1241 assert!(
1242 result.is_empty(),
1243 "Dataview DQL expression should not be flagged in Obsidian: {case}"
1244 );
1245 }
1246 }
1247
1248 #[test]
1250 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
1251 let rule = MD038NoSpaceInCode::new();
1252
1253 let valid_dvjs_cases = vec![
1255 "`$= dv.current().file.mtime`",
1256 "`$= dv.pages().length`",
1257 "`$= dv.current()`",
1258 "`$= dv.pages('#tag').length`",
1259 "`$= dv.pages('\"folder\"').length`",
1260 "`$= dv.current().file.name`",
1261 "`$= dv.current().file.path`",
1262 "`$= dv.current().file.folder`",
1263 "`$= dv.current().file.link`",
1264 ];
1265
1266 for case in valid_dvjs_cases {
1267 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1268 let result = rule.check(&ctx).unwrap();
1269 assert!(
1270 result.is_empty(),
1271 "Dataview JS expression should not be flagged in Obsidian: {case}"
1272 );
1273 }
1274 }
1275
1276 #[test]
1278 fn test_obsidian_dataview_complex_expressions() {
1279 let rule = MD038NoSpaceInCode::new();
1280
1281 let complex_cases = vec![
1282 "`= sum(filter(pages, (p) => p.done))`",
1284 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
1285 "`= choice(x > 5, \"big\", \"small\")`",
1287 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
1288 "`= date(today) - dur(7 days)`",
1290 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
1291 "`= sum(rows.amount)`",
1293 "`= round(average(rows.score), 2)`",
1294 "`= min(rows.priority)`",
1295 "`= max(rows.priority)`",
1296 "`= join(this.file.tags, \", \")`",
1298 "`= replace(this.title, \"-\", \" \")`",
1299 "`= lower(this.file.name)`",
1300 "`= upper(this.file.name)`",
1301 "`= length(this.file.outlinks)`",
1303 "`= contains(this.file.tags, \"important\")`",
1304 "`= [[Page Name]].field`",
1306 "`= [[Folder/Subfolder/Page]].nested.field`",
1307 "`= default(this.status, \"unknown\")`",
1309 "`= coalesce(this.priority, this.importance, 0)`",
1310 ];
1311
1312 for case in complex_cases {
1313 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1314 let result = rule.check(&ctx).unwrap();
1315 assert!(
1316 result.is_empty(),
1317 "Complex Dataview expression should not be flagged in Obsidian: {case}"
1318 );
1319 }
1320 }
1321
1322 #[test]
1324 fn test_obsidian_dataviewjs_method_chains() {
1325 let rule = MD038NoSpaceInCode::new();
1326
1327 let method_chain_cases = vec![
1328 "`$= dv.pages().where(p => p.status).length`",
1329 "`$= dv.pages('#project').where(p => !p.done).length`",
1330 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
1331 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
1332 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
1333 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
1334 "`$= dv.page('Index').children.map(p => p.title)`",
1335 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1336 ];
1337
1338 for case in method_chain_cases {
1339 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1340 let result = rule.check(&ctx).unwrap();
1341 assert!(
1342 result.is_empty(),
1343 "DataviewJS method chain should not be flagged in Obsidian: {case}"
1344 );
1345 }
1346 }
1347
1348 #[test]
1357 fn test_standard_flavor_vs_obsidian_dataview() {
1358 let rule = MD038NoSpaceInCode::new();
1359
1360 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1363
1364 for case in no_issue_cases {
1365 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1367 let result_std = rule.check(&ctx_std).unwrap();
1368 assert!(
1369 result_std.is_empty(),
1370 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1371 );
1372
1373 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1375 let result_obs = rule.check(&ctx_obs).unwrap();
1376 assert!(
1377 result_obs.is_empty(),
1378 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1379 );
1380 }
1381
1382 let space_issues = vec![
1385 "` code`", "`code `", ];
1388
1389 for case in space_issues {
1390 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1392 let result_std = rule.check(&ctx_std).unwrap();
1393 assert!(
1394 !result_std.is_empty(),
1395 "Code with spacing issue should be flagged in Standard: {case}"
1396 );
1397
1398 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1400 let result_obs = rule.check(&ctx_obs).unwrap();
1401 assert!(
1402 !result_obs.is_empty(),
1403 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1404 );
1405 }
1406 }
1407
1408 #[test]
1410 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1411 let rule = MD038NoSpaceInCode::new();
1412
1413 let invalid_cases = [
1416 "` regular code`", "`code `", "` code `", "` code`", ];
1421
1422 let expected_flags = [
1424 true, true, false, true, ];
1429
1430 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1431 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1432 let result = rule.check(&ctx).unwrap();
1433 if *should_flag {
1434 assert!(
1435 !result.is_empty(),
1436 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1437 );
1438 } else {
1439 assert!(
1440 result.is_empty(),
1441 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1442 );
1443 }
1444 }
1445 }
1446
1447 #[test]
1449 fn test_obsidian_dataview_edge_cases() {
1450 let rule = MD038NoSpaceInCode::new();
1451
1452 let valid_cases = vec![
1454 ("`= 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), ];
1470
1471 for (case, should_be_valid) in valid_cases {
1472 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1473 let result = rule.check(&ctx).unwrap();
1474 if should_be_valid {
1475 assert!(
1476 result.is_empty(),
1477 "Valid Dataview expression should not be flagged: {case}"
1478 );
1479 } else {
1480 let _ = result;
1483 }
1484 }
1485 }
1486
1487 #[test]
1489 fn test_obsidian_dataview_in_context() {
1490 let rule = MD038NoSpaceInCode::new();
1491
1492 let content = r#"# My Note
1494
1495The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1496
1497Regular code: `println!("hello")` and `let x = 5;`
1498
1499DataviewJS count: `$= dv.pages('#project').length` projects found.
1500
1501More regular code with issue: ` bad code` should be flagged.
1502"#;
1503
1504 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1505 let result = rule.check(&ctx).unwrap();
1506
1507 assert_eq!(
1509 result.len(),
1510 1,
1511 "Should only flag the regular code span with leading space, not Dataview expressions"
1512 );
1513 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1514 }
1515
1516 #[test]
1518 fn test_obsidian_dataview_in_code_blocks() {
1519 let rule = MD038NoSpaceInCode::new();
1520
1521 let content = r#"# Example
1524
1525```
1526`= this.file.name`
1527`$= dv.current()`
1528```
1529
1530Regular paragraph with `= this.file.name` Dataview.
1531"#;
1532
1533 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1534 let result = rule.check(&ctx).unwrap();
1535
1536 assert!(
1538 result.is_empty(),
1539 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1540 );
1541 }
1542
1543 #[test]
1545 fn test_obsidian_dataview_unicode() {
1546 let rule = MD038NoSpaceInCode::new();
1547
1548 let unicode_cases = vec![
1549 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1556
1557 for case in unicode_cases {
1558 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1559 let result = rule.check(&ctx).unwrap();
1560 assert!(
1561 result.is_empty(),
1562 "Unicode Dataview expression should not be flagged: {case}"
1563 );
1564 }
1565 }
1566
1567 #[test]
1569 fn test_obsidian_regular_equals_still_works() {
1570 let rule = MD038NoSpaceInCode::new();
1571
1572 let valid_regular_cases = vec![
1574 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1580
1581 for case in valid_regular_cases {
1582 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1583 let result = rule.check(&ctx).unwrap();
1584 assert!(
1585 result.is_empty(),
1586 "Regular code with equals should not be flagged: {case}"
1587 );
1588 }
1589 }
1590
1591 #[test]
1593 fn test_obsidian_dataview_fix_preserves_expressions() {
1594 let rule = MD038NoSpaceInCode::new();
1595
1596 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1598 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1599 let fixed = rule.fix(&ctx).unwrap();
1600
1601 assert!(
1603 fixed.contains("`= this.file.name`"),
1604 "Dataview expression should be preserved after fix"
1605 );
1606 assert!(
1607 fixed.contains("`fixme`"),
1608 "Regular code span should be fixed (space removed)"
1609 );
1610 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1611 }
1612
1613 #[test]
1615 fn test_obsidian_multiple_dataview_same_line() {
1616 let rule = MD038NoSpaceInCode::new();
1617
1618 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1619 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1620 let result = rule.check(&ctx).unwrap();
1621
1622 assert!(
1623 result.is_empty(),
1624 "Multiple Dataview expressions on same line should all be valid"
1625 );
1626 }
1627
1628 #[test]
1630 fn test_obsidian_dataview_performance() {
1631 let rule = MD038NoSpaceInCode::new();
1632
1633 let mut content = String::new();
1635 for i in 0..100 {
1636 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1637 }
1638
1639 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1640 let start = std::time::Instant::now();
1641 let result = rule.check(&ctx).unwrap();
1642 let duration = start.elapsed();
1643
1644 assert!(result.is_empty(), "All Dataview expressions should be valid");
1645 assert!(
1646 duration.as_millis() < 1000,
1647 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1648 );
1649 }
1650
1651 #[test]
1653 fn test_is_dataview_expression_helper() {
1654 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1656 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1657 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1658 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1659 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1660 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1661
1662 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")); }
1673
1674 #[test]
1676 fn test_obsidian_dataview_with_tags() {
1677 let rule = MD038NoSpaceInCode::new();
1678
1679 let content = r#"# Project Status
1681
1682Tags: #project #active
1683
1684Status: `= this.status`
1685Count: `$= dv.pages('#project').length`
1686
1687Regular code: `function test() {}`
1688"#;
1689
1690 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1691 let result = rule.check(&ctx).unwrap();
1692
1693 assert!(
1695 result.is_empty(),
1696 "Dataview expressions and regular code should work together"
1697 );
1698 }
1699
1700 #[test]
1701 fn test_unicode_between_code_spans_no_panic() {
1702 let rule = MD038NoSpaceInCode::new();
1705
1706 let content = "Use `one` \u{00DC}nited `two` for backtick examples.";
1708 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1709 let result = rule.check(&ctx);
1710 assert!(result.is_ok(), "Should not panic with Unicode between code spans");
1712
1713 let content_cjk = "Use `one` \u{4E16}\u{754C} `two` for examples.";
1715 let ctx_cjk = crate::lint_context::LintContext::new(content_cjk, crate::config::MarkdownFlavor::Standard, None);
1716 let result_cjk = rule.check(&ctx_cjk);
1717 assert!(result_cjk.is_ok(), "Should not panic with CJK between code spans");
1718 }
1719
1720 #[test]
1721 fn test_pandoc_inline_r_code_not_exempt() {
1722 let rule = MD038NoSpaceInCode::new();
1728 let content = "See `r foo ` for details.\n";
1731
1732 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1734 let result_quarto = rule.check(&ctx_quarto).unwrap();
1735 assert!(
1736 result_quarto.is_empty(),
1737 "MD038 should suppress trailing-space warning for `r expression` under Quarto: {result_quarto:?}"
1738 );
1739
1740 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1742 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1743 assert!(
1744 !result_pandoc.is_empty(),
1745 "MD038 should flag trailing space in `r expression` under Pandoc flavor (not Quarto/RMarkdown syntax): {result_pandoc:?}"
1746 );
1747 }
1748
1749 #[test]
1754 fn test_pandoc_inline_code_attr_does_not_suppress_leading_space() {
1755 let rule = MD038NoSpaceInCode::new();
1756 let content = "Use ` print()`{.python} for output.\n";
1757 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1758 let result = rule.check(&ctx).unwrap();
1759 assert!(
1760 !result.is_empty(),
1761 "MD038 must flag leading space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1762 );
1763 }
1764
1765 #[test]
1769 fn test_pandoc_inline_code_attr_does_not_suppress_trailing_space() {
1770 let rule = MD038NoSpaceInCode::new();
1771 let content = "Use `print() `{.python} for output.\n";
1772 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1773 let result = rule.check(&ctx).unwrap();
1774 assert!(
1775 !result.is_empty(),
1776 "MD038 must flag trailing space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1777 );
1778 }
1779
1780 #[test]
1782 fn test_standard_still_flags_leading_space_with_attr_syntax() {
1783 let rule = MD038NoSpaceInCode::new();
1784 let content = "Use ` print()`{.python} for output.\n";
1785 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1786 let result = rule.check(&ctx).unwrap();
1787 assert!(
1788 !result.is_empty(),
1789 "MD038 should flag leading space in code span under Standard flavor: {result:?}"
1790 );
1791 }
1792
1793 #[test]
1796 fn test_pandoc_inline_code_attr_clean_span_not_flagged() {
1797 let rule = MD038NoSpaceInCode::new();
1798 let content = "Use `print()`{.python} for output.\n";
1799 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1800 let result = rule.check(&ctx).unwrap();
1801 assert!(
1802 result.is_empty(),
1803 "MD038 must not flag a clean attributed code span under Pandoc: {result:?}"
1804 );
1805 }
1806}