Skip to main content

rumdl_lib/rules/
md070_nested_code_fence.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3/// Rule MD070: Nested code fence collision detection
4///
5/// Detects when a fenced code block contains fence markers that would cause
6/// premature closure. Suggests using longer fences to avoid this issue.
7///
8/// Checks languages where triple backtick sequences commonly appear:
9/// markdown, Python, JavaScript, shell, Rust, Go, and others with multiline
10/// strings, heredocs, template literals, or doc comments.
11///
12/// See [docs/md070.md](../../docs/md070.md) for full documentation.
13#[derive(Clone, Default)]
14pub struct MD070NestedCodeFence;
15
16impl MD070NestedCodeFence {
17    pub fn new() -> Self {
18        Self
19    }
20
21    /// Check if the given language should be checked for nested fences.
22    /// Covers languages where triple backtick sequences commonly appear in source:
23    /// multiline strings with embedded markdown, heredocs, doc comments, template
24    /// literals, and data formats with multiline string values.
25    fn should_check_language(lang: &str) -> bool {
26        let base = lang.split_whitespace().next().unwrap_or("");
27        matches!(
28            base.to_ascii_lowercase().as_str(),
29            // Documentation / markup
30            ""
31                | "markdown"
32                | "md"
33                | "mdx"
34                | "text"
35                | "txt"
36                | "plain"
37                // Multiline strings / docstrings
38                | "python"
39                | "py"
40                | "ruby"
41                | "rb"
42                | "perl"
43                | "pl"
44                | "php"
45                | "lua"
46                | "r"
47                | "rmd"
48                | "rmarkdown"
49                // Template literals / raw strings
50                | "javascript"
51                | "js"
52                | "jsx"
53                | "mjs"
54                | "cjs"
55                | "typescript"
56                | "ts"
57                | "tsx"
58                | "mts"
59                | "rust"
60                | "rs"
61                | "go"
62                | "golang"
63                | "swift"
64                | "kotlin"
65                | "kt"
66                | "kts"
67                | "java"
68                | "csharp"
69                | "cs"
70                | "c#"
71                | "scala"
72                // Shell heredocs
73                | "shell"
74                | "sh"
75                | "bash"
76                | "zsh"
77                | "fish"
78                | "powershell"
79                | "ps1"
80                | "pwsh"
81                // Data / config formats
82                | "yaml"
83                | "yml"
84                | "toml"
85                | "json"
86                | "jsonc"
87                | "json5"
88                // Template engines
89                | "jinja"
90                | "jinja2"
91                | "handlebars"
92                | "hbs"
93                | "liquid"
94                | "nunjucks"
95                | "njk"
96                | "ejs"
97                // Terminal output
98                | "console"
99                | "terminal"
100        )
101    }
102
103    /// Find the maximum fence length of same-character fences in the content
104    /// Returns (line_offset, fence_length) of the first collision, if any
105    fn find_fence_collision(content: &str, fence_char: char, outer_fence_length: usize) -> Option<(usize, usize)> {
106        for (line_idx, line) in content.lines().enumerate() {
107            let trimmed = line.trim_start();
108
109            // Check if line starts with the same fence character
110            if trimmed.starts_with(fence_char) {
111                let count = trimmed.chars().take_while(|&c| c == fence_char).count();
112
113                // Collision if same char AND at least as long as outer fence
114                if count >= outer_fence_length {
115                    // Verify it looks like a fence line (only fence chars + optional language/whitespace)
116                    let after_fence = &trimmed[count..];
117                    // A fence line is: fence chars + optional language identifier + optional whitespace
118                    // We detect collision if:
119                    // - Line ends after fence chars (closing fence)
120                    // - Line has alphanumeric after fence (opening fence with language)
121                    // - Line has only whitespace after fence
122                    if after_fence.is_empty()
123                        || after_fence.trim().is_empty()
124                        || after_fence
125                            .chars()
126                            .next()
127                            .is_some_and(|c| c.is_alphabetic() || c == '{')
128                    {
129                        return Some((line_idx, count));
130                    }
131                }
132            }
133        }
134        None
135    }
136
137    /// Find the maximum fence length needed to safely contain the content
138    fn find_safe_fence_length(content: &str, fence_char: char) -> usize {
139        let mut max_fence = 0;
140
141        for line in content.lines() {
142            let trimmed = line.trim_start();
143            if trimmed.starts_with(fence_char) {
144                let count = trimmed.chars().take_while(|&c| c == fence_char).count();
145                if count >= 3 {
146                    // Only count valid fence-like patterns
147                    let after_fence = &trimmed[count..];
148                    if after_fence.is_empty()
149                        || after_fence.trim().is_empty()
150                        || after_fence
151                            .chars()
152                            .next()
153                            .is_some_and(|c| c.is_alphabetic() || c == '{')
154                    {
155                        max_fence = max_fence.max(count);
156                    }
157                }
158            }
159        }
160
161        max_fence
162    }
163
164    /// Find the user's intended closing fence when a collision is detected.
165    /// Searches past the first (premature) closing fence for the last bare
166    /// fence of the same type before hitting a new opening fence.
167    fn find_intended_close(
168        lines: &[&str],
169        first_close: usize,
170        fence_char: char,
171        fence_length: usize,
172        opening_indent: usize,
173    ) -> usize {
174        let mut intended_close = first_close;
175        for (j, line_j) in lines.iter().enumerate().skip(first_close + 1) {
176            if Self::is_closing_fence(line_j, fence_char, fence_length) {
177                intended_close = j;
178            } else if Self::parse_fence_line(line_j)
179                .is_some_and(|(ind, ch, _, info)| ind <= opening_indent && ch == fence_char && !info.is_empty())
180            {
181                break;
182            }
183        }
184        intended_close
185    }
186
187    /// Parse a fence marker from a line, returning (indent, fence_char, fence_length, info_string)
188    fn parse_fence_line(line: &str) -> Option<(usize, char, usize, &str)> {
189        let indent = line.len() - line.trim_start().len();
190        // Per CommonMark, fence must have 0-3 spaces of indentation
191        if indent > 3 {
192            return None;
193        }
194
195        let trimmed = line.trim_start();
196
197        if trimmed.starts_with("```") {
198            let count = trimmed.chars().take_while(|&c| c == '`').count();
199            if count >= 3 {
200                let info = trimmed[count..].trim();
201                return Some((indent, '`', count, info));
202            }
203        } else if trimmed.starts_with("~~~") {
204            let count = trimmed.chars().take_while(|&c| c == '~').count();
205            if count >= 3 {
206                let info = trimmed[count..].trim();
207                return Some((indent, '~', count, info));
208            }
209        }
210
211        None
212    }
213
214    /// Check if a line is a valid closing fence for the given opening fence
215    /// Per CommonMark, closing fences can have 0-3 spaces of indentation regardless of opening fence
216    fn is_closing_fence(line: &str, fence_char: char, min_length: usize) -> bool {
217        let indent = line.len() - line.trim_start().len();
218        // Per CommonMark spec, closing fence can have 0-3 spaces of indentation
219        if indent > 3 {
220            return false;
221        }
222
223        let trimmed = line.trim_start();
224        if !trimmed.starts_with(fence_char) {
225            return false;
226        }
227
228        let count = trimmed.chars().take_while(|&c| c == fence_char).count();
229        if count < min_length {
230            return false;
231        }
232
233        // Closing fence must have only whitespace after fence chars
234        trimmed[count..].trim().is_empty()
235    }
236}
237
238impl Rule for MD070NestedCodeFence {
239    fn name(&self) -> &'static str {
240        "MD070"
241    }
242
243    fn description(&self) -> &'static str {
244        "Nested code fence collision - use longer fence to avoid premature closure"
245    }
246
247    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
248        let mut warnings = Vec::new();
249        let lines = ctx.raw_lines();
250
251        let mut i = 0;
252        while i < lines.len() {
253            // Skip lines in contexts that shouldn't be processed
254            if let Some(line_info) = ctx.lines.get(i)
255                && (line_info.in_front_matter
256                    || line_info.in_html_comment
257                    || line_info.in_mdx_comment
258                    || line_info.in_html_block)
259            {
260                i += 1;
261                continue;
262            }
263
264            // Skip if we're already inside a code block (check previous line).
265            // This handles list-indented code blocks (4+ spaces) which our rule doesn't
266            // parse directly, but the context detects correctly. If the previous line
267            // is in a code block, this line is either content or a closing fence for
268            // that block - not a new opening fence.
269            if i > 0
270                && let Some(prev_line_info) = ctx.lines.get(i - 1)
271                && prev_line_info.in_code_block
272            {
273                i += 1;
274                continue;
275            }
276
277            let line = lines[i];
278
279            // Try to parse as opening fence
280            if let Some((_indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
281                let block_start = i;
282
283                // Extract the language (first word of info string)
284                let language = info_string.split_whitespace().next().unwrap_or("");
285
286                // Find the closing fence
287                let mut block_end = None;
288                for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
289                    if Self::is_closing_fence(line_j, fence_char, fence_length) {
290                        block_end = Some(j);
291                        break;
292                    }
293                }
294
295                if let Some(end_line) = block_end {
296                    // We have a complete code block from block_start to end_line
297                    // Check if we should analyze this block
298                    if Self::should_check_language(language) {
299                        // Get the content between fences
300                        let block_content: String = if block_start + 1 < end_line {
301                            lines[(block_start + 1)..end_line].join("\n")
302                        } else {
303                            String::new()
304                        };
305
306                        // Check for fence collision
307                        if let Some((collision_line_offset, _collision_length)) =
308                            Self::find_fence_collision(&block_content, fence_char, fence_length)
309                        {
310                            let collision_line_num = block_start + 1 + collision_line_offset + 1; // 1-indexed
311
312                            // Find the user's intended closing fence (may be past the
313                            // CommonMark-visible close when inner ``` causes premature closure)
314                            let indent = line.len() - line.trim_start().len();
315                            let intended_close =
316                                Self::find_intended_close(lines, end_line, fence_char, fence_length, indent);
317
318                            // Compute safe fence length from the full intended content
319                            let full_content: String = if block_start + 1 < intended_close {
320                                lines[(block_start + 1)..intended_close].join("\n")
321                            } else {
322                                block_content.clone()
323                            };
324                            let safe_length = Self::find_safe_fence_length(&full_content, fence_char) + 1;
325                            let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
326
327                            // Build a Fix that replaces the block from opening fence
328                            // through the intended closing fence. This must be safe for
329                            // direct application by the LSP code action path.
330                            let open_byte_start = ctx.line_start_byte(block_start + 1).unwrap_or(0);
331                            let close_byte_end = ctx.line_start_byte(intended_close + 2).unwrap_or(ctx.content.len());
332
333                            let indent_str = &line[..indent];
334                            let closing_line = lines[intended_close];
335                            let closing_indent = &closing_line[..closing_line.len() - closing_line.trim_start().len()];
336                            let mut replacement = format!("{indent_str}{suggested_fence}");
337                            if !info_string.is_empty() {
338                                replacement.push_str(info_string);
339                            }
340                            replacement.push('\n');
341                            for content_line in &lines[(block_start + 1)..intended_close] {
342                                replacement.push_str(content_line);
343                                replacement.push('\n');
344                            }
345                            replacement.push_str(closing_indent);
346                            replacement.push_str(&suggested_fence);
347                            // Only add trailing newline if the replaced range ends with one
348                            if close_byte_end <= ctx.content.len() && ctx.content[..close_byte_end].ends_with('\n') {
349                                replacement.push('\n');
350                            }
351
352                            warnings.push(LintWarning {
353                                rule_name: Some(self.name().to_string()),
354                                message: format!(
355                                    "Code block contains fence markers at line {collision_line_num} that interfere with block parsing — use {suggested_fence} for outer fence"
356                                ),
357                                line: block_start + 1,
358                                column: 1,
359                                end_line: intended_close + 1,
360                                end_column: lines[intended_close].chars().count() + 1,
361                                severity: Severity::Warning,
362                                fix: Some(Fix::new(open_byte_start..close_byte_end, replacement)),
363                            });
364                        }
365                    }
366
367                    // Move past this code block
368                    i = end_line + 1;
369                    continue;
370                }
371            }
372
373            i += 1;
374        }
375
376        Ok(warnings)
377    }
378
379    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
380        if self.should_skip(ctx) {
381            return Ok(ctx.content.to_string());
382        }
383        let warnings = self.check(ctx)?;
384        if warnings.is_empty() {
385            return Ok(ctx.content.to_string());
386        }
387        let warnings =
388            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
389        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::FixFailed)
390    }
391
392    fn category(&self) -> RuleCategory {
393        RuleCategory::CodeBlock
394    }
395
396    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
397        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
398    }
399
400    fn as_any(&self) -> &dyn std::any::Any {
401        self
402    }
403
404    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
405    where
406        Self: Sized,
407    {
408        Box::new(MD070NestedCodeFence::new())
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::lint_context::LintContext;
416
417    fn run_check(content: &str) -> LintResult {
418        let rule = MD070NestedCodeFence::new();
419        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
420        rule.check(&ctx)
421    }
422
423    fn run_fix(content: &str) -> Result<String, LintError> {
424        let rule = MD070NestedCodeFence::new();
425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426        rule.fix(&ctx)
427    }
428
429    #[test]
430    fn test_no_collision_simple() {
431        let content = "```python\nprint('hello')\n```\n";
432        let result = run_check(content).unwrap();
433        assert!(result.is_empty(), "Simple code block should not trigger warning");
434    }
435
436    #[test]
437    fn test_no_collision_unchecked_language() {
438        // C is not checked for nested fences (triple backticks don't appear in C source)
439        let content = "```c\n```bash\necho hello\n```\n```\n";
440        let result = run_check(content).unwrap();
441        assert!(result.is_empty(), "Unchecked language should not trigger");
442    }
443
444    #[test]
445    fn test_collision_python_language() {
446        // Python is checked — triple-quoted strings commonly contain markdown
447        let content = "```python\n```json\n{}\n```\n```\n";
448        let result = run_check(content).unwrap();
449        assert_eq!(result.len(), 1, "Python should be checked for nested fences");
450        assert!(result[0].message.contains("````"));
451    }
452
453    #[test]
454    fn test_collision_javascript_language() {
455        let content = "```javascript\n```html\n<div></div>\n```\n```\n";
456        let result = run_check(content).unwrap();
457        assert_eq!(result.len(), 1, "JavaScript should be checked for nested fences");
458    }
459
460    #[test]
461    fn test_collision_shell_language() {
462        let content = "```bash\n```yaml\nkey: val\n```\n```\n";
463        let result = run_check(content).unwrap();
464        assert_eq!(result.len(), 1, "Shell should be checked for nested fences");
465    }
466
467    #[test]
468    fn test_collision_rust_language() {
469        let content = "```rust\n```toml\n[dep]\n```\n```\n";
470        let result = run_check(content).unwrap();
471        assert_eq!(result.len(), 1, "Rust should be checked for nested fences");
472    }
473
474    #[test]
475    fn test_no_collision_assembly_language() {
476        // Assembly, C, SQL etc. should NOT be checked
477        for lang in ["asm", "c", "cpp", "sql", "css", "fortran"] {
478            let content = format!("```{lang}\n```inner\ncontent\n```\n```\n");
479            let result = run_check(&content).unwrap();
480            assert!(result.is_empty(), "{lang} should not be checked for nested fences");
481        }
482    }
483
484    #[test]
485    fn test_collision_markdown_language() {
486        let content = "```markdown\n```python\ncode()\n```\n```\n";
487        let result = run_check(content).unwrap();
488        assert_eq!(result.len(), 1, "Should emit single warning for collision");
489        assert!(result[0].message.contains("fence markers at line"));
490        assert!(result[0].message.contains("interfere with block parsing"));
491        assert!(result[0].message.contains("use ````"));
492    }
493
494    #[test]
495    fn test_collision_empty_language() {
496        // Empty language (no language specified) is checked
497        let content = "```\n```python\ncode()\n```\n```\n";
498        let result = run_check(content).unwrap();
499        assert_eq!(result.len(), 1, "Empty language should be checked");
500    }
501
502    #[test]
503    fn test_no_collision_longer_outer_fence() {
504        let content = "````markdown\n```python\ncode()\n```\n````\n";
505        let result = run_check(content).unwrap();
506        assert!(result.is_empty(), "Longer outer fence should not trigger warning");
507    }
508
509    #[test]
510    fn test_tilde_fence_ignores_backticks() {
511        // Tildes and backticks don't conflict
512        let content = "~~~markdown\n```python\ncode()\n```\n~~~\n";
513        let result = run_check(content).unwrap();
514        assert!(result.is_empty(), "Different fence types should not collide");
515    }
516
517    #[test]
518    fn test_tilde_collision() {
519        let content = "~~~markdown\n~~~python\ncode()\n~~~\n~~~\n";
520        let result = run_check(content).unwrap();
521        assert_eq!(result.len(), 1, "Same fence type should collide");
522        assert!(result[0].message.contains("~~~~"));
523    }
524
525    #[test]
526    fn test_fix_increases_fence_length() {
527        let content = "```markdown\n```python\ncode()\n```\n```\n";
528        let fixed = run_fix(content).unwrap();
529        assert!(fixed.starts_with("````markdown"), "Should increase to 4 backticks");
530        assert!(
531            fixed.contains("````\n") || fixed.ends_with("````"),
532            "Closing should also be 4 backticks"
533        );
534    }
535
536    #[test]
537    fn test_fix_handles_longer_inner_fence() {
538        // Inner fence has 5 backticks, so outer needs 6
539        let content = "```markdown\n`````python\ncode()\n`````\n```\n";
540        let fixed = run_fix(content).unwrap();
541        assert!(fixed.starts_with("``````markdown"), "Should increase to 6 backticks");
542    }
543
544    #[test]
545    fn test_backticks_in_code_not_fence() {
546        // Template literals in JS shouldn't trigger
547        let content = "```markdown\nconst x = `template`;\n```\n";
548        let result = run_check(content).unwrap();
549        assert!(result.is_empty(), "Inline backticks should not be detected as fences");
550    }
551
552    #[test]
553    fn test_preserves_info_string() {
554        let content = "```markdown {.highlight}\n```python\ncode()\n```\n```\n";
555        let fixed = run_fix(content).unwrap();
556        assert!(
557            fixed.contains("````markdown {.highlight}"),
558            "Should preserve info string attributes"
559        );
560    }
561
562    #[test]
563    fn test_md_language_alias() {
564        let content = "```md\n```python\ncode()\n```\n```\n";
565        let result = run_check(content).unwrap();
566        assert_eq!(result.len(), 1, "md should be recognized as markdown");
567    }
568
569    #[test]
570    fn test_real_world_docs_case() {
571        // This is the actual pattern from docs/md031.md that triggered the PR
572        let content = r#"```markdown
5731. First item
574
575   ```python
576   code_in_list()
577   ```
578
5791. Second item
580
581```
582"#;
583        let result = run_check(content).unwrap();
584        assert_eq!(result.len(), 1, "Should emit single warning for nested fence issue");
585        assert!(result[0].message.contains("line 4")); // The nested ``` is on line 4
586
587        let fixed = run_fix(content).unwrap();
588        assert!(fixed.starts_with("````markdown"), "Should fix with longer fence");
589    }
590
591    #[test]
592    fn test_empty_code_block() {
593        let content = "```markdown\n```\n";
594        let result = run_check(content).unwrap();
595        assert!(result.is_empty(), "Empty code block should not trigger");
596    }
597
598    #[test]
599    fn test_multiple_code_blocks() {
600        // The markdown block has a collision (inner ```python closes it prematurely).
601        // The orphan closing fence (line 9) is NOT treated as a new opening fence
602        // because the context correctly detects it as part of the markdown block.
603        let content = r#"```python
604safe code
605```
606
607```markdown
608```python
609collision
610```
611```
612
613```javascript
614also safe
615```
616"#;
617        let result = run_check(content).unwrap();
618        // Only 1 warning for the markdown block collision.
619        // The orphan fence is correctly ignored (not parsed as new opening fence).
620        assert_eq!(result.len(), 1, "Should emit single warning for collision");
621        assert!(result[0].message.contains("line 6")); // The nested ```python is on line 6
622    }
623
624    #[test]
625    fn test_single_collision_properly_closed() {
626        // When the outer fence is properly longer, only the intended block triggers
627        let content = r#"```python
628safe code
629```
630
631````markdown
632```python
633collision
634```
635````
636
637```javascript
638also safe
639```
640"#;
641        let result = run_check(content).unwrap();
642        assert!(result.is_empty(), "Properly fenced blocks should not trigger");
643    }
644
645    #[test]
646    fn test_indented_code_block_in_list() {
647        let content = r#"- List item
648  ```markdown
649  ```python
650  nested
651  ```
652  ```
653"#;
654        let result = run_check(content).unwrap();
655        assert_eq!(result.len(), 1, "Should detect collision in indented block");
656        assert!(result[0].message.contains("````"));
657    }
658
659    #[test]
660    fn test_no_false_positive_list_indented_block() {
661        // 4-space indented code blocks in list context (GFM extension) should not
662        // cause false positives. The closing fence with 3-space indent should not
663        // be parsed as a new opening fence.
664        let content = r#"1. List item with code:
665
666    ```json
667    {"key": "value"}
668    ```
669
6702. Another item
671
672   ```python
673   code()
674   ```
675"#;
676        let result = run_check(content).unwrap();
677        // No collision - these are separate, well-formed code blocks
678        assert!(
679            result.is_empty(),
680            "List-indented code blocks should not trigger false positives"
681        );
682    }
683
684    // ==================== Comprehensive Edge Case Tests ====================
685
686    #[test]
687    fn test_case_insensitive_language() {
688        // MARKDOWN, Markdown, MD should all be checked
689        for lang in ["MARKDOWN", "Markdown", "MD", "Md", "mD"] {
690            let content = format!("```{lang}\n```python\ncode()\n```\n```\n");
691            let result = run_check(&content).unwrap();
692            assert_eq!(result.len(), 1, "{lang} should be recognized as markdown");
693        }
694    }
695
696    #[test]
697    fn test_unclosed_outer_fence() {
698        // If outer fence is never closed, no collision can be detected
699        let content = "```markdown\n```python\ncode()\n```\n";
700        let result = run_check(content).unwrap();
701        // The outer fence finds ```python as its closing fence (premature close)
702        // Then ```\n at the end becomes orphan - but context would handle this
703        assert!(result.len() <= 1, "Unclosed fence should not cause issues");
704    }
705
706    #[test]
707    fn test_deeply_nested_fences() {
708        // Multiple levels of nesting require progressively longer fences
709        let content = r#"```markdown
710````markdown
711```python
712code()
713```
714````
715```
716"#;
717        let result = run_check(content).unwrap();
718        // The outer ``` sees ```` as collision (4 >= 3)
719        assert_eq!(result.len(), 1, "Deep nesting should trigger warning");
720        assert!(result[0].message.contains("`````")); // Needs 5 to be safe
721    }
722
723    #[test]
724    fn test_very_long_fences() {
725        // 10 backtick fences should work correctly
726        let content = "``````````markdown\n```python\ncode()\n```\n``````````\n";
727        let result = run_check(content).unwrap();
728        assert!(result.is_empty(), "Very long outer fence should not trigger warning");
729    }
730
731    #[test]
732    fn test_blockquote_with_fence() {
733        // Fences inside blockquotes (CommonMark allows this)
734        let content = "> ```markdown\n> ```python\n> code()\n> ```\n> ```\n";
735        let result = run_check(content).unwrap();
736        // Blockquote prefixes are part of the line, so parsing may differ
737        // This documents current behavior
738        assert!(result.is_empty() || result.len() == 1);
739    }
740
741    #[test]
742    fn test_fence_with_attributes() {
743        // Info string with attributes like {.class #id}
744        let content = "```markdown {.highlight #example}\n```python\ncode()\n```\n```\n";
745        let result = run_check(content).unwrap();
746        assert_eq!(
747            result.len(),
748            1,
749            "Attributes in info string should not prevent detection"
750        );
751
752        let fixed = run_fix(content).unwrap();
753        assert!(
754            fixed.contains("````markdown {.highlight #example}"),
755            "Attributes should be preserved in fix"
756        );
757    }
758
759    #[test]
760    fn test_trailing_whitespace_in_info_string() {
761        let content = "```markdown   \n```python\ncode()\n```\n```\n";
762        let result = run_check(content).unwrap();
763        assert_eq!(result.len(), 1, "Trailing whitespace should not affect detection");
764    }
765
766    #[test]
767    fn test_only_closing_fence_pattern() {
768        // Content that has only closing fence patterns (no language)
769        let content = "```markdown\nsome text\n```\nmore text\n```\n";
770        let result = run_check(content).unwrap();
771        // The first ``` closes, second ``` is outside
772        assert!(result.is_empty(), "Properly closed block should not trigger");
773    }
774
775    #[test]
776    fn test_fence_at_end_of_file_no_newline() {
777        let content = "```markdown\n```python\ncode()\n```\n```";
778        let result = run_check(content).unwrap();
779        assert_eq!(result.len(), 1, "Should detect collision even without trailing newline");
780
781        let fixed = run_fix(content).unwrap();
782        assert!(!fixed.ends_with('\n'), "Should preserve lack of trailing newline");
783    }
784
785    #[test]
786    fn test_empty_lines_between_fences() {
787        let content = "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n";
788        let result = run_check(content).unwrap();
789        assert_eq!(result.len(), 1, "Empty lines should not affect collision detection");
790    }
791
792    #[test]
793    fn test_tab_indented_opening_fence() {
794        // Tab at start of line - CommonMark says tab = 4 spaces for indentation.
795        // A 4-space indented fence is NOT a valid fenced code block per CommonMark
796        // (only 0-3 spaces allowed). However, our implementation counts characters,
797        // treating tab as 1 character. This means tab-indented fences ARE parsed.
798        // This is intentional: consistent with other rules in rumdl and matches
799        // common editor behavior where tab = 1 indent level.
800        let content = "\t```markdown\n```python\ncode()\n```\n```\n";
801        let result = run_check(content).unwrap();
802        // With tab treated as 1 char (< 3), this IS parsed as a fence and triggers collision
803        assert_eq!(result.len(), 1, "Tab-indented fence is parsed (tab = 1 char)");
804    }
805
806    #[test]
807    fn test_mixed_fence_types_no_collision() {
808        // Backticks outer, tildes inner - should never collide
809        let content = "```markdown\n~~~python\ncode()\n~~~\n```\n";
810        let result = run_check(content).unwrap();
811        assert!(result.is_empty(), "Different fence chars should not collide");
812
813        // Tildes outer, backticks inner
814        let content2 = "~~~markdown\n```python\ncode()\n```\n~~~\n";
815        let result2 = run_check(content2).unwrap();
816        assert!(result2.is_empty(), "Different fence chars should not collide");
817    }
818
819    #[test]
820    fn test_frontmatter_not_confused_with_fence() {
821        // YAML frontmatter uses --- which shouldn't be confused with fences
822        let content = "---\ntitle: Test\n---\n\n```markdown\n```python\ncode()\n```\n```\n";
823        let result = run_check(content).unwrap();
824        assert_eq!(result.len(), 1, "Should detect collision after frontmatter");
825    }
826
827    #[test]
828    fn test_html_comment_with_fence_inside() {
829        // Fences inside HTML comments should be ignored
830        let content = "<!-- ```markdown\n```python\ncode()\n``` -->\n\n```markdown\nreal content\n```\n";
831        let result = run_check(content).unwrap();
832        // The fences inside HTML comment should be skipped
833        assert!(result.is_empty(), "Fences in HTML comments should be ignored");
834    }
835
836    #[test]
837    fn test_consecutive_code_blocks() {
838        // Multiple consecutive markdown blocks, each with collision
839        let content = r#"```markdown
840```python
841a()
842```
843```
844
845```markdown
846```ruby
847b()
848```
849```
850"#;
851        let result = run_check(content).unwrap();
852        // Each markdown block has its own collision
853        assert!(!result.is_empty(), "Should detect collision in first block");
854    }
855
856    #[test]
857    fn test_numeric_info_string() {
858        // Numbers after fence - some parsers treat this differently
859        let content = "```123\n```456\ncode()\n```\n```\n";
860        let result = run_check(content).unwrap();
861        // "123" is not "markdown" or "md", so should not check
862        assert!(result.is_empty(), "Numeric info string is not markdown");
863    }
864
865    #[test]
866    fn test_collision_at_exact_length() {
867        // An empty ``` is the closing fence, not a collision.
868        // For a collision, the inner fence must have content that looks like an opening fence.
869        let content = "```markdown\n```python\ncode()\n```\n```\n";
870        let result = run_check(content).unwrap();
871        assert_eq!(
872            result.len(),
873            1,
874            "Same-length fence with language should trigger collision"
875        );
876
877        // Inner fence one shorter than outer - not a collision
878        let content2 = "````markdown\n```python\ncode()\n```\n````\n";
879        let result2 = run_check(content2).unwrap();
880        assert!(result2.is_empty(), "Shorter inner fence should not collide");
881
882        // Empty markdown block followed by another fence - not a collision
883        let content3 = "```markdown\n```\n";
884        let result3 = run_check(content3).unwrap();
885        assert!(result3.is_empty(), "Empty closing fence is not a collision");
886    }
887
888    #[test]
889    fn test_fix_preserves_content_exactly() {
890        // Fix should not modify the content between fences
891        let content = "```markdown\n```python\n  indented\n\ttabbed\nspecial: !@#$%\n```\n```\n";
892        let fixed = run_fix(content).unwrap();
893        assert!(fixed.contains("  indented"), "Indentation should be preserved");
894        assert!(fixed.contains("\ttabbed"), "Tabs should be preserved");
895        assert!(fixed.contains("special: !@#$%"), "Special chars should be preserved");
896    }
897
898    #[test]
899    fn test_warning_line_numbers_accurate() {
900        let content = "# Title\n\nParagraph\n\n```markdown\n```python\ncode()\n```\n```\n";
901        let result = run_check(content).unwrap();
902        assert_eq!(result.len(), 1);
903        assert_eq!(result[0].line, 5, "Warning should be on opening fence line");
904        assert!(result[0].message.contains("line 6"), "Collision line should be line 6");
905    }
906
907    #[test]
908    fn test_should_skip_optimization() {
909        let rule = MD070NestedCodeFence::new();
910
911        // No code-like content
912        let ctx1 = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
913        assert!(
914            rule.should_skip(&ctx1),
915            "Should skip content without backticks or tildes"
916        );
917
918        // Has backticks
919        let ctx2 = LintContext::new("Has `code`", crate::config::MarkdownFlavor::Standard, None);
920        assert!(!rule.should_skip(&ctx2), "Should not skip content with backticks");
921
922        // Has tildes
923        let ctx3 = LintContext::new("Has ~~~", crate::config::MarkdownFlavor::Standard, None);
924        assert!(!rule.should_skip(&ctx3), "Should not skip content with tildes");
925
926        // Empty
927        let ctx4 = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
928        assert!(rule.should_skip(&ctx4), "Should skip empty content");
929    }
930
931    #[test]
932    fn test_python_triplestring_fence_collision_fix() {
933        // Reproduces GitHub issue #518: Python triple-quoted strings with embedded
934        // markdown cause premature fence closure
935        let content = "# Test\n\n```python\ndef f():\n    text = \"\"\"\n```json\n{}\n```\n\"\"\"\n```\n";
936        let result = run_check(content).unwrap();
937        assert_eq!(result.len(), 1, "Should detect collision in python block");
938        assert!(result[0].fix.is_some(), "Warning should be marked as fixable");
939
940        let fixed = run_fix(content).unwrap();
941        assert!(
942            fixed.contains("````python"),
943            "Should upgrade opening fence to 4 backticks"
944        );
945        assert!(
946            fixed.contains("````\n") || fixed.ends_with("````"),
947            "Should upgrade closing fence to 4 backticks"
948        );
949        // Content between fences should be preserved
950        assert!(fixed.contains("```json"), "Inner fences should be preserved as content");
951    }
952
953    #[test]
954    fn test_warning_is_fixable() {
955        // All MD070 warnings must have fix.is_some() so the fix coordinator calls fix()
956        let content = "```markdown\n```python\ncode()\n```\n```\n";
957        let result = run_check(content).unwrap();
958        assert_eq!(result.len(), 1);
959        assert!(
960            result[0].fix.is_some(),
961            "MD070 warnings must be marked fixable for the fix coordinator"
962        );
963    }
964
965    #[test]
966    fn test_fix_via_warning_struct_is_safe() {
967        // The Fix on warnings is used directly by the LSP code action path.
968        // It must produce valid output (not delete the fence or corrupt the file).
969        let content = "```markdown\n```python\ncode()\n```\n```\n";
970        let result = run_check(content).unwrap();
971        assert_eq!(result.len(), 1);
972
973        let fix = result[0].fix.as_ref().unwrap();
974        // Apply the Fix directly (simulating LSP path)
975        let mut fixed = String::new();
976        fixed.push_str(&content[..fix.range.start]);
977        fixed.push_str(&fix.replacement);
978        fixed.push_str(&content[fix.range.end..]);
979
980        // The fixed content should have upgraded fences
981        assert!(
982            fixed.contains("````markdown"),
983            "Direct Fix application should upgrade opening fence, got: {fixed}"
984        );
985        assert!(
986            fixed.contains("````\n") || fixed.ends_with("````"),
987            "Direct Fix application should upgrade closing fence, got: {fixed}"
988        );
989        // Content should be preserved
990        assert!(
991            fixed.contains("```python"),
992            "Inner content should be preserved, got: {fixed}"
993        );
994    }
995
996    #[test]
997    fn test_fix_via_warning_struct_python_block() {
998        // Test the LSP code action path for a Python block where CommonMark's
999        // closing fence differs from the user's intended closing fence.
1000        // CommonMark sees: ```python (line 1) closed by bare ``` (line 6).
1001        // User intended: ```python (line 1) closed by ``` (line 10).
1002        let content = "```python\ndef f():\n    text = \"\"\"\n```json\n{}\n```\n\"\"\"\n    print(text)\nf()\n```\n";
1003        let result = run_check(content).unwrap();
1004        assert_eq!(result.len(), 1);
1005
1006        let fix = result[0].fix.as_ref().unwrap();
1007        let mut fixed = String::new();
1008        fixed.push_str(&content[..fix.range.start]);
1009        fixed.push_str(&fix.replacement);
1010        fixed.push_str(&content[fix.range.end..]);
1011
1012        // The Fix must cover the full intended block (lines 1-10), not just
1013        // the CommonMark-visible block (lines 1-6). Verify the fixed content
1014        // has one code block containing ALL the Python code.
1015        assert!(
1016            fixed.starts_with("````python\n"),
1017            "Should upgrade opening fence, got:\n{fixed}"
1018        );
1019        assert!(
1020            fixed.contains("````\n") || fixed.trim_end().ends_with("````"),
1021            "Should upgrade closing fence, got:\n{fixed}"
1022        );
1023        // ALL Python code must be between the fences
1024        let fence_start = fixed.find("````python\n").unwrap();
1025        let after_open = fence_start + "````python\n".len();
1026        let close_pos = fixed[after_open..]
1027            .find("\n````\n")
1028            .or_else(|| fixed[after_open..].find("\n````"));
1029        assert!(
1030            close_pos.is_some(),
1031            "Should have closing fence after content, got:\n{fixed}"
1032        );
1033        let block_content = &fixed[after_open..after_open + close_pos.unwrap()];
1034        assert!(
1035            block_content.contains("print(text)"),
1036            "print(text) must be inside the code block, got block:\n{block_content}"
1037        );
1038        assert!(
1039            block_content.contains("f()"),
1040            "f() must be inside the code block, got block:\n{block_content}"
1041        );
1042        assert!(
1043            block_content.contains("```json"),
1044            "Inner fences must be preserved as content, got block:\n{block_content}"
1045        );
1046    }
1047
1048    #[test]
1049    fn test_fix_via_apply_warning_fixes() {
1050        // End-to-end test of the LSP fix path using apply_warning_fixes
1051        let content = "```markdown\n```python\ncode()\n```\n```\n";
1052        let result = run_check(content).unwrap();
1053        assert_eq!(result.len(), 1);
1054
1055        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &result).unwrap();
1056        assert!(
1057            fixed.contains("````markdown"),
1058            "apply_warning_fixes should upgrade opening fence"
1059        );
1060        assert!(
1061            fixed.contains("````\n") || fixed.ends_with("````"),
1062            "apply_warning_fixes should upgrade closing fence"
1063        );
1064
1065        // Re-check should find no issues
1066        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1067        let rule = MD070NestedCodeFence::new();
1068        let result2 = rule.check(&ctx2).unwrap();
1069        assert!(
1070            result2.is_empty(),
1071            "Re-check after LSP fix should find no issues, got: {:?}",
1072            result2.iter().map(|w| &w.message).collect::<Vec<_>>()
1073        );
1074    }
1075
1076    /// Helper: run fix() then check() on the result, asserting 0 violations remain
1077    fn assert_fix_roundtrip(content: &str, label: &str) {
1078        let fixed = run_fix(content).unwrap();
1079        let rule = MD070NestedCodeFence::new();
1080        let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1081        let remaining = rule.check(&ctx).unwrap();
1082        assert!(
1083            remaining.is_empty(),
1084            "[{label}] fix() should resolve all violations, but {n} remain: {msgs:?}\nFixed content:\n{fixed}",
1085            n = remaining.len(),
1086            msgs = remaining.iter().map(|w| &w.message).collect::<Vec<_>>(),
1087        );
1088    }
1089
1090    #[test]
1091    fn test_fix_roundtrip_basic() {
1092        assert_fix_roundtrip("```markdown\n```python\ncode()\n```\n```\n", "basic collision");
1093    }
1094
1095    #[test]
1096    fn test_fix_roundtrip_longer_inner_fence() {
1097        assert_fix_roundtrip("```markdown\n`````python\ncode()\n`````\n```\n", "longer inner fence");
1098    }
1099
1100    #[test]
1101    fn test_fix_roundtrip_tilde_collision() {
1102        assert_fix_roundtrip("~~~markdown\n~~~python\ncode()\n~~~\n~~~\n", "tilde collision");
1103    }
1104
1105    #[test]
1106    fn test_fix_roundtrip_info_string_attrs() {
1107        assert_fix_roundtrip(
1108            "```markdown {.highlight}\n```python\ncode()\n```\n```\n",
1109            "info string with attrs",
1110        );
1111    }
1112
1113    #[test]
1114    fn test_fix_roundtrip_no_trailing_newline() {
1115        assert_fix_roundtrip("```markdown\n```python\ncode()\n```\n```", "no trailing newline");
1116    }
1117
1118    #[test]
1119    fn test_fix_roundtrip_python_triple_string() {
1120        assert_fix_roundtrip(
1121            "# Test\n\n```python\ndef f():\n    text = \"\"\"\n```json\n{}\n```\n\"\"\"\n```\n",
1122            "python triple string",
1123        );
1124    }
1125
1126    #[test]
1127    fn test_fix_roundtrip_deeply_nested() {
1128        assert_fix_roundtrip(
1129            "```markdown\n````markdown\n```python\ncode()\n```\n````\n```\n",
1130            "deeply nested fences",
1131        );
1132    }
1133
1134    #[test]
1135    fn test_fix_roundtrip_real_world_docs() {
1136        let content = r#"```markdown
11371. First item
1138
1139   ```python
1140   code_in_list()
1141   ```
1142
11431. Second item
1144
1145```
1146"#;
1147        assert_fix_roundtrip(content, "real world docs case");
1148    }
1149
1150    #[test]
1151    fn test_fix_roundtrip_empty_lines() {
1152        assert_fix_roundtrip(
1153            "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n",
1154            "empty lines between fences",
1155        );
1156    }
1157
1158    #[test]
1159    fn test_fix_no_change_when_no_violations() {
1160        let content = "````markdown\n```python\ncode()\n```\n````\n";
1161        let fixed = run_fix(content).unwrap();
1162        assert_eq!(fixed, content, "fix() should not modify content with no violations");
1163    }
1164
1165    #[test]
1166    fn test_fix_roundtrip_consecutive_collisions() {
1167        let content = r#"```markdown
1168```python
1169a()
1170```
1171```
1172
1173```md
1174```ruby
1175b()
1176```
1177```
1178"#;
1179        // Fix and verify each collision is resolved
1180        let fixed = run_fix(content).unwrap();
1181        let rule = MD070NestedCodeFence::new();
1182        let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1183        let remaining = rule.check(&ctx).unwrap();
1184        // At minimum the first block should be fixed; consecutive blocks may
1185        // require multiple passes but the first pass must not make things worse
1186        assert!(
1187            remaining.len() < 2,
1188            "fix() should resolve at least one collision, remaining: {remaining:?}",
1189        );
1190    }
1191}