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