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_tabs;
5use crate::utils::range_utils::calculate_line_range;
6use toml;
7
8mod md046_config;
9use md046_config::MD046Config;
10
11#[derive(Clone)]
17pub struct MD046CodeBlockStyle {
18 config: MD046Config,
19}
20
21impl MD046CodeBlockStyle {
22 pub fn new(style: CodeBlockStyle) -> Self {
23 Self {
24 config: MD046Config { style },
25 }
26 }
27
28 pub fn from_config_struct(config: MD046Config) -> Self {
29 Self { config }
30 }
31
32 fn has_valid_fence_indent(line: &str) -> bool {
37 ElementCache::calculate_indentation_width_default(line) < 4
38 }
39
40 fn is_fenced_code_block_start(&self, line: &str) -> bool {
49 if !Self::has_valid_fence_indent(line) {
50 return false;
51 }
52
53 let trimmed = line.trim_start();
54 trimmed.starts_with("```") || trimmed.starts_with("~~~")
55 }
56
57 fn is_list_item(&self, line: &str) -> bool {
58 let trimmed = line.trim_start();
59 (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
60 || (trimmed.len() > 2
61 && trimmed.chars().next().unwrap().is_numeric()
62 && (trimmed.contains(". ") || trimmed.contains(") ")))
63 }
64
65 fn is_footnote_definition(&self, line: &str) -> bool {
85 let trimmed = line.trim_start();
86 if !trimmed.starts_with("[^") || trimmed.len() < 5 {
87 return false;
88 }
89
90 if let Some(close_bracket_pos) = trimmed.find("]:")
91 && close_bracket_pos > 2
92 {
93 let label = &trimmed[2..close_bracket_pos];
94
95 if label.trim().is_empty() {
96 return false;
97 }
98
99 if label.contains('\r') {
101 return false;
102 }
103
104 if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
106 return true;
107 }
108 }
109
110 false
111 }
112
113 fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
136 let mut in_continuation_context = vec![false; lines.len()];
137 let mut last_list_item_line: Option<usize> = None;
138 let mut last_footnote_line: Option<usize> = None;
139 let mut blank_line_count = 0;
140
141 for (i, line) in lines.iter().enumerate() {
142 let trimmed = line.trim_start();
143 let indent_len = line.len() - trimmed.len();
144
145 if self.is_list_item(line) {
147 last_list_item_line = Some(i);
148 last_footnote_line = None; blank_line_count = 0;
150 in_continuation_context[i] = true;
151 continue;
152 }
153
154 if self.is_footnote_definition(line) {
156 last_footnote_line = Some(i);
157 last_list_item_line = None; blank_line_count = 0;
159 in_continuation_context[i] = true;
160 continue;
161 }
162
163 if line.trim().is_empty() {
165 if last_list_item_line.is_some() || last_footnote_line.is_some() {
167 blank_line_count += 1;
168 in_continuation_context[i] = true;
169
170 }
174 continue;
175 }
176
177 if indent_len == 0 && !trimmed.is_empty() {
179 if trimmed.starts_with('#') {
183 last_list_item_line = None;
184 last_footnote_line = None;
185 blank_line_count = 0;
186 continue;
187 }
188
189 if trimmed.starts_with("---") || trimmed.starts_with("***") {
191 last_list_item_line = None;
192 last_footnote_line = None;
193 blank_line_count = 0;
194 continue;
195 }
196
197 if let Some(list_line) = last_list_item_line
200 && (i - list_line > 5 || blank_line_count > 1)
201 {
202 last_list_item_line = None;
203 }
204
205 if last_footnote_line.is_some() {
207 last_footnote_line = None;
208 }
209
210 blank_line_count = 0;
211
212 if last_list_item_line.is_none() && last_footnote_line.is_some() {
214 last_footnote_line = None;
215 }
216 continue;
217 }
218
219 if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
221 in_continuation_context[i] = true;
222 blank_line_count = 0;
223 }
224 }
225
226 in_continuation_context
227 }
228
229 fn is_indented_code_block_with_context(
231 &self,
232 lines: &[&str],
233 i: usize,
234 is_mkdocs: bool,
235 in_list_context: &[bool],
236 in_tab_context: &[bool],
237 ) -> bool {
238 if i >= lines.len() {
239 return false;
240 }
241
242 let line = lines[i];
243
244 let indent = ElementCache::calculate_indentation_width_default(line);
246 if indent < 4 {
247 return false;
248 }
249
250 if in_list_context[i] {
252 return false;
253 }
254
255 if is_mkdocs && in_tab_context[i] {
257 return false;
258 }
259
260 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
263 let prev_is_indented_code = i > 0
264 && ElementCache::calculate_indentation_width_default(lines[i - 1]) >= 4
265 && !in_list_context[i - 1]
266 && !(is_mkdocs && in_tab_context[i - 1]);
267
268 if !has_blank_line_before && !prev_is_indented_code {
271 return false;
272 }
273
274 true
275 }
276
277 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
279 let mut in_tab_context = vec![false; lines.len()];
280 let mut current_tab_indent: Option<usize> = None;
281
282 for (i, line) in lines.iter().enumerate() {
283 if mkdocs_tabs::is_tab_marker(line) {
285 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
286 current_tab_indent = Some(tab_indent);
287 in_tab_context[i] = true;
288 continue;
289 }
290
291 if let Some(tab_indent) = current_tab_indent {
293 if mkdocs_tabs::is_tab_content(line, tab_indent) {
294 in_tab_context[i] = true;
295 } else if !line.trim().is_empty() && ElementCache::calculate_indentation_width_default(line) < 4 {
296 current_tab_indent = None;
298 } else {
299 in_tab_context[i] = true;
301 }
302 }
303 }
304
305 in_tab_context
306 }
307
308 fn categorize_indented_blocks(
320 &self,
321 lines: &[&str],
322 is_mkdocs: bool,
323 in_list_context: &[bool],
324 in_tab_context: &[bool],
325 ) -> (Vec<bool>, Vec<bool>) {
326 let mut is_misplaced = vec![false; lines.len()];
327 let mut contains_fences = vec![false; lines.len()];
328
329 let mut i = 0;
331 while i < lines.len() {
332 if !self.is_indented_code_block_with_context(lines, i, is_mkdocs, in_list_context, in_tab_context) {
334 i += 1;
335 continue;
336 }
337
338 let block_start = i;
340 let mut block_end = i;
341
342 while block_end < lines.len()
343 && self.is_indented_code_block_with_context(
344 lines,
345 block_end,
346 is_mkdocs,
347 in_list_context,
348 in_tab_context,
349 )
350 {
351 block_end += 1;
352 }
353
354 if block_end > block_start {
356 let first_line = lines[block_start].trim_start();
357 let last_line = lines[block_end - 1].trim_start();
358
359 let is_backtick_fence = first_line.starts_with("```");
361 let is_tilde_fence = first_line.starts_with("~~~");
362
363 if is_backtick_fence || is_tilde_fence {
364 let fence_char = if is_backtick_fence { '`' } else { '~' };
365 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
366
367 let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
369 let after_closer = &last_line[closer_fence_len..];
370
371 if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
372 is_misplaced[block_start..block_end].fill(true);
374 } else {
375 contains_fences[block_start..block_end].fill(true);
377 }
378 } else {
379 let has_fence_markers = (block_start..block_end).any(|j| {
382 let trimmed = lines[j].trim_start();
383 trimmed.starts_with("```") || trimmed.starts_with("~~~")
384 });
385
386 if has_fence_markers {
387 contains_fences[block_start..block_end].fill(true);
388 }
389 }
390 }
391
392 i = block_end;
393 }
394
395 (is_misplaced, contains_fences)
396 }
397
398 fn check_unclosed_code_blocks(
399 &self,
400 ctx: &crate::lint_context::LintContext,
401 ) -> Result<Vec<LintWarning>, LintError> {
402 let mut warnings = Vec::new();
403 let lines: Vec<&str> = ctx.content.lines().collect();
404 let mut fence_stack: Vec<(String, usize, usize, bool, bool)> = Vec::new(); let mut inside_markdown_documentation_block = false;
409
410 for (i, line) in lines.iter().enumerate() {
411 let trimmed = line.trim_start();
412
413 if let Some(line_info) = ctx.lines.get(i)
415 && line_info.in_html_comment
416 {
417 continue;
418 }
419
420 if Self::has_valid_fence_indent(line) && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
423 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
424
425 let fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
427
428 let after_fence = &trimmed[fence_length..];
430
431 if fence_char == '`' && after_fence.contains('`') {
435 continue;
436 }
437
438 let is_valid_fence_pattern = if after_fence.is_empty() {
444 true
446 } else if after_fence.starts_with(' ') || after_fence.starts_with('\t') {
447 true
449 } else {
450 let identifier = after_fence.trim().to_lowercase();
453
454 if identifier.contains("fence") || identifier.contains("still") {
456 false
457 } else if identifier.len() > 20 {
458 false
460 } else if let Some(first_char) = identifier.chars().next() {
461 if !first_char.is_alphabetic() && first_char != '#' {
463 false
464 } else {
465 let valid_chars = identifier.chars().all(|c| {
468 c.is_alphanumeric() || c == '-' || c == '_' || c == '+' || c == '#' || c == '.'
469 });
470
471 valid_chars && identifier.len() >= 2
473 }
474 } else {
475 false
476 }
477 };
478
479 if !fence_stack.is_empty() {
481 if !is_valid_fence_pattern {
483 continue;
484 }
485
486 if let Some((open_marker, open_length, _, _, _)) = fence_stack.last() {
488 if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
489 if !after_fence.trim().is_empty() {
491 let has_special_chars = after_fence.chars().any(|c| {
497 !c.is_alphanumeric()
498 && c != '-'
499 && c != '_'
500 && c != '+'
501 && c != '#'
502 && c != '.'
503 && c != ' '
504 && c != '\t'
505 });
506
507 if has_special_chars {
508 continue; }
510
511 if fence_length > 4 && after_fence.chars().take(4).all(|c| !c.is_alphanumeric()) {
513 continue; }
515
516 if !after_fence.starts_with(' ') && !after_fence.starts_with('\t') {
518 let identifier = after_fence.trim();
519
520 if let Some(first) = identifier.chars().next()
522 && !first.is_alphabetic()
523 && first != '#'
524 {
525 continue;
526 }
527
528 if identifier.len() > 30 {
530 continue;
531 }
532 }
533 }
534 } else {
536 if !after_fence.is_empty()
541 && !after_fence.starts_with(' ')
542 && !after_fence.starts_with('\t')
543 {
544 let identifier = after_fence.trim();
546
547 if identifier.chars().any(|c| {
549 !c.is_alphanumeric() && c != '-' && c != '_' && c != '+' && c != '#' && c != '.'
550 }) {
551 continue;
552 }
553
554 if let Some(first) = identifier.chars().next()
556 && !first.is_alphabetic()
557 && first != '#'
558 {
559 continue;
560 }
561 }
562 }
563 }
564 }
565
566 if let Some((open_marker, open_length, _open_line, _flagged, _is_md)) = fence_stack.last() {
570 if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
572 let after_fence = &trimmed[fence_length..];
574 if after_fence.trim().is_empty() {
575 let _popped = fence_stack.pop();
577
578 if let Some((_, _, _, _, is_md)) = _popped
580 && is_md
581 {
582 inside_markdown_documentation_block = false;
583 }
584 continue;
585 }
586 }
587 }
588
589 if !after_fence.trim().is_empty() || fence_stack.is_empty() {
592 let has_nested_issue =
596 if let Some((open_marker, open_length, open_line, _, _)) = fence_stack.last_mut() {
597 if fence_char == open_marker.chars().next().unwrap()
598 && fence_length >= *open_length
599 && !inside_markdown_documentation_block
600 {
601 let (opening_start_line, opening_start_col, opening_end_line, opening_end_col) =
603 calculate_line_range(*open_line, lines[*open_line - 1]);
604
605 let line_start_byte = ctx.line_index.get_line_start_byte(i + 1).unwrap_or(0);
607
608 warnings.push(LintWarning {
609 rule_name: Some(self.name().to_string()),
610 line: opening_start_line,
611 column: opening_start_col,
612 end_line: opening_end_line,
613 end_column: opening_end_col,
614 message: format!(
615 "Code block '{}' should be closed before starting new one at line {}",
616 open_marker,
617 i + 1
618 ),
619 severity: Severity::Warning,
620 fix: Some(Fix {
621 range: (line_start_byte..line_start_byte),
622 replacement: format!("{open_marker}\n\n"),
623 }),
624 });
625
626 fence_stack.last_mut().unwrap().3 = true;
628 true } else {
630 false
631 }
632 } else {
633 false
634 };
635
636 let after_fence_for_lang = &trimmed[fence_length..];
638 let lang_info = after_fence_for_lang.trim().to_lowercase();
639 let is_markdown_fence = lang_info.starts_with("markdown") || lang_info.starts_with("md");
640
641 if is_markdown_fence && !inside_markdown_documentation_block {
643 inside_markdown_documentation_block = true;
644 }
645
646 let fence_marker = fence_char.to_string().repeat(fence_length);
648 fence_stack.push((fence_marker, fence_length, i + 1, has_nested_issue, is_markdown_fence));
649 }
650 }
651 }
652
653 for (fence_marker, _, opening_line, flagged_for_nested, _) in fence_stack {
656 if !flagged_for_nested {
657 let (start_line, start_col, end_line, end_col) =
658 calculate_line_range(opening_line, lines[opening_line - 1]);
659
660 warnings.push(LintWarning {
661 rule_name: Some(self.name().to_string()),
662 line: start_line,
663 column: start_col,
664 end_line,
665 end_column: end_col,
666 message: format!("Code block opened with '{fence_marker}' but never closed"),
667 severity: Severity::Warning,
668 fix: Some(Fix {
669 range: (ctx.content.len()..ctx.content.len()),
670 replacement: format!("\n{fence_marker}"),
671 }),
672 });
673 }
674 }
675
676 Ok(warnings)
677 }
678
679 fn detect_style(&self, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
680 if content.is_empty() {
682 return None;
683 }
684
685 let lines: Vec<&str> = content.lines().collect();
686 let mut fenced_count = 0;
687 let mut indented_count = 0;
688
689 let in_list_context = self.precompute_block_continuation_context(&lines);
691 let in_tab_context = if is_mkdocs {
692 self.precompute_mkdocs_tab_context(&lines)
693 } else {
694 vec![false; lines.len()]
695 };
696
697 let mut in_fenced = false;
699 let mut prev_was_indented = false;
700
701 for (i, line) in lines.iter().enumerate() {
702 if self.is_fenced_code_block_start(line) {
703 if !in_fenced {
704 fenced_count += 1;
706 in_fenced = true;
707 } else {
708 in_fenced = false;
710 }
711 } else if !in_fenced
712 && self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
713 {
714 if !prev_was_indented {
716 indented_count += 1;
717 }
718 prev_was_indented = true;
719 } else {
720 prev_was_indented = false;
721 }
722 }
723
724 if fenced_count == 0 && indented_count == 0 {
725 None
727 } else if fenced_count > 0 && indented_count == 0 {
728 Some(CodeBlockStyle::Fenced)
730 } else if fenced_count == 0 && indented_count > 0 {
731 Some(CodeBlockStyle::Indented)
733 } else {
734 if fenced_count >= indented_count {
737 Some(CodeBlockStyle::Fenced)
738 } else {
739 Some(CodeBlockStyle::Indented)
740 }
741 }
742 }
743}
744
745impl Rule for MD046CodeBlockStyle {
746 fn name(&self) -> &'static str {
747 "MD046"
748 }
749
750 fn description(&self) -> &'static str {
751 "Code blocks should use a consistent style"
752 }
753
754 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
755 if ctx.content.is_empty() {
757 return Ok(Vec::new());
758 }
759
760 if !ctx.content.contains("```")
762 && !ctx.content.contains("~~~")
763 && !ctx.content.contains(" ")
764 && !ctx.content.contains('\t')
765 {
766 return Ok(Vec::new());
767 }
768
769 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
771
772 if !unclosed_warnings.is_empty() {
774 return Ok(unclosed_warnings);
775 }
776
777 let lines: Vec<&str> = ctx.content.lines().collect();
779 let mut warnings = Vec::new();
780
781 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
783
784 let in_list_context = self.precompute_block_continuation_context(&lines);
786 let in_tab_context = if is_mkdocs {
787 self.precompute_mkdocs_tab_context(&lines)
788 } else {
789 vec![false; lines.len()]
790 };
791
792 let target_style = match self.config.style {
794 CodeBlockStyle::Consistent => self
795 .detect_style(ctx.content, is_mkdocs)
796 .unwrap_or(CodeBlockStyle::Fenced),
797 _ => self.config.style,
798 };
799
800 let mut in_fenced_block = vec![false; lines.len()];
804 for &(start, end) in &ctx.code_blocks {
805 if start < ctx.content.len() && end <= ctx.content.len() {
807 let block_content = &ctx.content[start..end];
808 let is_fenced = block_content.starts_with("```") || block_content.starts_with("~~~");
809
810 if is_fenced {
811 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
813 if line_info.byte_offset >= start && line_info.byte_offset < end {
814 in_fenced_block[line_idx] = true;
815 }
816 }
817 }
818 }
819 }
820
821 let mut in_fence = false;
822 for (i, line) in lines.iter().enumerate() {
823 let trimmed = line.trim_start();
824
825 if ctx.line_info(i + 1).is_some_and(|info| info.in_html_block) {
827 continue;
828 }
829
830 if ctx.line_info(i + 1).is_some_and(|info| info.in_html_comment) {
832 continue;
833 }
834
835 if ctx.lines[i].in_mkdocstrings {
838 continue;
839 }
840
841 if Self::has_valid_fence_indent(line) && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
844 if target_style == CodeBlockStyle::Indented && !in_fence {
845 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
848 warnings.push(LintWarning {
849 rule_name: Some(self.name().to_string()),
850 line: start_line,
851 column: start_col,
852 end_line,
853 end_column: end_col,
854 message: "Use indented code blocks".to_string(),
855 severity: Severity::Warning,
856 fix: Some(Fix {
857 range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
858 replacement: String::new(),
859 }),
860 });
861 }
862 in_fence = !in_fence;
864 continue;
865 }
866
867 if in_fenced_block[i] {
870 continue;
871 }
872
873 if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
875 && target_style == CodeBlockStyle::Fenced
876 {
877 let prev_line_is_indented = i > 0
879 && self.is_indented_code_block_with_context(
880 &lines,
881 i - 1,
882 is_mkdocs,
883 &in_list_context,
884 &in_tab_context,
885 );
886
887 if !prev_line_is_indented {
888 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
889 warnings.push(LintWarning {
890 rule_name: Some(self.name().to_string()),
891 line: start_line,
892 column: start_col,
893 end_line,
894 end_column: end_col,
895 message: "Use fenced code blocks".to_string(),
896 severity: Severity::Warning,
897 fix: Some(Fix {
898 range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
899 replacement: format!("```\n{}", line.trim_start()),
900 }),
901 });
902 }
903 }
904 }
905
906 Ok(warnings)
907 }
908
909 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
910 let content = ctx.content;
911 if content.is_empty() {
912 return Ok(String::new());
913 }
914
915 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
917
918 if !unclosed_warnings.is_empty() {
920 for warning in &unclosed_warnings {
922 if warning
923 .message
924 .contains("should be closed before starting new one at line")
925 {
926 if let Some(fix) = &warning.fix {
928 let mut result = String::new();
929 result.push_str(&content[..fix.range.start]);
930 result.push_str(&fix.replacement);
931 result.push_str(&content[fix.range.start..]);
932 return Ok(result);
933 }
934 }
935 }
936 }
937
938 let lines: Vec<&str> = content.lines().collect();
939
940 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
942 let target_style = match self.config.style {
943 CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
944 _ => self.config.style,
945 };
946
947 let in_list_context = self.precompute_block_continuation_context(&lines);
949 let in_tab_context = if is_mkdocs {
950 self.precompute_mkdocs_tab_context(&lines)
951 } else {
952 vec![false; lines.len()]
953 };
954
955 let (misplaced_fence_lines, unsafe_fence_lines) =
959 self.categorize_indented_blocks(&lines, is_mkdocs, &in_list_context, &in_tab_context);
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 for (i, line) in lines.iter().enumerate() {
967 let trimmed = line.trim_start();
968
969 if !in_fenced_block
972 && Self::has_valid_fence_indent(line)
973 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
974 {
975 in_fenced_block = true;
976 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
977
978 if target_style == CodeBlockStyle::Indented {
979 in_indented_block = true;
981 } else {
982 result.push_str(line);
984 result.push('\n');
985 }
986 } else if in_fenced_block && fenced_fence_type.is_some() {
987 let fence = fenced_fence_type.unwrap();
988 if trimmed.starts_with(fence) {
989 in_fenced_block = false;
990 fenced_fence_type = None;
991 in_indented_block = false;
992
993 if target_style == CodeBlockStyle::Indented {
994 } else {
996 result.push_str(line);
998 result.push('\n');
999 }
1000 } else if target_style == CodeBlockStyle::Indented {
1001 result.push_str(" ");
1003 result.push_str(trimmed);
1004 result.push('\n');
1005 } else {
1006 result.push_str(line);
1008 result.push('\n');
1009 }
1010 } else if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
1011 {
1012 let prev_line_is_indented = i > 0
1016 && self.is_indented_code_block_with_context(
1017 &lines,
1018 i - 1,
1019 is_mkdocs,
1020 &in_list_context,
1021 &in_tab_context,
1022 );
1023
1024 if target_style == CodeBlockStyle::Fenced {
1025 let trimmed_content = line.trim_start();
1026
1027 if misplaced_fence_lines[i] {
1030 result.push_str(trimmed_content);
1032 result.push('\n');
1033 } else if unsafe_fence_lines[i] {
1034 result.push_str(line);
1037 result.push('\n');
1038 } else if !prev_line_is_indented && !in_indented_block {
1039 result.push_str("```\n");
1041 result.push_str(trimmed_content);
1042 result.push('\n');
1043 in_indented_block = true;
1044 } else {
1045 result.push_str(trimmed_content);
1047 result.push('\n');
1048 }
1049
1050 let next_line_is_indented = i < lines.len() - 1
1052 && self.is_indented_code_block_with_context(
1053 &lines,
1054 i + 1,
1055 is_mkdocs,
1056 &in_list_context,
1057 &in_tab_context,
1058 );
1059 if !next_line_is_indented
1061 && in_indented_block
1062 && !misplaced_fence_lines[i]
1063 && !unsafe_fence_lines[i]
1064 {
1065 result.push_str("```\n");
1066 in_indented_block = false;
1067 }
1068 } else {
1069 result.push_str(line);
1071 result.push('\n');
1072 }
1073 } else {
1074 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1076 result.push_str("```\n");
1077 in_indented_block = false;
1078 }
1079
1080 result.push_str(line);
1081 result.push('\n');
1082 }
1083 }
1084
1085 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1087 result.push_str("```\n");
1088 }
1089
1090 if let Some(fence_type) = fenced_fence_type
1092 && in_fenced_block
1093 {
1094 result.push_str(fence_type);
1095 result.push('\n');
1096 }
1097
1098 if !content.ends_with('\n') && result.ends_with('\n') {
1100 result.pop();
1101 }
1102
1103 Ok(result)
1104 }
1105
1106 fn category(&self) -> RuleCategory {
1108 RuleCategory::CodeBlock
1109 }
1110
1111 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1113 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1116 }
1117
1118 fn as_any(&self) -> &dyn std::any::Any {
1119 self
1120 }
1121
1122 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1123 let json_value = serde_json::to_value(&self.config).ok()?;
1124 Some((
1125 self.name().to_string(),
1126 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1127 ))
1128 }
1129
1130 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1131 where
1132 Self: Sized,
1133 {
1134 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1135 Box::new(Self::from_config_struct(rule_config))
1136 }
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141 use super::*;
1142 use crate::lint_context::LintContext;
1143
1144 #[test]
1145 fn test_fenced_code_block_detection() {
1146 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1147 assert!(rule.is_fenced_code_block_start("```"));
1148 assert!(rule.is_fenced_code_block_start("```rust"));
1149 assert!(rule.is_fenced_code_block_start("~~~"));
1150 assert!(rule.is_fenced_code_block_start("~~~python"));
1151 assert!(rule.is_fenced_code_block_start(" ```"));
1152 assert!(!rule.is_fenced_code_block_start("``"));
1153 assert!(!rule.is_fenced_code_block_start("~~"));
1154 assert!(!rule.is_fenced_code_block_start("Regular text"));
1155 }
1156
1157 #[test]
1158 fn test_consistent_style_with_fenced_blocks() {
1159 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1160 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1162 let result = rule.check(&ctx).unwrap();
1163
1164 assert_eq!(result.len(), 0);
1166 }
1167
1168 #[test]
1169 fn test_consistent_style_with_indented_blocks() {
1170 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1171 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1173 let result = rule.check(&ctx).unwrap();
1174
1175 assert_eq!(result.len(), 0);
1177 }
1178
1179 #[test]
1180 fn test_consistent_style_mixed() {
1181 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1182 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184 let result = rule.check(&ctx).unwrap();
1185
1186 assert!(!result.is_empty());
1188 }
1189
1190 #[test]
1191 fn test_fenced_style_with_indented_blocks() {
1192 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1193 let content = "Text\n\n indented code\n more code\n\nMore text";
1194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195 let result = rule.check(&ctx).unwrap();
1196
1197 assert!(!result.is_empty());
1199 assert!(result[0].message.contains("Use fenced code blocks"));
1200 }
1201
1202 #[test]
1203 fn test_fenced_style_with_tab_indented_blocks() {
1204 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1205 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1207 let result = rule.check(&ctx).unwrap();
1208
1209 assert!(!result.is_empty());
1211 assert!(result[0].message.contains("Use fenced code blocks"));
1212 }
1213
1214 #[test]
1215 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1216 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1217 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220 let result = rule.check(&ctx).unwrap();
1221
1222 assert!(
1224 !result.is_empty(),
1225 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1226 );
1227 assert!(result[0].message.contains("Use fenced code blocks"));
1228 }
1229
1230 #[test]
1231 fn test_fenced_style_with_one_space_tab_indent() {
1232 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1233 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236 let result = rule.check(&ctx).unwrap();
1237
1238 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1239 assert!(result[0].message.contains("Use fenced code blocks"));
1240 }
1241
1242 #[test]
1243 fn test_indented_style_with_fenced_blocks() {
1244 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1245 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1247 let result = rule.check(&ctx).unwrap();
1248
1249 assert!(!result.is_empty());
1251 assert!(result[0].message.contains("Use indented code blocks"));
1252 }
1253
1254 #[test]
1255 fn test_unclosed_code_block() {
1256 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1257 let content = "```\ncode without closing fence";
1258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1259 let result = rule.check(&ctx).unwrap();
1260
1261 assert_eq!(result.len(), 1);
1262 assert!(result[0].message.contains("never closed"));
1263 }
1264
1265 #[test]
1266 fn test_nested_code_blocks() {
1267 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1268 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1270 let result = rule.check(&ctx).unwrap();
1271
1272 assert_eq!(result.len(), 0);
1274 }
1275
1276 #[test]
1277 fn test_fix_indented_to_fenced() {
1278 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1279 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281 let fixed = rule.fix(&ctx).unwrap();
1282
1283 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1284 }
1285
1286 #[test]
1287 fn test_fix_fenced_to_indented() {
1288 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1289 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1290 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1291 let fixed = rule.fix(&ctx).unwrap();
1292
1293 assert!(fixed.contains(" code line 1\n code line 2"));
1294 assert!(!fixed.contains("```"));
1295 }
1296
1297 #[test]
1298 fn test_fix_unclosed_block() {
1299 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1300 let content = "```\ncode without closing";
1301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1302 let fixed = rule.fix(&ctx).unwrap();
1303
1304 assert!(fixed.ends_with("```"));
1306 }
1307
1308 #[test]
1309 fn test_code_block_in_list() {
1310 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1311 let content = "- List item\n code in list\n more code\n- Next item";
1312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313 let result = rule.check(&ctx).unwrap();
1314
1315 assert_eq!(result.len(), 0);
1317 }
1318
1319 #[test]
1320 fn test_detect_style_fenced() {
1321 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1322 let content = "```\ncode\n```";
1323 let style = rule.detect_style(content, false);
1324
1325 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1326 }
1327
1328 #[test]
1329 fn test_detect_style_indented() {
1330 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1331 let content = "Text\n\n code\n\nMore";
1332 let style = rule.detect_style(content, false);
1333
1334 assert_eq!(style, Some(CodeBlockStyle::Indented));
1335 }
1336
1337 #[test]
1338 fn test_detect_style_none() {
1339 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1340 let content = "No code blocks here";
1341 let style = rule.detect_style(content, false);
1342
1343 assert_eq!(style, None);
1344 }
1345
1346 #[test]
1347 fn test_tilde_fence() {
1348 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1349 let content = "~~~\ncode\n~~~";
1350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351 let result = rule.check(&ctx).unwrap();
1352
1353 assert_eq!(result.len(), 0);
1355 }
1356
1357 #[test]
1358 fn test_language_specification() {
1359 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1360 let content = "```rust\nfn main() {}\n```";
1361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1362 let result = rule.check(&ctx).unwrap();
1363
1364 assert_eq!(result.len(), 0);
1365 }
1366
1367 #[test]
1368 fn test_empty_content() {
1369 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1370 let content = "";
1371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1372 let result = rule.check(&ctx).unwrap();
1373
1374 assert_eq!(result.len(), 0);
1375 }
1376
1377 #[test]
1378 fn test_default_config() {
1379 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1380 let (name, _config) = rule.default_config_section().unwrap();
1381 assert_eq!(name, "MD046");
1382 }
1383
1384 #[test]
1385 fn test_markdown_documentation_block() {
1386 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1387 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1389 let result = rule.check(&ctx).unwrap();
1390
1391 assert_eq!(result.len(), 0);
1393 }
1394
1395 #[test]
1396 fn test_preserve_trailing_newline() {
1397 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1398 let content = "```\ncode\n```\n";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 let fixed = rule.fix(&ctx).unwrap();
1401
1402 assert_eq!(fixed, content);
1403 }
1404
1405 #[test]
1406 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1407 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1408 let content = r#"# Document
1409
1410=== "Python"
1411
1412 This is tab content
1413 Not an indented code block
1414
1415 ```python
1416 def hello():
1417 print("Hello")
1418 ```
1419
1420=== "JavaScript"
1421
1422 More tab content here
1423 Also not an indented code block"#;
1424
1425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1426 let result = rule.check(&ctx).unwrap();
1427
1428 assert_eq!(result.len(), 0);
1430 }
1431
1432 #[test]
1433 fn test_mkdocs_tabs_with_actual_indented_code() {
1434 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1435 let content = r#"# Document
1436
1437=== "Tab 1"
1438
1439 This is tab content
1440
1441Regular text
1442
1443 This is an actual indented code block
1444 Should be flagged"#;
1445
1446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1447 let result = rule.check(&ctx).unwrap();
1448
1449 assert_eq!(result.len(), 1);
1451 assert!(result[0].message.contains("Use fenced code blocks"));
1452 }
1453
1454 #[test]
1455 fn test_mkdocs_tabs_detect_style() {
1456 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1457 let content = r#"=== "Tab 1"
1458
1459 Content in tab
1460 More content
1461
1462=== "Tab 2"
1463
1464 Content in second tab"#;
1465
1466 let style = rule.detect_style(content, true);
1468 assert_eq!(style, None); let style = rule.detect_style(content, false);
1472 assert_eq!(style, Some(CodeBlockStyle::Indented));
1473 }
1474
1475 #[test]
1476 fn test_mkdocs_nested_tabs() {
1477 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1478 let content = r#"# Document
1479
1480=== "Outer Tab"
1481
1482 Some content
1483
1484 === "Nested Tab"
1485
1486 Nested tab content
1487 Should not be flagged"#;
1488
1489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1490 let result = rule.check(&ctx).unwrap();
1491
1492 assert_eq!(result.len(), 0);
1494 }
1495
1496 #[test]
1497 fn test_footnote_indented_paragraphs_not_flagged() {
1498 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1499 let content = r#"# Test Document with Footnotes
1500
1501This is some text with a footnote[^1].
1502
1503Here's some code:
1504
1505```bash
1506echo "fenced code block"
1507```
1508
1509More text with another footnote[^2].
1510
1511[^1]: Really interesting footnote text.
1512
1513 Even more interesting second paragraph.
1514
1515[^2]: Another footnote.
1516
1517 With a second paragraph too.
1518
1519 And even a third paragraph!"#;
1520
1521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1522 let result = rule.check(&ctx).unwrap();
1523
1524 assert_eq!(result.len(), 0);
1526 }
1527
1528 #[test]
1529 fn test_footnote_definition_detection() {
1530 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1531
1532 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1535 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1536 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1537 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1538 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1539 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1540 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1541 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1542 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1543
1544 assert!(!rule.is_footnote_definition("[^]: No label"));
1546 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1547 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1548 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1549
1550 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1552 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1553 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1554 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1557
1558 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1560 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1561 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1562 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1563 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1564
1565 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1568 }
1569
1570 #[test]
1571 fn test_footnote_with_blank_lines() {
1572 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1576 let content = r#"# Document
1577
1578Text with footnote[^1].
1579
1580[^1]: First paragraph.
1581
1582 Second paragraph after blank line.
1583
1584 Third paragraph after another blank line.
1585
1586Regular text at column 0 ends the footnote."#;
1587
1588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1589 let result = rule.check(&ctx).unwrap();
1590
1591 assert_eq!(
1593 result.len(),
1594 0,
1595 "Indented content within footnotes should not trigger MD046"
1596 );
1597 }
1598
1599 #[test]
1600 fn test_footnote_multiple_consecutive_blank_lines() {
1601 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1604 let content = r#"Text[^1].
1605
1606[^1]: First paragraph.
1607
1608
1609
1610 Content after three blank lines (still part of footnote).
1611
1612Not indented, so footnote ends here."#;
1613
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615 let result = rule.check(&ctx).unwrap();
1616
1617 assert_eq!(
1619 result.len(),
1620 0,
1621 "Multiple blank lines shouldn't break footnote continuation"
1622 );
1623 }
1624
1625 #[test]
1626 fn test_footnote_terminated_by_non_indented_content() {
1627 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1630 let content = r#"[^1]: Footnote content.
1631
1632 More indented content in footnote.
1633
1634This paragraph is not indented, so footnote ends.
1635
1636 This should be flagged as indented code block."#;
1637
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640
1641 assert_eq!(
1643 result.len(),
1644 1,
1645 "Indented code after footnote termination should be flagged"
1646 );
1647 assert!(
1648 result[0].message.contains("Use fenced code blocks"),
1649 "Expected MD046 warning for indented code block"
1650 );
1651 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1652 }
1653
1654 #[test]
1655 fn test_footnote_terminated_by_structural_elements() {
1656 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1658 let content = r#"[^1]: Footnote content.
1659
1660 More content.
1661
1662## Heading terminates footnote
1663
1664 This indented content should be flagged.
1665
1666---
1667
1668 This should also be flagged (after horizontal rule)."#;
1669
1670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1671 let result = rule.check(&ctx).unwrap();
1672
1673 assert_eq!(
1675 result.len(),
1676 2,
1677 "Both indented blocks after termination should be flagged"
1678 );
1679 }
1680
1681 #[test]
1682 fn test_footnote_with_code_block_inside() {
1683 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1686 let content = r#"Text[^1].
1687
1688[^1]: Footnote with code:
1689
1690 ```python
1691 def hello():
1692 print("world")
1693 ```
1694
1695 More footnote text after code."#;
1696
1697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1698 let result = rule.check(&ctx).unwrap();
1699
1700 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1702 }
1703
1704 #[test]
1705 fn test_footnote_with_8_space_indented_code() {
1706 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1709 let content = r#"Text[^1].
1710
1711[^1]: Footnote with nested code.
1712
1713 code block
1714 more code"#;
1715
1716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1717 let result = rule.check(&ctx).unwrap();
1718
1719 assert_eq!(
1721 result.len(),
1722 0,
1723 "8-space indented code within footnotes represents nested code blocks"
1724 );
1725 }
1726
1727 #[test]
1728 fn test_multiple_footnotes() {
1729 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1732 let content = r#"Text[^1] and more[^2].
1733
1734[^1]: First footnote.
1735
1736 Continuation of first.
1737
1738[^2]: Second footnote starts here, ending the first.
1739
1740 Continuation of second."#;
1741
1742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1743 let result = rule.check(&ctx).unwrap();
1744
1745 assert_eq!(
1747 result.len(),
1748 0,
1749 "Multiple footnotes should each maintain their continuation context"
1750 );
1751 }
1752
1753 #[test]
1754 fn test_list_item_ends_footnote_context() {
1755 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1757 let content = r#"[^1]: Footnote.
1758
1759 Content in footnote.
1760
1761- List item starts here (ends footnote context).
1762
1763 This indented content is part of the list, not the footnote."#;
1764
1765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1766 let result = rule.check(&ctx).unwrap();
1767
1768 assert_eq!(
1770 result.len(),
1771 0,
1772 "List items should end footnote context and start their own"
1773 );
1774 }
1775
1776 #[test]
1777 fn test_footnote_vs_actual_indented_code() {
1778 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1781 let content = r#"# Heading
1782
1783Text with footnote[^1].
1784
1785[^1]: Footnote content.
1786
1787 Part of footnote (should not be flagged).
1788
1789Regular paragraph ends footnote context.
1790
1791 This is actual indented code (MUST be flagged)
1792 Should be detected as code block"#;
1793
1794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795 let result = rule.check(&ctx).unwrap();
1796
1797 assert_eq!(
1799 result.len(),
1800 1,
1801 "Must still detect indented code blocks outside footnotes"
1802 );
1803 assert!(
1804 result[0].message.contains("Use fenced code blocks"),
1805 "Expected MD046 warning for indented code"
1806 );
1807 assert!(
1808 result[0].line >= 11,
1809 "Warning should be on the actual indented code line"
1810 );
1811 }
1812
1813 #[test]
1814 fn test_spec_compliant_label_characters() {
1815 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1818
1819 assert!(rule.is_footnote_definition("[^test]: text"));
1821 assert!(rule.is_footnote_definition("[^TEST]: text"));
1822 assert!(rule.is_footnote_definition("[^test-name]: text"));
1823 assert!(rule.is_footnote_definition("[^test_name]: text"));
1824 assert!(rule.is_footnote_definition("[^test123]: text"));
1825 assert!(rule.is_footnote_definition("[^123]: text"));
1826 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
1827
1828 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")); }
1836
1837 #[test]
1838 fn test_code_block_inside_html_comment() {
1839 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1842 let content = r#"# Document
1843
1844Some text.
1845
1846<!--
1847Example code block in comment:
1848
1849```typescript
1850console.log("Hello");
1851```
1852
1853More comment text.
1854-->
1855
1856More content."#;
1857
1858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1859 let result = rule.check(&ctx).unwrap();
1860
1861 assert_eq!(
1862 result.len(),
1863 0,
1864 "Code blocks inside HTML comments should not be flagged as unclosed"
1865 );
1866 }
1867
1868 #[test]
1869 fn test_unclosed_fence_inside_html_comment() {
1870 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1872 let content = r#"# Document
1873
1874<!--
1875Example with intentionally unclosed fence:
1876
1877```
1878code without closing
1879-->
1880
1881More content."#;
1882
1883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1884 let result = rule.check(&ctx).unwrap();
1885
1886 assert_eq!(
1887 result.len(),
1888 0,
1889 "Unclosed fences inside HTML comments should be ignored"
1890 );
1891 }
1892
1893 #[test]
1894 fn test_multiline_html_comment_with_indented_code() {
1895 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1897 let content = r#"# Document
1898
1899<!--
1900Example:
1901
1902 indented code
1903 more code
1904
1905End of comment.
1906-->
1907
1908Regular text."#;
1909
1910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1911 let result = rule.check(&ctx).unwrap();
1912
1913 assert_eq!(
1914 result.len(),
1915 0,
1916 "Indented code inside HTML comments should not be flagged"
1917 );
1918 }
1919
1920 #[test]
1921 fn test_code_block_after_html_comment() {
1922 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1924 let content = r#"# Document
1925
1926<!-- comment -->
1927
1928Text before.
1929
1930 indented code should be flagged
1931
1932More text."#;
1933
1934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1935 let result = rule.check(&ctx).unwrap();
1936
1937 assert_eq!(
1938 result.len(),
1939 1,
1940 "Code blocks after HTML comments should still be detected"
1941 );
1942 assert!(result[0].message.contains("Use fenced code blocks"));
1943 }
1944
1945 #[test]
1946 fn test_four_space_indented_fence_is_not_valid_fence() {
1947 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1950
1951 assert!(rule.is_fenced_code_block_start("```"));
1953 assert!(rule.is_fenced_code_block_start(" ```"));
1954 assert!(rule.is_fenced_code_block_start(" ```"));
1955 assert!(rule.is_fenced_code_block_start(" ```"));
1956
1957 assert!(!rule.is_fenced_code_block_start(" ```"));
1959 assert!(!rule.is_fenced_code_block_start(" ```"));
1960 assert!(!rule.is_fenced_code_block_start(" ```"));
1961
1962 assert!(!rule.is_fenced_code_block_start("\t```"));
1964 }
1965
1966 #[test]
1967 fn test_issue_237_indented_fenced_block_detected_as_indented() {
1968 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1974
1975 let content = r#"## Test
1977
1978 ```js
1979 var foo = "hello";
1980 ```
1981"#;
1982
1983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1984 let result = rule.check(&ctx).unwrap();
1985
1986 assert_eq!(
1988 result.len(),
1989 1,
1990 "4-space indented fence should be detected as indented code block"
1991 );
1992 assert!(
1993 result[0].message.contains("Use fenced code blocks"),
1994 "Expected 'Use fenced code blocks' message"
1995 );
1996 }
1997
1998 #[test]
1999 fn test_three_space_indented_fence_is_valid() {
2000 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2002
2003 let content = r#"## Test
2004
2005 ```js
2006 var foo = "hello";
2007 ```
2008"#;
2009
2010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2011 let result = rule.check(&ctx).unwrap();
2012
2013 assert_eq!(
2015 result.len(),
2016 0,
2017 "3-space indented fence should be recognized as valid fenced code block"
2018 );
2019 }
2020
2021 #[test]
2022 fn test_indented_style_with_deeply_indented_fenced() {
2023 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2026
2027 let content = r#"Text
2028
2029 ```js
2030 var foo = "hello";
2031 ```
2032
2033More text
2034"#;
2035
2036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2037 let result = rule.check(&ctx).unwrap();
2038
2039 assert_eq!(
2042 result.len(),
2043 0,
2044 "4-space indented content should be valid when style=indented"
2045 );
2046 }
2047
2048 #[test]
2049 fn test_fix_misplaced_fenced_block() {
2050 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2053
2054 let content = r#"## Test
2055
2056 ```js
2057 var foo = "hello";
2058 ```
2059"#;
2060
2061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2062 let fixed = rule.fix(&ctx).unwrap();
2063
2064 let expected = r#"## Test
2066
2067```js
2068var foo = "hello";
2069```
2070"#;
2071
2072 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2073 }
2074
2075 #[test]
2076 fn test_fix_regular_indented_block() {
2077 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2080
2081 let content = r#"Text
2082
2083 var foo = "hello";
2084 console.log(foo);
2085
2086More text
2087"#;
2088
2089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2090 let fixed = rule.fix(&ctx).unwrap();
2091
2092 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2094 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2095 }
2096
2097 #[test]
2098 fn test_fix_indented_block_with_fence_like_content() {
2099 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2103
2104 let content = r#"Text
2105
2106 some code
2107 ```not a fence opener
2108 more code
2109"#;
2110
2111 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2112 let fixed = rule.fix(&ctx).unwrap();
2113
2114 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
2116 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2117 }
2118
2119 #[test]
2120 fn test_fix_mixed_indented_and_misplaced_blocks() {
2121 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2123
2124 let content = r#"Text
2125
2126 regular indented code
2127
2128More text
2129
2130 ```python
2131 print("hello")
2132 ```
2133"#;
2134
2135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136 let fixed = rule.fix(&ctx).unwrap();
2137
2138 assert!(
2140 fixed.contains("```\nregular indented code\n```"),
2141 "First block should be wrapped in fences"
2142 );
2143
2144 assert!(
2146 fixed.contains("\n```python\nprint(\"hello\")\n```"),
2147 "Second block should be dedented, not double-wrapped"
2148 );
2149 assert!(
2151 !fixed.contains("```\n```python"),
2152 "Should not have nested fence openers"
2153 );
2154 }
2155}