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 fn has_attached_nested_backtick_boundary(
216 &self,
217 ctx: &crate::lint_context::LintContext,
218 code_span: &crate::lint_context::CodeSpan,
219 ) -> bool {
220 let content = code_span.content.as_str();
221
222 let next_char = ctx.content[code_span.byte_end..].chars().next();
223 let prev_char = ctx.content[..code_span.byte_offset].chars().next_back();
224
225 (content.ends_with(char::is_whitespace) && next_char.is_some_and(|c| !c.is_whitespace()))
226 || (content.starts_with(char::is_whitespace) && prev_char.is_some_and(|c| !c.is_whitespace()))
227 }
228}
229
230impl Rule for MD038NoSpaceInCode {
231 fn name(&self) -> &'static str {
232 "MD038"
233 }
234
235 fn description(&self) -> &'static str {
236 "Spaces inside code span elements"
237 }
238
239 fn category(&self) -> RuleCategory {
240 RuleCategory::Other
241 }
242
243 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
244 if !self.enabled {
245 return Ok(vec![]);
246 }
247
248 let mut warnings = Vec::new();
249
250 let code_spans = ctx.code_spans();
252 for (i, code_span) in code_spans.iter().enumerate() {
253 if let Some(line_info) = ctx.lines.get(code_span.line - 1) {
255 if line_info.in_code_block {
256 continue;
257 }
258 if (line_info.in_mkdocs_container() || line_info.in_pymdown_block) && code_span.content.contains('\n') {
262 continue;
263 }
264 }
265
266 let code_content = &code_span.content;
267
268 if code_content.is_empty() {
270 continue;
271 }
272
273 let has_leading_space = code_content.chars().next().is_some_and(char::is_whitespace);
275 let has_trailing_space = code_content.chars().last().is_some_and(char::is_whitespace);
276
277 if !has_leading_space && !has_trailing_space {
278 continue;
279 }
280
281 let trimmed = code_content.trim();
282
283 if code_content != trimmed {
285 if has_leading_space && has_trailing_space && !trimmed.is_empty() {
297 let leading_spaces = code_content.len() - code_content.trim_start().len();
298 let trailing_spaces = code_content.len() - code_content.trim_end().len();
299
300 if leading_spaces == 1 && trailing_spaces == 1 {
302 continue;
303 }
304 }
305 if trimmed.contains('`') {
308 continue;
309 }
310
311 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
314 && trimmed.starts_with('r')
315 && trimmed.len() > 1
316 && trimmed.chars().nth(1).is_some_and(char::is_whitespace)
317 {
318 continue;
319 }
320
321 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
324 continue;
325 }
326
327 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && Self::is_dataview_expression(code_content) {
331 continue;
332 }
333
334 if self.is_hugo_template_syntax(ctx, code_span) {
337 continue;
338 }
339
340 if self.is_likely_nested_backticks(ctx, i) {
343 continue;
344 }
345
346 if self.has_attached_nested_backtick_boundary(ctx, code_span) {
347 continue;
348 }
349
350 warnings.push(LintWarning {
351 rule_name: Some(self.name().to_string()),
352 line: code_span.line,
353 column: code_span.start_col + 1, end_line: code_span.line,
355 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
357 severity: Severity::Warning,
358 fix: Some(Fix::new(
359 code_span.byte_offset..code_span.byte_end,
360 format!(
361 "{}{}{}",
362 "`".repeat(code_span.backtick_count),
363 trimmed,
364 "`".repeat(code_span.backtick_count)
365 ),
366 )),
367 });
368 }
369 }
370
371 Ok(warnings)
372 }
373
374 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
375 let content = ctx.content;
376 if !self.enabled {
377 return Ok(content.to_string());
378 }
379
380 if !content.contains('`') {
382 return Ok(content.to_string());
383 }
384
385 let warnings = self.check(ctx)?;
387 let warnings =
388 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
389 if warnings.is_empty() {
390 return Ok(content.to_string());
391 }
392
393 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
395 .into_iter()
396 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
397 .collect();
398
399 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
400
401 let mut result = content.to_string();
403 for (range, replacement) in fixes {
404 result.replace_range(range, &replacement);
405 }
406
407 Ok(result)
408 }
409
410 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
412 !ctx.likely_has_code()
413 }
414
415 fn as_any(&self) -> &dyn std::any::Any {
416 self
417 }
418
419 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
420 where
421 Self: Sized,
422 {
423 Box::new(MD038NoSpaceInCode { enabled: true })
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn test_md038_readme_false_positives() {
433 let rule = MD038NoSpaceInCode::new();
435 let valid_cases = vec![
436 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
437 "#### Effective Configuration (`rumdl config`)",
438 "- Blue: `.rumdl.toml`",
439 "### Defaults Only (`rumdl config --defaults`)",
440 ];
441
442 for case in valid_cases {
443 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
444 let result = rule.check(&ctx).unwrap();
445 assert!(
446 result.is_empty(),
447 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
448 case,
449 result.len()
450 );
451 }
452 }
453
454 #[test]
455 fn test_md038_valid() {
456 let rule = MD038NoSpaceInCode::new();
457 let valid_cases = vec![
458 "This is `code` in a sentence.",
459 "This is a `longer code span` in a sentence.",
460 "This is `code with internal spaces` which is fine.",
461 "Code span at `end of line`",
462 "`Start of line` code span",
463 "Multiple `code spans` in `one line` are fine",
464 "Code span with `symbols: !@#$%^&*()`",
465 "Empty code span `` is technically valid",
466 ];
467 for case in valid_cases {
468 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
469 let result = rule.check(&ctx).unwrap();
470 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
471 }
472 }
473
474 #[test]
475 fn test_md038_invalid() {
476 let rule = MD038NoSpaceInCode::new();
477 let invalid_cases = vec![
482 "This is ` code` with leading space.",
484 "This is `code ` with trailing space.",
486 "This is ` code ` with double leading space.",
488 "This is ` code ` with double trailing space.",
490 "This is ` code ` with double spaces both sides.",
492 ];
493 for case in invalid_cases {
494 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
495 let result = rule.check(&ctx).unwrap();
496 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
497 }
498 }
499
500 #[test]
501 fn test_md038_valid_commonmark_stripping() {
502 let rule = MD038NoSpaceInCode::new();
503 let valid_cases = vec![
507 "Type ` y ` to confirm.",
508 "Use ` git commit -m \"message\" ` to commit.",
509 "The variable ` $HOME ` contains home path.",
510 "The pattern ` *.txt ` matches text files.",
511 "This is ` random word ` with unnecessary spaces.",
512 "Text with ` plain text ` is valid.",
513 "Code with ` just code ` here.",
514 "Multiple ` word ` spans with ` text ` in one line.",
515 "This is ` code ` with both leading and trailing single space.",
516 "Use ` - ` as separator.",
517 ];
518 for case in valid_cases {
519 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
520 let result = rule.check(&ctx).unwrap();
521 assert!(
522 result.is_empty(),
523 "Single space on each side should not be flagged (CommonMark strips them): {case}"
524 );
525 }
526 }
527
528 #[test]
529 fn test_md038_fix() {
530 let rule = MD038NoSpaceInCode::new();
531 let test_cases = vec![
533 (
535 "This is ` code` with leading space.",
536 "This is `code` with leading space.",
537 ),
538 (
540 "This is `code ` with trailing space.",
541 "This is `code` with trailing space.",
542 ),
543 (
545 "This is ` code ` with both spaces.",
546 "This is ` code ` with both spaces.", ),
548 (
550 "This is ` code ` with double leading space.",
551 "This is `code` with double leading space.",
552 ),
553 (
555 "Multiple ` code ` and `spans ` to fix.",
556 "Multiple ` code ` and `spans` to fix.", ),
558 ];
559 for (input, expected) in test_cases {
560 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
561 let result = rule.fix(&ctx).unwrap();
562 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
563 }
564 }
565
566 #[test]
567 fn test_check_invalid_leading_space() {
568 let rule = MD038NoSpaceInCode::new();
569 let input = "This has a ` leading space` in code";
570 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
571 let result = rule.check(&ctx).unwrap();
572 assert_eq!(result.len(), 1);
573 assert_eq!(result[0].line, 1);
574 assert!(result[0].fix.is_some());
575 }
576
577 #[test]
578 fn test_code_span_parsing_nested_backticks() {
579 let content = "Code with ` nested `code` example ` should preserve backticks";
580 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581
582 println!("Content: {content}");
583 println!("Code spans found:");
584 let code_spans = ctx.code_spans();
585 for (i, span) in code_spans.iter().enumerate() {
586 println!(
587 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
588 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
589 );
590 }
591
592 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
594 }
595
596 #[test]
597 fn test_nested_backtick_detection() {
598 let rule = MD038NoSpaceInCode::new();
599
600 let content = "Code with `` `backticks` inside `` should not be flagged";
602 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604 assert!(result.is_empty(), "Code spans with backticks should be skipped");
605 }
606
607 #[test]
608 fn test_quarto_inline_r_code() {
609 let rule = MD038NoSpaceInCode::new();
611
612 let content = r#"The result is `r nchar("test")` which equals 4."#;
615
616 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
618 let result_quarto = rule.check(&ctx_quarto).unwrap();
619 assert!(
620 result_quarto.is_empty(),
621 "Quarto inline R code should not trigger warnings. Got {} warnings",
622 result_quarto.len()
623 );
624
625 let content_other = "This has `plain text ` with trailing space.";
628 let ctx_other =
629 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
630 let result_other = rule.check(&ctx_other).unwrap();
631 assert_eq!(
632 result_other.len(),
633 1,
634 "Quarto should still flag non-R code spans with improper spaces"
635 );
636 }
637
638 #[test]
644 fn test_hugo_template_syntax_comprehensive() {
645 let rule = MD038NoSpaceInCode::new();
646
647 let valid_hugo_cases = vec![
651 (
653 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
654 "Multi-line raw shortcode",
655 ),
656 (
657 "Some text {{raw ` code `}} more text",
658 "Inline raw shortcode with spaces",
659 ),
660 ("{{raw `code`}}", "Raw shortcode without spaces"),
661 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
663 ("{{< `code` >}}", "Partial shortcode without spaces"),
664 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
666 ("{{% `code` %}}", "Percent shortcode without spaces"),
667 ("{{ ` code ` }}", "Generic shortcode with spaces"),
669 ("{{ `code` }}", "Generic shortcode without spaces"),
670 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
672 ("{{< code `go list` >}}", "Shortcode with code parameter"),
673 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
675 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
676 (
678 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
679 "Nested Go template syntax",
680 ),
681 ("{{raw `code`}}", "Hugo template at line start"),
683 ("Text {{raw `code`}}", "Hugo template at end of line"),
685 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
687 ];
688
689 for (case, description) in valid_hugo_cases {
690 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
691 let result = rule.check(&ctx).unwrap();
692 assert!(
693 result.is_empty(),
694 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
695 );
696 }
697
698 let should_be_flagged = vec![
703 ("This is ` code` with leading space.", "Leading space only"),
704 ("This is `code ` with trailing space.", "Trailing space only"),
705 ("Text ` code ` here", "Extra leading space (asymmetric)"),
706 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
707 ("Text ` code` here", "Double leading, no trailing"),
708 ("Text `code ` here", "No leading, double trailing"),
709 ];
710
711 for (case, description) in should_be_flagged {
712 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
713 let result = rule.check(&ctx).unwrap();
714 assert!(
715 !result.is_empty(),
716 "Should flag asymmetric space code spans: {description} - {case}"
717 );
718 }
719
720 let symmetric_single_space = vec![
726 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
727 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
728 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
729 ];
730
731 for (case, description) in symmetric_single_space {
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 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
737 );
738 }
739
740 let unicode_cases = vec![
743 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
744 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
745 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
746 (
747 "{{raw `\n\tcode with 'single quotes'\n`}}",
748 "Single quotes in Hugo template",
749 ),
750 ];
751
752 for (case, description) in unicode_cases {
753 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
754 let result = rule.check(&ctx).unwrap();
755 assert!(
756 result.is_empty(),
757 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
758 );
759 }
760
761 assert!(
765 rule.check(&crate::lint_context::LintContext::new(
766 "{{ ` ` }}",
767 crate::config::MarkdownFlavor::Standard,
768 None
769 ))
770 .unwrap()
771 .is_empty(),
772 "Minimum Hugo pattern should be valid"
773 );
774
775 assert!(
777 rule.check(&crate::lint_context::LintContext::new(
778 "{{raw `\n\t\n`}}",
779 crate::config::MarkdownFlavor::Standard,
780 None
781 ))
782 .unwrap()
783 .is_empty(),
784 "Hugo template with only whitespace should be valid"
785 );
786 }
787
788 #[test]
790 fn test_hugo_template_with_other_markdown() {
791 let rule = MD038NoSpaceInCode::new();
792
793 let content = r#"1. First item
7952. Second item with {{raw `code`}} template
7963. Third item"#;
797 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
800
801 let content = r#"> Quote with {{raw `code`}} template"#;
803 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804 let result = rule.check(&ctx).unwrap();
805 assert!(
806 result.is_empty(),
807 "Hugo template in blockquote should not trigger warnings"
808 );
809
810 let content = r#"{{raw `code`}} and ` bad code` here"#;
812 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813 let result = rule.check(&ctx).unwrap();
814 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
815 }
816
817 #[test]
819 fn test_hugo_template_performance() {
820 let rule = MD038NoSpaceInCode::new();
821
822 let mut content = String::new();
824 for i in 0..100 {
825 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
826 }
827
828 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
829 let start = std::time::Instant::now();
830 let result = rule.check(&ctx).unwrap();
831 let duration = start.elapsed();
832
833 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
834 assert!(
835 duration.as_millis() < 1000,
836 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
837 );
838 }
839
840 #[test]
841 fn test_mkdocs_inline_hilite_not_flagged() {
842 let rule = MD038NoSpaceInCode::new();
845
846 let valid_cases = vec![
847 "`#!python print('hello')`",
848 "`#!js alert('hi')`",
849 "`#!c++ cout << x;`",
850 "Use `#!python import os` to import modules",
851 "`#!bash echo $HOME`",
852 ];
853
854 for case in valid_cases {
855 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
856 let result = rule.check(&ctx).unwrap();
857 assert!(
858 result.is_empty(),
859 "InlineHilite syntax should not be flagged in MkDocs: {case}"
860 );
861 }
862
863 let content = "`#!python print('hello')`";
865 let ctx_standard =
866 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let result_standard = rule.check(&ctx_standard).unwrap();
868 assert!(
871 result_standard.is_empty(),
872 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
873 );
874 }
875
876 #[test]
877 fn test_multibyte_utf8_no_panic() {
878 let rule = MD038NoSpaceInCode::new();
882
883 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
885 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
886 let result = rule.check(&ctx);
887 assert!(result.is_ok(), "Greek text should not panic");
888
889 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
891 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
892 let result = rule.check(&ctx);
893 assert!(result.is_ok(), "Chinese text should not panic");
894
895 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
897 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.check(&ctx);
899 assert!(result.is_ok(), "Cyrillic text should not panic");
900
901 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
903 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
904 let result = rule.check(&ctx);
905 assert!(
906 result.is_ok(),
907 "Mixed Chinese text with multiple code spans should not panic"
908 );
909 }
910
911 #[test]
915 fn test_obsidian_dataview_inline_dql_not_flagged() {
916 let rule = MD038NoSpaceInCode::new();
917
918 let valid_dql_cases = vec![
920 "`= this.file.name`",
921 "`= date(today)`",
922 "`= [[Page]].field`",
923 "`= choice(condition, \"yes\", \"no\")`",
924 "`= this.file.mtime`",
925 "`= this.file.ctime`",
926 "`= this.file.path`",
927 "`= this.file.folder`",
928 "`= this.file.size`",
929 "`= this.file.ext`",
930 "`= this.file.link`",
931 "`= this.file.outlinks`",
932 "`= this.file.inlinks`",
933 "`= this.file.tags`",
934 ];
935
936 for case in valid_dql_cases {
937 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
938 let result = rule.check(&ctx).unwrap();
939 assert!(
940 result.is_empty(),
941 "Dataview DQL expression should not be flagged in Obsidian: {case}"
942 );
943 }
944 }
945
946 #[test]
948 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
949 let rule = MD038NoSpaceInCode::new();
950
951 let valid_dvjs_cases = vec![
953 "`$= dv.current().file.mtime`",
954 "`$= dv.pages().length`",
955 "`$= dv.current()`",
956 "`$= dv.pages('#tag').length`",
957 "`$= dv.pages('\"folder\"').length`",
958 "`$= dv.current().file.name`",
959 "`$= dv.current().file.path`",
960 "`$= dv.current().file.folder`",
961 "`$= dv.current().file.link`",
962 ];
963
964 for case in valid_dvjs_cases {
965 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
966 let result = rule.check(&ctx).unwrap();
967 assert!(
968 result.is_empty(),
969 "Dataview JS expression should not be flagged in Obsidian: {case}"
970 );
971 }
972 }
973
974 #[test]
976 fn test_obsidian_dataview_complex_expressions() {
977 let rule = MD038NoSpaceInCode::new();
978
979 let complex_cases = vec![
980 "`= sum(filter(pages, (p) => p.done))`",
982 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
983 "`= choice(x > 5, \"big\", \"small\")`",
985 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
986 "`= date(today) - dur(7 days)`",
988 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
989 "`= sum(rows.amount)`",
991 "`= round(average(rows.score), 2)`",
992 "`= min(rows.priority)`",
993 "`= max(rows.priority)`",
994 "`= join(this.file.tags, \", \")`",
996 "`= replace(this.title, \"-\", \" \")`",
997 "`= lower(this.file.name)`",
998 "`= upper(this.file.name)`",
999 "`= length(this.file.outlinks)`",
1001 "`= contains(this.file.tags, \"important\")`",
1002 "`= [[Page Name]].field`",
1004 "`= [[Folder/Subfolder/Page]].nested.field`",
1005 "`= default(this.status, \"unknown\")`",
1007 "`= coalesce(this.priority, this.importance, 0)`",
1008 ];
1009
1010 for case in complex_cases {
1011 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1012 let result = rule.check(&ctx).unwrap();
1013 assert!(
1014 result.is_empty(),
1015 "Complex Dataview expression should not be flagged in Obsidian: {case}"
1016 );
1017 }
1018 }
1019
1020 #[test]
1022 fn test_obsidian_dataviewjs_method_chains() {
1023 let rule = MD038NoSpaceInCode::new();
1024
1025 let method_chain_cases = vec![
1026 "`$= dv.pages().where(p => p.status).length`",
1027 "`$= dv.pages('#project').where(p => !p.done).length`",
1028 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
1029 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
1030 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
1031 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
1032 "`$= dv.page('Index').children.map(p => p.title)`",
1033 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1034 ];
1035
1036 for case in method_chain_cases {
1037 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1038 let result = rule.check(&ctx).unwrap();
1039 assert!(
1040 result.is_empty(),
1041 "DataviewJS method chain should not be flagged in Obsidian: {case}"
1042 );
1043 }
1044 }
1045
1046 #[test]
1055 fn test_standard_flavor_vs_obsidian_dataview() {
1056 let rule = MD038NoSpaceInCode::new();
1057
1058 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1061
1062 for case in no_issue_cases {
1063 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1065 let result_std = rule.check(&ctx_std).unwrap();
1066 assert!(
1067 result_std.is_empty(),
1068 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1069 );
1070
1071 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1073 let result_obs = rule.check(&ctx_obs).unwrap();
1074 assert!(
1075 result_obs.is_empty(),
1076 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1077 );
1078 }
1079
1080 let space_issues = vec![
1083 "` code`", "`code `", ];
1086
1087 for case in space_issues {
1088 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1090 let result_std = rule.check(&ctx_std).unwrap();
1091 assert!(
1092 !result_std.is_empty(),
1093 "Code with spacing issue should be flagged in Standard: {case}"
1094 );
1095
1096 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1098 let result_obs = rule.check(&ctx_obs).unwrap();
1099 assert!(
1100 !result_obs.is_empty(),
1101 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1102 );
1103 }
1104 }
1105
1106 #[test]
1108 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1109 let rule = MD038NoSpaceInCode::new();
1110
1111 let invalid_cases = [
1114 "` regular code`", "`code `", "` code `", "` code`", ];
1119
1120 let expected_flags = [
1122 true, true, false, true, ];
1127
1128 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1129 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1130 let result = rule.check(&ctx).unwrap();
1131 if *should_flag {
1132 assert!(
1133 !result.is_empty(),
1134 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1135 );
1136 } else {
1137 assert!(
1138 result.is_empty(),
1139 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1140 );
1141 }
1142 }
1143 }
1144
1145 #[test]
1147 fn test_obsidian_dataview_edge_cases() {
1148 let rule = MD038NoSpaceInCode::new();
1149
1150 let valid_cases = vec![
1152 ("`= 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), ];
1168
1169 for (case, should_be_valid) in valid_cases {
1170 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1171 let result = rule.check(&ctx).unwrap();
1172 if should_be_valid {
1173 assert!(
1174 result.is_empty(),
1175 "Valid Dataview expression should not be flagged: {case}"
1176 );
1177 } else {
1178 let _ = result;
1181 }
1182 }
1183 }
1184
1185 #[test]
1187 fn test_obsidian_dataview_in_context() {
1188 let rule = MD038NoSpaceInCode::new();
1189
1190 let content = r#"# My Note
1192
1193The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1194
1195Regular code: `println!("hello")` and `let x = 5;`
1196
1197DataviewJS count: `$= dv.pages('#project').length` projects found.
1198
1199More regular code with issue: ` bad code` should be flagged.
1200"#;
1201
1202 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1203 let result = rule.check(&ctx).unwrap();
1204
1205 assert_eq!(
1207 result.len(),
1208 1,
1209 "Should only flag the regular code span with leading space, not Dataview expressions"
1210 );
1211 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1212 }
1213
1214 #[test]
1216 fn test_obsidian_dataview_in_code_blocks() {
1217 let rule = MD038NoSpaceInCode::new();
1218
1219 let content = r#"# Example
1222
1223```
1224`= this.file.name`
1225`$= dv.current()`
1226```
1227
1228Regular paragraph with `= this.file.name` Dataview.
1229"#;
1230
1231 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1232 let result = rule.check(&ctx).unwrap();
1233
1234 assert!(
1236 result.is_empty(),
1237 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1238 );
1239 }
1240
1241 #[test]
1243 fn test_obsidian_dataview_unicode() {
1244 let rule = MD038NoSpaceInCode::new();
1245
1246 let unicode_cases = vec![
1247 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1254
1255 for case in unicode_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 "Unicode Dataview expression should not be flagged: {case}"
1261 );
1262 }
1263 }
1264
1265 #[test]
1267 fn test_obsidian_regular_equals_still_works() {
1268 let rule = MD038NoSpaceInCode::new();
1269
1270 let valid_regular_cases = vec![
1272 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1278
1279 for case in valid_regular_cases {
1280 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1281 let result = rule.check(&ctx).unwrap();
1282 assert!(
1283 result.is_empty(),
1284 "Regular code with equals should not be flagged: {case}"
1285 );
1286 }
1287 }
1288
1289 #[test]
1291 fn test_obsidian_dataview_fix_preserves_expressions() {
1292 let rule = MD038NoSpaceInCode::new();
1293
1294 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1296 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1297 let fixed = rule.fix(&ctx).unwrap();
1298
1299 assert!(
1301 fixed.contains("`= this.file.name`"),
1302 "Dataview expression should be preserved after fix"
1303 );
1304 assert!(
1305 fixed.contains("`fixme`"),
1306 "Regular code span should be fixed (space removed)"
1307 );
1308 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1309 }
1310
1311 #[test]
1313 fn test_obsidian_multiple_dataview_same_line() {
1314 let rule = MD038NoSpaceInCode::new();
1315
1316 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1317 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1318 let result = rule.check(&ctx).unwrap();
1319
1320 assert!(
1321 result.is_empty(),
1322 "Multiple Dataview expressions on same line should all be valid"
1323 );
1324 }
1325
1326 #[test]
1328 fn test_obsidian_dataview_performance() {
1329 let rule = MD038NoSpaceInCode::new();
1330
1331 let mut content = String::new();
1333 for i in 0..100 {
1334 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1335 }
1336
1337 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1338 let start = std::time::Instant::now();
1339 let result = rule.check(&ctx).unwrap();
1340 let duration = start.elapsed();
1341
1342 assert!(result.is_empty(), "All Dataview expressions should be valid");
1343 assert!(
1344 duration.as_millis() < 1000,
1345 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1346 );
1347 }
1348
1349 #[test]
1351 fn test_is_dataview_expression_helper() {
1352 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1354 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1355 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1356 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1357 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1358 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1359
1360 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")); }
1371
1372 #[test]
1374 fn test_obsidian_dataview_with_tags() {
1375 let rule = MD038NoSpaceInCode::new();
1376
1377 let content = r#"# Project Status
1379
1380Tags: #project #active
1381
1382Status: `= this.status`
1383Count: `$= dv.pages('#project').length`
1384
1385Regular code: `function test() {}`
1386"#;
1387
1388 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1389 let result = rule.check(&ctx).unwrap();
1390
1391 assert!(
1393 result.is_empty(),
1394 "Dataview expressions and regular code should work together"
1395 );
1396 }
1397
1398 #[test]
1399 fn test_unicode_between_code_spans_no_panic() {
1400 let rule = MD038NoSpaceInCode::new();
1403
1404 let content = "Use `one` \u{00DC}nited `two` for backtick examples.";
1406 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407 let result = rule.check(&ctx);
1408 assert!(result.is_ok(), "Should not panic with Unicode between code spans");
1410
1411 let content_cjk = "Use `one` \u{4E16}\u{754C} `two` for examples.";
1413 let ctx_cjk = crate::lint_context::LintContext::new(content_cjk, crate::config::MarkdownFlavor::Standard, None);
1414 let result_cjk = rule.check(&ctx_cjk);
1415 assert!(result_cjk.is_ok(), "Should not panic with CJK between code spans");
1416 }
1417}