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#[derive(Clone, Default)]
15pub struct MD070NestedCodeFence;
16
17impl MD070NestedCodeFence {
18 pub fn new() -> Self {
19 Self
20 }
21
22 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 ""
32 | "markdown"
33 | "md"
34 | "mdx"
35 | "text"
36 | "txt"
37 | "plain"
38 | "python"
40 | "py"
41 | "ruby"
42 | "rb"
43 | "perl"
44 | "pl"
45 | "php"
46 | "lua"
47 | "r"
48 | "rmd"
49 | "rmarkdown"
50 | "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"
75 | "sh"
76 | "bash"
77 | "zsh"
78 | "fish"
79 | "powershell"
80 | "ps1"
81 | "pwsh"
82 | "yaml"
84 | "yml"
85 | "toml"
86 | "json"
87 | "jsonc"
88 | "json5"
89 | "jinja"
91 | "jinja2"
92 | "handlebars"
93 | "hbs"
94 | "liquid"
95 | "nunjucks"
96 | "njk"
97 | "ejs"
98 | "console"
100 | "terminal"
101 )
102 }
103
104 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 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 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 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 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 fn blocks_from_line_scan(ctx: &crate::lint_context::LintContext) -> bool {
157 ctx.flavor == crate::config::MarkdownFlavor::MDX
158 }
159
160 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 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 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 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 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 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 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 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 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 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 if strip_enclosure(line, &block.enclosing).is_none() {
339 break;
340 }
341 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 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 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
413struct FenceBlock {
415 start: usize,
417 ch: char,
418 len: usize,
420 info: String,
421 open_line: usize,
422 close_line: usize,
424 enclosing: Vec<Enclosure>,
425}
426
427struct Nesting<'c> {
429 openers: Vec<(usize, &'c [Enclosure])>,
432 continued: Vec<usize>,
437 next_shallower: Vec<usize>,
439}
440
441struct FenceLine<'l> {
442 indent: usize,
444 ch: char,
445 len: usize,
446 run_start: usize,
448 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 let mut covered_until = 0;
477 for block in &blocks {
478 if block.open_line < covered_until {
479 continue;
480 }
481 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 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 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 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 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 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 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 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 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 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")); 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 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 assert_eq!(result.len(), 1, "Should emit single warning for collision");
756 assert!(result[0].message.contains("line 6")); }
758
759 #[test]
760 fn test_single_collision_properly_closed() {
761 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 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 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 assert!(
828 result.is_empty(),
829 "List-indented code blocks should not trigger false positives"
830 );
831 }
832
833 #[test]
836 fn test_case_insensitive_language() {
837 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 let content = "```markdown\n```python\ncode()\n```\n";
849 let result = run_check(content).unwrap();
850 assert!(result.len() <= 1, "Unclosed fence should not cause issues");
853 }
854
855 #[test]
856 fn test_deeply_nested_fences() {
857 let content = r#"```markdown
859````markdown
860```python
861code()
862```
863````
864```
865"#;
866 let result = run_check(content).unwrap();
867 assert_eq!(result.len(), 1, "Deep nesting should trigger warning");
869 assert!(result[0].message.contains("`````")); }
871
872 #[test]
873 fn test_very_long_fences() {
874 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 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 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 let content = "```markdown\nsome text\n```\nmore text\n```\n";
923 let result = run_check(content).unwrap();
924 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 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 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 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 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 let content = "<!-- ```markdown\n```python\ncode()\n``` -->\n\n```markdown\nreal content\n```\n";
979 let result = run_check(content).unwrap();
980 assert!(result.is_empty(), "Fences in HTML comments should be ignored");
982 }
983
984 #[test]
985 fn test_consecutive_code_blocks() {
986 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 assert!(!result.is_empty(), "Should detect collision in first block");
1002 }
1003
1004 #[test]
1005 fn test_numeric_info_string() {
1006 let content = "```123\n```456\ncode()\n```\n```\n";
1008 let result = run_check(content).unwrap();
1009 assert!(result.is_empty(), "Numeric info string is not markdown");
1011 }
1012
1013 #[test]
1014 fn test_collision_at_exact_length() {
1015 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 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 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 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 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 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 let ctx3 = LintContext::new("Has ~~~", crate::config::MarkdownFlavor::Standard, None);
1072 assert!(!rule.should_skip(&ctx3), "Should not skip content with tildes");
1073
1074 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 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 assert!(fixed.contains("```json"), "Inner fences should be preserved as content");
1099 }
1100
1101 #[test]
1102 fn test_warning_is_fixable() {
1103 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 let content = "```markdown\n```python\ncode()\n```\n```\n";
1118 let result = run_check(content).unwrap();
1119 assert_eq!(result.len(), 1);
1120
1121 let fixed = apply_fix_edits(content, result[0].fix.as_ref().unwrap());
1123
1124 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 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 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 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 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 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 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 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 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 assert!(
1327 remaining.len() < 2,
1328 "fix() should resolve at least one collision, remaining: {remaining:?}",
1329 );
1330 }
1331
1332 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 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 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 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 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 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 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 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}