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;
1144 let mut indented_count = 0;
1145
1146 let mut in_fenced = false;
1156 let mut prev_block = None;
1157
1158 for (i, line) in lines.iter().enumerate() {
1159 let in_container = ictx.in_comment_or_html.get(i).copied().unwrap_or(false);
1160
1161 if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|l| l.in_code_block) {
1165 prev_block = None;
1166 continue;
1167 }
1168
1169 if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|l| l.in_myst_directive) {
1171 prev_block = None;
1172 continue;
1173 }
1174
1175 let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1176 if self.is_fenced_code_block_start_at(line, baseline) {
1177 if in_container {
1178 prev_block = None;
1181 continue;
1182 }
1183 if !in_fenced {
1184 fenced_count += 1;
1186 in_fenced = true;
1187 } else {
1188 in_fenced = false;
1190 }
1191 prev_block = None;
1192 } else if !in_fenced && block_lines[i].is_some() {
1193 if prev_block != block_lines[i] {
1195 indented_count += 1;
1196 }
1197 prev_block = block_lines[i];
1198 } else {
1199 prev_block = None;
1200 }
1201 }
1202
1203 if fenced_count == 0 && indented_count == 0 {
1204 None
1205 } else if fenced_count > 0 && indented_count == 0 {
1206 Some(CodeBlockStyle::Fenced)
1207 } else if fenced_count == 0 && indented_count > 0 {
1208 Some(CodeBlockStyle::Indented)
1209 } else if fenced_count >= indented_count {
1210 Some(CodeBlockStyle::Fenced)
1211 } else {
1212 Some(CodeBlockStyle::Indented)
1213 }
1214 }
1215}
1216
1217impl Rule for MD046CodeBlockStyle {
1218 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1219 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(46))
1220 }
1221
1222 fn name(&self) -> &'static str {
1223 "MD046"
1224 }
1225
1226 fn description(&self) -> &'static str {
1227 "Code blocks should use a consistent style"
1228 }
1229
1230 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1231 self.check_and_retain_conversion(ctx, None)
1232 }
1233
1234 fn check_with_prepared_fix(
1235 &self,
1236 ctx: &crate::lint_context::LintContext,
1237 ) -> Result<(Vec<LintWarning>, Option<String>), LintError> {
1238 let mut prepared = None;
1239 let warnings = self.check_and_retain_conversion(ctx, Some(&mut prepared))?;
1240 Ok((warnings, prepared))
1241 }
1242
1243 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1244 self.fix_native(ctx)
1245 }
1246
1247 fn category(&self) -> RuleCategory {
1249 RuleCategory::CodeBlock
1250 }
1251
1252 fn fix_capability(&self) -> FixCapability {
1253 FixCapability::ConditionallyFixable
1256 }
1257
1258 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1260 ctx.content.is_empty() || ctx.code_block_details.is_empty()
1263 }
1264
1265 fn as_any(&self) -> &dyn std::any::Any {
1266 self
1267 }
1268
1269 crate::impl_rule_config_sections!(MD046Config);
1270
1271 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1272 where
1273 Self: Sized,
1274 {
1275 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1276 let style_explicit = option_is_explicit(config, "MD046", "style");
1277
1278 Box::new(Self {
1279 config: rule_config,
1280 style_explicit,
1281 })
1282 }
1283}
1284
1285impl MD046CodeBlockStyle {
1286 fn check_and_retain_conversion(
1289 &self,
1290 ctx: &crate::lint_context::LintContext,
1291 prepared: Option<&mut Option<String>>,
1292 ) -> LintResult {
1293 if ctx.content.is_empty() {
1295 return Ok(Vec::new());
1296 }
1297
1298 if !ctx.content.contains("```")
1300 && !ctx.content.contains("~~~")
1301 && !ctx.content.contains(" ")
1302 && !ctx.content.contains('\t')
1303 {
1304 return Ok(Vec::new());
1305 }
1306
1307 let mut unclosed_warnings = self.check_unclosed_code_blocks(ctx);
1309
1310 if !unclosed_warnings.is_empty() {
1312 let fixed = self.fix(ctx)?;
1313 for warning in &mut unclosed_warnings {
1314 warning.fix = if fixed == ctx.content {
1315 None
1316 } else if let Some(suffix) = fixed.strip_prefix(ctx.content) {
1317 Some(Fix::new(ctx.content.len()..ctx.content.len(), suffix.to_string()))
1318 } else {
1319 Some(Fix::new(0..ctx.content.len(), fixed.clone()))
1320 };
1321 }
1322 if let Some(prepared) = prepared {
1323 *prepared = Some(fixed);
1324 }
1325 return Ok(unclosed_warnings);
1326 }
1327
1328 let lines = ctx.raw_lines();
1330 let mut warnings = Vec::new();
1331
1332 let target_style = self.effective_target_style(ctx, || {
1334 let owned = self.build_indent_context(ctx, lines);
1335 let detected = self.detect_style(ctx, lines, &owned.borrow());
1336 detected.unwrap_or(CodeBlockStyle::Fenced)
1337 });
1338
1339 let mdg_block_lines = (ctx.flavor == crate::config::MarkdownFlavor::MDG
1344 && ctx.code_block_details.iter().any(|detail| !detail.is_fenced))
1345 .then(|| Self::indented_block_lines(lines, ctx));
1346
1347 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
1349 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
1350
1351 for detail in &ctx.code_block_details {
1352 if detail.start >= ctx.content.len()
1353 || detail.end > ctx.content.len()
1354 || Self::block_is_literal(ctx, detail, ambiguity_limit)
1355 {
1356 continue;
1357 }
1358
1359 let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
1360 Ok(idx) => idx,
1361 Err(idx) => idx.saturating_sub(1),
1362 };
1363
1364 if detail.is_fenced {
1365 if target_style == CodeBlockStyle::Indented {
1366 let line = lines.get(start_line_idx).unwrap_or(&"");
1367
1368 if ctx.lines.get(start_line_idx).is_some_and(|info| {
1369 info.in_html_comment
1370 || info.in_mdx_comment
1371 || info.in_footnote_definition
1372 || info.in_front_matter
1373 }) {
1374 continue;
1375 }
1376
1377 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1378 warnings.push(LintWarning {
1379 rule_name: Some(self.name().to_string()),
1380 line: start_line,
1381 column: start_col,
1382 end_line,
1383 end_column: end_col,
1384 message: "Use indented code blocks".to_string(),
1385 severity: Severity::Warning,
1386 fix: None,
1387 });
1388 }
1389 } else {
1390 if target_style == CodeBlockStyle::Fenced {
1392 if !Self::indented_block_is_reported(ctx, detail, ambiguity_limit) {
1393 continue;
1394 }
1395
1396 let start_line_idx = match &mdg_block_lines {
1401 Some(block_lines) => {
1402 match Self::first_code_block_line(ctx, block_lines, start_line_idx, detail.end) {
1403 Some(idx) => idx,
1404 None => continue,
1405 }
1406 }
1407 None => start_line_idx,
1408 };
1409
1410 if reported_indented_lines.contains(&start_line_idx) {
1411 continue;
1412 }
1413
1414 let line = lines.get(start_line_idx).unwrap_or(&"");
1415
1416 reported_indented_lines.insert(start_line_idx);
1417
1418 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1419 warnings.push(LintWarning {
1420 rule_name: Some(self.name().to_string()),
1421 line: start_line,
1422 column: start_col,
1423 end_line,
1424 end_column: end_col,
1425 message: "Use fenced code blocks".to_string(),
1426 severity: Severity::Warning,
1427 fix: None,
1428 });
1429 }
1430 }
1431 }
1432
1433 if !warnings.is_empty() {
1436 let conversion = self.convert_closed_blocks(ctx)?;
1437 for warning in &mut warnings {
1438 let line_idx = warning.line - 1;
1439 warning.fix = conversion
1440 .edits
1441 .iter()
1442 .find(|edit| edit.lines.contains(&line_idx))
1443 .map(|edit| edit.fix.clone());
1444 }
1445 if let Some(prepared) = prepared {
1446 *prepared = Some(conversion.content);
1447 }
1448 }
1449
1450 warnings.sort_by_key(|w| (w.line, w.column));
1452
1453 Ok(warnings)
1454 }
1455
1456 fn fix_native(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1457 let content = ctx.content;
1458 if content.is_empty() {
1459 return Ok(String::new());
1460 }
1461
1462 let unclosed = crate::utils::fix_utils::filter_warnings_by_inline_config(
1463 self.check_unclosed_code_blocks(ctx),
1464 ctx.inline_config(),
1465 self.name(),
1466 );
1467 if !unclosed.is_empty() {
1468 let repaired =
1469 crate::utils::fix_utils::apply_warning_fixes(content, &unclosed).map_err(LintError::FixFailed)?;
1470 let repaired_ctx = crate::lint_context::LintContext::new(
1471 &repaired,
1472 ctx.flavor,
1473 ctx.source_file().map(std::path::Path::to_path_buf),
1474 );
1475 return Ok(self.convert_closed_blocks(&repaired_ctx)?.content);
1478 }
1479
1480 Ok(self.convert_closed_blocks(ctx)?.content)
1481 }
1482}
1483
1484impl MD046CodeBlockStyle {
1485 fn convert_closed_blocks(&self, ctx: &crate::lint_context::LintContext) -> Result<Conversion, LintError> {
1489 let content = ctx.content;
1490 let ambiguity_limit = crate::rules::code_fence_utils::fence_ambiguity_limit(ctx);
1491 let lines = ctx.raw_lines();
1492
1493 let owned = self.build_indent_context(ctx, lines);
1494 let ictx = owned.borrow();
1495
1496 let target_style = self.effective_target_style(ctx, || {
1497 self.detect_style(ctx, lines, &ictx).unwrap_or(CodeBlockStyle::Fenced)
1498 });
1499
1500 let block_lines = Self::indented_block_lines(lines, ctx);
1501 let mut block_columns = vec![0; lines.len()];
1502 for (range, column) in Self::convertible_indented_blocks(ctx) {
1503 let end = range.end.min(lines.len());
1504 if range.start < end {
1505 block_columns[range.start..end].fill(column);
1506 }
1507 }
1508 let fenced_separator_lines = if target_style == CodeBlockStyle::Indented {
1509 Self::fenced_separator_lines(ctx)
1510 } else {
1511 std::collections::HashSet::new()
1512 };
1513 let fenced_boundary_blank_lines = if target_style == CodeBlockStyle::Indented {
1514 Self::fenced_boundary_blank_lines(ctx, lines, &ictx)
1515 } else {
1516 std::collections::HashSet::new()
1517 };
1518 let fenced_start_lines: std::collections::HashSet<usize> = ctx
1522 .code_block_details
1523 .iter()
1524 .filter(|detail| detail.is_fenced && !Self::block_is_literal(ctx, detail, ambiguity_limit))
1525 .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1526 .collect();
1527 let has_unsupported_fence_opener = ctx
1528 .code_block_details
1529 .iter()
1530 .filter(|detail| detail.is_fenced && Self::code_block_is_style_eligible(ctx, detail, ambiguity_limit))
1531 .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1532 .any(|line_index| {
1533 let Some(line) = lines.get(line_index) else {
1534 return true;
1535 };
1536 let baseline = ictx.list_item_baseline.get(line_index).copied().flatten().unwrap_or(0);
1537 !self.is_fenced_code_block_start_at(line, baseline)
1538 });
1539
1540 let (misplaced_dedent, unsafe_fence_lines) =
1544 self.categorize_indented_blocks(lines, &block_lines, &block_columns);
1545
1546 let mut result = String::with_capacity(content.len());
1547 let mut in_fenced_block = false;
1548 let mut fenced_fence_opener: Option<(char, usize)> = None;
1552 let mut in_indented_block = false;
1553 let mut current_block_fence_indent = String::new();
1558
1559 let mut current_block_must_stay_fenced = false;
1563 let mut current_fence_indent = 0usize;
1564 let mut current_fence_baseline = 0usize;
1565 let mut current_block_indented_prefix = String::from(" ");
1566 let mut converted_fenced_to_indented = false;
1567 let mut retained_structurally_unsafe_fence =
1568 target_style == CodeBlockStyle::Indented && has_unsupported_fence_opener;
1569
1570 let mut output_starts = Vec::with_capacity(lines.len() + 1);
1573 let mut line_blocks: Vec<Option<usize>> = vec![None; lines.len()];
1574 let mut current_block: Option<usize> = None;
1575
1576 for (i, line) in lines.iter().enumerate() {
1577 output_starts.push(result.len());
1578 let line_num = i + 1;
1579 let trimmed = line.trim_start();
1580 let list_baseline = ictx.list_item_baseline.get(i).copied().flatten();
1581 let fence_baseline = list_baseline.unwrap_or(0);
1582
1583 if !in_fenced_block
1586 && fenced_start_lines.contains(&i)
1587 && Self::has_valid_fence_indent_at(line, fence_baseline)
1588 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1589 {
1590 let block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
1592 in_fenced_block = true;
1593 current_block = Some(i);
1594 line_blocks[i] = current_block;
1595 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
1596 let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
1597 fenced_fence_opener = Some((fence_char, opener_len));
1598 current_fence_indent = calculate_indentation_width_default(line);
1599 current_fence_baseline = fence_baseline;
1600 current_block_indented_prefix = " ".repeat(fence_baseline + 4);
1601 let follows_list_item = i
1602 .checked_sub(1)
1603 .and_then(|previous| ictx.list_item_baseline.get(previous))
1604 .copied()
1605 .flatten()
1606 .is_some();
1607 let would_become_list_prose = target_style == CodeBlockStyle::Indented
1608 && list_baseline.is_none()
1609 && (ictx.in_list_context.get(i).copied().unwrap_or(false) || follows_list_item);
1610 let would_interrupt_paragraph = target_style == CodeBlockStyle::Indented
1611 && i > 0
1612 && !lines[i - 1].trim().is_empty()
1613 && ctx
1614 .lines
1615 .get(i - 1)
1616 .is_some_and(crate::lint_context::LineInfo::is_paragraph_context)
1617 && crate::lint_context::is_paragraph_text_line(lines[i - 1]);
1618 let would_merge_code_blocks = fenced_separator_lines.contains(&i);
1619 let would_lose_boundary_blanks = fenced_boundary_blank_lines.contains(&i);
1620 current_block_must_stay_fenced = block_disabled
1621 || !trimmed[opener_len..].trim().is_empty()
1622 || would_become_list_prose
1623 || would_interrupt_paragraph
1624 || would_merge_code_blocks
1625 || would_lose_boundary_blanks;
1626 retained_structurally_unsafe_fence |= would_become_list_prose
1627 || would_interrupt_paragraph
1628 || would_merge_code_blocks
1629 || would_lose_boundary_blanks;
1630
1631 if current_block_must_stay_fenced {
1632 result.push_str(line);
1635 result.push('\n');
1636 } else if target_style == CodeBlockStyle::Indented {
1637 in_indented_block = true;
1639 converted_fenced_to_indented = true;
1640 } else {
1641 result.push_str(line);
1643 result.push('\n');
1644 }
1645 } else if in_fenced_block && fenced_fence_opener.is_some() {
1646 line_blocks[i] = current_block;
1647 let (fence_char, opener_len) = fenced_fence_opener.unwrap();
1648 let is_closer = Self::is_closing_fence(line, fence_char, opener_len, current_fence_baseline);
1651 if is_closer {
1652 in_fenced_block = false;
1653 fenced_fence_opener = None;
1654 in_indented_block = false;
1655
1656 if current_block_must_stay_fenced {
1657 result.push_str(line);
1658 result.push('\n');
1659 } else if target_style == CodeBlockStyle::Indented {
1660 } else {
1662 result.push_str(line);
1664 result.push('\n');
1665 }
1666 current_block_must_stay_fenced = false;
1667 current_fence_indent = 0;
1668 current_fence_baseline = 0;
1669 current_block_indented_prefix.clear();
1670 } else if current_block_must_stay_fenced {
1671 result.push_str(line);
1673 result.push('\n');
1674 } else if target_style == CodeBlockStyle::Indented {
1675 if !line.is_empty() {
1682 let body = Self::strip_indentation_columns(line, current_fence_indent);
1687 result.push_str(¤t_block_indented_prefix);
1688 result.push_str(&body);
1689 }
1690 result.push('\n');
1691 } else {
1692 result.push_str(line);
1694 result.push('\n');
1695 }
1696 } else if block_lines[i].is_some() {
1697 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
1701 result.push_str(line);
1702 result.push('\n');
1703 continue;
1704 }
1705
1706 let prev_line_is_indented = i > 0 && block_lines[i - 1] == block_lines[i];
1708 if !prev_line_is_indented {
1709 current_block = Some(i);
1710 }
1711 line_blocks[i] = current_block;
1712
1713 if target_style == CodeBlockStyle::Fenced {
1714 let baseline = block_columns[i];
1719 let is_blank = line.trim().is_empty();
1728 let width = calculate_indentation_width_default(line);
1729 let body = if is_blank && width <= baseline + 4 {
1730 String::new()
1731 } else {
1732 Self::strip_indentation_columns(line, 4)
1733 };
1734
1735 if let Some(dedent) = misplaced_dedent[i] {
1738 if !is_blank || width.saturating_sub(dedent) > baseline {
1742 result.push_str(&Self::shift_left_columns(line, dedent));
1743 }
1744 result.push('\n');
1745 } else if unsafe_fence_lines[i] {
1746 result.push_str(line);
1749 result.push('\n');
1750 } else if !prev_line_is_indented && !in_indented_block {
1751 current_block_fence_indent = " ".repeat(baseline);
1753 result.push_str(¤t_block_fence_indent);
1754 result.push_str(Self::FENCE);
1755 result.push('\n');
1756 result.push_str(&body);
1757 result.push('\n');
1758 in_indented_block = true;
1759 } else {
1760 result.push_str(&body);
1762 result.push('\n');
1763 }
1764
1765 let next_line_is_indented = block_lines.get(i + 1) == Some(&block_lines[i]);
1767 if !next_line_is_indented
1769 && in_indented_block
1770 && misplaced_dedent[i].is_none()
1771 && !unsafe_fence_lines[i]
1772 {
1773 result.push_str(¤t_block_fence_indent);
1774 result.push_str(Self::FENCE);
1775 result.push('\n');
1776 in_indented_block = false;
1777 current_block_fence_indent.clear();
1778 }
1779 } else {
1780 result.push_str(line);
1782 result.push('\n');
1783 }
1784 } else {
1785 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1787 result.push_str(¤t_block_fence_indent);
1788 result.push_str(Self::FENCE);
1789 result.push('\n');
1790 in_indented_block = false;
1791 current_block_fence_indent.clear();
1792 output_starts[i] = result.len();
1794 }
1795 current_block = None;
1796
1797 result.push_str(line);
1798 result.push('\n');
1799 }
1800 }
1801
1802 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1804 result.push_str(¤t_block_fence_indent);
1805 result.push_str(Self::FENCE);
1806 result.push('\n');
1807 }
1808 output_starts.push(result.len());
1809
1810 if !content.ends_with('\n') && result.ends_with('\n') {
1812 result.pop();
1813 }
1814
1815 if retained_structurally_unsafe_fence && self.config.style == CodeBlockStyle::Consistent {
1816 return Self::new(CodeBlockStyle::Fenced).convert_closed_blocks(ctx);
1817 }
1818
1819 if converted_fenced_to_indented {
1820 let reparsed_block_count = crate::utils::CodeBlockUtils::detect_code_blocks(&result).len();
1821 if reparsed_block_count != ctx.code_block_details.len() {
1822 if self.config.style == CodeBlockStyle::Consistent {
1827 return Self::new(CodeBlockStyle::Fenced).convert_closed_blocks(ctx);
1828 }
1829
1830 return Ok(Conversion::unchanged(content));
1831 }
1832 }
1833
1834 if result == content || (content.contains('\r') && result == content.replace("\r\n", "\n")) {
1835 return Ok(Conversion::unchanged(content));
1836 }
1837
1838 let mut edits = Self::block_edits(ctx, &result, &output_starts, &line_blocks);
1839 let unsafe_blocks: Vec<_> = ctx
1842 .code_block_details
1843 .iter()
1844 .filter(|detail| {
1845 !Self::block_is_literal(ctx, detail, ambiguity_limit)
1846 && Self::code_block_start_line(ctx, detail).is_some_and(|first| {
1847 let last = ctx.line_offsets.partition_point(|&offset| offset < detail.end);
1848 (first..last).any(|line| {
1849 !lines[line].is_empty()
1850 && ctx.lines.get(line).is_some_and(|info| {
1851 ctx.is_inside_template_code(info.byte_offset)
1852 || ctx.is_inside_mdx_code(info.byte_offset)
1853 })
1854 })
1855 })
1856 })
1857 .map(|detail| detail.start..detail.end)
1858 .collect();
1859 let original_edit_count = edits.len();
1860 edits.retain(|edit| {
1861 !unsafe_blocks
1862 .iter()
1863 .any(|block| edit.fix.range.start < block.end && block.start < edit.fix.range.end)
1864 });
1865 if edits.len() != original_edit_count {
1866 let mut preserved = content.to_string();
1867 for edit in edits.iter().rev() {
1868 preserved.replace_range(edit.fix.range.clone(), &edit.fix.replacement);
1869 }
1870 return Ok(Conversion {
1871 content: preserved,
1872 edits,
1873 });
1874 }
1875
1876 let content = crate::utils::line_ending::LineEndingPolicy::new(content).apply_owned(result);
1877
1878 Ok(Conversion { content, edits })
1879 }
1880
1881 fn block_edits(
1885 ctx: &crate::lint_context::LintContext,
1886 result: &str,
1887 output_starts: &[usize],
1888 line_blocks: &[Option<usize>],
1889 ) -> Vec<BlockEdit> {
1890 let content = ctx.content;
1891 let line_count = line_blocks.len();
1892 let line_start = |i: usize| {
1893 if i < line_count {
1894 ctx.line_offsets[i]
1895 } else {
1896 content.len()
1897 }
1898 };
1899
1900 let mut edits = Vec::new();
1901
1902 let endings = crate::utils::line_ending::LineEndingPolicy::new(content);
1903 let mut i = 0;
1904 while i < line_count {
1905 let Some(block) = line_blocks[i] else {
1906 i += 1;
1907 continue;
1908 };
1909 let first = i;
1910 while i < line_count && line_blocks[i] == Some(block) {
1911 i += 1;
1912 }
1913 let last = i - 1;
1914
1915 let original = &content[line_start(first)..line_start(i)];
1916 let replacement = &result[output_starts[first]..output_starts[i].min(result.len())];
1919 if original.replace("\r\n", "\n") == replacement {
1920 continue;
1921 }
1922
1923 let replacement = endings.apply_to(replacement);
1924
1925 edits.push(BlockEdit {
1926 lines: first..=last,
1927 fix: Fix::new(line_start(first)..line_start(i), replacement),
1928 });
1929 }
1930 edits
1931 }
1932}
1933
1934struct Conversion {
1936 content: String,
1937 edits: Vec<BlockEdit>,
1939}
1940
1941impl Conversion {
1942 fn unchanged(content: &str) -> Self {
1943 Self {
1944 content: content.to_string(),
1945 edits: Vec::new(),
1946 }
1947 }
1948}
1949
1950struct BlockEdit {
1952 lines: std::ops::RangeInclusive<usize>,
1953 fix: Fix,
1954}
1955
1956#[cfg(test)]
1957mod tests {
1958 use super::*;
1959 use crate::lint_context::LintContext;
1960
1961 #[test]
1962 fn prepared_conversion_preserves_native_findings_and_full_fixes() {
1963 let sources = [
1964 "plain prose\n",
1965 " one\n two\n",
1966 "```\none\n```\n",
1967 "```rust\none\n```\n",
1968 "~~~\none\n~~~\n\n two\n",
1969 "```rust\nunclosed\n",
1970 "- item\n\n code\n\n ```\n nested\n ```\n",
1971 "> ```\n> literal\n> ```\n\n outside\n",
1972 "---\nvalue: |\n literal\n---\n\n code\n",
1973 "<!-- rumdl-disable MD046 -->\n first\n<!-- rumdl-enable MD046 -->\n\n second\n",
1974 "<!-- rumdl-disable MD046 -->\n```\nunclosed\n",
1975 "<!-- rumdl-configure-file {\"MD046\": {\"style\": \"indented\"}} -->\n```\nx\n```\n",
1976 "<!--\n```\nliteral\n-->\n\n outside\n",
1977 " café\n\t🙂\n",
1978 ];
1979 let mut prepared_closed = 0;
1980 let mut prepared_unclosed = 0;
1981 for flavor in crate::format_proof::ALL_FLAVORS {
1982 for style in [
1983 CodeBlockStyle::Consistent,
1984 CodeBlockStyle::Fenced,
1985 CodeBlockStyle::Indented,
1986 ] {
1987 let rule = MD046CodeBlockStyle::new(style);
1988 for source in sources {
1989 for ending in ["\n", "\r\n", "\r\n\n"] {
1990 let source = source.replace('\n', ending);
1991 let ctx = LintContext::new(&source, flavor, Some("document.md".into()));
1992 let warnings = rule.check(&ctx).unwrap();
1993 let (actual_warnings, prepared) = rule.check_with_prepared_fix(&ctx).unwrap();
1994 assert_eq!(actual_warnings, warnings, "{flavor:?}/{style:?}/{source:?}");
1995 if let Some(prepared) = prepared {
1996 assert_eq!(prepared, rule.fix(&ctx).unwrap(), "{flavor:?}/{style:?}/{source:?}");
1997 if rule.check_unclosed_code_blocks(&ctx).is_empty() {
1998 prepared_closed += 1;
1999 } else {
2000 prepared_unclosed += 1;
2001 }
2002 }
2003 }
2004 }
2005 }
2006 }
2007 assert!(prepared_closed > 0);
2008 assert!(prepared_unclosed > 0);
2009 }
2010
2011 fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
2014 let flavor = if is_mkdocs {
2015 crate::config::MarkdownFlavor::MkDocs
2016 } else {
2017 crate::config::MarkdownFlavor::Standard
2018 };
2019 let ctx = LintContext::new(content, flavor, None);
2020 let lines = ctx.raw_lines();
2021 rule.detect_style(&ctx, lines, &rule.build_indent_context(&ctx, lines).borrow())
2022 }
2023
2024 #[test]
2025 fn test_unclosed_fence_diagnostic_matches_document_fix() {
2026 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2027 for (content, expected) in [
2028 ("```", "```\n```"),
2029 ("```\ncode\n", "```\ncode\n```\n"),
2030 ("````\ncode\n```\n", "````\ncode\n```\n````\n"),
2031 ("> ```rust\n> code\n", "> ```rust\n> code\n> ```\n"),
2032 ("> > ~~~~\n> > code", "> > ~~~~\n> > code\n> > ~~~~"),
2033 ("- ```\n code\n", "- ```\n code\n ```\n"),
2034 (" ```\n code\n", " ```\n code\n ```\n"),
2035 ] {
2036 for newline in ["\n", "\r\n"] {
2037 let content = content.replace('\n', newline);
2038 let expected = if content.contains('\n') {
2039 expected.replace('\n', newline)
2040 } else {
2041 expected.to_string()
2042 };
2043 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2044 let warnings = rule.check(&ctx).unwrap();
2045 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
2046 let edited = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings).unwrap();
2047 assert_eq!(edited, expected, "{content:?}");
2048 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
2049 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
2050 assert!(rule.check(&fixed_ctx).unwrap().is_empty(), "{expected:?}");
2051 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2052 }
2053 }
2054 }
2055
2056 #[test]
2057 fn test_closed_quote_fence_at_eof_is_unchanged() {
2058 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2059 for content in [
2060 "> - item\n> ```\n> code\n> ```",
2061 "> > ~~~~\n> > code\n> > ~~~~",
2062 "```\r\ncode\r\n```\r\n",
2063 "Text\r\n\n- item\r\n",
2064 ] {
2065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2066 let warnings = rule.check(&ctx).unwrap();
2067 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
2068 assert_eq!(rule.fix(&ctx).unwrap(), content);
2069 }
2070 }
2071
2072 #[test]
2073 fn test_unclosed_quote_repair_keeps_unsupported_style_warning() {
2074 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2075 let content = "> ```\n> code\n";
2076 let expected = "> ```\n> code\n> ```\n";
2077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2078 let warnings = rule.check(&ctx).unwrap();
2079 assert_eq!(warnings.len(), 1);
2080 assert_eq!(
2081 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
2082 expected
2083 );
2084 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2085 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
2086 let remaining = rule.check(&fixed_ctx).unwrap();
2087 assert_eq!(remaining.len(), 1);
2088 assert_eq!(remaining[0].message, "Use indented code blocks");
2089 assert!(
2090 remaining[0].fix.is_none(),
2091 "Container conversion is intentionally unsupported"
2092 );
2093 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2094 }
2095
2096 #[test]
2097 fn test_unclosed_fence_diagnostic_includes_indented_conversion() {
2098 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2099 let content = "```\ncode\n";
2100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2101 let warnings = rule.check(&ctx).unwrap();
2102 assert_eq!(warnings.len(), 1);
2103 let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
2104 assert_eq!(edited, " code\n");
2105 assert_eq!(rule.fix(&ctx).unwrap(), edited);
2106 let fixed_ctx = LintContext::new(&edited, crate::config::MarkdownFlavor::Standard, None);
2107 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2108 assert_eq!(rule.fix(&fixed_ctx).unwrap(), edited);
2109 }
2110
2111 #[test]
2112 fn test_fenced_code_block_detection() {
2113 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2114 assert!(rule.is_fenced_code_block_start("```"));
2115 assert!(rule.is_fenced_code_block_start("```rust"));
2116 assert!(rule.is_fenced_code_block_start("~~~"));
2117 assert!(rule.is_fenced_code_block_start("~~~python"));
2118 assert!(rule.is_fenced_code_block_start(" ```"));
2119 assert!(!rule.is_fenced_code_block_start("``"));
2120 assert!(!rule.is_fenced_code_block_start("~~"));
2121 assert!(!rule.is_fenced_code_block_start("Regular text"));
2122 }
2123
2124 #[test]
2125 fn test_fix_capability_is_conditional() {
2126 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2127 assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
2128 }
2129
2130 #[test]
2131 fn test_consistent_style_with_fenced_blocks() {
2132 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2133 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
2134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2135 let result = rule.check(&ctx).unwrap();
2136
2137 assert_eq!(result.len(), 0);
2139 }
2140
2141 #[test]
2142 fn test_consistent_style_with_indented_blocks() {
2143 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2144 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
2145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2146 let result = rule.check(&ctx).unwrap();
2147
2148 assert_eq!(result.len(), 0);
2150 }
2151
2152 #[test]
2153 fn test_consistent_style_mixed() {
2154 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2155 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
2156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2157 let result = rule.check(&ctx).unwrap();
2158
2159 assert!(!result.is_empty());
2161 }
2162
2163 #[test]
2164 fn test_fenced_style_with_indented_blocks() {
2165 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2166 let content = "Text\n\n indented code\n more code\n\nMore text";
2167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2168 let result = rule.check(&ctx).unwrap();
2169
2170 assert!(!result.is_empty());
2172 assert!(result[0].message.contains("Use fenced code blocks"));
2173 }
2174
2175 #[test]
2176 fn test_fenced_style_with_tab_indented_blocks() {
2177 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2178 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
2179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2180 let result = rule.check(&ctx).unwrap();
2181
2182 assert!(!result.is_empty());
2184 assert!(result[0].message.contains("Use fenced code blocks"));
2185 }
2186
2187 #[test]
2188 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
2189 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2190 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
2192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2193 let result = rule.check(&ctx).unwrap();
2194
2195 assert!(
2197 !result.is_empty(),
2198 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
2199 );
2200 assert!(result[0].message.contains("Use fenced code blocks"));
2201 }
2202
2203 #[test]
2204 fn test_fenced_style_with_one_space_tab_indent() {
2205 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2206 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
2208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2209 let result = rule.check(&ctx).unwrap();
2210
2211 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
2212 assert!(result[0].message.contains("Use fenced code blocks"));
2213 }
2214
2215 #[test]
2216 fn test_indented_style_with_fenced_blocks() {
2217 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2218 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
2219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2220 let result = rule.check(&ctx).unwrap();
2221
2222 assert!(!result.is_empty());
2224 assert!(result[0].message.contains("Use indented code blocks"));
2225 }
2226
2227 #[test]
2228 fn test_unclosed_code_block() {
2229 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2230 let content = "```\ncode without closing fence";
2231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2232 let result = rule.check(&ctx).unwrap();
2233
2234 assert_eq!(result.len(), 1);
2235 assert!(result[0].message.contains("never closed"));
2236 }
2237
2238 #[test]
2239 fn test_nested_code_blocks() {
2240 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2241 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
2242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2243 let result = rule.check(&ctx).unwrap();
2244
2245 assert_eq!(result.len(), 0);
2247 }
2248
2249 #[test]
2250 fn test_fix_indented_to_fenced() {
2251 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2252 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
2253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2254 let fixed = rule.fix(&ctx).unwrap();
2255
2256 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
2257 }
2258
2259 #[test]
2260 fn test_fix_fenced_to_indented() {
2261 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2262 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
2263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2264 let fixed = rule.fix(&ctx).unwrap();
2265
2266 assert!(fixed.contains(" code line 1\n code line 2"));
2267 assert!(!fixed.contains("```"));
2268 }
2269
2270 #[test]
2271 fn test_fix_fenced_to_indented_blank_lines_have_no_trailing_spaces() {
2272 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2276 let content = "Text\n\n```\ncode line 1\n\ncode line 2\n```\n\nMore text";
2277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2278 let fixed = rule.fix(&ctx).unwrap();
2279
2280 for line in fixed.lines() {
2281 assert!(
2282 line.is_empty() || !line.trim_end().is_empty() || line == line.trim_end(),
2283 "no line may have trailing whitespace, got {line:?}"
2284 );
2285 assert_ne!(line, " ", "blank line was indented to trailing spaces");
2286 }
2287 assert!(fixed.contains(" code line 1\n\n code line 2"));
2289 }
2290
2291 #[test]
2292 fn test_is_list_item_requires_delimiter_after_digits() {
2293 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2294 assert!(rule.is_list_item("1. First"));
2296 assert!(rule.is_list_item("42) Item"));
2297 assert!(rule.is_list_item(" 3. Indented item"));
2298 assert!(rule.is_list_item("- bullet"));
2300 assert!(rule.is_list_item("* bullet"));
2301 assert!(!rule.is_list_item("2 results. More info."));
2304 assert!(!rule.is_list_item("3 options (a, b) here"));
2305 assert!(!rule.is_list_item("100 items in stock. Buy now"));
2306 }
2307
2308 #[test]
2309 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
2310 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2313 let content = r#"# Test
2314
2315```
2316<!doctype html>
2317<html>
2318 <head>
2319 <title>Test</title>
2320 </head>
2321</html>
2322```
2323"#;
2324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2325 let fixed = rule.fix(&ctx).unwrap();
2326
2327 assert!(
2330 fixed.contains(" <head>"),
2331 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
2332 );
2333 assert!(
2334 fixed.contains(" <title>"),
2335 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
2336 );
2337 assert!(!fixed.contains("```"), "Fenced markers should be removed");
2338 }
2339
2340 #[test]
2341 fn test_fix_fenced_to_indented_preserves_python_indentation() {
2342 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2344 let content = r#"# Python Example
2345
2346```
2347def greet(name):
2348 if name:
2349 print(f"Hello, {name}!")
2350 else:
2351 print("Hello, World!")
2352```
2353"#;
2354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2355 let fixed = rule.fix(&ctx).unwrap();
2356
2357 assert!(
2359 fixed.contains(" def greet(name):"),
2360 "Function def should have 4 spaces (code block indent)"
2361 );
2362 assert!(
2363 fixed.contains(" if name:"),
2364 "if statement should have 8 spaces (4 code + 4 Python)"
2365 );
2366 assert!(
2367 fixed.contains(" print"),
2368 "print should have 12 spaces (4 code + 8 Python)"
2369 );
2370 }
2371
2372 #[test]
2373 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
2374 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2376 let content = r#"# Config
2377
2378```
2379server:
2380 host: localhost
2381 port: 8080
2382 ssl:
2383 enabled: true
2384 cert: /path/to/cert
2385```
2386"#;
2387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2388 let fixed = rule.fix(&ctx).unwrap();
2389
2390 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
2391 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
2392 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
2393 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
2394 }
2395
2396 #[test]
2397 fn test_fix_fenced_to_indented_preserves_empty_lines() {
2398 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2402 let content = "```\nline1\n\nline2\n```\n";
2403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2404 let fixed = rule.fix(&ctx).unwrap();
2405
2406 assert!(fixed.contains(" line1"), "line1 should be indented");
2408 assert!(fixed.contains(" line2"), "line2 should be indented");
2409 assert!(
2410 fixed.contains(" line1\n\n line2"),
2411 "blank line must stay empty, got {fixed:?}"
2412 );
2413 }
2414
2415 #[test]
2416 fn test_fix_fenced_to_indented_multiple_blocks() {
2417 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2419 let content = r#"# Doc
2420
2421```
2422def foo():
2423 pass
2424```
2425
2426Text between.
2427
2428```
2429key:
2430 value: 1
2431```
2432"#;
2433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2434 let fixed = rule.fix(&ctx).unwrap();
2435
2436 assert!(fixed.contains(" def foo():"), "Python def should be indented");
2437 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
2438 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
2439 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
2440 assert!(!fixed.contains("```"), "No fence markers should remain");
2441 }
2442
2443 #[test]
2444 fn test_fix_unclosed_block() {
2445 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2446 let content = "```\ncode without closing";
2447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2448 let fixed = rule.fix(&ctx).unwrap();
2449
2450 assert!(fixed.ends_with("```"));
2452 }
2453
2454 #[test]
2455 fn test_code_block_in_list() {
2456 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2457 let content = "- List item\n code in list\n more code\n- Next item";
2458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2459 let result = rule.check(&ctx).unwrap();
2460
2461 assert_eq!(result.len(), 0);
2463 }
2464
2465 #[test]
2466 fn test_detect_style_fenced() {
2467 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2468 let content = "```\ncode\n```";
2469 let style = detect_style_from_content(&rule, content, false);
2470
2471 assert_eq!(style, Some(CodeBlockStyle::Fenced));
2472 }
2473
2474 #[test]
2475 fn test_detect_style_indented() {
2476 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2477 let content = "Text\n\n code\n\nMore";
2478 let style = detect_style_from_content(&rule, content, false);
2479
2480 assert_eq!(style, Some(CodeBlockStyle::Indented));
2481 }
2482
2483 #[test]
2484 fn test_detect_style_none() {
2485 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2486 let content = "No code blocks here";
2487 let style = detect_style_from_content(&rule, content, false);
2488
2489 assert_eq!(style, None);
2490 }
2491
2492 #[test]
2493 fn test_tilde_fence() {
2494 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2495 let content = "~~~\ncode\n~~~";
2496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2497 let result = rule.check(&ctx).unwrap();
2498
2499 assert_eq!(result.len(), 0);
2501 }
2502
2503 #[test]
2504 fn test_language_specification() {
2505 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2506 let content = "```rust\nfn main() {}\n```";
2507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2508 let result = rule.check(&ctx).unwrap();
2509
2510 assert_eq!(result.len(), 0);
2511 }
2512
2513 #[test]
2514 fn test_empty_content() {
2515 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2516 let content = "";
2517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2518 let result = rule.check(&ctx).unwrap();
2519
2520 assert_eq!(result.len(), 0);
2521 }
2522
2523 #[test]
2524 fn test_default_config() {
2525 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2526 let (name, _config) = rule.default_config_section().unwrap();
2527 assert_eq!(name, "MD046");
2528 }
2529
2530 #[test]
2531 fn test_markdown_documentation_block() {
2532 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2533 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
2534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2535 let result = rule.check(&ctx).unwrap();
2536
2537 assert_eq!(result.len(), 0);
2539 }
2540
2541 #[test]
2542 fn test_preserve_trailing_newline() {
2543 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2544 let content = "```\ncode\n```\n";
2545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2546 let fixed = rule.fix(&ctx).unwrap();
2547
2548 assert_eq!(fixed, content);
2549 }
2550
2551 #[test]
2552 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
2553 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2554 let content = r#"# Document
2555
2556=== "Python"
2557
2558 This is tab content
2559 Not an indented code block
2560
2561 ```python
2562 def hello():
2563 print("Hello")
2564 ```
2565
2566=== "JavaScript"
2567
2568 More tab content here
2569 Also not an indented code block"#;
2570
2571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2572 let result = rule.check(&ctx).unwrap();
2573
2574 assert_eq!(result.len(), 0);
2576 }
2577
2578 #[test]
2579 fn test_mkdocs_tabs_with_actual_indented_code() {
2580 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2581 let content = r#"# Document
2582
2583=== "Tab 1"
2584
2585 This is tab content
2586
2587Regular text
2588
2589 This is an actual indented code block
2590 Should be flagged"#;
2591
2592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2593 let result = rule.check(&ctx).unwrap();
2594
2595 assert_eq!(result.len(), 1);
2597 assert!(result[0].message.contains("Use fenced code blocks"));
2598 }
2599
2600 #[test]
2601 fn test_mkdocs_tabs_detect_style() {
2602 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2603 let content = r#"=== "Tab 1"
2604
2605 Content in tab
2606 More content
2607
2608=== "Tab 2"
2609
2610 Content in second tab"#;
2611
2612 let style = detect_style_from_content(&rule, content, true);
2614 assert_eq!(style, None); let style = detect_style_from_content(&rule, content, false);
2618 assert_eq!(style, Some(CodeBlockStyle::Indented));
2619 }
2620
2621 #[test]
2622 fn test_mkdocs_nested_tabs() {
2623 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2624 let content = r#"# Document
2625
2626=== "Outer Tab"
2627
2628 Some content
2629
2630 === "Nested Tab"
2631
2632 Nested tab content
2633 Should not be flagged"#;
2634
2635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2636 let result = rule.check(&ctx).unwrap();
2637
2638 assert_eq!(result.len(), 0);
2640 }
2641
2642 #[test]
2643 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
2644 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2647 let content = r#"# Document
2648
2649!!! note
2650 This is normal admonition content, not a code block.
2651 It spans multiple lines.
2652
2653??? warning "Collapsible Warning"
2654 This is also admonition content.
2655
2656???+ tip "Expanded Tip"
2657 And this one too.
2658
2659Regular text outside admonitions."#;
2660
2661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2662 let result = rule.check(&ctx).unwrap();
2663
2664 assert_eq!(
2666 result.len(),
2667 0,
2668 "Admonition content in MkDocs mode should not trigger MD046"
2669 );
2670 }
2671
2672 #[test]
2673 fn test_mkdocs_admonition_with_actual_indented_code() {
2674 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2676 let content = r#"# Document
2677
2678!!! note
2679 This is admonition content.
2680
2681Regular text ends the admonition.
2682
2683 This is actual indented code (should be flagged)"#;
2684
2685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2686 let result = rule.check(&ctx).unwrap();
2687
2688 assert_eq!(result.len(), 1);
2690 assert!(result[0].message.contains("Use fenced code blocks"));
2691 }
2692
2693 #[test]
2694 fn test_admonition_in_standard_mode_flagged() {
2695 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2699 let content = r#"# Document
2700
2701!!! note
2702
2703 This looks like code in standard mode.
2704
2705Regular text."#;
2706
2707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2709 let result = rule.check(&ctx).unwrap();
2710
2711 assert_eq!(
2713 result.len(),
2714 1,
2715 "Admonition content in Standard mode should be flagged as indented code"
2716 );
2717 }
2718
2719 #[test]
2720 fn test_mkdocs_admonition_with_fenced_code_inside() {
2721 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2723 let content = r#"# Document
2724
2725!!! note "Code Example"
2726 Here's some code:
2727
2728 ```python
2729 def hello():
2730 print("world")
2731 ```
2732
2733 More text after code.
2734
2735Regular text."#;
2736
2737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2738 let result = rule.check(&ctx).unwrap();
2739
2740 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
2742 }
2743
2744 #[test]
2745 fn test_mkdocs_nested_admonitions() {
2746 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2748 let content = r#"# Document
2749
2750!!! note "Outer"
2751 Outer content.
2752
2753 !!! warning "Inner"
2754 Inner content.
2755 More inner content.
2756
2757 Back to outer.
2758
2759Regular text."#;
2760
2761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2762 let result = rule.check(&ctx).unwrap();
2763
2764 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
2766 }
2767
2768 #[test]
2769 fn test_mkdocs_admonition_fix_does_not_wrap() {
2770 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2772 let content = r#"!!! note
2773 Content that should stay as admonition content.
2774 Not be wrapped in code fences.
2775"#;
2776
2777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2778 let fixed = rule.fix(&ctx).unwrap();
2779
2780 assert!(
2782 !fixed.contains("```\n Content"),
2783 "Admonition content should not be wrapped in fences"
2784 );
2785 assert_eq!(fixed, content, "Content should remain unchanged");
2786 }
2787
2788 #[test]
2789 fn test_mkdocs_empty_admonition() {
2790 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2792 let content = r#"!!! note
2793
2794Regular paragraph after empty admonition.
2795
2796 This IS an indented code block (after blank + non-indented line)."#;
2797
2798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2799 let result = rule.check(&ctx).unwrap();
2800
2801 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
2803 }
2804
2805 #[test]
2806 fn test_mkdocs_indented_admonition() {
2807 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2809 let content = r#"- List item
2810
2811 !!! note
2812 Indented admonition content.
2813 More content.
2814
2815- Next item"#;
2816
2817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2818 let result = rule.check(&ctx).unwrap();
2819
2820 assert_eq!(
2822 result.len(),
2823 0,
2824 "Indented admonitions (e.g., in lists) should not be flagged"
2825 );
2826 }
2827
2828 #[test]
2829 fn test_footnote_indented_paragraphs_not_flagged() {
2830 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2831 let content = r#"# Test Document with Footnotes
2832
2833This is some text with a footnote[^1].
2834
2835Here's some code:
2836
2837```bash
2838echo "fenced code block"
2839```
2840
2841More text with another footnote[^2].
2842
2843[^1]: Really interesting footnote text.
2844
2845 Even more interesting second paragraph.
2846
2847[^2]: Another footnote.
2848
2849 With a second paragraph too.
2850
2851 And even a third paragraph!"#;
2852
2853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2854 let result = rule.check(&ctx).unwrap();
2855
2856 assert_eq!(result.len(), 0);
2858 }
2859
2860 #[test]
2861 fn test_footnote_definition_detection() {
2862 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2863
2864 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
2867 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
2868 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
2869 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
2870 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
2871 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
2872 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
2873 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
2874 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
2875
2876 assert!(!rule.is_footnote_definition("[^]: No label"));
2878 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
2879 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
2880 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
2881
2882 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
2884 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
2885 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
2886 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
2889
2890 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
2892 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
2893 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
2894 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
2895 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
2896
2897 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
2900 }
2901
2902 #[test]
2903 fn test_footnote_with_blank_lines() {
2904 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2908 let content = r#"# Document
2909
2910Text with footnote[^1].
2911
2912[^1]: First paragraph.
2913
2914 Second paragraph after blank line.
2915
2916 Third paragraph after another blank line.
2917
2918Regular text at column 0 ends the footnote."#;
2919
2920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2921 let result = rule.check(&ctx).unwrap();
2922
2923 assert_eq!(
2925 result.len(),
2926 0,
2927 "Indented content within footnotes should not trigger MD046"
2928 );
2929 }
2930
2931 #[test]
2932 fn test_footnote_multiple_consecutive_blank_lines() {
2933 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2936 let content = r#"Text[^1].
2937
2938[^1]: First paragraph.
2939
2940
2941
2942 Content after three blank lines (still part of footnote).
2943
2944Not indented, so footnote ends here."#;
2945
2946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2947 let result = rule.check(&ctx).unwrap();
2948
2949 assert_eq!(
2951 result.len(),
2952 0,
2953 "Multiple blank lines shouldn't break footnote continuation"
2954 );
2955 }
2956
2957 #[test]
2958 fn test_footnote_terminated_by_non_indented_content() {
2959 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2962 let content = r#"[^1]: Footnote content.
2963
2964 More indented content in footnote.
2965
2966This paragraph is not indented, so footnote ends.
2967
2968 This should be flagged as indented code block."#;
2969
2970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2971 let result = rule.check(&ctx).unwrap();
2972
2973 assert_eq!(
2975 result.len(),
2976 1,
2977 "Indented code after footnote termination should be flagged"
2978 );
2979 assert!(
2980 result[0].message.contains("Use fenced code blocks"),
2981 "Expected MD046 warning for indented code block"
2982 );
2983 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2984 }
2985
2986 #[test]
2987 fn test_footnote_terminated_by_structural_elements() {
2988 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2990 let content = r#"[^1]: Footnote content.
2991
2992 More content.
2993
2994## Heading terminates footnote
2995
2996 This indented content should be flagged.
2997
2998---
2999
3000 This should also be flagged (after horizontal rule)."#;
3001
3002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3003 let result = rule.check(&ctx).unwrap();
3004
3005 assert_eq!(
3007 result.len(),
3008 2,
3009 "Both indented blocks after termination should be flagged"
3010 );
3011 }
3012
3013 #[test]
3014 fn test_footnote_with_code_block_inside() {
3015 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3018 let content = r#"Text[^1].
3019
3020[^1]: Footnote with code:
3021
3022 ```python
3023 def hello():
3024 print("world")
3025 ```
3026
3027 More footnote text after code."#;
3028
3029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3030 let result = rule.check(&ctx).unwrap();
3031
3032 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
3034 }
3035
3036 #[test]
3037 fn test_footnote_with_8_space_indented_code() {
3038 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3041 let content = r#"Text[^1].
3042
3043[^1]: Footnote with nested code.
3044
3045 code block
3046 more code"#;
3047
3048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3049 let result = rule.check(&ctx).unwrap();
3050
3051 assert_eq!(
3053 result.len(),
3054 0,
3055 "8-space indented code within footnotes represents nested code blocks"
3056 );
3057 }
3058
3059 #[test]
3060 fn test_multiple_footnotes() {
3061 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3064 let content = r#"Text[^1] and more[^2].
3065
3066[^1]: First footnote.
3067
3068 Continuation of first.
3069
3070[^2]: Second footnote starts here, ending the first.
3071
3072 Continuation of second."#;
3073
3074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3075 let result = rule.check(&ctx).unwrap();
3076
3077 assert_eq!(
3079 result.len(),
3080 0,
3081 "Multiple footnotes should each maintain their continuation context"
3082 );
3083 }
3084
3085 #[test]
3086 fn test_list_item_ends_footnote_context() {
3087 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3089 let content = r#"[^1]: Footnote.
3090
3091 Content in footnote.
3092
3093- List item starts here (ends footnote context).
3094
3095 This indented content is part of the list, not the footnote."#;
3096
3097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3098 let result = rule.check(&ctx).unwrap();
3099
3100 assert_eq!(
3102 result.len(),
3103 0,
3104 "List items should end footnote context and start their own"
3105 );
3106 }
3107
3108 #[test]
3109 fn test_footnote_vs_actual_indented_code() {
3110 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3113 let content = r#"# Heading
3114
3115Text with footnote[^1].
3116
3117[^1]: Footnote content.
3118
3119 Part of footnote (should not be flagged).
3120
3121Regular paragraph ends footnote context.
3122
3123 This is actual indented code (MUST be flagged)
3124 Should be detected as code block"#;
3125
3126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3127 let result = rule.check(&ctx).unwrap();
3128
3129 assert_eq!(
3131 result.len(),
3132 1,
3133 "Must still detect indented code blocks outside footnotes"
3134 );
3135 assert!(
3136 result[0].message.contains("Use fenced code blocks"),
3137 "Expected MD046 warning for indented code"
3138 );
3139 assert!(
3140 result[0].line >= 11,
3141 "Warning should be on the actual indented code line"
3142 );
3143 }
3144
3145 #[test]
3146 fn test_spec_compliant_label_characters() {
3147 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3150
3151 assert!(rule.is_footnote_definition("[^test]: text"));
3153 assert!(rule.is_footnote_definition("[^TEST]: text"));
3154 assert!(rule.is_footnote_definition("[^test-name]: text"));
3155 assert!(rule.is_footnote_definition("[^test_name]: text"));
3156 assert!(rule.is_footnote_definition("[^test123]: text"));
3157 assert!(rule.is_footnote_definition("[^123]: text"));
3158 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
3159
3160 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")); }
3168
3169 #[test]
3170 fn test_code_block_inside_html_comment() {
3171 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3174 let content = r#"# Document
3175
3176Some text.
3177
3178<!--
3179Example code block in comment:
3180
3181```typescript
3182console.log("Hello");
3183```
3184
3185More comment text.
3186-->
3187
3188More content."#;
3189
3190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3191 let result = rule.check(&ctx).unwrap();
3192
3193 assert_eq!(
3194 result.len(),
3195 0,
3196 "Code blocks inside HTML comments should not be flagged as unclosed"
3197 );
3198 }
3199
3200 #[test]
3201 fn test_unclosed_fence_inside_html_comment() {
3202 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3204 let content = r#"# Document
3205
3206<!--
3207Example with intentionally unclosed fence:
3208
3209```
3210code without closing
3211-->
3212
3213More content."#;
3214
3215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3216 let result = rule.check(&ctx).unwrap();
3217
3218 assert_eq!(
3219 result.len(),
3220 0,
3221 "Unclosed fences inside HTML comments should be ignored"
3222 );
3223 }
3224
3225 #[test]
3226 fn test_multiline_html_comment_with_indented_code() {
3227 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3229 let content = r#"# Document
3230
3231<!--
3232Example:
3233
3234 indented code
3235 more code
3236
3237End of comment.
3238-->
3239
3240Regular text."#;
3241
3242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3243 let result = rule.check(&ctx).unwrap();
3244
3245 assert_eq!(
3246 result.len(),
3247 0,
3248 "Indented code inside HTML comments should not be flagged"
3249 );
3250 }
3251
3252 #[test]
3253 fn test_code_block_after_html_comment() {
3254 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3256 let content = r#"# Document
3257
3258<!-- comment -->
3259
3260Text before.
3261
3262 indented code should be flagged
3263
3264More text."#;
3265
3266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3267 let result = rule.check(&ctx).unwrap();
3268
3269 assert_eq!(
3270 result.len(),
3271 1,
3272 "Code blocks after HTML comments should still be detected"
3273 );
3274 assert!(result[0].message.contains("Use fenced code blocks"));
3275 }
3276
3277 #[test]
3278 fn test_consistent_style_indented_html_comment() {
3279 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3285 let content = "# MD046 false-positive reproduction\n\
3286 \n\
3287 <!--\n \
3288 This is just an indented comment, not a code block.\n\
3289 \n \
3290 A second line is required to trigger the false-positive.\n\
3291 \n \
3292 Actually, three lines are required.\n\
3293 -->\n\
3294 \n\
3295 ```md\n\
3296 This should be fine, since it's the only code block and therefore consistent.\n\
3297 ```\n";
3298
3299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3300 let result = rule.check(&ctx).unwrap();
3301
3302 assert_eq!(
3303 result,
3304 vec![],
3305 "A single fenced block and an indented HTML comment must produce no MD046 warnings",
3306 );
3307 }
3308
3309 #[test]
3310 fn test_consistent_style_indented_html_block() {
3311 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3318 let content = "# Heading\n\
3319 \n\
3320 <div class=\"note\">\n \
3321 line one of indented html content\n \
3322 line two of indented html content\n \
3323 line three of indented html content\n\
3324 </div>\n\
3325 \n\
3326 ```md\n\
3327 real fenced block\n\
3328 ```\n";
3329
3330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3331 let result = rule.check(&ctx).unwrap();
3332
3333 assert_eq!(
3334 result,
3335 vec![],
3336 "Indented content inside a raw HTML block must not influence MD046 style detection",
3337 );
3338 }
3339
3340 #[test]
3341 fn test_consistent_style_fake_fence_inside_html_comment() {
3342 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3348 let content = "# Title\n\
3349 \n\
3350 <!--\n\
3351 ```\n\
3352 fake fence inside comment\n\
3353 ```\n\
3354 -->\n\
3355 \n \
3356 real indented code block line 1\n \
3357 real indented code block line 2\n";
3358
3359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3360 let result = rule.check(&ctx).unwrap();
3361
3362 assert_eq!(
3363 result,
3364 vec![],
3365 "Fence markers inside an HTML comment must not influence MD046 style detection",
3366 );
3367 }
3368
3369 #[test]
3370 fn test_consistent_style_indented_footnote_definition() {
3371 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3375 let content = "# Heading\n\
3376 \n\
3377 Reference to a footnote[^note].\n\
3378 \n\
3379 [^note]: First line of the footnote.\n \
3380 Second indented continuation line.\n \
3381 Third indented continuation line.\n \
3382 Fourth indented continuation line.\n\
3383 \n\
3384 ```md\n\
3385 real fenced block\n\
3386 ```\n";
3387
3388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3389 let result = rule.check(&ctx).unwrap();
3390
3391 assert_eq!(
3392 result,
3393 vec![],
3394 "Footnote-definition continuation content must not influence MD046 style detection",
3395 );
3396 }
3397
3398 #[test]
3399 fn test_consistent_style_indented_blockquote() {
3400 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3405 let content = "# Heading\n\
3406 \n\
3407 > line one of quoted indented content\n\
3408 >\n\
3409 > line two of quoted indented content\n\
3410 >\n\
3411 > line three of quoted indented content\n\
3412 \n\
3413 ```md\n\
3414 real fenced block\n\
3415 ```\n";
3416
3417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3418 let result = rule.check(&ctx).unwrap();
3419
3420 assert_eq!(
3421 result,
3422 vec![],
3423 "Indented content inside a blockquote must not influence MD046 style detection",
3424 );
3425 }
3426
3427 #[test]
3428 fn test_consistent_style_genuine_indented_block_detected_as_indented() {
3429 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3434 let content = "# Heading\n\
3435 \n\
3436 Some prose.\n\
3437 \n \
3438 real indented code line 1\n \
3439 real indented code line 2\n";
3440
3441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3442 let result = rule.check(&ctx).unwrap();
3443
3444 assert_eq!(
3447 result,
3448 vec![],
3449 "A genuine top-level indented block must be detected as Indented style under Consistent",
3450 );
3451 }
3452
3453 #[test]
3454 fn test_consistent_style_skipped_lines_dont_override_real_block() {
3455 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3460 let content = "# Heading\n\
3461 \n\
3462 <!--\n \
3463 skipped indented comment line 1\n \
3464 skipped indented comment line 2\n\
3465 -->\n\
3466 \n\
3467 <!--\n \
3468 second skipped region\n \
3469 also skipped\n\
3470 -->\n\
3471 \n \
3472 real indented code line\n";
3473
3474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3475 let result = rule.check(&ctx).unwrap();
3476
3477 assert_eq!(
3478 result,
3479 vec![],
3480 "Skipped container lines must not outweigh the single real indented block",
3481 );
3482 }
3483
3484 #[test]
3485 fn test_consistent_style_fenced_wins_over_skipped_indented() {
3486 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3490 let content = "# Heading\n\
3491 \n\
3492 <!--\n \
3493 skipped indented region one\n \
3494 more of region one\n\
3495 -->\n\
3496 \n\
3497 <!--\n \
3498 skipped indented region two\n \
3499 more of region two\n\
3500 -->\n\
3501 \n\
3502 ```md\n\
3503 real fenced block\n\
3504 ```\n";
3505
3506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3507 let result = rule.check(&ctx).unwrap();
3508
3509 assert_eq!(
3510 result,
3511 vec![],
3512 "Fenced block must win when all indented lines are inside skipped containers",
3513 );
3514 }
3515
3516 #[test]
3517 fn test_four_space_indented_fence_is_not_valid_fence() {
3518 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3521
3522 assert!(rule.is_fenced_code_block_start("```"));
3524 assert!(rule.is_fenced_code_block_start(" ```"));
3525 assert!(rule.is_fenced_code_block_start(" ```"));
3526 assert!(rule.is_fenced_code_block_start(" ```"));
3527
3528 assert!(!rule.is_fenced_code_block_start(" ```"));
3530 assert!(!rule.is_fenced_code_block_start(" ```"));
3531 assert!(!rule.is_fenced_code_block_start(" ```"));
3532
3533 assert!(!rule.is_fenced_code_block_start("\t```"));
3535 }
3536
3537 #[test]
3538 fn test_issue_237_indented_fenced_block_detected_as_indented() {
3539 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3545
3546 let content = r#"## Test
3548
3549 ```js
3550 var foo = "hello";
3551 ```
3552"#;
3553
3554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3555 let result = rule.check(&ctx).unwrap();
3556
3557 assert_eq!(
3559 result.len(),
3560 1,
3561 "4-space indented fence should be detected as indented code block"
3562 );
3563 assert!(
3564 result[0].message.contains("Use fenced code blocks"),
3565 "Expected 'Use fenced code blocks' message"
3566 );
3567 }
3568
3569 #[test]
3570 fn test_issue_276_indented_code_in_list() {
3571 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3574
3575 let content = r#"1. First item
35762. Second item with code:
3577
3578 # This is a code block in a list
3579 print("Hello, world!")
3580
35814. Third item"#;
3582
3583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3584 let result = rule.check(&ctx).unwrap();
3585
3586 assert!(
3588 !result.is_empty(),
3589 "Indented code block inside list should be flagged when style=fenced"
3590 );
3591 assert!(
3592 result[0].message.contains("Use fenced code blocks"),
3593 "Expected 'Use fenced code blocks' message"
3594 );
3595 }
3596
3597 #[test]
3598 fn test_three_space_indented_fence_is_valid() {
3599 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3601
3602 let content = r#"## Test
3603
3604 ```js
3605 var foo = "hello";
3606 ```
3607"#;
3608
3609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3610 let result = rule.check(&ctx).unwrap();
3611
3612 assert_eq!(
3614 result.len(),
3615 0,
3616 "3-space indented fence should be recognized as valid fenced code block"
3617 );
3618 }
3619
3620 #[test]
3621 fn test_indented_style_with_deeply_indented_fenced() {
3622 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3625
3626 let content = r#"Text
3627
3628 ```js
3629 var foo = "hello";
3630 ```
3631
3632More text
3633"#;
3634
3635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3636 let result = rule.check(&ctx).unwrap();
3637
3638 assert_eq!(
3641 result.len(),
3642 0,
3643 "4-space indented content should be valid when style=indented"
3644 );
3645 }
3646
3647 #[test]
3648 fn test_fix_misplaced_fenced_block() {
3649 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3652
3653 let content = r#"## Test
3654
3655 ```js
3656 var foo = "hello";
3657 ```
3658"#;
3659
3660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3661 let fixed = rule.fix(&ctx).unwrap();
3662
3663 let expected = r#"## Test
3665
3666```js
3667var foo = "hello";
3668```
3669"#;
3670
3671 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
3672 }
3673
3674 #[test]
3675 fn test_fix_regular_indented_block() {
3676 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3679
3680 let content = r#"Text
3681
3682 var foo = "hello";
3683 console.log(foo);
3684
3685More text
3686"#;
3687
3688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3689 let fixed = rule.fix(&ctx).unwrap();
3690
3691 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
3693 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
3694 }
3695
3696 #[test]
3697 fn test_fix_indented_block_with_fence_like_content() {
3698 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3702
3703 let content = r#"Text
3704
3705 some code
3706 ```not a fence opener
3707 more code
3708"#;
3709
3710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3711 let fixed = rule.fix(&ctx).unwrap();
3712
3713 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
3715 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
3716 }
3717
3718 #[test]
3719 fn test_fix_mixed_indented_and_misplaced_blocks() {
3720 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3722
3723 let content = r#"Text
3724
3725 regular indented code
3726
3727More text
3728
3729 ```python
3730 print("hello")
3731 ```
3732"#;
3733
3734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3735 let fixed = rule.fix(&ctx).unwrap();
3736
3737 assert!(
3739 fixed.contains("```\nregular indented code\n```"),
3740 "First block should be wrapped in fences"
3741 );
3742
3743 assert!(
3745 fixed.contains("\n```python\nprint(\"hello\")\n```"),
3746 "Second block should be dedented, not double-wrapped"
3747 );
3748 assert!(
3750 !fixed.contains("```\n```python"),
3751 "Should not have nested fence openers"
3752 );
3753 }
3754
3755 #[test]
3756 fn test_md046_front_matter() {
3757 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3758 let content = "---\nmetadata:\n\n description: Indented\n---\n";
3759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3760 let result = rule.check(&ctx).unwrap();
3761 assert_eq!(result.len(), 0);
3762 }
3763
3764 #[test]
3765 fn test_md046_fix_front_matter() {
3766 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3767 let content = "---\nmetadata:\n\n description: Indented\n---\n";
3768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3769 let fixed = rule.fix(&ctx).unwrap();
3770 assert_eq!(fixed, content);
3771 }
3772
3773 #[test]
3774 fn test_whitespace_only_line_is_not_an_indented_code_block() {
3775 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3780 let content = "# T\n\nPara\n\n \nMore\n\n real code\n\nEnd\n";
3781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3782 let fixed = rule.fix(&ctx).unwrap();
3783 assert_eq!(fixed, "# T\n\nPara\n\n \nMore\n\n```\nreal code\n```\n\nEnd\n");
3784 }
3785
3786 #[test]
3787 fn test_interior_blank_line_keeps_indented_block_together() {
3788 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3792 let content = "# T\n\nPara\n\n a\n\n b\n\nAfter\n";
3793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3794 let fixed = rule.fix(&ctx).unwrap();
3795 assert_eq!(fixed, "# T\n\nPara\n\n```\na\n\nb\n```\n\nAfter\n");
3796 }
3797
3798 #[test]
3799 fn test_consistent_style_counts_a_block_with_interior_blank_once() {
3800 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3804 let content = "# T\n\n```\nfenced\n```\n\nPara\n\n a\n\n b\n\nEnd\n";
3805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3806 let result = rule.check(&ctx).unwrap();
3807 let reported: Vec<(usize, &str)> = result.iter().map(|w| (w.line, w.message.as_str())).collect();
3808 assert_eq!(reported, vec![(9, "Use fenced code blocks")]);
3809 }
3810
3811 #[test]
3812 fn test_indented_lazy_continuation_lines_are_not_code() {
3813 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3819 let content = "# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n real code\n\nEnd\n";
3820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3821 let fixed = rule.fix(&ctx).unwrap();
3822 assert_eq!(
3823 fixed,
3824 "# T\n\nPara\n lazy one\n lazy two\n lazy three\n\n```\nreal code\n```\n\nEnd\n"
3825 );
3826 }
3827
3828 #[test]
3829 fn test_misplaced_fence_with_interior_blank_dedents_as_one_block() {
3830 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3834 let content = "# T\n\nPara\n\n ```python\n x = 1\n\n y = 2\n ```\n\nAfter\n";
3835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3836 let fixed = rule.fix(&ctx).unwrap();
3837 assert_eq!(fixed, "# T\n\nPara\n\n```python\nx = 1\n\ny = 2\n```\n\nAfter\n");
3838 }
3839 #[test]
3840 fn test_mdg_overrides_indented_style_to_fenced() {
3841 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3846 let content = "# Feature: Payloads\n\n## Scenario: JSON payload\n\n* Given this payload\n\n ```json\n {\"ok\": true}\n ```\n";
3847
3848 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3849 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3850 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3851
3852 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3855 let standard_warnings = rule.check(&standard_ctx).unwrap();
3856 assert_eq!(standard_warnings.len(), 1);
3857 assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3858 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3859 }
3860
3861 #[test]
3862 fn test_mdg_indented_style_still_fences_indented_blocks() {
3863 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3867 let content =
3868 "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n ordinary indented code\n";
3869
3870 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3871 let warnings = rule.check(&mdg_ctx).unwrap();
3872 assert_eq!(warnings.len(), 1);
3873 assert_eq!(warnings[0].message, "Use fenced code blocks");
3874
3875 let fixed = rule.fix(&mdg_ctx).unwrap();
3876 assert_eq!(
3877 fixed,
3878 "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n```\n ordinary indented code\n```\n"
3879 );
3880
3881 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3882 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3883 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3884
3885 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3888 assert!(rule.check(&standard_ctx).unwrap().is_empty());
3889 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3890 }
3891
3892 #[test]
3893 fn test_mdg_steers_indented_code_to_fenced() {
3894 let content = "# Feature: Payloads\n\n## Scenario: Plain payload\n\n* Given this payload\n\n ordinary indented code\n";
3898
3899 for rule in [
3900 MD046CodeBlockStyle::new(CodeBlockStyle::Fenced),
3901 MD046CodeBlockStyle::new(CodeBlockStyle::Consistent),
3902 MD046CodeBlockStyle::new(CodeBlockStyle::Indented),
3903 ] {
3904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3905 let warnings = rule.check(&ctx).unwrap();
3906 assert_eq!(warnings.len(), 1);
3907 assert_eq!(warnings[0].message, "Use fenced code blocks");
3908
3909 let fixed = rule.fix(&ctx).unwrap();
3910 assert!(fixed.contains("```"), "MDG must fence the block: {fixed:?}");
3911
3912 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3913 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3914 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3915 }
3916 }
3917
3918 #[test]
3919 fn test_mdg_consistent_style_ignores_indented_prevalence() {
3920 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3923 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";
3924
3925 let standard_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::Standard, None);
3926 let standard_warnings = rule.check(&standard_ctx).unwrap();
3927 assert_eq!(standard_warnings.len(), 1);
3928 assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3929
3930 let mdg_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::MDG, None);
3931 let mdg_warnings = rule.check(&mdg_ctx).unwrap();
3932 assert_eq!(mdg_warnings.len(), 2);
3933 assert!(
3934 mdg_warnings
3935 .iter()
3936 .all(|warning| warning.message == "Use fenced code blocks")
3937 );
3938 }
3939
3940 #[test]
3941 fn test_mdg_repairs_unclosed_fence_like_standard() {
3942 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3945 let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3946
3947 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3948 let warnings = rule.check(&mdg_ctx).unwrap();
3949 assert_eq!(warnings.len(), 1);
3950 assert!(warnings[0].message.contains("never closed"));
3951
3952 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3953 assert_eq!(
3954 rule.fix(&mdg_ctx).unwrap(),
3955 rule.fix(&standard_ctx).unwrap(),
3956 "MDG must not differ from Standard"
3957 );
3958 }
3959
3960 #[test]
3961 fn test_mdg_table_above_prose_is_never_fenced() {
3962 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3968 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";
3969
3970 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3971 let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3972 assert_eq!(reported, vec![8, 12]);
3973
3974 let fixed = rule.fix(&mdg_ctx).unwrap();
3975 assert_eq!(
3976 fixed,
3977 "# 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"
3978 );
3979
3980 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3981 assert!(
3982 rule.check(&fixed_ctx).unwrap().is_empty(),
3983 "MDG check must have nothing left to report after its own fix"
3984 );
3985 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3986
3987 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3989 let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3990 assert_eq!(standard_reported, vec![5, 12]);
3991 assert!(rule.fix(&standard_ctx).unwrap().contains("```\n| start | eat | left |"));
3992 }
3993
3994 #[test]
3995 fn test_mdg_repairs_unclosed_fence_under_indented_style() {
3996 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4000 let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
4001
4002 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
4003 let warnings = rule.check(&mdg_ctx).unwrap();
4004 assert_eq!(warnings.len(), 1);
4005 assert!(warnings[0].message.contains("never closed"));
4006
4007 let fixed = rule.fix(&mdg_ctx).unwrap();
4008 assert_eq!(fixed, "# Feature: Payloads\n\n```json\n{\"ok\": true}\n```\n");
4009
4010 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
4011 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
4012
4013 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4016 assert_eq!(rule.fix(&standard_ctx).unwrap(), fixed);
4017 }
4018
4019 #[test]
4020 fn test_mdg_tab_indented_table_is_not_code() {
4021 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4025 for indent in ["\t\t", " \t"] {
4026 let content = format!(
4027 "# Feature: Eating\n\n#### Examples:\n\n{indent}| start | eat |\n{indent}| ----- | --- |\n\n## Scenario: Other\n\n code here\n"
4028 );
4029
4030 let mdg_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
4031 let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
4032 assert_eq!(reported, vec![10], "tab-indented rows are a table, not code");
4033
4034 let fixed = rule.fix(&mdg_ctx).unwrap();
4035 assert!(
4036 fixed.contains(&format!("{indent}| start | eat |\n{indent}| ----- | --- |")),
4037 "MDG must leave the tab-indented table alone: {fixed:?}"
4038 );
4039
4040 let standard_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
4041 let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
4042 assert_eq!(standard_reported, vec![5, 10]);
4043 }
4044 }
4045
4046 #[test]
4047 fn test_from_config_records_whether_style_was_configured() {
4048 use crate::config::Config;
4052 use std::collections::BTreeMap;
4053
4054 let mut values = BTreeMap::new();
4055 values.insert("style".to_string(), toml::Value::String("indented".to_string()));
4056 let mut config = Config::default();
4057 config.rules.insert(
4058 "MD046".to_string(),
4059 crate::config::RuleConfig { severity: None, values },
4060 );
4061
4062 let configured = MD046CodeBlockStyle::from_config(&config);
4063 let configured = configured.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
4064 assert_eq!(configured.config.style, CodeBlockStyle::Indented);
4065 assert!(configured.style_explicit);
4066
4067 let defaulted = MD046CodeBlockStyle::from_config(&Config::default());
4068 let defaulted = defaulted.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
4069 assert!(!defaulted.style_explicit);
4070
4071 let indented = MD046CodeBlockStyle::from_config_struct(MD046Config {
4074 style: CodeBlockStyle::Indented,
4075 });
4076 let content = "# Feature: F\n\nText.\n\n code here\n";
4077 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
4078 assert!(indented.fix(&mdg_ctx).unwrap().contains("```\n code here\n```"));
4079 }
4080
4081 #[test]
4082 fn test_mdg_indented_style_keeps_tables_out_of_code() {
4083 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4086 let content = "# Feature: Eating\n\n#### Examples:\n\n | start | eat | left |\n | ----- | --- | ---- |\n";
4087
4088 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
4089 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
4090 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
4091
4092 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4095 assert!(rule.check(&standard_ctx).unwrap().is_empty());
4096 assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
4097 }
4098
4099 #[test]
4103 fn test_style_warnings_carry_their_block_fix() {
4104 let cases = [
4105 (
4106 CodeBlockStyle::Fenced,
4107 "# T\n\n one\n\n still one\n\nText\n\n two\n",
4108 "# T\n\n```\none\n\nstill one\n```\n\nText\n\n```\ntwo\n```\n",
4109 ),
4110 (
4111 CodeBlockStyle::Indented,
4112 "# T\n\n```\none\n```\n\nText\n\n~~~\ntwo\n~~~\n",
4113 "# T\n\n one\n\nText\n\n two\n",
4114 ),
4115 (
4117 CodeBlockStyle::Fenced,
4118 "# T\n\n ```bash\n ls\n ```\n",
4119 "# T\n\n```bash\nls\n```\n",
4120 ),
4121 (CodeBlockStyle::Fenced, "# T\n\n code", "# T\n\n```\ncode\n```"),
4123 ];
4124 for (style, content, expected) in cases {
4125 let rule = MD046CodeBlockStyle::new(style);
4126 for newline in ["\n", "\r\n"] {
4127 let content = content.replace('\n', newline);
4128 let expected = expected.replace('\n', newline);
4129 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
4130 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
4131 let warnings = rule.check(&ctx).unwrap();
4132 assert!(!warnings.is_empty(), "{content:?}");
4133 for warning in &warnings {
4134 let fix = warning
4135 .fix
4136 .as_ref()
4137 .unwrap_or_else(|| panic!("{content:?}: {warning:?}"));
4138 let fix_line = content[..fix.range.start].matches('\n').count() + 1;
4139 assert_eq!(
4140 fix_line, warning.line,
4141 "a warning's fix starts at its block: {content:?}"
4142 );
4143 }
4144 let edited = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings).unwrap();
4145 assert_eq!(edited, expected, "{content:?}");
4146
4147 let first = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings[..1]).unwrap();
4149 let first_ctx = LintContext::new(&first, crate::config::MarkdownFlavor::Standard, None);
4150 assert_eq!(
4151 rule.check(&first_ctx).unwrap().len(),
4152 warnings.len() - 1,
4153 "{content:?} -> {first:?}"
4154 );
4155 }
4156 }
4157 }
4158
4159 #[test]
4161 fn test_style_warning_without_a_fix_where_fix_keeps_the_block() {
4162 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4163 let content = "# T\n\n ```\n code\n";
4166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4167 assert_eq!(rule.fix(&ctx).unwrap(), content);
4168 let warnings = rule.check(&ctx).unwrap();
4169 assert_eq!(warnings.len(), 1, "{warnings:?}");
4170 assert!(warnings[0].fix.is_none(), "{warnings:?}");
4171 }
4172
4173 #[test]
4174 fn literal_code_blocks_are_preserved_beside_real_style_fixes() {
4175 for (style, value, adjacent, target) in [
4176 (
4177 CodeBlockStyle::Fenced,
4178 "first\n\n inner\n\nlast",
4179 " Adjacent",
4180 "```\nAdjacent\n```",
4181 ),
4182 (
4183 CodeBlockStyle::Indented,
4184 "first\n\n~~~\ninner\n~~~\n\nlast",
4185 "~~~\nAdjacent\n~~~",
4186 " Adjacent",
4187 ),
4188 ] {
4189 let rule = MD046CodeBlockStyle::new(style);
4190 for (flavor, code) in [
4191 (
4192 crate::config::MarkdownFlavor::Standard,
4193 format!("{{% set unused=\"{value}\" %}}{{{{ unused|length }}}}"),
4194 ),
4195 (
4196 crate::config::MarkdownFlavor::Hugo,
4197 format!("{{{{< note title=`{value}` >}}}}"),
4198 ),
4199 ] {
4200 for ending in ["\n", "\r\n"] {
4201 let source = format!("{code}\n\n{adjacent}\n").replace('\n', ending);
4202 let expected = format!("{code}\n\n{target}\n").replace('\n', ending);
4203 let ctx = LintContext::new(&source, flavor, None);
4204 let warnings = rule.check(&ctx).unwrap();
4205 assert_eq!(warnings.len(), 1, "{source}");
4206 assert!(warnings[0].fix.is_some());
4207 assert_eq!(
4208 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
4209 expected
4210 );
4211 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4212 }
4213 }
4214 }
4215 }
4216
4217 #[test]
4218 fn literal_block_styles_do_not_choose_visible_code_style() {
4219 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
4220 for (value, adjacent) in [
4221 ("first\n\n~~~\nFake\n~~~\n\n~~~\nOther\n~~~\n\nlast", " Adjacent"),
4222 ("first\n\n Fake\n\nText\n\n Other\n\nlast", "~~~\nAdjacent\n~~~"),
4223 ] {
4224 let source = format!("{{% set unused=\"{value}\" %}}{{{{ unused|length }}}}\n\n{adjacent}\n");
4225 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4226 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
4227 assert_eq!(rule.fix(&ctx).unwrap(), source);
4228 }
4229 }
4230
4231 #[test]
4232 fn literal_unclosed_fences_do_not_trigger_document_repair() {
4233 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4234 for (flavor, code) in [
4235 (
4236 crate::config::MarkdownFlavor::Standard,
4237 "{% set unused=\"first\n\n~~~\ninner\nlast\" %}{{ unused|length }}",
4238 ),
4239 (
4240 crate::config::MarkdownFlavor::Hugo,
4241 "{{< note title=`first\n\n~~~\ninner\nlast` >}}",
4242 ),
4243 ] {
4244 for ending in ["\n", "\r\n"] {
4245 let source = format!("{code}\n\nVisible.\n").replace('\n', ending);
4246 let ctx = LintContext::new(&source, flavor, None);
4247 assert!(rule.check(&ctx).unwrap().is_empty());
4248 assert_eq!(rule.fix(&ctx).unwrap(), source);
4249 }
4250 }
4251 }
4252
4253 #[test]
4254 fn real_indented_template_blocks_are_not_partially_converted() {
4255 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4256 let title = " {% set unused=\"first\n last\" %}{{ unused|length }}";
4257 let source = format!("{title}\n\nParagraph.\n\n Adjacent\n");
4258 let expected = format!("{title}\n\nParagraph.\n\n```\nAdjacent\n```\n");
4259 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4260 let warnings = rule.check(&ctx).unwrap();
4261 assert_eq!(warnings.len(), 2);
4262 assert!(warnings[0].fix.is_none());
4263 assert!(warnings[1].fix.is_some());
4264 assert_eq!(
4265 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
4266 expected
4267 );
4268 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4269 }
4270
4271 #[test]
4272 fn real_fenced_template_blocks_are_not_partially_converted() {
4273 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4274 let title = "~~~\n{% set unused=\"first\nlast\" %}{{ unused|length }}\n~~~";
4275 let source = format!("{title}\n\nParagraph.\n\n~~~\nAdjacent\n~~~\n");
4276 let expected = format!("{title}\n\nParagraph.\n\n Adjacent\n");
4277 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4278 let warnings = rule.check(&ctx).unwrap();
4279 assert_eq!(warnings.len(), 2);
4280 assert!(warnings[0].fix.is_none());
4281 assert!(warnings[1].fix.is_some());
4282 assert_eq!(
4283 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
4284 expected
4285 );
4286 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4287 }
4288
4289 #[test]
4290 fn real_single_line_template_code_blocks_remain_convertible() {
4291 for (style, source, expected) in [
4292 (CodeBlockStyle::Fenced, " {{ title }}\n", "```\n{{ title }}\n```\n"),
4293 (CodeBlockStyle::Indented, "~~~\n{{ title }}\n~~~\n", " {{ title }}\n"),
4294 ] {
4295 let rule = MD046CodeBlockStyle::new(style);
4296 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
4297 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4298 assert!(rule.check(&ctx).unwrap()[0].fix.is_some());
4299 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4300 }
4301 }
4302
4303 #[test]
4304 fn mdx_literal_indentation_does_not_become_a_code_block() {
4305 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
4306 for code in [
4307 "export const text = `first\n\n inner\n\nlast`\n\n{text}",
4308 "{`first\n\n inner\n\nlast`}",
4309 ] {
4310 let source = format!("{code}\n\n```\nAdjacent\n```\n");
4311 let expected = format!("{code}\n\n```\nAdjacent\n```\n");
4312 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
4313 let warnings = rule.check(&ctx).unwrap();
4314 assert!(warnings.is_empty());
4315 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4316 }
4317 }
4318
4319 #[test]
4320 fn literal_closers_keep_ambiguous_fence_suffixes_unchanged() {
4321 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
4322 let suffix = "~~~\n{% set unused=\"first\n~~~\nlast\" %}{{ unused|length }}\n~~~\n";
4323 let source = format!("~~~\nEarlier\n~~~\n\nParagraph.\n\n{suffix}");
4324 let expected = format!(" Earlier\n\nParagraph.\n\n{suffix}");
4325 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4326 let warnings = rule.check(&ctx).unwrap();
4327 assert_eq!(warnings.len(), 1);
4328 assert!(warnings[0].fix.is_some());
4329 assert_eq!(rule.fix(&ctx).unwrap(), expected);
4330 }
4331}