rumdl_lib/rules/
md070_nested_code_fence.rs

1use crate::rule::{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/// Only checks markdown-related blocks (empty language, "markdown", "md")
9/// since other languages don't use fence syntax.
10///
11/// See [docs/md070.md](../../docs/md070.md) for full documentation.
12#[derive(Clone, Default)]
13pub struct MD070NestedCodeFence;
14
15impl MD070NestedCodeFence {
16    pub fn new() -> Self {
17        Self
18    }
19
20    /// Check if the given language should be checked for nested fences.
21    /// Only markdown-related blocks can have fence collisions.
22    fn should_check_language(lang: &str) -> bool {
23        lang.is_empty() || lang.eq_ignore_ascii_case("markdown") || lang.eq_ignore_ascii_case("md")
24    }
25
26    /// Find the maximum fence length of same-character fences in the content
27    /// Returns (line_offset, fence_length) of the first collision, if any
28    fn find_fence_collision(content: &str, fence_char: char, outer_fence_length: usize) -> Option<(usize, usize)> {
29        for (line_idx, line) in content.lines().enumerate() {
30            let trimmed = line.trim_start();
31
32            // Check if line starts with the same fence character
33            if trimmed.starts_with(fence_char) {
34                let count = trimmed.chars().take_while(|&c| c == fence_char).count();
35
36                // Collision if same char AND at least as long as outer fence
37                if count >= outer_fence_length {
38                    // Verify it looks like a fence line (only fence chars + optional language/whitespace)
39                    let after_fence = &trimmed[count..];
40                    // A fence line is: fence chars + optional language identifier + optional whitespace
41                    // We detect collision if:
42                    // - Line ends after fence chars (closing fence)
43                    // - Line has alphanumeric after fence (opening fence with language)
44                    // - Line has only whitespace after fence
45                    if after_fence.is_empty()
46                        || after_fence.trim().is_empty()
47                        || after_fence
48                            .chars()
49                            .next()
50                            .is_some_and(|c| c.is_alphabetic() || c == '{')
51                    {
52                        return Some((line_idx, count));
53                    }
54                }
55            }
56        }
57        None
58    }
59
60    /// Find the maximum fence length needed to safely contain the content
61    fn find_safe_fence_length(content: &str, fence_char: char) -> usize {
62        let mut max_fence = 0;
63
64        for line in content.lines() {
65            let trimmed = line.trim_start();
66            if trimmed.starts_with(fence_char) {
67                let count = trimmed.chars().take_while(|&c| c == fence_char).count();
68                if count >= 3 {
69                    // Only count valid fence-like patterns
70                    let after_fence = &trimmed[count..];
71                    if after_fence.is_empty()
72                        || after_fence.trim().is_empty()
73                        || after_fence
74                            .chars()
75                            .next()
76                            .is_some_and(|c| c.is_alphabetic() || c == '{')
77                    {
78                        max_fence = max_fence.max(count);
79                    }
80                }
81            }
82        }
83
84        max_fence
85    }
86
87    /// Parse a fence marker from a line, returning (indent, fence_char, fence_length, info_string)
88    fn parse_fence_line(line: &str) -> Option<(usize, char, usize, &str)> {
89        let indent = line.len() - line.trim_start().len();
90        // Per CommonMark, fence must have 0-3 spaces of indentation
91        if indent > 3 {
92            return None;
93        }
94
95        let trimmed = line.trim_start();
96
97        if trimmed.starts_with("```") {
98            let count = trimmed.chars().take_while(|&c| c == '`').count();
99            if count >= 3 {
100                let info = trimmed[count..].trim();
101                return Some((indent, '`', count, info));
102            }
103        } else if trimmed.starts_with("~~~") {
104            let count = trimmed.chars().take_while(|&c| c == '~').count();
105            if count >= 3 {
106                let info = trimmed[count..].trim();
107                return Some((indent, '~', count, info));
108            }
109        }
110
111        None
112    }
113
114    /// Check if a line is a valid closing fence for the given opening fence
115    /// Per CommonMark, closing fences can have 0-3 spaces of indentation regardless of opening fence
116    fn is_closing_fence(line: &str, fence_char: char, min_length: usize) -> bool {
117        let indent = line.len() - line.trim_start().len();
118        // Per CommonMark spec, closing fence can have 0-3 spaces of indentation
119        if indent > 3 {
120            return false;
121        }
122
123        let trimmed = line.trim_start();
124        if !trimmed.starts_with(fence_char) {
125            return false;
126        }
127
128        let count = trimmed.chars().take_while(|&c| c == fence_char).count();
129        if count < min_length {
130            return false;
131        }
132
133        // Closing fence must have only whitespace after fence chars
134        trimmed[count..].trim().is_empty()
135    }
136}
137
138impl Rule for MD070NestedCodeFence {
139    fn name(&self) -> &'static str {
140        "MD070"
141    }
142
143    fn description(&self) -> &'static str {
144        "Nested code fence collision - use longer fence to avoid premature closure"
145    }
146
147    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
148        let content = ctx.content;
149        let mut warnings = Vec::new();
150        let lines: Vec<&str> = content.lines().collect();
151
152        let mut i = 0;
153        while i < lines.len() {
154            // Skip lines in contexts that shouldn't be processed
155            if let Some(line_info) = ctx.lines.get(i)
156                && (line_info.in_front_matter || line_info.in_html_comment || line_info.in_html_block)
157            {
158                i += 1;
159                continue;
160            }
161
162            // Skip if we're already inside a code block (check previous line).
163            // This handles list-indented code blocks (4+ spaces) which our rule doesn't
164            // parse directly, but the context detects correctly. If the previous line
165            // is in a code block, this line is either content or a closing fence for
166            // that block - not a new opening fence.
167            if i > 0
168                && let Some(prev_line_info) = ctx.lines.get(i - 1)
169                && prev_line_info.in_code_block
170            {
171                i += 1;
172                continue;
173            }
174
175            let line = lines[i];
176
177            // Try to parse as opening fence
178            if let Some((_indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
179                let block_start = i;
180
181                // Extract the language (first word of info string)
182                let language = info_string.split_whitespace().next().unwrap_or("");
183
184                // Find the closing fence
185                let mut block_end = None;
186                for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
187                    if Self::is_closing_fence(line_j, fence_char, fence_length) {
188                        block_end = Some(j);
189                        break;
190                    }
191                }
192
193                if let Some(end_line) = block_end {
194                    // We have a complete code block from block_start to end_line
195                    // Check if we should analyze this block
196                    if Self::should_check_language(language) {
197                        // Get the content between fences
198                        let block_content: String = if block_start + 1 < end_line {
199                            lines[(block_start + 1)..end_line].join("\n")
200                        } else {
201                            String::new()
202                        };
203
204                        // Check for fence collision
205                        if let Some((collision_line_offset, _collision_length)) =
206                            Self::find_fence_collision(&block_content, fence_char, fence_length)
207                        {
208                            let safe_length = Self::find_safe_fence_length(&block_content, fence_char) + 1;
209                            let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
210                            let current_fence: String = std::iter::repeat_n(fence_char, fence_length).collect();
211
212                            let collision_line_num = block_start + 1 + collision_line_offset + 1; // 1-indexed
213
214                            // Single warning with clear message
215                            // Format matches other rules: "Problem description — solution"
216                            warnings.push(LintWarning {
217                                rule_name: Some(self.name().to_string()),
218                                message: format!(
219                                    "Nested {current_fence} at line {collision_line_num} closes block prematurely — use {suggested_fence} for outer fence"
220                                ),
221                                line: block_start + 1,
222                                column: 1,
223                                end_line: end_line + 1, // Span includes both fences
224                                end_column: lines[end_line].len() + 1,
225                                severity: Severity::Warning,
226                                fix: None, // Fix is handled by the fix() method which updates both fences
227                            });
228                        }
229                    }
230
231                    // Move past this code block
232                    i = end_line + 1;
233                    continue;
234                }
235            }
236
237            i += 1;
238        }
239
240        Ok(warnings)
241    }
242
243    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
244        let content = ctx.content;
245        let mut result = String::new();
246        let lines: Vec<&str> = content.lines().collect();
247
248        let mut i = 0;
249        while i < lines.len() {
250            // Skip lines in contexts that shouldn't be processed
251            if let Some(line_info) = ctx.lines.get(i)
252                && (line_info.in_front_matter || line_info.in_html_comment || line_info.in_html_block)
253            {
254                result.push_str(lines[i]);
255                result.push('\n');
256                i += 1;
257                continue;
258            }
259
260            // Skip if we're already inside a code block (check previous line)
261            if i > 0
262                && let Some(prev_line_info) = ctx.lines.get(i - 1)
263                && prev_line_info.in_code_block
264            {
265                result.push_str(lines[i]);
266                result.push('\n');
267                i += 1;
268                continue;
269            }
270
271            let line = lines[i];
272
273            // Try to parse as opening fence
274            if let Some((indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
275                let block_start = i;
276
277                // Extract the language
278                let language = info_string.split_whitespace().next().unwrap_or("");
279
280                // Find the first closing fence (what CommonMark sees)
281                let mut first_close = None;
282                for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
283                    if Self::is_closing_fence(line_j, fence_char, fence_length) {
284                        first_close = Some(j);
285                        break;
286                    }
287                }
288
289                if let Some(end_line) = first_close {
290                    // Check if we should fix this block
291                    if Self::should_check_language(language) {
292                        // Get the content between fences
293                        let block_content: String = if block_start + 1 < end_line {
294                            lines[(block_start + 1)..end_line].join("\n")
295                        } else {
296                            String::new()
297                        };
298
299                        // Check for fence collision
300                        if Self::find_fence_collision(&block_content, fence_char, fence_length).is_some() {
301                            // When there's a collision, find the INTENDED closing fence
302                            // This is the last matching closing fence at similar indentation
303                            let mut intended_close = end_line;
304                            for (j, line_j) in lines.iter().enumerate().skip(end_line + 1) {
305                                if Self::is_closing_fence(line_j, fence_char, fence_length) {
306                                    intended_close = j;
307                                    // Don't break - we want the last one in a reasonable range
308                                    // But stop if we hit another opening fence at same indent
309                                } else if Self::parse_fence_line(line_j).is_some_and(|(ind, ch, _, info)| {
310                                    ind <= indent && ch == fence_char && !info.is_empty()
311                                }) {
312                                    break; // Hit a new block, stop looking
313                                }
314                            }
315
316                            // Get content between opening and intended close
317                            let full_block_content: String = if block_start + 1 < intended_close {
318                                lines[(block_start + 1)..intended_close].join("\n")
319                            } else {
320                                String::new()
321                            };
322
323                            let safe_length = Self::find_safe_fence_length(&full_block_content, fence_char) + 1;
324                            let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
325
326                            // Write fixed opening fence
327                            let opening_indent = " ".repeat(indent);
328                            result.push_str(&format!("{opening_indent}{suggested_fence}{info_string}\n"));
329
330                            // Write content
331                            for line_content in &lines[(block_start + 1)..intended_close] {
332                                result.push_str(line_content);
333                                result.push('\n');
334                            }
335
336                            // Write fixed closing fence
337                            let closing_line = lines[intended_close];
338                            let closing_indent = closing_line.len() - closing_line.trim_start().len();
339                            let closing_indent_str = " ".repeat(closing_indent);
340                            result.push_str(&format!("{closing_indent_str}{suggested_fence}\n"));
341
342                            i = intended_close + 1;
343                            continue;
344                        }
345                    }
346
347                    // No collision or not a checked language - preserve as-is
348                    for line_content in &lines[block_start..=end_line] {
349                        result.push_str(line_content);
350                        result.push('\n');
351                    }
352                    i = end_line + 1;
353                    continue;
354                }
355            }
356
357            // Not a fence line, preserve as-is
358            result.push_str(line);
359            result.push('\n');
360            i += 1;
361        }
362
363        // Remove trailing newline if original didn't have one
364        if !content.ends_with('\n') && result.ends_with('\n') {
365            result.pop();
366        }
367
368        Ok(result)
369    }
370
371    fn category(&self) -> RuleCategory {
372        RuleCategory::CodeBlock
373    }
374
375    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
376        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
377    }
378
379    fn as_any(&self) -> &dyn std::any::Any {
380        self
381    }
382
383    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
384    where
385        Self: Sized,
386    {
387        Box::new(MD070NestedCodeFence::new())
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::lint_context::LintContext;
395
396    fn run_check(content: &str) -> LintResult {
397        let rule = MD070NestedCodeFence::new();
398        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399        rule.check(&ctx)
400    }
401
402    fn run_fix(content: &str) -> Result<String, LintError> {
403        let rule = MD070NestedCodeFence::new();
404        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
405        rule.fix(&ctx)
406    }
407
408    #[test]
409    fn test_no_collision_simple() {
410        let content = "```python\nprint('hello')\n```\n";
411        let result = run_check(content).unwrap();
412        assert!(result.is_empty(), "Simple code block should not trigger warning");
413    }
414
415    #[test]
416    fn test_no_collision_non_doc_language() {
417        // Python is not checked for nested fences
418        let content = "```python\n```bash\necho hello\n```\n```\n";
419        let result = run_check(content).unwrap();
420        assert!(result.is_empty(), "Non-doc language should not be checked");
421    }
422
423    #[test]
424    fn test_collision_markdown_language() {
425        let content = "```markdown\n```python\ncode()\n```\n```\n";
426        let result = run_check(content).unwrap();
427        assert_eq!(result.len(), 1, "Should emit single warning for collision");
428        assert!(result[0].message.contains("Nested"));
429        assert!(result[0].message.contains("closes block prematurely"));
430        assert!(result[0].message.contains("use ````"));
431    }
432
433    #[test]
434    fn test_collision_empty_language() {
435        // Empty language (no language specified) is checked
436        let content = "```\n```python\ncode()\n```\n```\n";
437        let result = run_check(content).unwrap();
438        assert_eq!(result.len(), 1, "Empty language should be checked");
439    }
440
441    #[test]
442    fn test_no_collision_longer_outer_fence() {
443        let content = "````markdown\n```python\ncode()\n```\n````\n";
444        let result = run_check(content).unwrap();
445        assert!(result.is_empty(), "Longer outer fence should not trigger warning");
446    }
447
448    #[test]
449    fn test_tilde_fence_ignores_backticks() {
450        // Tildes and backticks don't conflict
451        let content = "~~~markdown\n```python\ncode()\n```\n~~~\n";
452        let result = run_check(content).unwrap();
453        assert!(result.is_empty(), "Different fence types should not collide");
454    }
455
456    #[test]
457    fn test_tilde_collision() {
458        let content = "~~~markdown\n~~~python\ncode()\n~~~\n~~~\n";
459        let result = run_check(content).unwrap();
460        assert_eq!(result.len(), 1, "Same fence type should collide");
461        assert!(result[0].message.contains("~~~~"));
462    }
463
464    #[test]
465    fn test_fix_increases_fence_length() {
466        let content = "```markdown\n```python\ncode()\n```\n```\n";
467        let fixed = run_fix(content).unwrap();
468        assert!(fixed.starts_with("````markdown"), "Should increase to 4 backticks");
469        assert!(
470            fixed.contains("````\n") || fixed.ends_with("````"),
471            "Closing should also be 4 backticks"
472        );
473    }
474
475    #[test]
476    fn test_fix_handles_longer_inner_fence() {
477        // Inner fence has 5 backticks, so outer needs 6
478        let content = "```markdown\n`````python\ncode()\n`````\n```\n";
479        let fixed = run_fix(content).unwrap();
480        assert!(fixed.starts_with("``````markdown"), "Should increase to 6 backticks");
481    }
482
483    #[test]
484    fn test_backticks_in_code_not_fence() {
485        // Template literals in JS shouldn't trigger
486        let content = "```markdown\nconst x = `template`;\n```\n";
487        let result = run_check(content).unwrap();
488        assert!(result.is_empty(), "Inline backticks should not be detected as fences");
489    }
490
491    #[test]
492    fn test_preserves_info_string() {
493        let content = "```markdown {.highlight}\n```python\ncode()\n```\n```\n";
494        let fixed = run_fix(content).unwrap();
495        assert!(
496            fixed.contains("````markdown {.highlight}"),
497            "Should preserve info string attributes"
498        );
499    }
500
501    #[test]
502    fn test_md_language_alias() {
503        let content = "```md\n```python\ncode()\n```\n```\n";
504        let result = run_check(content).unwrap();
505        assert_eq!(result.len(), 1, "md should be recognized as markdown");
506    }
507
508    #[test]
509    fn test_real_world_docs_case() {
510        // This is the actual pattern from docs/md031.md that triggered the PR
511        let content = r#"```markdown
5121. First item
513
514   ```python
515   code_in_list()
516   ```
517
5181. Second item
519
520```
521"#;
522        let result = run_check(content).unwrap();
523        assert_eq!(result.len(), 1, "Should emit single warning for nested fence issue");
524        assert!(result[0].message.contains("line 4")); // The nested ``` is on line 4
525
526        let fixed = run_fix(content).unwrap();
527        assert!(fixed.starts_with("````markdown"), "Should fix with longer fence");
528    }
529
530    #[test]
531    fn test_empty_code_block() {
532        let content = "```markdown\n```\n";
533        let result = run_check(content).unwrap();
534        assert!(result.is_empty(), "Empty code block should not trigger");
535    }
536
537    #[test]
538    fn test_multiple_code_blocks() {
539        // The markdown block has a collision (inner ```python closes it prematurely).
540        // The orphan closing fence (line 9) is NOT treated as a new opening fence
541        // because the context correctly detects it as part of the markdown block.
542        let content = r#"```python
543safe code
544```
545
546```markdown
547```python
548collision
549```
550```
551
552```javascript
553also safe
554```
555"#;
556        let result = run_check(content).unwrap();
557        // Only 1 warning for the markdown block collision.
558        // The orphan fence is correctly ignored (not parsed as new opening fence).
559        assert_eq!(result.len(), 1, "Should emit single warning for collision");
560        assert!(result[0].message.contains("line 6")); // The nested ```python is on line 6
561    }
562
563    #[test]
564    fn test_single_collision_properly_closed() {
565        // When the outer fence is properly longer, only the intended block triggers
566        let content = r#"```python
567safe code
568```
569
570````markdown
571```python
572collision
573```
574````
575
576```javascript
577also safe
578```
579"#;
580        let result = run_check(content).unwrap();
581        assert!(result.is_empty(), "Properly fenced blocks should not trigger");
582    }
583
584    #[test]
585    fn test_indented_code_block_in_list() {
586        let content = r#"- List item
587  ```markdown
588  ```python
589  nested
590  ```
591  ```
592"#;
593        let result = run_check(content).unwrap();
594        assert_eq!(result.len(), 1, "Should detect collision in indented block");
595        assert!(result[0].message.contains("````"));
596    }
597
598    #[test]
599    fn test_no_false_positive_list_indented_block() {
600        // 4-space indented code blocks in list context (GFM extension) should not
601        // cause false positives. The closing fence with 3-space indent should not
602        // be parsed as a new opening fence.
603        let content = r#"1. List item with code:
604
605    ```json
606    {"key": "value"}
607    ```
608
6092. Another item
610
611   ```python
612   code()
613   ```
614"#;
615        let result = run_check(content).unwrap();
616        // No collision - these are separate, well-formed code blocks
617        assert!(
618            result.is_empty(),
619            "List-indented code blocks should not trigger false positives"
620        );
621    }
622
623    // ==================== Comprehensive Edge Case Tests ====================
624
625    #[test]
626    fn test_case_insensitive_language() {
627        // MARKDOWN, Markdown, MD should all be checked
628        for lang in ["MARKDOWN", "Markdown", "MD", "Md", "mD"] {
629            let content = format!("```{lang}\n```python\ncode()\n```\n```\n");
630            let result = run_check(&content).unwrap();
631            assert_eq!(result.len(), 1, "{lang} should be recognized as markdown");
632        }
633    }
634
635    #[test]
636    fn test_unclosed_outer_fence() {
637        // If outer fence is never closed, no collision can be detected
638        let content = "```markdown\n```python\ncode()\n```\n";
639        let result = run_check(content).unwrap();
640        // The outer fence finds ```python as its closing fence (premature close)
641        // Then ```\n at the end becomes orphan - but context would handle this
642        assert!(result.len() <= 1, "Unclosed fence should not cause issues");
643    }
644
645    #[test]
646    fn test_deeply_nested_fences() {
647        // Multiple levels of nesting require progressively longer fences
648        let content = r#"```markdown
649````markdown
650```python
651code()
652```
653````
654```
655"#;
656        let result = run_check(content).unwrap();
657        // The outer ``` sees ```` as collision (4 >= 3)
658        assert_eq!(result.len(), 1, "Deep nesting should trigger warning");
659        assert!(result[0].message.contains("`````")); // Needs 5 to be safe
660    }
661
662    #[test]
663    fn test_very_long_fences() {
664        // 10 backtick fences should work correctly
665        let content = "``````````markdown\n```python\ncode()\n```\n``````````\n";
666        let result = run_check(content).unwrap();
667        assert!(result.is_empty(), "Very long outer fence should not trigger warning");
668    }
669
670    #[test]
671    fn test_blockquote_with_fence() {
672        // Fences inside blockquotes (CommonMark allows this)
673        let content = "> ```markdown\n> ```python\n> code()\n> ```\n> ```\n";
674        let result = run_check(content).unwrap();
675        // Blockquote prefixes are part of the line, so parsing may differ
676        // This documents current behavior
677        assert!(result.is_empty() || result.len() == 1);
678    }
679
680    #[test]
681    fn test_fence_with_attributes() {
682        // Info string with attributes like {.class #id}
683        let content = "```markdown {.highlight #example}\n```python\ncode()\n```\n```\n";
684        let result = run_check(content).unwrap();
685        assert_eq!(
686            result.len(),
687            1,
688            "Attributes in info string should not prevent detection"
689        );
690
691        let fixed = run_fix(content).unwrap();
692        assert!(
693            fixed.contains("````markdown {.highlight #example}"),
694            "Attributes should be preserved in fix"
695        );
696    }
697
698    #[test]
699    fn test_trailing_whitespace_in_info_string() {
700        let content = "```markdown   \n```python\ncode()\n```\n```\n";
701        let result = run_check(content).unwrap();
702        assert_eq!(result.len(), 1, "Trailing whitespace should not affect detection");
703    }
704
705    #[test]
706    fn test_only_closing_fence_pattern() {
707        // Content that has only closing fence patterns (no language)
708        let content = "```markdown\nsome text\n```\nmore text\n```\n";
709        let result = run_check(content).unwrap();
710        // The first ``` closes, second ``` is outside
711        assert!(result.is_empty(), "Properly closed block should not trigger");
712    }
713
714    #[test]
715    fn test_fence_at_end_of_file_no_newline() {
716        let content = "```markdown\n```python\ncode()\n```\n```";
717        let result = run_check(content).unwrap();
718        assert_eq!(result.len(), 1, "Should detect collision even without trailing newline");
719
720        let fixed = run_fix(content).unwrap();
721        assert!(!fixed.ends_with('\n'), "Should preserve lack of trailing newline");
722    }
723
724    #[test]
725    fn test_empty_lines_between_fences() {
726        let content = "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n";
727        let result = run_check(content).unwrap();
728        assert_eq!(result.len(), 1, "Empty lines should not affect collision detection");
729    }
730
731    #[test]
732    fn test_tab_indented_opening_fence() {
733        // Tab at start of line - CommonMark says tab = 4 spaces for indentation.
734        // A 4-space indented fence is NOT a valid fenced code block per CommonMark
735        // (only 0-3 spaces allowed). However, our implementation counts characters,
736        // treating tab as 1 character. This means tab-indented fences ARE parsed.
737        // This is intentional: consistent with other rules in rumdl and matches
738        // common editor behavior where tab = 1 indent level.
739        let content = "\t```markdown\n```python\ncode()\n```\n```\n";
740        let result = run_check(content).unwrap();
741        // With tab treated as 1 char (< 3), this IS parsed as a fence and triggers collision
742        assert_eq!(result.len(), 1, "Tab-indented fence is parsed (tab = 1 char)");
743    }
744
745    #[test]
746    fn test_mixed_fence_types_no_collision() {
747        // Backticks outer, tildes inner - should never collide
748        let content = "```markdown\n~~~python\ncode()\n~~~\n```\n";
749        let result = run_check(content).unwrap();
750        assert!(result.is_empty(), "Different fence chars should not collide");
751
752        // Tildes outer, backticks inner
753        let content2 = "~~~markdown\n```python\ncode()\n```\n~~~\n";
754        let result2 = run_check(content2).unwrap();
755        assert!(result2.is_empty(), "Different fence chars should not collide");
756    }
757
758    #[test]
759    fn test_frontmatter_not_confused_with_fence() {
760        // YAML frontmatter uses --- which shouldn't be confused with fences
761        let content = "---\ntitle: Test\n---\n\n```markdown\n```python\ncode()\n```\n```\n";
762        let result = run_check(content).unwrap();
763        assert_eq!(result.len(), 1, "Should detect collision after frontmatter");
764    }
765
766    #[test]
767    fn test_html_comment_with_fence_inside() {
768        // Fences inside HTML comments should be ignored
769        let content = "<!-- ```markdown\n```python\ncode()\n``` -->\n\n```markdown\nreal content\n```\n";
770        let result = run_check(content).unwrap();
771        // The fences inside HTML comment should be skipped
772        assert!(result.is_empty(), "Fences in HTML comments should be ignored");
773    }
774
775    #[test]
776    fn test_consecutive_code_blocks() {
777        // Multiple consecutive markdown blocks, each with collision
778        let content = r#"```markdown
779```python
780a()
781```
782```
783
784```markdown
785```ruby
786b()
787```
788```
789"#;
790        let result = run_check(content).unwrap();
791        // Each markdown block has its own collision
792        assert!(!result.is_empty(), "Should detect collision in first block");
793    }
794
795    #[test]
796    fn test_numeric_info_string() {
797        // Numbers after fence - some parsers treat this differently
798        let content = "```123\n```456\ncode()\n```\n```\n";
799        let result = run_check(content).unwrap();
800        // "123" is not "markdown" or "md", so should not check
801        assert!(result.is_empty(), "Numeric info string is not markdown");
802    }
803
804    #[test]
805    fn test_collision_at_exact_length() {
806        // An empty ``` is the closing fence, not a collision.
807        // For a collision, the inner fence must have content that looks like an opening fence.
808        let content = "```markdown\n```python\ncode()\n```\n```\n";
809        let result = run_check(content).unwrap();
810        assert_eq!(
811            result.len(),
812            1,
813            "Same-length fence with language should trigger collision"
814        );
815
816        // Inner fence one shorter than outer - not a collision
817        let content2 = "````markdown\n```python\ncode()\n```\n````\n";
818        let result2 = run_check(content2).unwrap();
819        assert!(result2.is_empty(), "Shorter inner fence should not collide");
820
821        // Empty markdown block followed by another fence - not a collision
822        let content3 = "```markdown\n```\n";
823        let result3 = run_check(content3).unwrap();
824        assert!(result3.is_empty(), "Empty closing fence is not a collision");
825    }
826
827    #[test]
828    fn test_fix_preserves_content_exactly() {
829        // Fix should not modify the content between fences
830        let content = "```markdown\n```python\n  indented\n\ttabbed\nspecial: !@#$%\n```\n```\n";
831        let fixed = run_fix(content).unwrap();
832        assert!(fixed.contains("  indented"), "Indentation should be preserved");
833        assert!(fixed.contains("\ttabbed"), "Tabs should be preserved");
834        assert!(fixed.contains("special: !@#$%"), "Special chars should be preserved");
835    }
836
837    #[test]
838    fn test_warning_line_numbers_accurate() {
839        let content = "# Title\n\nParagraph\n\n```markdown\n```python\ncode()\n```\n```\n";
840        let result = run_check(content).unwrap();
841        assert_eq!(result.len(), 1);
842        assert_eq!(result[0].line, 5, "Warning should be on opening fence line");
843        assert!(result[0].message.contains("line 6"), "Collision line should be line 6");
844    }
845
846    #[test]
847    fn test_should_skip_optimization() {
848        let rule = MD070NestedCodeFence::new();
849
850        // No code-like content
851        let ctx1 = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
852        assert!(
853            rule.should_skip(&ctx1),
854            "Should skip content without backticks or tildes"
855        );
856
857        // Has backticks
858        let ctx2 = LintContext::new("Has `code`", crate::config::MarkdownFlavor::Standard, None);
859        assert!(!rule.should_skip(&ctx2), "Should not skip content with backticks");
860
861        // Has tildes
862        let ctx3 = LintContext::new("Has ~~~", crate::config::MarkdownFlavor::Standard, None);
863        assert!(!rule.should_skip(&ctx3), "Should not skip content with tildes");
864
865        // Empty
866        let ctx4 = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
867        assert!(rule.should_skip(&ctx4), "Should skip empty content");
868    }
869}