1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rules::code_block_utils::CodeBlockStyle;
3use crate::utils::mkdocs_tabs;
4use crate::utils::range_utils::{LineIndex, calculate_line_range};
5use toml;
6
7mod md046_config;
8use md046_config::MD046Config;
9
10#[derive(Clone)]
16pub struct MD046CodeBlockStyle {
17 config: MD046Config,
18}
19
20impl MD046CodeBlockStyle {
21 pub fn new(style: CodeBlockStyle) -> Self {
22 Self {
23 config: MD046Config { style },
24 }
25 }
26
27 pub fn from_config_struct(config: MD046Config) -> Self {
28 Self { config }
29 }
30
31 fn is_fenced_code_block_start(&self, line: &str) -> bool {
32 let trimmed = line.trim_start();
33 trimmed.starts_with("```") || trimmed.starts_with("~~~")
34 }
35
36 fn is_list_item(&self, line: &str) -> bool {
37 let trimmed = line.trim_start();
38 (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
39 || (trimmed.len() > 2
40 && trimmed.chars().next().unwrap().is_numeric()
41 && (trimmed.contains(". ") || trimmed.contains(") ")))
42 }
43
44 fn is_footnote_definition(&self, line: &str) -> bool {
64 let trimmed = line.trim_start();
65 if !trimmed.starts_with("[^") || trimmed.len() < 5 {
66 return false;
67 }
68
69 if let Some(close_bracket_pos) = trimmed.find("]:")
70 && close_bracket_pos > 2
71 {
72 let label = &trimmed[2..close_bracket_pos];
73
74 if label.trim().is_empty() {
75 return false;
76 }
77
78 if label.contains('\r') {
80 return false;
81 }
82
83 if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
85 return true;
86 }
87 }
88
89 false
90 }
91
92 fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
115 let mut in_continuation_context = vec![false; lines.len()];
116 let mut last_list_item_line: Option<usize> = None;
117 let mut last_footnote_line: Option<usize> = None;
118 let mut blank_line_count = 0;
119
120 for (i, line) in lines.iter().enumerate() {
121 let trimmed = line.trim_start();
122 let indent_len = line.len() - trimmed.len();
123
124 if self.is_list_item(line) {
126 last_list_item_line = Some(i);
127 last_footnote_line = None; blank_line_count = 0;
129 in_continuation_context[i] = true;
130 continue;
131 }
132
133 if self.is_footnote_definition(line) {
135 last_footnote_line = Some(i);
136 last_list_item_line = None; blank_line_count = 0;
138 in_continuation_context[i] = true;
139 continue;
140 }
141
142 if line.trim().is_empty() {
144 if last_list_item_line.is_some() || last_footnote_line.is_some() {
146 blank_line_count += 1;
147 in_continuation_context[i] = true;
148
149 }
153 continue;
154 }
155
156 if indent_len == 0 && !trimmed.is_empty() {
158 if trimmed.starts_with('#') {
162 last_list_item_line = None;
163 last_footnote_line = None;
164 blank_line_count = 0;
165 continue;
166 }
167
168 if trimmed.starts_with("---") || trimmed.starts_with("***") {
170 last_list_item_line = None;
171 last_footnote_line = None;
172 blank_line_count = 0;
173 continue;
174 }
175
176 if let Some(list_line) = last_list_item_line
179 && (i - list_line > 5 || blank_line_count > 1)
180 {
181 last_list_item_line = None;
182 }
183
184 if last_footnote_line.is_some() {
186 last_footnote_line = None;
187 }
188
189 blank_line_count = 0;
190
191 if last_list_item_line.is_none() && last_footnote_line.is_some() {
193 last_footnote_line = None;
194 }
195 continue;
196 }
197
198 if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
200 in_continuation_context[i] = true;
201 blank_line_count = 0;
202 }
203 }
204
205 in_continuation_context
206 }
207
208 fn is_indented_code_block_with_context(
210 &self,
211 lines: &[&str],
212 i: usize,
213 is_mkdocs: bool,
214 in_list_context: &[bool],
215 in_tab_context: &[bool],
216 ) -> bool {
217 if i >= lines.len() {
218 return false;
219 }
220
221 let line = lines[i];
222
223 if !(line.starts_with(" ") || line.starts_with("\t")) {
225 return false;
226 }
227
228 if in_list_context[i] {
230 return false;
231 }
232
233 if is_mkdocs && in_tab_context[i] {
235 return false;
236 }
237
238 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
241 let prev_is_indented_code = i > 0
242 && (lines[i - 1].starts_with(" ") || lines[i - 1].starts_with("\t"))
243 && !in_list_context[i - 1]
244 && !(is_mkdocs && in_tab_context[i - 1]);
245
246 if !has_blank_line_before && !prev_is_indented_code {
249 return false;
250 }
251
252 true
253 }
254
255 fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
257 let mut in_tab_context = vec![false; lines.len()];
258 let mut current_tab_indent: Option<usize> = None;
259
260 for (i, line) in lines.iter().enumerate() {
261 if mkdocs_tabs::is_tab_marker(line) {
263 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
264 current_tab_indent = Some(tab_indent);
265 in_tab_context[i] = true;
266 continue;
267 }
268
269 if let Some(tab_indent) = current_tab_indent {
271 if mkdocs_tabs::is_tab_content(line, tab_indent) {
272 in_tab_context[i] = true;
273 } else if !line.trim().is_empty() && !line.starts_with(" ") {
274 current_tab_indent = None;
276 } else {
277 in_tab_context[i] = true;
279 }
280 }
281 }
282
283 in_tab_context
284 }
285
286 fn check_unclosed_code_blocks(
287 &self,
288 ctx: &crate::lint_context::LintContext,
289 line_index: &LineIndex,
290 ) -> Result<Vec<LintWarning>, LintError> {
291 let mut warnings = Vec::new();
292 let lines: Vec<&str> = ctx.content.lines().collect();
293 let mut fence_stack: Vec<(String, usize, usize, bool, bool)> = Vec::new(); let mut inside_markdown_documentation_block = false;
298
299 for (i, line) in lines.iter().enumerate() {
300 let trimmed = line.trim_start();
301
302 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
304 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
305
306 let fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
308
309 let after_fence = &trimmed[fence_length..];
311
312 let is_valid_fence_pattern = if after_fence.is_empty() {
318 true
320 } else if after_fence.starts_with(' ') || after_fence.starts_with('\t') {
321 true
323 } else {
324 let identifier = after_fence.trim().to_lowercase();
327
328 if identifier.contains("fence") || identifier.contains("still") {
330 false
331 } else if identifier.len() > 20 {
332 false
334 } else if let Some(first_char) = identifier.chars().next() {
335 if !first_char.is_alphabetic() && first_char != '#' {
337 false
338 } else {
339 let valid_chars = identifier.chars().all(|c| {
342 c.is_alphanumeric() || c == '-' || c == '_' || c == '+' || c == '#' || c == '.'
343 });
344
345 valid_chars && identifier.len() >= 2
347 }
348 } else {
349 false
350 }
351 };
352
353 if !fence_stack.is_empty() {
355 if !is_valid_fence_pattern {
357 continue;
358 }
359
360 if let Some((open_marker, open_length, _, _, _)) = fence_stack.last() {
362 if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
363 if !after_fence.trim().is_empty() {
365 let has_special_chars = after_fence.chars().any(|c| {
371 !c.is_alphanumeric()
372 && c != '-'
373 && c != '_'
374 && c != '+'
375 && c != '#'
376 && c != '.'
377 && c != ' '
378 && c != '\t'
379 });
380
381 if has_special_chars {
382 continue; }
384
385 if fence_length > 4 && after_fence.chars().take(4).all(|c| !c.is_alphanumeric()) {
387 continue; }
389
390 if !after_fence.starts_with(' ') && !after_fence.starts_with('\t') {
392 let identifier = after_fence.trim();
393
394 if let Some(first) = identifier.chars().next()
396 && !first.is_alphabetic()
397 && first != '#'
398 {
399 continue;
400 }
401
402 if identifier.len() > 30 {
404 continue;
405 }
406 }
407 }
408 } else {
410 if !after_fence.is_empty()
415 && !after_fence.starts_with(' ')
416 && !after_fence.starts_with('\t')
417 {
418 let identifier = after_fence.trim();
420
421 if identifier.chars().any(|c| {
423 !c.is_alphanumeric() && c != '-' && c != '_' && c != '+' && c != '#' && c != '.'
424 }) {
425 continue;
426 }
427
428 if let Some(first) = identifier.chars().next()
430 && !first.is_alphabetic()
431 && first != '#'
432 {
433 continue;
434 }
435 }
436 }
437 }
438 }
439
440 if let Some((open_marker, open_length, _open_line, _flagged, _is_md)) = fence_stack.last() {
444 if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
446 let after_fence = &trimmed[fence_length..];
448 if after_fence.trim().is_empty() {
449 let _popped = fence_stack.pop();
451
452 if let Some((_, _, _, _, is_md)) = _popped
454 && is_md
455 {
456 inside_markdown_documentation_block = false;
457 }
458 continue;
459 }
460 }
461 }
462
463 if !after_fence.trim().is_empty() || fence_stack.is_empty() {
466 let has_nested_issue =
470 if let Some((open_marker, open_length, open_line, _, _)) = fence_stack.last_mut() {
471 if fence_char == open_marker.chars().next().unwrap()
472 && fence_length >= *open_length
473 && !inside_markdown_documentation_block
474 {
475 let (opening_start_line, opening_start_col, opening_end_line, opening_end_col) =
477 calculate_line_range(*open_line, lines[*open_line - 1]);
478
479 let line_start_byte = line_index.get_line_start_byte(i + 1).unwrap_or(0);
481
482 warnings.push(LintWarning {
483 rule_name: Some(self.name().to_string()),
484 line: opening_start_line,
485 column: opening_start_col,
486 end_line: opening_end_line,
487 end_column: opening_end_col,
488 message: format!(
489 "Code block '{}' should be closed before starting new one at line {}",
490 open_marker,
491 i + 1
492 ),
493 severity: Severity::Warning,
494 fix: Some(Fix {
495 range: (line_start_byte..line_start_byte),
496 replacement: format!("{open_marker}\n\n"),
497 }),
498 });
499
500 fence_stack.last_mut().unwrap().3 = true;
502 true } else {
504 false
505 }
506 } else {
507 false
508 };
509
510 let after_fence_for_lang = &trimmed[fence_length..];
512 let lang_info = after_fence_for_lang.trim().to_lowercase();
513 let is_markdown_fence = lang_info.starts_with("markdown") || lang_info.starts_with("md");
514
515 if is_markdown_fence && !inside_markdown_documentation_block {
517 inside_markdown_documentation_block = true;
518 }
519
520 let fence_marker = fence_char.to_string().repeat(fence_length);
522 fence_stack.push((fence_marker, fence_length, i + 1, has_nested_issue, is_markdown_fence));
523 }
524 }
525 }
526
527 for (fence_marker, _, opening_line, flagged_for_nested, _) in fence_stack {
530 if !flagged_for_nested {
531 let (start_line, start_col, end_line, end_col) =
532 calculate_line_range(opening_line, lines[opening_line - 1]);
533
534 warnings.push(LintWarning {
535 rule_name: Some(self.name().to_string()),
536 line: start_line,
537 column: start_col,
538 end_line,
539 end_column: end_col,
540 message: format!("Code block opened with '{fence_marker}' but never closed"),
541 severity: Severity::Warning,
542 fix: Some(Fix {
543 range: (ctx.content.len()..ctx.content.len()),
544 replacement: format!("\n{fence_marker}"),
545 }),
546 });
547 }
548 }
549
550 Ok(warnings)
551 }
552
553 fn detect_style(&self, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
554 if content.is_empty() {
556 return None;
557 }
558
559 let lines: Vec<&str> = content.lines().collect();
560 let mut fenced_found = false;
561 let mut indented_found = false;
562 let mut fenced_line = usize::MAX;
563 let mut indented_line = usize::MAX;
564
565 let in_list_context = self.precompute_block_continuation_context(&lines);
567 let in_tab_context = if is_mkdocs {
568 self.precompute_mkdocs_tab_context(&lines)
569 } else {
570 vec![false; lines.len()]
571 };
572
573 for (i, line) in lines.iter().enumerate() {
575 if self.is_fenced_code_block_start(line) {
576 fenced_found = true;
577 fenced_line = fenced_line.min(i);
578 } else if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
579 {
580 indented_found = true;
581 indented_line = indented_line.min(i);
582 }
583 }
584
585 if !fenced_found && !indented_found {
586 None
588 } else if fenced_found && !indented_found {
589 Some(CodeBlockStyle::Fenced)
591 } else if !fenced_found && indented_found {
592 Some(CodeBlockStyle::Indented)
594 } else {
595 if indented_line < fenced_line {
597 Some(CodeBlockStyle::Indented)
598 } else {
599 Some(CodeBlockStyle::Fenced)
600 }
601 }
602 }
603}
604
605impl Rule for MD046CodeBlockStyle {
606 fn name(&self) -> &'static str {
607 "MD046"
608 }
609
610 fn description(&self) -> &'static str {
611 "Code blocks should use a consistent style"
612 }
613
614 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
615 if ctx.content.is_empty() {
617 return Ok(Vec::new());
618 }
619
620 if !ctx.content.contains("```") && !ctx.content.contains("~~~") && !ctx.content.contains(" ") {
622 return Ok(Vec::new());
623 }
624
625 let line_index = LineIndex::new(ctx.content.to_string());
627 let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
628
629 if !unclosed_warnings.is_empty() {
631 return Ok(unclosed_warnings);
632 }
633
634 let lines: Vec<&str> = ctx.content.lines().collect();
636 let mut warnings = Vec::new();
637
638 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
640
641 let in_list_context = self.precompute_block_continuation_context(&lines);
643 let in_tab_context = if is_mkdocs {
644 self.precompute_mkdocs_tab_context(&lines)
645 } else {
646 vec![false; lines.len()]
647 };
648
649 let target_style = match self.config.style {
651 CodeBlockStyle::Consistent => self
652 .detect_style(ctx.content, is_mkdocs)
653 .unwrap_or(CodeBlockStyle::Fenced),
654 _ => self.config.style,
655 };
656
657 let line_index = LineIndex::new(ctx.content.to_string());
659
660 let mut in_fenced_block = vec![false; lines.len()];
663 for &(start, end) in &ctx.code_blocks {
664 if start < ctx.content.len() && end <= ctx.content.len() {
666 let block_content = &ctx.content[start..end];
667 let is_fenced = block_content.starts_with("```") || block_content.starts_with("~~~");
668
669 if is_fenced {
670 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
672 if line_info.byte_offset >= start && line_info.byte_offset < end {
673 in_fenced_block[line_idx] = true;
674 }
675 }
676 }
677 }
678 }
679
680 let mut in_fence = false;
681 for (i, line) in lines.iter().enumerate() {
682 let trimmed = line.trim_start();
683
684 if ctx.line_info(i + 1).is_some_and(|info| info.in_html_block) {
686 continue;
687 }
688
689 if ctx.lines[i].in_mkdocstrings {
692 continue;
693 }
694
695 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
697 if target_style == CodeBlockStyle::Indented && !in_fence {
698 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
701 warnings.push(LintWarning {
702 rule_name: Some(self.name().to_string()),
703 line: start_line,
704 column: start_col,
705 end_line,
706 end_column: end_col,
707 message: "Use indented code blocks".to_string(),
708 severity: Severity::Warning,
709 fix: Some(Fix {
710 range: line_index.line_col_to_byte_range(i + 1, 1),
711 replacement: String::new(),
712 }),
713 });
714 }
715 in_fence = !in_fence;
717 continue;
718 }
719
720 if in_fenced_block[i] {
723 continue;
724 }
725
726 if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
728 && target_style == CodeBlockStyle::Fenced
729 {
730 let prev_line_is_indented = i > 0
732 && self.is_indented_code_block_with_context(
733 &lines,
734 i - 1,
735 is_mkdocs,
736 &in_list_context,
737 &in_tab_context,
738 );
739
740 if !prev_line_is_indented {
741 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
742 warnings.push(LintWarning {
743 rule_name: Some(self.name().to_string()),
744 line: start_line,
745 column: start_col,
746 end_line,
747 end_column: end_col,
748 message: "Use fenced code blocks".to_string(),
749 severity: Severity::Warning,
750 fix: Some(Fix {
751 range: line_index.line_col_to_byte_range(i + 1, 1),
752 replacement: format!("```\n{}", line.trim_start()),
753 }),
754 });
755 }
756 }
757 }
758
759 Ok(warnings)
760 }
761
762 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
763 let content = ctx.content;
764 if content.is_empty() {
765 return Ok(String::new());
766 }
767
768 let line_index = LineIndex::new(ctx.content.to_string());
770 let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
771
772 if !unclosed_warnings.is_empty() {
774 for warning in &unclosed_warnings {
776 if warning
777 .message
778 .contains("should be closed before starting new one at line")
779 {
780 if let Some(fix) = &warning.fix {
782 let mut result = String::new();
783 result.push_str(&content[..fix.range.start]);
784 result.push_str(&fix.replacement);
785 result.push_str(&content[fix.range.start..]);
786 return Ok(result);
787 }
788 }
789 }
790 }
791
792 let lines: Vec<&str> = content.lines().collect();
793
794 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
796 let target_style = match self.config.style {
797 CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
798 _ => self.config.style,
799 };
800
801 let in_list_context = self.precompute_block_continuation_context(&lines);
803 let in_tab_context = if is_mkdocs {
804 self.precompute_mkdocs_tab_context(&lines)
805 } else {
806 vec![false; lines.len()]
807 };
808
809 let mut result = String::with_capacity(content.len());
810 let mut in_fenced_block = false;
811 let mut fenced_fence_type = None;
812 let mut in_indented_block = false;
813
814 for (i, line) in lines.iter().enumerate() {
815 let trimmed = line.trim_start();
816
817 if !in_fenced_block && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
819 in_fenced_block = true;
820 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
821
822 if target_style == CodeBlockStyle::Indented {
823 in_indented_block = true;
825 } else {
826 result.push_str(line);
828 result.push('\n');
829 }
830 } else if in_fenced_block && fenced_fence_type.is_some() {
831 let fence = fenced_fence_type.unwrap();
832 if trimmed.starts_with(fence) {
833 in_fenced_block = false;
834 fenced_fence_type = None;
835 in_indented_block = false;
836
837 if target_style == CodeBlockStyle::Indented {
838 } else {
840 result.push_str(line);
842 result.push('\n');
843 }
844 } else if target_style == CodeBlockStyle::Indented {
845 result.push_str(" ");
847 result.push_str(trimmed);
848 result.push('\n');
849 } else {
850 result.push_str(line);
852 result.push('\n');
853 }
854 } else if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
855 {
856 let prev_line_is_indented = i > 0
860 && self.is_indented_code_block_with_context(
861 &lines,
862 i - 1,
863 is_mkdocs,
864 &in_list_context,
865 &in_tab_context,
866 );
867
868 if target_style == CodeBlockStyle::Fenced {
869 if !prev_line_is_indented && !in_indented_block {
870 result.push_str("```\n");
872 result.push_str(line.trim_start());
873 result.push('\n');
874 in_indented_block = true;
875 } else {
876 result.push_str(line.trim_start());
878 result.push('\n');
879 }
880
881 let _next_line_is_indented = i < lines.len() - 1
883 && self.is_indented_code_block_with_context(
884 &lines,
885 i + 1,
886 is_mkdocs,
887 &in_list_context,
888 &in_tab_context,
889 );
890 if !_next_line_is_indented && in_indented_block {
891 result.push_str("```\n");
892 in_indented_block = false;
893 }
894 } else {
895 result.push_str(line);
897 result.push('\n');
898 }
899 } else {
900 if in_indented_block && target_style == CodeBlockStyle::Fenced {
902 result.push_str("```\n");
903 in_indented_block = false;
904 }
905
906 result.push_str(line);
907 result.push('\n');
908 }
909 }
910
911 if in_indented_block && target_style == CodeBlockStyle::Fenced {
913 result.push_str("```\n");
914 }
915
916 if let Some(fence_type) = fenced_fence_type
918 && in_fenced_block
919 {
920 result.push_str(fence_type);
921 result.push('\n');
922 }
923
924 if !content.ends_with('\n') && result.ends_with('\n') {
926 result.pop();
927 }
928
929 Ok(result)
930 }
931
932 fn category(&self) -> RuleCategory {
934 RuleCategory::CodeBlock
935 }
936
937 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
939 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
942 }
943
944 fn as_any(&self) -> &dyn std::any::Any {
945 self
946 }
947
948 fn default_config_section(&self) -> Option<(String, toml::Value)> {
949 let json_value = serde_json::to_value(&self.config).ok()?;
950 Some((
951 self.name().to_string(),
952 crate::rule_config_serde::json_to_toml_value(&json_value)?,
953 ))
954 }
955
956 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
957 where
958 Self: Sized,
959 {
960 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
961 Box::new(Self::from_config_struct(rule_config))
962 }
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use crate::lint_context::LintContext;
969
970 #[test]
971 fn test_fenced_code_block_detection() {
972 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
973 assert!(rule.is_fenced_code_block_start("```"));
974 assert!(rule.is_fenced_code_block_start("```rust"));
975 assert!(rule.is_fenced_code_block_start("~~~"));
976 assert!(rule.is_fenced_code_block_start("~~~python"));
977 assert!(rule.is_fenced_code_block_start(" ```"));
978 assert!(!rule.is_fenced_code_block_start("``"));
979 assert!(!rule.is_fenced_code_block_start("~~"));
980 assert!(!rule.is_fenced_code_block_start("Regular text"));
981 }
982
983 #[test]
984 fn test_consistent_style_with_fenced_blocks() {
985 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
986 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
988 let result = rule.check(&ctx).unwrap();
989
990 assert_eq!(result.len(), 0);
992 }
993
994 #[test]
995 fn test_consistent_style_with_indented_blocks() {
996 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
997 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
999 let result = rule.check(&ctx).unwrap();
1000
1001 assert_eq!(result.len(), 0);
1003 }
1004
1005 #[test]
1006 fn test_consistent_style_mixed() {
1007 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1008 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1010 let result = rule.check(&ctx).unwrap();
1011
1012 assert!(!result.is_empty());
1014 }
1015
1016 #[test]
1017 fn test_fenced_style_with_indented_blocks() {
1018 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1019 let content = "Text\n\n indented code\n more code\n\nMore text";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1021 let result = rule.check(&ctx).unwrap();
1022
1023 assert!(!result.is_empty());
1025 assert!(result[0].message.contains("Use fenced code blocks"));
1026 }
1027
1028 #[test]
1029 fn test_indented_style_with_fenced_blocks() {
1030 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1031 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1033 let result = rule.check(&ctx).unwrap();
1034
1035 assert!(!result.is_empty());
1037 assert!(result[0].message.contains("Use indented code blocks"));
1038 }
1039
1040 #[test]
1041 fn test_unclosed_code_block() {
1042 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1043 let content = "```\ncode without closing fence";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1045 let result = rule.check(&ctx).unwrap();
1046
1047 assert_eq!(result.len(), 1);
1048 assert!(result[0].message.contains("never closed"));
1049 }
1050
1051 #[test]
1052 fn test_nested_code_blocks() {
1053 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1054 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1056 let result = rule.check(&ctx).unwrap();
1057
1058 assert_eq!(result.len(), 0);
1060 }
1061
1062 #[test]
1063 fn test_fix_indented_to_fenced() {
1064 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1065 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1067 let fixed = rule.fix(&ctx).unwrap();
1068
1069 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1070 }
1071
1072 #[test]
1073 fn test_fix_fenced_to_indented() {
1074 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1075 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1077 let fixed = rule.fix(&ctx).unwrap();
1078
1079 assert!(fixed.contains(" code line 1\n code line 2"));
1080 assert!(!fixed.contains("```"));
1081 }
1082
1083 #[test]
1084 fn test_fix_unclosed_block() {
1085 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1086 let content = "```\ncode without closing";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1088 let fixed = rule.fix(&ctx).unwrap();
1089
1090 assert!(fixed.ends_with("```"));
1092 }
1093
1094 #[test]
1095 fn test_code_block_in_list() {
1096 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1097 let content = "- List item\n code in list\n more code\n- Next item";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1099 let result = rule.check(&ctx).unwrap();
1100
1101 assert_eq!(result.len(), 0);
1103 }
1104
1105 #[test]
1106 fn test_detect_style_fenced() {
1107 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1108 let content = "```\ncode\n```";
1109 let style = rule.detect_style(content, false);
1110
1111 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1112 }
1113
1114 #[test]
1115 fn test_detect_style_indented() {
1116 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1117 let content = "Text\n\n code\n\nMore";
1118 let style = rule.detect_style(content, false);
1119
1120 assert_eq!(style, Some(CodeBlockStyle::Indented));
1121 }
1122
1123 #[test]
1124 fn test_detect_style_none() {
1125 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1126 let content = "No code blocks here";
1127 let style = rule.detect_style(content, false);
1128
1129 assert_eq!(style, None);
1130 }
1131
1132 #[test]
1133 fn test_tilde_fence() {
1134 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1135 let content = "~~~\ncode\n~~~";
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1137 let result = rule.check(&ctx).unwrap();
1138
1139 assert_eq!(result.len(), 0);
1141 }
1142
1143 #[test]
1144 fn test_language_specification() {
1145 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1146 let content = "```rust\nfn main() {}\n```";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1148 let result = rule.check(&ctx).unwrap();
1149
1150 assert_eq!(result.len(), 0);
1151 }
1152
1153 #[test]
1154 fn test_empty_content() {
1155 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1156 let content = "";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1158 let result = rule.check(&ctx).unwrap();
1159
1160 assert_eq!(result.len(), 0);
1161 }
1162
1163 #[test]
1164 fn test_default_config() {
1165 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1166 let (name, _config) = rule.default_config_section().unwrap();
1167 assert_eq!(name, "MD046");
1168 }
1169
1170 #[test]
1171 fn test_markdown_documentation_block() {
1172 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1173 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1175 let result = rule.check(&ctx).unwrap();
1176
1177 assert_eq!(result.len(), 0);
1179 }
1180
1181 #[test]
1182 fn test_preserve_trailing_newline() {
1183 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1184 let content = "```\ncode\n```\n";
1185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1186 let fixed = rule.fix(&ctx).unwrap();
1187
1188 assert_eq!(fixed, content);
1189 }
1190
1191 #[test]
1192 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1193 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1194 let content = r#"# Document
1195
1196=== "Python"
1197
1198 This is tab content
1199 Not an indented code block
1200
1201 ```python
1202 def hello():
1203 print("Hello")
1204 ```
1205
1206=== "JavaScript"
1207
1208 More tab content here
1209 Also not an indented code block"#;
1210
1211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1212 let result = rule.check(&ctx).unwrap();
1213
1214 assert_eq!(result.len(), 0);
1216 }
1217
1218 #[test]
1219 fn test_mkdocs_tabs_with_actual_indented_code() {
1220 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1221 let content = r#"# Document
1222
1223=== "Tab 1"
1224
1225 This is tab content
1226
1227Regular text
1228
1229 This is an actual indented code block
1230 Should be flagged"#;
1231
1232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1233 let result = rule.check(&ctx).unwrap();
1234
1235 assert_eq!(result.len(), 1);
1237 assert!(result[0].message.contains("Use fenced code blocks"));
1238 }
1239
1240 #[test]
1241 fn test_mkdocs_tabs_detect_style() {
1242 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1243 let content = r#"=== "Tab 1"
1244
1245 Content in tab
1246 More content
1247
1248=== "Tab 2"
1249
1250 Content in second tab"#;
1251
1252 let style = rule.detect_style(content, true);
1254 assert_eq!(style, None); let style = rule.detect_style(content, false);
1258 assert_eq!(style, Some(CodeBlockStyle::Indented));
1259 }
1260
1261 #[test]
1262 fn test_mkdocs_nested_tabs() {
1263 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1264 let content = r#"# Document
1265
1266=== "Outer Tab"
1267
1268 Some content
1269
1270 === "Nested Tab"
1271
1272 Nested tab content
1273 Should not be flagged"#;
1274
1275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1276 let result = rule.check(&ctx).unwrap();
1277
1278 assert_eq!(result.len(), 0);
1280 }
1281
1282 #[test]
1283 fn test_footnote_indented_paragraphs_not_flagged() {
1284 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1285 let content = r#"# Test Document with Footnotes
1286
1287This is some text with a footnote[^1].
1288
1289Here's some code:
1290
1291```bash
1292echo "fenced code block"
1293```
1294
1295More text with another footnote[^2].
1296
1297[^1]: Really interesting footnote text.
1298
1299 Even more interesting second paragraph.
1300
1301[^2]: Another footnote.
1302
1303 With a second paragraph too.
1304
1305 And even a third paragraph!"#;
1306
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1308 let result = rule.check(&ctx).unwrap();
1309
1310 assert_eq!(result.len(), 0);
1312 }
1313
1314 #[test]
1315 fn test_footnote_definition_detection() {
1316 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1317
1318 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1321 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1322 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1323 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1324 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1325 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1326 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1327 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1328 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1329
1330 assert!(!rule.is_footnote_definition("[^]: No label"));
1332 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1333 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1334 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1335
1336 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1338 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1339 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1340 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1343
1344 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1346 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1347 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1348 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1349 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1350
1351 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1354 }
1355
1356 #[test]
1357 fn test_footnote_with_blank_lines() {
1358 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1362 let content = r#"# Document
1363
1364Text with footnote[^1].
1365
1366[^1]: First paragraph.
1367
1368 Second paragraph after blank line.
1369
1370 Third paragraph after another blank line.
1371
1372Regular text at column 0 ends the footnote."#;
1373
1374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1375 let result = rule.check(&ctx).unwrap();
1376
1377 assert_eq!(
1379 result.len(),
1380 0,
1381 "Indented content within footnotes should not trigger MD046"
1382 );
1383 }
1384
1385 #[test]
1386 fn test_footnote_multiple_consecutive_blank_lines() {
1387 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1390 let content = r#"Text[^1].
1391
1392[^1]: First paragraph.
1393
1394
1395
1396 Content after three blank lines (still part of footnote).
1397
1398Not indented, so footnote ends here."#;
1399
1400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1401 let result = rule.check(&ctx).unwrap();
1402
1403 assert_eq!(
1405 result.len(),
1406 0,
1407 "Multiple blank lines shouldn't break footnote continuation"
1408 );
1409 }
1410
1411 #[test]
1412 fn test_footnote_terminated_by_non_indented_content() {
1413 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1416 let content = r#"[^1]: Footnote content.
1417
1418 More indented content in footnote.
1419
1420This paragraph is not indented, so footnote ends.
1421
1422 This should be flagged as indented code block."#;
1423
1424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1425 let result = rule.check(&ctx).unwrap();
1426
1427 assert_eq!(
1429 result.len(),
1430 1,
1431 "Indented code after footnote termination should be flagged"
1432 );
1433 assert!(
1434 result[0].message.contains("Use fenced code blocks"),
1435 "Expected MD046 warning for indented code block"
1436 );
1437 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1438 }
1439
1440 #[test]
1441 fn test_footnote_terminated_by_structural_elements() {
1442 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1444 let content = r#"[^1]: Footnote content.
1445
1446 More content.
1447
1448## Heading terminates footnote
1449
1450 This indented content should be flagged.
1451
1452---
1453
1454 This should also be flagged (after horizontal rule)."#;
1455
1456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1457 let result = rule.check(&ctx).unwrap();
1458
1459 assert_eq!(
1461 result.len(),
1462 2,
1463 "Both indented blocks after termination should be flagged"
1464 );
1465 }
1466
1467 #[test]
1468 fn test_footnote_with_code_block_inside() {
1469 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1472 let content = r#"Text[^1].
1473
1474[^1]: Footnote with code:
1475
1476 ```python
1477 def hello():
1478 print("world")
1479 ```
1480
1481 More footnote text after code."#;
1482
1483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1484 let result = rule.check(&ctx).unwrap();
1485
1486 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1488 }
1489
1490 #[test]
1491 fn test_footnote_with_8_space_indented_code() {
1492 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1495 let content = r#"Text[^1].
1496
1497[^1]: Footnote with nested code.
1498
1499 code block
1500 more code"#;
1501
1502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1503 let result = rule.check(&ctx).unwrap();
1504
1505 assert_eq!(
1507 result.len(),
1508 0,
1509 "8-space indented code within footnotes represents nested code blocks"
1510 );
1511 }
1512
1513 #[test]
1514 fn test_multiple_footnotes() {
1515 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1518 let content = r#"Text[^1] and more[^2].
1519
1520[^1]: First footnote.
1521
1522 Continuation of first.
1523
1524[^2]: Second footnote starts here, ending the first.
1525
1526 Continuation of second."#;
1527
1528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1529 let result = rule.check(&ctx).unwrap();
1530
1531 assert_eq!(
1533 result.len(),
1534 0,
1535 "Multiple footnotes should each maintain their continuation context"
1536 );
1537 }
1538
1539 #[test]
1540 fn test_list_item_ends_footnote_context() {
1541 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1543 let content = r#"[^1]: Footnote.
1544
1545 Content in footnote.
1546
1547- List item starts here (ends footnote context).
1548
1549 This indented content is part of the list, not the footnote."#;
1550
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1552 let result = rule.check(&ctx).unwrap();
1553
1554 assert_eq!(
1556 result.len(),
1557 0,
1558 "List items should end footnote context and start their own"
1559 );
1560 }
1561
1562 #[test]
1563 fn test_footnote_vs_actual_indented_code() {
1564 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1567 let content = r#"# Heading
1568
1569Text with footnote[^1].
1570
1571[^1]: Footnote content.
1572
1573 Part of footnote (should not be flagged).
1574
1575Regular paragraph ends footnote context.
1576
1577 This is actual indented code (MUST be flagged)
1578 Should be detected as code block"#;
1579
1580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1581 let result = rule.check(&ctx).unwrap();
1582
1583 assert_eq!(
1585 result.len(),
1586 1,
1587 "Must still detect indented code blocks outside footnotes"
1588 );
1589 assert!(
1590 result[0].message.contains("Use fenced code blocks"),
1591 "Expected MD046 warning for indented code"
1592 );
1593 assert!(
1594 result[0].line >= 11,
1595 "Warning should be on the actual indented code line"
1596 );
1597 }
1598
1599 #[test]
1600 fn test_spec_compliant_label_characters() {
1601 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1604
1605 assert!(rule.is_footnote_definition("[^test]: text"));
1607 assert!(rule.is_footnote_definition("[^TEST]: text"));
1608 assert!(rule.is_footnote_definition("[^test-name]: text"));
1609 assert!(rule.is_footnote_definition("[^test_name]: text"));
1610 assert!(rule.is_footnote_definition("[^test123]: text"));
1611 assert!(rule.is_footnote_definition("[^123]: text"));
1612 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
1613
1614 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")); }
1622}