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 in_jsx_context: &[bool],
242 ) -> bool {
243 if i >= lines.len() {
244 return false;
245 }
246
247 let line = lines[i];
248
249 let indent = ElementCache::calculate_indentation_width_default(line);
251 if indent < 4 {
252 return false;
253 }
254
255 if in_list_context[i] {
257 return false;
258 }
259
260 if is_mkdocs && in_tab_context[i] {
262 return false;
263 }
264
265 if is_mkdocs && in_admonition_context[i] {
268 return false;
269 }
270
271 if in_jsx_context.get(i).copied().unwrap_or(false) {
273 return false;
274 }
275
276 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
279 let prev_is_indented_code = i > 0
280 && ElementCache::calculate_indentation_width_default(lines[i - 1]) >= 4
281 && !in_list_context[i - 1]
282 && !(is_mkdocs && in_tab_context[i - 1])
283 && !(is_mkdocs && in_admonition_context[i - 1]);
284
285 if !has_blank_line_before && !prev_is_indented_code {
288 return false;
289 }
290
291 true
292 }
293
294 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
296 let mut in_tab_context = vec![false; lines.len()];
297 let mut current_tab_indent: Option<usize> = None;
298
299 for (i, line) in lines.iter().enumerate() {
300 if mkdocs_tabs::is_tab_marker(line) {
302 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
303 current_tab_indent = Some(tab_indent);
304 in_tab_context[i] = true;
305 continue;
306 }
307
308 if let Some(tab_indent) = current_tab_indent {
310 if mkdocs_tabs::is_tab_content(line, tab_indent) {
311 in_tab_context[i] = true;
312 } else if !line.trim().is_empty() && ElementCache::calculate_indentation_width_default(line) < 4 {
313 current_tab_indent = None;
315 } else {
316 in_tab_context[i] = true;
318 }
319 }
320 }
321
322 in_tab_context
323 }
324
325 fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
334 let mut in_admonition_context = vec![false; lines.len()];
335 let mut admonition_stack: Vec<usize> = Vec::new();
337
338 for (i, line) in lines.iter().enumerate() {
339 let line_indent = ElementCache::calculate_indentation_width_default(line);
340
341 if mkdocs_admonitions::is_admonition_start(line) {
343 let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
344
345 while let Some(&top_indent) = admonition_stack.last() {
347 if adm_indent <= top_indent {
349 admonition_stack.pop();
350 } else {
351 break;
352 }
353 }
354
355 admonition_stack.push(adm_indent);
357 in_admonition_context[i] = true;
358 continue;
359 }
360
361 if line.trim().is_empty() {
363 if !admonition_stack.is_empty() {
364 in_admonition_context[i] = true;
365 }
366 continue;
367 }
368
369 while let Some(&top_indent) = admonition_stack.last() {
372 if line_indent >= top_indent + 4 {
374 break;
376 } else {
377 admonition_stack.pop();
379 }
380 }
381
382 if !admonition_stack.is_empty() {
384 in_admonition_context[i] = true;
385 }
386 }
387
388 in_admonition_context
389 }
390
391 fn categorize_indented_blocks(
403 &self,
404 lines: &[&str],
405 is_mkdocs: bool,
406 in_list_context: &[bool],
407 in_tab_context: &[bool],
408 in_admonition_context: &[bool],
409 in_jsx_context: &[bool],
410 ) -> (Vec<bool>, Vec<bool>) {
411 let mut is_misplaced = vec![false; lines.len()];
412 let mut contains_fences = vec![false; lines.len()];
413
414 let mut i = 0;
416 while i < lines.len() {
417 if !self.is_indented_code_block_with_context(
419 lines,
420 i,
421 is_mkdocs,
422 in_list_context,
423 in_tab_context,
424 in_admonition_context,
425 in_jsx_context,
426 ) {
427 i += 1;
428 continue;
429 }
430
431 let block_start = i;
433 let mut block_end = i;
434
435 while block_end < lines.len()
436 && self.is_indented_code_block_with_context(
437 lines,
438 block_end,
439 is_mkdocs,
440 in_list_context,
441 in_tab_context,
442 in_admonition_context,
443 in_jsx_context,
444 )
445 {
446 block_end += 1;
447 }
448
449 if block_end > block_start {
451 let first_line = lines[block_start].trim_start();
452 let last_line = lines[block_end - 1].trim_start();
453
454 let is_backtick_fence = first_line.starts_with("```");
456 let is_tilde_fence = first_line.starts_with("~~~");
457
458 if is_backtick_fence || is_tilde_fence {
459 let fence_char = if is_backtick_fence { '`' } else { '~' };
460 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
461
462 let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
464 let after_closer = &last_line[closer_fence_len..];
465
466 if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
467 is_misplaced[block_start..block_end].fill(true);
469 } else {
470 contains_fences[block_start..block_end].fill(true);
472 }
473 } else {
474 let has_fence_markers = (block_start..block_end).any(|j| {
477 let trimmed = lines[j].trim_start();
478 trimmed.starts_with("```") || trimmed.starts_with("~~~")
479 });
480
481 if has_fence_markers {
482 contains_fences[block_start..block_end].fill(true);
483 }
484 }
485 }
486
487 i = block_end;
488 }
489
490 (is_misplaced, contains_fences)
491 }
492
493 fn check_unclosed_code_blocks(
494 &self,
495 ctx: &crate::lint_context::LintContext,
496 ) -> Result<Vec<LintWarning>, LintError> {
497 let mut warnings = Vec::new();
498 let lines = ctx.raw_lines();
499
500 let options = Options::all();
502 let parser = Parser::new_ext(ctx.content, options).into_offset_iter();
503
504 let mut code_blocks: Vec<(usize, usize, String, usize, bool, bool)> = Vec::new();
506 let mut current_block_start: Option<(usize, String, usize, bool)> = None;
507
508 for (event, range) in parser {
509 match event {
510 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
511 let line_idx = ctx
513 .line_offsets
514 .iter()
515 .enumerate()
516 .rev()
517 .find(|&(_, offset)| *offset <= range.start)
518 .map(|(idx, _)| idx)
519 .unwrap_or(0);
520
521 let line = lines.get(line_idx).unwrap_or(&"");
523 let trimmed = line.trim();
524
525 let fence_marker = if let Some(pos) = trimmed.find("```") {
527 let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
528 "`".repeat(count)
529 } else if let Some(pos) = trimmed.find("~~~") {
530 let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
531 "~".repeat(count)
532 } else {
533 "```".to_string()
534 };
535
536 let lang_info = info.to_string().to_lowercase();
538 let is_markdown_doc = lang_info.starts_with("markdown") || lang_info.starts_with("md");
539
540 current_block_start = Some((range.start, fence_marker, line_idx, is_markdown_doc));
541 }
542 Event::End(TagEnd::CodeBlock) => {
543 if let Some((start, fence_marker, line_idx, is_markdown_doc)) = current_block_start.take() {
544 code_blocks.push((start, range.end, fence_marker, line_idx, true, is_markdown_doc));
545 }
546 }
547 _ => {}
548 }
549 }
550
551 let has_markdown_doc_block = code_blocks.iter().any(|(_, _, _, _, _, is_md)| *is_md);
555
556 if !has_markdown_doc_block {
560 for (block_start, block_end, fence_marker, opening_line_idx, is_fenced, _is_md) in &code_blocks {
561 if !is_fenced {
562 continue;
563 }
564
565 if *block_end != ctx.content.len() {
567 continue;
568 }
569
570 let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
573 let trimmed = last_non_empty_line.trim();
574 let fence_char = fence_marker.chars().next().unwrap_or('`');
575
576 let has_closing_fence = if fence_char == '`' {
578 trimmed.starts_with("```") && {
579 let fence_len = trimmed.chars().take_while(|&c| c == '`').count();
580 trimmed[fence_len..].trim().is_empty()
581 }
582 } else {
583 trimmed.starts_with("~~~") && {
584 let fence_len = trimmed.chars().take_while(|&c| c == '~').count();
585 trimmed[fence_len..].trim().is_empty()
586 }
587 };
588
589 if !has_closing_fence {
590 let line = lines.get(*opening_line_idx).unwrap_or(&"");
591 let (start_line, start_col, end_line, end_col) = calculate_line_range(*opening_line_idx + 1, line);
592
593 if let Some(line_info) = ctx.lines.get(*opening_line_idx)
595 && line_info.in_html_comment
596 {
597 continue;
598 }
599
600 warnings.push(LintWarning {
601 rule_name: Some(self.name().to_string()),
602 line: start_line,
603 column: start_col,
604 end_line,
605 end_column: end_col,
606 message: format!("Code block opened with '{fence_marker}' but never closed"),
607 severity: Severity::Warning,
608 fix: Some(Fix {
609 range: (ctx.content.len()..ctx.content.len()),
610 replacement: format!("\n{fence_marker}"),
611 }),
612 });
613 }
614
615 let _ = block_start; }
617 }
618
619 if !has_markdown_doc_block && let Some((_start, fence_marker, line_idx, _is_md)) = current_block_start {
622 let line = lines.get(line_idx).unwrap_or(&"");
623 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
624
625 if let Some(line_info) = ctx.lines.get(line_idx)
627 && line_info.in_html_comment
628 {
629 return Ok(warnings);
630 }
631
632 warnings.push(LintWarning {
633 rule_name: Some(self.name().to_string()),
634 line: start_line,
635 column: start_col,
636 end_line,
637 end_column: end_col,
638 message: format!("Code block opened with '{fence_marker}' but never closed"),
639 severity: Severity::Warning,
640 fix: Some(Fix {
641 range: (ctx.content.len()..ctx.content.len()),
642 replacement: format!("\n{fence_marker}"),
643 }),
644 });
645 }
646
647 if has_markdown_doc_block {
652 return Ok(warnings);
653 }
654
655 for (block_start, block_end, fence_marker, opening_line_idx, is_fenced, is_markdown_doc) in &code_blocks {
656 if !is_fenced {
657 continue;
658 }
659
660 if *is_markdown_doc {
662 continue;
663 }
664
665 let opening_line = lines.get(*opening_line_idx).unwrap_or(&"");
666
667 let fence_char = fence_marker.chars().next().unwrap_or('`');
668 let fence_length = fence_marker.len();
669
670 for (i, line) in lines.iter().enumerate() {
672 let line_start = ctx.line_offsets.get(i).copied().unwrap_or(0);
673 let line_end = ctx.line_offsets.get(i + 1).copied().unwrap_or(ctx.content.len());
674
675 if line_start <= *block_start || line_end >= *block_end {
677 continue;
678 }
679
680 if let Some(line_info) = ctx.lines.get(i)
682 && line_info.in_html_comment
683 {
684 continue;
685 }
686
687 let trimmed = line.trim();
688
689 if (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
691 && trimmed.starts_with(&fence_char.to_string())
692 {
693 let inner_fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
694 let after_fence = &trimmed[inner_fence_length..];
695
696 if inner_fence_length >= fence_length
698 && !after_fence.trim().is_empty()
699 && !after_fence.contains('`')
700 {
701 let identifier = after_fence.trim();
703 let looks_like_language =
704 identifier.chars().next().is_some_and(|c| c.is_alphabetic() || c == '#')
705 && identifier.len() <= 30
706 && identifier.chars().all(|c| c.is_alphanumeric() || "-_+#. ".contains(c));
707
708 if looks_like_language {
709 let (start_line, start_col, end_line, end_col) =
710 calculate_line_range(*opening_line_idx + 1, opening_line);
711
712 let line_start_byte = ctx.line_index.get_line_start_byte(i + 1).unwrap_or(0);
713
714 warnings.push(LintWarning {
715 rule_name: Some(self.name().to_string()),
716 line: start_line,
717 column: start_col,
718 end_line,
719 end_column: end_col,
720 message: format!(
721 "Code block '{fence_marker}' should be closed before starting new one at line {}",
722 i + 1
723 ),
724 severity: Severity::Warning,
725 fix: Some(Fix {
726 range: (line_start_byte..line_start_byte),
727 replacement: format!("{fence_marker}\n\n"),
728 }),
729 });
730
731 break; }
733 }
734 }
735 }
736 }
737
738 Ok(warnings)
739 }
740
741 fn detect_style(&self, content: &str, is_mkdocs: bool, in_jsx_context: &[bool]) -> Option<CodeBlockStyle> {
742 if content.is_empty() {
744 return None;
745 }
746
747 let lines: Vec<&str> = content.lines().collect();
748 let mut fenced_count = 0;
749 let mut indented_count = 0;
750
751 let in_list_context = self.precompute_block_continuation_context(&lines);
753 let in_tab_context = if is_mkdocs {
754 self.precompute_mkdocs_tab_context(&lines)
755 } else {
756 vec![false; lines.len()]
757 };
758 let in_admonition_context = if is_mkdocs {
759 self.precompute_mkdocs_admonition_context(&lines)
760 } else {
761 vec![false; lines.len()]
762 };
763
764 let mut in_fenced = false;
766 let mut prev_was_indented = false;
767
768 for (i, line) in lines.iter().enumerate() {
769 if self.is_fenced_code_block_start(line) {
770 if !in_fenced {
771 fenced_count += 1;
773 in_fenced = true;
774 } else {
775 in_fenced = false;
777 }
778 } else if !in_fenced
779 && self.is_indented_code_block_with_context(
780 &lines,
781 i,
782 is_mkdocs,
783 &in_list_context,
784 &in_tab_context,
785 &in_admonition_context,
786 in_jsx_context,
787 )
788 {
789 if !prev_was_indented {
791 indented_count += 1;
792 }
793 prev_was_indented = true;
794 } else {
795 prev_was_indented = false;
796 }
797 }
798
799 if fenced_count == 0 && indented_count == 0 {
800 None
802 } else if fenced_count > 0 && indented_count == 0 {
803 Some(CodeBlockStyle::Fenced)
805 } else if fenced_count == 0 && indented_count > 0 {
806 Some(CodeBlockStyle::Indented)
808 } else {
809 if fenced_count >= indented_count {
812 Some(CodeBlockStyle::Fenced)
813 } else {
814 Some(CodeBlockStyle::Indented)
815 }
816 }
817 }
818}
819
820#[inline]
821fn line_idx_from_offset(line_offsets: &[usize], offset: usize) -> usize {
822 match line_offsets.binary_search(&offset) {
823 Ok(idx) => idx,
824 Err(idx) => idx.saturating_sub(1),
825 }
826}
827
828impl Rule for MD046CodeBlockStyle {
829 fn name(&self) -> &'static str {
830 "MD046"
831 }
832
833 fn description(&self) -> &'static str {
834 "Code blocks should use a consistent style"
835 }
836
837 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
838 if ctx.content.is_empty() {
840 return Ok(Vec::new());
841 }
842
843 if !ctx.content.contains("```")
845 && !ctx.content.contains("~~~")
846 && !ctx.content.contains(" ")
847 && !ctx.content.contains('\t')
848 {
849 return Ok(Vec::new());
850 }
851
852 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
854
855 if !unclosed_warnings.is_empty() {
857 return Ok(unclosed_warnings);
858 }
859
860 let lines = ctx.raw_lines();
862 let mut warnings = Vec::new();
863
864 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
866
867 let in_jsx_context: Vec<bool> = (0..lines.len())
869 .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block))
870 .collect();
871
872 let target_style = match self.config.style {
874 CodeBlockStyle::Consistent => self
875 .detect_style(ctx.content, is_mkdocs, &in_jsx_context)
876 .unwrap_or(CodeBlockStyle::Fenced),
877 _ => self.config.style,
878 };
879
880 let in_tab_context = if is_mkdocs {
882 self.precompute_mkdocs_tab_context(lines)
883 } else {
884 vec![false; lines.len()]
885 };
886 let in_admonition_context = if is_mkdocs {
887 self.precompute_mkdocs_admonition_context(lines)
888 } else {
889 vec![false; lines.len()]
890 };
891
892 let mut in_fenced_block = vec![false; lines.len()];
895 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
896
897 let options = Options::all();
898 let parser = Parser::new_ext(ctx.content, options).into_offset_iter();
899
900 for (event, range) in parser {
901 let start = range.start;
902 let end = range.end;
903
904 if start >= ctx.content.len() || end > ctx.content.len() {
905 continue;
906 }
907
908 let start_line_idx = line_idx_from_offset(&ctx.line_offsets, start);
910
911 match event {
912 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => {
913 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
915 if line_info.byte_offset >= start && line_info.byte_offset < end {
916 in_fenced_block[line_idx] = true;
917 }
918 }
919
920 if target_style == CodeBlockStyle::Indented {
922 let line = lines.get(start_line_idx).unwrap_or(&"");
923
924 if ctx.lines.get(start_line_idx).is_some_and(|info| info.in_html_comment) {
926 continue;
927 }
928
929 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
930 warnings.push(LintWarning {
931 rule_name: Some(self.name().to_string()),
932 line: start_line,
933 column: start_col,
934 end_line,
935 end_column: end_col,
936 message: "Use indented code blocks".to_string(),
937 severity: Severity::Warning,
938 fix: None,
939 });
940 }
941 }
942 Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
943 if target_style == CodeBlockStyle::Fenced && !reported_indented_lines.contains(&start_line_idx) {
947 let line = lines.get(start_line_idx).unwrap_or(&"");
948
949 if ctx.lines.get(start_line_idx).is_some_and(|info| {
952 info.in_html_comment
953 || info.in_html_block
954 || info.in_jsx_block
955 || info.in_mkdocstrings
956 || info.blockquote.is_some()
957 }) {
958 continue;
959 }
960
961 if mkdocs_footnotes::is_within_footnote_definition(ctx.content, start) {
963 continue;
964 }
965
966 if is_mkdocs && in_tab_context.get(start_line_idx).copied().unwrap_or(false) {
968 continue;
969 }
970
971 if is_mkdocs && in_admonition_context.get(start_line_idx).copied().unwrap_or(false) {
973 continue;
974 }
975
976 reported_indented_lines.insert(start_line_idx);
977
978 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
979 warnings.push(LintWarning {
980 rule_name: Some(self.name().to_string()),
981 line: start_line,
982 column: start_col,
983 end_line,
984 end_column: end_col,
985 message: "Use fenced code blocks".to_string(),
986 severity: Severity::Warning,
987 fix: None,
988 });
989 }
990 }
991 _ => {}
992 }
993 }
994
995 warnings.sort_by_key(|w| (w.line, w.column));
997
998 Ok(warnings)
999 }
1000
1001 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1002 let content = ctx.content;
1003 if content.is_empty() {
1004 return Ok(String::new());
1005 }
1006
1007 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
1009
1010 if !unclosed_warnings.is_empty() {
1012 for warning in &unclosed_warnings {
1014 if warning
1015 .message
1016 .contains("should be closed before starting new one at line")
1017 {
1018 if let Some(fix) = &warning.fix {
1020 let mut result = String::new();
1021 result.push_str(&content[..fix.range.start]);
1022 result.push_str(&fix.replacement);
1023 result.push_str(&content[fix.range.start..]);
1024 return Ok(result);
1025 }
1026 }
1027 }
1028 }
1029
1030 let lines = ctx.raw_lines();
1031
1032 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1034
1035 let in_jsx_context: Vec<bool> = (0..lines.len())
1037 .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block))
1038 .collect();
1039
1040 let target_style = match self.config.style {
1041 CodeBlockStyle::Consistent => self
1042 .detect_style(content, is_mkdocs, &in_jsx_context)
1043 .unwrap_or(CodeBlockStyle::Fenced),
1044 _ => self.config.style,
1045 };
1046
1047 let in_list_context = self.precompute_block_continuation_context(lines);
1049 let in_tab_context = if is_mkdocs {
1050 self.precompute_mkdocs_tab_context(lines)
1051 } else {
1052 vec![false; lines.len()]
1053 };
1054 let in_admonition_context = if is_mkdocs {
1055 self.precompute_mkdocs_admonition_context(lines)
1056 } else {
1057 vec![false; lines.len()]
1058 };
1059
1060 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(
1064 lines,
1065 is_mkdocs,
1066 &in_list_context,
1067 &in_tab_context,
1068 &in_admonition_context,
1069 &in_jsx_context,
1070 );
1071
1072 let mut result = String::with_capacity(content.len());
1073 let mut in_fenced_block = false;
1074 let mut fenced_fence_type = None;
1075 let mut in_indented_block = false;
1076
1077 let mut current_block_disabled = false;
1079
1080 for (i, line) in lines.iter().enumerate() {
1081 let line_num = i + 1;
1082 let trimmed = line.trim_start();
1083
1084 if !in_fenced_block
1087 && Self::has_valid_fence_indent(line)
1088 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1089 {
1090 current_block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
1092 in_fenced_block = true;
1093 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
1094
1095 if current_block_disabled {
1096 result.push_str(line);
1098 result.push('\n');
1099 } else if target_style == CodeBlockStyle::Indented {
1100 in_indented_block = true;
1102 } else {
1103 result.push_str(line);
1105 result.push('\n');
1106 }
1107 } else if in_fenced_block && fenced_fence_type.is_some() {
1108 let fence = fenced_fence_type.unwrap();
1109 if trimmed.starts_with(fence) {
1110 in_fenced_block = false;
1111 fenced_fence_type = None;
1112 in_indented_block = false;
1113
1114 if current_block_disabled {
1115 result.push_str(line);
1116 result.push('\n');
1117 } else if target_style == CodeBlockStyle::Indented {
1118 } else {
1120 result.push_str(line);
1122 result.push('\n');
1123 }
1124 current_block_disabled = false;
1125 } else if current_block_disabled {
1126 result.push_str(line);
1128 result.push('\n');
1129 } else if target_style == CodeBlockStyle::Indented {
1130 result.push_str(" ");
1134 result.push_str(line);
1135 result.push('\n');
1136 } else {
1137 result.push_str(line);
1139 result.push('\n');
1140 }
1141 } else if self.is_indented_code_block_with_context(
1142 lines,
1143 i,
1144 is_mkdocs,
1145 &in_list_context,
1146 &in_tab_context,
1147 &in_admonition_context,
1148 &in_jsx_context,
1149 ) {
1150 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
1154 result.push_str(line);
1155 result.push('\n');
1156 continue;
1157 }
1158
1159 let prev_line_is_indented = i > 0
1161 && self.is_indented_code_block_with_context(
1162 lines,
1163 i - 1,
1164 is_mkdocs,
1165 &in_list_context,
1166 &in_tab_context,
1167 &in_admonition_context,
1168 &in_jsx_context,
1169 );
1170
1171 if target_style == CodeBlockStyle::Fenced {
1172 let trimmed_content = line.trim_start();
1173
1174 if misplaced_fence_lines[i] {
1177 result.push_str(trimmed_content);
1179 result.push('\n');
1180 } else if unsafe_fence_lines[i] {
1181 result.push_str(line);
1184 result.push('\n');
1185 } else if !prev_line_is_indented && !in_indented_block {
1186 result.push_str("```\n");
1188 result.push_str(trimmed_content);
1189 result.push('\n');
1190 in_indented_block = true;
1191 } else {
1192 result.push_str(trimmed_content);
1194 result.push('\n');
1195 }
1196
1197 let next_line_is_indented = i < lines.len() - 1
1199 && self.is_indented_code_block_with_context(
1200 lines,
1201 i + 1,
1202 is_mkdocs,
1203 &in_list_context,
1204 &in_tab_context,
1205 &in_admonition_context,
1206 &in_jsx_context,
1207 );
1208 if !next_line_is_indented
1210 && in_indented_block
1211 && !misplaced_fence_lines[i]
1212 && !unsafe_fence_lines[i]
1213 {
1214 result.push_str("```\n");
1215 in_indented_block = false;
1216 }
1217 } else {
1218 result.push_str(line);
1220 result.push('\n');
1221 }
1222 } else {
1223 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1225 result.push_str("```\n");
1226 in_indented_block = false;
1227 }
1228
1229 result.push_str(line);
1230 result.push('\n');
1231 }
1232 }
1233
1234 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1236 result.push_str("```\n");
1237 }
1238
1239 if let Some(fence_type) = fenced_fence_type
1241 && in_fenced_block
1242 {
1243 result.push_str(fence_type);
1244 result.push('\n');
1245 }
1246
1247 if !content.ends_with('\n') && result.ends_with('\n') {
1249 result.pop();
1250 }
1251
1252 Ok(result)
1253 }
1254
1255 fn category(&self) -> RuleCategory {
1257 RuleCategory::CodeBlock
1258 }
1259
1260 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1262 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1265 }
1266
1267 fn as_any(&self) -> &dyn std::any::Any {
1268 self
1269 }
1270
1271 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1272 let json_value = serde_json::to_value(&self.config).ok()?;
1273 Some((
1274 self.name().to_string(),
1275 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1276 ))
1277 }
1278
1279 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1280 where
1281 Self: Sized,
1282 {
1283 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1284 Box::new(Self::from_config_struct(rule_config))
1285 }
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290 use super::*;
1291 use crate::lint_context::LintContext;
1292
1293 #[test]
1294 fn test_fenced_code_block_detection() {
1295 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1296 assert!(rule.is_fenced_code_block_start("```"));
1297 assert!(rule.is_fenced_code_block_start("```rust"));
1298 assert!(rule.is_fenced_code_block_start("~~~"));
1299 assert!(rule.is_fenced_code_block_start("~~~python"));
1300 assert!(rule.is_fenced_code_block_start(" ```"));
1301 assert!(!rule.is_fenced_code_block_start("``"));
1302 assert!(!rule.is_fenced_code_block_start("~~"));
1303 assert!(!rule.is_fenced_code_block_start("Regular text"));
1304 }
1305
1306 #[test]
1307 fn test_consistent_style_with_fenced_blocks() {
1308 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1309 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311 let result = rule.check(&ctx).unwrap();
1312
1313 assert_eq!(result.len(), 0);
1315 }
1316
1317 #[test]
1318 fn test_consistent_style_with_indented_blocks() {
1319 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1320 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322 let result = rule.check(&ctx).unwrap();
1323
1324 assert_eq!(result.len(), 0);
1326 }
1327
1328 #[test]
1329 fn test_consistent_style_mixed() {
1330 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1331 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1333 let result = rule.check(&ctx).unwrap();
1334
1335 assert!(!result.is_empty());
1337 }
1338
1339 #[test]
1340 fn test_fenced_style_with_indented_blocks() {
1341 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1342 let content = "Text\n\n indented code\n more code\n\nMore text";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 let result = rule.check(&ctx).unwrap();
1345
1346 assert!(!result.is_empty());
1348 assert!(result[0].message.contains("Use fenced code blocks"));
1349 }
1350
1351 #[test]
1352 fn test_fenced_style_with_tab_indented_blocks() {
1353 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1354 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356 let result = rule.check(&ctx).unwrap();
1357
1358 assert!(!result.is_empty());
1360 assert!(result[0].message.contains("Use fenced code blocks"));
1361 }
1362
1363 #[test]
1364 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1365 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1366 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369 let result = rule.check(&ctx).unwrap();
1370
1371 assert!(
1373 !result.is_empty(),
1374 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1375 );
1376 assert!(result[0].message.contains("Use fenced code blocks"));
1377 }
1378
1379 #[test]
1380 fn test_fenced_style_with_one_space_tab_indent() {
1381 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1382 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1385 let result = rule.check(&ctx).unwrap();
1386
1387 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1388 assert!(result[0].message.contains("Use fenced code blocks"));
1389 }
1390
1391 #[test]
1392 fn test_indented_style_with_fenced_blocks() {
1393 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1394 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1396 let result = rule.check(&ctx).unwrap();
1397
1398 assert!(!result.is_empty());
1400 assert!(result[0].message.contains("Use indented code blocks"));
1401 }
1402
1403 #[test]
1404 fn test_unclosed_code_block() {
1405 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1406 let content = "```\ncode without closing fence";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let result = rule.check(&ctx).unwrap();
1409
1410 assert_eq!(result.len(), 1);
1411 assert!(result[0].message.contains("never closed"));
1412 }
1413
1414 #[test]
1415 fn test_nested_code_blocks() {
1416 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1417 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1419 let result = rule.check(&ctx).unwrap();
1420
1421 assert_eq!(result.len(), 0);
1423 }
1424
1425 #[test]
1426 fn test_fix_indented_to_fenced() {
1427 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1428 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1430 let fixed = rule.fix(&ctx).unwrap();
1431
1432 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1433 }
1434
1435 #[test]
1436 fn test_fix_fenced_to_indented() {
1437 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1438 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440 let fixed = rule.fix(&ctx).unwrap();
1441
1442 assert!(fixed.contains(" code line 1\n code line 2"));
1443 assert!(!fixed.contains("```"));
1444 }
1445
1446 #[test]
1447 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1448 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1451 let content = r#"# Test
1452
1453```html
1454<!doctype html>
1455<html>
1456 <head>
1457 <title>Test</title>
1458 </head>
1459</html>
1460```
1461"#;
1462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463 let fixed = rule.fix(&ctx).unwrap();
1464
1465 assert!(
1468 fixed.contains(" <head>"),
1469 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1470 );
1471 assert!(
1472 fixed.contains(" <title>"),
1473 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1474 );
1475 assert!(!fixed.contains("```"), "Fenced markers should be removed");
1476 }
1477
1478 #[test]
1479 fn test_fix_fenced_to_indented_preserves_python_indentation() {
1480 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1482 let content = r#"# Python Example
1483
1484```python
1485def greet(name):
1486 if name:
1487 print(f"Hello, {name}!")
1488 else:
1489 print("Hello, World!")
1490```
1491"#;
1492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1493 let fixed = rule.fix(&ctx).unwrap();
1494
1495 assert!(
1497 fixed.contains(" def greet(name):"),
1498 "Function def should have 4 spaces (code block indent)"
1499 );
1500 assert!(
1501 fixed.contains(" if name:"),
1502 "if statement should have 8 spaces (4 code + 4 Python)"
1503 );
1504 assert!(
1505 fixed.contains(" print"),
1506 "print should have 12 spaces (4 code + 8 Python)"
1507 );
1508 }
1509
1510 #[test]
1511 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1512 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1514 let content = r#"# Config
1515
1516```yaml
1517server:
1518 host: localhost
1519 port: 8080
1520 ssl:
1521 enabled: true
1522 cert: /path/to/cert
1523```
1524"#;
1525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1526 let fixed = rule.fix(&ctx).unwrap();
1527
1528 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
1529 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
1530 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
1531 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
1532 }
1533
1534 #[test]
1535 fn test_fix_fenced_to_indented_preserves_empty_lines() {
1536 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1538 let content = "```\nline1\n\nline2\n```\n";
1539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1540 let fixed = rule.fix(&ctx).unwrap();
1541
1542 assert!(fixed.contains(" line1"), "line1 should be indented");
1544 assert!(fixed.contains(" line2"), "line2 should be indented");
1545 }
1547
1548 #[test]
1549 fn test_fix_fenced_to_indented_multiple_blocks() {
1550 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1552 let content = r#"# Doc
1553
1554```python
1555def foo():
1556 pass
1557```
1558
1559Text between.
1560
1561```yaml
1562key:
1563 value: 1
1564```
1565"#;
1566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1567 let fixed = rule.fix(&ctx).unwrap();
1568
1569 assert!(fixed.contains(" def foo():"), "Python def should be indented");
1570 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
1571 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
1572 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
1573 assert!(!fixed.contains("```"), "No fence markers should remain");
1574 }
1575
1576 #[test]
1577 fn test_fix_unclosed_block() {
1578 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1579 let content = "```\ncode without closing";
1580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1581 let fixed = rule.fix(&ctx).unwrap();
1582
1583 assert!(fixed.ends_with("```"));
1585 }
1586
1587 #[test]
1588 fn test_code_block_in_list() {
1589 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1590 let content = "- List item\n code in list\n more code\n- Next item";
1591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592 let result = rule.check(&ctx).unwrap();
1593
1594 assert_eq!(result.len(), 0);
1596 }
1597
1598 #[test]
1599 fn test_detect_style_fenced() {
1600 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1601 let content = "```\ncode\n```";
1602 let style = rule.detect_style(content, false, &[]);
1603
1604 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1605 }
1606
1607 #[test]
1608 fn test_detect_style_indented() {
1609 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1610 let content = "Text\n\n code\n\nMore";
1611 let style = rule.detect_style(content, false, &[]);
1612
1613 assert_eq!(style, Some(CodeBlockStyle::Indented));
1614 }
1615
1616 #[test]
1617 fn test_detect_style_none() {
1618 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1619 let content = "No code blocks here";
1620 let style = rule.detect_style(content, false, &[]);
1621
1622 assert_eq!(style, None);
1623 }
1624
1625 #[test]
1626 fn test_tilde_fence() {
1627 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1628 let content = "~~~\ncode\n~~~";
1629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1630 let result = rule.check(&ctx).unwrap();
1631
1632 assert_eq!(result.len(), 0);
1634 }
1635
1636 #[test]
1637 fn test_language_specification() {
1638 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1639 let content = "```rust\nfn main() {}\n```";
1640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1641 let result = rule.check(&ctx).unwrap();
1642
1643 assert_eq!(result.len(), 0);
1644 }
1645
1646 #[test]
1647 fn test_empty_content() {
1648 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1649 let content = "";
1650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1651 let result = rule.check(&ctx).unwrap();
1652
1653 assert_eq!(result.len(), 0);
1654 }
1655
1656 #[test]
1657 fn test_default_config() {
1658 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1659 let (name, _config) = rule.default_config_section().unwrap();
1660 assert_eq!(name, "MD046");
1661 }
1662
1663 #[test]
1664 fn test_markdown_documentation_block() {
1665 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1666 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1668 let result = rule.check(&ctx).unwrap();
1669
1670 assert_eq!(result.len(), 0);
1672 }
1673
1674 #[test]
1675 fn test_preserve_trailing_newline() {
1676 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1677 let content = "```\ncode\n```\n";
1678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1679 let fixed = rule.fix(&ctx).unwrap();
1680
1681 assert_eq!(fixed, content);
1682 }
1683
1684 #[test]
1685 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1686 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1687 let content = r#"# Document
1688
1689=== "Python"
1690
1691 This is tab content
1692 Not an indented code block
1693
1694 ```python
1695 def hello():
1696 print("Hello")
1697 ```
1698
1699=== "JavaScript"
1700
1701 More tab content here
1702 Also not an indented code block"#;
1703
1704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1705 let result = rule.check(&ctx).unwrap();
1706
1707 assert_eq!(result.len(), 0);
1709 }
1710
1711 #[test]
1712 fn test_mkdocs_tabs_with_actual_indented_code() {
1713 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1714 let content = r#"# Document
1715
1716=== "Tab 1"
1717
1718 This is tab content
1719
1720Regular text
1721
1722 This is an actual indented code block
1723 Should be flagged"#;
1724
1725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1726 let result = rule.check(&ctx).unwrap();
1727
1728 assert_eq!(result.len(), 1);
1730 assert!(result[0].message.contains("Use fenced code blocks"));
1731 }
1732
1733 #[test]
1734 fn test_mkdocs_tabs_detect_style() {
1735 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1736 let content = r#"=== "Tab 1"
1737
1738 Content in tab
1739 More content
1740
1741=== "Tab 2"
1742
1743 Content in second tab"#;
1744
1745 let style = rule.detect_style(content, true, &[]);
1747 assert_eq!(style, None); let style = rule.detect_style(content, false, &[]);
1751 assert_eq!(style, Some(CodeBlockStyle::Indented));
1752 }
1753
1754 #[test]
1755 fn test_mkdocs_nested_tabs() {
1756 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1757 let content = r#"# Document
1758
1759=== "Outer Tab"
1760
1761 Some content
1762
1763 === "Nested Tab"
1764
1765 Nested tab content
1766 Should not 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(), 0);
1773 }
1774
1775 #[test]
1776 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1777 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1780 let content = r#"# Document
1781
1782!!! note
1783 This is normal admonition content, not a code block.
1784 It spans multiple lines.
1785
1786??? warning "Collapsible Warning"
1787 This is also admonition content.
1788
1789???+ tip "Expanded Tip"
1790 And this one too.
1791
1792Regular text outside admonitions."#;
1793
1794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1795 let result = rule.check(&ctx).unwrap();
1796
1797 assert_eq!(
1799 result.len(),
1800 0,
1801 "Admonition content in MkDocs mode should not trigger MD046"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_mkdocs_admonition_with_actual_indented_code() {
1807 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1809 let content = r#"# Document
1810
1811!!! note
1812 This is admonition content.
1813
1814Regular text ends the admonition.
1815
1816 This is actual indented code (should be flagged)"#;
1817
1818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1819 let result = rule.check(&ctx).unwrap();
1820
1821 assert_eq!(result.len(), 1);
1823 assert!(result[0].message.contains("Use fenced code blocks"));
1824 }
1825
1826 #[test]
1827 fn test_admonition_in_standard_mode_flagged() {
1828 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1832 let content = r#"# Document
1833
1834!!! note
1835
1836 This looks like code in standard mode.
1837
1838Regular text."#;
1839
1840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1842 let result = rule.check(&ctx).unwrap();
1843
1844 assert_eq!(
1846 result.len(),
1847 1,
1848 "Admonition content in Standard mode should be flagged as indented code"
1849 );
1850 }
1851
1852 #[test]
1853 fn test_mkdocs_admonition_with_fenced_code_inside() {
1854 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1856 let content = r#"# Document
1857
1858!!! note "Code Example"
1859 Here's some code:
1860
1861 ```python
1862 def hello():
1863 print("world")
1864 ```
1865
1866 More text after code.
1867
1868Regular text."#;
1869
1870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1871 let result = rule.check(&ctx).unwrap();
1872
1873 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1875 }
1876
1877 #[test]
1878 fn test_mkdocs_nested_admonitions() {
1879 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1881 let content = r#"# Document
1882
1883!!! note "Outer"
1884 Outer content.
1885
1886 !!! warning "Inner"
1887 Inner content.
1888 More inner content.
1889
1890 Back to outer.
1891
1892Regular text."#;
1893
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1895 let result = rule.check(&ctx).unwrap();
1896
1897 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1899 }
1900
1901 #[test]
1902 fn test_mkdocs_admonition_fix_does_not_wrap() {
1903 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1905 let content = r#"!!! note
1906 Content that should stay as admonition content.
1907 Not be wrapped in code fences.
1908"#;
1909
1910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1911 let fixed = rule.fix(&ctx).unwrap();
1912
1913 assert!(
1915 !fixed.contains("```\n Content"),
1916 "Admonition content should not be wrapped in fences"
1917 );
1918 assert_eq!(fixed, content, "Content should remain unchanged");
1919 }
1920
1921 #[test]
1922 fn test_mkdocs_empty_admonition() {
1923 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1925 let content = r#"!!! note
1926
1927Regular paragraph after empty admonition.
1928
1929 This IS an indented code block (after blank + non-indented line)."#;
1930
1931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1932 let result = rule.check(&ctx).unwrap();
1933
1934 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1936 }
1937
1938 #[test]
1939 fn test_mkdocs_indented_admonition() {
1940 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1942 let content = r#"- List item
1943
1944 !!! note
1945 Indented admonition content.
1946 More content.
1947
1948- Next item"#;
1949
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1951 let result = rule.check(&ctx).unwrap();
1952
1953 assert_eq!(
1955 result.len(),
1956 0,
1957 "Indented admonitions (e.g., in lists) should not be flagged"
1958 );
1959 }
1960
1961 #[test]
1962 fn test_footnote_indented_paragraphs_not_flagged() {
1963 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1964 let content = r#"# Test Document with Footnotes
1965
1966This is some text with a footnote[^1].
1967
1968Here's some code:
1969
1970```bash
1971echo "fenced code block"
1972```
1973
1974More text with another footnote[^2].
1975
1976[^1]: Really interesting footnote text.
1977
1978 Even more interesting second paragraph.
1979
1980[^2]: Another footnote.
1981
1982 With a second paragraph too.
1983
1984 And even a third paragraph!"#;
1985
1986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1987 let result = rule.check(&ctx).unwrap();
1988
1989 assert_eq!(result.len(), 0);
1991 }
1992
1993 #[test]
1994 fn test_footnote_definition_detection() {
1995 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1996
1997 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
2000 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
2001 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
2002 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
2003 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
2004 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
2005 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
2006 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
2007 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
2008
2009 assert!(!rule.is_footnote_definition("[^]: No label"));
2011 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
2012 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
2013 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
2014
2015 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
2017 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
2018 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
2019 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
2022
2023 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
2025 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
2026 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
2027 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
2028 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
2029
2030 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
2033 }
2034
2035 #[test]
2036 fn test_footnote_with_blank_lines() {
2037 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2041 let content = r#"# Document
2042
2043Text with footnote[^1].
2044
2045[^1]: First paragraph.
2046
2047 Second paragraph after blank line.
2048
2049 Third paragraph after another blank line.
2050
2051Regular text at column 0 ends the footnote."#;
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 0,
2060 "Indented content within footnotes should not trigger MD046"
2061 );
2062 }
2063
2064 #[test]
2065 fn test_footnote_multiple_consecutive_blank_lines() {
2066 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2069 let content = r#"Text[^1].
2070
2071[^1]: First paragraph.
2072
2073
2074
2075 Content after three blank lines (still part of footnote).
2076
2077Not indented, so footnote ends here."#;
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 0,
2086 "Multiple blank lines shouldn't break footnote continuation"
2087 );
2088 }
2089
2090 #[test]
2091 fn test_footnote_terminated_by_non_indented_content() {
2092 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2095 let content = r#"[^1]: Footnote content.
2096
2097 More indented content in footnote.
2098
2099This paragraph is not indented, so footnote ends.
2100
2101 This should be flagged as indented code block."#;
2102
2103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2104 let result = rule.check(&ctx).unwrap();
2105
2106 assert_eq!(
2108 result.len(),
2109 1,
2110 "Indented code after footnote termination should be flagged"
2111 );
2112 assert!(
2113 result[0].message.contains("Use fenced code blocks"),
2114 "Expected MD046 warning for indented code block"
2115 );
2116 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2117 }
2118
2119 #[test]
2120 fn test_footnote_terminated_by_structural_elements() {
2121 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2123 let content = r#"[^1]: Footnote content.
2124
2125 More content.
2126
2127## Heading terminates footnote
2128
2129 This indented content should be flagged.
2130
2131---
2132
2133 This should also be flagged (after horizontal rule)."#;
2134
2135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136 let result = rule.check(&ctx).unwrap();
2137
2138 assert_eq!(
2140 result.len(),
2141 2,
2142 "Both indented blocks after termination should be flagged"
2143 );
2144 }
2145
2146 #[test]
2147 fn test_footnote_with_code_block_inside() {
2148 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2151 let content = r#"Text[^1].
2152
2153[^1]: Footnote with code:
2154
2155 ```python
2156 def hello():
2157 print("world")
2158 ```
2159
2160 More footnote text after code."#;
2161
2162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2163 let result = rule.check(&ctx).unwrap();
2164
2165 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2167 }
2168
2169 #[test]
2170 fn test_footnote_with_8_space_indented_code() {
2171 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2174 let content = r#"Text[^1].
2175
2176[^1]: Footnote with nested code.
2177
2178 code block
2179 more code"#;
2180
2181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2182 let result = rule.check(&ctx).unwrap();
2183
2184 assert_eq!(
2186 result.len(),
2187 0,
2188 "8-space indented code within footnotes represents nested code blocks"
2189 );
2190 }
2191
2192 #[test]
2193 fn test_multiple_footnotes() {
2194 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2197 let content = r#"Text[^1] and more[^2].
2198
2199[^1]: First footnote.
2200
2201 Continuation of first.
2202
2203[^2]: Second footnote starts here, ending the first.
2204
2205 Continuation of second."#;
2206
2207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2208 let result = rule.check(&ctx).unwrap();
2209
2210 assert_eq!(
2212 result.len(),
2213 0,
2214 "Multiple footnotes should each maintain their continuation context"
2215 );
2216 }
2217
2218 #[test]
2219 fn test_list_item_ends_footnote_context() {
2220 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2222 let content = r#"[^1]: Footnote.
2223
2224 Content in footnote.
2225
2226- List item starts here (ends footnote context).
2227
2228 This indented content is part of the list, not the footnote."#;
2229
2230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2231 let result = rule.check(&ctx).unwrap();
2232
2233 assert_eq!(
2235 result.len(),
2236 0,
2237 "List items should end footnote context and start their own"
2238 );
2239 }
2240
2241 #[test]
2242 fn test_footnote_vs_actual_indented_code() {
2243 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2246 let content = r#"# Heading
2247
2248Text with footnote[^1].
2249
2250[^1]: Footnote content.
2251
2252 Part of footnote (should not be flagged).
2253
2254Regular paragraph ends footnote context.
2255
2256 This is actual indented code (MUST be flagged)
2257 Should be detected as code block"#;
2258
2259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2260 let result = rule.check(&ctx).unwrap();
2261
2262 assert_eq!(
2264 result.len(),
2265 1,
2266 "Must still detect indented code blocks outside footnotes"
2267 );
2268 assert!(
2269 result[0].message.contains("Use fenced code blocks"),
2270 "Expected MD046 warning for indented code"
2271 );
2272 assert!(
2273 result[0].line >= 11,
2274 "Warning should be on the actual indented code line"
2275 );
2276 }
2277
2278 #[test]
2279 fn test_spec_compliant_label_characters() {
2280 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2283
2284 assert!(rule.is_footnote_definition("[^test]: text"));
2286 assert!(rule.is_footnote_definition("[^TEST]: text"));
2287 assert!(rule.is_footnote_definition("[^test-name]: text"));
2288 assert!(rule.is_footnote_definition("[^test_name]: text"));
2289 assert!(rule.is_footnote_definition("[^test123]: text"));
2290 assert!(rule.is_footnote_definition("[^123]: text"));
2291 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2292
2293 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")); }
2301
2302 #[test]
2303 fn test_code_block_inside_html_comment() {
2304 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2307 let content = r#"# Document
2308
2309Some text.
2310
2311<!--
2312Example code block in comment:
2313
2314```typescript
2315console.log("Hello");
2316```
2317
2318More comment text.
2319-->
2320
2321More content."#;
2322
2323 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2324 let result = rule.check(&ctx).unwrap();
2325
2326 assert_eq!(
2327 result.len(),
2328 0,
2329 "Code blocks inside HTML comments should not be flagged as unclosed"
2330 );
2331 }
2332
2333 #[test]
2334 fn test_unclosed_fence_inside_html_comment() {
2335 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2337 let content = r#"# Document
2338
2339<!--
2340Example with intentionally unclosed fence:
2341
2342```
2343code without closing
2344-->
2345
2346More content."#;
2347
2348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2349 let result = rule.check(&ctx).unwrap();
2350
2351 assert_eq!(
2352 result.len(),
2353 0,
2354 "Unclosed fences inside HTML comments should be ignored"
2355 );
2356 }
2357
2358 #[test]
2359 fn test_multiline_html_comment_with_indented_code() {
2360 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2362 let content = r#"# Document
2363
2364<!--
2365Example:
2366
2367 indented code
2368 more code
2369
2370End of comment.
2371-->
2372
2373Regular text."#;
2374
2375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2376 let result = rule.check(&ctx).unwrap();
2377
2378 assert_eq!(
2379 result.len(),
2380 0,
2381 "Indented code inside HTML comments should not be flagged"
2382 );
2383 }
2384
2385 #[test]
2386 fn test_code_block_after_html_comment() {
2387 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2389 let content = r#"# Document
2390
2391<!-- comment -->
2392
2393Text before.
2394
2395 indented code should be flagged
2396
2397More text."#;
2398
2399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2400 let result = rule.check(&ctx).unwrap();
2401
2402 assert_eq!(
2403 result.len(),
2404 1,
2405 "Code blocks after HTML comments should still be detected"
2406 );
2407 assert!(result[0].message.contains("Use fenced code blocks"));
2408 }
2409
2410 #[test]
2411 fn test_four_space_indented_fence_is_not_valid_fence() {
2412 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2415
2416 assert!(rule.is_fenced_code_block_start("```"));
2418 assert!(rule.is_fenced_code_block_start(" ```"));
2419 assert!(rule.is_fenced_code_block_start(" ```"));
2420 assert!(rule.is_fenced_code_block_start(" ```"));
2421
2422 assert!(!rule.is_fenced_code_block_start(" ```"));
2424 assert!(!rule.is_fenced_code_block_start(" ```"));
2425 assert!(!rule.is_fenced_code_block_start(" ```"));
2426
2427 assert!(!rule.is_fenced_code_block_start("\t```"));
2429 }
2430
2431 #[test]
2432 fn test_issue_237_indented_fenced_block_detected_as_indented() {
2433 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2439
2440 let content = r#"## Test
2442
2443 ```js
2444 var foo = "hello";
2445 ```
2446"#;
2447
2448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2449 let result = rule.check(&ctx).unwrap();
2450
2451 assert_eq!(
2453 result.len(),
2454 1,
2455 "4-space indented fence should be detected as indented code block"
2456 );
2457 assert!(
2458 result[0].message.contains("Use fenced code blocks"),
2459 "Expected 'Use fenced code blocks' message"
2460 );
2461 }
2462
2463 #[test]
2464 fn test_issue_276_indented_code_in_list() {
2465 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2468
2469 let content = r#"1. First item
24702. Second item with code:
2471
2472 # This is a code block in a list
2473 print("Hello, world!")
2474
24754. Third item"#;
2476
2477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2478 let result = rule.check(&ctx).unwrap();
2479
2480 assert!(
2482 !result.is_empty(),
2483 "Indented code block inside list should be flagged when style=fenced"
2484 );
2485 assert!(
2486 result[0].message.contains("Use fenced code blocks"),
2487 "Expected 'Use fenced code blocks' message"
2488 );
2489 }
2490
2491 #[test]
2492 fn test_three_space_indented_fence_is_valid() {
2493 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2495
2496 let content = r#"## Test
2497
2498 ```js
2499 var foo = "hello";
2500 ```
2501"#;
2502
2503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2504 let result = rule.check(&ctx).unwrap();
2505
2506 assert_eq!(
2508 result.len(),
2509 0,
2510 "3-space indented fence should be recognized as valid fenced code block"
2511 );
2512 }
2513
2514 #[test]
2515 fn test_indented_style_with_deeply_indented_fenced() {
2516 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2519
2520 let content = r#"Text
2521
2522 ```js
2523 var foo = "hello";
2524 ```
2525
2526More text
2527"#;
2528
2529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2530 let result = rule.check(&ctx).unwrap();
2531
2532 assert_eq!(
2535 result.len(),
2536 0,
2537 "4-space indented content should be valid when style=indented"
2538 );
2539 }
2540
2541 #[test]
2542 fn test_fix_misplaced_fenced_block() {
2543 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2546
2547 let content = r#"## Test
2548
2549 ```js
2550 var foo = "hello";
2551 ```
2552"#;
2553
2554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2555 let fixed = rule.fix(&ctx).unwrap();
2556
2557 let expected = r#"## Test
2559
2560```js
2561var foo = "hello";
2562```
2563"#;
2564
2565 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2566 }
2567
2568 #[test]
2569 fn test_fix_regular_indented_block() {
2570 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2573
2574 let content = r#"Text
2575
2576 var foo = "hello";
2577 console.log(foo);
2578
2579More text
2580"#;
2581
2582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2583 let fixed = rule.fix(&ctx).unwrap();
2584
2585 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2587 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2588 }
2589
2590 #[test]
2591 fn test_fix_indented_block_with_fence_like_content() {
2592 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2596
2597 let content = r#"Text
2598
2599 some code
2600 ```not a fence opener
2601 more code
2602"#;
2603
2604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2605 let fixed = rule.fix(&ctx).unwrap();
2606
2607 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
2609 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2610 }
2611
2612 #[test]
2613 fn test_fix_mixed_indented_and_misplaced_blocks() {
2614 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2616
2617 let content = r#"Text
2618
2619 regular indented code
2620
2621More text
2622
2623 ```python
2624 print("hello")
2625 ```
2626"#;
2627
2628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2629 let fixed = rule.fix(&ctx).unwrap();
2630
2631 assert!(
2633 fixed.contains("```\nregular indented code\n```"),
2634 "First block should be wrapped in fences"
2635 );
2636
2637 assert!(
2639 fixed.contains("\n```python\nprint(\"hello\")\n```"),
2640 "Second block should be dedented, not double-wrapped"
2641 );
2642 assert!(
2644 !fixed.contains("```\n```python"),
2645 "Should not have nested fence openers"
2646 );
2647 }
2648}