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::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 ) -> Result<Vec<LintWarning>, LintError> {
290 let mut warnings = Vec::new();
291 let lines: Vec<&str> = ctx.content.lines().collect();
292 let mut fence_stack: Vec<(String, usize, usize, bool, bool)> = Vec::new(); let mut inside_markdown_documentation_block = false;
297
298 for (i, line) in lines.iter().enumerate() {
299 let trimmed = line.trim_start();
300
301 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
303 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
304
305 let fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
307
308 let after_fence = &trimmed[fence_length..];
310
311 let is_valid_fence_pattern = if after_fence.is_empty() {
317 true
319 } else if after_fence.starts_with(' ') || after_fence.starts_with('\t') {
320 true
322 } else {
323 let identifier = after_fence.trim().to_lowercase();
326
327 if identifier.contains("fence") || identifier.contains("still") {
329 false
330 } else if identifier.len() > 20 {
331 false
333 } else if let Some(first_char) = identifier.chars().next() {
334 if !first_char.is_alphabetic() && first_char != '#' {
336 false
337 } else {
338 let valid_chars = identifier.chars().all(|c| {
341 c.is_alphanumeric() || c == '-' || c == '_' || c == '+' || c == '#' || c == '.'
342 });
343
344 valid_chars && identifier.len() >= 2
346 }
347 } else {
348 false
349 }
350 };
351
352 if !fence_stack.is_empty() {
354 if !is_valid_fence_pattern {
356 continue;
357 }
358
359 if let Some((open_marker, open_length, _, _, _)) = fence_stack.last() {
361 if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
362 if !after_fence.trim().is_empty() {
364 let has_special_chars = after_fence.chars().any(|c| {
370 !c.is_alphanumeric()
371 && c != '-'
372 && c != '_'
373 && c != '+'
374 && c != '#'
375 && c != '.'
376 && c != ' '
377 && c != '\t'
378 });
379
380 if has_special_chars {
381 continue; }
383
384 if fence_length > 4 && after_fence.chars().take(4).all(|c| !c.is_alphanumeric()) {
386 continue; }
388
389 if !after_fence.starts_with(' ') && !after_fence.starts_with('\t') {
391 let identifier = after_fence.trim();
392
393 if let Some(first) = identifier.chars().next()
395 && !first.is_alphabetic()
396 && first != '#'
397 {
398 continue;
399 }
400
401 if identifier.len() > 30 {
403 continue;
404 }
405 }
406 }
407 } else {
409 if !after_fence.is_empty()
414 && !after_fence.starts_with(' ')
415 && !after_fence.starts_with('\t')
416 {
417 let identifier = after_fence.trim();
419
420 if identifier.chars().any(|c| {
422 !c.is_alphanumeric() && c != '-' && c != '_' && c != '+' && c != '#' && c != '.'
423 }) {
424 continue;
425 }
426
427 if let Some(first) = identifier.chars().next()
429 && !first.is_alphabetic()
430 && first != '#'
431 {
432 continue;
433 }
434 }
435 }
436 }
437 }
438
439 if let Some((open_marker, open_length, _open_line, _flagged, _is_md)) = fence_stack.last() {
443 if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
445 let after_fence = &trimmed[fence_length..];
447 if after_fence.trim().is_empty() {
448 let _popped = fence_stack.pop();
450
451 if let Some((_, _, _, _, is_md)) = _popped
453 && is_md
454 {
455 inside_markdown_documentation_block = false;
456 }
457 continue;
458 }
459 }
460 }
461
462 if !after_fence.trim().is_empty() || fence_stack.is_empty() {
465 let has_nested_issue =
469 if let Some((open_marker, open_length, open_line, _, _)) = fence_stack.last_mut() {
470 if fence_char == open_marker.chars().next().unwrap()
471 && fence_length >= *open_length
472 && !inside_markdown_documentation_block
473 {
474 let (opening_start_line, opening_start_col, opening_end_line, opening_end_col) =
476 calculate_line_range(*open_line, lines[*open_line - 1]);
477
478 let line_start_byte = ctx.line_index.get_line_start_byte(i + 1).unwrap_or(0);
480
481 warnings.push(LintWarning {
482 rule_name: Some(self.name().to_string()),
483 line: opening_start_line,
484 column: opening_start_col,
485 end_line: opening_end_line,
486 end_column: opening_end_col,
487 message: format!(
488 "Code block '{}' should be closed before starting new one at line {}",
489 open_marker,
490 i + 1
491 ),
492 severity: Severity::Warning,
493 fix: Some(Fix {
494 range: (line_start_byte..line_start_byte),
495 replacement: format!("{open_marker}\n\n"),
496 }),
497 });
498
499 fence_stack.last_mut().unwrap().3 = true;
501 true } else {
503 false
504 }
505 } else {
506 false
507 };
508
509 let after_fence_for_lang = &trimmed[fence_length..];
511 let lang_info = after_fence_for_lang.trim().to_lowercase();
512 let is_markdown_fence = lang_info.starts_with("markdown") || lang_info.starts_with("md");
513
514 if is_markdown_fence && !inside_markdown_documentation_block {
516 inside_markdown_documentation_block = true;
517 }
518
519 let fence_marker = fence_char.to_string().repeat(fence_length);
521 fence_stack.push((fence_marker, fence_length, i + 1, has_nested_issue, is_markdown_fence));
522 }
523 }
524 }
525
526 for (fence_marker, _, opening_line, flagged_for_nested, _) in fence_stack {
529 if !flagged_for_nested {
530 let (start_line, start_col, end_line, end_col) =
531 calculate_line_range(opening_line, lines[opening_line - 1]);
532
533 warnings.push(LintWarning {
534 rule_name: Some(self.name().to_string()),
535 line: start_line,
536 column: start_col,
537 end_line,
538 end_column: end_col,
539 message: format!("Code block opened with '{fence_marker}' but never closed"),
540 severity: Severity::Warning,
541 fix: Some(Fix {
542 range: (ctx.content.len()..ctx.content.len()),
543 replacement: format!("\n{fence_marker}"),
544 }),
545 });
546 }
547 }
548
549 Ok(warnings)
550 }
551
552 fn detect_style(&self, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
553 if content.is_empty() {
555 return None;
556 }
557
558 let lines: Vec<&str> = content.lines().collect();
559 let mut fenced_count = 0;
560 let mut indented_count = 0;
561
562 let in_list_context = self.precompute_block_continuation_context(&lines);
564 let in_tab_context = if is_mkdocs {
565 self.precompute_mkdocs_tab_context(&lines)
566 } else {
567 vec![false; lines.len()]
568 };
569
570 let mut in_fenced = false;
572 let mut prev_was_indented = false;
573
574 for (i, line) in lines.iter().enumerate() {
575 if self.is_fenced_code_block_start(line) {
576 if !in_fenced {
577 fenced_count += 1;
579 in_fenced = true;
580 } else {
581 in_fenced = false;
583 }
584 } else if !in_fenced
585 && self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
586 {
587 if !prev_was_indented {
589 indented_count += 1;
590 }
591 prev_was_indented = true;
592 } else {
593 prev_was_indented = false;
594 }
595 }
596
597 if fenced_count == 0 && indented_count == 0 {
598 None
600 } else if fenced_count > 0 && indented_count == 0 {
601 Some(CodeBlockStyle::Fenced)
603 } else if fenced_count == 0 && indented_count > 0 {
604 Some(CodeBlockStyle::Indented)
606 } else {
607 if fenced_count >= indented_count {
610 Some(CodeBlockStyle::Fenced)
611 } else {
612 Some(CodeBlockStyle::Indented)
613 }
614 }
615 }
616}
617
618impl Rule for MD046CodeBlockStyle {
619 fn name(&self) -> &'static str {
620 "MD046"
621 }
622
623 fn description(&self) -> &'static str {
624 "Code blocks should use a consistent style"
625 }
626
627 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
628 if ctx.content.is_empty() {
630 return Ok(Vec::new());
631 }
632
633 if !ctx.content.contains("```") && !ctx.content.contains("~~~") && !ctx.content.contains(" ") {
635 return Ok(Vec::new());
636 }
637
638 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
640
641 if !unclosed_warnings.is_empty() {
643 return Ok(unclosed_warnings);
644 }
645
646 let lines: Vec<&str> = ctx.content.lines().collect();
648 let mut warnings = Vec::new();
649
650 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
652
653 let in_list_context = self.precompute_block_continuation_context(&lines);
655 let in_tab_context = if is_mkdocs {
656 self.precompute_mkdocs_tab_context(&lines)
657 } else {
658 vec![false; lines.len()]
659 };
660
661 let target_style = match self.config.style {
663 CodeBlockStyle::Consistent => self
664 .detect_style(ctx.content, is_mkdocs)
665 .unwrap_or(CodeBlockStyle::Fenced),
666 _ => self.config.style,
667 };
668
669 let mut in_fenced_block = vec![false; lines.len()];
673 for &(start, end) in &ctx.code_blocks {
674 if start < ctx.content.len() && end <= ctx.content.len() {
676 let block_content = &ctx.content[start..end];
677 let is_fenced = block_content.starts_with("```") || block_content.starts_with("~~~");
678
679 if is_fenced {
680 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
682 if line_info.byte_offset >= start && line_info.byte_offset < end {
683 in_fenced_block[line_idx] = true;
684 }
685 }
686 }
687 }
688 }
689
690 let mut in_fence = false;
691 for (i, line) in lines.iter().enumerate() {
692 let trimmed = line.trim_start();
693
694 if ctx.line_info(i + 1).is_some_and(|info| info.in_html_block) {
696 continue;
697 }
698
699 if ctx.lines[i].in_mkdocstrings {
702 continue;
703 }
704
705 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
707 if target_style == CodeBlockStyle::Indented && !in_fence {
708 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
711 warnings.push(LintWarning {
712 rule_name: Some(self.name().to_string()),
713 line: start_line,
714 column: start_col,
715 end_line,
716 end_column: end_col,
717 message: "Use indented code blocks".to_string(),
718 severity: Severity::Warning,
719 fix: Some(Fix {
720 range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
721 replacement: String::new(),
722 }),
723 });
724 }
725 in_fence = !in_fence;
727 continue;
728 }
729
730 if in_fenced_block[i] {
733 continue;
734 }
735
736 if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
738 && target_style == CodeBlockStyle::Fenced
739 {
740 let prev_line_is_indented = i > 0
742 && self.is_indented_code_block_with_context(
743 &lines,
744 i - 1,
745 is_mkdocs,
746 &in_list_context,
747 &in_tab_context,
748 );
749
750 if !prev_line_is_indented {
751 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
752 warnings.push(LintWarning {
753 rule_name: Some(self.name().to_string()),
754 line: start_line,
755 column: start_col,
756 end_line,
757 end_column: end_col,
758 message: "Use fenced code blocks".to_string(),
759 severity: Severity::Warning,
760 fix: Some(Fix {
761 range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
762 replacement: format!("```\n{}", line.trim_start()),
763 }),
764 });
765 }
766 }
767 }
768
769 Ok(warnings)
770 }
771
772 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
773 let content = ctx.content;
774 if content.is_empty() {
775 return Ok(String::new());
776 }
777
778 let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
780
781 if !unclosed_warnings.is_empty() {
783 for warning in &unclosed_warnings {
785 if warning
786 .message
787 .contains("should be closed before starting new one at line")
788 {
789 if let Some(fix) = &warning.fix {
791 let mut result = String::new();
792 result.push_str(&content[..fix.range.start]);
793 result.push_str(&fix.replacement);
794 result.push_str(&content[fix.range.start..]);
795 return Ok(result);
796 }
797 }
798 }
799 }
800
801 let lines: Vec<&str> = content.lines().collect();
802
803 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
805 let target_style = match self.config.style {
806 CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
807 _ => self.config.style,
808 };
809
810 let in_list_context = self.precompute_block_continuation_context(&lines);
812 let in_tab_context = if is_mkdocs {
813 self.precompute_mkdocs_tab_context(&lines)
814 } else {
815 vec![false; lines.len()]
816 };
817
818 let mut result = String::with_capacity(content.len());
819 let mut in_fenced_block = false;
820 let mut fenced_fence_type = None;
821 let mut in_indented_block = false;
822
823 for (i, line) in lines.iter().enumerate() {
824 let trimmed = line.trim_start();
825
826 if !in_fenced_block && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
828 in_fenced_block = true;
829 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
830
831 if target_style == CodeBlockStyle::Indented {
832 in_indented_block = true;
834 } else {
835 result.push_str(line);
837 result.push('\n');
838 }
839 } else if in_fenced_block && fenced_fence_type.is_some() {
840 let fence = fenced_fence_type.unwrap();
841 if trimmed.starts_with(fence) {
842 in_fenced_block = false;
843 fenced_fence_type = None;
844 in_indented_block = false;
845
846 if target_style == CodeBlockStyle::Indented {
847 } else {
849 result.push_str(line);
851 result.push('\n');
852 }
853 } else if target_style == CodeBlockStyle::Indented {
854 result.push_str(" ");
856 result.push_str(trimmed);
857 result.push('\n');
858 } else {
859 result.push_str(line);
861 result.push('\n');
862 }
863 } else if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
864 {
865 let prev_line_is_indented = i > 0
869 && self.is_indented_code_block_with_context(
870 &lines,
871 i - 1,
872 is_mkdocs,
873 &in_list_context,
874 &in_tab_context,
875 );
876
877 if target_style == CodeBlockStyle::Fenced {
878 if !prev_line_is_indented && !in_indented_block {
879 result.push_str("```\n");
881 result.push_str(line.trim_start());
882 result.push('\n');
883 in_indented_block = true;
884 } else {
885 result.push_str(line.trim_start());
887 result.push('\n');
888 }
889
890 let _next_line_is_indented = i < lines.len() - 1
892 && self.is_indented_code_block_with_context(
893 &lines,
894 i + 1,
895 is_mkdocs,
896 &in_list_context,
897 &in_tab_context,
898 );
899 if !_next_line_is_indented && in_indented_block {
900 result.push_str("```\n");
901 in_indented_block = false;
902 }
903 } else {
904 result.push_str(line);
906 result.push('\n');
907 }
908 } else {
909 if in_indented_block && target_style == CodeBlockStyle::Fenced {
911 result.push_str("```\n");
912 in_indented_block = false;
913 }
914
915 result.push_str(line);
916 result.push('\n');
917 }
918 }
919
920 if in_indented_block && target_style == CodeBlockStyle::Fenced {
922 result.push_str("```\n");
923 }
924
925 if let Some(fence_type) = fenced_fence_type
927 && in_fenced_block
928 {
929 result.push_str(fence_type);
930 result.push('\n');
931 }
932
933 if !content.ends_with('\n') && result.ends_with('\n') {
935 result.pop();
936 }
937
938 Ok(result)
939 }
940
941 fn category(&self) -> RuleCategory {
943 RuleCategory::CodeBlock
944 }
945
946 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
948 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
951 }
952
953 fn as_any(&self) -> &dyn std::any::Any {
954 self
955 }
956
957 fn default_config_section(&self) -> Option<(String, toml::Value)> {
958 let json_value = serde_json::to_value(&self.config).ok()?;
959 Some((
960 self.name().to_string(),
961 crate::rule_config_serde::json_to_toml_value(&json_value)?,
962 ))
963 }
964
965 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
966 where
967 Self: Sized,
968 {
969 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
970 Box::new(Self::from_config_struct(rule_config))
971 }
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977 use crate::lint_context::LintContext;
978
979 #[test]
980 fn test_fenced_code_block_detection() {
981 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
982 assert!(rule.is_fenced_code_block_start("```"));
983 assert!(rule.is_fenced_code_block_start("```rust"));
984 assert!(rule.is_fenced_code_block_start("~~~"));
985 assert!(rule.is_fenced_code_block_start("~~~python"));
986 assert!(rule.is_fenced_code_block_start(" ```"));
987 assert!(!rule.is_fenced_code_block_start("``"));
988 assert!(!rule.is_fenced_code_block_start("~~"));
989 assert!(!rule.is_fenced_code_block_start("Regular text"));
990 }
991
992 #[test]
993 fn test_consistent_style_with_fenced_blocks() {
994 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
995 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
997 let result = rule.check(&ctx).unwrap();
998
999 assert_eq!(result.len(), 0);
1001 }
1002
1003 #[test]
1004 fn test_consistent_style_with_indented_blocks() {
1005 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1006 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1008 let result = rule.check(&ctx).unwrap();
1009
1010 assert_eq!(result.len(), 0);
1012 }
1013
1014 #[test]
1015 fn test_consistent_style_mixed() {
1016 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1017 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1019 let result = rule.check(&ctx).unwrap();
1020
1021 assert!(!result.is_empty());
1023 }
1024
1025 #[test]
1026 fn test_fenced_style_with_indented_blocks() {
1027 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1028 let content = "Text\n\n indented code\n more code\n\nMore text";
1029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1030 let result = rule.check(&ctx).unwrap();
1031
1032 assert!(!result.is_empty());
1034 assert!(result[0].message.contains("Use fenced code blocks"));
1035 }
1036
1037 #[test]
1038 fn test_indented_style_with_fenced_blocks() {
1039 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1040 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1042 let result = rule.check(&ctx).unwrap();
1043
1044 assert!(!result.is_empty());
1046 assert!(result[0].message.contains("Use indented code blocks"));
1047 }
1048
1049 #[test]
1050 fn test_unclosed_code_block() {
1051 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1052 let content = "```\ncode without closing fence";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1054 let result = rule.check(&ctx).unwrap();
1055
1056 assert_eq!(result.len(), 1);
1057 assert!(result[0].message.contains("never closed"));
1058 }
1059
1060 #[test]
1061 fn test_nested_code_blocks() {
1062 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1063 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1065 let result = rule.check(&ctx).unwrap();
1066
1067 assert_eq!(result.len(), 0);
1069 }
1070
1071 #[test]
1072 fn test_fix_indented_to_fenced() {
1073 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1074 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1076 let fixed = rule.fix(&ctx).unwrap();
1077
1078 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1079 }
1080
1081 #[test]
1082 fn test_fix_fenced_to_indented() {
1083 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1084 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1086 let fixed = rule.fix(&ctx).unwrap();
1087
1088 assert!(fixed.contains(" code line 1\n code line 2"));
1089 assert!(!fixed.contains("```"));
1090 }
1091
1092 #[test]
1093 fn test_fix_unclosed_block() {
1094 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1095 let content = "```\ncode without closing";
1096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1097 let fixed = rule.fix(&ctx).unwrap();
1098
1099 assert!(fixed.ends_with("```"));
1101 }
1102
1103 #[test]
1104 fn test_code_block_in_list() {
1105 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1106 let content = "- List item\n code in list\n more code\n- Next item";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1108 let result = rule.check(&ctx).unwrap();
1109
1110 assert_eq!(result.len(), 0);
1112 }
1113
1114 #[test]
1115 fn test_detect_style_fenced() {
1116 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1117 let content = "```\ncode\n```";
1118 let style = rule.detect_style(content, false);
1119
1120 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1121 }
1122
1123 #[test]
1124 fn test_detect_style_indented() {
1125 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1126 let content = "Text\n\n code\n\nMore";
1127 let style = rule.detect_style(content, false);
1128
1129 assert_eq!(style, Some(CodeBlockStyle::Indented));
1130 }
1131
1132 #[test]
1133 fn test_detect_style_none() {
1134 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1135 let content = "No code blocks here";
1136 let style = rule.detect_style(content, false);
1137
1138 assert_eq!(style, None);
1139 }
1140
1141 #[test]
1142 fn test_tilde_fence() {
1143 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1144 let content = "~~~\ncode\n~~~";
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1146 let result = rule.check(&ctx).unwrap();
1147
1148 assert_eq!(result.len(), 0);
1150 }
1151
1152 #[test]
1153 fn test_language_specification() {
1154 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1155 let content = "```rust\nfn main() {}\n```";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1157 let result = rule.check(&ctx).unwrap();
1158
1159 assert_eq!(result.len(), 0);
1160 }
1161
1162 #[test]
1163 fn test_empty_content() {
1164 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1165 let content = "";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1167 let result = rule.check(&ctx).unwrap();
1168
1169 assert_eq!(result.len(), 0);
1170 }
1171
1172 #[test]
1173 fn test_default_config() {
1174 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1175 let (name, _config) = rule.default_config_section().unwrap();
1176 assert_eq!(name, "MD046");
1177 }
1178
1179 #[test]
1180 fn test_markdown_documentation_block() {
1181 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1182 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1184 let result = rule.check(&ctx).unwrap();
1185
1186 assert_eq!(result.len(), 0);
1188 }
1189
1190 #[test]
1191 fn test_preserve_trailing_newline() {
1192 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1193 let content = "```\ncode\n```\n";
1194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1195 let fixed = rule.fix(&ctx).unwrap();
1196
1197 assert_eq!(fixed, content);
1198 }
1199
1200 #[test]
1201 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1202 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1203 let content = r#"# Document
1204
1205=== "Python"
1206
1207 This is tab content
1208 Not an indented code block
1209
1210 ```python
1211 def hello():
1212 print("Hello")
1213 ```
1214
1215=== "JavaScript"
1216
1217 More tab content here
1218 Also not an indented code block"#;
1219
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1221 let result = rule.check(&ctx).unwrap();
1222
1223 assert_eq!(result.len(), 0);
1225 }
1226
1227 #[test]
1228 fn test_mkdocs_tabs_with_actual_indented_code() {
1229 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1230 let content = r#"# Document
1231
1232=== "Tab 1"
1233
1234 This is tab content
1235
1236Regular text
1237
1238 This is an actual indented code block
1239 Should be flagged"#;
1240
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1242 let result = rule.check(&ctx).unwrap();
1243
1244 assert_eq!(result.len(), 1);
1246 assert!(result[0].message.contains("Use fenced code blocks"));
1247 }
1248
1249 #[test]
1250 fn test_mkdocs_tabs_detect_style() {
1251 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1252 let content = r#"=== "Tab 1"
1253
1254 Content in tab
1255 More content
1256
1257=== "Tab 2"
1258
1259 Content in second tab"#;
1260
1261 let style = rule.detect_style(content, true);
1263 assert_eq!(style, None); let style = rule.detect_style(content, false);
1267 assert_eq!(style, Some(CodeBlockStyle::Indented));
1268 }
1269
1270 #[test]
1271 fn test_mkdocs_nested_tabs() {
1272 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1273 let content = r#"# Document
1274
1275=== "Outer Tab"
1276
1277 Some content
1278
1279 === "Nested Tab"
1280
1281 Nested tab content
1282 Should not be flagged"#;
1283
1284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1285 let result = rule.check(&ctx).unwrap();
1286
1287 assert_eq!(result.len(), 0);
1289 }
1290
1291 #[test]
1292 fn test_footnote_indented_paragraphs_not_flagged() {
1293 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1294 let content = r#"# Test Document with Footnotes
1295
1296This is some text with a footnote[^1].
1297
1298Here's some code:
1299
1300```bash
1301echo "fenced code block"
1302```
1303
1304More text with another footnote[^2].
1305
1306[^1]: Really interesting footnote text.
1307
1308 Even more interesting second paragraph.
1309
1310[^2]: Another footnote.
1311
1312 With a second paragraph too.
1313
1314 And even a third paragraph!"#;
1315
1316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1317 let result = rule.check(&ctx).unwrap();
1318
1319 assert_eq!(result.len(), 0);
1321 }
1322
1323 #[test]
1324 fn test_footnote_definition_detection() {
1325 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1326
1327 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1330 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1331 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1332 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1333 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1334 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1335 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1336 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1337 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1338
1339 assert!(!rule.is_footnote_definition("[^]: No label"));
1341 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1342 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1343 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1344
1345 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1347 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1348 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1349 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1352
1353 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1355 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1356 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1357 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1358 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1359
1360 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1363 }
1364
1365 #[test]
1366 fn test_footnote_with_blank_lines() {
1367 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1371 let content = r#"# Document
1372
1373Text with footnote[^1].
1374
1375[^1]: First paragraph.
1376
1377 Second paragraph after blank line.
1378
1379 Third paragraph after another blank line.
1380
1381Regular text at column 0 ends the footnote."#;
1382
1383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1384 let result = rule.check(&ctx).unwrap();
1385
1386 assert_eq!(
1388 result.len(),
1389 0,
1390 "Indented content within footnotes should not trigger MD046"
1391 );
1392 }
1393
1394 #[test]
1395 fn test_footnote_multiple_consecutive_blank_lines() {
1396 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1399 let content = r#"Text[^1].
1400
1401[^1]: First paragraph.
1402
1403
1404
1405 Content after three blank lines (still part of footnote).
1406
1407Not indented, so footnote ends here."#;
1408
1409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1410 let result = rule.check(&ctx).unwrap();
1411
1412 assert_eq!(
1414 result.len(),
1415 0,
1416 "Multiple blank lines shouldn't break footnote continuation"
1417 );
1418 }
1419
1420 #[test]
1421 fn test_footnote_terminated_by_non_indented_content() {
1422 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1425 let content = r#"[^1]: Footnote content.
1426
1427 More indented content in footnote.
1428
1429This paragraph is not indented, so footnote ends.
1430
1431 This should be flagged as indented code block."#;
1432
1433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1434 let result = rule.check(&ctx).unwrap();
1435
1436 assert_eq!(
1438 result.len(),
1439 1,
1440 "Indented code after footnote termination should be flagged"
1441 );
1442 assert!(
1443 result[0].message.contains("Use fenced code blocks"),
1444 "Expected MD046 warning for indented code block"
1445 );
1446 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1447 }
1448
1449 #[test]
1450 fn test_footnote_terminated_by_structural_elements() {
1451 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1453 let content = r#"[^1]: Footnote content.
1454
1455 More content.
1456
1457## Heading terminates footnote
1458
1459 This indented content should be flagged.
1460
1461---
1462
1463 This should also be flagged (after horizontal rule)."#;
1464
1465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1466 let result = rule.check(&ctx).unwrap();
1467
1468 assert_eq!(
1470 result.len(),
1471 2,
1472 "Both indented blocks after termination should be flagged"
1473 );
1474 }
1475
1476 #[test]
1477 fn test_footnote_with_code_block_inside() {
1478 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1481 let content = r#"Text[^1].
1482
1483[^1]: Footnote with code:
1484
1485 ```python
1486 def hello():
1487 print("world")
1488 ```
1489
1490 More footnote text after code."#;
1491
1492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1493 let result = rule.check(&ctx).unwrap();
1494
1495 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1497 }
1498
1499 #[test]
1500 fn test_footnote_with_8_space_indented_code() {
1501 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1504 let content = r#"Text[^1].
1505
1506[^1]: Footnote with nested code.
1507
1508 code block
1509 more code"#;
1510
1511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1512 let result = rule.check(&ctx).unwrap();
1513
1514 assert_eq!(
1516 result.len(),
1517 0,
1518 "8-space indented code within footnotes represents nested code blocks"
1519 );
1520 }
1521
1522 #[test]
1523 fn test_multiple_footnotes() {
1524 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1527 let content = r#"Text[^1] and more[^2].
1528
1529[^1]: First footnote.
1530
1531 Continuation of first.
1532
1533[^2]: Second footnote starts here, ending the first.
1534
1535 Continuation of second."#;
1536
1537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1538 let result = rule.check(&ctx).unwrap();
1539
1540 assert_eq!(
1542 result.len(),
1543 0,
1544 "Multiple footnotes should each maintain their continuation context"
1545 );
1546 }
1547
1548 #[test]
1549 fn test_list_item_ends_footnote_context() {
1550 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1552 let content = r#"[^1]: Footnote.
1553
1554 Content in footnote.
1555
1556- List item starts here (ends footnote context).
1557
1558 This indented content is part of the list, not the footnote."#;
1559
1560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1561 let result = rule.check(&ctx).unwrap();
1562
1563 assert_eq!(
1565 result.len(),
1566 0,
1567 "List items should end footnote context and start their own"
1568 );
1569 }
1570
1571 #[test]
1572 fn test_footnote_vs_actual_indented_code() {
1573 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1576 let content = r#"# Heading
1577
1578Text with footnote[^1].
1579
1580[^1]: Footnote content.
1581
1582 Part of footnote (should not be flagged).
1583
1584Regular paragraph ends footnote context.
1585
1586 This is actual indented code (MUST be flagged)
1587 Should be detected as code block"#;
1588
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1590 let result = rule.check(&ctx).unwrap();
1591
1592 assert_eq!(
1594 result.len(),
1595 1,
1596 "Must still detect indented code blocks outside footnotes"
1597 );
1598 assert!(
1599 result[0].message.contains("Use fenced code blocks"),
1600 "Expected MD046 warning for indented code"
1601 );
1602 assert!(
1603 result[0].line >= 11,
1604 "Warning should be on the actual indented code line"
1605 );
1606 }
1607
1608 #[test]
1609 fn test_spec_compliant_label_characters() {
1610 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1613
1614 assert!(rule.is_footnote_definition("[^test]: text"));
1616 assert!(rule.is_footnote_definition("[^TEST]: text"));
1617 assert!(rule.is_footnote_definition("[^test-name]: text"));
1618 assert!(rule.is_footnote_definition("[^test_name]: text"));
1619 assert!(rule.is_footnote_definition("[^test123]: text"));
1620 assert!(rule.is_footnote_definition("[^123]: text"));
1621 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
1622
1623 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")); }
1631}