Skip to main content

rumdl_lib/rules/
md070_nested_code_fence.rs

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