Skip to main content

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