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