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 let trailing_neighbor_is_pandoc_attr =
229 ctx.flavor.is_pandoc_compatible() && ctx.is_in_inline_code_attr(code_span.byte_end);
230
231 (content.ends_with(char::is_whitespace)
232 && next_char.is_some_and(|c| !c.is_whitespace())
233 && !trailing_neighbor_is_pandoc_attr)
234 || (content.starts_with(char::is_whitespace) && prev_char.is_some_and(|c| !c.is_whitespace()))
235 }
236}
237
238impl Rule for MD038NoSpaceInCode {
239 fn name(&self) -> &'static str {
240 "MD038"
241 }
242
243 fn description(&self) -> &'static str {
244 "Spaces inside code span elements"
245 }
246
247 fn category(&self) -> RuleCategory {
248 RuleCategory::Other
249 }
250
251 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
252 if !self.enabled {
253 return Ok(vec![]);
254 }
255
256 let mut warnings = Vec::new();
257
258 let code_spans = ctx.code_spans();
260 for (i, code_span) in code_spans.iter().enumerate() {
261 if let Some(line_info) = ctx.lines.get(code_span.line - 1) {
263 if line_info.in_code_block {
264 continue;
265 }
266 if (line_info.in_mkdocs_container() || line_info.in_pymdown_block) && code_span.content.contains('\n') {
270 continue;
271 }
272 }
273
274 let code_content = &code_span.content;
275
276 if code_content.is_empty() {
278 continue;
279 }
280
281 let has_leading_space = code_content.chars().next().is_some_and(char::is_whitespace);
283 let has_trailing_space = code_content.chars().last().is_some_and(char::is_whitespace);
284
285 if !has_leading_space && !has_trailing_space {
286 continue;
287 }
288
289 let trimmed = code_content.trim();
290
291 if code_content != trimmed {
293 if has_leading_space && has_trailing_space && !trimmed.is_empty() {
305 let leading_spaces = code_content.len() - code_content.trim_start().len();
306 let trailing_spaces = code_content.len() - code_content.trim_end().len();
307
308 if leading_spaces == 1 && trailing_spaces == 1 {
310 continue;
311 }
312 }
313 if trimmed.contains('`') {
316 continue;
317 }
318
319 if ctx.flavor == crate::config::MarkdownFlavor::Quarto
324 && trimmed.starts_with('r')
325 && trimmed.len() > 1
326 && trimmed.chars().nth(1).is_some_and(char::is_whitespace)
327 {
328 continue;
329 }
330
331 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
334 continue;
335 }
336
337 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && Self::is_dataview_expression(code_content) {
341 continue;
342 }
343
344 if ctx.flavor.supports_myst_roles() && ctx.is_in_myst_role(code_span.byte_offset) {
347 continue;
348 }
349
350 if self.is_hugo_template_syntax(ctx, code_span) {
353 continue;
354 }
355
356 if self.is_likely_nested_backticks(ctx, i) {
359 continue;
360 }
361
362 if self.has_attached_nested_backtick_boundary(ctx, code_span) {
363 continue;
364 }
365
366 warnings.push(LintWarning {
367 rule_name: Some(self.name().to_string()),
368 line: code_span.line,
369 column: code_span.start_col + 1, end_line: code_span.line,
371 end_column: code_span.end_col, message: "Spaces inside code span elements".to_string(),
373 severity: Severity::Warning,
374 fix: Some(Fix::new(
375 code_span.byte_offset..code_span.byte_end,
376 format!(
377 "{}{}{}",
378 "`".repeat(code_span.backtick_count),
379 trimmed,
380 "`".repeat(code_span.backtick_count)
381 ),
382 )),
383 });
384 }
385 }
386
387 Ok(warnings)
388 }
389
390 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
391 let content = ctx.content;
392 if !self.enabled {
393 return Ok(content.to_string());
394 }
395
396 if !content.contains('`') {
398 return Ok(content.to_string());
399 }
400
401 let warnings = self.check(ctx)?;
403 let warnings =
404 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
405 if warnings.is_empty() {
406 return Ok(content.to_string());
407 }
408
409 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
411 .into_iter()
412 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
413 .collect();
414
415 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
416
417 let mut result = content.to_string();
419 for (range, replacement) in fixes {
420 result.replace_range(range, &replacement);
421 }
422
423 Ok(result)
424 }
425
426 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
428 !ctx.likely_has_code()
429 }
430
431 fn as_any(&self) -> &dyn std::any::Any {
432 self
433 }
434
435 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
436 where
437 Self: Sized,
438 {
439 Box::new(MD038NoSpaceInCode { enabled: true })
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 #[test]
448 fn test_md038_readme_false_positives() {
449 let rule = MD038NoSpaceInCode::new();
451 let valid_cases = vec![
452 "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
453 "#### Effective Configuration (`rumdl config`)",
454 "- Blue: `.rumdl.toml`",
455 "### Defaults Only (`rumdl config --defaults`)",
456 ];
457
458 for case in valid_cases {
459 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
460 let result = rule.check(&ctx).unwrap();
461 assert!(
462 result.is_empty(),
463 "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
464 case,
465 result.len()
466 );
467 }
468 }
469
470 #[test]
471 fn test_md038_valid() {
472 let rule = MD038NoSpaceInCode::new();
473 let valid_cases = vec![
474 "This is `code` in a sentence.",
475 "This is a `longer code span` in a sentence.",
476 "This is `code with internal spaces` which is fine.",
477 "Code span at `end of line`",
478 "`Start of line` code span",
479 "Multiple `code spans` in `one line` are fine",
480 "Code span with `symbols: !@#$%^&*()`",
481 "Empty code span `` is technically valid",
482 ];
483 for case in valid_cases {
484 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
485 let result = rule.check(&ctx).unwrap();
486 assert!(result.is_empty(), "Valid case should not have warnings: {case}");
487 }
488 }
489
490 #[test]
491 fn test_md038_invalid() {
492 let rule = MD038NoSpaceInCode::new();
493 let invalid_cases = vec![
498 "This is ` code` with leading space.",
500 "This is `code ` with trailing space.",
502 "This is ` code ` with double leading space.",
504 "This is ` code ` with double trailing space.",
506 "This is ` code ` with double spaces both sides.",
508 ];
509 for case in invalid_cases {
510 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
511 let result = rule.check(&ctx).unwrap();
512 assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
513 }
514 }
515
516 #[test]
517 fn test_md038_valid_commonmark_stripping() {
518 let rule = MD038NoSpaceInCode::new();
519 let valid_cases = vec![
523 "Type ` y ` to confirm.",
524 "Use ` git commit -m \"message\" ` to commit.",
525 "The variable ` $HOME ` contains home path.",
526 "The pattern ` *.txt ` matches text files.",
527 "This is ` random word ` with unnecessary spaces.",
528 "Text with ` plain text ` is valid.",
529 "Code with ` just code ` here.",
530 "Multiple ` word ` spans with ` text ` in one line.",
531 "This is ` code ` with both leading and trailing single space.",
532 "Use ` - ` as separator.",
533 ];
534 for case in valid_cases {
535 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
536 let result = rule.check(&ctx).unwrap();
537 assert!(
538 result.is_empty(),
539 "Single space on each side should not be flagged (CommonMark strips them): {case}"
540 );
541 }
542 }
543
544 #[test]
545 fn test_md038_fix() {
546 let rule = MD038NoSpaceInCode::new();
547 let test_cases = vec![
549 (
551 "This is ` code` with leading space.",
552 "This is `code` with leading space.",
553 ),
554 (
556 "This is `code ` with trailing space.",
557 "This is `code` with trailing space.",
558 ),
559 (
561 "This is ` code ` with both spaces.",
562 "This is ` code ` with both spaces.", ),
564 (
566 "This is ` code ` with double leading space.",
567 "This is `code` with double leading space.",
568 ),
569 (
571 "Multiple ` code ` and `spans ` to fix.",
572 "Multiple ` code ` and `spans` to fix.", ),
574 ];
575 for (input, expected) in test_cases {
576 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
577 let result = rule.fix(&ctx).unwrap();
578 assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
579 }
580 }
581
582 #[test]
583 fn test_check_invalid_leading_space() {
584 let rule = MD038NoSpaceInCode::new();
585 let input = "This has a ` leading space` in code";
586 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert_eq!(result.len(), 1);
589 assert_eq!(result[0].line, 1);
590 assert!(result[0].fix.is_some());
591 }
592
593 #[test]
594 fn test_code_span_parsing_nested_backticks() {
595 let content = "Code with ` nested `code` example ` should preserve backticks";
596 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597
598 println!("Content: {content}");
599 println!("Code spans found:");
600 let code_spans = ctx.code_spans();
601 for (i, span) in code_spans.iter().enumerate() {
602 println!(
603 " Span {}: line={}, col={}-{}, backticks={}, content='{}'",
604 i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
605 );
606 }
607
608 assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
610 }
611
612 #[test]
613 fn test_nested_backtick_detection() {
614 let rule = MD038NoSpaceInCode::new();
615
616 let content = "Code with `` `backticks` inside `` should not be flagged";
618 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
619 let result = rule.check(&ctx).unwrap();
620 assert!(result.is_empty(), "Code spans with backticks should be skipped");
621 }
622
623 #[test]
624 fn test_quarto_inline_r_code() {
625 let rule = MD038NoSpaceInCode::new();
627
628 let content = r#"The result is `r nchar("test")` which equals 4."#;
631
632 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
634 let result_quarto = rule.check(&ctx_quarto).unwrap();
635 assert!(
636 result_quarto.is_empty(),
637 "Quarto inline R code should not trigger warnings. Got {} warnings",
638 result_quarto.len()
639 );
640
641 let content_other = "This has `plain text ` with trailing space.";
644 let ctx_other =
645 crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
646 let result_other = rule.check(&ctx_other).unwrap();
647 assert_eq!(
648 result_other.len(),
649 1,
650 "Quarto should still flag non-R code spans with improper spaces"
651 );
652 }
653
654 #[test]
660 fn test_hugo_template_syntax_comprehensive() {
661 let rule = MD038NoSpaceInCode::new();
662
663 let valid_hugo_cases = vec![
667 (
669 "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
670 "Multi-line raw shortcode",
671 ),
672 (
673 "Some text {{raw ` code `}} more text",
674 "Inline raw shortcode with spaces",
675 ),
676 ("{{raw `code`}}", "Raw shortcode without spaces"),
677 ("{{< ` code ` >}}", "Partial shortcode with spaces"),
679 ("{{< `code` >}}", "Partial shortcode without spaces"),
680 ("{{% ` code ` %}}", "Percent shortcode with spaces"),
682 ("{{% `code` %}}", "Percent shortcode without spaces"),
683 ("{{ ` code ` }}", "Generic shortcode with spaces"),
685 ("{{ `code` }}", "Generic shortcode without spaces"),
686 ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
688 ("{{< code `go list` >}}", "Shortcode with code parameter"),
689 ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
691 ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
692 (
694 "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
695 "Nested Go template syntax",
696 ),
697 ("{{raw `code`}}", "Hugo template at line start"),
699 ("Text {{raw `code`}}", "Hugo template at end of line"),
701 ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
703 ];
704
705 for (case, description) in valid_hugo_cases {
706 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
707 let result = rule.check(&ctx).unwrap();
708 assert!(
709 result.is_empty(),
710 "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
711 );
712 }
713
714 let should_be_flagged = vec![
719 ("This is ` code` with leading space.", "Leading space only"),
720 ("This is `code ` with trailing space.", "Trailing space only"),
721 ("Text ` code ` here", "Extra leading space (asymmetric)"),
722 ("Text ` code ` here", "Extra trailing space (asymmetric)"),
723 ("Text ` code` here", "Double leading, no trailing"),
724 ("Text `code ` here", "No leading, double trailing"),
725 ];
726
727 for (case, description) in should_be_flagged {
728 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.check(&ctx).unwrap();
730 assert!(
731 !result.is_empty(),
732 "Should flag asymmetric space code spans: {description} - {case}"
733 );
734 }
735
736 let symmetric_single_space = vec![
742 ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
743 ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
744 ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
745 ];
746
747 for (case, description) in symmetric_single_space {
748 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert!(
751 result.is_empty(),
752 "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
753 );
754 }
755
756 let unicode_cases = vec![
759 ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
760 ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
761 ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
762 (
763 "{{raw `\n\tcode with 'single quotes'\n`}}",
764 "Single quotes in Hugo template",
765 ),
766 ];
767
768 for (case, description) in unicode_cases {
769 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert!(
772 result.is_empty(),
773 "Hugo templates with special characters should not trigger warnings: {description} - {case}"
774 );
775 }
776
777 assert!(
781 rule.check(&crate::lint_context::LintContext::new(
782 "{{ ` ` }}",
783 crate::config::MarkdownFlavor::Standard,
784 None
785 ))
786 .unwrap()
787 .is_empty(),
788 "Minimum Hugo pattern should be valid"
789 );
790
791 assert!(
793 rule.check(&crate::lint_context::LintContext::new(
794 "{{raw `\n\t\n`}}",
795 crate::config::MarkdownFlavor::Standard,
796 None
797 ))
798 .unwrap()
799 .is_empty(),
800 "Hugo template with only whitespace should be valid"
801 );
802 }
803
804 #[test]
806 fn test_hugo_template_with_other_markdown() {
807 let rule = MD038NoSpaceInCode::new();
808
809 let content = r#"1. First item
8112. Second item with {{raw `code`}} template
8123. Third item"#;
813 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814 let result = rule.check(&ctx).unwrap();
815 assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
816
817 let content = r#"> Quote with {{raw `code`}} template"#;
819 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820 let result = rule.check(&ctx).unwrap();
821 assert!(
822 result.is_empty(),
823 "Hugo template in blockquote should not trigger warnings"
824 );
825
826 let content = r#"{{raw `code`}} and ` bad code` here"#;
828 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829 let result = rule.check(&ctx).unwrap();
830 assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
831 }
832
833 #[test]
835 fn test_hugo_template_performance() {
836 let rule = MD038NoSpaceInCode::new();
837
838 let mut content = String::new();
840 for i in 0..100 {
841 content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
842 }
843
844 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
845 let start = std::time::Instant::now();
846 let result = rule.check(&ctx).unwrap();
847 let duration = start.elapsed();
848
849 assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
850 assert!(
851 duration.as_millis() < 1000,
852 "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
853 );
854 }
855
856 #[test]
857 fn test_mkdocs_inline_hilite_not_flagged() {
858 let rule = MD038NoSpaceInCode::new();
861
862 let valid_cases = vec![
863 "`#!python print('hello')`",
864 "`#!js alert('hi')`",
865 "`#!c++ cout << x;`",
866 "Use `#!python import os` to import modules",
867 "`#!bash echo $HOME`",
868 ];
869
870 for case in valid_cases {
871 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
872 let result = rule.check(&ctx).unwrap();
873 assert!(
874 result.is_empty(),
875 "InlineHilite syntax should not be flagged in MkDocs: {case}"
876 );
877 }
878
879 let content = "`#!python print('hello')`";
881 let ctx_standard =
882 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883 let result_standard = rule.check(&ctx_standard).unwrap();
884 assert!(
887 result_standard.is_empty(),
888 "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
889 );
890 }
891
892 #[test]
893 fn test_multibyte_utf8_no_panic() {
894 let rule = MD038NoSpaceInCode::new();
898
899 let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
901 let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
902 let result = rule.check(&ctx);
903 assert!(result.is_ok(), "Greek text should not panic");
904
905 let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
907 let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
908 let result = rule.check(&ctx);
909 assert!(result.is_ok(), "Chinese text should not panic");
910
911 let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
913 let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
914 let result = rule.check(&ctx);
915 assert!(result.is_ok(), "Cyrillic text should not panic");
916
917 let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
919 let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
920 let result = rule.check(&ctx);
921 assert!(
922 result.is_ok(),
923 "Mixed Chinese text with multiple code spans should not panic"
924 );
925 }
926
927 #[test]
931 fn test_obsidian_dataview_inline_dql_not_flagged() {
932 let rule = MD038NoSpaceInCode::new();
933
934 let valid_dql_cases = vec![
936 "`= this.file.name`",
937 "`= date(today)`",
938 "`= [[Page]].field`",
939 "`= choice(condition, \"yes\", \"no\")`",
940 "`= this.file.mtime`",
941 "`= this.file.ctime`",
942 "`= this.file.path`",
943 "`= this.file.folder`",
944 "`= this.file.size`",
945 "`= this.file.ext`",
946 "`= this.file.link`",
947 "`= this.file.outlinks`",
948 "`= this.file.inlinks`",
949 "`= this.file.tags`",
950 ];
951
952 for case in valid_dql_cases {
953 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
954 let result = rule.check(&ctx).unwrap();
955 assert!(
956 result.is_empty(),
957 "Dataview DQL expression should not be flagged in Obsidian: {case}"
958 );
959 }
960 }
961
962 #[test]
964 fn test_obsidian_dataview_inline_dvjs_not_flagged() {
965 let rule = MD038NoSpaceInCode::new();
966
967 let valid_dvjs_cases = vec![
969 "`$= dv.current().file.mtime`",
970 "`$= dv.pages().length`",
971 "`$= dv.current()`",
972 "`$= dv.pages('#tag').length`",
973 "`$= dv.pages('\"folder\"').length`",
974 "`$= dv.current().file.name`",
975 "`$= dv.current().file.path`",
976 "`$= dv.current().file.folder`",
977 "`$= dv.current().file.link`",
978 ];
979
980 for case in valid_dvjs_cases {
981 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
982 let result = rule.check(&ctx).unwrap();
983 assert!(
984 result.is_empty(),
985 "Dataview JS expression should not be flagged in Obsidian: {case}"
986 );
987 }
988 }
989
990 #[test]
992 fn test_obsidian_dataview_complex_expressions() {
993 let rule = MD038NoSpaceInCode::new();
994
995 let complex_cases = vec![
996 "`= sum(filter(pages, (p) => p.done))`",
998 "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
999 "`= choice(x > 5, \"big\", \"small\")`",
1001 "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
1002 "`= date(today) - dur(7 days)`",
1004 "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
1005 "`= sum(rows.amount)`",
1007 "`= round(average(rows.score), 2)`",
1008 "`= min(rows.priority)`",
1009 "`= max(rows.priority)`",
1010 "`= join(this.file.tags, \", \")`",
1012 "`= replace(this.title, \"-\", \" \")`",
1013 "`= lower(this.file.name)`",
1014 "`= upper(this.file.name)`",
1015 "`= length(this.file.outlinks)`",
1017 "`= contains(this.file.tags, \"important\")`",
1018 "`= [[Page Name]].field`",
1020 "`= [[Folder/Subfolder/Page]].nested.field`",
1021 "`= default(this.status, \"unknown\")`",
1023 "`= coalesce(this.priority, this.importance, 0)`",
1024 ];
1025
1026 for case in complex_cases {
1027 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1028 let result = rule.check(&ctx).unwrap();
1029 assert!(
1030 result.is_empty(),
1031 "Complex Dataview expression should not be flagged in Obsidian: {case}"
1032 );
1033 }
1034 }
1035
1036 #[test]
1038 fn test_obsidian_dataviewjs_method_chains() {
1039 let rule = MD038NoSpaceInCode::new();
1040
1041 let method_chain_cases = vec![
1042 "`$= dv.pages().where(p => p.status).length`",
1043 "`$= dv.pages('#project').where(p => !p.done).length`",
1044 "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
1045 "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
1046 "`$= dv.current().file.tasks.where(t => !t.completed).length`",
1047 "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
1048 "`$= dv.page('Index').children.map(p => p.title)`",
1049 "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1050 ];
1051
1052 for case in method_chain_cases {
1053 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1054 let result = rule.check(&ctx).unwrap();
1055 assert!(
1056 result.is_empty(),
1057 "DataviewJS method chain should not be flagged in Obsidian: {case}"
1058 );
1059 }
1060 }
1061
1062 #[test]
1071 fn test_standard_flavor_vs_obsidian_dataview() {
1072 let rule = MD038NoSpaceInCode::new();
1073
1074 let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1077
1078 for case in no_issue_cases {
1079 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1081 let result_std = rule.check(&ctx_std).unwrap();
1082 assert!(
1083 result_std.is_empty(),
1084 "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1085 );
1086
1087 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1089 let result_obs = rule.check(&ctx_obs).unwrap();
1090 assert!(
1091 result_obs.is_empty(),
1092 "Dataview expression shouldn't be flagged in Obsidian: {case}"
1093 );
1094 }
1095
1096 let space_issues = vec![
1099 "` code`", "`code `", ];
1102
1103 for case in space_issues {
1104 let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1106 let result_std = rule.check(&ctx_std).unwrap();
1107 assert!(
1108 !result_std.is_empty(),
1109 "Code with spacing issue should be flagged in Standard: {case}"
1110 );
1111
1112 let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1114 let result_obs = rule.check(&ctx_obs).unwrap();
1115 assert!(
1116 !result_obs.is_empty(),
1117 "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1118 );
1119 }
1120 }
1121
1122 #[test]
1124 fn test_obsidian_still_flags_regular_code_spans_with_space() {
1125 let rule = MD038NoSpaceInCode::new();
1126
1127 let invalid_cases = [
1130 "` regular code`", "`code `", "` code `", "` code`", ];
1135
1136 let expected_flags = [
1138 true, true, false, true, ];
1143
1144 for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1145 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1146 let result = rule.check(&ctx).unwrap();
1147 if *should_flag {
1148 assert!(
1149 !result.is_empty(),
1150 "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1151 );
1152 } else {
1153 assert!(
1154 result.is_empty(),
1155 "CommonMark-valid symmetric spacing should not be flagged: {case}"
1156 );
1157 }
1158 }
1159 }
1160
1161 #[test]
1163 fn test_obsidian_dataview_edge_cases() {
1164 let rule = MD038NoSpaceInCode::new();
1165
1166 let valid_cases = vec![
1168 ("`= 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), ];
1184
1185 for (case, should_be_valid) in valid_cases {
1186 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1187 let result = rule.check(&ctx).unwrap();
1188 if should_be_valid {
1189 assert!(
1190 result.is_empty(),
1191 "Valid Dataview expression should not be flagged: {case}"
1192 );
1193 } else {
1194 let _ = result;
1197 }
1198 }
1199 }
1200
1201 #[test]
1203 fn test_obsidian_dataview_in_context() {
1204 let rule = MD038NoSpaceInCode::new();
1205
1206 let content = r#"# My Note
1208
1209The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1210
1211Regular code: `println!("hello")` and `let x = 5;`
1212
1213DataviewJS count: `$= dv.pages('#project').length` projects found.
1214
1215More regular code with issue: ` bad code` should be flagged.
1216"#;
1217
1218 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1219 let result = rule.check(&ctx).unwrap();
1220
1221 assert_eq!(
1223 result.len(),
1224 1,
1225 "Should only flag the regular code span with leading space, not Dataview expressions"
1226 );
1227 assert_eq!(result[0].line, 9, "Warning should be on line 9");
1228 }
1229
1230 #[test]
1232 fn test_obsidian_dataview_in_code_blocks() {
1233 let rule = MD038NoSpaceInCode::new();
1234
1235 let content = r#"# Example
1238
1239```
1240`= this.file.name`
1241`$= dv.current()`
1242```
1243
1244Regular paragraph with `= this.file.name` Dataview.
1245"#;
1246
1247 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1248 let result = rule.check(&ctx).unwrap();
1249
1250 assert!(
1252 result.is_empty(),
1253 "Dataview in code blocks should be ignored, inline Dataview should be valid"
1254 );
1255 }
1256
1257 #[test]
1259 fn test_obsidian_dataview_unicode() {
1260 let rule = MD038NoSpaceInCode::new();
1261
1262 let unicode_cases = vec![
1263 "`= this.日本語`", "`= this.中文字段`", "`= \"Привет мир\"`", "`$= dv.pages('#日本語タグ')`", "`= choice(true, \"✅\", \"❌\")`", "`= this.file.name + \" 📝\"`", ];
1270
1271 for case in unicode_cases {
1272 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1273 let result = rule.check(&ctx).unwrap();
1274 assert!(
1275 result.is_empty(),
1276 "Unicode Dataview expression should not be flagged: {case}"
1277 );
1278 }
1279 }
1280
1281 #[test]
1283 fn test_obsidian_regular_equals_still_works() {
1284 let rule = MD038NoSpaceInCode::new();
1285
1286 let valid_regular_cases = vec![
1288 "`x = 5`", "`a == b`", "`x >= 10`", "`let x = 10`", "`const y = 5`", ];
1294
1295 for case in valid_regular_cases {
1296 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1297 let result = rule.check(&ctx).unwrap();
1298 assert!(
1299 result.is_empty(),
1300 "Regular code with equals should not be flagged: {case}"
1301 );
1302 }
1303 }
1304
1305 #[test]
1307 fn test_obsidian_dataview_fix_preserves_expressions() {
1308 let rule = MD038NoSpaceInCode::new();
1309
1310 let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1312 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1313 let fixed = rule.fix(&ctx).unwrap();
1314
1315 assert!(
1317 fixed.contains("`= this.file.name`"),
1318 "Dataview expression should be preserved after fix"
1319 );
1320 assert!(
1321 fixed.contains("`fixme`"),
1322 "Regular code span should be fixed (space removed)"
1323 );
1324 assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1325 }
1326
1327 #[test]
1329 fn test_obsidian_multiple_dataview_same_line() {
1330 let rule = MD038NoSpaceInCode::new();
1331
1332 let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1333 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1334 let result = rule.check(&ctx).unwrap();
1335
1336 assert!(
1337 result.is_empty(),
1338 "Multiple Dataview expressions on same line should all be valid"
1339 );
1340 }
1341
1342 #[test]
1344 fn test_obsidian_dataview_performance() {
1345 let rule = MD038NoSpaceInCode::new();
1346
1347 let mut content = String::new();
1349 for i in 0..100 {
1350 content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1351 }
1352
1353 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1354 let start = std::time::Instant::now();
1355 let result = rule.check(&ctx).unwrap();
1356 let duration = start.elapsed();
1357
1358 assert!(result.is_empty(), "All Dataview expressions should be valid");
1359 assert!(
1360 duration.as_millis() < 1000,
1361 "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1362 );
1363 }
1364
1365 #[test]
1367 fn test_is_dataview_expression_helper() {
1368 assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1370 assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1371 assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1372 assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1373 assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1374 assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1375
1376 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")); }
1387
1388 #[test]
1390 fn test_obsidian_dataview_with_tags() {
1391 let rule = MD038NoSpaceInCode::new();
1392
1393 let content = r#"# Project Status
1395
1396Tags: #project #active
1397
1398Status: `= this.status`
1399Count: `$= dv.pages('#project').length`
1400
1401Regular code: `function test() {}`
1402"#;
1403
1404 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1405 let result = rule.check(&ctx).unwrap();
1406
1407 assert!(
1409 result.is_empty(),
1410 "Dataview expressions and regular code should work together"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_unicode_between_code_spans_no_panic() {
1416 let rule = MD038NoSpaceInCode::new();
1419
1420 let content = "Use `one` \u{00DC}nited `two` for backtick examples.";
1422 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423 let result = rule.check(&ctx);
1424 assert!(result.is_ok(), "Should not panic with Unicode between code spans");
1426
1427 let content_cjk = "Use `one` \u{4E16}\u{754C} `two` for examples.";
1429 let ctx_cjk = crate::lint_context::LintContext::new(content_cjk, crate::config::MarkdownFlavor::Standard, None);
1430 let result_cjk = rule.check(&ctx_cjk);
1431 assert!(result_cjk.is_ok(), "Should not panic with CJK between code spans");
1432 }
1433
1434 #[test]
1435 fn test_pandoc_inline_r_code_not_exempt() {
1436 let rule = MD038NoSpaceInCode::new();
1442 let content = "See `r foo ` for details.\n";
1445
1446 let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1448 let result_quarto = rule.check(&ctx_quarto).unwrap();
1449 assert!(
1450 result_quarto.is_empty(),
1451 "MD038 should suppress trailing-space warning for `r expression` under Quarto: {result_quarto:?}"
1452 );
1453
1454 let ctx_pandoc = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1456 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1457 assert!(
1458 !result_pandoc.is_empty(),
1459 "MD038 should flag trailing space in `r expression` under Pandoc flavor (not Quarto/RMarkdown syntax): {result_pandoc:?}"
1460 );
1461 }
1462
1463 #[test]
1468 fn test_pandoc_inline_code_attr_does_not_suppress_leading_space() {
1469 let rule = MD038NoSpaceInCode::new();
1470 let content = "Use ` print()`{.python} for output.\n";
1471 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1472 let result = rule.check(&ctx).unwrap();
1473 assert!(
1474 !result.is_empty(),
1475 "MD038 must flag leading space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1476 );
1477 }
1478
1479 #[test]
1483 fn test_pandoc_inline_code_attr_does_not_suppress_trailing_space() {
1484 let rule = MD038NoSpaceInCode::new();
1485 let content = "Use `print() `{.python} for output.\n";
1486 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1487 let result = rule.check(&ctx).unwrap();
1488 assert!(
1489 !result.is_empty(),
1490 "MD038 must flag trailing space inside `code`{{.lang}} under Pandoc — the attribute is outside the span: {result:?}"
1491 );
1492 }
1493
1494 #[test]
1496 fn test_standard_still_flags_leading_space_with_attr_syntax() {
1497 let rule = MD038NoSpaceInCode::new();
1498 let content = "Use ` print()`{.python} for output.\n";
1499 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1500 let result = rule.check(&ctx).unwrap();
1501 assert!(
1502 !result.is_empty(),
1503 "MD038 should flag leading space in code span under Standard flavor: {result:?}"
1504 );
1505 }
1506
1507 #[test]
1510 fn test_pandoc_inline_code_attr_clean_span_not_flagged() {
1511 let rule = MD038NoSpaceInCode::new();
1512 let content = "Use `print()`{.python} for output.\n";
1513 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1514 let result = rule.check(&ctx).unwrap();
1515 assert!(
1516 result.is_empty(),
1517 "MD038 must not flag a clean attributed code span under Pandoc: {result:?}"
1518 );
1519 }
1520}