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::range_utils::calculate_line_range;
6use toml;
7
8mod md046_config;
9pub use md046_config::CodeBlockStyle;
10use md046_config::MD046Config;
11
12static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
14
15struct IndentContext<'a> {
17 in_list_context: &'a [bool],
18 in_comment_or_html: &'a [bool],
24 list_item_baseline: &'a [Option<usize>],
31}
32
33struct OwnedIndentContext {
36 in_list_context: Vec<bool>,
37 in_comment_or_html: Vec<bool>,
38 list_item_baseline: Vec<Option<usize>>,
39}
40
41impl OwnedIndentContext {
42 fn borrow(&self) -> IndentContext<'_> {
43 IndentContext {
44 in_list_context: &self.in_list_context,
45 in_comment_or_html: &self.in_comment_or_html,
46 list_item_baseline: &self.list_item_baseline,
47 }
48 }
49}
50
51#[derive(Clone)]
57pub struct MD046CodeBlockStyle {
58 config: MD046Config,
59 style_explicit: bool,
63}
64
65impl MD046CodeBlockStyle {
66 const FENCE: &'static str = "```";
68
69 pub fn new(style: CodeBlockStyle) -> Self {
70 Self {
71 config: MD046Config { style },
72 style_explicit: true,
73 }
74 }
75
76 pub fn from_config_struct(config: MD046Config) -> Self {
77 Self {
78 config,
79 style_explicit: false,
80 }
81 }
82
83 fn has_valid_fence_indent(line: &str) -> bool {
88 calculate_indentation_width_default(line) < 4
89 }
90
91 fn has_valid_fence_indent_at(line: &str, baseline: usize) -> bool {
95 let indent = calculate_indentation_width_default(line);
96 indent >= baseline && indent - baseline < 4
97 }
98
99 fn is_fenced_code_block_start(&self, line: &str) -> bool {
108 if !Self::has_valid_fence_indent(line) {
109 return false;
110 }
111
112 let trimmed = line.trim_start();
113 trimmed.starts_with("```") || trimmed.starts_with("~~~")
114 }
115
116 fn is_fenced_code_block_start_at(&self, line: &str, baseline: usize) -> bool {
117 if baseline == 0 {
118 return self.is_fenced_code_block_start(line);
119 }
120
121 Self::has_valid_fence_indent_at(line, baseline)
122 && (line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~"))
123 }
124
125 fn is_closing_fence(line: &str, fence_char: char, opener_len: usize, baseline: usize) -> bool {
126 if !Self::has_valid_fence_indent_at(line, baseline) {
127 return false;
128 }
129
130 let trimmed = line.trim_start();
131 let closer_len = trimmed.chars().take_while(|&ch| ch == fence_char).count();
132 closer_len >= opener_len && closer_len > 0 && trimmed[closer_len..].trim().is_empty()
133 }
134
135 fn strip_indentation_columns(line: &str, columns: usize) -> String {
140 if columns == 0 {
141 return line.to_string();
142 }
143
144 let mut width = 0usize;
145 let mut consumed = 0usize;
146
147 for (byte_index, ch) in line.char_indices() {
148 let next_width = match ch {
149 ' ' => width + 1,
150 '\t' => ((width / 4) + 1) * 4,
151 _ => break,
152 };
153 consumed = byte_index + ch.len_utf8();
154
155 if next_width >= columns {
156 let remainder = next_width - columns;
157 let mut stripped = String::with_capacity(remainder + line.len() - consumed);
158 stripped.extend(std::iter::repeat_n(' ', remainder));
159 stripped.push_str(&line[consumed..]);
160 return stripped;
161 }
162
163 width = next_width;
164 }
165
166 line[consumed..].to_string()
167 }
168
169 fn shift_left_columns(line: &str, columns: usize) -> String {
175 if columns.is_multiple_of(4) {
176 return Self::strip_indentation_columns(line, columns);
177 }
178 let text = line.trim_start_matches([' ', '\t']);
179 let width = calculate_indentation_width_default(&line[..line.len() - text.len()]);
180 format!("{}{text}", " ".repeat(width.saturating_sub(columns)))
181 }
182
183 fn is_list_item(&self, line: &str) -> bool {
184 let trimmed = line.trim_start();
185 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
186 return true;
187 }
188 let after_digits = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
193 after_digits.len() < trimmed.len() && (after_digits.starts_with(". ") || after_digits.starts_with(") "))
194 }
195
196 fn is_footnote_definition(&self, line: &str) -> bool {
216 let trimmed = line.trim_start();
217 if !trimmed.starts_with("[^") || trimmed.len() < 5 {
218 return false;
219 }
220
221 if let Some(close_bracket_pos) = trimmed.find("]:")
222 && close_bracket_pos > 2
223 {
224 let label = &trimmed[2..close_bracket_pos];
225
226 if label.trim().is_empty() {
227 return false;
228 }
229
230 if label.contains('\r') {
232 return false;
233 }
234
235 if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
237 return true;
238 }
239 }
240
241 false
242 }
243
244 fn precompute_block_continuation_context(&self, lines: &[&str], in_indented_code: &[bool]) -> Vec<bool> {
272 let mut in_continuation_context = vec![false; lines.len()];
273 let mut last_list_item_line: Option<usize> = None;
274 let mut last_footnote_line: Option<usize> = None;
275 let mut blank_line_count = 0;
276
277 for (i, line) in lines.iter().enumerate() {
278 let trimmed = line.trim_start();
279 let indent_len = line.len() - trimmed.len();
280
281 let is_code = in_indented_code.get(i).copied().unwrap_or(false);
282
283 if !is_code && self.is_list_item(line) {
285 last_list_item_line = Some(i);
286 last_footnote_line = None; blank_line_count = 0;
288 in_continuation_context[i] = true;
289 continue;
290 }
291
292 if !is_code && self.is_footnote_definition(line) {
294 last_footnote_line = Some(i);
295 last_list_item_line = None; blank_line_count = 0;
297 in_continuation_context[i] = true;
298 continue;
299 }
300
301 if line.trim().is_empty() {
303 if last_list_item_line.is_some() || last_footnote_line.is_some() {
305 blank_line_count += 1;
306 in_continuation_context[i] = true;
307
308 }
312 continue;
313 }
314
315 if indent_len == 0 && !trimmed.is_empty() {
317 if trimmed.starts_with('#') {
321 last_list_item_line = None;
322 last_footnote_line = None;
323 blank_line_count = 0;
324 continue;
325 }
326
327 if trimmed.starts_with("---") || trimmed.starts_with("***") {
329 last_list_item_line = None;
330 last_footnote_line = None;
331 blank_line_count = 0;
332 continue;
333 }
334
335 if let Some(list_line) = last_list_item_line
338 && (i - list_line > 5 || blank_line_count > 1)
339 {
340 last_list_item_line = None;
341 }
342
343 if last_footnote_line.is_some() {
345 last_footnote_line = None;
346 }
347
348 blank_line_count = 0;
349
350 if last_list_item_line.is_none() && last_footnote_line.is_some() {
352 last_footnote_line = None;
353 }
354 continue;
355 }
356
357 if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
359 in_continuation_context[i] = true;
360 blank_line_count = 0;
361 }
362 }
363
364 in_continuation_context
365 }
366
367 fn precompute_list_item_baseline(
383 &self,
384 ctx: &crate::lint_context::LintContext,
385 lines: &[&str],
386 ) -> Vec<Option<usize>> {
387 let mut opens_fence = vec![false; lines.len()];
388 for detail in ctx.code_block_details.iter().filter(|detail| detail.is_fenced) {
389 if let Some(line) = Self::code_block_start_line(ctx, detail).filter(|&line| line < lines.len()) {
390 opens_fence[line] = true;
391 }
392 }
393
394 let mut baselines = vec![None; lines.len()];
395 let mut open_items: Vec<usize> = Vec::new();
396 let mut last_list_item_line: Option<usize> = None;
397 let mut blank_line_count = 0usize;
398 let mut last_item_quoted = false;
399
400 for (i, line) in lines.iter().enumerate() {
401 let trimmed = line.trim_start();
402 let indent_len = line.len() - trimmed.len();
403
404 if let Some(info) = ctx.line_info(i + 1)
408 && let Some(item) = info.list_item.as_ref()
409 {
410 while open_items.last().is_some_and(|&column| column > item.marker_column) {
411 open_items.pop();
412 }
413 open_items.push(item.content_column);
414 last_list_item_line = Some(i);
415 last_item_quoted = info.blockquote.is_some();
416 blank_line_count = 0;
417 baselines[i] = Some(item.content_column);
418 continue;
419 }
420
421 if last_item_quoted && line.trim().is_empty() {
424 open_items.clear();
425 last_list_item_line = None;
426 last_item_quoted = false;
427 continue;
428 }
429
430 if line.trim().is_empty() {
432 if let Some(&column) = open_items.last() {
433 blank_line_count += 1;
434 baselines[i] = Some(column);
435 }
436 continue;
437 }
438
439 if indent_len == 0 {
443 if trimmed.starts_with('#') || trimmed.starts_with("---") || trimmed.starts_with("***") {
444 open_items.clear();
445 last_list_item_line = None;
446 } else if let Some(list_line) = last_list_item_line
447 && (i - list_line > 5 || blank_line_count > 1)
448 {
449 open_items.clear();
450 last_list_item_line = None;
451 }
452 blank_line_count = 0;
453 continue;
454 }
455
456 if opens_fence[i] || blank_line_count > 0 {
459 let indent = calculate_indentation_width_default(line);
460 let reached = open_items.iter().take_while(|&&column| column <= indent).count();
461 open_items.truncate(reached);
462 }
463
464 if let Some(&column) = open_items.last() {
466 baselines[i] = Some(column);
467 blank_line_count = 0;
468 }
469 }
470
471 baselines
472 }
473
474 fn first_code_block_line(
483 ctx: &crate::lint_context::LintContext,
484 block_lines: &[Option<usize>],
485 start: usize,
486 block_end: usize,
487 ) -> Option<usize> {
488 (start..block_lines.len())
489 .take_while(|&idx| ctx.line_offsets.get(idx).is_some_and(|&offset| offset < block_end))
490 .find(|&idx| block_lines[idx].is_some())
491 }
492
493 fn convertible_indented_blocks<'c>(
502 ctx: &'c crate::lint_context::LintContext<'_>,
503 ) -> impl Iterator<Item = (std::ops::Range<usize>, usize)> + 'c {
504 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
505 ctx.code_block_details
506 .iter()
507 .filter(move |detail| Self::indented_block_is_reported(ctx, detail, ambiguity_limit))
508 .filter_map(|detail| {
509 let first = Self::code_block_start_line(ctx, detail)?;
510 if !ctx.content[ctx.line_offsets[first]..detail.start].trim().is_empty() {
511 return None;
512 }
513 let end = ctx.line_offsets.partition_point(|&offset| offset < detail.end);
514 Some((first..end.max(first + 1), detail.content_column?))
515 })
516 }
517
518 fn indented_block_lines(lines: &[&str], ctx: &crate::lint_context::LintContext) -> Vec<Option<usize>> {
538 let blocks: Vec<std::ops::Range<usize>> = Self::convertible_indented_blocks(ctx)
539 .map(|(range, _)| range.start.min(lines.len())..range.end.min(lines.len()))
540 .collect();
541 let mut member = vec![None; lines.len()];
542 for (id, block) in blocks.iter().enumerate() {
543 for i in block.clone() {
544 member[i] = (!lines[i].trim().is_empty()).then_some(id);
545 }
546 }
547
548 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
555 let mut i = 0;
556 while i < member.len() {
557 if member[i].is_none() {
558 i += 1;
559 continue;
560 }
561 let start = i;
562 while i < member.len() && member[i] == member[start] {
563 i += 1;
564 }
565 if lines[start..i].iter().all(|line| mdg::is_table_row(line)) {
566 member[start..i].fill(None);
567 }
568 }
569 }
570
571 for (id, block) in blocks.into_iter().enumerate() {
572 let mut i = block.start;
573 while i < block.end {
574 if member[i] != Some(id) {
575 i += 1;
576 continue;
577 }
578 let mut next = i + 1;
579 while next < block.end && lines[next].trim().is_empty() {
580 next += 1;
581 }
582 if next < block.end && member[next] == Some(id) {
583 member[i + 1..next].fill(Some(id));
584 }
585 i = next;
586 }
587 }
588
589 member
590 }
591
592 fn precompute_comment_or_html_context(ctx: &crate::lint_context::LintContext, line_count: usize) -> Vec<bool> {
601 let mut ignored: Vec<bool> = (0..line_count)
602 .map(|i| {
603 ctx.line_info(i + 1)
604 .is_some_and(|info| Self::in_skipped_container(info, info.byte_offset + info.byte_len))
605 })
606 .collect();
607 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
608 for detail in &ctx.code_block_details {
609 if Self::block_is_literal(ctx, detail, ambiguity_limit)
610 && let Some(first) = Self::code_block_start_line(ctx, detail)
611 {
612 let last = ctx.line_offsets.partition_point(|&offset| offset < detail.end);
613 ignored[first..last.min(line_count)].fill(true);
614 }
615 }
616 if let Some(limit) = ambiguity_limit {
617 let first = ctx
618 .line_offsets
619 .partition_point(|&offset| offset <= limit)
620 .saturating_sub(1);
621 ignored[first..].fill(true);
622 }
623 ignored
624 }
625
626 fn build_indent_context(&self, ctx: &crate::lint_context::LintContext, lines: &[&str]) -> OwnedIndentContext {
637 OwnedIndentContext {
638 in_list_context: self
639 .precompute_block_continuation_context(lines, &Self::parsed_indented_code_lines(ctx, lines.len())),
640 in_comment_or_html: Self::precompute_comment_or_html_context(ctx, lines.len()),
641 list_item_baseline: self.precompute_list_item_baseline(ctx, lines),
642 }
643 }
644
645 fn categorize_indented_blocks(
665 &self,
666 lines: &[&str],
667 block_lines: &[Option<usize>],
668 block_columns: &[usize],
669 ) -> (Vec<Option<usize>>, Vec<bool>) {
670 let mut misplaced_dedent = vec![None; lines.len()];
671 let mut contains_fences = vec![false; lines.len()];
672
673 let mut i = 0;
675 while i < lines.len() {
676 if block_lines[i].is_none() {
678 i += 1;
679 continue;
680 }
681
682 let block_start = i;
684 let mut block_end = i;
685
686 while block_end < lines.len() && block_lines[block_end] == block_lines[block_start] {
687 block_end += 1;
688 }
689
690 if block_end > block_start {
692 let first_line = lines[block_start].trim_start();
693
694 let is_backtick_fence =
697 first_line.starts_with("```") && !first_line.trim_start_matches('`').contains('`');
698 let is_tilde_fence = first_line.starts_with("~~~");
699
700 if is_backtick_fence || is_tilde_fence {
701 let fence_char = if is_backtick_fence { '`' } else { '~' };
702 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
703
704 let column = block_columns[block_start];
705 let excess = |j: usize| calculate_indentation_width_default(lines[j]).saturating_sub(column);
706 let opener_excess = excess(block_start);
707
708 let is_closer_text = |j: usize| {
709 let trimmed = lines[j].trim_start();
710 let run = trimmed.chars().take_while(|&c| c == fence_char).count();
711 run >= opener_len && trimmed[run..].trim().is_empty()
712 };
713 let closes_early = (block_start + 1..block_end - 1)
718 .any(|j| excess(j).saturating_sub(opener_excess) < 4 && is_closer_text(j));
719
720 if block_end - block_start > 1 && is_closer_text(block_end - 1) && !closes_early {
721 for (j, dedent) in misplaced_dedent[block_start..block_end - 1].iter_mut().enumerate() {
723 *dedent = Some(opener_excess.min(excess(block_start + j)));
724 }
725 misplaced_dedent[block_end - 1] = Some(excess(block_end - 1));
726 } else {
727 contains_fences[block_start..block_end].fill(true);
730 }
731 } else {
732 let has_fence_markers = (block_start..block_end).any(|j| {
735 let trimmed = lines[j].trim_start();
736 trimmed.starts_with("```") || trimmed.starts_with("~~~")
737 });
738
739 if has_fence_markers {
740 contains_fences[block_start..block_end].fill(true);
741 }
742 }
743 }
744
745 i = block_end;
746 }
747
748 (misplaced_dedent, contains_fences)
749 }
750
751 fn check_unclosed_code_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
752 let mut warnings = Vec::new();
753 let lines = ctx.raw_lines();
754 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
755
756 let has_markdown_doc_block = ctx.code_block_details.iter().any(|d| {
758 if !d.is_fenced || Self::block_is_literal(ctx, d, ambiguity_limit) {
759 return false;
760 }
761 let lang = d.info_string.to_lowercase();
762 lang.starts_with("markdown") || lang.starts_with("md")
763 });
764
765 if has_markdown_doc_block {
768 return warnings;
769 }
770
771 for detail in &ctx.code_block_details {
772 if !detail.is_fenced || Self::block_is_literal(ctx, detail, ambiguity_limit) {
773 continue;
774 }
775
776 if detail.end != ctx.content.len() {
778 continue;
779 }
780
781 let opening_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
783 Ok(idx) => idx,
784 Err(idx) => idx.saturating_sub(1),
785 };
786
787 let line = lines.get(opening_line_idx).unwrap_or(&"");
788 let fence_pos = line.find("```").into_iter().chain(line.find("~~~")).min().unwrap_or(0);
789 let fence_char = line[fence_pos..].chars().next().unwrap_or('`');
790 let fence_marker: String = line[fence_pos..].chars().take_while(|&ch| ch == fence_char).collect();
791 let opening_quote = crate::utils::blockquote::parse_blockquote_prefix(line);
792 let quote_level = opening_quote.map_or(0, |quote| quote.nesting_level);
793 let owned = self.build_indent_context(ctx, lines);
794 let baseline = owned
795 .list_item_baseline
796 .get(opening_line_idx)
797 .copied()
798 .flatten()
799 .unwrap_or(0);
800
801 let has_closing_fence = lines
804 .iter()
805 .enumerate()
806 .rev()
807 .find_map(|(idx, candidate)| {
808 let quote = crate::utils::blockquote::parse_blockquote_prefix(candidate);
809 let body = quote.map_or(*candidate, |quote| quote.content);
810 if body.trim().is_empty() {
811 return None;
812 }
813 Some(
814 idx > opening_line_idx
815 && quote.map_or(0, |quote| quote.nesting_level) == quote_level
816 && Self::is_closing_fence(body, fence_char, fence_marker.len(), baseline),
817 )
818 })
819 .unwrap_or(false);
820
821 if !has_closing_fence {
822 if ctx
824 .lines
825 .get(opening_line_idx)
826 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
827 {
828 continue;
829 }
830
831 let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
832
833 warnings.push(LintWarning {
834 rule_name: Some(self.name().to_string()),
835 line: start_line,
836 column: start_col,
837 end_line,
838 end_column: end_col,
839 message: format!("Code block opened with '{fence_marker}' but never closed"),
840 severity: Severity::Warning,
841 fix: Some(Fix::new(ctx.content.len()..ctx.content.len(), {
842 let prefix: String = line[..fence_pos]
845 .chars()
846 .map(|ch| if ch == '>' || ch.is_whitespace() { ch } else { ' ' })
847 .collect();
848 let newline = crate::utils::detect_line_ending(ctx.content);
849 if ctx.content.ends_with('\n') {
850 format!("{prefix}{fence_marker}{newline}")
851 } else {
852 format!("{newline}{prefix}{fence_marker}")
853 }
854 })),
855 });
856 }
857 }
858
859 warnings
860 }
861
862 fn effective_target_style(
870 &self,
871 ctx: &crate::lint_context::LintContext,
872 detect: impl FnOnce() -> CodeBlockStyle,
873 ) -> CodeBlockStyle {
874 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
875 self.warn_once_about_overridden_style();
876 return CodeBlockStyle::Fenced;
877 }
878
879 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
880 match self.config.style {
881 CodeBlockStyle::Consistent => {
882 let detected = detect();
883 if detected == CodeBlockStyle::Indented
884 && ctx.code_block_details.iter().any(|detail| {
885 detail.is_fenced
886 && !detail.info_string.trim().is_empty()
887 && Self::code_block_is_style_eligible(ctx, detail, ambiguity_limit)
888 })
889 {
890 CodeBlockStyle::Fenced
894 } else {
895 detected
896 }
897 }
898 style => style,
899 }
900 }
901
902 fn block_is_literal(
905 ctx: &crate::lint_context::LintContext,
906 detail: &crate::utils::code_block_utils::CodeBlockDetail,
907 ambiguity_limit: Option<usize>,
908 ) -> bool {
909 ambiguity_limit.is_some_and(|limit| detail.start >= limit)
910 || Self::code_block_start_line(ctx, detail).is_some_and(|line| {
911 ctx.lines.get(line).is_some_and(|info| {
912 let marker = info.byte_offset + info.indent;
913 ctx.is_inside_template_code(marker) || ctx.is_inside_mdx_code(marker)
914 })
915 })
916 }
917
918 fn code_block_is_style_eligible(
922 ctx: &crate::lint_context::LintContext,
923 detail: &crate::utils::code_block_utils::CodeBlockDetail,
924 ambiguity_limit: Option<usize>,
925 ) -> bool {
926 if Self::block_is_literal(ctx, detail, ambiguity_limit) {
927 return false;
928 }
929 let Some(line_idx) = Self::code_block_start_line(ctx, detail) else {
930 return false;
931 };
932
933 !ctx.lines
934 .get(line_idx)
935 .is_some_and(|info| Self::in_skipped_container(info, detail.start))
936 }
937
938 fn in_skipped_container(info: &crate::lint_context::LineInfo, start: usize) -> bool {
948 info.in_html_comment
949 || info.in_mdx_comment
950 || info.in_html_block
951 || info.in_jsx_block
952 || info.in_mkdocstrings
953 || info.in_footnote_definition
954 || info
955 .blockquote
956 .as_ref()
957 .is_some_and(|quote| info.byte_offset + quote.marker_column < start)
958 || info.in_front_matter
959 }
960
961 fn indented_block_is_reported(
966 ctx: &crate::lint_context::LintContext,
967 detail: &crate::utils::code_block_utils::CodeBlockDetail,
968 ambiguity_limit: Option<usize>,
969 ) -> bool {
970 if detail.is_fenced || Self::block_is_literal(ctx, detail, ambiguity_limit) {
971 return false;
972 }
973 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
974 Self::code_block_start_line(ctx, detail)
975 .and_then(|line_idx| ctx.lines.get(line_idx))
976 .is_some_and(|info| {
977 !Self::in_skipped_container(info, detail.start)
978 && !(is_mkdocs && (info.in_admonition || info.in_content_tab))
979 })
980 }
981
982 fn code_block_start_line(
983 ctx: &crate::lint_context::LintContext,
984 detail: &crate::utils::code_block_utils::CodeBlockDetail,
985 ) -> Option<usize> {
986 if detail.start >= ctx.content.len() {
987 return None;
988 }
989
990 Some(match ctx.line_offsets.binary_search(&detail.start) {
991 Ok(idx) => idx,
992 Err(idx) => idx.saturating_sub(1),
993 })
994 }
995
996 fn parsed_indented_code_lines(ctx: &crate::lint_context::LintContext, line_count: usize) -> Vec<bool> {
998 let mut in_code = vec![false; line_count];
999 for block in Self::parsed_indented_block_lines(ctx) {
1000 let end = block.end.min(line_count);
1001 if block.start < end {
1002 in_code[block.start..end].fill(true);
1003 }
1004 }
1005 in_code
1006 }
1007
1008 fn parsed_indented_block_lines<'c>(
1010 ctx: &'c crate::lint_context::LintContext<'_>,
1011 ) -> impl Iterator<Item = std::ops::Range<usize>> + 'c {
1012 ctx.code_block_details
1013 .iter()
1014 .filter(|detail| !detail.is_fenced)
1015 .filter_map(|detail| {
1016 let first = Self::code_block_start_line(ctx, detail)?;
1017 let end = ctx.line_offsets.partition_point(|&offset| offset < detail.end);
1018 Some(first..end.max(first + 1))
1019 })
1020 }
1021
1022 fn fenced_separator_lines(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
1026 let mut lines = std::collections::HashSet::new();
1027
1028 for pair in ctx.code_block_details.windows(2) {
1029 let [previous, next] = pair else {
1030 continue;
1031 };
1032 if previous.end > next.start || next.start > ctx.content.len() {
1033 continue;
1034 }
1035 if !ctx.content[previous.end..next.start].trim().is_empty() {
1036 continue;
1037 }
1038
1039 for detail in [previous, next] {
1040 if detail.is_fenced
1041 && let Some(line) = Self::code_block_start_line(ctx, detail)
1042 {
1043 lines.insert(line);
1044 }
1045 }
1046 }
1047
1048 lines
1049 }
1050
1051 fn fenced_boundary_blank_lines(
1056 ctx: &crate::lint_context::LintContext,
1057 lines: &[&str],
1058 ictx: &IndentContext,
1059 ) -> std::collections::HashSet<usize> {
1060 let mut boundary_blank_lines = std::collections::HashSet::new();
1061
1062 for detail in ctx.code_block_details.iter().filter(|detail| detail.is_fenced) {
1063 let Some(start) = Self::code_block_start_line(ctx, detail) else {
1064 continue;
1065 };
1066 let Some(opener) = lines.get(start) else {
1067 continue;
1068 };
1069 let baseline = ictx.list_item_baseline.get(start).copied().flatten().unwrap_or(0);
1070 let trimmed = opener.trim_start();
1071 if !Self::has_valid_fence_indent_at(opener, baseline) {
1072 continue;
1073 }
1074 let fence_char = if trimmed.starts_with("```") {
1075 '`'
1076 } else if trimmed.starts_with("~~~") {
1077 '~'
1078 } else {
1079 continue;
1082 };
1083 let opener_len = trimmed.chars().take_while(|&ch| ch == fence_char).count();
1084
1085 let mut block_end = start + 1;
1086 let mut closer = None;
1087 while block_end < lines.len()
1088 && ctx
1089 .line_offsets
1090 .get(block_end)
1091 .is_some_and(|&offset| offset < detail.end)
1092 {
1093 if Self::is_closing_fence(lines[block_end], fence_char, opener_len, baseline) {
1094 closer = Some(block_end);
1095 break;
1096 }
1097 block_end += 1;
1098 }
1099
1100 let payload_end = closer.unwrap_or(block_end);
1101 if start + 1 == payload_end
1102 || (start + 1 < payload_end
1103 && (lines[start + 1].trim().is_empty() || lines[payload_end - 1].trim().is_empty()))
1104 {
1105 boundary_blank_lines.insert(start);
1106 }
1107 }
1108
1109 boundary_blank_lines
1110 }
1111
1112 fn warn_once_about_overridden_style(&self) {
1118 if !self.style_explicit || self.config.style != CodeBlockStyle::Indented {
1119 return;
1120 }
1121
1122 MDG_STYLE_OVERRIDE.report(
1123 "MD046",
1124 "style",
1125 "indented",
1126 "fenced",
1127 "a Gherkin Doc String is only ever a backtick fence",
1128 );
1129 }
1130
1131 fn detect_style(
1132 &self,
1133 ctx: &crate::lint_context::LintContext,
1134 lines: &[&str],
1135 ictx: &IndentContext,
1136 ) -> Option<CodeBlockStyle> {
1137 if lines.is_empty() {
1138 return None;
1139 }
1140
1141 let block_lines = Self::indented_block_lines(lines, ctx);
1142
1143 let mut fenced_count = 0;
1159 let mut indented_count = 0;
1160 let mut prev_block = None;
1161
1162 let mut fence_opens = vec![false; lines.len()];
1163 for detail in ctx.code_block_details.iter().filter(|detail| detail.is_fenced) {
1164 if let Some(line) = Self::code_block_start_line(ctx, detail)
1165 && line < fence_opens.len()
1166 {
1167 fence_opens[line] = true;
1168 }
1169 }
1170
1171 for (i, _) in lines.iter().enumerate() {
1172 let in_container = ictx.in_comment_or_html.get(i).copied().unwrap_or(false);
1173
1174 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|l| l.in_code_block) {
1178 prev_block = None;
1179 continue;
1180 }
1181
1182 if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|l| l.in_myst_directive) {
1184 prev_block = None;
1185 continue;
1186 }
1187
1188 if fence_opens[i] {
1189 if !in_container {
1190 fenced_count += 1;
1191 }
1192 prev_block = None;
1193 } else if block_lines[i].is_some() {
1194 if prev_block != block_lines[i] {
1196 indented_count += 1;
1197 }
1198 prev_block = block_lines[i];
1199 } else {
1200 prev_block = None;
1201 }
1202 }
1203
1204 if fenced_count == 0 && indented_count == 0 {
1205 None
1206 } else if fenced_count > 0 && indented_count == 0 {
1207 Some(CodeBlockStyle::Fenced)
1208 } else if fenced_count == 0 && indented_count > 0 {
1209 Some(CodeBlockStyle::Indented)
1210 } else if fenced_count >= indented_count {
1211 Some(CodeBlockStyle::Fenced)
1212 } else {
1213 Some(CodeBlockStyle::Indented)
1214 }
1215 }
1216}
1217
1218impl Rule for MD046CodeBlockStyle {
1219 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1220 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(46))
1221 }
1222
1223 fn name(&self) -> &'static str {
1224 "MD046"
1225 }
1226
1227 fn description(&self) -> &'static str {
1228 "Code blocks should use a consistent style"
1229 }
1230
1231 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1232 self.check_and_retain_conversion(ctx, None)
1233 }
1234
1235 fn check_with_prepared_fix(
1236 &self,
1237 ctx: &crate::lint_context::LintContext,
1238 ) -> Result<(Vec<LintWarning>, Option<String>), LintError> {
1239 let mut prepared = None;
1240 let warnings = self.check_and_retain_conversion(ctx, Some(&mut prepared))?;
1241 Ok((warnings, prepared))
1242 }
1243
1244 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1245 self.fix_native(ctx)
1246 }
1247
1248 fn category(&self) -> RuleCategory {
1250 RuleCategory::CodeBlock
1251 }
1252
1253 fn fix_capability(&self) -> FixCapability {
1254 FixCapability::ConditionallyFixable
1257 }
1258
1259 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1261 ctx.content.is_empty() || ctx.code_block_details.is_empty()
1264 }
1265
1266 fn as_any(&self) -> &dyn std::any::Any {
1267 self
1268 }
1269
1270 crate::impl_rule_config_sections!(MD046Config);
1271
1272 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1273 where
1274 Self: Sized,
1275 {
1276 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1277 let style_explicit = option_is_explicit(config, "MD046", "style");
1278
1279 Box::new(Self {
1280 config: rule_config,
1281 style_explicit,
1282 })
1283 }
1284}
1285
1286impl MD046CodeBlockStyle {
1287 fn check_and_retain_conversion(
1290 &self,
1291 ctx: &crate::lint_context::LintContext,
1292 prepared: Option<&mut Option<String>>,
1293 ) -> LintResult {
1294 if ctx.content.is_empty() {
1296 return Ok(Vec::new());
1297 }
1298
1299 if !ctx.content.contains("```")
1301 && !ctx.content.contains("~~~")
1302 && !ctx.content.contains(" ")
1303 && !ctx.content.contains('\t')
1304 {
1305 return Ok(Vec::new());
1306 }
1307
1308 let mut unclosed_warnings = self.check_unclosed_code_blocks(ctx);
1310
1311 if !unclosed_warnings.is_empty() {
1313 let fixed = self.fix(ctx)?;
1314 for warning in &mut unclosed_warnings {
1315 warning.fix = if fixed == ctx.content {
1316 None
1317 } else if let Some(suffix) = fixed.strip_prefix(ctx.content) {
1318 Some(Fix::new(ctx.content.len()..ctx.content.len(), suffix.to_string()))
1319 } else {
1320 Some(Fix::new(0..ctx.content.len(), fixed.clone()))
1321 };
1322 }
1323 if let Some(prepared) = prepared {
1324 *prepared = Some(fixed);
1325 }
1326 return Ok(unclosed_warnings);
1327 }
1328
1329 let lines = ctx.raw_lines();
1331 let mut warnings = Vec::new();
1332
1333 let target_style = self.effective_target_style(ctx, || {
1335 let owned = self.build_indent_context(ctx, lines);
1336 let detected = self.detect_style(ctx, lines, &owned.borrow());
1337 detected.unwrap_or(CodeBlockStyle::Fenced)
1338 });
1339
1340 let mdg_block_lines = (ctx.flavor == crate::config::MarkdownFlavor::MDG
1345 && ctx.code_block_details.iter().any(|detail| !detail.is_fenced))
1346 .then(|| Self::indented_block_lines(lines, ctx));
1347
1348 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
1350 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
1351
1352 for detail in &ctx.code_block_details {
1353 if detail.start >= ctx.content.len()
1354 || detail.end > ctx.content.len()
1355 || Self::block_is_literal(ctx, detail, ambiguity_limit)
1356 {
1357 continue;
1358 }
1359
1360 let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
1361 Ok(idx) => idx,
1362 Err(idx) => idx.saturating_sub(1),
1363 };
1364
1365 if detail.is_fenced {
1366 if target_style == CodeBlockStyle::Indented {
1367 let line = lines.get(start_line_idx).unwrap_or(&"");
1368
1369 if ctx.lines.get(start_line_idx).is_some_and(|info| {
1370 info.in_html_comment
1371 || info.in_mdx_comment
1372 || info.in_footnote_definition
1373 || info.in_front_matter
1374 }) {
1375 continue;
1376 }
1377
1378 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1379 warnings.push(LintWarning {
1380 rule_name: Some(self.name().to_string()),
1381 line: start_line,
1382 column: start_col,
1383 end_line,
1384 end_column: end_col,
1385 message: "Use indented code blocks".to_string(),
1386 severity: Severity::Warning,
1387 fix: None,
1388 });
1389 }
1390 } else {
1391 if target_style == CodeBlockStyle::Fenced {
1393 if !Self::indented_block_is_reported(ctx, detail, ambiguity_limit) {
1394 continue;
1395 }
1396
1397 let start_line_idx = match &mdg_block_lines {
1402 Some(block_lines) => {
1403 match Self::first_code_block_line(ctx, block_lines, start_line_idx, detail.end) {
1404 Some(idx) => idx,
1405 None => continue,
1406 }
1407 }
1408 None => start_line_idx,
1409 };
1410
1411 if reported_indented_lines.contains(&start_line_idx) {
1412 continue;
1413 }
1414
1415 let line = lines.get(start_line_idx).unwrap_or(&"");
1416
1417 reported_indented_lines.insert(start_line_idx);
1418
1419 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1420 warnings.push(LintWarning {
1421 rule_name: Some(self.name().to_string()),
1422 line: start_line,
1423 column: start_col,
1424 end_line,
1425 end_column: end_col,
1426 message: "Use fenced code blocks".to_string(),
1427 severity: Severity::Warning,
1428 fix: None,
1429 });
1430 }
1431 }
1432 }
1433
1434 if !warnings.is_empty() {
1437 let conversion = self.convert_closed_blocks(ctx)?;
1438 for warning in &mut warnings {
1439 let line_idx = warning.line - 1;
1440 warning.fix = conversion
1441 .edits
1442 .iter()
1443 .find(|edit| edit.lines.contains(&line_idx))
1444 .map(|edit| edit.fix.clone());
1445 }
1446 if let Some(prepared) = prepared {
1447 *prepared = Some(conversion.content);
1448 }
1449 }
1450
1451 warnings.sort_by_key(|w| (w.line, w.column));
1453
1454 Ok(warnings)
1455 }
1456
1457 fn fix_native(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1458 let content = ctx.content;
1459 if content.is_empty() {
1460 return Ok(String::new());
1461 }
1462
1463 let unclosed = crate::utils::fix_utils::filter_warnings_by_inline_config(
1464 self.check_unclosed_code_blocks(ctx),
1465 ctx.inline_config(),
1466 self.name(),
1467 );
1468 if !unclosed.is_empty() {
1469 let repaired =
1470 crate::utils::fix_utils::apply_warning_fixes(content, &unclosed).map_err(LintError::FixFailed)?;
1471 let repaired_ctx = crate::lint_context::LintContext::new(
1472 &repaired,
1473 ctx.flavor,
1474 ctx.source_file().map(std::path::Path::to_path_buf),
1475 );
1476 return Ok(self.convert_closed_blocks(&repaired_ctx)?.content);
1479 }
1480
1481 Ok(self.convert_closed_blocks(ctx)?.content)
1482 }
1483}
1484
1485impl MD046CodeBlockStyle {
1486 fn convert_closed_blocks(&self, ctx: &crate::lint_context::LintContext) -> Result<Conversion, LintError> {
1490 let content = ctx.content;
1491 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
1492 let lines = ctx.raw_lines();
1493
1494 let owned = self.build_indent_context(ctx, lines);
1495 let ictx = owned.borrow();
1496
1497 let target_style = self.effective_target_style(ctx, || {
1498 self.detect_style(ctx, lines, &ictx).unwrap_or(CodeBlockStyle::Fenced)
1499 });
1500
1501 let block_lines = Self::indented_block_lines(lines, ctx);
1502 let mut block_columns = vec![0; lines.len()];
1503 for (range, column) in Self::convertible_indented_blocks(ctx) {
1504 let end = range.end.min(lines.len());
1505 if range.start < end {
1506 block_columns[range.start..end].fill(column);
1507 }
1508 }
1509 let fenced_separator_lines = if target_style == CodeBlockStyle::Indented {
1510 Self::fenced_separator_lines(ctx)
1511 } else {
1512 std::collections::HashSet::new()
1513 };
1514 let fenced_boundary_blank_lines = if target_style == CodeBlockStyle::Indented {
1515 Self::fenced_boundary_blank_lines(ctx, lines, &ictx)
1516 } else {
1517 std::collections::HashSet::new()
1518 };
1519 let fenced_start_lines: std::collections::HashSet<usize> = ctx
1523 .code_block_details
1524 .iter()
1525 .filter(|detail| detail.is_fenced && !Self::block_is_literal(ctx, detail, ambiguity_limit))
1526 .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1527 .collect();
1528 let has_unsupported_fence_opener = ctx
1529 .code_block_details
1530 .iter()
1531 .filter(|detail| detail.is_fenced && Self::code_block_is_style_eligible(ctx, detail, ambiguity_limit))
1532 .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1533 .any(|line_index| {
1534 let Some(line) = lines.get(line_index) else {
1535 return true;
1536 };
1537 let baseline = ictx.list_item_baseline.get(line_index).copied().flatten().unwrap_or(0);
1538 !self.is_fenced_code_block_start_at(line, baseline)
1539 });
1540
1541 let (misplaced_dedent, unsafe_fence_lines) =
1545 self.categorize_indented_blocks(lines, &block_lines, &block_columns);
1546
1547 let mut result = String::with_capacity(content.len());
1548 let mut in_fenced_block = false;
1549 let mut fenced_fence_opener: Option<(char, usize)> = None;
1553 let mut in_indented_block = false;
1554 let mut current_block_fence_indent = String::new();
1559
1560 let mut current_block_must_stay_fenced = false;
1564 let mut current_fence_indent = 0usize;
1565 let mut current_fence_baseline = 0usize;
1566 let mut current_block_indented_prefix = String::from(" ");
1567 let mut converted_fenced_to_indented = false;
1568 let mut retained_structurally_unsafe_fence =
1569 target_style == CodeBlockStyle::Indented && has_unsupported_fence_opener;
1570
1571 let mut output_starts = Vec::with_capacity(lines.len() + 1);
1574 let mut line_blocks: Vec<Option<usize>> = vec![None; lines.len()];
1575 let mut current_block: Option<usize> = None;
1576
1577 for (i, line) in lines.iter().enumerate() {
1578 output_starts.push(result.len());
1579 let line_num = i + 1;
1580 let trimmed = line.trim_start();
1581 let list_baseline = ictx.list_item_baseline.get(i).copied().flatten();
1582 let fence_baseline = list_baseline.unwrap_or(0);
1583
1584 if !in_fenced_block
1587 && fenced_start_lines.contains(&i)
1588 && Self::has_valid_fence_indent_at(line, fence_baseline)
1589 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1590 {
1591 let block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
1593 in_fenced_block = true;
1594 current_block = Some(i);
1595 line_blocks[i] = current_block;
1596 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
1597 let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
1598 fenced_fence_opener = Some((fence_char, opener_len));
1599 current_fence_indent = calculate_indentation_width_default(line);
1600 current_fence_baseline = fence_baseline;
1601 current_block_indented_prefix = " ".repeat(fence_baseline + 4);
1602 let follows_list_item = i
1603 .checked_sub(1)
1604 .and_then(|previous| ictx.list_item_baseline.get(previous))
1605 .copied()
1606 .flatten()
1607 .is_some();
1608 let would_become_list_prose = target_style == CodeBlockStyle::Indented
1609 && list_baseline.is_none()
1610 && (ictx.in_list_context.get(i).copied().unwrap_or(false) || follows_list_item);
1611 let would_interrupt_paragraph = target_style == CodeBlockStyle::Indented
1612 && i > 0
1613 && !lines[i - 1].trim().is_empty()
1614 && ctx
1615 .lines
1616 .get(i - 1)
1617 .is_some_and(crate::lint_context::LineInfo::is_paragraph_context)
1618 && crate::lint_context::is_paragraph_text_line(lines[i - 1]);
1619 let would_merge_code_blocks = fenced_separator_lines.contains(&i);
1620 let would_lose_boundary_blanks = fenced_boundary_blank_lines.contains(&i);
1621 current_block_must_stay_fenced = block_disabled
1622 || !trimmed[opener_len..].trim().is_empty()
1623 || would_become_list_prose
1624 || would_interrupt_paragraph
1625 || would_merge_code_blocks
1626 || would_lose_boundary_blanks;
1627 retained_structurally_unsafe_fence |= would_become_list_prose
1628 || would_interrupt_paragraph
1629 || would_merge_code_blocks
1630 || would_lose_boundary_blanks;
1631
1632 if current_block_must_stay_fenced {
1633 result.push_str(line);
1636 result.push('\n');
1637 } else if target_style == CodeBlockStyle::Indented {
1638 in_indented_block = true;
1640 converted_fenced_to_indented = true;
1641 } else {
1642 result.push_str(line);
1644 result.push('\n');
1645 }
1646 } else if in_fenced_block && fenced_fence_opener.is_some() {
1647 line_blocks[i] = current_block;
1648 let (fence_char, opener_len) = fenced_fence_opener.unwrap();
1649 let is_closer = Self::is_closing_fence(line, fence_char, opener_len, current_fence_baseline);
1652 if is_closer {
1653 in_fenced_block = false;
1654 fenced_fence_opener = None;
1655 in_indented_block = false;
1656
1657 if current_block_must_stay_fenced {
1658 result.push_str(line);
1659 result.push('\n');
1660 } else if target_style == CodeBlockStyle::Indented {
1661 } else {
1663 result.push_str(line);
1665 result.push('\n');
1666 }
1667 current_block_must_stay_fenced = false;
1668 current_fence_indent = 0;
1669 current_fence_baseline = 0;
1670 current_block_indented_prefix.clear();
1671 } else if current_block_must_stay_fenced {
1672 result.push_str(line);
1674 result.push('\n');
1675 } else if target_style == CodeBlockStyle::Indented {
1676 if !line.is_empty() {
1683 let body = Self::strip_indentation_columns(line, current_fence_indent);
1688 result.push_str(¤t_block_indented_prefix);
1689 result.push_str(&body);
1690 }
1691 result.push('\n');
1692 } else {
1693 result.push_str(line);
1695 result.push('\n');
1696 }
1697 } else if block_lines[i].is_some() {
1698 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
1702 result.push_str(line);
1703 result.push('\n');
1704 continue;
1705 }
1706
1707 let prev_line_is_indented = i > 0 && block_lines[i - 1] == block_lines[i];
1709 if !prev_line_is_indented {
1710 current_block = Some(i);
1711 }
1712 line_blocks[i] = current_block;
1713
1714 if target_style == CodeBlockStyle::Fenced {
1715 let baseline = block_columns[i];
1720 let is_blank = line.trim().is_empty();
1729 let width = calculate_indentation_width_default(line);
1730 let body = if is_blank && width <= baseline + 4 {
1731 String::new()
1732 } else {
1733 Self::strip_indentation_columns(line, 4)
1734 };
1735
1736 if let Some(dedent) = misplaced_dedent[i] {
1739 if !is_blank || width.saturating_sub(dedent) > baseline {
1743 result.push_str(&Self::shift_left_columns(line, dedent));
1744 }
1745 result.push('\n');
1746 } else if unsafe_fence_lines[i] {
1747 result.push_str(line);
1750 result.push('\n');
1751 } else if !prev_line_is_indented && !in_indented_block {
1752 current_block_fence_indent = " ".repeat(baseline);
1754 result.push_str(¤t_block_fence_indent);
1755 result.push_str(Self::FENCE);
1756 result.push('\n');
1757 result.push_str(&body);
1758 result.push('\n');
1759 in_indented_block = true;
1760 } else {
1761 result.push_str(&body);
1763 result.push('\n');
1764 }
1765
1766 let next_line_is_indented = block_lines.get(i + 1) == Some(&block_lines[i]);
1768 if !next_line_is_indented
1770 && in_indented_block
1771 && misplaced_dedent[i].is_none()
1772 && !unsafe_fence_lines[i]
1773 {
1774 result.push_str(¤t_block_fence_indent);
1775 result.push_str(Self::FENCE);
1776 result.push('\n');
1777 in_indented_block = false;
1778 current_block_fence_indent.clear();
1779 }
1780 } else {
1781 result.push_str(line);
1783 result.push('\n');
1784 }
1785 } else {
1786 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1788 result.push_str(¤t_block_fence_indent);
1789 result.push_str(Self::FENCE);
1790 result.push('\n');
1791 in_indented_block = false;
1792 current_block_fence_indent.clear();
1793 output_starts[i] = result.len();
1795 }
1796 current_block = None;
1797
1798 result.push_str(line);
1799 result.push('\n');
1800 }
1801 }
1802
1803 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1805 result.push_str(¤t_block_fence_indent);
1806 result.push_str(Self::FENCE);
1807 result.push('\n');
1808 }
1809 output_starts.push(result.len());
1810
1811 if !content.ends_with('\n') && result.ends_with('\n') {
1813 result.pop();
1814 }
1815
1816 if retained_structurally_unsafe_fence && self.config.style == CodeBlockStyle::Consistent {
1817 return Self::new(CodeBlockStyle::Fenced).convert_closed_blocks(ctx);
1818 }
1819
1820 if converted_fenced_to_indented {
1821 let reparsed_block_count = crate::utils::CodeBlockUtils::detect_code_blocks(&result).len();
1822 if reparsed_block_count != ctx.code_block_details.len() {
1823 if self.config.style == CodeBlockStyle::Consistent {
1828 return Self::new(CodeBlockStyle::Fenced).convert_closed_blocks(ctx);
1829 }
1830
1831 return Ok(Conversion::unchanged(content));
1832 }
1833 }
1834
1835 if result == content || (content.contains('\r') && result == content.replace("\r\n", "\n")) {
1836 return Ok(Conversion::unchanged(content));
1837 }
1838
1839 let mut edits = Self::block_edits(ctx, &result, &output_starts, &line_blocks);
1840 let unsafe_blocks: Vec<_> = ctx
1843 .code_block_details
1844 .iter()
1845 .filter(|detail| {
1846 !Self::block_is_literal(ctx, detail, ambiguity_limit)
1847 && Self::code_block_start_line(ctx, detail).is_some_and(|first| {
1848 let last = ctx.line_offsets.partition_point(|&offset| offset < detail.end);
1849 (first..last).any(|line| {
1850 !lines[line].is_empty()
1851 && ctx.lines.get(line).is_some_and(|info| {
1852 ctx.is_inside_template_code(info.byte_offset)
1853 || ctx.is_inside_mdx_code(info.byte_offset)
1854 })
1855 })
1856 })
1857 })
1858 .map(|detail| detail.start..detail.end)
1859 .collect();
1860 let original_edit_count = edits.len();
1861 edits.retain(|edit| {
1862 !unsafe_blocks
1863 .iter()
1864 .any(|block| edit.fix.range.start < block.end && block.start < edit.fix.range.end)
1865 });
1866 if edits.len() != original_edit_count {
1867 let mut preserved = content.to_string();
1868 for edit in edits.iter().rev() {
1869 preserved.replace_range(edit.fix.range.clone(), &edit.fix.replacement);
1870 }
1871 return Ok(Conversion {
1872 content: preserved,
1873 edits,
1874 });
1875 }
1876
1877 let content = crate::utils::line_ending::LineEndingPolicy::new(content).apply_owned(result);
1878
1879 Ok(Conversion { content, edits })
1880 }
1881
1882 fn block_edits(
1886 ctx: &crate::lint_context::LintContext,
1887 result: &str,
1888 output_starts: &[usize],
1889 line_blocks: &[Option<usize>],
1890 ) -> Vec<BlockEdit> {
1891 let content = ctx.content;
1892 let line_count = line_blocks.len();
1893 let line_start = |i: usize| {
1894 if i < line_count {
1895 ctx.line_offsets[i]
1896 } else {
1897 content.len()
1898 }
1899 };
1900
1901 let mut edits = Vec::new();
1902
1903 let endings = crate::utils::line_ending::LineEndingPolicy::new(content);
1904 let mut i = 0;
1905 while i < line_count {
1906 let Some(block) = line_blocks[i] else {
1907 i += 1;
1908 continue;
1909 };
1910 let first = i;
1911 while i < line_count && line_blocks[i] == Some(block) {
1912 i += 1;
1913 }
1914 let last = i - 1;
1915
1916 let original = &content[line_start(first)..line_start(i)];
1917 let replacement = &result[output_starts[first]..output_starts[i].min(result.len())];
1920 if original.replace("\r\n", "\n") == replacement {
1921 continue;
1922 }
1923
1924 let replacement = endings.apply_to(replacement);
1925
1926 edits.push(BlockEdit {
1927 lines: first..=last,
1928 fix: Fix::new(line_start(first)..line_start(i), replacement),
1929 });
1930 }
1931 edits
1932 }
1933}
1934
1935struct Conversion {
1937 content: String,
1938 edits: Vec<BlockEdit>,
1940}
1941
1942impl Conversion {
1943 fn unchanged(content: &str) -> Self {
1944 Self {
1945 content: content.to_string(),
1946 edits: Vec::new(),
1947 }
1948 }
1949}
1950
1951struct BlockEdit {
1953 lines: std::ops::RangeInclusive<usize>,
1954 fix: Fix,
1955}
1956
1957#[cfg(test)]
1958mod tests {
1959 use super::*;
1960 use crate::lint_context::LintContext;
1961
1962 #[test]
1963 fn prepared_conversion_preserves_native_findings_and_full_fixes() {
1964 let sources = [
1965 "plain prose\n",
1966 " one\n two\n",
1967 "```\none\n```\n",
1968 "```rust\none\n```\n",
1969 "~~~\none\n~~~\n\n two\n",
1970 "```rust\nunclosed\n",
1971 "- item\n\n code\n\n ```\n nested\n ```\n",
1972 "> ```\n> literal\n> ```\n\n outside\n",
1973 "---\nvalue: |\n literal\n---\n\n code\n",
1974 "<!-- rumdl-disable MD046 -->\n first\n<!-- rumdl-enable MD046 -->\n\n second\n",
1975 "<!-- rumdl-disable MD046 -->\n```\nunclosed\n",
1976 "<!-- rumdl-configure-file {\"MD046\": {\"style\": \"indented\"}} -->\n```\nx\n```\n",
1977 "<!--\n```\nliteral\n-->\n\n outside\n",
1978 " café\n\t🙂\n",
1979 ];
1980 let mut prepared_closed = 0;
1981 let mut prepared_unclosed = 0;
1982 for flavor in crate::format_proof::ALL_FLAVORS {
1983 for style in [
1984 CodeBlockStyle::Consistent,
1985 CodeBlockStyle::Fenced,
1986 CodeBlockStyle::Indented,
1987 ] {
1988 let rule = MD046CodeBlockStyle::new(style);
1989 for source in sources {
1990 for ending in ["\n", "\r\n", "\r\n\n"] {
1991 let source = source.replace('\n', ending);
1992 let ctx = LintContext::new(&source, flavor, Some("document.md".into()));
1993 let warnings = rule.check(&ctx).unwrap();
1994 let (actual_warnings, prepared) = rule.check_with_prepared_fix(&ctx).unwrap();
1995 assert_eq!(actual_warnings, warnings, "{flavor:?}/{style:?}/{source:?}");
1996 if let Some(prepared) = prepared {
1997 assert_eq!(prepared, rule.fix(&ctx).unwrap(), "{flavor:?}/{style:?}/{source:?}");
1998 if rule.check_unclosed_code_blocks(&ctx).is_empty() {
1999 prepared_closed += 1;
2000 } else {
2001 prepared_unclosed += 1;
2002 }
2003 }
2004 }
2005 }
2006 }
2007 }
2008 assert!(prepared_closed > 0);
2009 assert!(prepared_unclosed > 0);
2010 }
2011
2012 fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
2015 let flavor = if is_mkdocs {
2016 crate::config::MarkdownFlavor::MkDocs
2017 } else {
2018 crate::config::MarkdownFlavor::Standard
2019 };
2020 let ctx = LintContext::new(content, flavor, None);
2021 let lines = ctx.raw_lines();
2022 rule.detect_style(&ctx, lines, &rule.build_indent_context(&ctx, lines).borrow())
2023 }
2024
2025 #[test]
2026 fn test_unclosed_fence_diagnostic_matches_document_fix() {
2027 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2028 for (content, expected) in [
2029 ("```", "```\n```"),
2030 ("```\ncode\n", "```\ncode\n```\n"),
2031 ("````\ncode\n```\n", "````\ncode\n```\n````\n"),
2032 ("> ```rust\n> code\n", "> ```rust\n> code\n> ```\n"),
2033 ("> > ~~~~\n> > code", "> > ~~~~\n> > code\n> > ~~~~"),
2034 ("- ```\n code\n", "- ```\n code\n ```\n"),
2035 (" ```\n code\n", " ```\n code\n ```\n"),
2036 ] {
2037 for newline in ["\n", "\r\n"] {
2038 let content = content.replace('\n', newline);
2039 let expected = if content.contains('\n') {
2040 expected.replace('\n', newline)
2041 } else {
2042 expected.to_string()
2043 };
2044 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2045 let warnings = rule.check(&ctx).unwrap();
2046 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
2047 let edited = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings).unwrap();
2048 assert_eq!(edited, expected, "{content:?}");
2049 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
2050 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
2051 assert!(rule.check(&fixed_ctx).unwrap().is_empty(), "{expected:?}");
2052 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2053 }
2054 }
2055 }
2056
2057 #[test]
2058 fn test_closed_quote_fence_at_eof_is_unchanged() {
2059 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2060 for content in [
2061 "> - item\n> ```\n> code\n> ```",
2062 "> > ~~~~\n> > code\n> > ~~~~",
2063 "```\r\ncode\r\n```\r\n",
2064 "Text\r\n\n- item\r\n",
2065 ] {
2066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2067 let warnings = rule.check(&ctx).unwrap();
2068 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
2069 assert_eq!(rule.fix(&ctx).unwrap(), content);
2070 }
2071 }
2072
2073 #[test]
2074 fn test_unclosed_quote_repair_keeps_unsupported_style_warning() {
2075 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2076 let content = "> ```\n> code\n";
2077 let expected = "> ```\n> code\n> ```\n";
2078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2079 let warnings = rule.check(&ctx).unwrap();
2080 assert_eq!(warnings.len(), 1);
2081 assert_eq!(
2082 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
2083 expected
2084 );
2085 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2086 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
2087 let remaining = rule.check(&fixed_ctx).unwrap();
2088 assert_eq!(remaining.len(), 1);
2089 assert_eq!(remaining[0].message, "Use indented code blocks");
2090 assert!(
2091 remaining[0].fix.is_none(),
2092 "Container conversion is intentionally unsupported"
2093 );
2094 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2095 }
2096
2097 #[test]
2098 fn test_unclosed_fence_diagnostic_includes_indented_conversion() {
2099 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2100 let content = "```\ncode\n";
2101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2102 let warnings = rule.check(&ctx).unwrap();
2103 assert_eq!(warnings.len(), 1);
2104 let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
2105 assert_eq!(edited, " code\n");
2106 assert_eq!(rule.fix(&ctx).unwrap(), edited);
2107 let fixed_ctx = LintContext::new(&edited, crate::config::MarkdownFlavor::Standard, None);
2108 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2109 assert_eq!(rule.fix(&fixed_ctx).unwrap(), edited);
2110 }
2111
2112 #[test]
2113 fn test_fenced_code_block_detection() {
2114 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2115 assert!(rule.is_fenced_code_block_start("```"));
2116 assert!(rule.is_fenced_code_block_start("```rust"));
2117 assert!(rule.is_fenced_code_block_start("~~~"));
2118 assert!(rule.is_fenced_code_block_start("~~~python"));
2119 assert!(rule.is_fenced_code_block_start(" ```"));
2120 assert!(!rule.is_fenced_code_block_start("``"));
2121 assert!(!rule.is_fenced_code_block_start("~~"));
2122 assert!(!rule.is_fenced_code_block_start("Regular text"));
2123 }
2124
2125 #[test]
2126 fn test_fix_capability_is_conditional() {
2127 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2128 assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
2129 }
2130
2131 #[test]
2132 fn test_consistent_style_with_fenced_blocks() {
2133 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2134 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
2135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136 let result = rule.check(&ctx).unwrap();
2137
2138 assert_eq!(result.len(), 0);
2140 }
2141
2142 #[test]
2143 fn test_consistent_style_with_indented_blocks() {
2144 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2145 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
2146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2147 let result = rule.check(&ctx).unwrap();
2148
2149 assert_eq!(result.len(), 0);
2151 }
2152
2153 #[test]
2154 fn test_consistent_style_mixed() {
2155 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2156 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
2157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2158 let result = rule.check(&ctx).unwrap();
2159
2160 assert!(!result.is_empty());
2162 }
2163
2164 #[test]
2165 fn test_fenced_style_with_indented_blocks() {
2166 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2167 let content = "Text\n\n indented code\n more code\n\nMore text";
2168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2169 let result = rule.check(&ctx).unwrap();
2170
2171 assert!(!result.is_empty());
2173 assert!(result[0].message.contains("Use fenced code blocks"));
2174 }
2175
2176 #[test]
2177 fn test_fenced_style_with_tab_indented_blocks() {
2178 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2179 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
2180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2181 let result = rule.check(&ctx).unwrap();
2182
2183 assert!(!result.is_empty());
2185 assert!(result[0].message.contains("Use fenced code blocks"));
2186 }
2187
2188 #[test]
2189 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
2190 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2191 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
2193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2194 let result = rule.check(&ctx).unwrap();
2195
2196 assert!(
2198 !result.is_empty(),
2199 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
2200 );
2201 assert!(result[0].message.contains("Use fenced code blocks"));
2202 }
2203
2204 #[test]
2205 fn test_fenced_style_with_one_space_tab_indent() {
2206 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2207 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
2209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2210 let result = rule.check(&ctx).unwrap();
2211
2212 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
2213 assert!(result[0].message.contains("Use fenced code blocks"));
2214 }
2215
2216 #[test]
2217 fn test_indented_style_with_fenced_blocks() {
2218 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2219 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
2220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2221 let result = rule.check(&ctx).unwrap();
2222
2223 assert!(!result.is_empty());
2225 assert!(result[0].message.contains("Use indented code blocks"));
2226 }
2227
2228 #[test]
2229 fn test_unclosed_code_block() {
2230 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2231 let content = "```\ncode without closing fence";
2232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2233 let result = rule.check(&ctx).unwrap();
2234
2235 assert_eq!(result.len(), 1);
2236 assert!(result[0].message.contains("never closed"));
2237 }
2238
2239 #[test]
2240 fn test_nested_code_blocks() {
2241 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2242 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
2243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2244 let result = rule.check(&ctx).unwrap();
2245
2246 assert_eq!(result.len(), 0);
2248 }
2249
2250 #[test]
2251 fn test_fix_indented_to_fenced() {
2252 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2253 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
2254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2255 let fixed = rule.fix(&ctx).unwrap();
2256
2257 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
2258 }
2259
2260 #[test]
2261 fn test_fix_fenced_to_indented() {
2262 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2263 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
2264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2265 let fixed = rule.fix(&ctx).unwrap();
2266
2267 assert!(fixed.contains(" code line 1\n code line 2"));
2268 assert!(!fixed.contains("```"));
2269 }
2270
2271 #[test]
2272 fn test_fix_fenced_to_indented_blank_lines_have_no_trailing_spaces() {
2273 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2277 let content = "Text\n\n```\ncode line 1\n\ncode line 2\n```\n\nMore text";
2278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2279 let fixed = rule.fix(&ctx).unwrap();
2280
2281 for line in fixed.lines() {
2282 assert!(
2283 line.is_empty() || !line.trim_end().is_empty() || line == line.trim_end(),
2284 "no line may have trailing whitespace, got {line:?}"
2285 );
2286 assert_ne!(line, " ", "blank line was indented to trailing spaces");
2287 }
2288 assert!(fixed.contains(" code line 1\n\n code line 2"));
2290 }
2291
2292 #[test]
2293 fn test_is_list_item_requires_delimiter_after_digits() {
2294 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2295 assert!(rule.is_list_item("1. First"));
2297 assert!(rule.is_list_item("42) Item"));
2298 assert!(rule.is_list_item(" 3. Indented item"));
2299 assert!(rule.is_list_item("- bullet"));
2301 assert!(rule.is_list_item("* bullet"));
2302 assert!(!rule.is_list_item("2 results. More info."));
2305 assert!(!rule.is_list_item("3 options (a, b) here"));
2306 assert!(!rule.is_list_item("100 items in stock. Buy now"));
2307 }
2308
2309 #[test]
2310 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
2311 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2314 let content = r#"# Test
2315
2316```
2317<!doctype html>
2318<html>
2319 <head>
2320 <title>Test</title>
2321 </head>
2322</html>
2323```
2324"#;
2325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2326 let fixed = rule.fix(&ctx).unwrap();
2327
2328 assert!(
2331 fixed.contains(" <head>"),
2332 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
2333 );
2334 assert!(
2335 fixed.contains(" <title>"),
2336 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
2337 );
2338 assert!(!fixed.contains("```"), "Fenced markers should be removed");
2339 }
2340
2341 #[test]
2342 fn test_fix_fenced_to_indented_preserves_python_indentation() {
2343 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2345 let content = r#"# Python Example
2346
2347```
2348def greet(name):
2349 if name:
2350 print(f"Hello, {name}!")
2351 else:
2352 print("Hello, World!")
2353```
2354"#;
2355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2356 let fixed = rule.fix(&ctx).unwrap();
2357
2358 assert!(
2360 fixed.contains(" def greet(name):"),
2361 "Function def should have 4 spaces (code block indent)"
2362 );
2363 assert!(
2364 fixed.contains(" if name:"),
2365 "if statement should have 8 spaces (4 code + 4 Python)"
2366 );
2367 assert!(
2368 fixed.contains(" print"),
2369 "print should have 12 spaces (4 code + 8 Python)"
2370 );
2371 }
2372
2373 #[test]
2374 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
2375 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2377 let content = r#"# Config
2378
2379```
2380server:
2381 host: localhost
2382 port: 8080
2383 ssl:
2384 enabled: true
2385 cert: /path/to/cert
2386```
2387"#;
2388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2389 let fixed = rule.fix(&ctx).unwrap();
2390
2391 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
2392 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
2393 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
2394 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
2395 }
2396
2397 #[test]
2398 fn test_fix_fenced_to_indented_preserves_empty_lines() {
2399 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2403 let content = "```\nline1\n\nline2\n```\n";
2404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2405 let fixed = rule.fix(&ctx).unwrap();
2406
2407 assert!(fixed.contains(" line1"), "line1 should be indented");
2409 assert!(fixed.contains(" line2"), "line2 should be indented");
2410 assert!(
2411 fixed.contains(" line1\n\n line2"),
2412 "blank line must stay empty, got {fixed:?}"
2413 );
2414 }
2415
2416 #[test]
2417 fn test_fix_fenced_to_indented_multiple_blocks() {
2418 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2420 let content = r#"# Doc
2421
2422```
2423def foo():
2424 pass
2425```
2426
2427Text between.
2428
2429```
2430key:
2431 value: 1
2432```
2433"#;
2434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2435 let fixed = rule.fix(&ctx).unwrap();
2436
2437 assert!(fixed.contains(" def foo():"), "Python def should be indented");
2438 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
2439 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
2440 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
2441 assert!(!fixed.contains("```"), "No fence markers should remain");
2442 }
2443
2444 #[test]
2445 fn test_fix_unclosed_block() {
2446 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2447 let content = "```\ncode without closing";
2448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2449 let fixed = rule.fix(&ctx).unwrap();
2450
2451 assert!(fixed.ends_with("```"));
2453 }
2454
2455 #[test]
2456 fn test_code_block_in_list() {
2457 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2458 let content = "- List item\n code in list\n more code\n- Next item";
2459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2460 let result = rule.check(&ctx).unwrap();
2461
2462 assert_eq!(result.len(), 0);
2464 }
2465
2466 #[test]
2467 fn test_detect_style_fenced() {
2468 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2469 let content = "```\ncode\n```";
2470 let style = detect_style_from_content(&rule, content, false);
2471
2472 assert_eq!(style, Some(CodeBlockStyle::Fenced));
2473 }
2474
2475 #[test]
2476 fn test_detect_style_indented() {
2477 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2478 let content = "Text\n\n code\n\nMore";
2479 let style = detect_style_from_content(&rule, content, false);
2480
2481 assert_eq!(style, Some(CodeBlockStyle::Indented));
2482 }
2483
2484 #[test]
2485 fn test_detect_style_none() {
2486 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2487 let content = "No code blocks here";
2488 let style = detect_style_from_content(&rule, content, false);
2489
2490 assert_eq!(style, None);
2491 }
2492
2493 #[test]
2494 fn test_tilde_fence() {
2495 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2496 let content = "~~~\ncode\n~~~";
2497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2498 let result = rule.check(&ctx).unwrap();
2499
2500 assert_eq!(result.len(), 0);
2502 }
2503
2504 #[test]
2505 fn test_language_specification() {
2506 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2507 let content = "```rust\nfn main() {}\n```";
2508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2509 let result = rule.check(&ctx).unwrap();
2510
2511 assert_eq!(result.len(), 0);
2512 }
2513
2514 #[test]
2515 fn test_empty_content() {
2516 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2517 let content = "";
2518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2519 let result = rule.check(&ctx).unwrap();
2520
2521 assert_eq!(result.len(), 0);
2522 }
2523
2524 #[test]
2525 fn test_default_config() {
2526 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2527 let (name, _config) = rule.default_config_section().unwrap();
2528 assert_eq!(name, "MD046");
2529 }
2530
2531 #[test]
2532 fn test_markdown_documentation_block() {
2533 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2534 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
2535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2536 let result = rule.check(&ctx).unwrap();
2537
2538 assert_eq!(result.len(), 0);
2540 }
2541
2542 #[test]
2543 fn test_preserve_trailing_newline() {
2544 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2545 let content = "```\ncode\n```\n";
2546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2547 let fixed = rule.fix(&ctx).unwrap();
2548
2549 assert_eq!(fixed, content);
2550 }
2551
2552 #[test]
2553 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
2554 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2555 let content = r#"# Document
2556
2557=== "Python"
2558
2559 This is tab content
2560 Not an indented code block
2561
2562 ```python
2563 def hello():
2564 print("Hello")
2565 ```
2566
2567=== "JavaScript"
2568
2569 More tab content here
2570 Also not an indented code block"#;
2571
2572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2573 let result = rule.check(&ctx).unwrap();
2574
2575 assert_eq!(result.len(), 0);
2577 }
2578
2579 #[test]
2580 fn test_mkdocs_tabs_with_actual_indented_code() {
2581 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2582 let content = r#"# Document
2583
2584=== "Tab 1"
2585
2586 This is tab content
2587
2588Regular text
2589
2590 This is an actual indented code block
2591 Should be flagged"#;
2592
2593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2594 let result = rule.check(&ctx).unwrap();
2595
2596 assert_eq!(result.len(), 1);
2598 assert!(result[0].message.contains("Use fenced code blocks"));
2599 }
2600
2601 #[test]
2602 fn test_mkdocs_tabs_detect_style() {
2603 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2604 let content = r#"=== "Tab 1"
2605
2606 Content in tab
2607 More content
2608
2609=== "Tab 2"
2610
2611 Content in second tab"#;
2612
2613 let style = detect_style_from_content(&rule, content, true);
2615 assert_eq!(style, None); let style = detect_style_from_content(&rule, content, false);
2619 assert_eq!(style, Some(CodeBlockStyle::Indented));
2620 }
2621
2622 #[test]
2623 fn test_mkdocs_nested_tabs() {
2624 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2625 let content = r#"# Document
2626
2627=== "Outer Tab"
2628
2629 Some content
2630
2631 === "Nested Tab"
2632
2633 Nested tab content
2634 Should not be flagged"#;
2635
2636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2637 let result = rule.check(&ctx).unwrap();
2638
2639 assert_eq!(result.len(), 0);
2641 }
2642
2643 #[test]
2644 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
2645 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2648 let content = r#"# Document
2649
2650!!! note
2651 This is normal admonition content, not a code block.
2652 It spans multiple lines.
2653
2654??? warning "Collapsible Warning"
2655 This is also admonition content.
2656
2657???+ tip "Expanded Tip"
2658 And this one too.
2659
2660Regular text outside admonitions."#;
2661
2662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2663 let result = rule.check(&ctx).unwrap();
2664
2665 assert_eq!(
2667 result.len(),
2668 0,
2669 "Admonition content in MkDocs mode should not trigger MD046"
2670 );
2671 }
2672
2673 #[test]
2674 fn test_mkdocs_admonition_with_actual_indented_code() {
2675 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2677 let content = r#"# Document
2678
2679!!! note
2680 This is admonition content.
2681
2682Regular text ends the admonition.
2683
2684 This is actual indented code (should be flagged)"#;
2685
2686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2687 let result = rule.check(&ctx).unwrap();
2688
2689 assert_eq!(result.len(), 1);
2691 assert!(result[0].message.contains("Use fenced code blocks"));
2692 }
2693
2694 #[test]
2695 fn test_admonition_in_standard_mode_flagged() {
2696 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2700 let content = r#"# Document
2701
2702!!! note
2703
2704 This looks like code in standard mode.
2705
2706Regular text."#;
2707
2708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2710 let result = rule.check(&ctx).unwrap();
2711
2712 assert_eq!(
2714 result.len(),
2715 1,
2716 "Admonition content in Standard mode should be flagged as indented code"
2717 );
2718 }
2719
2720 #[test]
2721 fn test_mkdocs_admonition_with_fenced_code_inside() {
2722 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2724 let content = r#"# Document
2725
2726!!! note "Code Example"
2727 Here's some code:
2728
2729 ```python
2730 def hello():
2731 print("world")
2732 ```
2733
2734 More text after code.
2735
2736Regular text."#;
2737
2738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2739 let result = rule.check(&ctx).unwrap();
2740
2741 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
2743 }
2744
2745 #[test]
2746 fn test_mkdocs_nested_admonitions() {
2747 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2749 let content = r#"# Document
2750
2751!!! note "Outer"
2752 Outer content.
2753
2754 !!! warning "Inner"
2755 Inner content.
2756 More inner content.
2757
2758 Back to outer.
2759
2760Regular text."#;
2761
2762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2763 let result = rule.check(&ctx).unwrap();
2764
2765 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
2767 }
2768
2769 #[test]
2770 fn test_mkdocs_admonition_fix_does_not_wrap() {
2771 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2773 let content = r#"!!! note
2774 Content that should stay as admonition content.
2775 Not be wrapped in code fences.
2776"#;
2777
2778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2779 let fixed = rule.fix(&ctx).unwrap();
2780
2781 assert!(
2783 !fixed.contains("```\n Content"),
2784 "Admonition content should not be wrapped in fences"
2785 );
2786 assert_eq!(fixed, content, "Content should remain unchanged");
2787 }
2788
2789 #[test]
2790 fn test_mkdocs_empty_admonition() {
2791 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2793 let content = r#"!!! note
2794
2795Regular paragraph after empty admonition.
2796
2797 This IS an indented code block (after blank + non-indented line)."#;
2798
2799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2800 let result = rule.check(&ctx).unwrap();
2801
2802 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
2804 }
2805
2806 #[test]
2807 fn test_mkdocs_indented_admonition() {
2808 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2810 let content = r#"- List item
2811
2812 !!! note
2813 Indented admonition content.
2814 More content.
2815
2816- Next item"#;
2817
2818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2819 let result = rule.check(&ctx).unwrap();
2820
2821 assert_eq!(
2823 result.len(),
2824 0,
2825 "Indented admonitions (e.g., in lists) should not be flagged"
2826 );
2827 }
2828
2829 #[test]
2830 fn test_footnote_indented_paragraphs_not_flagged() {
2831 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2832 let content = r#"# Test Document with Footnotes
2833
2834This is some text with a footnote[^1].
2835
2836Here's some code:
2837
2838```bash
2839echo "fenced code block"
2840```
2841
2842More text with another footnote[^2].
2843
2844[^1]: Really interesting footnote text.
2845
2846 Even more interesting second paragraph.
2847
2848[^2]: Another footnote.
2849
2850 With a second paragraph too.
2851
2852 And even a third paragraph!"#;
2853
2854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2855 let result = rule.check(&ctx).unwrap();
2856
2857 assert_eq!(result.len(), 0);
2859 }
2860
2861 #[test]
2862 fn test_footnote_definition_detection() {
2863 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2864
2865 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
2868 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
2869 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
2870 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
2871 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
2872 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
2873 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
2874 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
2875 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
2876
2877 assert!(!rule.is_footnote_definition("[^]: No label"));
2879 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
2880 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
2881 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
2882
2883 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
2885 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
2886 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
2887 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
2890
2891 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
2893 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
2894 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
2895 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
2896 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
2897
2898 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
2901 }
2902
2903 #[test]
2904 fn test_footnote_with_blank_lines() {
2905 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2909 let content = r#"# Document
2910
2911Text with footnote[^1].
2912
2913[^1]: First paragraph.
2914
2915 Second paragraph after blank line.
2916
2917 Third paragraph after another blank line.
2918
2919Regular text at column 0 ends the footnote."#;
2920
2921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2922 let result = rule.check(&ctx).unwrap();
2923
2924 assert_eq!(
2926 result.len(),
2927 0,
2928 "Indented content within footnotes should not trigger MD046"
2929 );
2930 }
2931
2932 #[test]
2933 fn test_footnote_multiple_consecutive_blank_lines() {
2934 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2937 let content = r#"Text[^1].
2938
2939[^1]: First paragraph.
2940
2941
2942
2943 Content after three blank lines (still part of footnote).
2944
2945Not indented, so footnote ends here."#;
2946
2947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2948 let result = rule.check(&ctx).unwrap();
2949
2950 assert_eq!(
2952 result.len(),
2953 0,
2954 "Multiple blank lines shouldn't break footnote continuation"
2955 );
2956 }
2957
2958 #[test]
2959 fn test_footnote_terminated_by_non_indented_content() {
2960 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2963 let content = r#"[^1]: Footnote content.
2964
2965 More indented content in footnote.
2966
2967This paragraph is not indented, so footnote ends.
2968
2969 This should be flagged as indented code block."#;
2970
2971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2972 let result = rule.check(&ctx).unwrap();
2973
2974 assert_eq!(
2976 result.len(),
2977 1,
2978 "Indented code after footnote termination should be flagged"
2979 );
2980 assert!(
2981 result[0].message.contains("Use fenced code blocks"),
2982 "Expected MD046 warning for indented code block"
2983 );
2984 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2985 }
2986
2987 #[test]
2988 fn test_footnote_terminated_by_structural_elements() {
2989 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2991 let content = r#"[^1]: Footnote content.
2992
2993 More content.
2994
2995## Heading terminates footnote
2996
2997 This indented content should be flagged.
2998
2999---
3000
3001 This should also be flagged (after horizontal rule)."#;
3002
3003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3004 let result = rule.check(&ctx).unwrap();
3005
3006 assert_eq!(
3008 result.len(),
3009 2,
3010 "Both indented blocks after termination should be flagged"
3011 );
3012 }
3013
3014 #[test]
3015 fn test_footnote_with_code_block_inside() {
3016 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3019 let content = r#"Text[^1].
3020
3021[^1]: Footnote with code:
3022
3023 ```python
3024 def hello():
3025 print("world")
3026 ```
3027
3028 More footnote text after code."#;
3029
3030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3031 let result = rule.check(&ctx).unwrap();
3032
3033 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
3035 }
3036
3037 #[test]
3038 fn test_footnote_with_8_space_indented_code() {
3039 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3042 let content = r#"Text[^1].
3043
3044[^1]: Footnote with nested code.
3045
3046 code block
3047 more code"#;
3048
3049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3050 let result = rule.check(&ctx).unwrap();
3051
3052 assert_eq!(
3054 result.len(),
3055 0,
3056 "8-space indented code within footnotes represents nested code blocks"
3057 );
3058 }
3059
3060 #[test]
3061 fn test_multiple_footnotes() {
3062 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3065 let content = r#"Text[^1] and more[^2].
3066
3067[^1]: First footnote.
3068
3069 Continuation of first.
3070
3071[^2]: Second footnote starts here, ending the first.
3072
3073 Continuation of second."#;
3074
3075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3076 let result = rule.check(&ctx).unwrap();
3077
3078 assert_eq!(
3080 result.len(),
3081 0,
3082 "Multiple footnotes should each maintain their continuation context"
3083 );
3084 }
3085
3086 #[test]
3087 fn test_list_item_ends_footnote_context() {
3088 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3090 let content = r#"[^1]: Footnote.
3091
3092 Content in footnote.
3093
3094- List item starts here (ends footnote context).
3095
3096 This indented content is part of the list, not the footnote."#;
3097
3098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3099 let result = rule.check(&ctx).unwrap();
3100
3101 assert_eq!(
3103 result.len(),
3104 0,
3105 "List items should end footnote context and start their own"
3106 );
3107 }
3108
3109 #[test]
3110 fn test_footnote_vs_actual_indented_code() {
3111 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3114 let content = r#"# Heading
3115
3116Text with footnote[^1].
3117
3118[^1]: Footnote content.
3119
3120 Part of footnote (should not be flagged).
3121
3122Regular paragraph ends footnote context.
3123
3124 This is actual indented code (MUST be flagged)
3125 Should be detected as code block"#;
3126
3127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3128 let result = rule.check(&ctx).unwrap();
3129
3130 assert_eq!(
3132 result.len(),
3133 1,
3134 "Must still detect indented code blocks outside footnotes"
3135 );
3136 assert!(
3137 result[0].message.contains("Use fenced code blocks"),
3138 "Expected MD046 warning for indented code"
3139 );
3140 assert!(
3141 result[0].line >= 11,
3142 "Warning should be on the actual indented code line"
3143 );
3144 }
3145
3146 #[test]
3147 fn test_spec_compliant_label_characters() {
3148 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3151
3152 assert!(rule.is_footnote_definition("[^test]: text"));
3154 assert!(rule.is_footnote_definition("[^TEST]: text"));
3155 assert!(rule.is_footnote_definition("[^test-name]: text"));
3156 assert!(rule.is_footnote_definition("[^test_name]: text"));
3157 assert!(rule.is_footnote_definition("[^test123]: text"));
3158 assert!(rule.is_footnote_definition("[^123]: text"));
3159 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
3160
3161 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")); }
3169
3170 #[test]
3171 fn test_code_block_inside_html_comment() {
3172 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3175 let content = r#"# Document
3176
3177Some text.
3178
3179<!--
3180Example code block in comment:
3181
3182```typescript
3183console.log("Hello");
3184```
3185
3186More comment text.
3187-->
3188
3189More content."#;
3190
3191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3192 let result = rule.check(&ctx).unwrap();
3193
3194 assert_eq!(
3195 result.len(),
3196 0,
3197 "Code blocks inside HTML comments should not be flagged as unclosed"
3198 );
3199 }
3200
3201 #[test]
3202 fn test_unclosed_fence_inside_html_comment() {
3203 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3205 let content = r#"# Document
3206
3207<!--
3208Example with intentionally unclosed fence:
3209
3210```
3211code without closing
3212-->
3213
3214More content."#;
3215
3216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3217 let result = rule.check(&ctx).unwrap();
3218
3219 assert_eq!(
3220 result.len(),
3221 0,
3222 "Unclosed fences inside HTML comments should be ignored"
3223 );
3224 }
3225
3226 #[test]
3227 fn test_multiline_html_comment_with_indented_code() {
3228 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3230 let content = r#"# Document
3231
3232<!--
3233Example:
3234
3235 indented code
3236 more code
3237
3238End of comment.
3239-->
3240
3241Regular text."#;
3242
3243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3244 let result = rule.check(&ctx).unwrap();
3245
3246 assert_eq!(
3247 result.len(),
3248 0,
3249 "Indented code inside HTML comments should not be flagged"
3250 );
3251 }
3252
3253 #[test]
3254 fn test_code_block_after_html_comment() {
3255 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3257 let content = r#"# Document
3258
3259<!-- comment -->
3260
3261Text before.
3262
3263 indented code should be flagged
3264
3265More text."#;
3266
3267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3268 let result = rule.check(&ctx).unwrap();
3269
3270 assert_eq!(
3271 result.len(),
3272 1,
3273 "Code blocks after HTML comments should still be detected"
3274 );
3275 assert!(result[0].message.contains("Use fenced code blocks"));
3276 }
3277
3278 #[test]
3279 fn test_consistent_style_indented_html_comment() {
3280 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3286 let content = "# MD046 false-positive reproduction\n\
3287 \n\
3288 <!--\n \
3289 This is just an indented comment, not a code block.\n\
3290 \n \
3291 A second line is required to trigger the false-positive.\n\
3292 \n \
3293 Actually, three lines are required.\n\
3294 -->\n\
3295 \n\
3296 ```md\n\
3297 This should be fine, since it's the only code block and therefore consistent.\n\
3298 ```\n";
3299
3300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3301 let result = rule.check(&ctx).unwrap();
3302
3303 assert_eq!(
3304 result,
3305 vec![],
3306 "A single fenced block and an indented HTML comment must produce no MD046 warnings",
3307 );
3308 }
3309
3310 #[test]
3311 fn test_consistent_style_indented_html_block() {
3312 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3319 let content = "# Heading\n\
3320 \n\
3321 <div class=\"note\">\n \
3322 line one of indented html content\n \
3323 line two of indented html content\n \
3324 line three of indented html content\n\
3325 </div>\n\
3326 \n\
3327 ```md\n\
3328 real fenced block\n\
3329 ```\n";
3330
3331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3332 let result = rule.check(&ctx).unwrap();
3333
3334 assert_eq!(
3335 result,
3336 vec![],
3337 "Indented content inside a raw HTML block must not influence MD046 style detection",
3338 );
3339 }
3340
3341 #[test]
3342 fn test_consistent_style_fake_fence_inside_html_comment() {
3343 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3349 let content = "# Title\n\
3350 \n\
3351 <!--\n\
3352 ```\n\
3353 fake fence inside comment\n\
3354 ```\n\
3355 -->\n\
3356 \n \
3357 real indented code block line 1\n \
3358 real indented code block line 2\n";
3359
3360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3361 let result = rule.check(&ctx).unwrap();
3362
3363 assert_eq!(
3364 result,
3365 vec![],
3366 "Fence markers inside an HTML comment must not influence MD046 style detection",
3367 );
3368 }
3369
3370 #[test]
3371 fn test_consistent_style_indented_footnote_definition() {
3372 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3376 let content = "# Heading\n\
3377 \n\
3378 Reference to a footnote[^note].\n\
3379 \n\
3380 [^note]: First line of the footnote.\n \
3381 Second indented continuation line.\n \
3382 Third indented continuation line.\n \
3383 Fourth indented continuation line.\n\
3384 \n\
3385 ```md\n\
3386 real fenced block\n\
3387 ```\n";
3388
3389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3390 let result = rule.check(&ctx).unwrap();
3391
3392 assert_eq!(
3393 result,
3394 vec![],
3395 "Footnote-definition continuation content must not influence MD046 style detection",
3396 );
3397 }
3398
3399 #[test]
3400 fn test_consistent_style_indented_blockquote() {
3401 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3406 let content = "# Heading\n\
3407 \n\
3408 > line one of quoted indented content\n\
3409 >\n\
3410 > line two of quoted indented content\n\
3411 >\n\
3412 > line three of quoted indented content\n\
3413 \n\
3414 ```md\n\
3415 real fenced block\n\
3416 ```\n";
3417
3418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3419 let result = rule.check(&ctx).unwrap();
3420
3421 assert_eq!(
3422 result,
3423 vec![],
3424 "Indented content inside a blockquote must not influence MD046 style detection",
3425 );
3426 }
3427
3428 #[test]
3429 fn test_consistent_style_genuine_indented_block_detected_as_indented() {
3430 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3435 let content = "# Heading\n\
3436 \n\
3437 Some prose.\n\
3438 \n \
3439 real indented code line 1\n \
3440 real indented code line 2\n";
3441
3442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3443 let result = rule.check(&ctx).unwrap();
3444
3445 assert_eq!(
3448 result,
3449 vec![],
3450 "A genuine top-level indented block must be detected as Indented style under Consistent",
3451 );
3452 }
3453
3454 #[test]
3455 fn test_consistent_style_skipped_lines_dont_override_real_block() {
3456 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3461 let content = "# Heading\n\
3462 \n\
3463 <!--\n \
3464 skipped indented comment line 1\n \
3465 skipped indented comment line 2\n\
3466 -->\n\
3467 \n\
3468 <!--\n \
3469 second skipped region\n \
3470 also skipped\n\
3471 -->\n\
3472 \n \
3473 real indented code line\n";
3474
3475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3476 let result = rule.check(&ctx).unwrap();
3477
3478 assert_eq!(
3479 result,
3480 vec![],
3481 "Skipped container lines must not outweigh the single real indented block",
3482 );
3483 }
3484
3485 #[test]
3486 fn test_consistent_style_fenced_wins_over_skipped_indented() {
3487 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3491 let content = "# Heading\n\
3492 \n\
3493 <!--\n \
3494 skipped indented region one\n \
3495 more of region one\n\
3496 -->\n\
3497 \n\
3498 <!--\n \
3499 skipped indented region two\n \
3500 more of region two\n\
3501 -->\n\
3502 \n\
3503 ```md\n\
3504 real fenced block\n\
3505 ```\n";
3506
3507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3508 let result = rule.check(&ctx).unwrap();
3509
3510 assert_eq!(
3511 result,
3512 vec![],
3513 "Fenced block must win when all indented lines are inside skipped containers",
3514 );
3515 }
3516
3517 #[test]
3518 fn test_four_space_indented_fence_is_not_valid_fence() {
3519 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3522
3523 assert!(rule.is_fenced_code_block_start("```"));
3525 assert!(rule.is_fenced_code_block_start(" ```"));
3526 assert!(rule.is_fenced_code_block_start(" ```"));
3527 assert!(rule.is_fenced_code_block_start(" ```"));
3528
3529 assert!(!rule.is_fenced_code_block_start(" ```"));
3531 assert!(!rule.is_fenced_code_block_start(" ```"));
3532 assert!(!rule.is_fenced_code_block_start(" ```"));
3533
3534 assert!(!rule.is_fenced_code_block_start("\t```"));
3536 }
3537
3538 #[test]
3539 fn test_issue_237_indented_fenced_block_detected_as_indented() {
3540 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3546
3547 let content = r#"## Test
3549
3550 ```js
3551 var foo = "hello";
3552 ```
3553"#;
3554
3555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3556 let result = rule.check(&ctx).unwrap();
3557
3558 assert_eq!(
3560 result.len(),
3561 1,
3562 "4-space indented fence should be detected as indented code block"
3563 );
3564 assert!(
3565 result[0].message.contains("Use fenced code blocks"),
3566 "Expected 'Use fenced code blocks' message"
3567 );
3568 }
3569
3570 #[test]
3571 fn test_issue_276_indented_code_in_list() {
3572 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3575
3576 let content = r#"1. First item
35772. Second item with code:
3578
3579 # This is a code block in a list
3580 print("Hello, world!")
3581
35824. Third item"#;
3583
3584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3585 let result = rule.check(&ctx).unwrap();
3586
3587 assert!(
3589 !result.is_empty(),
3590 "Indented code block inside list should be flagged when style=fenced"
3591 );
3592 assert!(
3593 result[0].message.contains("Use fenced code blocks"),
3594 "Expected 'Use fenced code blocks' message"
3595 );
3596 }
3597
3598 #[test]
3599 fn test_three_space_indented_fence_is_valid() {
3600 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3602
3603 let content = r#"## Test
3604
3605 ```js
3606 var foo = "hello";
3607 ```
3608"#;
3609
3610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3611 let result = rule.check(&ctx).unwrap();
3612
3613 assert_eq!(
3615 result.len(),
3616 0,
3617 "3-space indented fence should be recognized as valid fenced code block"
3618 );
3619 }
3620
3621 #[test]
3622 fn test_indented_style_with_deeply_indented_fenced() {
3623 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3626
3627 let content = r#"Text
3628
3629 ```js
3630 var foo = "hello";
3631 ```
3632
3633More text
3634"#;
3635
3636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3637 let result = rule.check(&ctx).unwrap();
3638
3639 assert_eq!(
3642 result.len(),
3643 0,
3644 "4-space indented content should be valid when style=indented"
3645 );
3646 }
3647
3648 #[test]
3649 fn test_fix_misplaced_fenced_block() {
3650 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3653
3654 let content = r#"## Test
3655
3656 ```js
3657 var foo = "hello";
3658 ```
3659"#;
3660
3661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3662 let fixed = rule.fix(&ctx).unwrap();
3663
3664 let expected = r#"## Test
3666
3667```js
3668var foo = "hello";
3669```
3670"#;
3671
3672 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
3673 }
3674
3675 #[test]
3676 fn test_fix_regular_indented_block() {
3677 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3680
3681 let content = r#"Text
3682
3683 var foo = "hello";
3684 console.log(foo);
3685
3686More text
3687"#;
3688
3689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3690 let fixed = rule.fix(&ctx).unwrap();
3691
3692 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
3694 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
3695 }
3696
3697 #[test]
3698 fn test_fix_indented_block_with_fence_like_content() {
3699 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3703
3704 let content = r#"Text
3705
3706 some code
3707 ```not a fence opener
3708 more code
3709"#;
3710
3711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3712 let fixed = rule.fix(&ctx).unwrap();
3713
3714 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
3716 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
3717 }
3718
3719 #[test]
3720 fn test_fix_mixed_indented_and_misplaced_blocks() {
3721 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3723
3724 let content = r#"Text
3725
3726 regular indented code
3727
3728More text
3729
3730 ```python
3731 print("hello")
3732 ```
3733"#;
3734
3735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3736 let fixed = rule.fix(&ctx).unwrap();
3737
3738 assert!(
3740 fixed.contains("```\nregular indented code\n```"),
3741 "First block should be wrapped in fences"
3742 );
3743
3744 assert!(
3746 fixed.contains("\n```python\nprint(\"hello\")\n```"),
3747 "Second block should be dedented, not double-wrapped"
3748 );
3749 assert!(
3751 !fixed.contains("```\n```python"),
3752 "Should not have nested fence openers"
3753 );
3754 }
3755
3756 #[test]
3757 fn test_md046_front_matter() {
3758 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3759 let content = "---\nmetadata:\n\n description: Indented\n---\n";
3760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3761 let result = rule.check(&ctx).unwrap();
3762 assert_eq!(result.len(), 0);
3763 }
3764
3765 #[test]
3766 fn test_md046_fix_front_matter() {
3767 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3768 let content = "---\nmetadata:\n\n description: Indented\n---\n";
3769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3770 let fixed = rule.fix(&ctx).unwrap();
3771 assert_eq!(fixed, content);
3772 }
3773
3774 #[test]
3775 fn test_whitespace_only_line_is_not_an_indented_code_block() {
3776 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3781 let content = "# T\n\nPara\n\n \nMore\n\n real code\n\nEnd\n";
3782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3783 let fixed = rule.fix(&ctx).unwrap();
3784 assert_eq!(fixed, "# T\n\nPara\n\n \nMore\n\n```\nreal code\n```\n\nEnd\n");
3785 }
3786
3787 #[test]
3788 fn test_interior_blank_line_keeps_indented_block_together() {
3789 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3793 let content = "# T\n\nPara\n\n a\n\n b\n\nAfter\n";
3794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3795 let fixed = rule.fix(&ctx).unwrap();
3796 assert_eq!(fixed, "# T\n\nPara\n\n```\na\n\nb\n```\n\nAfter\n");
3797 }
3798
3799 #[test]
3800 fn test_consistent_style_counts_a_block_with_interior_blank_once() {
3801 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3805 let content = "# T\n\n```\nfenced\n```\n\nPara\n\n a\n\n b\n\nEnd\n";
3806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3807 let result = rule.check(&ctx).unwrap();
3808 let reported: Vec<(usize, &str)> = result.iter().map(|w| (w.line, w.message.as_str())).collect();
3809 assert_eq!(reported, vec![(9, "Use fenced code blocks")]);
3810 }
3811
3812 #[test]
3813 fn test_indented_lazy_continuation_lines_are_not_code() {
3814 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3820 let content = "# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n real code\n\nEnd\n";
3821 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3822 let fixed = rule.fix(&ctx).unwrap();
3823 assert_eq!(
3824 fixed,
3825 "# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n```\nreal code\n```\n\nEnd\n"
3826 );
3827 }
3828
3829 #[test]
3830 fn test_misplaced_fence_with_interior_blank_dedents_as_one_block() {
3831 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3835 let content = "# T\n\nPara\n\n ```python\n x = 1\n\n y = 2\n ```\n\nAfter\n";
3836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3837 let fixed = rule.fix(&ctx).unwrap();
3838 assert_eq!(fixed, "# T\n\nPara\n\n```python\nx = 1\n\ny = 2\n```\n\nAfter\n");
3839 }
3840 #[test]
3841 fn test_mdg_overrides_indented_style_to_fenced() {
3842 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3847 let content = "# Feature: Payloads\n\n## Scenario: JSON payload\n\n* Given this payload\n\n ```json\n {\"ok\": true}\n ```\n";
3848
3849 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3850 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3851 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3852
3853 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3856 let standard_warnings = rule.check(&standard_ctx).unwrap();
3857 assert_eq!(standard_warnings.len(), 1);
3858 assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3859 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3860 }
3861
3862 #[test]
3863 fn test_mdg_indented_style_still_fences_indented_blocks() {
3864 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3868 let content =
3869 "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n ordinary indented code\n";
3870
3871 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3872 let warnings = rule.check(&mdg_ctx).unwrap();
3873 assert_eq!(warnings.len(), 1);
3874 assert_eq!(warnings[0].message, "Use fenced code blocks");
3875
3876 let fixed = rule.fix(&mdg_ctx).unwrap();
3877 assert_eq!(
3878 fixed,
3879 "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n```\n ordinary indented code\n```\n"
3880 );
3881
3882 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3883 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3884 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3885
3886 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3889 assert!(rule.check(&standard_ctx).unwrap().is_empty());
3890 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3891 }
3892
3893 #[test]
3894 fn test_mdg_steers_indented_code_to_fenced() {
3895 let content = "# Feature: Payloads\n\n## Scenario: Plain payload\n\n* Given this payload\n\n ordinary indented code\n";
3899
3900 for rule in [
3901 MD046CodeBlockStyle::new(CodeBlockStyle::Fenced),
3902 MD046CodeBlockStyle::new(CodeBlockStyle::Consistent),
3903 MD046CodeBlockStyle::new(CodeBlockStyle::Indented),
3904 ] {
3905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3906 let warnings = rule.check(&ctx).unwrap();
3907 assert_eq!(warnings.len(), 1);
3908 assert_eq!(warnings[0].message, "Use fenced code blocks");
3909
3910 let fixed = rule.fix(&ctx).unwrap();
3911 assert!(fixed.contains("```"), "MDG must fence the block: {fixed:?}");
3912
3913 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3914 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3915 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3916 }
3917 }
3918
3919 #[test]
3920 fn test_mdg_consistent_style_ignores_indented_prevalence() {
3921 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3924 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";
3925
3926 let standard_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::Standard, None);
3927 let standard_warnings = rule.check(&standard_ctx).unwrap();
3928 assert_eq!(standard_warnings.len(), 1);
3929 assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3930
3931 let mdg_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::MDG, None);
3932 let mdg_warnings = rule.check(&mdg_ctx).unwrap();
3933 assert_eq!(mdg_warnings.len(), 2);
3934 assert!(
3935 mdg_warnings
3936 .iter()
3937 .all(|warning| warning.message == "Use fenced code blocks")
3938 );
3939 }
3940
3941 #[test]
3942 fn test_mdg_repairs_unclosed_fence_like_standard() {
3943 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3946 let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3947
3948 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3949 let warnings = rule.check(&mdg_ctx).unwrap();
3950 assert_eq!(warnings.len(), 1);
3951 assert!(warnings[0].message.contains("never closed"));
3952
3953 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3954 assert_eq!(
3955 rule.fix(&mdg_ctx).unwrap(),
3956 rule.fix(&standard_ctx).unwrap(),
3957 "MDG must not differ from Standard"
3958 );
3959 }
3960
3961 #[test]
3962 fn test_mdg_table_above_prose_is_never_fenced() {
3963 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3969 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";
3970
3971 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3972 let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3973 assert_eq!(reported, vec![8, 12]);
3974
3975 let fixed = rule.fix(&mdg_ctx).unwrap();
3976 assert_eq!(
3977 fixed,
3978 "# 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"
3979 );
3980
3981 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3982 assert!(
3983 rule.check(&fixed_ctx).unwrap().is_empty(),
3984 "MDG check must have nothing left to report after its own fix"
3985 );
3986 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3987
3988 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3990 let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3991 assert_eq!(standard_reported, vec![5, 12]);
3992 assert!(rule.fix(&standard_ctx).unwrap().contains("```\n| start | eat | left |"));
3993 }
3994
3995 #[test]
3996 fn test_mdg_repairs_unclosed_fence_under_indented_style() {
3997 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4001 let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
4002
4003 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
4004 let warnings = rule.check(&mdg_ctx).unwrap();
4005 assert_eq!(warnings.len(), 1);
4006 assert!(warnings[0].message.contains("never closed"));
4007
4008 let fixed = rule.fix(&mdg_ctx).unwrap();
4009 assert_eq!(fixed, "# Feature: Payloads\n\n```json\n{\"ok\": true}\n```\n");
4010
4011 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
4012 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
4013
4014 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4017 assert_eq!(rule.fix(&standard_ctx).unwrap(), fixed);
4018 }
4019
4020 #[test]
4021 fn test_mdg_tab_indented_table_is_not_code() {
4022 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4026 for indent in ["\t\t", " \t"] {
4027 let content = format!(
4028 "# Feature: Eating\n\n#### Examples:\n\n{indent}| start | eat |\n{indent}| ----- | --- |\n\n## Scenario: Other\n\n code here\n"
4029 );
4030
4031 let mdg_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
4032 let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
4033 assert_eq!(reported, vec![10], "tab-indented rows are a table, not code");
4034
4035 let fixed = rule.fix(&mdg_ctx).unwrap();
4036 assert!(
4037 fixed.contains(&format!("{indent}| start | eat |\n{indent}| ----- | --- |")),
4038 "MDG must leave the tab-indented table alone: {fixed:?}"
4039 );
4040
4041 let standard_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
4042 let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
4043 assert_eq!(standard_reported, vec![5, 10]);
4044 }
4045 }
4046
4047 #[test]
4048 fn test_from_config_records_whether_style_was_configured() {
4049 use crate::config::Config;
4053 use std::collections::BTreeMap;
4054
4055 let mut values = BTreeMap::new();
4056 values.insert("style".to_string(), toml::Value::String("indented".to_string()));
4057 let mut config = Config::default();
4058 config.rules.insert(
4059 "MD046".to_string(),
4060 crate::config::RuleConfig { severity: None, values },
4061 );
4062
4063 let configured = MD046CodeBlockStyle::from_config(&config);
4064 let configured = configured.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
4065 assert_eq!(configured.config.style, CodeBlockStyle::Indented);
4066 assert!(configured.style_explicit);
4067
4068 let defaulted = MD046CodeBlockStyle::from_config(&Config::default());
4069 let defaulted = defaulted.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
4070 assert!(!defaulted.style_explicit);
4071
4072 let indented = MD046CodeBlockStyle::from_config_struct(MD046Config {
4075 style: CodeBlockStyle::Indented,
4076 });
4077 let content = "# Feature: F\n\nText.\n\n code here\n";
4078 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
4079 assert!(indented.fix(&mdg_ctx).unwrap().contains("```\n code here\n```"));
4080 }
4081
4082 #[test]
4083 fn test_mdg_indented_style_keeps_tables_out_of_code() {
4084 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4087 let content = "# Feature: Eating\n\n#### Examples:\n\n | start | eat | left |\n | ----- | --- | ---- |\n";
4088
4089 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
4090 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
4091 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
4092
4093 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4096 assert!(rule.check(&standard_ctx).unwrap().is_empty());
4097 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
4098 }
4099
4100 #[test]
4104 fn test_style_warnings_carry_their_block_fix() {
4105 let cases = [
4106 (
4107 CodeBlockStyle::Fenced,
4108 "# T\n\n one\n\n still one\n\nText\n\n two\n",
4109 "# T\n\n```\none\n\nstill one\n```\n\nText\n\n```\ntwo\n```\n",
4110 ),
4111 (
4112 CodeBlockStyle::Indented,
4113 "# T\n\n```\none\n```\n\nText\n\n~~~\ntwo\n~~~\n",
4114 "# T\n\n one\n\nText\n\n two\n",
4115 ),
4116 (
4118 CodeBlockStyle::Fenced,
4119 "# T\n\n ```bash\n ls\n ```\n",
4120 "# T\n\n```bash\nls\n```\n",
4121 ),
4122 (CodeBlockStyle::Fenced, "# T\n\n code", "# T\n\n```\ncode\n```"),
4124 ];
4125 for (style, content, expected) in cases {
4126 let rule = MD046CodeBlockStyle::new(style);
4127 for newline in ["\n", "\r\n"] {
4128 let content = content.replace('\n', newline);
4129 let expected = expected.replace('\n', newline);
4130 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
4131 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
4132 let warnings = rule.check(&ctx).unwrap();
4133 assert!(!warnings.is_empty(), "{content:?}");
4134 for warning in &warnings {
4135 let fix = warning
4136 .fix
4137 .as_ref()
4138 .unwrap_or_else(|| panic!("{content:?}: {warning:?}"));
4139 let fix_line = content[..fix.range.start].matches('\n').count() + 1;
4140 assert_eq!(
4141 fix_line, warning.line,
4142 "a warning's fix starts at its block: {content:?}"
4143 );
4144 }
4145 let edited = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings).unwrap();
4146 assert_eq!(edited, expected, "{content:?}");
4147
4148 let first = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings[..1]).unwrap();
4150 let first_ctx = LintContext::new(&first, crate::config::MarkdownFlavor::Standard, None);
4151 assert_eq!(
4152 rule.check(&first_ctx).unwrap().len(),
4153 warnings.len() - 1,
4154 "{content:?} -> {first:?}"
4155 );
4156 }
4157 }
4158 }
4159
4160 #[test]
4162 fn test_style_warning_without_a_fix_where_fix_keeps_the_block() {
4163 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4164 let content = "# T\n\n ```\n code\n";
4167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4168 assert_eq!(rule.fix(&ctx).unwrap(), content);
4169 let warnings = rule.check(&ctx).unwrap();
4170 assert_eq!(warnings.len(), 1, "{warnings:?}");
4171 assert!(warnings[0].fix.is_none(), "{warnings:?}");
4172 }
4173
4174 #[test]
4175 fn literal_code_blocks_are_preserved_beside_real_style_fixes() {
4176 for (style, value, adjacent, target) in [
4177 (
4178 CodeBlockStyle::Fenced,
4179 "first\n\n inner\n\nlast",
4180 " Adjacent",
4181 "```\nAdjacent\n```",
4182 ),
4183 (
4184 CodeBlockStyle::Indented,
4185 "first\n\n~~~\ninner\n~~~\n\nlast",
4186 "~~~\nAdjacent\n~~~",
4187 " Adjacent",
4188 ),
4189 ] {
4190 let rule = MD046CodeBlockStyle::new(style);
4191 for (flavor, code) in [
4192 (
4193 crate::config::MarkdownFlavor::Standard,
4194 format!("{{% set unused=\"{value}\" %}}{{{{ unused|length }}}}"),
4195 ),
4196 (
4197 crate::config::MarkdownFlavor::Hugo,
4198 format!("{{{{< note title=`{value}` >}}}}"),
4199 ),
4200 ] {
4201 for ending in ["\n", "\r\n"] {
4202 let source = format!("{code}\n\n{adjacent}\n").replace('\n', ending);
4203 let expected = format!("{code}\n\n{target}\n").replace('\n', ending);
4204 let ctx = LintContext::new(&source, flavor, None);
4205 let warnings = rule.check(&ctx).unwrap();
4206 assert_eq!(warnings.len(), 1, "{source}");
4207 assert!(warnings[0].fix.is_some());
4208 assert_eq!(
4209 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
4210 expected
4211 );
4212 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4213 }
4214 }
4215 }
4216 }
4217
4218 #[test]
4219 fn literal_block_styles_do_not_choose_visible_code_style() {
4220 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
4221 for (value, adjacent) in [
4222 ("first\n\n~~~\nFake\n~~~\n\n~~~\nOther\n~~~\n\nlast", " Adjacent"),
4223 ("first\n\n Fake\n\nText\n\n Other\n\nlast", "~~~\nAdjacent\n~~~"),
4224 ] {
4225 let source = format!("{{% set unused=\"{value}\" %}}{{{{ unused|length }}}}\n\n{adjacent}\n");
4226 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4227 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
4228 assert_eq!(rule.fix(&ctx).unwrap(), source);
4229 }
4230 }
4231
4232 #[test]
4233 fn literal_unclosed_fences_do_not_trigger_document_repair() {
4234 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4235 for (flavor, code) in [
4236 (
4237 crate::config::MarkdownFlavor::Standard,
4238 "{% set unused=\"first\n\n~~~\ninner\nlast\" %}{{ unused|length }}",
4239 ),
4240 (
4241 crate::config::MarkdownFlavor::Hugo,
4242 "{{< note title=`first\n\n~~~\ninner\nlast` >}}",
4243 ),
4244 ] {
4245 for ending in ["\n", "\r\n"] {
4246 let source = format!("{code}\n\nVisible.\n").replace('\n', ending);
4247 let ctx = LintContext::new(&source, flavor, None);
4248 assert!(rule.check(&ctx).unwrap().is_empty());
4249 assert_eq!(rule.fix(&ctx).unwrap(), source);
4250 }
4251 }
4252 }
4253
4254 #[test]
4255 fn real_indented_template_blocks_are_not_partially_converted() {
4256 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4257 let title = " {% set unused=\"first\n last\" %}{{ unused|length }}";
4258 let source = format!("{title}\n\nParagraph.\n\n Adjacent\n");
4259 let expected = format!("{title}\n\nParagraph.\n\n```\nAdjacent\n```\n");
4260 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4261 let warnings = rule.check(&ctx).unwrap();
4262 assert_eq!(warnings.len(), 2);
4263 assert!(warnings[0].fix.is_none());
4264 assert!(warnings[1].fix.is_some());
4265 assert_eq!(
4266 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
4267 expected
4268 );
4269 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4270 }
4271
4272 #[test]
4273 fn real_fenced_template_blocks_are_not_partially_converted() {
4274 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4275 let title = "~~~\n{% set unused=\"first\nlast\" %}{{ unused|length }}\n~~~";
4276 let source = format!("{title}\n\nParagraph.\n\n~~~\nAdjacent\n~~~\n");
4277 let expected = format!("{title}\n\nParagraph.\n\n Adjacent\n");
4278 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4279 let warnings = rule.check(&ctx).unwrap();
4280 assert_eq!(warnings.len(), 2);
4281 assert!(warnings[0].fix.is_none());
4282 assert!(warnings[1].fix.is_some());
4283 assert_eq!(
4284 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
4285 expected
4286 );
4287 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4288 }
4289
4290 #[test]
4291 fn real_single_line_template_code_blocks_remain_convertible() {
4292 for (style, source, expected) in [
4293 (CodeBlockStyle::Fenced, " {{ title }}\n", "```\n{{ title }}\n```\n"),
4294 (CodeBlockStyle::Indented, "~~~\n{{ title }}\n~~~\n", " {{ title }}\n"),
4295 ] {
4296 let rule = MD046CodeBlockStyle::new(style);
4297 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
4298 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4299 assert!(rule.check(&ctx).unwrap()[0].fix.is_some());
4300 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4301 }
4302 }
4303
4304 #[test]
4305 fn mdx_literal_indentation_does_not_become_a_code_block() {
4306 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4307 for code in [
4308 "export const text = `first\n\n inner\n\nlast`\n\n{text}",
4309 "{`first\n\n inner\n\nlast`}",
4310 ] {
4311 let source = format!("{code}\n\n```\nAdjacent\n```\n");
4312 let expected = format!("{code}\n\n```\nAdjacent\n```\n");
4313 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
4314 let warnings = rule.check(&ctx).unwrap();
4315 assert!(warnings.is_empty());
4316 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4317 }
4318 }
4319
4320 #[test]
4321 fn literal_closers_keep_ambiguous_fence_suffixes_unchanged() {
4322 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4323 let suffix = "~~~\n{% set unused=\"first\n~~~\nlast\" %}{{ unused|length }}\n~~~\n";
4324 let source = format!("~~~\nEarlier\n~~~\n\nParagraph.\n\n{suffix}");
4325 let expected = format!(" Earlier\n\nParagraph.\n\n{suffix}");
4326 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4327 let warnings = rule.check(&ctx).unwrap();
4328 assert_eq!(warnings.len(), 1);
4329 assert!(warnings[0].fix.is_some());
4330 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4331 }
4332
4333 #[test]
4336 fn detect_style_counts_indented_blocks_after_a_fence_holding_other_fence_lines() {
4337 let head = "# T\n\nPara.\n\n one\n\nPara.\n\n two\n\nPara.\n\n";
4338 for inner in [
4339 "~~~\n```\n~~~\n",
4340 "````\n```\n````\n",
4341 "~~~~\n~~~\n~~~~\n",
4342 "```\n~~~\n```\n",
4343 ] {
4344 let content = format!("{head}{inner}");
4345 for flavor in crate::format_proof::ALL_FLAVORS {
4346 if matches!(
4349 flavor,
4350 crate::config::MarkdownFlavor::MDG | crate::config::MarkdownFlavor::AzureDevOps
4351 ) {
4352 continue;
4353 }
4354 let ctx = LintContext::new(&content, flavor, None);
4355 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
4356 let lines = ctx.raw_lines();
4357 let owned = rule.build_indent_context(&ctx, lines);
4358 assert_eq!(
4359 rule.detect_style(&ctx, lines, &owned.borrow()),
4360 Some(CodeBlockStyle::Indented),
4361 "{flavor:?}: two indented blocks outnumber one fenced block in {inner:?}"
4362 );
4363 let warnings = rule.check(&ctx).unwrap();
4364 assert_eq!(
4365 warnings
4366 .iter()
4367 .map(|w| (w.line, w.message.as_str()))
4368 .collect::<Vec<_>>(),
4369 vec![(13, "Use indented code blocks")],
4370 "{flavor:?}: {inner:?}"
4371 );
4372 }
4373 }
4374 }
4375
4376 #[test]
4377 fn detect_style_keeps_fenced_when_fences_outnumber_indented_blocks() {
4378 let content = "# T\n\nPara.\n\n one\n\nPara.\n\n~~~\n```\n~~~\n\nPara.\n\n~~~\n```\n~~~\n";
4379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4380 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
4381 let lines = ctx.raw_lines();
4382 let owned = rule.build_indent_context(&ctx, lines);
4383 assert_eq!(
4384 rule.detect_style(&ctx, lines, &owned.borrow()),
4385 Some(CodeBlockStyle::Fenced)
4386 );
4387 let warnings = rule.check(&ctx).unwrap();
4388 assert_eq!(
4389 warnings
4390 .iter()
4391 .map(|w| (w.line, w.message.as_str()))
4392 .collect::<Vec<_>>(),
4393 vec![(5, "Use fenced code blocks")]
4394 );
4395 }
4396}