rumdl_lib/rules/
md046_code_block_style.rs

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/// Rule MD046: Code block style
11///
12/// See [docs/md046.md](../../docs/md046.md) for full documentation, configuration, and examples.
13///
14/// This rule is triggered when code blocks do not use a consistent style (either fenced or indented).
15#[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_indented_code_block(&self, lines: &[&str], i: usize, is_mkdocs: bool) -> bool {
45        if i >= lines.len() {
46            return false;
47        }
48
49        let line = lines[i];
50
51        // Check if indented by at least 4 spaces or tab
52        if !(line.starts_with("    ") || line.starts_with("\t")) {
53            return false;
54        }
55
56        // Check if this is part of a list structure
57        if self.is_part_of_list_structure(lines, i) {
58            return false;
59        }
60
61        // Skip if this is MkDocs tab content
62        if is_mkdocs && self.is_in_mkdocs_tab(lines, i) {
63            return false;
64        }
65
66        // Check if preceded by a blank line (typical for code blocks)
67        // OR if the previous line is also an indented code block (continuation)
68        let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
69        let prev_is_indented_code = i > 0
70            && (lines[i - 1].starts_with("    ") || lines[i - 1].starts_with("\t"))
71            && !self.is_part_of_list_structure(lines, i - 1)
72            && !(is_mkdocs && self.is_in_mkdocs_tab(lines, i - 1));
73
74        // If no blank line before and previous line is not indented code,
75        // it's likely list continuation, not a code block
76        if !has_blank_line_before && !prev_is_indented_code {
77            return false;
78        }
79
80        true
81    }
82
83    /// Check if an indented line is part of a list structure
84    fn is_part_of_list_structure(&self, lines: &[&str], i: usize) -> bool {
85        // Look backwards to find if we're in a list context
86        // We need to be more aggressive about detecting list contexts
87
88        for j in (0..i).rev() {
89            let line = lines[j];
90
91            // Skip empty lines - they don't break list context
92            if line.trim().is_empty() {
93                continue;
94            }
95
96            // If we find a list item, we're definitely in a list context
97            if self.is_list_item(line) {
98                return true;
99            }
100
101            // Check if this line looks like it's part of a list item
102            // (indented content that's not a code block)
103            let trimmed = line.trim_start();
104            let indent_len = line.len() - trimmed.len();
105
106            // If we find a line that starts at column 0 and is not a list item,
107            // check if it's a structural element that would end list context
108            if indent_len == 0 && !trimmed.is_empty() {
109                // Headings definitely end list context
110                if trimmed.starts_with('#') {
111                    break;
112                }
113                // Horizontal rules end list context
114                if trimmed.starts_with("---") || trimmed.starts_with("***") {
115                    break;
116                }
117                // If it's a paragraph that doesn't look like it's part of a list,
118                // we might not be in a list anymore, but let's be conservative
119                // and keep looking a bit more
120                if j > 0 && i >= 5 && j < i - 5 {
121                    // Only break if we've looked back a reasonable distance
122                    break;
123                }
124            }
125
126            // Continue looking backwards through indented content
127        }
128
129        false
130    }
131
132    /// Helper function to check if a line is part of MkDocs tab content
133    fn is_in_mkdocs_tab(&self, lines: &[&str], i: usize) -> bool {
134        // Look backwards for tab markers
135        for j in (0..i).rev() {
136            let line = lines[j];
137
138            // Check if this is a tab marker
139            if mkdocs_tabs::is_tab_marker(line) {
140                let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
141                // Check if current line has proper tab content indentation
142                if mkdocs_tabs::is_tab_content(lines[i], tab_indent) {
143                    return true;
144                }
145                // If we found a tab but indentation doesn't match, we're not in it
146                return false;
147            }
148
149            // If we hit a non-indented, non-empty line that's not a tab, stop searching
150            if !line.trim().is_empty() && !line.starts_with("    ") && !mkdocs_tabs::is_tab_marker(line) {
151                break;
152            }
153        }
154        false
155    }
156
157    fn check_unclosed_code_blocks(
158        &self,
159        ctx: &crate::lint_context::LintContext,
160        line_index: &LineIndex,
161    ) -> Result<Vec<LintWarning>, LintError> {
162        let mut warnings = Vec::new();
163        let lines: Vec<&str> = ctx.content.lines().collect();
164        let mut fence_stack: Vec<(String, usize, usize, bool, bool)> = Vec::new(); // (fence_marker, fence_length, opening_line, flagged_for_nested, is_markdown_example)
165
166        // Track if we're inside a markdown code block (for documentation examples)
167        // This is used to allow nested code blocks in markdown documentation
168        let mut inside_markdown_documentation_block = false;
169
170        for (i, line) in lines.iter().enumerate() {
171            let trimmed = line.trim_start();
172
173            // Check for fence markers (``` or ~~~)
174            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
175                let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
176
177                // Count the fence length
178                let fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
179
180                // Check what comes after the fence characters
181                let after_fence = &trimmed[fence_length..];
182
183                // Check if this is a valid fence pattern
184                // Valid markdown code fence syntax:
185                // - ``` or ~~~ (just fence)
186                // - ``` language or ~~~ language (fence with space then language)
187                // - ```language (without space) is accepted by many parsers but only for actual languages
188                let is_valid_fence_pattern = if after_fence.is_empty() {
189                    // Empty after fence is always valid (e.g., ``` or ~~~)
190                    true
191                } else if after_fence.starts_with(' ') || after_fence.starts_with('\t') {
192                    // Space after fence - anything following is valid as info string
193                    true
194                } else {
195                    // No space after fence - must be a valid language identifier
196                    // Be strict to avoid false positives on content that looks like fences
197                    let identifier = after_fence.trim().to_lowercase();
198
199                    // Reject obvious non-language patterns
200                    if identifier.contains("fence") || identifier.contains("still") {
201                        false
202                    } else if identifier.len() > 20 {
203                        // Most language identifiers are short
204                        false
205                    } else if let Some(first_char) = identifier.chars().next() {
206                        // Must start with letter or # (for C#, F#)
207                        if !first_char.is_alphabetic() && first_char != '#' {
208                            false
209                        } else {
210                            // Check all characters are valid for a language identifier
211                            // Also check it's not just random text
212                            let valid_chars = identifier.chars().all(|c| {
213                                c.is_alphanumeric() || c == '-' || c == '_' || c == '+' || c == '#' || c == '.'
214                            });
215
216                            // Additional check: at least 2 chars and not all consonants (helps filter random words)
217                            valid_chars && identifier.len() >= 2
218                        }
219                    } else {
220                        false
221                    }
222                };
223
224                // When inside a code block, be conservative about what we treat as a fence
225                if !fence_stack.is_empty() {
226                    // Skip if not a valid fence pattern to begin with
227                    if !is_valid_fence_pattern {
228                        continue;
229                    }
230
231                    // Check if this could be a closing fence for the current block
232                    if let Some((open_marker, open_length, _, _, _)) = fence_stack.last() {
233                        if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
234                            // Potential closing fence - check if it has content after
235                            if !after_fence.trim().is_empty() {
236                                // Has content after - likely not a closing fence
237                                // Apply structural validation to determine if it's a nested fence
238
239                                // Skip patterns that are clearly decorative or content
240                                // 1. Contains special characters not typical in language identifiers
241                                let has_special_chars = after_fence.chars().any(|c| {
242                                    !c.is_alphanumeric()
243                                        && c != '-'
244                                        && c != '_'
245                                        && c != '+'
246                                        && c != '#'
247                                        && c != '.'
248                                        && c != ' '
249                                        && c != '\t'
250                                });
251
252                                if has_special_chars {
253                                    continue; // e.g., ~~~!@#$%, ~~~~~~~~^^^^
254                                }
255
256                                // 2. Check for repetitive non-alphanumeric patterns
257                                if fence_length > 4 && after_fence.chars().take(4).all(|c| !c.is_alphanumeric()) {
258                                    continue; // e.g., ~~~~~~~~~~ or ````````
259                                }
260
261                                // 3. If no space after fence, must look like a valid language identifier
262                                if !after_fence.starts_with(' ') && !after_fence.starts_with('\t') {
263                                    let identifier = after_fence.trim();
264
265                                    // Must start with letter or # (for C#, F#)
266                                    if let Some(first) = identifier.chars().next()
267                                        && !first.is_alphabetic()
268                                        && first != '#'
269                                    {
270                                        continue;
271                                    }
272
273                                    // Reasonable length for a language identifier
274                                    if identifier.len() > 30 {
275                                        continue;
276                                    }
277                                }
278                            }
279                            // Otherwise, could be a closing fence - let it through
280                        } else {
281                            // Different fence type or insufficient length
282                            // Only treat as nested if it looks like a real fence with language
283
284                            // Must have proper spacing or no content after fence
285                            if !after_fence.is_empty()
286                                && !after_fence.starts_with(' ')
287                                && !after_fence.starts_with('\t')
288                            {
289                                // No space after fence - be very strict
290                                let identifier = after_fence.trim();
291
292                                // Skip if contains any special characters beyond common ones
293                                if identifier.chars().any(|c| {
294                                    !c.is_alphanumeric() && c != '-' && c != '_' && c != '+' && c != '#' && c != '.'
295                                }) {
296                                    continue;
297                                }
298
299                                // Skip if doesn't start with letter or #
300                                if let Some(first) = identifier.chars().next()
301                                    && !first.is_alphabetic()
302                                    && first != '#'
303                                {
304                                    continue;
305                                }
306                            }
307                        }
308                    }
309                }
310
311                // We'll check if this is a markdown block after determining if it's an opening fence
312
313                // Check if this is a closing fence for the current open fence
314                if let Some((open_marker, open_length, _open_line, _flagged, _is_md)) = fence_stack.last() {
315                    // Must match fence character and have at least as many characters
316                    if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
317                        // Check if this line has only whitespace after the fence marker
318                        let after_fence = &trimmed[fence_length..];
319                        if after_fence.trim().is_empty() {
320                            // This is a valid closing fence
321                            let _popped = fence_stack.pop();
322
323                            // Check if we're exiting a markdown documentation block
324                            if let Some((_, _, _, _, is_md)) = _popped
325                                && is_md
326                            {
327                                inside_markdown_documentation_block = false;
328                            }
329                            continue;
330                        }
331                    }
332                }
333
334                // This is an opening fence (has content after marker or no matching open fence)
335                // Note: after_fence was already calculated above during validation
336                if !after_fence.trim().is_empty() || fence_stack.is_empty() {
337                    // Only flag as problematic if we're opening a new fence while another is still open
338                    // AND they use the same fence character (indicating potential confusion)
339                    // AND we're not inside a markdown documentation block
340                    let has_nested_issue =
341                        if let Some((open_marker, open_length, open_line, _, _)) = fence_stack.last_mut() {
342                            if fence_char == open_marker.chars().next().unwrap()
343                                && fence_length >= *open_length
344                                && !inside_markdown_documentation_block
345                            {
346                                // This is problematic - same fence character used with equal or greater length while another is open
347                                let (opening_start_line, opening_start_col, opening_end_line, opening_end_col) =
348                                    calculate_line_range(*open_line, lines[*open_line - 1]);
349
350                                // Calculate the byte position to insert closing fence before this line
351                                let line_start_byte = line_index.get_line_start_byte(i + 1).unwrap_or(0);
352
353                                warnings.push(LintWarning {
354                                    rule_name: Some(self.name()),
355                                    line: opening_start_line,
356                                    column: opening_start_col,
357                                    end_line: opening_end_line,
358                                    end_column: opening_end_col,
359                                    message: format!(
360                                        "Code block '{}' should be closed before starting new one at line {}",
361                                        open_marker,
362                                        i + 1
363                                    ),
364                                    severity: Severity::Warning,
365                                    fix: Some(Fix {
366                                        range: (line_start_byte..line_start_byte),
367                                        replacement: format!("{open_marker}\n\n"),
368                                    }),
369                                });
370
371                                // Mark the current fence as flagged for nested issue
372                                fence_stack.last_mut().unwrap().3 = true;
373                                true // We flagged a nested issue for this fence
374                            } else {
375                                false
376                            }
377                        } else {
378                            false
379                        };
380
381                    // Check if this opening fence is a markdown code block
382                    let after_fence_for_lang = &trimmed[fence_length..];
383                    let lang_info = after_fence_for_lang.trim().to_lowercase();
384                    let is_markdown_fence = lang_info.starts_with("markdown") || lang_info.starts_with("md");
385
386                    // If we're opening a markdown documentation block, mark that we're inside one
387                    if is_markdown_fence && !inside_markdown_documentation_block {
388                        inside_markdown_documentation_block = true;
389                    }
390
391                    // Add this fence to the stack
392                    let fence_marker = fence_char.to_string().repeat(fence_length);
393                    fence_stack.push((fence_marker, fence_length, i + 1, has_nested_issue, is_markdown_fence));
394                }
395            }
396        }
397
398        // Check for unclosed fences at end of file
399        // Only flag unclosed if we haven't already flagged for nested issues
400        for (fence_marker, _, opening_line, flagged_for_nested, _) in fence_stack {
401            if !flagged_for_nested {
402                let (start_line, start_col, end_line, end_col) =
403                    calculate_line_range(opening_line, lines[opening_line - 1]);
404
405                warnings.push(LintWarning {
406                    rule_name: Some(self.name()),
407                    line: start_line,
408                    column: start_col,
409                    end_line,
410                    end_column: end_col,
411                    message: format!("Code block opened with '{fence_marker}' but never closed"),
412                    severity: Severity::Warning,
413                    fix: Some(Fix {
414                        range: (ctx.content.len()..ctx.content.len()),
415                        replacement: format!("\n{fence_marker}"),
416                    }),
417                });
418            }
419        }
420
421        Ok(warnings)
422    }
423
424    fn detect_style(&self, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
425        // Empty content has no style
426        if content.is_empty() {
427            return None;
428        }
429
430        let lines: Vec<&str> = content.lines().collect();
431        let mut fenced_found = false;
432        let mut indented_found = false;
433        let mut fenced_line = usize::MAX;
434        let mut indented_line = usize::MAX;
435
436        // First scan through all lines to find code blocks
437        for (i, line) in lines.iter().enumerate() {
438            if self.is_fenced_code_block_start(line) {
439                fenced_found = true;
440                fenced_line = fenced_line.min(i);
441            } else if self.is_indented_code_block(&lines, i, is_mkdocs) {
442                indented_found = true;
443                indented_line = indented_line.min(i);
444            }
445        }
446
447        if !fenced_found && !indented_found {
448            // No code blocks found
449            None
450        } else if fenced_found && !indented_found {
451            // Only fenced blocks found
452            Some(CodeBlockStyle::Fenced)
453        } else if !fenced_found && indented_found {
454            // Only indented blocks found
455            Some(CodeBlockStyle::Indented)
456        } else {
457            // Both types found - use the first one encountered
458            if indented_line < fenced_line {
459                Some(CodeBlockStyle::Indented)
460            } else {
461                Some(CodeBlockStyle::Fenced)
462            }
463        }
464    }
465}
466
467impl Rule for MD046CodeBlockStyle {
468    fn name(&self) -> &'static str {
469        "MD046"
470    }
471
472    fn description(&self) -> &'static str {
473        "Code blocks should use a consistent style"
474    }
475
476    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
477        // Early return for empty content
478        if ctx.content.is_empty() {
479            return Ok(Vec::new());
480        }
481
482        // Quick check for code blocks before processing
483        if !ctx.content.contains("```") && !ctx.content.contains("~~~") && !ctx.content.contains("    ") {
484            return Ok(Vec::new());
485        }
486
487        // First, always check for unclosed code blocks
488        let line_index = LineIndex::new(ctx.content.to_string());
489        let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
490
491        // If we found unclosed blocks, return those warnings first
492        if !unclosed_warnings.is_empty() {
493            return Ok(unclosed_warnings);
494        }
495
496        // Check for code block style consistency
497        let lines: Vec<&str> = ctx.content.lines().collect();
498        let mut warnings = Vec::new();
499
500        // Check if we're in MkDocs mode
501        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
502
503        // Determine the target style from the detected style in the document
504        let target_style = match self.config.style {
505            CodeBlockStyle::Consistent => self
506                .detect_style(ctx.content, is_mkdocs)
507                .unwrap_or(CodeBlockStyle::Fenced),
508            _ => self.config.style,
509        };
510
511        // Process each line to find style inconsistencies
512        let mut in_fenced_block = false;
513        let line_index = LineIndex::new(ctx.content.to_string());
514
515        for (i, line) in lines.iter().enumerate() {
516            let trimmed = line.trim_start();
517
518            // Skip lines that are in HTML blocks - they shouldn't be treated as indented code
519            if ctx.is_in_html_block(i + 1) {
520                continue;
521            }
522
523            // Skip if this line is in a mkdocstrings block (but not other skip contexts,
524            // since MD046 needs to detect regular code blocks)
525            if ctx.lines[i].in_mkdocstrings {
526                continue;
527            }
528
529            // Track fenced code blocks
530            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
531                in_fenced_block = !in_fenced_block;
532
533                if target_style == CodeBlockStyle::Indented && !in_fenced_block {
534                    // This is starting a fenced block but we want indented style
535                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
536                    warnings.push(LintWarning {
537                        rule_name: Some(self.name()),
538                        line: start_line,
539                        column: start_col,
540                        end_line,
541                        end_column: end_col,
542                        message: "Use indented code blocks".to_string(),
543                        severity: Severity::Warning,
544                        fix: Some(Fix {
545                            range: line_index.line_col_to_byte_range(i + 1, 1),
546                            replacement: String::new(),
547                        }),
548                    });
549                }
550            }
551            // Check for indented code blocks (when not in a fenced block)
552            else if !in_fenced_block
553                && self.is_indented_code_block(&lines, i, is_mkdocs)
554                && target_style == CodeBlockStyle::Fenced
555            {
556                // Check if this is the start of a new indented block
557                let prev_line_is_indented = i > 0 && self.is_indented_code_block(&lines, i - 1, is_mkdocs);
558
559                if !prev_line_is_indented {
560                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
561                    warnings.push(LintWarning {
562                        rule_name: Some(self.name()),
563                        line: start_line,
564                        column: start_col,
565                        end_line,
566                        end_column: end_col,
567                        message: "Use fenced code blocks".to_string(),
568                        severity: Severity::Warning,
569                        fix: Some(Fix {
570                            range: line_index.line_col_to_byte_range(i + 1, 1),
571                            replacement: format!("```\n{}", line.trim_start()),
572                        }),
573                    });
574                }
575            }
576        }
577
578        Ok(warnings)
579    }
580
581    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
582        let content = ctx.content;
583        if content.is_empty() {
584            return Ok(String::new());
585        }
586
587        // First check if we have nested fence issues that need special handling
588        let line_index = LineIndex::new(ctx.content.to_string());
589        let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
590
591        // If we have nested fence warnings, apply those fixes first
592        if !unclosed_warnings.is_empty() {
593            // Check if any warnings are about nested fences (not just unclosed blocks)
594            for warning in &unclosed_warnings {
595                if warning
596                    .message
597                    .contains("should be closed before starting new one at line")
598                {
599                    // Apply the nested fence fix
600                    if let Some(fix) = &warning.fix {
601                        let mut result = String::new();
602                        result.push_str(&content[..fix.range.start]);
603                        result.push_str(&fix.replacement);
604                        result.push_str(&content[fix.range.start..]);
605                        return Ok(result);
606                    }
607                }
608            }
609        }
610
611        let lines: Vec<&str> = content.lines().collect();
612
613        // Determine target style
614        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
615        let target_style = match self.config.style {
616            CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
617            _ => self.config.style,
618        };
619
620        let mut result = String::with_capacity(content.len());
621        let mut in_fenced_block = false;
622        let mut fenced_fence_type = None;
623        let mut in_indented_block = false;
624
625        for (i, line) in lines.iter().enumerate() {
626            let trimmed = line.trim_start();
627
628            // Handle fenced code blocks
629            if !in_fenced_block && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
630                in_fenced_block = true;
631                fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
632
633                if target_style == CodeBlockStyle::Indented {
634                    // Skip the opening fence
635                    in_indented_block = true;
636                } else {
637                    // Keep the fenced block
638                    result.push_str(line);
639                    result.push('\n');
640                }
641            } else if in_fenced_block && fenced_fence_type.is_some() {
642                let fence = fenced_fence_type.unwrap();
643                if trimmed.starts_with(fence) {
644                    in_fenced_block = false;
645                    fenced_fence_type = None;
646                    in_indented_block = false;
647
648                    if target_style == CodeBlockStyle::Indented {
649                        // Skip the closing fence
650                    } else {
651                        // Keep the fenced block
652                        result.push_str(line);
653                        result.push('\n');
654                    }
655                } else if target_style == CodeBlockStyle::Indented {
656                    // Convert content inside fenced block to indented
657                    result.push_str("    ");
658                    result.push_str(trimmed);
659                    result.push('\n');
660                } else {
661                    // Keep fenced block content as is
662                    result.push_str(line);
663                    result.push('\n');
664                }
665            } else if self.is_indented_code_block(&lines, i, is_mkdocs) {
666                // This is an indented code block
667
668                // Check if we need to start a new fenced block
669                let prev_line_is_indented = i > 0 && self.is_indented_code_block(&lines, i - 1, is_mkdocs);
670
671                if target_style == CodeBlockStyle::Fenced {
672                    if !prev_line_is_indented && !in_indented_block {
673                        // Start of a new indented block that should be fenced
674                        result.push_str("```\n");
675                        result.push_str(line.trim_start());
676                        result.push('\n');
677                        in_indented_block = true;
678                    } else {
679                        // Inside an indented block
680                        result.push_str(line.trim_start());
681                        result.push('\n');
682                    }
683
684                    // Check if this is the end of the indented block
685                    let _next_line_is_indented =
686                        i < lines.len() - 1 && self.is_indented_code_block(&lines, i + 1, is_mkdocs);
687                    if !_next_line_is_indented && in_indented_block {
688                        result.push_str("```\n");
689                        in_indented_block = false;
690                    }
691                } else {
692                    // Keep indented block as is
693                    result.push_str(line);
694                    result.push('\n');
695                }
696            } else {
697                // Regular line
698                if in_indented_block && target_style == CodeBlockStyle::Fenced {
699                    result.push_str("```\n");
700                    in_indented_block = false;
701                }
702
703                result.push_str(line);
704                result.push('\n');
705            }
706        }
707
708        // Close any remaining blocks
709        if in_indented_block && target_style == CodeBlockStyle::Fenced {
710            result.push_str("```\n");
711        }
712
713        // Close any unclosed fenced blocks
714        if let Some(fence_type) = fenced_fence_type
715            && in_fenced_block
716        {
717            result.push_str(fence_type);
718            result.push('\n');
719        }
720
721        // Remove trailing newline if original didn't have one
722        if !content.ends_with('\n') && result.ends_with('\n') {
723            result.pop();
724        }
725
726        Ok(result)
727    }
728
729    /// Get the category of this rule for selective processing
730    fn category(&self) -> RuleCategory {
731        RuleCategory::CodeBlock
732    }
733
734    /// Check if this rule should be skipped
735    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
736        // Skip if content is empty or unlikely to contain code blocks
737        // Note: indented code blocks use 4 spaces, can't optimize that easily
738        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains("    "))
739    }
740
741    fn as_any(&self) -> &dyn std::any::Any {
742        self
743    }
744
745    fn default_config_section(&self) -> Option<(String, toml::Value)> {
746        let json_value = serde_json::to_value(&self.config).ok()?;
747        Some((
748            self.name().to_string(),
749            crate::rule_config_serde::json_to_toml_value(&json_value)?,
750        ))
751    }
752
753    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
754    where
755        Self: Sized,
756    {
757        let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
758        Box::new(Self::from_config_struct(rule_config))
759    }
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use crate::lint_context::LintContext;
766
767    #[test]
768    fn test_fenced_code_block_detection() {
769        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
770        assert!(rule.is_fenced_code_block_start("```"));
771        assert!(rule.is_fenced_code_block_start("```rust"));
772        assert!(rule.is_fenced_code_block_start("~~~"));
773        assert!(rule.is_fenced_code_block_start("~~~python"));
774        assert!(rule.is_fenced_code_block_start("  ```"));
775        assert!(!rule.is_fenced_code_block_start("``"));
776        assert!(!rule.is_fenced_code_block_start("~~"));
777        assert!(!rule.is_fenced_code_block_start("Regular text"));
778    }
779
780    #[test]
781    fn test_consistent_style_with_fenced_blocks() {
782        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
783        let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
785        let result = rule.check(&ctx).unwrap();
786
787        // All blocks are fenced, so consistent style should be OK
788        assert_eq!(result.len(), 0);
789    }
790
791    #[test]
792    fn test_consistent_style_with_indented_blocks() {
793        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
794        let content = "Text\n\n    code\n    more code\n\nMore text\n\n    another block";
795        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
796        let result = rule.check(&ctx).unwrap();
797
798        // All blocks are indented, so consistent style should be OK
799        assert_eq!(result.len(), 0);
800    }
801
802    #[test]
803    fn test_consistent_style_mixed() {
804        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
805        let content = "```\nfenced code\n```\n\nText\n\n    indented code\n\nMore";
806        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
807        let result = rule.check(&ctx).unwrap();
808
809        // Mixed styles should be flagged
810        assert!(!result.is_empty());
811    }
812
813    #[test]
814    fn test_fenced_style_with_indented_blocks() {
815        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
816        let content = "Text\n\n    indented code\n    more code\n\nMore text";
817        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
818        let result = rule.check(&ctx).unwrap();
819
820        // Indented blocks should be flagged when fenced style is required
821        assert!(!result.is_empty());
822        assert!(result[0].message.contains("Use fenced code blocks"));
823    }
824
825    #[test]
826    fn test_indented_style_with_fenced_blocks() {
827        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
828        let content = "Text\n\n```\nfenced code\n```\n\nMore text";
829        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
830        let result = rule.check(&ctx).unwrap();
831
832        // Fenced blocks should be flagged when indented style is required
833        assert!(!result.is_empty());
834        assert!(result[0].message.contains("Use indented code blocks"));
835    }
836
837    #[test]
838    fn test_unclosed_code_block() {
839        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
840        let content = "```\ncode without closing fence";
841        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
842        let result = rule.check(&ctx).unwrap();
843
844        assert_eq!(result.len(), 1);
845        assert!(result[0].message.contains("never closed"));
846    }
847
848    #[test]
849    fn test_nested_code_blocks() {
850        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
851        let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
852        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
853        let result = rule.check(&ctx).unwrap();
854
855        // This should parse as two separate code blocks
856        assert_eq!(result.len(), 0);
857    }
858
859    #[test]
860    fn test_fix_indented_to_fenced() {
861        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
862        let content = "Text\n\n    code line 1\n    code line 2\n\nMore text";
863        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
864        let fixed = rule.fix(&ctx).unwrap();
865
866        assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
867    }
868
869    #[test]
870    fn test_fix_fenced_to_indented() {
871        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
872        let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
873        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
874        let fixed = rule.fix(&ctx).unwrap();
875
876        assert!(fixed.contains("    code line 1\n    code line 2"));
877        assert!(!fixed.contains("```"));
878    }
879
880    #[test]
881    fn test_fix_unclosed_block() {
882        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
883        let content = "```\ncode without closing";
884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
885        let fixed = rule.fix(&ctx).unwrap();
886
887        // Should add closing fence
888        assert!(fixed.ends_with("```"));
889    }
890
891    #[test]
892    fn test_code_block_in_list() {
893        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
894        let content = "- List item\n    code in list\n    more code\n- Next item";
895        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
896        let result = rule.check(&ctx).unwrap();
897
898        // Code in lists should not be flagged
899        assert_eq!(result.len(), 0);
900    }
901
902    #[test]
903    fn test_detect_style_fenced() {
904        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
905        let content = "```\ncode\n```";
906        let style = rule.detect_style(content, false);
907
908        assert_eq!(style, Some(CodeBlockStyle::Fenced));
909    }
910
911    #[test]
912    fn test_detect_style_indented() {
913        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
914        let content = "Text\n\n    code\n\nMore";
915        let style = rule.detect_style(content, false);
916
917        assert_eq!(style, Some(CodeBlockStyle::Indented));
918    }
919
920    #[test]
921    fn test_detect_style_none() {
922        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
923        let content = "No code blocks here";
924        let style = rule.detect_style(content, false);
925
926        assert_eq!(style, None);
927    }
928
929    #[test]
930    fn test_tilde_fence() {
931        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
932        let content = "~~~\ncode\n~~~";
933        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
934        let result = rule.check(&ctx).unwrap();
935
936        // Tilde fences should be accepted as fenced blocks
937        assert_eq!(result.len(), 0);
938    }
939
940    #[test]
941    fn test_language_specification() {
942        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
943        let content = "```rust\nfn main() {}\n```";
944        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
945        let result = rule.check(&ctx).unwrap();
946
947        assert_eq!(result.len(), 0);
948    }
949
950    #[test]
951    fn test_empty_content() {
952        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
953        let content = "";
954        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
955        let result = rule.check(&ctx).unwrap();
956
957        assert_eq!(result.len(), 0);
958    }
959
960    #[test]
961    fn test_default_config() {
962        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
963        let (name, _config) = rule.default_config_section().unwrap();
964        assert_eq!(name, "MD046");
965    }
966
967    #[test]
968    fn test_markdown_documentation_block() {
969        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
970        let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
971        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
972        let result = rule.check(&ctx).unwrap();
973
974        // Nested code blocks in markdown documentation should be allowed
975        assert_eq!(result.len(), 0);
976    }
977
978    #[test]
979    fn test_preserve_trailing_newline() {
980        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
981        let content = "```\ncode\n```\n";
982        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
983        let fixed = rule.fix(&ctx).unwrap();
984
985        assert_eq!(fixed, content);
986    }
987
988    #[test]
989    fn test_mkdocs_tabs_not_flagged_as_indented_code() {
990        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
991        let content = r#"# Document
992
993=== "Python"
994
995    This is tab content
996    Not an indented code block
997
998    ```python
999    def hello():
1000        print("Hello")
1001    ```
1002
1003=== "JavaScript"
1004
1005    More tab content here
1006    Also not an indented code block"#;
1007
1008        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1009        let result = rule.check(&ctx).unwrap();
1010
1011        // Should not flag tab content as indented code blocks
1012        assert_eq!(result.len(), 0);
1013    }
1014
1015    #[test]
1016    fn test_mkdocs_tabs_with_actual_indented_code() {
1017        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1018        let content = r#"# Document
1019
1020=== "Tab 1"
1021
1022    This is tab content
1023
1024Regular text
1025
1026    This is an actual indented code block
1027    Should be flagged"#;
1028
1029        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1030        let result = rule.check(&ctx).unwrap();
1031
1032        // Should flag the actual indented code block but not the tab content
1033        assert_eq!(result.len(), 1);
1034        assert!(result[0].message.contains("Use fenced code blocks"));
1035    }
1036
1037    #[test]
1038    fn test_mkdocs_tabs_detect_style() {
1039        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1040        let content = r#"=== "Tab 1"
1041
1042    Content in tab
1043    More content
1044
1045=== "Tab 2"
1046
1047    Content in second tab"#;
1048
1049        // In MkDocs mode, tab content should not be detected as indented code blocks
1050        let style = rule.detect_style(content, true);
1051        assert_eq!(style, None); // No code blocks detected
1052
1053        // In standard mode, it would detect indented code blocks
1054        let style = rule.detect_style(content, false);
1055        assert_eq!(style, Some(CodeBlockStyle::Indented));
1056    }
1057
1058    #[test]
1059    fn test_mkdocs_nested_tabs() {
1060        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1061        let content = r#"# Document
1062
1063=== "Outer Tab"
1064
1065    Some content
1066
1067    === "Nested Tab"
1068
1069        Nested tab content
1070        Should not be flagged"#;
1071
1072        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1073        let result = rule.check(&ctx).unwrap();
1074
1075        // Nested tabs should not be flagged
1076        assert_eq!(result.len(), 0);
1077    }
1078}