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(
488 &self,
489 ctx: &crate::lint_context::LintContext,
490 ) -> Result<Vec<LintWarning>, LintError> {
491 let mut warnings = Vec::new();
492 let lines = ctx.raw_lines();
493
494 let has_markdown_doc_block = ctx.code_block_details.iter().any(|d| {
496 if !d.is_fenced {
497 return false;
498 }
499 let lang = d.info_string.to_lowercase();
500 lang.starts_with("markdown") || lang.starts_with("md")
501 });
502
503 if has_markdown_doc_block {
506 return Ok(warnings);
507 }
508
509 for detail in &ctx.code_block_details {
510 if !detail.is_fenced {
511 continue;
512 }
513
514 if detail.end != ctx.content.len() {
516 continue;
517 }
518
519 let opening_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
521 Ok(idx) => idx,
522 Err(idx) => idx.saturating_sub(1),
523 };
524
525 let line = lines.get(opening_line_idx).unwrap_or(&"");
527 let trimmed = line.trim();
528 let fence_marker = if let Some(pos) = trimmed.find("```") {
529 let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
530 "`".repeat(count)
531 } else if let Some(pos) = trimmed.find("~~~") {
532 let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
533 "~".repeat(count)
534 } else {
535 "```".to_string()
536 };
537
538 let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
540 let last_trimmed = last_non_empty_line.trim();
541 let fence_char = fence_marker.chars().next().unwrap_or('`');
542
543 let has_closing_fence = if fence_char == '`' {
544 last_trimmed.starts_with("```") && {
545 let fence_len = last_trimmed.chars().take_while(|&c| c == '`').count();
546 last_trimmed[fence_len..].trim().is_empty()
547 }
548 } else {
549 last_trimmed.starts_with("~~~") && {
550 let fence_len = last_trimmed.chars().take_while(|&c| c == '~').count();
551 last_trimmed[fence_len..].trim().is_empty()
552 }
553 };
554
555 if !has_closing_fence {
556 if ctx
558 .lines
559 .get(opening_line_idx)
560 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
561 {
562 continue;
563 }
564
565 let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
566
567 warnings.push(LintWarning {
568 rule_name: Some(self.name().to_string()),
569 line: start_line,
570 column: start_col,
571 end_line,
572 end_column: end_col,
573 message: format!("Code block opened with '{fence_marker}' but never closed"),
574 severity: Severity::Warning,
575 fix: Some(Fix {
576 range: (ctx.content.len()..ctx.content.len()),
577 replacement: format!("\n{fence_marker}"),
578 }),
579 });
580 }
581 }
582
583 Ok(warnings)
584 }
585
586 fn detect_style(&self, lines: &[&str], is_mkdocs: bool, ictx: &IndentContext) -> Option<CodeBlockStyle> {
587 if lines.is_empty() {
588 return None;
589 }
590
591 let mut fenced_count = 0;
592 let mut indented_count = 0;
593
594 let mut in_fenced = false;
596 let mut prev_was_indented = false;
597
598 for (i, line) in lines.iter().enumerate() {
599 if self.is_fenced_code_block_start(line) {
600 if !in_fenced {
601 fenced_count += 1;
603 in_fenced = true;
604 } else {
605 in_fenced = false;
607 }
608 } else if !in_fenced && self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx) {
609 if !prev_was_indented {
611 indented_count += 1;
612 }
613 prev_was_indented = true;
614 } else {
615 prev_was_indented = false;
616 }
617 }
618
619 if fenced_count == 0 && indented_count == 0 {
620 None
621 } else if fenced_count > 0 && indented_count == 0 {
622 Some(CodeBlockStyle::Fenced)
623 } else if fenced_count == 0 && indented_count > 0 {
624 Some(CodeBlockStyle::Indented)
625 } else if fenced_count >= indented_count {
626 Some(CodeBlockStyle::Fenced)
627 } else {
628 Some(CodeBlockStyle::Indented)
629 }
630 }
631}
632
633impl Rule for MD046CodeBlockStyle {
634 fn name(&self) -> &'static str {
635 "MD046"
636 }
637
638 fn description(&self) -> &'static str {
639 "Code blocks should use a consistent style"
640 }
641
642 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
643 if ctx.content.is_empty() {
645 return Ok(Vec::new());
646 }
647
648 if !ctx.content.contains("```")
650 && !ctx.content.contains("~~~")
651 && !ctx.content.contains(" ")
652 && !ctx.content.contains('\t')
653 {
654 return Ok(Vec::new());
655 }
656
657 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
659
660 if !unclosed_warnings.is_empty() {
662 return Ok(unclosed_warnings);
663 }
664
665 let lines = ctx.raw_lines();
667 let mut warnings = Vec::new();
668
669 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
670
671 let target_style = match self.config.style {
673 CodeBlockStyle::Consistent => {
674 let in_list_context = self.precompute_block_continuation_context(lines);
675 let in_jsx_context: Vec<bool> = (0..lines.len())
676 .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block))
677 .collect();
678 let in_tab_context = if is_mkdocs {
679 self.precompute_mkdocs_tab_context(lines)
680 } else {
681 vec![false; lines.len()]
682 };
683 let in_admonition_context = if is_mkdocs {
684 self.precompute_mkdocs_admonition_context(lines)
685 } else {
686 vec![false; lines.len()]
687 };
688 let ictx = IndentContext {
689 in_list_context: &in_list_context,
690 in_tab_context: &in_tab_context,
691 in_admonition_context: &in_admonition_context,
692 in_jsx_context: &in_jsx_context,
693 };
694 self.detect_style(lines, is_mkdocs, &ictx)
695 .unwrap_or(CodeBlockStyle::Fenced)
696 }
697 _ => self.config.style,
698 };
699
700 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
702
703 for detail in &ctx.code_block_details {
704 if detail.start >= ctx.content.len() || detail.end > ctx.content.len() {
705 continue;
706 }
707
708 let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
709 Ok(idx) => idx,
710 Err(idx) => idx.saturating_sub(1),
711 };
712
713 if detail.is_fenced {
714 if target_style == CodeBlockStyle::Indented {
715 let line = lines.get(start_line_idx).unwrap_or(&"");
716
717 if ctx
718 .lines
719 .get(start_line_idx)
720 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
721 {
722 continue;
723 }
724
725 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
726 warnings.push(LintWarning {
727 rule_name: Some(self.name().to_string()),
728 line: start_line,
729 column: start_col,
730 end_line,
731 end_column: end_col,
732 message: "Use indented code blocks".to_string(),
733 severity: Severity::Warning,
734 fix: None,
735 });
736 }
737 } else {
738 if target_style == CodeBlockStyle::Fenced && !reported_indented_lines.contains(&start_line_idx) {
740 let line = lines.get(start_line_idx).unwrap_or(&"");
741
742 if ctx.lines.get(start_line_idx).is_some_and(|info| {
744 info.in_html_comment
745 || info.in_mdx_comment
746 || info.in_html_block
747 || info.in_jsx_block
748 || info.in_mkdocstrings
749 || info.in_footnote_definition
750 || info.blockquote.is_some()
751 }) {
752 continue;
753 }
754
755 if is_mkdocs
757 && ctx
758 .lines
759 .get(start_line_idx)
760 .is_some_and(|info| info.in_admonition || info.in_content_tab)
761 {
762 continue;
763 }
764
765 reported_indented_lines.insert(start_line_idx);
766
767 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
768 warnings.push(LintWarning {
769 rule_name: Some(self.name().to_string()),
770 line: start_line,
771 column: start_col,
772 end_line,
773 end_column: end_col,
774 message: "Use fenced code blocks".to_string(),
775 severity: Severity::Warning,
776 fix: None,
777 });
778 }
779 }
780 }
781
782 warnings.sort_by_key(|w| (w.line, w.column));
784
785 Ok(warnings)
786 }
787
788 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
789 let content = ctx.content;
790 if content.is_empty() {
791 return Ok(String::new());
792 }
793
794 let lines = ctx.raw_lines();
795
796 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
798
799 let in_jsx_context: Vec<bool> = (0..lines.len())
801 .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block))
802 .collect();
803
804 let in_list_context = self.precompute_block_continuation_context(lines);
806 let in_tab_context = if is_mkdocs {
807 self.precompute_mkdocs_tab_context(lines)
808 } else {
809 vec![false; lines.len()]
810 };
811 let in_admonition_context = if is_mkdocs {
812 self.precompute_mkdocs_admonition_context(lines)
813 } else {
814 vec![false; lines.len()]
815 };
816
817 let target_style = match self.config.style {
818 CodeBlockStyle::Consistent => {
819 let ictx = IndentContext {
820 in_list_context: &in_list_context,
821 in_tab_context: &in_tab_context,
822 in_admonition_context: &in_admonition_context,
823 in_jsx_context: &in_jsx_context,
824 };
825 self.detect_style(lines, is_mkdocs, &ictx)
826 .unwrap_or(CodeBlockStyle::Fenced)
827 }
828 _ => self.config.style,
829 };
830
831 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(
835 lines,
836 is_mkdocs,
837 &in_list_context,
838 &in_tab_context,
839 &in_admonition_context,
840 &in_jsx_context,
841 );
842
843 let ictx = IndentContext {
844 in_list_context: &in_list_context,
845 in_tab_context: &in_tab_context,
846 in_admonition_context: &in_admonition_context,
847 in_jsx_context: &in_jsx_context,
848 };
849
850 let mut result = String::with_capacity(content.len());
851 let mut in_fenced_block = false;
852 let mut fenced_fence_opener: Option<(char, usize)> = None;
856 let mut in_indented_block = false;
857
858 let mut current_block_disabled = false;
860
861 for (i, line) in lines.iter().enumerate() {
862 let line_num = i + 1;
863 let trimmed = line.trim_start();
864
865 if !in_fenced_block
868 && Self::has_valid_fence_indent(line)
869 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
870 {
871 current_block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
873 in_fenced_block = true;
874 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
875 let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
876 fenced_fence_opener = Some((fence_char, opener_len));
877
878 if current_block_disabled {
879 result.push_str(line);
881 result.push('\n');
882 } else if target_style == CodeBlockStyle::Indented {
883 in_indented_block = true;
885 } else {
886 result.push_str(line);
888 result.push('\n');
889 }
890 } else if in_fenced_block && fenced_fence_opener.is_some() {
891 let (fence_char, opener_len) = fenced_fence_opener.unwrap();
892 let closer_len = trimmed.chars().take_while(|&c| c == fence_char).count();
895 let after_closer = &trimmed[closer_len..];
896 let is_closer = closer_len >= opener_len && after_closer.trim().is_empty() && closer_len > 0;
897 if is_closer {
898 in_fenced_block = false;
899 fenced_fence_opener = None;
900 in_indented_block = false;
901
902 if current_block_disabled {
903 result.push_str(line);
904 result.push('\n');
905 } else if target_style == CodeBlockStyle::Indented {
906 } else {
908 result.push_str(line);
910 result.push('\n');
911 }
912 current_block_disabled = false;
913 } else if current_block_disabled {
914 result.push_str(line);
916 result.push('\n');
917 } else if target_style == CodeBlockStyle::Indented {
918 result.push_str(" ");
922 result.push_str(line);
923 result.push('\n');
924 } else {
925 result.push_str(line);
927 result.push('\n');
928 }
929 } else if self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) {
930 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
934 result.push_str(line);
935 result.push('\n');
936 continue;
937 }
938
939 let prev_line_is_indented =
941 i > 0 && self.is_indented_code_block_with_context(lines, i - 1, is_mkdocs, &ictx);
942
943 if target_style == CodeBlockStyle::Fenced {
944 let trimmed_content = line.trim_start();
945
946 if misplaced_fence_lines[i] {
949 result.push_str(trimmed_content);
951 result.push('\n');
952 } else if unsafe_fence_lines[i] {
953 result.push_str(line);
956 result.push('\n');
957 } else if !prev_line_is_indented && !in_indented_block {
958 result.push_str("```\n");
960 result.push_str(trimmed_content);
961 result.push('\n');
962 in_indented_block = true;
963 } else {
964 result.push_str(trimmed_content);
966 result.push('\n');
967 }
968
969 let next_line_is_indented =
971 i < lines.len() - 1 && self.is_indented_code_block_with_context(lines, i + 1, is_mkdocs, &ictx);
972 if !next_line_is_indented
974 && in_indented_block
975 && !misplaced_fence_lines[i]
976 && !unsafe_fence_lines[i]
977 {
978 result.push_str("```\n");
979 in_indented_block = false;
980 }
981 } else {
982 result.push_str(line);
984 result.push('\n');
985 }
986 } else {
987 if in_indented_block && target_style == CodeBlockStyle::Fenced {
989 result.push_str("```\n");
990 in_indented_block = false;
991 }
992
993 result.push_str(line);
994 result.push('\n');
995 }
996 }
997
998 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1000 result.push_str("```\n");
1001 }
1002
1003 if let Some((fence_char, opener_len)) = fenced_fence_opener
1009 && in_fenced_block
1010 {
1011 let has_unclosed_violation = self.check_unclosed_code_blocks(ctx).is_ok_and(|w| !w.is_empty());
1012 if has_unclosed_violation {
1013 let closer: String = std::iter::repeat_n(fence_char, opener_len).collect();
1014 result.push_str(&closer);
1015 result.push('\n');
1016 }
1017 }
1018
1019 if !content.ends_with('\n') && result.ends_with('\n') {
1021 result.pop();
1022 }
1023
1024 Ok(result)
1025 }
1026
1027 fn category(&self) -> RuleCategory {
1029 RuleCategory::CodeBlock
1030 }
1031
1032 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1034 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1037 }
1038
1039 fn as_any(&self) -> &dyn std::any::Any {
1040 self
1041 }
1042
1043 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1044 let json_value = serde_json::to_value(&self.config).ok()?;
1045 Some((
1046 self.name().to_string(),
1047 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1048 ))
1049 }
1050
1051 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1052 where
1053 Self: Sized,
1054 {
1055 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1056 Box::new(Self::from_config_struct(rule_config))
1057 }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062 use super::*;
1063 use crate::lint_context::LintContext;
1064
1065 fn detect_style_from_content(
1067 rule: &MD046CodeBlockStyle,
1068 content: &str,
1069 is_mkdocs: bool,
1070 in_jsx_context: &[bool],
1071 ) -> Option<CodeBlockStyle> {
1072 let lines: Vec<&str> = content.lines().collect();
1073 let in_list_context = rule.precompute_block_continuation_context(&lines);
1074 let in_tab_context = if is_mkdocs {
1075 rule.precompute_mkdocs_tab_context(&lines)
1076 } else {
1077 vec![false; lines.len()]
1078 };
1079 let in_admonition_context = if is_mkdocs {
1080 rule.precompute_mkdocs_admonition_context(&lines)
1081 } else {
1082 vec![false; lines.len()]
1083 };
1084 let ictx = IndentContext {
1085 in_list_context: &in_list_context,
1086 in_tab_context: &in_tab_context,
1087 in_admonition_context: &in_admonition_context,
1088 in_jsx_context,
1089 };
1090 rule.detect_style(&lines, is_mkdocs, &ictx)
1091 }
1092
1093 #[test]
1094 fn test_fenced_code_block_detection() {
1095 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1096 assert!(rule.is_fenced_code_block_start("```"));
1097 assert!(rule.is_fenced_code_block_start("```rust"));
1098 assert!(rule.is_fenced_code_block_start("~~~"));
1099 assert!(rule.is_fenced_code_block_start("~~~python"));
1100 assert!(rule.is_fenced_code_block_start(" ```"));
1101 assert!(!rule.is_fenced_code_block_start("``"));
1102 assert!(!rule.is_fenced_code_block_start("~~"));
1103 assert!(!rule.is_fenced_code_block_start("Regular text"));
1104 }
1105
1106 #[test]
1107 fn test_consistent_style_with_fenced_blocks() {
1108 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1109 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1111 let result = rule.check(&ctx).unwrap();
1112
1113 assert_eq!(result.len(), 0);
1115 }
1116
1117 #[test]
1118 fn test_consistent_style_with_indented_blocks() {
1119 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1120 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1121 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1122 let result = rule.check(&ctx).unwrap();
1123
1124 assert_eq!(result.len(), 0);
1126 }
1127
1128 #[test]
1129 fn test_consistent_style_mixed() {
1130 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1131 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133 let result = rule.check(&ctx).unwrap();
1134
1135 assert!(!result.is_empty());
1137 }
1138
1139 #[test]
1140 fn test_fenced_style_with_indented_blocks() {
1141 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1142 let content = "Text\n\n indented code\n more code\n\nMore text";
1143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1144 let result = rule.check(&ctx).unwrap();
1145
1146 assert!(!result.is_empty());
1148 assert!(result[0].message.contains("Use fenced code blocks"));
1149 }
1150
1151 #[test]
1152 fn test_fenced_style_with_tab_indented_blocks() {
1153 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1154 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1155 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1156 let result = rule.check(&ctx).unwrap();
1157
1158 assert!(!result.is_empty());
1160 assert!(result[0].message.contains("Use fenced code blocks"));
1161 }
1162
1163 #[test]
1164 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1165 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1166 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169 let result = rule.check(&ctx).unwrap();
1170
1171 assert!(
1173 !result.is_empty(),
1174 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1175 );
1176 assert!(result[0].message.contains("Use fenced code blocks"));
1177 }
1178
1179 #[test]
1180 fn test_fenced_style_with_one_space_tab_indent() {
1181 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1182 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1184 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1185 let result = rule.check(&ctx).unwrap();
1186
1187 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1188 assert!(result[0].message.contains("Use fenced code blocks"));
1189 }
1190
1191 #[test]
1192 fn test_indented_style_with_fenced_blocks() {
1193 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1194 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 let result = rule.check(&ctx).unwrap();
1197
1198 assert!(!result.is_empty());
1200 assert!(result[0].message.contains("Use indented code blocks"));
1201 }
1202
1203 #[test]
1204 fn test_unclosed_code_block() {
1205 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1206 let content = "```\ncode without closing fence";
1207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1208 let result = rule.check(&ctx).unwrap();
1209
1210 assert_eq!(result.len(), 1);
1211 assert!(result[0].message.contains("never closed"));
1212 }
1213
1214 #[test]
1215 fn test_nested_code_blocks() {
1216 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1217 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1218 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1219 let result = rule.check(&ctx).unwrap();
1220
1221 assert_eq!(result.len(), 0);
1223 }
1224
1225 #[test]
1226 fn test_fix_indented_to_fenced() {
1227 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1228 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230 let fixed = rule.fix(&ctx).unwrap();
1231
1232 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1233 }
1234
1235 #[test]
1236 fn test_fix_fenced_to_indented() {
1237 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1238 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let fixed = rule.fix(&ctx).unwrap();
1241
1242 assert!(fixed.contains(" code line 1\n code line 2"));
1243 assert!(!fixed.contains("```"));
1244 }
1245
1246 #[test]
1247 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1248 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1251 let content = r#"# Test
1252
1253```html
1254<!doctype html>
1255<html>
1256 <head>
1257 <title>Test</title>
1258 </head>
1259</html>
1260```
1261"#;
1262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1263 let fixed = rule.fix(&ctx).unwrap();
1264
1265 assert!(
1268 fixed.contains(" <head>"),
1269 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1270 );
1271 assert!(
1272 fixed.contains(" <title>"),
1273 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1274 );
1275 assert!(!fixed.contains("```"), "Fenced markers should be removed");
1276 }
1277
1278 #[test]
1279 fn test_fix_fenced_to_indented_preserves_python_indentation() {
1280 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1282 let content = r#"# Python Example
1283
1284```python
1285def greet(name):
1286 if name:
1287 print(f"Hello, {name}!")
1288 else:
1289 print("Hello, World!")
1290```
1291"#;
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 let fixed = rule.fix(&ctx).unwrap();
1294
1295 assert!(
1297 fixed.contains(" def greet(name):"),
1298 "Function def should have 4 spaces (code block indent)"
1299 );
1300 assert!(
1301 fixed.contains(" if name:"),
1302 "if statement should have 8 spaces (4 code + 4 Python)"
1303 );
1304 assert!(
1305 fixed.contains(" print"),
1306 "print should have 12 spaces (4 code + 8 Python)"
1307 );
1308 }
1309
1310 #[test]
1311 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1312 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1314 let content = r#"# Config
1315
1316```yaml
1317server:
1318 host: localhost
1319 port: 8080
1320 ssl:
1321 enabled: true
1322 cert: /path/to/cert
1323```
1324"#;
1325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1326 let fixed = rule.fix(&ctx).unwrap();
1327
1328 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
1329 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
1330 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
1331 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
1332 }
1333
1334 #[test]
1335 fn test_fix_fenced_to_indented_preserves_empty_lines() {
1336 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1338 let content = "```\nline1\n\nline2\n```\n";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let fixed = rule.fix(&ctx).unwrap();
1341
1342 assert!(fixed.contains(" line1"), "line1 should be indented");
1344 assert!(fixed.contains(" line2"), "line2 should be indented");
1345 }
1347
1348 #[test]
1349 fn test_fix_fenced_to_indented_multiple_blocks() {
1350 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1352 let content = r#"# Doc
1353
1354```python
1355def foo():
1356 pass
1357```
1358
1359Text between.
1360
1361```yaml
1362key:
1363 value: 1
1364```
1365"#;
1366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1367 let fixed = rule.fix(&ctx).unwrap();
1368
1369 assert!(fixed.contains(" def foo():"), "Python def should be indented");
1370 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
1371 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
1372 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
1373 assert!(!fixed.contains("```"), "No fence markers should remain");
1374 }
1375
1376 #[test]
1377 fn test_fix_unclosed_block() {
1378 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1379 let content = "```\ncode without closing";
1380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1381 let fixed = rule.fix(&ctx).unwrap();
1382
1383 assert!(fixed.ends_with("```"));
1385 }
1386
1387 #[test]
1388 fn test_code_block_in_list() {
1389 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1390 let content = "- List item\n code in list\n more code\n- Next item";
1391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1392 let result = rule.check(&ctx).unwrap();
1393
1394 assert_eq!(result.len(), 0);
1396 }
1397
1398 #[test]
1399 fn test_detect_style_fenced() {
1400 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1401 let content = "```\ncode\n```";
1402 let style = detect_style_from_content(&rule, content, false, &[]);
1403
1404 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1405 }
1406
1407 #[test]
1408 fn test_detect_style_indented() {
1409 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1410 let content = "Text\n\n code\n\nMore";
1411 let style = detect_style_from_content(&rule, content, false, &[]);
1412
1413 assert_eq!(style, Some(CodeBlockStyle::Indented));
1414 }
1415
1416 #[test]
1417 fn test_detect_style_none() {
1418 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1419 let content = "No code blocks here";
1420 let style = detect_style_from_content(&rule, content, false, &[]);
1421
1422 assert_eq!(style, None);
1423 }
1424
1425 #[test]
1426 fn test_tilde_fence() {
1427 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1428 let content = "~~~\ncode\n~~~";
1429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1430 let result = rule.check(&ctx).unwrap();
1431
1432 assert_eq!(result.len(), 0);
1434 }
1435
1436 #[test]
1437 fn test_language_specification() {
1438 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1439 let content = "```rust\nfn main() {}\n```";
1440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1441 let result = rule.check(&ctx).unwrap();
1442
1443 assert_eq!(result.len(), 0);
1444 }
1445
1446 #[test]
1447 fn test_empty_content() {
1448 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1449 let content = "";
1450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451 let result = rule.check(&ctx).unwrap();
1452
1453 assert_eq!(result.len(), 0);
1454 }
1455
1456 #[test]
1457 fn test_default_config() {
1458 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1459 let (name, _config) = rule.default_config_section().unwrap();
1460 assert_eq!(name, "MD046");
1461 }
1462
1463 #[test]
1464 fn test_markdown_documentation_block() {
1465 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1466 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1468 let result = rule.check(&ctx).unwrap();
1469
1470 assert_eq!(result.len(), 0);
1472 }
1473
1474 #[test]
1475 fn test_preserve_trailing_newline() {
1476 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1477 let content = "```\ncode\n```\n";
1478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479 let fixed = rule.fix(&ctx).unwrap();
1480
1481 assert_eq!(fixed, content);
1482 }
1483
1484 #[test]
1485 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1486 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1487 let content = r#"# Document
1488
1489=== "Python"
1490
1491 This is tab content
1492 Not an indented code block
1493
1494 ```python
1495 def hello():
1496 print("Hello")
1497 ```
1498
1499=== "JavaScript"
1500
1501 More tab content here
1502 Also not an indented code block"#;
1503
1504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1505 let result = rule.check(&ctx).unwrap();
1506
1507 assert_eq!(result.len(), 0);
1509 }
1510
1511 #[test]
1512 fn test_mkdocs_tabs_with_actual_indented_code() {
1513 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1514 let content = r#"# Document
1515
1516=== "Tab 1"
1517
1518 This is tab content
1519
1520Regular text
1521
1522 This is an actual indented code block
1523 Should be flagged"#;
1524
1525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1526 let result = rule.check(&ctx).unwrap();
1527
1528 assert_eq!(result.len(), 1);
1530 assert!(result[0].message.contains("Use fenced code blocks"));
1531 }
1532
1533 #[test]
1534 fn test_mkdocs_tabs_detect_style() {
1535 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1536 let content = r#"=== "Tab 1"
1537
1538 Content in tab
1539 More content
1540
1541=== "Tab 2"
1542
1543 Content in second tab"#;
1544
1545 let style = detect_style_from_content(&rule, content, true, &[]);
1547 assert_eq!(style, None); let style = detect_style_from_content(&rule, content, false, &[]);
1551 assert_eq!(style, Some(CodeBlockStyle::Indented));
1552 }
1553
1554 #[test]
1555 fn test_mkdocs_nested_tabs() {
1556 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1557 let content = r#"# Document
1558
1559=== "Outer Tab"
1560
1561 Some content
1562
1563 === "Nested Tab"
1564
1565 Nested tab content
1566 Should not be flagged"#;
1567
1568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1569 let result = rule.check(&ctx).unwrap();
1570
1571 assert_eq!(result.len(), 0);
1573 }
1574
1575 #[test]
1576 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1577 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1580 let content = r#"# Document
1581
1582!!! note
1583 This is normal admonition content, not a code block.
1584 It spans multiple lines.
1585
1586??? warning "Collapsible Warning"
1587 This is also admonition content.
1588
1589???+ tip "Expanded Tip"
1590 And this one too.
1591
1592Regular text outside admonitions."#;
1593
1594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1595 let result = rule.check(&ctx).unwrap();
1596
1597 assert_eq!(
1599 result.len(),
1600 0,
1601 "Admonition content in MkDocs mode should not trigger MD046"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_mkdocs_admonition_with_actual_indented_code() {
1607 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1609 let content = r#"# Document
1610
1611!!! note
1612 This is admonition content.
1613
1614Regular text ends the admonition.
1615
1616 This is actual indented code (should be flagged)"#;
1617
1618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1619 let result = rule.check(&ctx).unwrap();
1620
1621 assert_eq!(result.len(), 1);
1623 assert!(result[0].message.contains("Use fenced code blocks"));
1624 }
1625
1626 #[test]
1627 fn test_admonition_in_standard_mode_flagged() {
1628 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1632 let content = r#"# Document
1633
1634!!! note
1635
1636 This looks like code in standard mode.
1637
1638Regular text."#;
1639
1640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1642 let result = rule.check(&ctx).unwrap();
1643
1644 assert_eq!(
1646 result.len(),
1647 1,
1648 "Admonition content in Standard mode should be flagged as indented code"
1649 );
1650 }
1651
1652 #[test]
1653 fn test_mkdocs_admonition_with_fenced_code_inside() {
1654 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1656 let content = r#"# Document
1657
1658!!! note "Code Example"
1659 Here's some code:
1660
1661 ```python
1662 def hello():
1663 print("world")
1664 ```
1665
1666 More text after code.
1667
1668Regular text."#;
1669
1670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1671 let result = rule.check(&ctx).unwrap();
1672
1673 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1675 }
1676
1677 #[test]
1678 fn test_mkdocs_nested_admonitions() {
1679 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1681 let content = r#"# Document
1682
1683!!! note "Outer"
1684 Outer content.
1685
1686 !!! warning "Inner"
1687 Inner content.
1688 More inner content.
1689
1690 Back to outer.
1691
1692Regular text."#;
1693
1694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1695 let result = rule.check(&ctx).unwrap();
1696
1697 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1699 }
1700
1701 #[test]
1702 fn test_mkdocs_admonition_fix_does_not_wrap() {
1703 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1705 let content = r#"!!! note
1706 Content that should stay as admonition content.
1707 Not be wrapped in code fences.
1708"#;
1709
1710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1711 let fixed = rule.fix(&ctx).unwrap();
1712
1713 assert!(
1715 !fixed.contains("```\n Content"),
1716 "Admonition content should not be wrapped in fences"
1717 );
1718 assert_eq!(fixed, content, "Content should remain unchanged");
1719 }
1720
1721 #[test]
1722 fn test_mkdocs_empty_admonition() {
1723 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1725 let content = r#"!!! note
1726
1727Regular paragraph after empty admonition.
1728
1729 This IS an indented code block (after blank + non-indented line)."#;
1730
1731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1732 let result = rule.check(&ctx).unwrap();
1733
1734 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1736 }
1737
1738 #[test]
1739 fn test_mkdocs_indented_admonition() {
1740 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1742 let content = r#"- List item
1743
1744 !!! note
1745 Indented admonition content.
1746 More content.
1747
1748- Next item"#;
1749
1750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1751 let result = rule.check(&ctx).unwrap();
1752
1753 assert_eq!(
1755 result.len(),
1756 0,
1757 "Indented admonitions (e.g., in lists) should not be flagged"
1758 );
1759 }
1760
1761 #[test]
1762 fn test_footnote_indented_paragraphs_not_flagged() {
1763 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1764 let content = r#"# Test Document with Footnotes
1765
1766This is some text with a footnote[^1].
1767
1768Here's some code:
1769
1770```bash
1771echo "fenced code block"
1772```
1773
1774More text with another footnote[^2].
1775
1776[^1]: Really interesting footnote text.
1777
1778 Even more interesting second paragraph.
1779
1780[^2]: Another footnote.
1781
1782 With a second paragraph too.
1783
1784 And even a third paragraph!"#;
1785
1786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1787 let result = rule.check(&ctx).unwrap();
1788
1789 assert_eq!(result.len(), 0);
1791 }
1792
1793 #[test]
1794 fn test_footnote_definition_detection() {
1795 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1796
1797 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1800 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1801 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1802 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1803 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1804 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1805 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1806 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1807 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1808
1809 assert!(!rule.is_footnote_definition("[^]: No label"));
1811 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1812 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1813 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1814
1815 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1817 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1818 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1819 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1822
1823 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1825 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1826 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1827 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1828 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1829
1830 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1833 }
1834
1835 #[test]
1836 fn test_footnote_with_blank_lines() {
1837 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1841 let content = r#"# Document
1842
1843Text with footnote[^1].
1844
1845[^1]: First paragraph.
1846
1847 Second paragraph after blank line.
1848
1849 Third paragraph after another blank line.
1850
1851Regular text at column 0 ends the footnote."#;
1852
1853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1854 let result = rule.check(&ctx).unwrap();
1855
1856 assert_eq!(
1858 result.len(),
1859 0,
1860 "Indented content within footnotes should not trigger MD046"
1861 );
1862 }
1863
1864 #[test]
1865 fn test_footnote_multiple_consecutive_blank_lines() {
1866 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1869 let content = r#"Text[^1].
1870
1871[^1]: First paragraph.
1872
1873
1874
1875 Content after three blank lines (still part of footnote).
1876
1877Not indented, so footnote ends here."#;
1878
1879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880 let result = rule.check(&ctx).unwrap();
1881
1882 assert_eq!(
1884 result.len(),
1885 0,
1886 "Multiple blank lines shouldn't break footnote continuation"
1887 );
1888 }
1889
1890 #[test]
1891 fn test_footnote_terminated_by_non_indented_content() {
1892 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1895 let content = r#"[^1]: Footnote content.
1896
1897 More indented content in footnote.
1898
1899This paragraph is not indented, so footnote ends.
1900
1901 This should be flagged as indented code block."#;
1902
1903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1904 let result = rule.check(&ctx).unwrap();
1905
1906 assert_eq!(
1908 result.len(),
1909 1,
1910 "Indented code after footnote termination should be flagged"
1911 );
1912 assert!(
1913 result[0].message.contains("Use fenced code blocks"),
1914 "Expected MD046 warning for indented code block"
1915 );
1916 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1917 }
1918
1919 #[test]
1920 fn test_footnote_terminated_by_structural_elements() {
1921 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1923 let content = r#"[^1]: Footnote content.
1924
1925 More content.
1926
1927## Heading terminates footnote
1928
1929 This indented content should be flagged.
1930
1931---
1932
1933 This should also be flagged (after horizontal rule)."#;
1934
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 let result = rule.check(&ctx).unwrap();
1937
1938 assert_eq!(
1940 result.len(),
1941 2,
1942 "Both indented blocks after termination should be flagged"
1943 );
1944 }
1945
1946 #[test]
1947 fn test_footnote_with_code_block_inside() {
1948 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1951 let content = r#"Text[^1].
1952
1953[^1]: Footnote with code:
1954
1955 ```python
1956 def hello():
1957 print("world")
1958 ```
1959
1960 More footnote text after code."#;
1961
1962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1963 let result = rule.check(&ctx).unwrap();
1964
1965 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1967 }
1968
1969 #[test]
1970 fn test_footnote_with_8_space_indented_code() {
1971 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1974 let content = r#"Text[^1].
1975
1976[^1]: Footnote with nested code.
1977
1978 code block
1979 more code"#;
1980
1981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1982 let result = rule.check(&ctx).unwrap();
1983
1984 assert_eq!(
1986 result.len(),
1987 0,
1988 "8-space indented code within footnotes represents nested code blocks"
1989 );
1990 }
1991
1992 #[test]
1993 fn test_multiple_footnotes() {
1994 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1997 let content = r#"Text[^1] and more[^2].
1998
1999[^1]: First footnote.
2000
2001 Continuation of first.
2002
2003[^2]: Second footnote starts here, ending the first.
2004
2005 Continuation of second."#;
2006
2007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2008 let result = rule.check(&ctx).unwrap();
2009
2010 assert_eq!(
2012 result.len(),
2013 0,
2014 "Multiple footnotes should each maintain their continuation context"
2015 );
2016 }
2017
2018 #[test]
2019 fn test_list_item_ends_footnote_context() {
2020 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2022 let content = r#"[^1]: Footnote.
2023
2024 Content in footnote.
2025
2026- List item starts here (ends footnote context).
2027
2028 This indented content is part of the list, not the footnote."#;
2029
2030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2031 let result = rule.check(&ctx).unwrap();
2032
2033 assert_eq!(
2035 result.len(),
2036 0,
2037 "List items should end footnote context and start their own"
2038 );
2039 }
2040
2041 #[test]
2042 fn test_footnote_vs_actual_indented_code() {
2043 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2046 let content = r#"# Heading
2047
2048Text with footnote[^1].
2049
2050[^1]: Footnote content.
2051
2052 Part of footnote (should not be flagged).
2053
2054Regular paragraph ends footnote context.
2055
2056 This is actual indented code (MUST be flagged)
2057 Should be detected as code block"#;
2058
2059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2060 let result = rule.check(&ctx).unwrap();
2061
2062 assert_eq!(
2064 result.len(),
2065 1,
2066 "Must still detect indented code blocks outside footnotes"
2067 );
2068 assert!(
2069 result[0].message.contains("Use fenced code blocks"),
2070 "Expected MD046 warning for indented code"
2071 );
2072 assert!(
2073 result[0].line >= 11,
2074 "Warning should be on the actual indented code line"
2075 );
2076 }
2077
2078 #[test]
2079 fn test_spec_compliant_label_characters() {
2080 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2083
2084 assert!(rule.is_footnote_definition("[^test]: text"));
2086 assert!(rule.is_footnote_definition("[^TEST]: text"));
2087 assert!(rule.is_footnote_definition("[^test-name]: text"));
2088 assert!(rule.is_footnote_definition("[^test_name]: text"));
2089 assert!(rule.is_footnote_definition("[^test123]: text"));
2090 assert!(rule.is_footnote_definition("[^123]: text"));
2091 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2092
2093 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")); }
2101
2102 #[test]
2103 fn test_code_block_inside_html_comment() {
2104 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2107 let content = r#"# Document
2108
2109Some text.
2110
2111<!--
2112Example code block in comment:
2113
2114```typescript
2115console.log("Hello");
2116```
2117
2118More comment text.
2119-->
2120
2121More content."#;
2122
2123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2124 let result = rule.check(&ctx).unwrap();
2125
2126 assert_eq!(
2127 result.len(),
2128 0,
2129 "Code blocks inside HTML comments should not be flagged as unclosed"
2130 );
2131 }
2132
2133 #[test]
2134 fn test_unclosed_fence_inside_html_comment() {
2135 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2137 let content = r#"# Document
2138
2139<!--
2140Example with intentionally unclosed fence:
2141
2142```
2143code without closing
2144-->
2145
2146More content."#;
2147
2148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2149 let result = rule.check(&ctx).unwrap();
2150
2151 assert_eq!(
2152 result.len(),
2153 0,
2154 "Unclosed fences inside HTML comments should be ignored"
2155 );
2156 }
2157
2158 #[test]
2159 fn test_multiline_html_comment_with_indented_code() {
2160 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2162 let content = r#"# Document
2163
2164<!--
2165Example:
2166
2167 indented code
2168 more code
2169
2170End of comment.
2171-->
2172
2173Regular text."#;
2174
2175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2176 let result = rule.check(&ctx).unwrap();
2177
2178 assert_eq!(
2179 result.len(),
2180 0,
2181 "Indented code inside HTML comments should not be flagged"
2182 );
2183 }
2184
2185 #[test]
2186 fn test_code_block_after_html_comment() {
2187 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2189 let content = r#"# Document
2190
2191<!-- comment -->
2192
2193Text before.
2194
2195 indented code should be flagged
2196
2197More text."#;
2198
2199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2200 let result = rule.check(&ctx).unwrap();
2201
2202 assert_eq!(
2203 result.len(),
2204 1,
2205 "Code blocks after HTML comments should still be detected"
2206 );
2207 assert!(result[0].message.contains("Use fenced code blocks"));
2208 }
2209
2210 #[test]
2211 fn test_four_space_indented_fence_is_not_valid_fence() {
2212 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2215
2216 assert!(rule.is_fenced_code_block_start("```"));
2218 assert!(rule.is_fenced_code_block_start(" ```"));
2219 assert!(rule.is_fenced_code_block_start(" ```"));
2220 assert!(rule.is_fenced_code_block_start(" ```"));
2221
2222 assert!(!rule.is_fenced_code_block_start(" ```"));
2224 assert!(!rule.is_fenced_code_block_start(" ```"));
2225 assert!(!rule.is_fenced_code_block_start(" ```"));
2226
2227 assert!(!rule.is_fenced_code_block_start("\t```"));
2229 }
2230
2231 #[test]
2232 fn test_issue_237_indented_fenced_block_detected_as_indented() {
2233 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2239
2240 let content = r#"## Test
2242
2243 ```js
2244 var foo = "hello";
2245 ```
2246"#;
2247
2248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2249 let result = rule.check(&ctx).unwrap();
2250
2251 assert_eq!(
2253 result.len(),
2254 1,
2255 "4-space indented fence should be detected as indented code block"
2256 );
2257 assert!(
2258 result[0].message.contains("Use fenced code blocks"),
2259 "Expected 'Use fenced code blocks' message"
2260 );
2261 }
2262
2263 #[test]
2264 fn test_issue_276_indented_code_in_list() {
2265 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2268
2269 let content = r#"1. First item
22702. Second item with code:
2271
2272 # This is a code block in a list
2273 print("Hello, world!")
2274
22754. Third item"#;
2276
2277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2278 let result = rule.check(&ctx).unwrap();
2279
2280 assert!(
2282 !result.is_empty(),
2283 "Indented code block inside list should be flagged when style=fenced"
2284 );
2285 assert!(
2286 result[0].message.contains("Use fenced code blocks"),
2287 "Expected 'Use fenced code blocks' message"
2288 );
2289 }
2290
2291 #[test]
2292 fn test_three_space_indented_fence_is_valid() {
2293 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2295
2296 let content = r#"## Test
2297
2298 ```js
2299 var foo = "hello";
2300 ```
2301"#;
2302
2303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2304 let result = rule.check(&ctx).unwrap();
2305
2306 assert_eq!(
2308 result.len(),
2309 0,
2310 "3-space indented fence should be recognized as valid fenced code block"
2311 );
2312 }
2313
2314 #[test]
2315 fn test_indented_style_with_deeply_indented_fenced() {
2316 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2319
2320 let content = r#"Text
2321
2322 ```js
2323 var foo = "hello";
2324 ```
2325
2326More text
2327"#;
2328
2329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2330 let result = rule.check(&ctx).unwrap();
2331
2332 assert_eq!(
2335 result.len(),
2336 0,
2337 "4-space indented content should be valid when style=indented"
2338 );
2339 }
2340
2341 #[test]
2342 fn test_fix_misplaced_fenced_block() {
2343 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2346
2347 let content = r#"## Test
2348
2349 ```js
2350 var foo = "hello";
2351 ```
2352"#;
2353
2354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2355 let fixed = rule.fix(&ctx).unwrap();
2356
2357 let expected = r#"## Test
2359
2360```js
2361var foo = "hello";
2362```
2363"#;
2364
2365 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2366 }
2367
2368 #[test]
2369 fn test_fix_regular_indented_block() {
2370 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2373
2374 let content = r#"Text
2375
2376 var foo = "hello";
2377 console.log(foo);
2378
2379More text
2380"#;
2381
2382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2383 let fixed = rule.fix(&ctx).unwrap();
2384
2385 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2387 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2388 }
2389
2390 #[test]
2391 fn test_fix_indented_block_with_fence_like_content() {
2392 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2396
2397 let content = r#"Text
2398
2399 some code
2400 ```not a fence opener
2401 more code
2402"#;
2403
2404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2405 let fixed = rule.fix(&ctx).unwrap();
2406
2407 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
2409 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2410 }
2411
2412 #[test]
2413 fn test_fix_mixed_indented_and_misplaced_blocks() {
2414 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2416
2417 let content = r#"Text
2418
2419 regular indented code
2420
2421More text
2422
2423 ```python
2424 print("hello")
2425 ```
2426"#;
2427
2428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2429 let fixed = rule.fix(&ctx).unwrap();
2430
2431 assert!(
2433 fixed.contains("```\nregular indented code\n```"),
2434 "First block should be wrapped in fences"
2435 );
2436
2437 assert!(
2439 fixed.contains("\n```python\nprint(\"hello\")\n```"),
2440 "Second block should be dedented, not double-wrapped"
2441 );
2442 assert!(
2444 !fixed.contains("```\n```python"),
2445 "Should not have nested fence openers"
2446 );
2447 }
2448}