rumdl_lib/rules/
md046_code_block_style.rs

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