Skip to main content

rumdl_lib/rules/
md038_no_space_in_code.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mkdocs_extensions::is_inline_hilite_content;
3
4/// Rule MD038: No space inside code span markers
5///
6/// See [docs/md038.md](../../docs/md038.md) for full documentation, configuration, and examples.
7///
8/// MD038: Spaces inside code span elements
9///
10/// This rule is triggered when there are spaces inside code span elements.
11///
12/// For example:
13///
14/// ``` markdown
15/// ` some text`
16/// `some text `
17/// ` some text `
18/// ```
19///
20/// To fix this issue, remove the leading and trailing spaces within the code span markers:
21///
22/// ``` markdown
23/// `some text`
24/// ```
25///
26/// Note: Code spans containing backticks (e.g., `` `backticks` inside ``) are not flagged
27/// to avoid breaking nested backtick structures used to display backticks in documentation.
28#[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    /// Check if a code span is part of Hugo template syntax (e.g., {{raw `...`}})
39    ///
40    /// Hugo static site generator uses backticks as part of template delimiters,
41    /// not markdown code spans. This function detects common Hugo shortcode patterns:
42    /// - {{raw `...`}} - Raw HTML shortcode
43    /// - {{< `...` >}} - Partial shortcode
44    /// - {{% `...` %}} - Shortcode with percent delimiters
45    /// - {{ `...` }} - Generic shortcode
46    ///
47    /// The detection is conservative to avoid false positives:
48    /// - Requires opening {{ pattern before the backtick
49    /// - Requires closing }} after the code span
50    /// - Handles multi-line templates correctly
51    ///
52    /// Returns true if the code span is part of Hugo template syntax and should be skipped.
53    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        // start_col is 0-indexed character position
66        let span_start_col = code_span.start_col;
67
68        // Check if there's Hugo template syntax before the code span on the same line
69        // Pattern: {{raw ` or {{< ` or similar Hugo template patterns
70        // The code span starts at the backtick, so we need to check what's before it
71        // span_start_col is the position of the backtick (0-indexed character position)
72        // Minimum pattern is "{{ `" which has 3 characters before the backtick
73        if span_start_col >= 3 {
74            // Look backwards for Hugo template patterns
75            // Get the content up to (but not including) the backtick
76            let before_span: String = start_line_content.chars().take(span_start_col).collect();
77
78            // Check for Hugo template patterns: {{raw `, {{< `, {{% `, etc.
79            // The backtick is at span_start_col, so we check if the content before it
80            // ends with the Hugo pattern (without the backtick), and verify the next char is a backtick
81            let char_at_span_start = start_line_content.chars().nth(span_start_col).unwrap_or(' ');
82
83            // Match Hugo shortcode patterns:
84            // - {{raw ` - Raw HTML shortcode
85            // - {{< ` - Partial shortcode (may have parameters before backtick)
86            // - {{% ` - Shortcode with percent delimiters
87            // - {{ ` - Generic shortcode
88            // Also handle cases with parameters: {{< highlight go ` or {{< code ` etc.
89            // We check if the pattern starts with {{ and contains the shortcode type before the backtick
90            let is_hugo_start =
91                // Exact match: {{raw `
92                (before_span.ends_with("{{raw ") && char_at_span_start == '`')
93                // Partial shortcode: {{< ` or {{< name ` or {{< name param ` etc.
94                || (before_span.starts_with("{{<") && before_span.ends_with(' ') && char_at_span_start == '`')
95                // Percent shortcode: {{% `
96                || (before_span.ends_with("{{% ") && char_at_span_start == '`')
97                // Generic shortcode: {{ `
98                || (before_span.ends_with("{{ ") && char_at_span_start == '`');
99
100            if is_hugo_start {
101                // Check if there's a closing }} after the code span
102                // First check the end line of the code span
103                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                    // Check for closing }} on the same line as the end of the code span
110                    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                    // Also check the next line for closing }}
118                    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    /// Check if content is an Obsidian Dataview inline query
133    ///
134    /// Dataview plugin uses two inline query syntaxes:
135    /// - Inline DQL: `= expression` - Starts with "= "
136    /// - Inline DataviewJS: `$= expression` - Starts with "$= "
137    ///
138    /// Examples:
139    /// - `= this.file.name` - Get current file name
140    /// - `= date(today)` - Get today's date
141    /// - `= [[Page]].field` - Access field from another page
142    /// - `$= dv.current().file.mtime` - DataviewJS expression
143    /// - `$= dv.pages().length` - Count pages
144    ///
145    /// These patterns legitimately start with a space after = or $=,
146    /// so they should not trigger MD038.
147    fn is_dataview_expression(content: &str) -> bool {
148        // Inline DQL: starts with "= " (equals followed by space)
149        // Inline DataviewJS: starts with "$= " (dollar-equals followed by space)
150        content.starts_with("= ") || content.starts_with("$= ")
151    }
152
153    /// Check if a code span is likely part of a nested backtick structure
154    fn is_likely_nested_backticks(&self, ctx: &crate::lint_context::LintContext, span_index: usize) -> bool {
155        // If there are multiple code spans on the same line, and there's text
156        // between them that contains "code" or other indicators, it's likely nested
157        let code_spans = ctx.code_spans();
158        let current_span = &code_spans[span_index];
159        let current_line = current_span.line;
160
161        // Look for other code spans on the same line
162        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        // Check if there's content between spans that might indicate nesting
173        // Get the line content
174        let line_idx = current_line - 1; // Convert to 0-based
175        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 each pair of adjacent code spans, check what's between them
182        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                // Convert character positions to byte offsets for string slicing
188                let char_indices: Vec<(usize, char)> = line_content.char_indices().collect();
189                let start_byte = char_indices.get(start_char).map(|(i, _)| *i);
190                let end_byte = char_indices
191                    .get(end_char)
192                    .map(|(i, _)| *i)
193                    .unwrap_or(line_content.len());
194
195                if let Some(start_byte) = start_byte
196                    && start_byte < end_byte
197                    && end_byte <= line_content.len()
198                {
199                    let between = &line_content[start_byte..end_byte];
200                    // If there's text containing "code" or similar patterns between spans,
201                    // it's likely they're showing nested backticks
202                    if between.contains("code") || between.contains("backtick") {
203                        return true;
204                    }
205                }
206            }
207        }
208
209        false
210    }
211}
212
213impl Rule for MD038NoSpaceInCode {
214    fn name(&self) -> &'static str {
215        "MD038"
216    }
217
218    fn description(&self) -> &'static str {
219        "Spaces inside code span elements"
220    }
221
222    fn category(&self) -> RuleCategory {
223        RuleCategory::Other
224    }
225
226    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
227        if !self.enabled {
228            return Ok(vec![]);
229        }
230
231        let mut warnings = Vec::new();
232
233        // Use centralized code spans from LintContext
234        let code_spans = ctx.code_spans();
235        for (i, code_span) in code_spans.iter().enumerate() {
236            // Skip code spans that are inside fenced/indented code blocks
237            if let Some(line_info) = ctx.lines.get(code_span.line - 1) {
238                if line_info.in_code_block {
239                    continue;
240                }
241                // Skip multi-line code spans inside MkDocs containers where pulldown-cmark
242                // misinterprets indented fenced code block markers as code spans.
243                // Covers admonitions, tabs, HTML markdown blocks, and PyMdown blocks.
244                if (line_info.in_mkdocs_container() || line_info.in_pymdown_block) && code_span.content.contains('\n') {
245                    continue;
246                }
247            }
248
249            let code_content = &code_span.content;
250
251            // Skip empty code spans
252            if code_content.is_empty() {
253                continue;
254            }
255
256            // Early check: if no leading/trailing whitespace, skip
257            let has_leading_space = code_content.chars().next().is_some_and(|c| c.is_whitespace());
258            let has_trailing_space = code_content.chars().last().is_some_and(|c| c.is_whitespace());
259
260            if !has_leading_space && !has_trailing_space {
261                continue;
262            }
263
264            let trimmed = code_content.trim();
265
266            // Check if there are leading or trailing spaces
267            if code_content != trimmed {
268                // CommonMark behavior: if there is exactly ONE space at start AND ONE at end,
269                // and the content after trimming is non-empty, those spaces are stripped.
270                // We should NOT flag this case since the spaces are intentionally stripped.
271                // See: https://spec.commonmark.org/0.31.2/#code-spans
272                //
273                // Examples:
274                // ` text ` → "text" (spaces stripped, NOT flagged)
275                // `  text ` → " text" (extra leading space remains, FLAGGED)
276                // ` text  ` → "text " (extra trailing space remains, FLAGGED)
277                // ` text` → " text" (no trailing space to balance, FLAGGED)
278                // `text ` → "text " (no leading space to balance, FLAGGED)
279                if has_leading_space && has_trailing_space && !trimmed.is_empty() {
280                    let leading_spaces = code_content.len() - code_content.trim_start().len();
281                    let trailing_spaces = code_content.len() - code_content.trim_end().len();
282
283                    // Exactly one space on each side - CommonMark strips them
284                    if leading_spaces == 1 && trailing_spaces == 1 {
285                        continue;
286                    }
287                }
288                // Check if the content itself contains backticks - if so, skip to avoid
289                // breaking nested backtick structures
290                if trimmed.contains('`') {
291                    continue;
292                }
293
294                // Skip inline R code in Quarto/RMarkdown: `r expression`
295                // This is a legitimate pattern where space is required after 'r'
296                if ctx.flavor == crate::config::MarkdownFlavor::Quarto
297                    && trimmed.starts_with('r')
298                    && trimmed.len() > 1
299                    && trimmed.chars().nth(1).is_some_and(|c| c.is_whitespace())
300                {
301                    continue;
302                }
303
304                // Skip InlineHilite syntax in MkDocs: `#!python code`
305                // The space after the language specifier is legitimate
306                if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
307                    continue;
308                }
309
310                // Skip Dataview inline queries in Obsidian: `= expression` or `$= expression`
311                // Dataview plugin uses these patterns for inline DQL and DataviewJS queries.
312                // The space after = or $= is part of the syntax, not a spacing error.
313                if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && Self::is_dataview_expression(code_content) {
314                    continue;
315                }
316
317                // Check if this is part of Hugo template syntax (e.g., {{raw `...`}})
318                // Hugo uses backticks as part of template delimiters, not markdown code spans
319                if self.is_hugo_template_syntax(ctx, code_span) {
320                    continue;
321                }
322
323                // Check if this might be part of a nested backtick structure
324                // by looking for other code spans nearby that might indicate nesting
325                if self.is_likely_nested_backticks(ctx, i) {
326                    continue;
327                }
328
329                warnings.push(LintWarning {
330                    rule_name: Some(self.name().to_string()),
331                    line: code_span.line,
332                    column: code_span.start_col + 1, // Convert to 1-indexed
333                    end_line: code_span.line,
334                    end_column: code_span.end_col, // Don't add 1 to match test expectation
335                    message: "Spaces inside code span elements".to_string(),
336                    severity: Severity::Warning,
337                    fix: Some(Fix {
338                        range: code_span.byte_offset..code_span.byte_end,
339                        replacement: format!(
340                            "{}{}{}",
341                            "`".repeat(code_span.backtick_count),
342                            trimmed,
343                            "`".repeat(code_span.backtick_count)
344                        ),
345                    }),
346                });
347            }
348        }
349
350        Ok(warnings)
351    }
352
353    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
354        let content = ctx.content;
355        if !self.enabled {
356            return Ok(content.to_string());
357        }
358
359        // Early return if no backticks in content
360        if !content.contains('`') {
361            return Ok(content.to_string());
362        }
363
364        // Get warnings to identify what needs to be fixed
365        let warnings = self.check(ctx)?;
366        let warnings =
367            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
368        if warnings.is_empty() {
369            return Ok(content.to_string());
370        }
371
372        // Collect all fixes and sort by position (reverse order to avoid position shifts)
373        let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
374            .into_iter()
375            .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
376            .collect();
377
378        fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
379
380        // Apply fixes - only allocate string when we have fixes to apply
381        let mut result = content.to_string();
382        for (range, replacement) in fixes {
383            result.replace_range(range, &replacement);
384        }
385
386        Ok(result)
387    }
388
389    /// Check if content is likely to have code spans
390    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
391        !ctx.likely_has_code()
392    }
393
394    fn as_any(&self) -> &dyn std::any::Any {
395        self
396    }
397
398    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
399    where
400        Self: Sized,
401    {
402        Box::new(MD038NoSpaceInCode { enabled: true })
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn test_md038_readme_false_positives() {
412        // These are the exact cases from README.md that are incorrectly flagged
413        let rule = MD038NoSpaceInCode::new();
414        let valid_cases = vec![
415            "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
416            "#### Effective Configuration (`rumdl config`)",
417            "- Blue: `.rumdl.toml`",
418            "### Defaults Only (`rumdl config --defaults`)",
419        ];
420
421        for case in valid_cases {
422            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
423            let result = rule.check(&ctx).unwrap();
424            assert!(
425                result.is_empty(),
426                "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
427                case,
428                result.len()
429            );
430        }
431    }
432
433    #[test]
434    fn test_md038_valid() {
435        let rule = MD038NoSpaceInCode::new();
436        let valid_cases = vec![
437            "This is `code` in a sentence.",
438            "This is a `longer code span` in a sentence.",
439            "This is `code with internal spaces` which is fine.",
440            "Code span at `end of line`",
441            "`Start of line` code span",
442            "Multiple `code spans` in `one line` are fine",
443            "Code span with `symbols: !@#$%^&*()`",
444            "Empty code span `` is technically valid",
445        ];
446        for case in valid_cases {
447            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
448            let result = rule.check(&ctx).unwrap();
449            assert!(result.is_empty(), "Valid case should not have warnings: {case}");
450        }
451    }
452
453    #[test]
454    fn test_md038_invalid() {
455        let rule = MD038NoSpaceInCode::new();
456        // Flag cases that violate CommonMark:
457        // - Space only at start (no matching end space)
458        // - Space only at end (no matching start space)
459        // - Multiple spaces at start or end (extra space will remain after CommonMark stripping)
460        let invalid_cases = vec![
461            // Unbalanced: only leading space
462            "This is ` code` with leading space.",
463            // Unbalanced: only trailing space
464            "This is `code ` with trailing space.",
465            // Multiple leading spaces (one will remain after CommonMark strips one)
466            "This is `  code ` with double leading space.",
467            // Multiple trailing spaces (one will remain after CommonMark strips one)
468            "This is ` code  ` with double trailing space.",
469            // Multiple spaces both sides
470            "This is `  code  ` with double spaces both sides.",
471        ];
472        for case in invalid_cases {
473            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
474            let result = rule.check(&ctx).unwrap();
475            assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
476        }
477    }
478
479    #[test]
480    fn test_md038_valid_commonmark_stripping() {
481        let rule = MD038NoSpaceInCode::new();
482        // These cases have exactly ONE space at start AND ONE at end.
483        // CommonMark strips both, so these should NOT be flagged.
484        // See: https://spec.commonmark.org/0.31.2/#code-spans
485        let valid_cases = vec![
486            "Type ` y ` to confirm.",
487            "Use ` git commit -m \"message\" ` to commit.",
488            "The variable ` $HOME ` contains home path.",
489            "The pattern ` *.txt ` matches text files.",
490            "This is ` random word ` with unnecessary spaces.",
491            "Text with ` plain text ` is valid.",
492            "Code with ` just code ` here.",
493            "Multiple ` word ` spans with ` text ` in one line.",
494            "This is ` code ` with both leading and trailing single space.",
495            "Use ` - ` as separator.",
496        ];
497        for case in valid_cases {
498            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
499            let result = rule.check(&ctx).unwrap();
500            assert!(
501                result.is_empty(),
502                "Single space on each side should not be flagged (CommonMark strips them): {case}"
503            );
504        }
505    }
506
507    #[test]
508    fn test_md038_fix() {
509        let rule = MD038NoSpaceInCode::new();
510        // Only cases that violate CommonMark should be fixed
511        let test_cases = vec![
512            // Unbalanced: only leading space - should be fixed
513            (
514                "This is ` code` with leading space.",
515                "This is `code` with leading space.",
516            ),
517            // Unbalanced: only trailing space - should be fixed
518            (
519                "This is `code ` with trailing space.",
520                "This is `code` with trailing space.",
521            ),
522            // Single space on both sides - NOT fixed (valid per CommonMark)
523            (
524                "This is ` code ` with both spaces.",
525                "This is ` code ` with both spaces.", // unchanged
526            ),
527            // Double leading space - should be fixed
528            (
529                "This is `  code ` with double leading space.",
530                "This is `code` with double leading space.",
531            ),
532            // Mixed: one valid (single space both), one invalid (trailing only)
533            (
534                "Multiple ` code ` and `spans ` to fix.",
535                "Multiple ` code ` and `spans` to fix.", // only spans is fixed
536            ),
537        ];
538        for (input, expected) in test_cases {
539            let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
540            let result = rule.fix(&ctx).unwrap();
541            assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
542        }
543    }
544
545    #[test]
546    fn test_check_invalid_leading_space() {
547        let rule = MD038NoSpaceInCode::new();
548        let input = "This has a ` leading space` in code";
549        let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
550        let result = rule.check(&ctx).unwrap();
551        assert_eq!(result.len(), 1);
552        assert_eq!(result[0].line, 1);
553        assert!(result[0].fix.is_some());
554    }
555
556    #[test]
557    fn test_code_span_parsing_nested_backticks() {
558        let content = "Code with ` nested `code` example ` should preserve backticks";
559        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560
561        println!("Content: {content}");
562        println!("Code spans found:");
563        let code_spans = ctx.code_spans();
564        for (i, span) in code_spans.iter().enumerate() {
565            println!(
566                "  Span {}: line={}, col={}-{}, backticks={}, content='{}'",
567                i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
568            );
569        }
570
571        // This test reveals the issue - we're getting multiple separate code spans instead of one
572        assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
573    }
574
575    #[test]
576    fn test_nested_backtick_detection() {
577        let rule = MD038NoSpaceInCode::new();
578
579        // Test that code spans with backticks are skipped
580        let content = "Code with `` `backticks` inside `` should not be flagged";
581        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582        let result = rule.check(&ctx).unwrap();
583        assert!(result.is_empty(), "Code spans with backticks should be skipped");
584    }
585
586    #[test]
587    fn test_quarto_inline_r_code() {
588        // Test that Quarto-specific R code exception works
589        let rule = MD038NoSpaceInCode::new();
590
591        // Test inline R code - should NOT trigger warning in Quarto flavor
592        // The key pattern is "r " followed by code
593        let content = r#"The result is `r nchar("test")` which equals 4."#;
594
595        // Quarto flavor should allow R code
596        let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
597        let result_quarto = rule.check(&ctx_quarto).unwrap();
598        assert!(
599            result_quarto.is_empty(),
600            "Quarto inline R code should not trigger warnings. Got {} warnings",
601            result_quarto.len()
602        );
603
604        // Test that invalid code spans (not matching CommonMark stripping) still get flagged in Quarto
605        // Use only trailing space - this violates CommonMark (no balanced stripping)
606        let content_other = "This has `plain text ` with trailing space.";
607        let ctx_other =
608            crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
609        let result_other = rule.check(&ctx_other).unwrap();
610        assert_eq!(
611            result_other.len(),
612            1,
613            "Quarto should still flag non-R code spans with improper spaces"
614        );
615    }
616
617    /// Comprehensive tests for Hugo template syntax detection
618    ///
619    /// These tests ensure MD038 correctly handles Hugo template syntax patterns
620    /// without false positives, while maintaining correct detection of actual
621    /// code span spacing issues.
622    #[test]
623    fn test_hugo_template_syntax_comprehensive() {
624        let rule = MD038NoSpaceInCode::new();
625
626        // ===== VALID HUGO TEMPLATE SYNTAX (Should NOT trigger warnings) =====
627
628        // Basic Hugo shortcode patterns
629        let valid_hugo_cases = vec![
630            // Raw HTML shortcode
631            (
632                "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
633                "Multi-line raw shortcode",
634            ),
635            (
636                "Some text {{raw ` code `}} more text",
637                "Inline raw shortcode with spaces",
638            ),
639            ("{{raw `code`}}", "Raw shortcode without spaces"),
640            // Partial shortcode
641            ("{{< ` code ` >}}", "Partial shortcode with spaces"),
642            ("{{< `code` >}}", "Partial shortcode without spaces"),
643            // Shortcode with percent
644            ("{{% ` code ` %}}", "Percent shortcode with spaces"),
645            ("{{% `code` %}}", "Percent shortcode without spaces"),
646            // Generic shortcode
647            ("{{ ` code ` }}", "Generic shortcode with spaces"),
648            ("{{ `code` }}", "Generic shortcode without spaces"),
649            // Shortcodes with parameters (common Hugo pattern)
650            ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
651            ("{{< code `go list` >}}", "Shortcode with code parameter"),
652            // Multi-line Hugo templates
653            ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
654            ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
655            // Hugo templates with nested Go template syntax
656            (
657                "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
658                "Nested Go template syntax",
659            ),
660            // Edge case: Hugo template at start of line
661            ("{{raw `code`}}", "Hugo template at line start"),
662            // Edge case: Hugo template at end of line
663            ("Text {{raw `code`}}", "Hugo template at end of line"),
664            // Edge case: Multiple Hugo templates
665            ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
666        ];
667
668        for (case, description) in valid_hugo_cases {
669            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
670            let result = rule.check(&ctx).unwrap();
671            assert!(
672                result.is_empty(),
673                "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
674            );
675        }
676
677        // ===== FALSE POSITIVE PREVENTION (Non-Hugo asymmetric spaces should be flagged) =====
678
679        // These have asymmetric spaces (leading-only or trailing-only) and should be flagged
680        // Per CommonMark spec: symmetric single-space pairs are stripped and NOT flagged
681        let should_be_flagged = vec![
682            ("This is ` code` with leading space.", "Leading space only"),
683            ("This is `code ` with trailing space.", "Trailing space only"),
684            ("Text `  code ` here", "Extra leading space (asymmetric)"),
685            ("Text ` code  ` here", "Extra trailing space (asymmetric)"),
686            ("Text `  code` here", "Double leading, no trailing"),
687            ("Text `code  ` here", "No leading, double trailing"),
688        ];
689
690        for (case, description) in should_be_flagged {
691            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
692            let result = rule.check(&ctx).unwrap();
693            assert!(
694                !result.is_empty(),
695                "Should flag asymmetric space code spans: {description} - {case}"
696            );
697        }
698
699        // ===== COMMONMARK SYMMETRIC SPACE BEHAVIOR (Should NOT be flagged) =====
700
701        // Per CommonMark 0.31.2: When a code span has exactly one space at start AND end,
702        // those spaces are stripped from the output. This is intentional, not an error.
703        // These cases should NOT trigger MD038.
704        let symmetric_single_space = vec![
705            ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
706            ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
707            ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
708        ];
709
710        for (case, description) in symmetric_single_space {
711            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
712            let result = rule.check(&ctx).unwrap();
713            assert!(
714                result.is_empty(),
715                "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
716            );
717        }
718
719        // ===== EDGE CASES: Unicode and Special Characters =====
720
721        let unicode_cases = vec![
722            ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
723            ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
724            ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
725            (
726                "{{raw `\n\tcode with 'single quotes'\n`}}",
727                "Single quotes in Hugo template",
728            ),
729        ];
730
731        for (case, description) in unicode_cases {
732            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
733            let result = rule.check(&ctx).unwrap();
734            assert!(
735                result.is_empty(),
736                "Hugo templates with special characters should not trigger warnings: {description} - {case}"
737            );
738        }
739
740        // ===== BOUNDARY CONDITIONS =====
741
742        // Minimum valid Hugo pattern
743        assert!(
744            rule.check(&crate::lint_context::LintContext::new(
745                "{{ ` ` }}",
746                crate::config::MarkdownFlavor::Standard,
747                None
748            ))
749            .unwrap()
750            .is_empty(),
751            "Minimum Hugo pattern should be valid"
752        );
753
754        // Hugo template with only whitespace
755        assert!(
756            rule.check(&crate::lint_context::LintContext::new(
757                "{{raw `\n\t\n`}}",
758                crate::config::MarkdownFlavor::Standard,
759                None
760            ))
761            .unwrap()
762            .is_empty(),
763            "Hugo template with only whitespace should be valid"
764        );
765    }
766
767    /// Test interaction with other markdown elements
768    #[test]
769    fn test_hugo_template_with_other_markdown() {
770        let rule = MD038NoSpaceInCode::new();
771
772        // Hugo template inside a list
773        let content = r#"1. First item
7742. Second item with {{raw `code`}} template
7753. Third item"#;
776        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777        let result = rule.check(&ctx).unwrap();
778        assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
779
780        // Hugo template in blockquote
781        let content = r#"> Quote with {{raw `code`}} template"#;
782        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783        let result = rule.check(&ctx).unwrap();
784        assert!(
785            result.is_empty(),
786            "Hugo template in blockquote should not trigger warnings"
787        );
788
789        // Hugo template near regular code span (should flag the regular one)
790        let content = r#"{{raw `code`}} and ` bad code` here"#;
791        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
792        let result = rule.check(&ctx).unwrap();
793        assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
794    }
795
796    /// Performance test: Many Hugo templates
797    #[test]
798    fn test_hugo_template_performance() {
799        let rule = MD038NoSpaceInCode::new();
800
801        // Create content with many Hugo templates
802        let mut content = String::new();
803        for i in 0..100 {
804            content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
805        }
806
807        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
808        let start = std::time::Instant::now();
809        let result = rule.check(&ctx).unwrap();
810        let duration = start.elapsed();
811
812        assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
813        assert!(
814            duration.as_millis() < 1000,
815            "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
816        );
817    }
818
819    #[test]
820    fn test_mkdocs_inline_hilite_not_flagged() {
821        // InlineHilite syntax: `#!language code` should NOT be flagged
822        // The space after the language specifier is legitimate
823        let rule = MD038NoSpaceInCode::new();
824
825        let valid_cases = vec![
826            "`#!python print('hello')`",
827            "`#!js alert('hi')`",
828            "`#!c++ cout << x;`",
829            "Use `#!python import os` to import modules",
830            "`#!bash echo $HOME`",
831        ];
832
833        for case in valid_cases {
834            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
835            let result = rule.check(&ctx).unwrap();
836            assert!(
837                result.is_empty(),
838                "InlineHilite syntax should not be flagged in MkDocs: {case}"
839            );
840        }
841
842        // Test that InlineHilite IS flagged in Standard flavor (not MkDocs-aware)
843        let content = "`#!python print('hello')`";
844        let ctx_standard =
845            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846        let result_standard = rule.check(&ctx_standard).unwrap();
847        // In standard flavor, the content " print('hello')" has no special meaning
848        // But since "#!python print('hello')" doesn't have leading/trailing spaces, it's valid!
849        assert!(
850            result_standard.is_empty(),
851            "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
852        );
853    }
854
855    #[test]
856    fn test_multibyte_utf8_no_panic() {
857        // Regression test: ensure multi-byte UTF-8 characters don't cause panics
858        // when checking for nested backticks between code spans.
859        // These are real examples from the-art-of-command-line translations.
860        let rule = MD038NoSpaceInCode::new();
861
862        // Greek text with code spans
863        let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
864        let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
865        let result = rule.check(&ctx);
866        assert!(result.is_ok(), "Greek text should not panic");
867
868        // Chinese text with code spans
869        let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
870        let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
871        let result = rule.check(&ctx);
872        assert!(result.is_ok(), "Chinese text should not panic");
873
874        // Cyrillic/Ukrainian text with code spans
875        let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
876        let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
877        let result = rule.check(&ctx);
878        assert!(result.is_ok(), "Cyrillic text should not panic");
879
880        // Mixed multi-byte with multiple code spans on same line
881        let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
882        let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
883        let result = rule.check(&ctx);
884        assert!(
885            result.is_ok(),
886            "Mixed Chinese text with multiple code spans should not panic"
887        );
888    }
889
890    // ==================== Obsidian Dataview Plugin Tests ====================
891
892    /// Test that Dataview inline DQL expressions are not flagged in Obsidian flavor
893    #[test]
894    fn test_obsidian_dataview_inline_dql_not_flagged() {
895        let rule = MD038NoSpaceInCode::new();
896
897        // Basic inline DQL expressions - should NOT be flagged in Obsidian
898        let valid_dql_cases = vec![
899            "`= this.file.name`",
900            "`= date(today)`",
901            "`= [[Page]].field`",
902            "`= choice(condition, \"yes\", \"no\")`",
903            "`= this.file.mtime`",
904            "`= this.file.ctime`",
905            "`= this.file.path`",
906            "`= this.file.folder`",
907            "`= this.file.size`",
908            "`= this.file.ext`",
909            "`= this.file.link`",
910            "`= this.file.outlinks`",
911            "`= this.file.inlinks`",
912            "`= this.file.tags`",
913        ];
914
915        for case in valid_dql_cases {
916            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
917            let result = rule.check(&ctx).unwrap();
918            assert!(
919                result.is_empty(),
920                "Dataview DQL expression should not be flagged in Obsidian: {case}"
921            );
922        }
923    }
924
925    /// Test that Dataview inline DataviewJS expressions are not flagged in Obsidian flavor
926    #[test]
927    fn test_obsidian_dataview_inline_dvjs_not_flagged() {
928        let rule = MD038NoSpaceInCode::new();
929
930        // Inline DataviewJS expressions - should NOT be flagged in Obsidian
931        let valid_dvjs_cases = vec![
932            "`$= dv.current().file.mtime`",
933            "`$= dv.pages().length`",
934            "`$= dv.current()`",
935            "`$= dv.pages('#tag').length`",
936            "`$= dv.pages('\"folder\"').length`",
937            "`$= dv.current().file.name`",
938            "`$= dv.current().file.path`",
939            "`$= dv.current().file.folder`",
940            "`$= dv.current().file.link`",
941        ];
942
943        for case in valid_dvjs_cases {
944            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
945            let result = rule.check(&ctx).unwrap();
946            assert!(
947                result.is_empty(),
948                "Dataview JS expression should not be flagged in Obsidian: {case}"
949            );
950        }
951    }
952
953    /// Test complex Dataview expressions with nested parentheses
954    #[test]
955    fn test_obsidian_dataview_complex_expressions() {
956        let rule = MD038NoSpaceInCode::new();
957
958        let complex_cases = vec![
959            // Nested function calls
960            "`= sum(filter(pages, (p) => p.done))`",
961            "`= length(filter(file.tags, (t) => startswith(t, \"project\")))`",
962            // choice() function
963            "`= choice(x > 5, \"big\", \"small\")`",
964            "`= choice(this.status = \"done\", \"✅\", \"⏳\")`",
965            // date functions
966            "`= date(today) - dur(7 days)`",
967            "`= dateformat(this.file.mtime, \"yyyy-MM-dd\")`",
968            // Math expressions
969            "`= sum(rows.amount)`",
970            "`= round(average(rows.score), 2)`",
971            "`= min(rows.priority)`",
972            "`= max(rows.priority)`",
973            // String operations
974            "`= join(this.file.tags, \", \")`",
975            "`= replace(this.title, \"-\", \" \")`",
976            "`= lower(this.file.name)`",
977            "`= upper(this.file.name)`",
978            // List operations
979            "`= length(this.file.outlinks)`",
980            "`= contains(this.file.tags, \"important\")`",
981            // Link references
982            "`= [[Page Name]].field`",
983            "`= [[Folder/Subfolder/Page]].nested.field`",
984            // Conditional expressions
985            "`= default(this.status, \"unknown\")`",
986            "`= coalesce(this.priority, this.importance, 0)`",
987        ];
988
989        for case in complex_cases {
990            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
991            let result = rule.check(&ctx).unwrap();
992            assert!(
993                result.is_empty(),
994                "Complex Dataview expression should not be flagged in Obsidian: {case}"
995            );
996        }
997    }
998
999    /// Test that complex DataviewJS expressions with method chains are not flagged
1000    #[test]
1001    fn test_obsidian_dataviewjs_method_chains() {
1002        let rule = MD038NoSpaceInCode::new();
1003
1004        let method_chain_cases = vec![
1005            "`$= dv.pages().where(p => p.status).length`",
1006            "`$= dv.pages('#project').where(p => !p.done).length`",
1007            "`$= dv.pages().filter(p => p.file.day).sort(p => p.file.mtime, 'desc').limit(5)`",
1008            "`$= dv.pages('\"folder\"').map(p => p.file.link).join(', ')`",
1009            "`$= dv.current().file.tasks.where(t => !t.completed).length`",
1010            "`$= dv.pages().flatMap(p => p.file.tags).distinct().sort()`",
1011            "`$= dv.page('Index').children.map(p => p.title)`",
1012            "`$= dv.pages().groupBy(p => p.status).map(g => [g.key, g.rows.length])`",
1013        ];
1014
1015        for case in method_chain_cases {
1016            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1017            let result = rule.check(&ctx).unwrap();
1018            assert!(
1019                result.is_empty(),
1020                "DataviewJS method chain should not be flagged in Obsidian: {case}"
1021            );
1022        }
1023    }
1024
1025    /// Test Dataview-like patterns in Standard flavor
1026    ///
1027    /// Note: The actual content `= this.file.name` starts with `=`, not whitespace,
1028    /// so it doesn't have a leading space issue. Dataview expressions only become
1029    /// relevant when their content would otherwise be flagged.
1030    ///
1031    /// To properly test the difference, we need patterns that have leading whitespace
1032    /// issues that would be skipped in Obsidian but flagged in Standard.
1033    #[test]
1034    fn test_standard_flavor_vs_obsidian_dataview() {
1035        let rule = MD038NoSpaceInCode::new();
1036
1037        // These Dataview expressions don't have leading whitespace (they start with "=")
1038        // so they wouldn't be flagged in ANY flavor
1039        let no_issue_cases = vec!["`= this.file.name`", "`$= dv.current()`"];
1040
1041        for case in no_issue_cases {
1042            // Standard flavor - no issue because content doesn't start with whitespace
1043            let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1044            let result_std = rule.check(&ctx_std).unwrap();
1045            assert!(
1046                result_std.is_empty(),
1047                "Dataview expression without leading space shouldn't be flagged in Standard: {case}"
1048            );
1049
1050            // Obsidian flavor - also no issue
1051            let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1052            let result_obs = rule.check(&ctx_obs).unwrap();
1053            assert!(
1054                result_obs.is_empty(),
1055                "Dataview expression shouldn't be flagged in Obsidian: {case}"
1056            );
1057        }
1058
1059        // Test that regular code with leading/trailing spaces is still flagged in both flavors
1060        // (when not matching Dataview pattern)
1061        let space_issues = vec![
1062            "` code`", // Leading space, no trailing
1063            "`code `", // Trailing space, no leading
1064        ];
1065
1066        for case in space_issues {
1067            // Standard flavor - should be flagged
1068            let ctx_std = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1069            let result_std = rule.check(&ctx_std).unwrap();
1070            assert!(
1071                !result_std.is_empty(),
1072                "Code with spacing issue should be flagged in Standard: {case}"
1073            );
1074
1075            // Obsidian flavor - should also be flagged (not a Dataview pattern)
1076            let ctx_obs = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1077            let result_obs = rule.check(&ctx_obs).unwrap();
1078            assert!(
1079                !result_obs.is_empty(),
1080                "Code with spacing issue should be flagged in Obsidian (not Dataview): {case}"
1081            );
1082        }
1083    }
1084
1085    /// Test that regular code spans with leading space are still flagged in Obsidian
1086    #[test]
1087    fn test_obsidian_still_flags_regular_code_spans_with_space() {
1088        let rule = MD038NoSpaceInCode::new();
1089
1090        // These are NOT Dataview expressions, just regular code spans with leading space
1091        // They should still be flagged even in Obsidian flavor
1092        let invalid_cases = [
1093            "` regular code`", // Space at start, not Dataview
1094            "`code `",         // Space at end
1095            "` code `",        // This is valid per CommonMark (symmetric single space)
1096            "`  code`",        // Double space at start (not Dataview pattern)
1097        ];
1098
1099        // Only the asymmetric cases should be flagged
1100        let expected_flags = [
1101            true,  // ` regular code` - leading space, no trailing
1102            true,  // `code ` - trailing space, no leading
1103            false, // ` code ` - symmetric single space (CommonMark valid)
1104            true,  // `  code` - double leading space
1105        ];
1106
1107        for (case, should_flag) in invalid_cases.iter().zip(expected_flags.iter()) {
1108            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1109            let result = rule.check(&ctx).unwrap();
1110            if *should_flag {
1111                assert!(
1112                    !result.is_empty(),
1113                    "Non-Dataview code span with spacing issue should be flagged in Obsidian: {case}"
1114                );
1115            } else {
1116                assert!(
1117                    result.is_empty(),
1118                    "CommonMark-valid symmetric spacing should not be flagged: {case}"
1119                );
1120            }
1121        }
1122    }
1123
1124    /// Test edge cases for Dataview pattern detection
1125    #[test]
1126    fn test_obsidian_dataview_edge_cases() {
1127        let rule = MD038NoSpaceInCode::new();
1128
1129        // Valid Dataview patterns
1130        let valid_cases = vec![
1131            ("`= x`", true),                         // Minimal DQL
1132            ("`$= x`", true),                        // Minimal DVJS
1133            ("`= `", true),                          // Just equals-space (empty expression)
1134            ("`$= `", true),                         // Just dollar-equals-space (empty expression)
1135            ("`=x`", false),                         // No space after = (not Dataview, and no leading whitespace issue)
1136            ("`$=x`", false),       // No space after $= (not Dataview, and no leading whitespace issue)
1137            ("`= [[Link]]`", true), // Link in expression
1138            ("`= this`", true),     // Simple this reference
1139            ("`$= dv`", true),      // Just dv object reference
1140            ("`= 1 + 2`", true),    // Math expression
1141            ("`$= 1 + 2`", true),   // Math in DVJS
1142            ("`= \"string\"`", true), // String literal
1143            ("`$= 'string'`", true), // Single-quoted string
1144            ("`= this.field ?? \"default\"`", true), // Null coalescing
1145            ("`$= dv?.pages()`", true), // Optional chaining
1146        ];
1147
1148        for (case, should_be_valid) in valid_cases {
1149            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1150            let result = rule.check(&ctx).unwrap();
1151            if should_be_valid {
1152                assert!(
1153                    result.is_empty(),
1154                    "Valid Dataview expression should not be flagged: {case}"
1155                );
1156            } else {
1157                // These might or might not be flagged depending on other MD038 rules
1158                // We just verify they don't crash
1159                let _ = result;
1160            }
1161        }
1162    }
1163
1164    /// Test Dataview expressions in context (mixed with regular markdown)
1165    #[test]
1166    fn test_obsidian_dataview_in_context() {
1167        let rule = MD038NoSpaceInCode::new();
1168
1169        // Document with mixed Dataview and regular code spans
1170        let content = r#"# My Note
1171
1172The file name is `= this.file.name` and it was created on `= this.file.ctime`.
1173
1174Regular code: `println!("hello")` and `let x = 5;`
1175
1176DataviewJS count: `$= dv.pages('#project').length` projects found.
1177
1178More regular code with issue: ` bad code` should be flagged.
1179"#;
1180
1181        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1182        let result = rule.check(&ctx).unwrap();
1183
1184        // Should only flag ` bad code` (line 9)
1185        assert_eq!(
1186            result.len(),
1187            1,
1188            "Should only flag the regular code span with leading space, not Dataview expressions"
1189        );
1190        assert_eq!(result[0].line, 9, "Warning should be on line 9");
1191    }
1192
1193    /// Test that Dataview expressions in code blocks are properly handled
1194    #[test]
1195    fn test_obsidian_dataview_in_code_blocks() {
1196        let rule = MD038NoSpaceInCode::new();
1197
1198        // Dataview expressions inside fenced code blocks should be ignored
1199        // (because they're inside code blocks, not because of Dataview logic)
1200        let content = r#"# Example
1201
1202```
1203`= this.file.name`
1204`$= dv.current()`
1205```
1206
1207Regular paragraph with `= this.file.name` Dataview.
1208"#;
1209
1210        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1211        let result = rule.check(&ctx).unwrap();
1212
1213        // Should not flag anything - code blocks are skipped, and inline Dataview is valid
1214        assert!(
1215            result.is_empty(),
1216            "Dataview in code blocks should be ignored, inline Dataview should be valid"
1217        );
1218    }
1219
1220    /// Test Dataview with Unicode content
1221    #[test]
1222    fn test_obsidian_dataview_unicode() {
1223        let rule = MD038NoSpaceInCode::new();
1224
1225        let unicode_cases = vec![
1226            "`= this.日本語`",                  // Japanese field name
1227            "`= this.中文字段`",                // Chinese field name
1228            "`= \"Привет мир\"`",               // Russian string
1229            "`$= dv.pages('#日本語タグ')`",     // Japanese tag
1230            "`= choice(true, \"✅\", \"❌\")`", // Emoji in strings
1231            "`= this.file.name + \" 📝\"`",     // Emoji concatenation
1232        ];
1233
1234        for case in unicode_cases {
1235            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1236            let result = rule.check(&ctx).unwrap();
1237            assert!(
1238                result.is_empty(),
1239                "Unicode Dataview expression should not be flagged: {case}"
1240            );
1241        }
1242    }
1243
1244    /// Test that Dataview detection doesn't break regular equals patterns
1245    #[test]
1246    fn test_obsidian_regular_equals_still_works() {
1247        let rule = MD038NoSpaceInCode::new();
1248
1249        // Regular code with equals signs should still work normally
1250        let valid_regular_cases = vec![
1251            "`x = 5`",       // Assignment (no leading space)
1252            "`a == b`",      // Equality check
1253            "`x >= 10`",     // Comparison
1254            "`let x = 10`",  // Variable declaration
1255            "`const y = 5`", // Const declaration
1256        ];
1257
1258        for case in valid_regular_cases {
1259            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Obsidian, None);
1260            let result = rule.check(&ctx).unwrap();
1261            assert!(
1262                result.is_empty(),
1263                "Regular code with equals should not be flagged: {case}"
1264            );
1265        }
1266    }
1267
1268    /// Test fix behavior doesn't break Dataview expressions
1269    #[test]
1270    fn test_obsidian_dataview_fix_preserves_expressions() {
1271        let rule = MD038NoSpaceInCode::new();
1272
1273        // Content with Dataview expressions and one fixable issue
1274        let content = "Dataview: `= this.file.name` and bad: ` fixme`";
1275        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1276        let fixed = rule.fix(&ctx).unwrap();
1277
1278        // Should fix ` fixme` but preserve `= this.file.name`
1279        assert!(
1280            fixed.contains("`= this.file.name`"),
1281            "Dataview expression should be preserved after fix"
1282        );
1283        assert!(
1284            fixed.contains("`fixme`"),
1285            "Regular code span should be fixed (space removed)"
1286        );
1287        assert!(!fixed.contains("` fixme`"), "Bad code span should have been fixed");
1288    }
1289
1290    /// Test multiple Dataview expressions on same line
1291    #[test]
1292    fn test_obsidian_multiple_dataview_same_line() {
1293        let rule = MD038NoSpaceInCode::new();
1294
1295        let content = "Created: `= this.file.ctime` | Modified: `= this.file.mtime` | Count: `$= dv.pages().length`";
1296        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1297        let result = rule.check(&ctx).unwrap();
1298
1299        assert!(
1300            result.is_empty(),
1301            "Multiple Dataview expressions on same line should all be valid"
1302        );
1303    }
1304
1305    /// Performance test: Many Dataview expressions
1306    #[test]
1307    fn test_obsidian_dataview_performance() {
1308        let rule = MD038NoSpaceInCode::new();
1309
1310        // Create content with many Dataview expressions
1311        let mut content = String::new();
1312        for i in 0..100 {
1313            content.push_str(&format!("Field {i}: `= this.field{i}` | JS: `$= dv.current().f{i}`\n"));
1314        }
1315
1316        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Obsidian, None);
1317        let start = std::time::Instant::now();
1318        let result = rule.check(&ctx).unwrap();
1319        let duration = start.elapsed();
1320
1321        assert!(result.is_empty(), "All Dataview expressions should be valid");
1322        assert!(
1323            duration.as_millis() < 1000,
1324            "Performance test: Should process 200 Dataview expressions in <1s, took {duration:?}"
1325        );
1326    }
1327
1328    /// Test is_dataview_expression helper function directly
1329    #[test]
1330    fn test_is_dataview_expression_helper() {
1331        // Valid Dataview patterns
1332        assert!(MD038NoSpaceInCode::is_dataview_expression("= this.file.name"));
1333        assert!(MD038NoSpaceInCode::is_dataview_expression("= "));
1334        assert!(MD038NoSpaceInCode::is_dataview_expression("$= dv.current()"));
1335        assert!(MD038NoSpaceInCode::is_dataview_expression("$= "));
1336        assert!(MD038NoSpaceInCode::is_dataview_expression("= x"));
1337        assert!(MD038NoSpaceInCode::is_dataview_expression("$= x"));
1338
1339        // Invalid Dataview patterns
1340        assert!(!MD038NoSpaceInCode::is_dataview_expression("=")); // No space after =
1341        assert!(!MD038NoSpaceInCode::is_dataview_expression("$=")); // No space after $=
1342        assert!(!MD038NoSpaceInCode::is_dataview_expression("=x")); // No space
1343        assert!(!MD038NoSpaceInCode::is_dataview_expression("$=x")); // No space
1344        assert!(!MD038NoSpaceInCode::is_dataview_expression(" = x")); // Leading space before =
1345        assert!(!MD038NoSpaceInCode::is_dataview_expression("x = 5")); // Assignment, not Dataview
1346        assert!(!MD038NoSpaceInCode::is_dataview_expression("== x")); // Double equals
1347        assert!(!MD038NoSpaceInCode::is_dataview_expression("")); // Empty
1348        assert!(!MD038NoSpaceInCode::is_dataview_expression("regular")); // Regular text
1349    }
1350
1351    /// Test Dataview expressions work alongside other Obsidian features (tags)
1352    #[test]
1353    fn test_obsidian_dataview_with_tags() {
1354        let rule = MD038NoSpaceInCode::new();
1355
1356        // Document using both Dataview and Obsidian tags
1357        let content = r#"# Project Status
1358
1359Tags: #project #active
1360
1361Status: `= this.status`
1362Count: `$= dv.pages('#project').length`
1363
1364Regular code: `function test() {}`
1365"#;
1366
1367        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1368        let result = rule.check(&ctx).unwrap();
1369
1370        // Nothing should be flagged
1371        assert!(
1372            result.is_empty(),
1373            "Dataview expressions and regular code should work together"
1374        );
1375    }
1376
1377    #[test]
1378    fn test_unicode_between_code_spans_no_panic() {
1379        // Verify that multi-byte characters between code spans do not cause panics
1380        // or incorrect slicing in the nested-backtick detection logic.
1381        let rule = MD038NoSpaceInCode::new();
1382
1383        // Multi-byte character (U-umlaut = 2 bytes) between two code spans
1384        let content = "Use `one` \u{00DC}nited `two` for backtick examples.";
1385        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386        let result = rule.check(&ctx);
1387        // Should not panic; any warnings or lack thereof are acceptable
1388        assert!(result.is_ok(), "Should not panic with Unicode between code spans");
1389
1390        // CJK characters (3 bytes each) between code spans
1391        let content_cjk = "Use `one` \u{4E16}\u{754C} `two` for examples.";
1392        let ctx_cjk = crate::lint_context::LintContext::new(content_cjk, crate::config::MarkdownFlavor::Standard, None);
1393        let result_cjk = rule.check(&ctx_cjk);
1394        assert!(result_cjk.is_ok(), "Should not panic with CJK between code spans");
1395    }
1396}