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_char = current_span.end_col.min(other_span.end_col);
184 let end_char = current_span.start_col.max(other_span.start_col);
185
186 if start_char < end_char {
187 let char_indices: Vec<(usize, char)> = line_content.char_indices().collect();
189 let start_byte = char_indices.get(start_char).map(|(i, _)| *i);
190 let end_byte = char_indices
191 .get(end_char)
192 .map(|(i, _)| *i)
193 .unwrap_or(line_content.len());
194
195 if let Some(start_byte) = start_byte
196 && start_byte < end_byte
197 && end_byte <= line_content.len()
198 {
199 let between = &line_content[start_byte..end_byte];
200 if between.contains("code") || between.contains("backtick") {
203 return true;
204 }
205 }
206 }
207 }
208
209 false
210 }
211}
212
213impl Rule for MD038NoSpaceInCode {
214 fn name(&self) -> &'static str {
215 "MD038"
216 }
217
218 fn description(&self) -> &'static str {
219 "Spaces inside code span elements"
220 }
221
222 fn category(&self) -> RuleCategory {
223 RuleCategory::Other
224 }
225
226 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
227 if !self.enabled {
228 return Ok(vec![]);
229 }
230
231 let mut warnings = Vec::new();
232
233 let code_spans = ctx.code_spans();
235 for (i, code_span) in code_spans.iter().enumerate() {
236 if let Some(line_info) = ctx.lines.get(code_span.line - 1) {
238 if line_info.in_code_block {
239 continue;
240 }
241 if (line_info.in_mkdocs_container() || line_info.in_pymdown_block) && code_span.content.contains('\n') {
245 continue;
246 }
247 }
248
249 let code_content = &code_span.content;
250
251 if code_content.is_empty() {
253 continue;
254 }
255
256 let has_leading_space = code_content.chars().next().is_some_and(|c| c.is_whitespace());
258 let has_trailing_space = code_content.chars().last().is_some_and(|c| c.is_whitespace());
259
260 if !has_leading_space && !has_trailing_space {
261 continue;
262 }
263
264 let trimmed = code_content.trim();
265
266 if code_content != trimmed {
268 if has_leading_space && has_trailing_space && !trimmed.is_empty() {
280 let leading_spaces = code_content.len() - code_content.trim_start().len();
281 let trailing_spaces = code_content.len() - code_content.trim_end().len();
282
283 if leading_spaces == 1 && trailing_spaces == 1 {
285 continue;
286 }
287 }
288 if trimmed.contains('`') {
291 continue;
292 }
293
294 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
297 && trimmed.starts_with('r')
298 && trimmed.len() > 1
299 && trimmed.chars().nth(1).is_some_and(|c| c.is_whitespace())
300 {
301 continue;
302 }
303
304 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
307 continue;
308 }
309
310 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && Self::is_dataview_expression(code_content) {
314 continue;
315 }
316
317 if self.is_hugo_template_syntax(ctx, code_span) {
320 continue;
321 }
322
323 if self.is_likely_nested_backticks(ctx, i) {
326 continue;
327 }
328
329 warnings.push(LintWarning {
330 rule_name: Some(self.name().to_string()),
331 line: code_span.line,
332 column: code_span.start_col + 1, end_line: code_span.line,
334 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
336 severity: Severity::Warning,
337 fix: Some(Fix {
338 range: code_span.byte_offset..code_span.byte_end,
339 replacement: format!(
340 "{}{}{}",
341 "`".repeat(code_span.backtick_count),
342 trimmed,
343 "`".repeat(code_span.backtick_count)
344 ),
345 }),
346 });
347 }
348 }
349
350 Ok(warnings)
351 }
352
353 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
354 let content = ctx.content;
355 if !self.enabled {
356 return Ok(content.to_string());
357 }
358
359 if !content.contains('`') {
361 return Ok(content.to_string());
362 }
363
364 let warnings = self.check(ctx)?;
366 let warnings =
367 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
368 if warnings.is_empty() {
369 return Ok(content.to_string());
370 }
371
372 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
374 .into_iter()
375 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
376 .collect();
377
378 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
379
380 let mut result = content.to_string();
382 for (range, replacement) in fixes {
383 result.replace_range(range, &replacement);
384 }
385
386 Ok(result)
387 }
388
389 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
391 !ctx.likely_has_code()
392 }
393
394 fn as_any(&self) -> &dyn std::any::Any {
395 self
396 }
397
398 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
399 where
400 Self: Sized,
401 {
402 Box::new(MD038NoSpaceInCode { enabled: true })
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn test_md038_readme_false_positives() {
412 let rule = MD038NoSpaceInCode::new();
414 let valid_cases = vec![
415 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
416 "#### Effective Configuration (`rumdl config`)",
417 "- Blue: `.rumdl.toml`",
418 "### Defaults Only (`rumdl config --defaults`)",
419 ];
420
421 for case in valid_cases {
422 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
423 let result = rule.check(&ctx).unwrap();
424 assert!(
425 result.is_empty(),
426 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
427 case,
428 result.len()
429 );
430 }
431 }
432
433 #[test]
434 fn test_md038_valid() {
435 let rule = MD038NoSpaceInCode::new();
436 let valid_cases = vec![
437 "This is `code` in a sentence.",
438 "This is a `longer code span` in a sentence.",
439 "This is `code with internal spaces` which is fine.",
440 "Code span at `end of line`",
441 "`Start of line` code span",
442 "Multiple `code spans` in `one line` are fine",
443 "Code span with `symbols: !@#$%^&*()`",
444 "Empty code span `` is technically valid",
445 ];
446 for case in valid_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(), "Valid case should not have warnings: {case}");
450 }
451 }
452
453 #[test]
454 fn test_md038_invalid() {
455 let rule = MD038NoSpaceInCode::new();
456 let invalid_cases = vec![
461 "This is ` code` with leading space.",
463 "This is `code ` with trailing space.",
465 "This is ` code ` with double leading space.",
467 "This is ` code ` with double trailing space.",
469 "This is ` code ` with double spaces both sides.",
471 ];
472 for case in invalid_cases {
473 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
474 let result = rule.check(&ctx).unwrap();
475 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
476 }
477 }
478
479 #[test]
480 fn test_md038_valid_commonmark_stripping() {
481 let rule = MD038NoSpaceInCode::new();
482 let valid_cases = vec![
486 "Type ` y ` to confirm.",
487 "Use ` git commit -m \"message\" ` to commit.",
488 "The variable ` $HOME ` contains home path.",
489 "The pattern ` *.txt ` matches text files.",
490 "This is ` random word ` with unnecessary spaces.",
491 "Text with ` plain text ` is valid.",
492 "Code with ` just code ` here.",
493 "Multiple ` word ` spans with ` text ` in one line.",
494 "This is ` code ` with both leading and trailing single space.",
495 "Use ` - ` as separator.",
496 ];
497 for case in valid_cases {
498 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
499 let result = rule.check(&ctx).unwrap();
500 assert!(
501 result.is_empty(),
502 "Single space on each side should not be flagged (CommonMark strips them): {case}"
503 );
504 }
505 }
506
507 #[test]
508 fn test_md038_fix() {
509 let rule = MD038NoSpaceInCode::new();
510 let test_cases = vec![
512 (
514 "This is ` code` with leading space.",
515 "This is `code` with leading space.",
516 ),
517 (
519 "This is `code ` with trailing space.",
520 "This is `code` with trailing space.",
521 ),
522 (
524 "This is ` code ` with both spaces.",
525 "This is ` code ` with both spaces.", ),
527 (
529 "This is ` code ` with double leading space.",
530 "This is `code` with double leading space.",
531 ),
532 (
534 "Multiple ` code ` and `spans ` to fix.",
535 "Multiple ` code ` and `spans` to fix.", ),
537 ];
538 for (input, expected) in test_cases {
539 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
540 let result = rule.fix(&ctx).unwrap();
541 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
542 }
543 }
544
545 #[test]
546 fn test_check_invalid_leading_space() {
547 let rule = MD038NoSpaceInCode::new();
548 let input = "This has a ` leading space` in code";
549 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
550 let result = rule.check(&ctx).unwrap();
551 assert_eq!(result.len(), 1);
552 assert_eq!(result[0].line, 1);
553 assert!(result[0].fix.is_some());
554 }
555
556 #[test]
557 fn test_code_span_parsing_nested_backticks() {
558 let content = "Code with ` nested `code` example ` should preserve backticks";
559 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560
561 println!("Content: {content}");
562 println!("Code spans found:");
563 let code_spans = ctx.code_spans();
564 for (i, span) in code_spans.iter().enumerate() {
565 println!(
566 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
567 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
568 );
569 }
570
571 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
573 }
574
575 #[test]
576 fn test_nested_backtick_detection() {
577 let rule = MD038NoSpaceInCode::new();
578
579 let content = "Code with `` `backticks` inside `` should not be flagged";
581 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583 assert!(result.is_empty(), "Code spans with backticks should be skipped");
584 }
585
586 #[test]
587 fn test_quarto_inline_r_code() {
588 let rule = MD038NoSpaceInCode::new();
590
591 let content = r#"The result is `r nchar("test")` which equals 4."#;
594
595 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
597 let result_quarto = rule.check(&ctx_quarto).unwrap();
598 assert!(
599 result_quarto.is_empty(),
600 "Quarto inline R code should not trigger warnings. Got {} warnings",
601 result_quarto.len()
602 );
603
604 let content_other = "This has `plain text ` with trailing space.";
607 let ctx_other =
608 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
609 let result_other = rule.check(&ctx_other).unwrap();
610 assert_eq!(
611 result_other.len(),
612 1,
613 "Quarto should still flag non-R code spans with improper spaces"
614 );
615 }
616
617 #[test]
623 fn test_hugo_template_syntax_comprehensive() {
624 let rule = MD038NoSpaceInCode::new();
625
626 let valid_hugo_cases = vec![
630 (
632 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
633 "Multi-line raw shortcode",
634 ),
635 (
636 "Some text {{raw ` code `}} more text",
637 "Inline raw shortcode with spaces",
638 ),
639 ("{{raw `code`}}", "Raw shortcode without spaces"),
640 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
642 ("{{< `code` >}}", "Partial shortcode without spaces"),
643 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
645 ("{{% `code` %}}", "Percent shortcode without spaces"),
646 ("{{ ` code ` }}", "Generic shortcode with spaces"),
648 ("{{ `code` }}", "Generic shortcode without spaces"),
649 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
651 ("{{< code `go list` >}}", "Shortcode with code parameter"),
652 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
654 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
655 (
657 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
658 "Nested Go template syntax",
659 ),
660 ("{{raw `code`}}", "Hugo template at line start"),
662 ("Text {{raw `code`}}", "Hugo template at end of line"),
664 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
666 ];
667
668 for (case, description) in valid_hugo_cases {
669 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
670 let result = rule.check(&ctx).unwrap();
671 assert!(
672 result.is_empty(),
673 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
674 );
675 }
676
677 let should_be_flagged = vec![
682 ("This is ` code` with leading space.", "Leading space only"),
683 ("This is `code ` with trailing space.", "Trailing space only"),
684 ("Text ` code ` here", "Extra leading space (asymmetric)"),
685 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
686 ("Text ` code` here", "Double leading, no trailing"),
687 ("Text `code ` here", "No leading, double trailing"),
688 ];
689
690 for (case, description) in should_be_flagged {
691 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
692 let result = rule.check(&ctx).unwrap();
693 assert!(
694 !result.is_empty(),
695 "Should flag asymmetric space code spans: {description} - {case}"
696 );
697 }
698
699 let symmetric_single_space = vec![
705 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
706 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
707 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
708 ];
709
710 for (case, description) in symmetric_single_space {
711 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713 assert!(
714 result.is_empty(),
715 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
716 );
717 }
718
719 let unicode_cases = vec![
722 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
723 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
724 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
725 (
726 "{{raw `\n\tcode with 'single quotes'\n`}}",
727 "Single quotes in Hugo template",
728 ),
729 ];
730
731 for (case, description) in unicode_cases {
732 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
733 let result = rule.check(&ctx).unwrap();
734 assert!(
735 result.is_empty(),
736 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
737 );
738 }
739
740 assert!(
744 rule.check(&crate::lint_context::LintContext::new(
745 "{{ ` ` }}",
746 crate::config::MarkdownFlavor::Standard,
747 None
748 ))
749 .unwrap()
750 .is_empty(),
751 "Minimum Hugo pattern should be valid"
752 );
753
754 assert!(
756 rule.check(&crate::lint_context::LintContext::new(
757 "{{raw `\n\t\n`}}",
758 crate::config::MarkdownFlavor::Standard,
759 None
760 ))
761 .unwrap()
762 .is_empty(),
763 "Hugo template with only whitespace should be valid"
764 );
765 }
766
767 #[test]
769 fn test_hugo_template_with_other_markdown() {
770 let rule = MD038NoSpaceInCode::new();
771
772 let content = r#"1. First item
7742. Second item with {{raw `code`}} template
7753. Third item"#;
776 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let result = rule.check(&ctx).unwrap();
778 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
779
780 let content = r#"> Quote with {{raw `code`}} template"#;
782 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let result = rule.check(&ctx).unwrap();
784 assert!(
785 result.is_empty(),
786 "Hugo template in blockquote should not trigger warnings"
787 );
788
789 let content = r#"{{raw `code`}} and ` bad code` here"#;
791 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
792 let result = rule.check(&ctx).unwrap();
793 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
794 }
795
796 #[test]
798 fn test_hugo_template_performance() {
799 let rule = MD038NoSpaceInCode::new();
800
801 let mut content = String::new();
803 for i in 0..100 {
804 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
805 }
806
807 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
808 let start = std::time::Instant::now();
809 let result = rule.check(&ctx).unwrap();
810 let duration = start.elapsed();
811
812 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
813 assert!(
814 duration.as_millis() < 1000,
815 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
816 );
817 }
818
819 #[test]
820 fn test_mkdocs_inline_hilite_not_flagged() {
821 let rule = MD038NoSpaceInCode::new();
824
825 let valid_cases = vec![
826 "`#!python print('hello')`",
827 "`#!js alert('hi')`",
828 "`#!c++ cout << x;`",
829 "Use `#!python import os` to import modules",
830 "`#!bash echo $HOME`",
831 ];
832
833 for case in valid_cases {
834 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
835 let result = rule.check(&ctx).unwrap();
836 assert!(
837 result.is_empty(),
838 "InlineHilite syntax should not be flagged in MkDocs: {case}"
839 );
840 }
841
842 let content = "`#!python print('hello')`";
844 let ctx_standard =
845 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let result_standard = rule.check(&ctx_standard).unwrap();
847 assert!(
850 result_standard.is_empty(),
851 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
852 );
853 }
854
855 #[test]
856 fn test_multibyte_utf8_no_panic() {
857 let rule = MD038NoSpaceInCode::new();
861
862 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
864 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx);
866 assert!(result.is_ok(), "Greek text should not panic");
867
868 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
870 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
871 let result = rule.check(&ctx);
872 assert!(result.is_ok(), "Chinese text should not panic");
873
874 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
876 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
877 let result = rule.check(&ctx);
878 assert!(result.is_ok(), "Cyrillic text should not panic");
879
880 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
882 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
883 let result = rule.check(&ctx);
884 assert!(
885 result.is_ok(),
886 "Mixed Chinese text with multiple code spans should not panic"
887 );
888 }
889
890 #[test]
894 fn test_obsidian_dataview_inline_dql_not_flagged() {
895 let rule = MD038NoSpaceInCode::new();
896
897 let valid_dql_cases = vec![
899 "`= this.file.name`",
900 "`= date(today)`",
901 "`= [[Page]].field`",
902 "`= choice(condition, \"yes\", \"no\")`",
903 "`= this.file.mtime`",
904 "`= this.file.ctime`",
905 "`= this.file.path`",
906 "`= this.file.folder`",
907 "`= this.file.size`",
908 "`= this.file.ext`",
909 "`= this.file.link`",
910 "`= this.file.outlinks`",
911 "`= this.file.inlinks`",
912 "`= this.file.tags`",
913 ];
914
915 for case in valid_dql_cases {
916 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
917 let result = rule.check(&ctx).unwrap();
918 assert!(
919 result.is_empty(),
920 "Dataview DQL expression should not be flagged in Obsidian: {case}"
921 );
922 }
923 }
924
925 #[test]
927 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
928 let rule = MD038NoSpaceInCode::new();
929
930 let valid_dvjs_cases = vec![
932 "`$= dv.current().file.mtime`",
933 "`$= dv.pages().length`",
934 "`$= dv.current()`",
935 "`$= dv.pages('#tag').length`",
936 "`$= dv.pages('\"folder\"').length`",
937 "`$= dv.current().file.name`",
938 "`$= dv.current().file.path`",
939 "`$= dv.current().file.folder`",
940 "`$= dv.current().file.link`",
941 ];
942
943 for case in valid_dvjs_cases {
944 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
945 let result = rule.check(&ctx).unwrap();
946 assert!(
947 result.is_empty(),
948 "Dataview JS expression should not be flagged in Obsidian: {case}"
949 );
950 }
951 }
952
953 #[test]
955 fn test_obsidian_dataview_complex_expressions() {
956 let rule = MD038NoSpaceInCode::new();
957
958 let complex_cases = vec![
959 "`= sum(filter(pages, (p) => p.done))`",
961 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
962 "`= choice(x > 5, \"big\", \"small\")`",
964 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
965 "`= date(today) - dur(7 days)`",
967 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
968 "`= sum(rows.amount)`",
970 "`= round(average(rows.score), 2)`",
971 "`= min(rows.priority)`",
972 "`= max(rows.priority)`",
973 "`= join(this.file.tags, \", \")`",
975 "`= replace(this.title, \"-\", \" \")`",
976 "`= lower(this.file.name)`",
977 "`= upper(this.file.name)`",
978 "`= length(this.file.outlinks)`",
980 "`= contains(this.file.tags, \"important\")`",
981 "`= [[Page Name]].field`",
983 "`= [[Folder/Subfolder/Page]].nested.field`",
984 "`= default(this.status, \"unknown\")`",
986 "`= coalesce(this.priority, this.importance, 0)`",
987 ];
988
989 for case in complex_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 "Complex Dataview expression should not be flagged in Obsidian: {case}"
995 );
996 }
997 }
998
999 #[test]
1001 fn test_obsidian_dataviewjs_method_chains() {
1002 let rule = MD038NoSpaceInCode::new();
1003
1004 let method_chain_cases = vec![
1005 "`$= dv.pages().where(p => p.status).length`",
1006 "`$= dv.pages('#project').where(p => !p.done).length`",
1007 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
1008 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
1009 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
1010 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
1011 "`$= dv.page('Index').children.map(p => p.title)`",
1012 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1013 ];
1014
1015 for case in method_chain_cases {
1016 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1017 let result = rule.check(&ctx).unwrap();
1018 assert!(
1019 result.is_empty(),
1020 "DataviewJS method chain should not be flagged in Obsidian: {case}"
1021 );
1022 }
1023 }
1024
1025 #[test]
1034 fn test_standard_flavor_vs_obsidian_dataview() {
1035 let rule = MD038NoSpaceInCode::new();
1036
1037 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1040
1041 for case in no_issue_cases {
1042 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1044 let result_std = rule.check(&ctx_std).unwrap();
1045 assert!(
1046 result_std.is_empty(),
1047 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1048 );
1049
1050 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1052 let result_obs = rule.check(&ctx_obs).unwrap();
1053 assert!(
1054 result_obs.is_empty(),
1055 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1056 );
1057 }
1058
1059 let space_issues = vec![
1062 "` code`", "`code `", ];
1065
1066 for case in space_issues {
1067 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1069 let result_std = rule.check(&ctx_std).unwrap();
1070 assert!(
1071 !result_std.is_empty(),
1072 "Code with spacing issue should be flagged in Standard: {case}"
1073 );
1074
1075 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1077 let result_obs = rule.check(&ctx_obs).unwrap();
1078 assert!(
1079 !result_obs.is_empty(),
1080 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1081 );
1082 }
1083 }
1084
1085 #[test]
1087 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1088 let rule = MD038NoSpaceInCode::new();
1089
1090 let invalid_cases = [
1093 "` regular code`", "`code `", "` code `", "` code`", ];
1098
1099 let expected_flags = [
1101 true, true, false, true, ];
1106
1107 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1108 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1109 let result = rule.check(&ctx).unwrap();
1110 if *should_flag {
1111 assert!(
1112 !result.is_empty(),
1113 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1114 );
1115 } else {
1116 assert!(
1117 result.is_empty(),
1118 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1119 );
1120 }
1121 }
1122 }
1123
1124 #[test]
1126 fn test_obsidian_dataview_edge_cases() {
1127 let rule = MD038NoSpaceInCode::new();
1128
1129 let valid_cases = vec![
1131 ("`= 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), ];
1147
1148 for (case, should_be_valid) in valid_cases {
1149 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1150 let result = rule.check(&ctx).unwrap();
1151 if should_be_valid {
1152 assert!(
1153 result.is_empty(),
1154 "Valid Dataview expression should not be flagged: {case}"
1155 );
1156 } else {
1157 let _ = result;
1160 }
1161 }
1162 }
1163
1164 #[test]
1166 fn test_obsidian_dataview_in_context() {
1167 let rule = MD038NoSpaceInCode::new();
1168
1169 let content = r#"# My Note
1171
1172The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1173
1174Regular code: `println!("hello")` and `let x = 5;`
1175
1176DataviewJS count: `$= dv.pages('#project').length` projects found.
1177
1178More regular code with issue: ` bad code` should be flagged.
1179"#;
1180
1181 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1182 let result = rule.check(&ctx).unwrap();
1183
1184 assert_eq!(
1186 result.len(),
1187 1,
1188 "Should only flag the regular code span with leading space, not Dataview expressions"
1189 );
1190 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1191 }
1192
1193 #[test]
1195 fn test_obsidian_dataview_in_code_blocks() {
1196 let rule = MD038NoSpaceInCode::new();
1197
1198 let content = r#"# Example
1201
1202```
1203`= this.file.name`
1204`$= dv.current()`
1205```
1206
1207Regular paragraph with `= this.file.name` Dataview.
1208"#;
1209
1210 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1211 let result = rule.check(&ctx).unwrap();
1212
1213 assert!(
1215 result.is_empty(),
1216 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1217 );
1218 }
1219
1220 #[test]
1222 fn test_obsidian_dataview_unicode() {
1223 let rule = MD038NoSpaceInCode::new();
1224
1225 let unicode_cases = vec![
1226 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1233
1234 for case in unicode_cases {
1235 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1236 let result = rule.check(&ctx).unwrap();
1237 assert!(
1238 result.is_empty(),
1239 "Unicode Dataview expression should not be flagged: {case}"
1240 );
1241 }
1242 }
1243
1244 #[test]
1246 fn test_obsidian_regular_equals_still_works() {
1247 let rule = MD038NoSpaceInCode::new();
1248
1249 let valid_regular_cases = vec![
1251 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1257
1258 for case in valid_regular_cases {
1259 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1260 let result = rule.check(&ctx).unwrap();
1261 assert!(
1262 result.is_empty(),
1263 "Regular code with equals should not be flagged: {case}"
1264 );
1265 }
1266 }
1267
1268 #[test]
1270 fn test_obsidian_dataview_fix_preserves_expressions() {
1271 let rule = MD038NoSpaceInCode::new();
1272
1273 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1275 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1276 let fixed = rule.fix(&ctx).unwrap();
1277
1278 assert!(
1280 fixed.contains("`= this.file.name`"),
1281 "Dataview expression should be preserved after fix"
1282 );
1283 assert!(
1284 fixed.contains("`fixme`"),
1285 "Regular code span should be fixed (space removed)"
1286 );
1287 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1288 }
1289
1290 #[test]
1292 fn test_obsidian_multiple_dataview_same_line() {
1293 let rule = MD038NoSpaceInCode::new();
1294
1295 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1296 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1297 let result = rule.check(&ctx).unwrap();
1298
1299 assert!(
1300 result.is_empty(),
1301 "Multiple Dataview expressions on same line should all be valid"
1302 );
1303 }
1304
1305 #[test]
1307 fn test_obsidian_dataview_performance() {
1308 let rule = MD038NoSpaceInCode::new();
1309
1310 let mut content = String::new();
1312 for i in 0..100 {
1313 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1314 }
1315
1316 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1317 let start = std::time::Instant::now();
1318 let result = rule.check(&ctx).unwrap();
1319 let duration = start.elapsed();
1320
1321 assert!(result.is_empty(), "All Dataview expressions should be valid");
1322 assert!(
1323 duration.as_millis() < 1000,
1324 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1325 );
1326 }
1327
1328 #[test]
1330 fn test_is_dataview_expression_helper() {
1331 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1333 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1334 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1335 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1336 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1337 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1338
1339 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")); }
1350
1351 #[test]
1353 fn test_obsidian_dataview_with_tags() {
1354 let rule = MD038NoSpaceInCode::new();
1355
1356 let content = r#"# Project Status
1358
1359Tags: #project #active
1360
1361Status: `= this.status`
1362Count: `$= dv.pages('#project').length`
1363
1364Regular code: `function test() {}`
1365"#;
1366
1367 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1368 let result = rule.check(&ctx).unwrap();
1369
1370 assert!(
1372 result.is_empty(),
1373 "Dataview expressions and regular code should work together"
1374 );
1375 }
1376
1377 #[test]
1378 fn test_unicode_between_code_spans_no_panic() {
1379 let rule = MD038NoSpaceInCode::new();
1382
1383 let content = "Use `one` \u{00DC}nited `two` for backtick examples.";
1385 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386 let result = rule.check(&ctx);
1387 assert!(result.is_ok(), "Should not panic with Unicode between code spans");
1389
1390 let content_cjk = "Use `one` \u{4E16}\u{754C} `two` for examples.";
1392 let ctx_cjk = crate::lint_context::LintContext::new(content_cjk, crate::config::MarkdownFlavor::Standard, None);
1393 let result_cjk = rule.check(&ctx_cjk);
1394 assert!(result_cjk.is_ok(), "Should not panic with CJK between code spans");
1395 }
1396}