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: Vec<&str> = ctx.content.lines().collect();
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: Vec<&str> = ctx.content.lines().collect();
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: Some(Fix {
924 range: ctx.line_index.line_col_to_byte_range(start_line_idx + 1, 1),
925 replacement: String::new(),
926 }),
927 });
928 }
929 }
930 Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
931 if target_style == CodeBlockStyle::Fenced && !reported_indented_lines.contains(&start_line_idx) {
935 let line = lines.get(start_line_idx).unwrap_or(&"");
936
937 if ctx.lines.get(start_line_idx).is_some_and(|info| {
940 info.in_html_comment || info.in_mkdocstrings || info.blockquote.is_some()
941 }) {
942 continue;
943 }
944
945 if mkdocs_footnotes::is_within_footnote_definition(ctx.content, start) {
947 continue;
948 }
949
950 if is_mkdocs && in_tab_context.get(start_line_idx).copied().unwrap_or(false) {
952 continue;
953 }
954
955 if is_mkdocs && in_admonition_context.get(start_line_idx).copied().unwrap_or(false) {
957 continue;
958 }
959
960 reported_indented_lines.insert(start_line_idx);
961
962 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
963 warnings.push(LintWarning {
964 rule_name: Some(self.name().to_string()),
965 line: start_line,
966 column: start_col,
967 end_line,
968 end_column: end_col,
969 message: "Use fenced code blocks".to_string(),
970 severity: Severity::Warning,
971 fix: Some(Fix {
972 range: ctx.line_index.line_col_to_byte_range(start_line_idx + 1, 1),
973 replacement: format!("```\n{}", line.trim_start()),
974 }),
975 });
976 }
977 }
978 _ => {}
979 }
980 }
981
982 warnings.sort_by_key(|w| (w.line, w.column));
984
985 Ok(warnings)
986 }
987
988 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
989 let content = ctx.content;
990 if content.is_empty() {
991 return Ok(String::new());
992 }
993
994 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
996
997 if !unclosed_warnings.is_empty() {
999 for warning in &unclosed_warnings {
1001 if warning
1002 .message
1003 .contains("should be closed before starting new one at line")
1004 {
1005 if let Some(fix) = &warning.fix {
1007 let mut result = String::new();
1008 result.push_str(&content[..fix.range.start]);
1009 result.push_str(&fix.replacement);
1010 result.push_str(&content[fix.range.start..]);
1011 return Ok(result);
1012 }
1013 }
1014 }
1015 }
1016
1017 let lines: Vec<&str> = content.lines().collect();
1018
1019 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1021 let target_style = match self.config.style {
1022 CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
1023 _ => self.config.style,
1024 };
1025
1026 let in_list_context = self.precompute_block_continuation_context(&lines);
1028 let in_tab_context = if is_mkdocs {
1029 self.precompute_mkdocs_tab_context(&lines)
1030 } else {
1031 vec![false; lines.len()]
1032 };
1033 let in_admonition_context = if is_mkdocs {
1034 self.precompute_mkdocs_admonition_context(&lines)
1035 } else {
1036 vec![false; lines.len()]
1037 };
1038
1039 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(
1043 &lines,
1044 is_mkdocs,
1045 &in_list_context,
1046 &in_tab_context,
1047 &in_admonition_context,
1048 );
1049
1050 let mut result = String::with_capacity(content.len());
1051 let mut in_fenced_block = false;
1052 let mut fenced_fence_type = None;
1053 let mut in_indented_block = false;
1054
1055 for (i, line) in lines.iter().enumerate() {
1056 let trimmed = line.trim_start();
1057
1058 if !in_fenced_block
1061 && Self::has_valid_fence_indent(line)
1062 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1063 {
1064 in_fenced_block = true;
1065 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
1066
1067 if target_style == CodeBlockStyle::Indented {
1068 in_indented_block = true;
1070 } else {
1071 result.push_str(line);
1073 result.push('\n');
1074 }
1075 } else if in_fenced_block && fenced_fence_type.is_some() {
1076 let fence = fenced_fence_type.unwrap();
1077 if trimmed.starts_with(fence) {
1078 in_fenced_block = false;
1079 fenced_fence_type = None;
1080 in_indented_block = false;
1081
1082 if target_style == CodeBlockStyle::Indented {
1083 } else {
1085 result.push_str(line);
1087 result.push('\n');
1088 }
1089 } else if target_style == CodeBlockStyle::Indented {
1090 result.push_str(" ");
1094 result.push_str(line);
1095 result.push('\n');
1096 } else {
1097 result.push_str(line);
1099 result.push('\n');
1100 }
1101 } else if self.is_indented_code_block_with_context(
1102 &lines,
1103 i,
1104 is_mkdocs,
1105 &in_list_context,
1106 &in_tab_context,
1107 &in_admonition_context,
1108 ) {
1109 let prev_line_is_indented = i > 0
1113 && self.is_indented_code_block_with_context(
1114 &lines,
1115 i - 1,
1116 is_mkdocs,
1117 &in_list_context,
1118 &in_tab_context,
1119 &in_admonition_context,
1120 );
1121
1122 if target_style == CodeBlockStyle::Fenced {
1123 let trimmed_content = line.trim_start();
1124
1125 if misplaced_fence_lines[i] {
1128 result.push_str(trimmed_content);
1130 result.push('\n');
1131 } else if unsafe_fence_lines[i] {
1132 result.push_str(line);
1135 result.push('\n');
1136 } else if !prev_line_is_indented && !in_indented_block {
1137 result.push_str("```\n");
1139 result.push_str(trimmed_content);
1140 result.push('\n');
1141 in_indented_block = true;
1142 } else {
1143 result.push_str(trimmed_content);
1145 result.push('\n');
1146 }
1147
1148 let next_line_is_indented = i < lines.len() - 1
1150 && self.is_indented_code_block_with_context(
1151 &lines,
1152 i + 1,
1153 is_mkdocs,
1154 &in_list_context,
1155 &in_tab_context,
1156 &in_admonition_context,
1157 );
1158 if !next_line_is_indented
1160 && in_indented_block
1161 && !misplaced_fence_lines[i]
1162 && !unsafe_fence_lines[i]
1163 {
1164 result.push_str("```\n");
1165 in_indented_block = false;
1166 }
1167 } else {
1168 result.push_str(line);
1170 result.push('\n');
1171 }
1172 } else {
1173 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1175 result.push_str("```\n");
1176 in_indented_block = false;
1177 }
1178
1179 result.push_str(line);
1180 result.push('\n');
1181 }
1182 }
1183
1184 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1186 result.push_str("```\n");
1187 }
1188
1189 if let Some(fence_type) = fenced_fence_type
1191 && in_fenced_block
1192 {
1193 result.push_str(fence_type);
1194 result.push('\n');
1195 }
1196
1197 if !content.ends_with('\n') && result.ends_with('\n') {
1199 result.pop();
1200 }
1201
1202 Ok(result)
1203 }
1204
1205 fn category(&self) -> RuleCategory {
1207 RuleCategory::CodeBlock
1208 }
1209
1210 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1212 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1215 }
1216
1217 fn as_any(&self) -> &dyn std::any::Any {
1218 self
1219 }
1220
1221 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1222 let json_value = serde_json::to_value(&self.config).ok()?;
1223 Some((
1224 self.name().to_string(),
1225 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1226 ))
1227 }
1228
1229 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1230 where
1231 Self: Sized,
1232 {
1233 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1234 Box::new(Self::from_config_struct(rule_config))
1235 }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240 use super::*;
1241 use crate::lint_context::LintContext;
1242
1243 #[test]
1244 fn test_fenced_code_block_detection() {
1245 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1246 assert!(rule.is_fenced_code_block_start("```"));
1247 assert!(rule.is_fenced_code_block_start("```rust"));
1248 assert!(rule.is_fenced_code_block_start("~~~"));
1249 assert!(rule.is_fenced_code_block_start("~~~python"));
1250 assert!(rule.is_fenced_code_block_start(" ```"));
1251 assert!(!rule.is_fenced_code_block_start("``"));
1252 assert!(!rule.is_fenced_code_block_start("~~"));
1253 assert!(!rule.is_fenced_code_block_start("Regular text"));
1254 }
1255
1256 #[test]
1257 fn test_consistent_style_with_fenced_blocks() {
1258 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1259 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1261 let result = rule.check(&ctx).unwrap();
1262
1263 assert_eq!(result.len(), 0);
1265 }
1266
1267 #[test]
1268 fn test_consistent_style_with_indented_blocks() {
1269 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1270 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272 let result = rule.check(&ctx).unwrap();
1273
1274 assert_eq!(result.len(), 0);
1276 }
1277
1278 #[test]
1279 fn test_consistent_style_mixed() {
1280 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1281 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1283 let result = rule.check(&ctx).unwrap();
1284
1285 assert!(!result.is_empty());
1287 }
1288
1289 #[test]
1290 fn test_fenced_style_with_indented_blocks() {
1291 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1292 let content = "Text\n\n indented code\n more code\n\nMore text";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let result = rule.check(&ctx).unwrap();
1295
1296 assert!(!result.is_empty());
1298 assert!(result[0].message.contains("Use fenced code blocks"));
1299 }
1300
1301 #[test]
1302 fn test_fenced_style_with_tab_indented_blocks() {
1303 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1304 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306 let result = rule.check(&ctx).unwrap();
1307
1308 assert!(!result.is_empty());
1310 assert!(result[0].message.contains("Use fenced code blocks"));
1311 }
1312
1313 #[test]
1314 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1315 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1316 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319 let result = rule.check(&ctx).unwrap();
1320
1321 assert!(
1323 !result.is_empty(),
1324 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1325 );
1326 assert!(result[0].message.contains("Use fenced code blocks"));
1327 }
1328
1329 #[test]
1330 fn test_fenced_style_with_one_space_tab_indent() {
1331 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1332 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335 let result = rule.check(&ctx).unwrap();
1336
1337 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1338 assert!(result[0].message.contains("Use fenced code blocks"));
1339 }
1340
1341 #[test]
1342 fn test_indented_style_with_fenced_blocks() {
1343 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1344 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1346 let result = rule.check(&ctx).unwrap();
1347
1348 assert!(!result.is_empty());
1350 assert!(result[0].message.contains("Use indented code blocks"));
1351 }
1352
1353 #[test]
1354 fn test_unclosed_code_block() {
1355 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1356 let content = "```\ncode without closing fence";
1357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358 let result = rule.check(&ctx).unwrap();
1359
1360 assert_eq!(result.len(), 1);
1361 assert!(result[0].message.contains("never closed"));
1362 }
1363
1364 #[test]
1365 fn test_nested_code_blocks() {
1366 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1367 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369 let result = rule.check(&ctx).unwrap();
1370
1371 assert_eq!(result.len(), 0);
1373 }
1374
1375 #[test]
1376 fn test_fix_indented_to_fenced() {
1377 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1378 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380 let fixed = rule.fix(&ctx).unwrap();
1381
1382 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1383 }
1384
1385 #[test]
1386 fn test_fix_fenced_to_indented() {
1387 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1388 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1390 let fixed = rule.fix(&ctx).unwrap();
1391
1392 assert!(fixed.contains(" code line 1\n code line 2"));
1393 assert!(!fixed.contains("```"));
1394 }
1395
1396 #[test]
1397 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1398 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1401 let content = r#"# Test
1402
1403```html
1404<!doctype html>
1405<html>
1406 <head>
1407 <title>Test</title>
1408 </head>
1409</html>
1410```
1411"#;
1412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1413 let fixed = rule.fix(&ctx).unwrap();
1414
1415 assert!(
1418 fixed.contains(" <head>"),
1419 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1420 );
1421 assert!(
1422 fixed.contains(" <title>"),
1423 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1424 );
1425 assert!(!fixed.contains("```"), "Fenced markers should be removed");
1426 }
1427
1428 #[test]
1429 fn test_fix_fenced_to_indented_preserves_python_indentation() {
1430 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1432 let content = r#"# Python Example
1433
1434```python
1435def greet(name):
1436 if name:
1437 print(f"Hello, {name}!")
1438 else:
1439 print("Hello, World!")
1440```
1441"#;
1442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1443 let fixed = rule.fix(&ctx).unwrap();
1444
1445 assert!(
1447 fixed.contains(" def greet(name):"),
1448 "Function def should have 4 spaces (code block indent)"
1449 );
1450 assert!(
1451 fixed.contains(" if name:"),
1452 "if statement should have 8 spaces (4 code + 4 Python)"
1453 );
1454 assert!(
1455 fixed.contains(" print"),
1456 "print should have 12 spaces (4 code + 8 Python)"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1462 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1464 let content = r#"# Config
1465
1466```yaml
1467server:
1468 host: localhost
1469 port: 8080
1470 ssl:
1471 enabled: true
1472 cert: /path/to/cert
1473```
1474"#;
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let fixed = rule.fix(&ctx).unwrap();
1477
1478 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
1479 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
1480 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
1481 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
1482 }
1483
1484 #[test]
1485 fn test_fix_fenced_to_indented_preserves_empty_lines() {
1486 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1488 let content = "```\nline1\n\nline2\n```\n";
1489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490 let fixed = rule.fix(&ctx).unwrap();
1491
1492 assert!(fixed.contains(" line1"), "line1 should be indented");
1494 assert!(fixed.contains(" line2"), "line2 should be indented");
1495 }
1497
1498 #[test]
1499 fn test_fix_fenced_to_indented_multiple_blocks() {
1500 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1502 let content = r#"# Doc
1503
1504```python
1505def foo():
1506 pass
1507```
1508
1509Text between.
1510
1511```yaml
1512key:
1513 value: 1
1514```
1515"#;
1516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1517 let fixed = rule.fix(&ctx).unwrap();
1518
1519 assert!(fixed.contains(" def foo():"), "Python def should be indented");
1520 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
1521 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
1522 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
1523 assert!(!fixed.contains("```"), "No fence markers should remain");
1524 }
1525
1526 #[test]
1527 fn test_fix_unclosed_block() {
1528 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1529 let content = "```\ncode without closing";
1530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531 let fixed = rule.fix(&ctx).unwrap();
1532
1533 assert!(fixed.ends_with("```"));
1535 }
1536
1537 #[test]
1538 fn test_code_block_in_list() {
1539 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1540 let content = "- List item\n code in list\n more code\n- Next item";
1541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542 let result = rule.check(&ctx).unwrap();
1543
1544 assert_eq!(result.len(), 0);
1546 }
1547
1548 #[test]
1549 fn test_detect_style_fenced() {
1550 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1551 let content = "```\ncode\n```";
1552 let style = rule.detect_style(content, false);
1553
1554 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1555 }
1556
1557 #[test]
1558 fn test_detect_style_indented() {
1559 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1560 let content = "Text\n\n code\n\nMore";
1561 let style = rule.detect_style(content, false);
1562
1563 assert_eq!(style, Some(CodeBlockStyle::Indented));
1564 }
1565
1566 #[test]
1567 fn test_detect_style_none() {
1568 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1569 let content = "No code blocks here";
1570 let style = rule.detect_style(content, false);
1571
1572 assert_eq!(style, None);
1573 }
1574
1575 #[test]
1576 fn test_tilde_fence() {
1577 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1578 let content = "~~~\ncode\n~~~";
1579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1580 let result = rule.check(&ctx).unwrap();
1581
1582 assert_eq!(result.len(), 0);
1584 }
1585
1586 #[test]
1587 fn test_language_specification() {
1588 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1589 let content = "```rust\nfn main() {}\n```";
1590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1591 let result = rule.check(&ctx).unwrap();
1592
1593 assert_eq!(result.len(), 0);
1594 }
1595
1596 #[test]
1597 fn test_empty_content() {
1598 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1599 let content = "";
1600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1601 let result = rule.check(&ctx).unwrap();
1602
1603 assert_eq!(result.len(), 0);
1604 }
1605
1606 #[test]
1607 fn test_default_config() {
1608 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1609 let (name, _config) = rule.default_config_section().unwrap();
1610 assert_eq!(name, "MD046");
1611 }
1612
1613 #[test]
1614 fn test_markdown_documentation_block() {
1615 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1616 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618 let result = rule.check(&ctx).unwrap();
1619
1620 assert_eq!(result.len(), 0);
1622 }
1623
1624 #[test]
1625 fn test_preserve_trailing_newline() {
1626 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1627 let content = "```\ncode\n```\n";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629 let fixed = rule.fix(&ctx).unwrap();
1630
1631 assert_eq!(fixed, content);
1632 }
1633
1634 #[test]
1635 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1636 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1637 let content = r#"# Document
1638
1639=== "Python"
1640
1641 This is tab content
1642 Not an indented code block
1643
1644 ```python
1645 def hello():
1646 print("Hello")
1647 ```
1648
1649=== "JavaScript"
1650
1651 More tab content here
1652 Also not an indented code block"#;
1653
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1655 let result = rule.check(&ctx).unwrap();
1656
1657 assert_eq!(result.len(), 0);
1659 }
1660
1661 #[test]
1662 fn test_mkdocs_tabs_with_actual_indented_code() {
1663 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1664 let content = r#"# Document
1665
1666=== "Tab 1"
1667
1668 This is tab content
1669
1670Regular text
1671
1672 This is an actual indented code block
1673 Should be flagged"#;
1674
1675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1676 let result = rule.check(&ctx).unwrap();
1677
1678 assert_eq!(result.len(), 1);
1680 assert!(result[0].message.contains("Use fenced code blocks"));
1681 }
1682
1683 #[test]
1684 fn test_mkdocs_tabs_detect_style() {
1685 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1686 let content = r#"=== "Tab 1"
1687
1688 Content in tab
1689 More content
1690
1691=== "Tab 2"
1692
1693 Content in second tab"#;
1694
1695 let style = rule.detect_style(content, true);
1697 assert_eq!(style, None); let style = rule.detect_style(content, false);
1701 assert_eq!(style, Some(CodeBlockStyle::Indented));
1702 }
1703
1704 #[test]
1705 fn test_mkdocs_nested_tabs() {
1706 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1707 let content = r#"# Document
1708
1709=== "Outer Tab"
1710
1711 Some content
1712
1713 === "Nested Tab"
1714
1715 Nested tab content
1716 Should not be flagged"#;
1717
1718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1719 let result = rule.check(&ctx).unwrap();
1720
1721 assert_eq!(result.len(), 0);
1723 }
1724
1725 #[test]
1726 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1727 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1730 let content = r#"# Document
1731
1732!!! note
1733 This is normal admonition content, not a code block.
1734 It spans multiple lines.
1735
1736??? warning "Collapsible Warning"
1737 This is also admonition content.
1738
1739???+ tip "Expanded Tip"
1740 And this one too.
1741
1742Regular text outside admonitions."#;
1743
1744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1745 let result = rule.check(&ctx).unwrap();
1746
1747 assert_eq!(
1749 result.len(),
1750 0,
1751 "Admonition content in MkDocs mode should not trigger MD046"
1752 );
1753 }
1754
1755 #[test]
1756 fn test_mkdocs_admonition_with_actual_indented_code() {
1757 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1759 let content = r#"# Document
1760
1761!!! note
1762 This is admonition content.
1763
1764Regular text ends the admonition.
1765
1766 This is actual indented code (should be flagged)"#;
1767
1768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1769 let result = rule.check(&ctx).unwrap();
1770
1771 assert_eq!(result.len(), 1);
1773 assert!(result[0].message.contains("Use fenced code blocks"));
1774 }
1775
1776 #[test]
1777 fn test_admonition_in_standard_mode_flagged() {
1778 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1782 let content = r#"# Document
1783
1784!!! note
1785
1786 This looks like code in standard mode.
1787
1788Regular text."#;
1789
1790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1792 let result = rule.check(&ctx).unwrap();
1793
1794 assert_eq!(
1796 result.len(),
1797 1,
1798 "Admonition content in Standard mode should be flagged as indented code"
1799 );
1800 }
1801
1802 #[test]
1803 fn test_mkdocs_admonition_with_fenced_code_inside() {
1804 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1806 let content = r#"# Document
1807
1808!!! note "Code Example"
1809 Here's some code:
1810
1811 ```python
1812 def hello():
1813 print("world")
1814 ```
1815
1816 More text after code.
1817
1818Regular text."#;
1819
1820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1821 let result = rule.check(&ctx).unwrap();
1822
1823 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1825 }
1826
1827 #[test]
1828 fn test_mkdocs_nested_admonitions() {
1829 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1831 let content = r#"# Document
1832
1833!!! note "Outer"
1834 Outer content.
1835
1836 !!! warning "Inner"
1837 Inner content.
1838 More inner content.
1839
1840 Back to outer.
1841
1842Regular text."#;
1843
1844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1845 let result = rule.check(&ctx).unwrap();
1846
1847 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1849 }
1850
1851 #[test]
1852 fn test_mkdocs_admonition_fix_does_not_wrap() {
1853 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1855 let content = r#"!!! note
1856 Content that should stay as admonition content.
1857 Not be wrapped in code fences.
1858"#;
1859
1860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1861 let fixed = rule.fix(&ctx).unwrap();
1862
1863 assert!(
1865 !fixed.contains("```\n Content"),
1866 "Admonition content should not be wrapped in fences"
1867 );
1868 assert_eq!(fixed, content, "Content should remain unchanged");
1869 }
1870
1871 #[test]
1872 fn test_mkdocs_empty_admonition() {
1873 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1875 let content = r#"!!! note
1876
1877Regular paragraph after empty admonition.
1878
1879 This IS an indented code block (after blank + non-indented line)."#;
1880
1881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1882 let result = rule.check(&ctx).unwrap();
1883
1884 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1886 }
1887
1888 #[test]
1889 fn test_mkdocs_indented_admonition() {
1890 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1892 let content = r#"- List item
1893
1894 !!! note
1895 Indented admonition content.
1896 More content.
1897
1898- Next item"#;
1899
1900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1901 let result = rule.check(&ctx).unwrap();
1902
1903 assert_eq!(
1905 result.len(),
1906 0,
1907 "Indented admonitions (e.g., in lists) should not be flagged"
1908 );
1909 }
1910
1911 #[test]
1912 fn test_footnote_indented_paragraphs_not_flagged() {
1913 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1914 let content = r#"# Test Document with Footnotes
1915
1916This is some text with a footnote[^1].
1917
1918Here's some code:
1919
1920```bash
1921echo "fenced code block"
1922```
1923
1924More text with another footnote[^2].
1925
1926[^1]: Really interesting footnote text.
1927
1928 Even more interesting second paragraph.
1929
1930[^2]: Another footnote.
1931
1932 With a second paragraph too.
1933
1934 And even a third paragraph!"#;
1935
1936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1937 let result = rule.check(&ctx).unwrap();
1938
1939 assert_eq!(result.len(), 0);
1941 }
1942
1943 #[test]
1944 fn test_footnote_definition_detection() {
1945 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1946
1947 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1950 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1951 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1952 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1953 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1954 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1955 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1956 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1957 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1958
1959 assert!(!rule.is_footnote_definition("[^]: No label"));
1961 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1962 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1963 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1964
1965 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1967 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1968 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1969 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1972
1973 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1975 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1976 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1977 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1978 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1979
1980 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1983 }
1984
1985 #[test]
1986 fn test_footnote_with_blank_lines() {
1987 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1991 let content = r#"# Document
1992
1993Text with footnote[^1].
1994
1995[^1]: First paragraph.
1996
1997 Second paragraph after blank line.
1998
1999 Third paragraph after another blank line.
2000
2001Regular text at column 0 ends the footnote."#;
2002
2003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2004 let result = rule.check(&ctx).unwrap();
2005
2006 assert_eq!(
2008 result.len(),
2009 0,
2010 "Indented content within footnotes should not trigger MD046"
2011 );
2012 }
2013
2014 #[test]
2015 fn test_footnote_multiple_consecutive_blank_lines() {
2016 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2019 let content = r#"Text[^1].
2020
2021[^1]: First paragraph.
2022
2023
2024
2025 Content after three blank lines (still part of footnote).
2026
2027Not indented, so footnote ends here."#;
2028
2029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2030 let result = rule.check(&ctx).unwrap();
2031
2032 assert_eq!(
2034 result.len(),
2035 0,
2036 "Multiple blank lines shouldn't break footnote continuation"
2037 );
2038 }
2039
2040 #[test]
2041 fn test_footnote_terminated_by_non_indented_content() {
2042 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2045 let content = r#"[^1]: Footnote content.
2046
2047 More indented content in footnote.
2048
2049This paragraph is not indented, so footnote ends.
2050
2051 This should be flagged as indented code block."#;
2052
2053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054 let result = rule.check(&ctx).unwrap();
2055
2056 assert_eq!(
2058 result.len(),
2059 1,
2060 "Indented code after footnote termination should be flagged"
2061 );
2062 assert!(
2063 result[0].message.contains("Use fenced code blocks"),
2064 "Expected MD046 warning for indented code block"
2065 );
2066 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2067 }
2068
2069 #[test]
2070 fn test_footnote_terminated_by_structural_elements() {
2071 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2073 let content = r#"[^1]: Footnote content.
2074
2075 More content.
2076
2077## Heading terminates footnote
2078
2079 This indented content should be flagged.
2080
2081---
2082
2083 This should also be flagged (after horizontal rule)."#;
2084
2085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2086 let result = rule.check(&ctx).unwrap();
2087
2088 assert_eq!(
2090 result.len(),
2091 2,
2092 "Both indented blocks after termination should be flagged"
2093 );
2094 }
2095
2096 #[test]
2097 fn test_footnote_with_code_block_inside() {
2098 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2101 let content = r#"Text[^1].
2102
2103[^1]: Footnote with code:
2104
2105 ```python
2106 def hello():
2107 print("world")
2108 ```
2109
2110 More footnote text after code."#;
2111
2112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2113 let result = rule.check(&ctx).unwrap();
2114
2115 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2117 }
2118
2119 #[test]
2120 fn test_footnote_with_8_space_indented_code() {
2121 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2124 let content = r#"Text[^1].
2125
2126[^1]: Footnote with nested code.
2127
2128 code block
2129 more code"#;
2130
2131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2132 let result = rule.check(&ctx).unwrap();
2133
2134 assert_eq!(
2136 result.len(),
2137 0,
2138 "8-space indented code within footnotes represents nested code blocks"
2139 );
2140 }
2141
2142 #[test]
2143 fn test_multiple_footnotes() {
2144 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2147 let content = r#"Text[^1] and more[^2].
2148
2149[^1]: First footnote.
2150
2151 Continuation of first.
2152
2153[^2]: Second footnote starts here, ending the first.
2154
2155 Continuation of second."#;
2156
2157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2158 let result = rule.check(&ctx).unwrap();
2159
2160 assert_eq!(
2162 result.len(),
2163 0,
2164 "Multiple footnotes should each maintain their continuation context"
2165 );
2166 }
2167
2168 #[test]
2169 fn test_list_item_ends_footnote_context() {
2170 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2172 let content = r#"[^1]: Footnote.
2173
2174 Content in footnote.
2175
2176- List item starts here (ends footnote context).
2177
2178 This indented content is part of the list, not the footnote."#;
2179
2180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2181 let result = rule.check(&ctx).unwrap();
2182
2183 assert_eq!(
2185 result.len(),
2186 0,
2187 "List items should end footnote context and start their own"
2188 );
2189 }
2190
2191 #[test]
2192 fn test_footnote_vs_actual_indented_code() {
2193 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2196 let content = r#"# Heading
2197
2198Text with footnote[^1].
2199
2200[^1]: Footnote content.
2201
2202 Part of footnote (should not be flagged).
2203
2204Regular paragraph ends footnote context.
2205
2206 This is actual indented code (MUST be flagged)
2207 Should be detected as code block"#;
2208
2209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2210 let result = rule.check(&ctx).unwrap();
2211
2212 assert_eq!(
2214 result.len(),
2215 1,
2216 "Must still detect indented code blocks outside footnotes"
2217 );
2218 assert!(
2219 result[0].message.contains("Use fenced code blocks"),
2220 "Expected MD046 warning for indented code"
2221 );
2222 assert!(
2223 result[0].line >= 11,
2224 "Warning should be on the actual indented code line"
2225 );
2226 }
2227
2228 #[test]
2229 fn test_spec_compliant_label_characters() {
2230 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2233
2234 assert!(rule.is_footnote_definition("[^test]: text"));
2236 assert!(rule.is_footnote_definition("[^TEST]: text"));
2237 assert!(rule.is_footnote_definition("[^test-name]: text"));
2238 assert!(rule.is_footnote_definition("[^test_name]: text"));
2239 assert!(rule.is_footnote_definition("[^test123]: text"));
2240 assert!(rule.is_footnote_definition("[^123]: text"));
2241 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2242
2243 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")); }
2251
2252 #[test]
2253 fn test_code_block_inside_html_comment() {
2254 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2257 let content = r#"# Document
2258
2259Some text.
2260
2261<!--
2262Example code block in comment:
2263
2264```typescript
2265console.log("Hello");
2266```
2267
2268More comment text.
2269-->
2270
2271More content."#;
2272
2273 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2274 let result = rule.check(&ctx).unwrap();
2275
2276 assert_eq!(
2277 result.len(),
2278 0,
2279 "Code blocks inside HTML comments should not be flagged as unclosed"
2280 );
2281 }
2282
2283 #[test]
2284 fn test_unclosed_fence_inside_html_comment() {
2285 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2287 let content = r#"# Document
2288
2289<!--
2290Example with intentionally unclosed fence:
2291
2292```
2293code without closing
2294-->
2295
2296More content."#;
2297
2298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2299 let result = rule.check(&ctx).unwrap();
2300
2301 assert_eq!(
2302 result.len(),
2303 0,
2304 "Unclosed fences inside HTML comments should be ignored"
2305 );
2306 }
2307
2308 #[test]
2309 fn test_multiline_html_comment_with_indented_code() {
2310 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2312 let content = r#"# Document
2313
2314<!--
2315Example:
2316
2317 indented code
2318 more code
2319
2320End of comment.
2321-->
2322
2323Regular text."#;
2324
2325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2326 let result = rule.check(&ctx).unwrap();
2327
2328 assert_eq!(
2329 result.len(),
2330 0,
2331 "Indented code inside HTML comments should not be flagged"
2332 );
2333 }
2334
2335 #[test]
2336 fn test_code_block_after_html_comment() {
2337 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2339 let content = r#"# Document
2340
2341<!-- comment -->
2342
2343Text before.
2344
2345 indented code should be flagged
2346
2347More text."#;
2348
2349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2350 let result = rule.check(&ctx).unwrap();
2351
2352 assert_eq!(
2353 result.len(),
2354 1,
2355 "Code blocks after HTML comments should still be detected"
2356 );
2357 assert!(result[0].message.contains("Use fenced code blocks"));
2358 }
2359
2360 #[test]
2361 fn test_four_space_indented_fence_is_not_valid_fence() {
2362 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
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 assert!(rule.is_fenced_code_block_start(" ```"));
2371
2372 assert!(!rule.is_fenced_code_block_start(" ```"));
2374 assert!(!rule.is_fenced_code_block_start(" ```"));
2375 assert!(!rule.is_fenced_code_block_start(" ```"));
2376
2377 assert!(!rule.is_fenced_code_block_start("\t```"));
2379 }
2380
2381 #[test]
2382 fn test_issue_237_indented_fenced_block_detected_as_indented() {
2383 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2389
2390 let content = r#"## Test
2392
2393 ```js
2394 var foo = "hello";
2395 ```
2396"#;
2397
2398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2399 let result = rule.check(&ctx).unwrap();
2400
2401 assert_eq!(
2403 result.len(),
2404 1,
2405 "4-space indented fence should be detected as indented code block"
2406 );
2407 assert!(
2408 result[0].message.contains("Use fenced code blocks"),
2409 "Expected 'Use fenced code blocks' message"
2410 );
2411 }
2412
2413 #[test]
2414 fn test_issue_276_indented_code_in_list() {
2415 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2418
2419 let content = r#"1. First item
24202. Second item with code:
2421
2422 # This is a code block in a list
2423 print("Hello, world!")
2424
24254. Third item"#;
2426
2427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2428 let result = rule.check(&ctx).unwrap();
2429
2430 assert!(
2432 !result.is_empty(),
2433 "Indented code block inside list should be flagged when style=fenced"
2434 );
2435 assert!(
2436 result[0].message.contains("Use fenced code blocks"),
2437 "Expected 'Use fenced code blocks' message"
2438 );
2439 }
2440
2441 #[test]
2442 fn test_three_space_indented_fence_is_valid() {
2443 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2445
2446 let content = r#"## Test
2447
2448 ```js
2449 var foo = "hello";
2450 ```
2451"#;
2452
2453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2454 let result = rule.check(&ctx).unwrap();
2455
2456 assert_eq!(
2458 result.len(),
2459 0,
2460 "3-space indented fence should be recognized as valid fenced code block"
2461 );
2462 }
2463
2464 #[test]
2465 fn test_indented_style_with_deeply_indented_fenced() {
2466 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2469
2470 let content = r#"Text
2471
2472 ```js
2473 var foo = "hello";
2474 ```
2475
2476More text
2477"#;
2478
2479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2480 let result = rule.check(&ctx).unwrap();
2481
2482 assert_eq!(
2485 result.len(),
2486 0,
2487 "4-space indented content should be valid when style=indented"
2488 );
2489 }
2490
2491 #[test]
2492 fn test_fix_misplaced_fenced_block() {
2493 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2496
2497 let content = r#"## Test
2498
2499 ```js
2500 var foo = "hello";
2501 ```
2502"#;
2503
2504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2505 let fixed = rule.fix(&ctx).unwrap();
2506
2507 let expected = r#"## Test
2509
2510```js
2511var foo = "hello";
2512```
2513"#;
2514
2515 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2516 }
2517
2518 #[test]
2519 fn test_fix_regular_indented_block() {
2520 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2523
2524 let content = r#"Text
2525
2526 var foo = "hello";
2527 console.log(foo);
2528
2529More text
2530"#;
2531
2532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2533 let fixed = rule.fix(&ctx).unwrap();
2534
2535 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2537 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2538 }
2539
2540 #[test]
2541 fn test_fix_indented_block_with_fence_like_content() {
2542 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2546
2547 let content = r#"Text
2548
2549 some code
2550 ```not a fence opener
2551 more code
2552"#;
2553
2554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2555 let fixed = rule.fix(&ctx).unwrap();
2556
2557 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
2559 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2560 }
2561
2562 #[test]
2563 fn test_fix_mixed_indented_and_misplaced_blocks() {
2564 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2566
2567 let content = r#"Text
2568
2569 regular indented code
2570
2571More text
2572
2573 ```python
2574 print("hello")
2575 ```
2576"#;
2577
2578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2579 let fixed = rule.fix(&ctx).unwrap();
2580
2581 assert!(
2583 fixed.contains("```\nregular indented code\n```"),
2584 "First block should be wrapped in fences"
2585 );
2586
2587 assert!(
2589 fixed.contains("\n```python\nprint(\"hello\")\n```"),
2590 "Second block should be dedented, not double-wrapped"
2591 );
2592 assert!(
2594 !fixed.contains("```\n```python"),
2595 "Should not have nested fence openers"
2596 );
2597 }
2598}