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_footnotes;
5use crate::utils::mkdocs_tabs;
6use crate::utils::range_utils::calculate_line_range;
7use toml;
8
9mod md046_config;
10pub use md046_config::CodeBlockStyle;
11use md046_config::MD046Config;
12
13struct IndentContext<'a> {
15 in_list_context: &'a [bool],
16 in_tab_context: &'a [bool],
17 in_admonition_context: &'a [bool],
18 in_jsx_context: &'a [bool],
19}
20
21#[derive(Clone)]
27pub struct MD046CodeBlockStyle {
28 config: MD046Config,
29}
30
31impl MD046CodeBlockStyle {
32 pub fn new(style: CodeBlockStyle) -> Self {
33 Self {
34 config: MD046Config { style },
35 }
36 }
37
38 pub fn from_config_struct(config: MD046Config) -> Self {
39 Self { config }
40 }
41
42 fn has_valid_fence_indent(line: &str) -> bool {
47 calculate_indentation_width_default(line) < 4
48 }
49
50 fn is_fenced_code_block_start(&self, line: &str) -> bool {
59 if !Self::has_valid_fence_indent(line) {
60 return false;
61 }
62
63 let trimmed = line.trim_start();
64 trimmed.starts_with("```") || trimmed.starts_with("~~~")
65 }
66
67 fn is_list_item(&self, line: &str) -> bool {
68 let trimmed = line.trim_start();
69 (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
70 || (trimmed.len() > 2
71 && trimmed.chars().next().unwrap().is_numeric()
72 && (trimmed.contains(". ") || trimmed.contains(") ")))
73 }
74
75 fn is_footnote_definition(&self, line: &str) -> bool {
95 let trimmed = line.trim_start();
96 if !trimmed.starts_with("[^") || trimmed.len() < 5 {
97 return false;
98 }
99
100 if let Some(close_bracket_pos) = trimmed.find("]:")
101 && close_bracket_pos > 2
102 {
103 let label = &trimmed[2..close_bracket_pos];
104
105 if label.trim().is_empty() {
106 return false;
107 }
108
109 if label.contains('\r') {
111 return false;
112 }
113
114 if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
116 return true;
117 }
118 }
119
120 false
121 }
122
123 fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
146 let mut in_continuation_context = vec![false; lines.len()];
147 let mut last_list_item_line: Option<usize> = None;
148 let mut last_footnote_line: Option<usize> = None;
149 let mut blank_line_count = 0;
150
151 for (i, line) in lines.iter().enumerate() {
152 let trimmed = line.trim_start();
153 let indent_len = line.len() - trimmed.len();
154
155 if self.is_list_item(line) {
157 last_list_item_line = Some(i);
158 last_footnote_line = None; blank_line_count = 0;
160 in_continuation_context[i] = true;
161 continue;
162 }
163
164 if self.is_footnote_definition(line) {
166 last_footnote_line = Some(i);
167 last_list_item_line = None; blank_line_count = 0;
169 in_continuation_context[i] = true;
170 continue;
171 }
172
173 if line.trim().is_empty() {
175 if last_list_item_line.is_some() || last_footnote_line.is_some() {
177 blank_line_count += 1;
178 in_continuation_context[i] = true;
179
180 }
184 continue;
185 }
186
187 if indent_len == 0 && !trimmed.is_empty() {
189 if trimmed.starts_with('#') {
193 last_list_item_line = None;
194 last_footnote_line = None;
195 blank_line_count = 0;
196 continue;
197 }
198
199 if trimmed.starts_with("---") || trimmed.starts_with("***") {
201 last_list_item_line = None;
202 last_footnote_line = None;
203 blank_line_count = 0;
204 continue;
205 }
206
207 if let Some(list_line) = last_list_item_line
210 && (i - list_line > 5 || blank_line_count > 1)
211 {
212 last_list_item_line = None;
213 }
214
215 if last_footnote_line.is_some() {
217 last_footnote_line = None;
218 }
219
220 blank_line_count = 0;
221
222 if last_list_item_line.is_none() && last_footnote_line.is_some() {
224 last_footnote_line = None;
225 }
226 continue;
227 }
228
229 if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
231 in_continuation_context[i] = true;
232 blank_line_count = 0;
233 }
234 }
235
236 in_continuation_context
237 }
238
239 fn is_indented_code_block_with_context(
241 &self,
242 lines: &[&str],
243 i: usize,
244 is_mkdocs: bool,
245 ctx: &IndentContext,
246 ) -> bool {
247 if i >= lines.len() {
248 return false;
249 }
250
251 let line = lines[i];
252
253 let indent = calculate_indentation_width_default(line);
255 if indent < 4 {
256 return false;
257 }
258
259 if ctx.in_list_context[i] {
261 return false;
262 }
263
264 if is_mkdocs && ctx.in_tab_context[i] {
266 return false;
267 }
268
269 if is_mkdocs && ctx.in_admonition_context[i] {
272 return false;
273 }
274
275 if ctx.in_jsx_context.get(i).copied().unwrap_or(false) {
277 return false;
278 }
279
280 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
283 let prev_is_indented_code = i > 0
284 && calculate_indentation_width_default(lines[i - 1]) >= 4
285 && !ctx.in_list_context[i - 1]
286 && !(is_mkdocs && ctx.in_tab_context[i - 1])
287 && !(is_mkdocs && ctx.in_admonition_context[i - 1]);
288
289 if !has_blank_line_before && !prev_is_indented_code {
292 return false;
293 }
294
295 true
296 }
297
298 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
300 let mut in_tab_context = vec![false; lines.len()];
301 let mut current_tab_indent: Option<usize> = None;
302
303 for (i, line) in lines.iter().enumerate() {
304 if mkdocs_tabs::is_tab_marker(line) {
306 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
307 current_tab_indent = Some(tab_indent);
308 in_tab_context[i] = true;
309 continue;
310 }
311
312 if let Some(tab_indent) = current_tab_indent {
314 if mkdocs_tabs::is_tab_content(line, tab_indent) {
315 in_tab_context[i] = true;
316 } else if !line.trim().is_empty() && calculate_indentation_width_default(line) < 4 {
317 current_tab_indent = None;
319 } else {
320 in_tab_context[i] = true;
322 }
323 }
324 }
325
326 in_tab_context
327 }
328
329 fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
338 let mut in_admonition_context = vec![false; lines.len()];
339 let mut admonition_stack: Vec<usize> = Vec::new();
341
342 for (i, line) in lines.iter().enumerate() {
343 let line_indent = calculate_indentation_width_default(line);
344
345 if mkdocs_admonitions::is_admonition_start(line) {
347 let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
348
349 while let Some(&top_indent) = admonition_stack.last() {
351 if adm_indent <= top_indent {
353 admonition_stack.pop();
354 } else {
355 break;
356 }
357 }
358
359 admonition_stack.push(adm_indent);
361 in_admonition_context[i] = true;
362 continue;
363 }
364
365 if line.trim().is_empty() {
367 if !admonition_stack.is_empty() {
368 in_admonition_context[i] = true;
369 }
370 continue;
371 }
372
373 while let Some(&top_indent) = admonition_stack.last() {
376 if line_indent >= top_indent + 4 {
378 break;
380 } else {
381 admonition_stack.pop();
383 }
384 }
385
386 if !admonition_stack.is_empty() {
388 in_admonition_context[i] = true;
389 }
390 }
391
392 in_admonition_context
393 }
394
395 fn categorize_indented_blocks(
407 &self,
408 lines: &[&str],
409 is_mkdocs: bool,
410 in_list_context: &[bool],
411 in_tab_context: &[bool],
412 in_admonition_context: &[bool],
413 in_jsx_context: &[bool],
414 ) -> (Vec<bool>, Vec<bool>) {
415 let mut is_misplaced = vec![false; lines.len()];
416 let mut contains_fences = vec![false; lines.len()];
417
418 let ictx = IndentContext {
419 in_list_context,
420 in_tab_context,
421 in_admonition_context,
422 in_jsx_context,
423 };
424
425 let mut i = 0;
427 while i < lines.len() {
428 if !self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) {
430 i += 1;
431 continue;
432 }
433
434 let block_start = i;
436 let mut block_end = i;
437
438 while block_end < lines.len()
439 && self.is_indented_code_block_with_context(lines, block_end, is_mkdocs, &ictx)
440 {
441 block_end += 1;
442 }
443
444 if block_end > block_start {
446 let first_line = lines[block_start].trim_start();
447 let last_line = lines[block_end - 1].trim_start();
448
449 let is_backtick_fence = first_line.starts_with("```");
451 let is_tilde_fence = first_line.starts_with("~~~");
452
453 if is_backtick_fence || is_tilde_fence {
454 let fence_char = if is_backtick_fence { '`' } else { '~' };
455 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
456
457 let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
459 let after_closer = &last_line[closer_fence_len..];
460
461 if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
462 is_misplaced[block_start..block_end].fill(true);
464 } else {
465 contains_fences[block_start..block_end].fill(true);
467 }
468 } else {
469 let has_fence_markers = (block_start..block_end).any(|j| {
472 let trimmed = lines[j].trim_start();
473 trimmed.starts_with("```") || trimmed.starts_with("~~~")
474 });
475
476 if has_fence_markers {
477 contains_fences[block_start..block_end].fill(true);
478 }
479 }
480 }
481
482 i = block_end;
483 }
484
485 (is_misplaced, contains_fences)
486 }
487
488 fn check_unclosed_code_blocks(
489 &self,
490 ctx: &crate::lint_context::LintContext,
491 ) -> Result<Vec<LintWarning>, LintError> {
492 let mut warnings = Vec::new();
493 let lines = ctx.raw_lines();
494
495 let has_markdown_doc_block = ctx.code_block_details.iter().any(|d| {
497 if !d.is_fenced {
498 return false;
499 }
500 let lang = d.info_string.to_lowercase();
501 lang.starts_with("markdown") || lang.starts_with("md")
502 });
503
504 if has_markdown_doc_block {
507 return Ok(warnings);
508 }
509
510 for detail in &ctx.code_block_details {
511 if !detail.is_fenced {
512 continue;
513 }
514
515 if detail.end != ctx.content.len() {
517 continue;
518 }
519
520 let opening_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
522 Ok(idx) => idx,
523 Err(idx) => idx.saturating_sub(1),
524 };
525
526 let line = lines.get(opening_line_idx).unwrap_or(&"");
528 let trimmed = line.trim();
529 let fence_marker = if let Some(pos) = trimmed.find("```") {
530 let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
531 "`".repeat(count)
532 } else if let Some(pos) = trimmed.find("~~~") {
533 let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
534 "~".repeat(count)
535 } else {
536 "```".to_string()
537 };
538
539 let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
541 let last_trimmed = last_non_empty_line.trim();
542 let fence_char = fence_marker.chars().next().unwrap_or('`');
543
544 let has_closing_fence = if fence_char == '`' {
545 last_trimmed.starts_with("```") && {
546 let fence_len = last_trimmed.chars().take_while(|&c| c == '`').count();
547 last_trimmed[fence_len..].trim().is_empty()
548 }
549 } else {
550 last_trimmed.starts_with("~~~") && {
551 let fence_len = last_trimmed.chars().take_while(|&c| c == '~').count();
552 last_trimmed[fence_len..].trim().is_empty()
553 }
554 };
555
556 if !has_closing_fence {
557 if ctx.lines.get(opening_line_idx).is_some_and(|info| info.in_html_comment) {
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 Ok(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 footnote_ranges =
700 if target_style == CodeBlockStyle::Fenced && ctx.code_block_details.iter().any(|d| !d.is_fenced) {
701 compute_footnote_ranges(ctx.content)
702 } else {
703 Vec::new()
704 };
705
706 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
708
709 for detail in &ctx.code_block_details {
710 if detail.start >= ctx.content.len() || detail.end > ctx.content.len() {
711 continue;
712 }
713
714 let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
715 Ok(idx) => idx,
716 Err(idx) => idx.saturating_sub(1),
717 };
718
719 if detail.is_fenced {
720 if target_style == CodeBlockStyle::Indented {
721 let line = lines.get(start_line_idx).unwrap_or(&"");
722
723 if ctx.lines.get(start_line_idx).is_some_and(|info| info.in_html_comment) {
724 continue;
725 }
726
727 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
728 warnings.push(LintWarning {
729 rule_name: Some(self.name().to_string()),
730 line: start_line,
731 column: start_col,
732 end_line,
733 end_column: end_col,
734 message: "Use indented code blocks".to_string(),
735 severity: Severity::Warning,
736 fix: None,
737 });
738 }
739 } else {
740 if target_style == CodeBlockStyle::Fenced && !reported_indented_lines.contains(&start_line_idx) {
742 let line = lines.get(start_line_idx).unwrap_or(&"");
743
744 if ctx.lines.get(start_line_idx).is_some_and(|info| {
746 info.in_html_comment
747 || info.in_html_block
748 || info.in_jsx_block
749 || info.in_mkdocstrings
750 || info.blockquote.is_some()
751 }) {
752 continue;
753 }
754
755 if is_in_footnote_range(&footnote_ranges, detail.start) {
757 continue;
758 }
759
760 if is_mkdocs
762 && ctx
763 .lines
764 .get(start_line_idx)
765 .is_some_and(|info| info.in_admonition || info.in_content_tab)
766 {
767 continue;
768 }
769
770 reported_indented_lines.insert(start_line_idx);
771
772 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
773 warnings.push(LintWarning {
774 rule_name: Some(self.name().to_string()),
775 line: start_line,
776 column: start_col,
777 end_line,
778 end_column: end_col,
779 message: "Use fenced code blocks".to_string(),
780 severity: Severity::Warning,
781 fix: None,
782 });
783 }
784 }
785 }
786
787 warnings.sort_by_key(|w| (w.line, w.column));
789
790 Ok(warnings)
791 }
792
793 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
794 let content = ctx.content;
795 if content.is_empty() {
796 return Ok(String::new());
797 }
798
799 let lines = ctx.raw_lines();
800
801 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
803
804 let in_jsx_context: Vec<bool> = (0..lines.len())
806 .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block))
807 .collect();
808
809 let in_list_context = self.precompute_block_continuation_context(lines);
811 let in_tab_context = if is_mkdocs {
812 self.precompute_mkdocs_tab_context(lines)
813 } else {
814 vec![false; lines.len()]
815 };
816 let in_admonition_context = if is_mkdocs {
817 self.precompute_mkdocs_admonition_context(lines)
818 } else {
819 vec![false; lines.len()]
820 };
821
822 let target_style = match self.config.style {
823 CodeBlockStyle::Consistent => {
824 let ictx = IndentContext {
825 in_list_context: &in_list_context,
826 in_tab_context: &in_tab_context,
827 in_admonition_context: &in_admonition_context,
828 in_jsx_context: &in_jsx_context,
829 };
830 self.detect_style(lines, is_mkdocs, &ictx)
831 .unwrap_or(CodeBlockStyle::Fenced)
832 }
833 _ => self.config.style,
834 };
835
836 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(
840 lines,
841 is_mkdocs,
842 &in_list_context,
843 &in_tab_context,
844 &in_admonition_context,
845 &in_jsx_context,
846 );
847
848 let ictx = IndentContext {
849 in_list_context: &in_list_context,
850 in_tab_context: &in_tab_context,
851 in_admonition_context: &in_admonition_context,
852 in_jsx_context: &in_jsx_context,
853 };
854
855 let mut result = String::with_capacity(content.len());
856 let mut in_fenced_block = false;
857 let mut fenced_fence_type = None;
858 let mut in_indented_block = false;
859
860 let mut current_block_disabled = false;
862
863 for (i, line) in lines.iter().enumerate() {
864 let line_num = i + 1;
865 let trimmed = line.trim_start();
866
867 if !in_fenced_block
870 && Self::has_valid_fence_indent(line)
871 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
872 {
873 current_block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
875 in_fenced_block = true;
876 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
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_type.is_some() {
891 let fence = fenced_fence_type.unwrap();
892 if trimmed.starts_with(fence) {
893 in_fenced_block = false;
894 fenced_fence_type = None;
895 in_indented_block = false;
896
897 if current_block_disabled {
898 result.push_str(line);
899 result.push('\n');
900 } else if target_style == CodeBlockStyle::Indented {
901 } else {
903 result.push_str(line);
905 result.push('\n');
906 }
907 current_block_disabled = false;
908 } else if current_block_disabled {
909 result.push_str(line);
911 result.push('\n');
912 } else if target_style == CodeBlockStyle::Indented {
913 result.push_str(" ");
917 result.push_str(line);
918 result.push('\n');
919 } else {
920 result.push_str(line);
922 result.push('\n');
923 }
924 } else if self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) {
925 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
929 result.push_str(line);
930 result.push('\n');
931 continue;
932 }
933
934 let prev_line_is_indented =
936 i > 0 && self.is_indented_code_block_with_context(lines, i - 1, is_mkdocs, &ictx);
937
938 if target_style == CodeBlockStyle::Fenced {
939 let trimmed_content = line.trim_start();
940
941 if misplaced_fence_lines[i] {
944 result.push_str(trimmed_content);
946 result.push('\n');
947 } else if unsafe_fence_lines[i] {
948 result.push_str(line);
951 result.push('\n');
952 } else if !prev_line_is_indented && !in_indented_block {
953 result.push_str("```\n");
955 result.push_str(trimmed_content);
956 result.push('\n');
957 in_indented_block = true;
958 } else {
959 result.push_str(trimmed_content);
961 result.push('\n');
962 }
963
964 let next_line_is_indented =
966 i < lines.len() - 1 && self.is_indented_code_block_with_context(lines, i + 1, is_mkdocs, &ictx);
967 if !next_line_is_indented
969 && in_indented_block
970 && !misplaced_fence_lines[i]
971 && !unsafe_fence_lines[i]
972 {
973 result.push_str("```\n");
974 in_indented_block = false;
975 }
976 } else {
977 result.push_str(line);
979 result.push('\n');
980 }
981 } else {
982 if in_indented_block && target_style == CodeBlockStyle::Fenced {
984 result.push_str("```\n");
985 in_indented_block = false;
986 }
987
988 result.push_str(line);
989 result.push('\n');
990 }
991 }
992
993 if in_indented_block && target_style == CodeBlockStyle::Fenced {
995 result.push_str("```\n");
996 }
997
998 if let Some(fence_type) = fenced_fence_type
1000 && in_fenced_block
1001 {
1002 result.push_str(fence_type);
1003 result.push('\n');
1004 }
1005
1006 if !content.ends_with('\n') && result.ends_with('\n') {
1008 result.pop();
1009 }
1010
1011 Ok(result)
1012 }
1013
1014 fn category(&self) -> RuleCategory {
1016 RuleCategory::CodeBlock
1017 }
1018
1019 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1021 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1024 }
1025
1026 fn as_any(&self) -> &dyn std::any::Any {
1027 self
1028 }
1029
1030 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1031 let json_value = serde_json::to_value(&self.config).ok()?;
1032 Some((
1033 self.name().to_string(),
1034 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1035 ))
1036 }
1037
1038 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1039 where
1040 Self: Sized,
1041 {
1042 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1043 Box::new(Self::from_config_struct(rule_config))
1044 }
1045}
1046
1047fn compute_footnote_ranges(content: &str) -> Vec<(usize, usize)> {
1050 let mut ranges = Vec::new();
1051 let mut footnote_start: Option<(usize, usize)> = None; let mut offset = 0;
1054 for line in content.split('\n') {
1055 let line_end = offset + line.len();
1056
1057 if mkdocs_footnotes::is_footnote_definition(line) {
1058 if let Some((start, _)) = footnote_start.take() {
1060 ranges.push((start, offset.saturating_sub(1)));
1061 }
1062 let indent = mkdocs_footnotes::get_footnote_indent(line).unwrap_or(0);
1063 footnote_start = Some((offset, indent));
1064 } else if let Some((_, indent)) = footnote_start
1065 && !line.trim().is_empty()
1066 && !mkdocs_footnotes::is_footnote_continuation(line, indent)
1067 {
1068 let (start, _) = footnote_start.take().unwrap();
1070 ranges.push((start, offset.saturating_sub(1)));
1071 }
1072
1073 offset = line_end + 1; }
1075
1076 if let Some((start, _)) = footnote_start {
1078 ranges.push((start, content.len()));
1079 }
1080
1081 ranges
1082}
1083
1084fn is_in_footnote_range(ranges: &[(usize, usize)], pos: usize) -> bool {
1086 let idx = ranges.partition_point(|&(start, _)| start <= pos);
1087 idx > 0 && pos < ranges[idx - 1].1
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093 use crate::lint_context::LintContext;
1094
1095 fn detect_style_from_content(
1097 rule: &MD046CodeBlockStyle,
1098 content: &str,
1099 is_mkdocs: bool,
1100 in_jsx_context: &[bool],
1101 ) -> Option<CodeBlockStyle> {
1102 let lines: Vec<&str> = content.lines().collect();
1103 let in_list_context = rule.precompute_block_continuation_context(&lines);
1104 let in_tab_context = if is_mkdocs {
1105 rule.precompute_mkdocs_tab_context(&lines)
1106 } else {
1107 vec![false; lines.len()]
1108 };
1109 let in_admonition_context = if is_mkdocs {
1110 rule.precompute_mkdocs_admonition_context(&lines)
1111 } else {
1112 vec![false; lines.len()]
1113 };
1114 let ictx = IndentContext {
1115 in_list_context: &in_list_context,
1116 in_tab_context: &in_tab_context,
1117 in_admonition_context: &in_admonition_context,
1118 in_jsx_context,
1119 };
1120 rule.detect_style(&lines, is_mkdocs, &ictx)
1121 }
1122
1123 #[test]
1124 fn test_fenced_code_block_detection() {
1125 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1126 assert!(rule.is_fenced_code_block_start("```"));
1127 assert!(rule.is_fenced_code_block_start("```rust"));
1128 assert!(rule.is_fenced_code_block_start("~~~"));
1129 assert!(rule.is_fenced_code_block_start("~~~python"));
1130 assert!(rule.is_fenced_code_block_start(" ```"));
1131 assert!(!rule.is_fenced_code_block_start("``"));
1132 assert!(!rule.is_fenced_code_block_start("~~"));
1133 assert!(!rule.is_fenced_code_block_start("Regular text"));
1134 }
1135
1136 #[test]
1137 fn test_consistent_style_with_fenced_blocks() {
1138 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1139 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.check(&ctx).unwrap();
1142
1143 assert_eq!(result.len(), 0);
1145 }
1146
1147 #[test]
1148 fn test_consistent_style_with_indented_blocks() {
1149 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1150 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1152 let result = rule.check(&ctx).unwrap();
1153
1154 assert_eq!(result.len(), 0);
1156 }
1157
1158 #[test]
1159 fn test_consistent_style_mixed() {
1160 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1161 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163 let result = rule.check(&ctx).unwrap();
1164
1165 assert!(!result.is_empty());
1167 }
1168
1169 #[test]
1170 fn test_fenced_style_with_indented_blocks() {
1171 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1172 let content = "Text\n\n indented code\n more code\n\nMore text";
1173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1174 let result = rule.check(&ctx).unwrap();
1175
1176 assert!(!result.is_empty());
1178 assert!(result[0].message.contains("Use fenced code blocks"));
1179 }
1180
1181 #[test]
1182 fn test_fenced_style_with_tab_indented_blocks() {
1183 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1184 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186 let result = rule.check(&ctx).unwrap();
1187
1188 assert!(!result.is_empty());
1190 assert!(result[0].message.contains("Use fenced code blocks"));
1191 }
1192
1193 #[test]
1194 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1195 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1196 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1199 let result = rule.check(&ctx).unwrap();
1200
1201 assert!(
1203 !result.is_empty(),
1204 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1205 );
1206 assert!(result[0].message.contains("Use fenced code blocks"));
1207 }
1208
1209 #[test]
1210 fn test_fenced_style_with_one_space_tab_indent() {
1211 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1212 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1215 let result = rule.check(&ctx).unwrap();
1216
1217 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1218 assert!(result[0].message.contains("Use fenced code blocks"));
1219 }
1220
1221 #[test]
1222 fn test_indented_style_with_fenced_blocks() {
1223 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1224 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1226 let result = rule.check(&ctx).unwrap();
1227
1228 assert!(!result.is_empty());
1230 assert!(result[0].message.contains("Use indented code blocks"));
1231 }
1232
1233 #[test]
1234 fn test_unclosed_code_block() {
1235 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1236 let content = "```\ncode without closing fence";
1237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1238 let result = rule.check(&ctx).unwrap();
1239
1240 assert_eq!(result.len(), 1);
1241 assert!(result[0].message.contains("never closed"));
1242 }
1243
1244 #[test]
1245 fn test_nested_code_blocks() {
1246 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1247 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let result = rule.check(&ctx).unwrap();
1250
1251 assert_eq!(result.len(), 0);
1253 }
1254
1255 #[test]
1256 fn test_fix_indented_to_fenced() {
1257 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1258 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1260 let fixed = rule.fix(&ctx).unwrap();
1261
1262 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1263 }
1264
1265 #[test]
1266 fn test_fix_fenced_to_indented() {
1267 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1268 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1270 let fixed = rule.fix(&ctx).unwrap();
1271
1272 assert!(fixed.contains(" code line 1\n code line 2"));
1273 assert!(!fixed.contains("```"));
1274 }
1275
1276 #[test]
1277 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1278 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1281 let content = r#"# Test
1282
1283```html
1284<!doctype html>
1285<html>
1286 <head>
1287 <title>Test</title>
1288 </head>
1289</html>
1290```
1291"#;
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 let fixed = rule.fix(&ctx).unwrap();
1294
1295 assert!(
1298 fixed.contains(" <head>"),
1299 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1300 );
1301 assert!(
1302 fixed.contains(" <title>"),
1303 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1304 );
1305 assert!(!fixed.contains("```"), "Fenced markers should be removed");
1306 }
1307
1308 #[test]
1309 fn test_fix_fenced_to_indented_preserves_python_indentation() {
1310 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1312 let content = r#"# Python Example
1313
1314```python
1315def greet(name):
1316 if name:
1317 print(f"Hello, {name}!")
1318 else:
1319 print("Hello, World!")
1320```
1321"#;
1322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323 let fixed = rule.fix(&ctx).unwrap();
1324
1325 assert!(
1327 fixed.contains(" def greet(name):"),
1328 "Function def should have 4 spaces (code block indent)"
1329 );
1330 assert!(
1331 fixed.contains(" if name:"),
1332 "if statement should have 8 spaces (4 code + 4 Python)"
1333 );
1334 assert!(
1335 fixed.contains(" print"),
1336 "print should have 12 spaces (4 code + 8 Python)"
1337 );
1338 }
1339
1340 #[test]
1341 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1342 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1344 let content = r#"# Config
1345
1346```yaml
1347server:
1348 host: localhost
1349 port: 8080
1350 ssl:
1351 enabled: true
1352 cert: /path/to/cert
1353```
1354"#;
1355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356 let fixed = rule.fix(&ctx).unwrap();
1357
1358 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
1359 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
1360 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
1361 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
1362 }
1363
1364 #[test]
1365 fn test_fix_fenced_to_indented_preserves_empty_lines() {
1366 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1368 let content = "```\nline1\n\nline2\n```\n";
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370 let fixed = rule.fix(&ctx).unwrap();
1371
1372 assert!(fixed.contains(" line1"), "line1 should be indented");
1374 assert!(fixed.contains(" line2"), "line2 should be indented");
1375 }
1377
1378 #[test]
1379 fn test_fix_fenced_to_indented_multiple_blocks() {
1380 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1382 let content = r#"# Doc
1383
1384```python
1385def foo():
1386 pass
1387```
1388
1389Text between.
1390
1391```yaml
1392key:
1393 value: 1
1394```
1395"#;
1396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1397 let fixed = rule.fix(&ctx).unwrap();
1398
1399 assert!(fixed.contains(" def foo():"), "Python def should be indented");
1400 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
1401 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
1402 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
1403 assert!(!fixed.contains("```"), "No fence markers should remain");
1404 }
1405
1406 #[test]
1407 fn test_fix_unclosed_block() {
1408 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1409 let content = "```\ncode without closing";
1410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1411 let fixed = rule.fix(&ctx).unwrap();
1412
1413 assert!(fixed.ends_with("```"));
1415 }
1416
1417 #[test]
1418 fn test_code_block_in_list() {
1419 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1420 let content = "- List item\n code in list\n more code\n- Next item";
1421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1422 let result = rule.check(&ctx).unwrap();
1423
1424 assert_eq!(result.len(), 0);
1426 }
1427
1428 #[test]
1429 fn test_detect_style_fenced() {
1430 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1431 let content = "```\ncode\n```";
1432 let style = detect_style_from_content(&rule, content, false, &[]);
1433
1434 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1435 }
1436
1437 #[test]
1438 fn test_detect_style_indented() {
1439 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1440 let content = "Text\n\n code\n\nMore";
1441 let style = detect_style_from_content(&rule, content, false, &[]);
1442
1443 assert_eq!(style, Some(CodeBlockStyle::Indented));
1444 }
1445
1446 #[test]
1447 fn test_detect_style_none() {
1448 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1449 let content = "No code blocks here";
1450 let style = detect_style_from_content(&rule, content, false, &[]);
1451
1452 assert_eq!(style, None);
1453 }
1454
1455 #[test]
1456 fn test_tilde_fence() {
1457 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1458 let content = "~~~\ncode\n~~~";
1459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460 let result = rule.check(&ctx).unwrap();
1461
1462 assert_eq!(result.len(), 0);
1464 }
1465
1466 #[test]
1467 fn test_language_specification() {
1468 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1469 let content = "```rust\nfn main() {}\n```";
1470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1471 let result = rule.check(&ctx).unwrap();
1472
1473 assert_eq!(result.len(), 0);
1474 }
1475
1476 #[test]
1477 fn test_empty_content() {
1478 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1479 let content = "";
1480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1481 let result = rule.check(&ctx).unwrap();
1482
1483 assert_eq!(result.len(), 0);
1484 }
1485
1486 #[test]
1487 fn test_default_config() {
1488 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1489 let (name, _config) = rule.default_config_section().unwrap();
1490 assert_eq!(name, "MD046");
1491 }
1492
1493 #[test]
1494 fn test_markdown_documentation_block() {
1495 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1496 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498 let result = rule.check(&ctx).unwrap();
1499
1500 assert_eq!(result.len(), 0);
1502 }
1503
1504 #[test]
1505 fn test_preserve_trailing_newline() {
1506 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1507 let content = "```\ncode\n```\n";
1508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1509 let fixed = rule.fix(&ctx).unwrap();
1510
1511 assert_eq!(fixed, content);
1512 }
1513
1514 #[test]
1515 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1516 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1517 let content = r#"# Document
1518
1519=== "Python"
1520
1521 This is tab content
1522 Not an indented code block
1523
1524 ```python
1525 def hello():
1526 print("Hello")
1527 ```
1528
1529=== "JavaScript"
1530
1531 More tab content here
1532 Also not an indented code block"#;
1533
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1535 let result = rule.check(&ctx).unwrap();
1536
1537 assert_eq!(result.len(), 0);
1539 }
1540
1541 #[test]
1542 fn test_mkdocs_tabs_with_actual_indented_code() {
1543 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1544 let content = r#"# Document
1545
1546=== "Tab 1"
1547
1548 This is tab content
1549
1550Regular text
1551
1552 This is an actual indented code block
1553 Should be flagged"#;
1554
1555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1556 let result = rule.check(&ctx).unwrap();
1557
1558 assert_eq!(result.len(), 1);
1560 assert!(result[0].message.contains("Use fenced code blocks"));
1561 }
1562
1563 #[test]
1564 fn test_mkdocs_tabs_detect_style() {
1565 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1566 let content = r#"=== "Tab 1"
1567
1568 Content in tab
1569 More content
1570
1571=== "Tab 2"
1572
1573 Content in second tab"#;
1574
1575 let style = detect_style_from_content(&rule, content, true, &[]);
1577 assert_eq!(style, None); let style = detect_style_from_content(&rule, content, false, &[]);
1581 assert_eq!(style, Some(CodeBlockStyle::Indented));
1582 }
1583
1584 #[test]
1585 fn test_mkdocs_nested_tabs() {
1586 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1587 let content = r#"# Document
1588
1589=== "Outer Tab"
1590
1591 Some content
1592
1593 === "Nested Tab"
1594
1595 Nested tab content
1596 Should not be flagged"#;
1597
1598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1599 let result = rule.check(&ctx).unwrap();
1600
1601 assert_eq!(result.len(), 0);
1603 }
1604
1605 #[test]
1606 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1607 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1610 let content = r#"# Document
1611
1612!!! note
1613 This is normal admonition content, not a code block.
1614 It spans multiple lines.
1615
1616??? warning "Collapsible Warning"
1617 This is also admonition content.
1618
1619???+ tip "Expanded Tip"
1620 And this one too.
1621
1622Regular text outside admonitions."#;
1623
1624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1625 let result = rule.check(&ctx).unwrap();
1626
1627 assert_eq!(
1629 result.len(),
1630 0,
1631 "Admonition content in MkDocs mode should not trigger MD046"
1632 );
1633 }
1634
1635 #[test]
1636 fn test_mkdocs_admonition_with_actual_indented_code() {
1637 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1639 let content = r#"# Document
1640
1641!!! note
1642 This is admonition content.
1643
1644Regular text ends the admonition.
1645
1646 This is actual indented code (should be flagged)"#;
1647
1648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1649 let result = rule.check(&ctx).unwrap();
1650
1651 assert_eq!(result.len(), 1);
1653 assert!(result[0].message.contains("Use fenced code blocks"));
1654 }
1655
1656 #[test]
1657 fn test_admonition_in_standard_mode_flagged() {
1658 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1662 let content = r#"# Document
1663
1664!!! note
1665
1666 This looks like code in standard mode.
1667
1668Regular text."#;
1669
1670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1672 let result = rule.check(&ctx).unwrap();
1673
1674 assert_eq!(
1676 result.len(),
1677 1,
1678 "Admonition content in Standard mode should be flagged as indented code"
1679 );
1680 }
1681
1682 #[test]
1683 fn test_mkdocs_admonition_with_fenced_code_inside() {
1684 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1686 let content = r#"# Document
1687
1688!!! note "Code Example"
1689 Here's some code:
1690
1691 ```python
1692 def hello():
1693 print("world")
1694 ```
1695
1696 More text after code.
1697
1698Regular text."#;
1699
1700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1701 let result = rule.check(&ctx).unwrap();
1702
1703 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1705 }
1706
1707 #[test]
1708 fn test_mkdocs_nested_admonitions() {
1709 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1711 let content = r#"# Document
1712
1713!!! note "Outer"
1714 Outer content.
1715
1716 !!! warning "Inner"
1717 Inner content.
1718 More inner content.
1719
1720 Back to outer.
1721
1722Regular text."#;
1723
1724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1725 let result = rule.check(&ctx).unwrap();
1726
1727 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1729 }
1730
1731 #[test]
1732 fn test_mkdocs_admonition_fix_does_not_wrap() {
1733 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1735 let content = r#"!!! note
1736 Content that should stay as admonition content.
1737 Not be wrapped in code fences.
1738"#;
1739
1740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1741 let fixed = rule.fix(&ctx).unwrap();
1742
1743 assert!(
1745 !fixed.contains("```\n Content"),
1746 "Admonition content should not be wrapped in fences"
1747 );
1748 assert_eq!(fixed, content, "Content should remain unchanged");
1749 }
1750
1751 #[test]
1752 fn test_mkdocs_empty_admonition() {
1753 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1755 let content = r#"!!! note
1756
1757Regular paragraph after empty admonition.
1758
1759 This IS an indented code block (after blank + non-indented line)."#;
1760
1761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1762 let result = rule.check(&ctx).unwrap();
1763
1764 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1766 }
1767
1768 #[test]
1769 fn test_mkdocs_indented_admonition() {
1770 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1772 let content = r#"- List item
1773
1774 !!! note
1775 Indented admonition content.
1776 More content.
1777
1778- Next item"#;
1779
1780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1781 let result = rule.check(&ctx).unwrap();
1782
1783 assert_eq!(
1785 result.len(),
1786 0,
1787 "Indented admonitions (e.g., in lists) should not be flagged"
1788 );
1789 }
1790
1791 #[test]
1792 fn test_footnote_indented_paragraphs_not_flagged() {
1793 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1794 let content = r#"# Test Document with Footnotes
1795
1796This is some text with a footnote[^1].
1797
1798Here's some code:
1799
1800```bash
1801echo "fenced code block"
1802```
1803
1804More text with another footnote[^2].
1805
1806[^1]: Really interesting footnote text.
1807
1808 Even more interesting second paragraph.
1809
1810[^2]: Another footnote.
1811
1812 With a second paragraph too.
1813
1814 And even a third paragraph!"#;
1815
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817 let result = rule.check(&ctx).unwrap();
1818
1819 assert_eq!(result.len(), 0);
1821 }
1822
1823 #[test]
1824 fn test_footnote_definition_detection() {
1825 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1826
1827 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1830 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1831 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1832 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1833 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1834 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1835 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1836 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1837 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1838
1839 assert!(!rule.is_footnote_definition("[^]: No label"));
1841 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1842 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1843 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1844
1845 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1847 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1848 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1849 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1852
1853 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1855 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1856 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1857 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1858 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1859
1860 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1863 }
1864
1865 #[test]
1866 fn test_footnote_with_blank_lines() {
1867 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1871 let content = r#"# Document
1872
1873Text with footnote[^1].
1874
1875[^1]: First paragraph.
1876
1877 Second paragraph after blank line.
1878
1879 Third paragraph after another blank line.
1880
1881Regular text at column 0 ends the footnote."#;
1882
1883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1884 let result = rule.check(&ctx).unwrap();
1885
1886 assert_eq!(
1888 result.len(),
1889 0,
1890 "Indented content within footnotes should not trigger MD046"
1891 );
1892 }
1893
1894 #[test]
1895 fn test_footnote_multiple_consecutive_blank_lines() {
1896 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1899 let content = r#"Text[^1].
1900
1901[^1]: First paragraph.
1902
1903
1904
1905 Content after three blank lines (still part of footnote).
1906
1907Not indented, so footnote ends here."#;
1908
1909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1910 let result = rule.check(&ctx).unwrap();
1911
1912 assert_eq!(
1914 result.len(),
1915 0,
1916 "Multiple blank lines shouldn't break footnote continuation"
1917 );
1918 }
1919
1920 #[test]
1921 fn test_footnote_terminated_by_non_indented_content() {
1922 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1925 let content = r#"[^1]: Footnote content.
1926
1927 More indented content in footnote.
1928
1929This paragraph is not indented, so footnote ends.
1930
1931 This should be flagged as indented code block."#;
1932
1933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1934 let result = rule.check(&ctx).unwrap();
1935
1936 assert_eq!(
1938 result.len(),
1939 1,
1940 "Indented code after footnote termination should be flagged"
1941 );
1942 assert!(
1943 result[0].message.contains("Use fenced code blocks"),
1944 "Expected MD046 warning for indented code block"
1945 );
1946 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1947 }
1948
1949 #[test]
1950 fn test_footnote_terminated_by_structural_elements() {
1951 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1953 let content = r#"[^1]: Footnote content.
1954
1955 More content.
1956
1957## Heading terminates footnote
1958
1959 This indented content should be flagged.
1960
1961---
1962
1963 This should also be flagged (after horizontal rule)."#;
1964
1965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1966 let result = rule.check(&ctx).unwrap();
1967
1968 assert_eq!(
1970 result.len(),
1971 2,
1972 "Both indented blocks after termination should be flagged"
1973 );
1974 }
1975
1976 #[test]
1977 fn test_footnote_with_code_block_inside() {
1978 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1981 let content = r#"Text[^1].
1982
1983[^1]: Footnote with code:
1984
1985 ```python
1986 def hello():
1987 print("world")
1988 ```
1989
1990 More footnote text after code."#;
1991
1992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1993 let result = rule.check(&ctx).unwrap();
1994
1995 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1997 }
1998
1999 #[test]
2000 fn test_footnote_with_8_space_indented_code() {
2001 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2004 let content = r#"Text[^1].
2005
2006[^1]: Footnote with nested code.
2007
2008 code block
2009 more code"#;
2010
2011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2012 let result = rule.check(&ctx).unwrap();
2013
2014 assert_eq!(
2016 result.len(),
2017 0,
2018 "8-space indented code within footnotes represents nested code blocks"
2019 );
2020 }
2021
2022 #[test]
2023 fn test_multiple_footnotes() {
2024 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2027 let content = r#"Text[^1] and more[^2].
2028
2029[^1]: First footnote.
2030
2031 Continuation of first.
2032
2033[^2]: Second footnote starts here, ending the first.
2034
2035 Continuation of second."#;
2036
2037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2038 let result = rule.check(&ctx).unwrap();
2039
2040 assert_eq!(
2042 result.len(),
2043 0,
2044 "Multiple footnotes should each maintain their continuation context"
2045 );
2046 }
2047
2048 #[test]
2049 fn test_list_item_ends_footnote_context() {
2050 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2052 let content = r#"[^1]: Footnote.
2053
2054 Content in footnote.
2055
2056- List item starts here (ends footnote context).
2057
2058 This indented content is part of the list, not the footnote."#;
2059
2060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2061 let result = rule.check(&ctx).unwrap();
2062
2063 assert_eq!(
2065 result.len(),
2066 0,
2067 "List items should end footnote context and start their own"
2068 );
2069 }
2070
2071 #[test]
2072 fn test_footnote_vs_actual_indented_code() {
2073 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2076 let content = r#"# Heading
2077
2078Text with footnote[^1].
2079
2080[^1]: Footnote content.
2081
2082 Part of footnote (should not be flagged).
2083
2084Regular paragraph ends footnote context.
2085
2086 This is actual indented code (MUST be flagged)
2087 Should be detected as code block"#;
2088
2089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2090 let result = rule.check(&ctx).unwrap();
2091
2092 assert_eq!(
2094 result.len(),
2095 1,
2096 "Must still detect indented code blocks outside footnotes"
2097 );
2098 assert!(
2099 result[0].message.contains("Use fenced code blocks"),
2100 "Expected MD046 warning for indented code"
2101 );
2102 assert!(
2103 result[0].line >= 11,
2104 "Warning should be on the actual indented code line"
2105 );
2106 }
2107
2108 #[test]
2109 fn test_spec_compliant_label_characters() {
2110 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2113
2114 assert!(rule.is_footnote_definition("[^test]: text"));
2116 assert!(rule.is_footnote_definition("[^TEST]: text"));
2117 assert!(rule.is_footnote_definition("[^test-name]: text"));
2118 assert!(rule.is_footnote_definition("[^test_name]: text"));
2119 assert!(rule.is_footnote_definition("[^test123]: text"));
2120 assert!(rule.is_footnote_definition("[^123]: text"));
2121 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2122
2123 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")); }
2131
2132 #[test]
2133 fn test_code_block_inside_html_comment() {
2134 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2137 let content = r#"# Document
2138
2139Some text.
2140
2141<!--
2142Example code block in comment:
2143
2144```typescript
2145console.log("Hello");
2146```
2147
2148More comment text.
2149-->
2150
2151More content."#;
2152
2153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2154 let result = rule.check(&ctx).unwrap();
2155
2156 assert_eq!(
2157 result.len(),
2158 0,
2159 "Code blocks inside HTML comments should not be flagged as unclosed"
2160 );
2161 }
2162
2163 #[test]
2164 fn test_unclosed_fence_inside_html_comment() {
2165 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2167 let content = r#"# Document
2168
2169<!--
2170Example with intentionally unclosed fence:
2171
2172```
2173code without closing
2174-->
2175
2176More content."#;
2177
2178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2179 let result = rule.check(&ctx).unwrap();
2180
2181 assert_eq!(
2182 result.len(),
2183 0,
2184 "Unclosed fences inside HTML comments should be ignored"
2185 );
2186 }
2187
2188 #[test]
2189 fn test_multiline_html_comment_with_indented_code() {
2190 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2192 let content = r#"# Document
2193
2194<!--
2195Example:
2196
2197 indented code
2198 more code
2199
2200End of comment.
2201-->
2202
2203Regular text."#;
2204
2205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2206 let result = rule.check(&ctx).unwrap();
2207
2208 assert_eq!(
2209 result.len(),
2210 0,
2211 "Indented code inside HTML comments should not be flagged"
2212 );
2213 }
2214
2215 #[test]
2216 fn test_code_block_after_html_comment() {
2217 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2219 let content = r#"# Document
2220
2221<!-- comment -->
2222
2223Text before.
2224
2225 indented code should be flagged
2226
2227More text."#;
2228
2229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2230 let result = rule.check(&ctx).unwrap();
2231
2232 assert_eq!(
2233 result.len(),
2234 1,
2235 "Code blocks after HTML comments should still be detected"
2236 );
2237 assert!(result[0].message.contains("Use fenced code blocks"));
2238 }
2239
2240 #[test]
2241 fn test_four_space_indented_fence_is_not_valid_fence() {
2242 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2245
2246 assert!(rule.is_fenced_code_block_start("```"));
2248 assert!(rule.is_fenced_code_block_start(" ```"));
2249 assert!(rule.is_fenced_code_block_start(" ```"));
2250 assert!(rule.is_fenced_code_block_start(" ```"));
2251
2252 assert!(!rule.is_fenced_code_block_start(" ```"));
2254 assert!(!rule.is_fenced_code_block_start(" ```"));
2255 assert!(!rule.is_fenced_code_block_start(" ```"));
2256
2257 assert!(!rule.is_fenced_code_block_start("\t```"));
2259 }
2260
2261 #[test]
2262 fn test_issue_237_indented_fenced_block_detected_as_indented() {
2263 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2269
2270 let content = r#"## Test
2272
2273 ```js
2274 var foo = "hello";
2275 ```
2276"#;
2277
2278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2279 let result = rule.check(&ctx).unwrap();
2280
2281 assert_eq!(
2283 result.len(),
2284 1,
2285 "4-space indented fence should be detected as indented code block"
2286 );
2287 assert!(
2288 result[0].message.contains("Use fenced code blocks"),
2289 "Expected 'Use fenced code blocks' message"
2290 );
2291 }
2292
2293 #[test]
2294 fn test_issue_276_indented_code_in_list() {
2295 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2298
2299 let content = r#"1. First item
23002. Second item with code:
2301
2302 # This is a code block in a list
2303 print("Hello, world!")
2304
23054. Third item"#;
2306
2307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2308 let result = rule.check(&ctx).unwrap();
2309
2310 assert!(
2312 !result.is_empty(),
2313 "Indented code block inside list should be flagged when style=fenced"
2314 );
2315 assert!(
2316 result[0].message.contains("Use fenced code blocks"),
2317 "Expected 'Use fenced code blocks' message"
2318 );
2319 }
2320
2321 #[test]
2322 fn test_three_space_indented_fence_is_valid() {
2323 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2325
2326 let content = r#"## Test
2327
2328 ```js
2329 var foo = "hello";
2330 ```
2331"#;
2332
2333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2334 let result = rule.check(&ctx).unwrap();
2335
2336 assert_eq!(
2338 result.len(),
2339 0,
2340 "3-space indented fence should be recognized as valid fenced code block"
2341 );
2342 }
2343
2344 #[test]
2345 fn test_indented_style_with_deeply_indented_fenced() {
2346 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2349
2350 let content = r#"Text
2351
2352 ```js
2353 var foo = "hello";
2354 ```
2355
2356More text
2357"#;
2358
2359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2360 let result = rule.check(&ctx).unwrap();
2361
2362 assert_eq!(
2365 result.len(),
2366 0,
2367 "4-space indented content should be valid when style=indented"
2368 );
2369 }
2370
2371 #[test]
2372 fn test_fix_misplaced_fenced_block() {
2373 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2376
2377 let content = r#"## Test
2378
2379 ```js
2380 var foo = "hello";
2381 ```
2382"#;
2383
2384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2385 let fixed = rule.fix(&ctx).unwrap();
2386
2387 let expected = r#"## Test
2389
2390```js
2391var foo = "hello";
2392```
2393"#;
2394
2395 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2396 }
2397
2398 #[test]
2399 fn test_fix_regular_indented_block() {
2400 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2403
2404 let content = r#"Text
2405
2406 var foo = "hello";
2407 console.log(foo);
2408
2409More text
2410"#;
2411
2412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2413 let fixed = rule.fix(&ctx).unwrap();
2414
2415 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2417 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2418 }
2419
2420 #[test]
2421 fn test_fix_indented_block_with_fence_like_content() {
2422 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2426
2427 let content = r#"Text
2428
2429 some code
2430 ```not a fence opener
2431 more code
2432"#;
2433
2434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2435 let fixed = rule.fix(&ctx).unwrap();
2436
2437 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
2439 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2440 }
2441
2442 #[test]
2443 fn test_fix_mixed_indented_and_misplaced_blocks() {
2444 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2446
2447 let content = r#"Text
2448
2449 regular indented code
2450
2451More text
2452
2453 ```python
2454 print("hello")
2455 ```
2456"#;
2457
2458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2459 let fixed = rule.fix(&ctx).unwrap();
2460
2461 assert!(
2463 fixed.contains("```\nregular indented code\n```"),
2464 "First block should be wrapped in fences"
2465 );
2466
2467 assert!(
2469 fixed.contains("\n```python\nprint(\"hello\")\n```"),
2470 "Second block should be dedented, not double-wrapped"
2471 );
2472 assert!(
2474 !fixed.contains("```\n```python"),
2475 "Should not have nested fence openers"
2476 );
2477 }
2478}