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