rumdl_lib/rules/
md038_no_space_in_code.rs

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