1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::element_cache::ElementCache;
3use crate::utils::mkdocs_admonitions;
4use crate::utils::mkdocs_footnotes;
5use crate::utils::mkdocs_tabs;
6use crate::utils::range_utils::calculate_line_range;
7use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
8use toml;
9
10mod md046_config;
11pub use md046_config::CodeBlockStyle;
12use md046_config::MD046Config;
13
14struct IndentContext<'a> {
16 in_list_context: &'a [bool],
17 in_tab_context: &'a [bool],
18 in_admonition_context: &'a [bool],
19 in_jsx_context: &'a [bool],
20}
21
22#[derive(Clone)]
28pub struct MD046CodeBlockStyle {
29 config: MD046Config,
30}
31
32impl MD046CodeBlockStyle {
33 pub fn new(style: CodeBlockStyle) -> Self {
34 Self {
35 config: MD046Config { style },
36 }
37 }
38
39 pub fn from_config_struct(config: MD046Config) -> Self {
40 Self { config }
41 }
42
43 fn has_valid_fence_indent(line: &str) -> bool {
48 ElementCache::calculate_indentation_width_default(line) < 4
49 }
50
51 fn is_fenced_code_block_start(&self, line: &str) -> bool {
60 if !Self::has_valid_fence_indent(line) {
61 return false;
62 }
63
64 let trimmed = line.trim_start();
65 trimmed.starts_with("```") || trimmed.starts_with("~~~")
66 }
67
68 fn is_list_item(&self, line: &str) -> bool {
69 let trimmed = line.trim_start();
70 (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
71 || (trimmed.len() > 2
72 && trimmed.chars().next().unwrap().is_numeric()
73 && (trimmed.contains(". ") || trimmed.contains(") ")))
74 }
75
76 fn is_footnote_definition(&self, line: &str) -> bool {
96 let trimmed = line.trim_start();
97 if !trimmed.starts_with("[^") || trimmed.len() < 5 {
98 return false;
99 }
100
101 if let Some(close_bracket_pos) = trimmed.find("]:")
102 && close_bracket_pos > 2
103 {
104 let label = &trimmed[2..close_bracket_pos];
105
106 if label.trim().is_empty() {
107 return false;
108 }
109
110 if label.contains('\r') {
112 return false;
113 }
114
115 if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
117 return true;
118 }
119 }
120
121 false
122 }
123
124 fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
147 let mut in_continuation_context = vec![false; lines.len()];
148 let mut last_list_item_line: Option<usize> = None;
149 let mut last_footnote_line: Option<usize> = None;
150 let mut blank_line_count = 0;
151
152 for (i, line) in lines.iter().enumerate() {
153 let trimmed = line.trim_start();
154 let indent_len = line.len() - trimmed.len();
155
156 if self.is_list_item(line) {
158 last_list_item_line = Some(i);
159 last_footnote_line = None; blank_line_count = 0;
161 in_continuation_context[i] = true;
162 continue;
163 }
164
165 if self.is_footnote_definition(line) {
167 last_footnote_line = Some(i);
168 last_list_item_line = None; blank_line_count = 0;
170 in_continuation_context[i] = true;
171 continue;
172 }
173
174 if line.trim().is_empty() {
176 if last_list_item_line.is_some() || last_footnote_line.is_some() {
178 blank_line_count += 1;
179 in_continuation_context[i] = true;
180
181 }
185 continue;
186 }
187
188 if indent_len == 0 && !trimmed.is_empty() {
190 if trimmed.starts_with('#') {
194 last_list_item_line = None;
195 last_footnote_line = None;
196 blank_line_count = 0;
197 continue;
198 }
199
200 if trimmed.starts_with("---") || trimmed.starts_with("***") {
202 last_list_item_line = None;
203 last_footnote_line = None;
204 blank_line_count = 0;
205 continue;
206 }
207
208 if let Some(list_line) = last_list_item_line
211 && (i - list_line > 5 || blank_line_count > 1)
212 {
213 last_list_item_line = None;
214 }
215
216 if last_footnote_line.is_some() {
218 last_footnote_line = None;
219 }
220
221 blank_line_count = 0;
222
223 if last_list_item_line.is_none() && last_footnote_line.is_some() {
225 last_footnote_line = None;
226 }
227 continue;
228 }
229
230 if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
232 in_continuation_context[i] = true;
233 blank_line_count = 0;
234 }
235 }
236
237 in_continuation_context
238 }
239
240 fn is_indented_code_block_with_context(
242 &self,
243 lines: &[&str],
244 i: usize,
245 is_mkdocs: bool,
246 ctx: &IndentContext,
247 ) -> bool {
248 if i >= lines.len() {
249 return false;
250 }
251
252 let line = lines[i];
253
254 let indent = ElementCache::calculate_indentation_width_default(line);
256 if indent < 4 {
257 return false;
258 }
259
260 if ctx.in_list_context[i] {
262 return false;
263 }
264
265 if is_mkdocs && ctx.in_tab_context[i] {
267 return false;
268 }
269
270 if is_mkdocs && ctx.in_admonition_context[i] {
273 return false;
274 }
275
276 if ctx.in_jsx_context.get(i).copied().unwrap_or(false) {
278 return false;
279 }
280
281 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
284 let prev_is_indented_code = i > 0
285 && ElementCache::calculate_indentation_width_default(lines[i - 1]) >= 4
286 && !ctx.in_list_context[i - 1]
287 && !(is_mkdocs && ctx.in_tab_context[i - 1])
288 && !(is_mkdocs && ctx.in_admonition_context[i - 1]);
289
290 if !has_blank_line_before && !prev_is_indented_code {
293 return false;
294 }
295
296 true
297 }
298
299 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
301 let mut in_tab_context = vec![false; lines.len()];
302 let mut current_tab_indent: Option<usize> = None;
303
304 for (i, line) in lines.iter().enumerate() {
305 if mkdocs_tabs::is_tab_marker(line) {
307 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
308 current_tab_indent = Some(tab_indent);
309 in_tab_context[i] = true;
310 continue;
311 }
312
313 if let Some(tab_indent) = current_tab_indent {
315 if mkdocs_tabs::is_tab_content(line, tab_indent) {
316 in_tab_context[i] = true;
317 } else if !line.trim().is_empty() && ElementCache::calculate_indentation_width_default(line) < 4 {
318 current_tab_indent = None;
320 } else {
321 in_tab_context[i] = true;
323 }
324 }
325 }
326
327 in_tab_context
328 }
329
330 fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
339 let mut in_admonition_context = vec![false; lines.len()];
340 let mut admonition_stack: Vec<usize> = Vec::new();
342
343 for (i, line) in lines.iter().enumerate() {
344 let line_indent = ElementCache::calculate_indentation_width_default(line);
345
346 if mkdocs_admonitions::is_admonition_start(line) {
348 let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
349
350 while let Some(&top_indent) = admonition_stack.last() {
352 if adm_indent <= top_indent {
354 admonition_stack.pop();
355 } else {
356 break;
357 }
358 }
359
360 admonition_stack.push(adm_indent);
362 in_admonition_context[i] = true;
363 continue;
364 }
365
366 if line.trim().is_empty() {
368 if !admonition_stack.is_empty() {
369 in_admonition_context[i] = true;
370 }
371 continue;
372 }
373
374 while let Some(&top_indent) = admonition_stack.last() {
377 if line_indent >= top_indent + 4 {
379 break;
381 } else {
382 admonition_stack.pop();
384 }
385 }
386
387 if !admonition_stack.is_empty() {
389 in_admonition_context[i] = true;
390 }
391 }
392
393 in_admonition_context
394 }
395
396 fn categorize_indented_blocks(
408 &self,
409 lines: &[&str],
410 is_mkdocs: bool,
411 in_list_context: &[bool],
412 in_tab_context: &[bool],
413 in_admonition_context: &[bool],
414 in_jsx_context: &[bool],
415 ) -> (Vec<bool>, Vec<bool>) {
416 let mut is_misplaced = vec![false; lines.len()];
417 let mut contains_fences = vec![false; lines.len()];
418
419 let ictx = IndentContext {
420 in_list_context,
421 in_tab_context,
422 in_admonition_context,
423 in_jsx_context,
424 };
425
426 let mut i = 0;
428 while i < lines.len() {
429 if !self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) {
431 i += 1;
432 continue;
433 }
434
435 let block_start = i;
437 let mut block_end = i;
438
439 while block_end < lines.len()
440 && self.is_indented_code_block_with_context(lines, block_end, is_mkdocs, &ictx)
441 {
442 block_end += 1;
443 }
444
445 if block_end > block_start {
447 let first_line = lines[block_start].trim_start();
448 let last_line = lines[block_end - 1].trim_start();
449
450 let is_backtick_fence = first_line.starts_with("```");
452 let is_tilde_fence = first_line.starts_with("~~~");
453
454 if is_backtick_fence || is_tilde_fence {
455 let fence_char = if is_backtick_fence { '`' } else { '~' };
456 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
457
458 let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
460 let after_closer = &last_line[closer_fence_len..];
461
462 if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
463 is_misplaced[block_start..block_end].fill(true);
465 } else {
466 contains_fences[block_start..block_end].fill(true);
468 }
469 } else {
470 let has_fence_markers = (block_start..block_end).any(|j| {
473 let trimmed = lines[j].trim_start();
474 trimmed.starts_with("```") || trimmed.starts_with("~~~")
475 });
476
477 if has_fence_markers {
478 contains_fences[block_start..block_end].fill(true);
479 }
480 }
481 }
482
483 i = block_end;
484 }
485
486 (is_misplaced, contains_fences)
487 }
488
489 fn check_unclosed_code_blocks(
490 &self,
491 ctx: &crate::lint_context::LintContext,
492 ) -> Result<Vec<LintWarning>, LintError> {
493 let mut warnings = Vec::new();
494 let lines = ctx.raw_lines();
495
496 let options = Options::all();
498 let parser = Parser::new_ext(ctx.content, options).into_offset_iter();
499
500 let mut code_blocks: Vec<(usize, usize, String, usize, bool, bool)> = Vec::new();
502 let mut current_block_start: Option<(usize, String, usize, bool)> = None;
503
504 for (event, range) in parser {
505 match event {
506 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
507 let line_idx = ctx
509 .line_offsets
510 .iter()
511 .enumerate()
512 .rev()
513 .find(|&(_, offset)| *offset <= range.start)
514 .map(|(idx, _)| idx)
515 .unwrap_or(0);
516
517 let line = lines.get(line_idx).unwrap_or(&"");
519 let trimmed = line.trim();
520
521 let fence_marker = if let Some(pos) = trimmed.find("```") {
523 let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
524 "`".repeat(count)
525 } else if let Some(pos) = trimmed.find("~~~") {
526 let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
527 "~".repeat(count)
528 } else {
529 "```".to_string()
530 };
531
532 let lang_info = info.to_string().to_lowercase();
534 let is_markdown_doc = lang_info.starts_with("markdown") || lang_info.starts_with("md");
535
536 current_block_start = Some((range.start, fence_marker, line_idx, is_markdown_doc));
537 }
538 Event::End(TagEnd::CodeBlock) => {
539 if let Some((start, fence_marker, line_idx, is_markdown_doc)) = current_block_start.take() {
540 code_blocks.push((start, range.end, fence_marker, line_idx, true, is_markdown_doc));
541 }
542 }
543 _ => {}
544 }
545 }
546
547 let has_markdown_doc_block = code_blocks.iter().any(|(_, _, _, _, _, is_md)| *is_md);
551
552 if !has_markdown_doc_block {
556 for (block_start, block_end, fence_marker, opening_line_idx, is_fenced, _is_md) in &code_blocks {
557 if !is_fenced {
558 continue;
559 }
560
561 if *block_end != ctx.content.len() {
563 continue;
564 }
565
566 let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
569 let trimmed = last_non_empty_line.trim();
570 let fence_char = fence_marker.chars().next().unwrap_or('`');
571
572 let has_closing_fence = if fence_char == '`' {
574 trimmed.starts_with("```") && {
575 let fence_len = trimmed.chars().take_while(|&c| c == '`').count();
576 trimmed[fence_len..].trim().is_empty()
577 }
578 } else {
579 trimmed.starts_with("~~~") && {
580 let fence_len = trimmed.chars().take_while(|&c| c == '~').count();
581 trimmed[fence_len..].trim().is_empty()
582 }
583 };
584
585 if !has_closing_fence {
586 let line = lines.get(*opening_line_idx).unwrap_or(&"");
587 let (start_line, start_col, end_line, end_col) = calculate_line_range(*opening_line_idx + 1, line);
588
589 if let Some(line_info) = ctx.lines.get(*opening_line_idx)
591 && line_info.in_html_comment
592 {
593 continue;
594 }
595
596 warnings.push(LintWarning {
597 rule_name: Some(self.name().to_string()),
598 line: start_line,
599 column: start_col,
600 end_line,
601 end_column: end_col,
602 message: format!("Code block opened with '{fence_marker}' but never closed"),
603 severity: Severity::Warning,
604 fix: Some(Fix {
605 range: (ctx.content.len()..ctx.content.len()),
606 replacement: format!("\n{fence_marker}"),
607 }),
608 });
609 }
610
611 let _ = block_start; }
613 }
614
615 if !has_markdown_doc_block && let Some((_start, fence_marker, line_idx, _is_md)) = current_block_start {
618 let line = lines.get(line_idx).unwrap_or(&"");
619 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
620
621 if let Some(line_info) = ctx.lines.get(line_idx)
623 && line_info.in_html_comment
624 {
625 return Ok(warnings);
626 }
627
628 warnings.push(LintWarning {
629 rule_name: Some(self.name().to_string()),
630 line: start_line,
631 column: start_col,
632 end_line,
633 end_column: end_col,
634 message: format!("Code block opened with '{fence_marker}' but never closed"),
635 severity: Severity::Warning,
636 fix: Some(Fix {
637 range: (ctx.content.len()..ctx.content.len()),
638 replacement: format!("\n{fence_marker}"),
639 }),
640 });
641 }
642
643 Ok(warnings)
647 }
648
649 fn detect_style(&self, content: &str, is_mkdocs: bool, in_jsx_context: &[bool]) -> Option<CodeBlockStyle> {
650 if content.is_empty() {
652 return None;
653 }
654
655 let lines: Vec<&str> = content.lines().collect();
656 let mut fenced_count = 0;
657 let mut indented_count = 0;
658
659 let in_list_context = self.precompute_block_continuation_context(&lines);
661 let in_tab_context = if is_mkdocs {
662 self.precompute_mkdocs_tab_context(&lines)
663 } else {
664 vec![false; lines.len()]
665 };
666 let in_admonition_context = if is_mkdocs {
667 self.precompute_mkdocs_admonition_context(&lines)
668 } else {
669 vec![false; lines.len()]
670 };
671
672 let ictx = IndentContext {
673 in_list_context: &in_list_context,
674 in_tab_context: &in_tab_context,
675 in_admonition_context: &in_admonition_context,
676 in_jsx_context,
677 };
678
679 let mut in_fenced = false;
681 let mut prev_was_indented = false;
682
683 for (i, line) in lines.iter().enumerate() {
684 if self.is_fenced_code_block_start(line) {
685 if !in_fenced {
686 fenced_count += 1;
688 in_fenced = true;
689 } else {
690 in_fenced = false;
692 }
693 } else if !in_fenced && self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &ictx) {
694 if !prev_was_indented {
696 indented_count += 1;
697 }
698 prev_was_indented = true;
699 } else {
700 prev_was_indented = false;
701 }
702 }
703
704 if fenced_count == 0 && indented_count == 0 {
705 None
707 } else if fenced_count > 0 && indented_count == 0 {
708 Some(CodeBlockStyle::Fenced)
710 } else if fenced_count == 0 && indented_count > 0 {
711 Some(CodeBlockStyle::Indented)
713 } else {
714 if fenced_count >= indented_count {
717 Some(CodeBlockStyle::Fenced)
718 } else {
719 Some(CodeBlockStyle::Indented)
720 }
721 }
722 }
723}
724
725#[inline]
726fn line_idx_from_offset(line_offsets: &[usize], offset: usize) -> usize {
727 match line_offsets.binary_search(&offset) {
728 Ok(idx) => idx,
729 Err(idx) => idx.saturating_sub(1),
730 }
731}
732
733impl Rule for MD046CodeBlockStyle {
734 fn name(&self) -> &'static str {
735 "MD046"
736 }
737
738 fn description(&self) -> &'static str {
739 "Code blocks should use a consistent style"
740 }
741
742 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
743 if ctx.content.is_empty() {
745 return Ok(Vec::new());
746 }
747
748 if !ctx.content.contains("```")
750 && !ctx.content.contains("~~~")
751 && !ctx.content.contains(" ")
752 && !ctx.content.contains('\t')
753 {
754 return Ok(Vec::new());
755 }
756
757 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
759
760 if !unclosed_warnings.is_empty() {
762 return Ok(unclosed_warnings);
763 }
764
765 let lines = ctx.raw_lines();
767 let mut warnings = Vec::new();
768
769 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
771
772 let in_jsx_context: Vec<bool> = (0..lines.len())
774 .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block))
775 .collect();
776
777 let target_style = match self.config.style {
779 CodeBlockStyle::Consistent => self
780 .detect_style(ctx.content, is_mkdocs, &in_jsx_context)
781 .unwrap_or(CodeBlockStyle::Fenced),
782 _ => self.config.style,
783 };
784
785 let in_tab_context = if is_mkdocs {
787 self.precompute_mkdocs_tab_context(lines)
788 } else {
789 vec![false; lines.len()]
790 };
791 let in_admonition_context = if is_mkdocs {
792 self.precompute_mkdocs_admonition_context(lines)
793 } else {
794 vec![false; lines.len()]
795 };
796
797 let mut in_fenced_block = vec![false; lines.len()];
800 let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
801
802 let options = Options::all();
803 let parser = Parser::new_ext(ctx.content, options).into_offset_iter();
804
805 for (event, range) in parser {
806 let start = range.start;
807 let end = range.end;
808
809 if start >= ctx.content.len() || end > ctx.content.len() {
810 continue;
811 }
812
813 let start_line_idx = line_idx_from_offset(&ctx.line_offsets, start);
815
816 match event {
817 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => {
818 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
820 if line_info.byte_offset >= start && line_info.byte_offset < end {
821 in_fenced_block[line_idx] = true;
822 }
823 }
824
825 if target_style == CodeBlockStyle::Indented {
827 let line = lines.get(start_line_idx).unwrap_or(&"");
828
829 if ctx.lines.get(start_line_idx).is_some_and(|info| info.in_html_comment) {
831 continue;
832 }
833
834 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
835 warnings.push(LintWarning {
836 rule_name: Some(self.name().to_string()),
837 line: start_line,
838 column: start_col,
839 end_line,
840 end_column: end_col,
841 message: "Use indented code blocks".to_string(),
842 severity: Severity::Warning,
843 fix: None,
844 });
845 }
846 }
847 Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
848 if target_style == CodeBlockStyle::Fenced && !reported_indented_lines.contains(&start_line_idx) {
852 let line = lines.get(start_line_idx).unwrap_or(&"");
853
854 if ctx.lines.get(start_line_idx).is_some_and(|info| {
857 info.in_html_comment
858 || info.in_html_block
859 || info.in_jsx_block
860 || info.in_mkdocstrings
861 || info.blockquote.is_some()
862 }) {
863 continue;
864 }
865
866 if mkdocs_footnotes::is_within_footnote_definition(ctx.content, start) {
868 continue;
869 }
870
871 if is_mkdocs && in_tab_context.get(start_line_idx).copied().unwrap_or(false) {
873 continue;
874 }
875
876 if is_mkdocs && in_admonition_context.get(start_line_idx).copied().unwrap_or(false) {
878 continue;
879 }
880
881 reported_indented_lines.insert(start_line_idx);
882
883 let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
884 warnings.push(LintWarning {
885 rule_name: Some(self.name().to_string()),
886 line: start_line,
887 column: start_col,
888 end_line,
889 end_column: end_col,
890 message: "Use fenced code blocks".to_string(),
891 severity: Severity::Warning,
892 fix: None,
893 });
894 }
895 }
896 _ => {}
897 }
898 }
899
900 warnings.sort_by_key(|w| (w.line, w.column));
902
903 Ok(warnings)
904 }
905
906 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
907 let content = ctx.content;
908 if content.is_empty() {
909 return Ok(String::new());
910 }
911
912 let lines = ctx.raw_lines();
913
914 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
916
917 let in_jsx_context: Vec<bool> = (0..lines.len())
919 .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block))
920 .collect();
921
922 let target_style = match self.config.style {
923 CodeBlockStyle::Consistent => self
924 .detect_style(content, is_mkdocs, &in_jsx_context)
925 .unwrap_or(CodeBlockStyle::Fenced),
926 _ => self.config.style,
927 };
928
929 let in_list_context = self.precompute_block_continuation_context(lines);
931 let in_tab_context = if is_mkdocs {
932 self.precompute_mkdocs_tab_context(lines)
933 } else {
934 vec![false; lines.len()]
935 };
936 let in_admonition_context = if is_mkdocs {
937 self.precompute_mkdocs_admonition_context(lines)
938 } else {
939 vec![false; lines.len()]
940 };
941
942 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(
946 lines,
947 is_mkdocs,
948 &in_list_context,
949 &in_tab_context,
950 &in_admonition_context,
951 &in_jsx_context,
952 );
953
954 let ictx = IndentContext {
955 in_list_context: &in_list_context,
956 in_tab_context: &in_tab_context,
957 in_admonition_context: &in_admonition_context,
958 in_jsx_context: &in_jsx_context,
959 };
960
961 let mut result = String::with_capacity(content.len());
962 let mut in_fenced_block = false;
963 let mut fenced_fence_type = None;
964 let mut in_indented_block = false;
965
966 let mut current_block_disabled = false;
968
969 for (i, line) in lines.iter().enumerate() {
970 let line_num = i + 1;
971 let trimmed = line.trim_start();
972
973 if !in_fenced_block
976 && Self::has_valid_fence_indent(line)
977 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
978 {
979 current_block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
981 in_fenced_block = true;
982 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
983
984 if current_block_disabled {
985 result.push_str(line);
987 result.push('\n');
988 } else if target_style == CodeBlockStyle::Indented {
989 in_indented_block = true;
991 } else {
992 result.push_str(line);
994 result.push('\n');
995 }
996 } else if in_fenced_block && fenced_fence_type.is_some() {
997 let fence = fenced_fence_type.unwrap();
998 if trimmed.starts_with(fence) {
999 in_fenced_block = false;
1000 fenced_fence_type = None;
1001 in_indented_block = false;
1002
1003 if current_block_disabled {
1004 result.push_str(line);
1005 result.push('\n');
1006 } else if target_style == CodeBlockStyle::Indented {
1007 } else {
1009 result.push_str(line);
1011 result.push('\n');
1012 }
1013 current_block_disabled = false;
1014 } else if current_block_disabled {
1015 result.push_str(line);
1017 result.push('\n');
1018 } else if target_style == CodeBlockStyle::Indented {
1019 result.push_str(" ");
1023 result.push_str(line);
1024 result.push('\n');
1025 } else {
1026 result.push_str(line);
1028 result.push('\n');
1029 }
1030 } else if self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) {
1031 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
1035 result.push_str(line);
1036 result.push('\n');
1037 continue;
1038 }
1039
1040 let prev_line_is_indented =
1042 i > 0 && self.is_indented_code_block_with_context(lines, i - 1, is_mkdocs, &ictx);
1043
1044 if target_style == CodeBlockStyle::Fenced {
1045 let trimmed_content = line.trim_start();
1046
1047 if misplaced_fence_lines[i] {
1050 result.push_str(trimmed_content);
1052 result.push('\n');
1053 } else if unsafe_fence_lines[i] {
1054 result.push_str(line);
1057 result.push('\n');
1058 } else if !prev_line_is_indented && !in_indented_block {
1059 result.push_str("```\n");
1061 result.push_str(trimmed_content);
1062 result.push('\n');
1063 in_indented_block = true;
1064 } else {
1065 result.push_str(trimmed_content);
1067 result.push('\n');
1068 }
1069
1070 let next_line_is_indented =
1072 i < lines.len() - 1 && self.is_indented_code_block_with_context(lines, i + 1, is_mkdocs, &ictx);
1073 if !next_line_is_indented
1075 && in_indented_block
1076 && !misplaced_fence_lines[i]
1077 && !unsafe_fence_lines[i]
1078 {
1079 result.push_str("```\n");
1080 in_indented_block = false;
1081 }
1082 } else {
1083 result.push_str(line);
1085 result.push('\n');
1086 }
1087 } else {
1088 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1090 result.push_str("```\n");
1091 in_indented_block = false;
1092 }
1093
1094 result.push_str(line);
1095 result.push('\n');
1096 }
1097 }
1098
1099 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1101 result.push_str("```\n");
1102 }
1103
1104 if let Some(fence_type) = fenced_fence_type
1106 && in_fenced_block
1107 {
1108 result.push_str(fence_type);
1109 result.push('\n');
1110 }
1111
1112 if !content.ends_with('\n') && result.ends_with('\n') {
1114 result.pop();
1115 }
1116
1117 Ok(result)
1118 }
1119
1120 fn category(&self) -> RuleCategory {
1122 RuleCategory::CodeBlock
1123 }
1124
1125 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1127 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1130 }
1131
1132 fn as_any(&self) -> &dyn std::any::Any {
1133 self
1134 }
1135
1136 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1137 let json_value = serde_json::to_value(&self.config).ok()?;
1138 Some((
1139 self.name().to_string(),
1140 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1141 ))
1142 }
1143
1144 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1145 where
1146 Self: Sized,
1147 {
1148 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1149 Box::new(Self::from_config_struct(rule_config))
1150 }
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155 use super::*;
1156 use crate::lint_context::LintContext;
1157
1158 #[test]
1159 fn test_fenced_code_block_detection() {
1160 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1161 assert!(rule.is_fenced_code_block_start("```"));
1162 assert!(rule.is_fenced_code_block_start("```rust"));
1163 assert!(rule.is_fenced_code_block_start("~~~"));
1164 assert!(rule.is_fenced_code_block_start("~~~python"));
1165 assert!(rule.is_fenced_code_block_start(" ```"));
1166 assert!(!rule.is_fenced_code_block_start("``"));
1167 assert!(!rule.is_fenced_code_block_start("~~"));
1168 assert!(!rule.is_fenced_code_block_start("Regular text"));
1169 }
1170
1171 #[test]
1172 fn test_consistent_style_with_fenced_blocks() {
1173 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1174 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1176 let result = rule.check(&ctx).unwrap();
1177
1178 assert_eq!(result.len(), 0);
1180 }
1181
1182 #[test]
1183 fn test_consistent_style_with_indented_blocks() {
1184 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1185 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 let result = rule.check(&ctx).unwrap();
1188
1189 assert_eq!(result.len(), 0);
1191 }
1192
1193 #[test]
1194 fn test_consistent_style_mixed() {
1195 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1196 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198 let result = rule.check(&ctx).unwrap();
1199
1200 assert!(!result.is_empty());
1202 }
1203
1204 #[test]
1205 fn test_fenced_style_with_indented_blocks() {
1206 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1207 let content = "Text\n\n indented code\n more code\n\nMore text";
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209 let result = rule.check(&ctx).unwrap();
1210
1211 assert!(!result.is_empty());
1213 assert!(result[0].message.contains("Use fenced code blocks"));
1214 }
1215
1216 #[test]
1217 fn test_fenced_style_with_tab_indented_blocks() {
1218 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1219 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let result = rule.check(&ctx).unwrap();
1222
1223 assert!(!result.is_empty());
1225 assert!(result[0].message.contains("Use fenced code blocks"));
1226 }
1227
1228 #[test]
1229 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1230 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1231 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1234 let result = rule.check(&ctx).unwrap();
1235
1236 assert!(
1238 !result.is_empty(),
1239 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1240 );
1241 assert!(result[0].message.contains("Use fenced code blocks"));
1242 }
1243
1244 #[test]
1245 fn test_fenced_style_with_one_space_tab_indent() {
1246 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1247 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 let result = rule.check(&ctx).unwrap();
1251
1252 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1253 assert!(result[0].message.contains("Use fenced code blocks"));
1254 }
1255
1256 #[test]
1257 fn test_indented_style_with_fenced_blocks() {
1258 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1259 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1261 let result = rule.check(&ctx).unwrap();
1262
1263 assert!(!result.is_empty());
1265 assert!(result[0].message.contains("Use indented code blocks"));
1266 }
1267
1268 #[test]
1269 fn test_unclosed_code_block() {
1270 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1271 let content = "```\ncode without closing fence";
1272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1273 let result = rule.check(&ctx).unwrap();
1274
1275 assert_eq!(result.len(), 1);
1276 assert!(result[0].message.contains("never closed"));
1277 }
1278
1279 #[test]
1280 fn test_nested_code_blocks() {
1281 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1282 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284 let result = rule.check(&ctx).unwrap();
1285
1286 assert_eq!(result.len(), 0);
1288 }
1289
1290 #[test]
1291 fn test_fix_indented_to_fenced() {
1292 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1293 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295 let fixed = rule.fix(&ctx).unwrap();
1296
1297 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1298 }
1299
1300 #[test]
1301 fn test_fix_fenced_to_indented() {
1302 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1303 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1304 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1305 let fixed = rule.fix(&ctx).unwrap();
1306
1307 assert!(fixed.contains(" code line 1\n code line 2"));
1308 assert!(!fixed.contains("```"));
1309 }
1310
1311 #[test]
1312 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1313 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1316 let content = r#"# Test
1317
1318```html
1319<!doctype html>
1320<html>
1321 <head>
1322 <title>Test</title>
1323 </head>
1324</html>
1325```
1326"#;
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328 let fixed = rule.fix(&ctx).unwrap();
1329
1330 assert!(
1333 fixed.contains(" <head>"),
1334 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1335 );
1336 assert!(
1337 fixed.contains(" <title>"),
1338 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1339 );
1340 assert!(!fixed.contains("```"), "Fenced markers should be removed");
1341 }
1342
1343 #[test]
1344 fn test_fix_fenced_to_indented_preserves_python_indentation() {
1345 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1347 let content = r#"# Python Example
1348
1349```python
1350def greet(name):
1351 if name:
1352 print(f"Hello, {name}!")
1353 else:
1354 print("Hello, World!")
1355```
1356"#;
1357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358 let fixed = rule.fix(&ctx).unwrap();
1359
1360 assert!(
1362 fixed.contains(" def greet(name):"),
1363 "Function def should have 4 spaces (code block indent)"
1364 );
1365 assert!(
1366 fixed.contains(" if name:"),
1367 "if statement should have 8 spaces (4 code + 4 Python)"
1368 );
1369 assert!(
1370 fixed.contains(" print"),
1371 "print should have 12 spaces (4 code + 8 Python)"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1377 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1379 let content = r#"# Config
1380
1381```yaml
1382server:
1383 host: localhost
1384 port: 8080
1385 ssl:
1386 enabled: true
1387 cert: /path/to/cert
1388```
1389"#;
1390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391 let fixed = rule.fix(&ctx).unwrap();
1392
1393 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
1394 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
1395 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
1396 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
1397 }
1398
1399 #[test]
1400 fn test_fix_fenced_to_indented_preserves_empty_lines() {
1401 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1403 let content = "```\nline1\n\nline2\n```\n";
1404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1405 let fixed = rule.fix(&ctx).unwrap();
1406
1407 assert!(fixed.contains(" line1"), "line1 should be indented");
1409 assert!(fixed.contains(" line2"), "line2 should be indented");
1410 }
1412
1413 #[test]
1414 fn test_fix_fenced_to_indented_multiple_blocks() {
1415 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1417 let content = r#"# Doc
1418
1419```python
1420def foo():
1421 pass
1422```
1423
1424Text between.
1425
1426```yaml
1427key:
1428 value: 1
1429```
1430"#;
1431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432 let fixed = rule.fix(&ctx).unwrap();
1433
1434 assert!(fixed.contains(" def foo():"), "Python def should be indented");
1435 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
1436 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
1437 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
1438 assert!(!fixed.contains("```"), "No fence markers should remain");
1439 }
1440
1441 #[test]
1442 fn test_fix_unclosed_block() {
1443 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1444 let content = "```\ncode without closing";
1445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1446 let fixed = rule.fix(&ctx).unwrap();
1447
1448 assert!(fixed.ends_with("```"));
1450 }
1451
1452 #[test]
1453 fn test_code_block_in_list() {
1454 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1455 let content = "- List item\n code in list\n more code\n- Next item";
1456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1457 let result = rule.check(&ctx).unwrap();
1458
1459 assert_eq!(result.len(), 0);
1461 }
1462
1463 #[test]
1464 fn test_detect_style_fenced() {
1465 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1466 let content = "```\ncode\n```";
1467 let style = rule.detect_style(content, false, &[]);
1468
1469 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1470 }
1471
1472 #[test]
1473 fn test_detect_style_indented() {
1474 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1475 let content = "Text\n\n code\n\nMore";
1476 let style = rule.detect_style(content, false, &[]);
1477
1478 assert_eq!(style, Some(CodeBlockStyle::Indented));
1479 }
1480
1481 #[test]
1482 fn test_detect_style_none() {
1483 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1484 let content = "No code blocks here";
1485 let style = rule.detect_style(content, false, &[]);
1486
1487 assert_eq!(style, None);
1488 }
1489
1490 #[test]
1491 fn test_tilde_fence() {
1492 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1493 let content = "~~~\ncode\n~~~";
1494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495 let result = rule.check(&ctx).unwrap();
1496
1497 assert_eq!(result.len(), 0);
1499 }
1500
1501 #[test]
1502 fn test_language_specification() {
1503 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1504 let content = "```rust\nfn main() {}\n```";
1505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1506 let result = rule.check(&ctx).unwrap();
1507
1508 assert_eq!(result.len(), 0);
1509 }
1510
1511 #[test]
1512 fn test_empty_content() {
1513 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1514 let content = "";
1515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516 let result = rule.check(&ctx).unwrap();
1517
1518 assert_eq!(result.len(), 0);
1519 }
1520
1521 #[test]
1522 fn test_default_config() {
1523 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1524 let (name, _config) = rule.default_config_section().unwrap();
1525 assert_eq!(name, "MD046");
1526 }
1527
1528 #[test]
1529 fn test_markdown_documentation_block() {
1530 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1531 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1533 let result = rule.check(&ctx).unwrap();
1534
1535 assert_eq!(result.len(), 0);
1537 }
1538
1539 #[test]
1540 fn test_preserve_trailing_newline() {
1541 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1542 let content = "```\ncode\n```\n";
1543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544 let fixed = rule.fix(&ctx).unwrap();
1545
1546 assert_eq!(fixed, content);
1547 }
1548
1549 #[test]
1550 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1551 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1552 let content = r#"# Document
1553
1554=== "Python"
1555
1556 This is tab content
1557 Not an indented code block
1558
1559 ```python
1560 def hello():
1561 print("Hello")
1562 ```
1563
1564=== "JavaScript"
1565
1566 More tab content here
1567 Also not an indented code block"#;
1568
1569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1570 let result = rule.check(&ctx).unwrap();
1571
1572 assert_eq!(result.len(), 0);
1574 }
1575
1576 #[test]
1577 fn test_mkdocs_tabs_with_actual_indented_code() {
1578 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1579 let content = r#"# Document
1580
1581=== "Tab 1"
1582
1583 This is tab content
1584
1585Regular text
1586
1587 This is an actual indented code block
1588 Should be flagged"#;
1589
1590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1591 let result = rule.check(&ctx).unwrap();
1592
1593 assert_eq!(result.len(), 1);
1595 assert!(result[0].message.contains("Use fenced code blocks"));
1596 }
1597
1598 #[test]
1599 fn test_mkdocs_tabs_detect_style() {
1600 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1601 let content = r#"=== "Tab 1"
1602
1603 Content in tab
1604 More content
1605
1606=== "Tab 2"
1607
1608 Content in second tab"#;
1609
1610 let style = rule.detect_style(content, true, &[]);
1612 assert_eq!(style, None); let style = rule.detect_style(content, false, &[]);
1616 assert_eq!(style, Some(CodeBlockStyle::Indented));
1617 }
1618
1619 #[test]
1620 fn test_mkdocs_nested_tabs() {
1621 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1622 let content = r#"# Document
1623
1624=== "Outer Tab"
1625
1626 Some content
1627
1628 === "Nested Tab"
1629
1630 Nested tab content
1631 Should not be flagged"#;
1632
1633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1634 let result = rule.check(&ctx).unwrap();
1635
1636 assert_eq!(result.len(), 0);
1638 }
1639
1640 #[test]
1641 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1642 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1645 let content = r#"# Document
1646
1647!!! note
1648 This is normal admonition content, not a code block.
1649 It spans multiple lines.
1650
1651??? warning "Collapsible Warning"
1652 This is also admonition content.
1653
1654???+ tip "Expanded Tip"
1655 And this one too.
1656
1657Regular text outside admonitions."#;
1658
1659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1660 let result = rule.check(&ctx).unwrap();
1661
1662 assert_eq!(
1664 result.len(),
1665 0,
1666 "Admonition content in MkDocs mode should not trigger MD046"
1667 );
1668 }
1669
1670 #[test]
1671 fn test_mkdocs_admonition_with_actual_indented_code() {
1672 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1674 let content = r#"# Document
1675
1676!!! note
1677 This is admonition content.
1678
1679Regular text ends the admonition.
1680
1681 This is actual indented code (should be flagged)"#;
1682
1683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1684 let result = rule.check(&ctx).unwrap();
1685
1686 assert_eq!(result.len(), 1);
1688 assert!(result[0].message.contains("Use fenced code blocks"));
1689 }
1690
1691 #[test]
1692 fn test_admonition_in_standard_mode_flagged() {
1693 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1697 let content = r#"# Document
1698
1699!!! note
1700
1701 This looks like code in standard mode.
1702
1703Regular text."#;
1704
1705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1707 let result = rule.check(&ctx).unwrap();
1708
1709 assert_eq!(
1711 result.len(),
1712 1,
1713 "Admonition content in Standard mode should be flagged as indented code"
1714 );
1715 }
1716
1717 #[test]
1718 fn test_mkdocs_admonition_with_fenced_code_inside() {
1719 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1721 let content = r#"# Document
1722
1723!!! note "Code Example"
1724 Here's some code:
1725
1726 ```python
1727 def hello():
1728 print("world")
1729 ```
1730
1731 More text after code.
1732
1733Regular text."#;
1734
1735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1736 let result = rule.check(&ctx).unwrap();
1737
1738 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1740 }
1741
1742 #[test]
1743 fn test_mkdocs_nested_admonitions() {
1744 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1746 let content = r#"# Document
1747
1748!!! note "Outer"
1749 Outer content.
1750
1751 !!! warning "Inner"
1752 Inner content.
1753 More inner content.
1754
1755 Back to outer.
1756
1757Regular text."#;
1758
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1760 let result = rule.check(&ctx).unwrap();
1761
1762 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1764 }
1765
1766 #[test]
1767 fn test_mkdocs_admonition_fix_does_not_wrap() {
1768 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1770 let content = r#"!!! note
1771 Content that should stay as admonition content.
1772 Not be wrapped in code fences.
1773"#;
1774
1775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1776 let fixed = rule.fix(&ctx).unwrap();
1777
1778 assert!(
1780 !fixed.contains("```\n Content"),
1781 "Admonition content should not be wrapped in fences"
1782 );
1783 assert_eq!(fixed, content, "Content should remain unchanged");
1784 }
1785
1786 #[test]
1787 fn test_mkdocs_empty_admonition() {
1788 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1790 let content = r#"!!! note
1791
1792Regular paragraph after empty admonition.
1793
1794 This IS an indented code block (after blank + non-indented line)."#;
1795
1796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1797 let result = rule.check(&ctx).unwrap();
1798
1799 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1801 }
1802
1803 #[test]
1804 fn test_mkdocs_indented_admonition() {
1805 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1807 let content = r#"- List item
1808
1809 !!! note
1810 Indented admonition content.
1811 More content.
1812
1813- Next item"#;
1814
1815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1816 let result = rule.check(&ctx).unwrap();
1817
1818 assert_eq!(
1820 result.len(),
1821 0,
1822 "Indented admonitions (e.g., in lists) should not be flagged"
1823 );
1824 }
1825
1826 #[test]
1827 fn test_footnote_indented_paragraphs_not_flagged() {
1828 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1829 let content = r#"# Test Document with Footnotes
1830
1831This is some text with a footnote[^1].
1832
1833Here's some code:
1834
1835```bash
1836echo "fenced code block"
1837```
1838
1839More text with another footnote[^2].
1840
1841[^1]: Really interesting footnote text.
1842
1843 Even more interesting second paragraph.
1844
1845[^2]: Another footnote.
1846
1847 With a second paragraph too.
1848
1849 And even a third paragraph!"#;
1850
1851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1852 let result = rule.check(&ctx).unwrap();
1853
1854 assert_eq!(result.len(), 0);
1856 }
1857
1858 #[test]
1859 fn test_footnote_definition_detection() {
1860 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1861
1862 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1865 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1866 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1867 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1868 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1869 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1870 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1871 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1872 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1873
1874 assert!(!rule.is_footnote_definition("[^]: No label"));
1876 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1877 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1878 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1879
1880 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1882 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1883 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1884 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1887
1888 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1890 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1891 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1892 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1893 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1894
1895 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1898 }
1899
1900 #[test]
1901 fn test_footnote_with_blank_lines() {
1902 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1906 let content = r#"# Document
1907
1908Text with footnote[^1].
1909
1910[^1]: First paragraph.
1911
1912 Second paragraph after blank line.
1913
1914 Third paragraph after another blank line.
1915
1916Regular text at column 0 ends the footnote."#;
1917
1918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1919 let result = rule.check(&ctx).unwrap();
1920
1921 assert_eq!(
1923 result.len(),
1924 0,
1925 "Indented content within footnotes should not trigger MD046"
1926 );
1927 }
1928
1929 #[test]
1930 fn test_footnote_multiple_consecutive_blank_lines() {
1931 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1934 let content = r#"Text[^1].
1935
1936[^1]: First paragraph.
1937
1938
1939
1940 Content after three blank lines (still part of footnote).
1941
1942Not indented, so footnote ends here."#;
1943
1944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1945 let result = rule.check(&ctx).unwrap();
1946
1947 assert_eq!(
1949 result.len(),
1950 0,
1951 "Multiple blank lines shouldn't break footnote continuation"
1952 );
1953 }
1954
1955 #[test]
1956 fn test_footnote_terminated_by_non_indented_content() {
1957 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1960 let content = r#"[^1]: Footnote content.
1961
1962 More indented content in footnote.
1963
1964This paragraph is not indented, so footnote ends.
1965
1966 This should be flagged as indented code block."#;
1967
1968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1969 let result = rule.check(&ctx).unwrap();
1970
1971 assert_eq!(
1973 result.len(),
1974 1,
1975 "Indented code after footnote termination should be flagged"
1976 );
1977 assert!(
1978 result[0].message.contains("Use fenced code blocks"),
1979 "Expected MD046 warning for indented code block"
1980 );
1981 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1982 }
1983
1984 #[test]
1985 fn test_footnote_terminated_by_structural_elements() {
1986 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1988 let content = r#"[^1]: Footnote content.
1989
1990 More content.
1991
1992## Heading terminates footnote
1993
1994 This indented content should be flagged.
1995
1996---
1997
1998 This should also be flagged (after horizontal rule)."#;
1999
2000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2001 let result = rule.check(&ctx).unwrap();
2002
2003 assert_eq!(
2005 result.len(),
2006 2,
2007 "Both indented blocks after termination should be flagged"
2008 );
2009 }
2010
2011 #[test]
2012 fn test_footnote_with_code_block_inside() {
2013 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2016 let content = r#"Text[^1].
2017
2018[^1]: Footnote with code:
2019
2020 ```python
2021 def hello():
2022 print("world")
2023 ```
2024
2025 More footnote text after code."#;
2026
2027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2028 let result = rule.check(&ctx).unwrap();
2029
2030 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2032 }
2033
2034 #[test]
2035 fn test_footnote_with_8_space_indented_code() {
2036 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2039 let content = r#"Text[^1].
2040
2041[^1]: Footnote with nested code.
2042
2043 code block
2044 more code"#;
2045
2046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2047 let result = rule.check(&ctx).unwrap();
2048
2049 assert_eq!(
2051 result.len(),
2052 0,
2053 "8-space indented code within footnotes represents nested code blocks"
2054 );
2055 }
2056
2057 #[test]
2058 fn test_multiple_footnotes() {
2059 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2062 let content = r#"Text[^1] and more[^2].
2063
2064[^1]: First footnote.
2065
2066 Continuation of first.
2067
2068[^2]: Second footnote starts here, ending the first.
2069
2070 Continuation of second."#;
2071
2072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2073 let result = rule.check(&ctx).unwrap();
2074
2075 assert_eq!(
2077 result.len(),
2078 0,
2079 "Multiple footnotes should each maintain their continuation context"
2080 );
2081 }
2082
2083 #[test]
2084 fn test_list_item_ends_footnote_context() {
2085 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2087 let content = r#"[^1]: Footnote.
2088
2089 Content in footnote.
2090
2091- List item starts here (ends footnote context).
2092
2093 This indented content is part of the list, not the footnote."#;
2094
2095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2096 let result = rule.check(&ctx).unwrap();
2097
2098 assert_eq!(
2100 result.len(),
2101 0,
2102 "List items should end footnote context and start their own"
2103 );
2104 }
2105
2106 #[test]
2107 fn test_footnote_vs_actual_indented_code() {
2108 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2111 let content = r#"# Heading
2112
2113Text with footnote[^1].
2114
2115[^1]: Footnote content.
2116
2117 Part of footnote (should not be flagged).
2118
2119Regular paragraph ends footnote context.
2120
2121 This is actual indented code (MUST be flagged)
2122 Should be detected as code block"#;
2123
2124 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2125 let result = rule.check(&ctx).unwrap();
2126
2127 assert_eq!(
2129 result.len(),
2130 1,
2131 "Must still detect indented code blocks outside footnotes"
2132 );
2133 assert!(
2134 result[0].message.contains("Use fenced code blocks"),
2135 "Expected MD046 warning for indented code"
2136 );
2137 assert!(
2138 result[0].line >= 11,
2139 "Warning should be on the actual indented code line"
2140 );
2141 }
2142
2143 #[test]
2144 fn test_spec_compliant_label_characters() {
2145 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2148
2149 assert!(rule.is_footnote_definition("[^test]: text"));
2151 assert!(rule.is_footnote_definition("[^TEST]: text"));
2152 assert!(rule.is_footnote_definition("[^test-name]: text"));
2153 assert!(rule.is_footnote_definition("[^test_name]: text"));
2154 assert!(rule.is_footnote_definition("[^test123]: text"));
2155 assert!(rule.is_footnote_definition("[^123]: text"));
2156 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2157
2158 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")); }
2166
2167 #[test]
2168 fn test_code_block_inside_html_comment() {
2169 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2172 let content = r#"# Document
2173
2174Some text.
2175
2176<!--
2177Example code block in comment:
2178
2179```typescript
2180console.log("Hello");
2181```
2182
2183More comment text.
2184-->
2185
2186More content."#;
2187
2188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2189 let result = rule.check(&ctx).unwrap();
2190
2191 assert_eq!(
2192 result.len(),
2193 0,
2194 "Code blocks inside HTML comments should not be flagged as unclosed"
2195 );
2196 }
2197
2198 #[test]
2199 fn test_unclosed_fence_inside_html_comment() {
2200 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2202 let content = r#"# Document
2203
2204<!--
2205Example with intentionally unclosed fence:
2206
2207```
2208code without closing
2209-->
2210
2211More content."#;
2212
2213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2214 let result = rule.check(&ctx).unwrap();
2215
2216 assert_eq!(
2217 result.len(),
2218 0,
2219 "Unclosed fences inside HTML comments should be ignored"
2220 );
2221 }
2222
2223 #[test]
2224 fn test_multiline_html_comment_with_indented_code() {
2225 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2227 let content = r#"# Document
2228
2229<!--
2230Example:
2231
2232 indented code
2233 more code
2234
2235End of comment.
2236-->
2237
2238Regular text."#;
2239
2240 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2241 let result = rule.check(&ctx).unwrap();
2242
2243 assert_eq!(
2244 result.len(),
2245 0,
2246 "Indented code inside HTML comments should not be flagged"
2247 );
2248 }
2249
2250 #[test]
2251 fn test_code_block_after_html_comment() {
2252 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2254 let content = r#"# Document
2255
2256<!-- comment -->
2257
2258Text before.
2259
2260 indented code should be flagged
2261
2262More text."#;
2263
2264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2265 let result = rule.check(&ctx).unwrap();
2266
2267 assert_eq!(
2268 result.len(),
2269 1,
2270 "Code blocks after HTML comments should still be detected"
2271 );
2272 assert!(result[0].message.contains("Use fenced code blocks"));
2273 }
2274
2275 #[test]
2276 fn test_four_space_indented_fence_is_not_valid_fence() {
2277 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2280
2281 assert!(rule.is_fenced_code_block_start("```"));
2283 assert!(rule.is_fenced_code_block_start(" ```"));
2284 assert!(rule.is_fenced_code_block_start(" ```"));
2285 assert!(rule.is_fenced_code_block_start(" ```"));
2286
2287 assert!(!rule.is_fenced_code_block_start(" ```"));
2289 assert!(!rule.is_fenced_code_block_start(" ```"));
2290 assert!(!rule.is_fenced_code_block_start(" ```"));
2291
2292 assert!(!rule.is_fenced_code_block_start("\t```"));
2294 }
2295
2296 #[test]
2297 fn test_issue_237_indented_fenced_block_detected_as_indented() {
2298 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2304
2305 let content = r#"## Test
2307
2308 ```js
2309 var foo = "hello";
2310 ```
2311"#;
2312
2313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2314 let result = rule.check(&ctx).unwrap();
2315
2316 assert_eq!(
2318 result.len(),
2319 1,
2320 "4-space indented fence should be detected as indented code block"
2321 );
2322 assert!(
2323 result[0].message.contains("Use fenced code blocks"),
2324 "Expected 'Use fenced code blocks' message"
2325 );
2326 }
2327
2328 #[test]
2329 fn test_issue_276_indented_code_in_list() {
2330 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2333
2334 let content = r#"1. First item
23352. Second item with code:
2336
2337 # This is a code block in a list
2338 print("Hello, world!")
2339
23404. Third item"#;
2341
2342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2343 let result = rule.check(&ctx).unwrap();
2344
2345 assert!(
2347 !result.is_empty(),
2348 "Indented code block inside list should be flagged when style=fenced"
2349 );
2350 assert!(
2351 result[0].message.contains("Use fenced code blocks"),
2352 "Expected 'Use fenced code blocks' message"
2353 );
2354 }
2355
2356 #[test]
2357 fn test_three_space_indented_fence_is_valid() {
2358 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2360
2361 let content = r#"## Test
2362
2363 ```js
2364 var foo = "hello";
2365 ```
2366"#;
2367
2368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2369 let result = rule.check(&ctx).unwrap();
2370
2371 assert_eq!(
2373 result.len(),
2374 0,
2375 "3-space indented fence should be recognized as valid fenced code block"
2376 );
2377 }
2378
2379 #[test]
2380 fn test_indented_style_with_deeply_indented_fenced() {
2381 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2384
2385 let content = r#"Text
2386
2387 ```js
2388 var foo = "hello";
2389 ```
2390
2391More text
2392"#;
2393
2394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2395 let result = rule.check(&ctx).unwrap();
2396
2397 assert_eq!(
2400 result.len(),
2401 0,
2402 "4-space indented content should be valid when style=indented"
2403 );
2404 }
2405
2406 #[test]
2407 fn test_fix_misplaced_fenced_block() {
2408 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2411
2412 let content = r#"## Test
2413
2414 ```js
2415 var foo = "hello";
2416 ```
2417"#;
2418
2419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2420 let fixed = rule.fix(&ctx).unwrap();
2421
2422 let expected = r#"## Test
2424
2425```js
2426var foo = "hello";
2427```
2428"#;
2429
2430 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2431 }
2432
2433 #[test]
2434 fn test_fix_regular_indented_block() {
2435 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2438
2439 let content = r#"Text
2440
2441 var foo = "hello";
2442 console.log(foo);
2443
2444More text
2445"#;
2446
2447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2448 let fixed = rule.fix(&ctx).unwrap();
2449
2450 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2452 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2453 }
2454
2455 #[test]
2456 fn test_fix_indented_block_with_fence_like_content() {
2457 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2461
2462 let content = r#"Text
2463
2464 some code
2465 ```not a fence opener
2466 more code
2467"#;
2468
2469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2470 let fixed = rule.fix(&ctx).unwrap();
2471
2472 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
2474 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2475 }
2476
2477 #[test]
2478 fn test_fix_mixed_indented_and_misplaced_blocks() {
2479 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2481
2482 let content = r#"Text
2483
2484 regular indented code
2485
2486More text
2487
2488 ```python
2489 print("hello")
2490 ```
2491"#;
2492
2493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2494 let fixed = rule.fix(&ctx).unwrap();
2495
2496 assert!(
2498 fixed.contains("```\nregular indented code\n```"),
2499 "First block should be wrapped in fences"
2500 );
2501
2502 assert!(
2504 fixed.contains("\n```python\nprint(\"hello\")\n```"),
2505 "Second block should be dedented, not double-wrapped"
2506 );
2507 assert!(
2509 !fixed.contains("```\n```python"),
2510 "Should not have nested fence openers"
2511 );
2512 }
2513}