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