rumdl_lib/rules/
md038_no_space_in_code.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mkdocs_extensions::is_inline_hilite_content;
3
4/// Rule MD038: No space inside code span markers
5///
6/// See [docs/md038.md](../../docs/md038.md) for full documentation, configuration, and examples.
7///
8/// MD038: Spaces inside code span elements
9///
10/// This rule is triggered when there are spaces inside code span elements.
11///
12/// For example:
13///
14/// ``` markdown
15/// ` some text`
16/// `some text `
17/// ` some text `
18/// ```
19///
20/// To fix this issue, remove the leading and trailing spaces within the code span markers:
21///
22/// ``` markdown
23/// `some text`
24/// ```
25///
26/// Note: Code spans containing backticks (e.g., `` `backticks` inside ``) are not flagged
27/// to avoid breaking nested backtick structures used to display backticks in documentation.
28#[derive(Debug, Clone, Default)]
29pub struct MD038NoSpaceInCode {
30    pub enabled: bool,
31}
32
33impl MD038NoSpaceInCode {
34    pub fn new() -> Self {
35        Self { enabled: true }
36    }
37
38    /// Check if a code span is part of Hugo template syntax (e.g., {{raw `...`}})
39    ///
40    /// Hugo static site generator uses backticks as part of template delimiters,
41    /// not markdown code spans. This function detects common Hugo shortcode patterns:
42    /// - {{raw `...`}} - Raw HTML shortcode
43    /// - {{< `...` >}} - Partial shortcode
44    /// - {{% `...` %}} - Shortcode with percent delimiters
45    /// - {{ `...` }} - Generic shortcode
46    ///
47    /// The detection is conservative to avoid false positives:
48    /// - Requires opening {{ pattern before the backtick
49    /// - Requires closing }} after the code span
50    /// - Handles multi-line templates correctly
51    ///
52    /// Returns true if the code span is part of Hugo template syntax and should be skipped.
53    fn is_hugo_template_syntax(
54        &self,
55        ctx: &crate::lint_context::LintContext,
56        code_span: &crate::lint_context::CodeSpan,
57    ) -> bool {
58        let start_line_idx = code_span.line.saturating_sub(1);
59        if start_line_idx >= ctx.lines.len() {
60            return false;
61        }
62
63        let start_line_content = ctx.lines[start_line_idx].content(ctx.content);
64
65        // start_col is 0-indexed character position
66        let span_start_col = code_span.start_col;
67
68        // Check if there's Hugo template syntax before the code span on the same line
69        // Pattern: {{raw ` or {{< ` or similar Hugo template patterns
70        // The code span starts at the backtick, so we need to check what's before it
71        // span_start_col is the position of the backtick (0-indexed character position)
72        // Minimum pattern is "{{ `" which has 3 characters before the backtick
73        if span_start_col >= 3 {
74            // Look backwards for Hugo template patterns
75            // Get the content up to (but not including) the backtick
76            let before_span: String = start_line_content.chars().take(span_start_col).collect();
77
78            // Check for Hugo template patterns: {{raw `, {{< `, {{% `, etc.
79            // The backtick is at span_start_col, so we check if the content before it
80            // ends with the Hugo pattern (without the backtick), and verify the next char is a backtick
81            let char_at_span_start = start_line_content.chars().nth(span_start_col).unwrap_or(' ');
82
83            // Match Hugo shortcode patterns:
84            // - {{raw ` - Raw HTML shortcode
85            // - {{< ` - Partial shortcode (may have parameters before backtick)
86            // - {{% ` - Shortcode with percent delimiters
87            // - {{ ` - Generic shortcode
88            // Also handle cases with parameters: {{< highlight go ` or {{< code ` etc.
89            // We check if the pattern starts with {{ and contains the shortcode type before the backtick
90            let is_hugo_start =
91                // Exact match: {{raw `
92                (before_span.ends_with("{{raw ") && char_at_span_start == '`')
93                // Partial shortcode: {{< ` or {{< name ` or {{< name param ` etc.
94                || (before_span.starts_with("{{<") && before_span.ends_with(' ') && char_at_span_start == '`')
95                // Percent shortcode: {{% `
96                || (before_span.ends_with("{{% ") && char_at_span_start == '`')
97                // Generic shortcode: {{ `
98                || (before_span.ends_with("{{ ") && char_at_span_start == '`');
99
100            if is_hugo_start {
101                // Check if there's a closing }} after the code span
102                // First check the end line of the code span
103                let end_line_idx = code_span.end_line.saturating_sub(1);
104                if end_line_idx < ctx.lines.len() {
105                    let end_line_content = ctx.lines[end_line_idx].content(ctx.content);
106                    let end_line_char_count = end_line_content.chars().count();
107                    let span_end_col = code_span.end_col.min(end_line_char_count);
108
109                    // Check for closing }} on the same line as the end of the code span
110                    if span_end_col < end_line_char_count {
111                        let after_span: String = end_line_content.chars().skip(span_end_col).collect();
112                        if after_span.trim_start().starts_with("}}") {
113                            return true;
114                        }
115                    }
116
117                    // Also check the next line for closing }}
118                    let next_line_idx = code_span.end_line;
119                    if next_line_idx < ctx.lines.len() {
120                        let next_line = ctx.lines[next_line_idx].content(ctx.content);
121                        if next_line.trim_start().starts_with("}}") {
122                            return true;
123                        }
124                    }
125                }
126            }
127        }
128
129        false
130    }
131
132    /// Check if a code span is likely part of a nested backtick structure
133    fn is_likely_nested_backticks(&self, ctx: &crate::lint_context::LintContext, span_index: usize) -> bool {
134        // If there are multiple code spans on the same line, and there's text
135        // between them that contains "code" or other indicators, it's likely nested
136        let code_spans = ctx.code_spans();
137        let current_span = &code_spans[span_index];
138        let current_line = current_span.line;
139
140        // Look for other code spans on the same line
141        let same_line_spans: Vec<_> = code_spans
142            .iter()
143            .enumerate()
144            .filter(|(i, s)| s.line == current_line && *i != span_index)
145            .collect();
146
147        if same_line_spans.is_empty() {
148            return false;
149        }
150
151        // Check if there's content between spans that might indicate nesting
152        // Get the line content
153        let line_idx = current_line - 1; // Convert to 0-based
154        if line_idx >= ctx.lines.len() {
155            return false;
156        }
157
158        let line_content = &ctx.lines[line_idx].content(ctx.content);
159
160        // For each pair of adjacent code spans, check what's between them
161        for (_, other_span) in &same_line_spans {
162            let start = current_span.end_col.min(other_span.end_col);
163            let end = current_span.start_col.max(other_span.start_col);
164
165            if start < end && end <= line_content.len() {
166                // Use .get() to safely handle multi-byte UTF-8 characters
167                if let Some(between) = line_content.get(start..end) {
168                    // If there's text containing "code" or similar patterns between spans,
169                    // it's likely they're showing nested backticks
170                    if between.contains("code") || between.contains("backtick") {
171                        return true;
172                    }
173                }
174            }
175        }
176
177        false
178    }
179}
180
181impl Rule for MD038NoSpaceInCode {
182    fn name(&self) -> &'static str {
183        "MD038"
184    }
185
186    fn description(&self) -> &'static str {
187        "Spaces inside code span elements"
188    }
189
190    fn category(&self) -> RuleCategory {
191        RuleCategory::Other
192    }
193
194    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
195        if !self.enabled {
196            return Ok(vec![]);
197        }
198
199        let mut warnings = Vec::new();
200
201        // Use centralized code spans from LintContext
202        let code_spans = ctx.code_spans();
203        for (i, code_span) in code_spans.iter().enumerate() {
204            let code_content = &code_span.content;
205
206            // Skip empty code spans
207            if code_content.is_empty() {
208                continue;
209            }
210
211            // Early check: if no leading/trailing whitespace, skip
212            let has_leading_space = code_content.chars().next().is_some_and(|c| c.is_whitespace());
213            let has_trailing_space = code_content.chars().last().is_some_and(|c| c.is_whitespace());
214
215            if !has_leading_space && !has_trailing_space {
216                continue;
217            }
218
219            let trimmed = code_content.trim();
220
221            // Check if there are leading or trailing spaces
222            if code_content != trimmed {
223                // CommonMark behavior: if there is exactly ONE space at start AND ONE at end,
224                // and the content after trimming is non-empty, those spaces are stripped.
225                // We should NOT flag this case since the spaces are intentionally stripped.
226                // See: https://spec.commonmark.org/0.31.2/#code-spans
227                //
228                // Examples:
229                // ` text ` → "text" (spaces stripped, NOT flagged)
230                // `  text ` → " text" (extra leading space remains, FLAGGED)
231                // ` text  ` → "text " (extra trailing space remains, FLAGGED)
232                // ` text` → " text" (no trailing space to balance, FLAGGED)
233                // `text ` → "text " (no leading space to balance, FLAGGED)
234                if has_leading_space && has_trailing_space && !trimmed.is_empty() {
235                    let leading_spaces = code_content.len() - code_content.trim_start().len();
236                    let trailing_spaces = code_content.len() - code_content.trim_end().len();
237
238                    // Exactly one space on each side - CommonMark strips them
239                    if leading_spaces == 1 && trailing_spaces == 1 {
240                        continue;
241                    }
242                }
243                // Check if the content itself contains backticks - if so, skip to avoid
244                // breaking nested backtick structures
245                if trimmed.contains('`') {
246                    continue;
247                }
248
249                // Skip inline R code in Quarto/RMarkdown: `r expression`
250                // This is a legitimate pattern where space is required after 'r'
251                if ctx.flavor == crate::config::MarkdownFlavor::Quarto
252                    && trimmed.starts_with('r')
253                    && trimmed.len() > 1
254                    && trimmed.chars().nth(1).is_some_and(|c| c.is_whitespace())
255                {
256                    continue;
257                }
258
259                // Skip InlineHilite syntax in MkDocs: `#!python code`
260                // The space after the language specifier is legitimate
261                if ctx.flavor == crate::config::MarkdownFlavor::MkDocs && is_inline_hilite_content(trimmed) {
262                    continue;
263                }
264
265                // Check if this is part of Hugo template syntax (e.g., {{raw `...`}})
266                // Hugo uses backticks as part of template delimiters, not markdown code spans
267                if self.is_hugo_template_syntax(ctx, code_span) {
268                    continue;
269                }
270
271                // Check if this might be part of a nested backtick structure
272                // by looking for other code spans nearby that might indicate nesting
273                if self.is_likely_nested_backticks(ctx, i) {
274                    continue;
275                }
276
277                warnings.push(LintWarning {
278                    rule_name: Some(self.name().to_string()),
279                    line: code_span.line,
280                    column: code_span.start_col + 1, // Convert to 1-indexed
281                    end_line: code_span.line,
282                    end_column: code_span.end_col, // Don't add 1 to match test expectation
283                    message: "Spaces inside code span elements".to_string(),
284                    severity: Severity::Warning,
285                    fix: Some(Fix {
286                        range: code_span.byte_offset..code_span.byte_end,
287                        replacement: format!(
288                            "{}{}{}",
289                            "`".repeat(code_span.backtick_count),
290                            trimmed,
291                            "`".repeat(code_span.backtick_count)
292                        ),
293                    }),
294                });
295            }
296        }
297
298        Ok(warnings)
299    }
300
301    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
302        let content = ctx.content;
303        if !self.enabled {
304            return Ok(content.to_string());
305        }
306
307        // Early return if no backticks in content
308        if !content.contains('`') {
309            return Ok(content.to_string());
310        }
311
312        // Get warnings to identify what needs to be fixed
313        let warnings = self.check(ctx)?;
314        if warnings.is_empty() {
315            return Ok(content.to_string());
316        }
317
318        // Collect all fixes and sort by position (reverse order to avoid position shifts)
319        let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
320            .into_iter()
321            .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
322            .collect();
323
324        fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
325
326        // Apply fixes - only allocate string when we have fixes to apply
327        let mut result = content.to_string();
328        for (range, replacement) in fixes {
329            result.replace_range(range, &replacement);
330        }
331
332        Ok(result)
333    }
334
335    /// Check if content is likely to have code spans
336    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
337        !ctx.likely_has_code()
338    }
339
340    fn as_any(&self) -> &dyn std::any::Any {
341        self
342    }
343
344    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
345    where
346        Self: Sized,
347    {
348        Box::new(MD038NoSpaceInCode { enabled: true })
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_md038_readme_false_positives() {
358        // These are the exact cases from README.md that are incorrectly flagged
359        let rule = MD038NoSpaceInCode::new();
360        let valid_cases = vec![
361            "3. `pyproject.toml` (must contain `[tool.rumdl]` section)",
362            "#### Effective Configuration (`rumdl config`)",
363            "- Blue: `.rumdl.toml`",
364            "### Defaults Only (`rumdl config --defaults`)",
365        ];
366
367        for case in valid_cases {
368            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
369            let result = rule.check(&ctx).unwrap();
370            assert!(
371                result.is_empty(),
372                "Should not flag code spans without leading/trailing spaces: '{}'. Got {} warnings",
373                case,
374                result.len()
375            );
376        }
377    }
378
379    #[test]
380    fn test_md038_valid() {
381        let rule = MD038NoSpaceInCode::new();
382        let valid_cases = vec![
383            "This is `code` in a sentence.",
384            "This is a `longer code span` in a sentence.",
385            "This is `code with internal spaces` which is fine.",
386            "Code span at `end of line`",
387            "`Start of line` code span",
388            "Multiple `code spans` in `one line` are fine",
389            "Code span with `symbols: !@#$%^&*()`",
390            "Empty code span `` is technically valid",
391        ];
392        for case in valid_cases {
393            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
394            let result = rule.check(&ctx).unwrap();
395            assert!(result.is_empty(), "Valid case should not have warnings: {case}");
396        }
397    }
398
399    #[test]
400    fn test_md038_invalid() {
401        let rule = MD038NoSpaceInCode::new();
402        // Flag cases that violate CommonMark:
403        // - Space only at start (no matching end space)
404        // - Space only at end (no matching start space)
405        // - Multiple spaces at start or end (extra space will remain after CommonMark stripping)
406        let invalid_cases = vec![
407            // Unbalanced: only leading space
408            "This is ` code` with leading space.",
409            // Unbalanced: only trailing space
410            "This is `code ` with trailing space.",
411            // Multiple leading spaces (one will remain after CommonMark strips one)
412            "This is `  code ` with double leading space.",
413            // Multiple trailing spaces (one will remain after CommonMark strips one)
414            "This is ` code  ` with double trailing space.",
415            // Multiple spaces both sides
416            "This is `  code  ` with double spaces both sides.",
417        ];
418        for case in invalid_cases {
419            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
420            let result = rule.check(&ctx).unwrap();
421            assert!(!result.is_empty(), "Invalid case should have warnings: {case}");
422        }
423    }
424
425    #[test]
426    fn test_md038_valid_commonmark_stripping() {
427        let rule = MD038NoSpaceInCode::new();
428        // These cases have exactly ONE space at start AND ONE at end.
429        // CommonMark strips both, so these should NOT be flagged.
430        // See: https://spec.commonmark.org/0.31.2/#code-spans
431        let valid_cases = vec![
432            "Type ` y ` to confirm.",
433            "Use ` git commit -m \"message\" ` to commit.",
434            "The variable ` $HOME ` contains home path.",
435            "The pattern ` *.txt ` matches text files.",
436            "This is ` random word ` with unnecessary spaces.",
437            "Text with ` plain text ` is valid.",
438            "Code with ` just code ` here.",
439            "Multiple ` word ` spans with ` text ` in one line.",
440            "This is ` code ` with both leading and trailing single space.",
441            "Use ` - ` as separator.",
442        ];
443        for case in valid_cases {
444            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
445            let result = rule.check(&ctx).unwrap();
446            assert!(
447                result.is_empty(),
448                "Single space on each side should not be flagged (CommonMark strips them): {case}"
449            );
450        }
451    }
452
453    #[test]
454    fn test_md038_fix() {
455        let rule = MD038NoSpaceInCode::new();
456        // Only cases that violate CommonMark should be fixed
457        let test_cases = vec![
458            // Unbalanced: only leading space - should be fixed
459            (
460                "This is ` code` with leading space.",
461                "This is `code` with leading space.",
462            ),
463            // Unbalanced: only trailing space - should be fixed
464            (
465                "This is `code ` with trailing space.",
466                "This is `code` with trailing space.",
467            ),
468            // Single space on both sides - NOT fixed (valid per CommonMark)
469            (
470                "This is ` code ` with both spaces.",
471                "This is ` code ` with both spaces.", // unchanged
472            ),
473            // Double leading space - should be fixed
474            (
475                "This is `  code ` with double leading space.",
476                "This is `code` with double leading space.",
477            ),
478            // Mixed: one valid (single space both), one invalid (trailing only)
479            (
480                "Multiple ` code ` and `spans ` to fix.",
481                "Multiple ` code ` and `spans` to fix.", // only spans is fixed
482            ),
483        ];
484        for (input, expected) in test_cases {
485            let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
486            let result = rule.fix(&ctx).unwrap();
487            assert_eq!(result, expected, "Fix did not produce expected output for: {input}");
488        }
489    }
490
491    #[test]
492    fn test_check_invalid_leading_space() {
493        let rule = MD038NoSpaceInCode::new();
494        let input = "This has a ` leading space` in code";
495        let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
496        let result = rule.check(&ctx).unwrap();
497        assert_eq!(result.len(), 1);
498        assert_eq!(result[0].line, 1);
499        assert!(result[0].fix.is_some());
500    }
501
502    #[test]
503    fn test_code_span_parsing_nested_backticks() {
504        let content = "Code with ` nested `code` example ` should preserve backticks";
505        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506
507        println!("Content: {content}");
508        println!("Code spans found:");
509        let code_spans = ctx.code_spans();
510        for (i, span) in code_spans.iter().enumerate() {
511            println!(
512                "  Span {}: line={}, col={}-{}, backticks={}, content='{}'",
513                i, span.line, span.start_col, span.end_col, span.backtick_count, span.content
514            );
515        }
516
517        // This test reveals the issue - we're getting multiple separate code spans instead of one
518        assert_eq!(code_spans.len(), 2, "Should parse as 2 code spans");
519    }
520
521    #[test]
522    fn test_nested_backtick_detection() {
523        let rule = MD038NoSpaceInCode::new();
524
525        // Test that code spans with backticks are skipped
526        let content = "Code with `` `backticks` inside `` should not be flagged";
527        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528        let result = rule.check(&ctx).unwrap();
529        assert!(result.is_empty(), "Code spans with backticks should be skipped");
530    }
531
532    #[test]
533    fn test_quarto_inline_r_code() {
534        // Test that Quarto-specific R code exception works
535        let rule = MD038NoSpaceInCode::new();
536
537        // Test inline R code - should NOT trigger warning in Quarto flavor
538        // The key pattern is "r " followed by code
539        let content = r#"The result is `r nchar("test")` which equals 4."#;
540
541        // Quarto flavor should allow R code
542        let ctx_quarto = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
543        let result_quarto = rule.check(&ctx_quarto).unwrap();
544        assert!(
545            result_quarto.is_empty(),
546            "Quarto inline R code should not trigger warnings. Got {} warnings",
547            result_quarto.len()
548        );
549
550        // Test that invalid code spans (not matching CommonMark stripping) still get flagged in Quarto
551        // Use only trailing space - this violates CommonMark (no balanced stripping)
552        let content_other = "This has `plain text ` with trailing space.";
553        let ctx_other =
554            crate::lint_context::LintContext::new(content_other, crate::config::MarkdownFlavor::Quarto, None);
555        let result_other = rule.check(&ctx_other).unwrap();
556        assert_eq!(
557            result_other.len(),
558            1,
559            "Quarto should still flag non-R code spans with improper spaces"
560        );
561    }
562
563    /// Comprehensive tests for Hugo template syntax detection
564    ///
565    /// These tests ensure MD038 correctly handles Hugo template syntax patterns
566    /// without false positives, while maintaining correct detection of actual
567    /// code span spacing issues.
568    #[test]
569    fn test_hugo_template_syntax_comprehensive() {
570        let rule = MD038NoSpaceInCode::new();
571
572        // ===== VALID HUGO TEMPLATE SYNTAX (Should NOT trigger warnings) =====
573
574        // Basic Hugo shortcode patterns
575        let valid_hugo_cases = vec![
576            // Raw HTML shortcode
577            (
578                "{{raw `\n\tgo list -f '{{.DefaultGODEBUG}}' my/main/package\n`}}",
579                "Multi-line raw shortcode",
580            ),
581            (
582                "Some text {{raw ` code `}} more text",
583                "Inline raw shortcode with spaces",
584            ),
585            ("{{raw `code`}}", "Raw shortcode without spaces"),
586            // Partial shortcode
587            ("{{< ` code ` >}}", "Partial shortcode with spaces"),
588            ("{{< `code` >}}", "Partial shortcode without spaces"),
589            // Shortcode with percent
590            ("{{% ` code ` %}}", "Percent shortcode with spaces"),
591            ("{{% `code` %}}", "Percent shortcode without spaces"),
592            // Generic shortcode
593            ("{{ ` code ` }}", "Generic shortcode with spaces"),
594            ("{{ `code` }}", "Generic shortcode without spaces"),
595            // Shortcodes with parameters (common Hugo pattern)
596            ("{{< highlight go `code` >}}", "Shortcode with highlight parameter"),
597            ("{{< code `go list` >}}", "Shortcode with code parameter"),
598            // Multi-line Hugo templates
599            ("{{raw `\n\tcommand here\n\tmore code\n`}}", "Multi-line raw template"),
600            ("{{< highlight `\ncode here\n` >}}", "Multi-line highlight template"),
601            // Hugo templates with nested Go template syntax
602            (
603                "{{raw `\n\t{{.Variable}}\n\t{{range .Items}}\n`}}",
604                "Nested Go template syntax",
605            ),
606            // Edge case: Hugo template at start of line
607            ("{{raw `code`}}", "Hugo template at line start"),
608            // Edge case: Hugo template at end of line
609            ("Text {{raw `code`}}", "Hugo template at end of line"),
610            // Edge case: Multiple Hugo templates
611            ("{{raw `code1`}} and {{raw `code2`}}", "Multiple Hugo templates"),
612        ];
613
614        for (case, description) in valid_hugo_cases {
615            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
616            let result = rule.check(&ctx).unwrap();
617            assert!(
618                result.is_empty(),
619                "Hugo template syntax should not trigger MD038 warnings: {description} - {case}"
620            );
621        }
622
623        // ===== FALSE POSITIVE PREVENTION (Non-Hugo asymmetric spaces should be flagged) =====
624
625        // These have asymmetric spaces (leading-only or trailing-only) and should be flagged
626        // Per CommonMark spec: symmetric single-space pairs are stripped and NOT flagged
627        let should_be_flagged = vec![
628            ("This is ` code` with leading space.", "Leading space only"),
629            ("This is `code ` with trailing space.", "Trailing space only"),
630            ("Text `  code ` here", "Extra leading space (asymmetric)"),
631            ("Text ` code  ` here", "Extra trailing space (asymmetric)"),
632            ("Text `  code` here", "Double leading, no trailing"),
633            ("Text `code  ` here", "No leading, double trailing"),
634        ];
635
636        for (case, description) in should_be_flagged {
637            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
638            let result = rule.check(&ctx).unwrap();
639            assert!(
640                !result.is_empty(),
641                "Should flag asymmetric space code spans: {description} - {case}"
642            );
643        }
644
645        // ===== COMMONMARK SYMMETRIC SPACE BEHAVIOR (Should NOT be flagged) =====
646
647        // Per CommonMark 0.31.2: When a code span has exactly one space at start AND end,
648        // those spaces are stripped from the output. This is intentional, not an error.
649        // These cases should NOT trigger MD038.
650        let symmetric_single_space = vec![
651            ("Text ` code ` here", "Symmetric single space - CommonMark strips"),
652            ("{raw ` code `}", "Looks like Hugo but missing opening {{"),
653            ("raw ` code `}}", "Missing opening {{ - but symmetric spaces"),
654        ];
655
656        for (case, description) in symmetric_single_space {
657            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
658            let result = rule.check(&ctx).unwrap();
659            assert!(
660                result.is_empty(),
661                "CommonMark symmetric spaces should NOT be flagged: {description} - {case}"
662            );
663        }
664
665        // ===== EDGE CASES: Unicode and Special Characters =====
666
667        let unicode_cases = vec![
668            ("{{raw `\n\t你好世界\n`}}", "Unicode in Hugo template"),
669            ("{{raw `\n\t🎉 emoji\n`}}", "Emoji in Hugo template"),
670            ("{{raw `\n\tcode with \"quotes\"\n`}}", "Quotes in Hugo template"),
671            (
672                "{{raw `\n\tcode with 'single quotes'\n`}}",
673                "Single quotes in Hugo template",
674            ),
675        ];
676
677        for (case, description) in unicode_cases {
678            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
679            let result = rule.check(&ctx).unwrap();
680            assert!(
681                result.is_empty(),
682                "Hugo templates with special characters should not trigger warnings: {description} - {case}"
683            );
684        }
685
686        // ===== BOUNDARY CONDITIONS =====
687
688        // Minimum valid Hugo pattern
689        assert!(
690            rule.check(&crate::lint_context::LintContext::new(
691                "{{ ` ` }}",
692                crate::config::MarkdownFlavor::Standard,
693                None
694            ))
695            .unwrap()
696            .is_empty(),
697            "Minimum Hugo pattern should be valid"
698        );
699
700        // Hugo template with only whitespace
701        assert!(
702            rule.check(&crate::lint_context::LintContext::new(
703                "{{raw `\n\t\n`}}",
704                crate::config::MarkdownFlavor::Standard,
705                None
706            ))
707            .unwrap()
708            .is_empty(),
709            "Hugo template with only whitespace should be valid"
710        );
711    }
712
713    /// Test interaction with other markdown elements
714    #[test]
715    fn test_hugo_template_with_other_markdown() {
716        let rule = MD038NoSpaceInCode::new();
717
718        // Hugo template inside a list
719        let content = r#"1. First item
7202. Second item with {{raw `code`}} template
7213. Third item"#;
722        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723        let result = rule.check(&ctx).unwrap();
724        assert!(result.is_empty(), "Hugo template in list should not trigger warnings");
725
726        // Hugo template in blockquote
727        let content = r#"> Quote with {{raw `code`}} template"#;
728        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729        let result = rule.check(&ctx).unwrap();
730        assert!(
731            result.is_empty(),
732            "Hugo template in blockquote should not trigger warnings"
733        );
734
735        // Hugo template near regular code span (should flag the regular one)
736        let content = r#"{{raw `code`}} and ` bad code` here"#;
737        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738        let result = rule.check(&ctx).unwrap();
739        assert_eq!(result.len(), 1, "Should flag regular code span but not Hugo template");
740    }
741
742    /// Performance test: Many Hugo templates
743    #[test]
744    fn test_hugo_template_performance() {
745        let rule = MD038NoSpaceInCode::new();
746
747        // Create content with many Hugo templates
748        let mut content = String::new();
749        for i in 0..100 {
750            content.push_str(&format!("{{{{raw `code{i}\n`}}}}\n"));
751        }
752
753        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
754        let start = std::time::Instant::now();
755        let result = rule.check(&ctx).unwrap();
756        let duration = start.elapsed();
757
758        assert!(result.is_empty(), "Many Hugo templates should not trigger warnings");
759        assert!(
760            duration.as_millis() < 1000,
761            "Performance test: Should process 100 Hugo templates in <1s, took {duration:?}"
762        );
763    }
764
765    #[test]
766    fn test_mkdocs_inline_hilite_not_flagged() {
767        // InlineHilite syntax: `#!language code` should NOT be flagged
768        // The space after the language specifier is legitimate
769        let rule = MD038NoSpaceInCode::new();
770
771        let valid_cases = vec![
772            "`#!python print('hello')`",
773            "`#!js alert('hi')`",
774            "`#!c++ cout << x;`",
775            "Use `#!python import os` to import modules",
776            "`#!bash echo $HOME`",
777        ];
778
779        for case in valid_cases {
780            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::MkDocs, None);
781            let result = rule.check(&ctx).unwrap();
782            assert!(
783                result.is_empty(),
784                "InlineHilite syntax should not be flagged in MkDocs: {case}"
785            );
786        }
787
788        // Test that InlineHilite IS flagged in Standard flavor (not MkDocs-aware)
789        let content = "`#!python print('hello')`";
790        let ctx_standard =
791            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
792        let result_standard = rule.check(&ctx_standard).unwrap();
793        // In standard flavor, the content " print('hello')" has no special meaning
794        // But since "#!python print('hello')" doesn't have leading/trailing spaces, it's valid!
795        assert!(
796            result_standard.is_empty(),
797            "InlineHilite with no extra spaces should not be flagged even in Standard flavor"
798        );
799    }
800
801    #[test]
802    fn test_multibyte_utf8_no_panic() {
803        // Regression test: ensure multi-byte UTF-8 characters don't cause panics
804        // when checking for nested backticks between code spans.
805        // These are real examples from the-art-of-command-line translations.
806        let rule = MD038NoSpaceInCode::new();
807
808        // Greek text with code spans
809        let greek = "- Χρήσιμα εργαλεία της γραμμής εντολών είναι τα `ping`,` ipconfig`, `traceroute` και `netstat`.";
810        let ctx = crate::lint_context::LintContext::new(greek, crate::config::MarkdownFlavor::Standard, None);
811        let result = rule.check(&ctx);
812        assert!(result.is_ok(), "Greek text should not panic");
813
814        // Chinese text with code spans
815        let chinese = "- 當你需要對文字檔案做集合交、並、差運算時,`sort`/`uniq` 很有幫助。";
816        let ctx = crate::lint_context::LintContext::new(chinese, crate::config::MarkdownFlavor::Standard, None);
817        let result = rule.check(&ctx);
818        assert!(result.is_ok(), "Chinese text should not panic");
819
820        // Cyrillic/Ukrainian text with code spans
821        let cyrillic = "- Основи роботи з файлами: `ls` і `ls -l`, `less`, `head`,` tail` і `tail -f`.";
822        let ctx = crate::lint_context::LintContext::new(cyrillic, crate::config::MarkdownFlavor::Standard, None);
823        let result = rule.check(&ctx);
824        assert!(result.is_ok(), "Cyrillic text should not panic");
825
826        // Mixed multi-byte with multiple code spans on same line
827        let mixed = "使用 `git` 命令和 `npm` 工具来管理项目,可以用 `docker` 容器化。";
828        let ctx = crate::lint_context::LintContext::new(mixed, crate::config::MarkdownFlavor::Standard, None);
829        let result = rule.check(&ctx);
830        assert!(
831            result.is_ok(),
832            "Mixed Chinese text with multiple code spans should not panic"
833        );
834    }
835}