1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3#[derive(Clone, Default)]
13pub struct MD070NestedCodeFence;
14
15impl MD070NestedCodeFence {
16 pub fn new() -> Self {
17 Self
18 }
19
20 fn should_check_language(lang: &str) -> bool {
23 lang.is_empty() || lang.eq_ignore_ascii_case("markdown") || lang.eq_ignore_ascii_case("md")
24 }
25
26 fn find_fence_collision(content: &str, fence_char: char, outer_fence_length: usize) -> Option<(usize, usize)> {
29 for (line_idx, line) in content.lines().enumerate() {
30 let trimmed = line.trim_start();
31
32 if trimmed.starts_with(fence_char) {
34 let count = trimmed.chars().take_while(|&c| c == fence_char).count();
35
36 if count >= outer_fence_length {
38 let after_fence = &trimmed[count..];
40 if after_fence.is_empty()
46 || after_fence.trim().is_empty()
47 || after_fence
48 .chars()
49 .next()
50 .is_some_and(|c| c.is_alphabetic() || c == '{')
51 {
52 return Some((line_idx, count));
53 }
54 }
55 }
56 }
57 None
58 }
59
60 fn find_safe_fence_length(content: &str, fence_char: char) -> usize {
62 let mut max_fence = 0;
63
64 for line in content.lines() {
65 let trimmed = line.trim_start();
66 if trimmed.starts_with(fence_char) {
67 let count = trimmed.chars().take_while(|&c| c == fence_char).count();
68 if count >= 3 {
69 let after_fence = &trimmed[count..];
71 if after_fence.is_empty()
72 || after_fence.trim().is_empty()
73 || after_fence
74 .chars()
75 .next()
76 .is_some_and(|c| c.is_alphabetic() || c == '{')
77 {
78 max_fence = max_fence.max(count);
79 }
80 }
81 }
82 }
83
84 max_fence
85 }
86
87 fn parse_fence_line(line: &str) -> Option<(usize, char, usize, &str)> {
89 let indent = line.len() - line.trim_start().len();
90 if indent > 3 {
92 return None;
93 }
94
95 let trimmed = line.trim_start();
96
97 if trimmed.starts_with("```") {
98 let count = trimmed.chars().take_while(|&c| c == '`').count();
99 if count >= 3 {
100 let info = trimmed[count..].trim();
101 return Some((indent, '`', count, info));
102 }
103 } else if trimmed.starts_with("~~~") {
104 let count = trimmed.chars().take_while(|&c| c == '~').count();
105 if count >= 3 {
106 let info = trimmed[count..].trim();
107 return Some((indent, '~', count, info));
108 }
109 }
110
111 None
112 }
113
114 fn is_closing_fence(line: &str, fence_char: char, min_length: usize) -> bool {
117 let indent = line.len() - line.trim_start().len();
118 if indent > 3 {
120 return false;
121 }
122
123 let trimmed = line.trim_start();
124 if !trimmed.starts_with(fence_char) {
125 return false;
126 }
127
128 let count = trimmed.chars().take_while(|&c| c == fence_char).count();
129 if count < min_length {
130 return false;
131 }
132
133 trimmed[count..].trim().is_empty()
135 }
136}
137
138impl Rule for MD070NestedCodeFence {
139 fn name(&self) -> &'static str {
140 "MD070"
141 }
142
143 fn description(&self) -> &'static str {
144 "Nested code fence collision - use longer fence to avoid premature closure"
145 }
146
147 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
148 let mut warnings = Vec::new();
149 let lines = ctx.raw_lines();
150
151 let mut i = 0;
152 while i < lines.len() {
153 if let Some(line_info) = ctx.lines.get(i)
155 && (line_info.in_front_matter || line_info.in_html_comment || line_info.in_html_block)
156 {
157 i += 1;
158 continue;
159 }
160
161 if i > 0
167 && let Some(prev_line_info) = ctx.lines.get(i - 1)
168 && prev_line_info.in_code_block
169 {
170 i += 1;
171 continue;
172 }
173
174 let line = lines[i];
175
176 if let Some((_indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
178 let block_start = i;
179
180 let language = info_string.split_whitespace().next().unwrap_or("");
182
183 let mut block_end = None;
185 for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
186 if Self::is_closing_fence(line_j, fence_char, fence_length) {
187 block_end = Some(j);
188 break;
189 }
190 }
191
192 if let Some(end_line) = block_end {
193 if Self::should_check_language(language) {
196 let block_content: String = if block_start + 1 < end_line {
198 lines[(block_start + 1)..end_line].join("\n")
199 } else {
200 String::new()
201 };
202
203 if let Some((collision_line_offset, _collision_length)) =
205 Self::find_fence_collision(&block_content, fence_char, fence_length)
206 {
207 let safe_length = Self::find_safe_fence_length(&block_content, fence_char) + 1;
208 let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
209 let current_fence: String = std::iter::repeat_n(fence_char, fence_length).collect();
210
211 let collision_line_num = block_start + 1 + collision_line_offset + 1; warnings.push(LintWarning {
216 rule_name: Some(self.name().to_string()),
217 message: format!(
218 "Nested {current_fence} at line {collision_line_num} closes block prematurely — use {suggested_fence} for outer fence"
219 ),
220 line: block_start + 1,
221 column: 1,
222 end_line: end_line + 1, end_column: lines[end_line].len() + 1,
224 severity: Severity::Warning,
225 fix: None, });
227 }
228 }
229
230 i = end_line + 1;
232 continue;
233 }
234 }
235
236 i += 1;
237 }
238
239 Ok(warnings)
240 }
241
242 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
243 let content = ctx.content;
244 let mut result = String::new();
245 let lines = ctx.raw_lines();
246
247 let mut i = 0;
248 while i < lines.len() {
249 if let Some(line_info) = ctx.lines.get(i)
251 && (line_info.in_front_matter || line_info.in_html_comment || line_info.in_html_block)
252 {
253 result.push_str(lines[i]);
254 result.push('\n');
255 i += 1;
256 continue;
257 }
258
259 if i > 0
261 && let Some(prev_line_info) = ctx.lines.get(i - 1)
262 && prev_line_info.in_code_block
263 {
264 result.push_str(lines[i]);
265 result.push('\n');
266 i += 1;
267 continue;
268 }
269
270 let line = lines[i];
271
272 if let Some((indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
274 let block_start = i;
275
276 let language = info_string.split_whitespace().next().unwrap_or("");
278
279 let mut first_close = None;
281 for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
282 if Self::is_closing_fence(line_j, fence_char, fence_length) {
283 first_close = Some(j);
284 break;
285 }
286 }
287
288 if let Some(end_line) = first_close {
289 if Self::should_check_language(language) {
291 let block_content: String = if block_start + 1 < end_line {
293 lines[(block_start + 1)..end_line].join("\n")
294 } else {
295 String::new()
296 };
297
298 if Self::find_fence_collision(&block_content, fence_char, fence_length).is_some() {
300 let mut intended_close = end_line;
303 for (j, line_j) in lines.iter().enumerate().skip(end_line + 1) {
304 if Self::is_closing_fence(line_j, fence_char, fence_length) {
305 intended_close = j;
306 } else if Self::parse_fence_line(line_j).is_some_and(|(ind, ch, _, info)| {
309 ind <= indent && ch == fence_char && !info.is_empty()
310 }) {
311 break; }
313 }
314
315 let full_block_content: String = if block_start + 1 < intended_close {
317 lines[(block_start + 1)..intended_close].join("\n")
318 } else {
319 String::new()
320 };
321
322 let safe_length = Self::find_safe_fence_length(&full_block_content, fence_char) + 1;
323 let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
324
325 let opening_indent = " ".repeat(indent);
327 result.push_str(&format!("{opening_indent}{suggested_fence}{info_string}\n"));
328
329 for line_content in &lines[(block_start + 1)..intended_close] {
331 result.push_str(line_content);
332 result.push('\n');
333 }
334
335 let closing_line = lines[intended_close];
337 let closing_indent = closing_line.len() - closing_line.trim_start().len();
338 let closing_indent_str = " ".repeat(closing_indent);
339 result.push_str(&format!("{closing_indent_str}{suggested_fence}\n"));
340
341 i = intended_close + 1;
342 continue;
343 }
344 }
345
346 for line_content in &lines[block_start..=end_line] {
348 result.push_str(line_content);
349 result.push('\n');
350 }
351 i = end_line + 1;
352 continue;
353 }
354 }
355
356 result.push_str(line);
358 result.push('\n');
359 i += 1;
360 }
361
362 if !content.ends_with('\n') && result.ends_with('\n') {
364 result.pop();
365 }
366
367 Ok(result)
368 }
369
370 fn category(&self) -> RuleCategory {
371 RuleCategory::CodeBlock
372 }
373
374 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
375 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
376 }
377
378 fn as_any(&self) -> &dyn std::any::Any {
379 self
380 }
381
382 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
383 where
384 Self: Sized,
385 {
386 Box::new(MD070NestedCodeFence::new())
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use crate::lint_context::LintContext;
394
395 fn run_check(content: &str) -> LintResult {
396 let rule = MD070NestedCodeFence::new();
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 rule.check(&ctx)
399 }
400
401 fn run_fix(content: &str) -> Result<String, LintError> {
402 let rule = MD070NestedCodeFence::new();
403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
404 rule.fix(&ctx)
405 }
406
407 #[test]
408 fn test_no_collision_simple() {
409 let content = "```python\nprint('hello')\n```\n";
410 let result = run_check(content).unwrap();
411 assert!(result.is_empty(), "Simple code block should not trigger warning");
412 }
413
414 #[test]
415 fn test_no_collision_non_doc_language() {
416 let content = "```python\n```bash\necho hello\n```\n```\n";
418 let result = run_check(content).unwrap();
419 assert!(result.is_empty(), "Non-doc language should not be checked");
420 }
421
422 #[test]
423 fn test_collision_markdown_language() {
424 let content = "```markdown\n```python\ncode()\n```\n```\n";
425 let result = run_check(content).unwrap();
426 assert_eq!(result.len(), 1, "Should emit single warning for collision");
427 assert!(result[0].message.contains("Nested"));
428 assert!(result[0].message.contains("closes block prematurely"));
429 assert!(result[0].message.contains("use ````"));
430 }
431
432 #[test]
433 fn test_collision_empty_language() {
434 let content = "```\n```python\ncode()\n```\n```\n";
436 let result = run_check(content).unwrap();
437 assert_eq!(result.len(), 1, "Empty language should be checked");
438 }
439
440 #[test]
441 fn test_no_collision_longer_outer_fence() {
442 let content = "````markdown\n```python\ncode()\n```\n````\n";
443 let result = run_check(content).unwrap();
444 assert!(result.is_empty(), "Longer outer fence should not trigger warning");
445 }
446
447 #[test]
448 fn test_tilde_fence_ignores_backticks() {
449 let content = "~~~markdown\n```python\ncode()\n```\n~~~\n";
451 let result = run_check(content).unwrap();
452 assert!(result.is_empty(), "Different fence types should not collide");
453 }
454
455 #[test]
456 fn test_tilde_collision() {
457 let content = "~~~markdown\n~~~python\ncode()\n~~~\n~~~\n";
458 let result = run_check(content).unwrap();
459 assert_eq!(result.len(), 1, "Same fence type should collide");
460 assert!(result[0].message.contains("~~~~"));
461 }
462
463 #[test]
464 fn test_fix_increases_fence_length() {
465 let content = "```markdown\n```python\ncode()\n```\n```\n";
466 let fixed = run_fix(content).unwrap();
467 assert!(fixed.starts_with("````markdown"), "Should increase to 4 backticks");
468 assert!(
469 fixed.contains("````\n") || fixed.ends_with("````"),
470 "Closing should also be 4 backticks"
471 );
472 }
473
474 #[test]
475 fn test_fix_handles_longer_inner_fence() {
476 let content = "```markdown\n`````python\ncode()\n`````\n```\n";
478 let fixed = run_fix(content).unwrap();
479 assert!(fixed.starts_with("``````markdown"), "Should increase to 6 backticks");
480 }
481
482 #[test]
483 fn test_backticks_in_code_not_fence() {
484 let content = "```markdown\nconst x = `template`;\n```\n";
486 let result = run_check(content).unwrap();
487 assert!(result.is_empty(), "Inline backticks should not be detected as fences");
488 }
489
490 #[test]
491 fn test_preserves_info_string() {
492 let content = "```markdown {.highlight}\n```python\ncode()\n```\n```\n";
493 let fixed = run_fix(content).unwrap();
494 assert!(
495 fixed.contains("````markdown {.highlight}"),
496 "Should preserve info string attributes"
497 );
498 }
499
500 #[test]
501 fn test_md_language_alias() {
502 let content = "```md\n```python\ncode()\n```\n```\n";
503 let result = run_check(content).unwrap();
504 assert_eq!(result.len(), 1, "md should be recognized as markdown");
505 }
506
507 #[test]
508 fn test_real_world_docs_case() {
509 let content = r#"```markdown
5111. First item
512
513 ```python
514 code_in_list()
515 ```
516
5171. Second item
518
519```
520"#;
521 let result = run_check(content).unwrap();
522 assert_eq!(result.len(), 1, "Should emit single warning for nested fence issue");
523 assert!(result[0].message.contains("line 4")); let fixed = run_fix(content).unwrap();
526 assert!(fixed.starts_with("````markdown"), "Should fix with longer fence");
527 }
528
529 #[test]
530 fn test_empty_code_block() {
531 let content = "```markdown\n```\n";
532 let result = run_check(content).unwrap();
533 assert!(result.is_empty(), "Empty code block should not trigger");
534 }
535
536 #[test]
537 fn test_multiple_code_blocks() {
538 let content = r#"```python
542safe code
543```
544
545```markdown
546```python
547collision
548```
549```
550
551```javascript
552also safe
553```
554"#;
555 let result = run_check(content).unwrap();
556 assert_eq!(result.len(), 1, "Should emit single warning for collision");
559 assert!(result[0].message.contains("line 6")); }
561
562 #[test]
563 fn test_single_collision_properly_closed() {
564 let content = r#"```python
566safe code
567```
568
569````markdown
570```python
571collision
572```
573````
574
575```javascript
576also safe
577```
578"#;
579 let result = run_check(content).unwrap();
580 assert!(result.is_empty(), "Properly fenced blocks should not trigger");
581 }
582
583 #[test]
584 fn test_indented_code_block_in_list() {
585 let content = r#"- List item
586 ```markdown
587 ```python
588 nested
589 ```
590 ```
591"#;
592 let result = run_check(content).unwrap();
593 assert_eq!(result.len(), 1, "Should detect collision in indented block");
594 assert!(result[0].message.contains("````"));
595 }
596
597 #[test]
598 fn test_no_false_positive_list_indented_block() {
599 let content = r#"1. List item with code:
603
604 ```json
605 {"key": "value"}
606 ```
607
6082. Another item
609
610 ```python
611 code()
612 ```
613"#;
614 let result = run_check(content).unwrap();
615 assert!(
617 result.is_empty(),
618 "List-indented code blocks should not trigger false positives"
619 );
620 }
621
622 #[test]
625 fn test_case_insensitive_language() {
626 for lang in ["MARKDOWN", "Markdown", "MD", "Md", "mD"] {
628 let content = format!("```{lang}\n```python\ncode()\n```\n```\n");
629 let result = run_check(&content).unwrap();
630 assert_eq!(result.len(), 1, "{lang} should be recognized as markdown");
631 }
632 }
633
634 #[test]
635 fn test_unclosed_outer_fence() {
636 let content = "```markdown\n```python\ncode()\n```\n";
638 let result = run_check(content).unwrap();
639 assert!(result.len() <= 1, "Unclosed fence should not cause issues");
642 }
643
644 #[test]
645 fn test_deeply_nested_fences() {
646 let content = r#"```markdown
648````markdown
649```python
650code()
651```
652````
653```
654"#;
655 let result = run_check(content).unwrap();
656 assert_eq!(result.len(), 1, "Deep nesting should trigger warning");
658 assert!(result[0].message.contains("`````")); }
660
661 #[test]
662 fn test_very_long_fences() {
663 let content = "``````````markdown\n```python\ncode()\n```\n``````````\n";
665 let result = run_check(content).unwrap();
666 assert!(result.is_empty(), "Very long outer fence should not trigger warning");
667 }
668
669 #[test]
670 fn test_blockquote_with_fence() {
671 let content = "> ```markdown\n> ```python\n> code()\n> ```\n> ```\n";
673 let result = run_check(content).unwrap();
674 assert!(result.is_empty() || result.len() == 1);
677 }
678
679 #[test]
680 fn test_fence_with_attributes() {
681 let content = "```markdown {.highlight #example}\n```python\ncode()\n```\n```\n";
683 let result = run_check(content).unwrap();
684 assert_eq!(
685 result.len(),
686 1,
687 "Attributes in info string should not prevent detection"
688 );
689
690 let fixed = run_fix(content).unwrap();
691 assert!(
692 fixed.contains("````markdown {.highlight #example}"),
693 "Attributes should be preserved in fix"
694 );
695 }
696
697 #[test]
698 fn test_trailing_whitespace_in_info_string() {
699 let content = "```markdown \n```python\ncode()\n```\n```\n";
700 let result = run_check(content).unwrap();
701 assert_eq!(result.len(), 1, "Trailing whitespace should not affect detection");
702 }
703
704 #[test]
705 fn test_only_closing_fence_pattern() {
706 let content = "```markdown\nsome text\n```\nmore text\n```\n";
708 let result = run_check(content).unwrap();
709 assert!(result.is_empty(), "Properly closed block should not trigger");
711 }
712
713 #[test]
714 fn test_fence_at_end_of_file_no_newline() {
715 let content = "```markdown\n```python\ncode()\n```\n```";
716 let result = run_check(content).unwrap();
717 assert_eq!(result.len(), 1, "Should detect collision even without trailing newline");
718
719 let fixed = run_fix(content).unwrap();
720 assert!(!fixed.ends_with('\n'), "Should preserve lack of trailing newline");
721 }
722
723 #[test]
724 fn test_empty_lines_between_fences() {
725 let content = "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n";
726 let result = run_check(content).unwrap();
727 assert_eq!(result.len(), 1, "Empty lines should not affect collision detection");
728 }
729
730 #[test]
731 fn test_tab_indented_opening_fence() {
732 let content = "\t```markdown\n```python\ncode()\n```\n```\n";
739 let result = run_check(content).unwrap();
740 assert_eq!(result.len(), 1, "Tab-indented fence is parsed (tab = 1 char)");
742 }
743
744 #[test]
745 fn test_mixed_fence_types_no_collision() {
746 let content = "```markdown\n~~~python\ncode()\n~~~\n```\n";
748 let result = run_check(content).unwrap();
749 assert!(result.is_empty(), "Different fence chars should not collide");
750
751 let content2 = "~~~markdown\n```python\ncode()\n```\n~~~\n";
753 let result2 = run_check(content2).unwrap();
754 assert!(result2.is_empty(), "Different fence chars should not collide");
755 }
756
757 #[test]
758 fn test_frontmatter_not_confused_with_fence() {
759 let content = "---\ntitle: Test\n---\n\n```markdown\n```python\ncode()\n```\n```\n";
761 let result = run_check(content).unwrap();
762 assert_eq!(result.len(), 1, "Should detect collision after frontmatter");
763 }
764
765 #[test]
766 fn test_html_comment_with_fence_inside() {
767 let content = "<!-- ```markdown\n```python\ncode()\n``` -->\n\n```markdown\nreal content\n```\n";
769 let result = run_check(content).unwrap();
770 assert!(result.is_empty(), "Fences in HTML comments should be ignored");
772 }
773
774 #[test]
775 fn test_consecutive_code_blocks() {
776 let content = r#"```markdown
778```python
779a()
780```
781```
782
783```markdown
784```ruby
785b()
786```
787```
788"#;
789 let result = run_check(content).unwrap();
790 assert!(!result.is_empty(), "Should detect collision in first block");
792 }
793
794 #[test]
795 fn test_numeric_info_string() {
796 let content = "```123\n```456\ncode()\n```\n```\n";
798 let result = run_check(content).unwrap();
799 assert!(result.is_empty(), "Numeric info string is not markdown");
801 }
802
803 #[test]
804 fn test_collision_at_exact_length() {
805 let content = "```markdown\n```python\ncode()\n```\n```\n";
808 let result = run_check(content).unwrap();
809 assert_eq!(
810 result.len(),
811 1,
812 "Same-length fence with language should trigger collision"
813 );
814
815 let content2 = "````markdown\n```python\ncode()\n```\n````\n";
817 let result2 = run_check(content2).unwrap();
818 assert!(result2.is_empty(), "Shorter inner fence should not collide");
819
820 let content3 = "```markdown\n```\n";
822 let result3 = run_check(content3).unwrap();
823 assert!(result3.is_empty(), "Empty closing fence is not a collision");
824 }
825
826 #[test]
827 fn test_fix_preserves_content_exactly() {
828 let content = "```markdown\n```python\n indented\n\ttabbed\nspecial: !@#$%\n```\n```\n";
830 let fixed = run_fix(content).unwrap();
831 assert!(fixed.contains(" indented"), "Indentation should be preserved");
832 assert!(fixed.contains("\ttabbed"), "Tabs should be preserved");
833 assert!(fixed.contains("special: !@#$%"), "Special chars should be preserved");
834 }
835
836 #[test]
837 fn test_warning_line_numbers_accurate() {
838 let content = "# Title\n\nParagraph\n\n```markdown\n```python\ncode()\n```\n```\n";
839 let result = run_check(content).unwrap();
840 assert_eq!(result.len(), 1);
841 assert_eq!(result[0].line, 5, "Warning should be on opening fence line");
842 assert!(result[0].message.contains("line 6"), "Collision line should be line 6");
843 }
844
845 #[test]
846 fn test_should_skip_optimization() {
847 let rule = MD070NestedCodeFence::new();
848
849 let ctx1 = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
851 assert!(
852 rule.should_skip(&ctx1),
853 "Should skip content without backticks or tildes"
854 );
855
856 let ctx2 = LintContext::new("Has `code`", crate::config::MarkdownFlavor::Standard, None);
858 assert!(!rule.should_skip(&ctx2), "Should not skip content with backticks");
859
860 let ctx3 = LintContext::new("Has ~~~", crate::config::MarkdownFlavor::Standard, None);
862 assert!(!rule.should_skip(&ctx3), "Should not skip content with tildes");
863
864 let ctx4 = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
866 assert!(rule.should_skip(&ctx4), "Should skip empty content");
867 }
868}