Skip to main content

rumdl_lib/rules/
md070_nested_code_fence.rs

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