1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mkdocs_extensions::is_inline_hilite_content;
3
4#[derive(Debug, Clone, Default)]
29pub struct MD038NoSpaceInCode {
30 pub enabled: bool,
31}
32
33impl MD038NoSpaceInCode {
34 pub fn new() -> Self {
35 Self { enabled: true }
36 }
37
38 fn is_hugo_template_syntax(
54 &self,
55 ctx: &crate::lint_context::LintContext,
56 code_span: &crate::lint_context::CodeSpan,
57 ) -> bool {
58 let start_line_idx = code_span.line.saturating_sub(1);
59 if start_line_idx >= ctx.lines.len() {
60 return false;
61 }
62
63 let start_line_content = ctx.lines[start_line_idx].content(ctx.content);
64
65 let span_start_col = code_span.start_col;
67
68 if span_start_col >= 3 {
74 let before_span: String = start_line_content.chars().take(span_start_col).collect();
77
78 let char_at_span_start = start_line_content.chars().nth(span_start_col).unwrap_or(' ');
82
83 let is_hugo_start =
91 (before_span.ends_with("{{raw ") && char_at_span_start == '`')
93 || (before_span.starts_with("{{<") && before_span.ends_with(' ') && char_at_span_start == '`')
95 || (before_span.ends_with("{{% ") && char_at_span_start == '`')
97 || (before_span.ends_with("{{ ") && char_at_span_start == '`');
99
100 if is_hugo_start {
101 let end_line_idx = code_span.end_line.saturating_sub(1);
104 if end_line_idx < ctx.lines.len() {
105 let end_line_content = ctx.lines[end_line_idx].content(ctx.content);
106 let end_line_char_count = end_line_content.chars().count();
107 let span_end_col = code_span.end_col.min(end_line_char_count);
108
109 if span_end_col < end_line_char_count {
111 let after_span: String = end_line_content.chars().skip(span_end_col).collect();
112 if after_span.trim_start().starts_with("}}") {
113 return true;
114 }
115 }
116
117 let next_line_idx = code_span.end_line;
119 if next_line_idx < ctx.lines.len() {
120 let next_line = ctx.lines[next_line_idx].content(ctx.content);
121 if next_line.trim_start().starts_with("}}") {
122 return true;
123 }
124 }
125 }
126 }
127 }
128
129 false
130 }
131
132 fn is_dataview_expression(content: &str) -> bool {
148 content.starts_with("= ") || content.starts_with("$= ")
151 }
152
153 fn is_likely_nested_backticks(&self, ctx: &crate::lint_context::LintContext, span_index: usize) -> bool {
155 let code_spans = ctx.code_spans();
158 let current_span = &code_spans[span_index];
159 let current_line = current_span.line;
160
161 let same_line_spans: Vec<_> = code_spans
163 .iter()
164 .enumerate()
165 .filter(|(i, s)| s.line == current_line && *i != span_index)
166 .collect();
167
168 if same_line_spans.is_empty() {
169 return false;
170 }
171
172 let line_idx = current_line - 1; if line_idx >= ctx.lines.len() {
176 return false;
177 }
178
179 let line_content = &ctx.lines[line_idx].content(ctx.content);
180
181 for (_, other_span) in &same_line_spans {
183 let start = current_span.end_col.min(other_span.end_col);
184 let end = current_span.start_col.max(other_span.start_col);
185
186 if start < end && end <= line_content.len() {
187 if let Some(between) = line_content.get(start..end) {
189 if between.contains("code") || between.contains("backtick") {
192 return true;
193 }
194 }
195 }
196 }
197
198 false
199 }
200}
201
202impl Rule for MD038NoSpaceInCode {
203 fn name(&self) -> &'static str {
204 "MD038"
205 }
206
207 fn description(&self) -> &'static str {
208 "Spaces inside code span elements"
209 }
210
211 fn category(&self) -> RuleCategory {
212 RuleCategory::Other
213 }
214
215 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
216 if !self.enabled {
217 return Ok(vec![]);
218 }
219
220 let mut warnings = Vec::new();
221
222 let code_spans = ctx.code_spans();
224 for (i, code_span) in code_spans.iter().enumerate() {
225 if let Some(line_info) = ctx.lines.get(code_span.line - 1) {
227 if line_info.in_code_block {
228 continue;
229 }
230 if (line_info.in_mkdocs_container() || line_info.in_pymdown_block) && code_span.content.contains('\n') {
234 continue;
235 }
236 }
237
238 let code_content = &code_span.content;
239
240 if code_content.is_empty() {
242 continue;
243 }
244
245 let has_leading_space = code_content.chars().next().is_some_and(|c| c.is_whitespace());
247 let has_trailing_space = code_content.chars().last().is_some_and(|c| c.is_whitespace());
248
249 if !has_leading_space && !has_trailing_space {
250 continue;
251 }
252
253 let trimmed = code_content.trim();
254
255 if code_content != trimmed {
257 if has_leading_space && has_trailing_space && !trimmed.is_empty() {
269 let leading_spaces = code_content.len() - code_content.trim_start().len();
270 let trailing_spaces = code_content.len() - code_content.trim_end().len();
271
272 if leading_spaces == 1 && trailing_spaces == 1 {
274 continue;
275 }
276 }
277 if trimmed.contains('`') {
280 continue;
281 }
282
283 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
286 && trimmed.starts_with('r')
287 && trimmed.len() > 1
288 && trimmed.chars().nth(1).is_some_and(|c| c.is_whitespace())
289 {
290 continue;
291 }
292
293 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
296 continue;
297 }
298
299 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && Self::is_dataview_expression(code_content) {
303 continue;
304 }
305
306 if self.is_hugo_template_syntax(ctx, code_span) {
309 continue;
310 }
311
312 if self.is_likely_nested_backticks(ctx, i) {
315 continue;
316 }
317
318 warnings.push(LintWarning {
319 rule_name: Some(self.name().to_string()),
320 line: code_span.line,
321 column: code_span.start_col + 1, end_line: code_span.line,
323 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
325 severity: Severity::Warning,
326 fix: Some(Fix {
327 range: code_span.byte_offset..code_span.byte_end,
328 replacement: format!(
329 "{}{}{}",
330 "`".repeat(code_span.backtick_count),
331 trimmed,
332 "`".repeat(code_span.backtick_count)
333 ),
334 }),
335 });
336 }
337 }
338
339 Ok(warnings)
340 }
341
342 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
343 let content = ctx.content;
344 if !self.enabled {
345 return Ok(content.to_string());
346 }
347
348 if !content.contains('`') {
350 return Ok(content.to_string());
351 }
352
353 let warnings = self.check(ctx)?;
355 if warnings.is_empty() {
356 return Ok(content.to_string());
357 }
358
359 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
361 .into_iter()
362 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
363 .collect();
364
365 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
366
367 let mut result = content.to_string();
369 for (range, replacement) in fixes {
370 result.replace_range(range, &replacement);
371 }
372
373 Ok(result)
374 }
375
376 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
378 !ctx.likely_has_code()
379 }
380
381 fn as_any(&self) -> &dyn std::any::Any {
382 self
383 }
384
385 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
386 where
387 Self: Sized,
388 {
389 Box::new(MD038NoSpaceInCode { enabled: true })
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn test_md038_readme_false_positives() {
399 let rule = MD038NoSpaceInCode::new();
401 let valid_cases = vec![
402 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
403 "#### Effective Configuration (`rumdl config`)",
404 "- Blue: `.rumdl.toml`",
405 "### Defaults Only (`rumdl config --defaults`)",
406 ];
407
408 for case in valid_cases {
409 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
410 let result = rule.check(&ctx).unwrap();
411 assert!(
412 result.is_empty(),
413 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
414 case,
415 result.len()
416 );
417 }
418 }
419
420 #[test]
421 fn test_md038_valid() {
422 let rule = MD038NoSpaceInCode::new();
423 let valid_cases = vec![
424 "This is `code` in a sentence.",
425 "This is a `longer code span` in a sentence.",
426 "This is `code with internal spaces` which is fine.",
427 "Code span at `end of line`",
428 "`Start of line` code span",
429 "Multiple `code spans` in `one line` are fine",
430 "Code span with `symbols: !@#$%^&*()`",
431 "Empty code span `` is technically valid",
432 ];
433 for case in valid_cases {
434 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
435 let result = rule.check(&ctx).unwrap();
436 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
437 }
438 }
439
440 #[test]
441 fn test_md038_invalid() {
442 let rule = MD038NoSpaceInCode::new();
443 let invalid_cases = vec![
448 "This is ` code` with leading space.",
450 "This is `code ` with trailing space.",
452 "This is ` code ` with double leading space.",
454 "This is ` code ` with double trailing space.",
456 "This is ` code ` with double spaces both sides.",
458 ];
459 for case in invalid_cases {
460 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
461 let result = rule.check(&ctx).unwrap();
462 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
463 }
464 }
465
466 #[test]
467 fn test_md038_valid_commonmark_stripping() {
468 let rule = MD038NoSpaceInCode::new();
469 let valid_cases = vec![
473 "Type ` y ` to confirm.",
474 "Use ` git commit -m \"message\" ` to commit.",
475 "The variable ` $HOME ` contains home path.",
476 "The pattern ` *.txt ` matches text files.",
477 "This is ` random word ` with unnecessary spaces.",
478 "Text with ` plain text ` is valid.",
479 "Code with ` just code ` here.",
480 "Multiple ` word ` spans with ` text ` in one line.",
481 "This is ` code ` with both leading and trailing single space.",
482 "Use ` - ` as separator.",
483 ];
484 for case in valid_cases {
485 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
486 let result = rule.check(&ctx).unwrap();
487 assert!(
488 result.is_empty(),
489 "Single space on each side should not be flagged (CommonMark strips them): {case}"
490 );
491 }
492 }
493
494 #[test]
495 fn test_md038_fix() {
496 let rule = MD038NoSpaceInCode::new();
497 let test_cases = vec![
499 (
501 "This is ` code` with leading space.",
502 "This is `code` with leading space.",
503 ),
504 (
506 "This is `code ` with trailing space.",
507 "This is `code` with trailing space.",
508 ),
509 (
511 "This is ` code ` with both spaces.",
512 "This is ` code ` with both spaces.", ),
514 (
516 "This is ` code ` with double leading space.",
517 "This is `code` with double leading space.",
518 ),
519 (
521 "Multiple ` code ` and `spans ` to fix.",
522 "Multiple ` code ` and `spans` to fix.", ),
524 ];
525 for (input, expected) in test_cases {
526 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
527 let result = rule.fix(&ctx).unwrap();
528 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
529 }
530 }
531
532 #[test]
533 fn test_check_invalid_leading_space() {
534 let rule = MD038NoSpaceInCode::new();
535 let input = "This has a ` leading space` in code";
536 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
537 let result = rule.check(&ctx).unwrap();
538 assert_eq!(result.len(), 1);
539 assert_eq!(result[0].line, 1);
540 assert!(result[0].fix.is_some());
541 }
542
543 #[test]
544 fn test_code_span_parsing_nested_backticks() {
545 let content = "Code with ` nested `code` example ` should preserve backticks";
546 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
547
548 println!("Content: {content}");
549 println!("Code spans found:");
550 let code_spans = ctx.code_spans();
551 for (i, span) in code_spans.iter().enumerate() {
552 println!(
553 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
554 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
555 );
556 }
557
558 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
560 }
561
562 #[test]
563 fn test_nested_backtick_detection() {
564 let rule = MD038NoSpaceInCode::new();
565
566 let content = "Code with `` `backticks` inside `` should not be flagged";
568 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569 let result = rule.check(&ctx).unwrap();
570 assert!(result.is_empty(), "Code spans with backticks should be skipped");
571 }
572
573 #[test]
574 fn test_quarto_inline_r_code() {
575 let rule = MD038NoSpaceInCode::new();
577
578 let content = r#"The result is `r nchar("test")` which equals 4."#;
581
582 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
584 let result_quarto = rule.check(&ctx_quarto).unwrap();
585 assert!(
586 result_quarto.is_empty(),
587 "Quarto inline R code should not trigger warnings. Got {} warnings",
588 result_quarto.len()
589 );
590
591 let content_other = "This has `plain text ` with trailing space.";
594 let ctx_other =
595 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
596 let result_other = rule.check(&ctx_other).unwrap();
597 assert_eq!(
598 result_other.len(),
599 1,
600 "Quarto should still flag non-R code spans with improper spaces"
601 );
602 }
603
604 #[test]
610 fn test_hugo_template_syntax_comprehensive() {
611 let rule = MD038NoSpaceInCode::new();
612
613 let valid_hugo_cases = vec![
617 (
619 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
620 "Multi-line raw shortcode",
621 ),
622 (
623 "Some text {{raw ` code `}} more text",
624 "Inline raw shortcode with spaces",
625 ),
626 ("{{raw `code`}}", "Raw shortcode without spaces"),
627 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
629 ("{{< `code` >}}", "Partial shortcode without spaces"),
630 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
632 ("{{% `code` %}}", "Percent shortcode without spaces"),
633 ("{{ ` code ` }}", "Generic shortcode with spaces"),
635 ("{{ `code` }}", "Generic shortcode without spaces"),
636 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
638 ("{{< code `go list` >}}", "Shortcode with code parameter"),
639 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
641 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
642 (
644 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
645 "Nested Go template syntax",
646 ),
647 ("{{raw `code`}}", "Hugo template at line start"),
649 ("Text {{raw `code`}}", "Hugo template at end of line"),
651 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
653 ];
654
655 for (case, description) in valid_hugo_cases {
656 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
657 let result = rule.check(&ctx).unwrap();
658 assert!(
659 result.is_empty(),
660 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
661 );
662 }
663
664 let should_be_flagged = vec![
669 ("This is ` code` with leading space.", "Leading space only"),
670 ("This is `code ` with trailing space.", "Trailing space only"),
671 ("Text ` code ` here", "Extra leading space (asymmetric)"),
672 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
673 ("Text ` code` here", "Double leading, no trailing"),
674 ("Text `code ` here", "No leading, double trailing"),
675 ];
676
677 for (case, description) in should_be_flagged {
678 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.check(&ctx).unwrap();
680 assert!(
681 !result.is_empty(),
682 "Should flag asymmetric space code spans: {description} - {case}"
683 );
684 }
685
686 let symmetric_single_space = vec![
692 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
693 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
694 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
695 ];
696
697 for (case, description) in symmetric_single_space {
698 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
699 let result = rule.check(&ctx).unwrap();
700 assert!(
701 result.is_empty(),
702 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
703 );
704 }
705
706 let unicode_cases = vec![
709 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
710 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
711 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
712 (
713 "{{raw `\n\tcode with 'single quotes'\n`}}",
714 "Single quotes in Hugo template",
715 ),
716 ];
717
718 for (case, description) in unicode_cases {
719 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
720 let result = rule.check(&ctx).unwrap();
721 assert!(
722 result.is_empty(),
723 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
724 );
725 }
726
727 assert!(
731 rule.check(&crate::lint_context::LintContext::new(
732 "{{ ` ` }}",
733 crate::config::MarkdownFlavor::Standard,
734 None
735 ))
736 .unwrap()
737 .is_empty(),
738 "Minimum Hugo pattern should be valid"
739 );
740
741 assert!(
743 rule.check(&crate::lint_context::LintContext::new(
744 "{{raw `\n\t\n`}}",
745 crate::config::MarkdownFlavor::Standard,
746 None
747 ))
748 .unwrap()
749 .is_empty(),
750 "Hugo template with only whitespace should be valid"
751 );
752 }
753
754 #[test]
756 fn test_hugo_template_with_other_markdown() {
757 let rule = MD038NoSpaceInCode::new();
758
759 let content = r#"1. First item
7612. Second item with {{raw `code`}} template
7623. Third item"#;
763 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
764 let result = rule.check(&ctx).unwrap();
765 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
766
767 let content = r#"> Quote with {{raw `code`}} template"#;
769 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert!(
772 result.is_empty(),
773 "Hugo template in blockquote should not trigger warnings"
774 );
775
776 let content = r#"{{raw `code`}} and ` bad code` here"#;
778 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779 let result = rule.check(&ctx).unwrap();
780 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
781 }
782
783 #[test]
785 fn test_hugo_template_performance() {
786 let rule = MD038NoSpaceInCode::new();
787
788 let mut content = String::new();
790 for i in 0..100 {
791 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
792 }
793
794 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
795 let start = std::time::Instant::now();
796 let result = rule.check(&ctx).unwrap();
797 let duration = start.elapsed();
798
799 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
800 assert!(
801 duration.as_millis() < 1000,
802 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
803 );
804 }
805
806 #[test]
807 fn test_mkdocs_inline_hilite_not_flagged() {
808 let rule = MD038NoSpaceInCode::new();
811
812 let valid_cases = vec![
813 "`#!python print('hello')`",
814 "`#!js alert('hi')`",
815 "`#!c++ cout << x;`",
816 "Use `#!python import os` to import modules",
817 "`#!bash echo $HOME`",
818 ];
819
820 for case in valid_cases {
821 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
822 let result = rule.check(&ctx).unwrap();
823 assert!(
824 result.is_empty(),
825 "InlineHilite syntax should not be flagged in MkDocs: {case}"
826 );
827 }
828
829 let content = "`#!python print('hello')`";
831 let ctx_standard =
832 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833 let result_standard = rule.check(&ctx_standard).unwrap();
834 assert!(
837 result_standard.is_empty(),
838 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
839 );
840 }
841
842 #[test]
843 fn test_multibyte_utf8_no_panic() {
844 let rule = MD038NoSpaceInCode::new();
848
849 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
851 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
852 let result = rule.check(&ctx);
853 assert!(result.is_ok(), "Greek text should not panic");
854
855 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
857 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
858 let result = rule.check(&ctx);
859 assert!(result.is_ok(), "Chinese text should not panic");
860
861 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
863 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx);
865 assert!(result.is_ok(), "Cyrillic text should not panic");
866
867 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
869 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
870 let result = rule.check(&ctx);
871 assert!(
872 result.is_ok(),
873 "Mixed Chinese text with multiple code spans should not panic"
874 );
875 }
876
877 #[test]
881 fn test_obsidian_dataview_inline_dql_not_flagged() {
882 let rule = MD038NoSpaceInCode::new();
883
884 let valid_dql_cases = vec![
886 "`= this.file.name`",
887 "`= date(today)`",
888 "`= [[Page]].field`",
889 "`= choice(condition, \"yes\", \"no\")`",
890 "`= this.file.mtime`",
891 "`= this.file.ctime`",
892 "`= this.file.path`",
893 "`= this.file.folder`",
894 "`= this.file.size`",
895 "`= this.file.ext`",
896 "`= this.file.link`",
897 "`= this.file.outlinks`",
898 "`= this.file.inlinks`",
899 "`= this.file.tags`",
900 ];
901
902 for case in valid_dql_cases {
903 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
904 let result = rule.check(&ctx).unwrap();
905 assert!(
906 result.is_empty(),
907 "Dataview DQL expression should not be flagged in Obsidian: {case}"
908 );
909 }
910 }
911
912 #[test]
914 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
915 let rule = MD038NoSpaceInCode::new();
916
917 let valid_dvjs_cases = vec![
919 "`$= dv.current().file.mtime`",
920 "`$= dv.pages().length`",
921 "`$= dv.current()`",
922 "`$= dv.pages('#tag').length`",
923 "`$= dv.pages('\"folder\"').length`",
924 "`$= dv.current().file.name`",
925 "`$= dv.current().file.path`",
926 "`$= dv.current().file.folder`",
927 "`$= dv.current().file.link`",
928 ];
929
930 for case in valid_dvjs_cases {
931 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
932 let result = rule.check(&ctx).unwrap();
933 assert!(
934 result.is_empty(),
935 "Dataview JS expression should not be flagged in Obsidian: {case}"
936 );
937 }
938 }
939
940 #[test]
942 fn test_obsidian_dataview_complex_expressions() {
943 let rule = MD038NoSpaceInCode::new();
944
945 let complex_cases = vec![
946 "`= sum(filter(pages, (p) => p.done))`",
948 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
949 "`= choice(x > 5, \"big\", \"small\")`",
951 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
952 "`= date(today) - dur(7 days)`",
954 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
955 "`= sum(rows.amount)`",
957 "`= round(average(rows.score), 2)`",
958 "`= min(rows.priority)`",
959 "`= max(rows.priority)`",
960 "`= join(this.file.tags, \", \")`",
962 "`= replace(this.title, \"-\", \" \")`",
963 "`= lower(this.file.name)`",
964 "`= upper(this.file.name)`",
965 "`= length(this.file.outlinks)`",
967 "`= contains(this.file.tags, \"important\")`",
968 "`= [[Page Name]].field`",
970 "`= [[Folder/Subfolder/Page]].nested.field`",
971 "`= default(this.status, \"unknown\")`",
973 "`= coalesce(this.priority, this.importance, 0)`",
974 ];
975
976 for case in complex_cases {
977 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
978 let result = rule.check(&ctx).unwrap();
979 assert!(
980 result.is_empty(),
981 "Complex Dataview expression should not be flagged in Obsidian: {case}"
982 );
983 }
984 }
985
986 #[test]
988 fn test_obsidian_dataviewjs_method_chains() {
989 let rule = MD038NoSpaceInCode::new();
990
991 let method_chain_cases = vec![
992 "`$= dv.pages().where(p => p.status).length`",
993 "`$= dv.pages('#project').where(p => !p.done).length`",
994 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
995 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
996 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
997 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
998 "`$= dv.page('Index').children.map(p => p.title)`",
999 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1000 ];
1001
1002 for case in method_chain_cases {
1003 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1004 let result = rule.check(&ctx).unwrap();
1005 assert!(
1006 result.is_empty(),
1007 "DataviewJS method chain should not be flagged in Obsidian: {case}"
1008 );
1009 }
1010 }
1011
1012 #[test]
1021 fn test_standard_flavor_vs_obsidian_dataview() {
1022 let rule = MD038NoSpaceInCode::new();
1023
1024 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1027
1028 for case in no_issue_cases {
1029 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1031 let result_std = rule.check(&ctx_std).unwrap();
1032 assert!(
1033 result_std.is_empty(),
1034 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1035 );
1036
1037 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1039 let result_obs = rule.check(&ctx_obs).unwrap();
1040 assert!(
1041 result_obs.is_empty(),
1042 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1043 );
1044 }
1045
1046 let space_issues = vec![
1049 "` code`", "`code `", ];
1052
1053 for case in space_issues {
1054 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1056 let result_std = rule.check(&ctx_std).unwrap();
1057 assert!(
1058 !result_std.is_empty(),
1059 "Code with spacing issue should be flagged in Standard: {case}"
1060 );
1061
1062 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1064 let result_obs = rule.check(&ctx_obs).unwrap();
1065 assert!(
1066 !result_obs.is_empty(),
1067 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1068 );
1069 }
1070 }
1071
1072 #[test]
1074 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1075 let rule = MD038NoSpaceInCode::new();
1076
1077 let invalid_cases = [
1080 "` regular code`", "`code `", "` code `", "` code`", ];
1085
1086 let expected_flags = [
1088 true, true, false, true, ];
1093
1094 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1095 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1096 let result = rule.check(&ctx).unwrap();
1097 if *should_flag {
1098 assert!(
1099 !result.is_empty(),
1100 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1101 );
1102 } else {
1103 assert!(
1104 result.is_empty(),
1105 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1106 );
1107 }
1108 }
1109 }
1110
1111 #[test]
1113 fn test_obsidian_dataview_edge_cases() {
1114 let rule = MD038NoSpaceInCode::new();
1115
1116 let valid_cases = vec![
1118 ("`= 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), ];
1134
1135 for (case, should_be_valid) in valid_cases {
1136 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1137 let result = rule.check(&ctx).unwrap();
1138 if should_be_valid {
1139 assert!(
1140 result.is_empty(),
1141 "Valid Dataview expression should not be flagged: {case}"
1142 );
1143 } else {
1144 let _ = result;
1147 }
1148 }
1149 }
1150
1151 #[test]
1153 fn test_obsidian_dataview_in_context() {
1154 let rule = MD038NoSpaceInCode::new();
1155
1156 let content = r#"# My Note
1158
1159The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1160
1161Regular code: `println!("hello")` and `let x = 5;`
1162
1163DataviewJS count: `$= dv.pages('#project').length` projects found.
1164
1165More regular code with issue: ` bad code` should be flagged.
1166"#;
1167
1168 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1169 let result = rule.check(&ctx).unwrap();
1170
1171 assert_eq!(
1173 result.len(),
1174 1,
1175 "Should only flag the regular code span with leading space, not Dataview expressions"
1176 );
1177 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1178 }
1179
1180 #[test]
1182 fn test_obsidian_dataview_in_code_blocks() {
1183 let rule = MD038NoSpaceInCode::new();
1184
1185 let content = r#"# Example
1188
1189```
1190`= this.file.name`
1191`$= dv.current()`
1192```
1193
1194Regular paragraph with `= this.file.name` Dataview.
1195"#;
1196
1197 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1198 let result = rule.check(&ctx).unwrap();
1199
1200 assert!(
1202 result.is_empty(),
1203 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1204 );
1205 }
1206
1207 #[test]
1209 fn test_obsidian_dataview_unicode() {
1210 let rule = MD038NoSpaceInCode::new();
1211
1212 let unicode_cases = vec![
1213 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1220
1221 for case in unicode_cases {
1222 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1223 let result = rule.check(&ctx).unwrap();
1224 assert!(
1225 result.is_empty(),
1226 "Unicode Dataview expression should not be flagged: {case}"
1227 );
1228 }
1229 }
1230
1231 #[test]
1233 fn test_obsidian_regular_equals_still_works() {
1234 let rule = MD038NoSpaceInCode::new();
1235
1236 let valid_regular_cases = vec![
1238 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1244
1245 for case in valid_regular_cases {
1246 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1247 let result = rule.check(&ctx).unwrap();
1248 assert!(
1249 result.is_empty(),
1250 "Regular code with equals should not be flagged: {case}"
1251 );
1252 }
1253 }
1254
1255 #[test]
1257 fn test_obsidian_dataview_fix_preserves_expressions() {
1258 let rule = MD038NoSpaceInCode::new();
1259
1260 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1262 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1263 let fixed = rule.fix(&ctx).unwrap();
1264
1265 assert!(
1267 fixed.contains("`= this.file.name`"),
1268 "Dataview expression should be preserved after fix"
1269 );
1270 assert!(
1271 fixed.contains("`fixme`"),
1272 "Regular code span should be fixed (space removed)"
1273 );
1274 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1275 }
1276
1277 #[test]
1279 fn test_obsidian_multiple_dataview_same_line() {
1280 let rule = MD038NoSpaceInCode::new();
1281
1282 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1283 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1284 let result = rule.check(&ctx).unwrap();
1285
1286 assert!(
1287 result.is_empty(),
1288 "Multiple Dataview expressions on same line should all be valid"
1289 );
1290 }
1291
1292 #[test]
1294 fn test_obsidian_dataview_performance() {
1295 let rule = MD038NoSpaceInCode::new();
1296
1297 let mut content = String::new();
1299 for i in 0..100 {
1300 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1301 }
1302
1303 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1304 let start = std::time::Instant::now();
1305 let result = rule.check(&ctx).unwrap();
1306 let duration = start.elapsed();
1307
1308 assert!(result.is_empty(), "All Dataview expressions should be valid");
1309 assert!(
1310 duration.as_millis() < 1000,
1311 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1312 );
1313 }
1314
1315 #[test]
1317 fn test_is_dataview_expression_helper() {
1318 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1320 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1321 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1322 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1323 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1324 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1325
1326 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")); }
1337
1338 #[test]
1340 fn test_obsidian_dataview_with_tags() {
1341 let rule = MD038NoSpaceInCode::new();
1342
1343 let content = r#"# Project Status
1345
1346Tags: #project #active
1347
1348Status: `= this.status`
1349Count: `$= dv.pages('#project').length`
1350
1351Regular code: `function test() {}`
1352"#;
1353
1354 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1355 let result = rule.check(&ctx).unwrap();
1356
1357 assert!(
1359 result.is_empty(),
1360 "Dataview expressions and regular code should work together"
1361 );
1362 }
1363}