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().to_string()),
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().to_string()),
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 line_index = LineIndex::new(ctx.content.to_string());
513
514        // Pre-compute which lines are inside FENCED code blocks (not indented)
515        // We detect all code blocks, then filter for fenced ones only
516        let code_blocks = crate::utils::code_block_utils::CodeBlockUtils::detect_code_blocks(ctx.content);
517        let mut in_fenced_block = vec![false; lines.len()];
518        for &(start, end) in &code_blocks {
519            // Check if this block is fenced by examining its content
520            if start < ctx.content.len() && end <= ctx.content.len() {
521                let block_content = &ctx.content[start..end];
522                let is_fenced = block_content.starts_with("```") || block_content.starts_with("~~~");
523
524                if is_fenced {
525                    // Mark all lines in this fenced block
526                    for (line_idx, line_info) in ctx.lines.iter().enumerate() {
527                        if line_info.byte_offset >= start && line_info.byte_offset < end {
528                            in_fenced_block[line_idx] = true;
529                        }
530                    }
531                }
532            }
533        }
534
535        let mut in_fence = false;
536        for (i, line) in lines.iter().enumerate() {
537            let trimmed = line.trim_start();
538
539            // Skip lines that are in HTML blocks - they shouldn't be treated as indented code
540            if ctx.line_info(i + 1).is_some_and(|info| info.in_html_block) {
541                continue;
542            }
543
544            // Skip if this line is in a mkdocstrings block (but not other skip contexts,
545            // since MD046 needs to detect regular code blocks)
546            if ctx.lines[i].in_mkdocstrings {
547                continue;
548            }
549
550            // Check for fenced code block markers (for style checking)
551            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
552                if target_style == CodeBlockStyle::Indented && !in_fence {
553                    // This is an opening fence marker but we want indented style
554                    // Only flag the opening marker, not the closing one
555                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
556                    warnings.push(LintWarning {
557                        rule_name: Some(self.name().to_string()),
558                        line: start_line,
559                        column: start_col,
560                        end_line,
561                        end_column: end_col,
562                        message: "Use indented code blocks".to_string(),
563                        severity: Severity::Warning,
564                        fix: Some(Fix {
565                            range: line_index.line_col_to_byte_range(i + 1, 1),
566                            replacement: String::new(),
567                        }),
568                    });
569                }
570                // Toggle fence state
571                in_fence = !in_fence;
572                continue;
573            }
574
575            // Skip content lines inside fenced blocks
576            // This prevents false positives like flagging ~~~~ inside bash output
577            if in_fenced_block[i] {
578                continue;
579            }
580
581            // Check for indented code blocks (when not inside a fenced block)
582            if self.is_indented_code_block(&lines, i, is_mkdocs) && target_style == CodeBlockStyle::Fenced {
583                // Check if this is the start of a new indented block
584                let prev_line_is_indented = i > 0 && self.is_indented_code_block(&lines, i - 1, is_mkdocs);
585
586                if !prev_line_is_indented {
587                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
588                    warnings.push(LintWarning {
589                        rule_name: Some(self.name().to_string()),
590                        line: start_line,
591                        column: start_col,
592                        end_line,
593                        end_column: end_col,
594                        message: "Use fenced code blocks".to_string(),
595                        severity: Severity::Warning,
596                        fix: Some(Fix {
597                            range: line_index.line_col_to_byte_range(i + 1, 1),
598                            replacement: format!("```\n{}", line.trim_start()),
599                        }),
600                    });
601                }
602            }
603        }
604
605        Ok(warnings)
606    }
607
608    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
609        let content = ctx.content;
610        if content.is_empty() {
611            return Ok(String::new());
612        }
613
614        // First check if we have nested fence issues that need special handling
615        let line_index = LineIndex::new(ctx.content.to_string());
616        let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
617
618        // If we have nested fence warnings, apply those fixes first
619        if !unclosed_warnings.is_empty() {
620            // Check if any warnings are about nested fences (not just unclosed blocks)
621            for warning in &unclosed_warnings {
622                if warning
623                    .message
624                    .contains("should be closed before starting new one at line")
625                {
626                    // Apply the nested fence fix
627                    if let Some(fix) = &warning.fix {
628                        let mut result = String::new();
629                        result.push_str(&content[..fix.range.start]);
630                        result.push_str(&fix.replacement);
631                        result.push_str(&content[fix.range.start..]);
632                        return Ok(result);
633                    }
634                }
635            }
636        }
637
638        let lines: Vec<&str> = content.lines().collect();
639
640        // Determine target style
641        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
642        let target_style = match self.config.style {
643            CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
644            _ => self.config.style,
645        };
646
647        let mut result = String::with_capacity(content.len());
648        let mut in_fenced_block = false;
649        let mut fenced_fence_type = None;
650        let mut in_indented_block = false;
651
652        for (i, line) in lines.iter().enumerate() {
653            let trimmed = line.trim_start();
654
655            // Handle fenced code blocks
656            if !in_fenced_block && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
657                in_fenced_block = true;
658                fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
659
660                if target_style == CodeBlockStyle::Indented {
661                    // Skip the opening fence
662                    in_indented_block = true;
663                } else {
664                    // Keep the fenced block
665                    result.push_str(line);
666                    result.push('\n');
667                }
668            } else if in_fenced_block && fenced_fence_type.is_some() {
669                let fence = fenced_fence_type.unwrap();
670                if trimmed.starts_with(fence) {
671                    in_fenced_block = false;
672                    fenced_fence_type = None;
673                    in_indented_block = false;
674
675                    if target_style == CodeBlockStyle::Indented {
676                        // Skip the closing fence
677                    } else {
678                        // Keep the fenced block
679                        result.push_str(line);
680                        result.push('\n');
681                    }
682                } else if target_style == CodeBlockStyle::Indented {
683                    // Convert content inside fenced block to indented
684                    result.push_str("    ");
685                    result.push_str(trimmed);
686                    result.push('\n');
687                } else {
688                    // Keep fenced block content as is
689                    result.push_str(line);
690                    result.push('\n');
691                }
692            } else if self.is_indented_code_block(&lines, i, is_mkdocs) {
693                // This is an indented code block
694
695                // Check if we need to start a new fenced block
696                let prev_line_is_indented = i > 0 && self.is_indented_code_block(&lines, i - 1, is_mkdocs);
697
698                if target_style == CodeBlockStyle::Fenced {
699                    if !prev_line_is_indented && !in_indented_block {
700                        // Start of a new indented block that should be fenced
701                        result.push_str("```\n");
702                        result.push_str(line.trim_start());
703                        result.push('\n');
704                        in_indented_block = true;
705                    } else {
706                        // Inside an indented block
707                        result.push_str(line.trim_start());
708                        result.push('\n');
709                    }
710
711                    // Check if this is the end of the indented block
712                    let _next_line_is_indented =
713                        i < lines.len() - 1 && self.is_indented_code_block(&lines, i + 1, is_mkdocs);
714                    if !_next_line_is_indented && in_indented_block {
715                        result.push_str("```\n");
716                        in_indented_block = false;
717                    }
718                } else {
719                    // Keep indented block as is
720                    result.push_str(line);
721                    result.push('\n');
722                }
723            } else {
724                // Regular line
725                if in_indented_block && target_style == CodeBlockStyle::Fenced {
726                    result.push_str("```\n");
727                    in_indented_block = false;
728                }
729
730                result.push_str(line);
731                result.push('\n');
732            }
733        }
734
735        // Close any remaining blocks
736        if in_indented_block && target_style == CodeBlockStyle::Fenced {
737            result.push_str("```\n");
738        }
739
740        // Close any unclosed fenced blocks
741        if let Some(fence_type) = fenced_fence_type
742            && in_fenced_block
743        {
744            result.push_str(fence_type);
745            result.push('\n');
746        }
747
748        // Remove trailing newline if original didn't have one
749        if !content.ends_with('\n') && result.ends_with('\n') {
750            result.pop();
751        }
752
753        Ok(result)
754    }
755
756    /// Get the category of this rule for selective processing
757    fn category(&self) -> RuleCategory {
758        RuleCategory::CodeBlock
759    }
760
761    /// Check if this rule should be skipped
762    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
763        // Skip if content is empty or unlikely to contain code blocks
764        // Note: indented code blocks use 4 spaces, can't optimize that easily
765        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains("    "))
766    }
767
768    fn as_any(&self) -> &dyn std::any::Any {
769        self
770    }
771
772    fn default_config_section(&self) -> Option<(String, toml::Value)> {
773        let json_value = serde_json::to_value(&self.config).ok()?;
774        Some((
775            self.name().to_string(),
776            crate::rule_config_serde::json_to_toml_value(&json_value)?,
777        ))
778    }
779
780    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
781    where
782        Self: Sized,
783    {
784        let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
785        Box::new(Self::from_config_struct(rule_config))
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use crate::lint_context::LintContext;
793
794    #[test]
795    fn test_fenced_code_block_detection() {
796        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
797        assert!(rule.is_fenced_code_block_start("```"));
798        assert!(rule.is_fenced_code_block_start("```rust"));
799        assert!(rule.is_fenced_code_block_start("~~~"));
800        assert!(rule.is_fenced_code_block_start("~~~python"));
801        assert!(rule.is_fenced_code_block_start("  ```"));
802        assert!(!rule.is_fenced_code_block_start("``"));
803        assert!(!rule.is_fenced_code_block_start("~~"));
804        assert!(!rule.is_fenced_code_block_start("Regular text"));
805    }
806
807    #[test]
808    fn test_consistent_style_with_fenced_blocks() {
809        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
810        let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
811        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
812        let result = rule.check(&ctx).unwrap();
813
814        // All blocks are fenced, so consistent style should be OK
815        assert_eq!(result.len(), 0);
816    }
817
818    #[test]
819    fn test_consistent_style_with_indented_blocks() {
820        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
821        let content = "Text\n\n    code\n    more code\n\nMore text\n\n    another block";
822        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
823        let result = rule.check(&ctx).unwrap();
824
825        // All blocks are indented, so consistent style should be OK
826        assert_eq!(result.len(), 0);
827    }
828
829    #[test]
830    fn test_consistent_style_mixed() {
831        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
832        let content = "```\nfenced code\n```\n\nText\n\n    indented code\n\nMore";
833        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
834        let result = rule.check(&ctx).unwrap();
835
836        // Mixed styles should be flagged
837        assert!(!result.is_empty());
838    }
839
840    #[test]
841    fn test_fenced_style_with_indented_blocks() {
842        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
843        let content = "Text\n\n    indented code\n    more code\n\nMore text";
844        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
845        let result = rule.check(&ctx).unwrap();
846
847        // Indented blocks should be flagged when fenced style is required
848        assert!(!result.is_empty());
849        assert!(result[0].message.contains("Use fenced code blocks"));
850    }
851
852    #[test]
853    fn test_indented_style_with_fenced_blocks() {
854        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
855        let content = "Text\n\n```\nfenced code\n```\n\nMore text";
856        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
857        let result = rule.check(&ctx).unwrap();
858
859        // Fenced blocks should be flagged when indented style is required
860        assert!(!result.is_empty());
861        assert!(result[0].message.contains("Use indented code blocks"));
862    }
863
864    #[test]
865    fn test_unclosed_code_block() {
866        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
867        let content = "```\ncode without closing fence";
868        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
869        let result = rule.check(&ctx).unwrap();
870
871        assert_eq!(result.len(), 1);
872        assert!(result[0].message.contains("never closed"));
873    }
874
875    #[test]
876    fn test_nested_code_blocks() {
877        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
878        let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
879        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
880        let result = rule.check(&ctx).unwrap();
881
882        // This should parse as two separate code blocks
883        assert_eq!(result.len(), 0);
884    }
885
886    #[test]
887    fn test_fix_indented_to_fenced() {
888        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
889        let content = "Text\n\n    code line 1\n    code line 2\n\nMore text";
890        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
891        let fixed = rule.fix(&ctx).unwrap();
892
893        assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
894    }
895
896    #[test]
897    fn test_fix_fenced_to_indented() {
898        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
899        let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
901        let fixed = rule.fix(&ctx).unwrap();
902
903        assert!(fixed.contains("    code line 1\n    code line 2"));
904        assert!(!fixed.contains("```"));
905    }
906
907    #[test]
908    fn test_fix_unclosed_block() {
909        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
910        let content = "```\ncode without closing";
911        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
912        let fixed = rule.fix(&ctx).unwrap();
913
914        // Should add closing fence
915        assert!(fixed.ends_with("```"));
916    }
917
918    #[test]
919    fn test_code_block_in_list() {
920        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
921        let content = "- List item\n    code in list\n    more code\n- Next item";
922        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
923        let result = rule.check(&ctx).unwrap();
924
925        // Code in lists should not be flagged
926        assert_eq!(result.len(), 0);
927    }
928
929    #[test]
930    fn test_detect_style_fenced() {
931        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
932        let content = "```\ncode\n```";
933        let style = rule.detect_style(content, false);
934
935        assert_eq!(style, Some(CodeBlockStyle::Fenced));
936    }
937
938    #[test]
939    fn test_detect_style_indented() {
940        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
941        let content = "Text\n\n    code\n\nMore";
942        let style = rule.detect_style(content, false);
943
944        assert_eq!(style, Some(CodeBlockStyle::Indented));
945    }
946
947    #[test]
948    fn test_detect_style_none() {
949        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
950        let content = "No code blocks here";
951        let style = rule.detect_style(content, false);
952
953        assert_eq!(style, None);
954    }
955
956    #[test]
957    fn test_tilde_fence() {
958        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
959        let content = "~~~\ncode\n~~~";
960        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
961        let result = rule.check(&ctx).unwrap();
962
963        // Tilde fences should be accepted as fenced blocks
964        assert_eq!(result.len(), 0);
965    }
966
967    #[test]
968    fn test_language_specification() {
969        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
970        let content = "```rust\nfn main() {}\n```";
971        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
972        let result = rule.check(&ctx).unwrap();
973
974        assert_eq!(result.len(), 0);
975    }
976
977    #[test]
978    fn test_empty_content() {
979        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
980        let content = "";
981        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
982        let result = rule.check(&ctx).unwrap();
983
984        assert_eq!(result.len(), 0);
985    }
986
987    #[test]
988    fn test_default_config() {
989        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
990        let (name, _config) = rule.default_config_section().unwrap();
991        assert_eq!(name, "MD046");
992    }
993
994    #[test]
995    fn test_markdown_documentation_block() {
996        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
997        let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
998        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
999        let result = rule.check(&ctx).unwrap();
1000
1001        // Nested code blocks in markdown documentation should be allowed
1002        assert_eq!(result.len(), 0);
1003    }
1004
1005    #[test]
1006    fn test_preserve_trailing_newline() {
1007        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1008        let content = "```\ncode\n```\n";
1009        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1010        let fixed = rule.fix(&ctx).unwrap();
1011
1012        assert_eq!(fixed, content);
1013    }
1014
1015    #[test]
1016    fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1017        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1018        let content = r#"# Document
1019
1020=== "Python"
1021
1022    This is tab content
1023    Not an indented code block
1024
1025    ```python
1026    def hello():
1027        print("Hello")
1028    ```
1029
1030=== "JavaScript"
1031
1032    More tab content here
1033    Also not an indented code block"#;
1034
1035        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1036        let result = rule.check(&ctx).unwrap();
1037
1038        // Should not flag tab content as indented code blocks
1039        assert_eq!(result.len(), 0);
1040    }
1041
1042    #[test]
1043    fn test_mkdocs_tabs_with_actual_indented_code() {
1044        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1045        let content = r#"# Document
1046
1047=== "Tab 1"
1048
1049    This is tab content
1050
1051Regular text
1052
1053    This is an actual indented code block
1054    Should be flagged"#;
1055
1056        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1057        let result = rule.check(&ctx).unwrap();
1058
1059        // Should flag the actual indented code block but not the tab content
1060        assert_eq!(result.len(), 1);
1061        assert!(result[0].message.contains("Use fenced code blocks"));
1062    }
1063
1064    #[test]
1065    fn test_mkdocs_tabs_detect_style() {
1066        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1067        let content = r#"=== "Tab 1"
1068
1069    Content in tab
1070    More content
1071
1072=== "Tab 2"
1073
1074    Content in second tab"#;
1075
1076        // In MkDocs mode, tab content should not be detected as indented code blocks
1077        let style = rule.detect_style(content, true);
1078        assert_eq!(style, None); // No code blocks detected
1079
1080        // In standard mode, it would detect indented code blocks
1081        let style = rule.detect_style(content, false);
1082        assert_eq!(style, Some(CodeBlockStyle::Indented));
1083    }
1084
1085    #[test]
1086    fn test_mkdocs_nested_tabs() {
1087        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1088        let content = r#"# Document
1089
1090=== "Outer Tab"
1091
1092    Some content
1093
1094    === "Nested Tab"
1095
1096        Nested tab content
1097        Should not be flagged"#;
1098
1099        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1100        let result = rule.check(&ctx).unwrap();
1101
1102        // Nested tabs should not be flagged
1103        assert_eq!(result.len(), 0);
1104    }
1105}