1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::calculate_indentation_width_default;
3use crate::utils::mkdocs_admonitions;
4use crate::utils::mkdocs_tabs;
5use crate::utils::range_utils::calculate_line_range;
6use toml;
7
8mod md046_config;
9pub use md046_config::CodeBlockStyle;
10use md046_config::MD046Config;
11
12struct IndentContext<'a> {
14 in_list_context: &'a [bool],
15 in_tab_context: &'a [bool],
16 in_admonition_context: &'a [bool],
17 in_jsx_context: &'a [bool],
18}
19
20#[derive(Clone)]
26pub struct MD046CodeBlockStyle {
27 config: MD046Config,
28}
29
30impl MD046CodeBlockStyle {
31 pub fn new(style: CodeBlockStyle) -> Self {
32 Self {
33 config: MD046Config { style },
34 }
35 }
36
37 pub fn from_config_struct(config: MD046Config) -> Self {
38 Self { config }
39 }
40
41 fn has_valid_fence_indent(line: &str) -> bool {
46 calculate_indentation_width_default(line) < 4
47 }
48
49 fn is_fenced_code_block_start(&self, line: &str) -> bool {
58 if !Self::has_valid_fence_indent(line) {
59 return false;
60 }
61
62 let trimmed = line.trim_start();
63 trimmed.starts_with("```") || trimmed.starts_with("~~~")
64 }
65
66 fn is_list_item(&self, line: &str) -> bool {
67 let trimmed = line.trim_start();
68 (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
69 || (trimmed.len() > 2
70 && trimmed.chars().next().unwrap().is_numeric()
71 && (trimmed.contains(". ") || trimmed.contains(") ")))
72 }
73
74 fn is_footnote_definition(&self, line: &str) -> bool {
94 let trimmed = line.trim_start();
95 if !trimmed.starts_with("[^") || trimmed.len() < 5 {
96 return false;
97 }
98
99 if let Some(close_bracket_pos) = trimmed.find("]:")
100 && close_bracket_pos > 2
101 {
102 let label = &trimmed[2..close_bracket_pos];
103
104 if label.trim().is_empty() {
105 return false;
106 }
107
108 if label.contains('\r') {
110 return false;
111 }
112
113 if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
115 return true;
116 }
117 }
118
119 false
120 }
121
122 fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
145 let mut in_continuation_context = vec![false; lines.len()];
146 let mut last_list_item_line: Option<usize> = None;
147 let mut last_footnote_line: Option<usize> = None;
148 let mut blank_line_count = 0;
149
150 for (i, line) in lines.iter().enumerate() {
151 let trimmed = line.trim_start();
152 let indent_len = line.len() - trimmed.len();
153
154 if self.is_list_item(line) {
156 last_list_item_line = Some(i);
157 last_footnote_line = None; blank_line_count = 0;
159 in_continuation_context[i] = true;
160 continue;
161 }
162
163 if self.is_footnote_definition(line) {
165 last_footnote_line = Some(i);
166 last_list_item_line = None; blank_line_count = 0;
168 in_continuation_context[i] = true;
169 continue;
170 }
171
172 if line.trim().is_empty() {
174 if last_list_item_line.is_some() || last_footnote_line.is_some() {
176 blank_line_count += 1;
177 in_continuation_context[i] = true;
178
179 }
183 continue;
184 }
185
186 if indent_len == 0 && !trimmed.is_empty() {
188 if trimmed.starts_with('#') {
192 last_list_item_line = None;
193 last_footnote_line = None;
194 blank_line_count = 0;
195 continue;
196 }
197
198 if trimmed.starts_with("---") || trimmed.starts_with("***") {
200 last_list_item_line = None;
201 last_footnote_line = None;
202 blank_line_count = 0;
203 continue;
204 }
205
206 if let Some(list_line) = last_list_item_line
209 && (i - list_line > 5 || blank_line_count > 1)
210 {
211 last_list_item_line = None;
212 }
213
214 if last_footnote_line.is_some() {
216 last_footnote_line = None;
217 }
218
219 blank_line_count = 0;
220
221 if last_list_item_line.is_none() && last_footnote_line.is_some() {
223 last_footnote_line = None;
224 }
225 continue;
226 }
227
228 if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
230 in_continuation_context[i] = true;
231 blank_line_count = 0;
232 }
233 }
234
235 in_continuation_context
236 }
237
238 fn is_indented_code_block_with_context(
240 &self,
241 lines: &[&str],
242 i: usize,
243 is_mkdocs: bool,
244 ctx: &IndentContext,
245 ) -> bool {
246 if i >= lines.len() {
247 return false;
248 }
249
250 let line = lines[i];
251
252 let indent = calculate_indentation_width_default(line);
254 if indent < 4 {
255 return false;
256 }
257
258 if ctx.in_list_context[i] {
260 return false;
261 }
262
263 if is_mkdocs && ctx.in_tab_context[i] {
265 return false;
266 }
267
268 if is_mkdocs && ctx.in_admonition_context[i] {
271 return false;
272 }
273
274 if ctx.in_jsx_context.get(i).copied().unwrap_or(false) {
276 return false;
277 }
278
279 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
282 let prev_is_indented_code = i > 0
283 && calculate_indentation_width_default(lines[i - 1]) >= 4
284 && !ctx.in_list_context[i - 1]
285 && !(is_mkdocs && ctx.in_tab_context[i - 1])
286 && !(is_mkdocs && ctx.in_admonition_context[i - 1]);
287
288 if !has_blank_line_before && !prev_is_indented_code {
291 return false;
292 }
293
294 true
295 }
296
297 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
299 let mut in_tab_context = vec![false; lines.len()];
300 let mut current_tab_indent: Option<usize> = None;
301
302 for (i, line) in lines.iter().enumerate() {
303 if mkdocs_tabs::is_tab_marker(line) {
305 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
306 current_tab_indent = Some(tab_indent);
307 in_tab_context[i] = true;
308 continue;
309 }
310
311 if let Some(tab_indent) = current_tab_indent {
313 if mkdocs_tabs::is_tab_content(line, tab_indent) {
314 in_tab_context[i] = true;
315 } else if !line.trim().is_empty() && calculate_indentation_width_default(line) < 4 {
316 current_tab_indent = None;
318 } else {
319 in_tab_context[i] = true;
321 }
322 }
323 }
324
325 in_tab_context
326 }
327
328 fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
337 let mut in_admonition_context = vec![false; lines.len()];
338 let mut admonition_stack: Vec<usize> = Vec::new();
340
341 for (i, line) in lines.iter().enumerate() {
342 let line_indent = calculate_indentation_width_default(line);
343
344 if mkdocs_admonitions::is_admonition_start(line) {
346 let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
347
348 while let Some(&top_indent) = admonition_stack.last() {
350 if adm_indent <= top_indent {
352 admonition_stack.pop();
353 } else {
354 break;
355 }
356 }
357
358 admonition_stack.push(adm_indent);
360 in_admonition_context[i] = true;
361 continue;
362 }
363
364 if line.trim().is_empty() {
366 if !admonition_stack.is_empty() {
367 in_admonition_context[i] = true;
368 }
369 continue;
370 }
371
372 while let Some(&top_indent) = admonition_stack.last() {
375 if line_indent >= top_indent + 4 {
377 break;
379 } else {
380 admonition_stack.pop();
382 }
383 }
384
385 if !admonition_stack.is_empty() {
387 in_admonition_context[i] = true;
388 }
389 }
390
391 in_admonition_context
392 }
393
394 fn categorize_indented_blocks(
406 &self,
407 lines: &[&str],
408 is_mkdocs: bool,
409 in_list_context: &[bool],
410 in_tab_context: &[bool],
411 in_admonition_context: &[bool],
412 in_jsx_context: &[bool],
413 ) -> (Vec<bool>, Vec<bool>) {
414 let mut is_misplaced = vec![false; lines.len()];
415 let mut contains_fences = vec![false; lines.len()];
416
417 let ictx = IndentContext {
418 in_list_context,
419 in_tab_context,
420 in_admonition_context,
421 in_jsx_context,
422 };
423
424 let mut i = 0;
426 while i < lines.len() {
427 if !self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) {
429 i += 1;
430 continue;
431 }
432
433 let block_start = i;
435 let mut block_end = i;
436
437 while block_end < lines.len()
438 && self.is_indented_code_block_with_context(lines, block_end, is_mkdocs, &ictx)
439 {
440 block_end += 1;
441 }
442
443 if block_end > block_start {
445 let first_line = lines[block_start].trim_start();
446 let last_line = lines[block_end - 1].trim_start();
447
448 let is_backtick_fence = first_line.starts_with("```");
450 let is_tilde_fence = first_line.starts_with("~~~");
451
452 if is_backtick_fence || is_tilde_fence {
453 let fence_char = if is_backtick_fence { '`' } else { '~' };
454 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
455
456 let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
458 let after_closer = &last_line[closer_fence_len..];
459
460 if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
461 is_misplaced[block_start..block_end].fill(true);
463 } else {
464 contains_fences[block_start..block_end].fill(true);
466 }
467 } else {
468 let has_fence_markers = (block_start..block_end).any(|j| {
471 let trimmed = lines[j].trim_start();
472 trimmed.starts_with("```") || trimmed.starts_with("~~~")
473 });
474
475 if has_fence_markers {
476 contains_fences[block_start..block_end].fill(true);
477 }
478 }
479 }
480
481 i = block_end;
482 }
483
484 (is_misplaced, contains_fences)
485 }
486
487 fn check_unclosed_code_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
488 let mut warnings = Vec::new();
489 let lines = ctx.raw_lines();
490
491 let has_markdown_doc_block = ctx.code_block_details.iter().any(|d| {
493 if !d.is_fenced {
494 return false;
495 }
496 let lang = d.info_string.to_lowercase();
497 lang.starts_with("markdown") || lang.starts_with("md")
498 });
499
500 if has_markdown_doc_block {
503 return warnings;
504 }
505
506 for detail in &ctx.code_block_details {
507 if !detail.is_fenced {
508 continue;
509 }
510
511 if detail.end != ctx.content.len() {
513 continue;
514 }
515
516 let opening_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
518 Ok(idx) => idx,
519 Err(idx) => idx.saturating_sub(1),
520 };
521
522 let line = lines.get(opening_line_idx).unwrap_or(&"");
524 let trimmed = line.trim();
525 let fence_marker = if let Some(pos) = trimmed.find("```") {
526 let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
527 "`".repeat(count)
528 } else if let Some(pos) = trimmed.find("~~~") {
529 let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
530 "~".repeat(count)
531 } else {
532 "```".to_string()
533 };
534
535 let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
537 let last_trimmed = last_non_empty_line.trim();
538 let fence_char = fence_marker.chars().next().unwrap_or('`');
539
540 let has_closing_fence = if fence_char == '`' {
541 last_trimmed.starts_with("```") && {
542 let fence_len = last_trimmed.chars().take_while(|&c| c == '`').count();
543 last_trimmed[fence_len..].trim().is_empty()
544 }
545 } else {
546 last_trimmed.starts_with("~~~") && {
547 let fence_len = last_trimmed.chars().take_while(|&c| c == '~').count();
548 last_trimmed[fence_len..].trim().is_empty()
549 }
550 };
551
552 if !has_closing_fence {
553 if ctx
555 .lines
556 .get(opening_line_idx)
557 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
558 {
559 continue;
560 }
561
562 let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
563
564 warnings.push(LintWarning {
565 rule_name: Some(self.name().to_string()),
566 line: start_line,
567 column: start_col,
568 end_line,
569 end_column: end_col,
570 message: format!("Code block opened with '{fence_marker}' but never closed"),
571 severity: Severity::Warning,
572 fix: Some(Fix {
573 range: (ctx.content.len()..ctx.content.len()),
574 replacement: format!("\n{fence_marker}"),
575 }),
576 });
577 }
578 }
579
580 warnings
581 }
582
583 fn detect_style(&self, lines: &[&str], is_mkdocs: bool, ictx: &IndentContext) -> Option<CodeBlockStyle> {
584 if lines.is_empty() {
585 return None;
586 }
587
588 let mut fenced_count = 0;
589 let mut indented_count = 0;
590
591 let mut in_fenced = false;
593 let mut prev_was_indented = false;
594
595 for (i, line) in lines.iter().enumerate() {
596 if self.is_fenced_code_block_start(line) {
597 if !in_fenced {
598 fenced_count += 1;
600 in_fenced = true;
601 } else {
602 in_fenced = false;
604 }
605 } else if !in_fenced && self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx) {
606 if !prev_was_indented {
608 indented_count += 1;
609 }
610 prev_was_indented = true;
611 } else {
612 prev_was_indented = false;
613 }
614 }
615
616 if fenced_count == 0 && indented_count == 0 {
617 None
618 } else if fenced_count > 0 && indented_count == 0 {
619 Some(CodeBlockStyle::Fenced)
620 } else if fenced_count == 0 && indented_count > 0 {
621 Some(CodeBlockStyle::Indented)
622 } else if fenced_count >= indented_count {
623 Some(CodeBlockStyle::Fenced)
624 } else {
625 Some(CodeBlockStyle::Indented)
626 }
627 }
628}
629
630impl Rule for MD046CodeBlockStyle {
631 fn name(&self) -> &'static str {
632 "MD046"
633 }
634
635 fn description(&self) -> &'static str {
636 "Code blocks should use a consistent style"
637 }
638
639 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
640 if ctx.content.is_empty() {
642 return Ok(Vec::new());
643 }
644
645 if !ctx.content.contains("```")
647 && !ctx.content.contains("~~~")
648 && !ctx.content.contains(" ")
649 && !ctx.content.contains('\t')
650 {
651 return Ok(Vec::new());
652 }
653
654 let unclosed_warnings = self.check_unclosed_code_blocks(ctx);
656
657 if !unclosed_warnings.is_empty() {
659 return Ok(unclosed_warnings);
660 }
661
662 let lines = ctx.raw_lines();
664 let mut warnings = Vec::new();
665
666 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
667
668 let target_style = match self.config.style {
670 CodeBlockStyle::Consistent => {
671 let in_list_context = self.precompute_block_continuation_context(lines);
672 let in_jsx_context: Vec<bool> = (0..lines.len())
673 .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block))
674 .collect();
675 let in_tab_context = if is_mkdocs {
676 self.precompute_mkdocs_tab_context(lines)
677 } else {
678 vec![false; lines.len()]
679 };
680 let in_admonition_context = if is_mkdocs {
681 self.precompute_mkdocs_admonition_context(lines)
682 } else {
683 vec![false; lines.len()]
684 };
685 let ictx = IndentContext {
686 in_list_context: &in_list_context,
687 in_tab_context: &in_tab_context,
688 in_admonition_context: &in_admonition_context,
689 in_jsx_context: &in_jsx_context,
690 };
691 self.detect_style(lines, is_mkdocs, &ictx)
692 .unwrap_or(CodeBlockStyle::Fenced)
693 }
694 _ => self.config.style,
695 };
696
697 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
699
700 for detail in &ctx.code_block_details {
701 if detail.start >= ctx.content.len() || detail.end > ctx.content.len() {
702 continue;
703 }
704
705 let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
706 Ok(idx) => idx,
707 Err(idx) => idx.saturating_sub(1),
708 };
709
710 if detail.is_fenced {
711 if target_style == CodeBlockStyle::Indented {
712 let line = lines.get(start_line_idx).unwrap_or(&"");
713
714 if ctx
715 .lines
716 .get(start_line_idx)
717 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
718 {
719 continue;
720 }
721
722 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
723 warnings.push(LintWarning {
724 rule_name: Some(self.name().to_string()),
725 line: start_line,
726 column: start_col,
727 end_line,
728 end_column: end_col,
729 message: "Use indented code blocks".to_string(),
730 severity: Severity::Warning,
731 fix: None,
732 });
733 }
734 } else {
735 if target_style == CodeBlockStyle::Fenced && !reported_indented_lines.contains(&start_line_idx) {
737 let line = lines.get(start_line_idx).unwrap_or(&"");
738
739 if ctx.lines.get(start_line_idx).is_some_and(|info| {
741 info.in_html_comment
742 || info.in_mdx_comment
743 || info.in_html_block
744 || info.in_jsx_block
745 || info.in_mkdocstrings
746 || info.in_footnote_definition
747 || info.blockquote.is_some()
748 }) {
749 continue;
750 }
751
752 if is_mkdocs
754 && ctx
755 .lines
756 .get(start_line_idx)
757 .is_some_and(|info| info.in_admonition || info.in_content_tab)
758 {
759 continue;
760 }
761
762 reported_indented_lines.insert(start_line_idx);
763
764 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
765 warnings.push(LintWarning {
766 rule_name: Some(self.name().to_string()),
767 line: start_line,
768 column: start_col,
769 end_line,
770 end_column: end_col,
771 message: "Use fenced code blocks".to_string(),
772 severity: Severity::Warning,
773 fix: None,
774 });
775 }
776 }
777 }
778
779 warnings.sort_by_key(|w| (w.line, w.column));
781
782 Ok(warnings)
783 }
784
785 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
786 let content = ctx.content;
787 if content.is_empty() {
788 return Ok(String::new());
789 }
790
791 let lines = ctx.raw_lines();
792
793 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
795
796 let in_jsx_context: Vec<bool> = (0..lines.len())
798 .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block))
799 .collect();
800
801 let in_list_context = self.precompute_block_continuation_context(lines);
803 let in_tab_context = if is_mkdocs {
804 self.precompute_mkdocs_tab_context(lines)
805 } else {
806 vec![false; lines.len()]
807 };
808 let in_admonition_context = if is_mkdocs {
809 self.precompute_mkdocs_admonition_context(lines)
810 } else {
811 vec![false; lines.len()]
812 };
813
814 let target_style = match self.config.style {
815 CodeBlockStyle::Consistent => {
816 let ictx = IndentContext {
817 in_list_context: &in_list_context,
818 in_tab_context: &in_tab_context,
819 in_admonition_context: &in_admonition_context,
820 in_jsx_context: &in_jsx_context,
821 };
822 self.detect_style(lines, is_mkdocs, &ictx)
823 .unwrap_or(CodeBlockStyle::Fenced)
824 }
825 _ => self.config.style,
826 };
827
828 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(
832 lines,
833 is_mkdocs,
834 &in_list_context,
835 &in_tab_context,
836 &in_admonition_context,
837 &in_jsx_context,
838 );
839
840 let ictx = IndentContext {
841 in_list_context: &in_list_context,
842 in_tab_context: &in_tab_context,
843 in_admonition_context: &in_admonition_context,
844 in_jsx_context: &in_jsx_context,
845 };
846
847 let mut result = String::with_capacity(content.len());
848 let mut in_fenced_block = false;
849 let mut fenced_fence_opener: Option<(char, usize)> = None;
853 let mut in_indented_block = false;
854
855 let mut current_block_disabled = false;
857
858 for (i, line) in lines.iter().enumerate() {
859 let line_num = i + 1;
860 let trimmed = line.trim_start();
861
862 if !in_fenced_block
865 && Self::has_valid_fence_indent(line)
866 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
867 {
868 current_block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
870 in_fenced_block = true;
871 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
872 let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
873 fenced_fence_opener = Some((fence_char, opener_len));
874
875 if current_block_disabled {
876 result.push_str(line);
878 result.push('\n');
879 } else if target_style == CodeBlockStyle::Indented {
880 in_indented_block = true;
882 } else {
883 result.push_str(line);
885 result.push('\n');
886 }
887 } else if in_fenced_block && fenced_fence_opener.is_some() {
888 let (fence_char, opener_len) = fenced_fence_opener.unwrap();
889 let closer_len = trimmed.chars().take_while(|&c| c == fence_char).count();
892 let after_closer = &trimmed[closer_len..];
893 let is_closer = closer_len >= opener_len && after_closer.trim().is_empty() && closer_len > 0;
894 if is_closer {
895 in_fenced_block = false;
896 fenced_fence_opener = None;
897 in_indented_block = false;
898
899 if current_block_disabled {
900 result.push_str(line);
901 result.push('\n');
902 } else if target_style == CodeBlockStyle::Indented {
903 } else {
905 result.push_str(line);
907 result.push('\n');
908 }
909 current_block_disabled = false;
910 } else if current_block_disabled {
911 result.push_str(line);
913 result.push('\n');
914 } else if target_style == CodeBlockStyle::Indented {
915 result.push_str(" ");
919 result.push_str(line);
920 result.push('\n');
921 } else {
922 result.push_str(line);
924 result.push('\n');
925 }
926 } else if self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) {
927 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
931 result.push_str(line);
932 result.push('\n');
933 continue;
934 }
935
936 let prev_line_is_indented =
938 i > 0 && self.is_indented_code_block_with_context(lines, i - 1, is_mkdocs, &ictx);
939
940 if target_style == CodeBlockStyle::Fenced {
941 let trimmed_content = line.trim_start();
942
943 if misplaced_fence_lines[i] {
946 result.push_str(trimmed_content);
948 result.push('\n');
949 } else if unsafe_fence_lines[i] {
950 result.push_str(line);
953 result.push('\n');
954 } else if !prev_line_is_indented && !in_indented_block {
955 result.push_str("```\n");
957 result.push_str(trimmed_content);
958 result.push('\n');
959 in_indented_block = true;
960 } else {
961 result.push_str(trimmed_content);
963 result.push('\n');
964 }
965
966 let next_line_is_indented =
968 i < lines.len() - 1 && self.is_indented_code_block_with_context(lines, i + 1, is_mkdocs, &ictx);
969 if !next_line_is_indented
971 && in_indented_block
972 && !misplaced_fence_lines[i]
973 && !unsafe_fence_lines[i]
974 {
975 result.push_str("```\n");
976 in_indented_block = false;
977 }
978 } else {
979 result.push_str(line);
981 result.push('\n');
982 }
983 } else {
984 if in_indented_block && target_style == CodeBlockStyle::Fenced {
986 result.push_str("```\n");
987 in_indented_block = false;
988 }
989
990 result.push_str(line);
991 result.push('\n');
992 }
993 }
994
995 if in_indented_block && target_style == CodeBlockStyle::Fenced {
997 result.push_str("```\n");
998 }
999
1000 if let Some((fence_char, opener_len)) = fenced_fence_opener
1006 && in_fenced_block
1007 {
1008 let has_unclosed_violation = !self.check_unclosed_code_blocks(ctx).is_empty();
1009 if has_unclosed_violation {
1010 let closer: String = std::iter::repeat_n(fence_char, opener_len).collect();
1011 result.push_str(&closer);
1012 result.push('\n');
1013 }
1014 }
1015
1016 if !content.ends_with('\n') && result.ends_with('\n') {
1018 result.pop();
1019 }
1020
1021 Ok(result)
1022 }
1023
1024 fn category(&self) -> RuleCategory {
1026 RuleCategory::CodeBlock
1027 }
1028
1029 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1031 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1034 }
1035
1036 fn as_any(&self) -> &dyn std::any::Any {
1037 self
1038 }
1039
1040 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1041 let json_value = serde_json::to_value(&self.config).ok()?;
1042 Some((
1043 self.name().to_string(),
1044 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1045 ))
1046 }
1047
1048 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1049 where
1050 Self: Sized,
1051 {
1052 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1053 Box::new(Self::from_config_struct(rule_config))
1054 }
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060 use crate::lint_context::LintContext;
1061
1062 fn detect_style_from_content(
1064 rule: &MD046CodeBlockStyle,
1065 content: &str,
1066 is_mkdocs: bool,
1067 in_jsx_context: &[bool],
1068 ) -> Option<CodeBlockStyle> {
1069 let lines: Vec<&str> = content.lines().collect();
1070 let in_list_context = rule.precompute_block_continuation_context(&lines);
1071 let in_tab_context = if is_mkdocs {
1072 rule.precompute_mkdocs_tab_context(&lines)
1073 } else {
1074 vec![false; lines.len()]
1075 };
1076 let in_admonition_context = if is_mkdocs {
1077 rule.precompute_mkdocs_admonition_context(&lines)
1078 } else {
1079 vec![false; lines.len()]
1080 };
1081 let ictx = IndentContext {
1082 in_list_context: &in_list_context,
1083 in_tab_context: &in_tab_context,
1084 in_admonition_context: &in_admonition_context,
1085 in_jsx_context,
1086 };
1087 rule.detect_style(&lines, is_mkdocs, &ictx)
1088 }
1089
1090 #[test]
1091 fn test_fenced_code_block_detection() {
1092 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1093 assert!(rule.is_fenced_code_block_start("```"));
1094 assert!(rule.is_fenced_code_block_start("```rust"));
1095 assert!(rule.is_fenced_code_block_start("~~~"));
1096 assert!(rule.is_fenced_code_block_start("~~~python"));
1097 assert!(rule.is_fenced_code_block_start(" ```"));
1098 assert!(!rule.is_fenced_code_block_start("``"));
1099 assert!(!rule.is_fenced_code_block_start("~~"));
1100 assert!(!rule.is_fenced_code_block_start("Regular text"));
1101 }
1102
1103 #[test]
1104 fn test_consistent_style_with_fenced_blocks() {
1105 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1106 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108 let result = rule.check(&ctx).unwrap();
1109
1110 assert_eq!(result.len(), 0);
1112 }
1113
1114 #[test]
1115 fn test_consistent_style_with_indented_blocks() {
1116 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1117 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119 let result = rule.check(&ctx).unwrap();
1120
1121 assert_eq!(result.len(), 0);
1123 }
1124
1125 #[test]
1126 fn test_consistent_style_mixed() {
1127 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1128 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130 let result = rule.check(&ctx).unwrap();
1131
1132 assert!(!result.is_empty());
1134 }
1135
1136 #[test]
1137 fn test_fenced_style_with_indented_blocks() {
1138 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1139 let content = "Text\n\n indented code\n more code\n\nMore text";
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.check(&ctx).unwrap();
1142
1143 assert!(!result.is_empty());
1145 assert!(result[0].message.contains("Use fenced code blocks"));
1146 }
1147
1148 #[test]
1149 fn test_fenced_style_with_tab_indented_blocks() {
1150 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1151 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153 let result = rule.check(&ctx).unwrap();
1154
1155 assert!(!result.is_empty());
1157 assert!(result[0].message.contains("Use fenced code blocks"));
1158 }
1159
1160 #[test]
1161 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1162 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1163 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1166 let result = rule.check(&ctx).unwrap();
1167
1168 assert!(
1170 !result.is_empty(),
1171 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1172 );
1173 assert!(result[0].message.contains("Use fenced code blocks"));
1174 }
1175
1176 #[test]
1177 fn test_fenced_style_with_one_space_tab_indent() {
1178 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1179 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1182 let result = rule.check(&ctx).unwrap();
1183
1184 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1185 assert!(result[0].message.contains("Use fenced code blocks"));
1186 }
1187
1188 #[test]
1189 fn test_indented_style_with_fenced_blocks() {
1190 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1191 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193 let result = rule.check(&ctx).unwrap();
1194
1195 assert!(!result.is_empty());
1197 assert!(result[0].message.contains("Use indented code blocks"));
1198 }
1199
1200 #[test]
1201 fn test_unclosed_code_block() {
1202 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1203 let content = "```\ncode without closing fence";
1204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1205 let result = rule.check(&ctx).unwrap();
1206
1207 assert_eq!(result.len(), 1);
1208 assert!(result[0].message.contains("never closed"));
1209 }
1210
1211 #[test]
1212 fn test_nested_code_blocks() {
1213 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1214 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1216 let result = rule.check(&ctx).unwrap();
1217
1218 assert_eq!(result.len(), 0);
1220 }
1221
1222 #[test]
1223 fn test_fix_indented_to_fenced() {
1224 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1225 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1227 let fixed = rule.fix(&ctx).unwrap();
1228
1229 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1230 }
1231
1232 #[test]
1233 fn test_fix_fenced_to_indented() {
1234 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1235 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237 let fixed = rule.fix(&ctx).unwrap();
1238
1239 assert!(fixed.contains(" code line 1\n code line 2"));
1240 assert!(!fixed.contains("```"));
1241 }
1242
1243 #[test]
1244 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1245 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1248 let content = r#"# Test
1249
1250```html
1251<!doctype html>
1252<html>
1253 <head>
1254 <title>Test</title>
1255 </head>
1256</html>
1257```
1258"#;
1259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1260 let fixed = rule.fix(&ctx).unwrap();
1261
1262 assert!(
1265 fixed.contains(" <head>"),
1266 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1267 );
1268 assert!(
1269 fixed.contains(" <title>"),
1270 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1271 );
1272 assert!(!fixed.contains("```"), "Fenced markers should be removed");
1273 }
1274
1275 #[test]
1276 fn test_fix_fenced_to_indented_preserves_python_indentation() {
1277 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1279 let content = r#"# Python Example
1280
1281```python
1282def greet(name):
1283 if name:
1284 print(f"Hello, {name}!")
1285 else:
1286 print("Hello, World!")
1287```
1288"#;
1289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1290 let fixed = rule.fix(&ctx).unwrap();
1291
1292 assert!(
1294 fixed.contains(" def greet(name):"),
1295 "Function def should have 4 spaces (code block indent)"
1296 );
1297 assert!(
1298 fixed.contains(" if name:"),
1299 "if statement should have 8 spaces (4 code + 4 Python)"
1300 );
1301 assert!(
1302 fixed.contains(" print"),
1303 "print should have 12 spaces (4 code + 8 Python)"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1309 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1311 let content = r#"# Config
1312
1313```yaml
1314server:
1315 host: localhost
1316 port: 8080
1317 ssl:
1318 enabled: true
1319 cert: /path/to/cert
1320```
1321"#;
1322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323 let fixed = rule.fix(&ctx).unwrap();
1324
1325 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
1326 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
1327 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
1328 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
1329 }
1330
1331 #[test]
1332 fn test_fix_fenced_to_indented_preserves_empty_lines() {
1333 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1335 let content = "```\nline1\n\nline2\n```\n";
1336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1337 let fixed = rule.fix(&ctx).unwrap();
1338
1339 assert!(fixed.contains(" line1"), "line1 should be indented");
1341 assert!(fixed.contains(" line2"), "line2 should be indented");
1342 }
1344
1345 #[test]
1346 fn test_fix_fenced_to_indented_multiple_blocks() {
1347 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1349 let content = r#"# Doc
1350
1351```python
1352def foo():
1353 pass
1354```
1355
1356Text between.
1357
1358```yaml
1359key:
1360 value: 1
1361```
1362"#;
1363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364 let fixed = rule.fix(&ctx).unwrap();
1365
1366 assert!(fixed.contains(" def foo():"), "Python def should be indented");
1367 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
1368 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
1369 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
1370 assert!(!fixed.contains("```"), "No fence markers should remain");
1371 }
1372
1373 #[test]
1374 fn test_fix_unclosed_block() {
1375 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1376 let content = "```\ncode without closing";
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378 let fixed = rule.fix(&ctx).unwrap();
1379
1380 assert!(fixed.ends_with("```"));
1382 }
1383
1384 #[test]
1385 fn test_code_block_in_list() {
1386 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1387 let content = "- List item\n code in list\n more code\n- Next item";
1388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1389 let result = rule.check(&ctx).unwrap();
1390
1391 assert_eq!(result.len(), 0);
1393 }
1394
1395 #[test]
1396 fn test_detect_style_fenced() {
1397 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1398 let content = "```\ncode\n```";
1399 let style = detect_style_from_content(&rule, content, false, &[]);
1400
1401 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1402 }
1403
1404 #[test]
1405 fn test_detect_style_indented() {
1406 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1407 let content = "Text\n\n code\n\nMore";
1408 let style = detect_style_from_content(&rule, content, false, &[]);
1409
1410 assert_eq!(style, Some(CodeBlockStyle::Indented));
1411 }
1412
1413 #[test]
1414 fn test_detect_style_none() {
1415 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1416 let content = "No code blocks here";
1417 let style = detect_style_from_content(&rule, content, false, &[]);
1418
1419 assert_eq!(style, None);
1420 }
1421
1422 #[test]
1423 fn test_tilde_fence() {
1424 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1425 let content = "~~~\ncode\n~~~";
1426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1427 let result = rule.check(&ctx).unwrap();
1428
1429 assert_eq!(result.len(), 0);
1431 }
1432
1433 #[test]
1434 fn test_language_specification() {
1435 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1436 let content = "```rust\nfn main() {}\n```";
1437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1438 let result = rule.check(&ctx).unwrap();
1439
1440 assert_eq!(result.len(), 0);
1441 }
1442
1443 #[test]
1444 fn test_empty_content() {
1445 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1446 let content = "";
1447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448 let result = rule.check(&ctx).unwrap();
1449
1450 assert_eq!(result.len(), 0);
1451 }
1452
1453 #[test]
1454 fn test_default_config() {
1455 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1456 let (name, _config) = rule.default_config_section().unwrap();
1457 assert_eq!(name, "MD046");
1458 }
1459
1460 #[test]
1461 fn test_markdown_documentation_block() {
1462 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1463 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1465 let result = rule.check(&ctx).unwrap();
1466
1467 assert_eq!(result.len(), 0);
1469 }
1470
1471 #[test]
1472 fn test_preserve_trailing_newline() {
1473 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1474 let content = "```\ncode\n```\n";
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let fixed = rule.fix(&ctx).unwrap();
1477
1478 assert_eq!(fixed, content);
1479 }
1480
1481 #[test]
1482 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1483 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1484 let content = r#"# Document
1485
1486=== "Python"
1487
1488 This is tab content
1489 Not an indented code block
1490
1491 ```python
1492 def hello():
1493 print("Hello")
1494 ```
1495
1496=== "JavaScript"
1497
1498 More tab content here
1499 Also not an indented code block"#;
1500
1501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1502 let result = rule.check(&ctx).unwrap();
1503
1504 assert_eq!(result.len(), 0);
1506 }
1507
1508 #[test]
1509 fn test_mkdocs_tabs_with_actual_indented_code() {
1510 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1511 let content = r#"# Document
1512
1513=== "Tab 1"
1514
1515 This is tab content
1516
1517Regular text
1518
1519 This is an actual indented code block
1520 Should be flagged"#;
1521
1522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1523 let result = rule.check(&ctx).unwrap();
1524
1525 assert_eq!(result.len(), 1);
1527 assert!(result[0].message.contains("Use fenced code blocks"));
1528 }
1529
1530 #[test]
1531 fn test_mkdocs_tabs_detect_style() {
1532 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1533 let content = r#"=== "Tab 1"
1534
1535 Content in tab
1536 More content
1537
1538=== "Tab 2"
1539
1540 Content in second tab"#;
1541
1542 let style = detect_style_from_content(&rule, content, true, &[]);
1544 assert_eq!(style, None); let style = detect_style_from_content(&rule, content, false, &[]);
1548 assert_eq!(style, Some(CodeBlockStyle::Indented));
1549 }
1550
1551 #[test]
1552 fn test_mkdocs_nested_tabs() {
1553 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1554 let content = r#"# Document
1555
1556=== "Outer Tab"
1557
1558 Some content
1559
1560 === "Nested Tab"
1561
1562 Nested tab content
1563 Should not be flagged"#;
1564
1565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1566 let result = rule.check(&ctx).unwrap();
1567
1568 assert_eq!(result.len(), 0);
1570 }
1571
1572 #[test]
1573 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1574 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1577 let content = r#"# Document
1578
1579!!! note
1580 This is normal admonition content, not a code block.
1581 It spans multiple lines.
1582
1583??? warning "Collapsible Warning"
1584 This is also admonition content.
1585
1586???+ tip "Expanded Tip"
1587 And this one too.
1588
1589Regular text outside admonitions."#;
1590
1591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1592 let result = rule.check(&ctx).unwrap();
1593
1594 assert_eq!(
1596 result.len(),
1597 0,
1598 "Admonition content in MkDocs mode should not trigger MD046"
1599 );
1600 }
1601
1602 #[test]
1603 fn test_mkdocs_admonition_with_actual_indented_code() {
1604 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1606 let content = r#"# Document
1607
1608!!! note
1609 This is admonition content.
1610
1611Regular text ends the admonition.
1612
1613 This is actual indented code (should be flagged)"#;
1614
1615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1616 let result = rule.check(&ctx).unwrap();
1617
1618 assert_eq!(result.len(), 1);
1620 assert!(result[0].message.contains("Use fenced code blocks"));
1621 }
1622
1623 #[test]
1624 fn test_admonition_in_standard_mode_flagged() {
1625 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1629 let content = r#"# Document
1630
1631!!! note
1632
1633 This looks like code in standard mode.
1634
1635Regular text."#;
1636
1637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640
1641 assert_eq!(
1643 result.len(),
1644 1,
1645 "Admonition content in Standard mode should be flagged as indented code"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_mkdocs_admonition_with_fenced_code_inside() {
1651 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1653 let content = r#"# Document
1654
1655!!! note "Code Example"
1656 Here's some code:
1657
1658 ```python
1659 def hello():
1660 print("world")
1661 ```
1662
1663 More text after code.
1664
1665Regular text."#;
1666
1667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1668 let result = rule.check(&ctx).unwrap();
1669
1670 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1672 }
1673
1674 #[test]
1675 fn test_mkdocs_nested_admonitions() {
1676 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1678 let content = r#"# Document
1679
1680!!! note "Outer"
1681 Outer content.
1682
1683 !!! warning "Inner"
1684 Inner content.
1685 More inner content.
1686
1687 Back to outer.
1688
1689Regular text."#;
1690
1691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1692 let result = rule.check(&ctx).unwrap();
1693
1694 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1696 }
1697
1698 #[test]
1699 fn test_mkdocs_admonition_fix_does_not_wrap() {
1700 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1702 let content = r#"!!! note
1703 Content that should stay as admonition content.
1704 Not be wrapped in code fences.
1705"#;
1706
1707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1708 let fixed = rule.fix(&ctx).unwrap();
1709
1710 assert!(
1712 !fixed.contains("```\n Content"),
1713 "Admonition content should not be wrapped in fences"
1714 );
1715 assert_eq!(fixed, content, "Content should remain unchanged");
1716 }
1717
1718 #[test]
1719 fn test_mkdocs_empty_admonition() {
1720 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1722 let content = r#"!!! note
1723
1724Regular paragraph after empty admonition.
1725
1726 This IS an indented code block (after blank + non-indented line)."#;
1727
1728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1729 let result = rule.check(&ctx).unwrap();
1730
1731 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1733 }
1734
1735 #[test]
1736 fn test_mkdocs_indented_admonition() {
1737 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1739 let content = r#"- List item
1740
1741 !!! note
1742 Indented admonition content.
1743 More content.
1744
1745- Next item"#;
1746
1747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1748 let result = rule.check(&ctx).unwrap();
1749
1750 assert_eq!(
1752 result.len(),
1753 0,
1754 "Indented admonitions (e.g., in lists) should not be flagged"
1755 );
1756 }
1757
1758 #[test]
1759 fn test_footnote_indented_paragraphs_not_flagged() {
1760 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1761 let content = r#"# Test Document with Footnotes
1762
1763This is some text with a footnote[^1].
1764
1765Here's some code:
1766
1767```bash
1768echo "fenced code block"
1769```
1770
1771More text with another footnote[^2].
1772
1773[^1]: Really interesting footnote text.
1774
1775 Even more interesting second paragraph.
1776
1777[^2]: Another footnote.
1778
1779 With a second paragraph too.
1780
1781 And even a third paragraph!"#;
1782
1783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1784 let result = rule.check(&ctx).unwrap();
1785
1786 assert_eq!(result.len(), 0);
1788 }
1789
1790 #[test]
1791 fn test_footnote_definition_detection() {
1792 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1793
1794 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1797 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1798 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1799 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1800 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1801 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1802 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1803 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1804 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1805
1806 assert!(!rule.is_footnote_definition("[^]: No label"));
1808 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1809 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1810 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1811
1812 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1814 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1815 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1816 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1819
1820 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1822 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1823 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1824 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1825 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1826
1827 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1830 }
1831
1832 #[test]
1833 fn test_footnote_with_blank_lines() {
1834 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1838 let content = r#"# Document
1839
1840Text with footnote[^1].
1841
1842[^1]: First paragraph.
1843
1844 Second paragraph after blank line.
1845
1846 Third paragraph after another blank line.
1847
1848Regular text at column 0 ends the footnote."#;
1849
1850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1851 let result = rule.check(&ctx).unwrap();
1852
1853 assert_eq!(
1855 result.len(),
1856 0,
1857 "Indented content within footnotes should not trigger MD046"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_footnote_multiple_consecutive_blank_lines() {
1863 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1866 let content = r#"Text[^1].
1867
1868[^1]: First paragraph.
1869
1870
1871
1872 Content after three blank lines (still part of footnote).
1873
1874Not indented, so footnote ends here."#;
1875
1876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1877 let result = rule.check(&ctx).unwrap();
1878
1879 assert_eq!(
1881 result.len(),
1882 0,
1883 "Multiple blank lines shouldn't break footnote continuation"
1884 );
1885 }
1886
1887 #[test]
1888 fn test_footnote_terminated_by_non_indented_content() {
1889 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1892 let content = r#"[^1]: Footnote content.
1893
1894 More indented content in footnote.
1895
1896This paragraph is not indented, so footnote ends.
1897
1898 This should be flagged as indented code block."#;
1899
1900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1901 let result = rule.check(&ctx).unwrap();
1902
1903 assert_eq!(
1905 result.len(),
1906 1,
1907 "Indented code after footnote termination should be flagged"
1908 );
1909 assert!(
1910 result[0].message.contains("Use fenced code blocks"),
1911 "Expected MD046 warning for indented code block"
1912 );
1913 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1914 }
1915
1916 #[test]
1917 fn test_footnote_terminated_by_structural_elements() {
1918 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1920 let content = r#"[^1]: Footnote content.
1921
1922 More content.
1923
1924## Heading terminates footnote
1925
1926 This indented content should be flagged.
1927
1928---
1929
1930 This should also be flagged (after horizontal rule)."#;
1931
1932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1933 let result = rule.check(&ctx).unwrap();
1934
1935 assert_eq!(
1937 result.len(),
1938 2,
1939 "Both indented blocks after termination should be flagged"
1940 );
1941 }
1942
1943 #[test]
1944 fn test_footnote_with_code_block_inside() {
1945 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1948 let content = r#"Text[^1].
1949
1950[^1]: Footnote with code:
1951
1952 ```python
1953 def hello():
1954 print("world")
1955 ```
1956
1957 More footnote text after code."#;
1958
1959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1960 let result = rule.check(&ctx).unwrap();
1961
1962 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1964 }
1965
1966 #[test]
1967 fn test_footnote_with_8_space_indented_code() {
1968 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1971 let content = r#"Text[^1].
1972
1973[^1]: Footnote with nested code.
1974
1975 code block
1976 more code"#;
1977
1978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1979 let result = rule.check(&ctx).unwrap();
1980
1981 assert_eq!(
1983 result.len(),
1984 0,
1985 "8-space indented code within footnotes represents nested code blocks"
1986 );
1987 }
1988
1989 #[test]
1990 fn test_multiple_footnotes() {
1991 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1994 let content = r#"Text[^1] and more[^2].
1995
1996[^1]: First footnote.
1997
1998 Continuation of first.
1999
2000[^2]: Second footnote starts here, ending the first.
2001
2002 Continuation of second."#;
2003
2004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2005 let result = rule.check(&ctx).unwrap();
2006
2007 assert_eq!(
2009 result.len(),
2010 0,
2011 "Multiple footnotes should each maintain their continuation context"
2012 );
2013 }
2014
2015 #[test]
2016 fn test_list_item_ends_footnote_context() {
2017 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2019 let content = r#"[^1]: Footnote.
2020
2021 Content in footnote.
2022
2023- List item starts here (ends footnote context).
2024
2025 This indented content is part of the list, not the footnote."#;
2026
2027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2028 let result = rule.check(&ctx).unwrap();
2029
2030 assert_eq!(
2032 result.len(),
2033 0,
2034 "List items should end footnote context and start their own"
2035 );
2036 }
2037
2038 #[test]
2039 fn test_footnote_vs_actual_indented_code() {
2040 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2043 let content = r#"# Heading
2044
2045Text with footnote[^1].
2046
2047[^1]: Footnote content.
2048
2049 Part of footnote (should not be flagged).
2050
2051Regular paragraph ends footnote context.
2052
2053 This is actual indented code (MUST be flagged)
2054 Should be detected as code block"#;
2055
2056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2057 let result = rule.check(&ctx).unwrap();
2058
2059 assert_eq!(
2061 result.len(),
2062 1,
2063 "Must still detect indented code blocks outside footnotes"
2064 );
2065 assert!(
2066 result[0].message.contains("Use fenced code blocks"),
2067 "Expected MD046 warning for indented code"
2068 );
2069 assert!(
2070 result[0].line >= 11,
2071 "Warning should be on the actual indented code line"
2072 );
2073 }
2074
2075 #[test]
2076 fn test_spec_compliant_label_characters() {
2077 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2080
2081 assert!(rule.is_footnote_definition("[^test]: text"));
2083 assert!(rule.is_footnote_definition("[^TEST]: text"));
2084 assert!(rule.is_footnote_definition("[^test-name]: text"));
2085 assert!(rule.is_footnote_definition("[^test_name]: text"));
2086 assert!(rule.is_footnote_definition("[^test123]: text"));
2087 assert!(rule.is_footnote_definition("[^123]: text"));
2088 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2089
2090 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")); }
2098
2099 #[test]
2100 fn test_code_block_inside_html_comment() {
2101 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2104 let content = r#"# Document
2105
2106Some text.
2107
2108<!--
2109Example code block in comment:
2110
2111```typescript
2112console.log("Hello");
2113```
2114
2115More comment text.
2116-->
2117
2118More content."#;
2119
2120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2121 let result = rule.check(&ctx).unwrap();
2122
2123 assert_eq!(
2124 result.len(),
2125 0,
2126 "Code blocks inside HTML comments should not be flagged as unclosed"
2127 );
2128 }
2129
2130 #[test]
2131 fn test_unclosed_fence_inside_html_comment() {
2132 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2134 let content = r#"# Document
2135
2136<!--
2137Example with intentionally unclosed fence:
2138
2139```
2140code without closing
2141-->
2142
2143More content."#;
2144
2145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2146 let result = rule.check(&ctx).unwrap();
2147
2148 assert_eq!(
2149 result.len(),
2150 0,
2151 "Unclosed fences inside HTML comments should be ignored"
2152 );
2153 }
2154
2155 #[test]
2156 fn test_multiline_html_comment_with_indented_code() {
2157 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2159 let content = r#"# Document
2160
2161<!--
2162Example:
2163
2164 indented code
2165 more code
2166
2167End of comment.
2168-->
2169
2170Regular text."#;
2171
2172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2173 let result = rule.check(&ctx).unwrap();
2174
2175 assert_eq!(
2176 result.len(),
2177 0,
2178 "Indented code inside HTML comments should not be flagged"
2179 );
2180 }
2181
2182 #[test]
2183 fn test_code_block_after_html_comment() {
2184 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2186 let content = r#"# Document
2187
2188<!-- comment -->
2189
2190Text before.
2191
2192 indented code should be flagged
2193
2194More text."#;
2195
2196 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2197 let result = rule.check(&ctx).unwrap();
2198
2199 assert_eq!(
2200 result.len(),
2201 1,
2202 "Code blocks after HTML comments should still be detected"
2203 );
2204 assert!(result[0].message.contains("Use fenced code blocks"));
2205 }
2206
2207 #[test]
2208 fn test_four_space_indented_fence_is_not_valid_fence() {
2209 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2212
2213 assert!(rule.is_fenced_code_block_start("```"));
2215 assert!(rule.is_fenced_code_block_start(" ```"));
2216 assert!(rule.is_fenced_code_block_start(" ```"));
2217 assert!(rule.is_fenced_code_block_start(" ```"));
2218
2219 assert!(!rule.is_fenced_code_block_start(" ```"));
2221 assert!(!rule.is_fenced_code_block_start(" ```"));
2222 assert!(!rule.is_fenced_code_block_start(" ```"));
2223
2224 assert!(!rule.is_fenced_code_block_start("\t```"));
2226 }
2227
2228 #[test]
2229 fn test_issue_237_indented_fenced_block_detected_as_indented() {
2230 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2236
2237 let content = r#"## Test
2239
2240 ```js
2241 var foo = "hello";
2242 ```
2243"#;
2244
2245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2246 let result = rule.check(&ctx).unwrap();
2247
2248 assert_eq!(
2250 result.len(),
2251 1,
2252 "4-space indented fence should be detected as indented code block"
2253 );
2254 assert!(
2255 result[0].message.contains("Use fenced code blocks"),
2256 "Expected 'Use fenced code blocks' message"
2257 );
2258 }
2259
2260 #[test]
2261 fn test_issue_276_indented_code_in_list() {
2262 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2265
2266 let content = r#"1. First item
22672. Second item with code:
2268
2269 # This is a code block in a list
2270 print("Hello, world!")
2271
22724. Third item"#;
2273
2274 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2275 let result = rule.check(&ctx).unwrap();
2276
2277 assert!(
2279 !result.is_empty(),
2280 "Indented code block inside list should be flagged when style=fenced"
2281 );
2282 assert!(
2283 result[0].message.contains("Use fenced code blocks"),
2284 "Expected 'Use fenced code blocks' message"
2285 );
2286 }
2287
2288 #[test]
2289 fn test_three_space_indented_fence_is_valid() {
2290 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2292
2293 let content = r#"## Test
2294
2295 ```js
2296 var foo = "hello";
2297 ```
2298"#;
2299
2300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2301 let result = rule.check(&ctx).unwrap();
2302
2303 assert_eq!(
2305 result.len(),
2306 0,
2307 "3-space indented fence should be recognized as valid fenced code block"
2308 );
2309 }
2310
2311 #[test]
2312 fn test_indented_style_with_deeply_indented_fenced() {
2313 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2316
2317 let content = r#"Text
2318
2319 ```js
2320 var foo = "hello";
2321 ```
2322
2323More text
2324"#;
2325
2326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2327 let result = rule.check(&ctx).unwrap();
2328
2329 assert_eq!(
2332 result.len(),
2333 0,
2334 "4-space indented content should be valid when style=indented"
2335 );
2336 }
2337
2338 #[test]
2339 fn test_fix_misplaced_fenced_block() {
2340 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2343
2344 let content = r#"## Test
2345
2346 ```js
2347 var foo = "hello";
2348 ```
2349"#;
2350
2351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2352 let fixed = rule.fix(&ctx).unwrap();
2353
2354 let expected = r#"## Test
2356
2357```js
2358var foo = "hello";
2359```
2360"#;
2361
2362 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2363 }
2364
2365 #[test]
2366 fn test_fix_regular_indented_block() {
2367 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2370
2371 let content = r#"Text
2372
2373 var foo = "hello";
2374 console.log(foo);
2375
2376More text
2377"#;
2378
2379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2380 let fixed = rule.fix(&ctx).unwrap();
2381
2382 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2384 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2385 }
2386
2387 #[test]
2388 fn test_fix_indented_block_with_fence_like_content() {
2389 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2393
2394 let content = r#"Text
2395
2396 some code
2397 ```not a fence opener
2398 more code
2399"#;
2400
2401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2402 let fixed = rule.fix(&ctx).unwrap();
2403
2404 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
2406 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2407 }
2408
2409 #[test]
2410 fn test_fix_mixed_indented_and_misplaced_blocks() {
2411 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2413
2414 let content = r#"Text
2415
2416 regular indented code
2417
2418More text
2419
2420 ```python
2421 print("hello")
2422 ```
2423"#;
2424
2425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2426 let fixed = rule.fix(&ctx).unwrap();
2427
2428 assert!(
2430 fixed.contains("```\nregular indented code\n```"),
2431 "First block should be wrapped in fences"
2432 );
2433
2434 assert!(
2436 fixed.contains("\n```python\nprint(\"hello\")\n```"),
2437 "Second block should be dedented, not double-wrapped"
2438 );
2439 assert!(
2441 !fixed.contains("```\n```python"),
2442 "Should not have nested fence openers"
2443 );
2444 }
2445}