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_count = 0;
561 let mut indented_count = 0;
562
563 let in_list_context = self.precompute_block_continuation_context(&lines);
565 let in_tab_context = if is_mkdocs {
566 self.precompute_mkdocs_tab_context(&lines)
567 } else {
568 vec![false; lines.len()]
569 };
570
571 let mut in_fenced = false;
573 let mut prev_was_indented = false;
574
575 for (i, line) in lines.iter().enumerate() {
576 if self.is_fenced_code_block_start(line) {
577 if !in_fenced {
578 fenced_count += 1;
580 in_fenced = true;
581 } else {
582 in_fenced = false;
584 }
585 } else if !in_fenced
586 && self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
587 {
588 if !prev_was_indented {
590 indented_count += 1;
591 }
592 prev_was_indented = true;
593 } else {
594 prev_was_indented = false;
595 }
596 }
597
598 if fenced_count == 0 && indented_count == 0 {
599 None
601 } else if fenced_count > 0 && indented_count == 0 {
602 Some(CodeBlockStyle::Fenced)
604 } else if fenced_count == 0 && indented_count > 0 {
605 Some(CodeBlockStyle::Indented)
607 } else {
608 if fenced_count >= indented_count {
611 Some(CodeBlockStyle::Fenced)
612 } else {
613 Some(CodeBlockStyle::Indented)
614 }
615 }
616 }
617}
618
619impl Rule for MD046CodeBlockStyle {
620 fn name(&self) -> &'static str {
621 "MD046"
622 }
623
624 fn description(&self) -> &'static str {
625 "Code blocks should use a consistent style"
626 }
627
628 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
629 if ctx.content.is_empty() {
631 return Ok(Vec::new());
632 }
633
634 if !ctx.content.contains("```") && !ctx.content.contains("~~~") && !ctx.content.contains(" ") {
636 return Ok(Vec::new());
637 }
638
639 let line_index = LineIndex::new(ctx.content.to_string());
641 let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
642
643 if !unclosed_warnings.is_empty() {
645 return Ok(unclosed_warnings);
646 }
647
648 let lines: Vec<&str> = ctx.content.lines().collect();
650 let mut warnings = Vec::new();
651
652 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
654
655 let in_list_context = self.precompute_block_continuation_context(&lines);
657 let in_tab_context = if is_mkdocs {
658 self.precompute_mkdocs_tab_context(&lines)
659 } else {
660 vec![false; lines.len()]
661 };
662
663 let target_style = match self.config.style {
665 CodeBlockStyle::Consistent => self
666 .detect_style(ctx.content, is_mkdocs)
667 .unwrap_or(CodeBlockStyle::Fenced),
668 _ => self.config.style,
669 };
670
671 let line_index = LineIndex::new(ctx.content.to_string());
673
674 let mut in_fenced_block = vec![false; lines.len()];
677 for &(start, end) in &ctx.code_blocks {
678 if start < ctx.content.len() && end <= ctx.content.len() {
680 let block_content = &ctx.content[start..end];
681 let is_fenced = block_content.starts_with("```") || block_content.starts_with("~~~");
682
683 if is_fenced {
684 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
686 if line_info.byte_offset >= start && line_info.byte_offset < end {
687 in_fenced_block[line_idx] = true;
688 }
689 }
690 }
691 }
692 }
693
694 let mut in_fence = false;
695 for (i, line) in lines.iter().enumerate() {
696 let trimmed = line.trim_start();
697
698 if ctx.line_info(i + 1).is_some_and(|info| info.in_html_block) {
700 continue;
701 }
702
703 if ctx.lines[i].in_mkdocstrings {
706 continue;
707 }
708
709 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
711 if target_style == CodeBlockStyle::Indented && !in_fence {
712 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
715 warnings.push(LintWarning {
716 rule_name: Some(self.name().to_string()),
717 line: start_line,
718 column: start_col,
719 end_line,
720 end_column: end_col,
721 message: "Use indented code blocks".to_string(),
722 severity: Severity::Warning,
723 fix: Some(Fix {
724 range: line_index.line_col_to_byte_range(i + 1, 1),
725 replacement: String::new(),
726 }),
727 });
728 }
729 in_fence = !in_fence;
731 continue;
732 }
733
734 if in_fenced_block[i] {
737 continue;
738 }
739
740 if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
742 && target_style == CodeBlockStyle::Fenced
743 {
744 let prev_line_is_indented = i > 0
746 && self.is_indented_code_block_with_context(
747 &lines,
748 i - 1,
749 is_mkdocs,
750 &in_list_context,
751 &in_tab_context,
752 );
753
754 if !prev_line_is_indented {
755 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
756 warnings.push(LintWarning {
757 rule_name: Some(self.name().to_string()),
758 line: start_line,
759 column: start_col,
760 end_line,
761 end_column: end_col,
762 message: "Use fenced code blocks".to_string(),
763 severity: Severity::Warning,
764 fix: Some(Fix {
765 range: line_index.line_col_to_byte_range(i + 1, 1),
766 replacement: format!("```\n{}", line.trim_start()),
767 }),
768 });
769 }
770 }
771 }
772
773 Ok(warnings)
774 }
775
776 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
777 let content = ctx.content;
778 if content.is_empty() {
779 return Ok(String::new());
780 }
781
782 let line_index = LineIndex::new(ctx.content.to_string());
784 let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
785
786 if !unclosed_warnings.is_empty() {
788 for warning in &unclosed_warnings {
790 if warning
791 .message
792 .contains("should be closed before starting new one at line")
793 {
794 if let Some(fix) = &warning.fix {
796 let mut result = String::new();
797 result.push_str(&content[..fix.range.start]);
798 result.push_str(&fix.replacement);
799 result.push_str(&content[fix.range.start..]);
800 return Ok(result);
801 }
802 }
803 }
804 }
805
806 let lines: Vec<&str> = content.lines().collect();
807
808 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
810 let target_style = match self.config.style {
811 CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
812 _ => self.config.style,
813 };
814
815 let in_list_context = self.precompute_block_continuation_context(&lines);
817 let in_tab_context = if is_mkdocs {
818 self.precompute_mkdocs_tab_context(&lines)
819 } else {
820 vec![false; lines.len()]
821 };
822
823 let mut result = String::with_capacity(content.len());
824 let mut in_fenced_block = false;
825 let mut fenced_fence_type = None;
826 let mut in_indented_block = false;
827
828 for (i, line) in lines.iter().enumerate() {
829 let trimmed = line.trim_start();
830
831 if !in_fenced_block && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
833 in_fenced_block = true;
834 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
835
836 if target_style == CodeBlockStyle::Indented {
837 in_indented_block = true;
839 } else {
840 result.push_str(line);
842 result.push('\n');
843 }
844 } else if in_fenced_block && fenced_fence_type.is_some() {
845 let fence = fenced_fence_type.unwrap();
846 if trimmed.starts_with(fence) {
847 in_fenced_block = false;
848 fenced_fence_type = None;
849 in_indented_block = false;
850
851 if target_style == CodeBlockStyle::Indented {
852 } else {
854 result.push_str(line);
856 result.push('\n');
857 }
858 } else if target_style == CodeBlockStyle::Indented {
859 result.push_str(" ");
861 result.push_str(trimmed);
862 result.push('\n');
863 } else {
864 result.push_str(line);
866 result.push('\n');
867 }
868 } else if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
869 {
870 let prev_line_is_indented = i > 0
874 && self.is_indented_code_block_with_context(
875 &lines,
876 i - 1,
877 is_mkdocs,
878 &in_list_context,
879 &in_tab_context,
880 );
881
882 if target_style == CodeBlockStyle::Fenced {
883 if !prev_line_is_indented && !in_indented_block {
884 result.push_str("```\n");
886 result.push_str(line.trim_start());
887 result.push('\n');
888 in_indented_block = true;
889 } else {
890 result.push_str(line.trim_start());
892 result.push('\n');
893 }
894
895 let _next_line_is_indented = i < lines.len() - 1
897 && self.is_indented_code_block_with_context(
898 &lines,
899 i + 1,
900 is_mkdocs,
901 &in_list_context,
902 &in_tab_context,
903 );
904 if !_next_line_is_indented && in_indented_block {
905 result.push_str("```\n");
906 in_indented_block = false;
907 }
908 } else {
909 result.push_str(line);
911 result.push('\n');
912 }
913 } else {
914 if in_indented_block && target_style == CodeBlockStyle::Fenced {
916 result.push_str("```\n");
917 in_indented_block = false;
918 }
919
920 result.push_str(line);
921 result.push('\n');
922 }
923 }
924
925 if in_indented_block && target_style == CodeBlockStyle::Fenced {
927 result.push_str("```\n");
928 }
929
930 if let Some(fence_type) = fenced_fence_type
932 && in_fenced_block
933 {
934 result.push_str(fence_type);
935 result.push('\n');
936 }
937
938 if !content.ends_with('\n') && result.ends_with('\n') {
940 result.pop();
941 }
942
943 Ok(result)
944 }
945
946 fn category(&self) -> RuleCategory {
948 RuleCategory::CodeBlock
949 }
950
951 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
953 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains(" "))
956 }
957
958 fn as_any(&self) -> &dyn std::any::Any {
959 self
960 }
961
962 fn default_config_section(&self) -> Option<(String, toml::Value)> {
963 let json_value = serde_json::to_value(&self.config).ok()?;
964 Some((
965 self.name().to_string(),
966 crate::rule_config_serde::json_to_toml_value(&json_value)?,
967 ))
968 }
969
970 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
971 where
972 Self: Sized,
973 {
974 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
975 Box::new(Self::from_config_struct(rule_config))
976 }
977}
978
979#[cfg(test)]
980mod tests {
981 use super::*;
982 use crate::lint_context::LintContext;
983
984 #[test]
985 fn test_fenced_code_block_detection() {
986 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
987 assert!(rule.is_fenced_code_block_start("```"));
988 assert!(rule.is_fenced_code_block_start("```rust"));
989 assert!(rule.is_fenced_code_block_start("~~~"));
990 assert!(rule.is_fenced_code_block_start("~~~python"));
991 assert!(rule.is_fenced_code_block_start(" ```"));
992 assert!(!rule.is_fenced_code_block_start("``"));
993 assert!(!rule.is_fenced_code_block_start("~~"));
994 assert!(!rule.is_fenced_code_block_start("Regular text"));
995 }
996
997 #[test]
998 fn test_consistent_style_with_fenced_blocks() {
999 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1000 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1002 let result = rule.check(&ctx).unwrap();
1003
1004 assert_eq!(result.len(), 0);
1006 }
1007
1008 #[test]
1009 fn test_consistent_style_with_indented_blocks() {
1010 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1011 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
1012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1013 let result = rule.check(&ctx).unwrap();
1014
1015 assert_eq!(result.len(), 0);
1017 }
1018
1019 #[test]
1020 fn test_consistent_style_mixed() {
1021 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1022 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1024 let result = rule.check(&ctx).unwrap();
1025
1026 assert!(!result.is_empty());
1028 }
1029
1030 #[test]
1031 fn test_fenced_style_with_indented_blocks() {
1032 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1033 let content = "Text\n\n indented code\n more code\n\nMore text";
1034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1035 let result = rule.check(&ctx).unwrap();
1036
1037 assert!(!result.is_empty());
1039 assert!(result[0].message.contains("Use fenced code blocks"));
1040 }
1041
1042 #[test]
1043 fn test_indented_style_with_fenced_blocks() {
1044 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1045 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1047 let result = rule.check(&ctx).unwrap();
1048
1049 assert!(!result.is_empty());
1051 assert!(result[0].message.contains("Use indented code blocks"));
1052 }
1053
1054 #[test]
1055 fn test_unclosed_code_block() {
1056 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1057 let content = "```\ncode without closing fence";
1058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1059 let result = rule.check(&ctx).unwrap();
1060
1061 assert_eq!(result.len(), 1);
1062 assert!(result[0].message.contains("never closed"));
1063 }
1064
1065 #[test]
1066 fn test_nested_code_blocks() {
1067 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1068 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1070 let result = rule.check(&ctx).unwrap();
1071
1072 assert_eq!(result.len(), 0);
1074 }
1075
1076 #[test]
1077 fn test_fix_indented_to_fenced() {
1078 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1079 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1081 let fixed = rule.fix(&ctx).unwrap();
1082
1083 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1084 }
1085
1086 #[test]
1087 fn test_fix_fenced_to_indented() {
1088 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1089 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1091 let fixed = rule.fix(&ctx).unwrap();
1092
1093 assert!(fixed.contains(" code line 1\n code line 2"));
1094 assert!(!fixed.contains("```"));
1095 }
1096
1097 #[test]
1098 fn test_fix_unclosed_block() {
1099 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1100 let content = "```\ncode without closing";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1102 let fixed = rule.fix(&ctx).unwrap();
1103
1104 assert!(fixed.ends_with("```"));
1106 }
1107
1108 #[test]
1109 fn test_code_block_in_list() {
1110 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1111 let content = "- List item\n code in list\n more code\n- Next item";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1113 let result = rule.check(&ctx).unwrap();
1114
1115 assert_eq!(result.len(), 0);
1117 }
1118
1119 #[test]
1120 fn test_detect_style_fenced() {
1121 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1122 let content = "```\ncode\n```";
1123 let style = rule.detect_style(content, false);
1124
1125 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1126 }
1127
1128 #[test]
1129 fn test_detect_style_indented() {
1130 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1131 let content = "Text\n\n code\n\nMore";
1132 let style = rule.detect_style(content, false);
1133
1134 assert_eq!(style, Some(CodeBlockStyle::Indented));
1135 }
1136
1137 #[test]
1138 fn test_detect_style_none() {
1139 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1140 let content = "No code blocks here";
1141 let style = rule.detect_style(content, false);
1142
1143 assert_eq!(style, None);
1144 }
1145
1146 #[test]
1147 fn test_tilde_fence() {
1148 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1149 let content = "~~~\ncode\n~~~";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1151 let result = rule.check(&ctx).unwrap();
1152
1153 assert_eq!(result.len(), 0);
1155 }
1156
1157 #[test]
1158 fn test_language_specification() {
1159 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1160 let content = "```rust\nfn main() {}\n```";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1162 let result = rule.check(&ctx).unwrap();
1163
1164 assert_eq!(result.len(), 0);
1165 }
1166
1167 #[test]
1168 fn test_empty_content() {
1169 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1170 let content = "";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1172 let result = rule.check(&ctx).unwrap();
1173
1174 assert_eq!(result.len(), 0);
1175 }
1176
1177 #[test]
1178 fn test_default_config() {
1179 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1180 let (name, _config) = rule.default_config_section().unwrap();
1181 assert_eq!(name, "MD046");
1182 }
1183
1184 #[test]
1185 fn test_markdown_documentation_block() {
1186 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1187 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1189 let result = rule.check(&ctx).unwrap();
1190
1191 assert_eq!(result.len(), 0);
1193 }
1194
1195 #[test]
1196 fn test_preserve_trailing_newline() {
1197 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1198 let content = "```\ncode\n```\n";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1200 let fixed = rule.fix(&ctx).unwrap();
1201
1202 assert_eq!(fixed, content);
1203 }
1204
1205 #[test]
1206 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1207 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1208 let content = r#"# Document
1209
1210=== "Python"
1211
1212 This is tab content
1213 Not an indented code block
1214
1215 ```python
1216 def hello():
1217 print("Hello")
1218 ```
1219
1220=== "JavaScript"
1221
1222 More tab content here
1223 Also not an indented code block"#;
1224
1225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1226 let result = rule.check(&ctx).unwrap();
1227
1228 assert_eq!(result.len(), 0);
1230 }
1231
1232 #[test]
1233 fn test_mkdocs_tabs_with_actual_indented_code() {
1234 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1235 let content = r#"# Document
1236
1237=== "Tab 1"
1238
1239 This is tab content
1240
1241Regular text
1242
1243 This is an actual indented code block
1244 Should be flagged"#;
1245
1246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1247 let result = rule.check(&ctx).unwrap();
1248
1249 assert_eq!(result.len(), 1);
1251 assert!(result[0].message.contains("Use fenced code blocks"));
1252 }
1253
1254 #[test]
1255 fn test_mkdocs_tabs_detect_style() {
1256 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1257 let content = r#"=== "Tab 1"
1258
1259 Content in tab
1260 More content
1261
1262=== "Tab 2"
1263
1264 Content in second tab"#;
1265
1266 let style = rule.detect_style(content, true);
1268 assert_eq!(style, None); let style = rule.detect_style(content, false);
1272 assert_eq!(style, Some(CodeBlockStyle::Indented));
1273 }
1274
1275 #[test]
1276 fn test_mkdocs_nested_tabs() {
1277 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1278 let content = r#"# Document
1279
1280=== "Outer Tab"
1281
1282 Some content
1283
1284 === "Nested Tab"
1285
1286 Nested tab content
1287 Should not be flagged"#;
1288
1289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1290 let result = rule.check(&ctx).unwrap();
1291
1292 assert_eq!(result.len(), 0);
1294 }
1295
1296 #[test]
1297 fn test_footnote_indented_paragraphs_not_flagged() {
1298 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1299 let content = r#"# Test Document with Footnotes
1300
1301This is some text with a footnote[^1].
1302
1303Here's some code:
1304
1305```bash
1306echo "fenced code block"
1307```
1308
1309More text with another footnote[^2].
1310
1311[^1]: Really interesting footnote text.
1312
1313 Even more interesting second paragraph.
1314
1315[^2]: Another footnote.
1316
1317 With a second paragraph too.
1318
1319 And even a third paragraph!"#;
1320
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1322 let result = rule.check(&ctx).unwrap();
1323
1324 assert_eq!(result.len(), 0);
1326 }
1327
1328 #[test]
1329 fn test_footnote_definition_detection() {
1330 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1331
1332 assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1335 assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1336 assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1337 assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1338 assert!(rule.is_footnote_definition(" [^1]: Indented footnote"));
1339 assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1340 assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1341 assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1342 assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1343
1344 assert!(!rule.is_footnote_definition("[^]: No label"));
1346 assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1347 assert!(!rule.is_footnote_definition("[^ ]: Multiple spaces"));
1348 assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1349
1350 assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1352 assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1353 assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1354 assert!(!rule.is_footnote_definition("[^")); assert!(!rule.is_footnote_definition("[^1:")); assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1357
1358 assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1360 assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1361 assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1362 assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1363 assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1364
1365 assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1368 }
1369
1370 #[test]
1371 fn test_footnote_with_blank_lines() {
1372 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1376 let content = r#"# Document
1377
1378Text with footnote[^1].
1379
1380[^1]: First paragraph.
1381
1382 Second paragraph after blank line.
1383
1384 Third paragraph after another blank line.
1385
1386Regular text at column 0 ends the footnote."#;
1387
1388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1389 let result = rule.check(&ctx).unwrap();
1390
1391 assert_eq!(
1393 result.len(),
1394 0,
1395 "Indented content within footnotes should not trigger MD046"
1396 );
1397 }
1398
1399 #[test]
1400 fn test_footnote_multiple_consecutive_blank_lines() {
1401 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1404 let content = r#"Text[^1].
1405
1406[^1]: First paragraph.
1407
1408
1409
1410 Content after three blank lines (still part of footnote).
1411
1412Not indented, so footnote ends here."#;
1413
1414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1415 let result = rule.check(&ctx).unwrap();
1416
1417 assert_eq!(
1419 result.len(),
1420 0,
1421 "Multiple blank lines shouldn't break footnote continuation"
1422 );
1423 }
1424
1425 #[test]
1426 fn test_footnote_terminated_by_non_indented_content() {
1427 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1430 let content = r#"[^1]: Footnote content.
1431
1432 More indented content in footnote.
1433
1434This paragraph is not indented, so footnote ends.
1435
1436 This should be flagged as indented code block."#;
1437
1438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1439 let result = rule.check(&ctx).unwrap();
1440
1441 assert_eq!(
1443 result.len(),
1444 1,
1445 "Indented code after footnote termination should be flagged"
1446 );
1447 assert!(
1448 result[0].message.contains("Use fenced code blocks"),
1449 "Expected MD046 warning for indented code block"
1450 );
1451 assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1452 }
1453
1454 #[test]
1455 fn test_footnote_terminated_by_structural_elements() {
1456 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1458 let content = r#"[^1]: Footnote content.
1459
1460 More content.
1461
1462## Heading terminates footnote
1463
1464 This indented content should be flagged.
1465
1466---
1467
1468 This should also be flagged (after horizontal rule)."#;
1469
1470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1471 let result = rule.check(&ctx).unwrap();
1472
1473 assert_eq!(
1475 result.len(),
1476 2,
1477 "Both indented blocks after termination should be flagged"
1478 );
1479 }
1480
1481 #[test]
1482 fn test_footnote_with_code_block_inside() {
1483 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1486 let content = r#"Text[^1].
1487
1488[^1]: Footnote with code:
1489
1490 ```python
1491 def hello():
1492 print("world")
1493 ```
1494
1495 More footnote text after code."#;
1496
1497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1498 let result = rule.check(&ctx).unwrap();
1499
1500 assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1502 }
1503
1504 #[test]
1505 fn test_footnote_with_8_space_indented_code() {
1506 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1509 let content = r#"Text[^1].
1510
1511[^1]: Footnote with nested code.
1512
1513 code block
1514 more code"#;
1515
1516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1517 let result = rule.check(&ctx).unwrap();
1518
1519 assert_eq!(
1521 result.len(),
1522 0,
1523 "8-space indented code within footnotes represents nested code blocks"
1524 );
1525 }
1526
1527 #[test]
1528 fn test_multiple_footnotes() {
1529 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1532 let content = r#"Text[^1] and more[^2].
1533
1534[^1]: First footnote.
1535
1536 Continuation of first.
1537
1538[^2]: Second footnote starts here, ending the first.
1539
1540 Continuation of second."#;
1541
1542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1543 let result = rule.check(&ctx).unwrap();
1544
1545 assert_eq!(
1547 result.len(),
1548 0,
1549 "Multiple footnotes should each maintain their continuation context"
1550 );
1551 }
1552
1553 #[test]
1554 fn test_list_item_ends_footnote_context() {
1555 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1557 let content = r#"[^1]: Footnote.
1558
1559 Content in footnote.
1560
1561- List item starts here (ends footnote context).
1562
1563 This indented content is part of the list, not the footnote."#;
1564
1565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1566 let result = rule.check(&ctx).unwrap();
1567
1568 assert_eq!(
1570 result.len(),
1571 0,
1572 "List items should end footnote context and start their own"
1573 );
1574 }
1575
1576 #[test]
1577 fn test_footnote_vs_actual_indented_code() {
1578 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1581 let content = r#"# Heading
1582
1583Text with footnote[^1].
1584
1585[^1]: Footnote content.
1586
1587 Part of footnote (should not be flagged).
1588
1589Regular paragraph ends footnote context.
1590
1591 This is actual indented code (MUST be flagged)
1592 Should be detected as code block"#;
1593
1594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1595 let result = rule.check(&ctx).unwrap();
1596
1597 assert_eq!(
1599 result.len(),
1600 1,
1601 "Must still detect indented code blocks outside footnotes"
1602 );
1603 assert!(
1604 result[0].message.contains("Use fenced code blocks"),
1605 "Expected MD046 warning for indented code"
1606 );
1607 assert!(
1608 result[0].line >= 11,
1609 "Warning should be on the actual indented code line"
1610 );
1611 }
1612
1613 #[test]
1614 fn test_spec_compliant_label_characters() {
1615 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1618
1619 assert!(rule.is_footnote_definition("[^test]: text"));
1621 assert!(rule.is_footnote_definition("[^TEST]: text"));
1622 assert!(rule.is_footnote_definition("[^test-name]: text"));
1623 assert!(rule.is_footnote_definition("[^test_name]: text"));
1624 assert!(rule.is_footnote_definition("[^test123]: text"));
1625 assert!(rule.is_footnote_definition("[^123]: text"));
1626 assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
1627
1628 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")); }
1636}