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 pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
7use toml;
8
9mod md046_config;
10use md046_config::MD046Config;
11
12#[derive(Clone)]
18pub struct MD046CodeBlockStyle {
19 config: MD046Config,
20}
21
22impl MD046CodeBlockStyle {
23 pub fn new(style: CodeBlockStyle) -> Self {
24 Self {
25 config: MD046Config { style },
26 }
27 }
28
29 pub fn from_config_struct(config: MD046Config) -> Self {
30 Self { config }
31 }
32
33 fn has_valid_fence_indent(line: &str) -> bool {
38 ElementCache::calculate_indentation_width_default(line) < 4
39 }
40
41 fn is_fenced_code_block_start(&self, line: &str) -> bool {
50 if !Self::has_valid_fence_indent(line) {
51 return false;
52 }
53
54 let trimmed = line.trim_start();
55 trimmed.starts_with("```") || trimmed.starts_with("~~~")
56 }
57
58 fn is_list_item(&self, line: &str) -> bool {
59 let trimmed = line.trim_start();
60 (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
61 || (trimmed.len() > 2
62 && trimmed.chars().next().unwrap().is_numeric()
63 && (trimmed.contains(". ") || trimmed.contains(") ")))
64 }
65
66 fn is_footnote_definition(&self, line: &str) -> bool {
86 let trimmed = line.trim_start();
87 if !trimmed.starts_with("[^") || trimmed.len() < 5 {
88 return false;
89 }
90
91 if let Some(close_bracket_pos) = trimmed.find("]:")
92 && close_bracket_pos > 2
93 {
94 let label = &trimmed[2..close_bracket_pos];
95
96 if label.trim().is_empty() {
97 return false;
98 }
99
100 if label.contains('\r') {
102 return false;
103 }
104
105 if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
107 return true;
108 }
109 }
110
111 false
112 }
113
114 fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
137 let mut in_continuation_context = vec![false; lines.len()];
138 let mut last_list_item_line: Option<usize> = None;
139 let mut last_footnote_line: Option<usize> = None;
140 let mut blank_line_count = 0;
141
142 for (i, line) in lines.iter().enumerate() {
143 let trimmed = line.trim_start();
144 let indent_len = line.len() - trimmed.len();
145
146 if self.is_list_item(line) {
148 last_list_item_line = Some(i);
149 last_footnote_line = None; blank_line_count = 0;
151 in_continuation_context[i] = true;
152 continue;
153 }
154
155 if self.is_footnote_definition(line) {
157 last_footnote_line = Some(i);
158 last_list_item_line = None; blank_line_count = 0;
160 in_continuation_context[i] = true;
161 continue;
162 }
163
164 if line.trim().is_empty() {
166 if last_list_item_line.is_some() || last_footnote_line.is_some() {
168 blank_line_count += 1;
169 in_continuation_context[i] = true;
170
171 }
175 continue;
176 }
177
178 if indent_len == 0 && !trimmed.is_empty() {
180 if trimmed.starts_with('#') {
184 last_list_item_line = None;
185 last_footnote_line = None;
186 blank_line_count = 0;
187 continue;
188 }
189
190 if trimmed.starts_with("---") || trimmed.starts_with("***") {
192 last_list_item_line = None;
193 last_footnote_line = None;
194 blank_line_count = 0;
195 continue;
196 }
197
198 if let Some(list_line) = last_list_item_line
201 && (i - list_line > 5 || blank_line_count > 1)
202 {
203 last_list_item_line = None;
204 }
205
206 if last_footnote_line.is_some() {
208 last_footnote_line = None;
209 }
210
211 blank_line_count = 0;
212
213 if last_list_item_line.is_none() && last_footnote_line.is_some() {
215 last_footnote_line = None;
216 }
217 continue;
218 }
219
220 if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
222 in_continuation_context[i] = true;
223 blank_line_count = 0;
224 }
225 }
226
227 in_continuation_context
228 }
229
230 fn is_indented_code_block_with_context(
232 &self,
233 lines: &[&str],
234 i: usize,
235 is_mkdocs: bool,
236 in_list_context: &[bool],
237 in_tab_context: &[bool],
238 ) -> bool {
239 if i >= lines.len() {
240 return false;
241 }
242
243 let line = lines[i];
244
245 let indent = ElementCache::calculate_indentation_width_default(line);
247 if indent < 4 {
248 return false;
249 }
250
251 if in_list_context[i] {
253 return false;
254 }
255
256 if is_mkdocs && in_tab_context[i] {
258 return false;
259 }
260
261 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
264 let prev_is_indented_code = i > 0
265 && ElementCache::calculate_indentation_width_default(lines[i - 1]) >= 4
266 && !in_list_context[i - 1]
267 && !(is_mkdocs && in_tab_context[i - 1]);
268
269 if !has_blank_line_before && !prev_is_indented_code {
272 return false;
273 }
274
275 true
276 }
277
278 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
280 let mut in_tab_context = vec![false; lines.len()];
281 let mut current_tab_indent: Option<usize> = None;
282
283 for (i, line) in lines.iter().enumerate() {
284 if mkdocs_tabs::is_tab_marker(line) {
286 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
287 current_tab_indent = Some(tab_indent);
288 in_tab_context[i] = true;
289 continue;
290 }
291
292 if let Some(tab_indent) = current_tab_indent {
294 if mkdocs_tabs::is_tab_content(line, tab_indent) {
295 in_tab_context[i] = true;
296 } else if !line.trim().is_empty() && ElementCache::calculate_indentation_width_default(line) < 4 {
297 current_tab_indent = None;
299 } else {
300 in_tab_context[i] = true;
302 }
303 }
304 }
305
306 in_tab_context
307 }
308
309 fn categorize_indented_blocks(
321 &self,
322 lines: &[&str],
323 is_mkdocs: bool,
324 in_list_context: &[bool],
325 in_tab_context: &[bool],
326 ) -> (Vec<bool>, Vec<bool>) {
327 let mut is_misplaced = vec![false; lines.len()];
328 let mut contains_fences = vec![false; lines.len()];
329
330 let mut i = 0;
332 while i < lines.len() {
333 if !self.is_indented_code_block_with_context(lines, i, is_mkdocs, in_list_context, in_tab_context) {
335 i += 1;
336 continue;
337 }
338
339 let block_start = i;
341 let mut block_end = i;
342
343 while block_end < lines.len()
344 && self.is_indented_code_block_with_context(
345 lines,
346 block_end,
347 is_mkdocs,
348 in_list_context,
349 in_tab_context,
350 )
351 {
352 block_end += 1;
353 }
354
355 if block_end > block_start {
357 let first_line = lines[block_start].trim_start();
358 let last_line = lines[block_end - 1].trim_start();
359
360 let is_backtick_fence = first_line.starts_with("```");
362 let is_tilde_fence = first_line.starts_with("~~~");
363
364 if is_backtick_fence || is_tilde_fence {
365 let fence_char = if is_backtick_fence { '`' } else { '~' };
366 let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
367
368 let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
370 let after_closer = &last_line[closer_fence_len..];
371
372 if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
373 is_misplaced[block_start..block_end].fill(true);
375 } else {
376 contains_fences[block_start..block_end].fill(true);
378 }
379 } else {
380 let has_fence_markers = (block_start..block_end).any(|j| {
383 let trimmed = lines[j].trim_start();
384 trimmed.starts_with("```") || trimmed.starts_with("~~~")
385 });
386
387 if has_fence_markers {
388 contains_fences[block_start..block_end].fill(true);
389 }
390 }
391 }
392
393 i = block_end;
394 }
395
396 (is_misplaced, contains_fences)
397 }
398
399 fn check_unclosed_code_blocks(
400 &self,
401 ctx: &crate::lint_context::LintContext,
402 ) -> Result<Vec<LintWarning>, LintError> {
403 let mut warnings = Vec::new();
404 let lines: Vec<&str> = ctx.content.lines().collect();
405
406 let options = Options::all();
408 let parser = Parser::new_ext(ctx.content, options).into_offset_iter();
409
410 let mut code_blocks: Vec<(usize, usize, String, usize, bool, bool)> = Vec::new();
412 let mut current_block_start: Option<(usize, String, usize, bool)> = None;
413
414 for (event, range) in parser {
415 match event {
416 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
417 let line_idx = ctx
419 .line_offsets
420 .iter()
421 .enumerate()
422 .rev()
423 .find(|&(_, offset)| *offset <= range.start)
424 .map(|(idx, _)| idx)
425 .unwrap_or(0);
426
427 let line = lines.get(line_idx).unwrap_or(&"");
429 let trimmed = line.trim();
430
431 let fence_marker = if let Some(pos) = trimmed.find("```") {
433 let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
434 "`".repeat(count)
435 } else if let Some(pos) = trimmed.find("~~~") {
436 let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
437 "~".repeat(count)
438 } else {
439 "```".to_string()
440 };
441
442 let lang_info = info.to_string().to_lowercase();
444 let is_markdown_doc = lang_info.starts_with("markdown") || lang_info.starts_with("md");
445
446 current_block_start = Some((range.start, fence_marker, line_idx, is_markdown_doc));
447 }
448 Event::End(TagEnd::CodeBlock) => {
449 if let Some((start, fence_marker, line_idx, is_markdown_doc)) = current_block_start.take() {
450 code_blocks.push((start, range.end, fence_marker, line_idx, true, is_markdown_doc));
451 }
452 }
453 _ => {}
454 }
455 }
456
457 let has_markdown_doc_block = code_blocks.iter().any(|(_, _, _, _, _, is_md)| *is_md);
461
462 if !has_markdown_doc_block {
466 for (block_start, block_end, fence_marker, opening_line_idx, is_fenced, _is_md) in &code_blocks {
467 if !is_fenced {
468 continue;
469 }
470
471 if *block_end != ctx.content.len() {
473 continue;
474 }
475
476 let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
479 let trimmed = last_non_empty_line.trim();
480 let fence_char = fence_marker.chars().next().unwrap_or('`');
481
482 let has_closing_fence = if fence_char == '`' {
484 trimmed.starts_with("```") && {
485 let fence_len = trimmed.chars().take_while(|&c| c == '`').count();
486 trimmed[fence_len..].trim().is_empty()
487 }
488 } else {
489 trimmed.starts_with("~~~") && {
490 let fence_len = trimmed.chars().take_while(|&c| c == '~').count();
491 trimmed[fence_len..].trim().is_empty()
492 }
493 };
494
495 if !has_closing_fence {
496 let line = lines.get(*opening_line_idx).unwrap_or(&"");
497 let (start_line, start_col, end_line, end_col) = calculate_line_range(*opening_line_idx + 1, line);
498
499 if let Some(line_info) = ctx.lines.get(*opening_line_idx)
501 && line_info.in_html_comment
502 {
503 continue;
504 }
505
506 warnings.push(LintWarning {
507 rule_name: Some(self.name().to_string()),
508 line: start_line,
509 column: start_col,
510 end_line,
511 end_column: end_col,
512 message: format!("Code block opened with '{fence_marker}' but never closed"),
513 severity: Severity::Warning,
514 fix: Some(Fix {
515 range: (ctx.content.len()..ctx.content.len()),
516 replacement: format!("\n{fence_marker}"),
517 }),
518 });
519 }
520
521 let _ = block_start; }
523 }
524
525 if !has_markdown_doc_block && let Some((_start, fence_marker, line_idx, _is_md)) = current_block_start {
528 let line = lines.get(line_idx).unwrap_or(&"");
529 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, line);
530
531 if let Some(line_info) = ctx.lines.get(line_idx)
533 && line_info.in_html_comment
534 {
535 return Ok(warnings);
536 }
537
538 warnings.push(LintWarning {
539 rule_name: Some(self.name().to_string()),
540 line: start_line,
541 column: start_col,
542 end_line,
543 end_column: end_col,
544 message: format!("Code block opened with '{fence_marker}' but never closed"),
545 severity: Severity::Warning,
546 fix: Some(Fix {
547 range: (ctx.content.len()..ctx.content.len()),
548 replacement: format!("\n{fence_marker}"),
549 }),
550 });
551 }
552
553 if has_markdown_doc_block {
558 return Ok(warnings);
559 }
560
561 for (block_start, block_end, fence_marker, opening_line_idx, is_fenced, is_markdown_doc) in &code_blocks {
562 if !is_fenced {
563 continue;
564 }
565
566 if *is_markdown_doc {
568 continue;
569 }
570
571 let opening_line = lines.get(*opening_line_idx).unwrap_or(&"");
572
573 let fence_char = fence_marker.chars().next().unwrap_or('`');
574 let fence_length = fence_marker.len();
575
576 for (i, line) in lines.iter().enumerate() {
578 let line_start = ctx.line_offsets.get(i).copied().unwrap_or(0);
579 let line_end = ctx.line_offsets.get(i + 1).copied().unwrap_or(ctx.content.len());
580
581 if line_start <= *block_start || line_end >= *block_end {
583 continue;
584 }
585
586 if let Some(line_info) = ctx.lines.get(i)
588 && line_info.in_html_comment
589 {
590 continue;
591 }
592
593 let trimmed = line.trim();
594
595 if (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
597 && trimmed.starts_with(&fence_char.to_string())
598 {
599 let inner_fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
600 let after_fence = &trimmed[inner_fence_length..];
601
602 if inner_fence_length >= fence_length
604 && !after_fence.trim().is_empty()
605 && !after_fence.contains('`')
606 {
607 let identifier = after_fence.trim();
609 let looks_like_language =
610 identifier.chars().next().is_some_and(|c| c.is_alphabetic() || c == '#')
611 && identifier.len() <= 30
612 && identifier.chars().all(|c| c.is_alphanumeric() || "-_+#. ".contains(c));
613
614 if looks_like_language {
615 let (start_line, start_col, end_line, end_col) =
616 calculate_line_range(*opening_line_idx + 1, opening_line);
617
618 let line_start_byte = ctx.line_index.get_line_start_byte(i + 1).unwrap_or(0);
619
620 warnings.push(LintWarning {
621 rule_name: Some(self.name().to_string()),
622 line: start_line,
623 column: start_col,
624 end_line,
625 end_column: end_col,
626 message: format!(
627 "Code block '{fence_marker}' should be closed before starting new one at line {}",
628 i + 1
629 ),
630 severity: Severity::Warning,
631 fix: Some(Fix {
632 range: (line_start_byte..line_start_byte),
633 replacement: format!("{fence_marker}\n\n"),
634 }),
635 });
636
637 break; }
639 }
640 }
641 }
642 }
643
644 Ok(warnings)
645 }
646
647 fn detect_style(&self, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
648 if content.is_empty() {
650 return None;
651 }
652
653 let lines: Vec<&str> = content.lines().collect();
654 let mut fenced_count = 0;
655 let mut indented_count = 0;
656
657 let in_list_context = self.precompute_block_continuation_context(&lines);
659 let in_tab_context = if is_mkdocs {
660 self.precompute_mkdocs_tab_context(&lines)
661 } else {
662 vec![false; lines.len()]
663 };
664
665 let mut in_fenced = false;
667 let mut prev_was_indented = false;
668
669 for (i, line) in lines.iter().enumerate() {
670 if self.is_fenced_code_block_start(line) {
671 if !in_fenced {
672 fenced_count += 1;
674 in_fenced = true;
675 } else {
676 in_fenced = false;
678 }
679 } else if !in_fenced
680 && self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
681 {
682 if !prev_was_indented {
684 indented_count += 1;
685 }
686 prev_was_indented = true;
687 } else {
688 prev_was_indented = false;
689 }
690 }
691
692 if fenced_count == 0 && indented_count == 0 {
693 None
695 } else if fenced_count > 0 && indented_count == 0 {
696 Some(CodeBlockStyle::Fenced)
698 } else if fenced_count == 0 && indented_count > 0 {
699 Some(CodeBlockStyle::Indented)
701 } else {
702 if fenced_count >= indented_count {
705 Some(CodeBlockStyle::Fenced)
706 } else {
707 Some(CodeBlockStyle::Indented)
708 }
709 }
710 }
711}
712
713impl Rule for MD046CodeBlockStyle {
714 fn name(&self) -> &'static str {
715 "MD046"
716 }
717
718 fn description(&self) -> &'static str {
719 "Code blocks should use a consistent style"
720 }
721
722 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
723 if ctx.content.is_empty() {
725 return Ok(Vec::new());
726 }
727
728 if !ctx.content.contains("```")
730 && !ctx.content.contains("~~~")
731 && !ctx.content.contains(" ")
732 && !ctx.content.contains('\t')
733 {
734 return Ok(Vec::new());
735 }
736
737 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
739
740 if !unclosed_warnings.is_empty() {
742 return Ok(unclosed_warnings);
743 }
744
745 let lines: Vec<&str> = ctx.content.lines().collect();
747 let mut warnings = Vec::new();
748
749 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
751
752 let in_list_context = self.precompute_block_continuation_context(&lines);
754 let in_tab_context = if is_mkdocs {
755 self.precompute_mkdocs_tab_context(&lines)
756 } else {
757 vec![false; lines.len()]
758 };
759
760 let target_style = match self.config.style {
762 CodeBlockStyle::Consistent => self
763 .detect_style(ctx.content, is_mkdocs)
764 .unwrap_or(CodeBlockStyle::Fenced),
765 _ => self.config.style,
766 };
767
768 let mut in_fenced_block = vec![false; lines.len()];
772 for &(start, end) in &ctx.code_blocks {
773 if start < ctx.content.len() && end <= ctx.content.len() {
775 let block_content = &ctx.content[start..end];
776 let is_fenced = block_content.starts_with("```") || block_content.starts_with("~~~");
777
778 if is_fenced {
779 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
781 if line_info.byte_offset >= start && line_info.byte_offset < end {
782 in_fenced_block[line_idx] = true;
783 }
784 }
785 }
786 }
787 }
788
789 let mut in_fence = false;
790 for (i, line) in lines.iter().enumerate() {
791 let trimmed = line.trim_start();
792
793 if ctx.line_info(i + 1).is_some_and(|info| info.in_html_block) {
795 continue;
796 }
797
798 if ctx.line_info(i + 1).is_some_and(|info| info.in_html_comment) {
800 continue;
801 }
802
803 if ctx.lines[i].in_mkdocstrings {
806 continue;
807 }
808
809 if Self::has_valid_fence_indent(line) && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
812 if target_style == CodeBlockStyle::Indented && !in_fence {
813 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
816 warnings.push(LintWarning {
817 rule_name: Some(self.name().to_string()),
818 line: start_line,
819 column: start_col,
820 end_line,
821 end_column: end_col,
822 message: "Use indented code blocks".to_string(),
823 severity: Severity::Warning,
824 fix: Some(Fix {
825 range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
826 replacement: String::new(),
827 }),
828 });
829 }
830 in_fence = !in_fence;
832 continue;
833 }
834
835 if in_fenced_block[i] {
838 continue;
839 }
840
841 if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
843 && target_style == CodeBlockStyle::Fenced
844 {
845 let prev_line_is_indented = i > 0
847 && self.is_indented_code_block_with_context(
848 &lines,
849 i - 1,
850 is_mkdocs,
851 &in_list_context,
852 &in_tab_context,
853 );
854
855 if !prev_line_is_indented {
856 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
857 warnings.push(LintWarning {
858 rule_name: Some(self.name().to_string()),
859 line: start_line,
860 column: start_col,
861 end_line,
862 end_column: end_col,
863 message: "Use fenced code blocks".to_string(),
864 severity: Severity::Warning,
865 fix: Some(Fix {
866 range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
867 replacement: format!("```\n{}", line.trim_start()),
868 }),
869 });
870 }
871 }
872 }
873
874 Ok(warnings)
875 }
876
877 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
878 let content = ctx.content;
879 if content.is_empty() {
880 return Ok(String::new());
881 }
882
883 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
885
886 if !unclosed_warnings.is_empty() {
888 for warning in &unclosed_warnings {
890 if warning
891 .message
892 .contains("should be closed before starting new one at line")
893 {
894 if let Some(fix) = &warning.fix {
896 let mut result = String::new();
897 result.push_str(&content[..fix.range.start]);
898 result.push_str(&fix.replacement);
899 result.push_str(&content[fix.range.start..]);
900 return Ok(result);
901 }
902 }
903 }
904 }
905
906 let lines: Vec<&str> = content.lines().collect();
907
908 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
910 let target_style = match self.config.style {
911 CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
912 _ => self.config.style,
913 };
914
915 let in_list_context = self.precompute_block_continuation_context(&lines);
917 let in_tab_context = if is_mkdocs {
918 self.precompute_mkdocs_tab_context(&lines)
919 } else {
920 vec![false; lines.len()]
921 };
922
923 let (misplaced_fence_lines, unsafe_fence_lines) =
927 self.categorize_indented_blocks(&lines, is_mkdocs, &in_list_context, &in_tab_context);
928
929 let mut result = String::with_capacity(content.len());
930 let mut in_fenced_block = false;
931 let mut fenced_fence_type = None;
932 let mut in_indented_block = false;
933
934 for (i, line) in lines.iter().enumerate() {
935 let trimmed = line.trim_start();
936
937 if !in_fenced_block
940 && Self::has_valid_fence_indent(line)
941 && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
942 {
943 in_fenced_block = true;
944 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
945
946 if target_style == CodeBlockStyle::Indented {
947 in_indented_block = true;
949 } else {
950 result.push_str(line);
952 result.push('\n');
953 }
954 } else if in_fenced_block && fenced_fence_type.is_some() {
955 let fence = fenced_fence_type.unwrap();
956 if trimmed.starts_with(fence) {
957 in_fenced_block = false;
958 fenced_fence_type = None;
959 in_indented_block = false;
960
961 if target_style == CodeBlockStyle::Indented {
962 } else {
964 result.push_str(line);
966 result.push('\n');
967 }
968 } else if target_style == CodeBlockStyle::Indented {
969 result.push_str(" ");
971 result.push_str(trimmed);
972 result.push('\n');
973 } else {
974 result.push_str(line);
976 result.push('\n');
977 }
978 } else if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
979 {
980 let prev_line_is_indented = i > 0
984 && self.is_indented_code_block_with_context(
985 &lines,
986 i - 1,
987 is_mkdocs,
988 &in_list_context,
989 &in_tab_context,
990 );
991
992 if target_style == CodeBlockStyle::Fenced {
993 let trimmed_content = line.trim_start();
994
995 if misplaced_fence_lines[i] {
998 result.push_str(trimmed_content);
1000 result.push('\n');
1001 } else if unsafe_fence_lines[i] {
1002 result.push_str(line);
1005 result.push('\n');
1006 } else if !prev_line_is_indented && !in_indented_block {
1007 result.push_str("```\n");
1009 result.push_str(trimmed_content);
1010 result.push('\n');
1011 in_indented_block = true;
1012 } else {
1013 result.push_str(trimmed_content);
1015 result.push('\n');
1016 }
1017
1018 let next_line_is_indented = i < lines.len() - 1
1020 && self.is_indented_code_block_with_context(
1021 &lines,
1022 i + 1,
1023 is_mkdocs,
1024 &in_list_context,
1025 &in_tab_context,
1026 );
1027 if !next_line_is_indented
1029 && in_indented_block
1030 && !misplaced_fence_lines[i]
1031 && !unsafe_fence_lines[i]
1032 {
1033 result.push_str("```\n");
1034 in_indented_block = false;
1035 }
1036 } else {
1037 result.push_str(line);
1039 result.push('\n');
1040 }
1041 } else {
1042 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1044 result.push_str("```\n");
1045 in_indented_block = false;
1046 }
1047
1048 result.push_str(line);
1049 result.push('\n');
1050 }
1051 }
1052
1053 if in_indented_block && target_style == CodeBlockStyle::Fenced {
1055 result.push_str("```\n");
1056 }
1057
1058 if let Some(fence_type) = fenced_fence_type
1060 && in_fenced_block
1061 {
1062 result.push_str(fence_type);
1063 result.push('\n');
1064 }
1065
1066 if !content.ends_with('\n') && result.ends_with('\n') {
1068 result.pop();
1069 }
1070
1071 Ok(result)
1072 }
1073
1074 fn category(&self) -> RuleCategory {
1076 RuleCategory::CodeBlock
1077 }
1078
1079 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1081 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
1084 }
1085
1086 fn as_any(&self) -> &dyn std::any::Any {
1087 self
1088 }
1089
1090 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1091 let json_value = serde_json::to_value(&self.config).ok()?;
1092 Some((
1093 self.name().to_string(),
1094 crate::rule_config_serde::json_to_toml_value(&json_value)?,
1095 ))
1096 }
1097
1098 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1099 where
1100 Self: Sized,
1101 {
1102 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1103 Box::new(Self::from_config_struct(rule_config))
1104 }
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110 use crate::lint_context::LintContext;
1111
1112 #[test]
1113 fn test_fenced_code_block_detection() {
1114 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1115 assert!(rule.is_fenced_code_block_start("```"));
1116 assert!(rule.is_fenced_code_block_start("```rust"));
1117 assert!(rule.is_fenced_code_block_start("~~~"));
1118 assert!(rule.is_fenced_code_block_start("~~~python"));
1119 assert!(rule.is_fenced_code_block_start(" ```"));
1120 assert!(!rule.is_fenced_code_block_start("``"));
1121 assert!(!rule.is_fenced_code_block_start("~~"));
1122 assert!(!rule.is_fenced_code_block_start("Regular text"));
1123 }
1124
1125 #[test]
1126 fn test_consistent_style_with_fenced_blocks() {
1127 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1128 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130 let result = rule.check(&ctx).unwrap();
1131
1132 assert_eq!(result.len(), 0);
1134 }
1135
1136 #[test]
1137 fn test_consistent_style_with_indented_blocks() {
1138 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1139 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.check(&ctx).unwrap();
1142
1143 assert_eq!(result.len(), 0);
1145 }
1146
1147 #[test]
1148 fn test_consistent_style_mixed() {
1149 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1150 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1152 let result = rule.check(&ctx).unwrap();
1153
1154 assert!(!result.is_empty());
1156 }
1157
1158 #[test]
1159 fn test_fenced_style_with_indented_blocks() {
1160 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1161 let content = "Text\n\n indented code\n more code\n\nMore text";
1162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163 let result = rule.check(&ctx).unwrap();
1164
1165 assert!(!result.is_empty());
1167 assert!(result[0].message.contains("Use fenced code blocks"));
1168 }
1169
1170 #[test]
1171 fn test_fenced_style_with_tab_indented_blocks() {
1172 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1173 let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175 let result = rule.check(&ctx).unwrap();
1176
1177 assert!(!result.is_empty());
1179 assert!(result[0].message.contains("Use fenced code blocks"));
1180 }
1181
1182 #[test]
1183 fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1184 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1185 let content = "Text\n\n \tmixed indent code\n \tmore code\n\nMore text";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let result = rule.check(&ctx).unwrap();
1189
1190 assert!(
1192 !result.is_empty(),
1193 "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1194 );
1195 assert!(result[0].message.contains("Use fenced code blocks"));
1196 }
1197
1198 #[test]
1199 fn test_fenced_style_with_one_space_tab_indent() {
1200 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1201 let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1204 let result = rule.check(&ctx).unwrap();
1205
1206 assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1207 assert!(result[0].message.contains("Use fenced code blocks"));
1208 }
1209
1210 #[test]
1211 fn test_indented_style_with_fenced_blocks() {
1212 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1213 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1215 let result = rule.check(&ctx).unwrap();
1216
1217 assert!(!result.is_empty());
1219 assert!(result[0].message.contains("Use indented code blocks"));
1220 }
1221
1222 #[test]
1223 fn test_unclosed_code_block() {
1224 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1225 let content = "```\ncode without closing fence";
1226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1227 let result = rule.check(&ctx).unwrap();
1228
1229 assert_eq!(result.len(), 1);
1230 assert!(result[0].message.contains("never closed"));
1231 }
1232
1233 #[test]
1234 fn test_nested_code_blocks() {
1235 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1236 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1238 let result = rule.check(&ctx).unwrap();
1239
1240 assert_eq!(result.len(), 0);
1242 }
1243
1244 #[test]
1245 fn test_fix_indented_to_fenced() {
1246 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1247 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let fixed = rule.fix(&ctx).unwrap();
1250
1251 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1252 }
1253
1254 #[test]
1255 fn test_fix_fenced_to_indented() {
1256 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1257 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1259 let fixed = rule.fix(&ctx).unwrap();
1260
1261 assert!(fixed.contains(" code line 1\n code line 2"));
1262 assert!(!fixed.contains("```"));
1263 }
1264
1265 #[test]
1266 fn test_fix_unclosed_block() {
1267 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1268 let content = "```\ncode without closing";
1269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1270 let fixed = rule.fix(&ctx).unwrap();
1271
1272 assert!(fixed.ends_with("```"));
1274 }
1275
1276 #[test]
1277 fn test_code_block_in_list() {
1278 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1279 let content = "- List item\n code in list\n more code\n- Next item";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281 let result = rule.check(&ctx).unwrap();
1282
1283 assert_eq!(result.len(), 0);
1285 }
1286
1287 #[test]
1288 fn test_detect_style_fenced() {
1289 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1290 let content = "```\ncode\n```";
1291 let style = rule.detect_style(content, false);
1292
1293 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1294 }
1295
1296 #[test]
1297 fn test_detect_style_indented() {
1298 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1299 let content = "Text\n\n code\n\nMore";
1300 let style = rule.detect_style(content, false);
1301
1302 assert_eq!(style, Some(CodeBlockStyle::Indented));
1303 }
1304
1305 #[test]
1306 fn test_detect_style_none() {
1307 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1308 let content = "No code blocks here";
1309 let style = rule.detect_style(content, false);
1310
1311 assert_eq!(style, None);
1312 }
1313
1314 #[test]
1315 fn test_tilde_fence() {
1316 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1317 let content = "~~~\ncode\n~~~";
1318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319 let result = rule.check(&ctx).unwrap();
1320
1321 assert_eq!(result.len(), 0);
1323 }
1324
1325 #[test]
1326 fn test_language_specification() {
1327 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1328 let content = "```rust\nfn main() {}\n```";
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330 let result = rule.check(&ctx).unwrap();
1331
1332 assert_eq!(result.len(), 0);
1333 }
1334
1335 #[test]
1336 fn test_empty_content() {
1337 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1338 let content = "";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let result = rule.check(&ctx).unwrap();
1341
1342 assert_eq!(result.len(), 0);
1343 }
1344
1345 #[test]
1346 fn test_default_config() {
1347 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1348 let (name, _config) = rule.default_config_section().unwrap();
1349 assert_eq!(name, "MD046");
1350 }
1351
1352 #[test]
1353 fn test_markdown_documentation_block() {
1354 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1355 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let result = rule.check(&ctx).unwrap();
1358
1359 assert_eq!(result.len(), 0);
1361 }
1362
1363 #[test]
1364 fn test_preserve_trailing_newline() {
1365 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1366 let content = "```\ncode\n```\n";
1367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1368 let fixed = rule.fix(&ctx).unwrap();
1369
1370 assert_eq!(fixed, content);
1371 }
1372
1373 #[test]
1374 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1375 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1376 let content = r#"# Document
1377
1378=== "Python"
1379
1380 This is tab content
1381 Not an indented code block
1382
1383 ```python
1384 def hello():
1385 print("Hello")
1386 ```
1387
1388=== "JavaScript"
1389
1390 More tab content here
1391 Also not an indented code block"#;
1392
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1394 let result = rule.check(&ctx).unwrap();
1395
1396 assert_eq!(result.len(), 0);
1398 }
1399
1400 #[test]
1401 fn test_mkdocs_tabs_with_actual_indented_code() {
1402 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1403 let content = r#"# Document
1404
1405=== "Tab 1"
1406
1407 This is tab content
1408
1409Regular text
1410
1411 This is an actual indented code block
1412 Should be flagged"#;
1413
1414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1415 let result = rule.check(&ctx).unwrap();
1416
1417 assert_eq!(result.len(), 1);
1419 assert!(result[0].message.contains("Use fenced code blocks"));
1420 }
1421
1422 #[test]
1423 fn test_mkdocs_tabs_detect_style() {
1424 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1425 let content = r#"=== "Tab 1"
1426
1427 Content in tab
1428 More content
1429
1430=== "Tab 2"
1431
1432 Content in second tab"#;
1433
1434 let style = rule.detect_style(content, true);
1436 assert_eq!(style, None); let style = rule.detect_style(content, false);
1440 assert_eq!(style, Some(CodeBlockStyle::Indented));
1441 }
1442
1443 #[test]
1444 fn test_mkdocs_nested_tabs() {
1445 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1446 let content = r#"# Document
1447
1448=== "Outer Tab"
1449
1450 Some content
1451
1452 === "Nested Tab"
1453
1454 Nested tab content
1455 Should not be flagged"#;
1456
1457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1458 let result = rule.check(&ctx).unwrap();
1459
1460 assert_eq!(result.len(), 0);
1462 }
1463
1464 #[test]
1465 fn test_footnote_indented_paragraphs_not_flagged() {
1466 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1467 let content = r#"# Test Document with Footnotes
1468
1469This is some text with a footnote[^1].
1470
1471Here's some code:
1472
1473```bash
1474echo "fenced code block"
1475```
1476
1477More text with another footnote[^2].
1478
1479[^1]: Really interesting footnote text.
1480
1481 Even more interesting second paragraph.
1482
1483[^2]: Another footnote.
1484
1485 With a second paragraph too.
1486
1487 And even a third paragraph!"#;
1488
1489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490 let result = rule.check(&ctx).unwrap();
1491
1492 assert_eq!(result.len(), 0);
1494 }
1495
1496 #[test]
1497 fn test_footnote_definition_detection() {
1498 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1499
1500 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1503 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1504 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1505 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1506 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1507 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1508 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1509 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1510 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1511
1512 assert!(!rule.is_footnote_definition("[^]: No label"));
1514 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1515 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1516 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1517
1518 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1520 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1521 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1522 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1525
1526 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1528 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1529 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1530 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1531 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1532
1533 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1536 }
1537
1538 #[test]
1539 fn test_footnote_with_blank_lines() {
1540 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1544 let content = r#"# Document
1545
1546Text with footnote[^1].
1547
1548[^1]: First paragraph.
1549
1550 Second paragraph after blank line.
1551
1552 Third paragraph after another blank line.
1553
1554Regular text at column 0 ends the footnote."#;
1555
1556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1557 let result = rule.check(&ctx).unwrap();
1558
1559 assert_eq!(
1561 result.len(),
1562 0,
1563 "Indented content within footnotes should not trigger MD046"
1564 );
1565 }
1566
1567 #[test]
1568 fn test_footnote_multiple_consecutive_blank_lines() {
1569 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1572 let content = r#"Text[^1].
1573
1574[^1]: First paragraph.
1575
1576
1577
1578 Content after three blank lines (still part of footnote).
1579
1580Not indented, so footnote ends here."#;
1581
1582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1583 let result = rule.check(&ctx).unwrap();
1584
1585 assert_eq!(
1587 result.len(),
1588 0,
1589 "Multiple blank lines shouldn't break footnote continuation"
1590 );
1591 }
1592
1593 #[test]
1594 fn test_footnote_terminated_by_non_indented_content() {
1595 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1598 let content = r#"[^1]: Footnote content.
1599
1600 More indented content in footnote.
1601
1602This paragraph is not indented, so footnote ends.
1603
1604 This should be flagged as indented code block."#;
1605
1606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1607 let result = rule.check(&ctx).unwrap();
1608
1609 assert_eq!(
1611 result.len(),
1612 1,
1613 "Indented code after footnote termination should be flagged"
1614 );
1615 assert!(
1616 result[0].message.contains("Use fenced code blocks"),
1617 "Expected MD046 warning for indented code block"
1618 );
1619 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1620 }
1621
1622 #[test]
1623 fn test_footnote_terminated_by_structural_elements() {
1624 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1626 let content = r#"[^1]: Footnote content.
1627
1628 More content.
1629
1630## Heading terminates footnote
1631
1632 This indented content should be flagged.
1633
1634---
1635
1636 This should also be flagged (after horizontal rule)."#;
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 2,
1645 "Both indented blocks after termination should be flagged"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_footnote_with_code_block_inside() {
1651 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1654 let content = r#"Text[^1].
1655
1656[^1]: Footnote with code:
1657
1658 ```python
1659 def hello():
1660 print("world")
1661 ```
1662
1663 More footnote text after code."#;
1664
1665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1666 let result = rule.check(&ctx).unwrap();
1667
1668 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1670 }
1671
1672 #[test]
1673 fn test_footnote_with_8_space_indented_code() {
1674 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1677 let content = r#"Text[^1].
1678
1679[^1]: Footnote with nested code.
1680
1681 code block
1682 more code"#;
1683
1684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1685 let result = rule.check(&ctx).unwrap();
1686
1687 assert_eq!(
1689 result.len(),
1690 0,
1691 "8-space indented code within footnotes represents nested code blocks"
1692 );
1693 }
1694
1695 #[test]
1696 fn test_multiple_footnotes() {
1697 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1700 let content = r#"Text[^1] and more[^2].
1701
1702[^1]: First footnote.
1703
1704 Continuation of first.
1705
1706[^2]: Second footnote starts here, ending the first.
1707
1708 Continuation of second."#;
1709
1710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1711 let result = rule.check(&ctx).unwrap();
1712
1713 assert_eq!(
1715 result.len(),
1716 0,
1717 "Multiple footnotes should each maintain their continuation context"
1718 );
1719 }
1720
1721 #[test]
1722 fn test_list_item_ends_footnote_context() {
1723 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1725 let content = r#"[^1]: Footnote.
1726
1727 Content in footnote.
1728
1729- List item starts here (ends footnote context).
1730
1731 This indented content is part of the list, not the footnote."#;
1732
1733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1734 let result = rule.check(&ctx).unwrap();
1735
1736 assert_eq!(
1738 result.len(),
1739 0,
1740 "List items should end footnote context and start their own"
1741 );
1742 }
1743
1744 #[test]
1745 fn test_footnote_vs_actual_indented_code() {
1746 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1749 let content = r#"# Heading
1750
1751Text with footnote[^1].
1752
1753[^1]: Footnote content.
1754
1755 Part of footnote (should not be flagged).
1756
1757Regular paragraph ends footnote context.
1758
1759 This is actual indented code (MUST be flagged)
1760 Should be detected as code block"#;
1761
1762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1763 let result = rule.check(&ctx).unwrap();
1764
1765 assert_eq!(
1767 result.len(),
1768 1,
1769 "Must still detect indented code blocks outside footnotes"
1770 );
1771 assert!(
1772 result[0].message.contains("Use fenced code blocks"),
1773 "Expected MD046 warning for indented code"
1774 );
1775 assert!(
1776 result[0].line >= 11,
1777 "Warning should be on the actual indented code line"
1778 );
1779 }
1780
1781 #[test]
1782 fn test_spec_compliant_label_characters() {
1783 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1786
1787 assert!(rule.is_footnote_definition("[^test]: text"));
1789 assert!(rule.is_footnote_definition("[^TEST]: text"));
1790 assert!(rule.is_footnote_definition("[^test-name]: text"));
1791 assert!(rule.is_footnote_definition("[^test_name]: text"));
1792 assert!(rule.is_footnote_definition("[^test123]: text"));
1793 assert!(rule.is_footnote_definition("[^123]: text"));
1794 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
1795
1796 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")); }
1804
1805 #[test]
1806 fn test_code_block_inside_html_comment() {
1807 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1810 let content = r#"# Document
1811
1812Some text.
1813
1814<!--
1815Example code block in comment:
1816
1817```typescript
1818console.log("Hello");
1819```
1820
1821More comment text.
1822-->
1823
1824More content."#;
1825
1826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1827 let result = rule.check(&ctx).unwrap();
1828
1829 assert_eq!(
1830 result.len(),
1831 0,
1832 "Code blocks inside HTML comments should not be flagged as unclosed"
1833 );
1834 }
1835
1836 #[test]
1837 fn test_unclosed_fence_inside_html_comment() {
1838 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1840 let content = r#"# Document
1841
1842<!--
1843Example with intentionally unclosed fence:
1844
1845```
1846code without closing
1847-->
1848
1849More content."#;
1850
1851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1852 let result = rule.check(&ctx).unwrap();
1853
1854 assert_eq!(
1855 result.len(),
1856 0,
1857 "Unclosed fences inside HTML comments should be ignored"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_multiline_html_comment_with_indented_code() {
1863 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1865 let content = r#"# Document
1866
1867<!--
1868Example:
1869
1870 indented code
1871 more code
1872
1873End of comment.
1874-->
1875
1876Regular text."#;
1877
1878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1879 let result = rule.check(&ctx).unwrap();
1880
1881 assert_eq!(
1882 result.len(),
1883 0,
1884 "Indented code inside HTML comments should not be flagged"
1885 );
1886 }
1887
1888 #[test]
1889 fn test_code_block_after_html_comment() {
1890 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1892 let content = r#"# Document
1893
1894<!-- comment -->
1895
1896Text before.
1897
1898 indented code should be flagged
1899
1900More text."#;
1901
1902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1903 let result = rule.check(&ctx).unwrap();
1904
1905 assert_eq!(
1906 result.len(),
1907 1,
1908 "Code blocks after HTML comments should still be detected"
1909 );
1910 assert!(result[0].message.contains("Use fenced code blocks"));
1911 }
1912
1913 #[test]
1914 fn test_four_space_indented_fence_is_not_valid_fence() {
1915 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1918
1919 assert!(rule.is_fenced_code_block_start("```"));
1921 assert!(rule.is_fenced_code_block_start(" ```"));
1922 assert!(rule.is_fenced_code_block_start(" ```"));
1923 assert!(rule.is_fenced_code_block_start(" ```"));
1924
1925 assert!(!rule.is_fenced_code_block_start(" ```"));
1927 assert!(!rule.is_fenced_code_block_start(" ```"));
1928 assert!(!rule.is_fenced_code_block_start(" ```"));
1929
1930 assert!(!rule.is_fenced_code_block_start("\t```"));
1932 }
1933
1934 #[test]
1935 fn test_issue_237_indented_fenced_block_detected_as_indented() {
1936 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1942
1943 let content = r#"## Test
1945
1946 ```js
1947 var foo = "hello";
1948 ```
1949"#;
1950
1951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1952 let result = rule.check(&ctx).unwrap();
1953
1954 assert_eq!(
1956 result.len(),
1957 1,
1958 "4-space indented fence should be detected as indented code block"
1959 );
1960 assert!(
1961 result[0].message.contains("Use fenced code blocks"),
1962 "Expected 'Use fenced code blocks' message"
1963 );
1964 }
1965
1966 #[test]
1967 fn test_three_space_indented_fence_is_valid() {
1968 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1970
1971 let content = r#"## Test
1972
1973 ```js
1974 var foo = "hello";
1975 ```
1976"#;
1977
1978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1979 let result = rule.check(&ctx).unwrap();
1980
1981 assert_eq!(
1983 result.len(),
1984 0,
1985 "3-space indented fence should be recognized as valid fenced code block"
1986 );
1987 }
1988
1989 #[test]
1990 fn test_indented_style_with_deeply_indented_fenced() {
1991 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1994
1995 let content = r#"Text
1996
1997 ```js
1998 var foo = "hello";
1999 ```
2000
2001More text
2002"#;
2003
2004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2005 let result = rule.check(&ctx).unwrap();
2006
2007 assert_eq!(
2010 result.len(),
2011 0,
2012 "4-space indented content should be valid when style=indented"
2013 );
2014 }
2015
2016 #[test]
2017 fn test_fix_misplaced_fenced_block() {
2018 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2021
2022 let content = r#"## Test
2023
2024 ```js
2025 var foo = "hello";
2026 ```
2027"#;
2028
2029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2030 let fixed = rule.fix(&ctx).unwrap();
2031
2032 let expected = r#"## Test
2034
2035```js
2036var foo = "hello";
2037```
2038"#;
2039
2040 assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2041 }
2042
2043 #[test]
2044 fn test_fix_regular_indented_block() {
2045 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2048
2049 let content = r#"Text
2050
2051 var foo = "hello";
2052 console.log(foo);
2053
2054More text
2055"#;
2056
2057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2058 let fixed = rule.fix(&ctx).unwrap();
2059
2060 assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2062 assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2063 }
2064
2065 #[test]
2066 fn test_fix_indented_block_with_fence_like_content() {
2067 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2071
2072 let content = r#"Text
2073
2074 some code
2075 ```not a fence opener
2076 more code
2077"#;
2078
2079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2080 let fixed = rule.fix(&ctx).unwrap();
2081
2082 assert!(fixed.contains(" some code"), "Unsafe block should be left unchanged");
2084 assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2085 }
2086
2087 #[test]
2088 fn test_fix_mixed_indented_and_misplaced_blocks() {
2089 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2091
2092 let content = r#"Text
2093
2094 regular indented code
2095
2096More text
2097
2098 ```python
2099 print("hello")
2100 ```
2101"#;
2102
2103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2104 let fixed = rule.fix(&ctx).unwrap();
2105
2106 assert!(
2108 fixed.contains("```\nregular indented code\n```"),
2109 "First block should be wrapped in fences"
2110 );
2111
2112 assert!(
2114 fixed.contains("\n```python\nprint(\"hello\")\n```"),
2115 "Second block should be dedented, not double-wrapped"
2116 );
2117 assert!(
2119 !fixed.contains("```\n```python"),
2120 "Should not have nested fence openers"
2121 );
2122 }
2123}