1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rules::code_block_utils::CodeBlockStyle;
3use crate::utils::element_cache::ElementCache;
4use crate::utils::mkdocs_admonitions;
5use crate::utils::mkdocs_footnotes;
6use crate::utils::mkdocs_tabs;
7use crate::utils::range_utils::calculate_line_range;
8use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
9use toml;
10
11mod md046_config;
12use md046_config::MD046Config;
13
14#[derive(Clone)]
20pub struct MD046CodeBlockStyle {
21 config: MD046Config,
22}
23
24impl MD046CodeBlockStyle {
25 pub fn new(style: CodeBlockStyle) -> Self {
26 Self {
27 config: MD046Config { style },
28 }
29 }
30
31 pub fn from_config_struct(config: MD046Config) -> Self {
32 Self { config }
33 }
34
35 fn has_valid_fence_indent(line: &str) -> bool {
40 ElementCache::calculate_indentation_width_default(line) < 4
41 }
42
43 fn is_fenced_code_block_start(&self, line: &str) -> bool {
52 if !Self::has_valid_fence_indent(line) {
53 return false;
54 }
55
56 let trimmed = line.trim_start();
57 trimmed.starts_with("```") || trimmed.starts_with("~~~")
58 }
59
60 fn is_list_item(&self, line: &str) -> bool {
61 let trimmed = line.trim_start();
62 (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
63 || (trimmed.len() > 2
64 && trimmed.chars().next().unwrap().is_numeric()
65 && (trimmed.contains(". ") || trimmed.contains(") ")))
66 }
67
68 fn is_footnote_definition(&self, line: &str) -> bool {
88 let trimmed = line.trim_start();
89 if !trimmed.starts_with("[^") || trimmed.len() < 5 {
90 return false;
91 }
92
93 if let Some(close_bracket_pos) = trimmed.find("]:")
94 && close_bracket_pos > 2
95 {
96 let label = &trimmed[2..close_bracket_pos];
97
98 if label.trim().is_empty() {
99 return false;
100 }
101
102 if label.contains('\r') {
104 return false;
105 }
106
107 if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
109 return true;
110 }
111 }
112
113 false
114 }
115
116 fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
139 let mut in_continuation_context = vec![false; lines.len()];
140 let mut last_list_item_line: Option<usize> = None;
141 let mut last_footnote_line: Option<usize> = None;
142 let mut blank_line_count = 0;
143
144 for (i, line) in lines.iter().enumerate() {
145 let trimmed = line.trim_start();
146 let indent_len = line.len() - trimmed.len();
147
148 if self.is_list_item(line) {
150 last_list_item_line = Some(i);
151 last_footnote_line = None; blank_line_count = 0;
153 in_continuation_context[i] = true;
154 continue;
155 }
156
157 if self.is_footnote_definition(line) {
159 last_footnote_line = Some(i);
160 last_list_item_line = None; blank_line_count = 0;
162 in_continuation_context[i] = true;
163 continue;
164 }
165
166 if line.trim().is_empty() {
168 if last_list_item_line.is_some() || last_footnote_line.is_some() {
170 blank_line_count += 1;
171 in_continuation_context[i] = true;
172
173 }
177 continue;
178 }
179
180 if indent_len == 0 && !trimmed.is_empty() {
182 if trimmed.starts_with('#') {
186 last_list_item_line = None;
187 last_footnote_line = None;
188 blank_line_count = 0;
189 continue;
190 }
191
192 if trimmed.starts_with("---") || trimmed.starts_with("***") {
194 last_list_item_line = None;
195 last_footnote_line = None;
196 blank_line_count = 0;
197 continue;
198 }
199
200 if let Some(list_line) = last_list_item_line
203 && (i - list_line > 5 || blank_line_count > 1)
204 {
205 last_list_item_line = None;
206 }
207
208 if last_footnote_line.is_some() {
210 last_footnote_line = None;
211 }
212
213 blank_line_count = 0;
214
215 if last_list_item_line.is_none() && last_footnote_line.is_some() {
217 last_footnote_line = None;
218 }
219 continue;
220 }
221
222 if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
224 in_continuation_context[i] = true;
225 blank_line_count = 0;
226 }
227 }
228
229 in_continuation_context
230 }
231
232 fn is_indented_code_block_with_context(
234 &self,
235 lines: &[&str],
236 i: usize,
237 is_mkdocs: bool,
238 in_list_context: &[bool],
239 in_tab_context: &[bool],
240 in_admonition_context: &[bool],
241 ) -> bool {
242 if i >= lines.len() {
243 return false;
244 }
245
246 let line = lines[i];
247
248 let indent = ElementCache::calculate_indentation_width_default(line);
250 if indent < 4 {
251 return false;
252 }
253
254 if in_list_context[i] {
256 return false;
257 }
258
259 if is_mkdocs && in_tab_context[i] {
261 return false;
262 }
263
264 if is_mkdocs && in_admonition_context[i] {
267 return false;
268 }
269
270 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
273 let prev_is_indented_code = i > 0
274 && ElementCache::calculate_indentation_width_default(lines[i - 1]) >= 4
275 && !in_list_context[i - 1]
276 && !(is_mkdocs && in_tab_context[i - 1])
277 && !(is_mkdocs && in_admonition_context[i - 1]);
278
279 if !has_blank_line_before && !prev_is_indented_code {
282 return false;
283 }
284
285 true
286 }
287
288 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
290 let mut in_tab_context = vec![false; lines.len()];
291 let mut current_tab_indent: Option<usize> = None;
292
293 for (i, line) in lines.iter().enumerate() {
294 if mkdocs_tabs::is_tab_marker(line) {
296 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
297 current_tab_indent = Some(tab_indent);
298 in_tab_context[i] = true;
299 continue;
300 }
301
302 if let Some(tab_indent) = current_tab_indent {
304 if mkdocs_tabs::is_tab_content(line, tab_indent) {
305 in_tab_context[i] = true;
306 } else if !line.trim().is_empty() && ElementCache::calculate_indentation_width_default(line) < 4 {
307 current_tab_indent = None;
309 } else {
310 in_tab_context[i] = true;
312 }
313 }
314 }
315
316 in_tab_context
317 }
318
319 fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
328 let mut in_admonition_context = vec![false; lines.len()];
329 let mut admonition_stack: Vec<usize> = Vec::new();
331
332 for (i, line) in lines.iter().enumerate() {
333 let line_indent = ElementCache::calculate_indentation_width_default(line);
334
335 if mkdocs_admonitions::is_admonition_start(line) {
337 let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
338
339 while let Some(&top_indent) = admonition_stack.last() {
341 if adm_indent <= top_indent {
343 admonition_stack.pop();
344 } else {
345 break;
346 }
347 }
348
349 admonition_stack.push(adm_indent);
351 in_admonition_context[i] = true;
352 continue;
353 }
354
355 if line.trim().is_empty() {
357 if !admonition_stack.is_empty() {
358 in_admonition_context[i] = true;
359 }
360 continue;
361 }
362
363 while let Some(&top_indent) = admonition_stack.last() {
366 if line_indent >= top_indent + 4 {
368 break;
370 } else {
371 admonition_stack.pop();
373 }
374 }
375
376 if !admonition_stack.is_empty() {
378 in_admonition_context[i] = true;
379 }
380 }
381
382 in_admonition_context
383 }
384
385 fn categorize_indented_blocks(
397 &self,
398 lines: &[&str],
399 is_mkdocs: bool,
400 in_list_context: &[bool],
401 in_tab_context: &[bool],
402 in_admonition_context: &[bool],
403 ) -> (Vec<bool>, Vec<bool>) {
404 let mut is_misplaced = vec![false; lines.len()];
405 let mut contains_fences = vec![false; lines.len()];
406
407 let mut i = 0;
409 while i < lines.len() {
410 if !self.is_indented_code_block_with_context(
412 lines,
413 i,
414 is_mkdocs,
415 in_list_context,
416 in_tab_context,
417 in_admonition_context,
418 ) {
419 i += 1;
420 continue;
421 }
422
423 let block_start = i;
425 let mut block_end = i;
426
427 while block_end < lines.len()
428 && self.is_indented_code_block_with_context(
429 lines,
430 block_end,
431 is_mkdocs,
432 in_list_context,
433 in_tab_context,
434 in_admonition_context,
435 )
436 {
437 block_end += 1;
438 }
439
440 if block_end > block_start {
442 let first_line = lines[block_start].trim_start();
443 let last_line = lines[block_end - 1].trim_start();
444
445 let is_backtick_fence = first_line.starts_with("```");
447 let is_tilde_fence = first_line.starts_with("~~~");
448
449 if is_backtick_fence || is_tilde_fence {
450 let fence_char = if is_backtick_fence { '`' } else { '~' };
451 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
452
453 let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
455 let after_closer = &last_line[closer_fence_len..];
456
457 if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
458 is_misplaced[block_start..block_end].fill(true);
460 } else {
461 contains_fences[block_start..block_end].fill(true);
463 }
464 } else {
465 let has_fence_markers = (block_start..block_end).any(|j| {
468 let trimmed = lines[j].trim_start();
469 trimmed.starts_with("```") || trimmed.starts_with("~~~")
470 });
471
472 if has_fence_markers {
473 contains_fences[block_start..block_end].fill(true);
474 }
475 }
476 }
477
478 i = block_end;
479 }
480
481 (is_misplaced, contains_fences)
482 }
483
484 fn check_unclosed_code_blocks(
485 &self,
486 ctx: &crate::lint_context::LintContext,
487 ) -> Result<Vec<LintWarning>, LintError> {
488 let mut warnings = Vec::new();
489 let lines = ctx.raw_lines();
490
491 let options = Options::all();
493 let parser = Parser::new_ext(ctx.content, options).into_offset_iter();
494
495 let mut code_blocks: Vec<(usize, usize, String, usize, bool, bool)> = Vec::new();
497 let mut current_block_start: Option<(usize, String, usize, bool)> = None;
498
499 for (event, range) in parser {
500 match event {
501 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
502 let line_idx = ctx
504 .line_offsets
505 .iter()
506 .enumerate()
507 .rev()
508 .find(|&(_, offset)| *offset <= range.start)
509 .map(|(idx, _)| idx)
510 .unwrap_or(0);
511
512 let line = lines.get(line_idx).unwrap_or(&"");
514 let trimmed = line.trim();
515
516 let fence_marker = if let Some(pos) = trimmed.find("```") {
518 let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
519 "`".repeat(count)
520 } else if let Some(pos) = trimmed.find("~~~") {
521 let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
522 "~".repeat(count)
523 } else {
524 "```".to_string()
525 };
526
527 let lang_info = info.to_string().to_lowercase();
529 let is_markdown_doc = lang_info.starts_with("markdown") || lang_info.starts_with("md");
530
531 current_block_start = Some((range.start, fence_marker, line_idx, is_markdown_doc));
532 }
533 Event::End(TagEnd::CodeBlock) => {
534 if let Some((start, fence_marker, line_idx, is_markdown_doc)) = current_block_start.take() {
535 code_blocks.push((start, range.end, fence_marker, line_idx, true, is_markdown_doc));
536 }
537 }
538 _ => {}
539 }
540 }
541
542 let has_markdown_doc_block = code_blocks.iter().any(|(_, _, _, _, _, is_md)| *is_md);
546
547 if !has_markdown_doc_block {
551 for (block_start, block_end, fence_marker, opening_line_idx, is_fenced, _is_md) in &code_blocks {
552 if !is_fenced {
553 continue;
554 }
555
556 if *block_end != ctx.content.len() {
558 continue;
559 }
560
561 let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
564 let trimmed = last_non_empty_line.trim();
565 let fence_char = fence_marker.chars().next().unwrap_or('`');
566
567 let has_closing_fence = if fence_char == '`' {
569 trimmed.starts_with("```") && {
570 let fence_len = trimmed.chars().take_while(|&c| c == '`').count();
571 trimmed[fence_len..].trim().is_empty()
572 }
573 } else {
574 trimmed.starts_with("~~~") && {
575 let fence_len = trimmed.chars().take_while(|&c| c == '~').count();
576 trimmed[fence_len..].trim().is_empty()
577 }
578 };
579
580 if !has_closing_fence {
581 let line = lines.get(*opening_line_idx).unwrap_or(&"");
582 let (start_line, start_col, end_line, end_col) = calculate_line_range(*opening_line_idx + 1, line);
583
584 if let Some(line_info) = ctx.lines.get(*opening_line_idx)
586 && line_info.in_html_comment
587 {
588 continue;
589 }
590
591 warnings.push(LintWarning {
592 rule_name: Some(self.name().to_string()),
593 line: start_line,
594 column: start_col,
595 end_line,
596 end_column: end_col,
597 message: format!("Code block opened with '{fence_marker}' but never closed"),
598 severity: Severity::Warning,
599 fix: Some(Fix {
600 range: (ctx.content.len()..ctx.content.len()),
601 replacement: format!("\n{fence_marker}"),
602 }),
603 });
604 }
605
606 let _ = block_start; }
608 }
609
610 if !has_markdown_doc_block && let Some((_start, fence_marker, line_idx, _is_md)) = current_block_start {
613 let line = lines.get(line_idx).unwrap_or(&"");
614 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
615
616 if let Some(line_info) = ctx.lines.get(line_idx)
618 && line_info.in_html_comment
619 {
620 return Ok(warnings);
621 }
622
623 warnings.push(LintWarning {
624 rule_name: Some(self.name().to_string()),
625 line: start_line,
626 column: start_col,
627 end_line,
628 end_column: end_col,
629 message: format!("Code block opened with '{fence_marker}' but never closed"),
630 severity: Severity::Warning,
631 fix: Some(Fix {
632 range: (ctx.content.len()..ctx.content.len()),
633 replacement: format!("\n{fence_marker}"),
634 }),
635 });
636 }
637
638 if has_markdown_doc_block {
643 return Ok(warnings);
644 }
645
646 for (block_start, block_end, fence_marker, opening_line_idx, is_fenced, is_markdown_doc) in &code_blocks {
647 if !is_fenced {
648 continue;
649 }
650
651 if *is_markdown_doc {
653 continue;
654 }
655
656 let opening_line = lines.get(*opening_line_idx).unwrap_or(&"");
657
658 let fence_char = fence_marker.chars().next().unwrap_or('`');
659 let fence_length = fence_marker.len();
660
661 for (i, line) in lines.iter().enumerate() {
663 let line_start = ctx.line_offsets.get(i).copied().unwrap_or(0);
664 let line_end = ctx.line_offsets.get(i + 1).copied().unwrap_or(ctx.content.len());
665
666 if line_start <= *block_start || line_end >= *block_end {
668 continue;
669 }
670
671 if let Some(line_info) = ctx.lines.get(i)
673 && line_info.in_html_comment
674 {
675 continue;
676 }
677
678 let trimmed = line.trim();
679
680 if (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
682 && trimmed.starts_with(&fence_char.to_string())
683 {
684 let inner_fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
685 let after_fence = &trimmed[inner_fence_length..];
686
687 if inner_fence_length >= fence_length
689 && !after_fence.trim().is_empty()
690 && !after_fence.contains('`')
691 {
692 let identifier = after_fence.trim();
694 let looks_like_language =
695 identifier.chars().next().is_some_and(|c| c.is_alphabetic() || c == '#')
696 && identifier.len() <= 30
697 && identifier.chars().all(|c| c.is_alphanumeric() || "-_+#. ".contains(c));
698
699 if looks_like_language {
700 let (start_line, start_col, end_line, end_col) =
701 calculate_line_range(*opening_line_idx + 1, opening_line);
702
703 let line_start_byte = ctx.line_index.get_line_start_byte(i + 1).unwrap_or(0);
704
705 warnings.push(LintWarning {
706 rule_name: Some(self.name().to_string()),
707 line: start_line,
708 column: start_col,
709 end_line,
710 end_column: end_col,
711 message: format!(
712 "Code block '{fence_marker}' should be closed before starting new one at line {}",
713 i + 1
714 ),
715 severity: Severity::Warning,
716 fix: Some(Fix {
717 range: (line_start_byte..line_start_byte),
718 replacement: format!("{fence_marker}\n\n"),
719 }),
720 });
721
722 break; }
724 }
725 }
726 }
727 }
728
729 Ok(warnings)
730 }
731
732 fn detect_style(&self, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
733 if content.is_empty() {
735 return None;
736 }
737
738 let lines: Vec<&str> = content.lines().collect();
739 let mut fenced_count = 0;
740 let mut indented_count = 0;
741
742 let in_list_context = self.precompute_block_continuation_context(&lines);
744 let in_tab_context = if is_mkdocs {
745 self.precompute_mkdocs_tab_context(&lines)
746 } else {
747 vec![false; lines.len()]
748 };
749 let in_admonition_context = if is_mkdocs {
750 self.precompute_mkdocs_admonition_context(&lines)
751 } else {
752 vec![false; lines.len()]
753 };
754
755 let mut in_fenced = false;
757 let mut prev_was_indented = false;
758
759 for (i, line) in lines.iter().enumerate() {
760 if self.is_fenced_code_block_start(line) {
761 if !in_fenced {
762 fenced_count += 1;
764 in_fenced = true;
765 } else {
766 in_fenced = false;
768 }
769 } else if !in_fenced
770 && self.is_indented_code_block_with_context(
771 &lines,
772 i,
773 is_mkdocs,
774 &in_list_context,
775 &in_tab_context,
776 &in_admonition_context,
777 )
778 {
779 if !prev_was_indented {
781 indented_count += 1;
782 }
783 prev_was_indented = true;
784 } else {
785 prev_was_indented = false;
786 }
787 }
788
789 if fenced_count == 0 && indented_count == 0 {
790 None
792 } else if fenced_count > 0 && indented_count == 0 {
793 Some(CodeBlockStyle::Fenced)
795 } else if fenced_count == 0 && indented_count > 0 {
796 Some(CodeBlockStyle::Indented)
798 } else {
799 if fenced_count >= indented_count {
802 Some(CodeBlockStyle::Fenced)
803 } else {
804 Some(CodeBlockStyle::Indented)
805 }
806 }
807 }
808}
809
810#[inline]
811fn line_idx_from_offset(line_offsets: &[usize], offset: usize) -> usize {
812 match line_offsets.binary_search(&offset) {
813 Ok(idx) => idx,
814 Err(idx) => idx.saturating_sub(1),
815 }
816}
817
818impl Rule for MD046CodeBlockStyle {
819 fn name(&self) -> &'static str {
820 "MD046"
821 }
822
823 fn description(&self) -> &'static str {
824 "Code blocks should use a consistent style"
825 }
826
827 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
828 if ctx.content.is_empty() {
830 return Ok(Vec::new());
831 }
832
833 if !ctx.content.contains("```")
835 && !ctx.content.contains("~~~")
836 && !ctx.content.contains(" ")
837 && !ctx.content.contains('\t')
838 {
839 return Ok(Vec::new());
840 }
841
842 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
844
845 if !unclosed_warnings.is_empty() {
847 return Ok(unclosed_warnings);
848 }
849
850 let lines = ctx.raw_lines();
852 let mut warnings = Vec::new();
853
854 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
856
857 let target_style = match self.config.style {
859 CodeBlockStyle::Consistent => self
860 .detect_style(ctx.content, is_mkdocs)
861 .unwrap_or(CodeBlockStyle::Fenced),
862 _ => self.config.style,
863 };
864
865 let in_tab_context = if is_mkdocs {
867 self.precompute_mkdocs_tab_context(lines)
868 } else {
869 vec![false; lines.len()]
870 };
871 let in_admonition_context = if is_mkdocs {
872 self.precompute_mkdocs_admonition_context(lines)
873 } else {
874 vec![false; lines.len()]
875 };
876
877 let mut in_fenced_block = vec![false; lines.len()];
880 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
881
882 let options = Options::all();
883 let parser = Parser::new_ext(ctx.content, options).into_offset_iter();
884
885 for (event, range) in parser {
886 let start = range.start;
887 let end = range.end;
888
889 if start >= ctx.content.len() || end > ctx.content.len() {
890 continue;
891 }
892
893 let start_line_idx = line_idx_from_offset(&ctx.line_offsets, start);
895
896 match event {
897 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => {
898 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
900 if line_info.byte_offset >= start && line_info.byte_offset < end {
901 in_fenced_block[line_idx] = true;
902 }
903 }
904
905 if target_style == CodeBlockStyle::Indented {
907 let line = lines.get(start_line_idx).unwrap_or(&"");
908
909 if ctx.lines.get(start_line_idx).is_some_and(|info| info.in_html_comment) {
911 continue;
912 }
913
914 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
915 warnings.push(LintWarning {
916 rule_name: Some(self.name().to_string()),
917 line: start_line,
918 column: start_col,
919 end_line,
920 end_column: end_col,
921 message: "Use indented code blocks".to_string(),
922 severity: Severity::Warning,
923 fix: None,
924 });
925 }
926 }
927 Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
928 if target_style == CodeBlockStyle::Fenced && !reported_indented_lines.contains(&start_line_idx) {
932 let line = lines.get(start_line_idx).unwrap_or(&"");
933
934 if ctx.lines.get(start_line_idx).is_some_and(|info| {
937 info.in_html_comment || info.in_mkdocstrings || info.blockquote.is_some()
938 }) {
939 continue;
940 }
941
942 if mkdocs_footnotes::is_within_footnote_definition(ctx.content, start) {
944 continue;
945 }
946
947 if is_mkdocs && in_tab_context.get(start_line_idx).copied().unwrap_or(false) {
949 continue;
950 }
951
952 if is_mkdocs && in_admonition_context.get(start_line_idx).copied().unwrap_or(false) {
954 continue;
955 }
956
957 reported_indented_lines.insert(start_line_idx);
958
959 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
960 warnings.push(LintWarning {
961 rule_name: Some(self.name().to_string()),
962 line: start_line,
963 column: start_col,
964 end_line,
965 end_column: end_col,
966 message: "Use fenced code blocks".to_string(),
967 severity: Severity::Warning,
968 fix: None,
969 });
970 }
971 }
972 _ => {}
973 }
974 }
975
976 warnings.sort_by_key(|w| (w.line, w.column));
978
979 Ok(warnings)
980 }
981
982 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
983 let content = ctx.content;
984 if content.is_empty() {
985 return Ok(String::new());
986 }
987
988 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
990
991 if !unclosed_warnings.is_empty() {
993 for warning in &unclosed_warnings {
995 if warning
996 .message
997 .contains("should be closed before starting new one at line")
998 {
999 if let Some(fix) = &warning.fix {
1001 let mut result = String::new();
1002 result.push_str(&content[..fix.range.start]);
1003 result.push_str(&fix.replacement);
1004 result.push_str(&content[fix.range.start..]);
1005 return Ok(result);
1006 }
1007 }
1008 }
1009 }
1010
1011 let lines = ctx.raw_lines();
1012
1013 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1015 let target_style = match self.config.style {
1016 CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
1017 _ => self.config.style,
1018 };
1019
1020 let in_list_context = self.precompute_block_continuation_context(lines);
1022 let in_tab_context = if is_mkdocs {
1023 self.precompute_mkdocs_tab_context(lines)
1024 } else {
1025 vec![false; lines.len()]
1026 };
1027 let in_admonition_context = if is_mkdocs {
1028 self.precompute_mkdocs_admonition_context(lines)
1029 } else {
1030 vec![false; lines.len()]
1031 };
1032
1033 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(
1037 lines,
1038 is_mkdocs,
1039 &in_list_context,
1040 &in_tab_context,
1041 &in_admonition_context,
1042 );
1043
1044 let mut result = String::with_capacity(content.len());
1045 let mut in_fenced_block = false;
1046 let mut fenced_fence_type = None;
1047 let mut in_indented_block = false;
1048
1049 for (i, line) in lines.iter().enumerate() {
1050 let trimmed = line.trim_start();
1051
1052 if !in_fenced_block
1055 && Self::has_valid_fence_indent(line)
1056 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1057 {
1058 in_fenced_block = true;
1059 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
1060
1061 if target_style == CodeBlockStyle::Indented {
1062 in_indented_block = true;
1064 } else {
1065 result.push_str(line);
1067 result.push('\n');
1068 }
1069 } else if in_fenced_block && fenced_fence_type.is_some() {
1070 let fence = fenced_fence_type.unwrap();
1071 if trimmed.starts_with(fence) {
1072 in_fenced_block = false;
1073 fenced_fence_type = None;
1074 in_indented_block = false;
1075
1076 if target_style == CodeBlockStyle::Indented {
1077 } else {
1079 result.push_str(line);
1081 result.push('\n');
1082 }
1083 } else if target_style == CodeBlockStyle::Indented {
1084 result.push_str(" ");
1088 result.push_str(line);
1089 result.push('\n');
1090 } else {
1091 result.push_str(line);
1093 result.push('\n');
1094 }
1095 } else if self.is_indented_code_block_with_context(
1096 lines,
1097 i,
1098 is_mkdocs,
1099 &in_list_context,
1100 &in_tab_context,
1101 &in_admonition_context,
1102 ) {
1103 let prev_line_is_indented = i > 0
1107 && self.is_indented_code_block_with_context(
1108 lines,
1109 i - 1,
1110 is_mkdocs,
1111 &in_list_context,
1112 &in_tab_context,
1113 &in_admonition_context,
1114 );
1115
1116 if target_style == CodeBlockStyle::Fenced {
1117 let trimmed_content = line.trim_start();
1118
1119 if misplaced_fence_lines[i] {
1122 result.push_str(trimmed_content);
1124 result.push('\n');
1125 } else if unsafe_fence_lines[i] {
1126 result.push_str(line);
1129 result.push('\n');
1130 } else if !prev_line_is_indented && !in_indented_block {
1131 result.push_str("```\n");
1133 result.push_str(trimmed_content);
1134 result.push('\n');
1135 in_indented_block = true;
1136 } else {
1137 result.push_str(trimmed_content);
1139 result.push('\n');
1140 }
1141
1142 let next_line_is_indented = i < lines.len() - 1
1144 && self.is_indented_code_block_with_context(
1145 lines,
1146 i + 1,
1147 is_mkdocs,
1148 &in_list_context,
1149 &in_tab_context,
1150 &in_admonition_context,
1151 );
1152 if !next_line_is_indented
1154 && in_indented_block
1155 && !misplaced_fence_lines[i]
1156 && !unsafe_fence_lines[i]
1157 {
1158 result.push_str("```\n");
1159 in_indented_block = false;
1160 }
1161 } else {
1162 result.push_str(line);
1164 result.push('\n');
1165 }
1166 } else {
1167 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1169 result.push_str("```\n");
1170 in_indented_block = false;
1171 }
1172
1173 result.push_str(line);
1174 result.push('\n');
1175 }
1176 }
1177
1178 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1180 result.push_str("```\n");
1181 }
1182
1183 if let Some(fence_type) = fenced_fence_type
1185 && in_fenced_block
1186 {
1187 result.push_str(fence_type);
1188 result.push('\n');
1189 }
1190
1191 if !content.ends_with('\n') && result.ends_with('\n') {
1193 result.pop();
1194 }
1195
1196 Ok(result)
1197 }
1198
1199 fn category(&self) -> RuleCategory {
1201 RuleCategory::CodeBlock
1202 }
1203
1204 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1206 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1209 }
1210
1211 fn as_any(&self) -> &dyn std::any::Any {
1212 self
1213 }
1214
1215 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1216 let json_value = serde_json::to_value(&self.config).ok()?;
1217 Some((
1218 self.name().to_string(),
1219 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1220 ))
1221 }
1222
1223 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1224 where
1225 Self: Sized,
1226 {
1227 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1228 Box::new(Self::from_config_struct(rule_config))
1229 }
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234 use super::*;
1235 use crate::lint_context::LintContext;
1236
1237 #[test]
1238 fn test_fenced_code_block_detection() {
1239 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1240 assert!(rule.is_fenced_code_block_start("```"));
1241 assert!(rule.is_fenced_code_block_start("```rust"));
1242 assert!(rule.is_fenced_code_block_start("~~~"));
1243 assert!(rule.is_fenced_code_block_start("~~~python"));
1244 assert!(rule.is_fenced_code_block_start(" ```"));
1245 assert!(!rule.is_fenced_code_block_start("``"));
1246 assert!(!rule.is_fenced_code_block_start("~~"));
1247 assert!(!rule.is_fenced_code_block_start("Regular text"));
1248 }
1249
1250 #[test]
1251 fn test_consistent_style_with_fenced_blocks() {
1252 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1253 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 let result = rule.check(&ctx).unwrap();
1256
1257 assert_eq!(result.len(), 0);
1259 }
1260
1261 #[test]
1262 fn test_consistent_style_with_indented_blocks() {
1263 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1264 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let result = rule.check(&ctx).unwrap();
1267
1268 assert_eq!(result.len(), 0);
1270 }
1271
1272 #[test]
1273 fn test_consistent_style_mixed() {
1274 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1275 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 let result = rule.check(&ctx).unwrap();
1278
1279 assert!(!result.is_empty());
1281 }
1282
1283 #[test]
1284 fn test_fenced_style_with_indented_blocks() {
1285 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1286 let content = "Text\n\n indented code\n more code\n\nMore text";
1287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1288 let result = rule.check(&ctx).unwrap();
1289
1290 assert!(!result.is_empty());
1292 assert!(result[0].message.contains("Use fenced code blocks"));
1293 }
1294
1295 #[test]
1296 fn test_fenced_style_with_tab_indented_blocks() {
1297 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1298 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let result = rule.check(&ctx).unwrap();
1301
1302 assert!(!result.is_empty());
1304 assert!(result[0].message.contains("Use fenced code blocks"));
1305 }
1306
1307 #[test]
1308 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1309 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1310 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313 let result = rule.check(&ctx).unwrap();
1314
1315 assert!(
1317 !result.is_empty(),
1318 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1319 );
1320 assert!(result[0].message.contains("Use fenced code blocks"));
1321 }
1322
1323 #[test]
1324 fn test_fenced_style_with_one_space_tab_indent() {
1325 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1326 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329 let result = rule.check(&ctx).unwrap();
1330
1331 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1332 assert!(result[0].message.contains("Use fenced code blocks"));
1333 }
1334
1335 #[test]
1336 fn test_indented_style_with_fenced_blocks() {
1337 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1338 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let result = rule.check(&ctx).unwrap();
1341
1342 assert!(!result.is_empty());
1344 assert!(result[0].message.contains("Use indented code blocks"));
1345 }
1346
1347 #[test]
1348 fn test_unclosed_code_block() {
1349 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1350 let content = "```\ncode without closing fence";
1351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1352 let result = rule.check(&ctx).unwrap();
1353
1354 assert_eq!(result.len(), 1);
1355 assert!(result[0].message.contains("never closed"));
1356 }
1357
1358 #[test]
1359 fn test_nested_code_blocks() {
1360 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1361 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1363 let result = rule.check(&ctx).unwrap();
1364
1365 assert_eq!(result.len(), 0);
1367 }
1368
1369 #[test]
1370 fn test_fix_indented_to_fenced() {
1371 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1372 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1374 let fixed = rule.fix(&ctx).unwrap();
1375
1376 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1377 }
1378
1379 #[test]
1380 fn test_fix_fenced_to_indented() {
1381 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1382 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1384 let fixed = rule.fix(&ctx).unwrap();
1385
1386 assert!(fixed.contains(" code line 1\n code line 2"));
1387 assert!(!fixed.contains("```"));
1388 }
1389
1390 #[test]
1391 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1392 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1395 let content = r#"# Test
1396
1397```html
1398<!doctype html>
1399<html>
1400 <head>
1401 <title>Test</title>
1402 </head>
1403</html>
1404```
1405"#;
1406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407 let fixed = rule.fix(&ctx).unwrap();
1408
1409 assert!(
1412 fixed.contains(" <head>"),
1413 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1414 );
1415 assert!(
1416 fixed.contains(" <title>"),
1417 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1418 );
1419 assert!(!fixed.contains("```"), "Fenced markers should be removed");
1420 }
1421
1422 #[test]
1423 fn test_fix_fenced_to_indented_preserves_python_indentation() {
1424 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1426 let content = r#"# Python Example
1427
1428```python
1429def greet(name):
1430 if name:
1431 print(f"Hello, {name}!")
1432 else:
1433 print("Hello, World!")
1434```
1435"#;
1436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1437 let fixed = rule.fix(&ctx).unwrap();
1438
1439 assert!(
1441 fixed.contains(" def greet(name):"),
1442 "Function def should have 4 spaces (code block indent)"
1443 );
1444 assert!(
1445 fixed.contains(" if name:"),
1446 "if statement should have 8 spaces (4 code + 4 Python)"
1447 );
1448 assert!(
1449 fixed.contains(" print"),
1450 "print should have 12 spaces (4 code + 8 Python)"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1456 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1458 let content = r#"# Config
1459
1460```yaml
1461server:
1462 host: localhost
1463 port: 8080
1464 ssl:
1465 enabled: true
1466 cert: /path/to/cert
1467```
1468"#;
1469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1470 let fixed = rule.fix(&ctx).unwrap();
1471
1472 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
1473 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
1474 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
1475 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
1476 }
1477
1478 #[test]
1479 fn test_fix_fenced_to_indented_preserves_empty_lines() {
1480 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1482 let content = "```\nline1\n\nline2\n```\n";
1483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1484 let fixed = rule.fix(&ctx).unwrap();
1485
1486 assert!(fixed.contains(" line1"), "line1 should be indented");
1488 assert!(fixed.contains(" line2"), "line2 should be indented");
1489 }
1491
1492 #[test]
1493 fn test_fix_fenced_to_indented_multiple_blocks() {
1494 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1496 let content = r#"# Doc
1497
1498```python
1499def foo():
1500 pass
1501```
1502
1503Text between.
1504
1505```yaml
1506key:
1507 value: 1
1508```
1509"#;
1510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511 let fixed = rule.fix(&ctx).unwrap();
1512
1513 assert!(fixed.contains(" def foo():"), "Python def should be indented");
1514 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
1515 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
1516 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
1517 assert!(!fixed.contains("```"), "No fence markers should remain");
1518 }
1519
1520 #[test]
1521 fn test_fix_unclosed_block() {
1522 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1523 let content = "```\ncode without closing";
1524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1525 let fixed = rule.fix(&ctx).unwrap();
1526
1527 assert!(fixed.ends_with("```"));
1529 }
1530
1531 #[test]
1532 fn test_code_block_in_list() {
1533 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1534 let content = "- List item\n code in list\n more code\n- Next item";
1535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1536 let result = rule.check(&ctx).unwrap();
1537
1538 assert_eq!(result.len(), 0);
1540 }
1541
1542 #[test]
1543 fn test_detect_style_fenced() {
1544 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1545 let content = "```\ncode\n```";
1546 let style = rule.detect_style(content, false);
1547
1548 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1549 }
1550
1551 #[test]
1552 fn test_detect_style_indented() {
1553 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1554 let content = "Text\n\n code\n\nMore";
1555 let style = rule.detect_style(content, false);
1556
1557 assert_eq!(style, Some(CodeBlockStyle::Indented));
1558 }
1559
1560 #[test]
1561 fn test_detect_style_none() {
1562 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1563 let content = "No code blocks here";
1564 let style = rule.detect_style(content, false);
1565
1566 assert_eq!(style, None);
1567 }
1568
1569 #[test]
1570 fn test_tilde_fence() {
1571 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1572 let content = "~~~\ncode\n~~~";
1573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1574 let result = rule.check(&ctx).unwrap();
1575
1576 assert_eq!(result.len(), 0);
1578 }
1579
1580 #[test]
1581 fn test_language_specification() {
1582 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1583 let content = "```rust\nfn main() {}\n```";
1584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585 let result = rule.check(&ctx).unwrap();
1586
1587 assert_eq!(result.len(), 0);
1588 }
1589
1590 #[test]
1591 fn test_empty_content() {
1592 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1593 let content = "";
1594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1595 let result = rule.check(&ctx).unwrap();
1596
1597 assert_eq!(result.len(), 0);
1598 }
1599
1600 #[test]
1601 fn test_default_config() {
1602 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1603 let (name, _config) = rule.default_config_section().unwrap();
1604 assert_eq!(name, "MD046");
1605 }
1606
1607 #[test]
1608 fn test_markdown_documentation_block() {
1609 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1610 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let result = rule.check(&ctx).unwrap();
1613
1614 assert_eq!(result.len(), 0);
1616 }
1617
1618 #[test]
1619 fn test_preserve_trailing_newline() {
1620 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1621 let content = "```\ncode\n```\n";
1622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1623 let fixed = rule.fix(&ctx).unwrap();
1624
1625 assert_eq!(fixed, content);
1626 }
1627
1628 #[test]
1629 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1630 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1631 let content = r#"# Document
1632
1633=== "Python"
1634
1635 This is tab content
1636 Not an indented code block
1637
1638 ```python
1639 def hello():
1640 print("Hello")
1641 ```
1642
1643=== "JavaScript"
1644
1645 More tab content here
1646 Also not an indented code block"#;
1647
1648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1649 let result = rule.check(&ctx).unwrap();
1650
1651 assert_eq!(result.len(), 0);
1653 }
1654
1655 #[test]
1656 fn test_mkdocs_tabs_with_actual_indented_code() {
1657 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1658 let content = r#"# Document
1659
1660=== "Tab 1"
1661
1662 This is tab content
1663
1664Regular text
1665
1666 This is an actual indented code block
1667 Should be flagged"#;
1668
1669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1670 let result = rule.check(&ctx).unwrap();
1671
1672 assert_eq!(result.len(), 1);
1674 assert!(result[0].message.contains("Use fenced code blocks"));
1675 }
1676
1677 #[test]
1678 fn test_mkdocs_tabs_detect_style() {
1679 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1680 let content = r#"=== "Tab 1"
1681
1682 Content in tab
1683 More content
1684
1685=== "Tab 2"
1686
1687 Content in second tab"#;
1688
1689 let style = rule.detect_style(content, true);
1691 assert_eq!(style, None); let style = rule.detect_style(content, false);
1695 assert_eq!(style, Some(CodeBlockStyle::Indented));
1696 }
1697
1698 #[test]
1699 fn test_mkdocs_nested_tabs() {
1700 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1701 let content = r#"# Document
1702
1703=== "Outer Tab"
1704
1705 Some content
1706
1707 === "Nested Tab"
1708
1709 Nested tab content
1710 Should not be flagged"#;
1711
1712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1713 let result = rule.check(&ctx).unwrap();
1714
1715 assert_eq!(result.len(), 0);
1717 }
1718
1719 #[test]
1720 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1721 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1724 let content = r#"# Document
1725
1726!!! note
1727 This is normal admonition content, not a code block.
1728 It spans multiple lines.
1729
1730??? warning "Collapsible Warning"
1731 This is also admonition content.
1732
1733???+ tip "Expanded Tip"
1734 And this one too.
1735
1736Regular text outside admonitions."#;
1737
1738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1739 let result = rule.check(&ctx).unwrap();
1740
1741 assert_eq!(
1743 result.len(),
1744 0,
1745 "Admonition content in MkDocs mode should not trigger MD046"
1746 );
1747 }
1748
1749 #[test]
1750 fn test_mkdocs_admonition_with_actual_indented_code() {
1751 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1753 let content = r#"# Document
1754
1755!!! note
1756 This is admonition content.
1757
1758Regular text ends the admonition.
1759
1760 This is actual indented code (should be flagged)"#;
1761
1762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1763 let result = rule.check(&ctx).unwrap();
1764
1765 assert_eq!(result.len(), 1);
1767 assert!(result[0].message.contains("Use fenced code blocks"));
1768 }
1769
1770 #[test]
1771 fn test_admonition_in_standard_mode_flagged() {
1772 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1776 let content = r#"# Document
1777
1778!!! note
1779
1780 This looks like code in standard mode.
1781
1782Regular text."#;
1783
1784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1786 let result = rule.check(&ctx).unwrap();
1787
1788 assert_eq!(
1790 result.len(),
1791 1,
1792 "Admonition content in Standard mode should be flagged as indented code"
1793 );
1794 }
1795
1796 #[test]
1797 fn test_mkdocs_admonition_with_fenced_code_inside() {
1798 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1800 let content = r#"# Document
1801
1802!!! note "Code Example"
1803 Here's some code:
1804
1805 ```python
1806 def hello():
1807 print("world")
1808 ```
1809
1810 More text after code.
1811
1812Regular text."#;
1813
1814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1815 let result = rule.check(&ctx).unwrap();
1816
1817 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1819 }
1820
1821 #[test]
1822 fn test_mkdocs_nested_admonitions() {
1823 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1825 let content = r#"# Document
1826
1827!!! note "Outer"
1828 Outer content.
1829
1830 !!! warning "Inner"
1831 Inner content.
1832 More inner content.
1833
1834 Back to outer.
1835
1836Regular text."#;
1837
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1839 let result = rule.check(&ctx).unwrap();
1840
1841 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1843 }
1844
1845 #[test]
1846 fn test_mkdocs_admonition_fix_does_not_wrap() {
1847 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1849 let content = r#"!!! note
1850 Content that should stay as admonition content.
1851 Not be wrapped in code fences.
1852"#;
1853
1854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1855 let fixed = rule.fix(&ctx).unwrap();
1856
1857 assert!(
1859 !fixed.contains("```\n Content"),
1860 "Admonition content should not be wrapped in fences"
1861 );
1862 assert_eq!(fixed, content, "Content should remain unchanged");
1863 }
1864
1865 #[test]
1866 fn test_mkdocs_empty_admonition() {
1867 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1869 let content = r#"!!! note
1870
1871Regular paragraph after empty admonition.
1872
1873 This IS an indented code block (after blank + non-indented line)."#;
1874
1875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1876 let result = rule.check(&ctx).unwrap();
1877
1878 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1880 }
1881
1882 #[test]
1883 fn test_mkdocs_indented_admonition() {
1884 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1886 let content = r#"- List item
1887
1888 !!! note
1889 Indented admonition content.
1890 More content.
1891
1892- Next item"#;
1893
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1895 let result = rule.check(&ctx).unwrap();
1896
1897 assert_eq!(
1899 result.len(),
1900 0,
1901 "Indented admonitions (e.g., in lists) should not be flagged"
1902 );
1903 }
1904
1905 #[test]
1906 fn test_footnote_indented_paragraphs_not_flagged() {
1907 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1908 let content = r#"# Test Document with Footnotes
1909
1910This is some text with a footnote[^1].
1911
1912Here's some code:
1913
1914```bash
1915echo "fenced code block"
1916```
1917
1918More text with another footnote[^2].
1919
1920[^1]: Really interesting footnote text.
1921
1922 Even more interesting second paragraph.
1923
1924[^2]: Another footnote.
1925
1926 With a second paragraph too.
1927
1928 And even a third paragraph!"#;
1929
1930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1931 let result = rule.check(&ctx).unwrap();
1932
1933 assert_eq!(result.len(), 0);
1935 }
1936
1937 #[test]
1938 fn test_footnote_definition_detection() {
1939 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1940
1941 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1944 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1945 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1946 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1947 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1948 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1949 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1950 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1951 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1952
1953 assert!(!rule.is_footnote_definition("[^]: No label"));
1955 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1956 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1957 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1958
1959 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1961 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1962 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1963 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1966
1967 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1969 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1970 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1971 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1972 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1973
1974 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1977 }
1978
1979 #[test]
1980 fn test_footnote_with_blank_lines() {
1981 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1985 let content = r#"# Document
1986
1987Text with footnote[^1].
1988
1989[^1]: First paragraph.
1990
1991 Second paragraph after blank line.
1992
1993 Third paragraph after another blank line.
1994
1995Regular text at column 0 ends the footnote."#;
1996
1997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998 let result = rule.check(&ctx).unwrap();
1999
2000 assert_eq!(
2002 result.len(),
2003 0,
2004 "Indented content within footnotes should not trigger MD046"
2005 );
2006 }
2007
2008 #[test]
2009 fn test_footnote_multiple_consecutive_blank_lines() {
2010 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2013 let content = r#"Text[^1].
2014
2015[^1]: First paragraph.
2016
2017
2018
2019 Content after three blank lines (still part of footnote).
2020
2021Not indented, so footnote ends here."#;
2022
2023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2024 let result = rule.check(&ctx).unwrap();
2025
2026 assert_eq!(
2028 result.len(),
2029 0,
2030 "Multiple blank lines shouldn't break footnote continuation"
2031 );
2032 }
2033
2034 #[test]
2035 fn test_footnote_terminated_by_non_indented_content() {
2036 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2039 let content = r#"[^1]: Footnote content.
2040
2041 More indented content in footnote.
2042
2043This paragraph is not indented, so footnote ends.
2044
2045 This should be flagged as indented code block."#;
2046
2047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2048 let result = rule.check(&ctx).unwrap();
2049
2050 assert_eq!(
2052 result.len(),
2053 1,
2054 "Indented code after footnote termination should be flagged"
2055 );
2056 assert!(
2057 result[0].message.contains("Use fenced code blocks"),
2058 "Expected MD046 warning for indented code block"
2059 );
2060 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2061 }
2062
2063 #[test]
2064 fn test_footnote_terminated_by_structural_elements() {
2065 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2067 let content = r#"[^1]: Footnote content.
2068
2069 More content.
2070
2071## Heading terminates footnote
2072
2073 This indented content should be flagged.
2074
2075---
2076
2077 This should also be flagged (after horizontal rule)."#;
2078
2079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2080 let result = rule.check(&ctx).unwrap();
2081
2082 assert_eq!(
2084 result.len(),
2085 2,
2086 "Both indented blocks after termination should be flagged"
2087 );
2088 }
2089
2090 #[test]
2091 fn test_footnote_with_code_block_inside() {
2092 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2095 let content = r#"Text[^1].
2096
2097[^1]: Footnote with code:
2098
2099 ```python
2100 def hello():
2101 print("world")
2102 ```
2103
2104 More footnote text after code."#;
2105
2106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2107 let result = rule.check(&ctx).unwrap();
2108
2109 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2111 }
2112
2113 #[test]
2114 fn test_footnote_with_8_space_indented_code() {
2115 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2118 let content = r#"Text[^1].
2119
2120[^1]: Footnote with nested code.
2121
2122 code block
2123 more code"#;
2124
2125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2126 let result = rule.check(&ctx).unwrap();
2127
2128 assert_eq!(
2130 result.len(),
2131 0,
2132 "8-space indented code within footnotes represents nested code blocks"
2133 );
2134 }
2135
2136 #[test]
2137 fn test_multiple_footnotes() {
2138 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2141 let content = r#"Text[^1] and more[^2].
2142
2143[^1]: First footnote.
2144
2145 Continuation of first.
2146
2147[^2]: Second footnote starts here, ending the first.
2148
2149 Continuation of second."#;
2150
2151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2152 let result = rule.check(&ctx).unwrap();
2153
2154 assert_eq!(
2156 result.len(),
2157 0,
2158 "Multiple footnotes should each maintain their continuation context"
2159 );
2160 }
2161
2162 #[test]
2163 fn test_list_item_ends_footnote_context() {
2164 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2166 let content = r#"[^1]: Footnote.
2167
2168 Content in footnote.
2169
2170- List item starts here (ends footnote context).
2171
2172 This indented content is part of the list, not the footnote."#;
2173
2174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2175 let result = rule.check(&ctx).unwrap();
2176
2177 assert_eq!(
2179 result.len(),
2180 0,
2181 "List items should end footnote context and start their own"
2182 );
2183 }
2184
2185 #[test]
2186 fn test_footnote_vs_actual_indented_code() {
2187 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2190 let content = r#"# Heading
2191
2192Text with footnote[^1].
2193
2194[^1]: Footnote content.
2195
2196 Part of footnote (should not be flagged).
2197
2198Regular paragraph ends footnote context.
2199
2200 This is actual indented code (MUST be flagged)
2201 Should be detected as code block"#;
2202
2203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2204 let result = rule.check(&ctx).unwrap();
2205
2206 assert_eq!(
2208 result.len(),
2209 1,
2210 "Must still detect indented code blocks outside footnotes"
2211 );
2212 assert!(
2213 result[0].message.contains("Use fenced code blocks"),
2214 "Expected MD046 warning for indented code"
2215 );
2216 assert!(
2217 result[0].line >= 11,
2218 "Warning should be on the actual indented code line"
2219 );
2220 }
2221
2222 #[test]
2223 fn test_spec_compliant_label_characters() {
2224 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2227
2228 assert!(rule.is_footnote_definition("[^test]: text"));
2230 assert!(rule.is_footnote_definition("[^TEST]: text"));
2231 assert!(rule.is_footnote_definition("[^test-name]: text"));
2232 assert!(rule.is_footnote_definition("[^test_name]: text"));
2233 assert!(rule.is_footnote_definition("[^test123]: text"));
2234 assert!(rule.is_footnote_definition("[^123]: text"));
2235 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2236
2237 assert!(!rule.is_footnote_definition("[^test.name]: text")); assert!(!rule.is_footnote_definition("[^test name]: text")); assert!(!rule.is_footnote_definition("[^test@name]: text")); assert!(!rule.is_footnote_definition("[^test#name]: text")); assert!(!rule.is_footnote_definition("[^test$name]: text")); assert!(!rule.is_footnote_definition("[^test%name]: text")); }
2245
2246 #[test]
2247 fn test_code_block_inside_html_comment() {
2248 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2251 let content = r#"# Document
2252
2253Some text.
2254
2255<!--
2256Example code block in comment:
2257
2258```typescript
2259console.log("Hello");
2260```
2261
2262More comment text.
2263-->
2264
2265More content."#;
2266
2267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2268 let result = rule.check(&ctx).unwrap();
2269
2270 assert_eq!(
2271 result.len(),
2272 0,
2273 "Code blocks inside HTML comments should not be flagged as unclosed"
2274 );
2275 }
2276
2277 #[test]
2278 fn test_unclosed_fence_inside_html_comment() {
2279 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2281 let content = r#"# Document
2282
2283<!--
2284Example with intentionally unclosed fence:
2285
2286```
2287code without closing
2288-->
2289
2290More content."#;
2291
2292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2293 let result = rule.check(&ctx).unwrap();
2294
2295 assert_eq!(
2296 result.len(),
2297 0,
2298 "Unclosed fences inside HTML comments should be ignored"
2299 );
2300 }
2301
2302 #[test]
2303 fn test_multiline_html_comment_with_indented_code() {
2304 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2306 let content = r#"# Document
2307
2308<!--
2309Example:
2310
2311 indented code
2312 more code
2313
2314End of comment.
2315-->
2316
2317Regular text."#;
2318
2319 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2320 let result = rule.check(&ctx).unwrap();
2321
2322 assert_eq!(
2323 result.len(),
2324 0,
2325 "Indented code inside HTML comments should not be flagged"
2326 );
2327 }
2328
2329 #[test]
2330 fn test_code_block_after_html_comment() {
2331 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2333 let content = r#"# Document
2334
2335<!-- comment -->
2336
2337Text before.
2338
2339 indented code should be flagged
2340
2341More text."#;
2342
2343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2344 let result = rule.check(&ctx).unwrap();
2345
2346 assert_eq!(
2347 result.len(),
2348 1,
2349 "Code blocks after HTML comments should still be detected"
2350 );
2351 assert!(result[0].message.contains("Use fenced code blocks"));
2352 }
2353
2354 #[test]
2355 fn test_four_space_indented_fence_is_not_valid_fence() {
2356 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2359
2360 assert!(rule.is_fenced_code_block_start("```"));
2362 assert!(rule.is_fenced_code_block_start(" ```"));
2363 assert!(rule.is_fenced_code_block_start(" ```"));
2364 assert!(rule.is_fenced_code_block_start(" ```"));
2365
2366 assert!(!rule.is_fenced_code_block_start(" ```"));
2368 assert!(!rule.is_fenced_code_block_start(" ```"));
2369 assert!(!rule.is_fenced_code_block_start(" ```"));
2370
2371 assert!(!rule.is_fenced_code_block_start("\t```"));
2373 }
2374
2375 #[test]
2376 fn test_issue_237_indented_fenced_block_detected_as_indented() {
2377 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2383
2384 let content = r#"## Test
2386
2387 ```js
2388 var foo = "hello";
2389 ```
2390"#;
2391
2392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2393 let result = rule.check(&ctx).unwrap();
2394
2395 assert_eq!(
2397 result.len(),
2398 1,
2399 "4-space indented fence should be detected as indented code block"
2400 );
2401 assert!(
2402 result[0].message.contains("Use fenced code blocks"),
2403 "Expected 'Use fenced code blocks' message"
2404 );
2405 }
2406
2407 #[test]
2408 fn test_issue_276_indented_code_in_list() {
2409 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2412
2413 let content = r#"1. First item
24142. Second item with code:
2415
2416 # This is a code block in a list
2417 print("Hello, world!")
2418
24194. Third item"#;
2420
2421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2422 let result = rule.check(&ctx).unwrap();
2423
2424 assert!(
2426 !result.is_empty(),
2427 "Indented code block inside list should be flagged when style=fenced"
2428 );
2429 assert!(
2430 result[0].message.contains("Use fenced code blocks"),
2431 "Expected 'Use fenced code blocks' message"
2432 );
2433 }
2434
2435 #[test]
2436 fn test_three_space_indented_fence_is_valid() {
2437 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2439
2440 let content = r#"## Test
2441
2442 ```js
2443 var foo = "hello";
2444 ```
2445"#;
2446
2447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2448 let result = rule.check(&ctx).unwrap();
2449
2450 assert_eq!(
2452 result.len(),
2453 0,
2454 "3-space indented fence should be recognized as valid fenced code block"
2455 );
2456 }
2457
2458 #[test]
2459 fn test_indented_style_with_deeply_indented_fenced() {
2460 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2463
2464 let content = r#"Text
2465
2466 ```js
2467 var foo = "hello";
2468 ```
2469
2470More text
2471"#;
2472
2473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2474 let result = rule.check(&ctx).unwrap();
2475
2476 assert_eq!(
2479 result.len(),
2480 0,
2481 "4-space indented content should be valid when style=indented"
2482 );
2483 }
2484
2485 #[test]
2486 fn test_fix_misplaced_fenced_block() {
2487 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2490
2491 let content = r#"## Test
2492
2493 ```js
2494 var foo = "hello";
2495 ```
2496"#;
2497
2498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2499 let fixed = rule.fix(&ctx).unwrap();
2500
2501 let expected = r#"## Test
2503
2504```js
2505var foo = "hello";
2506```
2507"#;
2508
2509 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2510 }
2511
2512 #[test]
2513 fn test_fix_regular_indented_block() {
2514 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2517
2518 let content = r#"Text
2519
2520 var foo = "hello";
2521 console.log(foo);
2522
2523More text
2524"#;
2525
2526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2527 let fixed = rule.fix(&ctx).unwrap();
2528
2529 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2531 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2532 }
2533
2534 #[test]
2535 fn test_fix_indented_block_with_fence_like_content() {
2536 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2540
2541 let content = r#"Text
2542
2543 some code
2544 ```not a fence opener
2545 more code
2546"#;
2547
2548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2549 let fixed = rule.fix(&ctx).unwrap();
2550
2551 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
2553 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2554 }
2555
2556 #[test]
2557 fn test_fix_mixed_indented_and_misplaced_blocks() {
2558 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2560
2561 let content = r#"Text
2562
2563 regular indented code
2564
2565More text
2566
2567 ```python
2568 print("hello")
2569 ```
2570"#;
2571
2572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2573 let fixed = rule.fix(&ctx).unwrap();
2574
2575 assert!(
2577 fixed.contains("```\nregular indented code\n```"),
2578 "First block should be wrapped in fences"
2579 );
2580
2581 assert!(
2583 fixed.contains("\n```python\nprint(\"hello\")\n```"),
2584 "Second block should be dedented, not double-wrapped"
2585 );
2586 assert!(
2588 !fixed.contains("```\n```python"),
2589 "Should not have nested fence openers"
2590 );
2591 }
2592}