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 if warnings.is_empty() {
367 return Ok(content.to_string());
368 }
369
370 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
372 .into_iter()
373 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
374 .collect();
375
376 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
377
378 let mut result = content.to_string();
380 for (range, replacement) in fixes {
381 result.replace_range(range, &replacement);
382 }
383
384 Ok(result)
385 }
386
387 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
389 !ctx.likely_has_code()
390 }
391
392 fn as_any(&self) -> &dyn std::any::Any {
393 self
394 }
395
396 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
397 where
398 Self: Sized,
399 {
400 Box::new(MD038NoSpaceInCode { enabled: true })
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn test_md038_readme_false_positives() {
410 let rule = MD038NoSpaceInCode::new();
412 let valid_cases = vec![
413 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
414 "#### Effective Configuration (`rumdl config`)",
415 "- Blue: `.rumdl.toml`",
416 "### Defaults Only (`rumdl config --defaults`)",
417 ];
418
419 for case in valid_cases {
420 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
421 let result = rule.check(&ctx).unwrap();
422 assert!(
423 result.is_empty(),
424 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
425 case,
426 result.len()
427 );
428 }
429 }
430
431 #[test]
432 fn test_md038_valid() {
433 let rule = MD038NoSpaceInCode::new();
434 let valid_cases = vec![
435 "This is `code` in a sentence.",
436 "This is a `longer code span` in a sentence.",
437 "This is `code with internal spaces` which is fine.",
438 "Code span at `end of line`",
439 "`Start of line` code span",
440 "Multiple `code spans` in `one line` are fine",
441 "Code span with `symbols: !@#$%^&*()`",
442 "Empty code span `` is technically valid",
443 ];
444 for case in valid_cases {
445 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
446 let result = rule.check(&ctx).unwrap();
447 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
448 }
449 }
450
451 #[test]
452 fn test_md038_invalid() {
453 let rule = MD038NoSpaceInCode::new();
454 let invalid_cases = vec![
459 "This is ` code` with leading space.",
461 "This is `code ` with trailing space.",
463 "This is ` code ` with double leading space.",
465 "This is ` code ` with double trailing space.",
467 "This is ` code ` with double spaces both sides.",
469 ];
470 for case in invalid_cases {
471 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
472 let result = rule.check(&ctx).unwrap();
473 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
474 }
475 }
476
477 #[test]
478 fn test_md038_valid_commonmark_stripping() {
479 let rule = MD038NoSpaceInCode::new();
480 let valid_cases = vec![
484 "Type ` y ` to confirm.",
485 "Use ` git commit -m \"message\" ` to commit.",
486 "The variable ` $HOME ` contains home path.",
487 "The pattern ` *.txt ` matches text files.",
488 "This is ` random word ` with unnecessary spaces.",
489 "Text with ` plain text ` is valid.",
490 "Code with ` just code ` here.",
491 "Multiple ` word ` spans with ` text ` in one line.",
492 "This is ` code ` with both leading and trailing single space.",
493 "Use ` - ` as separator.",
494 ];
495 for case in valid_cases {
496 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
497 let result = rule.check(&ctx).unwrap();
498 assert!(
499 result.is_empty(),
500 "Single space on each side should not be flagged (CommonMark strips them): {case}"
501 );
502 }
503 }
504
505 #[test]
506 fn test_md038_fix() {
507 let rule = MD038NoSpaceInCode::new();
508 let test_cases = vec![
510 (
512 "This is ` code` with leading space.",
513 "This is `code` with leading space.",
514 ),
515 (
517 "This is `code ` with trailing space.",
518 "This is `code` with trailing space.",
519 ),
520 (
522 "This is ` code ` with both spaces.",
523 "This is ` code ` with both spaces.", ),
525 (
527 "This is ` code ` with double leading space.",
528 "This is `code` with double leading space.",
529 ),
530 (
532 "Multiple ` code ` and `spans ` to fix.",
533 "Multiple ` code ` and `spans` to fix.", ),
535 ];
536 for (input, expected) in test_cases {
537 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
538 let result = rule.fix(&ctx).unwrap();
539 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
540 }
541 }
542
543 #[test]
544 fn test_check_invalid_leading_space() {
545 let rule = MD038NoSpaceInCode::new();
546 let input = "This has a ` leading space` in code";
547 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
548 let result = rule.check(&ctx).unwrap();
549 assert_eq!(result.len(), 1);
550 assert_eq!(result[0].line, 1);
551 assert!(result[0].fix.is_some());
552 }
553
554 #[test]
555 fn test_code_span_parsing_nested_backticks() {
556 let content = "Code with ` nested `code` example ` should preserve backticks";
557 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558
559 println!("Content: {content}");
560 println!("Code spans found:");
561 let code_spans = ctx.code_spans();
562 for (i, span) in code_spans.iter().enumerate() {
563 println!(
564 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
565 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
566 );
567 }
568
569 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
571 }
572
573 #[test]
574 fn test_nested_backtick_detection() {
575 let rule = MD038NoSpaceInCode::new();
576
577 let content = "Code with `` `backticks` inside `` should not be flagged";
579 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580 let result = rule.check(&ctx).unwrap();
581 assert!(result.is_empty(), "Code spans with backticks should be skipped");
582 }
583
584 #[test]
585 fn test_quarto_inline_r_code() {
586 let rule = MD038NoSpaceInCode::new();
588
589 let content = r#"The result is `r nchar("test")` which equals 4."#;
592
593 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
595 let result_quarto = rule.check(&ctx_quarto).unwrap();
596 assert!(
597 result_quarto.is_empty(),
598 "Quarto inline R code should not trigger warnings. Got {} warnings",
599 result_quarto.len()
600 );
601
602 let content_other = "This has `plain text ` with trailing space.";
605 let ctx_other =
606 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
607 let result_other = rule.check(&ctx_other).unwrap();
608 assert_eq!(
609 result_other.len(),
610 1,
611 "Quarto should still flag non-R code spans with improper spaces"
612 );
613 }
614
615 #[test]
621 fn test_hugo_template_syntax_comprehensive() {
622 let rule = MD038NoSpaceInCode::new();
623
624 let valid_hugo_cases = vec![
628 (
630 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
631 "Multi-line raw shortcode",
632 ),
633 (
634 "Some text {{raw ` code `}} more text",
635 "Inline raw shortcode with spaces",
636 ),
637 ("{{raw `code`}}", "Raw shortcode without spaces"),
638 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
640 ("{{< `code` >}}", "Partial shortcode without spaces"),
641 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
643 ("{{% `code` %}}", "Percent shortcode without spaces"),
644 ("{{ ` code ` }}", "Generic shortcode with spaces"),
646 ("{{ `code` }}", "Generic shortcode without spaces"),
647 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
649 ("{{< code `go list` >}}", "Shortcode with code parameter"),
650 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
652 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
653 (
655 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
656 "Nested Go template syntax",
657 ),
658 ("{{raw `code`}}", "Hugo template at line start"),
660 ("Text {{raw `code`}}", "Hugo template at end of line"),
662 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
664 ];
665
666 for (case, description) in valid_hugo_cases {
667 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
668 let result = rule.check(&ctx).unwrap();
669 assert!(
670 result.is_empty(),
671 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
672 );
673 }
674
675 let should_be_flagged = vec![
680 ("This is ` code` with leading space.", "Leading space only"),
681 ("This is `code ` with trailing space.", "Trailing space only"),
682 ("Text ` code ` here", "Extra leading space (asymmetric)"),
683 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
684 ("Text ` code` here", "Double leading, no trailing"),
685 ("Text `code ` here", "No leading, double trailing"),
686 ];
687
688 for (case, description) in should_be_flagged {
689 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
690 let result = rule.check(&ctx).unwrap();
691 assert!(
692 !result.is_empty(),
693 "Should flag asymmetric space code spans: {description} - {case}"
694 );
695 }
696
697 let symmetric_single_space = vec![
703 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
704 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
705 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
706 ];
707
708 for (case, description) in symmetric_single_space {
709 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
710 let result = rule.check(&ctx).unwrap();
711 assert!(
712 result.is_empty(),
713 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
714 );
715 }
716
717 let unicode_cases = vec![
720 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
721 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
722 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
723 (
724 "{{raw `\n\tcode with 'single quotes'\n`}}",
725 "Single quotes in Hugo template",
726 ),
727 ];
728
729 for (case, description) in unicode_cases {
730 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
731 let result = rule.check(&ctx).unwrap();
732 assert!(
733 result.is_empty(),
734 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
735 );
736 }
737
738 assert!(
742 rule.check(&crate::lint_context::LintContext::new(
743 "{{ ` ` }}",
744 crate::config::MarkdownFlavor::Standard,
745 None
746 ))
747 .unwrap()
748 .is_empty(),
749 "Minimum Hugo pattern should be valid"
750 );
751
752 assert!(
754 rule.check(&crate::lint_context::LintContext::new(
755 "{{raw `\n\t\n`}}",
756 crate::config::MarkdownFlavor::Standard,
757 None
758 ))
759 .unwrap()
760 .is_empty(),
761 "Hugo template with only whitespace should be valid"
762 );
763 }
764
765 #[test]
767 fn test_hugo_template_with_other_markdown() {
768 let rule = MD038NoSpaceInCode::new();
769
770 let content = r#"1. First item
7722. Second item with {{raw `code`}} template
7733. Third item"#;
774 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775 let result = rule.check(&ctx).unwrap();
776 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
777
778 let content = r#"> Quote with {{raw `code`}} template"#;
780 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782 assert!(
783 result.is_empty(),
784 "Hugo template in blockquote should not trigger warnings"
785 );
786
787 let content = r#"{{raw `code`}} and ` bad code` here"#;
789 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
792 }
793
794 #[test]
796 fn test_hugo_template_performance() {
797 let rule = MD038NoSpaceInCode::new();
798
799 let mut content = String::new();
801 for i in 0..100 {
802 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
803 }
804
805 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
806 let start = std::time::Instant::now();
807 let result = rule.check(&ctx).unwrap();
808 let duration = start.elapsed();
809
810 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
811 assert!(
812 duration.as_millis() < 1000,
813 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
814 );
815 }
816
817 #[test]
818 fn test_mkdocs_inline_hilite_not_flagged() {
819 let rule = MD038NoSpaceInCode::new();
822
823 let valid_cases = vec![
824 "`#!python print('hello')`",
825 "`#!js alert('hi')`",
826 "`#!c++ cout << x;`",
827 "Use `#!python import os` to import modules",
828 "`#!bash echo $HOME`",
829 ];
830
831 for case in valid_cases {
832 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
833 let result = rule.check(&ctx).unwrap();
834 assert!(
835 result.is_empty(),
836 "InlineHilite syntax should not be flagged in MkDocs: {case}"
837 );
838 }
839
840 let content = "`#!python print('hello')`";
842 let ctx_standard =
843 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
844 let result_standard = rule.check(&ctx_standard).unwrap();
845 assert!(
848 result_standard.is_empty(),
849 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
850 );
851 }
852
853 #[test]
854 fn test_multibyte_utf8_no_panic() {
855 let rule = MD038NoSpaceInCode::new();
859
860 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
862 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
863 let result = rule.check(&ctx);
864 assert!(result.is_ok(), "Greek text should not panic");
865
866 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
868 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
869 let result = rule.check(&ctx);
870 assert!(result.is_ok(), "Chinese text should not panic");
871
872 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
874 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
875 let result = rule.check(&ctx);
876 assert!(result.is_ok(), "Cyrillic text should not panic");
877
878 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
880 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx);
882 assert!(
883 result.is_ok(),
884 "Mixed Chinese text with multiple code spans should not panic"
885 );
886 }
887
888 #[test]
892 fn test_obsidian_dataview_inline_dql_not_flagged() {
893 let rule = MD038NoSpaceInCode::new();
894
895 let valid_dql_cases = vec![
897 "`= this.file.name`",
898 "`= date(today)`",
899 "`= [[Page]].field`",
900 "`= choice(condition, \"yes\", \"no\")`",
901 "`= this.file.mtime`",
902 "`= this.file.ctime`",
903 "`= this.file.path`",
904 "`= this.file.folder`",
905 "`= this.file.size`",
906 "`= this.file.ext`",
907 "`= this.file.link`",
908 "`= this.file.outlinks`",
909 "`= this.file.inlinks`",
910 "`= this.file.tags`",
911 ];
912
913 for case in valid_dql_cases {
914 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
915 let result = rule.check(&ctx).unwrap();
916 assert!(
917 result.is_empty(),
918 "Dataview DQL expression should not be flagged in Obsidian: {case}"
919 );
920 }
921 }
922
923 #[test]
925 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
926 let rule = MD038NoSpaceInCode::new();
927
928 let valid_dvjs_cases = vec![
930 "`$= dv.current().file.mtime`",
931 "`$= dv.pages().length`",
932 "`$= dv.current()`",
933 "`$= dv.pages('#tag').length`",
934 "`$= dv.pages('\"folder\"').length`",
935 "`$= dv.current().file.name`",
936 "`$= dv.current().file.path`",
937 "`$= dv.current().file.folder`",
938 "`$= dv.current().file.link`",
939 ];
940
941 for case in valid_dvjs_cases {
942 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
943 let result = rule.check(&ctx).unwrap();
944 assert!(
945 result.is_empty(),
946 "Dataview JS expression should not be flagged in Obsidian: {case}"
947 );
948 }
949 }
950
951 #[test]
953 fn test_obsidian_dataview_complex_expressions() {
954 let rule = MD038NoSpaceInCode::new();
955
956 let complex_cases = vec![
957 "`= sum(filter(pages, (p) => p.done))`",
959 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
960 "`= choice(x > 5, \"big\", \"small\")`",
962 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
963 "`= date(today) - dur(7 days)`",
965 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
966 "`= sum(rows.amount)`",
968 "`= round(average(rows.score), 2)`",
969 "`= min(rows.priority)`",
970 "`= max(rows.priority)`",
971 "`= join(this.file.tags, \", \")`",
973 "`= replace(this.title, \"-\", \" \")`",
974 "`= lower(this.file.name)`",
975 "`= upper(this.file.name)`",
976 "`= length(this.file.outlinks)`",
978 "`= contains(this.file.tags, \"important\")`",
979 "`= [[Page Name]].field`",
981 "`= [[Folder/Subfolder/Page]].nested.field`",
982 "`= default(this.status, \"unknown\")`",
984 "`= coalesce(this.priority, this.importance, 0)`",
985 ];
986
987 for case in complex_cases {
988 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
989 let result = rule.check(&ctx).unwrap();
990 assert!(
991 result.is_empty(),
992 "Complex Dataview expression should not be flagged in Obsidian: {case}"
993 );
994 }
995 }
996
997 #[test]
999 fn test_obsidian_dataviewjs_method_chains() {
1000 let rule = MD038NoSpaceInCode::new();
1001
1002 let method_chain_cases = vec![
1003 "`$= dv.pages().where(p => p.status).length`",
1004 "`$= dv.pages('#project').where(p => !p.done).length`",
1005 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
1006 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
1007 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
1008 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
1009 "`$= dv.page('Index').children.map(p => p.title)`",
1010 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1011 ];
1012
1013 for case in method_chain_cases {
1014 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1015 let result = rule.check(&ctx).unwrap();
1016 assert!(
1017 result.is_empty(),
1018 "DataviewJS method chain should not be flagged in Obsidian: {case}"
1019 );
1020 }
1021 }
1022
1023 #[test]
1032 fn test_standard_flavor_vs_obsidian_dataview() {
1033 let rule = MD038NoSpaceInCode::new();
1034
1035 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1038
1039 for case in no_issue_cases {
1040 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1042 let result_std = rule.check(&ctx_std).unwrap();
1043 assert!(
1044 result_std.is_empty(),
1045 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1046 );
1047
1048 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1050 let result_obs = rule.check(&ctx_obs).unwrap();
1051 assert!(
1052 result_obs.is_empty(),
1053 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1054 );
1055 }
1056
1057 let space_issues = vec![
1060 "` code`", "`code `", ];
1063
1064 for case in space_issues {
1065 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1067 let result_std = rule.check(&ctx_std).unwrap();
1068 assert!(
1069 !result_std.is_empty(),
1070 "Code with spacing issue should be flagged in Standard: {case}"
1071 );
1072
1073 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1075 let result_obs = rule.check(&ctx_obs).unwrap();
1076 assert!(
1077 !result_obs.is_empty(),
1078 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1079 );
1080 }
1081 }
1082
1083 #[test]
1085 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1086 let rule = MD038NoSpaceInCode::new();
1087
1088 let invalid_cases = [
1091 "` regular code`", "`code `", "` code `", "` code`", ];
1096
1097 let expected_flags = [
1099 true, true, false, true, ];
1104
1105 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1106 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1107 let result = rule.check(&ctx).unwrap();
1108 if *should_flag {
1109 assert!(
1110 !result.is_empty(),
1111 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1112 );
1113 } else {
1114 assert!(
1115 result.is_empty(),
1116 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1117 );
1118 }
1119 }
1120 }
1121
1122 #[test]
1124 fn test_obsidian_dataview_edge_cases() {
1125 let rule = MD038NoSpaceInCode::new();
1126
1127 let valid_cases = vec![
1129 ("`= 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), ];
1145
1146 for (case, should_be_valid) in valid_cases {
1147 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1148 let result = rule.check(&ctx).unwrap();
1149 if should_be_valid {
1150 assert!(
1151 result.is_empty(),
1152 "Valid Dataview expression should not be flagged: {case}"
1153 );
1154 } else {
1155 let _ = result;
1158 }
1159 }
1160 }
1161
1162 #[test]
1164 fn test_obsidian_dataview_in_context() {
1165 let rule = MD038NoSpaceInCode::new();
1166
1167 let content = r#"# My Note
1169
1170The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1171
1172Regular code: `println!("hello")` and `let x = 5;`
1173
1174DataviewJS count: `$= dv.pages('#project').length` projects found.
1175
1176More regular code with issue: ` bad code` should be flagged.
1177"#;
1178
1179 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1180 let result = rule.check(&ctx).unwrap();
1181
1182 assert_eq!(
1184 result.len(),
1185 1,
1186 "Should only flag the regular code span with leading space, not Dataview expressions"
1187 );
1188 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1189 }
1190
1191 #[test]
1193 fn test_obsidian_dataview_in_code_blocks() {
1194 let rule = MD038NoSpaceInCode::new();
1195
1196 let content = r#"# Example
1199
1200```
1201`= this.file.name`
1202`$= dv.current()`
1203```
1204
1205Regular paragraph with `= this.file.name` Dataview.
1206"#;
1207
1208 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1209 let result = rule.check(&ctx).unwrap();
1210
1211 assert!(
1213 result.is_empty(),
1214 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1215 );
1216 }
1217
1218 #[test]
1220 fn test_obsidian_dataview_unicode() {
1221 let rule = MD038NoSpaceInCode::new();
1222
1223 let unicode_cases = vec![
1224 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1231
1232 for case in unicode_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 "Unicode Dataview expression should not be flagged: {case}"
1238 );
1239 }
1240 }
1241
1242 #[test]
1244 fn test_obsidian_regular_equals_still_works() {
1245 let rule = MD038NoSpaceInCode::new();
1246
1247 let valid_regular_cases = vec![
1249 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1255
1256 for case in valid_regular_cases {
1257 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1258 let result = rule.check(&ctx).unwrap();
1259 assert!(
1260 result.is_empty(),
1261 "Regular code with equals should not be flagged: {case}"
1262 );
1263 }
1264 }
1265
1266 #[test]
1268 fn test_obsidian_dataview_fix_preserves_expressions() {
1269 let rule = MD038NoSpaceInCode::new();
1270
1271 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1273 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1274 let fixed = rule.fix(&ctx).unwrap();
1275
1276 assert!(
1278 fixed.contains("`= this.file.name`"),
1279 "Dataview expression should be preserved after fix"
1280 );
1281 assert!(
1282 fixed.contains("`fixme`"),
1283 "Regular code span should be fixed (space removed)"
1284 );
1285 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1286 }
1287
1288 #[test]
1290 fn test_obsidian_multiple_dataview_same_line() {
1291 let rule = MD038NoSpaceInCode::new();
1292
1293 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1294 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1295 let result = rule.check(&ctx).unwrap();
1296
1297 assert!(
1298 result.is_empty(),
1299 "Multiple Dataview expressions on same line should all be valid"
1300 );
1301 }
1302
1303 #[test]
1305 fn test_obsidian_dataview_performance() {
1306 let rule = MD038NoSpaceInCode::new();
1307
1308 let mut content = String::new();
1310 for i in 0..100 {
1311 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1312 }
1313
1314 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1315 let start = std::time::Instant::now();
1316 let result = rule.check(&ctx).unwrap();
1317 let duration = start.elapsed();
1318
1319 assert!(result.is_empty(), "All Dataview expressions should be valid");
1320 assert!(
1321 duration.as_millis() < 1000,
1322 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1323 );
1324 }
1325
1326 #[test]
1328 fn test_is_dataview_expression_helper() {
1329 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1331 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1332 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1333 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1334 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1335 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1336
1337 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")); }
1348
1349 #[test]
1351 fn test_obsidian_dataview_with_tags() {
1352 let rule = MD038NoSpaceInCode::new();
1353
1354 let content = r#"# Project Status
1356
1357Tags: #project #active
1358
1359Status: `= this.status`
1360Count: `$= dv.pages('#project').length`
1361
1362Regular code: `function test() {}`
1363"#;
1364
1365 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1366 let result = rule.check(&ctx).unwrap();
1367
1368 assert!(
1370 result.is_empty(),
1371 "Dataview expressions and regular code should work together"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_unicode_between_code_spans_no_panic() {
1377 let rule = MD038NoSpaceInCode::new();
1380
1381 let content = "Use `one` \u{00DC}nited `two` for backtick examples.";
1383 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1384 let result = rule.check(&ctx);
1385 assert!(result.is_ok(), "Should not panic with Unicode between code spans");
1387
1388 let content_cjk = "Use `one` \u{4E16}\u{754C} `two` for examples.";
1390 let ctx_cjk = crate::lint_context::LintContext::new(content_cjk, crate::config::MarkdownFlavor::Standard, None);
1391 let result_cjk = rule.check(&ctx_cjk);
1392 assert!(result_cjk.is_ok(), "Should not panic with CJK between code spans");
1393 }
1394}