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 content = ctx.content;
149 let mut warnings = Vec::new();
150 let lines: Vec<&str> = content.lines().collect();
151
152 let mut i = 0;
153 while i < lines.len() {
154 if let Some(line_info) = ctx.lines.get(i)
156 && (line_info.in_front_matter || line_info.in_html_comment || line_info.in_html_block)
157 {
158 i += 1;
159 continue;
160 }
161
162 if i > 0
168 && let Some(prev_line_info) = ctx.lines.get(i - 1)
169 && prev_line_info.in_code_block
170 {
171 i += 1;
172 continue;
173 }
174
175 let line = lines[i];
176
177 if let Some((_indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
179 let block_start = i;
180
181 let language = info_string.split_whitespace().next().unwrap_or("");
183
184 let mut block_end = None;
186 for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
187 if Self::is_closing_fence(line_j, fence_char, fence_length) {
188 block_end = Some(j);
189 break;
190 }
191 }
192
193 if let Some(end_line) = block_end {
194 if Self::should_check_language(language) {
197 let block_content: String = if block_start + 1 < end_line {
199 lines[(block_start + 1)..end_line].join("\n")
200 } else {
201 String::new()
202 };
203
204 if let Some((collision_line_offset, _collision_length)) =
206 Self::find_fence_collision(&block_content, fence_char, fence_length)
207 {
208 let safe_length = Self::find_safe_fence_length(&block_content, fence_char) + 1;
209 let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
210 let current_fence: String = std::iter::repeat_n(fence_char, fence_length).collect();
211
212 let collision_line_num = block_start + 1 + collision_line_offset + 1; warnings.push(LintWarning {
217 rule_name: Some(self.name().to_string()),
218 message: format!(
219 "Nested {current_fence} at line {collision_line_num} closes block prematurely — use {suggested_fence} for outer fence"
220 ),
221 line: block_start + 1,
222 column: 1,
223 end_line: end_line + 1, end_column: lines[end_line].len() + 1,
225 severity: Severity::Warning,
226 fix: None, });
228 }
229 }
230
231 i = end_line + 1;
233 continue;
234 }
235 }
236
237 i += 1;
238 }
239
240 Ok(warnings)
241 }
242
243 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
244 let content = ctx.content;
245 let mut result = String::new();
246 let lines: Vec<&str> = content.lines().collect();
247
248 let mut i = 0;
249 while i < lines.len() {
250 if let Some(line_info) = ctx.lines.get(i)
252 && (line_info.in_front_matter || line_info.in_html_comment || line_info.in_html_block)
253 {
254 result.push_str(lines[i]);
255 result.push('\n');
256 i += 1;
257 continue;
258 }
259
260 if i > 0
262 && let Some(prev_line_info) = ctx.lines.get(i - 1)
263 && prev_line_info.in_code_block
264 {
265 result.push_str(lines[i]);
266 result.push('\n');
267 i += 1;
268 continue;
269 }
270
271 let line = lines[i];
272
273 if let Some((indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
275 let block_start = i;
276
277 let language = info_string.split_whitespace().next().unwrap_or("");
279
280 let mut first_close = None;
282 for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
283 if Self::is_closing_fence(line_j, fence_char, fence_length) {
284 first_close = Some(j);
285 break;
286 }
287 }
288
289 if let Some(end_line) = first_close {
290 if Self::should_check_language(language) {
292 let block_content: String = if block_start + 1 < end_line {
294 lines[(block_start + 1)..end_line].join("\n")
295 } else {
296 String::new()
297 };
298
299 if Self::find_fence_collision(&block_content, fence_char, fence_length).is_some() {
301 let mut intended_close = end_line;
304 for (j, line_j) in lines.iter().enumerate().skip(end_line + 1) {
305 if Self::is_closing_fence(line_j, fence_char, fence_length) {
306 intended_close = j;
307 } else if Self::parse_fence_line(line_j).is_some_and(|(ind, ch, _, info)| {
310 ind <= indent && ch == fence_char && !info.is_empty()
311 }) {
312 break; }
314 }
315
316 let full_block_content: String = if block_start + 1 < intended_close {
318 lines[(block_start + 1)..intended_close].join("\n")
319 } else {
320 String::new()
321 };
322
323 let safe_length = Self::find_safe_fence_length(&full_block_content, fence_char) + 1;
324 let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
325
326 let opening_indent = " ".repeat(indent);
328 result.push_str(&format!("{opening_indent}{suggested_fence}{info_string}\n"));
329
330 for line_content in &lines[(block_start + 1)..intended_close] {
332 result.push_str(line_content);
333 result.push('\n');
334 }
335
336 let closing_line = lines[intended_close];
338 let closing_indent = closing_line.len() - closing_line.trim_start().len();
339 let closing_indent_str = " ".repeat(closing_indent);
340 result.push_str(&format!("{closing_indent_str}{suggested_fence}\n"));
341
342 i = intended_close + 1;
343 continue;
344 }
345 }
346
347 for line_content in &lines[block_start..=end_line] {
349 result.push_str(line_content);
350 result.push('\n');
351 }
352 i = end_line + 1;
353 continue;
354 }
355 }
356
357 result.push_str(line);
359 result.push('\n');
360 i += 1;
361 }
362
363 if !content.ends_with('\n') && result.ends_with('\n') {
365 result.pop();
366 }
367
368 Ok(result)
369 }
370
371 fn category(&self) -> RuleCategory {
372 RuleCategory::CodeBlock
373 }
374
375 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
376 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
377 }
378
379 fn as_any(&self) -> &dyn std::any::Any {
380 self
381 }
382
383 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
384 where
385 Self: Sized,
386 {
387 Box::new(MD070NestedCodeFence::new())
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::lint_context::LintContext;
395
396 fn run_check(content: &str) -> LintResult {
397 let rule = MD070NestedCodeFence::new();
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 rule.check(&ctx)
400 }
401
402 fn run_fix(content: &str) -> Result<String, LintError> {
403 let rule = MD070NestedCodeFence::new();
404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
405 rule.fix(&ctx)
406 }
407
408 #[test]
409 fn test_no_collision_simple() {
410 let content = "```python\nprint('hello')\n```\n";
411 let result = run_check(content).unwrap();
412 assert!(result.is_empty(), "Simple code block should not trigger warning");
413 }
414
415 #[test]
416 fn test_no_collision_non_doc_language() {
417 let content = "```python\n```bash\necho hello\n```\n```\n";
419 let result = run_check(content).unwrap();
420 assert!(result.is_empty(), "Non-doc language should not be checked");
421 }
422
423 #[test]
424 fn test_collision_markdown_language() {
425 let content = "```markdown\n```python\ncode()\n```\n```\n";
426 let result = run_check(content).unwrap();
427 assert_eq!(result.len(), 1, "Should emit single warning for collision");
428 assert!(result[0].message.contains("Nested"));
429 assert!(result[0].message.contains("closes block prematurely"));
430 assert!(result[0].message.contains("use ````"));
431 }
432
433 #[test]
434 fn test_collision_empty_language() {
435 let content = "```\n```python\ncode()\n```\n```\n";
437 let result = run_check(content).unwrap();
438 assert_eq!(result.len(), 1, "Empty language should be checked");
439 }
440
441 #[test]
442 fn test_no_collision_longer_outer_fence() {
443 let content = "````markdown\n```python\ncode()\n```\n````\n";
444 let result = run_check(content).unwrap();
445 assert!(result.is_empty(), "Longer outer fence should not trigger warning");
446 }
447
448 #[test]
449 fn test_tilde_fence_ignores_backticks() {
450 let content = "~~~markdown\n```python\ncode()\n```\n~~~\n";
452 let result = run_check(content).unwrap();
453 assert!(result.is_empty(), "Different fence types should not collide");
454 }
455
456 #[test]
457 fn test_tilde_collision() {
458 let content = "~~~markdown\n~~~python\ncode()\n~~~\n~~~\n";
459 let result = run_check(content).unwrap();
460 assert_eq!(result.len(), 1, "Same fence type should collide");
461 assert!(result[0].message.contains("~~~~"));
462 }
463
464 #[test]
465 fn test_fix_increases_fence_length() {
466 let content = "```markdown\n```python\ncode()\n```\n```\n";
467 let fixed = run_fix(content).unwrap();
468 assert!(fixed.starts_with("````markdown"), "Should increase to 4 backticks");
469 assert!(
470 fixed.contains("````\n") || fixed.ends_with("````"),
471 "Closing should also be 4 backticks"
472 );
473 }
474
475 #[test]
476 fn test_fix_handles_longer_inner_fence() {
477 let content = "```markdown\n`````python\ncode()\n`````\n```\n";
479 let fixed = run_fix(content).unwrap();
480 assert!(fixed.starts_with("``````markdown"), "Should increase to 6 backticks");
481 }
482
483 #[test]
484 fn test_backticks_in_code_not_fence() {
485 let content = "```markdown\nconst x = `template`;\n```\n";
487 let result = run_check(content).unwrap();
488 assert!(result.is_empty(), "Inline backticks should not be detected as fences");
489 }
490
491 #[test]
492 fn test_preserves_info_string() {
493 let content = "```markdown {.highlight}\n```python\ncode()\n```\n```\n";
494 let fixed = run_fix(content).unwrap();
495 assert!(
496 fixed.contains("````markdown {.highlight}"),
497 "Should preserve info string attributes"
498 );
499 }
500
501 #[test]
502 fn test_md_language_alias() {
503 let content = "```md\n```python\ncode()\n```\n```\n";
504 let result = run_check(content).unwrap();
505 assert_eq!(result.len(), 1, "md should be recognized as markdown");
506 }
507
508 #[test]
509 fn test_real_world_docs_case() {
510 let content = r#"```markdown
5121. First item
513
514 ```python
515 code_in_list()
516 ```
517
5181. Second item
519
520```
521"#;
522 let result = run_check(content).unwrap();
523 assert_eq!(result.len(), 1, "Should emit single warning for nested fence issue");
524 assert!(result[0].message.contains("line 4")); let fixed = run_fix(content).unwrap();
527 assert!(fixed.starts_with("````markdown"), "Should fix with longer fence");
528 }
529
530 #[test]
531 fn test_empty_code_block() {
532 let content = "```markdown\n```\n";
533 let result = run_check(content).unwrap();
534 assert!(result.is_empty(), "Empty code block should not trigger");
535 }
536
537 #[test]
538 fn test_multiple_code_blocks() {
539 let content = r#"```python
543safe code
544```
545
546```markdown
547```python
548collision
549```
550```
551
552```javascript
553also safe
554```
555"#;
556 let result = run_check(content).unwrap();
557 assert_eq!(result.len(), 1, "Should emit single warning for collision");
560 assert!(result[0].message.contains("line 6")); }
562
563 #[test]
564 fn test_single_collision_properly_closed() {
565 let content = r#"```python
567safe code
568```
569
570````markdown
571```python
572collision
573```
574````
575
576```javascript
577also safe
578```
579"#;
580 let result = run_check(content).unwrap();
581 assert!(result.is_empty(), "Properly fenced blocks should not trigger");
582 }
583
584 #[test]
585 fn test_indented_code_block_in_list() {
586 let content = r#"- List item
587 ```markdown
588 ```python
589 nested
590 ```
591 ```
592"#;
593 let result = run_check(content).unwrap();
594 assert_eq!(result.len(), 1, "Should detect collision in indented block");
595 assert!(result[0].message.contains("````"));
596 }
597
598 #[test]
599 fn test_no_false_positive_list_indented_block() {
600 let content = r#"1. List item with code:
604
605 ```json
606 {"key": "value"}
607 ```
608
6092. Another item
610
611 ```python
612 code()
613 ```
614"#;
615 let result = run_check(content).unwrap();
616 assert!(
618 result.is_empty(),
619 "List-indented code blocks should not trigger false positives"
620 );
621 }
622
623 #[test]
626 fn test_case_insensitive_language() {
627 for lang in ["MARKDOWN", "Markdown", "MD", "Md", "mD"] {
629 let content = format!("```{lang}\n```python\ncode()\n```\n```\n");
630 let result = run_check(&content).unwrap();
631 assert_eq!(result.len(), 1, "{lang} should be recognized as markdown");
632 }
633 }
634
635 #[test]
636 fn test_unclosed_outer_fence() {
637 let content = "```markdown\n```python\ncode()\n```\n";
639 let result = run_check(content).unwrap();
640 assert!(result.len() <= 1, "Unclosed fence should not cause issues");
643 }
644
645 #[test]
646 fn test_deeply_nested_fences() {
647 let content = r#"```markdown
649````markdown
650```python
651code()
652```
653````
654```
655"#;
656 let result = run_check(content).unwrap();
657 assert_eq!(result.len(), 1, "Deep nesting should trigger warning");
659 assert!(result[0].message.contains("`````")); }
661
662 #[test]
663 fn test_very_long_fences() {
664 let content = "``````````markdown\n```python\ncode()\n```\n``````````\n";
666 let result = run_check(content).unwrap();
667 assert!(result.is_empty(), "Very long outer fence should not trigger warning");
668 }
669
670 #[test]
671 fn test_blockquote_with_fence() {
672 let content = "> ```markdown\n> ```python\n> code()\n> ```\n> ```\n";
674 let result = run_check(content).unwrap();
675 assert!(result.is_empty() || result.len() == 1);
678 }
679
680 #[test]
681 fn test_fence_with_attributes() {
682 let content = "```markdown {.highlight #example}\n```python\ncode()\n```\n```\n";
684 let result = run_check(content).unwrap();
685 assert_eq!(
686 result.len(),
687 1,
688 "Attributes in info string should not prevent detection"
689 );
690
691 let fixed = run_fix(content).unwrap();
692 assert!(
693 fixed.contains("````markdown {.highlight #example}"),
694 "Attributes should be preserved in fix"
695 );
696 }
697
698 #[test]
699 fn test_trailing_whitespace_in_info_string() {
700 let content = "```markdown \n```python\ncode()\n```\n```\n";
701 let result = run_check(content).unwrap();
702 assert_eq!(result.len(), 1, "Trailing whitespace should not affect detection");
703 }
704
705 #[test]
706 fn test_only_closing_fence_pattern() {
707 let content = "```markdown\nsome text\n```\nmore text\n```\n";
709 let result = run_check(content).unwrap();
710 assert!(result.is_empty(), "Properly closed block should not trigger");
712 }
713
714 #[test]
715 fn test_fence_at_end_of_file_no_newline() {
716 let content = "```markdown\n```python\ncode()\n```\n```";
717 let result = run_check(content).unwrap();
718 assert_eq!(result.len(), 1, "Should detect collision even without trailing newline");
719
720 let fixed = run_fix(content).unwrap();
721 assert!(!fixed.ends_with('\n'), "Should preserve lack of trailing newline");
722 }
723
724 #[test]
725 fn test_empty_lines_between_fences() {
726 let content = "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n";
727 let result = run_check(content).unwrap();
728 assert_eq!(result.len(), 1, "Empty lines should not affect collision detection");
729 }
730
731 #[test]
732 fn test_tab_indented_opening_fence() {
733 let content = "\t```markdown\n```python\ncode()\n```\n```\n";
740 let result = run_check(content).unwrap();
741 assert_eq!(result.len(), 1, "Tab-indented fence is parsed (tab = 1 char)");
743 }
744
745 #[test]
746 fn test_mixed_fence_types_no_collision() {
747 let content = "```markdown\n~~~python\ncode()\n~~~\n```\n";
749 let result = run_check(content).unwrap();
750 assert!(result.is_empty(), "Different fence chars should not collide");
751
752 let content2 = "~~~markdown\n```python\ncode()\n```\n~~~\n";
754 let result2 = run_check(content2).unwrap();
755 assert!(result2.is_empty(), "Different fence chars should not collide");
756 }
757
758 #[test]
759 fn test_frontmatter_not_confused_with_fence() {
760 let content = "---\ntitle: Test\n---\n\n```markdown\n```python\ncode()\n```\n```\n";
762 let result = run_check(content).unwrap();
763 assert_eq!(result.len(), 1, "Should detect collision after frontmatter");
764 }
765
766 #[test]
767 fn test_html_comment_with_fence_inside() {
768 let content = "<!-- ```markdown\n```python\ncode()\n``` -->\n\n```markdown\nreal content\n```\n";
770 let result = run_check(content).unwrap();
771 assert!(result.is_empty(), "Fences in HTML comments should be ignored");
773 }
774
775 #[test]
776 fn test_consecutive_code_blocks() {
777 let content = r#"```markdown
779```python
780a()
781```
782```
783
784```markdown
785```ruby
786b()
787```
788```
789"#;
790 let result = run_check(content).unwrap();
791 assert!(!result.is_empty(), "Should detect collision in first block");
793 }
794
795 #[test]
796 fn test_numeric_info_string() {
797 let content = "```123\n```456\ncode()\n```\n```\n";
799 let result = run_check(content).unwrap();
800 assert!(result.is_empty(), "Numeric info string is not markdown");
802 }
803
804 #[test]
805 fn test_collision_at_exact_length() {
806 let content = "```markdown\n```python\ncode()\n```\n```\n";
809 let result = run_check(content).unwrap();
810 assert_eq!(
811 result.len(),
812 1,
813 "Same-length fence with language should trigger collision"
814 );
815
816 let content2 = "````markdown\n```python\ncode()\n```\n````\n";
818 let result2 = run_check(content2).unwrap();
819 assert!(result2.is_empty(), "Shorter inner fence should not collide");
820
821 let content3 = "```markdown\n```\n";
823 let result3 = run_check(content3).unwrap();
824 assert!(result3.is_empty(), "Empty closing fence is not a collision");
825 }
826
827 #[test]
828 fn test_fix_preserves_content_exactly() {
829 let content = "```markdown\n```python\n indented\n\ttabbed\nspecial: !@#$%\n```\n```\n";
831 let fixed = run_fix(content).unwrap();
832 assert!(fixed.contains(" indented"), "Indentation should be preserved");
833 assert!(fixed.contains("\ttabbed"), "Tabs should be preserved");
834 assert!(fixed.contains("special: !@#$%"), "Special chars should be preserved");
835 }
836
837 #[test]
838 fn test_warning_line_numbers_accurate() {
839 let content = "# Title\n\nParagraph\n\n```markdown\n```python\ncode()\n```\n```\n";
840 let result = run_check(content).unwrap();
841 assert_eq!(result.len(), 1);
842 assert_eq!(result[0].line, 5, "Warning should be on opening fence line");
843 assert!(result[0].message.contains("line 6"), "Collision line should be line 6");
844 }
845
846 #[test]
847 fn test_should_skip_optimization() {
848 let rule = MD070NestedCodeFence::new();
849
850 let ctx1 = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
852 assert!(
853 rule.should_skip(&ctx1),
854 "Should skip content without backticks or tildes"
855 );
856
857 let ctx2 = LintContext::new("Has `code`", crate::config::MarkdownFlavor::Standard, None);
859 assert!(!rule.should_skip(&ctx2), "Should not skip content with backticks");
860
861 let ctx3 = LintContext::new("Has ~~~", crate::config::MarkdownFlavor::Standard, None);
863 assert!(!rule.should_skip(&ctx3), "Should not skip content with tildes");
864
865 let ctx4 = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
867 assert!(rule.should_skip(&ctx4), "Should skip empty content");
868 }
869}