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 let code_content = &code_span.content;
226
227 if code_content.is_empty() {
229 continue;
230 }
231
232 let has_leading_space = code_content.chars().next().is_some_and(|c| c.is_whitespace());
234 let has_trailing_space = code_content.chars().last().is_some_and(|c| c.is_whitespace());
235
236 if !has_leading_space && !has_trailing_space {
237 continue;
238 }
239
240 let trimmed = code_content.trim();
241
242 if code_content != trimmed {
244 if has_leading_space && has_trailing_space && !trimmed.is_empty() {
256 let leading_spaces = code_content.len() - code_content.trim_start().len();
257 let trailing_spaces = code_content.len() - code_content.trim_end().len();
258
259 if leading_spaces == 1 && trailing_spaces == 1 {
261 continue;
262 }
263 }
264 if trimmed.contains('`') {
267 continue;
268 }
269
270 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
273 && trimmed.starts_with('r')
274 && trimmed.len() > 1
275 && trimmed.chars().nth(1).is_some_and(|c| c.is_whitespace())
276 {
277 continue;
278 }
279
280 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
283 continue;
284 }
285
286 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && Self::is_dataview_expression(code_content) {
290 continue;
291 }
292
293 if self.is_hugo_template_syntax(ctx, code_span) {
296 continue;
297 }
298
299 if self.is_likely_nested_backticks(ctx, i) {
302 continue;
303 }
304
305 warnings.push(LintWarning {
306 rule_name: Some(self.name().to_string()),
307 line: code_span.line,
308 column: code_span.start_col + 1, end_line: code_span.line,
310 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
312 severity: Severity::Warning,
313 fix: Some(Fix {
314 range: code_span.byte_offset..code_span.byte_end,
315 replacement: format!(
316 "{}{}{}",
317 "`".repeat(code_span.backtick_count),
318 trimmed,
319 "`".repeat(code_span.backtick_count)
320 ),
321 }),
322 });
323 }
324 }
325
326 Ok(warnings)
327 }
328
329 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
330 let content = ctx.content;
331 if !self.enabled {
332 return Ok(content.to_string());
333 }
334
335 if !content.contains('`') {
337 return Ok(content.to_string());
338 }
339
340 let warnings = self.check(ctx)?;
342 if warnings.is_empty() {
343 return Ok(content.to_string());
344 }
345
346 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
348 .into_iter()
349 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
350 .collect();
351
352 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
353
354 let mut result = content.to_string();
356 for (range, replacement) in fixes {
357 result.replace_range(range, &replacement);
358 }
359
360 Ok(result)
361 }
362
363 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
365 !ctx.likely_has_code()
366 }
367
368 fn as_any(&self) -> &dyn std::any::Any {
369 self
370 }
371
372 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
373 where
374 Self: Sized,
375 {
376 Box::new(MD038NoSpaceInCode { enabled: true })
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn test_md038_readme_false_positives() {
386 let rule = MD038NoSpaceInCode::new();
388 let valid_cases = vec![
389 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
390 "#### Effective Configuration (`rumdl config`)",
391 "- Blue: `.rumdl.toml`",
392 "### Defaults Only (`rumdl config --defaults`)",
393 ];
394
395 for case in valid_cases {
396 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
397 let result = rule.check(&ctx).unwrap();
398 assert!(
399 result.is_empty(),
400 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
401 case,
402 result.len()
403 );
404 }
405 }
406
407 #[test]
408 fn test_md038_valid() {
409 let rule = MD038NoSpaceInCode::new();
410 let valid_cases = vec![
411 "This is `code` in a sentence.",
412 "This is a `longer code span` in a sentence.",
413 "This is `code with internal spaces` which is fine.",
414 "Code span at `end of line`",
415 "`Start of line` code span",
416 "Multiple `code spans` in `one line` are fine",
417 "Code span with `symbols: !@#$%^&*()`",
418 "Empty code span `` is technically valid",
419 ];
420 for case in valid_cases {
421 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
422 let result = rule.check(&ctx).unwrap();
423 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
424 }
425 }
426
427 #[test]
428 fn test_md038_invalid() {
429 let rule = MD038NoSpaceInCode::new();
430 let invalid_cases = vec![
435 "This is ` code` with leading space.",
437 "This is `code ` with trailing space.",
439 "This is ` code ` with double leading space.",
441 "This is ` code ` with double trailing space.",
443 "This is ` code ` with double spaces both sides.",
445 ];
446 for case in invalid_cases {
447 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
448 let result = rule.check(&ctx).unwrap();
449 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
450 }
451 }
452
453 #[test]
454 fn test_md038_valid_commonmark_stripping() {
455 let rule = MD038NoSpaceInCode::new();
456 let valid_cases = vec![
460 "Type ` y ` to confirm.",
461 "Use ` git commit -m \"message\" ` to commit.",
462 "The variable ` $HOME ` contains home path.",
463 "The pattern ` *.txt ` matches text files.",
464 "This is ` random word ` with unnecessary spaces.",
465 "Text with ` plain text ` is valid.",
466 "Code with ` just code ` here.",
467 "Multiple ` word ` spans with ` text ` in one line.",
468 "This is ` code ` with both leading and trailing single space.",
469 "Use ` - ` as separator.",
470 ];
471 for case in valid_cases {
472 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
473 let result = rule.check(&ctx).unwrap();
474 assert!(
475 result.is_empty(),
476 "Single space on each side should not be flagged (CommonMark strips them): {case}"
477 );
478 }
479 }
480
481 #[test]
482 fn test_md038_fix() {
483 let rule = MD038NoSpaceInCode::new();
484 let test_cases = vec![
486 (
488 "This is ` code` with leading space.",
489 "This is `code` with leading space.",
490 ),
491 (
493 "This is `code ` with trailing space.",
494 "This is `code` with trailing space.",
495 ),
496 (
498 "This is ` code ` with both spaces.",
499 "This is ` code ` with both spaces.", ),
501 (
503 "This is ` code ` with double leading space.",
504 "This is `code` with double leading space.",
505 ),
506 (
508 "Multiple ` code ` and `spans ` to fix.",
509 "Multiple ` code ` and `spans` to fix.", ),
511 ];
512 for (input, expected) in test_cases {
513 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
514 let result = rule.fix(&ctx).unwrap();
515 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
516 }
517 }
518
519 #[test]
520 fn test_check_invalid_leading_space() {
521 let rule = MD038NoSpaceInCode::new();
522 let input = "This has a ` leading space` in code";
523 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
524 let result = rule.check(&ctx).unwrap();
525 assert_eq!(result.len(), 1);
526 assert_eq!(result[0].line, 1);
527 assert!(result[0].fix.is_some());
528 }
529
530 #[test]
531 fn test_code_span_parsing_nested_backticks() {
532 let content = "Code with ` nested `code` example ` should preserve backticks";
533 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
534
535 println!("Content: {content}");
536 println!("Code spans found:");
537 let code_spans = ctx.code_spans();
538 for (i, span) in code_spans.iter().enumerate() {
539 println!(
540 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
541 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
542 );
543 }
544
545 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
547 }
548
549 #[test]
550 fn test_nested_backtick_detection() {
551 let rule = MD038NoSpaceInCode::new();
552
553 let content = "Code with `` `backticks` inside `` should not be flagged";
555 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
556 let result = rule.check(&ctx).unwrap();
557 assert!(result.is_empty(), "Code spans with backticks should be skipped");
558 }
559
560 #[test]
561 fn test_quarto_inline_r_code() {
562 let rule = MD038NoSpaceInCode::new();
564
565 let content = r#"The result is `r nchar("test")` which equals 4."#;
568
569 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
571 let result_quarto = rule.check(&ctx_quarto).unwrap();
572 assert!(
573 result_quarto.is_empty(),
574 "Quarto inline R code should not trigger warnings. Got {} warnings",
575 result_quarto.len()
576 );
577
578 let content_other = "This has `plain text ` with trailing space.";
581 let ctx_other =
582 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
583 let result_other = rule.check(&ctx_other).unwrap();
584 assert_eq!(
585 result_other.len(),
586 1,
587 "Quarto should still flag non-R code spans with improper spaces"
588 );
589 }
590
591 #[test]
597 fn test_hugo_template_syntax_comprehensive() {
598 let rule = MD038NoSpaceInCode::new();
599
600 let valid_hugo_cases = vec![
604 (
606 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
607 "Multi-line raw shortcode",
608 ),
609 (
610 "Some text {{raw ` code `}} more text",
611 "Inline raw shortcode with spaces",
612 ),
613 ("{{raw `code`}}", "Raw shortcode without spaces"),
614 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
616 ("{{< `code` >}}", "Partial shortcode without spaces"),
617 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
619 ("{{% `code` %}}", "Percent shortcode without spaces"),
620 ("{{ ` code ` }}", "Generic shortcode with spaces"),
622 ("{{ `code` }}", "Generic shortcode without spaces"),
623 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
625 ("{{< code `go list` >}}", "Shortcode with code parameter"),
626 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
628 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
629 (
631 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
632 "Nested Go template syntax",
633 ),
634 ("{{raw `code`}}", "Hugo template at line start"),
636 ("Text {{raw `code`}}", "Hugo template at end of line"),
638 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
640 ];
641
642 for (case, description) in valid_hugo_cases {
643 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
644 let result = rule.check(&ctx).unwrap();
645 assert!(
646 result.is_empty(),
647 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
648 );
649 }
650
651 let should_be_flagged = vec![
656 ("This is ` code` with leading space.", "Leading space only"),
657 ("This is `code ` with trailing space.", "Trailing space only"),
658 ("Text ` code ` here", "Extra leading space (asymmetric)"),
659 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
660 ("Text ` code` here", "Double leading, no trailing"),
661 ("Text `code ` here", "No leading, double trailing"),
662 ];
663
664 for (case, description) in should_be_flagged {
665 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
666 let result = rule.check(&ctx).unwrap();
667 assert!(
668 !result.is_empty(),
669 "Should flag asymmetric space code spans: {description} - {case}"
670 );
671 }
672
673 let symmetric_single_space = vec![
679 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
680 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
681 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
682 ];
683
684 for (case, description) in symmetric_single_space {
685 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
686 let result = rule.check(&ctx).unwrap();
687 assert!(
688 result.is_empty(),
689 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
690 );
691 }
692
693 let unicode_cases = vec![
696 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
697 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
698 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
699 (
700 "{{raw `\n\tcode with 'single quotes'\n`}}",
701 "Single quotes in Hugo template",
702 ),
703 ];
704
705 for (case, description) in unicode_cases {
706 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
707 let result = rule.check(&ctx).unwrap();
708 assert!(
709 result.is_empty(),
710 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
711 );
712 }
713
714 assert!(
718 rule.check(&crate::lint_context::LintContext::new(
719 "{{ ` ` }}",
720 crate::config::MarkdownFlavor::Standard,
721 None
722 ))
723 .unwrap()
724 .is_empty(),
725 "Minimum Hugo pattern should be valid"
726 );
727
728 assert!(
730 rule.check(&crate::lint_context::LintContext::new(
731 "{{raw `\n\t\n`}}",
732 crate::config::MarkdownFlavor::Standard,
733 None
734 ))
735 .unwrap()
736 .is_empty(),
737 "Hugo template with only whitespace should be valid"
738 );
739 }
740
741 #[test]
743 fn test_hugo_template_with_other_markdown() {
744 let rule = MD038NoSpaceInCode::new();
745
746 let content = r#"1. First item
7482. Second item with {{raw `code`}} template
7493. Third item"#;
750 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751 let result = rule.check(&ctx).unwrap();
752 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
753
754 let content = r#"> Quote with {{raw `code`}} template"#;
756 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758 assert!(
759 result.is_empty(),
760 "Hugo template in blockquote should not trigger warnings"
761 );
762
763 let content = r#"{{raw `code`}} and ` bad code` here"#;
765 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
766 let result = rule.check(&ctx).unwrap();
767 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
768 }
769
770 #[test]
772 fn test_hugo_template_performance() {
773 let rule = MD038NoSpaceInCode::new();
774
775 let mut content = String::new();
777 for i in 0..100 {
778 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
779 }
780
781 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
782 let start = std::time::Instant::now();
783 let result = rule.check(&ctx).unwrap();
784 let duration = start.elapsed();
785
786 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
787 assert!(
788 duration.as_millis() < 1000,
789 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
790 );
791 }
792
793 #[test]
794 fn test_mkdocs_inline_hilite_not_flagged() {
795 let rule = MD038NoSpaceInCode::new();
798
799 let valid_cases = vec![
800 "`#!python print('hello')`",
801 "`#!js alert('hi')`",
802 "`#!c++ cout << x;`",
803 "Use `#!python import os` to import modules",
804 "`#!bash echo $HOME`",
805 ];
806
807 for case in valid_cases {
808 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
809 let result = rule.check(&ctx).unwrap();
810 assert!(
811 result.is_empty(),
812 "InlineHilite syntax should not be flagged in MkDocs: {case}"
813 );
814 }
815
816 let content = "`#!python print('hello')`";
818 let ctx_standard =
819 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820 let result_standard = rule.check(&ctx_standard).unwrap();
821 assert!(
824 result_standard.is_empty(),
825 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
826 );
827 }
828
829 #[test]
830 fn test_multibyte_utf8_no_panic() {
831 let rule = MD038NoSpaceInCode::new();
835
836 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
838 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
839 let result = rule.check(&ctx);
840 assert!(result.is_ok(), "Greek text should not panic");
841
842 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
844 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
845 let result = rule.check(&ctx);
846 assert!(result.is_ok(), "Chinese text should not panic");
847
848 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
850 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
851 let result = rule.check(&ctx);
852 assert!(result.is_ok(), "Cyrillic text should not panic");
853
854 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
856 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx);
858 assert!(
859 result.is_ok(),
860 "Mixed Chinese text with multiple code spans should not panic"
861 );
862 }
863
864 #[test]
868 fn test_obsidian_dataview_inline_dql_not_flagged() {
869 let rule = MD038NoSpaceInCode::new();
870
871 let valid_dql_cases = vec![
873 "`= this.file.name`",
874 "`= date(today)`",
875 "`= [[Page]].field`",
876 "`= choice(condition, \"yes\", \"no\")`",
877 "`= this.file.mtime`",
878 "`= this.file.ctime`",
879 "`= this.file.path`",
880 "`= this.file.folder`",
881 "`= this.file.size`",
882 "`= this.file.ext`",
883 "`= this.file.link`",
884 "`= this.file.outlinks`",
885 "`= this.file.inlinks`",
886 "`= this.file.tags`",
887 ];
888
889 for case in valid_dql_cases {
890 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
891 let result = rule.check(&ctx).unwrap();
892 assert!(
893 result.is_empty(),
894 "Dataview DQL expression should not be flagged in Obsidian: {case}"
895 );
896 }
897 }
898
899 #[test]
901 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
902 let rule = MD038NoSpaceInCode::new();
903
904 let valid_dvjs_cases = vec![
906 "`$= dv.current().file.mtime`",
907 "`$= dv.pages().length`",
908 "`$= dv.current()`",
909 "`$= dv.pages('#tag').length`",
910 "`$= dv.pages('\"folder\"').length`",
911 "`$= dv.current().file.name`",
912 "`$= dv.current().file.path`",
913 "`$= dv.current().file.folder`",
914 "`$= dv.current().file.link`",
915 ];
916
917 for case in valid_dvjs_cases {
918 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
919 let result = rule.check(&ctx).unwrap();
920 assert!(
921 result.is_empty(),
922 "Dataview JS expression should not be flagged in Obsidian: {case}"
923 );
924 }
925 }
926
927 #[test]
929 fn test_obsidian_dataview_complex_expressions() {
930 let rule = MD038NoSpaceInCode::new();
931
932 let complex_cases = vec![
933 "`= sum(filter(pages, (p) => p.done))`",
935 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
936 "`= choice(x > 5, \"big\", \"small\")`",
938 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
939 "`= date(today) - dur(7 days)`",
941 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
942 "`= sum(rows.amount)`",
944 "`= round(average(rows.score), 2)`",
945 "`= min(rows.priority)`",
946 "`= max(rows.priority)`",
947 "`= join(this.file.tags, \", \")`",
949 "`= replace(this.title, \"-\", \" \")`",
950 "`= lower(this.file.name)`",
951 "`= upper(this.file.name)`",
952 "`= length(this.file.outlinks)`",
954 "`= contains(this.file.tags, \"important\")`",
955 "`= [[Page Name]].field`",
957 "`= [[Folder/Subfolder/Page]].nested.field`",
958 "`= default(this.status, \"unknown\")`",
960 "`= coalesce(this.priority, this.importance, 0)`",
961 ];
962
963 for case in complex_cases {
964 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
965 let result = rule.check(&ctx).unwrap();
966 assert!(
967 result.is_empty(),
968 "Complex Dataview expression should not be flagged in Obsidian: {case}"
969 );
970 }
971 }
972
973 #[test]
975 fn test_obsidian_dataviewjs_method_chains() {
976 let rule = MD038NoSpaceInCode::new();
977
978 let method_chain_cases = vec![
979 "`$= dv.pages().where(p => p.status).length`",
980 "`$= dv.pages('#project').where(p => !p.done).length`",
981 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
982 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
983 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
984 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
985 "`$= dv.page('Index').children.map(p => p.title)`",
986 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
987 ];
988
989 for case in method_chain_cases {
990 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
991 let result = rule.check(&ctx).unwrap();
992 assert!(
993 result.is_empty(),
994 "DataviewJS method chain should not be flagged in Obsidian: {case}"
995 );
996 }
997 }
998
999 #[test]
1008 fn test_standard_flavor_vs_obsidian_dataview() {
1009 let rule = MD038NoSpaceInCode::new();
1010
1011 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1014
1015 for case in no_issue_cases {
1016 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1018 let result_std = rule.check(&ctx_std).unwrap();
1019 assert!(
1020 result_std.is_empty(),
1021 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1022 );
1023
1024 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1026 let result_obs = rule.check(&ctx_obs).unwrap();
1027 assert!(
1028 result_obs.is_empty(),
1029 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1030 );
1031 }
1032
1033 let space_issues = vec![
1036 "` code`", "`code `", ];
1039
1040 for case in space_issues {
1041 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1043 let result_std = rule.check(&ctx_std).unwrap();
1044 assert!(
1045 !result_std.is_empty(),
1046 "Code with spacing issue should be flagged in Standard: {case}"
1047 );
1048
1049 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1051 let result_obs = rule.check(&ctx_obs).unwrap();
1052 assert!(
1053 !result_obs.is_empty(),
1054 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1055 );
1056 }
1057 }
1058
1059 #[test]
1061 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1062 let rule = MD038NoSpaceInCode::new();
1063
1064 let invalid_cases = [
1067 "` regular code`", "`code `", "` code `", "` code`", ];
1072
1073 let expected_flags = [
1075 true, true, false, true, ];
1080
1081 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1082 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1083 let result = rule.check(&ctx).unwrap();
1084 if *should_flag {
1085 assert!(
1086 !result.is_empty(),
1087 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1088 );
1089 } else {
1090 assert!(
1091 result.is_empty(),
1092 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1093 );
1094 }
1095 }
1096 }
1097
1098 #[test]
1100 fn test_obsidian_dataview_edge_cases() {
1101 let rule = MD038NoSpaceInCode::new();
1102
1103 let valid_cases = vec![
1105 ("`= 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), ];
1121
1122 for (case, should_be_valid) in valid_cases {
1123 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1124 let result = rule.check(&ctx).unwrap();
1125 if should_be_valid {
1126 assert!(
1127 result.is_empty(),
1128 "Valid Dataview expression should not be flagged: {case}"
1129 );
1130 } else {
1131 let _ = result;
1134 }
1135 }
1136 }
1137
1138 #[test]
1140 fn test_obsidian_dataview_in_context() {
1141 let rule = MD038NoSpaceInCode::new();
1142
1143 let content = r#"# My Note
1145
1146The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1147
1148Regular code: `println!("hello")` and `let x = 5;`
1149
1150DataviewJS count: `$= dv.pages('#project').length` projects found.
1151
1152More regular code with issue: ` bad code` should be flagged.
1153"#;
1154
1155 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1156 let result = rule.check(&ctx).unwrap();
1157
1158 assert_eq!(
1160 result.len(),
1161 1,
1162 "Should only flag the regular code span with leading space, not Dataview expressions"
1163 );
1164 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1165 }
1166
1167 #[test]
1169 fn test_obsidian_dataview_in_code_blocks() {
1170 let rule = MD038NoSpaceInCode::new();
1171
1172 let content = r#"# Example
1175
1176```
1177`= this.file.name`
1178`$= dv.current()`
1179```
1180
1181Regular paragraph with `= this.file.name` Dataview.
1182"#;
1183
1184 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1185 let result = rule.check(&ctx).unwrap();
1186
1187 assert!(
1189 result.is_empty(),
1190 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1191 );
1192 }
1193
1194 #[test]
1196 fn test_obsidian_dataview_unicode() {
1197 let rule = MD038NoSpaceInCode::new();
1198
1199 let unicode_cases = vec![
1200 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1207
1208 for case in unicode_cases {
1209 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1210 let result = rule.check(&ctx).unwrap();
1211 assert!(
1212 result.is_empty(),
1213 "Unicode Dataview expression should not be flagged: {case}"
1214 );
1215 }
1216 }
1217
1218 #[test]
1220 fn test_obsidian_regular_equals_still_works() {
1221 let rule = MD038NoSpaceInCode::new();
1222
1223 let valid_regular_cases = vec![
1225 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1231
1232 for case in valid_regular_cases {
1233 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1234 let result = rule.check(&ctx).unwrap();
1235 assert!(
1236 result.is_empty(),
1237 "Regular code with equals should not be flagged: {case}"
1238 );
1239 }
1240 }
1241
1242 #[test]
1244 fn test_obsidian_dataview_fix_preserves_expressions() {
1245 let rule = MD038NoSpaceInCode::new();
1246
1247 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1249 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1250 let fixed = rule.fix(&ctx).unwrap();
1251
1252 assert!(
1254 fixed.contains("`= this.file.name`"),
1255 "Dataview expression should be preserved after fix"
1256 );
1257 assert!(
1258 fixed.contains("`fixme`"),
1259 "Regular code span should be fixed (space removed)"
1260 );
1261 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1262 }
1263
1264 #[test]
1266 fn test_obsidian_multiple_dataview_same_line() {
1267 let rule = MD038NoSpaceInCode::new();
1268
1269 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1270 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1271 let result = rule.check(&ctx).unwrap();
1272
1273 assert!(
1274 result.is_empty(),
1275 "Multiple Dataview expressions on same line should all be valid"
1276 );
1277 }
1278
1279 #[test]
1281 fn test_obsidian_dataview_performance() {
1282 let rule = MD038NoSpaceInCode::new();
1283
1284 let mut content = String::new();
1286 for i in 0..100 {
1287 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1288 }
1289
1290 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1291 let start = std::time::Instant::now();
1292 let result = rule.check(&ctx).unwrap();
1293 let duration = start.elapsed();
1294
1295 assert!(result.is_empty(), "All Dataview expressions should be valid");
1296 assert!(
1297 duration.as_millis() < 1000,
1298 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1299 );
1300 }
1301
1302 #[test]
1304 fn test_is_dataview_expression_helper() {
1305 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1307 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1308 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1309 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1310 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1311 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1312
1313 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")); }
1324
1325 #[test]
1327 fn test_obsidian_dataview_with_tags() {
1328 let rule = MD038NoSpaceInCode::new();
1329
1330 let content = r#"# Project Status
1332
1333Tags: #project #active
1334
1335Status: `= this.status`
1336Count: `$= dv.pages('#project').length`
1337
1338Regular code: `function test() {}`
1339"#;
1340
1341 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1342 let result = rule.check(&ctx).unwrap();
1343
1344 assert!(
1346 result.is_empty(),
1347 "Dataview expressions and regular code should work together"
1348 );
1349 }
1350}