1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3#[derive(Clone, Default)]
14pub struct MD070NestedCodeFence;
15
16impl MD070NestedCodeFence {
17 pub fn new() -> Self {
18 Self
19 }
20
21 fn should_check_language(lang: &str) -> bool {
26 let base = lang.split_whitespace().next().unwrap_or("");
27 matches!(
28 base.to_ascii_lowercase().as_str(),
29 ""
31 | "markdown"
32 | "md"
33 | "mdx"
34 | "text"
35 | "txt"
36 | "plain"
37 | "python"
39 | "py"
40 | "ruby"
41 | "rb"
42 | "perl"
43 | "pl"
44 | "php"
45 | "lua"
46 | "r"
47 | "rmd"
48 | "rmarkdown"
49 | "javascript"
51 | "js"
52 | "jsx"
53 | "mjs"
54 | "cjs"
55 | "typescript"
56 | "ts"
57 | "tsx"
58 | "mts"
59 | "rust"
60 | "rs"
61 | "go"
62 | "golang"
63 | "swift"
64 | "kotlin"
65 | "kt"
66 | "kts"
67 | "java"
68 | "csharp"
69 | "cs"
70 | "c#"
71 | "scala"
72 | "shell"
74 | "sh"
75 | "bash"
76 | "zsh"
77 | "fish"
78 | "powershell"
79 | "ps1"
80 | "pwsh"
81 | "yaml"
83 | "yml"
84 | "toml"
85 | "json"
86 | "jsonc"
87 | "json5"
88 | "jinja"
90 | "jinja2"
91 | "handlebars"
92 | "hbs"
93 | "liquid"
94 | "nunjucks"
95 | "njk"
96 | "ejs"
97 | "console"
99 | "terminal"
100 )
101 }
102
103 fn find_fence_collision(content: &str, fence_char: char, outer_fence_length: usize) -> Option<(usize, usize)> {
106 for (line_idx, line) in content.lines().enumerate() {
107 let trimmed = line.trim_start();
108
109 if trimmed.starts_with(fence_char) {
111 let count = trimmed.chars().take_while(|&c| c == fence_char).count();
112
113 if count >= outer_fence_length {
115 let after_fence = &trimmed[count..];
117 if after_fence.is_empty()
123 || after_fence.trim().is_empty()
124 || after_fence
125 .chars()
126 .next()
127 .is_some_and(|c| c.is_alphabetic() || c == '{')
128 {
129 return Some((line_idx, count));
130 }
131 }
132 }
133 }
134 None
135 }
136
137 fn find_safe_fence_length(content: &str, fence_char: char) -> usize {
139 let mut max_fence = 0;
140
141 for line in content.lines() {
142 let trimmed = line.trim_start();
143 if trimmed.starts_with(fence_char) {
144 let count = trimmed.chars().take_while(|&c| c == fence_char).count();
145 if count >= 3 {
146 let after_fence = &trimmed[count..];
148 if after_fence.is_empty()
149 || after_fence.trim().is_empty()
150 || after_fence
151 .chars()
152 .next()
153 .is_some_and(|c| c.is_alphabetic() || c == '{')
154 {
155 max_fence = max_fence.max(count);
156 }
157 }
158 }
159 }
160
161 max_fence
162 }
163
164 fn find_intended_close(
168 lines: &[&str],
169 first_close: usize,
170 fence_char: char,
171 fence_length: usize,
172 opening_indent: usize,
173 ) -> usize {
174 let mut intended_close = first_close;
175 for (j, line_j) in lines.iter().enumerate().skip(first_close + 1) {
176 if Self::is_closing_fence(line_j, fence_char, fence_length) {
177 intended_close = j;
178 } else if Self::parse_fence_line(line_j)
179 .is_some_and(|(ind, ch, _, info)| ind <= opening_indent && ch == fence_char && !info.is_empty())
180 {
181 break;
182 }
183 }
184 intended_close
185 }
186
187 fn parse_fence_line(line: &str) -> Option<(usize, char, usize, &str)> {
189 let indent = line.len() - line.trim_start().len();
190 if indent > 3 {
192 return None;
193 }
194
195 let trimmed = line.trim_start();
196
197 if trimmed.starts_with("```") {
198 let count = trimmed.chars().take_while(|&c| c == '`').count();
199 if count >= 3 {
200 let info = trimmed[count..].trim();
201 return Some((indent, '`', count, info));
202 }
203 } else if trimmed.starts_with("~~~") {
204 let count = trimmed.chars().take_while(|&c| c == '~').count();
205 if count >= 3 {
206 let info = trimmed[count..].trim();
207 return Some((indent, '~', count, info));
208 }
209 }
210
211 None
212 }
213
214 fn is_closing_fence(line: &str, fence_char: char, min_length: usize) -> bool {
217 let indent = line.len() - line.trim_start().len();
218 if indent > 3 {
220 return false;
221 }
222
223 let trimmed = line.trim_start();
224 if !trimmed.starts_with(fence_char) {
225 return false;
226 }
227
228 let count = trimmed.chars().take_while(|&c| c == fence_char).count();
229 if count < min_length {
230 return false;
231 }
232
233 trimmed[count..].trim().is_empty()
235 }
236}
237
238impl Rule for MD070NestedCodeFence {
239 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
240 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(70))
241 }
242
243 fn name(&self) -> &'static str {
244 "MD070"
245 }
246
247 fn description(&self) -> &'static str {
248 "Nested code fence collision - use longer fence to avoid premature closure"
249 }
250
251 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
252 let mut warnings = Vec::new();
253 let lines = ctx.raw_lines();
254
255 let mut i = 0;
256 while i < lines.len() {
257 if let Some(line_info) = ctx.lines.get(i)
259 && (line_info.in_front_matter
260 || line_info.in_html_comment
261 || line_info.in_mdx_comment
262 || line_info.in_html_block)
263 {
264 i += 1;
265 continue;
266 }
267
268 if i > 0
274 && let Some(prev_line_info) = ctx.lines.get(i - 1)
275 && prev_line_info.in_code_block
276 {
277 i += 1;
278 continue;
279 }
280
281 let line = lines[i];
282
283 if let Some((_indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
285 let block_start = i;
286
287 let language = info_string.split_whitespace().next().unwrap_or("");
289
290 let mut block_end = None;
292 for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
293 if Self::is_closing_fence(line_j, fence_char, fence_length) {
294 block_end = Some(j);
295 break;
296 }
297 }
298
299 if let Some(end_line) = block_end {
300 if Self::should_check_language(language) {
303 let block_content: String = if block_start + 1 < end_line {
305 lines[(block_start + 1)..end_line].join("\n")
306 } else {
307 String::new()
308 };
309
310 if let Some((collision_line_offset, _collision_length)) =
312 Self::find_fence_collision(&block_content, fence_char, fence_length)
313 {
314 let collision_line_num = block_start + 1 + collision_line_offset + 1; let indent = line.len() - line.trim_start().len();
319 let intended_close =
320 Self::find_intended_close(lines, end_line, fence_char, fence_length, indent);
321
322 let full_content: String = if block_start + 1 < intended_close {
324 lines[(block_start + 1)..intended_close].join("\n")
325 } else {
326 block_content.clone()
327 };
328 let safe_length = Self::find_safe_fence_length(&full_content, fence_char) + 1;
329 let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
330
331 let open_byte_start = ctx.line_start_byte(block_start + 1).unwrap_or(0);
335 let close_byte_end = ctx.line_start_byte(intended_close + 2).unwrap_or(ctx.content.len());
336
337 let indent_str = &line[..indent];
338 let closing_line = lines[intended_close];
339 let closing_indent = &closing_line[..closing_line.len() - closing_line.trim_start().len()];
340 let mut replacement = format!("{indent_str}{suggested_fence}");
341 if !info_string.is_empty() {
342 replacement.push_str(info_string);
343 }
344 replacement.push('\n');
345 for content_line in &lines[(block_start + 1)..intended_close] {
346 replacement.push_str(content_line);
347 replacement.push('\n');
348 }
349 replacement.push_str(closing_indent);
350 replacement.push_str(&suggested_fence);
351 if close_byte_end <= ctx.content.len() && ctx.content[..close_byte_end].ends_with('\n') {
353 replacement.push('\n');
354 }
355
356 warnings.push(LintWarning {
357 rule_name: Some(self.name().to_string()),
358 message: format!(
359 "Code block contains fence markers at line {collision_line_num} that interfere with block parsing — use {suggested_fence} for outer fence"
360 ),
361 line: block_start + 1,
362 column: 1,
363 end_line: intended_close + 1,
364 end_column: lines[intended_close].chars().count() + 1,
365 severity: Severity::Warning,
366 fix: Some(Fix::new(open_byte_start..close_byte_end, replacement)),
367 });
368 }
369 }
370
371 i = end_line + 1;
373 continue;
374 }
375 }
376
377 i += 1;
378 }
379
380 Ok(warnings)
381 }
382
383 fn fixes_from_diagnostics(&self) -> bool {
384 true
385 }
386
387 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
388 if self.should_skip(ctx) {
389 return Ok(ctx.content.to_string());
390 }
391 let warnings = self.check(ctx)?;
392 if warnings.is_empty() {
393 return Ok(ctx.content.to_string());
394 }
395 let warnings =
396 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
397 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::FixFailed)
398 }
399
400 fn category(&self) -> RuleCategory {
401 RuleCategory::CodeBlock
402 }
403
404 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
405 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
406 }
407
408 fn as_any(&self) -> &dyn std::any::Any {
409 self
410 }
411
412 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
413 where
414 Self: Sized,
415 {
416 Box::new(MD070NestedCodeFence::new())
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use crate::lint_context::LintContext;
424
425 fn run_check(content: &str) -> LintResult {
426 let rule = MD070NestedCodeFence::new();
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
428 rule.check(&ctx)
429 }
430
431 fn run_fix(content: &str) -> Result<String, LintError> {
432 let rule = MD070NestedCodeFence::new();
433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
434 rule.fix(&ctx)
435 }
436
437 #[test]
438 fn test_no_collision_simple() {
439 let content = "```python\nprint('hello')\n```\n";
440 let result = run_check(content).unwrap();
441 assert!(result.is_empty(), "Simple code block should not trigger warning");
442 }
443
444 #[test]
445 fn test_no_collision_unchecked_language() {
446 let content = "```c\n```bash\necho hello\n```\n```\n";
448 let result = run_check(content).unwrap();
449 assert!(result.is_empty(), "Unchecked language should not trigger");
450 }
451
452 #[test]
453 fn test_collision_python_language() {
454 let content = "```python\n```json\n{}\n```\n```\n";
456 let result = run_check(content).unwrap();
457 assert_eq!(result.len(), 1, "Python should be checked for nested fences");
458 assert!(result[0].message.contains("````"));
459 }
460
461 #[test]
462 fn test_collision_javascript_language() {
463 let content = "```javascript\n```html\n<div></div>\n```\n```\n";
464 let result = run_check(content).unwrap();
465 assert_eq!(result.len(), 1, "JavaScript should be checked for nested fences");
466 }
467
468 #[test]
469 fn test_collision_shell_language() {
470 let content = "```bash\n```yaml\nkey: val\n```\n```\n";
471 let result = run_check(content).unwrap();
472 assert_eq!(result.len(), 1, "Shell should be checked for nested fences");
473 }
474
475 #[test]
476 fn test_collision_rust_language() {
477 let content = "```rust\n```toml\n[dep]\n```\n```\n";
478 let result = run_check(content).unwrap();
479 assert_eq!(result.len(), 1, "Rust should be checked for nested fences");
480 }
481
482 #[test]
483 fn test_no_collision_assembly_language() {
484 for lang in ["asm", "c", "cpp", "sql", "css", "fortran"] {
486 let content = format!("```{lang}\n```inner\ncontent\n```\n```\n");
487 let result = run_check(&content).unwrap();
488 assert!(result.is_empty(), "{lang} should not be checked for nested fences");
489 }
490 }
491
492 #[test]
493 fn test_collision_markdown_language() {
494 let content = "```markdown\n```python\ncode()\n```\n```\n";
495 let result = run_check(content).unwrap();
496 assert_eq!(result.len(), 1, "Should emit single warning for collision");
497 assert!(result[0].message.contains("fence markers at line"));
498 assert!(result[0].message.contains("interfere with block parsing"));
499 assert!(result[0].message.contains("use ````"));
500 }
501
502 #[test]
503 fn test_collision_empty_language() {
504 let content = "```\n```python\ncode()\n```\n```\n";
506 let result = run_check(content).unwrap();
507 assert_eq!(result.len(), 1, "Empty language should be checked");
508 }
509
510 #[test]
511 fn test_no_collision_longer_outer_fence() {
512 let content = "````markdown\n```python\ncode()\n```\n````\n";
513 let result = run_check(content).unwrap();
514 assert!(result.is_empty(), "Longer outer fence should not trigger warning");
515 }
516
517 #[test]
518 fn test_tilde_fence_ignores_backticks() {
519 let content = "~~~markdown\n```python\ncode()\n```\n~~~\n";
521 let result = run_check(content).unwrap();
522 assert!(result.is_empty(), "Different fence types should not collide");
523 }
524
525 #[test]
526 fn test_tilde_collision() {
527 let content = "~~~markdown\n~~~python\ncode()\n~~~\n~~~\n";
528 let result = run_check(content).unwrap();
529 assert_eq!(result.len(), 1, "Same fence type should collide");
530 assert!(result[0].message.contains("~~~~"));
531 }
532
533 #[test]
534 fn test_fix_increases_fence_length() {
535 let content = "```markdown\n```python\ncode()\n```\n```\n";
536 let fixed = run_fix(content).unwrap();
537 assert!(fixed.starts_with("````markdown"), "Should increase to 4 backticks");
538 assert!(
539 fixed.contains("````\n") || fixed.ends_with("````"),
540 "Closing should also be 4 backticks"
541 );
542 }
543
544 #[test]
545 fn test_fix_handles_longer_inner_fence() {
546 let content = "```markdown\n`````python\ncode()\n`````\n```\n";
548 let fixed = run_fix(content).unwrap();
549 assert!(fixed.starts_with("``````markdown"), "Should increase to 6 backticks");
550 }
551
552 #[test]
553 fn test_backticks_in_code_not_fence() {
554 let content = "```markdown\nconst x = `template`;\n```\n";
556 let result = run_check(content).unwrap();
557 assert!(result.is_empty(), "Inline backticks should not be detected as fences");
558 }
559
560 #[test]
561 fn test_preserves_info_string() {
562 let content = "```markdown {.highlight}\n```python\ncode()\n```\n```\n";
563 let fixed = run_fix(content).unwrap();
564 assert!(
565 fixed.contains("````markdown {.highlight}"),
566 "Should preserve info string attributes"
567 );
568 }
569
570 #[test]
571 fn test_md_language_alias() {
572 let content = "```md\n```python\ncode()\n```\n```\n";
573 let result = run_check(content).unwrap();
574 assert_eq!(result.len(), 1, "md should be recognized as markdown");
575 }
576
577 #[test]
578 fn test_real_world_docs_case() {
579 let content = r#"```markdown
5811. First item
582
583 ```python
584 code_in_list()
585 ```
586
5871. Second item
588
589```
590"#;
591 let result = run_check(content).unwrap();
592 assert_eq!(result.len(), 1, "Should emit single warning for nested fence issue");
593 assert!(result[0].message.contains("line 4")); let fixed = run_fix(content).unwrap();
596 assert!(fixed.starts_with("````markdown"), "Should fix with longer fence");
597 }
598
599 #[test]
600 fn test_empty_code_block() {
601 let content = "```markdown\n```\n";
602 let result = run_check(content).unwrap();
603 assert!(result.is_empty(), "Empty code block should not trigger");
604 }
605
606 #[test]
607 fn test_multiple_code_blocks() {
608 let content = r#"```python
612safe code
613```
614
615```markdown
616```python
617collision
618```
619```
620
621```javascript
622also safe
623```
624"#;
625 let result = run_check(content).unwrap();
626 assert_eq!(result.len(), 1, "Should emit single warning for collision");
629 assert!(result[0].message.contains("line 6")); }
631
632 #[test]
633 fn test_single_collision_properly_closed() {
634 let content = r#"```python
636safe code
637```
638
639````markdown
640```python
641collision
642```
643````
644
645```javascript
646also safe
647```
648"#;
649 let result = run_check(content).unwrap();
650 assert!(result.is_empty(), "Properly fenced blocks should not trigger");
651 }
652
653 #[test]
654 fn test_indented_code_block_in_list() {
655 let content = r#"- List item
656 ```markdown
657 ```python
658 nested
659 ```
660 ```
661"#;
662 let result = run_check(content).unwrap();
663 assert_eq!(result.len(), 1, "Should detect collision in indented block");
664 assert!(result[0].message.contains("````"));
665 }
666
667 #[test]
668 fn test_no_false_positive_list_indented_block() {
669 let content = r#"1. List item with code:
673
674 ```json
675 {"key": "value"}
676 ```
677
6782. Another item
679
680 ```python
681 code()
682 ```
683"#;
684 let result = run_check(content).unwrap();
685 assert!(
687 result.is_empty(),
688 "List-indented code blocks should not trigger false positives"
689 );
690 }
691
692 #[test]
695 fn test_case_insensitive_language() {
696 for lang in ["MARKDOWN", "Markdown", "MD", "Md", "mD"] {
698 let content = format!("```{lang}\n```python\ncode()\n```\n```\n");
699 let result = run_check(&content).unwrap();
700 assert_eq!(result.len(), 1, "{lang} should be recognized as markdown");
701 }
702 }
703
704 #[test]
705 fn test_unclosed_outer_fence() {
706 let content = "```markdown\n```python\ncode()\n```\n";
708 let result = run_check(content).unwrap();
709 assert!(result.len() <= 1, "Unclosed fence should not cause issues");
712 }
713
714 #[test]
715 fn test_deeply_nested_fences() {
716 let content = r#"```markdown
718````markdown
719```python
720code()
721```
722````
723```
724"#;
725 let result = run_check(content).unwrap();
726 assert_eq!(result.len(), 1, "Deep nesting should trigger warning");
728 assert!(result[0].message.contains("`````")); }
730
731 #[test]
732 fn test_very_long_fences() {
733 let content = "``````````markdown\n```python\ncode()\n```\n``````````\n";
735 let result = run_check(content).unwrap();
736 assert!(result.is_empty(), "Very long outer fence should not trigger warning");
737 }
738
739 #[test]
740 fn test_blockquote_with_fence() {
741 let content = "> ```markdown\n> ```python\n> code()\n> ```\n> ```\n";
743 let result = run_check(content).unwrap();
744 assert!(result.is_empty() || result.len() == 1);
747 }
748
749 #[test]
750 fn test_fence_with_attributes() {
751 let content = "```markdown {.highlight #example}\n```python\ncode()\n```\n```\n";
753 let result = run_check(content).unwrap();
754 assert_eq!(
755 result.len(),
756 1,
757 "Attributes in info string should not prevent detection"
758 );
759
760 let fixed = run_fix(content).unwrap();
761 assert!(
762 fixed.contains("````markdown {.highlight #example}"),
763 "Attributes should be preserved in fix"
764 );
765 }
766
767 #[test]
768 fn test_trailing_whitespace_in_info_string() {
769 let content = "```markdown \n```python\ncode()\n```\n```\n";
770 let result = run_check(content).unwrap();
771 assert_eq!(result.len(), 1, "Trailing whitespace should not affect detection");
772 }
773
774 #[test]
775 fn test_only_closing_fence_pattern() {
776 let content = "```markdown\nsome text\n```\nmore text\n```\n";
778 let result = run_check(content).unwrap();
779 assert!(result.is_empty(), "Properly closed block should not trigger");
781 }
782
783 #[test]
784 fn test_fence_at_end_of_file_no_newline() {
785 let content = "```markdown\n```python\ncode()\n```\n```";
786 let result = run_check(content).unwrap();
787 assert_eq!(result.len(), 1, "Should detect collision even without trailing newline");
788
789 let fixed = run_fix(content).unwrap();
790 assert!(!fixed.ends_with('\n'), "Should preserve lack of trailing newline");
791 }
792
793 #[test]
794 fn test_empty_lines_between_fences() {
795 let content = "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n";
796 let result = run_check(content).unwrap();
797 assert_eq!(result.len(), 1, "Empty lines should not affect collision detection");
798 }
799
800 #[test]
801 fn test_tab_indented_opening_fence() {
802 let content = "\t```markdown\n```python\ncode()\n```\n```\n";
809 let result = run_check(content).unwrap();
810 assert_eq!(result.len(), 1, "Tab-indented fence is parsed (tab = 1 char)");
812 }
813
814 #[test]
815 fn test_mixed_fence_types_no_collision() {
816 let content = "```markdown\n~~~python\ncode()\n~~~\n```\n";
818 let result = run_check(content).unwrap();
819 assert!(result.is_empty(), "Different fence chars should not collide");
820
821 let content2 = "~~~markdown\n```python\ncode()\n```\n~~~\n";
823 let result2 = run_check(content2).unwrap();
824 assert!(result2.is_empty(), "Different fence chars should not collide");
825 }
826
827 #[test]
828 fn test_frontmatter_not_confused_with_fence() {
829 let content = "---\ntitle: Test\n---\n\n```markdown\n```python\ncode()\n```\n```\n";
831 let result = run_check(content).unwrap();
832 assert_eq!(result.len(), 1, "Should detect collision after frontmatter");
833 }
834
835 #[test]
836 fn test_html_comment_with_fence_inside() {
837 let content = "<!-- ```markdown\n```python\ncode()\n``` -->\n\n```markdown\nreal content\n```\n";
839 let result = run_check(content).unwrap();
840 assert!(result.is_empty(), "Fences in HTML comments should be ignored");
842 }
843
844 #[test]
845 fn test_consecutive_code_blocks() {
846 let content = r#"```markdown
848```python
849a()
850```
851```
852
853```markdown
854```ruby
855b()
856```
857```
858"#;
859 let result = run_check(content).unwrap();
860 assert!(!result.is_empty(), "Should detect collision in first block");
862 }
863
864 #[test]
865 fn test_numeric_info_string() {
866 let content = "```123\n```456\ncode()\n```\n```\n";
868 let result = run_check(content).unwrap();
869 assert!(result.is_empty(), "Numeric info string is not markdown");
871 }
872
873 #[test]
874 fn test_collision_at_exact_length() {
875 let content = "```markdown\n```python\ncode()\n```\n```\n";
878 let result = run_check(content).unwrap();
879 assert_eq!(
880 result.len(),
881 1,
882 "Same-length fence with language should trigger collision"
883 );
884
885 let content2 = "````markdown\n```python\ncode()\n```\n````\n";
887 let result2 = run_check(content2).unwrap();
888 assert!(result2.is_empty(), "Shorter inner fence should not collide");
889
890 let content3 = "```markdown\n```\n";
892 let result3 = run_check(content3).unwrap();
893 assert!(result3.is_empty(), "Empty closing fence is not a collision");
894 }
895
896 #[test]
897 fn test_fix_preserves_content_exactly() {
898 let content = "```markdown\n```python\n indented\n\ttabbed\nspecial: !@#$%\n```\n```\n";
900 let fixed = run_fix(content).unwrap();
901 assert!(fixed.contains(" indented"), "Indentation should be preserved");
902 assert!(fixed.contains("\ttabbed"), "Tabs should be preserved");
903 assert!(fixed.contains("special: !@#$%"), "Special chars should be preserved");
904 }
905
906 #[test]
907 fn test_warning_line_numbers_accurate() {
908 let content = "# Title\n\nParagraph\n\n```markdown\n```python\ncode()\n```\n```\n";
909 let result = run_check(content).unwrap();
910 assert_eq!(result.len(), 1);
911 assert_eq!(result[0].line, 5, "Warning should be on opening fence line");
912 assert!(result[0].message.contains("line 6"), "Collision line should be line 6");
913 }
914
915 #[test]
916 fn test_should_skip_optimization() {
917 let rule = MD070NestedCodeFence::new();
918
919 let ctx1 = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
921 assert!(
922 rule.should_skip(&ctx1),
923 "Should skip content without backticks or tildes"
924 );
925
926 let ctx2 = LintContext::new("Has `code`", crate::config::MarkdownFlavor::Standard, None);
928 assert!(!rule.should_skip(&ctx2), "Should not skip content with backticks");
929
930 let ctx3 = LintContext::new("Has ~~~", crate::config::MarkdownFlavor::Standard, None);
932 assert!(!rule.should_skip(&ctx3), "Should not skip content with tildes");
933
934 let ctx4 = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
936 assert!(rule.should_skip(&ctx4), "Should skip empty content");
937 }
938
939 #[test]
940 fn test_python_triplestring_fence_collision_fix() {
941 let content = "# Test\n\n```python\ndef f():\n text = \"\"\"\n```json\n{}\n```\n\"\"\"\n```\n";
944 let result = run_check(content).unwrap();
945 assert_eq!(result.len(), 1, "Should detect collision in python block");
946 assert!(result[0].fix.is_some(), "Warning should be marked as fixable");
947
948 let fixed = run_fix(content).unwrap();
949 assert!(
950 fixed.contains("````python"),
951 "Should upgrade opening fence to 4 backticks"
952 );
953 assert!(
954 fixed.contains("````\n") || fixed.ends_with("````"),
955 "Should upgrade closing fence to 4 backticks"
956 );
957 assert!(fixed.contains("```json"), "Inner fences should be preserved as content");
959 }
960
961 #[test]
962 fn test_warning_is_fixable() {
963 let content = "```markdown\n```python\ncode()\n```\n```\n";
965 let result = run_check(content).unwrap();
966 assert_eq!(result.len(), 1);
967 assert!(
968 result[0].fix.is_some(),
969 "MD070 warnings must be marked fixable for the fix coordinator"
970 );
971 }
972
973 #[test]
974 fn test_fix_via_warning_struct_is_safe() {
975 let content = "```markdown\n```python\ncode()\n```\n```\n";
978 let result = run_check(content).unwrap();
979 assert_eq!(result.len(), 1);
980
981 let fix = result[0].fix.as_ref().unwrap();
982 let mut fixed = String::new();
984 fixed.push_str(&content[..fix.range.start]);
985 fixed.push_str(&fix.replacement);
986 fixed.push_str(&content[fix.range.end..]);
987
988 assert!(
990 fixed.contains("````markdown"),
991 "Direct Fix application should upgrade opening fence, got: {fixed}"
992 );
993 assert!(
994 fixed.contains("````\n") || fixed.ends_with("````"),
995 "Direct Fix application should upgrade closing fence, got: {fixed}"
996 );
997 assert!(
999 fixed.contains("```python"),
1000 "Inner content should be preserved, got: {fixed}"
1001 );
1002 }
1003
1004 #[test]
1005 fn test_fix_via_warning_struct_python_block() {
1006 let content = "```python\ndef f():\n text = \"\"\"\n```json\n{}\n```\n\"\"\"\n print(text)\nf()\n```\n";
1011 let result = run_check(content).unwrap();
1012 assert_eq!(result.len(), 1);
1013
1014 let fix = result[0].fix.as_ref().unwrap();
1015 let mut fixed = String::new();
1016 fixed.push_str(&content[..fix.range.start]);
1017 fixed.push_str(&fix.replacement);
1018 fixed.push_str(&content[fix.range.end..]);
1019
1020 assert!(
1024 fixed.starts_with("````python\n"),
1025 "Should upgrade opening fence, got:\n{fixed}"
1026 );
1027 assert!(
1028 fixed.contains("````\n") || fixed.trim_end().ends_with("````"),
1029 "Should upgrade closing fence, got:\n{fixed}"
1030 );
1031 let fence_start = fixed.find("````python\n").unwrap();
1033 let after_open = fence_start + "````python\n".len();
1034 let close_pos = fixed[after_open..]
1035 .find("\n````\n")
1036 .or_else(|| fixed[after_open..].find("\n````"));
1037 assert!(
1038 close_pos.is_some(),
1039 "Should have closing fence after content, got:\n{fixed}"
1040 );
1041 let block_content = &fixed[after_open..after_open + close_pos.unwrap()];
1042 assert!(
1043 block_content.contains("print(text)"),
1044 "print(text) must be inside the code block, got block:\n{block_content}"
1045 );
1046 assert!(
1047 block_content.contains("f()"),
1048 "f() must be inside the code block, got block:\n{block_content}"
1049 );
1050 assert!(
1051 block_content.contains("```json"),
1052 "Inner fences must be preserved as content, got block:\n{block_content}"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_fix_via_apply_warning_fixes() {
1058 let content = "```markdown\n```python\ncode()\n```\n```\n";
1060 let result = run_check(content).unwrap();
1061 assert_eq!(result.len(), 1);
1062
1063 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &result).unwrap();
1064 assert!(
1065 fixed.contains("````markdown"),
1066 "apply_warning_fixes should upgrade opening fence"
1067 );
1068 assert!(
1069 fixed.contains("````\n") || fixed.ends_with("````"),
1070 "apply_warning_fixes should upgrade closing fence"
1071 );
1072
1073 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1075 let rule = MD070NestedCodeFence::new();
1076 let result2 = rule.check(&ctx2).unwrap();
1077 assert!(
1078 result2.is_empty(),
1079 "Re-check after LSP fix should find no issues, got: {:?}",
1080 result2.iter().map(|w| &w.message).collect::<Vec<_>>()
1081 );
1082 }
1083
1084 fn assert_fix_roundtrip(content: &str, label: &str) {
1086 let fixed = run_fix(content).unwrap();
1087 let rule = MD070NestedCodeFence::new();
1088 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1089 let remaining = rule.check(&ctx).unwrap();
1090 assert!(
1091 remaining.is_empty(),
1092 "[{label}] fix() should resolve all violations, but {n} remain: {msgs:?}\nFixed content:\n{fixed}",
1093 n = remaining.len(),
1094 msgs = remaining.iter().map(|w| &w.message).collect::<Vec<_>>(),
1095 );
1096 }
1097
1098 #[test]
1099 fn test_fix_roundtrip_basic() {
1100 assert_fix_roundtrip("```markdown\n```python\ncode()\n```\n```\n", "basic collision");
1101 }
1102
1103 #[test]
1104 fn test_fix_roundtrip_longer_inner_fence() {
1105 assert_fix_roundtrip("```markdown\n`````python\ncode()\n`````\n```\n", "longer inner fence");
1106 }
1107
1108 #[test]
1109 fn test_fix_roundtrip_tilde_collision() {
1110 assert_fix_roundtrip("~~~markdown\n~~~python\ncode()\n~~~\n~~~\n", "tilde collision");
1111 }
1112
1113 #[test]
1114 fn test_fix_roundtrip_info_string_attrs() {
1115 assert_fix_roundtrip(
1116 "```markdown {.highlight}\n```python\ncode()\n```\n```\n",
1117 "info string with attrs",
1118 );
1119 }
1120
1121 #[test]
1122 fn test_fix_roundtrip_no_trailing_newline() {
1123 assert_fix_roundtrip("```markdown\n```python\ncode()\n```\n```", "no trailing newline");
1124 }
1125
1126 #[test]
1127 fn test_fix_roundtrip_python_triple_string() {
1128 assert_fix_roundtrip(
1129 "# Test\n\n```python\ndef f():\n text = \"\"\"\n```json\n{}\n```\n\"\"\"\n```\n",
1130 "python triple string",
1131 );
1132 }
1133
1134 #[test]
1135 fn test_fix_roundtrip_deeply_nested() {
1136 assert_fix_roundtrip(
1137 "```markdown\n````markdown\n```python\ncode()\n```\n````\n```\n",
1138 "deeply nested fences",
1139 );
1140 }
1141
1142 #[test]
1143 fn test_fix_roundtrip_real_world_docs() {
1144 let content = r#"```markdown
11451. First item
1146
1147 ```python
1148 code_in_list()
1149 ```
1150
11511. Second item
1152
1153```
1154"#;
1155 assert_fix_roundtrip(content, "real world docs case");
1156 }
1157
1158 #[test]
1159 fn test_fix_roundtrip_empty_lines() {
1160 assert_fix_roundtrip(
1161 "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n",
1162 "empty lines between fences",
1163 );
1164 }
1165
1166 #[test]
1167 fn test_fix_no_change_when_no_violations() {
1168 let content = "````markdown\n```python\ncode()\n```\n````\n";
1169 let fixed = run_fix(content).unwrap();
1170 assert_eq!(fixed, content, "fix() should not modify content with no violations");
1171 }
1172
1173 #[test]
1174 fn test_fix_roundtrip_consecutive_collisions() {
1175 let content = r#"```markdown
1176```python
1177a()
1178```
1179```
1180
1181```md
1182```ruby
1183b()
1184```
1185```
1186"#;
1187 let fixed = run_fix(content).unwrap();
1189 let rule = MD070NestedCodeFence::new();
1190 let ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1191 let remaining = rule.check(&ctx).unwrap();
1192 assert!(
1195 remaining.len() < 2,
1196 "fix() should resolve at least one collision, remaining: {remaining:?}",
1197 );
1198 }
1199}