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 name(&self) -> &'static str {
240 "MD070"
241 }
242
243 fn description(&self) -> &'static str {
244 "Nested code fence collision - use longer fence to avoid premature closure"
245 }
246
247 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
248 let mut warnings = Vec::new();
249 let lines = ctx.raw_lines();
250
251 let mut i = 0;
252 while i < lines.len() {
253 if let Some(line_info) = ctx.lines.get(i)
255 && (line_info.in_front_matter
256 || line_info.in_html_comment
257 || line_info.in_mdx_comment
258 || line_info.in_html_block)
259 {
260 i += 1;
261 continue;
262 }
263
264 if i > 0
270 && let Some(prev_line_info) = ctx.lines.get(i - 1)
271 && prev_line_info.in_code_block
272 {
273 i += 1;
274 continue;
275 }
276
277 let line = lines[i];
278
279 if let Some((_indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
281 let block_start = i;
282
283 let language = info_string.split_whitespace().next().unwrap_or("");
285
286 let mut block_end = None;
288 for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
289 if Self::is_closing_fence(line_j, fence_char, fence_length) {
290 block_end = Some(j);
291 break;
292 }
293 }
294
295 if let Some(end_line) = block_end {
296 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 let Some((collision_line_offset, _collision_length)) =
308 Self::find_fence_collision(&block_content, fence_char, fence_length)
309 {
310 let collision_line_num = block_start + 1 + collision_line_offset + 1; let indent = line.len() - line.trim_start().len();
315 let intended_close =
316 Self::find_intended_close(lines, end_line, fence_char, fence_length, indent);
317
318 let full_content: String = if block_start + 1 < intended_close {
320 lines[(block_start + 1)..intended_close].join("\n")
321 } else {
322 block_content.clone()
323 };
324 let safe_length = Self::find_safe_fence_length(&full_content, fence_char) + 1;
325 let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
326
327 let open_byte_start = ctx.line_index.get_line_start_byte(block_start + 1).unwrap_or(0);
331 let close_byte_end = ctx
332 .line_index
333 .get_line_start_byte(intended_close + 2)
334 .unwrap_or(ctx.content.len());
335
336 let indent_str = &line[..indent];
337 let closing_line = lines[intended_close];
338 let closing_indent = &closing_line[..closing_line.len() - closing_line.trim_start().len()];
339 let mut replacement = format!("{indent_str}{suggested_fence}");
340 if !info_string.is_empty() {
341 replacement.push_str(info_string);
342 }
343 replacement.push('\n');
344 for content_line in &lines[(block_start + 1)..intended_close] {
345 replacement.push_str(content_line);
346 replacement.push('\n');
347 }
348 replacement.push_str(closing_indent);
349 replacement.push_str(&suggested_fence);
350 replacement.push('\n');
351
352 warnings.push(LintWarning {
353 rule_name: Some(self.name().to_string()),
354 message: format!(
355 "Code block contains fence markers at line {collision_line_num} that interfere with block parsing — use {suggested_fence} for outer fence"
356 ),
357 line: block_start + 1,
358 column: 1,
359 end_line: intended_close + 1,
360 end_column: lines[intended_close].len() + 1,
361 severity: Severity::Warning,
362 fix: Some(Fix {
363 range: (open_byte_start..close_byte_end),
364 replacement,
365 }),
366 });
367 }
368 }
369
370 i = end_line + 1;
372 continue;
373 }
374 }
375
376 i += 1;
377 }
378
379 Ok(warnings)
380 }
381
382 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
383 let content = ctx.content;
384 let mut result = String::new();
385 let lines = ctx.raw_lines();
386
387 let mut i = 0;
388 while i < lines.len() {
389 if ctx.is_rule_disabled(self.name(), i + 1) {
391 result.push_str(lines[i]);
392 result.push('\n');
393 i += 1;
394 continue;
395 }
396
397 if let Some(line_info) = ctx.lines.get(i)
399 && (line_info.in_front_matter
400 || line_info.in_html_comment
401 || line_info.in_mdx_comment
402 || line_info.in_html_block)
403 {
404 result.push_str(lines[i]);
405 result.push('\n');
406 i += 1;
407 continue;
408 }
409
410 if i > 0
412 && let Some(prev_line_info) = ctx.lines.get(i - 1)
413 && prev_line_info.in_code_block
414 {
415 result.push_str(lines[i]);
416 result.push('\n');
417 i += 1;
418 continue;
419 }
420
421 let line = lines[i];
422
423 if let Some((indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
425 let block_start = i;
426
427 let language = info_string.split_whitespace().next().unwrap_or("");
429
430 let mut first_close = None;
432 for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
433 if Self::is_closing_fence(line_j, fence_char, fence_length) {
434 first_close = Some(j);
435 break;
436 }
437 }
438
439 if let Some(end_line) = first_close {
440 if Self::should_check_language(language) {
442 let block_content: String = if block_start + 1 < end_line {
444 lines[(block_start + 1)..end_line].join("\n")
445 } else {
446 String::new()
447 };
448
449 if Self::find_fence_collision(&block_content, fence_char, fence_length).is_some() {
451 let intended_close =
452 Self::find_intended_close(lines, end_line, fence_char, fence_length, indent);
453
454 let full_block_content: String = if block_start + 1 < intended_close {
456 lines[(block_start + 1)..intended_close].join("\n")
457 } else {
458 String::new()
459 };
460
461 let safe_length = Self::find_safe_fence_length(&full_block_content, fence_char) + 1;
462 let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
463
464 let opening_indent = " ".repeat(indent);
466 result.push_str(&format!("{opening_indent}{suggested_fence}{info_string}\n"));
467
468 for line_content in &lines[(block_start + 1)..intended_close] {
470 result.push_str(line_content);
471 result.push('\n');
472 }
473
474 let closing_line = lines[intended_close];
476 let closing_indent = closing_line.len() - closing_line.trim_start().len();
477 let closing_indent_str = " ".repeat(closing_indent);
478 result.push_str(&format!("{closing_indent_str}{suggested_fence}\n"));
479
480 i = intended_close + 1;
481 continue;
482 }
483 }
484
485 for line_content in &lines[block_start..=end_line] {
487 result.push_str(line_content);
488 result.push('\n');
489 }
490 i = end_line + 1;
491 continue;
492 }
493 }
494
495 result.push_str(line);
497 result.push('\n');
498 i += 1;
499 }
500
501 if !content.ends_with('\n') && result.ends_with('\n') {
503 result.pop();
504 }
505
506 Ok(result)
507 }
508
509 fn category(&self) -> RuleCategory {
510 RuleCategory::CodeBlock
511 }
512
513 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
514 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
515 }
516
517 fn as_any(&self) -> &dyn std::any::Any {
518 self
519 }
520
521 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
522 where
523 Self: Sized,
524 {
525 Box::new(MD070NestedCodeFence::new())
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use crate::lint_context::LintContext;
533
534 fn run_check(content: &str) -> LintResult {
535 let rule = MD070NestedCodeFence::new();
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537 rule.check(&ctx)
538 }
539
540 fn run_fix(content: &str) -> Result<String, LintError> {
541 let rule = MD070NestedCodeFence::new();
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 rule.fix(&ctx)
544 }
545
546 #[test]
547 fn test_no_collision_simple() {
548 let content = "```python\nprint('hello')\n```\n";
549 let result = run_check(content).unwrap();
550 assert!(result.is_empty(), "Simple code block should not trigger warning");
551 }
552
553 #[test]
554 fn test_no_collision_unchecked_language() {
555 let content = "```c\n```bash\necho hello\n```\n```\n";
557 let result = run_check(content).unwrap();
558 assert!(result.is_empty(), "Unchecked language should not trigger");
559 }
560
561 #[test]
562 fn test_collision_python_language() {
563 let content = "```python\n```json\n{}\n```\n```\n";
565 let result = run_check(content).unwrap();
566 assert_eq!(result.len(), 1, "Python should be checked for nested fences");
567 assert!(result[0].message.contains("````"));
568 }
569
570 #[test]
571 fn test_collision_javascript_language() {
572 let content = "```javascript\n```html\n<div></div>\n```\n```\n";
573 let result = run_check(content).unwrap();
574 assert_eq!(result.len(), 1, "JavaScript should be checked for nested fences");
575 }
576
577 #[test]
578 fn test_collision_shell_language() {
579 let content = "```bash\n```yaml\nkey: val\n```\n```\n";
580 let result = run_check(content).unwrap();
581 assert_eq!(result.len(), 1, "Shell should be checked for nested fences");
582 }
583
584 #[test]
585 fn test_collision_rust_language() {
586 let content = "```rust\n```toml\n[dep]\n```\n```\n";
587 let result = run_check(content).unwrap();
588 assert_eq!(result.len(), 1, "Rust should be checked for nested fences");
589 }
590
591 #[test]
592 fn test_no_collision_assembly_language() {
593 for lang in ["asm", "c", "cpp", "sql", "css", "fortran"] {
595 let content = format!("```{lang}\n```inner\ncontent\n```\n```\n");
596 let result = run_check(&content).unwrap();
597 assert!(result.is_empty(), "{lang} should not be checked for nested fences");
598 }
599 }
600
601 #[test]
602 fn test_collision_markdown_language() {
603 let content = "```markdown\n```python\ncode()\n```\n```\n";
604 let result = run_check(content).unwrap();
605 assert_eq!(result.len(), 1, "Should emit single warning for collision");
606 assert!(result[0].message.contains("fence markers at line"));
607 assert!(result[0].message.contains("interfere with block parsing"));
608 assert!(result[0].message.contains("use ````"));
609 }
610
611 #[test]
612 fn test_collision_empty_language() {
613 let content = "```\n```python\ncode()\n```\n```\n";
615 let result = run_check(content).unwrap();
616 assert_eq!(result.len(), 1, "Empty language should be checked");
617 }
618
619 #[test]
620 fn test_no_collision_longer_outer_fence() {
621 let content = "````markdown\n```python\ncode()\n```\n````\n";
622 let result = run_check(content).unwrap();
623 assert!(result.is_empty(), "Longer outer fence should not trigger warning");
624 }
625
626 #[test]
627 fn test_tilde_fence_ignores_backticks() {
628 let content = "~~~markdown\n```python\ncode()\n```\n~~~\n";
630 let result = run_check(content).unwrap();
631 assert!(result.is_empty(), "Different fence types should not collide");
632 }
633
634 #[test]
635 fn test_tilde_collision() {
636 let content = "~~~markdown\n~~~python\ncode()\n~~~\n~~~\n";
637 let result = run_check(content).unwrap();
638 assert_eq!(result.len(), 1, "Same fence type should collide");
639 assert!(result[0].message.contains("~~~~"));
640 }
641
642 #[test]
643 fn test_fix_increases_fence_length() {
644 let content = "```markdown\n```python\ncode()\n```\n```\n";
645 let fixed = run_fix(content).unwrap();
646 assert!(fixed.starts_with("````markdown"), "Should increase to 4 backticks");
647 assert!(
648 fixed.contains("````\n") || fixed.ends_with("````"),
649 "Closing should also be 4 backticks"
650 );
651 }
652
653 #[test]
654 fn test_fix_handles_longer_inner_fence() {
655 let content = "```markdown\n`````python\ncode()\n`````\n```\n";
657 let fixed = run_fix(content).unwrap();
658 assert!(fixed.starts_with("``````markdown"), "Should increase to 6 backticks");
659 }
660
661 #[test]
662 fn test_backticks_in_code_not_fence() {
663 let content = "```markdown\nconst x = `template`;\n```\n";
665 let result = run_check(content).unwrap();
666 assert!(result.is_empty(), "Inline backticks should not be detected as fences");
667 }
668
669 #[test]
670 fn test_preserves_info_string() {
671 let content = "```markdown {.highlight}\n```python\ncode()\n```\n```\n";
672 let fixed = run_fix(content).unwrap();
673 assert!(
674 fixed.contains("````markdown {.highlight}"),
675 "Should preserve info string attributes"
676 );
677 }
678
679 #[test]
680 fn test_md_language_alias() {
681 let content = "```md\n```python\ncode()\n```\n```\n";
682 let result = run_check(content).unwrap();
683 assert_eq!(result.len(), 1, "md should be recognized as markdown");
684 }
685
686 #[test]
687 fn test_real_world_docs_case() {
688 let content = r#"```markdown
6901. First item
691
692 ```python
693 code_in_list()
694 ```
695
6961. Second item
697
698```
699"#;
700 let result = run_check(content).unwrap();
701 assert_eq!(result.len(), 1, "Should emit single warning for nested fence issue");
702 assert!(result[0].message.contains("line 4")); let fixed = run_fix(content).unwrap();
705 assert!(fixed.starts_with("````markdown"), "Should fix with longer fence");
706 }
707
708 #[test]
709 fn test_empty_code_block() {
710 let content = "```markdown\n```\n";
711 let result = run_check(content).unwrap();
712 assert!(result.is_empty(), "Empty code block should not trigger");
713 }
714
715 #[test]
716 fn test_multiple_code_blocks() {
717 let content = r#"```python
721safe code
722```
723
724```markdown
725```python
726collision
727```
728```
729
730```javascript
731also safe
732```
733"#;
734 let result = run_check(content).unwrap();
735 assert_eq!(result.len(), 1, "Should emit single warning for collision");
738 assert!(result[0].message.contains("line 6")); }
740
741 #[test]
742 fn test_single_collision_properly_closed() {
743 let content = r#"```python
745safe code
746```
747
748````markdown
749```python
750collision
751```
752````
753
754```javascript
755also safe
756```
757"#;
758 let result = run_check(content).unwrap();
759 assert!(result.is_empty(), "Properly fenced blocks should not trigger");
760 }
761
762 #[test]
763 fn test_indented_code_block_in_list() {
764 let content = r#"- List item
765 ```markdown
766 ```python
767 nested
768 ```
769 ```
770"#;
771 let result = run_check(content).unwrap();
772 assert_eq!(result.len(), 1, "Should detect collision in indented block");
773 assert!(result[0].message.contains("````"));
774 }
775
776 #[test]
777 fn test_no_false_positive_list_indented_block() {
778 let content = r#"1. List item with code:
782
783 ```json
784 {"key": "value"}
785 ```
786
7872. Another item
788
789 ```python
790 code()
791 ```
792"#;
793 let result = run_check(content).unwrap();
794 assert!(
796 result.is_empty(),
797 "List-indented code blocks should not trigger false positives"
798 );
799 }
800
801 #[test]
804 fn test_case_insensitive_language() {
805 for lang in ["MARKDOWN", "Markdown", "MD", "Md", "mD"] {
807 let content = format!("```{lang}\n```python\ncode()\n```\n```\n");
808 let result = run_check(&content).unwrap();
809 assert_eq!(result.len(), 1, "{lang} should be recognized as markdown");
810 }
811 }
812
813 #[test]
814 fn test_unclosed_outer_fence() {
815 let content = "```markdown\n```python\ncode()\n```\n";
817 let result = run_check(content).unwrap();
818 assert!(result.len() <= 1, "Unclosed fence should not cause issues");
821 }
822
823 #[test]
824 fn test_deeply_nested_fences() {
825 let content = r#"```markdown
827````markdown
828```python
829code()
830```
831````
832```
833"#;
834 let result = run_check(content).unwrap();
835 assert_eq!(result.len(), 1, "Deep nesting should trigger warning");
837 assert!(result[0].message.contains("`````")); }
839
840 #[test]
841 fn test_very_long_fences() {
842 let content = "``````````markdown\n```python\ncode()\n```\n``````````\n";
844 let result = run_check(content).unwrap();
845 assert!(result.is_empty(), "Very long outer fence should not trigger warning");
846 }
847
848 #[test]
849 fn test_blockquote_with_fence() {
850 let content = "> ```markdown\n> ```python\n> code()\n> ```\n> ```\n";
852 let result = run_check(content).unwrap();
853 assert!(result.is_empty() || result.len() == 1);
856 }
857
858 #[test]
859 fn test_fence_with_attributes() {
860 let content = "```markdown {.highlight #example}\n```python\ncode()\n```\n```\n";
862 let result = run_check(content).unwrap();
863 assert_eq!(
864 result.len(),
865 1,
866 "Attributes in info string should not prevent detection"
867 );
868
869 let fixed = run_fix(content).unwrap();
870 assert!(
871 fixed.contains("````markdown {.highlight #example}"),
872 "Attributes should be preserved in fix"
873 );
874 }
875
876 #[test]
877 fn test_trailing_whitespace_in_info_string() {
878 let content = "```markdown \n```python\ncode()\n```\n```\n";
879 let result = run_check(content).unwrap();
880 assert_eq!(result.len(), 1, "Trailing whitespace should not affect detection");
881 }
882
883 #[test]
884 fn test_only_closing_fence_pattern() {
885 let content = "```markdown\nsome text\n```\nmore text\n```\n";
887 let result = run_check(content).unwrap();
888 assert!(result.is_empty(), "Properly closed block should not trigger");
890 }
891
892 #[test]
893 fn test_fence_at_end_of_file_no_newline() {
894 let content = "```markdown\n```python\ncode()\n```\n```";
895 let result = run_check(content).unwrap();
896 assert_eq!(result.len(), 1, "Should detect collision even without trailing newline");
897
898 let fixed = run_fix(content).unwrap();
899 assert!(!fixed.ends_with('\n'), "Should preserve lack of trailing newline");
900 }
901
902 #[test]
903 fn test_empty_lines_between_fences() {
904 let content = "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n";
905 let result = run_check(content).unwrap();
906 assert_eq!(result.len(), 1, "Empty lines should not affect collision detection");
907 }
908
909 #[test]
910 fn test_tab_indented_opening_fence() {
911 let content = "\t```markdown\n```python\ncode()\n```\n```\n";
918 let result = run_check(content).unwrap();
919 assert_eq!(result.len(), 1, "Tab-indented fence is parsed (tab = 1 char)");
921 }
922
923 #[test]
924 fn test_mixed_fence_types_no_collision() {
925 let content = "```markdown\n~~~python\ncode()\n~~~\n```\n";
927 let result = run_check(content).unwrap();
928 assert!(result.is_empty(), "Different fence chars should not collide");
929
930 let content2 = "~~~markdown\n```python\ncode()\n```\n~~~\n";
932 let result2 = run_check(content2).unwrap();
933 assert!(result2.is_empty(), "Different fence chars should not collide");
934 }
935
936 #[test]
937 fn test_frontmatter_not_confused_with_fence() {
938 let content = "---\ntitle: Test\n---\n\n```markdown\n```python\ncode()\n```\n```\n";
940 let result = run_check(content).unwrap();
941 assert_eq!(result.len(), 1, "Should detect collision after frontmatter");
942 }
943
944 #[test]
945 fn test_html_comment_with_fence_inside() {
946 let content = "<!-- ```markdown\n```python\ncode()\n``` -->\n\n```markdown\nreal content\n```\n";
948 let result = run_check(content).unwrap();
949 assert!(result.is_empty(), "Fences in HTML comments should be ignored");
951 }
952
953 #[test]
954 fn test_consecutive_code_blocks() {
955 let content = r#"```markdown
957```python
958a()
959```
960```
961
962```markdown
963```ruby
964b()
965```
966```
967"#;
968 let result = run_check(content).unwrap();
969 assert!(!result.is_empty(), "Should detect collision in first block");
971 }
972
973 #[test]
974 fn test_numeric_info_string() {
975 let content = "```123\n```456\ncode()\n```\n```\n";
977 let result = run_check(content).unwrap();
978 assert!(result.is_empty(), "Numeric info string is not markdown");
980 }
981
982 #[test]
983 fn test_collision_at_exact_length() {
984 let content = "```markdown\n```python\ncode()\n```\n```\n";
987 let result = run_check(content).unwrap();
988 assert_eq!(
989 result.len(),
990 1,
991 "Same-length fence with language should trigger collision"
992 );
993
994 let content2 = "````markdown\n```python\ncode()\n```\n````\n";
996 let result2 = run_check(content2).unwrap();
997 assert!(result2.is_empty(), "Shorter inner fence should not collide");
998
999 let content3 = "```markdown\n```\n";
1001 let result3 = run_check(content3).unwrap();
1002 assert!(result3.is_empty(), "Empty closing fence is not a collision");
1003 }
1004
1005 #[test]
1006 fn test_fix_preserves_content_exactly() {
1007 let content = "```markdown\n```python\n indented\n\ttabbed\nspecial: !@#$%\n```\n```\n";
1009 let fixed = run_fix(content).unwrap();
1010 assert!(fixed.contains(" indented"), "Indentation should be preserved");
1011 assert!(fixed.contains("\ttabbed"), "Tabs should be preserved");
1012 assert!(fixed.contains("special: !@#$%"), "Special chars should be preserved");
1013 }
1014
1015 #[test]
1016 fn test_warning_line_numbers_accurate() {
1017 let content = "# Title\n\nParagraph\n\n```markdown\n```python\ncode()\n```\n```\n";
1018 let result = run_check(content).unwrap();
1019 assert_eq!(result.len(), 1);
1020 assert_eq!(result[0].line, 5, "Warning should be on opening fence line");
1021 assert!(result[0].message.contains("line 6"), "Collision line should be line 6");
1022 }
1023
1024 #[test]
1025 fn test_should_skip_optimization() {
1026 let rule = MD070NestedCodeFence::new();
1027
1028 let ctx1 = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1030 assert!(
1031 rule.should_skip(&ctx1),
1032 "Should skip content without backticks or tildes"
1033 );
1034
1035 let ctx2 = LintContext::new("Has `code`", crate::config::MarkdownFlavor::Standard, None);
1037 assert!(!rule.should_skip(&ctx2), "Should not skip content with backticks");
1038
1039 let ctx3 = LintContext::new("Has ~~~", crate::config::MarkdownFlavor::Standard, None);
1041 assert!(!rule.should_skip(&ctx3), "Should not skip content with tildes");
1042
1043 let ctx4 = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1045 assert!(rule.should_skip(&ctx4), "Should skip empty content");
1046 }
1047
1048 #[test]
1049 fn test_python_triplestring_fence_collision_fix() {
1050 let content = "# Test\n\n```python\ndef f():\n text = \"\"\"\n```json\n{}\n```\n\"\"\"\n```\n";
1053 let result = run_check(content).unwrap();
1054 assert_eq!(result.len(), 1, "Should detect collision in python block");
1055 assert!(result[0].fix.is_some(), "Warning should be marked as fixable");
1056
1057 let fixed = run_fix(content).unwrap();
1058 assert!(
1059 fixed.contains("````python"),
1060 "Should upgrade opening fence to 4 backticks"
1061 );
1062 assert!(
1063 fixed.contains("````\n") || fixed.ends_with("````"),
1064 "Should upgrade closing fence to 4 backticks"
1065 );
1066 assert!(fixed.contains("```json"), "Inner fences should be preserved as content");
1068 }
1069
1070 #[test]
1071 fn test_warning_is_fixable() {
1072 let content = "```markdown\n```python\ncode()\n```\n```\n";
1074 let result = run_check(content).unwrap();
1075 assert_eq!(result.len(), 1);
1076 assert!(
1077 result[0].fix.is_some(),
1078 "MD070 warnings must be marked fixable for the fix coordinator"
1079 );
1080 }
1081
1082 #[test]
1083 fn test_fix_via_warning_struct_is_safe() {
1084 let content = "```markdown\n```python\ncode()\n```\n```\n";
1087 let result = run_check(content).unwrap();
1088 assert_eq!(result.len(), 1);
1089
1090 let fix = result[0].fix.as_ref().unwrap();
1091 let mut fixed = String::new();
1093 fixed.push_str(&content[..fix.range.start]);
1094 fixed.push_str(&fix.replacement);
1095 fixed.push_str(&content[fix.range.end..]);
1096
1097 assert!(
1099 fixed.contains("````markdown"),
1100 "Direct Fix application should upgrade opening fence, got: {fixed}"
1101 );
1102 assert!(
1103 fixed.contains("````\n") || fixed.ends_with("````"),
1104 "Direct Fix application should upgrade closing fence, got: {fixed}"
1105 );
1106 assert!(
1108 fixed.contains("```python"),
1109 "Inner content should be preserved, got: {fixed}"
1110 );
1111 }
1112
1113 #[test]
1114 fn test_fix_via_warning_struct_python_block() {
1115 let content = "```python\ndef f():\n text = \"\"\"\n```json\n{}\n```\n\"\"\"\n print(text)\nf()\n```\n";
1120 let result = run_check(content).unwrap();
1121 assert_eq!(result.len(), 1);
1122
1123 let fix = result[0].fix.as_ref().unwrap();
1124 let mut fixed = String::new();
1125 fixed.push_str(&content[..fix.range.start]);
1126 fixed.push_str(&fix.replacement);
1127 fixed.push_str(&content[fix.range.end..]);
1128
1129 assert!(
1133 fixed.starts_with("````python\n"),
1134 "Should upgrade opening fence, got:\n{fixed}"
1135 );
1136 assert!(
1137 fixed.contains("````\n") || fixed.trim_end().ends_with("````"),
1138 "Should upgrade closing fence, got:\n{fixed}"
1139 );
1140 let fence_start = fixed.find("````python\n").unwrap();
1142 let after_open = fence_start + "````python\n".len();
1143 let close_pos = fixed[after_open..]
1144 .find("\n````\n")
1145 .or_else(|| fixed[after_open..].find("\n````"));
1146 assert!(
1147 close_pos.is_some(),
1148 "Should have closing fence after content, got:\n{fixed}"
1149 );
1150 let block_content = &fixed[after_open..after_open + close_pos.unwrap()];
1151 assert!(
1152 block_content.contains("print(text)"),
1153 "print(text) must be inside the code block, got block:\n{block_content}"
1154 );
1155 assert!(
1156 block_content.contains("f()"),
1157 "f() must be inside the code block, got block:\n{block_content}"
1158 );
1159 assert!(
1160 block_content.contains("```json"),
1161 "Inner fences must be preserved as content, got block:\n{block_content}"
1162 );
1163 }
1164
1165 #[test]
1166 fn test_fix_via_apply_warning_fixes() {
1167 let content = "```markdown\n```python\ncode()\n```\n```\n";
1169 let result = run_check(content).unwrap();
1170 assert_eq!(result.len(), 1);
1171
1172 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &result).unwrap();
1173 assert!(
1174 fixed.contains("````markdown"),
1175 "apply_warning_fixes should upgrade opening fence"
1176 );
1177 assert!(
1178 fixed.contains("````\n") || fixed.ends_with("````"),
1179 "apply_warning_fixes should upgrade closing fence"
1180 );
1181
1182 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1184 let rule = MD070NestedCodeFence::new();
1185 let result2 = rule.check(&ctx2).unwrap();
1186 assert!(
1187 result2.is_empty(),
1188 "Re-check after LSP fix should find no issues, got: {:?}",
1189 result2.iter().map(|w| &w.message).collect::<Vec<_>>()
1190 );
1191 }
1192}