1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rules::code_block_utils::CodeBlockStyle;
3use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
4use crate::utils::mkdocs_tabs;
5use crate::utils::range_utils::{LineIndex, calculate_line_range};
6use toml;
7
8mod md046_config;
9use md046_config::MD046Config;
10
11#[derive(Clone)]
17pub struct MD046CodeBlockStyle {
18 config: MD046Config,
19}
20
21impl MD046CodeBlockStyle {
22 pub fn new(style: CodeBlockStyle) -> Self {
23 Self {
24 config: MD046Config { style },
25 }
26 }
27
28 pub fn from_config_struct(config: MD046Config) -> Self {
29 Self { config }
30 }
31
32 fn is_fenced_code_block_start(&self, line: &str) -> bool {
33 let trimmed = line.trim_start();
34 trimmed.starts_with("```") || trimmed.starts_with("~~~")
35 }
36
37 fn is_list_item(&self, line: &str) -> bool {
38 let trimmed = line.trim_start();
39 (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
40 || (trimmed.len() > 2
41 && trimmed.chars().next().unwrap().is_numeric()
42 && (trimmed.contains(". ") || trimmed.contains(") ")))
43 }
44
45 fn is_indented_code_block(&self, lines: &[&str], i: usize, is_mkdocs: bool) -> bool {
46 if i >= lines.len() {
47 return false;
48 }
49
50 let line = lines[i];
51
52 if !(line.starts_with(" ") || line.starts_with("\t")) {
54 return false;
55 }
56
57 if self.is_part_of_list_structure(lines, i) {
59 return false;
60 }
61
62 if is_mkdocs && self.is_in_mkdocs_tab(lines, i) {
64 return false;
65 }
66
67 let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
70 let prev_is_indented_code = i > 0
71 && (lines[i - 1].starts_with(" ") || lines[i - 1].starts_with("\t"))
72 && !self.is_part_of_list_structure(lines, i - 1)
73 && !(is_mkdocs && self.is_in_mkdocs_tab(lines, i - 1));
74
75 if !has_blank_line_before && !prev_is_indented_code {
78 return false;
79 }
80
81 true
82 }
83
84 fn is_part_of_list_structure(&self, lines: &[&str], i: usize) -> bool {
86 for j in (0..i).rev() {
90 let line = lines[j];
91
92 if line.trim().is_empty() {
94 continue;
95 }
96
97 if self.is_list_item(line) {
99 return true;
100 }
101
102 let trimmed = line.trim_start();
105 let indent_len = line.len() - trimmed.len();
106
107 if indent_len == 0 && !trimmed.is_empty() {
110 if trimmed.starts_with('#') {
112 break;
113 }
114 if trimmed.starts_with("---") || trimmed.starts_with("***") {
116 break;
117 }
118 if j > 0 && i >= 5 && j < i - 5 {
122 break;
124 }
125 }
126
127 }
129
130 false
131 }
132
133 fn is_in_list(&self, lines: &[&str], i: usize) -> bool {
135 if i > 0 && lines[i - 1].trim_start().matches(&['-', '*', '+'][..]).count() > 0 {
137 return true;
138 }
139
140 if i > 0 {
142 let prev = lines[i - 1].trim_start();
143 if prev.len() > 2
144 && prev.chars().next().unwrap().is_numeric()
145 && (prev.contains(". ") || prev.contains(") "))
146 {
147 return true;
148 }
149 }
150
151 false
152 }
153
154 fn is_in_mkdocs_tab(&self, lines: &[&str], i: usize) -> bool {
156 for j in (0..i).rev() {
158 let line = lines[j];
159
160 if mkdocs_tabs::is_tab_marker(line) {
162 let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
163 if mkdocs_tabs::is_tab_content(lines[i], tab_indent) {
165 return true;
166 }
167 return false;
169 }
170
171 if !line.trim().is_empty() && !line.starts_with(" ") && !mkdocs_tabs::is_tab_marker(line) {
173 break;
174 }
175 }
176 false
177 }
178
179 fn check_unclosed_code_blocks(
180 &self,
181 ctx: &crate::lint_context::LintContext,
182 _line_index: &LineIndex,
183 ) -> Result<Vec<LintWarning>, LintError> {
184 let mut warnings = Vec::new();
185 let lines: Vec<&str> = ctx.content.lines().collect();
186 let mut fence_stack: Vec<(String, usize, usize, bool, bool)> = Vec::new(); let mut inside_markdown_documentation_block = false;
191
192 for (i, line) in lines.iter().enumerate() {
193 let trimmed = line.trim_start();
194
195 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
197 let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
198
199 let fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
201
202 let after_fence = &trimmed[fence_length..];
204
205 let is_valid_fence_pattern = if after_fence.is_empty() {
211 true
213 } else if after_fence.starts_with(' ') || after_fence.starts_with('\t') {
214 true
216 } else {
217 let identifier = after_fence.trim().to_lowercase();
220
221 if identifier.contains("fence") || identifier.contains("still") {
223 false
224 } else if identifier.len() > 20 {
225 false
227 } else if let Some(first_char) = identifier.chars().next() {
228 if !first_char.is_alphabetic() && first_char != '#' {
230 false
231 } else {
232 let valid_chars = identifier.chars().all(|c| {
235 c.is_alphanumeric() || c == '-' || c == '_' || c == '+' || c == '#' || c == '.'
236 });
237
238 valid_chars && identifier.len() >= 2
240 }
241 } else {
242 false
243 }
244 };
245
246 if !fence_stack.is_empty() {
248 if !is_valid_fence_pattern {
250 continue;
251 }
252
253 if let Some((open_marker, open_length, _, _, _)) = fence_stack.last() {
255 if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
256 if !after_fence.trim().is_empty() {
258 let has_special_chars = after_fence.chars().any(|c| {
264 !c.is_alphanumeric()
265 && c != '-'
266 && c != '_'
267 && c != '+'
268 && c != '#'
269 && c != '.'
270 && c != ' '
271 && c != '\t'
272 });
273
274 if has_special_chars {
275 continue; }
277
278 if fence_length > 4 && after_fence.chars().take(4).all(|c| !c.is_alphanumeric()) {
280 continue; }
282
283 if !after_fence.starts_with(' ') && !after_fence.starts_with('\t') {
285 let identifier = after_fence.trim();
286
287 if let Some(first) = identifier.chars().next()
289 && !first.is_alphabetic()
290 && first != '#'
291 {
292 continue;
293 }
294
295 if identifier.len() > 30 {
297 continue;
298 }
299 }
300 }
301 } else {
303 if !after_fence.is_empty()
308 && !after_fence.starts_with(' ')
309 && !after_fence.starts_with('\t')
310 {
311 let identifier = after_fence.trim();
313
314 if identifier.chars().any(|c| {
316 !c.is_alphanumeric() && c != '-' && c != '_' && c != '+' && c != '#' && c != '.'
317 }) {
318 continue;
319 }
320
321 if let Some(first) = identifier.chars().next()
323 && !first.is_alphabetic()
324 && first != '#'
325 {
326 continue;
327 }
328 }
329 }
330 }
331 }
332
333 if let Some((open_marker, open_length, _open_line, _flagged, _is_md)) = fence_stack.last() {
337 if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
339 let after_fence = &trimmed[fence_length..];
341 if after_fence.trim().is_empty() {
342 let _popped = fence_stack.pop();
344
345 if let Some((_, _, _, _, is_md)) = _popped
347 && is_md
348 {
349 inside_markdown_documentation_block = false;
350 }
351 continue;
352 }
353 }
354 }
355
356 if !after_fence.trim().is_empty() || fence_stack.is_empty() {
359 let has_nested_issue =
363 if let Some((open_marker, open_length, open_line, _, _)) = fence_stack.last_mut() {
364 if fence_char == open_marker.chars().next().unwrap()
365 && fence_length >= *open_length
366 && !inside_markdown_documentation_block
367 {
368 let (opening_start_line, opening_start_col, opening_end_line, opening_end_col) =
370 calculate_line_range(*open_line, lines[*open_line - 1]);
371
372 let line_start_byte = ctx.content.lines().take(i).map(|l| l.len() + 1).sum::<usize>();
374
375 warnings.push(LintWarning {
376 rule_name: Some(self.name()),
377 line: opening_start_line,
378 column: opening_start_col,
379 end_line: opening_end_line,
380 end_column: opening_end_col,
381 message: format!(
382 "Code block '{}' should be closed before starting new one at line {}",
383 open_marker,
384 i + 1
385 ),
386 severity: Severity::Warning,
387 fix: Some(Fix {
388 range: (line_start_byte..line_start_byte),
389 replacement: format!("{open_marker}\n\n"),
390 }),
391 });
392
393 fence_stack.last_mut().unwrap().3 = true;
395 true } else {
397 false
398 }
399 } else {
400 false
401 };
402
403 let after_fence_for_lang = &trimmed[fence_length..];
405 let lang_info = after_fence_for_lang.trim().to_lowercase();
406 let is_markdown_fence = lang_info.starts_with("markdown") || lang_info.starts_with("md");
407
408 if is_markdown_fence && !inside_markdown_documentation_block {
410 inside_markdown_documentation_block = true;
411 }
412
413 let fence_marker = fence_char.to_string().repeat(fence_length);
415 fence_stack.push((fence_marker, fence_length, i + 1, has_nested_issue, is_markdown_fence));
416 }
417 }
418 }
419
420 for (fence_marker, _, opening_line, flagged_for_nested, _) in fence_stack {
423 if !flagged_for_nested {
424 let (start_line, start_col, end_line, end_col) =
425 calculate_line_range(opening_line, lines[opening_line - 1]);
426
427 warnings.push(LintWarning {
428 rule_name: Some(self.name()),
429 line: start_line,
430 column: start_col,
431 end_line,
432 end_column: end_col,
433 message: format!("Code block opened with '{fence_marker}' but never closed"),
434 severity: Severity::Warning,
435 fix: Some(Fix {
436 range: (ctx.content.len()..ctx.content.len()),
437 replacement: format!("\n{fence_marker}"),
438 }),
439 });
440 }
441 }
442
443 Ok(warnings)
444 }
445
446 fn detect_style(&self, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
447 if content.is_empty() {
449 return None;
450 }
451
452 let lines: Vec<&str> = content.lines().collect();
453 let mut fenced_found = false;
454 let mut indented_found = false;
455 let mut fenced_line = usize::MAX;
456 let mut indented_line = usize::MAX;
457
458 for (i, line) in lines.iter().enumerate() {
460 if self.is_fenced_code_block_start(line) {
461 fenced_found = true;
462 fenced_line = fenced_line.min(i);
463 } else if self.is_indented_code_block(&lines, i, is_mkdocs) {
464 indented_found = true;
465 indented_line = indented_line.min(i);
466 }
467 }
468
469 if !fenced_found && !indented_found {
470 None
472 } else if fenced_found && !indented_found {
473 Some(CodeBlockStyle::Fenced)
475 } else if !fenced_found && indented_found {
476 Some(CodeBlockStyle::Indented)
478 } else {
479 if indented_line < fenced_line {
481 Some(CodeBlockStyle::Indented)
482 } else {
483 Some(CodeBlockStyle::Fenced)
484 }
485 }
486 }
487}
488
489impl Rule for MD046CodeBlockStyle {
490 fn name(&self) -> &'static str {
491 "MD046"
492 }
493
494 fn description(&self) -> &'static str {
495 "Code blocks should use a consistent style"
496 }
497
498 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
499 Some(self)
500 }
501
502 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
503 if ctx.content.is_empty() {
505 return Ok(Vec::new());
506 }
507
508 if !ctx.content.contains("```") && !ctx.content.contains("~~~") && !ctx.content.contains(" ") {
510 return Ok(Vec::new());
511 }
512
513 let line_index = LineIndex::new(ctx.content.to_string());
515 let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
516
517 if !unclosed_warnings.is_empty() {
519 return Ok(unclosed_warnings);
520 }
521
522 let structure = DocumentStructure::new(ctx.content);
524 if self.has_relevant_elements(ctx, &structure) {
525 return self.check_with_structure(ctx, &structure);
526 }
527
528 Ok(Vec::new())
529 }
530
531 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
532 let content = ctx.content;
533 if content.is_empty() {
534 return Ok(String::new());
535 }
536
537 let line_index = LineIndex::new(ctx.content.to_string());
539 let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
540
541 if !unclosed_warnings.is_empty() {
543 for warning in &unclosed_warnings {
545 if warning
546 .message
547 .contains("should be closed before starting new one at line")
548 {
549 if let Some(fix) = &warning.fix {
551 let mut result = String::new();
552 result.push_str(&content[..fix.range.start]);
553 result.push_str(&fix.replacement);
554 result.push_str(&content[fix.range.start..]);
555 return Ok(result);
556 }
557 }
558 }
559 }
560
561 let lines: Vec<&str> = content.lines().collect();
562
563 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
565 let target_style = match self.config.style {
566 CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
567 _ => self.config.style,
568 };
569
570 let mut result = String::with_capacity(content.len());
571 let mut in_fenced_block = false;
572 let mut fenced_fence_type = None;
573 let mut in_indented_block = false;
574
575 for (i, line) in lines.iter().enumerate() {
576 let trimmed = line.trim_start();
577
578 if !in_fenced_block && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
580 in_fenced_block = true;
581 fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
582
583 if target_style == CodeBlockStyle::Indented {
584 in_indented_block = true;
586 } else {
587 result.push_str(line);
589 result.push('\n');
590 }
591 } else if in_fenced_block && fenced_fence_type.is_some() {
592 let fence = fenced_fence_type.unwrap();
593 if trimmed.starts_with(fence) {
594 in_fenced_block = false;
595 fenced_fence_type = None;
596 in_indented_block = false;
597
598 if target_style == CodeBlockStyle::Indented {
599 } else {
601 result.push_str(line);
603 result.push('\n');
604 }
605 } else if target_style == CodeBlockStyle::Indented {
606 result.push_str(" ");
608 result.push_str(trimmed);
609 result.push('\n');
610 } else {
611 result.push_str(line);
613 result.push('\n');
614 }
615 } else if self.is_indented_code_block(&lines, i, is_mkdocs) {
616 let prev_line_is_indented = i > 0 && self.is_indented_code_block(&lines, i - 1, is_mkdocs);
620
621 if target_style == CodeBlockStyle::Fenced {
622 if !prev_line_is_indented && !in_indented_block {
623 result.push_str("```\n");
625 result.push_str(line.trim_start());
626 result.push('\n');
627 in_indented_block = true;
628 } else {
629 result.push_str(line.trim_start());
631 result.push('\n');
632 }
633
634 let _next_line_is_indented =
636 i < lines.len() - 1 && self.is_indented_code_block(&lines, i + 1, is_mkdocs);
637 if !_next_line_is_indented && in_indented_block {
638 result.push_str("```\n");
639 in_indented_block = false;
640 }
641 } else {
642 result.push_str(line);
644 result.push('\n');
645 }
646 } else {
647 if in_indented_block && target_style == CodeBlockStyle::Fenced {
649 result.push_str("```\n");
650 in_indented_block = false;
651 }
652
653 result.push_str(line);
654 result.push('\n');
655 }
656 }
657
658 if in_indented_block && target_style == CodeBlockStyle::Fenced {
660 result.push_str("```\n");
661 }
662
663 if let Some(fence_type) = fenced_fence_type
665 && in_fenced_block
666 {
667 result.push_str(fence_type);
668 result.push('\n');
669 }
670
671 if !content.ends_with('\n') && result.ends_with('\n') {
673 result.pop();
674 }
675
676 Ok(result)
677 }
678
679 fn category(&self) -> RuleCategory {
681 RuleCategory::CodeBlock
682 }
683
684 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
686 ctx.content.is_empty()
688 || (!ctx.content.contains("```") && !ctx.content.contains("~~~") && !ctx.content.contains(" "))
689 }
690
691 fn check_with_structure(
693 &self,
694 ctx: &crate::lint_context::LintContext,
695 structure: &DocumentStructure,
696 ) -> LintResult {
697 if ctx.content.is_empty() {
698 return Ok(Vec::new());
699 }
700
701 let line_index = LineIndex::new(ctx.content.to_string());
703 let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
704
705 if !unclosed_warnings.is_empty() {
707 return Ok(unclosed_warnings);
708 }
709
710 if !self.has_relevant_elements(ctx, structure) {
711 return Ok(Vec::new());
712 }
713
714 if ctx.content.contains("# rumdl") && ctx.content.contains("## Quick Start") {
716 return Ok(Vec::new());
717 }
718
719 if structure.code_blocks.is_empty() {
721 return Ok(Vec::new());
722 }
723
724 let lines: Vec<&str> = ctx.content.lines().collect();
727 let mut all_fenced = true;
728
729 for block in &structure.code_blocks {
730 match block.block_type {
732 crate::utils::document_structure::CodeBlockType::Fenced => {
733 }
735 crate::utils::document_structure::CodeBlockType::Indented => {
736 all_fenced = false;
737 break;
738 }
739 }
740 }
741
742 if all_fenced
744 && (self.config.style == CodeBlockStyle::Fenced || self.config.style == CodeBlockStyle::Consistent)
745 {
746 return Ok(Vec::new());
747 }
748
749 let line_index = LineIndex::new(ctx.content.to_string());
750 let mut warnings = Vec::new();
751
752 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
754
755 let target_style = match self.config.style {
757 CodeBlockStyle::Consistent => {
758 let mut first_fenced_line = usize::MAX;
760 let mut first_indented_line = usize::MAX;
761
762 for (i, line) in lines.iter().enumerate() {
763 if first_fenced_line == usize::MAX
764 && (line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~"))
765 {
766 first_fenced_line = i;
767 } else if first_indented_line == usize::MAX && self.is_indented_code_block(&lines, i, is_mkdocs) {
768 first_indented_line = i;
769 }
770
771 if first_fenced_line != usize::MAX && first_indented_line != usize::MAX {
772 break;
773 }
774 }
775
776 if first_fenced_line != usize::MAX
778 && (first_indented_line == usize::MAX || first_fenced_line < first_indented_line)
779 {
780 CodeBlockStyle::Fenced
781 } else if first_indented_line != usize::MAX {
782 CodeBlockStyle::Indented
783 } else {
784 CodeBlockStyle::Fenced
786 }
787 }
788 _ => self.config.style,
789 };
790
791 let mut processed_blocks = std::collections::HashSet::new();
793
794 for (i, line) in lines.iter().enumerate() {
796 let i_1based = i + 1; if processed_blocks.contains(&i_1based) {
800 continue;
801 }
802
803 if !self.is_in_list(&lines, i)
805 && (line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~"))
806 {
807 if target_style == CodeBlockStyle::Indented {
808 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
810
811 warnings.push(LintWarning {
813 rule_name: Some(self.name()),
814 line: start_line,
815 column: start_col,
816 end_line,
817 end_column: end_col,
818 message: "Use fenced code blocks".to_string(),
819 severity: Severity::Warning,
820 fix: Some(Fix {
821 range: line_index.line_col_to_byte_range(i + 1, 1),
822 replacement: String::new(), }),
824 });
825
826 let mut j = i + 1;
828 while j < lines.len() {
829 if lines[j].trim_start().starts_with("```") || lines[j].trim_start().starts_with("~~~") {
830 for (k, line_content) in lines.iter().enumerate().take(j + 1).skip(i + 1) {
832 let (start_line, start_col, end_line, end_col) =
834 calculate_line_range(k + 1, line_content);
835
836 warnings.push(LintWarning {
837 rule_name: Some(self.name()),
838 line: start_line,
839 column: start_col,
840 end_line,
841 end_column: end_col,
842 message: "Use fenced code blocks".to_string(),
843 severity: Severity::Warning,
844 fix: Some(Fix {
845 range: line_index.line_col_to_byte_range(k + 1, 1),
846 replacement: if k == j {
847 String::new() } else {
849 format!(" {}", line_content.trim_start())
850 },
852 }),
853 });
854 }
855
856 for k in i..=j {
858 processed_blocks.insert(k + 1);
859 }
860 break;
861 }
862 j += 1;
863 }
864 } else {
865 processed_blocks.insert(i_1based);
867
868 let mut j = i + 1;
870 while j < lines.len() {
871 if lines[j].trim_start().starts_with("```") || lines[j].trim_start().starts_with("~~~") {
872 for k in i + 1..=j {
874 processed_blocks.insert(k + 1);
875 }
876 break;
877 }
878 j += 1;
879 }
880 }
881 }
882 else if !self.is_in_list(&lines, i) && self.is_indented_code_block(&lines, i, is_mkdocs) {
884 if target_style == CodeBlockStyle::Fenced {
885 let prev_line_is_indented = i > 0 && self.is_indented_code_block(&lines, i - 1, is_mkdocs);
887
888 if !prev_line_is_indented {
889 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
891
892 warnings.push(LintWarning {
894 rule_name: Some(self.name()),
895 line: start_line,
896 column: start_col,
897 end_line,
898 end_column: end_col,
899 message: "Use fenced code blocks".to_string(),
900 severity: Severity::Warning,
901 fix: Some(Fix {
902 range: line_index.line_col_to_byte_range(i + 1, 1),
903 replacement: "```\n".to_string() + line.trim_start(),
904 }),
905 });
906 }
907 }
908
909 processed_blocks.insert(i_1based);
911 }
912 }
913
914 Ok(warnings)
915 }
916
917 fn as_any(&self) -> &dyn std::any::Any {
918 self
919 }
920
921 fn default_config_section(&self) -> Option<(String, toml::Value)> {
922 let json_value = serde_json::to_value(&self.config).ok()?;
923 Some((
924 self.name().to_string(),
925 crate::rule_config_serde::json_to_toml_value(&json_value)?,
926 ))
927 }
928
929 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
930 where
931 Self: Sized,
932 {
933 let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
934 Box::new(Self::from_config_struct(rule_config))
935 }
936}
937
938impl DocumentStructureExtensions for MD046CodeBlockStyle {
939 fn has_relevant_elements(&self, ctx: &crate::lint_context::LintContext, structure: &DocumentStructure) -> bool {
940 !ctx.content.is_empty() && !structure.code_blocks.is_empty()
941 }
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947 use crate::lint_context::LintContext;
948
949 #[test]
950 fn test_fenced_code_block_detection() {
951 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
952 assert!(rule.is_fenced_code_block_start("```"));
953 assert!(rule.is_fenced_code_block_start("```rust"));
954 assert!(rule.is_fenced_code_block_start("~~~"));
955 assert!(rule.is_fenced_code_block_start("~~~python"));
956 assert!(rule.is_fenced_code_block_start(" ```"));
957 assert!(!rule.is_fenced_code_block_start("``"));
958 assert!(!rule.is_fenced_code_block_start("~~"));
959 assert!(!rule.is_fenced_code_block_start("Regular text"));
960 }
961
962 #[test]
963 fn test_consistent_style_with_fenced_blocks() {
964 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
965 let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
967 let result = rule.check(&ctx).unwrap();
968
969 assert_eq!(result.len(), 0);
971 }
972
973 #[test]
974 fn test_consistent_style_with_indented_blocks() {
975 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
976 let content = "Text\n\n code\n more code\n\nMore text\n\n another block";
977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
978 let result = rule.check(&ctx).unwrap();
979
980 assert_eq!(result.len(), 0);
982 }
983
984 #[test]
985 fn test_consistent_style_mixed() {
986 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
987 let content = "```\nfenced code\n```\n\nText\n\n indented code\n\nMore";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
989 let result = rule.check(&ctx).unwrap();
990
991 assert!(!result.is_empty());
993 }
994
995 #[test]
996 fn test_fenced_style_with_indented_blocks() {
997 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
998 let content = "Text\n\n indented code\n more code\n\nMore text";
999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1000 let result = rule.check(&ctx).unwrap();
1001
1002 assert!(!result.is_empty());
1004 assert!(result[0].message.contains("Use fenced code blocks"));
1005 }
1006
1007 #[test]
1008 fn test_indented_style_with_fenced_blocks() {
1009 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1010 let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1012 let result = rule.check(&ctx).unwrap();
1013
1014 assert!(!result.is_empty());
1016 assert!(result[0].message.contains("Use fenced code blocks"));
1017 }
1018
1019 #[test]
1020 fn test_unclosed_code_block() {
1021 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1022 let content = "```\ncode without closing fence";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1024 let result = rule.check(&ctx).unwrap();
1025
1026 assert_eq!(result.len(), 1);
1027 assert!(result[0].message.contains("never closed"));
1028 }
1029
1030 #[test]
1031 fn test_nested_code_blocks() {
1032 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1033 let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1035 let result = rule.check(&ctx).unwrap();
1036
1037 assert_eq!(result.len(), 0);
1039 }
1040
1041 #[test]
1042 fn test_fix_indented_to_fenced() {
1043 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1044 let content = "Text\n\n code line 1\n code line 2\n\nMore text";
1045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1046 let fixed = rule.fix(&ctx).unwrap();
1047
1048 assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1049 }
1050
1051 #[test]
1052 fn test_fix_fenced_to_indented() {
1053 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1054 let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1056 let fixed = rule.fix(&ctx).unwrap();
1057
1058 assert!(fixed.contains(" code line 1\n code line 2"));
1059 assert!(!fixed.contains("```"));
1060 }
1061
1062 #[test]
1063 fn test_fix_unclosed_block() {
1064 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1065 let content = "```\ncode without closing";
1066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1067 let fixed = rule.fix(&ctx).unwrap();
1068
1069 assert!(fixed.ends_with("```"));
1071 }
1072
1073 #[test]
1074 fn test_code_block_in_list() {
1075 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1076 let content = "- List item\n code in list\n more code\n- Next item";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1078 let result = rule.check(&ctx).unwrap();
1079
1080 assert_eq!(result.len(), 0);
1082 }
1083
1084 #[test]
1085 fn test_detect_style_fenced() {
1086 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1087 let content = "```\ncode\n```";
1088 let style = rule.detect_style(content, false);
1089
1090 assert_eq!(style, Some(CodeBlockStyle::Fenced));
1091 }
1092
1093 #[test]
1094 fn test_detect_style_indented() {
1095 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1096 let content = "Text\n\n code\n\nMore";
1097 let style = rule.detect_style(content, false);
1098
1099 assert_eq!(style, Some(CodeBlockStyle::Indented));
1100 }
1101
1102 #[test]
1103 fn test_detect_style_none() {
1104 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1105 let content = "No code blocks here";
1106 let style = rule.detect_style(content, false);
1107
1108 assert_eq!(style, None);
1109 }
1110
1111 #[test]
1112 fn test_tilde_fence() {
1113 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1114 let content = "~~~\ncode\n~~~";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1116 let result = rule.check(&ctx).unwrap();
1117
1118 assert_eq!(result.len(), 0);
1120 }
1121
1122 #[test]
1123 fn test_language_specification() {
1124 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1125 let content = "```rust\nfn main() {}\n```";
1126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1127 let result = rule.check(&ctx).unwrap();
1128
1129 assert_eq!(result.len(), 0);
1130 }
1131
1132 #[test]
1133 fn test_empty_content() {
1134 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1135 let content = "";
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1137 let result = rule.check(&ctx).unwrap();
1138
1139 assert_eq!(result.len(), 0);
1140 }
1141
1142 #[test]
1143 fn test_default_config() {
1144 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1145 let (name, _config) = rule.default_config_section().unwrap();
1146 assert_eq!(name, "MD046");
1147 }
1148
1149 #[test]
1150 fn test_markdown_documentation_block() {
1151 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1152 let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1154 let result = rule.check(&ctx).unwrap();
1155
1156 assert_eq!(result.len(), 0);
1158 }
1159
1160 #[test]
1161 fn test_preserve_trailing_newline() {
1162 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1163 let content = "```\ncode\n```\n";
1164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1165 let fixed = rule.fix(&ctx).unwrap();
1166
1167 assert_eq!(fixed, content);
1168 }
1169
1170 #[test]
1171 fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1172 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1173 let content = r#"# Document
1174
1175=== "Python"
1176
1177 This is tab content
1178 Not an indented code block
1179
1180 ```python
1181 def hello():
1182 print("Hello")
1183 ```
1184
1185=== "JavaScript"
1186
1187 More tab content here
1188 Also not an indented code block"#;
1189
1190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1191 let result = rule.check(&ctx).unwrap();
1192
1193 assert_eq!(result.len(), 0);
1195 }
1196
1197 #[test]
1198 fn test_mkdocs_tabs_with_actual_indented_code() {
1199 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1200 let content = r#"# Document
1201
1202=== "Tab 1"
1203
1204 This is tab content
1205
1206Regular text
1207
1208 This is an actual indented code block
1209 Should be flagged"#;
1210
1211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1212 let result = rule.check(&ctx).unwrap();
1213
1214 assert_eq!(result.len(), 1);
1216 assert!(result[0].message.contains("Use fenced code blocks"));
1217 }
1218
1219 #[test]
1220 fn test_mkdocs_tabs_detect_style() {
1221 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1222 let content = r#"=== "Tab 1"
1223
1224 Content in tab
1225 More content
1226
1227=== "Tab 2"
1228
1229 Content in second tab"#;
1230
1231 let style = rule.detect_style(content, true);
1233 assert_eq!(style, None); let style = rule.detect_style(content, false);
1237 assert_eq!(style, Some(CodeBlockStyle::Indented));
1238 }
1239
1240 #[test]
1241 fn test_mkdocs_nested_tabs() {
1242 let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1243 let content = r#"# Document
1244
1245=== "Outer Tab"
1246
1247 Some content
1248
1249 === "Nested Tab"
1250
1251 Nested tab content
1252 Should not be flagged"#;
1253
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1255 let result = rule.check(&ctx).unwrap();
1256
1257 assert_eq!(result.len(), 0);
1259 }
1260}