Skip to main content

rumdl_lib/rules/
md038_no_space_in_code.rs

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