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