rumdl_lib/rules/
md046_code_block_style.rs

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