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