1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rules::code_block_utils::CodeBlockStyle;
3use crate::utils::element_cache::ElementCache;
4use crate::utils::mkdocs_admonitions;
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;
11use md046_config::MD046Config;
12
13#[derive(Clone)]
19pub struct MD046CodeBlockStyle {
20 config: MD046Config,
21}
22
23impl MD046CodeBlockStyle {
24 pub fn new(style: CodeBlockStyle) -> Self {
25 Self {
26 config: MD046Config { style },
27 }
28 }
29
30 pub fn from_config_struct(config: MD046Config) -> Self {
31 Self { config }
32 }
33
34 fn has_valid_fence_indent(line: &str) -> bool {
39 ElementCache::calculate_indentation_width_default(line) < 4
40 }
41
42 fn is_fenced_code_block_start(&self, line: &str) -> bool {
51 if !Self::has_valid_fence_indent(line) {
52 return false;
53 }
54
55 let trimmed = line.trim_start();
56 trimmed.starts_with("```") || trimmed.starts_with("~~~")
57 }
58
59 fn is_list_item(&self, line: &str) -> bool {
60 let trimmed = line.trim_start();
61 (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
62 || (trimmed.len() > 2
63 && trimmed.chars().next().unwrap().is_numeric()
64 && (trimmed.contains(". ") || trimmed.contains(") ")))
65 }
66
67 fn is_footnote_definition(&self, line: &str) -> bool {
87 let trimmed = line.trim_start();
88 if !trimmed.starts_with("[^") || trimmed.len() < 5 {
89 return false;
90 }
91
92 if let Some(close_bracket_pos) = trimmed.find("]:")
93 && close_bracket_pos > 2
94 {
95 let label = &trimmed[2..close_bracket_pos];
96
97 if label.trim().is_empty() {
98 return false;
99 }
100
101 if label.contains('\r') {
103 return false;
104 }
105
106 if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
108 return true;
109 }
110 }
111
112 false
113 }
114
115 fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
138 let mut in_continuation_context = vec![false; lines.len()];
139 let mut last_list_item_line: Option<usize> = None;
140 let mut last_footnote_line: Option<usize> = None;
141 let mut blank_line_count = 0;
142
143 for (i, line) in lines.iter().enumerate() {
144 let trimmed = line.trim_start();
145 let indent_len = line.len() - trimmed.len();
146
147 if self.is_list_item(line) {
149 last_list_item_line = Some(i);
150 last_footnote_line = None; blank_line_count = 0;
152 in_continuation_context[i] = true;
153 continue;
154 }
155
156 if self.is_footnote_definition(line) {
158 last_footnote_line = Some(i);
159 last_list_item_line = None; blank_line_count = 0;
161 in_continuation_context[i] = true;
162 continue;
163 }
164
165 if line.trim().is_empty() {
167 if last_list_item_line.is_some() || last_footnote_line.is_some() {
169 blank_line_count += 1;
170 in_continuation_context[i] = true;
171
172 }
176 continue;
177 }
178
179 if indent_len == 0 && !trimmed.is_empty() {
181 if trimmed.starts_with('#') {
185 last_list_item_line = None;
186 last_footnote_line = None;
187 blank_line_count = 0;
188 continue;
189 }
190
191 if trimmed.starts_with("---") || 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 let Some(list_line) = last_list_item_line
202 && (i - list_line > 5 || blank_line_count > 1)
203 {
204 last_list_item_line = None;
205 }
206
207 if last_footnote_line.is_some() {
209 last_footnote_line = None;
210 }
211
212 blank_line_count = 0;
213
214 if last_list_item_line.is_none() && last_footnote_line.is_some() {
216 last_footnote_line = None;
217 }
218 continue;
219 }
220
221 if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
223 in_continuation_context[i] = true;
224 blank_line_count = 0;
225 }
226 }
227
228 in_continuation_context
229 }
230
231 fn is_indented_code_block_with_context(
233 &self,
234 lines: &[&str],
235 i: usize,
236 is_mkdocs: bool,
237 in_list_context: &[bool],
238 in_tab_context: &[bool],
239 in_admonition_context: &[bool],
240 ) -> bool {
241 if i >= lines.len() {
242 return false;
243 }
244
245 let line = lines[i];
246
247 let indent = ElementCache::calculate_indentation_width_default(line);
249 if indent < 4 {
250 return false;
251 }
252
253 if in_list_context[i] {
255 return false;
256 }
257
258 if is_mkdocs && in_tab_context[i] {
260 return false;
261 }
262
263 if is_mkdocs && in_admonition_context[i] {
266 return false;
267 }
268
269 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
272 let prev_is_indented_code = i > 0
273 && ElementCache::calculate_indentation_width_default(lines[i - 1]) >= 4
274 && !in_list_context[i - 1]
275 && !(is_mkdocs && in_tab_context[i - 1])
276 && !(is_mkdocs && in_admonition_context[i - 1]);
277
278 if !has_blank_line_before && !prev_is_indented_code {
281 return false;
282 }
283
284 true
285 }
286
287 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
289 let mut in_tab_context = vec![false; lines.len()];
290 let mut current_tab_indent: Option<usize> = None;
291
292 for (i, line) in lines.iter().enumerate() {
293 if mkdocs_tabs::is_tab_marker(line) {
295 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
296 current_tab_indent = Some(tab_indent);
297 in_tab_context[i] = true;
298 continue;
299 }
300
301 if let Some(tab_indent) = current_tab_indent {
303 if mkdocs_tabs::is_tab_content(line, tab_indent) {
304 in_tab_context[i] = true;
305 } else if !line.trim().is_empty() && ElementCache::calculate_indentation_width_default(line) < 4 {
306 current_tab_indent = None;
308 } else {
309 in_tab_context[i] = true;
311 }
312 }
313 }
314
315 in_tab_context
316 }
317
318 fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
327 let mut in_admonition_context = vec![false; lines.len()];
328 let mut admonition_stack: Vec<usize> = Vec::new();
330
331 for (i, line) in lines.iter().enumerate() {
332 let line_indent = ElementCache::calculate_indentation_width_default(line);
333
334 if mkdocs_admonitions::is_admonition_start(line) {
336 let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
337
338 while let Some(&top_indent) = admonition_stack.last() {
340 if adm_indent <= top_indent {
342 admonition_stack.pop();
343 } else {
344 break;
345 }
346 }
347
348 admonition_stack.push(adm_indent);
350 in_admonition_context[i] = true;
351 continue;
352 }
353
354 if line.trim().is_empty() {
356 if !admonition_stack.is_empty() {
357 in_admonition_context[i] = true;
358 }
359 continue;
360 }
361
362 while let Some(&top_indent) = admonition_stack.last() {
365 if line_indent >= top_indent + 4 {
367 break;
369 } else {
370 admonition_stack.pop();
372 }
373 }
374
375 if !admonition_stack.is_empty() {
377 in_admonition_context[i] = true;
378 }
379 }
380
381 in_admonition_context
382 }
383
384 fn categorize_indented_blocks(
396 &self,
397 lines: &[&str],
398 is_mkdocs: bool,
399 in_list_context: &[bool],
400 in_tab_context: &[bool],
401 in_admonition_context: &[bool],
402 ) -> (Vec<bool>, Vec<bool>) {
403 let mut is_misplaced = vec![false; lines.len()];
404 let mut contains_fences = vec![false; lines.len()];
405
406 let mut i = 0;
408 while i < lines.len() {
409 if !self.is_indented_code_block_with_context(
411 lines,
412 i,
413 is_mkdocs,
414 in_list_context,
415 in_tab_context,
416 in_admonition_context,
417 ) {
418 i += 1;
419 continue;
420 }
421
422 let block_start = i;
424 let mut block_end = i;
425
426 while block_end < lines.len()
427 && self.is_indented_code_block_with_context(
428 lines,
429 block_end,
430 is_mkdocs,
431 in_list_context,
432 in_tab_context,
433 in_admonition_context,
434 )
435 {
436 block_end += 1;
437 }
438
439 if block_end > block_start {
441 let first_line = lines[block_start].trim_start();
442 let last_line = lines[block_end - 1].trim_start();
443
444 let is_backtick_fence = first_line.starts_with("```");
446 let is_tilde_fence = first_line.starts_with("~~~");
447
448 if is_backtick_fence || is_tilde_fence {
449 let fence_char = if is_backtick_fence { '`' } else { '~' };
450 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
451
452 let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
454 let after_closer = &last_line[closer_fence_len..];
455
456 if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
457 is_misplaced[block_start..block_end].fill(true);
459 } else {
460 contains_fences[block_start..block_end].fill(true);
462 }
463 } else {
464 let has_fence_markers = (block_start..block_end).any(|j| {
467 let trimmed = lines[j].trim_start();
468 trimmed.starts_with("```") || trimmed.starts_with("~~~")
469 });
470
471 if has_fence_markers {
472 contains_fences[block_start..block_end].fill(true);
473 }
474 }
475 }
476
477 i = block_end;
478 }
479
480 (is_misplaced, contains_fences)
481 }
482
483 fn check_unclosed_code_blocks(
484 &self,
485 ctx: &crate::lint_context::LintContext,
486 ) -> Result<Vec<LintWarning>, LintError> {
487 let mut warnings = Vec::new();
488 let lines: Vec<&str> = ctx.content.lines().collect();
489
490 let options = Options::all();
492 let parser = Parser::new_ext(ctx.content, options).into_offset_iter();
493
494 let mut code_blocks: Vec<(usize, usize, String, usize, bool, bool)> = Vec::new();
496 let mut current_block_start: Option<(usize, String, usize, bool)> = None;
497
498 for (event, range) in parser {
499 match event {
500 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
501 let line_idx = ctx
503 .line_offsets
504 .iter()
505 .enumerate()
506 .rev()
507 .find(|&(_, offset)| *offset <= range.start)
508 .map(|(idx, _)| idx)
509 .unwrap_or(0);
510
511 let line = lines.get(line_idx).unwrap_or(&"");
513 let trimmed = line.trim();
514
515 let fence_marker = if let Some(pos) = trimmed.find("```") {
517 let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
518 "`".repeat(count)
519 } else if let Some(pos) = trimmed.find("~~~") {
520 let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
521 "~".repeat(count)
522 } else {
523 "```".to_string()
524 };
525
526 let lang_info = info.to_string().to_lowercase();
528 let is_markdown_doc = lang_info.starts_with("markdown") || lang_info.starts_with("md");
529
530 current_block_start = Some((range.start, fence_marker, line_idx, is_markdown_doc));
531 }
532 Event::End(TagEnd::CodeBlock) => {
533 if let Some((start, fence_marker, line_idx, is_markdown_doc)) = current_block_start.take() {
534 code_blocks.push((start, range.end, fence_marker, line_idx, true, is_markdown_doc));
535 }
536 }
537 _ => {}
538 }
539 }
540
541 let has_markdown_doc_block = code_blocks.iter().any(|(_, _, _, _, _, is_md)| *is_md);
545
546 if !has_markdown_doc_block {
550 for (block_start, block_end, fence_marker, opening_line_idx, is_fenced, _is_md) in &code_blocks {
551 if !is_fenced {
552 continue;
553 }
554
555 if *block_end != ctx.content.len() {
557 continue;
558 }
559
560 let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
563 let trimmed = last_non_empty_line.trim();
564 let fence_char = fence_marker.chars().next().unwrap_or('`');
565
566 let has_closing_fence = if fence_char == '`' {
568 trimmed.starts_with("```") && {
569 let fence_len = trimmed.chars().take_while(|&c| c == '`').count();
570 trimmed[fence_len..].trim().is_empty()
571 }
572 } else {
573 trimmed.starts_with("~~~") && {
574 let fence_len = trimmed.chars().take_while(|&c| c == '~').count();
575 trimmed[fence_len..].trim().is_empty()
576 }
577 };
578
579 if !has_closing_fence {
580 let line = lines.get(*opening_line_idx).unwrap_or(&"");
581 let (start_line, start_col, end_line, end_col) = calculate_line_range(*opening_line_idx + 1, line);
582
583 if let Some(line_info) = ctx.lines.get(*opening_line_idx)
585 && line_info.in_html_comment
586 {
587 continue;
588 }
589
590 warnings.push(LintWarning {
591 rule_name: Some(self.name().to_string()),
592 line: start_line,
593 column: start_col,
594 end_line,
595 end_column: end_col,
596 message: format!("Code block opened with '{fence_marker}' but never closed"),
597 severity: Severity::Warning,
598 fix: Some(Fix {
599 range: (ctx.content.len()..ctx.content.len()),
600 replacement: format!("\n{fence_marker}"),
601 }),
602 });
603 }
604
605 let _ = block_start; }
607 }
608
609 if !has_markdown_doc_block && let Some((_start, fence_marker, line_idx, _is_md)) = current_block_start {
612 let line = lines.get(line_idx).unwrap_or(&"");
613 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
614
615 if let Some(line_info) = ctx.lines.get(line_idx)
617 && line_info.in_html_comment
618 {
619 return Ok(warnings);
620 }
621
622 warnings.push(LintWarning {
623 rule_name: Some(self.name().to_string()),
624 line: start_line,
625 column: start_col,
626 end_line,
627 end_column: end_col,
628 message: format!("Code block opened with '{fence_marker}' but never closed"),
629 severity: Severity::Warning,
630 fix: Some(Fix {
631 range: (ctx.content.len()..ctx.content.len()),
632 replacement: format!("\n{fence_marker}"),
633 }),
634 });
635 }
636
637 if has_markdown_doc_block {
642 return Ok(warnings);
643 }
644
645 for (block_start, block_end, fence_marker, opening_line_idx, is_fenced, is_markdown_doc) in &code_blocks {
646 if !is_fenced {
647 continue;
648 }
649
650 if *is_markdown_doc {
652 continue;
653 }
654
655 let opening_line = lines.get(*opening_line_idx).unwrap_or(&"");
656
657 let fence_char = fence_marker.chars().next().unwrap_or('`');
658 let fence_length = fence_marker.len();
659
660 for (i, line) in lines.iter().enumerate() {
662 let line_start = ctx.line_offsets.get(i).copied().unwrap_or(0);
663 let line_end = ctx.line_offsets.get(i + 1).copied().unwrap_or(ctx.content.len());
664
665 if line_start <= *block_start || line_end >= *block_end {
667 continue;
668 }
669
670 if let Some(line_info) = ctx.lines.get(i)
672 && line_info.in_html_comment
673 {
674 continue;
675 }
676
677 let trimmed = line.trim();
678
679 if (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
681 && trimmed.starts_with(&fence_char.to_string())
682 {
683 let inner_fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
684 let after_fence = &trimmed[inner_fence_length..];
685
686 if inner_fence_length >= fence_length
688 && !after_fence.trim().is_empty()
689 && !after_fence.contains('`')
690 {
691 let identifier = after_fence.trim();
693 let looks_like_language =
694 identifier.chars().next().is_some_and(|c| c.is_alphabetic() || c == '#')
695 && identifier.len() <= 30
696 && identifier.chars().all(|c| c.is_alphanumeric() || "-_+#. ".contains(c));
697
698 if looks_like_language {
699 let (start_line, start_col, end_line, end_col) =
700 calculate_line_range(*opening_line_idx + 1, opening_line);
701
702 let line_start_byte = ctx.line_index.get_line_start_byte(i + 1).unwrap_or(0);
703
704 warnings.push(LintWarning {
705 rule_name: Some(self.name().to_string()),
706 line: start_line,
707 column: start_col,
708 end_line,
709 end_column: end_col,
710 message: format!(
711 "Code block '{fence_marker}' should be closed before starting new one at line {}",
712 i + 1
713 ),
714 severity: Severity::Warning,
715 fix: Some(Fix {
716 range: (line_start_byte..line_start_byte),
717 replacement: format!("{fence_marker}\n\n"),
718 }),
719 });
720
721 break; }
723 }
724 }
725 }
726 }
727
728 Ok(warnings)
729 }
730
731 fn detect_style(&self, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
732 if content.is_empty() {
734 return None;
735 }
736
737 let lines: Vec<&str> = content.lines().collect();
738 let mut fenced_count = 0;
739 let mut indented_count = 0;
740
741 let in_list_context = self.precompute_block_continuation_context(&lines);
743 let in_tab_context = if is_mkdocs {
744 self.precompute_mkdocs_tab_context(&lines)
745 } else {
746 vec![false; lines.len()]
747 };
748 let in_admonition_context = if is_mkdocs {
749 self.precompute_mkdocs_admonition_context(&lines)
750 } else {
751 vec![false; lines.len()]
752 };
753
754 let mut in_fenced = false;
756 let mut prev_was_indented = false;
757
758 for (i, line) in lines.iter().enumerate() {
759 if self.is_fenced_code_block_start(line) {
760 if !in_fenced {
761 fenced_count += 1;
763 in_fenced = true;
764 } else {
765 in_fenced = false;
767 }
768 } else if !in_fenced
769 && self.is_indented_code_block_with_context(
770 &lines,
771 i,
772 is_mkdocs,
773 &in_list_context,
774 &in_tab_context,
775 &in_admonition_context,
776 )
777 {
778 if !prev_was_indented {
780 indented_count += 1;
781 }
782 prev_was_indented = true;
783 } else {
784 prev_was_indented = false;
785 }
786 }
787
788 if fenced_count == 0 && indented_count == 0 {
789 None
791 } else if fenced_count > 0 && indented_count == 0 {
792 Some(CodeBlockStyle::Fenced)
794 } else if fenced_count == 0 && indented_count > 0 {
795 Some(CodeBlockStyle::Indented)
797 } else {
798 if fenced_count >= indented_count {
801 Some(CodeBlockStyle::Fenced)
802 } else {
803 Some(CodeBlockStyle::Indented)
804 }
805 }
806 }
807}
808
809impl Rule for MD046CodeBlockStyle {
810 fn name(&self) -> &'static str {
811 "MD046"
812 }
813
814 fn description(&self) -> &'static str {
815 "Code blocks should use a consistent style"
816 }
817
818 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
819 if ctx.content.is_empty() {
821 return Ok(Vec::new());
822 }
823
824 if !ctx.content.contains("```")
826 && !ctx.content.contains("~~~")
827 && !ctx.content.contains(" ")
828 && !ctx.content.contains('\t')
829 {
830 return Ok(Vec::new());
831 }
832
833 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
835
836 if !unclosed_warnings.is_empty() {
838 return Ok(unclosed_warnings);
839 }
840
841 let lines: Vec<&str> = ctx.content.lines().collect();
843 let mut warnings = Vec::new();
844
845 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
847
848 let in_list_context = self.precompute_block_continuation_context(&lines);
850 let in_tab_context = if is_mkdocs {
851 self.precompute_mkdocs_tab_context(&lines)
852 } else {
853 vec![false; lines.len()]
854 };
855 let in_admonition_context = if is_mkdocs {
856 self.precompute_mkdocs_admonition_context(&lines)
857 } else {
858 vec![false; lines.len()]
859 };
860
861 let target_style = match self.config.style {
863 CodeBlockStyle::Consistent => self
864 .detect_style(ctx.content, is_mkdocs)
865 .unwrap_or(CodeBlockStyle::Fenced),
866 _ => self.config.style,
867 };
868
869 let mut in_fenced_block = vec![false; lines.len()];
873 for &(start, end) in &ctx.code_blocks {
874 if start < ctx.content.len() && end <= ctx.content.len() {
876 let block_content = &ctx.content[start..end];
877 let is_fenced = block_content.starts_with("```") || block_content.starts_with("~~~");
878
879 if is_fenced {
880 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
882 if line_info.byte_offset >= start && line_info.byte_offset < end {
883 in_fenced_block[line_idx] = true;
884 }
885 }
886 }
887 }
888 }
889
890 let mut in_fence = false;
891 for (i, line) in lines.iter().enumerate() {
892 let trimmed = line.trim_start();
893
894 if ctx.line_info(i + 1).is_some_and(|info| info.in_html_block) {
896 continue;
897 }
898
899 if ctx.line_info(i + 1).is_some_and(|info| info.in_html_comment) {
901 continue;
902 }
903
904 if ctx.lines[i].in_mkdocstrings {
907 continue;
908 }
909
910 if Self::has_valid_fence_indent(line) && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
913 if target_style == CodeBlockStyle::Indented && !in_fence {
914 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
917 warnings.push(LintWarning {
918 rule_name: Some(self.name().to_string()),
919 line: start_line,
920 column: start_col,
921 end_line,
922 end_column: end_col,
923 message: "Use indented code blocks".to_string(),
924 severity: Severity::Warning,
925 fix: Some(Fix {
926 range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
927 replacement: String::new(),
928 }),
929 });
930 }
931 in_fence = !in_fence;
933 continue;
934 }
935
936 if in_fenced_block[i] {
939 continue;
940 }
941
942 if self.is_indented_code_block_with_context(
944 &lines,
945 i,
946 is_mkdocs,
947 &in_list_context,
948 &in_tab_context,
949 &in_admonition_context,
950 ) && target_style == CodeBlockStyle::Fenced
951 {
952 let prev_line_is_indented = i > 0
954 && self.is_indented_code_block_with_context(
955 &lines,
956 i - 1,
957 is_mkdocs,
958 &in_list_context,
959 &in_tab_context,
960 &in_admonition_context,
961 );
962
963 if !prev_line_is_indented {
964 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
965 warnings.push(LintWarning {
966 rule_name: Some(self.name().to_string()),
967 line: start_line,
968 column: start_col,
969 end_line,
970 end_column: end_col,
971 message: "Use fenced code blocks".to_string(),
972 severity: Severity::Warning,
973 fix: Some(Fix {
974 range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
975 replacement: format!("```\n{}", line.trim_start()),
976 }),
977 });
978 }
979 }
980 }
981
982 Ok(warnings)
983 }
984
985 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
986 let content = ctx.content;
987 if content.is_empty() {
988 return Ok(String::new());
989 }
990
991 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
993
994 if !unclosed_warnings.is_empty() {
996 for warning in &unclosed_warnings {
998 if warning
999 .message
1000 .contains("should be closed before starting new one at line")
1001 {
1002 if let Some(fix) = &warning.fix {
1004 let mut result = String::new();
1005 result.push_str(&content[..fix.range.start]);
1006 result.push_str(&fix.replacement);
1007 result.push_str(&content[fix.range.start..]);
1008 return Ok(result);
1009 }
1010 }
1011 }
1012 }
1013
1014 let lines: Vec<&str> = content.lines().collect();
1015
1016 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1018 let target_style = match self.config.style {
1019 CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
1020 _ => self.config.style,
1021 };
1022
1023 let in_list_context = self.precompute_block_continuation_context(&lines);
1025 let in_tab_context = if is_mkdocs {
1026 self.precompute_mkdocs_tab_context(&lines)
1027 } else {
1028 vec![false; lines.len()]
1029 };
1030 let in_admonition_context = if is_mkdocs {
1031 self.precompute_mkdocs_admonition_context(&lines)
1032 } else {
1033 vec![false; lines.len()]
1034 };
1035
1036 let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(
1040 &lines,
1041 is_mkdocs,
1042 &in_list_context,
1043 &in_tab_context,
1044 &in_admonition_context,
1045 );
1046
1047 let mut result = String::with_capacity(content.len());
1048 let mut in_fenced_block = false;
1049 let mut fenced_fence_type = None;
1050 let mut in_indented_block = false;
1051
1052 for (i, line) in lines.iter().enumerate() {
1053 let trimmed = line.trim_start();
1054
1055 if !in_fenced_block
1058 && Self::has_valid_fence_indent(line)
1059 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1060 {
1061 in_fenced_block = true;
1062 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
1063
1064 if target_style == CodeBlockStyle::Indented {
1065 in_indented_block = true;
1067 } else {
1068 result.push_str(line);
1070 result.push('\n');
1071 }
1072 } else if in_fenced_block && fenced_fence_type.is_some() {
1073 let fence = fenced_fence_type.unwrap();
1074 if trimmed.starts_with(fence) {
1075 in_fenced_block = false;
1076 fenced_fence_type = None;
1077 in_indented_block = false;
1078
1079 if target_style == CodeBlockStyle::Indented {
1080 } else {
1082 result.push_str(line);
1084 result.push('\n');
1085 }
1086 } else if target_style == CodeBlockStyle::Indented {
1087 result.push_str(" ");
1091 result.push_str(line);
1092 result.push('\n');
1093 } else {
1094 result.push_str(line);
1096 result.push('\n');
1097 }
1098 } else if self.is_indented_code_block_with_context(
1099 &lines,
1100 i,
1101 is_mkdocs,
1102 &in_list_context,
1103 &in_tab_context,
1104 &in_admonition_context,
1105 ) {
1106 let prev_line_is_indented = i > 0
1110 && self.is_indented_code_block_with_context(
1111 &lines,
1112 i - 1,
1113 is_mkdocs,
1114 &in_list_context,
1115 &in_tab_context,
1116 &in_admonition_context,
1117 );
1118
1119 if target_style == CodeBlockStyle::Fenced {
1120 let trimmed_content = line.trim_start();
1121
1122 if misplaced_fence_lines[i] {
1125 result.push_str(trimmed_content);
1127 result.push('\n');
1128 } else if unsafe_fence_lines[i] {
1129 result.push_str(line);
1132 result.push('\n');
1133 } else if !prev_line_is_indented && !in_indented_block {
1134 result.push_str("```\n");
1136 result.push_str(trimmed_content);
1137 result.push('\n');
1138 in_indented_block = true;
1139 } else {
1140 result.push_str(trimmed_content);
1142 result.push('\n');
1143 }
1144
1145 let next_line_is_indented = i < lines.len() - 1
1147 && self.is_indented_code_block_with_context(
1148 &lines,
1149 i + 1,
1150 is_mkdocs,
1151 &in_list_context,
1152 &in_tab_context,
1153 &in_admonition_context,
1154 );
1155 if !next_line_is_indented
1157 && in_indented_block
1158 && !misplaced_fence_lines[i]
1159 && !unsafe_fence_lines[i]
1160 {
1161 result.push_str("```\n");
1162 in_indented_block = false;
1163 }
1164 } else {
1165 result.push_str(line);
1167 result.push('\n');
1168 }
1169 } else {
1170 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1172 result.push_str("```\n");
1173 in_indented_block = false;
1174 }
1175
1176 result.push_str(line);
1177 result.push('\n');
1178 }
1179 }
1180
1181 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1183 result.push_str("```\n");
1184 }
1185
1186 if let Some(fence_type) = fenced_fence_type
1188 && in_fenced_block
1189 {
1190 result.push_str(fence_type);
1191 result.push('\n');
1192 }
1193
1194 if !content.ends_with('\n') && result.ends_with('\n') {
1196 result.pop();
1197 }
1198
1199 Ok(result)
1200 }
1201
1202 fn category(&self) -> RuleCategory {
1204 RuleCategory::CodeBlock
1205 }
1206
1207 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1209 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1212 }
1213
1214 fn as_any(&self) -> &dyn std::any::Any {
1215 self
1216 }
1217
1218 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1219 let json_value = serde_json::to_value(&self.config).ok()?;
1220 Some((
1221 self.name().to_string(),
1222 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1223 ))
1224 }
1225
1226 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1227 where
1228 Self: Sized,
1229 {
1230 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1231 Box::new(Self::from_config_struct(rule_config))
1232 }
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237 use super::*;
1238 use crate::lint_context::LintContext;
1239
1240 #[test]
1241 fn test_fenced_code_block_detection() {
1242 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1243 assert!(rule.is_fenced_code_block_start("```"));
1244 assert!(rule.is_fenced_code_block_start("```rust"));
1245 assert!(rule.is_fenced_code_block_start("~~~"));
1246 assert!(rule.is_fenced_code_block_start("~~~python"));
1247 assert!(rule.is_fenced_code_block_start(" ```"));
1248 assert!(!rule.is_fenced_code_block_start("``"));
1249 assert!(!rule.is_fenced_code_block_start("~~"));
1250 assert!(!rule.is_fenced_code_block_start("Regular text"));
1251 }
1252
1253 #[test]
1254 fn test_consistent_style_with_fenced_blocks() {
1255 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1256 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let result = rule.check(&ctx).unwrap();
1259
1260 assert_eq!(result.len(), 0);
1262 }
1263
1264 #[test]
1265 fn test_consistent_style_with_indented_blocks() {
1266 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1267 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269 let result = rule.check(&ctx).unwrap();
1270
1271 assert_eq!(result.len(), 0);
1273 }
1274
1275 #[test]
1276 fn test_consistent_style_mixed() {
1277 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1278 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 let result = rule.check(&ctx).unwrap();
1281
1282 assert!(!result.is_empty());
1284 }
1285
1286 #[test]
1287 fn test_fenced_style_with_indented_blocks() {
1288 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1289 let content = "Text\n\n indented code\n more code\n\nMore text";
1290 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1291 let result = rule.check(&ctx).unwrap();
1292
1293 assert!(!result.is_empty());
1295 assert!(result[0].message.contains("Use fenced code blocks"));
1296 }
1297
1298 #[test]
1299 fn test_fenced_style_with_tab_indented_blocks() {
1300 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1301 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1302 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1303 let result = rule.check(&ctx).unwrap();
1304
1305 assert!(!result.is_empty());
1307 assert!(result[0].message.contains("Use fenced code blocks"));
1308 }
1309
1310 #[test]
1311 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1312 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1313 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316 let result = rule.check(&ctx).unwrap();
1317
1318 assert!(
1320 !result.is_empty(),
1321 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1322 );
1323 assert!(result[0].message.contains("Use fenced code blocks"));
1324 }
1325
1326 #[test]
1327 fn test_fenced_style_with_one_space_tab_indent() {
1328 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1329 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332 let result = rule.check(&ctx).unwrap();
1333
1334 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1335 assert!(result[0].message.contains("Use fenced code blocks"));
1336 }
1337
1338 #[test]
1339 fn test_indented_style_with_fenced_blocks() {
1340 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1341 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1343 let result = rule.check(&ctx).unwrap();
1344
1345 assert!(!result.is_empty());
1347 assert!(result[0].message.contains("Use indented code blocks"));
1348 }
1349
1350 #[test]
1351 fn test_unclosed_code_block() {
1352 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1353 let content = "```\ncode without closing fence";
1354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355 let result = rule.check(&ctx).unwrap();
1356
1357 assert_eq!(result.len(), 1);
1358 assert!(result[0].message.contains("never closed"));
1359 }
1360
1361 #[test]
1362 fn test_nested_code_blocks() {
1363 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1364 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366 let result = rule.check(&ctx).unwrap();
1367
1368 assert_eq!(result.len(), 0);
1370 }
1371
1372 #[test]
1373 fn test_fix_indented_to_fenced() {
1374 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1375 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1377 let fixed = rule.fix(&ctx).unwrap();
1378
1379 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1380 }
1381
1382 #[test]
1383 fn test_fix_fenced_to_indented() {
1384 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1385 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387 let fixed = rule.fix(&ctx).unwrap();
1388
1389 assert!(fixed.contains(" code line 1\n code line 2"));
1390 assert!(!fixed.contains("```"));
1391 }
1392
1393 #[test]
1394 fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1395 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1398 let content = r#"# Test
1399
1400```html
1401<!doctype html>
1402<html>
1403 <head>
1404 <title>Test</title>
1405 </head>
1406</html>
1407```
1408"#;
1409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410 let fixed = rule.fix(&ctx).unwrap();
1411
1412 assert!(
1415 fixed.contains(" <head>"),
1416 "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1417 );
1418 assert!(
1419 fixed.contains(" <title>"),
1420 "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1421 );
1422 assert!(!fixed.contains("```"), "Fenced markers should be removed");
1423 }
1424
1425 #[test]
1426 fn test_fix_fenced_to_indented_preserves_python_indentation() {
1427 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1429 let content = r#"# Python Example
1430
1431```python
1432def greet(name):
1433 if name:
1434 print(f"Hello, {name}!")
1435 else:
1436 print("Hello, World!")
1437```
1438"#;
1439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440 let fixed = rule.fix(&ctx).unwrap();
1441
1442 assert!(
1444 fixed.contains(" def greet(name):"),
1445 "Function def should have 4 spaces (code block indent)"
1446 );
1447 assert!(
1448 fixed.contains(" if name:"),
1449 "if statement should have 8 spaces (4 code + 4 Python)"
1450 );
1451 assert!(
1452 fixed.contains(" print"),
1453 "print should have 12 spaces (4 code + 8 Python)"
1454 );
1455 }
1456
1457 #[test]
1458 fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1459 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1461 let content = r#"# Config
1462
1463```yaml
1464server:
1465 host: localhost
1466 port: 8080
1467 ssl:
1468 enabled: true
1469 cert: /path/to/cert
1470```
1471"#;
1472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473 let fixed = rule.fix(&ctx).unwrap();
1474
1475 assert!(fixed.contains(" server:"), "Root key should have 4 spaces");
1476 assert!(fixed.contains(" host:"), "First level should have 6 spaces");
1477 assert!(fixed.contains(" ssl:"), "ssl key should have 6 spaces");
1478 assert!(fixed.contains(" enabled:"), "Nested ssl should have 8 spaces");
1479 }
1480
1481 #[test]
1482 fn test_fix_fenced_to_indented_preserves_empty_lines() {
1483 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1485 let content = "```\nline1\n\nline2\n```\n";
1486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1487 let fixed = rule.fix(&ctx).unwrap();
1488
1489 assert!(fixed.contains(" line1"), "line1 should be indented");
1491 assert!(fixed.contains(" line2"), "line2 should be indented");
1492 }
1494
1495 #[test]
1496 fn test_fix_fenced_to_indented_multiple_blocks() {
1497 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1499 let content = r#"# Doc
1500
1501```python
1502def foo():
1503 pass
1504```
1505
1506Text between.
1507
1508```yaml
1509key:
1510 value: 1
1511```
1512"#;
1513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514 let fixed = rule.fix(&ctx).unwrap();
1515
1516 assert!(fixed.contains(" def foo():"), "Python def should be indented");
1517 assert!(fixed.contains(" pass"), "Python body should have 8 spaces");
1518 assert!(fixed.contains(" key:"), "YAML root should have 4 spaces");
1519 assert!(fixed.contains(" value:"), "YAML nested should have 6 spaces");
1520 assert!(!fixed.contains("```"), "No fence markers should remain");
1521 }
1522
1523 #[test]
1524 fn test_fix_unclosed_block() {
1525 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1526 let content = "```\ncode without closing";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528 let fixed = rule.fix(&ctx).unwrap();
1529
1530 assert!(fixed.ends_with("```"));
1532 }
1533
1534 #[test]
1535 fn test_code_block_in_list() {
1536 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1537 let content = "- List item\n code in list\n more code\n- Next item";
1538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1539 let result = rule.check(&ctx).unwrap();
1540
1541 assert_eq!(result.len(), 0);
1543 }
1544
1545 #[test]
1546 fn test_detect_style_fenced() {
1547 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1548 let content = "```\ncode\n```";
1549 let style = rule.detect_style(content, false);
1550
1551 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1552 }
1553
1554 #[test]
1555 fn test_detect_style_indented() {
1556 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1557 let content = "Text\n\n code\n\nMore";
1558 let style = rule.detect_style(content, false);
1559
1560 assert_eq!(style, Some(CodeBlockStyle::Indented));
1561 }
1562
1563 #[test]
1564 fn test_detect_style_none() {
1565 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1566 let content = "No code blocks here";
1567 let style = rule.detect_style(content, false);
1568
1569 assert_eq!(style, None);
1570 }
1571
1572 #[test]
1573 fn test_tilde_fence() {
1574 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1575 let content = "~~~\ncode\n~~~";
1576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1577 let result = rule.check(&ctx).unwrap();
1578
1579 assert_eq!(result.len(), 0);
1581 }
1582
1583 #[test]
1584 fn test_language_specification() {
1585 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1586 let content = "```rust\nfn main() {}\n```";
1587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1588 let result = rule.check(&ctx).unwrap();
1589
1590 assert_eq!(result.len(), 0);
1591 }
1592
1593 #[test]
1594 fn test_empty_content() {
1595 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1596 let content = "";
1597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1598 let result = rule.check(&ctx).unwrap();
1599
1600 assert_eq!(result.len(), 0);
1601 }
1602
1603 #[test]
1604 fn test_default_config() {
1605 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1606 let (name, _config) = rule.default_config_section().unwrap();
1607 assert_eq!(name, "MD046");
1608 }
1609
1610 #[test]
1611 fn test_markdown_documentation_block() {
1612 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1613 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615 let result = rule.check(&ctx).unwrap();
1616
1617 assert_eq!(result.len(), 0);
1619 }
1620
1621 #[test]
1622 fn test_preserve_trailing_newline() {
1623 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1624 let content = "```\ncode\n```\n";
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626 let fixed = rule.fix(&ctx).unwrap();
1627
1628 assert_eq!(fixed, content);
1629 }
1630
1631 #[test]
1632 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1633 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1634 let content = r#"# Document
1635
1636=== "Python"
1637
1638 This is tab content
1639 Not an indented code block
1640
1641 ```python
1642 def hello():
1643 print("Hello")
1644 ```
1645
1646=== "JavaScript"
1647
1648 More tab content here
1649 Also not an indented code block"#;
1650
1651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1652 let result = rule.check(&ctx).unwrap();
1653
1654 assert_eq!(result.len(), 0);
1656 }
1657
1658 #[test]
1659 fn test_mkdocs_tabs_with_actual_indented_code() {
1660 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1661 let content = r#"# Document
1662
1663=== "Tab 1"
1664
1665 This is tab content
1666
1667Regular text
1668
1669 This is an actual indented code block
1670 Should be flagged"#;
1671
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1673 let result = rule.check(&ctx).unwrap();
1674
1675 assert_eq!(result.len(), 1);
1677 assert!(result[0].message.contains("Use fenced code blocks"));
1678 }
1679
1680 #[test]
1681 fn test_mkdocs_tabs_detect_style() {
1682 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1683 let content = r#"=== "Tab 1"
1684
1685 Content in tab
1686 More content
1687
1688=== "Tab 2"
1689
1690 Content in second tab"#;
1691
1692 let style = rule.detect_style(content, true);
1694 assert_eq!(style, None); let style = rule.detect_style(content, false);
1698 assert_eq!(style, Some(CodeBlockStyle::Indented));
1699 }
1700
1701 #[test]
1702 fn test_mkdocs_nested_tabs() {
1703 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1704 let content = r#"# Document
1705
1706=== "Outer Tab"
1707
1708 Some content
1709
1710 === "Nested Tab"
1711
1712 Nested tab content
1713 Should not be flagged"#;
1714
1715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1716 let result = rule.check(&ctx).unwrap();
1717
1718 assert_eq!(result.len(), 0);
1720 }
1721
1722 #[test]
1723 fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1724 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1727 let content = r#"# Document
1728
1729!!! note
1730 This is normal admonition content, not a code block.
1731 It spans multiple lines.
1732
1733??? warning "Collapsible Warning"
1734 This is also admonition content.
1735
1736???+ tip "Expanded Tip"
1737 And this one too.
1738
1739Regular text outside admonitions."#;
1740
1741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1742 let result = rule.check(&ctx).unwrap();
1743
1744 assert_eq!(
1746 result.len(),
1747 0,
1748 "Admonition content in MkDocs mode should not trigger MD046"
1749 );
1750 }
1751
1752 #[test]
1753 fn test_mkdocs_admonition_with_actual_indented_code() {
1754 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1756 let content = r#"# Document
1757
1758!!! note
1759 This is admonition content.
1760
1761Regular text ends the admonition.
1762
1763 This is actual indented code (should be flagged)"#;
1764
1765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1766 let result = rule.check(&ctx).unwrap();
1767
1768 assert_eq!(result.len(), 1);
1770 assert!(result[0].message.contains("Use fenced code blocks"));
1771 }
1772
1773 #[test]
1774 fn test_admonition_in_standard_mode_flagged() {
1775 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1779 let content = r#"# Document
1780
1781!!! note
1782
1783 This looks like code in standard mode.
1784
1785Regular text."#;
1786
1787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1789 let result = rule.check(&ctx).unwrap();
1790
1791 assert_eq!(
1793 result.len(),
1794 1,
1795 "Admonition content in Standard mode should be flagged as indented code"
1796 );
1797 }
1798
1799 #[test]
1800 fn test_mkdocs_admonition_with_fenced_code_inside() {
1801 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1803 let content = r#"# Document
1804
1805!!! note "Code Example"
1806 Here's some code:
1807
1808 ```python
1809 def hello():
1810 print("world")
1811 ```
1812
1813 More text after code.
1814
1815Regular text."#;
1816
1817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1818 let result = rule.check(&ctx).unwrap();
1819
1820 assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1822 }
1823
1824 #[test]
1825 fn test_mkdocs_nested_admonitions() {
1826 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1828 let content = r#"# Document
1829
1830!!! note "Outer"
1831 Outer content.
1832
1833 !!! warning "Inner"
1834 Inner content.
1835 More inner content.
1836
1837 Back to outer.
1838
1839Regular text."#;
1840
1841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1842 let result = rule.check(&ctx).unwrap();
1843
1844 assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1846 }
1847
1848 #[test]
1849 fn test_mkdocs_admonition_fix_does_not_wrap() {
1850 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1852 let content = r#"!!! note
1853 Content that should stay as admonition content.
1854 Not be wrapped in code fences.
1855"#;
1856
1857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1858 let fixed = rule.fix(&ctx).unwrap();
1859
1860 assert!(
1862 !fixed.contains("```\n Content"),
1863 "Admonition content should not be wrapped in fences"
1864 );
1865 assert_eq!(fixed, content, "Content should remain unchanged");
1866 }
1867
1868 #[test]
1869 fn test_mkdocs_empty_admonition() {
1870 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1872 let content = r#"!!! note
1873
1874Regular paragraph after empty admonition.
1875
1876 This IS an indented code block (after blank + non-indented line)."#;
1877
1878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1879 let result = rule.check(&ctx).unwrap();
1880
1881 assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1883 }
1884
1885 #[test]
1886 fn test_mkdocs_indented_admonition() {
1887 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1889 let content = r#"- List item
1890
1891 !!! note
1892 Indented admonition content.
1893 More content.
1894
1895- Next item"#;
1896
1897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1898 let result = rule.check(&ctx).unwrap();
1899
1900 assert_eq!(
1902 result.len(),
1903 0,
1904 "Indented admonitions (e.g., in lists) should not be flagged"
1905 );
1906 }
1907
1908 #[test]
1909 fn test_footnote_indented_paragraphs_not_flagged() {
1910 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1911 let content = r#"# Test Document with Footnotes
1912
1913This is some text with a footnote[^1].
1914
1915Here's some code:
1916
1917```bash
1918echo "fenced code block"
1919```
1920
1921More text with another footnote[^2].
1922
1923[^1]: Really interesting footnote text.
1924
1925 Even more interesting second paragraph.
1926
1927[^2]: Another footnote.
1928
1929 With a second paragraph too.
1930
1931 And even a third paragraph!"#;
1932
1933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1934 let result = rule.check(&ctx).unwrap();
1935
1936 assert_eq!(result.len(), 0);
1938 }
1939
1940 #[test]
1941 fn test_footnote_definition_detection() {
1942 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1943
1944 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1947 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1948 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1949 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1950 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1951 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1952 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1953 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1954 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1955
1956 assert!(!rule.is_footnote_definition("[^]: No label"));
1958 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1959 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1960 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1961
1962 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1964 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1965 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1966 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1969
1970 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1972 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1973 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1974 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1975 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1976
1977 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1980 }
1981
1982 #[test]
1983 fn test_footnote_with_blank_lines() {
1984 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1988 let content = r#"# Document
1989
1990Text with footnote[^1].
1991
1992[^1]: First paragraph.
1993
1994 Second paragraph after blank line.
1995
1996 Third paragraph after another blank line.
1997
1998Regular text at column 0 ends the footnote."#;
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 0,
2007 "Indented content within footnotes should not trigger MD046"
2008 );
2009 }
2010
2011 #[test]
2012 fn test_footnote_multiple_consecutive_blank_lines() {
2013 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2016 let content = r#"Text[^1].
2017
2018[^1]: First paragraph.
2019
2020
2021
2022 Content after three blank lines (still part of footnote).
2023
2024Not indented, so footnote ends here."#;
2025
2026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2027 let result = rule.check(&ctx).unwrap();
2028
2029 assert_eq!(
2031 result.len(),
2032 0,
2033 "Multiple blank lines shouldn't break footnote continuation"
2034 );
2035 }
2036
2037 #[test]
2038 fn test_footnote_terminated_by_non_indented_content() {
2039 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2042 let content = r#"[^1]: Footnote content.
2043
2044 More indented content in footnote.
2045
2046This paragraph is not indented, so footnote ends.
2047
2048 This should be flagged as indented code block."#;
2049
2050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2051 let result = rule.check(&ctx).unwrap();
2052
2053 assert_eq!(
2055 result.len(),
2056 1,
2057 "Indented code after footnote termination should be flagged"
2058 );
2059 assert!(
2060 result[0].message.contains("Use fenced code blocks"),
2061 "Expected MD046 warning for indented code block"
2062 );
2063 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2064 }
2065
2066 #[test]
2067 fn test_footnote_terminated_by_structural_elements() {
2068 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2070 let content = r#"[^1]: Footnote content.
2071
2072 More content.
2073
2074## Heading terminates footnote
2075
2076 This indented content should be flagged.
2077
2078---
2079
2080 This should also be flagged (after horizontal rule)."#;
2081
2082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2083 let result = rule.check(&ctx).unwrap();
2084
2085 assert_eq!(
2087 result.len(),
2088 2,
2089 "Both indented blocks after termination should be flagged"
2090 );
2091 }
2092
2093 #[test]
2094 fn test_footnote_with_code_block_inside() {
2095 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2098 let content = r#"Text[^1].
2099
2100[^1]: Footnote with code:
2101
2102 ```python
2103 def hello():
2104 print("world")
2105 ```
2106
2107 More footnote text after code."#;
2108
2109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2110 let result = rule.check(&ctx).unwrap();
2111
2112 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2114 }
2115
2116 #[test]
2117 fn test_footnote_with_8_space_indented_code() {
2118 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2121 let content = r#"Text[^1].
2122
2123[^1]: Footnote with nested code.
2124
2125 code block
2126 more code"#;
2127
2128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2129 let result = rule.check(&ctx).unwrap();
2130
2131 assert_eq!(
2133 result.len(),
2134 0,
2135 "8-space indented code within footnotes represents nested code blocks"
2136 );
2137 }
2138
2139 #[test]
2140 fn test_multiple_footnotes() {
2141 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2144 let content = r#"Text[^1] and more[^2].
2145
2146[^1]: First footnote.
2147
2148 Continuation of first.
2149
2150[^2]: Second footnote starts here, ending the first.
2151
2152 Continuation of second."#;
2153
2154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2155 let result = rule.check(&ctx).unwrap();
2156
2157 assert_eq!(
2159 result.len(),
2160 0,
2161 "Multiple footnotes should each maintain their continuation context"
2162 );
2163 }
2164
2165 #[test]
2166 fn test_list_item_ends_footnote_context() {
2167 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2169 let content = r#"[^1]: Footnote.
2170
2171 Content in footnote.
2172
2173- List item starts here (ends footnote context).
2174
2175 This indented content is part of the list, not the footnote."#;
2176
2177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2178 let result = rule.check(&ctx).unwrap();
2179
2180 assert_eq!(
2182 result.len(),
2183 0,
2184 "List items should end footnote context and start their own"
2185 );
2186 }
2187
2188 #[test]
2189 fn test_footnote_vs_actual_indented_code() {
2190 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2193 let content = r#"# Heading
2194
2195Text with footnote[^1].
2196
2197[^1]: Footnote content.
2198
2199 Part of footnote (should not be flagged).
2200
2201Regular paragraph ends footnote context.
2202
2203 This is actual indented code (MUST be flagged)
2204 Should be detected as code block"#;
2205
2206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2207 let result = rule.check(&ctx).unwrap();
2208
2209 assert_eq!(
2211 result.len(),
2212 1,
2213 "Must still detect indented code blocks outside footnotes"
2214 );
2215 assert!(
2216 result[0].message.contains("Use fenced code blocks"),
2217 "Expected MD046 warning for indented code"
2218 );
2219 assert!(
2220 result[0].line >= 11,
2221 "Warning should be on the actual indented code line"
2222 );
2223 }
2224
2225 #[test]
2226 fn test_spec_compliant_label_characters() {
2227 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2230
2231 assert!(rule.is_footnote_definition("[^test]: text"));
2233 assert!(rule.is_footnote_definition("[^TEST]: text"));
2234 assert!(rule.is_footnote_definition("[^test-name]: text"));
2235 assert!(rule.is_footnote_definition("[^test_name]: text"));
2236 assert!(rule.is_footnote_definition("[^test123]: text"));
2237 assert!(rule.is_footnote_definition("[^123]: text"));
2238 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2239
2240 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")); }
2248
2249 #[test]
2250 fn test_code_block_inside_html_comment() {
2251 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2254 let content = r#"# Document
2255
2256Some text.
2257
2258<!--
2259Example code block in comment:
2260
2261```typescript
2262console.log("Hello");
2263```
2264
2265More comment text.
2266-->
2267
2268More content."#;
2269
2270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2271 let result = rule.check(&ctx).unwrap();
2272
2273 assert_eq!(
2274 result.len(),
2275 0,
2276 "Code blocks inside HTML comments should not be flagged as unclosed"
2277 );
2278 }
2279
2280 #[test]
2281 fn test_unclosed_fence_inside_html_comment() {
2282 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2284 let content = r#"# Document
2285
2286<!--
2287Example with intentionally unclosed fence:
2288
2289```
2290code without closing
2291-->
2292
2293More content."#;
2294
2295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2296 let result = rule.check(&ctx).unwrap();
2297
2298 assert_eq!(
2299 result.len(),
2300 0,
2301 "Unclosed fences inside HTML comments should be ignored"
2302 );
2303 }
2304
2305 #[test]
2306 fn test_multiline_html_comment_with_indented_code() {
2307 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2309 let content = r#"# Document
2310
2311<!--
2312Example:
2313
2314 indented code
2315 more code
2316
2317End of comment.
2318-->
2319
2320Regular text."#;
2321
2322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2323 let result = rule.check(&ctx).unwrap();
2324
2325 assert_eq!(
2326 result.len(),
2327 0,
2328 "Indented code inside HTML comments should not be flagged"
2329 );
2330 }
2331
2332 #[test]
2333 fn test_code_block_after_html_comment() {
2334 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2336 let content = r#"# Document
2337
2338<!-- comment -->
2339
2340Text before.
2341
2342 indented code should be flagged
2343
2344More text."#;
2345
2346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2347 let result = rule.check(&ctx).unwrap();
2348
2349 assert_eq!(
2350 result.len(),
2351 1,
2352 "Code blocks after HTML comments should still be detected"
2353 );
2354 assert!(result[0].message.contains("Use fenced code blocks"));
2355 }
2356
2357 #[test]
2358 fn test_four_space_indented_fence_is_not_valid_fence() {
2359 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2362
2363 assert!(rule.is_fenced_code_block_start("```"));
2365 assert!(rule.is_fenced_code_block_start(" ```"));
2366 assert!(rule.is_fenced_code_block_start(" ```"));
2367 assert!(rule.is_fenced_code_block_start(" ```"));
2368
2369 assert!(!rule.is_fenced_code_block_start(" ```"));
2371 assert!(!rule.is_fenced_code_block_start(" ```"));
2372 assert!(!rule.is_fenced_code_block_start(" ```"));
2373
2374 assert!(!rule.is_fenced_code_block_start("\t```"));
2376 }
2377
2378 #[test]
2379 fn test_issue_237_indented_fenced_block_detected_as_indented() {
2380 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2386
2387 let content = r#"## Test
2389
2390 ```js
2391 var foo = "hello";
2392 ```
2393"#;
2394
2395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2396 let result = rule.check(&ctx).unwrap();
2397
2398 assert_eq!(
2400 result.len(),
2401 1,
2402 "4-space indented fence should be detected as indented code block"
2403 );
2404 assert!(
2405 result[0].message.contains("Use fenced code blocks"),
2406 "Expected 'Use fenced code blocks' message"
2407 );
2408 }
2409
2410 #[test]
2411 fn test_three_space_indented_fence_is_valid() {
2412 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2414
2415 let content = r#"## Test
2416
2417 ```js
2418 var foo = "hello";
2419 ```
2420"#;
2421
2422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2423 let result = rule.check(&ctx).unwrap();
2424
2425 assert_eq!(
2427 result.len(),
2428 0,
2429 "3-space indented fence should be recognized as valid fenced code block"
2430 );
2431 }
2432
2433 #[test]
2434 fn test_indented_style_with_deeply_indented_fenced() {
2435 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2438
2439 let content = r#"Text
2440
2441 ```js
2442 var foo = "hello";
2443 ```
2444
2445More text
2446"#;
2447
2448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2449 let result = rule.check(&ctx).unwrap();
2450
2451 assert_eq!(
2454 result.len(),
2455 0,
2456 "4-space indented content should be valid when style=indented"
2457 );
2458 }
2459
2460 #[test]
2461 fn test_fix_misplaced_fenced_block() {
2462 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2465
2466 let content = r#"## Test
2467
2468 ```js
2469 var foo = "hello";
2470 ```
2471"#;
2472
2473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2474 let fixed = rule.fix(&ctx).unwrap();
2475
2476 let expected = r#"## Test
2478
2479```js
2480var foo = "hello";
2481```
2482"#;
2483
2484 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2485 }
2486
2487 #[test]
2488 fn test_fix_regular_indented_block() {
2489 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2492
2493 let content = r#"Text
2494
2495 var foo = "hello";
2496 console.log(foo);
2497
2498More text
2499"#;
2500
2501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2502 let fixed = rule.fix(&ctx).unwrap();
2503
2504 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2506 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2507 }
2508
2509 #[test]
2510 fn test_fix_indented_block_with_fence_like_content() {
2511 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2515
2516 let content = r#"Text
2517
2518 some code
2519 ```not a fence opener
2520 more code
2521"#;
2522
2523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2524 let fixed = rule.fix(&ctx).unwrap();
2525
2526 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
2528 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2529 }
2530
2531 #[test]
2532 fn test_fix_mixed_indented_and_misplaced_blocks() {
2533 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2535
2536 let content = r#"Text
2537
2538 regular indented code
2539
2540More text
2541
2542 ```python
2543 print("hello")
2544 ```
2545"#;
2546
2547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2548 let fixed = rule.fix(&ctx).unwrap();
2549
2550 assert!(
2552 fixed.contains("```\nregular indented code\n```"),
2553 "First block should be wrapped in fences"
2554 );
2555
2556 assert!(
2558 fixed.contains("\n```python\nprint(\"hello\")\n```"),
2559 "Second block should be dedented, not double-wrapped"
2560 );
2561 assert!(
2563 !fixed.contains("```\n```python"),
2564 "Should not have nested fence openers"
2565 );
2566 }
2567}