Skip to main content

rumdl_lib/rules/
md038_no_space_in_code.rs

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