Skip to main content

rumdl_lib/rules/
md038_no_space_in_code.rs

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