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