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 ctx.is_rule_disabled(self.name(), i + 1) {
251 result.push_str(lines[i]);
252 result.push('\n');
253 i += 1;
254 continue;
255 }
256
257 if let Some(line_info) = ctx.lines.get(i)
259 && (line_info.in_front_matter || line_info.in_html_comment || line_info.in_html_block)
260 {
261 result.push_str(lines[i]);
262 result.push('\n');
263 i += 1;
264 continue;
265 }
266
267 if i > 0
269 && let Some(prev_line_info) = ctx.lines.get(i - 1)
270 && prev_line_info.in_code_block
271 {
272 result.push_str(lines[i]);
273 result.push('\n');
274 i += 1;
275 continue;
276 }
277
278 let line = lines[i];
279
280 if let Some((indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
282 let block_start = i;
283
284 let language = info_string.split_whitespace().next().unwrap_or("");
286
287 let mut first_close = None;
289 for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
290 if Self::is_closing_fence(line_j, fence_char, fence_length) {
291 first_close = Some(j);
292 break;
293 }
294 }
295
296 if let Some(end_line) = first_close {
297 if Self::should_check_language(language) {
299 let block_content: String = if block_start + 1 < end_line {
301 lines[(block_start + 1)..end_line].join("\n")
302 } else {
303 String::new()
304 };
305
306 if Self::find_fence_collision(&block_content, fence_char, fence_length).is_some() {
308 let mut intended_close = end_line;
311 for (j, line_j) in lines.iter().enumerate().skip(end_line + 1) {
312 if Self::is_closing_fence(line_j, fence_char, fence_length) {
313 intended_close = j;
314 } else if Self::parse_fence_line(line_j).is_some_and(|(ind, ch, _, info)| {
317 ind <= indent && ch == fence_char && !info.is_empty()
318 }) {
319 break; }
321 }
322
323 let full_block_content: String = if block_start + 1 < intended_close {
325 lines[(block_start + 1)..intended_close].join("\n")
326 } else {
327 String::new()
328 };
329
330 let safe_length = Self::find_safe_fence_length(&full_block_content, fence_char) + 1;
331 let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
332
333 let opening_indent = " ".repeat(indent);
335 result.push_str(&format!("{opening_indent}{suggested_fence}{info_string}\n"));
336
337 for line_content in &lines[(block_start + 1)..intended_close] {
339 result.push_str(line_content);
340 result.push('\n');
341 }
342
343 let closing_line = lines[intended_close];
345 let closing_indent = closing_line.len() - closing_line.trim_start().len();
346 let closing_indent_str = " ".repeat(closing_indent);
347 result.push_str(&format!("{closing_indent_str}{suggested_fence}\n"));
348
349 i = intended_close + 1;
350 continue;
351 }
352 }
353
354 for line_content in &lines[block_start..=end_line] {
356 result.push_str(line_content);
357 result.push('\n');
358 }
359 i = end_line + 1;
360 continue;
361 }
362 }
363
364 result.push_str(line);
366 result.push('\n');
367 i += 1;
368 }
369
370 if !content.ends_with('\n') && result.ends_with('\n') {
372 result.pop();
373 }
374
375 Ok(result)
376 }
377
378 fn category(&self) -> RuleCategory {
379 RuleCategory::CodeBlock
380 }
381
382 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
383 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
384 }
385
386 fn as_any(&self) -> &dyn std::any::Any {
387 self
388 }
389
390 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
391 where
392 Self: Sized,
393 {
394 Box::new(MD070NestedCodeFence::new())
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::lint_context::LintContext;
402
403 fn run_check(content: &str) -> LintResult {
404 let rule = MD070NestedCodeFence::new();
405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
406 rule.check(&ctx)
407 }
408
409 fn run_fix(content: &str) -> Result<String, LintError> {
410 let rule = MD070NestedCodeFence::new();
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412 rule.fix(&ctx)
413 }
414
415 #[test]
416 fn test_no_collision_simple() {
417 let content = "```python\nprint('hello')\n```\n";
418 let result = run_check(content).unwrap();
419 assert!(result.is_empty(), "Simple code block should not trigger warning");
420 }
421
422 #[test]
423 fn test_no_collision_non_doc_language() {
424 let content = "```python\n```bash\necho hello\n```\n```\n";
426 let result = run_check(content).unwrap();
427 assert!(result.is_empty(), "Non-doc language should not be checked");
428 }
429
430 #[test]
431 fn test_collision_markdown_language() {
432 let content = "```markdown\n```python\ncode()\n```\n```\n";
433 let result = run_check(content).unwrap();
434 assert_eq!(result.len(), 1, "Should emit single warning for collision");
435 assert!(result[0].message.contains("Nested"));
436 assert!(result[0].message.contains("closes block prematurely"));
437 assert!(result[0].message.contains("use ````"));
438 }
439
440 #[test]
441 fn test_collision_empty_language() {
442 let content = "```\n```python\ncode()\n```\n```\n";
444 let result = run_check(content).unwrap();
445 assert_eq!(result.len(), 1, "Empty language should be checked");
446 }
447
448 #[test]
449 fn test_no_collision_longer_outer_fence() {
450 let content = "````markdown\n```python\ncode()\n```\n````\n";
451 let result = run_check(content).unwrap();
452 assert!(result.is_empty(), "Longer outer fence should not trigger warning");
453 }
454
455 #[test]
456 fn test_tilde_fence_ignores_backticks() {
457 let content = "~~~markdown\n```python\ncode()\n```\n~~~\n";
459 let result = run_check(content).unwrap();
460 assert!(result.is_empty(), "Different fence types should not collide");
461 }
462
463 #[test]
464 fn test_tilde_collision() {
465 let content = "~~~markdown\n~~~python\ncode()\n~~~\n~~~\n";
466 let result = run_check(content).unwrap();
467 assert_eq!(result.len(), 1, "Same fence type should collide");
468 assert!(result[0].message.contains("~~~~"));
469 }
470
471 #[test]
472 fn test_fix_increases_fence_length() {
473 let content = "```markdown\n```python\ncode()\n```\n```\n";
474 let fixed = run_fix(content).unwrap();
475 assert!(fixed.starts_with("````markdown"), "Should increase to 4 backticks");
476 assert!(
477 fixed.contains("````\n") || fixed.ends_with("````"),
478 "Closing should also be 4 backticks"
479 );
480 }
481
482 #[test]
483 fn test_fix_handles_longer_inner_fence() {
484 let content = "```markdown\n`````python\ncode()\n`````\n```\n";
486 let fixed = run_fix(content).unwrap();
487 assert!(fixed.starts_with("``````markdown"), "Should increase to 6 backticks");
488 }
489
490 #[test]
491 fn test_backticks_in_code_not_fence() {
492 let content = "```markdown\nconst x = `template`;\n```\n";
494 let result = run_check(content).unwrap();
495 assert!(result.is_empty(), "Inline backticks should not be detected as fences");
496 }
497
498 #[test]
499 fn test_preserves_info_string() {
500 let content = "```markdown {.highlight}\n```python\ncode()\n```\n```\n";
501 let fixed = run_fix(content).unwrap();
502 assert!(
503 fixed.contains("````markdown {.highlight}"),
504 "Should preserve info string attributes"
505 );
506 }
507
508 #[test]
509 fn test_md_language_alias() {
510 let content = "```md\n```python\ncode()\n```\n```\n";
511 let result = run_check(content).unwrap();
512 assert_eq!(result.len(), 1, "md should be recognized as markdown");
513 }
514
515 #[test]
516 fn test_real_world_docs_case() {
517 let content = r#"```markdown
5191. First item
520
521 ```python
522 code_in_list()
523 ```
524
5251. Second item
526
527```
528"#;
529 let result = run_check(content).unwrap();
530 assert_eq!(result.len(), 1, "Should emit single warning for nested fence issue");
531 assert!(result[0].message.contains("line 4")); let fixed = run_fix(content).unwrap();
534 assert!(fixed.starts_with("````markdown"), "Should fix with longer fence");
535 }
536
537 #[test]
538 fn test_empty_code_block() {
539 let content = "```markdown\n```\n";
540 let result = run_check(content).unwrap();
541 assert!(result.is_empty(), "Empty code block should not trigger");
542 }
543
544 #[test]
545 fn test_multiple_code_blocks() {
546 let content = r#"```python
550safe code
551```
552
553```markdown
554```python
555collision
556```
557```
558
559```javascript
560also safe
561```
562"#;
563 let result = run_check(content).unwrap();
564 assert_eq!(result.len(), 1, "Should emit single warning for collision");
567 assert!(result[0].message.contains("line 6")); }
569
570 #[test]
571 fn test_single_collision_properly_closed() {
572 let content = r#"```python
574safe code
575```
576
577````markdown
578```python
579collision
580```
581````
582
583```javascript
584also safe
585```
586"#;
587 let result = run_check(content).unwrap();
588 assert!(result.is_empty(), "Properly fenced blocks should not trigger");
589 }
590
591 #[test]
592 fn test_indented_code_block_in_list() {
593 let content = r#"- List item
594 ```markdown
595 ```python
596 nested
597 ```
598 ```
599"#;
600 let result = run_check(content).unwrap();
601 assert_eq!(result.len(), 1, "Should detect collision in indented block");
602 assert!(result[0].message.contains("````"));
603 }
604
605 #[test]
606 fn test_no_false_positive_list_indented_block() {
607 let content = r#"1. List item with code:
611
612 ```json
613 {"key": "value"}
614 ```
615
6162. Another item
617
618 ```python
619 code()
620 ```
621"#;
622 let result = run_check(content).unwrap();
623 assert!(
625 result.is_empty(),
626 "List-indented code blocks should not trigger false positives"
627 );
628 }
629
630 #[test]
633 fn test_case_insensitive_language() {
634 for lang in ["MARKDOWN", "Markdown", "MD", "Md", "mD"] {
636 let content = format!("```{lang}\n```python\ncode()\n```\n```\n");
637 let result = run_check(&content).unwrap();
638 assert_eq!(result.len(), 1, "{lang} should be recognized as markdown");
639 }
640 }
641
642 #[test]
643 fn test_unclosed_outer_fence() {
644 let content = "```markdown\n```python\ncode()\n```\n";
646 let result = run_check(content).unwrap();
647 assert!(result.len() <= 1, "Unclosed fence should not cause issues");
650 }
651
652 #[test]
653 fn test_deeply_nested_fences() {
654 let content = r#"```markdown
656````markdown
657```python
658code()
659```
660````
661```
662"#;
663 let result = run_check(content).unwrap();
664 assert_eq!(result.len(), 1, "Deep nesting should trigger warning");
666 assert!(result[0].message.contains("`````")); }
668
669 #[test]
670 fn test_very_long_fences() {
671 let content = "``````````markdown\n```python\ncode()\n```\n``````````\n";
673 let result = run_check(content).unwrap();
674 assert!(result.is_empty(), "Very long outer fence should not trigger warning");
675 }
676
677 #[test]
678 fn test_blockquote_with_fence() {
679 let content = "> ```markdown\n> ```python\n> code()\n> ```\n> ```\n";
681 let result = run_check(content).unwrap();
682 assert!(result.is_empty() || result.len() == 1);
685 }
686
687 #[test]
688 fn test_fence_with_attributes() {
689 let content = "```markdown {.highlight #example}\n```python\ncode()\n```\n```\n";
691 let result = run_check(content).unwrap();
692 assert_eq!(
693 result.len(),
694 1,
695 "Attributes in info string should not prevent detection"
696 );
697
698 let fixed = run_fix(content).unwrap();
699 assert!(
700 fixed.contains("````markdown {.highlight #example}"),
701 "Attributes should be preserved in fix"
702 );
703 }
704
705 #[test]
706 fn test_trailing_whitespace_in_info_string() {
707 let content = "```markdown \n```python\ncode()\n```\n```\n";
708 let result = run_check(content).unwrap();
709 assert_eq!(result.len(), 1, "Trailing whitespace should not affect detection");
710 }
711
712 #[test]
713 fn test_only_closing_fence_pattern() {
714 let content = "```markdown\nsome text\n```\nmore text\n```\n";
716 let result = run_check(content).unwrap();
717 assert!(result.is_empty(), "Properly closed block should not trigger");
719 }
720
721 #[test]
722 fn test_fence_at_end_of_file_no_newline() {
723 let content = "```markdown\n```python\ncode()\n```\n```";
724 let result = run_check(content).unwrap();
725 assert_eq!(result.len(), 1, "Should detect collision even without trailing newline");
726
727 let fixed = run_fix(content).unwrap();
728 assert!(!fixed.ends_with('\n'), "Should preserve lack of trailing newline");
729 }
730
731 #[test]
732 fn test_empty_lines_between_fences() {
733 let content = "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n";
734 let result = run_check(content).unwrap();
735 assert_eq!(result.len(), 1, "Empty lines should not affect collision detection");
736 }
737
738 #[test]
739 fn test_tab_indented_opening_fence() {
740 let content = "\t```markdown\n```python\ncode()\n```\n```\n";
747 let result = run_check(content).unwrap();
748 assert_eq!(result.len(), 1, "Tab-indented fence is parsed (tab = 1 char)");
750 }
751
752 #[test]
753 fn test_mixed_fence_types_no_collision() {
754 let content = "```markdown\n~~~python\ncode()\n~~~\n```\n";
756 let result = run_check(content).unwrap();
757 assert!(result.is_empty(), "Different fence chars should not collide");
758
759 let content2 = "~~~markdown\n```python\ncode()\n```\n~~~\n";
761 let result2 = run_check(content2).unwrap();
762 assert!(result2.is_empty(), "Different fence chars should not collide");
763 }
764
765 #[test]
766 fn test_frontmatter_not_confused_with_fence() {
767 let content = "---\ntitle: Test\n---\n\n```markdown\n```python\ncode()\n```\n```\n";
769 let result = run_check(content).unwrap();
770 assert_eq!(result.len(), 1, "Should detect collision after frontmatter");
771 }
772
773 #[test]
774 fn test_html_comment_with_fence_inside() {
775 let content = "<!-- ```markdown\n```python\ncode()\n``` -->\n\n```markdown\nreal content\n```\n";
777 let result = run_check(content).unwrap();
778 assert!(result.is_empty(), "Fences in HTML comments should be ignored");
780 }
781
782 #[test]
783 fn test_consecutive_code_blocks() {
784 let content = r#"```markdown
786```python
787a()
788```
789```
790
791```markdown
792```ruby
793b()
794```
795```
796"#;
797 let result = run_check(content).unwrap();
798 assert!(!result.is_empty(), "Should detect collision in first block");
800 }
801
802 #[test]
803 fn test_numeric_info_string() {
804 let content = "```123\n```456\ncode()\n```\n```\n";
806 let result = run_check(content).unwrap();
807 assert!(result.is_empty(), "Numeric info string is not markdown");
809 }
810
811 #[test]
812 fn test_collision_at_exact_length() {
813 let content = "```markdown\n```python\ncode()\n```\n```\n";
816 let result = run_check(content).unwrap();
817 assert_eq!(
818 result.len(),
819 1,
820 "Same-length fence with language should trigger collision"
821 );
822
823 let content2 = "````markdown\n```python\ncode()\n```\n````\n";
825 let result2 = run_check(content2).unwrap();
826 assert!(result2.is_empty(), "Shorter inner fence should not collide");
827
828 let content3 = "```markdown\n```\n";
830 let result3 = run_check(content3).unwrap();
831 assert!(result3.is_empty(), "Empty closing fence is not a collision");
832 }
833
834 #[test]
835 fn test_fix_preserves_content_exactly() {
836 let content = "```markdown\n```python\n indented\n\ttabbed\nspecial: !@#$%\n```\n```\n";
838 let fixed = run_fix(content).unwrap();
839 assert!(fixed.contains(" indented"), "Indentation should be preserved");
840 assert!(fixed.contains("\ttabbed"), "Tabs should be preserved");
841 assert!(fixed.contains("special: !@#$%"), "Special chars should be preserved");
842 }
843
844 #[test]
845 fn test_warning_line_numbers_accurate() {
846 let content = "# Title\n\nParagraph\n\n```markdown\n```python\ncode()\n```\n```\n";
847 let result = run_check(content).unwrap();
848 assert_eq!(result.len(), 1);
849 assert_eq!(result[0].line, 5, "Warning should be on opening fence line");
850 assert!(result[0].message.contains("line 6"), "Collision line should be line 6");
851 }
852
853 #[test]
854 fn test_should_skip_optimization() {
855 let rule = MD070NestedCodeFence::new();
856
857 let ctx1 = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
859 assert!(
860 rule.should_skip(&ctx1),
861 "Should skip content without backticks or tildes"
862 );
863
864 let ctx2 = LintContext::new("Has `code`", crate::config::MarkdownFlavor::Standard, None);
866 assert!(!rule.should_skip(&ctx2), "Should not skip content with backticks");
867
868 let ctx3 = LintContext::new("Has ~~~", crate::config::MarkdownFlavor::Standard, None);
870 assert!(!rule.should_skip(&ctx3), "Should not skip content with tildes");
871
872 let ctx4 = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
874 assert!(rule.should_skip(&ctx4), "Should skip empty content");
875 }
876}