Skip to main content

rumdl_lib/rules/
md070_nested_code_fence.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::code_block_utils::{Enclosure, continued_depth, item_runs, strip_enclosure};
3
4/// Rule MD070: Nested code fence collision detection
5///
6/// Detects when a fenced code block contains fence markers that would cause
7/// premature closure. Suggests using longer fences to avoid this issue.
8///
9/// Checks languages where triple backtick sequences commonly appear:
10/// markdown, Python, JavaScript, shell, Rust, Go, and others with multiline
11/// strings, heredocs, template literals, or doc comments.
12///
13/// See [docs/md070.md](../../docs/md070.md) for full documentation.
14#[derive(Clone, Default)]
15pub struct MD070NestedCodeFence;
16
17impl MD070NestedCodeFence {
18    pub fn new() -> Self {
19        Self
20    }
21
22    /// Check if the given language should be checked for nested fences.
23    /// Covers languages where triple backtick sequences commonly appear in source:
24    /// multiline strings with embedded markdown, heredocs, doc comments, template
25    /// literals, and data formats with multiline string values.
26    fn should_check_language(lang: &str) -> bool {
27        let base = lang.split_whitespace().next().unwrap_or("");
28        matches!(
29            base.to_ascii_lowercase().as_str(),
30            // Documentation / markup
31            ""
32                | "markdown"
33                | "md"
34                | "mdx"
35                | "text"
36                | "txt"
37                | "plain"
38                // Multiline strings / docstrings
39                | "python"
40                | "py"
41                | "ruby"
42                | "rb"
43                | "perl"
44                | "pl"
45                | "php"
46                | "lua"
47                | "r"
48                | "rmd"
49                | "rmarkdown"
50                // Template literals / raw strings
51                | "javascript"
52                | "js"
53                | "jsx"
54                | "mjs"
55                | "cjs"
56                | "typescript"
57                | "ts"
58                | "tsx"
59                | "mts"
60                | "rust"
61                | "rs"
62                | "go"
63                | "golang"
64                | "swift"
65                | "kotlin"
66                | "kt"
67                | "kts"
68                | "java"
69                | "csharp"
70                | "cs"
71                | "c#"
72                | "scala"
73                // Shell heredocs
74                | "shell"
75                | "sh"
76                | "bash"
77                | "zsh"
78                | "fish"
79                | "powershell"
80                | "ps1"
81                | "pwsh"
82                // Data / config formats
83                | "yaml"
84                | "yml"
85                | "toml"
86                | "json"
87                | "jsonc"
88                | "json5"
89                // Template engines
90                | "jinja"
91                | "jinja2"
92                | "handlebars"
93                | "hbs"
94                | "liquid"
95                | "nunjucks"
96                | "njk"
97                | "ejs"
98                // Terminal output
99                | "console"
100                | "terminal"
101        )
102    }
103
104    /// A fence-like line of the block's container, with its indentation
105    /// measured from the container's content.
106    fn fence_line<'l>(line: &'l str, enclosing: &[Enclosure]) -> Option<FenceLine<'l>> {
107        let stripped = strip_enclosure(line, enclosing)?;
108        let (indent, text) = stripped.indent(line);
109        let run_start = text?;
110        // Per CommonMark a fence has 0-3 columns of indentation; more is code.
111        if indent > 3 {
112            return None;
113        }
114        let ch = line[run_start..].chars().next().filter(|&c| c == '`' || c == '~')?;
115        let len = line[run_start..].chars().take_while(|&c| c == ch).count();
116        if len < 3 {
117            return None;
118        }
119        Some(FenceLine {
120            indent,
121            ch,
122            len,
123            run_start,
124            rest: &line[run_start + len..],
125        })
126    }
127
128    /// A line that opens a fence of its own: the character, a run, then an info
129    /// string (a backtick fence's info string holds no backtick).
130    fn is_fence_like(fence: &FenceLine<'_>) -> bool {
131        let info = fence.rest.trim_matches([' ', '\t']);
132        if info.is_empty() {
133            return true;
134        }
135        (fence.ch != '`' || !info.contains('`'))
136            && fence.rest.chars().next().is_some_and(|c| c.is_alphabetic() || c == '{')
137    }
138
139    /// A line that opens an independent block of its own: it carries an info
140    /// string (a backtick fence's info string holds no backtick).
141    fn opens_block(fence: &FenceLine<'_>) -> bool {
142        let info = fence.rest.trim_matches([' ', '\t']);
143        !info.is_empty() && (fence.ch != '`' || !info.contains('`'))
144    }
145
146    /// A line CommonMark closes a block of `ch` fences of at least `min_length`
147    /// on: only spaces and tabs follow the run.
148    fn is_closer(fence: &FenceLine<'_>, ch: char, min_length: usize) -> bool {
149        fence.ch == ch && fence.len >= min_length && fence.rest.trim_matches([' ', '\t']).is_empty()
150    }
151
152    /// Whether fences are found from the line text instead of the CommonMark
153    /// blocks. MDX parses JSX and expressions itself, so a fence inside a JSX
154    /// element has no CommonMark block (the parser reads the region as HTML)
155    /// and its container structure is not available.
156    fn blocks_from_line_scan(ctx: &crate::lint_context::LintContext) -> bool {
157        ctx.flavor == crate::config::MarkdownFlavor::MDX
158    }
159
160    /// The closed fenced blocks, from the parser.
161    fn blocks_from_parser(ctx: &crate::lint_context::LintContext) -> Vec<FenceBlock> {
162        let lines = ctx.raw_lines();
163        ctx.code_block_details
164            .iter()
165            .filter(|detail| detail.is_fenced && detail.closed)
166            .filter_map(|detail| {
167                let ch = ctx.content[detail.start..]
168                    .chars()
169                    .next()
170                    .filter(|&c| c == '`' || c == '~')?;
171                let len = ctx.content[detail.start..].chars().take_while(|&c| c == ch).count();
172                let open_line = Self::line_of(ctx, detail.start);
173                let close_line = Self::line_of(ctx, detail.end.saturating_sub(1));
174                (close_line > open_line && close_line < lines.len()).then(|| FenceBlock {
175                    start: detail.start,
176                    ch,
177                    len,
178                    info: detail.info_string.trim().to_string(),
179                    open_line,
180                    close_line,
181                    enclosing: detail.enclosing.clone(),
182                })
183            })
184            .collect()
185    }
186
187    /// The closed fenced blocks of a document the parser cannot see through,
188    /// found from the line text: top-level fences only, minus those the parser
189    /// ended without a closing fence (a container ending ends the block).
190    fn blocks_from_lines(ctx: &crate::lint_context::LintContext) -> Vec<FenceBlock> {
191        let lines = ctx.raw_lines();
192        let unclosed: std::collections::HashSet<usize> = ctx
193            .code_block_details
194            .iter()
195            .filter(|detail| detail.is_fenced && !detail.closed)
196            .map(|detail| Self::line_of(ctx, detail.start))
197            .collect();
198        let mut blocks = Vec::new();
199        let mut i = 0;
200        while i < lines.len() {
201            // A line after a code line is its content or its closer.
202            if i > 0 && ctx.lines.get(i - 1).is_some_and(|prev| prev.in_code_block) {
203                i += 1;
204                continue;
205            }
206            let Some(open) = Self::fence_line(lines[i], &[]) else {
207                i += 1;
208                continue;
209            };
210            // Markup that is not Markdown source opens no fence.
211            if Self::is_ignored_context(&ctx.lines[i]) {
212                i += 1;
213                continue;
214            }
215            if unclosed.contains(&i) {
216                i += 1;
217                continue;
218            }
219            let close = (i + 1..lines.len()).find(|&j| {
220                Self::fence_line(lines[j], &[]).is_some_and(|fence| Self::is_closer(&fence, open.ch, open.len))
221            });
222            let Some(close_line) = close else {
223                i += 1;
224                continue;
225            };
226            blocks.push(FenceBlock {
227                start: ctx.lines[i].byte_offset + open.run_start,
228                ch: open.ch,
229                len: open.len,
230                info: open.rest.trim().to_string(),
231                open_line: i,
232                close_line,
233                enclosing: Vec::new(),
234            });
235            i = close_line + 1;
236        }
237        blocks
238    }
239
240    /// Whether a line sits in front matter, a comment or an HTML block.
241    fn is_ignored_context(line: &crate::lint_context::LineInfo) -> bool {
242        line.in_front_matter || line.in_html_comment || line.in_mdx_comment || line.in_html_block
243    }
244
245    /// The container structure the intended-close search needs. The line scan
246    /// sees no containers, so it gets no openers.
247    fn nesting<'c>(ctx: &'c crate::lint_context::LintContext) -> Nesting<'c> {
248        let mut openers: Vec<_> = if Self::blocks_from_line_scan(ctx) {
249            Vec::new()
250        } else {
251            ctx.code_block_details
252                .iter()
253                .filter(|detail| detail.is_fenced)
254                .map(|detail| (Self::line_of(ctx, detail.start), detail.enclosing.as_slice()))
255                .collect()
256        };
257        openers.sort_by_key(|&(open, _)| open);
258
259        let lines = ctx.raw_lines();
260        let mut continued = vec![usize::MAX; lines.len()];
261        let mut nearest = None;
262        let mut next = 0;
263        for (i, line) in lines.iter().enumerate() {
264            while let Some(&(_, enclosing)) = openers.get(next).filter(|&&(open, _)| open <= i) {
265                nearest = Some((enclosing, item_runs(enclosing)));
266                next += 1;
267            }
268            if let Some((enclosing, runs)) = &nearest {
269                continued[i] = continued_depth(line, enclosing, runs);
270            }
271        }
272
273        let mut next_shallower = vec![lines.len(); lines.len()];
274        let mut deeper: Vec<usize> = Vec::new();
275        for i in (0..lines.len()).rev() {
276            while deeper.last().is_some_and(|&k| continued[k] >= continued[i]) {
277                deeper.pop();
278            }
279            if let Some(&k) = deeper.last() {
280                next_shallower[i] = k;
281            }
282            deeper.push(i);
283        }
284
285        Nesting {
286            openers,
287            continued,
288            next_shallower,
289        }
290    }
291
292    /// Zero-based line holding byte `offset`.
293    fn line_of(ctx: &crate::lint_context::LintContext, offset: usize) -> usize {
294        ctx.lines
295            .partition_point(|line| line.byte_offset <= offset)
296            .saturating_sub(1)
297    }
298
299    /// The warning for one fenced block, when a fence-like line inside it makes
300    /// the parser end it before the author meant it to end.
301    fn check_block(
302        &self,
303        ctx: &crate::lint_context::LintContext,
304        block: &FenceBlock,
305        nesting: &Nesting,
306    ) -> Option<(LintWarning, usize)> {
307        let openers = &nesting.openers;
308        let lines = ctx.raw_lines();
309        let language = block.info.split_whitespace().next().unwrap_or("");
310        if !Self::should_check_language(language) {
311            return None;
312        }
313
314        // The first fence-like line inside the extent the parser gave the block.
315        let collision = (block.open_line + 1..block.close_line).find(|&j| {
316            Self::fence_line(lines[j], &block.enclosing)
317                .is_some_and(|fence| fence.ch == block.ch && fence.len >= block.len && Self::is_fence_like(&fence))
318        })?;
319
320        // Where the author meant the block to end: the last bare fence before
321        // the container ends, a line stops being part of it, or another fence
322        // with an info string begins at the opener's own indentation.
323        let line_start = ctx.lines[block.open_line].byte_offset;
324        let opener_indent = strip_enclosure(&lines[block.open_line][..block.start - line_start], &block.enclosing)
325            .map_or(0, |prefix| {
326                prefix.indent(&lines[block.open_line][..block.start - line_start]).0
327            });
328        let bound = block.enclosing.last().map(Enclosure::end);
329        let depth = block.enclosing.len();
330        let mut intended_close = block.close_line;
331        let mut next_opener = openers.partition_point(|&(open, _)| open <= block.close_line);
332        let mut j = block.close_line + 1;
333        while let Some(line) = lines.get(j) {
334            if bound.is_some_and(|end| ctx.lines[j].byte_offset >= end) {
335                break;
336            }
337            // A line that does not continue every container ends the search.
338            if strip_enclosure(line, &block.enclosing).is_none() {
339                break;
340            }
341            // A quote or list item nested inside the block's container holds
342            // its own blocks, and its fences open and close those, so the
343            // search resumes after it. Any fence of such a container comes at
344            // or after the opener of its first fenced block. A lazy line in it
345            // continues it but not the block's own containers, and ends the
346            // search as it does anywhere else.
347            while openers.get(next_opener).is_some_and(|&(open, _)| open < j) {
348                next_opener += 1;
349            }
350            if let Some(&(_, enclosing)) = openers.get(next_opener).filter(|&&(open, _)| open == j)
351                && let Some(nested) = enclosing.get(depth)
352            {
353                let end = Self::line_of(ctx, nested.end().saturating_sub(1)).max(j);
354                let mut k = j;
355                while k <= end && nesting.continued[k] >= depth {
356                    k = nesting.next_shallower[k];
357                }
358                if k <= end {
359                    break;
360                }
361                j = end + 1;
362                continue;
363            }
364            // A blank line or text decides nothing: the container goes on.
365            if let Some(fence) = Self::fence_line(line, &block.enclosing) {
366                if Self::is_closer(&fence, block.ch, block.len) {
367                    intended_close = j;
368                } else if fence.ch == block.ch && fence.indent <= opener_indent && Self::opens_block(&fence) {
369                    break;
370                }
371            }
372            j += 1;
373        }
374
375        // The longest fence inside what the author meant to hold.
376        let longest = (block.open_line + 1..intended_close)
377            .filter_map(|j| Self::fence_line(lines[j], &block.enclosing))
378            .filter(|fence| fence.ch == block.ch && Self::is_fence_like(fence))
379            .map(|fence| fence.len)
380            .max()
381            .unwrap_or(0);
382        let suggested_fence: String = std::iter::repeat_n(block.ch, longest.max(block.len) + 1).collect();
383
384        let closer = Self::fence_line(lines[intended_close], &block.enclosing)?;
385        let closer_start = ctx.lines[intended_close].byte_offset + closer.run_start;
386        let fix = Fix::with_additional_edits(
387            block.start..block.start + block.len,
388            suggested_fence.clone(),
389            vec![Fix::new(
390                closer_start..closer_start + closer.len,
391                suggested_fence.clone(),
392            )],
393        );
394        let collision_line_num = collision + 1;
395        Some((
396            LintWarning {
397                rule_name: Some(self.name().to_string()),
398                message: format!(
399                    "Code block contains fence markers at line {collision_line_num} that interfere with block parsing — use {suggested_fence} for outer fence"
400                ),
401                line: block.open_line + 1,
402                column: 1,
403                end_line: intended_close + 1,
404                end_column: lines[intended_close].chars().count() + 1,
405                severity: Severity::Warning,
406                fix: Some(fix),
407            },
408            intended_close,
409        ))
410    }
411}
412
413/// A closed fenced block to judge, wherever it was found.
414struct FenceBlock {
415    /// Byte offset of the opening fence's first character.
416    start: usize,
417    ch: char,
418    /// Length of the opening fence.
419    len: usize,
420    info: String,
421    open_line: usize,
422    /// The line the parser ends the block on.
423    close_line: usize,
424    enclosing: Vec<Enclosure>,
425}
426
427/// The containers of a document, as the intended-close search sees them.
428struct Nesting<'c> {
429    /// The opening line and containers of every fenced block the parser found,
430    /// closed or not, in document order.
431    openers: Vec<(usize, &'c [Enclosure])>,
432    /// For each line, how many containers of the nearest fenced block opening
433    /// at or before it the line continues. Every container a search skips
434    /// holds that opener, so the count answers for the skipping block's own
435    /// containers too. `usize::MAX` before the first opener.
436    continued: Vec<usize>,
437    /// For each line, the next line that continues fewer containers.
438    next_shallower: Vec<usize>,
439}
440
441struct FenceLine<'l> {
442    /// Columns of indentation past the container's prefix.
443    indent: usize,
444    ch: char,
445    len: usize,
446    /// Byte offset of the first fence character in the line.
447    run_start: usize,
448    /// What follows the run.
449    rest: &'l str,
450}
451
452impl Rule for MD070NestedCodeFence {
453    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
454        Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(70))
455    }
456
457    fn name(&self) -> &'static str {
458        "MD070"
459    }
460
461    fn description(&self) -> &'static str {
462        "Nested code fence collision - use longer fence to avoid premature closure"
463    }
464
465    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
466        let mut warnings = Vec::new();
467        let blocks = if Self::blocks_from_line_scan(ctx) {
468            Self::blocks_from_lines(ctx)
469        } else {
470            Self::blocks_from_parser(ctx)
471        };
472        let nesting = Self::nesting(ctx);
473
474        // Blocks the author's intended closing fence already covers are skipped,
475        // so no two fixes touch the same lines.
476        let mut covered_until = 0;
477        for block in &blocks {
478            if block.open_line < covered_until {
479                continue;
480            }
481            // Skip contexts that are not Markdown source.
482            let Some(line_info) = ctx.lines.get(block.open_line) else {
483                continue;
484            };
485            if Self::is_ignored_context(line_info)
486                || block.enclosing.iter().any(|e| matches!(e, Enclosure::Opaque { .. }))
487            {
488                continue;
489            }
490            if let Some((warning, intended_close)) = self.check_block(ctx, block, &nesting) {
491                covered_until = intended_close + 1;
492                warnings.push(warning);
493            }
494        }
495
496        Ok(warnings)
497    }
498
499    fn fixes_from_diagnostics(&self) -> bool {
500        true
501    }
502
503    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
504        if self.should_skip(ctx) {
505            return Ok(ctx.content.to_string());
506        }
507        let warnings = self.check(ctx)?;
508        if warnings.is_empty() {
509            return Ok(ctx.content.to_string());
510        }
511        let warnings =
512            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
513        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::FixFailed)
514    }
515
516    fn category(&self) -> RuleCategory {
517        RuleCategory::CodeBlock
518    }
519
520    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
521        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
522    }
523
524    fn as_any(&self) -> &dyn std::any::Any {
525        self
526    }
527
528    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
529    where
530        Self: Sized,
531    {
532        Box::new(MD070NestedCodeFence::new())
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use crate::lint_context::LintContext;
540
541    fn run_check(content: &str) -> LintResult {
542        let rule = MD070NestedCodeFence::new();
543        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544        rule.check(&ctx)
545    }
546
547    /// Apply a Fix's primary and additional edits as one LSP workspace edit does.
548    fn apply_fix_edits(content: &str, fix: &Fix) -> String {
549        let mut edits: Vec<&Fix> = std::iter::once(fix).chain(fix.additional_edits.iter()).collect();
550        edits.sort_by_key(|edit| std::cmp::Reverse(edit.range.start));
551        let mut text = content.to_string();
552        for edit in edits {
553            text.replace_range(edit.range.clone(), &edit.replacement);
554        }
555        text
556    }
557
558    fn run_fix(content: &str) -> Result<String, LintError> {
559        let rule = MD070NestedCodeFence::new();
560        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561        rule.fix(&ctx)
562    }
563
564    #[test]
565    fn test_no_collision_simple() {
566        let content = "```python\nprint('hello')\n```\n";
567        let result = run_check(content).unwrap();
568        assert!(result.is_empty(), "Simple code block should not trigger warning");
569    }
570
571    #[test]
572    fn test_no_collision_unchecked_language() {
573        // C is not checked for nested fences (triple backticks don't appear in C source)
574        let content = "```c\n```bash\necho hello\n```\n```\n";
575        let result = run_check(content).unwrap();
576        assert!(result.is_empty(), "Unchecked language should not trigger");
577    }
578
579    #[test]
580    fn test_collision_python_language() {
581        // Python is checked — triple-quoted strings commonly contain markdown
582        let content = "```python\n```json\n{}\n```\n```\n";
583        let result = run_check(content).unwrap();
584        assert_eq!(result.len(), 1, "Python should be checked for nested fences");
585        assert!(result[0].message.contains("````"));
586    }
587
588    #[test]
589    fn test_collision_javascript_language() {
590        let content = "```javascript\n```html\n<div></div>\n```\n```\n";
591        let result = run_check(content).unwrap();
592        assert_eq!(result.len(), 1, "JavaScript should be checked for nested fences");
593    }
594
595    #[test]
596    fn test_collision_shell_language() {
597        let content = "```bash\n```yaml\nkey: val\n```\n```\n";
598        let result = run_check(content).unwrap();
599        assert_eq!(result.len(), 1, "Shell should be checked for nested fences");
600    }
601
602    #[test]
603    fn test_collision_rust_language() {
604        let content = "```rust\n```toml\n[dep]\n```\n```\n";
605        let result = run_check(content).unwrap();
606        assert_eq!(result.len(), 1, "Rust should be checked for nested fences");
607    }
608
609    #[test]
610    fn test_no_collision_assembly_language() {
611        // Assembly, C, SQL etc. should NOT be checked
612        for lang in ["asm", "c", "cpp", "sql", "css", "fortran"] {
613            let content = format!("```{lang}\n```inner\ncontent\n```\n```\n");
614            let result = run_check(&content).unwrap();
615            assert!(result.is_empty(), "{lang} should not be checked for nested fences");
616        }
617    }
618
619    #[test]
620    fn test_collision_markdown_language() {
621        let content = "```markdown\n```python\ncode()\n```\n```\n";
622        let result = run_check(content).unwrap();
623        assert_eq!(result.len(), 1, "Should emit single warning for collision");
624        assert!(result[0].message.contains("fence markers at line"));
625        assert!(result[0].message.contains("interfere with block parsing"));
626        assert!(result[0].message.contains("use ````"));
627    }
628
629    #[test]
630    fn test_collision_empty_language() {
631        // Empty language (no language specified) is checked
632        let content = "```\n```python\ncode()\n```\n```\n";
633        let result = run_check(content).unwrap();
634        assert_eq!(result.len(), 1, "Empty language should be checked");
635    }
636
637    #[test]
638    fn test_no_collision_longer_outer_fence() {
639        let content = "````markdown\n```python\ncode()\n```\n````\n";
640        let result = run_check(content).unwrap();
641        assert!(result.is_empty(), "Longer outer fence should not trigger warning");
642    }
643
644    #[test]
645    fn test_tilde_fence_ignores_backticks() {
646        // Tildes and backticks don't conflict
647        let content = "~~~markdown\n```python\ncode()\n```\n~~~\n";
648        let result = run_check(content).unwrap();
649        assert!(result.is_empty(), "Different fence types should not collide");
650    }
651
652    #[test]
653    fn test_tilde_collision() {
654        let content = "~~~markdown\n~~~python\ncode()\n~~~\n~~~\n";
655        let result = run_check(content).unwrap();
656        assert_eq!(result.len(), 1, "Same fence type should collide");
657        assert!(result[0].message.contains("~~~~"));
658    }
659
660    #[test]
661    fn test_fix_increases_fence_length() {
662        let content = "```markdown\n```python\ncode()\n```\n```\n";
663        let fixed = run_fix(content).unwrap();
664        assert!(fixed.starts_with("````markdown"), "Should increase to 4 backticks");
665        assert!(
666            fixed.contains("````\n") || fixed.ends_with("````"),
667            "Closing should also be 4 backticks"
668        );
669    }
670
671    #[test]
672    fn test_fix_handles_longer_inner_fence() {
673        // Inner fence has 5 backticks, so outer needs 6
674        let content = "```markdown\n`````python\ncode()\n`````\n```\n";
675        let fixed = run_fix(content).unwrap();
676        assert!(fixed.starts_with("``````markdown"), "Should increase to 6 backticks");
677    }
678
679    #[test]
680    fn test_backticks_in_code_not_fence() {
681        // Template literals in JS shouldn't trigger
682        let content = "```markdown\nconst x = `template`;\n```\n";
683        let result = run_check(content).unwrap();
684        assert!(result.is_empty(), "Inline backticks should not be detected as fences");
685    }
686
687    #[test]
688    fn test_preserves_info_string() {
689        let content = "```markdown {.highlight}\n```python\ncode()\n```\n```\n";
690        let fixed = run_fix(content).unwrap();
691        assert!(
692            fixed.contains("````markdown {.highlight}"),
693            "Should preserve info string attributes"
694        );
695    }
696
697    #[test]
698    fn test_md_language_alias() {
699        let content = "```md\n```python\ncode()\n```\n```\n";
700        let result = run_check(content).unwrap();
701        assert_eq!(result.len(), 1, "md should be recognized as markdown");
702    }
703
704    #[test]
705    fn test_real_world_docs_case() {
706        // This is the actual pattern from docs/md031.md that triggered the PR
707        let content = r#"```markdown
7081. First item
709
710   ```python
711   code_in_list()
712   ```
713
7141. Second item
715
716```
717"#;
718        let result = run_check(content).unwrap();
719        assert_eq!(result.len(), 1, "Should emit single warning for nested fence issue");
720        assert!(result[0].message.contains("line 4")); // The nested ``` is on line 4
721
722        let fixed = run_fix(content).unwrap();
723        assert!(fixed.starts_with("````markdown"), "Should fix with longer fence");
724    }
725
726    #[test]
727    fn test_empty_code_block() {
728        let content = "```markdown\n```\n";
729        let result = run_check(content).unwrap();
730        assert!(result.is_empty(), "Empty code block should not trigger");
731    }
732
733    #[test]
734    fn test_multiple_code_blocks() {
735        // The markdown block has a collision (inner ```python closes it prematurely).
736        // The orphan closing fence (line 9) is NOT treated as a new opening fence
737        // because the context correctly detects it as part of the markdown block.
738        let content = r#"```python
739safe code
740```
741
742```markdown
743```python
744collision
745```
746```
747
748```javascript
749also safe
750```
751"#;
752        let result = run_check(content).unwrap();
753        // Only 1 warning for the markdown block collision.
754        // The orphan fence is correctly ignored (not parsed as new opening fence).
755        assert_eq!(result.len(), 1, "Should emit single warning for collision");
756        assert!(result[0].message.contains("line 6")); // The nested ```python is on line 6
757    }
758
759    #[test]
760    fn test_single_collision_properly_closed() {
761        // When the outer fence is properly longer, only the intended block triggers
762        let content = r#"```python
763safe code
764```
765
766````markdown
767```python
768collision
769```
770````
771
772```javascript
773also safe
774```
775"#;
776        let result = run_check(content).unwrap();
777        assert!(result.is_empty(), "Properly fenced blocks should not trigger");
778    }
779
780    #[test]
781    fn test_indented_code_block_in_list() {
782        let content = r#"- List item
783  ```markdown
784  ```python
785  nested
786  ```
787  ```
788"#;
789        let result = run_check(content).unwrap();
790        assert_eq!(result.len(), 1, "Should detect collision in indented block");
791        assert!(result[0].message.contains("````"));
792    }
793
794    #[test]
795    fn test_unclosed_fence_ends_with_its_list_item() {
796        // CommonMark ends a fence that is never closed with the list item that
797        // holds it, so the paragraph and the fenced block after the list are not
798        // part of it, and the fence after the list cannot collide with it.
799        let content = "# Title\n\n- Item with a nested example:\n\n  ```yaml\n  key: |\n    raw\n\nAfter the list.\n\n```text\nplain\n```\n";
800        let result = run_check(content).unwrap();
801        assert!(
802            result.is_empty(),
803            "A fence ended by its list item must not collide: {result:?}"
804        );
805        assert_eq!(run_fix(content).unwrap(), content);
806    }
807
808    #[test]
809    fn test_no_false_positive_list_indented_block() {
810        // 4-space indented code blocks in list context (GFM extension) should not
811        // cause false positives. The closing fence with 3-space indent should not
812        // be parsed as a new opening fence.
813        let content = r#"1. List item with code:
814
815    ```json
816    {"key": "value"}
817    ```
818
8192. Another item
820
821   ```python
822   code()
823   ```
824"#;
825        let result = run_check(content).unwrap();
826        // No collision - these are separate, well-formed code blocks
827        assert!(
828            result.is_empty(),
829            "List-indented code blocks should not trigger false positives"
830        );
831    }
832
833    // ==================== Comprehensive Edge Case Tests ====================
834
835    #[test]
836    fn test_case_insensitive_language() {
837        // MARKDOWN, Markdown, MD should all be checked
838        for lang in ["MARKDOWN", "Markdown", "MD", "Md", "mD"] {
839            let content = format!("```{lang}\n```python\ncode()\n```\n```\n");
840            let result = run_check(&content).unwrap();
841            assert_eq!(result.len(), 1, "{lang} should be recognized as markdown");
842        }
843    }
844
845    #[test]
846    fn test_unclosed_outer_fence() {
847        // If outer fence is never closed, no collision can be detected
848        let content = "```markdown\n```python\ncode()\n```\n";
849        let result = run_check(content).unwrap();
850        // The outer fence finds ```python as its closing fence (premature close)
851        // Then ```\n at the end becomes orphan - but context would handle this
852        assert!(result.len() <= 1, "Unclosed fence should not cause issues");
853    }
854
855    #[test]
856    fn test_deeply_nested_fences() {
857        // Multiple levels of nesting require progressively longer fences
858        let content = r#"```markdown
859````markdown
860```python
861code()
862```
863````
864```
865"#;
866        let result = run_check(content).unwrap();
867        // The outer ``` sees ```` as collision (4 >= 3)
868        assert_eq!(result.len(), 1, "Deep nesting should trigger warning");
869        assert!(result[0].message.contains("`````")); // Needs 5 to be safe
870    }
871
872    #[test]
873    fn test_very_long_fences() {
874        // 10 backtick fences should work correctly
875        let content = "``````````markdown\n```python\ncode()\n```\n``````````\n";
876        let result = run_check(content).unwrap();
877        assert!(result.is_empty(), "Very long outer fence should not trigger warning");
878    }
879
880    #[test]
881    fn test_blockquote_with_fence() {
882        // The quote's own closer ends the block early, so the outer fence
883        // needs to be longer; the fix touches only the two fence runs.
884        let content = "> ```markdown\n> ```python\n> code()\n> ```\n> ```\n";
885        let result = run_check(content).unwrap();
886        assert_eq!(result.len(), 1);
887        assert_eq!((result[0].line, result[0].end_line), (1, 5));
888        assert_eq!(
889            run_fix(content).unwrap(),
890            "> ````markdown\n> ```python\n> code()\n> ```\n> ````\n"
891        );
892    }
893
894    #[test]
895    fn test_fence_with_attributes() {
896        // Info string with attributes like {.class #id}
897        let content = "```markdown {.highlight #example}\n```python\ncode()\n```\n```\n";
898        let result = run_check(content).unwrap();
899        assert_eq!(
900            result.len(),
901            1,
902            "Attributes in info string should not prevent detection"
903        );
904
905        let fixed = run_fix(content).unwrap();
906        assert!(
907            fixed.contains("````markdown {.highlight #example}"),
908            "Attributes should be preserved in fix"
909        );
910    }
911
912    #[test]
913    fn test_trailing_whitespace_in_info_string() {
914        let content = "```markdown   \n```python\ncode()\n```\n```\n";
915        let result = run_check(content).unwrap();
916        assert_eq!(result.len(), 1, "Trailing whitespace should not affect detection");
917    }
918
919    #[test]
920    fn test_only_closing_fence_pattern() {
921        // Content that has only closing fence patterns (no language)
922        let content = "```markdown\nsome text\n```\nmore text\n```\n";
923        let result = run_check(content).unwrap();
924        // The first ``` closes, second ``` is outside
925        assert!(result.is_empty(), "Properly closed block should not trigger");
926    }
927
928    #[test]
929    fn test_fence_at_end_of_file_no_newline() {
930        let content = "```markdown\n```python\ncode()\n```\n```";
931        let result = run_check(content).unwrap();
932        assert_eq!(result.len(), 1, "Should detect collision even without trailing newline");
933
934        let fixed = run_fix(content).unwrap();
935        assert!(!fixed.ends_with('\n'), "Should preserve lack of trailing newline");
936    }
937
938    #[test]
939    fn test_empty_lines_between_fences() {
940        let content = "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n";
941        let result = run_check(content).unwrap();
942        assert_eq!(result.len(), 1, "Empty lines should not affect collision detection");
943    }
944
945    #[test]
946    fn test_tab_indented_opening_fence() {
947        // A tab is four columns of indentation, so `\t```markdown` is an
948        // indented code block, not a fence: nothing to collide with.
949        let content = "\t```markdown\n```python\ncode()\n```\n```\n";
950        let result = run_check(content).unwrap();
951        assert!(result.is_empty(), "tab-indented line is indented code: {result:?}");
952    }
953
954    #[test]
955    fn test_mixed_fence_types_no_collision() {
956        // Backticks outer, tildes inner - should never collide
957        let content = "```markdown\n~~~python\ncode()\n~~~\n```\n";
958        let result = run_check(content).unwrap();
959        assert!(result.is_empty(), "Different fence chars should not collide");
960
961        // Tildes outer, backticks inner
962        let content2 = "~~~markdown\n```python\ncode()\n```\n~~~\n";
963        let result2 = run_check(content2).unwrap();
964        assert!(result2.is_empty(), "Different fence chars should not collide");
965    }
966
967    #[test]
968    fn test_frontmatter_not_confused_with_fence() {
969        // YAML frontmatter uses --- which shouldn't be confused with fences
970        let content = "---\ntitle: Test\n---\n\n```markdown\n```python\ncode()\n```\n```\n";
971        let result = run_check(content).unwrap();
972        assert_eq!(result.len(), 1, "Should detect collision after frontmatter");
973    }
974
975    #[test]
976    fn test_html_comment_with_fence_inside() {
977        // Fences inside HTML comments should be ignored
978        let content = "<!-- ```markdown\n```python\ncode()\n``` -->\n\n```markdown\nreal content\n```\n";
979        let result = run_check(content).unwrap();
980        // The fences inside HTML comment should be skipped
981        assert!(result.is_empty(), "Fences in HTML comments should be ignored");
982    }
983
984    #[test]
985    fn test_consecutive_code_blocks() {
986        // Multiple consecutive markdown blocks, each with collision
987        let content = r#"```markdown
988```python
989a()
990```
991```
992
993```markdown
994```ruby
995b()
996```
997```
998"#;
999        let result = run_check(content).unwrap();
1000        // Each markdown block has its own collision
1001        assert!(!result.is_empty(), "Should detect collision in first block");
1002    }
1003
1004    #[test]
1005    fn test_numeric_info_string() {
1006        // Numbers after fence - some parsers treat this differently
1007        let content = "```123\n```456\ncode()\n```\n```\n";
1008        let result = run_check(content).unwrap();
1009        // "123" is not "markdown" or "md", so should not check
1010        assert!(result.is_empty(), "Numeric info string is not markdown");
1011    }
1012
1013    #[test]
1014    fn test_collision_at_exact_length() {
1015        // An empty ``` is the closing fence, not a collision.
1016        // For a collision, the inner fence must have content that looks like an opening fence.
1017        let content = "```markdown\n```python\ncode()\n```\n```\n";
1018        let result = run_check(content).unwrap();
1019        assert_eq!(
1020            result.len(),
1021            1,
1022            "Same-length fence with language should trigger collision"
1023        );
1024
1025        // Inner fence one shorter than outer - not a collision
1026        let content2 = "````markdown\n```python\ncode()\n```\n````\n";
1027        let result2 = run_check(content2).unwrap();
1028        assert!(result2.is_empty(), "Shorter inner fence should not collide");
1029
1030        // Empty markdown block followed by another fence - not a collision
1031        let content3 = "```markdown\n```\n";
1032        let result3 = run_check(content3).unwrap();
1033        assert!(result3.is_empty(), "Empty closing fence is not a collision");
1034    }
1035
1036    #[test]
1037    fn test_fix_preserves_content_exactly() {
1038        // Fix should not modify the content between fences
1039        let content = "```markdown\n```python\n  indented\n\ttabbed\nspecial: !@#$%\n```\n```\n";
1040        let fixed = run_fix(content).unwrap();
1041        assert!(fixed.contains("  indented"), "Indentation should be preserved");
1042        assert!(fixed.contains("\ttabbed"), "Tabs should be preserved");
1043        assert!(fixed.contains("special: !@#$%"), "Special chars should be preserved");
1044    }
1045
1046    #[test]
1047    fn test_warning_line_numbers_accurate() {
1048        let content = "# Title\n\nParagraph\n\n```markdown\n```python\ncode()\n```\n```\n";
1049        let result = run_check(content).unwrap();
1050        assert_eq!(result.len(), 1);
1051        assert_eq!(result[0].line, 5, "Warning should be on opening fence line");
1052        assert!(result[0].message.contains("line 6"), "Collision line should be line 6");
1053    }
1054
1055    #[test]
1056    fn test_should_skip_optimization() {
1057        let rule = MD070NestedCodeFence::new();
1058
1059        // No code-like content
1060        let ctx1 = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1061        assert!(
1062            rule.should_skip(&ctx1),
1063            "Should skip content without backticks or tildes"
1064        );
1065
1066        // Has backticks
1067        let ctx2 = LintContext::new("Has `code`", crate::config::MarkdownFlavor::Standard, None);
1068        assert!(!rule.should_skip(&ctx2), "Should not skip content with backticks");
1069
1070        // Has tildes
1071        let ctx3 = LintContext::new("Has ~~~", crate::config::MarkdownFlavor::Standard, None);
1072        assert!(!rule.should_skip(&ctx3), "Should not skip content with tildes");
1073
1074        // Empty
1075        let ctx4 = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1076        assert!(rule.should_skip(&ctx4), "Should skip empty content");
1077    }
1078
1079    #[test]
1080    fn test_python_triplestring_fence_collision_fix() {
1081        // Reproduces GitHub issue #518: Python triple-quoted strings with embedded
1082        // markdown cause premature fence closure
1083        let content = "# Test\n\n```python\ndef f():\n    text = \"\"\"\n```json\n{}\n```\n\"\"\"\n```\n";
1084        let result = run_check(content).unwrap();
1085        assert_eq!(result.len(), 1, "Should detect collision in python block");
1086        assert!(result[0].fix.is_some(), "Warning should be marked as fixable");
1087
1088        let fixed = run_fix(content).unwrap();
1089        assert!(
1090            fixed.contains("````python"),
1091            "Should upgrade opening fence to 4 backticks"
1092        );
1093        assert!(
1094            fixed.contains("````\n") || fixed.ends_with("````"),
1095            "Should upgrade closing fence to 4 backticks"
1096        );
1097        // Content between fences should be preserved
1098        assert!(fixed.contains("```json"), "Inner fences should be preserved as content");
1099    }
1100
1101    #[test]
1102    fn test_warning_is_fixable() {
1103        // All MD070 warnings must have fix.is_some() so the fix coordinator calls fix()
1104        let content = "```markdown\n```python\ncode()\n```\n```\n";
1105        let result = run_check(content).unwrap();
1106        assert_eq!(result.len(), 1);
1107        assert!(
1108            result[0].fix.is_some(),
1109            "MD070 warnings must be marked fixable for the fix coordinator"
1110        );
1111    }
1112
1113    #[test]
1114    fn test_fix_via_warning_struct_is_safe() {
1115        // The Fix on warnings is used directly by the LSP code action path.
1116        // It must produce valid output (not delete the fence or corrupt the file).
1117        let content = "```markdown\n```python\ncode()\n```\n```\n";
1118        let result = run_check(content).unwrap();
1119        assert_eq!(result.len(), 1);
1120
1121        // Apply every edit of the Fix the way an LSP code action does.
1122        let fixed = apply_fix_edits(content, result[0].fix.as_ref().unwrap());
1123
1124        // The fixed content should have upgraded fences
1125        assert!(
1126            fixed.contains("````markdown"),
1127            "Direct Fix application should upgrade opening fence, got: {fixed}"
1128        );
1129        assert!(
1130            fixed.contains("````\n") || fixed.ends_with("````"),
1131            "Direct Fix application should upgrade closing fence, got: {fixed}"
1132        );
1133        // Content should be preserved
1134        assert!(
1135            fixed.contains("```python"),
1136            "Inner content should be preserved, got: {fixed}"
1137        );
1138    }
1139
1140    #[test]
1141    fn test_fix_via_warning_struct_python_block() {
1142        // Test the LSP code action path for a Python block where CommonMark's
1143        // closing fence differs from the user's intended closing fence.
1144        // CommonMark sees: ```python (line 1) closed by bare ``` (line 6).
1145        // User intended: ```python (line 1) closed by ``` (line 10).
1146        let content = "```python\ndef f():\n    text = \"\"\"\n```json\n{}\n```\n\"\"\"\n    print(text)\nf()\n```\n";
1147        let result = run_check(content).unwrap();
1148        assert_eq!(result.len(), 1);
1149
1150        let fixed = apply_fix_edits(content, result[0].fix.as_ref().unwrap());
1151
1152        // The Fix must cover the full intended block (lines 1-10), not just
1153        // the CommonMark-visible block (lines 1-6). Verify the fixed content
1154        // has one code block containing ALL the Python code.
1155        assert!(
1156            fixed.starts_with("````python\n"),
1157            "Should upgrade opening fence, got:\n{fixed}"
1158        );
1159        assert!(
1160            fixed.contains("````\n") || fixed.trim_end().ends_with("````"),
1161            "Should upgrade closing fence, got:\n{fixed}"
1162        );
1163        // ALL Python code must be between the fences
1164        let fence_start = fixed.find("````python\n").unwrap();
1165        let after_open = fence_start + "````python\n".len();
1166        let close_pos = fixed[after_open..]
1167            .find("\n````\n")
1168            .or_else(|| fixed[after_open..].find("\n````"));
1169        assert!(
1170            close_pos.is_some(),
1171            "Should have closing fence after content, got:\n{fixed}"
1172        );
1173        let block_content = &fixed[after_open..after_open + close_pos.unwrap()];
1174        assert!(
1175            block_content.contains("print(text)"),
1176            "print(text) must be inside the code block, got block:\n{block_content}"
1177        );
1178        assert!(
1179            block_content.contains("f()"),
1180            "f() must be inside the code block, got block:\n{block_content}"
1181        );
1182        assert!(
1183            block_content.contains("```json"),
1184            "Inner fences must be preserved as content, got block:\n{block_content}"
1185        );
1186    }
1187
1188    #[test]
1189    fn test_fix_via_apply_warning_fixes() {
1190        // End-to-end test of the LSP fix path using apply_warning_fixes
1191        let content = "```markdown\n```python\ncode()\n```\n```\n";
1192        let result = run_check(content).unwrap();
1193        assert_eq!(result.len(), 1);
1194
1195        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &result).unwrap();
1196        assert!(
1197            fixed.contains("````markdown"),
1198            "apply_warning_fixes should upgrade opening fence"
1199        );
1200        assert!(
1201            fixed.contains("````\n") || fixed.ends_with("````"),
1202            "apply_warning_fixes should upgrade closing fence"
1203        );
1204
1205        // Re-check should find no issues
1206        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1207        let rule = MD070NestedCodeFence::new();
1208        let result2 = rule.check(&ctx2).unwrap();
1209        assert!(
1210            result2.is_empty(),
1211            "Re-check after LSP fix should find no issues, got: {:?}",
1212            result2.iter().map(|w| &w.message).collect::<Vec<_>>()
1213        );
1214    }
1215
1216    /// Helper: run fix() then check() on the result, asserting 0 violations remain
1217    fn assert_fix_roundtrip(content: &str, label: &str) {
1218        let fixed = run_fix(content).unwrap();
1219        let rule = MD070NestedCodeFence::new();
1220        let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1221        let remaining = rule.check(&ctx).unwrap();
1222        assert!(
1223            remaining.is_empty(),
1224            "[{label}] fix() should resolve all violations, but {n} remain: {msgs:?}\nFixed content:\n{fixed}",
1225            n = remaining.len(),
1226            msgs = remaining.iter().map(|w| &w.message).collect::<Vec<_>>(),
1227        );
1228    }
1229
1230    #[test]
1231    fn test_fix_roundtrip_basic() {
1232        assert_fix_roundtrip("```markdown\n```python\ncode()\n```\n```\n", "basic collision");
1233    }
1234
1235    #[test]
1236    fn test_fix_roundtrip_longer_inner_fence() {
1237        assert_fix_roundtrip("```markdown\n`````python\ncode()\n`````\n```\n", "longer inner fence");
1238    }
1239
1240    #[test]
1241    fn test_fix_roundtrip_tilde_collision() {
1242        assert_fix_roundtrip("~~~markdown\n~~~python\ncode()\n~~~\n~~~\n", "tilde collision");
1243    }
1244
1245    #[test]
1246    fn test_fix_roundtrip_info_string_attrs() {
1247        assert_fix_roundtrip(
1248            "```markdown {.highlight}\n```python\ncode()\n```\n```\n",
1249            "info string with attrs",
1250        );
1251    }
1252
1253    #[test]
1254    fn test_fix_roundtrip_no_trailing_newline() {
1255        assert_fix_roundtrip("```markdown\n```python\ncode()\n```\n```", "no trailing newline");
1256    }
1257
1258    #[test]
1259    fn test_fix_roundtrip_python_triple_string() {
1260        assert_fix_roundtrip(
1261            "# Test\n\n```python\ndef f():\n    text = \"\"\"\n```json\n{}\n```\n\"\"\"\n```\n",
1262            "python triple string",
1263        );
1264    }
1265
1266    #[test]
1267    fn test_fix_roundtrip_deeply_nested() {
1268        assert_fix_roundtrip(
1269            "```markdown\n````markdown\n```python\ncode()\n```\n````\n```\n",
1270            "deeply nested fences",
1271        );
1272    }
1273
1274    #[test]
1275    fn test_fix_roundtrip_real_world_docs() {
1276        let content = r#"```markdown
12771. First item
1278
1279   ```python
1280   code_in_list()
1281   ```
1282
12831. Second item
1284
1285```
1286"#;
1287        assert_fix_roundtrip(content, "real world docs case");
1288    }
1289
1290    #[test]
1291    fn test_fix_roundtrip_empty_lines() {
1292        assert_fix_roundtrip(
1293            "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n",
1294            "empty lines between fences",
1295        );
1296    }
1297
1298    #[test]
1299    fn test_fix_no_change_when_no_violations() {
1300        let content = "````markdown\n```python\ncode()\n```\n````\n";
1301        let fixed = run_fix(content).unwrap();
1302        assert_eq!(fixed, content, "fix() should not modify content with no violations");
1303    }
1304
1305    #[test]
1306    fn test_fix_roundtrip_consecutive_collisions() {
1307        let content = r#"```markdown
1308```python
1309a()
1310```
1311```
1312
1313```md
1314```ruby
1315b()
1316```
1317```
1318"#;
1319        // Fix and verify each collision is resolved
1320        let fixed = run_fix(content).unwrap();
1321        let rule = MD070NestedCodeFence::new();
1322        let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1323        let remaining = rule.check(&ctx).unwrap();
1324        // At minimum the first block should be fixed; consecutive blocks may
1325        // require multiple passes but the first pass must not make things worse
1326        assert!(
1327            remaining.len() < 2,
1328            "fix() should resolve at least one collision, remaining: {remaining:?}",
1329        );
1330    }
1331
1332    // ---- container-relative behavior -------------------------------------
1333
1334    const IN_ITEM_VALID_CLOSER: &str = "- a\n\n  ```md\n  x\n     ```\n\n```text\ny\n```\n";
1335
1336    #[test]
1337    fn test_closer_valid_relative_to_list_item_is_not_a_collision() {
1338        // Five raw spaces is three columns past the item's content column.
1339        assert!(run_check(IN_ITEM_VALID_CLOSER).unwrap().is_empty());
1340        assert_eq!(run_fix(IN_ITEM_VALID_CLOSER).unwrap(), IN_ITEM_VALID_CLOSER);
1341    }
1342
1343    #[test]
1344    fn test_inner_fence_four_columns_inside_item_is_code() {
1345        let content = "- a\n\n  ```markdown\n      ```bash\n  ```\n";
1346        assert!(run_check(content).unwrap().is_empty());
1347    }
1348
1349    fn assert_fixes(name: &str, content: &str, expected: &str, lines: (usize, usize)) {
1350        let warnings = run_check(content).unwrap();
1351        assert_eq!(warnings.len(), 1, "{name}: {warnings:?}");
1352        assert_eq!((warnings[0].line, warnings[0].end_line), lines, "{name}: range");
1353        let fixed = run_fix(content).unwrap();
1354        assert_eq!(fixed, expected, "{name}: fix output");
1355        assert!(run_check(&fixed).unwrap().is_empty(), "{name}: fixed output is clean");
1356        assert_eq!(run_fix(&fixed).unwrap(), fixed, "{name}: idempotent");
1357    }
1358
1359    #[test]
1360    fn test_wide_ordered_item() {
1361        assert_fixes(
1362            "wide ordered item",
1363            "10. a\n\n    ```markdown\n    ```bash\n    x\n    ```\n    ```\n",
1364            "10. a\n\n    ````markdown\n    ```bash\n    x\n    ```\n    ````\n",
1365            (3, 7),
1366        );
1367    }
1368
1369    #[test]
1370    fn test_nested_list_item() {
1371        assert_fixes(
1372            "nested list item",
1373            "- a\n  - b\n\n    ```markdown\n    ```bash\n    x\n    ```\n    ```\n",
1374            "- a\n  - b\n\n    ````markdown\n    ```bash\n    x\n    ```\n    ````\n",
1375            (4, 8),
1376        );
1377    }
1378
1379    #[test]
1380    fn test_quote_in_list_item_and_list_item_in_quote() {
1381        assert_fixes(
1382            "quote in item",
1383            "- a\n\n  > ```markdown\n  > ```bash\n  > x\n  > ```\n  > ```\n",
1384            "- a\n\n  > ````markdown\n  > ```bash\n  > x\n  > ```\n  > ````\n",
1385            (3, 7),
1386        );
1387        assert_fixes(
1388            "item in quote",
1389            "> - a\n>\n>   ```markdown\n>   ```bash\n>   x\n>   ```\n>   ```\n",
1390            "> - a\n>\n>   ````markdown\n>   ```bash\n>   x\n>   ```\n>   ````\n",
1391            (3, 7),
1392        );
1393    }
1394
1395    #[test]
1396    fn test_item_width_is_relative_to_a_quote_whose_indent_varies() {
1397        assert_fixes(
1398            "varying quote indent",
1399            "   > - a\n   >\n   >   ```md\n>   ```python\n>   x\n>   ```\n>   ```\n",
1400            "   > - a\n   >\n   >   ````md\n>   ```python\n>   x\n>   ```\n>   ````\n",
1401            (3, 7),
1402        );
1403    }
1404
1405    #[test]
1406    fn test_task_list_item_width_comes_from_the_marker_line() {
1407        assert_fixes(
1408            "task item",
1409            "-  [ ] a\n\n   ```markdown\n   ```python\n   x\n   ```\n   ```\n",
1410            "-  [ ] a\n\n   ````markdown\n   ```python\n   x\n   ```\n   ````\n",
1411            (3, 7),
1412        );
1413    }
1414
1415    #[test]
1416    fn test_tab_indented_item_content() {
1417        assert_fixes(
1418            "tab item",
1419            "-\ta\n\n\t```markdown\n\t```bash\n\tx\n\t```\n\t```\n",
1420            "-\ta\n\n\t````markdown\n\t```bash\n\tx\n\t```\n\t````\n",
1421            (3, 7),
1422        );
1423    }
1424
1425    #[test]
1426    fn test_fence_opened_on_the_marker_line() {
1427        assert_fixes(
1428            "marker line opener",
1429            "- ```markdown\n  ```bash\n  x\n  ```\n  ```\n",
1430            "- ````markdown\n  ```bash\n  x\n  ```\n  ````\n",
1431            (1, 5),
1432        );
1433    }
1434
1435    #[test]
1436    fn test_tilde_fences_in_a_quote() {
1437        assert_fixes(
1438            "tilde quote",
1439            "> ~~~markdown\n> ~~~bash\n> x\n> ~~~\n> ~~~\n",
1440            "> ~~~~markdown\n> ~~~bash\n> x\n> ~~~\n> ~~~~\n",
1441            (1, 5),
1442        );
1443    }
1444
1445    #[test]
1446    fn test_search_stays_inside_the_enclosing_item() {
1447        // The bare fence in the next sibling item is not this block's closer.
1448        let content = "- a\n  - b\n\n    ```markdown\n    ```bash\n    x\n    ```\n- c\n\n  ```\n";
1449        assert_fixes(
1450            "sibling item",
1451            content,
1452            "- a\n  - b\n\n    ````markdown\n    ```bash\n    x\n    ````\n- c\n\n  ```\n",
1453            (4, 7),
1454        );
1455    }
1456
1457    #[test]
1458    fn test_search_stays_inside_the_quote() {
1459        assert_fixes(
1460            "quote bound",
1461            "> ```markdown\n> ```bash\n> x\n> ```\n\n```\n",
1462            "> ````markdown\n> ```bash\n> x\n> ````\n\n```\n",
1463            (1, 4),
1464        );
1465    }
1466
1467    #[test]
1468    fn test_lazy_line_ends_the_search() {
1469        assert_fixes(
1470            "lazy line",
1471            "- a\n\n  ```markdown\n  ```bash\n  x\n  ```\nlazy\n  ```\n",
1472            "- a\n\n  ````markdown\n  ```bash\n  x\n  ````\nlazy\n  ```\n",
1473            (3, 6),
1474        );
1475    }
1476
1477    #[test]
1478    fn test_unicode_space_after_a_fence_does_not_close_it() {
1479        // U+00A0 after the run is info-string text, so it cannot be the closer.
1480        let content = "> ```markdown\n> ```python\n> x\n> ```\n> ```\u{a0}\n> tail\n";
1481        assert_fixes(
1482            "nbsp",
1483            content,
1484            "> ````markdown\n> ```python\n> x\n> ````\n> ```\u{a0}\n> tail\n",
1485            (1, 4),
1486        );
1487    }
1488
1489    #[test]
1490    fn test_fix_edits_only_the_two_fence_runs() {
1491        let content = "- a\n\n  ```markdown   title  \n  ```bash\n  x\n  ```\n  ```   \n";
1492        let rule = MD070NestedCodeFence::new();
1493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494        let warnings = rule.check(&ctx).unwrap();
1495        assert_eq!(warnings.len(), 1);
1496        let fix = warnings[0].fix.as_ref().unwrap();
1497        let mut edits: Vec<_> = std::iter::once(fix).chain(fix.additional_edits.iter()).collect();
1498        edits.sort_by_key(|edit| edit.range.start);
1499        assert_eq!(edits.len(), 2, "opener run and closer run only");
1500        for edit in &edits {
1501            assert_eq!(&content[edit.range.clone()], "```");
1502            assert_eq!(edit.replacement, "````");
1503        }
1504        assert_eq!(
1505            rule.fix(&ctx).unwrap(),
1506            "- a\n\n  ````markdown   title  \n  ```bash\n  x\n  ```\n  ````   \n"
1507        );
1508    }
1509
1510    #[test]
1511    fn test_crlf_and_missing_final_newline_are_preserved() {
1512        let rule = MD070NestedCodeFence::new();
1513        for content in [
1514            "> ```markdown\r\n> ```bash\r\n> x\r\n> ```\r\n> ```\r\n",
1515            "> ```markdown\r\n> ```bash\r\n> x\r\n> ```\r\n> ```",
1516        ] {
1517            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1518            let fixed = rule.fix(&ctx).unwrap();
1519            let expected = content.replacen("```markdown", "````markdown", 1);
1520            let last = expected.rfind("> ```").unwrap();
1521            let expected = format!("{}> ````{}", &expected[..last], &expected[last + 5..]);
1522            assert_eq!(fixed, expected);
1523        }
1524    }
1525
1526    #[test]
1527    fn test_definition_body_is_not_judged() {
1528        // The definition ends before `Outside`; the unrelated fence is not a closer.
1529        let content = "Term\n: ```md\n  ```python\n  x\n  ```\n\nOutside\n\n```\n";
1530        assert!(run_check(content).unwrap().is_empty());
1531    }
1532
1533    #[test]
1534    fn test_mdx_jsx_element_fences_are_still_checked() {
1535        let content = "<Comp>\n```markdown\n```python\nx\n```\n```\n</Comp>\n";
1536        let rule = MD070NestedCodeFence::new();
1537        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1538        let warnings = rule.check(&ctx).unwrap();
1539        assert_eq!(warnings.len(), 1, "{warnings:?}");
1540        assert_eq!(
1541            rule.fix(&ctx).unwrap(),
1542            "<Comp>\n````markdown\n```python\nx\n```\n````\n</Comp>\n"
1543        );
1544    }
1545
1546    #[test]
1547    fn test_search_stops_at_any_independent_fence_with_an_info_string() {
1548        // A spaced or numeric info string opens an independent block; it is not absorbed.
1549        for opener in ["``` json", "```123"] {
1550            let content = format!("```markdown\n```python\nx\n```\n{opener}\ny\n```\n");
1551            let rule = MD070NestedCodeFence::new();
1552            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1553            let warnings = rule.check(&ctx).unwrap();
1554            assert_eq!(warnings.len(), 1, "{opener}: {warnings:?}");
1555            assert_eq!(
1556                warnings[0].end_line, 4,
1557                "{opener}: the block ends at the first bare fence"
1558            );
1559        }
1560    }
1561
1562    #[test]
1563    fn test_search_skips_the_fences_of_a_nested_container() {
1564        // Each item's block ends at its own closer: the child item's closer
1565        // belongs to the child's block, not to the parent's.
1566        let content = "- item\n\n  ```markdown\n  ```python\n  x\n  ```\n\n  - item\n\n    ```markdown\n    ```python\n    x\n    ```\n";
1567        let warnings = run_check(content).unwrap();
1568        let spans: Vec<_> = warnings.iter().map(|w| (w.line, w.end_line)).collect();
1569        assert_eq!(spans, [(3, 6), (10, 13)], "{warnings:?}");
1570    }
1571
1572    #[test]
1573    fn test_lazy_line_in_a_skipped_container_ends_the_search() {
1574        let content = "> ```md\n> ```py\n> x\n> ```\n> > ```js\n> > y\n> > ```\n> > p\nlazy\n> ```\n> tail\n> ```\n";
1575        let warnings = run_check(content).unwrap();
1576        let spans: Vec<_> = warnings.iter().map(|w| (w.line, w.end_line)).collect();
1577        assert_eq!(spans, [(1, 4)], "{warnings:?}");
1578    }
1579
1580    #[test]
1581    fn test_mdx_commented_fence_does_not_hide_a_later_collision() {
1582        let content = "<!--\n```md\n-->\n\n```markdown\n```python\nx\n```\n```\n";
1583        let rule = MD070NestedCodeFence::new();
1584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1585        let warnings = rule.check(&ctx).unwrap();
1586        assert_eq!(warnings.len(), 1, "{warnings:?}");
1587        assert_eq!(warnings[0].line, 5);
1588    }
1589
1590    #[test]
1591    fn test_many_unclosed_fences_in_items_are_linear() {
1592        // Each item's fence is ended by its item; none can collide.
1593        let content = (0..2000).fold(String::new(), |mut acc, i| {
1594            acc.push_str(&format!("- {i}\n\n  ```md\n  x\n\n"));
1595            acc
1596        });
1597        let started = std::time::Instant::now();
1598        assert!(run_check(&content).unwrap().is_empty());
1599        assert!(started.elapsed() < std::time::Duration::from_secs(5));
1600    }
1601}