1use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::{FlavorOverrideNotice, option_is_explicit};
3use crate::utils::calculate_indentation_width_default;
4use crate::utils::mdg;
5use crate::utils::mkdocs_admonitions;
6use crate::utils::mkdocs_tabs;
7use crate::utils::range_utils::calculate_line_range;
8use toml;
9
10mod md046_config;
11pub use md046_config::CodeBlockStyle;
12use md046_config::MD046Config;
13
14static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
16
17struct IndentContext<'a> {
19 in_list_context: &'a [bool],
20 in_tab_context: &'a [bool],
21 in_admonition_context: &'a [bool],
22 in_comment_or_html: &'a [bool],
32 list_item_baseline: &'a [Option<usize>],
42}
43
44struct OwnedIndentContext {
47 in_list_context: Vec<bool>,
48 in_tab_context: Vec<bool>,
49 in_admonition_context: Vec<bool>,
50 in_comment_or_html: Vec<bool>,
51 list_item_baseline: Vec<Option<usize>>,
52}
53
54impl OwnedIndentContext {
55 fn borrow(&self) -> IndentContext<'_> {
56 IndentContext {
57 in_list_context: &self.in_list_context,
58 in_tab_context: &self.in_tab_context,
59 in_admonition_context: &self.in_admonition_context,
60 in_comment_or_html: &self.in_comment_or_html,
61 list_item_baseline: &self.list_item_baseline,
62 }
63 }
64}
65
66#[derive(Clone)]
72pub struct MD046CodeBlockStyle {
73 config: MD046Config,
74 style_explicit: bool,
78}
79
80impl MD046CodeBlockStyle {
81 const FENCE: &'static str = "```";
83
84 pub fn new(style: CodeBlockStyle) -> Self {
85 Self {
86 config: MD046Config { style },
87 style_explicit: true,
88 }
89 }
90
91 pub fn from_config_struct(config: MD046Config) -> Self {
92 Self {
93 config,
94 style_explicit: false,
95 }
96 }
97
98 fn has_valid_fence_indent(line: &str) -> bool {
103 calculate_indentation_width_default(line) < 4
104 }
105
106 fn has_valid_fence_indent_at(line: &str, baseline: usize) -> bool {
110 let indent = calculate_indentation_width_default(line);
111 indent >= baseline && indent - baseline < 4
112 }
113
114 fn is_fenced_code_block_start(&self, line: &str) -> bool {
123 if !Self::has_valid_fence_indent(line) {
124 return false;
125 }
126
127 let trimmed = line.trim_start();
128 trimmed.starts_with("```") || trimmed.starts_with("~~~")
129 }
130
131 fn is_fenced_code_block_start_at(&self, line: &str, baseline: usize) -> bool {
132 if baseline == 0 {
133 return self.is_fenced_code_block_start(line);
134 }
135
136 Self::has_valid_fence_indent_at(line, baseline)
137 && (line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~"))
138 }
139
140 fn is_closing_fence(line: &str, fence_char: char, opener_len: usize, baseline: usize) -> bool {
141 if !Self::has_valid_fence_indent_at(line, baseline) {
142 return false;
143 }
144
145 let trimmed = line.trim_start();
146 let closer_len = trimmed.chars().take_while(|&ch| ch == fence_char).count();
147 closer_len >= opener_len && closer_len > 0 && trimmed[closer_len..].trim().is_empty()
148 }
149
150 fn strip_indentation_columns(line: &str, columns: usize) -> String {
155 if columns == 0 {
156 return line.to_string();
157 }
158
159 let mut width = 0usize;
160 let mut consumed = 0usize;
161
162 for (byte_index, ch) in line.char_indices() {
163 let next_width = match ch {
164 ' ' => width + 1,
165 '\t' => ((width / 4) + 1) * 4,
166 _ => break,
167 };
168 consumed = byte_index + ch.len_utf8();
169
170 if next_width >= columns {
171 let remainder = next_width - columns;
172 let mut stripped = String::with_capacity(remainder + line.len() - consumed);
173 stripped.extend(std::iter::repeat_n(' ', remainder));
174 stripped.push_str(&line[consumed..]);
175 return stripped;
176 }
177
178 width = next_width;
179 }
180
181 line[consumed..].to_string()
182 }
183
184 fn is_list_item(&self, line: &str) -> bool {
185 let trimmed = line.trim_start();
186 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
187 return true;
188 }
189 let after_digits = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
194 after_digits.len() < trimmed.len() && (after_digits.starts_with(". ") || after_digits.starts_with(") "))
195 }
196
197 fn is_footnote_definition(&self, line: &str) -> bool {
217 let trimmed = line.trim_start();
218 if !trimmed.starts_with("[^") || trimmed.len() < 5 {
219 return false;
220 }
221
222 if let Some(close_bracket_pos) = trimmed.find("]:")
223 && close_bracket_pos > 2
224 {
225 let label = &trimmed[2..close_bracket_pos];
226
227 if label.trim().is_empty() {
228 return false;
229 }
230
231 if label.contains('\r') {
233 return false;
234 }
235
236 if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
238 return true;
239 }
240 }
241
242 false
243 }
244
245 fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
268 let mut in_continuation_context = vec![false; lines.len()];
269 let mut last_list_item_line: Option<usize> = None;
270 let mut last_footnote_line: Option<usize> = None;
271 let mut blank_line_count = 0;
272
273 for (i, line) in lines.iter().enumerate() {
274 let trimmed = line.trim_start();
275 let indent_len = line.len() - trimmed.len();
276
277 if self.is_list_item(line) {
279 last_list_item_line = Some(i);
280 last_footnote_line = None; blank_line_count = 0;
282 in_continuation_context[i] = true;
283 continue;
284 }
285
286 if self.is_footnote_definition(line) {
288 last_footnote_line = Some(i);
289 last_list_item_line = None; blank_line_count = 0;
291 in_continuation_context[i] = true;
292 continue;
293 }
294
295 if line.trim().is_empty() {
297 if last_list_item_line.is_some() || last_footnote_line.is_some() {
299 blank_line_count += 1;
300 in_continuation_context[i] = true;
301
302 }
306 continue;
307 }
308
309 if indent_len == 0 && !trimmed.is_empty() {
311 if trimmed.starts_with('#') {
315 last_list_item_line = None;
316 last_footnote_line = None;
317 blank_line_count = 0;
318 continue;
319 }
320
321 if trimmed.starts_with("---") || trimmed.starts_with("***") {
323 last_list_item_line = None;
324 last_footnote_line = None;
325 blank_line_count = 0;
326 continue;
327 }
328
329 if let Some(list_line) = last_list_item_line
332 && (i - list_line > 5 || blank_line_count > 1)
333 {
334 last_list_item_line = None;
335 }
336
337 if last_footnote_line.is_some() {
339 last_footnote_line = None;
340 }
341
342 blank_line_count = 0;
343
344 if last_list_item_line.is_none() && last_footnote_line.is_some() {
346 last_footnote_line = None;
347 }
348 continue;
349 }
350
351 if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
353 in_continuation_context[i] = true;
354 blank_line_count = 0;
355 }
356 }
357
358 in_continuation_context
359 }
360
361 fn precompute_list_item_baseline(
372 &self,
373 ctx: &crate::lint_context::LintContext,
374 lines: &[&str],
375 ) -> Vec<Option<usize>> {
376 let mut baselines = vec![None; lines.len()];
377 let mut last_baseline: Option<usize> = None;
378 let mut last_list_item_line: Option<usize> = None;
379 let mut blank_line_count = 0usize;
380
381 for (i, line) in lines.iter().enumerate() {
382 let trimmed = line.trim_start();
383 let indent_len = line.len() - trimmed.len();
384
385 if let Some(item) = ctx.line_info(i + 1).and_then(|li| li.list_item.as_ref()) {
387 last_baseline = Some(item.content_column);
388 last_list_item_line = Some(i);
389 blank_line_count = 0;
390 baselines[i] = last_baseline;
391 continue;
392 }
393
394 if line.trim().is_empty() {
396 if last_baseline.is_some() {
397 blank_line_count += 1;
398 baselines[i] = last_baseline;
399 }
400 continue;
401 }
402
403 if indent_len == 0 {
407 if trimmed.starts_with('#') || trimmed.starts_with("---") || trimmed.starts_with("***") {
408 last_baseline = None;
409 last_list_item_line = None;
410 } else if let Some(list_line) = last_list_item_line
411 && (i - list_line > 5 || blank_line_count > 1)
412 {
413 last_baseline = None;
414 last_list_item_line = None;
415 }
416 blank_line_count = 0;
417 continue;
418 }
419
420 if last_baseline.is_some() {
422 baselines[i] = last_baseline;
423 blank_line_count = 0;
424 }
425 }
426
427 baselines
428 }
429
430 fn is_indented_code_block_with_context(
434 &self,
435 lines: &[&str],
436 i: usize,
437 is_mkdocs: bool,
438 ctx: &IndentContext,
439 prev_is_code: bool,
440 ) -> bool {
441 if i >= lines.len() {
442 return false;
443 }
444
445 let line = lines[i];
446
447 if line.trim().is_empty() {
452 return false;
453 }
454
455 let indent = calculate_indentation_width_default(line);
457 if indent < 4 {
458 return false;
459 }
460
461 if ctx.in_list_context[i] {
467 let crosses_baseline = ctx
468 .list_item_baseline
469 .get(i)
470 .copied()
471 .flatten()
472 .is_some_and(|base| indent >= base + 4);
473 if !crosses_baseline {
474 return false;
475 }
476 }
477
478 if is_mkdocs && ctx.in_tab_context[i] {
480 return false;
481 }
482
483 if is_mkdocs && ctx.in_admonition_context[i] {
486 return false;
487 }
488
489 if ctx.in_comment_or_html.get(i).copied().unwrap_or(false) {
495 return false;
496 }
497
498 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
506 has_blank_line_before || prev_is_code
507 }
508
509 fn first_code_block_line(
518 ctx: &crate::lint_context::LintContext,
519 block_lines: &[bool],
520 start: usize,
521 block_end: usize,
522 ) -> Option<usize> {
523 (start..block_lines.len())
524 .take_while(|&idx| ctx.line_offsets.get(idx).is_some_and(|&offset| offset < block_end))
525 .find(|&idx| block_lines[idx])
526 }
527
528 fn indented_block_lines(
544 &self,
545 lines: &[&str],
546 is_mkdocs: bool,
547 ictx: &IndentContext<'_>,
548 ctx: &crate::lint_context::LintContext,
549 ) -> Vec<bool> {
550 let mut member = vec![false; lines.len()];
551 for i in 0..lines.len() {
552 let prev_is_code = i > 0 && member[i - 1];
553 member[i] = self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx, prev_is_code);
554 }
555
556 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
563 let mut i = 0;
564 while i < member.len() {
565 if !member[i] {
566 i += 1;
567 continue;
568 }
569 let start = i;
570 while i < member.len() && member[i] {
571 i += 1;
572 }
573 if lines[start..i].iter().all(|line| mdg::is_table_row(line)) {
574 member[start..i].fill(false);
575 }
576 }
577 }
578
579 let mut i = 0;
580 while i < lines.len() {
581 if !member[i] {
582 i += 1;
583 continue;
584 }
585 let mut next = i + 1;
586 while next < lines.len() && lines[next].trim().is_empty() {
587 next += 1;
588 }
589 if next < lines.len() && member[next] {
590 member[i + 1..next].fill(true);
591 }
592 i = next;
593 }
594
595 member
596 }
597
598 fn precompute_comment_or_html_context(ctx: &crate::lint_context::LintContext, line_count: usize) -> Vec<bool> {
607 (0..line_count)
608 .map(|i| {
609 ctx.line_info(i + 1).is_some_and(|info| {
610 info.in_html_comment
611 || info.in_mdx_comment
612 || info.in_html_block
613 || info.in_jsx_block
614 || info.in_mkdocstrings
615 || info.in_footnote_definition
616 || info.blockquote.is_some()
617 || info.in_front_matter
618 })
619 })
620 .collect()
621 }
622
623 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
625 let mut in_tab_context = vec![false; lines.len()];
626 let mut current_tab_indent: Option<usize> = None;
627
628 for (i, line) in lines.iter().enumerate() {
629 if mkdocs_tabs::is_tab_marker(line) {
631 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
632 current_tab_indent = Some(tab_indent);
633 in_tab_context[i] = true;
634 continue;
635 }
636
637 if let Some(tab_indent) = current_tab_indent {
639 if mkdocs_tabs::is_tab_content(line, tab_indent) {
640 in_tab_context[i] = true;
641 } else if !line.trim().is_empty() && calculate_indentation_width_default(line) < 4 {
642 current_tab_indent = None;
644 } else {
645 in_tab_context[i] = true;
647 }
648 }
649 }
650
651 in_tab_context
652 }
653
654 fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
663 let mut in_admonition_context = vec![false; lines.len()];
664 let mut admonition_stack: Vec<usize> = Vec::new();
666
667 for (i, line) in lines.iter().enumerate() {
668 let line_indent = calculate_indentation_width_default(line);
669
670 if mkdocs_admonitions::is_admonition_start(line) {
672 let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
673
674 while let Some(&top_indent) = admonition_stack.last() {
676 if adm_indent <= top_indent {
678 admonition_stack.pop();
679 } else {
680 break;
681 }
682 }
683
684 admonition_stack.push(adm_indent);
686 in_admonition_context[i] = true;
687 continue;
688 }
689
690 if line.trim().is_empty() {
692 if !admonition_stack.is_empty() {
693 in_admonition_context[i] = true;
694 }
695 continue;
696 }
697
698 while let Some(&top_indent) = admonition_stack.last() {
701 if line_indent >= top_indent + 4 {
703 break;
705 } else {
706 admonition_stack.pop();
708 }
709 }
710
711 if !admonition_stack.is_empty() {
713 in_admonition_context[i] = true;
714 }
715 }
716
717 in_admonition_context
718 }
719
720 fn build_indent_context(
732 &self,
733 ctx: &crate::lint_context::LintContext,
734 lines: &[&str],
735 is_mkdocs: bool,
736 ) -> OwnedIndentContext {
737 OwnedIndentContext {
738 in_list_context: self.precompute_block_continuation_context(lines),
739 in_tab_context: if is_mkdocs {
740 self.precompute_mkdocs_tab_context(lines)
741 } else {
742 vec![false; lines.len()]
743 },
744 in_admonition_context: if is_mkdocs {
745 self.precompute_mkdocs_admonition_context(lines)
746 } else {
747 vec![false; lines.len()]
748 },
749 in_comment_or_html: Self::precompute_comment_or_html_context(ctx, lines.len()),
750 list_item_baseline: self.precompute_list_item_baseline(ctx, lines),
751 }
752 }
753
754 fn categorize_indented_blocks(&self, lines: &[&str], block_lines: &[bool]) -> (Vec<bool>, Vec<bool>) {
766 let mut is_misplaced = vec![false; lines.len()];
767 let mut contains_fences = vec![false; lines.len()];
768
769 let mut i = 0;
771 while i < lines.len() {
772 if !block_lines[i] {
774 i += 1;
775 continue;
776 }
777
778 let block_start = i;
780 let mut block_end = i;
781
782 while block_end < lines.len() && block_lines[block_end] {
783 block_end += 1;
784 }
785
786 if block_end > block_start {
788 let first_line = lines[block_start].trim_start();
789 let last_line = lines[block_end - 1].trim_start();
790
791 let is_backtick_fence = first_line.starts_with("```");
793 let is_tilde_fence = first_line.starts_with("~~~");
794
795 if is_backtick_fence || is_tilde_fence {
796 let fence_char = if is_backtick_fence { '`' } else { '~' };
797 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
798
799 let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
801 let after_closer = &last_line[closer_fence_len..];
802
803 if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
804 is_misplaced[block_start..block_end].fill(true);
806 } else {
807 contains_fences[block_start..block_end].fill(true);
809 }
810 } else {
811 let has_fence_markers = (block_start..block_end).any(|j| {
814 let trimmed = lines[j].trim_start();
815 trimmed.starts_with("```") || trimmed.starts_with("~~~")
816 });
817
818 if has_fence_markers {
819 contains_fences[block_start..block_end].fill(true);
820 }
821 }
822 }
823
824 i = block_end;
825 }
826
827 (is_misplaced, contains_fences)
828 }
829
830 fn check_unclosed_code_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
831 let mut warnings = Vec::new();
832 let lines = ctx.raw_lines();
833
834 let has_markdown_doc_block = ctx.code_block_details.iter().any(|d| {
836 if !d.is_fenced {
837 return false;
838 }
839 let lang = d.info_string.to_lowercase();
840 lang.starts_with("markdown") || lang.starts_with("md")
841 });
842
843 if has_markdown_doc_block {
846 return warnings;
847 }
848
849 for detail in &ctx.code_block_details {
850 if !detail.is_fenced {
851 continue;
852 }
853
854 if detail.end != ctx.content.len() {
856 continue;
857 }
858
859 let opening_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
861 Ok(idx) => idx,
862 Err(idx) => idx.saturating_sub(1),
863 };
864
865 let line = lines.get(opening_line_idx).unwrap_or(&"");
867 let trimmed = line.trim();
868 let fence_marker = if let Some(pos) = trimmed.find("```") {
869 let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
870 "`".repeat(count)
871 } else if let Some(pos) = trimmed.find("~~~") {
872 let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
873 "~".repeat(count)
874 } else {
875 "```".to_string()
876 };
877
878 let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
880 let last_trimmed = last_non_empty_line.trim();
881 let fence_char = fence_marker.chars().next().unwrap_or('`');
882
883 let has_closing_fence = if fence_char == '`' {
884 last_trimmed.starts_with("```") && {
885 let fence_len = last_trimmed.chars().take_while(|&c| c == '`').count();
886 last_trimmed[fence_len..].trim().is_empty()
887 }
888 } else {
889 last_trimmed.starts_with("~~~") && {
890 let fence_len = last_trimmed.chars().take_while(|&c| c == '~').count();
891 last_trimmed[fence_len..].trim().is_empty()
892 }
893 };
894
895 if !has_closing_fence {
896 if ctx
898 .lines
899 .get(opening_line_idx)
900 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
901 {
902 continue;
903 }
904
905 let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
906
907 warnings.push(LintWarning {
908 rule_name: Some(self.name().to_string()),
909 line: start_line,
910 column: start_col,
911 end_line,
912 end_column: end_col,
913 message: format!("Code block opened with '{fence_marker}' but never closed"),
914 severity: Severity::Warning,
915 fix: Some(Fix::new(
916 ctx.content.len()..ctx.content.len(),
917 format!("\n{fence_marker}"),
918 )),
919 });
920 }
921 }
922
923 warnings
924 }
925
926 fn effective_target_style(
934 &self,
935 ctx: &crate::lint_context::LintContext,
936 detect: impl FnOnce() -> CodeBlockStyle,
937 ) -> CodeBlockStyle {
938 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
939 self.warn_once_about_overridden_style();
940 return CodeBlockStyle::Fenced;
941 }
942
943 match self.config.style {
944 CodeBlockStyle::Consistent => {
945 let detected = detect();
946 if detected == CodeBlockStyle::Indented
947 && ctx.code_block_details.iter().any(|detail| {
948 detail.is_fenced
949 && !detail.info_string.trim().is_empty()
950 && Self::code_block_is_style_eligible(ctx, detail)
951 })
952 {
953 CodeBlockStyle::Fenced
957 } else {
958 detected
959 }
960 }
961 style => style,
962 }
963 }
964
965 fn code_block_is_style_eligible(
969 ctx: &crate::lint_context::LintContext,
970 detail: &crate::utils::code_block_utils::CodeBlockDetail,
971 ) -> bool {
972 let Some(line_idx) = Self::code_block_start_line(ctx, detail) else {
973 return false;
974 };
975
976 !ctx.lines.get(line_idx).is_some_and(|info| {
977 info.in_html_comment
978 || info.in_mdx_comment
979 || info.in_html_block
980 || info.in_jsx_block
981 || info.in_mkdocstrings
982 || info.in_footnote_definition
983 || info.blockquote.is_some()
984 || info.in_front_matter
985 })
986 }
987
988 fn code_block_start_line(
989 ctx: &crate::lint_context::LintContext,
990 detail: &crate::utils::code_block_utils::CodeBlockDetail,
991 ) -> Option<usize> {
992 if detail.start >= ctx.content.len() {
993 return None;
994 }
995
996 Some(match ctx.line_offsets.binary_search(&detail.start) {
997 Ok(idx) => idx,
998 Err(idx) => idx.saturating_sub(1),
999 })
1000 }
1001
1002 fn fenced_separator_lines(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
1006 let mut lines = std::collections::HashSet::new();
1007
1008 for pair in ctx.code_block_details.windows(2) {
1009 let [previous, next] = pair else {
1010 continue;
1011 };
1012 if previous.end > next.start || next.start > ctx.content.len() {
1013 continue;
1014 }
1015 if !ctx.content[previous.end..next.start].trim().is_empty() {
1016 continue;
1017 }
1018
1019 for detail in [previous, next] {
1020 if detail.is_fenced
1021 && let Some(line) = Self::code_block_start_line(ctx, detail)
1022 {
1023 lines.insert(line);
1024 }
1025 }
1026 }
1027
1028 lines
1029 }
1030
1031 fn fenced_boundary_blank_lines(
1036 ctx: &crate::lint_context::LintContext,
1037 lines: &[&str],
1038 ictx: &IndentContext,
1039 ) -> std::collections::HashSet<usize> {
1040 let mut boundary_blank_lines = std::collections::HashSet::new();
1041
1042 for detail in ctx.code_block_details.iter().filter(|detail| detail.is_fenced) {
1043 let Some(start) = Self::code_block_start_line(ctx, detail) else {
1044 continue;
1045 };
1046 let Some(opener) = lines.get(start) else {
1047 continue;
1048 };
1049 let baseline = ictx.list_item_baseline.get(start).copied().flatten().unwrap_or(0);
1050 let trimmed = opener.trim_start();
1051 if !Self::has_valid_fence_indent_at(opener, baseline) {
1052 continue;
1053 }
1054 let fence_char = if trimmed.starts_with("```") {
1055 '`'
1056 } else if trimmed.starts_with("~~~") {
1057 '~'
1058 } else {
1059 continue;
1062 };
1063 let opener_len = trimmed.chars().take_while(|&ch| ch == fence_char).count();
1064
1065 let mut block_end = start + 1;
1066 let mut closer = None;
1067 while block_end < lines.len()
1068 && ctx
1069 .line_offsets
1070 .get(block_end)
1071 .is_some_and(|&offset| offset < detail.end)
1072 {
1073 if Self::is_closing_fence(lines[block_end], fence_char, opener_len, baseline) {
1074 closer = Some(block_end);
1075 break;
1076 }
1077 block_end += 1;
1078 }
1079
1080 let payload_end = closer.unwrap_or(block_end);
1081 if start + 1 == payload_end
1082 || (start + 1 < payload_end
1083 && (lines[start + 1].trim().is_empty() || lines[payload_end - 1].trim().is_empty()))
1084 {
1085 boundary_blank_lines.insert(start);
1086 }
1087 }
1088
1089 boundary_blank_lines
1090 }
1091
1092 fn warn_once_about_overridden_style(&self) {
1098 if !self.style_explicit || self.config.style != CodeBlockStyle::Indented {
1099 return;
1100 }
1101
1102 MDG_STYLE_OVERRIDE.report(
1103 "MD046",
1104 "style",
1105 "indented",
1106 "fenced",
1107 "a Gherkin Doc String is only ever a backtick fence",
1108 );
1109 }
1110
1111 fn detect_style(
1112 &self,
1113 ctx: &crate::lint_context::LintContext,
1114 lines: &[&str],
1115 is_mkdocs: bool,
1116 ictx: &IndentContext,
1117 ) -> Option<CodeBlockStyle> {
1118 if lines.is_empty() {
1119 return None;
1120 }
1121
1122 let block_lines = self.indented_block_lines(lines, is_mkdocs, ictx, ctx);
1123
1124 let mut fenced_count = 0;
1125 let mut indented_count = 0;
1126
1127 let mut in_fenced = false;
1137 let mut prev_was_indented = false;
1138
1139 for (i, line) in lines.iter().enumerate() {
1140 let in_container = ictx.in_comment_or_html.get(i).copied().unwrap_or(false);
1141
1142 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|l| l.in_code_block) {
1146 prev_was_indented = false;
1147 continue;
1148 }
1149
1150 if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|l| l.in_myst_directive) {
1152 prev_was_indented = false;
1153 continue;
1154 }
1155
1156 let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1157 if self.is_fenced_code_block_start_at(line, baseline) {
1158 if in_container {
1159 prev_was_indented = false;
1162 continue;
1163 }
1164 if !in_fenced {
1165 fenced_count += 1;
1167 in_fenced = true;
1168 } else {
1169 in_fenced = false;
1171 }
1172 prev_was_indented = false;
1173 } else if !in_fenced && block_lines[i] {
1174 if !prev_was_indented {
1176 indented_count += 1;
1177 }
1178 prev_was_indented = true;
1179 } else {
1180 prev_was_indented = false;
1181 }
1182 }
1183
1184 if fenced_count == 0 && indented_count == 0 {
1185 None
1186 } else if fenced_count > 0 && indented_count == 0 {
1187 Some(CodeBlockStyle::Fenced)
1188 } else if fenced_count == 0 && indented_count > 0 {
1189 Some(CodeBlockStyle::Indented)
1190 } else if fenced_count >= indented_count {
1191 Some(CodeBlockStyle::Fenced)
1192 } else {
1193 Some(CodeBlockStyle::Indented)
1194 }
1195 }
1196}
1197
1198impl Rule for MD046CodeBlockStyle {
1199 fn name(&self) -> &'static str {
1200 "MD046"
1201 }
1202
1203 fn description(&self) -> &'static str {
1204 "Code blocks should use a consistent style"
1205 }
1206
1207 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1208 if ctx.content.is_empty() {
1210 return Ok(Vec::new());
1211 }
1212
1213 if !ctx.content.contains("```")
1215 && !ctx.content.contains("~~~")
1216 && !ctx.content.contains(" ")
1217 && !ctx.content.contains('\t')
1218 {
1219 return Ok(Vec::new());
1220 }
1221
1222 let unclosed_warnings = self.check_unclosed_code_blocks(ctx);
1224
1225 if !unclosed_warnings.is_empty() {
1227 return Ok(unclosed_warnings);
1228 }
1229
1230 let lines = ctx.raw_lines();
1232 let mut warnings = Vec::new();
1233
1234 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1235
1236 let target_style = self.effective_target_style(ctx, || {
1238 let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1239 let detected = self.detect_style(ctx, lines, is_mkdocs, &owned.borrow());
1240 detected.unwrap_or(CodeBlockStyle::Fenced)
1241 });
1242
1243 let mdg_block_lines = (ctx.flavor == crate::config::MarkdownFlavor::MDG
1248 && ctx.code_block_details.iter().any(|detail| !detail.is_fenced))
1249 .then(|| {
1250 let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1251 self.indented_block_lines(lines, is_mkdocs, &owned.borrow(), ctx)
1252 });
1253
1254 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
1256
1257 for detail in &ctx.code_block_details {
1258 if detail.start >= ctx.content.len() || detail.end > ctx.content.len() {
1259 continue;
1260 }
1261
1262 let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
1263 Ok(idx) => idx,
1264 Err(idx) => idx.saturating_sub(1),
1265 };
1266
1267 if detail.is_fenced {
1268 if target_style == CodeBlockStyle::Indented {
1269 let line = lines.get(start_line_idx).unwrap_or(&"");
1270
1271 if ctx
1272 .lines
1273 .get(start_line_idx)
1274 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
1275 {
1276 continue;
1277 }
1278
1279 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1280 warnings.push(LintWarning {
1281 rule_name: Some(self.name().to_string()),
1282 line: start_line,
1283 column: start_col,
1284 end_line,
1285 end_column: end_col,
1286 message: "Use indented code blocks".to_string(),
1287 severity: Severity::Warning,
1288 fix: None,
1289 });
1290 }
1291 } else {
1292 if target_style == CodeBlockStyle::Fenced {
1294 let start_line_idx = match &mdg_block_lines {
1299 Some(block_lines) => {
1300 match Self::first_code_block_line(ctx, block_lines, start_line_idx, detail.end) {
1301 Some(idx) => idx,
1302 None => continue,
1303 }
1304 }
1305 None => start_line_idx,
1306 };
1307
1308 if reported_indented_lines.contains(&start_line_idx) {
1309 continue;
1310 }
1311
1312 let line = lines.get(start_line_idx).unwrap_or(&"");
1313
1314 if ctx.lines.get(start_line_idx).is_some_and(|info| {
1316 info.in_html_comment
1317 || info.in_mdx_comment
1318 || info.in_html_block
1319 || info.in_jsx_block
1320 || info.in_mkdocstrings
1321 || info.in_footnote_definition
1322 || info.blockquote.is_some()
1323 || info.in_front_matter
1324 }) {
1325 continue;
1326 }
1327
1328 if is_mkdocs
1330 && ctx
1331 .lines
1332 .get(start_line_idx)
1333 .is_some_and(|info| info.in_admonition || info.in_content_tab)
1334 {
1335 continue;
1336 }
1337
1338 reported_indented_lines.insert(start_line_idx);
1339
1340 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1341 warnings.push(LintWarning {
1342 rule_name: Some(self.name().to_string()),
1343 line: start_line,
1344 column: start_col,
1345 end_line,
1346 end_column: end_col,
1347 message: "Use fenced code blocks".to_string(),
1348 severity: Severity::Warning,
1349 fix: None,
1350 });
1351 }
1352 }
1353 }
1354
1355 warnings.sort_by_key(|w| (w.line, w.column));
1357
1358 Ok(warnings)
1359 }
1360
1361 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1362 let content = ctx.content;
1363 if content.is_empty() {
1364 return Ok(String::new());
1365 }
1366
1367 let lines = ctx.raw_lines();
1368
1369 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1371
1372 let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1373 let ictx = owned.borrow();
1374
1375 let target_style = self.effective_target_style(ctx, || {
1380 self.detect_style(ctx, lines, is_mkdocs, &ictx)
1381 .unwrap_or(CodeBlockStyle::Fenced)
1382 });
1383
1384 let block_lines = self.indented_block_lines(lines, is_mkdocs, &ictx, ctx);
1385 let fenced_separator_lines = if target_style == CodeBlockStyle::Indented {
1386 Self::fenced_separator_lines(ctx)
1387 } else {
1388 std::collections::HashSet::new()
1389 };
1390 let fenced_boundary_blank_lines = if target_style == CodeBlockStyle::Indented {
1391 Self::fenced_boundary_blank_lines(ctx, lines, &ictx)
1392 } else {
1393 std::collections::HashSet::new()
1394 };
1395 let fenced_start_lines: std::collections::HashSet<usize> = ctx
1399 .code_block_details
1400 .iter()
1401 .filter(|detail| detail.is_fenced)
1402 .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1403 .collect();
1404 let has_unsupported_fence_opener = ctx
1405 .code_block_details
1406 .iter()
1407 .filter(|detail| detail.is_fenced && Self::code_block_is_style_eligible(ctx, detail))
1408 .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1409 .any(|line_index| {
1410 let Some(line) = lines.get(line_index) else {
1411 return true;
1412 };
1413 let baseline = ictx.list_item_baseline.get(line_index).copied().flatten().unwrap_or(0);
1414 !self.is_fenced_code_block_start_at(line, baseline)
1415 });
1416
1417 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(lines, &block_lines);
1421
1422 let mut result = String::with_capacity(content.len());
1423 let mut in_fenced_block = false;
1424 let mut fenced_fence_opener: Option<(char, usize)> = None;
1428 let mut in_indented_block = false;
1429 let mut current_block_fence_indent = String::new();
1434
1435 let mut current_block_must_stay_fenced = false;
1439 let mut current_fence_indent = 0usize;
1440 let mut current_fence_baseline = 0usize;
1441 let mut current_block_indented_prefix = String::from(" ");
1442 let mut converted_fenced_to_indented = false;
1443 let mut retained_structurally_unsafe_fence =
1444 target_style == CodeBlockStyle::Indented && has_unsupported_fence_opener;
1445
1446 for (i, line) in lines.iter().enumerate() {
1447 let line_num = i + 1;
1448 let trimmed = line.trim_start();
1449 let list_baseline = ictx.list_item_baseline.get(i).copied().flatten();
1450 let fence_baseline = list_baseline.unwrap_or(0);
1451
1452 if !in_fenced_block
1455 && fenced_start_lines.contains(&i)
1456 && Self::has_valid_fence_indent_at(line, fence_baseline)
1457 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1458 {
1459 let block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
1461 in_fenced_block = true;
1462 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
1463 let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
1464 fenced_fence_opener = Some((fence_char, opener_len));
1465 current_fence_indent = calculate_indentation_width_default(line);
1466 current_fence_baseline = fence_baseline;
1467 current_block_indented_prefix = " ".repeat(fence_baseline + 4);
1468 let follows_list_item = i
1469 .checked_sub(1)
1470 .and_then(|previous| ictx.list_item_baseline.get(previous))
1471 .copied()
1472 .flatten()
1473 .is_some();
1474 let would_become_list_prose = target_style == CodeBlockStyle::Indented
1475 && list_baseline.is_none()
1476 && (ictx.in_list_context.get(i).copied().unwrap_or(false) || follows_list_item);
1477 let would_interrupt_paragraph = target_style == CodeBlockStyle::Indented
1478 && i > 0
1479 && !lines[i - 1].trim().is_empty()
1480 && ctx
1481 .lines
1482 .get(i - 1)
1483 .is_some_and(crate::lint_context::LineInfo::is_paragraph_context)
1484 && crate::lint_context::is_paragraph_text_line(lines[i - 1]);
1485 let would_merge_code_blocks = fenced_separator_lines.contains(&i);
1486 let would_lose_boundary_blanks = fenced_boundary_blank_lines.contains(&i);
1487 current_block_must_stay_fenced = block_disabled
1488 || !trimmed[opener_len..].trim().is_empty()
1489 || would_become_list_prose
1490 || would_interrupt_paragraph
1491 || would_merge_code_blocks
1492 || would_lose_boundary_blanks;
1493 retained_structurally_unsafe_fence |= would_become_list_prose
1494 || would_interrupt_paragraph
1495 || would_merge_code_blocks
1496 || would_lose_boundary_blanks;
1497
1498 if current_block_must_stay_fenced {
1499 result.push_str(line);
1502 result.push('\n');
1503 } else if target_style == CodeBlockStyle::Indented {
1504 in_indented_block = true;
1506 converted_fenced_to_indented = true;
1507 } else {
1508 result.push_str(line);
1510 result.push('\n');
1511 }
1512 } else if in_fenced_block && fenced_fence_opener.is_some() {
1513 let (fence_char, opener_len) = fenced_fence_opener.unwrap();
1514 let is_closer = Self::is_closing_fence(line, fence_char, opener_len, current_fence_baseline);
1517 if is_closer {
1518 in_fenced_block = false;
1519 fenced_fence_opener = None;
1520 in_indented_block = false;
1521
1522 if current_block_must_stay_fenced {
1523 result.push_str(line);
1524 result.push('\n');
1525 } else if target_style == CodeBlockStyle::Indented {
1526 } else {
1528 result.push_str(line);
1530 result.push('\n');
1531 }
1532 current_block_must_stay_fenced = false;
1533 current_fence_indent = 0;
1534 current_fence_baseline = 0;
1535 current_block_indented_prefix.clear();
1536 } else if current_block_must_stay_fenced {
1537 result.push_str(line);
1539 result.push('\n');
1540 } else if target_style == CodeBlockStyle::Indented {
1541 if !line.is_empty() {
1548 let body = Self::strip_indentation_columns(line, current_fence_indent);
1553 result.push_str(¤t_block_indented_prefix);
1554 result.push_str(&body);
1555 }
1556 result.push('\n');
1557 } else {
1558 result.push_str(line);
1560 result.push('\n');
1561 }
1562 } else if block_lines[i] {
1563 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
1567 result.push_str(line);
1568 result.push('\n');
1569 continue;
1570 }
1571
1572 let prev_line_is_indented = i > 0 && block_lines[i - 1];
1574
1575 if target_style == CodeBlockStyle::Fenced {
1576 let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1582 let body = if line.trim().is_empty() {
1590 String::new()
1591 } else {
1592 Self::strip_indentation_columns(line, 4)
1593 };
1594
1595 if misplaced_fence_lines[i] {
1598 result.push_str(line.trim_start());
1600 result.push('\n');
1601 } else if unsafe_fence_lines[i] {
1602 result.push_str(line);
1605 result.push('\n');
1606 } else if !prev_line_is_indented && !in_indented_block {
1607 current_block_fence_indent = " ".repeat(baseline);
1609 result.push_str(¤t_block_fence_indent);
1610 result.push_str(Self::FENCE);
1611 result.push('\n');
1612 result.push_str(&body);
1613 result.push('\n');
1614 in_indented_block = true;
1615 } else {
1616 result.push_str(&body);
1618 result.push('\n');
1619 }
1620
1621 let next_line_is_indented = i < lines.len() - 1 && block_lines[i + 1];
1623 if !next_line_is_indented
1625 && in_indented_block
1626 && !misplaced_fence_lines[i]
1627 && !unsafe_fence_lines[i]
1628 {
1629 result.push_str(¤t_block_fence_indent);
1630 result.push_str(Self::FENCE);
1631 result.push('\n');
1632 in_indented_block = false;
1633 current_block_fence_indent.clear();
1634 }
1635 } else {
1636 result.push_str(line);
1638 result.push('\n');
1639 }
1640 } else {
1641 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1643 result.push_str(¤t_block_fence_indent);
1644 result.push_str(Self::FENCE);
1645 result.push('\n');
1646 in_indented_block = false;
1647 current_block_fence_indent.clear();
1648 }
1649
1650 result.push_str(line);
1651 result.push('\n');
1652 }
1653 }
1654
1655 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1657 result.push_str(¤t_block_fence_indent);
1658 result.push_str(Self::FENCE);
1659 result.push('\n');
1660 }
1661
1662 if let Some((fence_char, opener_len)) = fenced_fence_opener
1668 && in_fenced_block
1669 {
1670 let has_unclosed_violation = !self.check_unclosed_code_blocks(ctx).is_empty();
1671 if has_unclosed_violation && (target_style != CodeBlockStyle::Indented || current_block_must_stay_fenced) {
1675 let closer: String = std::iter::repeat_n(fence_char, opener_len).collect();
1676 result.push_str(&closer);
1677 result.push('\n');
1678 }
1679 }
1680
1681 if !content.ends_with('\n') && result.ends_with('\n') {
1683 result.pop();
1684 }
1685
1686 if retained_structurally_unsafe_fence && self.config.style == CodeBlockStyle::Consistent {
1687 return Self::new(CodeBlockStyle::Fenced).fix(ctx);
1688 }
1689
1690 if converted_fenced_to_indented {
1691 let reparsed_block_count = crate::utils::CodeBlockUtils::detect_code_blocks(&result).len();
1692 if reparsed_block_count != ctx.code_block_details.len() {
1693 if self.config.style == CodeBlockStyle::Consistent {
1698 return Self::new(CodeBlockStyle::Fenced).fix(ctx);
1699 }
1700
1701 return Ok(content.to_string());
1702 }
1703 }
1704
1705 Ok(result)
1706 }
1707
1708 fn category(&self) -> RuleCategory {
1710 RuleCategory::CodeBlock
1711 }
1712
1713 fn fix_capability(&self) -> FixCapability {
1714 FixCapability::ConditionallyFixable
1717 }
1718
1719 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1721 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1724 }
1725
1726 fn as_any(&self) -> &dyn std::any::Any {
1727 self
1728 }
1729
1730 crate::impl_rule_config_sections!(MD046Config);
1731
1732 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1733 where
1734 Self: Sized,
1735 {
1736 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1737 let style_explicit = option_is_explicit(config, "MD046", "style");
1738
1739 Box::new(Self {
1740 config: rule_config,
1741 style_explicit,
1742 })
1743 }
1744}
1745
1746#[cfg(test)]
1747mod tests {
1748 use super::*;
1749 use crate::lint_context::LintContext;
1750
1751 fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
1763 let flavor = if is_mkdocs {
1764 crate::config::MarkdownFlavor::MkDocs
1765 } else {
1766 crate::config::MarkdownFlavor::Standard
1767 };
1768 let ctx = LintContext::new(content, flavor, None);
1769 let lines: Vec<&str> = content.lines().collect();
1770 let in_list_context = rule.precompute_block_continuation_context(&lines);
1771 let in_tab_context = if is_mkdocs {
1772 rule.precompute_mkdocs_tab_context(&lines)
1773 } else {
1774 vec![false; lines.len()]
1775 };
1776 let in_admonition_context = if is_mkdocs {
1777 rule.precompute_mkdocs_admonition_context(&lines)
1778 } else {
1779 vec![false; lines.len()]
1780 };
1781 let in_comment_or_html = vec![false; lines.len()];
1782 let list_item_baseline: Vec<Option<usize>> = vec![None; lines.len()];
1788 let ictx = IndentContext {
1789 in_list_context: &in_list_context,
1790 in_tab_context: &in_tab_context,
1791 in_admonition_context: &in_admonition_context,
1792 in_comment_or_html: &in_comment_or_html,
1793 list_item_baseline: &list_item_baseline,
1794 };
1795 rule.detect_style(&ctx, &lines, is_mkdocs, &ictx)
1796 }
1797
1798 #[test]
1799 fn test_fenced_code_block_detection() {
1800 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1801 assert!(rule.is_fenced_code_block_start("```"));
1802 assert!(rule.is_fenced_code_block_start("```rust"));
1803 assert!(rule.is_fenced_code_block_start("~~~"));
1804 assert!(rule.is_fenced_code_block_start("~~~python"));
1805 assert!(rule.is_fenced_code_block_start(" ```"));
1806 assert!(!rule.is_fenced_code_block_start("``"));
1807 assert!(!rule.is_fenced_code_block_start("~~"));
1808 assert!(!rule.is_fenced_code_block_start("Regular text"));
1809 }
1810
1811 #[test]
1812 fn test_fix_capability_is_conditional() {
1813 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1814 assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
1815 }
1816
1817 #[test]
1818 fn test_consistent_style_with_fenced_blocks() {
1819 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1820 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1821 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1822 let result = rule.check(&ctx).unwrap();
1823
1824 assert_eq!(result.len(), 0);
1826 }
1827
1828 #[test]
1829 fn test_consistent_style_with_indented_blocks() {
1830 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1831 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1833 let result = rule.check(&ctx).unwrap();
1834
1835 assert_eq!(result.len(), 0);
1837 }
1838
1839 #[test]
1840 fn test_consistent_style_mixed() {
1841 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1842 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1844 let result = rule.check(&ctx).unwrap();
1845
1846 assert!(!result.is_empty());
1848 }
1849
1850 #[test]
1851 fn test_fenced_style_with_indented_blocks() {
1852 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1853 let content = "Text\n\n indented code\n more code\n\nMore text";
1854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1855 let result = rule.check(&ctx).unwrap();
1856
1857 assert!(!result.is_empty());
1859 assert!(result[0].message.contains("Use fenced code blocks"));
1860 }
1861
1862 #[test]
1863 fn test_fenced_style_with_tab_indented_blocks() {
1864 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1865 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1867 let result = rule.check(&ctx).unwrap();
1868
1869 assert!(!result.is_empty());
1871 assert!(result[0].message.contains("Use fenced code blocks"));
1872 }
1873
1874 #[test]
1875 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1876 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1877 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880 let result = rule.check(&ctx).unwrap();
1881
1882 assert!(
1884 !result.is_empty(),
1885 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1886 );
1887 assert!(result[0].message.contains("Use fenced code blocks"));
1888 }
1889
1890 #[test]
1891 fn test_fenced_style_with_one_space_tab_indent() {
1892 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1893 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896 let result = rule.check(&ctx).unwrap();
1897
1898 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1899 assert!(result[0].message.contains("Use fenced code blocks"));
1900 }
1901
1902 #[test]
1903 fn test_indented_style_with_fenced_blocks() {
1904 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1905 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1907 let result = rule.check(&ctx).unwrap();
1908
1909 assert!(!result.is_empty());
1911 assert!(result[0].message.contains("Use indented code blocks"));
1912 }
1913
1914 #[test]
1915 fn test_unclosed_code_block() {
1916 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1917 let content = "```\ncode without closing fence";
1918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1919 let result = rule.check(&ctx).unwrap();
1920
1921 assert_eq!(result.len(), 1);
1922 assert!(result[0].message.contains("never closed"));
1923 }
1924
1925 #[test]
1926 fn test_nested_code_blocks() {
1927 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1928 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1930 let result = rule.check(&ctx).unwrap();
1931
1932 assert_eq!(result.len(), 0);
1934 }
1935
1936 #[test]
1937 fn test_fix_indented_to_fenced() {
1938 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1939 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1941 let fixed = rule.fix(&ctx).unwrap();
1942
1943 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1944 }
1945
1946 #[test]
1947 fn test_fix_fenced_to_indented() {
1948 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1949 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951 let fixed = rule.fix(&ctx).unwrap();
1952
1953 assert!(fixed.contains(" code line 1\n code line 2"));
1954 assert!(!fixed.contains("```"));
1955 }
1956
1957 #[test]
1958 fn test_fix_fenced_to_indented_blank_lines_have_no_trailing_spaces() {
1959 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1963 let content = "Text\n\n```\ncode line 1\n\ncode line 2\n```\n\nMore text";
1964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1965 let fixed = rule.fix(&ctx).unwrap();
1966
1967 for line in fixed.lines() {
1968 assert!(
1969 line.is_empty() || !line.trim_end().is_empty() || line == line.trim_end(),
1970 "no line may have trailing whitespace, got {line:?}"
1971 );
1972 assert_ne!(line, " ", "blank line was indented to trailing spaces");
1973 }
1974 assert!(fixed.contains(" code line 1\n\n code line 2"));
1976 }
1977
1978 #[test]
1979 fn test_is_list_item_requires_delimiter_after_digits() {
1980 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1981 assert!(rule.is_list_item("1. First"));
1983 assert!(rule.is_list_item("42) Item"));
1984 assert!(rule.is_list_item(" 3. Indented item"));
1985 assert!(rule.is_list_item("- bullet"));
1987 assert!(rule.is_list_item("* bullet"));
1988 assert!(!rule.is_list_item("2 results. More info."));
1991 assert!(!rule.is_list_item("3 options (a, b) here"));
1992 assert!(!rule.is_list_item("100 items in stock. Buy now"));
1993 }
1994
1995 #[test]
1996 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1997 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2000 let content = r#"# Test
2001
2002```
2003<!doctype html>
2004<html>
2005 <head>
2006 <title>Test</title>
2007 </head>
2008</html>
2009```
2010"#;
2011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2012 let fixed = rule.fix(&ctx).unwrap();
2013
2014 assert!(
2017 fixed.contains(" <head>"),
2018 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
2019 );
2020 assert!(
2021 fixed.contains(" <title>"),
2022 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
2023 );
2024 assert!(!fixed.contains("```"), "Fenced markers should be removed");
2025 }
2026
2027 #[test]
2028 fn test_fix_fenced_to_indented_preserves_python_indentation() {
2029 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2031 let content = r#"# Python Example
2032
2033```
2034def greet(name):
2035 if name:
2036 print(f"Hello, {name}!")
2037 else:
2038 print("Hello, World!")
2039```
2040"#;
2041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2042 let fixed = rule.fix(&ctx).unwrap();
2043
2044 assert!(
2046 fixed.contains(" def greet(name):"),
2047 "Function def should have 4 spaces (code block indent)"
2048 );
2049 assert!(
2050 fixed.contains(" if name:"),
2051 "if statement should have 8 spaces (4 code + 4 Python)"
2052 );
2053 assert!(
2054 fixed.contains(" print"),
2055 "print should have 12 spaces (4 code + 8 Python)"
2056 );
2057 }
2058
2059 #[test]
2060 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
2061 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2063 let content = r#"# Config
2064
2065```
2066server:
2067 host: localhost
2068 port: 8080
2069 ssl:
2070 enabled: true
2071 cert: /path/to/cert
2072```
2073"#;
2074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2075 let fixed = rule.fix(&ctx).unwrap();
2076
2077 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
2078 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
2079 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
2080 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
2081 }
2082
2083 #[test]
2084 fn test_fix_fenced_to_indented_preserves_empty_lines() {
2085 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2089 let content = "```\nline1\n\nline2\n```\n";
2090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2091 let fixed = rule.fix(&ctx).unwrap();
2092
2093 assert!(fixed.contains(" line1"), "line1 should be indented");
2095 assert!(fixed.contains(" line2"), "line2 should be indented");
2096 assert!(
2097 fixed.contains(" line1\n\n line2"),
2098 "blank line must stay empty, got {fixed:?}"
2099 );
2100 }
2101
2102 #[test]
2103 fn test_fix_fenced_to_indented_multiple_blocks() {
2104 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2106 let content = r#"# Doc
2107
2108```
2109def foo():
2110 pass
2111```
2112
2113Text between.
2114
2115```
2116key:
2117 value: 1
2118```
2119"#;
2120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2121 let fixed = rule.fix(&ctx).unwrap();
2122
2123 assert!(fixed.contains(" def foo():"), "Python def should be indented");
2124 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
2125 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
2126 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
2127 assert!(!fixed.contains("```"), "No fence markers should remain");
2128 }
2129
2130 #[test]
2131 fn test_fix_unclosed_block() {
2132 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2133 let content = "```\ncode without closing";
2134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2135 let fixed = rule.fix(&ctx).unwrap();
2136
2137 assert!(fixed.ends_with("```"));
2139 }
2140
2141 #[test]
2142 fn test_code_block_in_list() {
2143 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2144 let content = "- List item\n code in list\n more code\n- Next item";
2145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2146 let result = rule.check(&ctx).unwrap();
2147
2148 assert_eq!(result.len(), 0);
2150 }
2151
2152 #[test]
2153 fn test_detect_style_fenced() {
2154 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2155 let content = "```\ncode\n```";
2156 let style = detect_style_from_content(&rule, content, false);
2157
2158 assert_eq!(style, Some(CodeBlockStyle::Fenced));
2159 }
2160
2161 #[test]
2162 fn test_detect_style_indented() {
2163 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2164 let content = "Text\n\n code\n\nMore";
2165 let style = detect_style_from_content(&rule, content, false);
2166
2167 assert_eq!(style, Some(CodeBlockStyle::Indented));
2168 }
2169
2170 #[test]
2171 fn test_detect_style_none() {
2172 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2173 let content = "No code blocks here";
2174 let style = detect_style_from_content(&rule, content, false);
2175
2176 assert_eq!(style, None);
2177 }
2178
2179 #[test]
2180 fn test_tilde_fence() {
2181 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2182 let content = "~~~\ncode\n~~~";
2183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2184 let result = rule.check(&ctx).unwrap();
2185
2186 assert_eq!(result.len(), 0);
2188 }
2189
2190 #[test]
2191 fn test_language_specification() {
2192 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2193 let content = "```rust\nfn main() {}\n```";
2194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2195 let result = rule.check(&ctx).unwrap();
2196
2197 assert_eq!(result.len(), 0);
2198 }
2199
2200 #[test]
2201 fn test_empty_content() {
2202 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2203 let content = "";
2204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2205 let result = rule.check(&ctx).unwrap();
2206
2207 assert_eq!(result.len(), 0);
2208 }
2209
2210 #[test]
2211 fn test_default_config() {
2212 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2213 let (name, _config) = rule.default_config_section().unwrap();
2214 assert_eq!(name, "MD046");
2215 }
2216
2217 #[test]
2218 fn test_markdown_documentation_block() {
2219 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2220 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
2221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2222 let result = rule.check(&ctx).unwrap();
2223
2224 assert_eq!(result.len(), 0);
2226 }
2227
2228 #[test]
2229 fn test_preserve_trailing_newline() {
2230 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2231 let content = "```\ncode\n```\n";
2232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2233 let fixed = rule.fix(&ctx).unwrap();
2234
2235 assert_eq!(fixed, content);
2236 }
2237
2238 #[test]
2239 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
2240 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2241 let content = r#"# Document
2242
2243=== "Python"
2244
2245 This is tab content
2246 Not an indented code block
2247
2248 ```python
2249 def hello():
2250 print("Hello")
2251 ```
2252
2253=== "JavaScript"
2254
2255 More tab content here
2256 Also not an indented code block"#;
2257
2258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2259 let result = rule.check(&ctx).unwrap();
2260
2261 assert_eq!(result.len(), 0);
2263 }
2264
2265 #[test]
2266 fn test_mkdocs_tabs_with_actual_indented_code() {
2267 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2268 let content = r#"# Document
2269
2270=== "Tab 1"
2271
2272 This is tab content
2273
2274Regular text
2275
2276 This is an actual indented code block
2277 Should be flagged"#;
2278
2279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2280 let result = rule.check(&ctx).unwrap();
2281
2282 assert_eq!(result.len(), 1);
2284 assert!(result[0].message.contains("Use fenced code blocks"));
2285 }
2286
2287 #[test]
2288 fn test_mkdocs_tabs_detect_style() {
2289 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2290 let content = r#"=== "Tab 1"
2291
2292 Content in tab
2293 More content
2294
2295=== "Tab 2"
2296
2297 Content in second tab"#;
2298
2299 let style = detect_style_from_content(&rule, content, true);
2301 assert_eq!(style, None); let style = detect_style_from_content(&rule, content, false);
2305 assert_eq!(style, Some(CodeBlockStyle::Indented));
2306 }
2307
2308 #[test]
2309 fn test_mkdocs_nested_tabs() {
2310 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2311 let content = r#"# Document
2312
2313=== "Outer Tab"
2314
2315 Some content
2316
2317 === "Nested Tab"
2318
2319 Nested tab content
2320 Should not be flagged"#;
2321
2322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2323 let result = rule.check(&ctx).unwrap();
2324
2325 assert_eq!(result.len(), 0);
2327 }
2328
2329 #[test]
2330 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
2331 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2334 let content = r#"# Document
2335
2336!!! note
2337 This is normal admonition content, not a code block.
2338 It spans multiple lines.
2339
2340??? warning "Collapsible Warning"
2341 This is also admonition content.
2342
2343???+ tip "Expanded Tip"
2344 And this one too.
2345
2346Regular text outside admonitions."#;
2347
2348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2349 let result = rule.check(&ctx).unwrap();
2350
2351 assert_eq!(
2353 result.len(),
2354 0,
2355 "Admonition content in MkDocs mode should not trigger MD046"
2356 );
2357 }
2358
2359 #[test]
2360 fn test_mkdocs_admonition_with_actual_indented_code() {
2361 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2363 let content = r#"# Document
2364
2365!!! note
2366 This is admonition content.
2367
2368Regular text ends the admonition.
2369
2370 This is actual indented code (should be flagged)"#;
2371
2372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2373 let result = rule.check(&ctx).unwrap();
2374
2375 assert_eq!(result.len(), 1);
2377 assert!(result[0].message.contains("Use fenced code blocks"));
2378 }
2379
2380 #[test]
2381 fn test_admonition_in_standard_mode_flagged() {
2382 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2386 let content = r#"# Document
2387
2388!!! note
2389
2390 This looks like code in standard mode.
2391
2392Regular text."#;
2393
2394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2396 let result = rule.check(&ctx).unwrap();
2397
2398 assert_eq!(
2400 result.len(),
2401 1,
2402 "Admonition content in Standard mode should be flagged as indented code"
2403 );
2404 }
2405
2406 #[test]
2407 fn test_mkdocs_admonition_with_fenced_code_inside() {
2408 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2410 let content = r#"# Document
2411
2412!!! note "Code Example"
2413 Here's some code:
2414
2415 ```python
2416 def hello():
2417 print("world")
2418 ```
2419
2420 More text after code.
2421
2422Regular text."#;
2423
2424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2425 let result = rule.check(&ctx).unwrap();
2426
2427 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
2429 }
2430
2431 #[test]
2432 fn test_mkdocs_nested_admonitions() {
2433 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2435 let content = r#"# Document
2436
2437!!! note "Outer"
2438 Outer content.
2439
2440 !!! warning "Inner"
2441 Inner content.
2442 More inner content.
2443
2444 Back to outer.
2445
2446Regular text."#;
2447
2448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2449 let result = rule.check(&ctx).unwrap();
2450
2451 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
2453 }
2454
2455 #[test]
2456 fn test_mkdocs_admonition_fix_does_not_wrap() {
2457 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2459 let content = r#"!!! note
2460 Content that should stay as admonition content.
2461 Not be wrapped in code fences.
2462"#;
2463
2464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2465 let fixed = rule.fix(&ctx).unwrap();
2466
2467 assert!(
2469 !fixed.contains("```\n Content"),
2470 "Admonition content should not be wrapped in fences"
2471 );
2472 assert_eq!(fixed, content, "Content should remain unchanged");
2473 }
2474
2475 #[test]
2476 fn test_mkdocs_empty_admonition() {
2477 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2479 let content = r#"!!! note
2480
2481Regular paragraph after empty admonition.
2482
2483 This IS an indented code block (after blank + non-indented line)."#;
2484
2485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2486 let result = rule.check(&ctx).unwrap();
2487
2488 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
2490 }
2491
2492 #[test]
2493 fn test_mkdocs_indented_admonition() {
2494 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2496 let content = r#"- List item
2497
2498 !!! note
2499 Indented admonition content.
2500 More content.
2501
2502- Next item"#;
2503
2504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2505 let result = rule.check(&ctx).unwrap();
2506
2507 assert_eq!(
2509 result.len(),
2510 0,
2511 "Indented admonitions (e.g., in lists) should not be flagged"
2512 );
2513 }
2514
2515 #[test]
2516 fn test_footnote_indented_paragraphs_not_flagged() {
2517 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2518 let content = r#"# Test Document with Footnotes
2519
2520This is some text with a footnote[^1].
2521
2522Here's some code:
2523
2524```bash
2525echo "fenced code block"
2526```
2527
2528More text with another footnote[^2].
2529
2530[^1]: Really interesting footnote text.
2531
2532 Even more interesting second paragraph.
2533
2534[^2]: Another footnote.
2535
2536 With a second paragraph too.
2537
2538 And even a third paragraph!"#;
2539
2540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2541 let result = rule.check(&ctx).unwrap();
2542
2543 assert_eq!(result.len(), 0);
2545 }
2546
2547 #[test]
2548 fn test_footnote_definition_detection() {
2549 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2550
2551 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
2554 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
2555 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
2556 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
2557 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
2558 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
2559 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
2560 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
2561 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
2562
2563 assert!(!rule.is_footnote_definition("[^]: No label"));
2565 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
2566 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
2567 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
2568
2569 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
2571 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
2572 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
2573 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
2576
2577 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
2579 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
2580 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
2581 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
2582 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
2583
2584 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
2587 }
2588
2589 #[test]
2590 fn test_footnote_with_blank_lines() {
2591 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2595 let content = r#"# Document
2596
2597Text with footnote[^1].
2598
2599[^1]: First paragraph.
2600
2601 Second paragraph after blank line.
2602
2603 Third paragraph after another blank line.
2604
2605Regular text at column 0 ends the footnote."#;
2606
2607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2608 let result = rule.check(&ctx).unwrap();
2609
2610 assert_eq!(
2612 result.len(),
2613 0,
2614 "Indented content within footnotes should not trigger MD046"
2615 );
2616 }
2617
2618 #[test]
2619 fn test_footnote_multiple_consecutive_blank_lines() {
2620 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2623 let content = r#"Text[^1].
2624
2625[^1]: First paragraph.
2626
2627
2628
2629 Content after three blank lines (still part of footnote).
2630
2631Not indented, so footnote ends here."#;
2632
2633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2634 let result = rule.check(&ctx).unwrap();
2635
2636 assert_eq!(
2638 result.len(),
2639 0,
2640 "Multiple blank lines shouldn't break footnote continuation"
2641 );
2642 }
2643
2644 #[test]
2645 fn test_footnote_terminated_by_non_indented_content() {
2646 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2649 let content = r#"[^1]: Footnote content.
2650
2651 More indented content in footnote.
2652
2653This paragraph is not indented, so footnote ends.
2654
2655 This should be flagged as indented code block."#;
2656
2657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2658 let result = rule.check(&ctx).unwrap();
2659
2660 assert_eq!(
2662 result.len(),
2663 1,
2664 "Indented code after footnote termination should be flagged"
2665 );
2666 assert!(
2667 result[0].message.contains("Use fenced code blocks"),
2668 "Expected MD046 warning for indented code block"
2669 );
2670 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2671 }
2672
2673 #[test]
2674 fn test_footnote_terminated_by_structural_elements() {
2675 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2677 let content = r#"[^1]: Footnote content.
2678
2679 More content.
2680
2681## Heading terminates footnote
2682
2683 This indented content should be flagged.
2684
2685---
2686
2687 This should also be flagged (after horizontal rule)."#;
2688
2689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2690 let result = rule.check(&ctx).unwrap();
2691
2692 assert_eq!(
2694 result.len(),
2695 2,
2696 "Both indented blocks after termination should be flagged"
2697 );
2698 }
2699
2700 #[test]
2701 fn test_footnote_with_code_block_inside() {
2702 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2705 let content = r#"Text[^1].
2706
2707[^1]: Footnote with code:
2708
2709 ```python
2710 def hello():
2711 print("world")
2712 ```
2713
2714 More footnote text after code."#;
2715
2716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2717 let result = rule.check(&ctx).unwrap();
2718
2719 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2721 }
2722
2723 #[test]
2724 fn test_footnote_with_8_space_indented_code() {
2725 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2728 let content = r#"Text[^1].
2729
2730[^1]: Footnote with nested code.
2731
2732 code block
2733 more code"#;
2734
2735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2736 let result = rule.check(&ctx).unwrap();
2737
2738 assert_eq!(
2740 result.len(),
2741 0,
2742 "8-space indented code within footnotes represents nested code blocks"
2743 );
2744 }
2745
2746 #[test]
2747 fn test_multiple_footnotes() {
2748 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2751 let content = r#"Text[^1] and more[^2].
2752
2753[^1]: First footnote.
2754
2755 Continuation of first.
2756
2757[^2]: Second footnote starts here, ending the first.
2758
2759 Continuation of second."#;
2760
2761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2762 let result = rule.check(&ctx).unwrap();
2763
2764 assert_eq!(
2766 result.len(),
2767 0,
2768 "Multiple footnotes should each maintain their continuation context"
2769 );
2770 }
2771
2772 #[test]
2773 fn test_list_item_ends_footnote_context() {
2774 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2776 let content = r#"[^1]: Footnote.
2777
2778 Content in footnote.
2779
2780- List item starts here (ends footnote context).
2781
2782 This indented content is part of the list, not the footnote."#;
2783
2784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2785 let result = rule.check(&ctx).unwrap();
2786
2787 assert_eq!(
2789 result.len(),
2790 0,
2791 "List items should end footnote context and start their own"
2792 );
2793 }
2794
2795 #[test]
2796 fn test_footnote_vs_actual_indented_code() {
2797 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2800 let content = r#"# Heading
2801
2802Text with footnote[^1].
2803
2804[^1]: Footnote content.
2805
2806 Part of footnote (should not be flagged).
2807
2808Regular paragraph ends footnote context.
2809
2810 This is actual indented code (MUST be flagged)
2811 Should be detected as code block"#;
2812
2813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2814 let result = rule.check(&ctx).unwrap();
2815
2816 assert_eq!(
2818 result.len(),
2819 1,
2820 "Must still detect indented code blocks outside footnotes"
2821 );
2822 assert!(
2823 result[0].message.contains("Use fenced code blocks"),
2824 "Expected MD046 warning for indented code"
2825 );
2826 assert!(
2827 result[0].line >= 11,
2828 "Warning should be on the actual indented code line"
2829 );
2830 }
2831
2832 #[test]
2833 fn test_spec_compliant_label_characters() {
2834 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2837
2838 assert!(rule.is_footnote_definition("[^test]: text"));
2840 assert!(rule.is_footnote_definition("[^TEST]: text"));
2841 assert!(rule.is_footnote_definition("[^test-name]: text"));
2842 assert!(rule.is_footnote_definition("[^test_name]: text"));
2843 assert!(rule.is_footnote_definition("[^test123]: text"));
2844 assert!(rule.is_footnote_definition("[^123]: text"));
2845 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2846
2847 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")); }
2855
2856 #[test]
2857 fn test_code_block_inside_html_comment() {
2858 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2861 let content = r#"# Document
2862
2863Some text.
2864
2865<!--
2866Example code block in comment:
2867
2868```typescript
2869console.log("Hello");
2870```
2871
2872More comment text.
2873-->
2874
2875More content."#;
2876
2877 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2878 let result = rule.check(&ctx).unwrap();
2879
2880 assert_eq!(
2881 result.len(),
2882 0,
2883 "Code blocks inside HTML comments should not be flagged as unclosed"
2884 );
2885 }
2886
2887 #[test]
2888 fn test_unclosed_fence_inside_html_comment() {
2889 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2891 let content = r#"# Document
2892
2893<!--
2894Example with intentionally unclosed fence:
2895
2896```
2897code without closing
2898-->
2899
2900More content."#;
2901
2902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2903 let result = rule.check(&ctx).unwrap();
2904
2905 assert_eq!(
2906 result.len(),
2907 0,
2908 "Unclosed fences inside HTML comments should be ignored"
2909 );
2910 }
2911
2912 #[test]
2913 fn test_multiline_html_comment_with_indented_code() {
2914 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2916 let content = r#"# Document
2917
2918<!--
2919Example:
2920
2921 indented code
2922 more code
2923
2924End of comment.
2925-->
2926
2927Regular text."#;
2928
2929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2930 let result = rule.check(&ctx).unwrap();
2931
2932 assert_eq!(
2933 result.len(),
2934 0,
2935 "Indented code inside HTML comments should not be flagged"
2936 );
2937 }
2938
2939 #[test]
2940 fn test_code_block_after_html_comment() {
2941 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2943 let content = r#"# Document
2944
2945<!-- comment -->
2946
2947Text before.
2948
2949 indented code should be flagged
2950
2951More text."#;
2952
2953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2954 let result = rule.check(&ctx).unwrap();
2955
2956 assert_eq!(
2957 result.len(),
2958 1,
2959 "Code blocks after HTML comments should still be detected"
2960 );
2961 assert!(result[0].message.contains("Use fenced code blocks"));
2962 }
2963
2964 #[test]
2965 fn test_consistent_style_indented_html_comment() {
2966 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2972 let content = "# MD046 false-positive reproduction\n\
2973 \n\
2974 <!--\n \
2975 This is just an indented comment, not a code block.\n\
2976 \n \
2977 A second line is required to trigger the false-positive.\n\
2978 \n \
2979 Actually, three lines are required.\n\
2980 -->\n\
2981 \n\
2982 ```md\n\
2983 This should be fine, since it's the only code block and therefore consistent.\n\
2984 ```\n";
2985
2986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2987 let result = rule.check(&ctx).unwrap();
2988
2989 assert_eq!(
2990 result,
2991 vec![],
2992 "A single fenced block and an indented HTML comment must produce no MD046 warnings",
2993 );
2994 }
2995
2996 #[test]
2997 fn test_consistent_style_indented_html_block() {
2998 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3005 let content = "# Heading\n\
3006 \n\
3007 <div class=\"note\">\n \
3008 line one of indented html content\n \
3009 line two of indented html content\n \
3010 line three of indented html content\n\
3011 </div>\n\
3012 \n\
3013 ```md\n\
3014 real fenced block\n\
3015 ```\n";
3016
3017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3018 let result = rule.check(&ctx).unwrap();
3019
3020 assert_eq!(
3021 result,
3022 vec![],
3023 "Indented content inside a raw HTML block must not influence MD046 style detection",
3024 );
3025 }
3026
3027 #[test]
3028 fn test_consistent_style_fake_fence_inside_html_comment() {
3029 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3035 let content = "# Title\n\
3036 \n\
3037 <!--\n\
3038 ```\n\
3039 fake fence inside comment\n\
3040 ```\n\
3041 -->\n\
3042 \n \
3043 real indented code block line 1\n \
3044 real indented code block line 2\n";
3045
3046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3047 let result = rule.check(&ctx).unwrap();
3048
3049 assert_eq!(
3050 result,
3051 vec![],
3052 "Fence markers inside an HTML comment must not influence MD046 style detection",
3053 );
3054 }
3055
3056 #[test]
3057 fn test_consistent_style_indented_footnote_definition() {
3058 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3062 let content = "# Heading\n\
3063 \n\
3064 Reference to a footnote[^note].\n\
3065 \n\
3066 [^note]: First line of the footnote.\n \
3067 Second indented continuation line.\n \
3068 Third indented continuation line.\n \
3069 Fourth indented continuation line.\n\
3070 \n\
3071 ```md\n\
3072 real fenced block\n\
3073 ```\n";
3074
3075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3076 let result = rule.check(&ctx).unwrap();
3077
3078 assert_eq!(
3079 result,
3080 vec![],
3081 "Footnote-definition continuation content must not influence MD046 style detection",
3082 );
3083 }
3084
3085 #[test]
3086 fn test_consistent_style_indented_blockquote() {
3087 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3092 let content = "# Heading\n\
3093 \n\
3094 > line one of quoted indented content\n\
3095 >\n\
3096 > line two of quoted indented content\n\
3097 >\n\
3098 > line three of quoted indented content\n\
3099 \n\
3100 ```md\n\
3101 real fenced block\n\
3102 ```\n";
3103
3104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3105 let result = rule.check(&ctx).unwrap();
3106
3107 assert_eq!(
3108 result,
3109 vec![],
3110 "Indented content inside a blockquote must not influence MD046 style detection",
3111 );
3112 }
3113
3114 #[test]
3115 fn test_consistent_style_genuine_indented_block_detected_as_indented() {
3116 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3121 let content = "# Heading\n\
3122 \n\
3123 Some prose.\n\
3124 \n \
3125 real indented code line 1\n \
3126 real indented code line 2\n";
3127
3128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3129 let result = rule.check(&ctx).unwrap();
3130
3131 assert_eq!(
3134 result,
3135 vec![],
3136 "A genuine top-level indented block must be detected as Indented style under Consistent",
3137 );
3138 }
3139
3140 #[test]
3141 fn test_consistent_style_skipped_lines_dont_override_real_block() {
3142 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3147 let content = "# Heading\n\
3148 \n\
3149 <!--\n \
3150 skipped indented comment line 1\n \
3151 skipped indented comment line 2\n\
3152 -->\n\
3153 \n\
3154 <!--\n \
3155 second skipped region\n \
3156 also skipped\n\
3157 -->\n\
3158 \n \
3159 real indented code line\n";
3160
3161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3162 let result = rule.check(&ctx).unwrap();
3163
3164 assert_eq!(
3165 result,
3166 vec![],
3167 "Skipped container lines must not outweigh the single real indented block",
3168 );
3169 }
3170
3171 #[test]
3172 fn test_consistent_style_fenced_wins_over_skipped_indented() {
3173 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3177 let content = "# Heading\n\
3178 \n\
3179 <!--\n \
3180 skipped indented region one\n \
3181 more of region one\n\
3182 -->\n\
3183 \n\
3184 <!--\n \
3185 skipped indented region two\n \
3186 more of region two\n\
3187 -->\n\
3188 \n\
3189 ```md\n\
3190 real fenced block\n\
3191 ```\n";
3192
3193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3194 let result = rule.check(&ctx).unwrap();
3195
3196 assert_eq!(
3197 result,
3198 vec![],
3199 "Fenced block must win when all indented lines are inside skipped containers",
3200 );
3201 }
3202
3203 #[test]
3204 fn test_four_space_indented_fence_is_not_valid_fence() {
3205 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3208
3209 assert!(rule.is_fenced_code_block_start("```"));
3211 assert!(rule.is_fenced_code_block_start(" ```"));
3212 assert!(rule.is_fenced_code_block_start(" ```"));
3213 assert!(rule.is_fenced_code_block_start(" ```"));
3214
3215 assert!(!rule.is_fenced_code_block_start(" ```"));
3217 assert!(!rule.is_fenced_code_block_start(" ```"));
3218 assert!(!rule.is_fenced_code_block_start(" ```"));
3219
3220 assert!(!rule.is_fenced_code_block_start("\t```"));
3222 }
3223
3224 #[test]
3225 fn test_issue_237_indented_fenced_block_detected_as_indented() {
3226 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3232
3233 let content = r#"## Test
3235
3236 ```js
3237 var foo = "hello";
3238 ```
3239"#;
3240
3241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3242 let result = rule.check(&ctx).unwrap();
3243
3244 assert_eq!(
3246 result.len(),
3247 1,
3248 "4-space indented fence should be detected as indented code block"
3249 );
3250 assert!(
3251 result[0].message.contains("Use fenced code blocks"),
3252 "Expected 'Use fenced code blocks' message"
3253 );
3254 }
3255
3256 #[test]
3257 fn test_issue_276_indented_code_in_list() {
3258 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3261
3262 let content = r#"1. First item
32632. Second item with code:
3264
3265 # This is a code block in a list
3266 print("Hello, world!")
3267
32684. Third item"#;
3269
3270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3271 let result = rule.check(&ctx).unwrap();
3272
3273 assert!(
3275 !result.is_empty(),
3276 "Indented code block inside list should be flagged when style=fenced"
3277 );
3278 assert!(
3279 result[0].message.contains("Use fenced code blocks"),
3280 "Expected 'Use fenced code blocks' message"
3281 );
3282 }
3283
3284 #[test]
3285 fn test_three_space_indented_fence_is_valid() {
3286 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3288
3289 let content = r#"## Test
3290
3291 ```js
3292 var foo = "hello";
3293 ```
3294"#;
3295
3296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3297 let result = rule.check(&ctx).unwrap();
3298
3299 assert_eq!(
3301 result.len(),
3302 0,
3303 "3-space indented fence should be recognized as valid fenced code block"
3304 );
3305 }
3306
3307 #[test]
3308 fn test_indented_style_with_deeply_indented_fenced() {
3309 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3312
3313 let content = r#"Text
3314
3315 ```js
3316 var foo = "hello";
3317 ```
3318
3319More text
3320"#;
3321
3322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3323 let result = rule.check(&ctx).unwrap();
3324
3325 assert_eq!(
3328 result.len(),
3329 0,
3330 "4-space indented content should be valid when style=indented"
3331 );
3332 }
3333
3334 #[test]
3335 fn test_fix_misplaced_fenced_block() {
3336 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3339
3340 let content = r#"## Test
3341
3342 ```js
3343 var foo = "hello";
3344 ```
3345"#;
3346
3347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3348 let fixed = rule.fix(&ctx).unwrap();
3349
3350 let expected = r#"## Test
3352
3353```js
3354var foo = "hello";
3355```
3356"#;
3357
3358 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
3359 }
3360
3361 #[test]
3362 fn test_fix_regular_indented_block() {
3363 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3366
3367 let content = r#"Text
3368
3369 var foo = "hello";
3370 console.log(foo);
3371
3372More text
3373"#;
3374
3375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3376 let fixed = rule.fix(&ctx).unwrap();
3377
3378 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
3380 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
3381 }
3382
3383 #[test]
3384 fn test_fix_indented_block_with_fence_like_content() {
3385 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3389
3390 let content = r#"Text
3391
3392 some code
3393 ```not a fence opener
3394 more code
3395"#;
3396
3397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3398 let fixed = rule.fix(&ctx).unwrap();
3399
3400 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
3402 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
3403 }
3404
3405 #[test]
3406 fn test_fix_mixed_indented_and_misplaced_blocks() {
3407 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3409
3410 let content = r#"Text
3411
3412 regular indented code
3413
3414More text
3415
3416 ```python
3417 print("hello")
3418 ```
3419"#;
3420
3421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3422 let fixed = rule.fix(&ctx).unwrap();
3423
3424 assert!(
3426 fixed.contains("```\nregular indented code\n```"),
3427 "First block should be wrapped in fences"
3428 );
3429
3430 assert!(
3432 fixed.contains("\n```python\nprint(\"hello\")\n```"),
3433 "Second block should be dedented, not double-wrapped"
3434 );
3435 assert!(
3437 !fixed.contains("```\n```python"),
3438 "Should not have nested fence openers"
3439 );
3440 }
3441
3442 #[test]
3443 fn test_md046_front_matter() {
3444 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3445 let content = "---\nmetadata:\n\n description: Indented\n---\n";
3446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3447 let result = rule.check(&ctx).unwrap();
3448 assert_eq!(result.len(), 0);
3449 }
3450
3451 #[test]
3452 fn test_md046_fix_front_matter() {
3453 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3454 let content = "---\nmetadata:\n\n description: Indented\n---\n";
3455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3456 let fixed = rule.fix(&ctx).unwrap();
3457 assert_eq!(fixed, content);
3458 }
3459
3460 #[test]
3461 fn test_whitespace_only_line_is_not_an_indented_code_block() {
3462 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3467 let content = "# T\n\nPara\n\n \nMore\n\n real code\n\nEnd\n";
3468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3469 let fixed = rule.fix(&ctx).unwrap();
3470 assert_eq!(fixed, "# T\n\nPara\n\n \nMore\n\n```\nreal code\n```\n\nEnd\n");
3471 }
3472
3473 #[test]
3474 fn test_interior_blank_line_keeps_indented_block_together() {
3475 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3479 let content = "# T\n\nPara\n\n a\n\n b\n\nAfter\n";
3480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3481 let fixed = rule.fix(&ctx).unwrap();
3482 assert_eq!(fixed, "# T\n\nPara\n\n```\na\n\nb\n```\n\nAfter\n");
3483 }
3484
3485 #[test]
3486 fn test_consistent_style_counts_a_block_with_interior_blank_once() {
3487 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3491 let content = "# T\n\n```\nfenced\n```\n\nPara\n\n a\n\n b\n\nEnd\n";
3492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3493 let result = rule.check(&ctx).unwrap();
3494 let reported: Vec<(usize, &str)> = result.iter().map(|w| (w.line, w.message.as_str())).collect();
3495 assert_eq!(reported, vec![(9, "Use fenced code blocks")]);
3496 }
3497
3498 #[test]
3499 fn test_indented_lazy_continuation_lines_are_not_code() {
3500 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3506 let content = "# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n real code\n\nEnd\n";
3507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3508 let fixed = rule.fix(&ctx).unwrap();
3509 assert_eq!(
3510 fixed,
3511 "# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n```\nreal code\n```\n\nEnd\n"
3512 );
3513 }
3514
3515 #[test]
3516 fn test_misplaced_fence_with_interior_blank_dedents_as_one_block() {
3517 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3521 let content = "# T\n\nPara\n\n ```python\n x = 1\n\n y = 2\n ```\n\nAfter\n";
3522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3523 let fixed = rule.fix(&ctx).unwrap();
3524 assert_eq!(fixed, "# T\n\nPara\n\n```python\nx = 1\n\ny = 2\n```\n\nAfter\n");
3525 }
3526 #[test]
3527 fn test_mdg_overrides_indented_style_to_fenced() {
3528 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3533 let content = "# Feature: Payloads\n\n## Scenario: JSON payload\n\n* Given this payload\n\n ```json\n {\"ok\": true}\n ```\n";
3534
3535 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3536 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3537 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3538
3539 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3542 let standard_warnings = rule.check(&standard_ctx).unwrap();
3543 assert_eq!(standard_warnings.len(), 1);
3544 assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3545 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3546 }
3547
3548 #[test]
3549 fn test_mdg_indented_style_still_fences_indented_blocks() {
3550 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3554 let content =
3555 "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n ordinary indented code\n";
3556
3557 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3558 let warnings = rule.check(&mdg_ctx).unwrap();
3559 assert_eq!(warnings.len(), 1);
3560 assert_eq!(warnings[0].message, "Use fenced code blocks");
3561
3562 let fixed = rule.fix(&mdg_ctx).unwrap();
3563 assert_eq!(
3564 fixed,
3565 "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n```\n ordinary indented code\n```\n"
3566 );
3567
3568 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3569 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3570 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3571
3572 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3575 assert!(rule.check(&standard_ctx).unwrap().is_empty());
3576 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3577 }
3578
3579 #[test]
3580 fn test_mdg_steers_indented_code_to_fenced() {
3581 let content = "# Feature: Payloads\n\n## Scenario: Plain payload\n\n* Given this payload\n\n ordinary indented code\n";
3585
3586 for rule in [
3587 MD046CodeBlockStyle::new(CodeBlockStyle::Fenced),
3588 MD046CodeBlockStyle::new(CodeBlockStyle::Consistent),
3589 MD046CodeBlockStyle::new(CodeBlockStyle::Indented),
3590 ] {
3591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3592 let warnings = rule.check(&ctx).unwrap();
3593 assert_eq!(warnings.len(), 1);
3594 assert_eq!(warnings[0].message, "Use fenced code blocks");
3595
3596 let fixed = rule.fix(&ctx).unwrap();
3597 assert!(fixed.contains("```"), "MDG must fence the block: {fixed:?}");
3598
3599 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3600 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3601 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3602 }
3603 }
3604
3605 #[test]
3606 fn test_mdg_consistent_style_ignores_indented_prevalence() {
3607 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3610 let indented_majority = "# Feature: Payloads\n\n## Scenario: Mixed payloads\n\n* Given this payload\n\n```\n{\"ok\": true}\n```\n\nFirst ordinary example:\n\n one\n\nSecond ordinary example:\n\n two\n";
3611
3612 let standard_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::Standard, None);
3613 let standard_warnings = rule.check(&standard_ctx).unwrap();
3614 assert_eq!(standard_warnings.len(), 1);
3615 assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3616
3617 let mdg_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::MDG, None);
3618 let mdg_warnings = rule.check(&mdg_ctx).unwrap();
3619 assert_eq!(mdg_warnings.len(), 2);
3620 assert!(
3621 mdg_warnings
3622 .iter()
3623 .all(|warning| warning.message == "Use fenced code blocks")
3624 );
3625 }
3626
3627 #[test]
3628 fn test_mdg_repairs_unclosed_fence_like_standard() {
3629 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3632 let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3633
3634 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3635 let warnings = rule.check(&mdg_ctx).unwrap();
3636 assert_eq!(warnings.len(), 1);
3637 assert!(warnings[0].message.contains("never closed"));
3638
3639 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3640 assert_eq!(
3641 rule.fix(&mdg_ctx).unwrap(),
3642 rule.fix(&standard_ctx).unwrap(),
3643 "MDG must not differ from Standard"
3644 );
3645 }
3646
3647 #[test]
3648 fn test_mdg_table_above_prose_is_never_fenced() {
3649 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3655 let content = "# Feature: Eating\n\n#### Examples:\n\n | start | eat | left |\n | ----- | --- | ---- |\n\n a note about the data\n\n## Scenario: Other\n\n unrelated indented code\n";
3656
3657 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3658 let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3659 assert_eq!(reported, vec![8, 12]);
3660
3661 let fixed = rule.fix(&mdg_ctx).unwrap();
3662 assert_eq!(
3663 fixed,
3664 "# Feature: Eating\n\n#### Examples:\n\n | start | eat | left |\n | ----- | --- | ---- |\n\n```\na note about the data\n```\n\n## Scenario: Other\n\n```\n unrelated indented code\n```\n"
3665 );
3666
3667 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3668 assert!(
3669 rule.check(&fixed_ctx).unwrap().is_empty(),
3670 "MDG check must have nothing left to report after its own fix"
3671 );
3672 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3673
3674 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3676 let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3677 assert_eq!(standard_reported, vec![5, 12]);
3678 assert!(rule.fix(&standard_ctx).unwrap().contains("```\n| start | eat | left |"));
3679 }
3680
3681 #[test]
3682 fn test_mdg_repairs_unclosed_fence_under_indented_style() {
3683 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3687 let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3688
3689 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3690 let warnings = rule.check(&mdg_ctx).unwrap();
3691 assert_eq!(warnings.len(), 1);
3692 assert!(warnings[0].message.contains("never closed"));
3693
3694 let fixed = rule.fix(&mdg_ctx).unwrap();
3695 assert_eq!(fixed, "# Feature: Payloads\n\n```json\n{\"ok\": true}\n```\n");
3696
3697 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3698 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3699
3700 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3703 assert_eq!(rule.fix(&standard_ctx).unwrap(), fixed);
3704 }
3705
3706 #[test]
3707 fn test_mdg_tab_indented_table_is_not_code() {
3708 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3712 for indent in ["\t\t", " \t"] {
3713 let content = format!(
3714 "# Feature: Eating\n\n#### Examples:\n\n{indent}| start | eat |\n{indent}| ----- | --- |\n\n## Scenario: Other\n\n code here\n"
3715 );
3716
3717 let mdg_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
3718 let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3719 assert_eq!(reported, vec![10], "tab-indented rows are a table, not code");
3720
3721 let fixed = rule.fix(&mdg_ctx).unwrap();
3722 assert!(
3723 fixed.contains(&format!("{indent}| start | eat |\n{indent}| ----- | --- |")),
3724 "MDG must leave the tab-indented table alone: {fixed:?}"
3725 );
3726
3727 let standard_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3728 let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3729 assert_eq!(standard_reported, vec![5, 10]);
3730 }
3731 }
3732
3733 #[test]
3734 fn test_from_config_records_whether_style_was_configured() {
3735 use crate::config::Config;
3739 use std::collections::BTreeMap;
3740
3741 let mut values = BTreeMap::new();
3742 values.insert("style".to_string(), toml::Value::String("indented".to_string()));
3743 let mut config = Config::default();
3744 config.rules.insert(
3745 "MD046".to_string(),
3746 crate::config::RuleConfig { severity: None, values },
3747 );
3748
3749 let configured = MD046CodeBlockStyle::from_config(&config);
3750 let configured = configured.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
3751 assert_eq!(configured.config.style, CodeBlockStyle::Indented);
3752 assert!(configured.style_explicit);
3753
3754 let defaulted = MD046CodeBlockStyle::from_config(&Config::default());
3755 let defaulted = defaulted.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
3756 assert!(!defaulted.style_explicit);
3757
3758 let indented = MD046CodeBlockStyle::from_config_struct(MD046Config {
3761 style: CodeBlockStyle::Indented,
3762 });
3763 let content = "# Feature: F\n\nText.\n\n code here\n";
3764 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3765 assert!(indented.fix(&mdg_ctx).unwrap().contains("```\n code here\n```"));
3766 }
3767
3768 #[test]
3769 fn test_mdg_indented_style_keeps_tables_out_of_code() {
3770 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3773 let content = "# Feature: Eating\n\n#### Examples:\n\n | start | eat | left |\n | ----- | --- | ---- |\n";
3774
3775 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3776 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3777 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3778
3779 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3782 assert!(rule.check(&standard_ctx).unwrap().is_empty());
3783 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3784 }
3785}