1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::calculate_indentation_width_default;
3use crate::utils::mkdocs_admonitions;
4use crate::utils::mkdocs_tabs;
5use crate::utils::range_utils::calculate_line_range;
6use toml;
7
8mod md046_config;
9pub use md046_config::CodeBlockStyle;
10use md046_config::MD046Config;
11
12struct IndentContext<'a> {
14 in_list_context: &'a [bool],
15 in_tab_context: &'a [bool],
16 in_admonition_context: &'a [bool],
17 in_jsx_context: &'a [bool],
18}
19
20#[derive(Clone)]
26pub struct MD046CodeBlockStyle {
27 config: MD046Config,
28}
29
30impl MD046CodeBlockStyle {
31 pub fn new(style: CodeBlockStyle) -> Self {
32 Self {
33 config: MD046Config { style },
34 }
35 }
36
37 pub fn from_config_struct(config: MD046Config) -> Self {
38 Self { config }
39 }
40
41 fn has_valid_fence_indent(line: &str) -> bool {
46 calculate_indentation_width_default(line) < 4
47 }
48
49 fn is_fenced_code_block_start(&self, line: &str) -> bool {
58 if !Self::has_valid_fence_indent(line) {
59 return false;
60 }
61
62 let trimmed = line.trim_start();
63 trimmed.starts_with("```") || trimmed.starts_with("~~~")
64 }
65
66 fn is_list_item(&self, line: &str) -> bool {
67 let trimmed = line.trim_start();
68 (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
69 || (trimmed.len() > 2
70 && trimmed.chars().next().unwrap().is_numeric()
71 && (trimmed.contains(". ") || trimmed.contains(") ")))
72 }
73
74 fn is_footnote_definition(&self, line: &str) -> bool {
94 let trimmed = line.trim_start();
95 if !trimmed.starts_with("[^") || trimmed.len() < 5 {
96 return false;
97 }
98
99 if let Some(close_bracket_pos) = trimmed.find("]:")
100 && close_bracket_pos > 2
101 {
102 let label = &trimmed[2..close_bracket_pos];
103
104 if label.trim().is_empty() {
105 return false;
106 }
107
108 if label.contains('\r') {
110 return false;
111 }
112
113 if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
115 return true;
116 }
117 }
118
119 false
120 }
121
122 fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
145 let mut in_continuation_context = vec![false; lines.len()];
146 let mut last_list_item_line: Option<usize> = None;
147 let mut last_footnote_line: Option<usize> = None;
148 let mut blank_line_count = 0;
149
150 for (i, line) in lines.iter().enumerate() {
151 let trimmed = line.trim_start();
152 let indent_len = line.len() - trimmed.len();
153
154 if self.is_list_item(line) {
156 last_list_item_line = Some(i);
157 last_footnote_line = None; blank_line_count = 0;
159 in_continuation_context[i] = true;
160 continue;
161 }
162
163 if self.is_footnote_definition(line) {
165 last_footnote_line = Some(i);
166 last_list_item_line = None; blank_line_count = 0;
168 in_continuation_context[i] = true;
169 continue;
170 }
171
172 if line.trim().is_empty() {
174 if last_list_item_line.is_some() || last_footnote_line.is_some() {
176 blank_line_count += 1;
177 in_continuation_context[i] = true;
178
179 }
183 continue;
184 }
185
186 if indent_len == 0 && !trimmed.is_empty() {
188 if trimmed.starts_with('#') {
192 last_list_item_line = None;
193 last_footnote_line = None;
194 blank_line_count = 0;
195 continue;
196 }
197
198 if trimmed.starts_with("---") || trimmed.starts_with("***") {
200 last_list_item_line = None;
201 last_footnote_line = None;
202 blank_line_count = 0;
203 continue;
204 }
205
206 if let Some(list_line) = last_list_item_line
209 && (i - list_line > 5 || blank_line_count > 1)
210 {
211 last_list_item_line = None;
212 }
213
214 if last_footnote_line.is_some() {
216 last_footnote_line = None;
217 }
218
219 blank_line_count = 0;
220
221 if last_list_item_line.is_none() && last_footnote_line.is_some() {
223 last_footnote_line = None;
224 }
225 continue;
226 }
227
228 if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
230 in_continuation_context[i] = true;
231 blank_line_count = 0;
232 }
233 }
234
235 in_continuation_context
236 }
237
238 fn is_indented_code_block_with_context(
240 &self,
241 lines: &[&str],
242 i: usize,
243 is_mkdocs: bool,
244 ctx: &IndentContext,
245 ) -> bool {
246 if i >= lines.len() {
247 return false;
248 }
249
250 let line = lines[i];
251
252 let indent = calculate_indentation_width_default(line);
254 if indent < 4 {
255 return false;
256 }
257
258 if ctx.in_list_context[i] {
260 return false;
261 }
262
263 if is_mkdocs && ctx.in_tab_context[i] {
265 return false;
266 }
267
268 if is_mkdocs && ctx.in_admonition_context[i] {
271 return false;
272 }
273
274 if ctx.in_jsx_context.get(i).copied().unwrap_or(false) {
276 return false;
277 }
278
279 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
282 let prev_is_indented_code = i > 0
283 && calculate_indentation_width_default(lines[i - 1]) >= 4
284 && !ctx.in_list_context[i - 1]
285 && !(is_mkdocs && ctx.in_tab_context[i - 1])
286 && !(is_mkdocs && ctx.in_admonition_context[i - 1]);
287
288 if !has_blank_line_before && !prev_is_indented_code {
291 return false;
292 }
293
294 true
295 }
296
297 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
299 let mut in_tab_context = vec![false; lines.len()];
300 let mut current_tab_indent: Option<usize> = None;
301
302 for (i, line) in lines.iter().enumerate() {
303 if mkdocs_tabs::is_tab_marker(line) {
305 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
306 current_tab_indent = Some(tab_indent);
307 in_tab_context[i] = true;
308 continue;
309 }
310
311 if let Some(tab_indent) = current_tab_indent {
313 if mkdocs_tabs::is_tab_content(line, tab_indent) {
314 in_tab_context[i] = true;
315 } else if !line.trim().is_empty() && calculate_indentation_width_default(line) < 4 {
316 current_tab_indent = None;
318 } else {
319 in_tab_context[i] = true;
321 }
322 }
323 }
324
325 in_tab_context
326 }
327
328 fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
337 let mut in_admonition_context = vec![false; lines.len()];
338 let mut admonition_stack: Vec<usize> = Vec::new();
340
341 for (i, line) in lines.iter().enumerate() {
342 let line_indent = calculate_indentation_width_default(line);
343
344 if mkdocs_admonitions::is_admonition_start(line) {
346 let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
347
348 while let Some(&top_indent) = admonition_stack.last() {
350 if adm_indent <= top_indent {
352 admonition_stack.pop();
353 } else {
354 break;
355 }
356 }
357
358 admonition_stack.push(adm_indent);
360 in_admonition_context[i] = true;
361 continue;
362 }
363
364 if line.trim().is_empty() {
366 if !admonition_stack.is_empty() {
367 in_admonition_context[i] = true;
368 }
369 continue;
370 }
371
372 while let Some(&top_indent) = admonition_stack.last() {
375 if line_indent >= top_indent + 4 {
377 break;
379 } else {
380 admonition_stack.pop();
382 }
383 }
384
385 if !admonition_stack.is_empty() {
387 in_admonition_context[i] = true;
388 }
389 }
390
391 in_admonition_context
392 }
393
394 fn categorize_indented_blocks(
406 &self,
407 lines: &[&str],
408 is_mkdocs: bool,
409 in_list_context: &[bool],
410 in_tab_context: &[bool],
411 in_admonition_context: &[bool],
412 in_jsx_context: &[bool],
413 ) -> (Vec<bool>, Vec<bool>) {
414 let mut is_misplaced = vec![false; lines.len()];
415 let mut contains_fences = vec![false; lines.len()];
416
417 let ictx = IndentContext {
418 in_list_context,
419 in_tab_context,
420 in_admonition_context,
421 in_jsx_context,
422 };
423
424 let mut i = 0;
426 while i < lines.len() {
427 if !self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) {
429 i += 1;
430 continue;
431 }
432
433 let block_start = i;
435 let mut block_end = i;
436
437 while block_end < lines.len()
438 && self.is_indented_code_block_with_context(lines, block_end, is_mkdocs, &ictx)
439 {
440 block_end += 1;
441 }
442
443 if block_end > block_start {
445 let first_line = lines[block_start].trim_start();
446 let last_line = lines[block_end - 1].trim_start();
447
448 let is_backtick_fence = first_line.starts_with("```");
450 let is_tilde_fence = first_line.starts_with("~~~");
451
452 if is_backtick_fence || is_tilde_fence {
453 let fence_char = if is_backtick_fence { '`' } else { '~' };
454 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
455
456 let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
458 let after_closer = &last_line[closer_fence_len..];
459
460 if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
461 is_misplaced[block_start..block_end].fill(true);
463 } else {
464 contains_fences[block_start..block_end].fill(true);
466 }
467 } else {
468 let has_fence_markers = (block_start..block_end).any(|j| {
471 let trimmed = lines[j].trim_start();
472 trimmed.starts_with("```") || trimmed.starts_with("~~~")
473 });
474
475 if has_fence_markers {
476 contains_fences[block_start..block_end].fill(true);
477 }
478 }
479 }
480
481 i = block_end;
482 }
483
484 (is_misplaced, contains_fences)
485 }
486
487 fn check_unclosed_code_blocks(
488 &self,
489 ctx: &crate::lint_context::LintContext,
490 ) -> Result<Vec<LintWarning>, LintError> {
491 let mut warnings = Vec::new();
492 let lines = ctx.raw_lines();
493
494 let has_markdown_doc_block = ctx.code_block_details.iter().any(|d| {
496 if !d.is_fenced {
497 return false;
498 }
499 let lang = d.info_string.to_lowercase();
500 lang.starts_with("markdown") || lang.starts_with("md")
501 });
502
503 if has_markdown_doc_block {
506 return Ok(warnings);
507 }
508
509 for detail in &ctx.code_block_details {
510 if !detail.is_fenced {
511 continue;
512 }
513
514 if detail.end != ctx.content.len() {
516 continue;
517 }
518
519 let opening_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
521 Ok(idx) => idx,
522 Err(idx) => idx.saturating_sub(1),
523 };
524
525 let line = lines.get(opening_line_idx).unwrap_or(&"");
527 let trimmed = line.trim();
528 let fence_marker = if let Some(pos) = trimmed.find("```") {
529 let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
530 "`".repeat(count)
531 } else if let Some(pos) = trimmed.find("~~~") {
532 let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
533 "~".repeat(count)
534 } else {
535 "```".to_string()
536 };
537
538 let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
540 let last_trimmed = last_non_empty_line.trim();
541 let fence_char = fence_marker.chars().next().unwrap_or('`');
542
543 let has_closing_fence = if fence_char == '`' {
544 last_trimmed.starts_with("```") && {
545 let fence_len = last_trimmed.chars().take_while(|&c| c == '`').count();
546 last_trimmed[fence_len..].trim().is_empty()
547 }
548 } else {
549 last_trimmed.starts_with("~~~") && {
550 let fence_len = last_trimmed.chars().take_while(|&c| c == '~').count();
551 last_trimmed[fence_len..].trim().is_empty()
552 }
553 };
554
555 if !has_closing_fence {
556 if ctx
558 .lines
559 .get(opening_line_idx)
560 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
561 {
562 continue;
563 }
564
565 let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
566
567 warnings.push(LintWarning {
568 rule_name: Some(self.name().to_string()),
569 line: start_line,
570 column: start_col,
571 end_line,
572 end_column: end_col,
573 message: format!("Code block opened with '{fence_marker}' but never closed"),
574 severity: Severity::Warning,
575 fix: Some(Fix {
576 range: (ctx.content.len()..ctx.content.len()),
577 replacement: format!("\n{fence_marker}"),
578 }),
579 });
580 }
581 }
582
583 Ok(warnings)
584 }
585
586 fn detect_style(&self, lines: &[&str], is_mkdocs: bool, ictx: &IndentContext) -> Option<CodeBlockStyle> {
587 if lines.is_empty() {
588 return None;
589 }
590
591 let mut fenced_count = 0;
592 let mut indented_count = 0;
593
594 let mut in_fenced = false;
596 let mut prev_was_indented = false;
597
598 for (i, line) in lines.iter().enumerate() {
599 if self.is_fenced_code_block_start(line) {
600 if !in_fenced {
601 fenced_count += 1;
603 in_fenced = true;
604 } else {
605 in_fenced = false;
607 }
608 } else if !in_fenced && self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx) {
609 if !prev_was_indented {
611 indented_count += 1;
612 }
613 prev_was_indented = true;
614 } else {
615 prev_was_indented = false;
616 }
617 }
618
619 if fenced_count == 0 && indented_count == 0 {
620 None
621 } else if fenced_count > 0 && indented_count == 0 {
622 Some(CodeBlockStyle::Fenced)
623 } else if fenced_count == 0 && indented_count > 0 {
624 Some(CodeBlockStyle::Indented)
625 } else if fenced_count >= indented_count {
626 Some(CodeBlockStyle::Fenced)
627 } else {
628 Some(CodeBlockStyle::Indented)
629 }
630 }
631}
632
633impl Rule for MD046CodeBlockStyle {
634 fn name(&self) -> &'static str {
635 "MD046"
636 }
637
638 fn description(&self) -> &'static str {
639 "Code blocks should use a consistent style"
640 }
641
642 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
643 if ctx.content.is_empty() {
645 return Ok(Vec::new());
646 }
647
648 if !ctx.content.contains("```")
650 && !ctx.content.contains("~~~")
651 && !ctx.content.contains(" ")
652 && !ctx.content.contains('\t')
653 {
654 return Ok(Vec::new());
655 }
656
657 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
659
660 if !unclosed_warnings.is_empty() {
662 return Ok(unclosed_warnings);
663 }
664
665 let lines = ctx.raw_lines();
667 let mut warnings = Vec::new();
668
669 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
670
671 let target_style = match self.config.style {
673 CodeBlockStyle::Consistent => {
674 let in_list_context = self.precompute_block_continuation_context(lines);
675 let in_jsx_context: Vec<bool> = (0..lines.len())
676 .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block))
677 .collect();
678 let in_tab_context = if is_mkdocs {
679 self.precompute_mkdocs_tab_context(lines)
680 } else {
681 vec![false; lines.len()]
682 };
683 let in_admonition_context = if is_mkdocs {
684 self.precompute_mkdocs_admonition_context(lines)
685 } else {
686 vec![false; lines.len()]
687 };
688 let ictx = IndentContext {
689 in_list_context: &in_list_context,
690 in_tab_context: &in_tab_context,
691 in_admonition_context: &in_admonition_context,
692 in_jsx_context: &in_jsx_context,
693 };
694 self.detect_style(lines, is_mkdocs, &ictx)
695 .unwrap_or(CodeBlockStyle::Fenced)
696 }
697 _ => self.config.style,
698 };
699
700 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
702
703 for detail in &ctx.code_block_details {
704 if detail.start >= ctx.content.len() || detail.end > ctx.content.len() {
705 continue;
706 }
707
708 let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
709 Ok(idx) => idx,
710 Err(idx) => idx.saturating_sub(1),
711 };
712
713 if detail.is_fenced {
714 if target_style == CodeBlockStyle::Indented {
715 let line = lines.get(start_line_idx).unwrap_or(&"");
716
717 if ctx
718 .lines
719 .get(start_line_idx)
720 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
721 {
722 continue;
723 }
724
725 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
726 warnings.push(LintWarning {
727 rule_name: Some(self.name().to_string()),
728 line: start_line,
729 column: start_col,
730 end_line,
731 end_column: end_col,
732 message: "Use indented code blocks".to_string(),
733 severity: Severity::Warning,
734 fix: None,
735 });
736 }
737 } else {
738 if target_style == CodeBlockStyle::Fenced && !reported_indented_lines.contains(&start_line_idx) {
740 let line = lines.get(start_line_idx).unwrap_or(&"");
741
742 if ctx.lines.get(start_line_idx).is_some_and(|info| {
744 info.in_html_comment
745 || info.in_mdx_comment
746 || info.in_html_block
747 || info.in_jsx_block
748 || info.in_mkdocstrings
749 || info.in_footnote_definition
750 || info.blockquote.is_some()
751 }) {
752 continue;
753 }
754
755 if is_mkdocs
757 && ctx
758 .lines
759 .get(start_line_idx)
760 .is_some_and(|info| info.in_admonition || info.in_content_tab)
761 {
762 continue;
763 }
764
765 reported_indented_lines.insert(start_line_idx);
766
767 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
768 warnings.push(LintWarning {
769 rule_name: Some(self.name().to_string()),
770 line: start_line,
771 column: start_col,
772 end_line,
773 end_column: end_col,
774 message: "Use fenced code blocks".to_string(),
775 severity: Severity::Warning,
776 fix: None,
777 });
778 }
779 }
780 }
781
782 warnings.sort_by_key(|w| (w.line, w.column));
784
785 Ok(warnings)
786 }
787
788 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
789 let content = ctx.content;
790 if content.is_empty() {
791 return Ok(String::new());
792 }
793
794 let lines = ctx.raw_lines();
795
796 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
798
799 let in_jsx_context: Vec<bool> = (0..lines.len())
801 .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block))
802 .collect();
803
804 let in_list_context = self.precompute_block_continuation_context(lines);
806 let in_tab_context = if is_mkdocs {
807 self.precompute_mkdocs_tab_context(lines)
808 } else {
809 vec![false; lines.len()]
810 };
811 let in_admonition_context = if is_mkdocs {
812 self.precompute_mkdocs_admonition_context(lines)
813 } else {
814 vec![false; lines.len()]
815 };
816
817 let target_style = match self.config.style {
818 CodeBlockStyle::Consistent => {
819 let ictx = IndentContext {
820 in_list_context: &in_list_context,
821 in_tab_context: &in_tab_context,
822 in_admonition_context: &in_admonition_context,
823 in_jsx_context: &in_jsx_context,
824 };
825 self.detect_style(lines, is_mkdocs, &ictx)
826 .unwrap_or(CodeBlockStyle::Fenced)
827 }
828 _ => self.config.style,
829 };
830
831 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(
835 lines,
836 is_mkdocs,
837 &in_list_context,
838 &in_tab_context,
839 &in_admonition_context,
840 &in_jsx_context,
841 );
842
843 let ictx = IndentContext {
844 in_list_context: &in_list_context,
845 in_tab_context: &in_tab_context,
846 in_admonition_context: &in_admonition_context,
847 in_jsx_context: &in_jsx_context,
848 };
849
850 let mut result = String::with_capacity(content.len());
851 let mut in_fenced_block = false;
852 let mut fenced_fence_type = None;
853 let mut in_indented_block = false;
854
855 let mut current_block_disabled = false;
857
858 for (i, line) in lines.iter().enumerate() {
859 let line_num = i + 1;
860 let trimmed = line.trim_start();
861
862 if !in_fenced_block
865 && Self::has_valid_fence_indent(line)
866 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
867 {
868 current_block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
870 in_fenced_block = true;
871 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
872
873 if current_block_disabled {
874 result.push_str(line);
876 result.push('\n');
877 } else if target_style == CodeBlockStyle::Indented {
878 in_indented_block = true;
880 } else {
881 result.push_str(line);
883 result.push('\n');
884 }
885 } else if in_fenced_block && fenced_fence_type.is_some() {
886 let fence = fenced_fence_type.unwrap();
887 if trimmed.starts_with(fence) {
888 in_fenced_block = false;
889 fenced_fence_type = None;
890 in_indented_block = false;
891
892 if current_block_disabled {
893 result.push_str(line);
894 result.push('\n');
895 } else if target_style == CodeBlockStyle::Indented {
896 } else {
898 result.push_str(line);
900 result.push('\n');
901 }
902 current_block_disabled = false;
903 } else if current_block_disabled {
904 result.push_str(line);
906 result.push('\n');
907 } else if target_style == CodeBlockStyle::Indented {
908 result.push_str(" ");
912 result.push_str(line);
913 result.push('\n');
914 } else {
915 result.push_str(line);
917 result.push('\n');
918 }
919 } else if self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) {
920 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
924 result.push_str(line);
925 result.push('\n');
926 continue;
927 }
928
929 let prev_line_is_indented =
931 i > 0 && self.is_indented_code_block_with_context(lines, i - 1, is_mkdocs, &ictx);
932
933 if target_style == CodeBlockStyle::Fenced {
934 let trimmed_content = line.trim_start();
935
936 if misplaced_fence_lines[i] {
939 result.push_str(trimmed_content);
941 result.push('\n');
942 } else if unsafe_fence_lines[i] {
943 result.push_str(line);
946 result.push('\n');
947 } else if !prev_line_is_indented && !in_indented_block {
948 result.push_str("```\n");
950 result.push_str(trimmed_content);
951 result.push('\n');
952 in_indented_block = true;
953 } else {
954 result.push_str(trimmed_content);
956 result.push('\n');
957 }
958
959 let next_line_is_indented =
961 i < lines.len() - 1 && self.is_indented_code_block_with_context(lines, i + 1, is_mkdocs, &ictx);
962 if !next_line_is_indented
964 && in_indented_block
965 && !misplaced_fence_lines[i]
966 && !unsafe_fence_lines[i]
967 {
968 result.push_str("```\n");
969 in_indented_block = false;
970 }
971 } else {
972 result.push_str(line);
974 result.push('\n');
975 }
976 } else {
977 if in_indented_block && target_style == CodeBlockStyle::Fenced {
979 result.push_str("```\n");
980 in_indented_block = false;
981 }
982
983 result.push_str(line);
984 result.push('\n');
985 }
986 }
987
988 if in_indented_block && target_style == CodeBlockStyle::Fenced {
990 result.push_str("```\n");
991 }
992
993 if let Some(fence_type) = fenced_fence_type
995 && in_fenced_block
996 {
997 result.push_str(fence_type);
998 result.push('\n');
999 }
1000
1001 if !content.ends_with('\n') && result.ends_with('\n') {
1003 result.pop();
1004 }
1005
1006 Ok(result)
1007 }
1008
1009 fn category(&self) -> RuleCategory {
1011 RuleCategory::CodeBlock
1012 }
1013
1014 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1016 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1019 }
1020
1021 fn as_any(&self) -> &dyn std::any::Any {
1022 self
1023 }
1024
1025 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1026 let json_value = serde_json::to_value(&self.config).ok()?;
1027 Some((
1028 self.name().to_string(),
1029 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1030 ))
1031 }
1032
1033 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1034 where
1035 Self: Sized,
1036 {
1037 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1038 Box::new(Self::from_config_struct(rule_config))
1039 }
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044 use super::*;
1045 use crate::lint_context::LintContext;
1046
1047 fn detect_style_from_content(
1049 rule: &MD046CodeBlockStyle,
1050 content: &str,
1051 is_mkdocs: bool,
1052 in_jsx_context: &[bool],
1053 ) -> Option<CodeBlockStyle> {
1054 let lines: Vec<&str> = content.lines().collect();
1055 let in_list_context = rule.precompute_block_continuation_context(&lines);
1056 let in_tab_context = if is_mkdocs {
1057 rule.precompute_mkdocs_tab_context(&lines)
1058 } else {
1059 vec![false; lines.len()]
1060 };
1061 let in_admonition_context = if is_mkdocs {
1062 rule.precompute_mkdocs_admonition_context(&lines)
1063 } else {
1064 vec![false; lines.len()]
1065 };
1066 let ictx = IndentContext {
1067 in_list_context: &in_list_context,
1068 in_tab_context: &in_tab_context,
1069 in_admonition_context: &in_admonition_context,
1070 in_jsx_context,
1071 };
1072 rule.detect_style(&lines, is_mkdocs, &ictx)
1073 }
1074
1075 #[test]
1076 fn test_fenced_code_block_detection() {
1077 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1078 assert!(rule.is_fenced_code_block_start("```"));
1079 assert!(rule.is_fenced_code_block_start("```rust"));
1080 assert!(rule.is_fenced_code_block_start("~~~"));
1081 assert!(rule.is_fenced_code_block_start("~~~python"));
1082 assert!(rule.is_fenced_code_block_start(" ```"));
1083 assert!(!rule.is_fenced_code_block_start("``"));
1084 assert!(!rule.is_fenced_code_block_start("~~"));
1085 assert!(!rule.is_fenced_code_block_start("Regular text"));
1086 }
1087
1088 #[test]
1089 fn test_consistent_style_with_fenced_blocks() {
1090 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1091 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093 let result = rule.check(&ctx).unwrap();
1094
1095 assert_eq!(result.len(), 0);
1097 }
1098
1099 #[test]
1100 fn test_consistent_style_with_indented_blocks() {
1101 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1102 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104 let result = rule.check(&ctx).unwrap();
1105
1106 assert_eq!(result.len(), 0);
1108 }
1109
1110 #[test]
1111 fn test_consistent_style_mixed() {
1112 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1113 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1115 let result = rule.check(&ctx).unwrap();
1116
1117 assert!(!result.is_empty());
1119 }
1120
1121 #[test]
1122 fn test_fenced_style_with_indented_blocks() {
1123 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1124 let content = "Text\n\n indented code\n more code\n\nMore text";
1125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1126 let result = rule.check(&ctx).unwrap();
1127
1128 assert!(!result.is_empty());
1130 assert!(result[0].message.contains("Use fenced code blocks"));
1131 }
1132
1133 #[test]
1134 fn test_fenced_style_with_tab_indented_blocks() {
1135 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1136 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1137 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1138 let result = rule.check(&ctx).unwrap();
1139
1140 assert!(!result.is_empty());
1142 assert!(result[0].message.contains("Use fenced code blocks"));
1143 }
1144
1145 #[test]
1146 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1147 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1148 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151 let result = rule.check(&ctx).unwrap();
1152
1153 assert!(
1155 !result.is_empty(),
1156 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1157 );
1158 assert!(result[0].message.contains("Use fenced code blocks"));
1159 }
1160
1161 #[test]
1162 fn test_fenced_style_with_one_space_tab_indent() {
1163 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1164 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 let result = rule.check(&ctx).unwrap();
1168
1169 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1170 assert!(result[0].message.contains("Use fenced code blocks"));
1171 }
1172
1173 #[test]
1174 fn test_indented_style_with_fenced_blocks() {
1175 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1176 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1178 let result = rule.check(&ctx).unwrap();
1179
1180 assert!(!result.is_empty());
1182 assert!(result[0].message.contains("Use indented code blocks"));
1183 }
1184
1185 #[test]
1186 fn test_unclosed_code_block() {
1187 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1188 let content = "```\ncode without closing fence";
1189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1190 let result = rule.check(&ctx).unwrap();
1191
1192 assert_eq!(result.len(), 1);
1193 assert!(result[0].message.contains("never closed"));
1194 }
1195
1196 #[test]
1197 fn test_nested_code_blocks() {
1198 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1199 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1201 let result = rule.check(&ctx).unwrap();
1202
1203 assert_eq!(result.len(), 0);
1205 }
1206
1207 #[test]
1208 fn test_fix_indented_to_fenced() {
1209 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1210 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1212 let fixed = rule.fix(&ctx).unwrap();
1213
1214 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1215 }
1216
1217 #[test]
1218 fn test_fix_fenced_to_indented() {
1219 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1220 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222 let fixed = rule.fix(&ctx).unwrap();
1223
1224 assert!(fixed.contains(" code line 1\n code line 2"));
1225 assert!(!fixed.contains("```"));
1226 }
1227
1228 #[test]
1229 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1230 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1233 let content = r#"# Test
1234
1235```html
1236<!doctype html>
1237<html>
1238 <head>
1239 <title>Test</title>
1240 </head>
1241</html>
1242```
1243"#;
1244 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245 let fixed = rule.fix(&ctx).unwrap();
1246
1247 assert!(
1250 fixed.contains(" <head>"),
1251 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1252 );
1253 assert!(
1254 fixed.contains(" <title>"),
1255 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1256 );
1257 assert!(!fixed.contains("```"), "Fenced markers should be removed");
1258 }
1259
1260 #[test]
1261 fn test_fix_fenced_to_indented_preserves_python_indentation() {
1262 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1264 let content = r#"# Python Example
1265
1266```python
1267def greet(name):
1268 if name:
1269 print(f"Hello, {name}!")
1270 else:
1271 print("Hello, World!")
1272```
1273"#;
1274 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1275 let fixed = rule.fix(&ctx).unwrap();
1276
1277 assert!(
1279 fixed.contains(" def greet(name):"),
1280 "Function def should have 4 spaces (code block indent)"
1281 );
1282 assert!(
1283 fixed.contains(" if name:"),
1284 "if statement should have 8 spaces (4 code + 4 Python)"
1285 );
1286 assert!(
1287 fixed.contains(" print"),
1288 "print should have 12 spaces (4 code + 8 Python)"
1289 );
1290 }
1291
1292 #[test]
1293 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1294 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1296 let content = r#"# Config
1297
1298```yaml
1299server:
1300 host: localhost
1301 port: 8080
1302 ssl:
1303 enabled: true
1304 cert: /path/to/cert
1305```
1306"#;
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308 let fixed = rule.fix(&ctx).unwrap();
1309
1310 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
1311 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
1312 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
1313 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
1314 }
1315
1316 #[test]
1317 fn test_fix_fenced_to_indented_preserves_empty_lines() {
1318 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1320 let content = "```\nline1\n\nline2\n```\n";
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322 let fixed = rule.fix(&ctx).unwrap();
1323
1324 assert!(fixed.contains(" line1"), "line1 should be indented");
1326 assert!(fixed.contains(" line2"), "line2 should be indented");
1327 }
1329
1330 #[test]
1331 fn test_fix_fenced_to_indented_multiple_blocks() {
1332 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1334 let content = r#"# Doc
1335
1336```python
1337def foo():
1338 pass
1339```
1340
1341Text between.
1342
1343```yaml
1344key:
1345 value: 1
1346```
1347"#;
1348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349 let fixed = rule.fix(&ctx).unwrap();
1350
1351 assert!(fixed.contains(" def foo():"), "Python def should be indented");
1352 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
1353 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
1354 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
1355 assert!(!fixed.contains("```"), "No fence markers should remain");
1356 }
1357
1358 #[test]
1359 fn test_fix_unclosed_block() {
1360 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1361 let content = "```\ncode without closing";
1362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1363 let fixed = rule.fix(&ctx).unwrap();
1364
1365 assert!(fixed.ends_with("```"));
1367 }
1368
1369 #[test]
1370 fn test_code_block_in_list() {
1371 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1372 let content = "- List item\n code in list\n more code\n- Next item";
1373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1374 let result = rule.check(&ctx).unwrap();
1375
1376 assert_eq!(result.len(), 0);
1378 }
1379
1380 #[test]
1381 fn test_detect_style_fenced() {
1382 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1383 let content = "```\ncode\n```";
1384 let style = detect_style_from_content(&rule, content, false, &[]);
1385
1386 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1387 }
1388
1389 #[test]
1390 fn test_detect_style_indented() {
1391 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1392 let content = "Text\n\n code\n\nMore";
1393 let style = detect_style_from_content(&rule, content, false, &[]);
1394
1395 assert_eq!(style, Some(CodeBlockStyle::Indented));
1396 }
1397
1398 #[test]
1399 fn test_detect_style_none() {
1400 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1401 let content = "No code blocks here";
1402 let style = detect_style_from_content(&rule, content, false, &[]);
1403
1404 assert_eq!(style, None);
1405 }
1406
1407 #[test]
1408 fn test_tilde_fence() {
1409 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1410 let content = "~~~\ncode\n~~~";
1411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1412 let result = rule.check(&ctx).unwrap();
1413
1414 assert_eq!(result.len(), 0);
1416 }
1417
1418 #[test]
1419 fn test_language_specification() {
1420 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1421 let content = "```rust\nfn main() {}\n```";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423 let result = rule.check(&ctx).unwrap();
1424
1425 assert_eq!(result.len(), 0);
1426 }
1427
1428 #[test]
1429 fn test_empty_content() {
1430 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1431 let content = "";
1432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1433 let result = rule.check(&ctx).unwrap();
1434
1435 assert_eq!(result.len(), 0);
1436 }
1437
1438 #[test]
1439 fn test_default_config() {
1440 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1441 let (name, _config) = rule.default_config_section().unwrap();
1442 assert_eq!(name, "MD046");
1443 }
1444
1445 #[test]
1446 fn test_markdown_documentation_block() {
1447 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1448 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1450 let result = rule.check(&ctx).unwrap();
1451
1452 assert_eq!(result.len(), 0);
1454 }
1455
1456 #[test]
1457 fn test_preserve_trailing_newline() {
1458 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1459 let content = "```\ncode\n```\n";
1460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1461 let fixed = rule.fix(&ctx).unwrap();
1462
1463 assert_eq!(fixed, content);
1464 }
1465
1466 #[test]
1467 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1468 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1469 let content = r#"# Document
1470
1471=== "Python"
1472
1473 This is tab content
1474 Not an indented code block
1475
1476 ```python
1477 def hello():
1478 print("Hello")
1479 ```
1480
1481=== "JavaScript"
1482
1483 More tab content here
1484 Also not an indented code block"#;
1485
1486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1487 let result = rule.check(&ctx).unwrap();
1488
1489 assert_eq!(result.len(), 0);
1491 }
1492
1493 #[test]
1494 fn test_mkdocs_tabs_with_actual_indented_code() {
1495 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1496 let content = r#"# Document
1497
1498=== "Tab 1"
1499
1500 This is tab content
1501
1502Regular text
1503
1504 This is an actual indented code block
1505 Should be flagged"#;
1506
1507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1508 let result = rule.check(&ctx).unwrap();
1509
1510 assert_eq!(result.len(), 1);
1512 assert!(result[0].message.contains("Use fenced code blocks"));
1513 }
1514
1515 #[test]
1516 fn test_mkdocs_tabs_detect_style() {
1517 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1518 let content = r#"=== "Tab 1"
1519
1520 Content in tab
1521 More content
1522
1523=== "Tab 2"
1524
1525 Content in second tab"#;
1526
1527 let style = detect_style_from_content(&rule, content, true, &[]);
1529 assert_eq!(style, None); let style = detect_style_from_content(&rule, content, false, &[]);
1533 assert_eq!(style, Some(CodeBlockStyle::Indented));
1534 }
1535
1536 #[test]
1537 fn test_mkdocs_nested_tabs() {
1538 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1539 let content = r#"# Document
1540
1541=== "Outer Tab"
1542
1543 Some content
1544
1545 === "Nested Tab"
1546
1547 Nested tab content
1548 Should not be flagged"#;
1549
1550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1551 let result = rule.check(&ctx).unwrap();
1552
1553 assert_eq!(result.len(), 0);
1555 }
1556
1557 #[test]
1558 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1559 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1562 let content = r#"# Document
1563
1564!!! note
1565 This is normal admonition content, not a code block.
1566 It spans multiple lines.
1567
1568??? warning "Collapsible Warning"
1569 This is also admonition content.
1570
1571???+ tip "Expanded Tip"
1572 And this one too.
1573
1574Regular text outside admonitions."#;
1575
1576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1577 let result = rule.check(&ctx).unwrap();
1578
1579 assert_eq!(
1581 result.len(),
1582 0,
1583 "Admonition content in MkDocs mode should not trigger MD046"
1584 );
1585 }
1586
1587 #[test]
1588 fn test_mkdocs_admonition_with_actual_indented_code() {
1589 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1591 let content = r#"# Document
1592
1593!!! note
1594 This is admonition content.
1595
1596Regular text ends the admonition.
1597
1598 This is actual indented code (should be flagged)"#;
1599
1600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1601 let result = rule.check(&ctx).unwrap();
1602
1603 assert_eq!(result.len(), 1);
1605 assert!(result[0].message.contains("Use fenced code blocks"));
1606 }
1607
1608 #[test]
1609 fn test_admonition_in_standard_mode_flagged() {
1610 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1614 let content = r#"# Document
1615
1616!!! note
1617
1618 This looks like code in standard mode.
1619
1620Regular text."#;
1621
1622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1624 let result = rule.check(&ctx).unwrap();
1625
1626 assert_eq!(
1628 result.len(),
1629 1,
1630 "Admonition content in Standard mode should be flagged as indented code"
1631 );
1632 }
1633
1634 #[test]
1635 fn test_mkdocs_admonition_with_fenced_code_inside() {
1636 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1638 let content = r#"# Document
1639
1640!!! note "Code Example"
1641 Here's some code:
1642
1643 ```python
1644 def hello():
1645 print("world")
1646 ```
1647
1648 More text after code.
1649
1650Regular text."#;
1651
1652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1653 let result = rule.check(&ctx).unwrap();
1654
1655 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1657 }
1658
1659 #[test]
1660 fn test_mkdocs_nested_admonitions() {
1661 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1663 let content = r#"# Document
1664
1665!!! note "Outer"
1666 Outer content.
1667
1668 !!! warning "Inner"
1669 Inner content.
1670 More inner content.
1671
1672 Back to outer.
1673
1674Regular text."#;
1675
1676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1677 let result = rule.check(&ctx).unwrap();
1678
1679 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1681 }
1682
1683 #[test]
1684 fn test_mkdocs_admonition_fix_does_not_wrap() {
1685 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1687 let content = r#"!!! note
1688 Content that should stay as admonition content.
1689 Not be wrapped in code fences.
1690"#;
1691
1692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1693 let fixed = rule.fix(&ctx).unwrap();
1694
1695 assert!(
1697 !fixed.contains("```\n Content"),
1698 "Admonition content should not be wrapped in fences"
1699 );
1700 assert_eq!(fixed, content, "Content should remain unchanged");
1701 }
1702
1703 #[test]
1704 fn test_mkdocs_empty_admonition() {
1705 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1707 let content = r#"!!! note
1708
1709Regular paragraph after empty admonition.
1710
1711 This IS an indented code block (after blank + non-indented line)."#;
1712
1713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1714 let result = rule.check(&ctx).unwrap();
1715
1716 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1718 }
1719
1720 #[test]
1721 fn test_mkdocs_indented_admonition() {
1722 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1724 let content = r#"- List item
1725
1726 !!! note
1727 Indented admonition content.
1728 More content.
1729
1730- Next item"#;
1731
1732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1733 let result = rule.check(&ctx).unwrap();
1734
1735 assert_eq!(
1737 result.len(),
1738 0,
1739 "Indented admonitions (e.g., in lists) should not be flagged"
1740 );
1741 }
1742
1743 #[test]
1744 fn test_footnote_indented_paragraphs_not_flagged() {
1745 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1746 let content = r#"# Test Document with Footnotes
1747
1748This is some text with a footnote[^1].
1749
1750Here's some code:
1751
1752```bash
1753echo "fenced code block"
1754```
1755
1756More text with another footnote[^2].
1757
1758[^1]: Really interesting footnote text.
1759
1760 Even more interesting second paragraph.
1761
1762[^2]: Another footnote.
1763
1764 With a second paragraph too.
1765
1766 And even a third paragraph!"#;
1767
1768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1769 let result = rule.check(&ctx).unwrap();
1770
1771 assert_eq!(result.len(), 0);
1773 }
1774
1775 #[test]
1776 fn test_footnote_definition_detection() {
1777 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1778
1779 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1782 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1783 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1784 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1785 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1786 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1787 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1788 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1789 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1790
1791 assert!(!rule.is_footnote_definition("[^]: No label"));
1793 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1794 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1795 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1796
1797 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1799 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1800 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1801 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1804
1805 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1807 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1808 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1809 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1810 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1811
1812 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1815 }
1816
1817 #[test]
1818 fn test_footnote_with_blank_lines() {
1819 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1823 let content = r#"# Document
1824
1825Text with footnote[^1].
1826
1827[^1]: First paragraph.
1828
1829 Second paragraph after blank line.
1830
1831 Third paragraph after another blank line.
1832
1833Regular text at column 0 ends the footnote."#;
1834
1835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1836 let result = rule.check(&ctx).unwrap();
1837
1838 assert_eq!(
1840 result.len(),
1841 0,
1842 "Indented content within footnotes should not trigger MD046"
1843 );
1844 }
1845
1846 #[test]
1847 fn test_footnote_multiple_consecutive_blank_lines() {
1848 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1851 let content = r#"Text[^1].
1852
1853[^1]: First paragraph.
1854
1855
1856
1857 Content after three blank lines (still part of footnote).
1858
1859Not indented, so footnote ends here."#;
1860
1861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1862 let result = rule.check(&ctx).unwrap();
1863
1864 assert_eq!(
1866 result.len(),
1867 0,
1868 "Multiple blank lines shouldn't break footnote continuation"
1869 );
1870 }
1871
1872 #[test]
1873 fn test_footnote_terminated_by_non_indented_content() {
1874 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1877 let content = r#"[^1]: Footnote content.
1878
1879 More indented content in footnote.
1880
1881This paragraph is not indented, so footnote ends.
1882
1883 This should be flagged as indented code block."#;
1884
1885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1886 let result = rule.check(&ctx).unwrap();
1887
1888 assert_eq!(
1890 result.len(),
1891 1,
1892 "Indented code after footnote termination should be flagged"
1893 );
1894 assert!(
1895 result[0].message.contains("Use fenced code blocks"),
1896 "Expected MD046 warning for indented code block"
1897 );
1898 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1899 }
1900
1901 #[test]
1902 fn test_footnote_terminated_by_structural_elements() {
1903 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1905 let content = r#"[^1]: Footnote content.
1906
1907 More content.
1908
1909## Heading terminates footnote
1910
1911 This indented content should be flagged.
1912
1913---
1914
1915 This should also be flagged (after horizontal rule)."#;
1916
1917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1918 let result = rule.check(&ctx).unwrap();
1919
1920 assert_eq!(
1922 result.len(),
1923 2,
1924 "Both indented blocks after termination should be flagged"
1925 );
1926 }
1927
1928 #[test]
1929 fn test_footnote_with_code_block_inside() {
1930 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1933 let content = r#"Text[^1].
1934
1935[^1]: Footnote with code:
1936
1937 ```python
1938 def hello():
1939 print("world")
1940 ```
1941
1942 More footnote text after code."#;
1943
1944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1945 let result = rule.check(&ctx).unwrap();
1946
1947 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1949 }
1950
1951 #[test]
1952 fn test_footnote_with_8_space_indented_code() {
1953 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1956 let content = r#"Text[^1].
1957
1958[^1]: Footnote with nested code.
1959
1960 code block
1961 more code"#;
1962
1963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1964 let result = rule.check(&ctx).unwrap();
1965
1966 assert_eq!(
1968 result.len(),
1969 0,
1970 "8-space indented code within footnotes represents nested code blocks"
1971 );
1972 }
1973
1974 #[test]
1975 fn test_multiple_footnotes() {
1976 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1979 let content = r#"Text[^1] and more[^2].
1980
1981[^1]: First footnote.
1982
1983 Continuation of first.
1984
1985[^2]: Second footnote starts here, ending the first.
1986
1987 Continuation of second."#;
1988
1989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1990 let result = rule.check(&ctx).unwrap();
1991
1992 assert_eq!(
1994 result.len(),
1995 0,
1996 "Multiple footnotes should each maintain their continuation context"
1997 );
1998 }
1999
2000 #[test]
2001 fn test_list_item_ends_footnote_context() {
2002 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2004 let content = r#"[^1]: Footnote.
2005
2006 Content in footnote.
2007
2008- List item starts here (ends footnote context).
2009
2010 This indented content is part of the list, not the footnote."#;
2011
2012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2013 let result = rule.check(&ctx).unwrap();
2014
2015 assert_eq!(
2017 result.len(),
2018 0,
2019 "List items should end footnote context and start their own"
2020 );
2021 }
2022
2023 #[test]
2024 fn test_footnote_vs_actual_indented_code() {
2025 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2028 let content = r#"# Heading
2029
2030Text with footnote[^1].
2031
2032[^1]: Footnote content.
2033
2034 Part of footnote (should not be flagged).
2035
2036Regular paragraph ends footnote context.
2037
2038 This is actual indented code (MUST be flagged)
2039 Should be detected as code block"#;
2040
2041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2042 let result = rule.check(&ctx).unwrap();
2043
2044 assert_eq!(
2046 result.len(),
2047 1,
2048 "Must still detect indented code blocks outside footnotes"
2049 );
2050 assert!(
2051 result[0].message.contains("Use fenced code blocks"),
2052 "Expected MD046 warning for indented code"
2053 );
2054 assert!(
2055 result[0].line >= 11,
2056 "Warning should be on the actual indented code line"
2057 );
2058 }
2059
2060 #[test]
2061 fn test_spec_compliant_label_characters() {
2062 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2065
2066 assert!(rule.is_footnote_definition("[^test]: text"));
2068 assert!(rule.is_footnote_definition("[^TEST]: text"));
2069 assert!(rule.is_footnote_definition("[^test-name]: text"));
2070 assert!(rule.is_footnote_definition("[^test_name]: text"));
2071 assert!(rule.is_footnote_definition("[^test123]: text"));
2072 assert!(rule.is_footnote_definition("[^123]: text"));
2073 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2074
2075 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")); }
2083
2084 #[test]
2085 fn test_code_block_inside_html_comment() {
2086 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2089 let content = r#"# Document
2090
2091Some text.
2092
2093<!--
2094Example code block in comment:
2095
2096```typescript
2097console.log("Hello");
2098```
2099
2100More comment text.
2101-->
2102
2103More content."#;
2104
2105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2106 let result = rule.check(&ctx).unwrap();
2107
2108 assert_eq!(
2109 result.len(),
2110 0,
2111 "Code blocks inside HTML comments should not be flagged as unclosed"
2112 );
2113 }
2114
2115 #[test]
2116 fn test_unclosed_fence_inside_html_comment() {
2117 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2119 let content = r#"# Document
2120
2121<!--
2122Example with intentionally unclosed fence:
2123
2124```
2125code without closing
2126-->
2127
2128More content."#;
2129
2130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2131 let result = rule.check(&ctx).unwrap();
2132
2133 assert_eq!(
2134 result.len(),
2135 0,
2136 "Unclosed fences inside HTML comments should be ignored"
2137 );
2138 }
2139
2140 #[test]
2141 fn test_multiline_html_comment_with_indented_code() {
2142 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2144 let content = r#"# Document
2145
2146<!--
2147Example:
2148
2149 indented code
2150 more code
2151
2152End of comment.
2153-->
2154
2155Regular text."#;
2156
2157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2158 let result = rule.check(&ctx).unwrap();
2159
2160 assert_eq!(
2161 result.len(),
2162 0,
2163 "Indented code inside HTML comments should not be flagged"
2164 );
2165 }
2166
2167 #[test]
2168 fn test_code_block_after_html_comment() {
2169 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2171 let content = r#"# Document
2172
2173<!-- comment -->
2174
2175Text before.
2176
2177 indented code should be flagged
2178
2179More text."#;
2180
2181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2182 let result = rule.check(&ctx).unwrap();
2183
2184 assert_eq!(
2185 result.len(),
2186 1,
2187 "Code blocks after HTML comments should still be detected"
2188 );
2189 assert!(result[0].message.contains("Use fenced code blocks"));
2190 }
2191
2192 #[test]
2193 fn test_four_space_indented_fence_is_not_valid_fence() {
2194 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2197
2198 assert!(rule.is_fenced_code_block_start("```"));
2200 assert!(rule.is_fenced_code_block_start(" ```"));
2201 assert!(rule.is_fenced_code_block_start(" ```"));
2202 assert!(rule.is_fenced_code_block_start(" ```"));
2203
2204 assert!(!rule.is_fenced_code_block_start(" ```"));
2206 assert!(!rule.is_fenced_code_block_start(" ```"));
2207 assert!(!rule.is_fenced_code_block_start(" ```"));
2208
2209 assert!(!rule.is_fenced_code_block_start("\t```"));
2211 }
2212
2213 #[test]
2214 fn test_issue_237_indented_fenced_block_detected_as_indented() {
2215 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2221
2222 let content = r#"## Test
2224
2225 ```js
2226 var foo = "hello";
2227 ```
2228"#;
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 1,
2237 "4-space indented fence should be detected as indented code block"
2238 );
2239 assert!(
2240 result[0].message.contains("Use fenced code blocks"),
2241 "Expected 'Use fenced code blocks' message"
2242 );
2243 }
2244
2245 #[test]
2246 fn test_issue_276_indented_code_in_list() {
2247 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2250
2251 let content = r#"1. First item
22522. Second item with code:
2253
2254 # This is a code block in a list
2255 print("Hello, world!")
2256
22574. Third item"#;
2258
2259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2260 let result = rule.check(&ctx).unwrap();
2261
2262 assert!(
2264 !result.is_empty(),
2265 "Indented code block inside list should be flagged when style=fenced"
2266 );
2267 assert!(
2268 result[0].message.contains("Use fenced code blocks"),
2269 "Expected 'Use fenced code blocks' message"
2270 );
2271 }
2272
2273 #[test]
2274 fn test_three_space_indented_fence_is_valid() {
2275 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2277
2278 let content = r#"## Test
2279
2280 ```js
2281 var foo = "hello";
2282 ```
2283"#;
2284
2285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2286 let result = rule.check(&ctx).unwrap();
2287
2288 assert_eq!(
2290 result.len(),
2291 0,
2292 "3-space indented fence should be recognized as valid fenced code block"
2293 );
2294 }
2295
2296 #[test]
2297 fn test_indented_style_with_deeply_indented_fenced() {
2298 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2301
2302 let content = r#"Text
2303
2304 ```js
2305 var foo = "hello";
2306 ```
2307
2308More text
2309"#;
2310
2311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2312 let result = rule.check(&ctx).unwrap();
2313
2314 assert_eq!(
2317 result.len(),
2318 0,
2319 "4-space indented content should be valid when style=indented"
2320 );
2321 }
2322
2323 #[test]
2324 fn test_fix_misplaced_fenced_block() {
2325 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2328
2329 let content = r#"## Test
2330
2331 ```js
2332 var foo = "hello";
2333 ```
2334"#;
2335
2336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2337 let fixed = rule.fix(&ctx).unwrap();
2338
2339 let expected = r#"## Test
2341
2342```js
2343var foo = "hello";
2344```
2345"#;
2346
2347 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2348 }
2349
2350 #[test]
2351 fn test_fix_regular_indented_block() {
2352 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2355
2356 let content = r#"Text
2357
2358 var foo = "hello";
2359 console.log(foo);
2360
2361More text
2362"#;
2363
2364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2365 let fixed = rule.fix(&ctx).unwrap();
2366
2367 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2369 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2370 }
2371
2372 #[test]
2373 fn test_fix_indented_block_with_fence_like_content() {
2374 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2378
2379 let content = r#"Text
2380
2381 some code
2382 ```not a fence opener
2383 more code
2384"#;
2385
2386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2387 let fixed = rule.fix(&ctx).unwrap();
2388
2389 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
2391 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2392 }
2393
2394 #[test]
2395 fn test_fix_mixed_indented_and_misplaced_blocks() {
2396 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2398
2399 let content = r#"Text
2400
2401 regular indented code
2402
2403More text
2404
2405 ```python
2406 print("hello")
2407 ```
2408"#;
2409
2410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2411 let fixed = rule.fix(&ctx).unwrap();
2412
2413 assert!(
2415 fixed.contains("```\nregular indented code\n```"),
2416 "First block should be wrapped in fences"
2417 );
2418
2419 assert!(
2421 fixed.contains("\n```python\nprint(\"hello\")\n```"),
2422 "Second block should be dedented, not double-wrapped"
2423 );
2424 assert!(
2426 !fixed.contains("```\n```python"),
2427 "Should not have nested fence openers"
2428 );
2429 }
2430}