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 let mut last_item_quoted = false;
381
382 for (i, line) in lines.iter().enumerate() {
383 let trimmed = line.trim_start();
384 let indent_len = line.len() - trimmed.len();
385
386 if let Some(info) = ctx.line_info(i + 1)
388 && let Some(item) = info.list_item.as_ref()
389 {
390 last_baseline = Some(item.content_column);
391 last_list_item_line = Some(i);
392 last_item_quoted = info.blockquote.is_some();
393 blank_line_count = 0;
394 baselines[i] = last_baseline;
395 continue;
396 }
397
398 if last_item_quoted && line.trim().is_empty() {
401 last_baseline = None;
402 last_list_item_line = None;
403 last_item_quoted = false;
404 continue;
405 }
406
407 if line.trim().is_empty() {
409 if last_baseline.is_some() {
410 blank_line_count += 1;
411 baselines[i] = last_baseline;
412 }
413 continue;
414 }
415
416 if indent_len == 0 {
420 if trimmed.starts_with('#') || trimmed.starts_with("---") || trimmed.starts_with("***") {
421 last_baseline = None;
422 last_list_item_line = None;
423 } else if let Some(list_line) = last_list_item_line
424 && (i - list_line > 5 || blank_line_count > 1)
425 {
426 last_baseline = None;
427 last_list_item_line = None;
428 }
429 blank_line_count = 0;
430 continue;
431 }
432
433 if last_baseline.is_some() {
435 baselines[i] = last_baseline;
436 blank_line_count = 0;
437 }
438 }
439
440 baselines
441 }
442
443 fn is_indented_code_block_with_context(
447 &self,
448 lines: &[&str],
449 i: usize,
450 is_mkdocs: bool,
451 ctx: &IndentContext,
452 prev_is_code: bool,
453 ) -> bool {
454 if i >= lines.len() {
455 return false;
456 }
457
458 let line = lines[i];
459
460 if line.trim().is_empty() {
465 return false;
466 }
467
468 let indent = calculate_indentation_width_default(line);
470 if indent < 4 {
471 return false;
472 }
473
474 if ctx.in_list_context[i] {
480 let crosses_baseline = ctx
481 .list_item_baseline
482 .get(i)
483 .copied()
484 .flatten()
485 .is_some_and(|base| indent >= base + 4);
486 if !crosses_baseline {
487 return false;
488 }
489 }
490
491 if is_mkdocs && ctx.in_tab_context[i] {
493 return false;
494 }
495
496 if is_mkdocs && ctx.in_admonition_context[i] {
499 return false;
500 }
501
502 if ctx.in_comment_or_html.get(i).copied().unwrap_or(false) {
508 return false;
509 }
510
511 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
519 has_blank_line_before || prev_is_code
520 }
521
522 fn first_code_block_line(
531 ctx: &crate::lint_context::LintContext,
532 block_lines: &[bool],
533 start: usize,
534 block_end: usize,
535 ) -> Option<usize> {
536 (start..block_lines.len())
537 .take_while(|&idx| ctx.line_offsets.get(idx).is_some_and(|&offset| offset < block_end))
538 .find(|&idx| block_lines[idx])
539 }
540
541 fn indented_block_lines(
557 &self,
558 lines: &[&str],
559 is_mkdocs: bool,
560 ictx: &IndentContext<'_>,
561 ctx: &crate::lint_context::LintContext,
562 ) -> Vec<bool> {
563 let mut member = vec![false; lines.len()];
564 for i in 0..lines.len() {
565 let prev_is_code = i > 0 && member[i - 1];
566 member[i] = self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx, prev_is_code);
567 }
568
569 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
576 let mut i = 0;
577 while i < member.len() {
578 if !member[i] {
579 i += 1;
580 continue;
581 }
582 let start = i;
583 while i < member.len() && member[i] {
584 i += 1;
585 }
586 if lines[start..i].iter().all(|line| mdg::is_table_row(line)) {
587 member[start..i].fill(false);
588 }
589 }
590 }
591
592 let mut i = 0;
593 while i < lines.len() {
594 if !member[i] {
595 i += 1;
596 continue;
597 }
598 let mut next = i + 1;
599 while next < lines.len() && lines[next].trim().is_empty() {
600 next += 1;
601 }
602 if next < lines.len() && member[next] {
603 member[i + 1..next].fill(true);
604 }
605 i = next;
606 }
607
608 member
609 }
610
611 fn precompute_comment_or_html_context(ctx: &crate::lint_context::LintContext, line_count: usize) -> Vec<bool> {
620 let mut ignored: Vec<bool> = (0..line_count)
621 .map(|i| {
622 ctx.line_info(i + 1).is_some_and(|info| {
623 info.in_html_comment
624 || info.in_mdx_comment
625 || info.in_html_block
626 || info.in_jsx_block
627 || info.in_mkdocstrings
628 || info.in_footnote_definition
629 || info.blockquote.is_some()
630 || info.in_front_matter
631 })
632 })
633 .collect();
634 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
635 for detail in &ctx.code_block_details {
636 if Self::block_is_literal(ctx, detail, ambiguity_limit)
637 && let Some(first) = Self::code_block_start_line(ctx, detail)
638 {
639 let last = ctx.line_offsets.partition_point(|&offset| offset < detail.end);
640 ignored[first..last.min(line_count)].fill(true);
641 }
642 }
643 if let Some(limit) = ambiguity_limit {
644 let first = ctx
645 .line_offsets
646 .partition_point(|&offset| offset <= limit)
647 .saturating_sub(1);
648 ignored[first..].fill(true);
649 }
650 ignored
651 }
652
653 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
655 let mut in_tab_context = vec![false; lines.len()];
656 let mut current_tab_indent: Option<usize> = None;
657
658 for (i, line) in lines.iter().enumerate() {
659 if mkdocs_tabs::is_tab_marker(line) {
661 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
662 current_tab_indent = Some(tab_indent);
663 in_tab_context[i] = true;
664 continue;
665 }
666
667 if let Some(tab_indent) = current_tab_indent {
669 if mkdocs_tabs::is_tab_content(line, tab_indent) {
670 in_tab_context[i] = true;
671 } else if !line.trim().is_empty() && calculate_indentation_width_default(line) < 4 {
672 current_tab_indent = None;
674 } else {
675 in_tab_context[i] = true;
677 }
678 }
679 }
680
681 in_tab_context
682 }
683
684 fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
693 let mut in_admonition_context = vec![false; lines.len()];
694 let mut admonition_stack: Vec<usize> = Vec::new();
696
697 for (i, line) in lines.iter().enumerate() {
698 let line_indent = calculate_indentation_width_default(line);
699
700 if mkdocs_admonitions::is_admonition_start(line) {
702 let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
703
704 while let Some(&top_indent) = admonition_stack.last() {
706 if adm_indent <= top_indent {
708 admonition_stack.pop();
709 } else {
710 break;
711 }
712 }
713
714 admonition_stack.push(adm_indent);
716 in_admonition_context[i] = true;
717 continue;
718 }
719
720 if line.trim().is_empty() {
722 if !admonition_stack.is_empty() {
723 in_admonition_context[i] = true;
724 }
725 continue;
726 }
727
728 while let Some(&top_indent) = admonition_stack.last() {
731 if line_indent >= top_indent + 4 {
733 break;
735 } else {
736 admonition_stack.pop();
738 }
739 }
740
741 if !admonition_stack.is_empty() {
743 in_admonition_context[i] = true;
744 }
745 }
746
747 in_admonition_context
748 }
749
750 fn build_indent_context(
762 &self,
763 ctx: &crate::lint_context::LintContext,
764 lines: &[&str],
765 is_mkdocs: bool,
766 ) -> OwnedIndentContext {
767 OwnedIndentContext {
768 in_list_context: self.precompute_block_continuation_context(lines),
769 in_tab_context: if is_mkdocs {
770 self.precompute_mkdocs_tab_context(lines)
771 } else {
772 vec![false; lines.len()]
773 },
774 in_admonition_context: if is_mkdocs {
775 self.precompute_mkdocs_admonition_context(lines)
776 } else {
777 vec![false; lines.len()]
778 },
779 in_comment_or_html: Self::precompute_comment_or_html_context(ctx, lines.len()),
780 list_item_baseline: self.precompute_list_item_baseline(ctx, lines),
781 }
782 }
783
784 fn categorize_indented_blocks(&self, lines: &[&str], block_lines: &[bool]) -> (Vec<bool>, Vec<bool>) {
796 let mut is_misplaced = vec![false; lines.len()];
797 let mut contains_fences = vec![false; lines.len()];
798
799 let mut i = 0;
801 while i < lines.len() {
802 if !block_lines[i] {
804 i += 1;
805 continue;
806 }
807
808 let block_start = i;
810 let mut block_end = i;
811
812 while block_end < lines.len() && block_lines[block_end] {
813 block_end += 1;
814 }
815
816 if block_end > block_start {
818 let first_line = lines[block_start].trim_start();
819 let last_line = lines[block_end - 1].trim_start();
820
821 let is_backtick_fence = first_line.starts_with("```");
823 let is_tilde_fence = first_line.starts_with("~~~");
824
825 if is_backtick_fence || is_tilde_fence {
826 let fence_char = if is_backtick_fence { '`' } else { '~' };
827 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
828
829 let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
831 let after_closer = &last_line[closer_fence_len..];
832
833 if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
834 is_misplaced[block_start..block_end].fill(true);
836 } else {
837 contains_fences[block_start..block_end].fill(true);
839 }
840 } else {
841 let has_fence_markers = (block_start..block_end).any(|j| {
844 let trimmed = lines[j].trim_start();
845 trimmed.starts_with("```") || trimmed.starts_with("~~~")
846 });
847
848 if has_fence_markers {
849 contains_fences[block_start..block_end].fill(true);
850 }
851 }
852 }
853
854 i = block_end;
855 }
856
857 (is_misplaced, contains_fences)
858 }
859
860 fn check_unclosed_code_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
861 let mut warnings = Vec::new();
862 let lines = ctx.raw_lines();
863 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
864
865 let has_markdown_doc_block = ctx.code_block_details.iter().any(|d| {
867 if !d.is_fenced || Self::block_is_literal(ctx, d, ambiguity_limit) {
868 return false;
869 }
870 let lang = d.info_string.to_lowercase();
871 lang.starts_with("markdown") || lang.starts_with("md")
872 });
873
874 if has_markdown_doc_block {
877 return warnings;
878 }
879
880 for detail in &ctx.code_block_details {
881 if !detail.is_fenced || Self::block_is_literal(ctx, detail, ambiguity_limit) {
882 continue;
883 }
884
885 if detail.end != ctx.content.len() {
887 continue;
888 }
889
890 let opening_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
892 Ok(idx) => idx,
893 Err(idx) => idx.saturating_sub(1),
894 };
895
896 let line = lines.get(opening_line_idx).unwrap_or(&"");
897 let fence_pos = line.find("```").into_iter().chain(line.find("~~~")).min().unwrap_or(0);
898 let fence_char = line[fence_pos..].chars().next().unwrap_or('`');
899 let fence_marker: String = line[fence_pos..].chars().take_while(|&ch| ch == fence_char).collect();
900 let opening_quote = crate::utils::blockquote::parse_blockquote_prefix(line);
901 let quote_level = opening_quote.map_or(0, |quote| quote.nesting_level);
902 let owned = self.build_indent_context(ctx, lines, ctx.flavor == crate::config::MarkdownFlavor::MkDocs);
903 let baseline = owned
904 .list_item_baseline
905 .get(opening_line_idx)
906 .copied()
907 .flatten()
908 .unwrap_or(0);
909
910 let has_closing_fence = lines
913 .iter()
914 .enumerate()
915 .rev()
916 .find_map(|(idx, candidate)| {
917 let quote = crate::utils::blockquote::parse_blockquote_prefix(candidate);
918 let body = quote.map_or(*candidate, |quote| quote.content);
919 if body.trim().is_empty() {
920 return None;
921 }
922 Some(
923 idx > opening_line_idx
924 && quote.map_or(0, |quote| quote.nesting_level) == quote_level
925 && Self::is_closing_fence(body, fence_char, fence_marker.len(), baseline),
926 )
927 })
928 .unwrap_or(false);
929
930 if !has_closing_fence {
931 if ctx
933 .lines
934 .get(opening_line_idx)
935 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
936 {
937 continue;
938 }
939
940 let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
941
942 warnings.push(LintWarning {
943 rule_name: Some(self.name().to_string()),
944 line: start_line,
945 column: start_col,
946 end_line,
947 end_column: end_col,
948 message: format!("Code block opened with '{fence_marker}' but never closed"),
949 severity: Severity::Warning,
950 fix: Some(Fix::new(ctx.content.len()..ctx.content.len(), {
951 let prefix: String = line[..fence_pos]
954 .chars()
955 .map(|ch| if ch == '>' || ch.is_whitespace() { ch } else { ' ' })
956 .collect();
957 let newline = crate::utils::detect_line_ending(ctx.content);
958 if ctx.content.ends_with('\n') {
959 format!("{prefix}{fence_marker}{newline}")
960 } else {
961 format!("{newline}{prefix}{fence_marker}")
962 }
963 })),
964 });
965 }
966 }
967
968 warnings
969 }
970
971 fn effective_target_style(
979 &self,
980 ctx: &crate::lint_context::LintContext,
981 detect: impl FnOnce() -> CodeBlockStyle,
982 ) -> CodeBlockStyle {
983 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
984 self.warn_once_about_overridden_style();
985 return CodeBlockStyle::Fenced;
986 }
987
988 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
989 match self.config.style {
990 CodeBlockStyle::Consistent => {
991 let detected = detect();
992 if detected == CodeBlockStyle::Indented
993 && ctx.code_block_details.iter().any(|detail| {
994 detail.is_fenced
995 && !detail.info_string.trim().is_empty()
996 && Self::code_block_is_style_eligible(ctx, detail, ambiguity_limit)
997 })
998 {
999 CodeBlockStyle::Fenced
1003 } else {
1004 detected
1005 }
1006 }
1007 style => style,
1008 }
1009 }
1010
1011 fn block_is_literal(
1014 ctx: &crate::lint_context::LintContext,
1015 detail: &crate::utils::code_block_utils::CodeBlockDetail,
1016 ambiguity_limit: Option<usize>,
1017 ) -> bool {
1018 ambiguity_limit.is_some_and(|limit| detail.start >= limit)
1019 || Self::code_block_start_line(ctx, detail).is_some_and(|line| {
1020 ctx.lines.get(line).is_some_and(|info| {
1021 let marker = info.byte_offset + info.indent;
1022 ctx.is_inside_template_code(marker) || ctx.is_inside_mdx_code(marker)
1023 })
1024 })
1025 }
1026
1027 fn code_block_is_style_eligible(
1031 ctx: &crate::lint_context::LintContext,
1032 detail: &crate::utils::code_block_utils::CodeBlockDetail,
1033 ambiguity_limit: Option<usize>,
1034 ) -> bool {
1035 if Self::block_is_literal(ctx, detail, ambiguity_limit) {
1036 return false;
1037 }
1038 let Some(line_idx) = Self::code_block_start_line(ctx, detail) else {
1039 return false;
1040 };
1041
1042 !ctx.lines.get(line_idx).is_some_and(|info| {
1043 info.in_html_comment
1044 || info.in_mdx_comment
1045 || info.in_html_block
1046 || info.in_jsx_block
1047 || info.in_mkdocstrings
1048 || info.in_footnote_definition
1049 || info.blockquote.is_some()
1050 || info.in_front_matter
1051 })
1052 }
1053
1054 fn code_block_start_line(
1055 ctx: &crate::lint_context::LintContext,
1056 detail: &crate::utils::code_block_utils::CodeBlockDetail,
1057 ) -> Option<usize> {
1058 if detail.start >= ctx.content.len() {
1059 return None;
1060 }
1061
1062 Some(match ctx.line_offsets.binary_search(&detail.start) {
1063 Ok(idx) => idx,
1064 Err(idx) => idx.saturating_sub(1),
1065 })
1066 }
1067
1068 fn fenced_separator_lines(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
1072 let mut lines = std::collections::HashSet::new();
1073
1074 for pair in ctx.code_block_details.windows(2) {
1075 let [previous, next] = pair else {
1076 continue;
1077 };
1078 if previous.end > next.start || next.start > ctx.content.len() {
1079 continue;
1080 }
1081 if !ctx.content[previous.end..next.start].trim().is_empty() {
1082 continue;
1083 }
1084
1085 for detail in [previous, next] {
1086 if detail.is_fenced
1087 && let Some(line) = Self::code_block_start_line(ctx, detail)
1088 {
1089 lines.insert(line);
1090 }
1091 }
1092 }
1093
1094 lines
1095 }
1096
1097 fn fenced_boundary_blank_lines(
1102 ctx: &crate::lint_context::LintContext,
1103 lines: &[&str],
1104 ictx: &IndentContext,
1105 ) -> std::collections::HashSet<usize> {
1106 let mut boundary_blank_lines = std::collections::HashSet::new();
1107
1108 for detail in ctx.code_block_details.iter().filter(|detail| detail.is_fenced) {
1109 let Some(start) = Self::code_block_start_line(ctx, detail) else {
1110 continue;
1111 };
1112 let Some(opener) = lines.get(start) else {
1113 continue;
1114 };
1115 let baseline = ictx.list_item_baseline.get(start).copied().flatten().unwrap_or(0);
1116 let trimmed = opener.trim_start();
1117 if !Self::has_valid_fence_indent_at(opener, baseline) {
1118 continue;
1119 }
1120 let fence_char = if trimmed.starts_with("```") {
1121 '`'
1122 } else if trimmed.starts_with("~~~") {
1123 '~'
1124 } else {
1125 continue;
1128 };
1129 let opener_len = trimmed.chars().take_while(|&ch| ch == fence_char).count();
1130
1131 let mut block_end = start + 1;
1132 let mut closer = None;
1133 while block_end < lines.len()
1134 && ctx
1135 .line_offsets
1136 .get(block_end)
1137 .is_some_and(|&offset| offset < detail.end)
1138 {
1139 if Self::is_closing_fence(lines[block_end], fence_char, opener_len, baseline) {
1140 closer = Some(block_end);
1141 break;
1142 }
1143 block_end += 1;
1144 }
1145
1146 let payload_end = closer.unwrap_or(block_end);
1147 if start + 1 == payload_end
1148 || (start + 1 < payload_end
1149 && (lines[start + 1].trim().is_empty() || lines[payload_end - 1].trim().is_empty()))
1150 {
1151 boundary_blank_lines.insert(start);
1152 }
1153 }
1154
1155 boundary_blank_lines
1156 }
1157
1158 fn warn_once_about_overridden_style(&self) {
1164 if !self.style_explicit || self.config.style != CodeBlockStyle::Indented {
1165 return;
1166 }
1167
1168 MDG_STYLE_OVERRIDE.report(
1169 "MD046",
1170 "style",
1171 "indented",
1172 "fenced",
1173 "a Gherkin Doc String is only ever a backtick fence",
1174 );
1175 }
1176
1177 fn detect_style(
1178 &self,
1179 ctx: &crate::lint_context::LintContext,
1180 lines: &[&str],
1181 is_mkdocs: bool,
1182 ictx: &IndentContext,
1183 ) -> Option<CodeBlockStyle> {
1184 if lines.is_empty() {
1185 return None;
1186 }
1187
1188 let block_lines = self.indented_block_lines(lines, is_mkdocs, ictx, ctx);
1189
1190 let mut fenced_count = 0;
1191 let mut indented_count = 0;
1192
1193 let mut in_fenced = false;
1203 let mut prev_was_indented = false;
1204
1205 for (i, line) in lines.iter().enumerate() {
1206 let in_container = ictx.in_comment_or_html.get(i).copied().unwrap_or(false);
1207
1208 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|l| l.in_code_block) {
1212 prev_was_indented = false;
1213 continue;
1214 }
1215
1216 if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|l| l.in_myst_directive) {
1218 prev_was_indented = false;
1219 continue;
1220 }
1221
1222 let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1223 if self.is_fenced_code_block_start_at(line, baseline) {
1224 if in_container {
1225 prev_was_indented = false;
1228 continue;
1229 }
1230 if !in_fenced {
1231 fenced_count += 1;
1233 in_fenced = true;
1234 } else {
1235 in_fenced = false;
1237 }
1238 prev_was_indented = false;
1239 } else if !in_fenced && block_lines[i] {
1240 if !prev_was_indented {
1242 indented_count += 1;
1243 }
1244 prev_was_indented = true;
1245 } else {
1246 prev_was_indented = false;
1247 }
1248 }
1249
1250 if fenced_count == 0 && indented_count == 0 {
1251 None
1252 } else if fenced_count > 0 && indented_count == 0 {
1253 Some(CodeBlockStyle::Fenced)
1254 } else if fenced_count == 0 && indented_count > 0 {
1255 Some(CodeBlockStyle::Indented)
1256 } else if fenced_count >= indented_count {
1257 Some(CodeBlockStyle::Fenced)
1258 } else {
1259 Some(CodeBlockStyle::Indented)
1260 }
1261 }
1262}
1263
1264impl Rule for MD046CodeBlockStyle {
1265 fn name(&self) -> &'static str {
1266 "MD046"
1267 }
1268
1269 fn description(&self) -> &'static str {
1270 "Code blocks should use a consistent style"
1271 }
1272
1273 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1274 if ctx.content.is_empty() {
1276 return Ok(Vec::new());
1277 }
1278
1279 if !ctx.content.contains("```")
1281 && !ctx.content.contains("~~~")
1282 && !ctx.content.contains(" ")
1283 && !ctx.content.contains('\t')
1284 {
1285 return Ok(Vec::new());
1286 }
1287
1288 let mut unclosed_warnings = self.check_unclosed_code_blocks(ctx);
1290
1291 if !unclosed_warnings.is_empty() {
1293 let fixed = self.fix(ctx)?;
1294 for warning in &mut unclosed_warnings {
1295 warning.fix = if fixed == ctx.content {
1296 None
1297 } else if let Some(suffix) = fixed.strip_prefix(ctx.content) {
1298 Some(Fix::new(ctx.content.len()..ctx.content.len(), suffix.to_string()))
1299 } else {
1300 Some(Fix::new(0..ctx.content.len(), fixed.clone()))
1301 };
1302 }
1303 return Ok(unclosed_warnings);
1304 }
1305
1306 let lines = ctx.raw_lines();
1308 let mut warnings = Vec::new();
1309
1310 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1311
1312 let target_style = self.effective_target_style(ctx, || {
1314 let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1315 let detected = self.detect_style(ctx, lines, is_mkdocs, &owned.borrow());
1316 detected.unwrap_or(CodeBlockStyle::Fenced)
1317 });
1318
1319 let mdg_block_lines = (ctx.flavor == crate::config::MarkdownFlavor::MDG
1324 && ctx.code_block_details.iter().any(|detail| !detail.is_fenced))
1325 .then(|| {
1326 let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1327 self.indented_block_lines(lines, is_mkdocs, &owned.borrow(), ctx)
1328 });
1329
1330 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
1332 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
1333
1334 for detail in &ctx.code_block_details {
1335 if detail.start >= ctx.content.len()
1336 || detail.end > ctx.content.len()
1337 || Self::block_is_literal(ctx, detail, ambiguity_limit)
1338 {
1339 continue;
1340 }
1341
1342 let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
1343 Ok(idx) => idx,
1344 Err(idx) => idx.saturating_sub(1),
1345 };
1346
1347 if detail.is_fenced {
1348 if target_style == CodeBlockStyle::Indented {
1349 let line = lines.get(start_line_idx).unwrap_or(&"");
1350
1351 if ctx
1352 .lines
1353 .get(start_line_idx)
1354 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
1355 {
1356 continue;
1357 }
1358
1359 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1360 warnings.push(LintWarning {
1361 rule_name: Some(self.name().to_string()),
1362 line: start_line,
1363 column: start_col,
1364 end_line,
1365 end_column: end_col,
1366 message: "Use indented code blocks".to_string(),
1367 severity: Severity::Warning,
1368 fix: None,
1369 });
1370 }
1371 } else {
1372 if target_style == CodeBlockStyle::Fenced {
1374 let start_line_idx = match &mdg_block_lines {
1379 Some(block_lines) => {
1380 match Self::first_code_block_line(ctx, block_lines, start_line_idx, detail.end) {
1381 Some(idx) => idx,
1382 None => continue,
1383 }
1384 }
1385 None => start_line_idx,
1386 };
1387
1388 if reported_indented_lines.contains(&start_line_idx) {
1389 continue;
1390 }
1391
1392 let line = lines.get(start_line_idx).unwrap_or(&"");
1393
1394 if ctx.lines.get(start_line_idx).is_some_and(|info| {
1396 info.in_html_comment
1397 || info.in_mdx_comment
1398 || info.in_html_block
1399 || info.in_jsx_block
1400 || info.in_mkdocstrings
1401 || info.in_footnote_definition
1402 || info.blockquote.is_some()
1403 || info.in_front_matter
1404 }) {
1405 continue;
1406 }
1407
1408 if is_mkdocs
1410 && ctx
1411 .lines
1412 .get(start_line_idx)
1413 .is_some_and(|info| info.in_admonition || info.in_content_tab)
1414 {
1415 continue;
1416 }
1417
1418 reported_indented_lines.insert(start_line_idx);
1419
1420 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1421 warnings.push(LintWarning {
1422 rule_name: Some(self.name().to_string()),
1423 line: start_line,
1424 column: start_col,
1425 end_line,
1426 end_column: end_col,
1427 message: "Use fenced code blocks".to_string(),
1428 severity: Severity::Warning,
1429 fix: None,
1430 });
1431 }
1432 }
1433 }
1434
1435 if !warnings.is_empty() {
1438 let conversion = self.convert_closed_blocks(ctx)?;
1439 for warning in &mut warnings {
1440 let line_idx = warning.line - 1;
1441 warning.fix = conversion
1442 .edits
1443 .iter()
1444 .find(|edit| edit.lines.contains(&line_idx))
1445 .map(|edit| edit.fix.clone());
1446 }
1447 }
1448
1449 warnings.sort_by_key(|w| (w.line, w.column));
1451
1452 Ok(warnings)
1453 }
1454
1455 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1456 let content = ctx.content;
1457 if content.is_empty() {
1458 return Ok(String::new());
1459 }
1460
1461 let unclosed = crate::utils::fix_utils::filter_warnings_by_inline_config(
1462 self.check_unclosed_code_blocks(ctx),
1463 ctx.inline_config(),
1464 self.name(),
1465 );
1466 if !unclosed.is_empty() {
1467 let repaired =
1468 crate::utils::fix_utils::apply_warning_fixes(content, &unclosed).map_err(LintError::FixFailed)?;
1469 let repaired_ctx = crate::lint_context::LintContext::new(
1470 &repaired,
1471 ctx.flavor,
1472 ctx.source_file().map(std::path::Path::to_path_buf),
1473 );
1474 return Ok(self.convert_closed_blocks(&repaired_ctx)?.content);
1477 }
1478
1479 Ok(self.convert_closed_blocks(ctx)?.content)
1480 }
1481
1482 fn category(&self) -> RuleCategory {
1484 RuleCategory::CodeBlock
1485 }
1486
1487 fn fix_capability(&self) -> FixCapability {
1488 FixCapability::ConditionallyFixable
1491 }
1492
1493 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1495 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1498 }
1499
1500 fn as_any(&self) -> &dyn std::any::Any {
1501 self
1502 }
1503
1504 crate::impl_rule_config_sections!(MD046Config);
1505
1506 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1507 where
1508 Self: Sized,
1509 {
1510 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1511 let style_explicit = option_is_explicit(config, "MD046", "style");
1512
1513 Box::new(Self {
1514 config: rule_config,
1515 style_explicit,
1516 })
1517 }
1518}
1519
1520impl MD046CodeBlockStyle {
1521 fn convert_closed_blocks(&self, ctx: &crate::lint_context::LintContext) -> Result<Conversion, LintError> {
1525 let content = ctx.content;
1526 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
1527 let lines = ctx.raw_lines();
1528
1529 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1531
1532 let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1533 let ictx = owned.borrow();
1534
1535 let target_style = self.effective_target_style(ctx, || {
1536 self.detect_style(ctx, lines, is_mkdocs, &ictx)
1537 .unwrap_or(CodeBlockStyle::Fenced)
1538 });
1539
1540 let block_lines = self.indented_block_lines(lines, is_mkdocs, &ictx, ctx);
1541 let fenced_separator_lines = if target_style == CodeBlockStyle::Indented {
1542 Self::fenced_separator_lines(ctx)
1543 } else {
1544 std::collections::HashSet::new()
1545 };
1546 let fenced_boundary_blank_lines = if target_style == CodeBlockStyle::Indented {
1547 Self::fenced_boundary_blank_lines(ctx, lines, &ictx)
1548 } else {
1549 std::collections::HashSet::new()
1550 };
1551 let fenced_start_lines: std::collections::HashSet<usize> = ctx
1555 .code_block_details
1556 .iter()
1557 .filter(|detail| detail.is_fenced && !Self::block_is_literal(ctx, detail, ambiguity_limit))
1558 .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1559 .collect();
1560 let has_unsupported_fence_opener = ctx
1561 .code_block_details
1562 .iter()
1563 .filter(|detail| detail.is_fenced && Self::code_block_is_style_eligible(ctx, detail, ambiguity_limit))
1564 .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1565 .any(|line_index| {
1566 let Some(line) = lines.get(line_index) else {
1567 return true;
1568 };
1569 let baseline = ictx.list_item_baseline.get(line_index).copied().flatten().unwrap_or(0);
1570 !self.is_fenced_code_block_start_at(line, baseline)
1571 });
1572
1573 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(lines, &block_lines);
1577
1578 let mut result = String::with_capacity(content.len());
1579 let mut in_fenced_block = false;
1580 let mut fenced_fence_opener: Option<(char, usize)> = None;
1584 let mut in_indented_block = false;
1585 let mut current_block_fence_indent = String::new();
1590
1591 let mut current_block_must_stay_fenced = false;
1595 let mut current_fence_indent = 0usize;
1596 let mut current_fence_baseline = 0usize;
1597 let mut current_block_indented_prefix = String::from(" ");
1598 let mut converted_fenced_to_indented = false;
1599 let mut retained_structurally_unsafe_fence =
1600 target_style == CodeBlockStyle::Indented && has_unsupported_fence_opener;
1601
1602 let mut output_starts = Vec::with_capacity(lines.len() + 1);
1605 let mut line_blocks: Vec<Option<usize>> = vec![None; lines.len()];
1606 let mut current_block: Option<usize> = None;
1607
1608 for (i, line) in lines.iter().enumerate() {
1609 output_starts.push(result.len());
1610 let line_num = i + 1;
1611 let trimmed = line.trim_start();
1612 let list_baseline = ictx.list_item_baseline.get(i).copied().flatten();
1613 let fence_baseline = list_baseline.unwrap_or(0);
1614
1615 if !in_fenced_block
1618 && fenced_start_lines.contains(&i)
1619 && Self::has_valid_fence_indent_at(line, fence_baseline)
1620 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1621 {
1622 let block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
1624 in_fenced_block = true;
1625 current_block = Some(i);
1626 line_blocks[i] = current_block;
1627 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
1628 let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
1629 fenced_fence_opener = Some((fence_char, opener_len));
1630 current_fence_indent = calculate_indentation_width_default(line);
1631 current_fence_baseline = fence_baseline;
1632 current_block_indented_prefix = " ".repeat(fence_baseline + 4);
1633 let follows_list_item = i
1634 .checked_sub(1)
1635 .and_then(|previous| ictx.list_item_baseline.get(previous))
1636 .copied()
1637 .flatten()
1638 .is_some();
1639 let would_become_list_prose = target_style == CodeBlockStyle::Indented
1640 && list_baseline.is_none()
1641 && (ictx.in_list_context.get(i).copied().unwrap_or(false) || follows_list_item);
1642 let would_interrupt_paragraph = target_style == CodeBlockStyle::Indented
1643 && i > 0
1644 && !lines[i - 1].trim().is_empty()
1645 && ctx
1646 .lines
1647 .get(i - 1)
1648 .is_some_and(crate::lint_context::LineInfo::is_paragraph_context)
1649 && crate::lint_context::is_paragraph_text_line(lines[i - 1]);
1650 let would_merge_code_blocks = fenced_separator_lines.contains(&i);
1651 let would_lose_boundary_blanks = fenced_boundary_blank_lines.contains(&i);
1652 current_block_must_stay_fenced = block_disabled
1653 || !trimmed[opener_len..].trim().is_empty()
1654 || would_become_list_prose
1655 || would_interrupt_paragraph
1656 || would_merge_code_blocks
1657 || would_lose_boundary_blanks;
1658 retained_structurally_unsafe_fence |= would_become_list_prose
1659 || would_interrupt_paragraph
1660 || would_merge_code_blocks
1661 || would_lose_boundary_blanks;
1662
1663 if current_block_must_stay_fenced {
1664 result.push_str(line);
1667 result.push('\n');
1668 } else if target_style == CodeBlockStyle::Indented {
1669 in_indented_block = true;
1671 converted_fenced_to_indented = true;
1672 } else {
1673 result.push_str(line);
1675 result.push('\n');
1676 }
1677 } else if in_fenced_block && fenced_fence_opener.is_some() {
1678 line_blocks[i] = current_block;
1679 let (fence_char, opener_len) = fenced_fence_opener.unwrap();
1680 let is_closer = Self::is_closing_fence(line, fence_char, opener_len, current_fence_baseline);
1683 if is_closer {
1684 in_fenced_block = false;
1685 fenced_fence_opener = None;
1686 in_indented_block = false;
1687
1688 if current_block_must_stay_fenced {
1689 result.push_str(line);
1690 result.push('\n');
1691 } else if target_style == CodeBlockStyle::Indented {
1692 } else {
1694 result.push_str(line);
1696 result.push('\n');
1697 }
1698 current_block_must_stay_fenced = false;
1699 current_fence_indent = 0;
1700 current_fence_baseline = 0;
1701 current_block_indented_prefix.clear();
1702 } else if current_block_must_stay_fenced {
1703 result.push_str(line);
1705 result.push('\n');
1706 } else if target_style == CodeBlockStyle::Indented {
1707 if !line.is_empty() {
1714 let body = Self::strip_indentation_columns(line, current_fence_indent);
1719 result.push_str(¤t_block_indented_prefix);
1720 result.push_str(&body);
1721 }
1722 result.push('\n');
1723 } else {
1724 result.push_str(line);
1726 result.push('\n');
1727 }
1728 } else if block_lines[i] {
1729 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
1733 result.push_str(line);
1734 result.push('\n');
1735 continue;
1736 }
1737
1738 let prev_line_is_indented = i > 0 && block_lines[i - 1];
1740 if !prev_line_is_indented {
1741 current_block = Some(i);
1742 }
1743 line_blocks[i] = current_block;
1744
1745 if target_style == CodeBlockStyle::Fenced {
1746 let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1752 let body = if line.trim().is_empty() {
1760 String::new()
1761 } else {
1762 Self::strip_indentation_columns(line, 4)
1763 };
1764
1765 if misplaced_fence_lines[i] {
1768 result.push_str(line.trim_start());
1770 result.push('\n');
1771 } else if unsafe_fence_lines[i] {
1772 result.push_str(line);
1775 result.push('\n');
1776 } else if !prev_line_is_indented && !in_indented_block {
1777 current_block_fence_indent = " ".repeat(baseline);
1779 result.push_str(¤t_block_fence_indent);
1780 result.push_str(Self::FENCE);
1781 result.push('\n');
1782 result.push_str(&body);
1783 result.push('\n');
1784 in_indented_block = true;
1785 } else {
1786 result.push_str(&body);
1788 result.push('\n');
1789 }
1790
1791 let next_line_is_indented = i < lines.len() - 1 && block_lines[i + 1];
1793 if !next_line_is_indented
1795 && in_indented_block
1796 && !misplaced_fence_lines[i]
1797 && !unsafe_fence_lines[i]
1798 {
1799 result.push_str(¤t_block_fence_indent);
1800 result.push_str(Self::FENCE);
1801 result.push('\n');
1802 in_indented_block = false;
1803 current_block_fence_indent.clear();
1804 }
1805 } else {
1806 result.push_str(line);
1808 result.push('\n');
1809 }
1810 } else {
1811 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1813 result.push_str(¤t_block_fence_indent);
1814 result.push_str(Self::FENCE);
1815 result.push('\n');
1816 in_indented_block = false;
1817 current_block_fence_indent.clear();
1818 output_starts[i] = result.len();
1820 }
1821 current_block = None;
1822
1823 result.push_str(line);
1824 result.push('\n');
1825 }
1826 }
1827
1828 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1830 result.push_str(¤t_block_fence_indent);
1831 result.push_str(Self::FENCE);
1832 result.push('\n');
1833 }
1834 output_starts.push(result.len());
1835
1836 if !content.ends_with('\n') && result.ends_with('\n') {
1838 result.pop();
1839 }
1840
1841 if retained_structurally_unsafe_fence && self.config.style == CodeBlockStyle::Consistent {
1842 return Self::new(CodeBlockStyle::Fenced).convert_closed_blocks(ctx);
1843 }
1844
1845 if converted_fenced_to_indented {
1846 let reparsed_block_count = crate::utils::CodeBlockUtils::detect_code_blocks(&result).len();
1847 if reparsed_block_count != ctx.code_block_details.len() {
1848 if self.config.style == CodeBlockStyle::Consistent {
1853 return Self::new(CodeBlockStyle::Fenced).convert_closed_blocks(ctx);
1854 }
1855
1856 return Ok(Conversion::unchanged(content));
1857 }
1858 }
1859
1860 if result == content || (content.contains('\r') && result == content.replace("\r\n", "\n")) {
1861 return Ok(Conversion::unchanged(content));
1862 }
1863
1864 let mut edits = Self::block_edits(ctx, &result, &output_starts, &line_blocks);
1865 let unsafe_blocks: Vec<_> = ctx
1868 .code_block_details
1869 .iter()
1870 .filter(|detail| {
1871 !Self::block_is_literal(ctx, detail, ambiguity_limit)
1872 && Self::code_block_start_line(ctx, detail).is_some_and(|first| {
1873 let last = ctx.line_offsets.partition_point(|&offset| offset < detail.end);
1874 (first..last).any(|line| {
1875 !lines[line].is_empty()
1876 && ctx.lines.get(line).is_some_and(|info| {
1877 ctx.is_inside_template_code(info.byte_offset)
1878 || ctx.is_inside_mdx_code(info.byte_offset)
1879 })
1880 })
1881 })
1882 })
1883 .map(|detail| detail.start..detail.end)
1884 .collect();
1885 let original_edit_count = edits.len();
1886 edits.retain(|edit| {
1887 !unsafe_blocks
1888 .iter()
1889 .any(|block| edit.fix.range.start < block.end && block.start < edit.fix.range.end)
1890 });
1891 if edits.len() != original_edit_count {
1892 let mut preserved = content.to_string();
1893 for edit in edits.iter().rev() {
1894 preserved.replace_range(edit.fix.range.clone(), &edit.fix.replacement);
1895 }
1896 return Ok(Conversion {
1897 content: preserved,
1898 edits,
1899 });
1900 }
1901 Ok(Conversion {
1902 content: crate::utils::ensure_consistent_line_endings(content, &result),
1903 edits,
1904 })
1905 }
1906
1907 fn block_edits(
1911 ctx: &crate::lint_context::LintContext,
1912 result: &str,
1913 output_starts: &[usize],
1914 line_blocks: &[Option<usize>],
1915 ) -> Vec<BlockEdit> {
1916 let content = ctx.content;
1917 let line_count = line_blocks.len();
1918 let line_start = |i: usize| {
1919 if i < line_count {
1920 ctx.line_offsets[i]
1921 } else {
1922 content.len()
1923 }
1924 };
1925
1926 let mut edits = Vec::new();
1927 let mut i = 0;
1928 while i < line_count {
1929 let Some(block) = line_blocks[i] else {
1930 i += 1;
1931 continue;
1932 };
1933 let first = i;
1934 while i < line_count && line_blocks[i] == Some(block) {
1935 i += 1;
1936 }
1937 let last = i - 1;
1938
1939 let original = &content[line_start(first)..line_start(i)];
1940 let replacement = &result[output_starts[first]..output_starts[i].min(result.len())];
1943 if original.replace("\r\n", "\n") == replacement {
1944 continue;
1945 }
1946 edits.push(BlockEdit {
1947 lines: first..=last,
1948 fix: Fix::new(
1949 line_start(first)..line_start(i),
1950 crate::utils::ensure_consistent_line_endings(content, replacement),
1951 ),
1952 });
1953 }
1954 edits
1955 }
1956}
1957
1958struct Conversion {
1960 content: String,
1961 edits: Vec<BlockEdit>,
1963}
1964
1965impl Conversion {
1966 fn unchanged(content: &str) -> Self {
1967 Self {
1968 content: content.to_string(),
1969 edits: Vec::new(),
1970 }
1971 }
1972}
1973
1974struct BlockEdit {
1976 lines: std::ops::RangeInclusive<usize>,
1977 fix: Fix,
1978}
1979
1980#[cfg(test)]
1981mod tests {
1982 use super::*;
1983 use crate::lint_context::LintContext;
1984
1985 fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
1997 let flavor = if is_mkdocs {
1998 crate::config::MarkdownFlavor::MkDocs
1999 } else {
2000 crate::config::MarkdownFlavor::Standard
2001 };
2002 let ctx = LintContext::new(content, flavor, None);
2003 let lines: Vec<&str> = content.lines().collect();
2004 let in_list_context = rule.precompute_block_continuation_context(&lines);
2005 let in_tab_context = if is_mkdocs {
2006 rule.precompute_mkdocs_tab_context(&lines)
2007 } else {
2008 vec![false; lines.len()]
2009 };
2010 let in_admonition_context = if is_mkdocs {
2011 rule.precompute_mkdocs_admonition_context(&lines)
2012 } else {
2013 vec![false; lines.len()]
2014 };
2015 let in_comment_or_html = vec![false; lines.len()];
2016 let list_item_baseline: Vec<Option<usize>> = vec![None; lines.len()];
2022 let ictx = IndentContext {
2023 in_list_context: &in_list_context,
2024 in_tab_context: &in_tab_context,
2025 in_admonition_context: &in_admonition_context,
2026 in_comment_or_html: &in_comment_or_html,
2027 list_item_baseline: &list_item_baseline,
2028 };
2029 rule.detect_style(&ctx, &lines, is_mkdocs, &ictx)
2030 }
2031
2032 #[test]
2033 fn test_unclosed_fence_diagnostic_matches_document_fix() {
2034 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2035 for (content, expected) in [
2036 ("```", "```\n```"),
2037 ("```\ncode\n", "```\ncode\n```\n"),
2038 ("````\ncode\n```\n", "````\ncode\n```\n````\n"),
2039 ("> ```rust\n> code\n", "> ```rust\n> code\n> ```\n"),
2040 ("> > ~~~~\n> > code", "> > ~~~~\n> > code\n> > ~~~~"),
2041 ("- ```\n code\n", "- ```\n code\n ```\n"),
2042 (" ```\n code\n", " ```\n code\n ```\n"),
2043 ] {
2044 for newline in ["\n", "\r\n"] {
2045 let content = content.replace('\n', newline);
2046 let expected = if content.contains('\n') {
2047 expected.replace('\n', newline)
2048 } else {
2049 expected.to_string()
2050 };
2051 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2052 let warnings = rule.check(&ctx).unwrap();
2053 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
2054 let edited = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings).unwrap();
2055 assert_eq!(edited, expected, "{content:?}");
2056 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
2057 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
2058 assert!(rule.check(&fixed_ctx).unwrap().is_empty(), "{expected:?}");
2059 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2060 }
2061 }
2062 }
2063
2064 #[test]
2065 fn test_closed_quote_fence_at_eof_is_unchanged() {
2066 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2067 for content in [
2068 "> - item\n> ```\n> code\n> ```",
2069 "> > ~~~~\n> > code\n> > ~~~~",
2070 "```\r\ncode\r\n```\r\n",
2071 "Text\r\n\n- item\r\n",
2072 ] {
2073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2074 let warnings = rule.check(&ctx).unwrap();
2075 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
2076 assert_eq!(rule.fix(&ctx).unwrap(), content);
2077 }
2078 }
2079
2080 #[test]
2081 fn test_unclosed_quote_repair_keeps_unsupported_style_warning() {
2082 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2083 let content = "> ```\n> code\n";
2084 let expected = "> ```\n> code\n> ```\n";
2085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2086 let warnings = rule.check(&ctx).unwrap();
2087 assert_eq!(warnings.len(), 1);
2088 assert_eq!(
2089 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
2090 expected
2091 );
2092 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2093 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
2094 let remaining = rule.check(&fixed_ctx).unwrap();
2095 assert_eq!(remaining.len(), 1);
2096 assert_eq!(remaining[0].message, "Use indented code blocks");
2097 assert!(
2098 remaining[0].fix.is_none(),
2099 "Container conversion is intentionally unsupported"
2100 );
2101 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2102 }
2103
2104 #[test]
2105 fn test_unclosed_fence_diagnostic_includes_indented_conversion() {
2106 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2107 let content = "```\ncode\n";
2108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2109 let warnings = rule.check(&ctx).unwrap();
2110 assert_eq!(warnings.len(), 1);
2111 let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
2112 assert_eq!(edited, " code\n");
2113 assert_eq!(rule.fix(&ctx).unwrap(), edited);
2114 let fixed_ctx = LintContext::new(&edited, crate::config::MarkdownFlavor::Standard, None);
2115 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2116 assert_eq!(rule.fix(&fixed_ctx).unwrap(), edited);
2117 }
2118
2119 #[test]
2120 fn test_fenced_code_block_detection() {
2121 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2122 assert!(rule.is_fenced_code_block_start("```"));
2123 assert!(rule.is_fenced_code_block_start("```rust"));
2124 assert!(rule.is_fenced_code_block_start("~~~"));
2125 assert!(rule.is_fenced_code_block_start("~~~python"));
2126 assert!(rule.is_fenced_code_block_start(" ```"));
2127 assert!(!rule.is_fenced_code_block_start("``"));
2128 assert!(!rule.is_fenced_code_block_start("~~"));
2129 assert!(!rule.is_fenced_code_block_start("Regular text"));
2130 }
2131
2132 #[test]
2133 fn test_fix_capability_is_conditional() {
2134 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2135 assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
2136 }
2137
2138 #[test]
2139 fn test_consistent_style_with_fenced_blocks() {
2140 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2141 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
2142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2143 let result = rule.check(&ctx).unwrap();
2144
2145 assert_eq!(result.len(), 0);
2147 }
2148
2149 #[test]
2150 fn test_consistent_style_with_indented_blocks() {
2151 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2152 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
2153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2154 let result = rule.check(&ctx).unwrap();
2155
2156 assert_eq!(result.len(), 0);
2158 }
2159
2160 #[test]
2161 fn test_consistent_style_mixed() {
2162 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2163 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
2164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2165 let result = rule.check(&ctx).unwrap();
2166
2167 assert!(!result.is_empty());
2169 }
2170
2171 #[test]
2172 fn test_fenced_style_with_indented_blocks() {
2173 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2174 let content = "Text\n\n indented code\n more code\n\nMore text";
2175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2176 let result = rule.check(&ctx).unwrap();
2177
2178 assert!(!result.is_empty());
2180 assert!(result[0].message.contains("Use fenced code blocks"));
2181 }
2182
2183 #[test]
2184 fn test_fenced_style_with_tab_indented_blocks() {
2185 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2186 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
2187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2188 let result = rule.check(&ctx).unwrap();
2189
2190 assert!(!result.is_empty());
2192 assert!(result[0].message.contains("Use fenced code blocks"));
2193 }
2194
2195 #[test]
2196 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
2197 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2198 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
2200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2201 let result = rule.check(&ctx).unwrap();
2202
2203 assert!(
2205 !result.is_empty(),
2206 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
2207 );
2208 assert!(result[0].message.contains("Use fenced code blocks"));
2209 }
2210
2211 #[test]
2212 fn test_fenced_style_with_one_space_tab_indent() {
2213 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2214 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
2216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2217 let result = rule.check(&ctx).unwrap();
2218
2219 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
2220 assert!(result[0].message.contains("Use fenced code blocks"));
2221 }
2222
2223 #[test]
2224 fn test_indented_style_with_fenced_blocks() {
2225 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2226 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
2227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2228 let result = rule.check(&ctx).unwrap();
2229
2230 assert!(!result.is_empty());
2232 assert!(result[0].message.contains("Use indented code blocks"));
2233 }
2234
2235 #[test]
2236 fn test_unclosed_code_block() {
2237 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2238 let content = "```\ncode without closing fence";
2239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2240 let result = rule.check(&ctx).unwrap();
2241
2242 assert_eq!(result.len(), 1);
2243 assert!(result[0].message.contains("never closed"));
2244 }
2245
2246 #[test]
2247 fn test_nested_code_blocks() {
2248 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2249 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
2250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2251 let result = rule.check(&ctx).unwrap();
2252
2253 assert_eq!(result.len(), 0);
2255 }
2256
2257 #[test]
2258 fn test_fix_indented_to_fenced() {
2259 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2260 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
2261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2262 let fixed = rule.fix(&ctx).unwrap();
2263
2264 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
2265 }
2266
2267 #[test]
2268 fn test_fix_fenced_to_indented() {
2269 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2270 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
2271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2272 let fixed = rule.fix(&ctx).unwrap();
2273
2274 assert!(fixed.contains(" code line 1\n code line 2"));
2275 assert!(!fixed.contains("```"));
2276 }
2277
2278 #[test]
2279 fn test_fix_fenced_to_indented_blank_lines_have_no_trailing_spaces() {
2280 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2284 let content = "Text\n\n```\ncode line 1\n\ncode line 2\n```\n\nMore text";
2285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2286 let fixed = rule.fix(&ctx).unwrap();
2287
2288 for line in fixed.lines() {
2289 assert!(
2290 line.is_empty() || !line.trim_end().is_empty() || line == line.trim_end(),
2291 "no line may have trailing whitespace, got {line:?}"
2292 );
2293 assert_ne!(line, " ", "blank line was indented to trailing spaces");
2294 }
2295 assert!(fixed.contains(" code line 1\n\n code line 2"));
2297 }
2298
2299 #[test]
2300 fn test_is_list_item_requires_delimiter_after_digits() {
2301 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2302 assert!(rule.is_list_item("1. First"));
2304 assert!(rule.is_list_item("42) Item"));
2305 assert!(rule.is_list_item(" 3. Indented item"));
2306 assert!(rule.is_list_item("- bullet"));
2308 assert!(rule.is_list_item("* bullet"));
2309 assert!(!rule.is_list_item("2 results. More info."));
2312 assert!(!rule.is_list_item("3 options (a, b) here"));
2313 assert!(!rule.is_list_item("100 items in stock. Buy now"));
2314 }
2315
2316 #[test]
2317 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
2318 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2321 let content = r#"# Test
2322
2323```
2324<!doctype html>
2325<html>
2326 <head>
2327 <title>Test</title>
2328 </head>
2329</html>
2330```
2331"#;
2332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2333 let fixed = rule.fix(&ctx).unwrap();
2334
2335 assert!(
2338 fixed.contains(" <head>"),
2339 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
2340 );
2341 assert!(
2342 fixed.contains(" <title>"),
2343 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
2344 );
2345 assert!(!fixed.contains("```"), "Fenced markers should be removed");
2346 }
2347
2348 #[test]
2349 fn test_fix_fenced_to_indented_preserves_python_indentation() {
2350 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2352 let content = r#"# Python Example
2353
2354```
2355def greet(name):
2356 if name:
2357 print(f"Hello, {name}!")
2358 else:
2359 print("Hello, World!")
2360```
2361"#;
2362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2363 let fixed = rule.fix(&ctx).unwrap();
2364
2365 assert!(
2367 fixed.contains(" def greet(name):"),
2368 "Function def should have 4 spaces (code block indent)"
2369 );
2370 assert!(
2371 fixed.contains(" if name:"),
2372 "if statement should have 8 spaces (4 code + 4 Python)"
2373 );
2374 assert!(
2375 fixed.contains(" print"),
2376 "print should have 12 spaces (4 code + 8 Python)"
2377 );
2378 }
2379
2380 #[test]
2381 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
2382 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2384 let content = r#"# Config
2385
2386```
2387server:
2388 host: localhost
2389 port: 8080
2390 ssl:
2391 enabled: true
2392 cert: /path/to/cert
2393```
2394"#;
2395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2396 let fixed = rule.fix(&ctx).unwrap();
2397
2398 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
2399 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
2400 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
2401 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
2402 }
2403
2404 #[test]
2405 fn test_fix_fenced_to_indented_preserves_empty_lines() {
2406 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2410 let content = "```\nline1\n\nline2\n```\n";
2411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2412 let fixed = rule.fix(&ctx).unwrap();
2413
2414 assert!(fixed.contains(" line1"), "line1 should be indented");
2416 assert!(fixed.contains(" line2"), "line2 should be indented");
2417 assert!(
2418 fixed.contains(" line1\n\n line2"),
2419 "blank line must stay empty, got {fixed:?}"
2420 );
2421 }
2422
2423 #[test]
2424 fn test_fix_fenced_to_indented_multiple_blocks() {
2425 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2427 let content = r#"# Doc
2428
2429```
2430def foo():
2431 pass
2432```
2433
2434Text between.
2435
2436```
2437key:
2438 value: 1
2439```
2440"#;
2441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2442 let fixed = rule.fix(&ctx).unwrap();
2443
2444 assert!(fixed.contains(" def foo():"), "Python def should be indented");
2445 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
2446 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
2447 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
2448 assert!(!fixed.contains("```"), "No fence markers should remain");
2449 }
2450
2451 #[test]
2452 fn test_fix_unclosed_block() {
2453 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2454 let content = "```\ncode without closing";
2455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2456 let fixed = rule.fix(&ctx).unwrap();
2457
2458 assert!(fixed.ends_with("```"));
2460 }
2461
2462 #[test]
2463 fn test_code_block_in_list() {
2464 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2465 let content = "- List item\n code in list\n more code\n- Next item";
2466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2467 let result = rule.check(&ctx).unwrap();
2468
2469 assert_eq!(result.len(), 0);
2471 }
2472
2473 #[test]
2474 fn test_detect_style_fenced() {
2475 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2476 let content = "```\ncode\n```";
2477 let style = detect_style_from_content(&rule, content, false);
2478
2479 assert_eq!(style, Some(CodeBlockStyle::Fenced));
2480 }
2481
2482 #[test]
2483 fn test_detect_style_indented() {
2484 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2485 let content = "Text\n\n code\n\nMore";
2486 let style = detect_style_from_content(&rule, content, false);
2487
2488 assert_eq!(style, Some(CodeBlockStyle::Indented));
2489 }
2490
2491 #[test]
2492 fn test_detect_style_none() {
2493 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2494 let content = "No code blocks here";
2495 let style = detect_style_from_content(&rule, content, false);
2496
2497 assert_eq!(style, None);
2498 }
2499
2500 #[test]
2501 fn test_tilde_fence() {
2502 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2503 let content = "~~~\ncode\n~~~";
2504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2505 let result = rule.check(&ctx).unwrap();
2506
2507 assert_eq!(result.len(), 0);
2509 }
2510
2511 #[test]
2512 fn test_language_specification() {
2513 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2514 let content = "```rust\nfn main() {}\n```";
2515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2516 let result = rule.check(&ctx).unwrap();
2517
2518 assert_eq!(result.len(), 0);
2519 }
2520
2521 #[test]
2522 fn test_empty_content() {
2523 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2524 let content = "";
2525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2526 let result = rule.check(&ctx).unwrap();
2527
2528 assert_eq!(result.len(), 0);
2529 }
2530
2531 #[test]
2532 fn test_default_config() {
2533 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2534 let (name, _config) = rule.default_config_section().unwrap();
2535 assert_eq!(name, "MD046");
2536 }
2537
2538 #[test]
2539 fn test_markdown_documentation_block() {
2540 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2541 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
2542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2543 let result = rule.check(&ctx).unwrap();
2544
2545 assert_eq!(result.len(), 0);
2547 }
2548
2549 #[test]
2550 fn test_preserve_trailing_newline() {
2551 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2552 let content = "```\ncode\n```\n";
2553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2554 let fixed = rule.fix(&ctx).unwrap();
2555
2556 assert_eq!(fixed, content);
2557 }
2558
2559 #[test]
2560 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
2561 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2562 let content = r#"# Document
2563
2564=== "Python"
2565
2566 This is tab content
2567 Not an indented code block
2568
2569 ```python
2570 def hello():
2571 print("Hello")
2572 ```
2573
2574=== "JavaScript"
2575
2576 More tab content here
2577 Also not an indented code block"#;
2578
2579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2580 let result = rule.check(&ctx).unwrap();
2581
2582 assert_eq!(result.len(), 0);
2584 }
2585
2586 #[test]
2587 fn test_mkdocs_tabs_with_actual_indented_code() {
2588 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2589 let content = r#"# Document
2590
2591=== "Tab 1"
2592
2593 This is tab content
2594
2595Regular text
2596
2597 This is an actual indented code block
2598 Should be flagged"#;
2599
2600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2601 let result = rule.check(&ctx).unwrap();
2602
2603 assert_eq!(result.len(), 1);
2605 assert!(result[0].message.contains("Use fenced code blocks"));
2606 }
2607
2608 #[test]
2609 fn test_mkdocs_tabs_detect_style() {
2610 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2611 let content = r#"=== "Tab 1"
2612
2613 Content in tab
2614 More content
2615
2616=== "Tab 2"
2617
2618 Content in second tab"#;
2619
2620 let style = detect_style_from_content(&rule, content, true);
2622 assert_eq!(style, None); let style = detect_style_from_content(&rule, content, false);
2626 assert_eq!(style, Some(CodeBlockStyle::Indented));
2627 }
2628
2629 #[test]
2630 fn test_mkdocs_nested_tabs() {
2631 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2632 let content = r#"# Document
2633
2634=== "Outer Tab"
2635
2636 Some content
2637
2638 === "Nested Tab"
2639
2640 Nested tab content
2641 Should not be flagged"#;
2642
2643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2644 let result = rule.check(&ctx).unwrap();
2645
2646 assert_eq!(result.len(), 0);
2648 }
2649
2650 #[test]
2651 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
2652 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2655 let content = r#"# Document
2656
2657!!! note
2658 This is normal admonition content, not a code block.
2659 It spans multiple lines.
2660
2661??? warning "Collapsible Warning"
2662 This is also admonition content.
2663
2664???+ tip "Expanded Tip"
2665 And this one too.
2666
2667Regular text outside admonitions."#;
2668
2669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2670 let result = rule.check(&ctx).unwrap();
2671
2672 assert_eq!(
2674 result.len(),
2675 0,
2676 "Admonition content in MkDocs mode should not trigger MD046"
2677 );
2678 }
2679
2680 #[test]
2681 fn test_mkdocs_admonition_with_actual_indented_code() {
2682 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2684 let content = r#"# Document
2685
2686!!! note
2687 This is admonition content.
2688
2689Regular text ends the admonition.
2690
2691 This is actual indented code (should be flagged)"#;
2692
2693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2694 let result = rule.check(&ctx).unwrap();
2695
2696 assert_eq!(result.len(), 1);
2698 assert!(result[0].message.contains("Use fenced code blocks"));
2699 }
2700
2701 #[test]
2702 fn test_admonition_in_standard_mode_flagged() {
2703 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2707 let content = r#"# Document
2708
2709!!! note
2710
2711 This looks like code in standard mode.
2712
2713Regular text."#;
2714
2715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2717 let result = rule.check(&ctx).unwrap();
2718
2719 assert_eq!(
2721 result.len(),
2722 1,
2723 "Admonition content in Standard mode should be flagged as indented code"
2724 );
2725 }
2726
2727 #[test]
2728 fn test_mkdocs_admonition_with_fenced_code_inside() {
2729 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2731 let content = r#"# Document
2732
2733!!! note "Code Example"
2734 Here's some code:
2735
2736 ```python
2737 def hello():
2738 print("world")
2739 ```
2740
2741 More text after code.
2742
2743Regular text."#;
2744
2745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2746 let result = rule.check(&ctx).unwrap();
2747
2748 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
2750 }
2751
2752 #[test]
2753 fn test_mkdocs_nested_admonitions() {
2754 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2756 let content = r#"# Document
2757
2758!!! note "Outer"
2759 Outer content.
2760
2761 !!! warning "Inner"
2762 Inner content.
2763 More inner content.
2764
2765 Back to outer.
2766
2767Regular text."#;
2768
2769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2770 let result = rule.check(&ctx).unwrap();
2771
2772 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
2774 }
2775
2776 #[test]
2777 fn test_mkdocs_admonition_fix_does_not_wrap() {
2778 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2780 let content = r#"!!! note
2781 Content that should stay as admonition content.
2782 Not be wrapped in code fences.
2783"#;
2784
2785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2786 let fixed = rule.fix(&ctx).unwrap();
2787
2788 assert!(
2790 !fixed.contains("```\n Content"),
2791 "Admonition content should not be wrapped in fences"
2792 );
2793 assert_eq!(fixed, content, "Content should remain unchanged");
2794 }
2795
2796 #[test]
2797 fn test_mkdocs_empty_admonition() {
2798 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2800 let content = r#"!!! note
2801
2802Regular paragraph after empty admonition.
2803
2804 This IS an indented code block (after blank + non-indented line)."#;
2805
2806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2807 let result = rule.check(&ctx).unwrap();
2808
2809 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
2811 }
2812
2813 #[test]
2814 fn test_mkdocs_indented_admonition() {
2815 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2817 let content = r#"- List item
2818
2819 !!! note
2820 Indented admonition content.
2821 More content.
2822
2823- Next item"#;
2824
2825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2826 let result = rule.check(&ctx).unwrap();
2827
2828 assert_eq!(
2830 result.len(),
2831 0,
2832 "Indented admonitions (e.g., in lists) should not be flagged"
2833 );
2834 }
2835
2836 #[test]
2837 fn test_footnote_indented_paragraphs_not_flagged() {
2838 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2839 let content = r#"# Test Document with Footnotes
2840
2841This is some text with a footnote[^1].
2842
2843Here's some code:
2844
2845```bash
2846echo "fenced code block"
2847```
2848
2849More text with another footnote[^2].
2850
2851[^1]: Really interesting footnote text.
2852
2853 Even more interesting second paragraph.
2854
2855[^2]: Another footnote.
2856
2857 With a second paragraph too.
2858
2859 And even a third paragraph!"#;
2860
2861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2862 let result = rule.check(&ctx).unwrap();
2863
2864 assert_eq!(result.len(), 0);
2866 }
2867
2868 #[test]
2869 fn test_footnote_definition_detection() {
2870 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2871
2872 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
2875 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
2876 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
2877 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
2878 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
2879 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
2880 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
2881 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
2882 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
2883
2884 assert!(!rule.is_footnote_definition("[^]: No label"));
2886 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
2887 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
2888 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
2889
2890 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
2892 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
2893 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
2894 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
2897
2898 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
2900 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
2901 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
2902 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
2903 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
2904
2905 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
2908 }
2909
2910 #[test]
2911 fn test_footnote_with_blank_lines() {
2912 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2916 let content = r#"# Document
2917
2918Text with footnote[^1].
2919
2920[^1]: First paragraph.
2921
2922 Second paragraph after blank line.
2923
2924 Third paragraph after another blank line.
2925
2926Regular text at column 0 ends the footnote."#;
2927
2928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2929 let result = rule.check(&ctx).unwrap();
2930
2931 assert_eq!(
2933 result.len(),
2934 0,
2935 "Indented content within footnotes should not trigger MD046"
2936 );
2937 }
2938
2939 #[test]
2940 fn test_footnote_multiple_consecutive_blank_lines() {
2941 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2944 let content = r#"Text[^1].
2945
2946[^1]: First paragraph.
2947
2948
2949
2950 Content after three blank lines (still part of footnote).
2951
2952Not indented, so footnote ends here."#;
2953
2954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2955 let result = rule.check(&ctx).unwrap();
2956
2957 assert_eq!(
2959 result.len(),
2960 0,
2961 "Multiple blank lines shouldn't break footnote continuation"
2962 );
2963 }
2964
2965 #[test]
2966 fn test_footnote_terminated_by_non_indented_content() {
2967 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2970 let content = r#"[^1]: Footnote content.
2971
2972 More indented content in footnote.
2973
2974This paragraph is not indented, so footnote ends.
2975
2976 This should be flagged as indented code block."#;
2977
2978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2979 let result = rule.check(&ctx).unwrap();
2980
2981 assert_eq!(
2983 result.len(),
2984 1,
2985 "Indented code after footnote termination should be flagged"
2986 );
2987 assert!(
2988 result[0].message.contains("Use fenced code blocks"),
2989 "Expected MD046 warning for indented code block"
2990 );
2991 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2992 }
2993
2994 #[test]
2995 fn test_footnote_terminated_by_structural_elements() {
2996 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2998 let content = r#"[^1]: Footnote content.
2999
3000 More content.
3001
3002## Heading terminates footnote
3003
3004 This indented content should be flagged.
3005
3006---
3007
3008 This should also be flagged (after horizontal rule)."#;
3009
3010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3011 let result = rule.check(&ctx).unwrap();
3012
3013 assert_eq!(
3015 result.len(),
3016 2,
3017 "Both indented blocks after termination should be flagged"
3018 );
3019 }
3020
3021 #[test]
3022 fn test_footnote_with_code_block_inside() {
3023 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3026 let content = r#"Text[^1].
3027
3028[^1]: Footnote with code:
3029
3030 ```python
3031 def hello():
3032 print("world")
3033 ```
3034
3035 More footnote text after code."#;
3036
3037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3038 let result = rule.check(&ctx).unwrap();
3039
3040 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
3042 }
3043
3044 #[test]
3045 fn test_footnote_with_8_space_indented_code() {
3046 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3049 let content = r#"Text[^1].
3050
3051[^1]: Footnote with nested code.
3052
3053 code block
3054 more code"#;
3055
3056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3057 let result = rule.check(&ctx).unwrap();
3058
3059 assert_eq!(
3061 result.len(),
3062 0,
3063 "8-space indented code within footnotes represents nested code blocks"
3064 );
3065 }
3066
3067 #[test]
3068 fn test_multiple_footnotes() {
3069 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3072 let content = r#"Text[^1] and more[^2].
3073
3074[^1]: First footnote.
3075
3076 Continuation of first.
3077
3078[^2]: Second footnote starts here, ending the first.
3079
3080 Continuation of second."#;
3081
3082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3083 let result = rule.check(&ctx).unwrap();
3084
3085 assert_eq!(
3087 result.len(),
3088 0,
3089 "Multiple footnotes should each maintain their continuation context"
3090 );
3091 }
3092
3093 #[test]
3094 fn test_list_item_ends_footnote_context() {
3095 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3097 let content = r#"[^1]: Footnote.
3098
3099 Content in footnote.
3100
3101- List item starts here (ends footnote context).
3102
3103 This indented content is part of the list, not the footnote."#;
3104
3105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3106 let result = rule.check(&ctx).unwrap();
3107
3108 assert_eq!(
3110 result.len(),
3111 0,
3112 "List items should end footnote context and start their own"
3113 );
3114 }
3115
3116 #[test]
3117 fn test_footnote_vs_actual_indented_code() {
3118 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3121 let content = r#"# Heading
3122
3123Text with footnote[^1].
3124
3125[^1]: Footnote content.
3126
3127 Part of footnote (should not be flagged).
3128
3129Regular paragraph ends footnote context.
3130
3131 This is actual indented code (MUST be flagged)
3132 Should be detected as code block"#;
3133
3134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3135 let result = rule.check(&ctx).unwrap();
3136
3137 assert_eq!(
3139 result.len(),
3140 1,
3141 "Must still detect indented code blocks outside footnotes"
3142 );
3143 assert!(
3144 result[0].message.contains("Use fenced code blocks"),
3145 "Expected MD046 warning for indented code"
3146 );
3147 assert!(
3148 result[0].line >= 11,
3149 "Warning should be on the actual indented code line"
3150 );
3151 }
3152
3153 #[test]
3154 fn test_spec_compliant_label_characters() {
3155 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3158
3159 assert!(rule.is_footnote_definition("[^test]: text"));
3161 assert!(rule.is_footnote_definition("[^TEST]: text"));
3162 assert!(rule.is_footnote_definition("[^test-name]: text"));
3163 assert!(rule.is_footnote_definition("[^test_name]: text"));
3164 assert!(rule.is_footnote_definition("[^test123]: text"));
3165 assert!(rule.is_footnote_definition("[^123]: text"));
3166 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
3167
3168 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")); }
3176
3177 #[test]
3178 fn test_code_block_inside_html_comment() {
3179 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3182 let content = r#"# Document
3183
3184Some text.
3185
3186<!--
3187Example code block in comment:
3188
3189```typescript
3190console.log("Hello");
3191```
3192
3193More comment text.
3194-->
3195
3196More content."#;
3197
3198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3199 let result = rule.check(&ctx).unwrap();
3200
3201 assert_eq!(
3202 result.len(),
3203 0,
3204 "Code blocks inside HTML comments should not be flagged as unclosed"
3205 );
3206 }
3207
3208 #[test]
3209 fn test_unclosed_fence_inside_html_comment() {
3210 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3212 let content = r#"# Document
3213
3214<!--
3215Example with intentionally unclosed fence:
3216
3217```
3218code without closing
3219-->
3220
3221More content."#;
3222
3223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3224 let result = rule.check(&ctx).unwrap();
3225
3226 assert_eq!(
3227 result.len(),
3228 0,
3229 "Unclosed fences inside HTML comments should be ignored"
3230 );
3231 }
3232
3233 #[test]
3234 fn test_multiline_html_comment_with_indented_code() {
3235 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3237 let content = r#"# Document
3238
3239<!--
3240Example:
3241
3242 indented code
3243 more code
3244
3245End of comment.
3246-->
3247
3248Regular text."#;
3249
3250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3251 let result = rule.check(&ctx).unwrap();
3252
3253 assert_eq!(
3254 result.len(),
3255 0,
3256 "Indented code inside HTML comments should not be flagged"
3257 );
3258 }
3259
3260 #[test]
3261 fn test_code_block_after_html_comment() {
3262 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3264 let content = r#"# Document
3265
3266<!-- comment -->
3267
3268Text before.
3269
3270 indented code should be flagged
3271
3272More text."#;
3273
3274 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3275 let result = rule.check(&ctx).unwrap();
3276
3277 assert_eq!(
3278 result.len(),
3279 1,
3280 "Code blocks after HTML comments should still be detected"
3281 );
3282 assert!(result[0].message.contains("Use fenced code blocks"));
3283 }
3284
3285 #[test]
3286 fn test_consistent_style_indented_html_comment() {
3287 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3293 let content = "# MD046 false-positive reproduction\n\
3294 \n\
3295 <!--\n \
3296 This is just an indented comment, not a code block.\n\
3297 \n \
3298 A second line is required to trigger the false-positive.\n\
3299 \n \
3300 Actually, three lines are required.\n\
3301 -->\n\
3302 \n\
3303 ```md\n\
3304 This should be fine, since it's the only code block and therefore consistent.\n\
3305 ```\n";
3306
3307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3308 let result = rule.check(&ctx).unwrap();
3309
3310 assert_eq!(
3311 result,
3312 vec![],
3313 "A single fenced block and an indented HTML comment must produce no MD046 warnings",
3314 );
3315 }
3316
3317 #[test]
3318 fn test_consistent_style_indented_html_block() {
3319 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3326 let content = "# Heading\n\
3327 \n\
3328 <div class=\"note\">\n \
3329 line one of indented html content\n \
3330 line two of indented html content\n \
3331 line three of indented html content\n\
3332 </div>\n\
3333 \n\
3334 ```md\n\
3335 real fenced block\n\
3336 ```\n";
3337
3338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3339 let result = rule.check(&ctx).unwrap();
3340
3341 assert_eq!(
3342 result,
3343 vec![],
3344 "Indented content inside a raw HTML block must not influence MD046 style detection",
3345 );
3346 }
3347
3348 #[test]
3349 fn test_consistent_style_fake_fence_inside_html_comment() {
3350 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3356 let content = "# Title\n\
3357 \n\
3358 <!--\n\
3359 ```\n\
3360 fake fence inside comment\n\
3361 ```\n\
3362 -->\n\
3363 \n \
3364 real indented code block line 1\n \
3365 real indented code block line 2\n";
3366
3367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3368 let result = rule.check(&ctx).unwrap();
3369
3370 assert_eq!(
3371 result,
3372 vec![],
3373 "Fence markers inside an HTML comment must not influence MD046 style detection",
3374 );
3375 }
3376
3377 #[test]
3378 fn test_consistent_style_indented_footnote_definition() {
3379 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3383 let content = "# Heading\n\
3384 \n\
3385 Reference to a footnote[^note].\n\
3386 \n\
3387 [^note]: First line of the footnote.\n \
3388 Second indented continuation line.\n \
3389 Third indented continuation line.\n \
3390 Fourth indented continuation line.\n\
3391 \n\
3392 ```md\n\
3393 real fenced block\n\
3394 ```\n";
3395
3396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3397 let result = rule.check(&ctx).unwrap();
3398
3399 assert_eq!(
3400 result,
3401 vec![],
3402 "Footnote-definition continuation content must not influence MD046 style detection",
3403 );
3404 }
3405
3406 #[test]
3407 fn test_consistent_style_indented_blockquote() {
3408 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3413 let content = "# Heading\n\
3414 \n\
3415 > line one of quoted indented content\n\
3416 >\n\
3417 > line two of quoted indented content\n\
3418 >\n\
3419 > line three of quoted indented content\n\
3420 \n\
3421 ```md\n\
3422 real fenced block\n\
3423 ```\n";
3424
3425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3426 let result = rule.check(&ctx).unwrap();
3427
3428 assert_eq!(
3429 result,
3430 vec![],
3431 "Indented content inside a blockquote must not influence MD046 style detection",
3432 );
3433 }
3434
3435 #[test]
3436 fn test_consistent_style_genuine_indented_block_detected_as_indented() {
3437 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3442 let content = "# Heading\n\
3443 \n\
3444 Some prose.\n\
3445 \n \
3446 real indented code line 1\n \
3447 real indented code line 2\n";
3448
3449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3450 let result = rule.check(&ctx).unwrap();
3451
3452 assert_eq!(
3455 result,
3456 vec![],
3457 "A genuine top-level indented block must be detected as Indented style under Consistent",
3458 );
3459 }
3460
3461 #[test]
3462 fn test_consistent_style_skipped_lines_dont_override_real_block() {
3463 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3468 let content = "# Heading\n\
3469 \n\
3470 <!--\n \
3471 skipped indented comment line 1\n \
3472 skipped indented comment line 2\n\
3473 -->\n\
3474 \n\
3475 <!--\n \
3476 second skipped region\n \
3477 also skipped\n\
3478 -->\n\
3479 \n \
3480 real indented code line\n";
3481
3482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3483 let result = rule.check(&ctx).unwrap();
3484
3485 assert_eq!(
3486 result,
3487 vec![],
3488 "Skipped container lines must not outweigh the single real indented block",
3489 );
3490 }
3491
3492 #[test]
3493 fn test_consistent_style_fenced_wins_over_skipped_indented() {
3494 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3498 let content = "# Heading\n\
3499 \n\
3500 <!--\n \
3501 skipped indented region one\n \
3502 more of region one\n\
3503 -->\n\
3504 \n\
3505 <!--\n \
3506 skipped indented region two\n \
3507 more of region two\n\
3508 -->\n\
3509 \n\
3510 ```md\n\
3511 real fenced block\n\
3512 ```\n";
3513
3514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3515 let result = rule.check(&ctx).unwrap();
3516
3517 assert_eq!(
3518 result,
3519 vec![],
3520 "Fenced block must win when all indented lines are inside skipped containers",
3521 );
3522 }
3523
3524 #[test]
3525 fn test_four_space_indented_fence_is_not_valid_fence() {
3526 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3529
3530 assert!(rule.is_fenced_code_block_start("```"));
3532 assert!(rule.is_fenced_code_block_start(" ```"));
3533 assert!(rule.is_fenced_code_block_start(" ```"));
3534 assert!(rule.is_fenced_code_block_start(" ```"));
3535
3536 assert!(!rule.is_fenced_code_block_start(" ```"));
3538 assert!(!rule.is_fenced_code_block_start(" ```"));
3539 assert!(!rule.is_fenced_code_block_start(" ```"));
3540
3541 assert!(!rule.is_fenced_code_block_start("\t```"));
3543 }
3544
3545 #[test]
3546 fn test_issue_237_indented_fenced_block_detected_as_indented() {
3547 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3553
3554 let content = r#"## Test
3556
3557 ```js
3558 var foo = "hello";
3559 ```
3560"#;
3561
3562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3563 let result = rule.check(&ctx).unwrap();
3564
3565 assert_eq!(
3567 result.len(),
3568 1,
3569 "4-space indented fence should be detected as indented code block"
3570 );
3571 assert!(
3572 result[0].message.contains("Use fenced code blocks"),
3573 "Expected 'Use fenced code blocks' message"
3574 );
3575 }
3576
3577 #[test]
3578 fn test_issue_276_indented_code_in_list() {
3579 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3582
3583 let content = r#"1. First item
35842. Second item with code:
3585
3586 # This is a code block in a list
3587 print("Hello, world!")
3588
35894. Third item"#;
3590
3591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3592 let result = rule.check(&ctx).unwrap();
3593
3594 assert!(
3596 !result.is_empty(),
3597 "Indented code block inside list should be flagged when style=fenced"
3598 );
3599 assert!(
3600 result[0].message.contains("Use fenced code blocks"),
3601 "Expected 'Use fenced code blocks' message"
3602 );
3603 }
3604
3605 #[test]
3606 fn test_three_space_indented_fence_is_valid() {
3607 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3609
3610 let content = r#"## Test
3611
3612 ```js
3613 var foo = "hello";
3614 ```
3615"#;
3616
3617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3618 let result = rule.check(&ctx).unwrap();
3619
3620 assert_eq!(
3622 result.len(),
3623 0,
3624 "3-space indented fence should be recognized as valid fenced code block"
3625 );
3626 }
3627
3628 #[test]
3629 fn test_indented_style_with_deeply_indented_fenced() {
3630 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3633
3634 let content = r#"Text
3635
3636 ```js
3637 var foo = "hello";
3638 ```
3639
3640More text
3641"#;
3642
3643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3644 let result = rule.check(&ctx).unwrap();
3645
3646 assert_eq!(
3649 result.len(),
3650 0,
3651 "4-space indented content should be valid when style=indented"
3652 );
3653 }
3654
3655 #[test]
3656 fn test_fix_misplaced_fenced_block() {
3657 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3660
3661 let content = r#"## Test
3662
3663 ```js
3664 var foo = "hello";
3665 ```
3666"#;
3667
3668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3669 let fixed = rule.fix(&ctx).unwrap();
3670
3671 let expected = r#"## Test
3673
3674```js
3675var foo = "hello";
3676```
3677"#;
3678
3679 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
3680 }
3681
3682 #[test]
3683 fn test_fix_regular_indented_block() {
3684 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3687
3688 let content = r#"Text
3689
3690 var foo = "hello";
3691 console.log(foo);
3692
3693More text
3694"#;
3695
3696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3697 let fixed = rule.fix(&ctx).unwrap();
3698
3699 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
3701 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
3702 }
3703
3704 #[test]
3705 fn test_fix_indented_block_with_fence_like_content() {
3706 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3710
3711 let content = r#"Text
3712
3713 some code
3714 ```not a fence opener
3715 more code
3716"#;
3717
3718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3719 let fixed = rule.fix(&ctx).unwrap();
3720
3721 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
3723 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
3724 }
3725
3726 #[test]
3727 fn test_fix_mixed_indented_and_misplaced_blocks() {
3728 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3730
3731 let content = r#"Text
3732
3733 regular indented code
3734
3735More text
3736
3737 ```python
3738 print("hello")
3739 ```
3740"#;
3741
3742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3743 let fixed = rule.fix(&ctx).unwrap();
3744
3745 assert!(
3747 fixed.contains("```\nregular indented code\n```"),
3748 "First block should be wrapped in fences"
3749 );
3750
3751 assert!(
3753 fixed.contains("\n```python\nprint(\"hello\")\n```"),
3754 "Second block should be dedented, not double-wrapped"
3755 );
3756 assert!(
3758 !fixed.contains("```\n```python"),
3759 "Should not have nested fence openers"
3760 );
3761 }
3762
3763 #[test]
3764 fn test_md046_front_matter() {
3765 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3766 let content = "---\nmetadata:\n\n description: Indented\n---\n";
3767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3768 let result = rule.check(&ctx).unwrap();
3769 assert_eq!(result.len(), 0);
3770 }
3771
3772 #[test]
3773 fn test_md046_fix_front_matter() {
3774 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3775 let content = "---\nmetadata:\n\n description: Indented\n---\n";
3776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3777 let fixed = rule.fix(&ctx).unwrap();
3778 assert_eq!(fixed, content);
3779 }
3780
3781 #[test]
3782 fn test_whitespace_only_line_is_not_an_indented_code_block() {
3783 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3788 let content = "# T\n\nPara\n\n \nMore\n\n real code\n\nEnd\n";
3789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3790 let fixed = rule.fix(&ctx).unwrap();
3791 assert_eq!(fixed, "# T\n\nPara\n\n \nMore\n\n```\nreal code\n```\n\nEnd\n");
3792 }
3793
3794 #[test]
3795 fn test_interior_blank_line_keeps_indented_block_together() {
3796 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3800 let content = "# T\n\nPara\n\n a\n\n b\n\nAfter\n";
3801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3802 let fixed = rule.fix(&ctx).unwrap();
3803 assert_eq!(fixed, "# T\n\nPara\n\n```\na\n\nb\n```\n\nAfter\n");
3804 }
3805
3806 #[test]
3807 fn test_consistent_style_counts_a_block_with_interior_blank_once() {
3808 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3812 let content = "# T\n\n```\nfenced\n```\n\nPara\n\n a\n\n b\n\nEnd\n";
3813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3814 let result = rule.check(&ctx).unwrap();
3815 let reported: Vec<(usize, &str)> = result.iter().map(|w| (w.line, w.message.as_str())).collect();
3816 assert_eq!(reported, vec![(9, "Use fenced code blocks")]);
3817 }
3818
3819 #[test]
3820 fn test_indented_lazy_continuation_lines_are_not_code() {
3821 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3827 let content = "# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n real code\n\nEnd\n";
3828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3829 let fixed = rule.fix(&ctx).unwrap();
3830 assert_eq!(
3831 fixed,
3832 "# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n```\nreal code\n```\n\nEnd\n"
3833 );
3834 }
3835
3836 #[test]
3837 fn test_misplaced_fence_with_interior_blank_dedents_as_one_block() {
3838 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3842 let content = "# T\n\nPara\n\n ```python\n x = 1\n\n y = 2\n ```\n\nAfter\n";
3843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3844 let fixed = rule.fix(&ctx).unwrap();
3845 assert_eq!(fixed, "# T\n\nPara\n\n```python\nx = 1\n\ny = 2\n```\n\nAfter\n");
3846 }
3847 #[test]
3848 fn test_mdg_overrides_indented_style_to_fenced() {
3849 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3854 let content = "# Feature: Payloads\n\n## Scenario: JSON payload\n\n* Given this payload\n\n ```json\n {\"ok\": true}\n ```\n";
3855
3856 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3857 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3858 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3859
3860 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3863 let standard_warnings = rule.check(&standard_ctx).unwrap();
3864 assert_eq!(standard_warnings.len(), 1);
3865 assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3866 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3867 }
3868
3869 #[test]
3870 fn test_mdg_indented_style_still_fences_indented_blocks() {
3871 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3875 let content =
3876 "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n ordinary indented code\n";
3877
3878 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3879 let warnings = rule.check(&mdg_ctx).unwrap();
3880 assert_eq!(warnings.len(), 1);
3881 assert_eq!(warnings[0].message, "Use fenced code blocks");
3882
3883 let fixed = rule.fix(&mdg_ctx).unwrap();
3884 assert_eq!(
3885 fixed,
3886 "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n```\n ordinary indented code\n```\n"
3887 );
3888
3889 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3890 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3891 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3892
3893 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3896 assert!(rule.check(&standard_ctx).unwrap().is_empty());
3897 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3898 }
3899
3900 #[test]
3901 fn test_mdg_steers_indented_code_to_fenced() {
3902 let content = "# Feature: Payloads\n\n## Scenario: Plain payload\n\n* Given this payload\n\n ordinary indented code\n";
3906
3907 for rule in [
3908 MD046CodeBlockStyle::new(CodeBlockStyle::Fenced),
3909 MD046CodeBlockStyle::new(CodeBlockStyle::Consistent),
3910 MD046CodeBlockStyle::new(CodeBlockStyle::Indented),
3911 ] {
3912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3913 let warnings = rule.check(&ctx).unwrap();
3914 assert_eq!(warnings.len(), 1);
3915 assert_eq!(warnings[0].message, "Use fenced code blocks");
3916
3917 let fixed = rule.fix(&ctx).unwrap();
3918 assert!(fixed.contains("```"), "MDG must fence the block: {fixed:?}");
3919
3920 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3921 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3922 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3923 }
3924 }
3925
3926 #[test]
3927 fn test_mdg_consistent_style_ignores_indented_prevalence() {
3928 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3931 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";
3932
3933 let standard_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::Standard, None);
3934 let standard_warnings = rule.check(&standard_ctx).unwrap();
3935 assert_eq!(standard_warnings.len(), 1);
3936 assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3937
3938 let mdg_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::MDG, None);
3939 let mdg_warnings = rule.check(&mdg_ctx).unwrap();
3940 assert_eq!(mdg_warnings.len(), 2);
3941 assert!(
3942 mdg_warnings
3943 .iter()
3944 .all(|warning| warning.message == "Use fenced code blocks")
3945 );
3946 }
3947
3948 #[test]
3949 fn test_mdg_repairs_unclosed_fence_like_standard() {
3950 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3953 let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3954
3955 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3956 let warnings = rule.check(&mdg_ctx).unwrap();
3957 assert_eq!(warnings.len(), 1);
3958 assert!(warnings[0].message.contains("never closed"));
3959
3960 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3961 assert_eq!(
3962 rule.fix(&mdg_ctx).unwrap(),
3963 rule.fix(&standard_ctx).unwrap(),
3964 "MDG must not differ from Standard"
3965 );
3966 }
3967
3968 #[test]
3969 fn test_mdg_table_above_prose_is_never_fenced() {
3970 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3976 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";
3977
3978 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3979 let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3980 assert_eq!(reported, vec![8, 12]);
3981
3982 let fixed = rule.fix(&mdg_ctx).unwrap();
3983 assert_eq!(
3984 fixed,
3985 "# 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"
3986 );
3987
3988 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3989 assert!(
3990 rule.check(&fixed_ctx).unwrap().is_empty(),
3991 "MDG check must have nothing left to report after its own fix"
3992 );
3993 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3994
3995 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3997 let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3998 assert_eq!(standard_reported, vec![5, 12]);
3999 assert!(rule.fix(&standard_ctx).unwrap().contains("```\n| start | eat | left |"));
4000 }
4001
4002 #[test]
4003 fn test_mdg_repairs_unclosed_fence_under_indented_style() {
4004 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4008 let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
4009
4010 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
4011 let warnings = rule.check(&mdg_ctx).unwrap();
4012 assert_eq!(warnings.len(), 1);
4013 assert!(warnings[0].message.contains("never closed"));
4014
4015 let fixed = rule.fix(&mdg_ctx).unwrap();
4016 assert_eq!(fixed, "# Feature: Payloads\n\n```json\n{\"ok\": true}\n```\n");
4017
4018 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
4019 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
4020
4021 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4024 assert_eq!(rule.fix(&standard_ctx).unwrap(), fixed);
4025 }
4026
4027 #[test]
4028 fn test_mdg_tab_indented_table_is_not_code() {
4029 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4033 for indent in ["\t\t", " \t"] {
4034 let content = format!(
4035 "# Feature: Eating\n\n#### Examples:\n\n{indent}| start | eat |\n{indent}| ----- | --- |\n\n## Scenario: Other\n\n code here\n"
4036 );
4037
4038 let mdg_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
4039 let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
4040 assert_eq!(reported, vec![10], "tab-indented rows are a table, not code");
4041
4042 let fixed = rule.fix(&mdg_ctx).unwrap();
4043 assert!(
4044 fixed.contains(&format!("{indent}| start | eat |\n{indent}| ----- | --- |")),
4045 "MDG must leave the tab-indented table alone: {fixed:?}"
4046 );
4047
4048 let standard_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
4049 let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
4050 assert_eq!(standard_reported, vec![5, 10]);
4051 }
4052 }
4053
4054 #[test]
4055 fn test_from_config_records_whether_style_was_configured() {
4056 use crate::config::Config;
4060 use std::collections::BTreeMap;
4061
4062 let mut values = BTreeMap::new();
4063 values.insert("style".to_string(), toml::Value::String("indented".to_string()));
4064 let mut config = Config::default();
4065 config.rules.insert(
4066 "MD046".to_string(),
4067 crate::config::RuleConfig { severity: None, values },
4068 );
4069
4070 let configured = MD046CodeBlockStyle::from_config(&config);
4071 let configured = configured.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
4072 assert_eq!(configured.config.style, CodeBlockStyle::Indented);
4073 assert!(configured.style_explicit);
4074
4075 let defaulted = MD046CodeBlockStyle::from_config(&Config::default());
4076 let defaulted = defaulted.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
4077 assert!(!defaulted.style_explicit);
4078
4079 let indented = MD046CodeBlockStyle::from_config_struct(MD046Config {
4082 style: CodeBlockStyle::Indented,
4083 });
4084 let content = "# Feature: F\n\nText.\n\n code here\n";
4085 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
4086 assert!(indented.fix(&mdg_ctx).unwrap().contains("```\n code here\n```"));
4087 }
4088
4089 #[test]
4090 fn test_mdg_indented_style_keeps_tables_out_of_code() {
4091 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4094 let content = "# Feature: Eating\n\n#### Examples:\n\n | start | eat | left |\n | ----- | --- | ---- |\n";
4095
4096 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
4097 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
4098 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
4099
4100 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4103 assert!(rule.check(&standard_ctx).unwrap().is_empty());
4104 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
4105 }
4106
4107 #[test]
4111 fn test_style_warnings_carry_their_block_fix() {
4112 let cases = [
4113 (
4114 CodeBlockStyle::Fenced,
4115 "# T\n\n one\n\n still one\n\nText\n\n two\n",
4116 "# T\n\n```\none\n\nstill one\n```\n\nText\n\n```\ntwo\n```\n",
4117 ),
4118 (
4119 CodeBlockStyle::Indented,
4120 "# T\n\n```\none\n```\n\nText\n\n~~~\ntwo\n~~~\n",
4121 "# T\n\n one\n\nText\n\n two\n",
4122 ),
4123 (
4125 CodeBlockStyle::Fenced,
4126 "# T\n\n ```bash\n ls\n ```\n",
4127 "# T\n\n```bash\nls\n```\n",
4128 ),
4129 (CodeBlockStyle::Fenced, "# T\n\n code", "# T\n\n```\ncode\n```"),
4131 ];
4132 for (style, content, expected) in cases {
4133 let rule = MD046CodeBlockStyle::new(style);
4134 for newline in ["\n", "\r\n"] {
4135 let content = content.replace('\n', newline);
4136 let expected = expected.replace('\n', newline);
4137 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
4138 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
4139 let warnings = rule.check(&ctx).unwrap();
4140 assert!(!warnings.is_empty(), "{content:?}");
4141 for warning in &warnings {
4142 let fix = warning
4143 .fix
4144 .as_ref()
4145 .unwrap_or_else(|| panic!("{content:?}: {warning:?}"));
4146 let fix_line = content[..fix.range.start].matches('\n').count() + 1;
4147 assert_eq!(
4148 fix_line, warning.line,
4149 "a warning's fix starts at its block: {content:?}"
4150 );
4151 }
4152 let edited = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings).unwrap();
4153 assert_eq!(edited, expected, "{content:?}");
4154
4155 let first = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings[..1]).unwrap();
4157 let first_ctx = LintContext::new(&first, crate::config::MarkdownFlavor::Standard, None);
4158 assert_eq!(
4159 rule.check(&first_ctx).unwrap().len(),
4160 warnings.len() - 1,
4161 "{content:?} -> {first:?}"
4162 );
4163 }
4164 }
4165 }
4166
4167 #[test]
4169 fn test_style_warning_without_a_fix_where_fix_keeps_the_block() {
4170 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4171 let content = "# T\n\n ```\n code\n";
4174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4175 assert_eq!(rule.fix(&ctx).unwrap(), content);
4176 let warnings = rule.check(&ctx).unwrap();
4177 assert_eq!(warnings.len(), 1, "{warnings:?}");
4178 assert!(warnings[0].fix.is_none(), "{warnings:?}");
4179 }
4180
4181 #[test]
4182 fn literal_code_blocks_are_preserved_beside_real_style_fixes() {
4183 for (style, value, adjacent, target) in [
4184 (
4185 CodeBlockStyle::Fenced,
4186 "first\n\n inner\n\nlast",
4187 " Adjacent",
4188 "```\nAdjacent\n```",
4189 ),
4190 (
4191 CodeBlockStyle::Indented,
4192 "first\n\n~~~\ninner\n~~~\n\nlast",
4193 "~~~\nAdjacent\n~~~",
4194 " Adjacent",
4195 ),
4196 ] {
4197 let rule = MD046CodeBlockStyle::new(style);
4198 for (flavor, code) in [
4199 (
4200 crate::config::MarkdownFlavor::Standard,
4201 format!("{{% set unused=\"{value}\" %}}{{{{ unused|length }}}}"),
4202 ),
4203 (
4204 crate::config::MarkdownFlavor::Hugo,
4205 format!("{{{{< note title=`{value}` >}}}}"),
4206 ),
4207 ] {
4208 for ending in ["\n", "\r\n"] {
4209 let source = format!("{code}\n\n{adjacent}\n").replace('\n', ending);
4210 let expected = format!("{code}\n\n{target}\n").replace('\n', ending);
4211 let ctx = LintContext::new(&source, flavor, None);
4212 let warnings = rule.check(&ctx).unwrap();
4213 assert_eq!(warnings.len(), 1, "{source}");
4214 assert!(warnings[0].fix.is_some());
4215 assert_eq!(
4216 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
4217 expected
4218 );
4219 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4220 }
4221 }
4222 }
4223 }
4224
4225 #[test]
4226 fn literal_block_styles_do_not_choose_visible_code_style() {
4227 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
4228 for (value, adjacent) in [
4229 ("first\n\n~~~\nFake\n~~~\n\n~~~\nOther\n~~~\n\nlast", " Adjacent"),
4230 ("first\n\n Fake\n\nText\n\n Other\n\nlast", "~~~\nAdjacent\n~~~"),
4231 ] {
4232 let source = format!("{{% set unused=\"{value}\" %}}{{{{ unused|length }}}}\n\n{adjacent}\n");
4233 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4234 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
4235 assert_eq!(rule.fix(&ctx).unwrap(), source);
4236 }
4237 }
4238
4239 #[test]
4240 fn literal_unclosed_fences_do_not_trigger_document_repair() {
4241 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4242 for (flavor, code) in [
4243 (
4244 crate::config::MarkdownFlavor::Standard,
4245 "{% set unused=\"first\n\n~~~\ninner\nlast\" %}{{ unused|length }}",
4246 ),
4247 (
4248 crate::config::MarkdownFlavor::Hugo,
4249 "{{< note title=`first\n\n~~~\ninner\nlast` >}}",
4250 ),
4251 ] {
4252 for ending in ["\n", "\r\n"] {
4253 let source = format!("{code}\n\nVisible.\n").replace('\n', ending);
4254 let ctx = LintContext::new(&source, flavor, None);
4255 assert!(rule.check(&ctx).unwrap().is_empty());
4256 assert_eq!(rule.fix(&ctx).unwrap(), source);
4257 }
4258 }
4259 }
4260
4261 #[test]
4262 fn real_indented_template_blocks_are_not_partially_converted() {
4263 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4264 let title = " {% set unused=\"first\n last\" %}{{ unused|length }}";
4265 let source = format!("{title}\n\nParagraph.\n\n Adjacent\n");
4266 let expected = format!("{title}\n\nParagraph.\n\n```\nAdjacent\n```\n");
4267 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4268 let warnings = rule.check(&ctx).unwrap();
4269 assert_eq!(warnings.len(), 2);
4270 assert!(warnings[0].fix.is_none());
4271 assert!(warnings[1].fix.is_some());
4272 assert_eq!(
4273 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
4274 expected
4275 );
4276 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4277 }
4278
4279 #[test]
4280 fn real_fenced_template_blocks_are_not_partially_converted() {
4281 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4282 let title = "~~~\n{% set unused=\"first\nlast\" %}{{ unused|length }}\n~~~";
4283 let source = format!("{title}\n\nParagraph.\n\n~~~\nAdjacent\n~~~\n");
4284 let expected = format!("{title}\n\nParagraph.\n\n Adjacent\n");
4285 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4286 let warnings = rule.check(&ctx).unwrap();
4287 assert_eq!(warnings.len(), 2);
4288 assert!(warnings[0].fix.is_none());
4289 assert!(warnings[1].fix.is_some());
4290 assert_eq!(
4291 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
4292 expected
4293 );
4294 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4295 }
4296
4297 #[test]
4298 fn real_single_line_template_code_blocks_remain_convertible() {
4299 for (style, source, expected) in [
4300 (CodeBlockStyle::Fenced, " {{ title }}\n", "```\n{{ title }}\n```\n"),
4301 (CodeBlockStyle::Indented, "~~~\n{{ title }}\n~~~\n", " {{ title }}\n"),
4302 ] {
4303 let rule = MD046CodeBlockStyle::new(style);
4304 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
4305 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4306 assert!(rule.check(&ctx).unwrap()[0].fix.is_some());
4307 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4308 }
4309 }
4310
4311 #[test]
4312 fn mdx_literal_indentation_does_not_become_a_code_block() {
4313 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4314 for code in [
4315 "export const text = `first\n\n inner\n\nlast`\n\n{text}",
4316 "{`first\n\n inner\n\nlast`}",
4317 ] {
4318 let source = format!("{code}\n\n```\nAdjacent\n```\n");
4319 let expected = format!("{code}\n\n```\nAdjacent\n```\n");
4320 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
4321 let warnings = rule.check(&ctx).unwrap();
4322 assert!(warnings.is_empty());
4323 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4324 }
4325 }
4326
4327 #[test]
4328 fn literal_closers_keep_ambiguous_fence_suffixes_unchanged() {
4329 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4330 let suffix = "~~~\n{% set unused=\"first\n~~~\nlast\" %}{{ unused|length }}\n~~~\n";
4331 let source = format!("~~~\nEarlier\n~~~\n\nParagraph.\n\n{suffix}");
4332 let expected = format!(" Earlier\n\nParagraph.\n\n{suffix}");
4333 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4334 let warnings = rule.check(&ctx).unwrap();
4335 assert_eq!(warnings.len(), 1);
4336 assert!(warnings[0].fix.is_some());
4337 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4338 }
4339}