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(&"");
866 let fence_pos = line.find("```").into_iter().chain(line.find("~~~")).min().unwrap_or(0);
867 let fence_char = line[fence_pos..].chars().next().unwrap_or('`');
868 let fence_marker: String = line[fence_pos..].chars().take_while(|&ch| ch == fence_char).collect();
869 let opening_quote = crate::utils::blockquote::parse_blockquote_prefix(line);
870 let quote_level = opening_quote.map_or(0, |quote| quote.nesting_level);
871 let owned = self.build_indent_context(ctx, lines, ctx.flavor == crate::config::MarkdownFlavor::MkDocs);
872 let baseline = owned
873 .list_item_baseline
874 .get(opening_line_idx)
875 .copied()
876 .flatten()
877 .unwrap_or(0);
878
879 let has_closing_fence = lines
882 .iter()
883 .enumerate()
884 .rev()
885 .find_map(|(idx, candidate)| {
886 let quote = crate::utils::blockquote::parse_blockquote_prefix(candidate);
887 let body = quote.map_or(*candidate, |quote| quote.content);
888 if body.trim().is_empty() {
889 return None;
890 }
891 Some(
892 idx > opening_line_idx
893 && quote.map_or(0, |quote| quote.nesting_level) == quote_level
894 && Self::is_closing_fence(body, fence_char, fence_marker.len(), baseline),
895 )
896 })
897 .unwrap_or(false);
898
899 if !has_closing_fence {
900 if ctx
902 .lines
903 .get(opening_line_idx)
904 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
905 {
906 continue;
907 }
908
909 let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
910
911 warnings.push(LintWarning {
912 rule_name: Some(self.name().to_string()),
913 line: start_line,
914 column: start_col,
915 end_line,
916 end_column: end_col,
917 message: format!("Code block opened with '{fence_marker}' but never closed"),
918 severity: Severity::Warning,
919 fix: Some(Fix::new(ctx.content.len()..ctx.content.len(), {
920 let prefix: String = line[..fence_pos]
923 .chars()
924 .map(|ch| if ch == '>' || ch.is_whitespace() { ch } else { ' ' })
925 .collect();
926 let newline = crate::utils::detect_line_ending(ctx.content);
927 if ctx.content.ends_with('\n') {
928 format!("{prefix}{fence_marker}{newline}")
929 } else {
930 format!("{newline}{prefix}{fence_marker}")
931 }
932 })),
933 });
934 }
935 }
936
937 warnings
938 }
939
940 fn effective_target_style(
948 &self,
949 ctx: &crate::lint_context::LintContext,
950 detect: impl FnOnce() -> CodeBlockStyle,
951 ) -> CodeBlockStyle {
952 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
953 self.warn_once_about_overridden_style();
954 return CodeBlockStyle::Fenced;
955 }
956
957 match self.config.style {
958 CodeBlockStyle::Consistent => {
959 let detected = detect();
960 if detected == CodeBlockStyle::Indented
961 && ctx.code_block_details.iter().any(|detail| {
962 detail.is_fenced
963 && !detail.info_string.trim().is_empty()
964 && Self::code_block_is_style_eligible(ctx, detail)
965 })
966 {
967 CodeBlockStyle::Fenced
971 } else {
972 detected
973 }
974 }
975 style => style,
976 }
977 }
978
979 fn code_block_is_style_eligible(
983 ctx: &crate::lint_context::LintContext,
984 detail: &crate::utils::code_block_utils::CodeBlockDetail,
985 ) -> bool {
986 let Some(line_idx) = Self::code_block_start_line(ctx, detail) else {
987 return false;
988 };
989
990 !ctx.lines.get(line_idx).is_some_and(|info| {
991 info.in_html_comment
992 || info.in_mdx_comment
993 || info.in_html_block
994 || info.in_jsx_block
995 || info.in_mkdocstrings
996 || info.in_footnote_definition
997 || info.blockquote.is_some()
998 || info.in_front_matter
999 })
1000 }
1001
1002 fn code_block_start_line(
1003 ctx: &crate::lint_context::LintContext,
1004 detail: &crate::utils::code_block_utils::CodeBlockDetail,
1005 ) -> Option<usize> {
1006 if detail.start >= ctx.content.len() {
1007 return None;
1008 }
1009
1010 Some(match ctx.line_offsets.binary_search(&detail.start) {
1011 Ok(idx) => idx,
1012 Err(idx) => idx.saturating_sub(1),
1013 })
1014 }
1015
1016 fn fenced_separator_lines(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
1020 let mut lines = std::collections::HashSet::new();
1021
1022 for pair in ctx.code_block_details.windows(2) {
1023 let [previous, next] = pair else {
1024 continue;
1025 };
1026 if previous.end > next.start || next.start > ctx.content.len() {
1027 continue;
1028 }
1029 if !ctx.content[previous.end..next.start].trim().is_empty() {
1030 continue;
1031 }
1032
1033 for detail in [previous, next] {
1034 if detail.is_fenced
1035 && let Some(line) = Self::code_block_start_line(ctx, detail)
1036 {
1037 lines.insert(line);
1038 }
1039 }
1040 }
1041
1042 lines
1043 }
1044
1045 fn fenced_boundary_blank_lines(
1050 ctx: &crate::lint_context::LintContext,
1051 lines: &[&str],
1052 ictx: &IndentContext,
1053 ) -> std::collections::HashSet<usize> {
1054 let mut boundary_blank_lines = std::collections::HashSet::new();
1055
1056 for detail in ctx.code_block_details.iter().filter(|detail| detail.is_fenced) {
1057 let Some(start) = Self::code_block_start_line(ctx, detail) else {
1058 continue;
1059 };
1060 let Some(opener) = lines.get(start) else {
1061 continue;
1062 };
1063 let baseline = ictx.list_item_baseline.get(start).copied().flatten().unwrap_or(0);
1064 let trimmed = opener.trim_start();
1065 if !Self::has_valid_fence_indent_at(opener, baseline) {
1066 continue;
1067 }
1068 let fence_char = if trimmed.starts_with("```") {
1069 '`'
1070 } else if trimmed.starts_with("~~~") {
1071 '~'
1072 } else {
1073 continue;
1076 };
1077 let opener_len = trimmed.chars().take_while(|&ch| ch == fence_char).count();
1078
1079 let mut block_end = start + 1;
1080 let mut closer = None;
1081 while block_end < lines.len()
1082 && ctx
1083 .line_offsets
1084 .get(block_end)
1085 .is_some_and(|&offset| offset < detail.end)
1086 {
1087 if Self::is_closing_fence(lines[block_end], fence_char, opener_len, baseline) {
1088 closer = Some(block_end);
1089 break;
1090 }
1091 block_end += 1;
1092 }
1093
1094 let payload_end = closer.unwrap_or(block_end);
1095 if start + 1 == payload_end
1096 || (start + 1 < payload_end
1097 && (lines[start + 1].trim().is_empty() || lines[payload_end - 1].trim().is_empty()))
1098 {
1099 boundary_blank_lines.insert(start);
1100 }
1101 }
1102
1103 boundary_blank_lines
1104 }
1105
1106 fn warn_once_about_overridden_style(&self) {
1112 if !self.style_explicit || self.config.style != CodeBlockStyle::Indented {
1113 return;
1114 }
1115
1116 MDG_STYLE_OVERRIDE.report(
1117 "MD046",
1118 "style",
1119 "indented",
1120 "fenced",
1121 "a Gherkin Doc String is only ever a backtick fence",
1122 );
1123 }
1124
1125 fn detect_style(
1126 &self,
1127 ctx: &crate::lint_context::LintContext,
1128 lines: &[&str],
1129 is_mkdocs: bool,
1130 ictx: &IndentContext,
1131 ) -> Option<CodeBlockStyle> {
1132 if lines.is_empty() {
1133 return None;
1134 }
1135
1136 let block_lines = self.indented_block_lines(lines, is_mkdocs, ictx, ctx);
1137
1138 let mut fenced_count = 0;
1139 let mut indented_count = 0;
1140
1141 let mut in_fenced = false;
1151 let mut prev_was_indented = false;
1152
1153 for (i, line) in lines.iter().enumerate() {
1154 let in_container = ictx.in_comment_or_html.get(i).copied().unwrap_or(false);
1155
1156 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|l| l.in_code_block) {
1160 prev_was_indented = false;
1161 continue;
1162 }
1163
1164 if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|l| l.in_myst_directive) {
1166 prev_was_indented = false;
1167 continue;
1168 }
1169
1170 let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1171 if self.is_fenced_code_block_start_at(line, baseline) {
1172 if in_container {
1173 prev_was_indented = false;
1176 continue;
1177 }
1178 if !in_fenced {
1179 fenced_count += 1;
1181 in_fenced = true;
1182 } else {
1183 in_fenced = false;
1185 }
1186 prev_was_indented = false;
1187 } else if !in_fenced && block_lines[i] {
1188 if !prev_was_indented {
1190 indented_count += 1;
1191 }
1192 prev_was_indented = true;
1193 } else {
1194 prev_was_indented = false;
1195 }
1196 }
1197
1198 if fenced_count == 0 && indented_count == 0 {
1199 None
1200 } else if fenced_count > 0 && indented_count == 0 {
1201 Some(CodeBlockStyle::Fenced)
1202 } else if fenced_count == 0 && indented_count > 0 {
1203 Some(CodeBlockStyle::Indented)
1204 } else if fenced_count >= indented_count {
1205 Some(CodeBlockStyle::Fenced)
1206 } else {
1207 Some(CodeBlockStyle::Indented)
1208 }
1209 }
1210}
1211
1212impl Rule for MD046CodeBlockStyle {
1213 fn name(&self) -> &'static str {
1214 "MD046"
1215 }
1216
1217 fn description(&self) -> &'static str {
1218 "Code blocks should use a consistent style"
1219 }
1220
1221 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1222 if ctx.content.is_empty() {
1224 return Ok(Vec::new());
1225 }
1226
1227 if !ctx.content.contains("```")
1229 && !ctx.content.contains("~~~")
1230 && !ctx.content.contains(" ")
1231 && !ctx.content.contains('\t')
1232 {
1233 return Ok(Vec::new());
1234 }
1235
1236 let mut unclosed_warnings = self.check_unclosed_code_blocks(ctx);
1238
1239 if !unclosed_warnings.is_empty() {
1241 let fixed = self.fix(ctx)?;
1242 for warning in &mut unclosed_warnings {
1243 warning.fix = if fixed == ctx.content {
1244 None
1245 } else if let Some(suffix) = fixed.strip_prefix(ctx.content) {
1246 Some(Fix::new(ctx.content.len()..ctx.content.len(), suffix.to_string()))
1247 } else {
1248 Some(Fix::new(0..ctx.content.len(), fixed.clone()))
1249 };
1250 }
1251 return Ok(unclosed_warnings);
1252 }
1253
1254 let lines = ctx.raw_lines();
1256 let mut warnings = Vec::new();
1257
1258 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1259
1260 let target_style = self.effective_target_style(ctx, || {
1262 let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1263 let detected = self.detect_style(ctx, lines, is_mkdocs, &owned.borrow());
1264 detected.unwrap_or(CodeBlockStyle::Fenced)
1265 });
1266
1267 let mdg_block_lines = (ctx.flavor == crate::config::MarkdownFlavor::MDG
1272 && ctx.code_block_details.iter().any(|detail| !detail.is_fenced))
1273 .then(|| {
1274 let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1275 self.indented_block_lines(lines, is_mkdocs, &owned.borrow(), ctx)
1276 });
1277
1278 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
1280
1281 for detail in &ctx.code_block_details {
1282 if detail.start >= ctx.content.len() || detail.end > ctx.content.len() {
1283 continue;
1284 }
1285
1286 let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
1287 Ok(idx) => idx,
1288 Err(idx) => idx.saturating_sub(1),
1289 };
1290
1291 if detail.is_fenced {
1292 if target_style == CodeBlockStyle::Indented {
1293 let line = lines.get(start_line_idx).unwrap_or(&"");
1294
1295 if ctx
1296 .lines
1297 .get(start_line_idx)
1298 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
1299 {
1300 continue;
1301 }
1302
1303 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1304 warnings.push(LintWarning {
1305 rule_name: Some(self.name().to_string()),
1306 line: start_line,
1307 column: start_col,
1308 end_line,
1309 end_column: end_col,
1310 message: "Use indented code blocks".to_string(),
1311 severity: Severity::Warning,
1312 fix: None,
1313 });
1314 }
1315 } else {
1316 if target_style == CodeBlockStyle::Fenced {
1318 let start_line_idx = match &mdg_block_lines {
1323 Some(block_lines) => {
1324 match Self::first_code_block_line(ctx, block_lines, start_line_idx, detail.end) {
1325 Some(idx) => idx,
1326 None => continue,
1327 }
1328 }
1329 None => start_line_idx,
1330 };
1331
1332 if reported_indented_lines.contains(&start_line_idx) {
1333 continue;
1334 }
1335
1336 let line = lines.get(start_line_idx).unwrap_or(&"");
1337
1338 if ctx.lines.get(start_line_idx).is_some_and(|info| {
1340 info.in_html_comment
1341 || info.in_mdx_comment
1342 || info.in_html_block
1343 || info.in_jsx_block
1344 || info.in_mkdocstrings
1345 || info.in_footnote_definition
1346 || info.blockquote.is_some()
1347 || info.in_front_matter
1348 }) {
1349 continue;
1350 }
1351
1352 if is_mkdocs
1354 && ctx
1355 .lines
1356 .get(start_line_idx)
1357 .is_some_and(|info| info.in_admonition || info.in_content_tab)
1358 {
1359 continue;
1360 }
1361
1362 reported_indented_lines.insert(start_line_idx);
1363
1364 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1365 warnings.push(LintWarning {
1366 rule_name: Some(self.name().to_string()),
1367 line: start_line,
1368 column: start_col,
1369 end_line,
1370 end_column: end_col,
1371 message: "Use fenced code blocks".to_string(),
1372 severity: Severity::Warning,
1373 fix: None,
1374 });
1375 }
1376 }
1377 }
1378
1379 warnings.sort_by_key(|w| (w.line, w.column));
1381
1382 Ok(warnings)
1383 }
1384
1385 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1386 let content = ctx.content;
1387 if content.is_empty() {
1388 return Ok(String::new());
1389 }
1390
1391 let unclosed = crate::utils::fix_utils::filter_warnings_by_inline_config(
1392 self.check_unclosed_code_blocks(ctx),
1393 ctx.inline_config(),
1394 self.name(),
1395 );
1396 if !unclosed.is_empty() {
1397 let repaired =
1398 crate::utils::fix_utils::apply_warning_fixes(content, &unclosed).map_err(LintError::FixFailed)?;
1399 let repaired_ctx = crate::lint_context::LintContext::new(
1400 &repaired,
1401 ctx.flavor,
1402 ctx.source_file().map(std::path::Path::to_path_buf),
1403 );
1404 return self.fix_closed_blocks(&repaired_ctx);
1407 }
1408
1409 self.fix_closed_blocks(ctx)
1410 }
1411
1412 fn category(&self) -> RuleCategory {
1414 RuleCategory::CodeBlock
1415 }
1416
1417 fn fix_capability(&self) -> FixCapability {
1418 FixCapability::ConditionallyFixable
1421 }
1422
1423 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1425 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1428 }
1429
1430 fn as_any(&self) -> &dyn std::any::Any {
1431 self
1432 }
1433
1434 crate::impl_rule_config_sections!(MD046Config);
1435
1436 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1437 where
1438 Self: Sized,
1439 {
1440 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1441 let style_explicit = option_is_explicit(config, "MD046", "style");
1442
1443 Box::new(Self {
1444 config: rule_config,
1445 style_explicit,
1446 })
1447 }
1448}
1449
1450impl MD046CodeBlockStyle {
1451 fn fix_closed_blocks(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1453 let content = ctx.content;
1454 let lines = ctx.raw_lines();
1455
1456 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1458
1459 let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1460 let ictx = owned.borrow();
1461
1462 let target_style = self.effective_target_style(ctx, || {
1463 self.detect_style(ctx, lines, is_mkdocs, &ictx)
1464 .unwrap_or(CodeBlockStyle::Fenced)
1465 });
1466
1467 let block_lines = self.indented_block_lines(lines, is_mkdocs, &ictx, ctx);
1468 let fenced_separator_lines = if target_style == CodeBlockStyle::Indented {
1469 Self::fenced_separator_lines(ctx)
1470 } else {
1471 std::collections::HashSet::new()
1472 };
1473 let fenced_boundary_blank_lines = if target_style == CodeBlockStyle::Indented {
1474 Self::fenced_boundary_blank_lines(ctx, lines, &ictx)
1475 } else {
1476 std::collections::HashSet::new()
1477 };
1478 let fenced_start_lines: std::collections::HashSet<usize> = ctx
1482 .code_block_details
1483 .iter()
1484 .filter(|detail| detail.is_fenced)
1485 .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1486 .collect();
1487 let has_unsupported_fence_opener = ctx
1488 .code_block_details
1489 .iter()
1490 .filter(|detail| detail.is_fenced && Self::code_block_is_style_eligible(ctx, detail))
1491 .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1492 .any(|line_index| {
1493 let Some(line) = lines.get(line_index) else {
1494 return true;
1495 };
1496 let baseline = ictx.list_item_baseline.get(line_index).copied().flatten().unwrap_or(0);
1497 !self.is_fenced_code_block_start_at(line, baseline)
1498 });
1499
1500 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(lines, &block_lines);
1504
1505 let mut result = String::with_capacity(content.len());
1506 let mut in_fenced_block = false;
1507 let mut fenced_fence_opener: Option<(char, usize)> = None;
1511 let mut in_indented_block = false;
1512 let mut current_block_fence_indent = String::new();
1517
1518 let mut current_block_must_stay_fenced = false;
1522 let mut current_fence_indent = 0usize;
1523 let mut current_fence_baseline = 0usize;
1524 let mut current_block_indented_prefix = String::from(" ");
1525 let mut converted_fenced_to_indented = false;
1526 let mut retained_structurally_unsafe_fence =
1527 target_style == CodeBlockStyle::Indented && has_unsupported_fence_opener;
1528
1529 for (i, line) in lines.iter().enumerate() {
1530 let line_num = i + 1;
1531 let trimmed = line.trim_start();
1532 let list_baseline = ictx.list_item_baseline.get(i).copied().flatten();
1533 let fence_baseline = list_baseline.unwrap_or(0);
1534
1535 if !in_fenced_block
1538 && fenced_start_lines.contains(&i)
1539 && Self::has_valid_fence_indent_at(line, fence_baseline)
1540 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1541 {
1542 let block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
1544 in_fenced_block = true;
1545 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
1546 let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
1547 fenced_fence_opener = Some((fence_char, opener_len));
1548 current_fence_indent = calculate_indentation_width_default(line);
1549 current_fence_baseline = fence_baseline;
1550 current_block_indented_prefix = " ".repeat(fence_baseline + 4);
1551 let follows_list_item = i
1552 .checked_sub(1)
1553 .and_then(|previous| ictx.list_item_baseline.get(previous))
1554 .copied()
1555 .flatten()
1556 .is_some();
1557 let would_become_list_prose = target_style == CodeBlockStyle::Indented
1558 && list_baseline.is_none()
1559 && (ictx.in_list_context.get(i).copied().unwrap_or(false) || follows_list_item);
1560 let would_interrupt_paragraph = target_style == CodeBlockStyle::Indented
1561 && i > 0
1562 && !lines[i - 1].trim().is_empty()
1563 && ctx
1564 .lines
1565 .get(i - 1)
1566 .is_some_and(crate::lint_context::LineInfo::is_paragraph_context)
1567 && crate::lint_context::is_paragraph_text_line(lines[i - 1]);
1568 let would_merge_code_blocks = fenced_separator_lines.contains(&i);
1569 let would_lose_boundary_blanks = fenced_boundary_blank_lines.contains(&i);
1570 current_block_must_stay_fenced = block_disabled
1571 || !trimmed[opener_len..].trim().is_empty()
1572 || would_become_list_prose
1573 || would_interrupt_paragraph
1574 || would_merge_code_blocks
1575 || would_lose_boundary_blanks;
1576 retained_structurally_unsafe_fence |= would_become_list_prose
1577 || would_interrupt_paragraph
1578 || would_merge_code_blocks
1579 || would_lose_boundary_blanks;
1580
1581 if current_block_must_stay_fenced {
1582 result.push_str(line);
1585 result.push('\n');
1586 } else if target_style == CodeBlockStyle::Indented {
1587 in_indented_block = true;
1589 converted_fenced_to_indented = true;
1590 } else {
1591 result.push_str(line);
1593 result.push('\n');
1594 }
1595 } else if in_fenced_block && fenced_fence_opener.is_some() {
1596 let (fence_char, opener_len) = fenced_fence_opener.unwrap();
1597 let is_closer = Self::is_closing_fence(line, fence_char, opener_len, current_fence_baseline);
1600 if is_closer {
1601 in_fenced_block = false;
1602 fenced_fence_opener = None;
1603 in_indented_block = false;
1604
1605 if current_block_must_stay_fenced {
1606 result.push_str(line);
1607 result.push('\n');
1608 } else if target_style == CodeBlockStyle::Indented {
1609 } else {
1611 result.push_str(line);
1613 result.push('\n');
1614 }
1615 current_block_must_stay_fenced = false;
1616 current_fence_indent = 0;
1617 current_fence_baseline = 0;
1618 current_block_indented_prefix.clear();
1619 } else if current_block_must_stay_fenced {
1620 result.push_str(line);
1622 result.push('\n');
1623 } else if target_style == CodeBlockStyle::Indented {
1624 if !line.is_empty() {
1631 let body = Self::strip_indentation_columns(line, current_fence_indent);
1636 result.push_str(¤t_block_indented_prefix);
1637 result.push_str(&body);
1638 }
1639 result.push('\n');
1640 } else {
1641 result.push_str(line);
1643 result.push('\n');
1644 }
1645 } else if block_lines[i] {
1646 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
1650 result.push_str(line);
1651 result.push('\n');
1652 continue;
1653 }
1654
1655 let prev_line_is_indented = i > 0 && block_lines[i - 1];
1657
1658 if target_style == CodeBlockStyle::Fenced {
1659 let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1665 let body = if line.trim().is_empty() {
1673 String::new()
1674 } else {
1675 Self::strip_indentation_columns(line, 4)
1676 };
1677
1678 if misplaced_fence_lines[i] {
1681 result.push_str(line.trim_start());
1683 result.push('\n');
1684 } else if unsafe_fence_lines[i] {
1685 result.push_str(line);
1688 result.push('\n');
1689 } else if !prev_line_is_indented && !in_indented_block {
1690 current_block_fence_indent = " ".repeat(baseline);
1692 result.push_str(¤t_block_fence_indent);
1693 result.push_str(Self::FENCE);
1694 result.push('\n');
1695 result.push_str(&body);
1696 result.push('\n');
1697 in_indented_block = true;
1698 } else {
1699 result.push_str(&body);
1701 result.push('\n');
1702 }
1703
1704 let next_line_is_indented = i < lines.len() - 1 && block_lines[i + 1];
1706 if !next_line_is_indented
1708 && in_indented_block
1709 && !misplaced_fence_lines[i]
1710 && !unsafe_fence_lines[i]
1711 {
1712 result.push_str(¤t_block_fence_indent);
1713 result.push_str(Self::FENCE);
1714 result.push('\n');
1715 in_indented_block = false;
1716 current_block_fence_indent.clear();
1717 }
1718 } else {
1719 result.push_str(line);
1721 result.push('\n');
1722 }
1723 } else {
1724 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1726 result.push_str(¤t_block_fence_indent);
1727 result.push_str(Self::FENCE);
1728 result.push('\n');
1729 in_indented_block = false;
1730 current_block_fence_indent.clear();
1731 }
1732
1733 result.push_str(line);
1734 result.push('\n');
1735 }
1736 }
1737
1738 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1740 result.push_str(¤t_block_fence_indent);
1741 result.push_str(Self::FENCE);
1742 result.push('\n');
1743 }
1744
1745 if !content.ends_with('\n') && result.ends_with('\n') {
1747 result.pop();
1748 }
1749
1750 if retained_structurally_unsafe_fence && self.config.style == CodeBlockStyle::Consistent {
1751 return Self::new(CodeBlockStyle::Fenced).fix(ctx);
1752 }
1753
1754 if converted_fenced_to_indented {
1755 let reparsed_block_count = crate::utils::CodeBlockUtils::detect_code_blocks(&result).len();
1756 if reparsed_block_count != ctx.code_block_details.len() {
1757 if self.config.style == CodeBlockStyle::Consistent {
1762 return Self::new(CodeBlockStyle::Fenced).fix(ctx);
1763 }
1764
1765 return Ok(content.to_string());
1766 }
1767 }
1768
1769 if result == content || (content.contains('\r') && result == content.replace("\r\n", "\n")) {
1770 Ok(content.to_string())
1771 } else {
1772 Ok(crate::utils::ensure_consistent_line_endings(content, &result))
1773 }
1774 }
1775}
1776
1777#[cfg(test)]
1778mod tests {
1779 use super::*;
1780 use crate::lint_context::LintContext;
1781
1782 fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
1794 let flavor = if is_mkdocs {
1795 crate::config::MarkdownFlavor::MkDocs
1796 } else {
1797 crate::config::MarkdownFlavor::Standard
1798 };
1799 let ctx = LintContext::new(content, flavor, None);
1800 let lines: Vec<&str> = content.lines().collect();
1801 let in_list_context = rule.precompute_block_continuation_context(&lines);
1802 let in_tab_context = if is_mkdocs {
1803 rule.precompute_mkdocs_tab_context(&lines)
1804 } else {
1805 vec![false; lines.len()]
1806 };
1807 let in_admonition_context = if is_mkdocs {
1808 rule.precompute_mkdocs_admonition_context(&lines)
1809 } else {
1810 vec![false; lines.len()]
1811 };
1812 let in_comment_or_html = vec![false; lines.len()];
1813 let list_item_baseline: Vec<Option<usize>> = vec![None; lines.len()];
1819 let ictx = IndentContext {
1820 in_list_context: &in_list_context,
1821 in_tab_context: &in_tab_context,
1822 in_admonition_context: &in_admonition_context,
1823 in_comment_or_html: &in_comment_or_html,
1824 list_item_baseline: &list_item_baseline,
1825 };
1826 rule.detect_style(&ctx, &lines, is_mkdocs, &ictx)
1827 }
1828
1829 #[test]
1830 fn test_unclosed_fence_diagnostic_matches_document_fix() {
1831 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1832 for (content, expected) in [
1833 ("```", "```\n```"),
1834 ("```\ncode\n", "```\ncode\n```\n"),
1835 ("````\ncode\n```\n", "````\ncode\n```\n````\n"),
1836 ("> ```rust\n> code\n", "> ```rust\n> code\n> ```\n"),
1837 ("> > ~~~~\n> > code", "> > ~~~~\n> > code\n> > ~~~~"),
1838 ("- ```\n code\n", "- ```\n code\n ```\n"),
1839 (" ```\n code\n", " ```\n code\n ```\n"),
1840 ] {
1841 for newline in ["\n", "\r\n"] {
1842 let content = content.replace('\n', newline);
1843 let expected = if content.contains('\n') {
1844 expected.replace('\n', newline)
1845 } else {
1846 expected.to_string()
1847 };
1848 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1849 let warnings = rule.check(&ctx).unwrap();
1850 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1851 let edited = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings).unwrap();
1852 assert_eq!(edited, expected, "{content:?}");
1853 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1854 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1855 assert!(rule.check(&fixed_ctx).unwrap().is_empty(), "{expected:?}");
1856 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1857 }
1858 }
1859 }
1860
1861 #[test]
1862 fn test_closed_quote_fence_at_eof_is_unchanged() {
1863 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1864 for content in [
1865 "> - item\n> ```\n> code\n> ```",
1866 "> > ~~~~\n> > code\n> > ~~~~",
1867 "```\r\ncode\r\n```\r\n",
1868 "Text\r\n\n- item\r\n",
1869 ] {
1870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1871 let warnings = rule.check(&ctx).unwrap();
1872 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1873 assert_eq!(rule.fix(&ctx).unwrap(), content);
1874 }
1875 }
1876
1877 #[test]
1878 fn test_unclosed_quote_repair_keeps_unsupported_style_warning() {
1879 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1880 let content = "> ```\n> code\n";
1881 let expected = "> ```\n> code\n> ```\n";
1882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1883 let warnings = rule.check(&ctx).unwrap();
1884 assert_eq!(warnings.len(), 1);
1885 assert_eq!(
1886 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
1887 expected
1888 );
1889 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1890 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
1891 let remaining = rule.check(&fixed_ctx).unwrap();
1892 assert_eq!(remaining.len(), 1);
1893 assert_eq!(remaining[0].message, "Use indented code blocks");
1894 assert!(
1895 remaining[0].fix.is_none(),
1896 "Container conversion is intentionally unsupported"
1897 );
1898 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1899 }
1900
1901 #[test]
1902 fn test_unclosed_fence_diagnostic_includes_indented_conversion() {
1903 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1904 let content = "```\ncode\n";
1905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1906 let warnings = rule.check(&ctx).unwrap();
1907 assert_eq!(warnings.len(), 1);
1908 let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1909 assert_eq!(edited, " code\n");
1910 assert_eq!(rule.fix(&ctx).unwrap(), edited);
1911 let fixed_ctx = LintContext::new(&edited, crate::config::MarkdownFlavor::Standard, None);
1912 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1913 assert_eq!(rule.fix(&fixed_ctx).unwrap(), edited);
1914 }
1915
1916 #[test]
1917 fn test_fenced_code_block_detection() {
1918 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1919 assert!(rule.is_fenced_code_block_start("```"));
1920 assert!(rule.is_fenced_code_block_start("```rust"));
1921 assert!(rule.is_fenced_code_block_start("~~~"));
1922 assert!(rule.is_fenced_code_block_start("~~~python"));
1923 assert!(rule.is_fenced_code_block_start(" ```"));
1924 assert!(!rule.is_fenced_code_block_start("``"));
1925 assert!(!rule.is_fenced_code_block_start("~~"));
1926 assert!(!rule.is_fenced_code_block_start("Regular text"));
1927 }
1928
1929 #[test]
1930 fn test_fix_capability_is_conditional() {
1931 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1932 assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
1933 }
1934
1935 #[test]
1936 fn test_consistent_style_with_fenced_blocks() {
1937 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1938 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1940 let result = rule.check(&ctx).unwrap();
1941
1942 assert_eq!(result.len(), 0);
1944 }
1945
1946 #[test]
1947 fn test_consistent_style_with_indented_blocks() {
1948 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1949 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951 let result = rule.check(&ctx).unwrap();
1952
1953 assert_eq!(result.len(), 0);
1955 }
1956
1957 #[test]
1958 fn test_consistent_style_mixed() {
1959 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1960 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1962 let result = rule.check(&ctx).unwrap();
1963
1964 assert!(!result.is_empty());
1966 }
1967
1968 #[test]
1969 fn test_fenced_style_with_indented_blocks() {
1970 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1971 let content = "Text\n\n indented code\n more code\n\nMore text";
1972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1973 let result = rule.check(&ctx).unwrap();
1974
1975 assert!(!result.is_empty());
1977 assert!(result[0].message.contains("Use fenced code blocks"));
1978 }
1979
1980 #[test]
1981 fn test_fenced_style_with_tab_indented_blocks() {
1982 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1983 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1985 let result = rule.check(&ctx).unwrap();
1986
1987 assert!(!result.is_empty());
1989 assert!(result[0].message.contains("Use fenced code blocks"));
1990 }
1991
1992 #[test]
1993 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1994 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1995 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998 let result = rule.check(&ctx).unwrap();
1999
2000 assert!(
2002 !result.is_empty(),
2003 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
2004 );
2005 assert!(result[0].message.contains("Use fenced code blocks"));
2006 }
2007
2008 #[test]
2009 fn test_fenced_style_with_one_space_tab_indent() {
2010 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2011 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
2013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014 let result = rule.check(&ctx).unwrap();
2015
2016 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
2017 assert!(result[0].message.contains("Use fenced code blocks"));
2018 }
2019
2020 #[test]
2021 fn test_indented_style_with_fenced_blocks() {
2022 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2023 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
2024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2025 let result = rule.check(&ctx).unwrap();
2026
2027 assert!(!result.is_empty());
2029 assert!(result[0].message.contains("Use indented code blocks"));
2030 }
2031
2032 #[test]
2033 fn test_unclosed_code_block() {
2034 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2035 let content = "```\ncode without closing fence";
2036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2037 let result = rule.check(&ctx).unwrap();
2038
2039 assert_eq!(result.len(), 1);
2040 assert!(result[0].message.contains("never closed"));
2041 }
2042
2043 #[test]
2044 fn test_nested_code_blocks() {
2045 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2046 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
2047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2048 let result = rule.check(&ctx).unwrap();
2049
2050 assert_eq!(result.len(), 0);
2052 }
2053
2054 #[test]
2055 fn test_fix_indented_to_fenced() {
2056 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2057 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
2058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2059 let fixed = rule.fix(&ctx).unwrap();
2060
2061 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
2062 }
2063
2064 #[test]
2065 fn test_fix_fenced_to_indented() {
2066 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2067 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
2068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2069 let fixed = rule.fix(&ctx).unwrap();
2070
2071 assert!(fixed.contains(" code line 1\n code line 2"));
2072 assert!(!fixed.contains("```"));
2073 }
2074
2075 #[test]
2076 fn test_fix_fenced_to_indented_blank_lines_have_no_trailing_spaces() {
2077 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2081 let content = "Text\n\n```\ncode line 1\n\ncode line 2\n```\n\nMore text";
2082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2083 let fixed = rule.fix(&ctx).unwrap();
2084
2085 for line in fixed.lines() {
2086 assert!(
2087 line.is_empty() || !line.trim_end().is_empty() || line == line.trim_end(),
2088 "no line may have trailing whitespace, got {line:?}"
2089 );
2090 assert_ne!(line, " ", "blank line was indented to trailing spaces");
2091 }
2092 assert!(fixed.contains(" code line 1\n\n code line 2"));
2094 }
2095
2096 #[test]
2097 fn test_is_list_item_requires_delimiter_after_digits() {
2098 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2099 assert!(rule.is_list_item("1. First"));
2101 assert!(rule.is_list_item("42) Item"));
2102 assert!(rule.is_list_item(" 3. Indented item"));
2103 assert!(rule.is_list_item("- bullet"));
2105 assert!(rule.is_list_item("* bullet"));
2106 assert!(!rule.is_list_item("2 results. More info."));
2109 assert!(!rule.is_list_item("3 options (a, b) here"));
2110 assert!(!rule.is_list_item("100 items in stock. Buy now"));
2111 }
2112
2113 #[test]
2114 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
2115 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2118 let content = r#"# Test
2119
2120```
2121<!doctype html>
2122<html>
2123 <head>
2124 <title>Test</title>
2125 </head>
2126</html>
2127```
2128"#;
2129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2130 let fixed = rule.fix(&ctx).unwrap();
2131
2132 assert!(
2135 fixed.contains(" <head>"),
2136 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
2137 );
2138 assert!(
2139 fixed.contains(" <title>"),
2140 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
2141 );
2142 assert!(!fixed.contains("```"), "Fenced markers should be removed");
2143 }
2144
2145 #[test]
2146 fn test_fix_fenced_to_indented_preserves_python_indentation() {
2147 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2149 let content = r#"# Python Example
2150
2151```
2152def greet(name):
2153 if name:
2154 print(f"Hello, {name}!")
2155 else:
2156 print("Hello, World!")
2157```
2158"#;
2159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2160 let fixed = rule.fix(&ctx).unwrap();
2161
2162 assert!(
2164 fixed.contains(" def greet(name):"),
2165 "Function def should have 4 spaces (code block indent)"
2166 );
2167 assert!(
2168 fixed.contains(" if name:"),
2169 "if statement should have 8 spaces (4 code + 4 Python)"
2170 );
2171 assert!(
2172 fixed.contains(" print"),
2173 "print should have 12 spaces (4 code + 8 Python)"
2174 );
2175 }
2176
2177 #[test]
2178 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
2179 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2181 let content = r#"# Config
2182
2183```
2184server:
2185 host: localhost
2186 port: 8080
2187 ssl:
2188 enabled: true
2189 cert: /path/to/cert
2190```
2191"#;
2192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2193 let fixed = rule.fix(&ctx).unwrap();
2194
2195 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
2196 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
2197 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
2198 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
2199 }
2200
2201 #[test]
2202 fn test_fix_fenced_to_indented_preserves_empty_lines() {
2203 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2207 let content = "```\nline1\n\nline2\n```\n";
2208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2209 let fixed = rule.fix(&ctx).unwrap();
2210
2211 assert!(fixed.contains(" line1"), "line1 should be indented");
2213 assert!(fixed.contains(" line2"), "line2 should be indented");
2214 assert!(
2215 fixed.contains(" line1\n\n line2"),
2216 "blank line must stay empty, got {fixed:?}"
2217 );
2218 }
2219
2220 #[test]
2221 fn test_fix_fenced_to_indented_multiple_blocks() {
2222 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2224 let content = r#"# Doc
2225
2226```
2227def foo():
2228 pass
2229```
2230
2231Text between.
2232
2233```
2234key:
2235 value: 1
2236```
2237"#;
2238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2239 let fixed = rule.fix(&ctx).unwrap();
2240
2241 assert!(fixed.contains(" def foo():"), "Python def should be indented");
2242 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
2243 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
2244 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
2245 assert!(!fixed.contains("```"), "No fence markers should remain");
2246 }
2247
2248 #[test]
2249 fn test_fix_unclosed_block() {
2250 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2251 let content = "```\ncode without closing";
2252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2253 let fixed = rule.fix(&ctx).unwrap();
2254
2255 assert!(fixed.ends_with("```"));
2257 }
2258
2259 #[test]
2260 fn test_code_block_in_list() {
2261 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2262 let content = "- List item\n code in list\n more code\n- Next item";
2263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2264 let result = rule.check(&ctx).unwrap();
2265
2266 assert_eq!(result.len(), 0);
2268 }
2269
2270 #[test]
2271 fn test_detect_style_fenced() {
2272 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2273 let content = "```\ncode\n```";
2274 let style = detect_style_from_content(&rule, content, false);
2275
2276 assert_eq!(style, Some(CodeBlockStyle::Fenced));
2277 }
2278
2279 #[test]
2280 fn test_detect_style_indented() {
2281 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2282 let content = "Text\n\n code\n\nMore";
2283 let style = detect_style_from_content(&rule, content, false);
2284
2285 assert_eq!(style, Some(CodeBlockStyle::Indented));
2286 }
2287
2288 #[test]
2289 fn test_detect_style_none() {
2290 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2291 let content = "No code blocks here";
2292 let style = detect_style_from_content(&rule, content, false);
2293
2294 assert_eq!(style, None);
2295 }
2296
2297 #[test]
2298 fn test_tilde_fence() {
2299 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2300 let content = "~~~\ncode\n~~~";
2301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2302 let result = rule.check(&ctx).unwrap();
2303
2304 assert_eq!(result.len(), 0);
2306 }
2307
2308 #[test]
2309 fn test_language_specification() {
2310 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2311 let content = "```rust\nfn main() {}\n```";
2312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2313 let result = rule.check(&ctx).unwrap();
2314
2315 assert_eq!(result.len(), 0);
2316 }
2317
2318 #[test]
2319 fn test_empty_content() {
2320 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2321 let content = "";
2322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2323 let result = rule.check(&ctx).unwrap();
2324
2325 assert_eq!(result.len(), 0);
2326 }
2327
2328 #[test]
2329 fn test_default_config() {
2330 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2331 let (name, _config) = rule.default_config_section().unwrap();
2332 assert_eq!(name, "MD046");
2333 }
2334
2335 #[test]
2336 fn test_markdown_documentation_block() {
2337 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2338 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
2339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2340 let result = rule.check(&ctx).unwrap();
2341
2342 assert_eq!(result.len(), 0);
2344 }
2345
2346 #[test]
2347 fn test_preserve_trailing_newline() {
2348 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2349 let content = "```\ncode\n```\n";
2350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2351 let fixed = rule.fix(&ctx).unwrap();
2352
2353 assert_eq!(fixed, content);
2354 }
2355
2356 #[test]
2357 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
2358 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2359 let content = r#"# Document
2360
2361=== "Python"
2362
2363 This is tab content
2364 Not an indented code block
2365
2366 ```python
2367 def hello():
2368 print("Hello")
2369 ```
2370
2371=== "JavaScript"
2372
2373 More tab content here
2374 Also not an indented code block"#;
2375
2376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2377 let result = rule.check(&ctx).unwrap();
2378
2379 assert_eq!(result.len(), 0);
2381 }
2382
2383 #[test]
2384 fn test_mkdocs_tabs_with_actual_indented_code() {
2385 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2386 let content = r#"# Document
2387
2388=== "Tab 1"
2389
2390 This is tab content
2391
2392Regular text
2393
2394 This is an actual indented code block
2395 Should be flagged"#;
2396
2397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2398 let result = rule.check(&ctx).unwrap();
2399
2400 assert_eq!(result.len(), 1);
2402 assert!(result[0].message.contains("Use fenced code blocks"));
2403 }
2404
2405 #[test]
2406 fn test_mkdocs_tabs_detect_style() {
2407 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2408 let content = r#"=== "Tab 1"
2409
2410 Content in tab
2411 More content
2412
2413=== "Tab 2"
2414
2415 Content in second tab"#;
2416
2417 let style = detect_style_from_content(&rule, content, true);
2419 assert_eq!(style, None); let style = detect_style_from_content(&rule, content, false);
2423 assert_eq!(style, Some(CodeBlockStyle::Indented));
2424 }
2425
2426 #[test]
2427 fn test_mkdocs_nested_tabs() {
2428 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2429 let content = r#"# Document
2430
2431=== "Outer Tab"
2432
2433 Some content
2434
2435 === "Nested Tab"
2436
2437 Nested tab content
2438 Should not be flagged"#;
2439
2440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2441 let result = rule.check(&ctx).unwrap();
2442
2443 assert_eq!(result.len(), 0);
2445 }
2446
2447 #[test]
2448 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
2449 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2452 let content = r#"# Document
2453
2454!!! note
2455 This is normal admonition content, not a code block.
2456 It spans multiple lines.
2457
2458??? warning "Collapsible Warning"
2459 This is also admonition content.
2460
2461???+ tip "Expanded Tip"
2462 And this one too.
2463
2464Regular text outside admonitions."#;
2465
2466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2467 let result = rule.check(&ctx).unwrap();
2468
2469 assert_eq!(
2471 result.len(),
2472 0,
2473 "Admonition content in MkDocs mode should not trigger MD046"
2474 );
2475 }
2476
2477 #[test]
2478 fn test_mkdocs_admonition_with_actual_indented_code() {
2479 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2481 let content = r#"# Document
2482
2483!!! note
2484 This is admonition content.
2485
2486Regular text ends the admonition.
2487
2488 This is actual indented code (should be flagged)"#;
2489
2490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2491 let result = rule.check(&ctx).unwrap();
2492
2493 assert_eq!(result.len(), 1);
2495 assert!(result[0].message.contains("Use fenced code blocks"));
2496 }
2497
2498 #[test]
2499 fn test_admonition_in_standard_mode_flagged() {
2500 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2504 let content = r#"# Document
2505
2506!!! note
2507
2508 This looks like code in standard mode.
2509
2510Regular text."#;
2511
2512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2514 let result = rule.check(&ctx).unwrap();
2515
2516 assert_eq!(
2518 result.len(),
2519 1,
2520 "Admonition content in Standard mode should be flagged as indented code"
2521 );
2522 }
2523
2524 #[test]
2525 fn test_mkdocs_admonition_with_fenced_code_inside() {
2526 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2528 let content = r#"# Document
2529
2530!!! note "Code Example"
2531 Here's some code:
2532
2533 ```python
2534 def hello():
2535 print("world")
2536 ```
2537
2538 More text after code.
2539
2540Regular text."#;
2541
2542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2543 let result = rule.check(&ctx).unwrap();
2544
2545 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
2547 }
2548
2549 #[test]
2550 fn test_mkdocs_nested_admonitions() {
2551 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2553 let content = r#"# Document
2554
2555!!! note "Outer"
2556 Outer content.
2557
2558 !!! warning "Inner"
2559 Inner content.
2560 More inner content.
2561
2562 Back to outer.
2563
2564Regular text."#;
2565
2566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2567 let result = rule.check(&ctx).unwrap();
2568
2569 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
2571 }
2572
2573 #[test]
2574 fn test_mkdocs_admonition_fix_does_not_wrap() {
2575 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2577 let content = r#"!!! note
2578 Content that should stay as admonition content.
2579 Not be wrapped in code fences.
2580"#;
2581
2582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2583 let fixed = rule.fix(&ctx).unwrap();
2584
2585 assert!(
2587 !fixed.contains("```\n Content"),
2588 "Admonition content should not be wrapped in fences"
2589 );
2590 assert_eq!(fixed, content, "Content should remain unchanged");
2591 }
2592
2593 #[test]
2594 fn test_mkdocs_empty_admonition() {
2595 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2597 let content = r#"!!! note
2598
2599Regular paragraph after empty admonition.
2600
2601 This IS an indented code block (after blank + non-indented line)."#;
2602
2603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2604 let result = rule.check(&ctx).unwrap();
2605
2606 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
2608 }
2609
2610 #[test]
2611 fn test_mkdocs_indented_admonition() {
2612 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2614 let content = r#"- List item
2615
2616 !!! note
2617 Indented admonition content.
2618 More content.
2619
2620- Next item"#;
2621
2622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2623 let result = rule.check(&ctx).unwrap();
2624
2625 assert_eq!(
2627 result.len(),
2628 0,
2629 "Indented admonitions (e.g., in lists) should not be flagged"
2630 );
2631 }
2632
2633 #[test]
2634 fn test_footnote_indented_paragraphs_not_flagged() {
2635 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2636 let content = r#"# Test Document with Footnotes
2637
2638This is some text with a footnote[^1].
2639
2640Here's some code:
2641
2642```bash
2643echo "fenced code block"
2644```
2645
2646More text with another footnote[^2].
2647
2648[^1]: Really interesting footnote text.
2649
2650 Even more interesting second paragraph.
2651
2652[^2]: Another footnote.
2653
2654 With a second paragraph too.
2655
2656 And even a third paragraph!"#;
2657
2658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2659 let result = rule.check(&ctx).unwrap();
2660
2661 assert_eq!(result.len(), 0);
2663 }
2664
2665 #[test]
2666 fn test_footnote_definition_detection() {
2667 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2668
2669 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
2672 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
2673 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
2674 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
2675 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
2676 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
2677 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
2678 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
2679 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
2680
2681 assert!(!rule.is_footnote_definition("[^]: No label"));
2683 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
2684 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
2685 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
2686
2687 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
2689 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
2690 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
2691 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
2694
2695 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
2697 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
2698 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
2699 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
2700 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
2701
2702 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
2705 }
2706
2707 #[test]
2708 fn test_footnote_with_blank_lines() {
2709 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2713 let content = r#"# Document
2714
2715Text with footnote[^1].
2716
2717[^1]: First paragraph.
2718
2719 Second paragraph after blank line.
2720
2721 Third paragraph after another blank line.
2722
2723Regular text at column 0 ends the footnote."#;
2724
2725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2726 let result = rule.check(&ctx).unwrap();
2727
2728 assert_eq!(
2730 result.len(),
2731 0,
2732 "Indented content within footnotes should not trigger MD046"
2733 );
2734 }
2735
2736 #[test]
2737 fn test_footnote_multiple_consecutive_blank_lines() {
2738 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2741 let content = r#"Text[^1].
2742
2743[^1]: First paragraph.
2744
2745
2746
2747 Content after three blank lines (still part of footnote).
2748
2749Not indented, so footnote ends here."#;
2750
2751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2752 let result = rule.check(&ctx).unwrap();
2753
2754 assert_eq!(
2756 result.len(),
2757 0,
2758 "Multiple blank lines shouldn't break footnote continuation"
2759 );
2760 }
2761
2762 #[test]
2763 fn test_footnote_terminated_by_non_indented_content() {
2764 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2767 let content = r#"[^1]: Footnote content.
2768
2769 More indented content in footnote.
2770
2771This paragraph is not indented, so footnote ends.
2772
2773 This should be flagged as indented code block."#;
2774
2775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2776 let result = rule.check(&ctx).unwrap();
2777
2778 assert_eq!(
2780 result.len(),
2781 1,
2782 "Indented code after footnote termination should be flagged"
2783 );
2784 assert!(
2785 result[0].message.contains("Use fenced code blocks"),
2786 "Expected MD046 warning for indented code block"
2787 );
2788 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2789 }
2790
2791 #[test]
2792 fn test_footnote_terminated_by_structural_elements() {
2793 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2795 let content = r#"[^1]: Footnote content.
2796
2797 More content.
2798
2799## Heading terminates footnote
2800
2801 This indented content should be flagged.
2802
2803---
2804
2805 This should also be flagged (after horizontal rule)."#;
2806
2807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2808 let result = rule.check(&ctx).unwrap();
2809
2810 assert_eq!(
2812 result.len(),
2813 2,
2814 "Both indented blocks after termination should be flagged"
2815 );
2816 }
2817
2818 #[test]
2819 fn test_footnote_with_code_block_inside() {
2820 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2823 let content = r#"Text[^1].
2824
2825[^1]: Footnote with code:
2826
2827 ```python
2828 def hello():
2829 print("world")
2830 ```
2831
2832 More footnote text after code."#;
2833
2834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2835 let result = rule.check(&ctx).unwrap();
2836
2837 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2839 }
2840
2841 #[test]
2842 fn test_footnote_with_8_space_indented_code() {
2843 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2846 let content = r#"Text[^1].
2847
2848[^1]: Footnote with nested code.
2849
2850 code block
2851 more code"#;
2852
2853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2854 let result = rule.check(&ctx).unwrap();
2855
2856 assert_eq!(
2858 result.len(),
2859 0,
2860 "8-space indented code within footnotes represents nested code blocks"
2861 );
2862 }
2863
2864 #[test]
2865 fn test_multiple_footnotes() {
2866 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2869 let content = r#"Text[^1] and more[^2].
2870
2871[^1]: First footnote.
2872
2873 Continuation of first.
2874
2875[^2]: Second footnote starts here, ending the first.
2876
2877 Continuation of second."#;
2878
2879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2880 let result = rule.check(&ctx).unwrap();
2881
2882 assert_eq!(
2884 result.len(),
2885 0,
2886 "Multiple footnotes should each maintain their continuation context"
2887 );
2888 }
2889
2890 #[test]
2891 fn test_list_item_ends_footnote_context() {
2892 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2894 let content = r#"[^1]: Footnote.
2895
2896 Content in footnote.
2897
2898- List item starts here (ends footnote context).
2899
2900 This indented content is part of the list, not the footnote."#;
2901
2902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2903 let result = rule.check(&ctx).unwrap();
2904
2905 assert_eq!(
2907 result.len(),
2908 0,
2909 "List items should end footnote context and start their own"
2910 );
2911 }
2912
2913 #[test]
2914 fn test_footnote_vs_actual_indented_code() {
2915 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2918 let content = r#"# Heading
2919
2920Text with footnote[^1].
2921
2922[^1]: Footnote content.
2923
2924 Part of footnote (should not be flagged).
2925
2926Regular paragraph ends footnote context.
2927
2928 This is actual indented code (MUST be flagged)
2929 Should be detected as code block"#;
2930
2931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2932 let result = rule.check(&ctx).unwrap();
2933
2934 assert_eq!(
2936 result.len(),
2937 1,
2938 "Must still detect indented code blocks outside footnotes"
2939 );
2940 assert!(
2941 result[0].message.contains("Use fenced code blocks"),
2942 "Expected MD046 warning for indented code"
2943 );
2944 assert!(
2945 result[0].line >= 11,
2946 "Warning should be on the actual indented code line"
2947 );
2948 }
2949
2950 #[test]
2951 fn test_spec_compliant_label_characters() {
2952 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2955
2956 assert!(rule.is_footnote_definition("[^test]: text"));
2958 assert!(rule.is_footnote_definition("[^TEST]: text"));
2959 assert!(rule.is_footnote_definition("[^test-name]: text"));
2960 assert!(rule.is_footnote_definition("[^test_name]: text"));
2961 assert!(rule.is_footnote_definition("[^test123]: text"));
2962 assert!(rule.is_footnote_definition("[^123]: text"));
2963 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2964
2965 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")); }
2973
2974 #[test]
2975 fn test_code_block_inside_html_comment() {
2976 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2979 let content = r#"# Document
2980
2981Some text.
2982
2983<!--
2984Example code block in comment:
2985
2986```typescript
2987console.log("Hello");
2988```
2989
2990More comment text.
2991-->
2992
2993More content."#;
2994
2995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2996 let result = rule.check(&ctx).unwrap();
2997
2998 assert_eq!(
2999 result.len(),
3000 0,
3001 "Code blocks inside HTML comments should not be flagged as unclosed"
3002 );
3003 }
3004
3005 #[test]
3006 fn test_unclosed_fence_inside_html_comment() {
3007 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3009 let content = r#"# Document
3010
3011<!--
3012Example with intentionally unclosed fence:
3013
3014```
3015code without closing
3016-->
3017
3018More content."#;
3019
3020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3021 let result = rule.check(&ctx).unwrap();
3022
3023 assert_eq!(
3024 result.len(),
3025 0,
3026 "Unclosed fences inside HTML comments should be ignored"
3027 );
3028 }
3029
3030 #[test]
3031 fn test_multiline_html_comment_with_indented_code() {
3032 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3034 let content = r#"# Document
3035
3036<!--
3037Example:
3038
3039 indented code
3040 more code
3041
3042End of comment.
3043-->
3044
3045Regular text."#;
3046
3047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3048 let result = rule.check(&ctx).unwrap();
3049
3050 assert_eq!(
3051 result.len(),
3052 0,
3053 "Indented code inside HTML comments should not be flagged"
3054 );
3055 }
3056
3057 #[test]
3058 fn test_code_block_after_html_comment() {
3059 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3061 let content = r#"# Document
3062
3063<!-- comment -->
3064
3065Text before.
3066
3067 indented code should be flagged
3068
3069More text."#;
3070
3071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3072 let result = rule.check(&ctx).unwrap();
3073
3074 assert_eq!(
3075 result.len(),
3076 1,
3077 "Code blocks after HTML comments should still be detected"
3078 );
3079 assert!(result[0].message.contains("Use fenced code blocks"));
3080 }
3081
3082 #[test]
3083 fn test_consistent_style_indented_html_comment() {
3084 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3090 let content = "# MD046 false-positive reproduction\n\
3091 \n\
3092 <!--\n \
3093 This is just an indented comment, not a code block.\n\
3094 \n \
3095 A second line is required to trigger the false-positive.\n\
3096 \n \
3097 Actually, three lines are required.\n\
3098 -->\n\
3099 \n\
3100 ```md\n\
3101 This should be fine, since it's the only code block and therefore consistent.\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 "A single fenced block and an indented HTML comment must produce no MD046 warnings",
3111 );
3112 }
3113
3114 #[test]
3115 fn test_consistent_style_indented_html_block() {
3116 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3123 let content = "# Heading\n\
3124 \n\
3125 <div class=\"note\">\n \
3126 line one of indented html content\n \
3127 line two of indented html content\n \
3128 line three of indented html content\n\
3129 </div>\n\
3130 \n\
3131 ```md\n\
3132 real fenced block\n\
3133 ```\n";
3134
3135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3136 let result = rule.check(&ctx).unwrap();
3137
3138 assert_eq!(
3139 result,
3140 vec![],
3141 "Indented content inside a raw HTML block must not influence MD046 style detection",
3142 );
3143 }
3144
3145 #[test]
3146 fn test_consistent_style_fake_fence_inside_html_comment() {
3147 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3153 let content = "# Title\n\
3154 \n\
3155 <!--\n\
3156 ```\n\
3157 fake fence inside comment\n\
3158 ```\n\
3159 -->\n\
3160 \n \
3161 real indented code block line 1\n \
3162 real indented code block line 2\n";
3163
3164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3165 let result = rule.check(&ctx).unwrap();
3166
3167 assert_eq!(
3168 result,
3169 vec![],
3170 "Fence markers inside an HTML comment must not influence MD046 style detection",
3171 );
3172 }
3173
3174 #[test]
3175 fn test_consistent_style_indented_footnote_definition() {
3176 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3180 let content = "# Heading\n\
3181 \n\
3182 Reference to a footnote[^note].\n\
3183 \n\
3184 [^note]: First line of the footnote.\n \
3185 Second indented continuation line.\n \
3186 Third indented continuation line.\n \
3187 Fourth indented continuation line.\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 "Footnote-definition continuation content must not influence MD046 style detection",
3200 );
3201 }
3202
3203 #[test]
3204 fn test_consistent_style_indented_blockquote() {
3205 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3210 let content = "# Heading\n\
3211 \n\
3212 > line one of quoted indented content\n\
3213 >\n\
3214 > line two of quoted indented content\n\
3215 >\n\
3216 > line three of quoted indented content\n\
3217 \n\
3218 ```md\n\
3219 real fenced block\n\
3220 ```\n";
3221
3222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3223 let result = rule.check(&ctx).unwrap();
3224
3225 assert_eq!(
3226 result,
3227 vec![],
3228 "Indented content inside a blockquote must not influence MD046 style detection",
3229 );
3230 }
3231
3232 #[test]
3233 fn test_consistent_style_genuine_indented_block_detected_as_indented() {
3234 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3239 let content = "# Heading\n\
3240 \n\
3241 Some prose.\n\
3242 \n \
3243 real indented code line 1\n \
3244 real indented code line 2\n";
3245
3246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3247 let result = rule.check(&ctx).unwrap();
3248
3249 assert_eq!(
3252 result,
3253 vec![],
3254 "A genuine top-level indented block must be detected as Indented style under Consistent",
3255 );
3256 }
3257
3258 #[test]
3259 fn test_consistent_style_skipped_lines_dont_override_real_block() {
3260 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3265 let content = "# Heading\n\
3266 \n\
3267 <!--\n \
3268 skipped indented comment line 1\n \
3269 skipped indented comment line 2\n\
3270 -->\n\
3271 \n\
3272 <!--\n \
3273 second skipped region\n \
3274 also skipped\n\
3275 -->\n\
3276 \n \
3277 real indented code line\n";
3278
3279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3280 let result = rule.check(&ctx).unwrap();
3281
3282 assert_eq!(
3283 result,
3284 vec![],
3285 "Skipped container lines must not outweigh the single real indented block",
3286 );
3287 }
3288
3289 #[test]
3290 fn test_consistent_style_fenced_wins_over_skipped_indented() {
3291 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3295 let content = "# Heading\n\
3296 \n\
3297 <!--\n \
3298 skipped indented region one\n \
3299 more of region one\n\
3300 -->\n\
3301 \n\
3302 <!--\n \
3303 skipped indented region two\n \
3304 more of region two\n\
3305 -->\n\
3306 \n\
3307 ```md\n\
3308 real fenced block\n\
3309 ```\n";
3310
3311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3312 let result = rule.check(&ctx).unwrap();
3313
3314 assert_eq!(
3315 result,
3316 vec![],
3317 "Fenced block must win when all indented lines are inside skipped containers",
3318 );
3319 }
3320
3321 #[test]
3322 fn test_four_space_indented_fence_is_not_valid_fence() {
3323 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3326
3327 assert!(rule.is_fenced_code_block_start("```"));
3329 assert!(rule.is_fenced_code_block_start(" ```"));
3330 assert!(rule.is_fenced_code_block_start(" ```"));
3331 assert!(rule.is_fenced_code_block_start(" ```"));
3332
3333 assert!(!rule.is_fenced_code_block_start(" ```"));
3335 assert!(!rule.is_fenced_code_block_start(" ```"));
3336 assert!(!rule.is_fenced_code_block_start(" ```"));
3337
3338 assert!(!rule.is_fenced_code_block_start("\t```"));
3340 }
3341
3342 #[test]
3343 fn test_issue_237_indented_fenced_block_detected_as_indented() {
3344 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3350
3351 let content = r#"## Test
3353
3354 ```js
3355 var foo = "hello";
3356 ```
3357"#;
3358
3359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3360 let result = rule.check(&ctx).unwrap();
3361
3362 assert_eq!(
3364 result.len(),
3365 1,
3366 "4-space indented fence should be detected as indented code block"
3367 );
3368 assert!(
3369 result[0].message.contains("Use fenced code blocks"),
3370 "Expected 'Use fenced code blocks' message"
3371 );
3372 }
3373
3374 #[test]
3375 fn test_issue_276_indented_code_in_list() {
3376 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3379
3380 let content = r#"1. First item
33812. Second item with code:
3382
3383 # This is a code block in a list
3384 print("Hello, world!")
3385
33864. Third item"#;
3387
3388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3389 let result = rule.check(&ctx).unwrap();
3390
3391 assert!(
3393 !result.is_empty(),
3394 "Indented code block inside list should be flagged when style=fenced"
3395 );
3396 assert!(
3397 result[0].message.contains("Use fenced code blocks"),
3398 "Expected 'Use fenced code blocks' message"
3399 );
3400 }
3401
3402 #[test]
3403 fn test_three_space_indented_fence_is_valid() {
3404 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3406
3407 let content = r#"## Test
3408
3409 ```js
3410 var foo = "hello";
3411 ```
3412"#;
3413
3414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3415 let result = rule.check(&ctx).unwrap();
3416
3417 assert_eq!(
3419 result.len(),
3420 0,
3421 "3-space indented fence should be recognized as valid fenced code block"
3422 );
3423 }
3424
3425 #[test]
3426 fn test_indented_style_with_deeply_indented_fenced() {
3427 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3430
3431 let content = r#"Text
3432
3433 ```js
3434 var foo = "hello";
3435 ```
3436
3437More text
3438"#;
3439
3440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3441 let result = rule.check(&ctx).unwrap();
3442
3443 assert_eq!(
3446 result.len(),
3447 0,
3448 "4-space indented content should be valid when style=indented"
3449 );
3450 }
3451
3452 #[test]
3453 fn test_fix_misplaced_fenced_block() {
3454 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3457
3458 let content = r#"## Test
3459
3460 ```js
3461 var foo = "hello";
3462 ```
3463"#;
3464
3465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3466 let fixed = rule.fix(&ctx).unwrap();
3467
3468 let expected = r#"## Test
3470
3471```js
3472var foo = "hello";
3473```
3474"#;
3475
3476 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
3477 }
3478
3479 #[test]
3480 fn test_fix_regular_indented_block() {
3481 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3484
3485 let content = r#"Text
3486
3487 var foo = "hello";
3488 console.log(foo);
3489
3490More text
3491"#;
3492
3493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3494 let fixed = rule.fix(&ctx).unwrap();
3495
3496 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
3498 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
3499 }
3500
3501 #[test]
3502 fn test_fix_indented_block_with_fence_like_content() {
3503 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3507
3508 let content = r#"Text
3509
3510 some code
3511 ```not a fence opener
3512 more code
3513"#;
3514
3515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3516 let fixed = rule.fix(&ctx).unwrap();
3517
3518 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
3520 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
3521 }
3522
3523 #[test]
3524 fn test_fix_mixed_indented_and_misplaced_blocks() {
3525 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3527
3528 let content = r#"Text
3529
3530 regular indented code
3531
3532More text
3533
3534 ```python
3535 print("hello")
3536 ```
3537"#;
3538
3539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3540 let fixed = rule.fix(&ctx).unwrap();
3541
3542 assert!(
3544 fixed.contains("```\nregular indented code\n```"),
3545 "First block should be wrapped in fences"
3546 );
3547
3548 assert!(
3550 fixed.contains("\n```python\nprint(\"hello\")\n```"),
3551 "Second block should be dedented, not double-wrapped"
3552 );
3553 assert!(
3555 !fixed.contains("```\n```python"),
3556 "Should not have nested fence openers"
3557 );
3558 }
3559
3560 #[test]
3561 fn test_md046_front_matter() {
3562 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3563 let content = "---\nmetadata:\n\n description: Indented\n---\n";
3564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3565 let result = rule.check(&ctx).unwrap();
3566 assert_eq!(result.len(), 0);
3567 }
3568
3569 #[test]
3570 fn test_md046_fix_front_matter() {
3571 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3572 let content = "---\nmetadata:\n\n description: Indented\n---\n";
3573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3574 let fixed = rule.fix(&ctx).unwrap();
3575 assert_eq!(fixed, content);
3576 }
3577
3578 #[test]
3579 fn test_whitespace_only_line_is_not_an_indented_code_block() {
3580 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3585 let content = "# T\n\nPara\n\n \nMore\n\n real code\n\nEnd\n";
3586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3587 let fixed = rule.fix(&ctx).unwrap();
3588 assert_eq!(fixed, "# T\n\nPara\n\n \nMore\n\n```\nreal code\n```\n\nEnd\n");
3589 }
3590
3591 #[test]
3592 fn test_interior_blank_line_keeps_indented_block_together() {
3593 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3597 let content = "# T\n\nPara\n\n a\n\n b\n\nAfter\n";
3598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3599 let fixed = rule.fix(&ctx).unwrap();
3600 assert_eq!(fixed, "# T\n\nPara\n\n```\na\n\nb\n```\n\nAfter\n");
3601 }
3602
3603 #[test]
3604 fn test_consistent_style_counts_a_block_with_interior_blank_once() {
3605 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3609 let content = "# T\n\n```\nfenced\n```\n\nPara\n\n a\n\n b\n\nEnd\n";
3610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3611 let result = rule.check(&ctx).unwrap();
3612 let reported: Vec<(usize, &str)> = result.iter().map(|w| (w.line, w.message.as_str())).collect();
3613 assert_eq!(reported, vec![(9, "Use fenced code blocks")]);
3614 }
3615
3616 #[test]
3617 fn test_indented_lazy_continuation_lines_are_not_code() {
3618 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3624 let content = "# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n real code\n\nEnd\n";
3625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3626 let fixed = rule.fix(&ctx).unwrap();
3627 assert_eq!(
3628 fixed,
3629 "# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n```\nreal code\n```\n\nEnd\n"
3630 );
3631 }
3632
3633 #[test]
3634 fn test_misplaced_fence_with_interior_blank_dedents_as_one_block() {
3635 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3639 let content = "# T\n\nPara\n\n ```python\n x = 1\n\n y = 2\n ```\n\nAfter\n";
3640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3641 let fixed = rule.fix(&ctx).unwrap();
3642 assert_eq!(fixed, "# T\n\nPara\n\n```python\nx = 1\n\ny = 2\n```\n\nAfter\n");
3643 }
3644 #[test]
3645 fn test_mdg_overrides_indented_style_to_fenced() {
3646 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3651 let content = "# Feature: Payloads\n\n## Scenario: JSON payload\n\n* Given this payload\n\n ```json\n {\"ok\": true}\n ```\n";
3652
3653 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3654 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3655 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3656
3657 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3660 let standard_warnings = rule.check(&standard_ctx).unwrap();
3661 assert_eq!(standard_warnings.len(), 1);
3662 assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3663 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3664 }
3665
3666 #[test]
3667 fn test_mdg_indented_style_still_fences_indented_blocks() {
3668 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3672 let content =
3673 "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n ordinary indented code\n";
3674
3675 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3676 let warnings = rule.check(&mdg_ctx).unwrap();
3677 assert_eq!(warnings.len(), 1);
3678 assert_eq!(warnings[0].message, "Use fenced code blocks");
3679
3680 let fixed = rule.fix(&mdg_ctx).unwrap();
3681 assert_eq!(
3682 fixed,
3683 "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n```\n ordinary indented code\n```\n"
3684 );
3685
3686 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3687 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3688 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3689
3690 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3693 assert!(rule.check(&standard_ctx).unwrap().is_empty());
3694 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3695 }
3696
3697 #[test]
3698 fn test_mdg_steers_indented_code_to_fenced() {
3699 let content = "# Feature: Payloads\n\n## Scenario: Plain payload\n\n* Given this payload\n\n ordinary indented code\n";
3703
3704 for rule in [
3705 MD046CodeBlockStyle::new(CodeBlockStyle::Fenced),
3706 MD046CodeBlockStyle::new(CodeBlockStyle::Consistent),
3707 MD046CodeBlockStyle::new(CodeBlockStyle::Indented),
3708 ] {
3709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3710 let warnings = rule.check(&ctx).unwrap();
3711 assert_eq!(warnings.len(), 1);
3712 assert_eq!(warnings[0].message, "Use fenced code blocks");
3713
3714 let fixed = rule.fix(&ctx).unwrap();
3715 assert!(fixed.contains("```"), "MDG must fence the block: {fixed:?}");
3716
3717 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3718 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3719 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3720 }
3721 }
3722
3723 #[test]
3724 fn test_mdg_consistent_style_ignores_indented_prevalence() {
3725 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3728 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";
3729
3730 let standard_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::Standard, None);
3731 let standard_warnings = rule.check(&standard_ctx).unwrap();
3732 assert_eq!(standard_warnings.len(), 1);
3733 assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3734
3735 let mdg_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::MDG, None);
3736 let mdg_warnings = rule.check(&mdg_ctx).unwrap();
3737 assert_eq!(mdg_warnings.len(), 2);
3738 assert!(
3739 mdg_warnings
3740 .iter()
3741 .all(|warning| warning.message == "Use fenced code blocks")
3742 );
3743 }
3744
3745 #[test]
3746 fn test_mdg_repairs_unclosed_fence_like_standard() {
3747 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3750 let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3751
3752 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3753 let warnings = rule.check(&mdg_ctx).unwrap();
3754 assert_eq!(warnings.len(), 1);
3755 assert!(warnings[0].message.contains("never closed"));
3756
3757 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3758 assert_eq!(
3759 rule.fix(&mdg_ctx).unwrap(),
3760 rule.fix(&standard_ctx).unwrap(),
3761 "MDG must not differ from Standard"
3762 );
3763 }
3764
3765 #[test]
3766 fn test_mdg_table_above_prose_is_never_fenced() {
3767 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3773 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";
3774
3775 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3776 let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3777 assert_eq!(reported, vec![8, 12]);
3778
3779 let fixed = rule.fix(&mdg_ctx).unwrap();
3780 assert_eq!(
3781 fixed,
3782 "# 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"
3783 );
3784
3785 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3786 assert!(
3787 rule.check(&fixed_ctx).unwrap().is_empty(),
3788 "MDG check must have nothing left to report after its own fix"
3789 );
3790 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3791
3792 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3794 let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3795 assert_eq!(standard_reported, vec![5, 12]);
3796 assert!(rule.fix(&standard_ctx).unwrap().contains("```\n| start | eat | left |"));
3797 }
3798
3799 #[test]
3800 fn test_mdg_repairs_unclosed_fence_under_indented_style() {
3801 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3805 let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3806
3807 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3808 let warnings = rule.check(&mdg_ctx).unwrap();
3809 assert_eq!(warnings.len(), 1);
3810 assert!(warnings[0].message.contains("never closed"));
3811
3812 let fixed = rule.fix(&mdg_ctx).unwrap();
3813 assert_eq!(fixed, "# Feature: Payloads\n\n```json\n{\"ok\": true}\n```\n");
3814
3815 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3816 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3817
3818 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3821 assert_eq!(rule.fix(&standard_ctx).unwrap(), fixed);
3822 }
3823
3824 #[test]
3825 fn test_mdg_tab_indented_table_is_not_code() {
3826 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3830 for indent in ["\t\t", " \t"] {
3831 let content = format!(
3832 "# Feature: Eating\n\n#### Examples:\n\n{indent}| start | eat |\n{indent}| ----- | --- |\n\n## Scenario: Other\n\n code here\n"
3833 );
3834
3835 let mdg_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
3836 let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3837 assert_eq!(reported, vec![10], "tab-indented rows are a table, not code");
3838
3839 let fixed = rule.fix(&mdg_ctx).unwrap();
3840 assert!(
3841 fixed.contains(&format!("{indent}| start | eat |\n{indent}| ----- | --- |")),
3842 "MDG must leave the tab-indented table alone: {fixed:?}"
3843 );
3844
3845 let standard_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3846 let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3847 assert_eq!(standard_reported, vec![5, 10]);
3848 }
3849 }
3850
3851 #[test]
3852 fn test_from_config_records_whether_style_was_configured() {
3853 use crate::config::Config;
3857 use std::collections::BTreeMap;
3858
3859 let mut values = BTreeMap::new();
3860 values.insert("style".to_string(), toml::Value::String("indented".to_string()));
3861 let mut config = Config::default();
3862 config.rules.insert(
3863 "MD046".to_string(),
3864 crate::config::RuleConfig { severity: None, values },
3865 );
3866
3867 let configured = MD046CodeBlockStyle::from_config(&config);
3868 let configured = configured.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
3869 assert_eq!(configured.config.style, CodeBlockStyle::Indented);
3870 assert!(configured.style_explicit);
3871
3872 let defaulted = MD046CodeBlockStyle::from_config(&Config::default());
3873 let defaulted = defaulted.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
3874 assert!(!defaulted.style_explicit);
3875
3876 let indented = MD046CodeBlockStyle::from_config_struct(MD046Config {
3879 style: CodeBlockStyle::Indented,
3880 });
3881 let content = "# Feature: F\n\nText.\n\n code here\n";
3882 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3883 assert!(indented.fix(&mdg_ctx).unwrap().contains("```\n code here\n```"));
3884 }
3885
3886 #[test]
3887 fn test_mdg_indented_style_keeps_tables_out_of_code() {
3888 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3891 let content = "# Feature: Eating\n\n#### Examples:\n\n | start | eat | left |\n | ----- | --- | ---- |\n";
3892
3893 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3894 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3895 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3896
3897 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3900 assert!(rule.check(&standard_ctx).unwrap().is_empty());
3901 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3902 }
3903}