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::element_cache::ElementCache;
4use crate::utils::mkdocs_tabs;
5use crate::utils::range_utils::calculate_line_range;
6use toml;
7
8mod md046_config;
9use md046_config::MD046Config;
10
11/// Rule MD046: Code block style
12///
13/// See [docs/md046.md](../../docs/md046.md) for full documentation, configuration, and examples.
14///
15/// This rule is triggered when code blocks do not use a consistent style (either fenced or indented).
16#[derive(Clone)]
17pub struct MD046CodeBlockStyle {
18    config: MD046Config,
19}
20
21impl MD046CodeBlockStyle {
22    pub fn new(style: CodeBlockStyle) -> Self {
23        Self {
24            config: MD046Config { style },
25        }
26    }
27
28    pub fn from_config_struct(config: MD046Config) -> Self {
29        Self { config }
30    }
31
32    /// Check if line has valid fence indentation per CommonMark spec (0-3 spaces)
33    ///
34    /// Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces."
35    /// 4+ spaces of indentation makes it an indented code block instead.
36    fn has_valid_fence_indent(line: &str) -> bool {
37        ElementCache::calculate_indentation_width_default(line) < 4
38    }
39
40    /// Check if a line is a valid fenced code block start per CommonMark spec
41    ///
42    /// Per CommonMark 0.31.2: "A code fence is a sequence of at least three consecutive
43    /// backtick characters (`) or tilde characters (~). An opening code fence may be
44    /// indented 0-3 spaces."
45    ///
46    /// This means 4+ spaces of indentation makes it an indented code block instead,
47    /// where the fence characters become literal content.
48    fn is_fenced_code_block_start(&self, line: &str) -> bool {
49        if !Self::has_valid_fence_indent(line) {
50            return false;
51        }
52
53        let trimmed = line.trim_start();
54        trimmed.starts_with("```") || trimmed.starts_with("~~~")
55    }
56
57    fn is_list_item(&self, line: &str) -> bool {
58        let trimmed = line.trim_start();
59        (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
60            || (trimmed.len() > 2
61                && trimmed.chars().next().unwrap().is_numeric()
62                && (trimmed.contains(". ") || trimmed.contains(") ")))
63    }
64
65    /// Check if a line is a footnote definition according to CommonMark footnote extension spec
66    ///
67    /// # Specification Compliance
68    /// Based on commonmark-hs footnote extension and GitHub's implementation:
69    /// - Format: `[^label]: content`
70    /// - Labels cannot be empty or whitespace-only
71    /// - Labels cannot contain line breaks (unlike regular link references)
72    /// - Labels typically contain alphanumerics, hyphens, underscores (though some parsers are more permissive)
73    ///
74    /// # Examples
75    /// Valid:
76    /// - `[^1]: Footnote text`
77    /// - `[^foo-bar]: Content`
78    /// - `[^test_123]: More content`
79    ///
80    /// Invalid:
81    /// - `[^]: No label`
82    /// - `[^ ]: Whitespace only`
83    /// - `[^]]: Extra bracket`
84    fn is_footnote_definition(&self, line: &str) -> bool {
85        let trimmed = line.trim_start();
86        if !trimmed.starts_with("[^") || trimmed.len() < 5 {
87            return false;
88        }
89
90        if let Some(close_bracket_pos) = trimmed.find("]:")
91            && close_bracket_pos > 2
92        {
93            let label = &trimmed[2..close_bracket_pos];
94
95            if label.trim().is_empty() {
96                return false;
97            }
98
99            // Per spec: labels cannot contain line breaks (check for \r since \n can't appear in a single line)
100            if label.contains('\r') {
101                return false;
102            }
103
104            // Validate characters per GitHub's behavior: alphanumeric, hyphens, underscores only
105            if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
106                return true;
107            }
108        }
109
110        false
111    }
112
113    /// Pre-compute which lines are in block continuation context (lists, footnotes) with a single forward pass
114    ///
115    /// # Specification-Based Context Tracking
116    /// This function implements CommonMark-style block continuation semantics:
117    ///
118    /// ## List Items
119    /// - List items can contain multiple paragraphs and blocks
120    /// - Content continues if indented appropriately
121    /// - Context ends at structural boundaries (headings, horizontal rules) or column-0 paragraphs
122    ///
123    /// ## Footnotes
124    /// Per commonmark-hs footnote extension and GitHub's implementation:
125    /// - Footnote content continues as long as it's indented
126    /// - Blank lines within footnotes don't terminate them (if next content is indented)
127    /// - Non-indented content terminates the footnote
128    /// - Similar to list items but can span more content
129    ///
130    /// # Performance
131    /// O(n) single forward pass, replacing O(n²) backward scanning
132    ///
133    /// # Returns
134    /// Boolean vector where `true` indicates the line is part of a list/footnote continuation
135    fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
136        let mut in_continuation_context = vec![false; lines.len()];
137        let mut last_list_item_line: Option<usize> = None;
138        let mut last_footnote_line: Option<usize> = None;
139        let mut blank_line_count = 0;
140
141        for (i, line) in lines.iter().enumerate() {
142            let trimmed = line.trim_start();
143            let indent_len = line.len() - trimmed.len();
144
145            // Check if this is a list item
146            if self.is_list_item(line) {
147                last_list_item_line = Some(i);
148                last_footnote_line = None; // List item ends any footnote context
149                blank_line_count = 0;
150                in_continuation_context[i] = true;
151                continue;
152            }
153
154            // Check if this is a footnote definition
155            if self.is_footnote_definition(line) {
156                last_footnote_line = Some(i);
157                last_list_item_line = None; // Footnote ends any list context
158                blank_line_count = 0;
159                in_continuation_context[i] = true;
160                continue;
161            }
162
163            // Handle empty lines
164            if line.trim().is_empty() {
165                // Blank lines within continuations are allowed
166                if last_list_item_line.is_some() || last_footnote_line.is_some() {
167                    blank_line_count += 1;
168                    in_continuation_context[i] = true;
169
170                    // Per spec: multiple consecutive blank lines might terminate context
171                    // GitHub allows multiple blank lines within footnotes if next content is indented
172                    // We'll check on the next non-blank line
173                }
174                continue;
175            }
176
177            // Non-empty line - check for structural breaks or continuation
178            if indent_len == 0 && !trimmed.is_empty() {
179                // Content at column 0 (not indented)
180
181                // Headings definitely end all contexts
182                if trimmed.starts_with('#') {
183                    last_list_item_line = None;
184                    last_footnote_line = None;
185                    blank_line_count = 0;
186                    continue;
187                }
188
189                // Horizontal rules end all contexts
190                if trimmed.starts_with("---") || trimmed.starts_with("***") {
191                    last_list_item_line = None;
192                    last_footnote_line = None;
193                    blank_line_count = 0;
194                    continue;
195                }
196
197                // Non-indented paragraph/content terminates contexts
198                // But be conservative: allow some distance for lists
199                if let Some(list_line) = last_list_item_line
200                    && (i - list_line > 5 || blank_line_count > 1)
201                {
202                    last_list_item_line = None;
203                }
204
205                // For footnotes, non-indented content always terminates
206                if last_footnote_line.is_some() {
207                    last_footnote_line = None;
208                }
209
210                blank_line_count = 0;
211
212                // If no active context, this is a regular line
213                if last_list_item_line.is_none() && last_footnote_line.is_some() {
214                    last_footnote_line = None;
215                }
216                continue;
217            }
218
219            // Indented content - part of continuation if we have active context
220            if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
221                in_continuation_context[i] = true;
222                blank_line_count = 0;
223            }
224        }
225
226        in_continuation_context
227    }
228
229    /// Check if a line is an indented code block using pre-computed context arrays
230    fn is_indented_code_block_with_context(
231        &self,
232        lines: &[&str],
233        i: usize,
234        is_mkdocs: bool,
235        in_list_context: &[bool],
236        in_tab_context: &[bool],
237    ) -> bool {
238        if i >= lines.len() {
239            return false;
240        }
241
242        let line = lines[i];
243
244        // Check if indented by at least 4 columns (accounting for tab expansion)
245        let indent = ElementCache::calculate_indentation_width_default(line);
246        if indent < 4 {
247            return false;
248        }
249
250        // Check if this is part of a list structure (pre-computed)
251        if in_list_context[i] {
252            return false;
253        }
254
255        // Skip if this is MkDocs tab content (pre-computed)
256        if is_mkdocs && in_tab_context[i] {
257            return false;
258        }
259
260        // Check if preceded by a blank line (typical for code blocks)
261        // OR if the previous line is also an indented code block (continuation)
262        let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
263        let prev_is_indented_code = i > 0
264            && ElementCache::calculate_indentation_width_default(lines[i - 1]) >= 4
265            && !in_list_context[i - 1]
266            && !(is_mkdocs && in_tab_context[i - 1]);
267
268        // If no blank line before and previous line is not indented code,
269        // it's likely list continuation, not a code block
270        if !has_blank_line_before && !prev_is_indented_code {
271            return false;
272        }
273
274        true
275    }
276
277    /// Pre-compute which lines are in MkDocs tab context with a single forward pass
278    fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
279        let mut in_tab_context = vec![false; lines.len()];
280        let mut current_tab_indent: Option<usize> = None;
281
282        for (i, line) in lines.iter().enumerate() {
283            // Check if this is a tab marker
284            if mkdocs_tabs::is_tab_marker(line) {
285                let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
286                current_tab_indent = Some(tab_indent);
287                in_tab_context[i] = true;
288                continue;
289            }
290
291            // If we have a current tab, check if this line is tab content
292            if let Some(tab_indent) = current_tab_indent {
293                if mkdocs_tabs::is_tab_content(line, tab_indent) {
294                    in_tab_context[i] = true;
295                } else if !line.trim().is_empty() && ElementCache::calculate_indentation_width_default(line) < 4 {
296                    // Non-indented, non-empty line ends tab context
297                    current_tab_indent = None;
298                } else {
299                    // Empty or indented line maintains tab context
300                    in_tab_context[i] = true;
301                }
302            }
303        }
304
305        in_tab_context
306    }
307
308    /// Categorize indented blocks for fix behavior
309    ///
310    /// Returns two vectors:
311    /// - `is_misplaced`: Lines that are part of a complete misplaced fenced block (dedent only)
312    /// - `contains_fences`: Lines that contain fence markers but aren't a complete block (skip fixing)
313    ///
314    /// A misplaced fenced block is a contiguous indented block that:
315    /// 1. Starts with a valid fence opener (``` or ~~~)
316    /// 2. Ends with a matching fence closer
317    ///
318    /// An unsafe block contains fence markers but isn't complete - wrapping would create invalid markdown.
319    fn categorize_indented_blocks(
320        &self,
321        lines: &[&str],
322        is_mkdocs: bool,
323        in_list_context: &[bool],
324        in_tab_context: &[bool],
325    ) -> (Vec<bool>, Vec<bool>) {
326        let mut is_misplaced = vec![false; lines.len()];
327        let mut contains_fences = vec![false; lines.len()];
328
329        // Find contiguous indented blocks and categorize them
330        let mut i = 0;
331        while i < lines.len() {
332            // Find the start of an indented block
333            if !self.is_indented_code_block_with_context(lines, i, is_mkdocs, in_list_context, in_tab_context) {
334                i += 1;
335                continue;
336            }
337
338            // Found start of an indented block - collect all contiguous lines
339            let block_start = i;
340            let mut block_end = i;
341
342            while block_end < lines.len()
343                && self.is_indented_code_block_with_context(
344                    lines,
345                    block_end,
346                    is_mkdocs,
347                    in_list_context,
348                    in_tab_context,
349                )
350            {
351                block_end += 1;
352            }
353
354            // Now we have an indented block from block_start to block_end (exclusive)
355            if block_end > block_start {
356                let first_line = lines[block_start].trim_start();
357                let last_line = lines[block_end - 1].trim_start();
358
359                // Check if first line is a fence opener
360                let is_backtick_fence = first_line.starts_with("```");
361                let is_tilde_fence = first_line.starts_with("~~~");
362
363                if is_backtick_fence || is_tilde_fence {
364                    let fence_char = if is_backtick_fence { '`' } else { '~' };
365                    let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
366
367                    // Check if last line is a matching fence closer
368                    let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
369                    let after_closer = &last_line[closer_fence_len..];
370
371                    if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
372                        // Complete misplaced fenced block - safe to dedent
373                        is_misplaced[block_start..block_end].fill(true);
374                    } else {
375                        // Incomplete fenced block - unsafe to wrap (would create nested fences)
376                        contains_fences[block_start..block_end].fill(true);
377                    }
378                } else {
379                    // Check if ANY line in the block contains fence markers
380                    // If so, wrapping would create invalid markdown
381                    let has_fence_markers = (block_start..block_end).any(|j| {
382                        let trimmed = lines[j].trim_start();
383                        trimmed.starts_with("```") || trimmed.starts_with("~~~")
384                    });
385
386                    if has_fence_markers {
387                        contains_fences[block_start..block_end].fill(true);
388                    }
389                }
390            }
391
392            i = block_end;
393        }
394
395        (is_misplaced, contains_fences)
396    }
397
398    fn check_unclosed_code_blocks(
399        &self,
400        ctx: &crate::lint_context::LintContext,
401    ) -> Result<Vec<LintWarning>, LintError> {
402        let mut warnings = Vec::new();
403        let lines: Vec<&str> = ctx.content.lines().collect();
404        let mut fence_stack: Vec<(String, usize, usize, bool, bool)> = Vec::new(); // (fence_marker, fence_length, opening_line, flagged_for_nested, is_markdown_example)
405
406        // Track if we're inside a markdown code block (for documentation examples)
407        // This is used to allow nested code blocks in markdown documentation
408        let mut inside_markdown_documentation_block = false;
409
410        for (i, line) in lines.iter().enumerate() {
411            let trimmed = line.trim_start();
412
413            // Skip lines inside HTML comments - code block examples in comments are not real code blocks
414            if let Some(line_info) = ctx.lines.get(i)
415                && line_info.in_html_comment
416            {
417                continue;
418            }
419
420            // Check for fence markers (``` or ~~~)
421            // Per CommonMark: fence must have 0-3 spaces of indentation
422            if Self::has_valid_fence_indent(line) && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
423                let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
424
425                // Count the fence length
426                let fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
427
428                // Check what comes after the fence characters
429                let after_fence = &trimmed[fence_length..];
430
431                // CommonMark spec: "If the info string comes after a backtick fence,
432                // it may not contain any backtick characters."
433                // This means ```something``` is NOT a valid fence - the backticks are inline code.
434                if fence_char == '`' && after_fence.contains('`') {
435                    continue;
436                }
437
438                // Check if this is a valid fence pattern
439                // Valid markdown code fence syntax:
440                // - ``` or ~~~ (just fence)
441                // - ``` language or ~~~ language (fence with space then language)
442                // - ```language (without space) is accepted by many parsers but only for actual languages
443                let is_valid_fence_pattern = if after_fence.is_empty() {
444                    // Empty after fence is always valid (e.g., ``` or ~~~)
445                    true
446                } else if after_fence.starts_with(' ') || after_fence.starts_with('\t') {
447                    // Space after fence - anything following is valid as info string
448                    true
449                } else {
450                    // No space after fence - must be a valid language identifier
451                    // Be strict to avoid false positives on content that looks like fences
452                    let identifier = after_fence.trim().to_lowercase();
453
454                    // Reject obvious non-language patterns
455                    if identifier.contains("fence") || identifier.contains("still") {
456                        false
457                    } else if identifier.len() > 20 {
458                        // Most language identifiers are short
459                        false
460                    } else if let Some(first_char) = identifier.chars().next() {
461                        // Must start with letter or # (for C#, F#)
462                        if !first_char.is_alphabetic() && first_char != '#' {
463                            false
464                        } else {
465                            // Check all characters are valid for a language identifier
466                            // Also check it's not just random text
467                            let valid_chars = identifier.chars().all(|c| {
468                                c.is_alphanumeric() || c == '-' || c == '_' || c == '+' || c == '#' || c == '.'
469                            });
470
471                            // Additional check: at least 2 chars and not all consonants (helps filter random words)
472                            valid_chars && identifier.len() >= 2
473                        }
474                    } else {
475                        false
476                    }
477                };
478
479                // When inside a code block, be conservative about what we treat as a fence
480                if !fence_stack.is_empty() {
481                    // Skip if not a valid fence pattern to begin with
482                    if !is_valid_fence_pattern {
483                        continue;
484                    }
485
486                    // Check if this could be a closing fence for the current block
487                    if let Some((open_marker, open_length, _, _, _)) = fence_stack.last() {
488                        if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
489                            // Potential closing fence - check if it has content after
490                            if !after_fence.trim().is_empty() {
491                                // Has content after - likely not a closing fence
492                                // Apply structural validation to determine if it's a nested fence
493
494                                // Skip patterns that are clearly decorative or content
495                                // 1. Contains special characters not typical in language identifiers
496                                let has_special_chars = after_fence.chars().any(|c| {
497                                    !c.is_alphanumeric()
498                                        && c != '-'
499                                        && c != '_'
500                                        && c != '+'
501                                        && c != '#'
502                                        && c != '.'
503                                        && c != ' '
504                                        && c != '\t'
505                                });
506
507                                if has_special_chars {
508                                    continue; // e.g., ~~~!@#$%, ~~~~~~~~^^^^
509                                }
510
511                                // 2. Check for repetitive non-alphanumeric patterns
512                                if fence_length > 4 && after_fence.chars().take(4).all(|c| !c.is_alphanumeric()) {
513                                    continue; // e.g., ~~~~~~~~~~ or ````````
514                                }
515
516                                // 3. If no space after fence, must look like a valid language identifier
517                                if !after_fence.starts_with(' ') && !after_fence.starts_with('\t') {
518                                    let identifier = after_fence.trim();
519
520                                    // Must start with letter or # (for C#, F#)
521                                    if let Some(first) = identifier.chars().next()
522                                        && !first.is_alphabetic()
523                                        && first != '#'
524                                    {
525                                        continue;
526                                    }
527
528                                    // Reasonable length for a language identifier
529                                    if identifier.len() > 30 {
530                                        continue;
531                                    }
532                                }
533                            }
534                            // Otherwise, could be a closing fence - let it through
535                        } else {
536                            // Different fence type or insufficient length
537                            // Only treat as nested if it looks like a real fence with language
538
539                            // Must have proper spacing or no content after fence
540                            if !after_fence.is_empty()
541                                && !after_fence.starts_with(' ')
542                                && !after_fence.starts_with('\t')
543                            {
544                                // No space after fence - be very strict
545                                let identifier = after_fence.trim();
546
547                                // Skip if contains any special characters beyond common ones
548                                if identifier.chars().any(|c| {
549                                    !c.is_alphanumeric() && c != '-' && c != '_' && c != '+' && c != '#' && c != '.'
550                                }) {
551                                    continue;
552                                }
553
554                                // Skip if doesn't start with letter or #
555                                if let Some(first) = identifier.chars().next()
556                                    && !first.is_alphabetic()
557                                    && first != '#'
558                                {
559                                    continue;
560                                }
561                            }
562                        }
563                    }
564                }
565
566                // We'll check if this is a markdown block after determining if it's an opening fence
567
568                // Check if this is a closing fence for the current open fence
569                if let Some((open_marker, open_length, _open_line, _flagged, _is_md)) = fence_stack.last() {
570                    // Must match fence character and have at least as many characters
571                    if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
572                        // Check if this line has only whitespace after the fence marker
573                        let after_fence = &trimmed[fence_length..];
574                        if after_fence.trim().is_empty() {
575                            // This is a valid closing fence
576                            let _popped = fence_stack.pop();
577
578                            // Check if we're exiting a markdown documentation block
579                            if let Some((_, _, _, _, is_md)) = _popped
580                                && is_md
581                            {
582                                inside_markdown_documentation_block = false;
583                            }
584                            continue;
585                        }
586                    }
587                }
588
589                // This is an opening fence (has content after marker or no matching open fence)
590                // Note: after_fence was already calculated above during validation
591                if !after_fence.trim().is_empty() || fence_stack.is_empty() {
592                    // Only flag as problematic if we're opening a new fence while another is still open
593                    // AND they use the same fence character (indicating potential confusion)
594                    // AND we're not inside a markdown documentation block
595                    let has_nested_issue =
596                        if let Some((open_marker, open_length, open_line, _, _)) = fence_stack.last_mut() {
597                            if fence_char == open_marker.chars().next().unwrap()
598                                && fence_length >= *open_length
599                                && !inside_markdown_documentation_block
600                            {
601                                // This is problematic - same fence character used with equal or greater length while another is open
602                                let (opening_start_line, opening_start_col, opening_end_line, opening_end_col) =
603                                    calculate_line_range(*open_line, lines[*open_line - 1]);
604
605                                // Calculate the byte position to insert closing fence before this line
606                                let line_start_byte = ctx.line_index.get_line_start_byte(i + 1).unwrap_or(0);
607
608                                warnings.push(LintWarning {
609                                    rule_name: Some(self.name().to_string()),
610                                    line: opening_start_line,
611                                    column: opening_start_col,
612                                    end_line: opening_end_line,
613                                    end_column: opening_end_col,
614                                    message: format!(
615                                        "Code block '{}' should be closed before starting new one at line {}",
616                                        open_marker,
617                                        i + 1
618                                    ),
619                                    severity: Severity::Warning,
620                                    fix: Some(Fix {
621                                        range: (line_start_byte..line_start_byte),
622                                        replacement: format!("{open_marker}\n\n"),
623                                    }),
624                                });
625
626                                // Mark the current fence as flagged for nested issue
627                                fence_stack.last_mut().unwrap().3 = true;
628                                true // We flagged a nested issue for this fence
629                            } else {
630                                false
631                            }
632                        } else {
633                            false
634                        };
635
636                    // Check if this opening fence is a markdown code block
637                    let after_fence_for_lang = &trimmed[fence_length..];
638                    let lang_info = after_fence_for_lang.trim().to_lowercase();
639                    let is_markdown_fence = lang_info.starts_with("markdown") || lang_info.starts_with("md");
640
641                    // If we're opening a markdown documentation block, mark that we're inside one
642                    if is_markdown_fence && !inside_markdown_documentation_block {
643                        inside_markdown_documentation_block = true;
644                    }
645
646                    // Add this fence to the stack
647                    let fence_marker = fence_char.to_string().repeat(fence_length);
648                    fence_stack.push((fence_marker, fence_length, i + 1, has_nested_issue, is_markdown_fence));
649                }
650            }
651        }
652
653        // Check for unclosed fences at end of file
654        // Only flag unclosed if we haven't already flagged for nested issues
655        for (fence_marker, _, opening_line, flagged_for_nested, _) in fence_stack {
656            if !flagged_for_nested {
657                let (start_line, start_col, end_line, end_col) =
658                    calculate_line_range(opening_line, lines[opening_line - 1]);
659
660                warnings.push(LintWarning {
661                    rule_name: Some(self.name().to_string()),
662                    line: start_line,
663                    column: start_col,
664                    end_line,
665                    end_column: end_col,
666                    message: format!("Code block opened with '{fence_marker}' but never closed"),
667                    severity: Severity::Warning,
668                    fix: Some(Fix {
669                        range: (ctx.content.len()..ctx.content.len()),
670                        replacement: format!("\n{fence_marker}"),
671                    }),
672                });
673            }
674        }
675
676        Ok(warnings)
677    }
678
679    fn detect_style(&self, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
680        // Empty content has no style
681        if content.is_empty() {
682            return None;
683        }
684
685        let lines: Vec<&str> = content.lines().collect();
686        let mut fenced_count = 0;
687        let mut indented_count = 0;
688
689        // Pre-compute list and tab contexts for efficiency
690        let in_list_context = self.precompute_block_continuation_context(&lines);
691        let in_tab_context = if is_mkdocs {
692            self.precompute_mkdocs_tab_context(&lines)
693        } else {
694            vec![false; lines.len()]
695        };
696
697        // Count all code block occurrences (prevalence-based approach)
698        let mut in_fenced = false;
699        let mut prev_was_indented = false;
700
701        for (i, line) in lines.iter().enumerate() {
702            if self.is_fenced_code_block_start(line) {
703                if !in_fenced {
704                    // Opening fence
705                    fenced_count += 1;
706                    in_fenced = true;
707                } else {
708                    // Closing fence
709                    in_fenced = false;
710                }
711            } else if !in_fenced
712                && self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
713            {
714                // Count each continuous indented block once
715                if !prev_was_indented {
716                    indented_count += 1;
717                }
718                prev_was_indented = true;
719            } else {
720                prev_was_indented = false;
721            }
722        }
723
724        if fenced_count == 0 && indented_count == 0 {
725            // No code blocks found
726            None
727        } else if fenced_count > 0 && indented_count == 0 {
728            // Only fenced blocks found
729            Some(CodeBlockStyle::Fenced)
730        } else if fenced_count == 0 && indented_count > 0 {
731            // Only indented blocks found
732            Some(CodeBlockStyle::Indented)
733        } else {
734            // Both types found - use most prevalent
735            // In case of tie, prefer fenced (more common, widely supported)
736            if fenced_count >= indented_count {
737                Some(CodeBlockStyle::Fenced)
738            } else {
739                Some(CodeBlockStyle::Indented)
740            }
741        }
742    }
743}
744
745impl Rule for MD046CodeBlockStyle {
746    fn name(&self) -> &'static str {
747        "MD046"
748    }
749
750    fn description(&self) -> &'static str {
751        "Code blocks should use a consistent style"
752    }
753
754    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
755        // Early return for empty content
756        if ctx.content.is_empty() {
757            return Ok(Vec::new());
758        }
759
760        // Quick check for code blocks before processing
761        if !ctx.content.contains("```")
762            && !ctx.content.contains("~~~")
763            && !ctx.content.contains("    ")
764            && !ctx.content.contains('\t')
765        {
766            return Ok(Vec::new());
767        }
768
769        // First, always check for unclosed code blocks
770        let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
771
772        // If we found unclosed blocks, return those warnings first
773        if !unclosed_warnings.is_empty() {
774            return Ok(unclosed_warnings);
775        }
776
777        // Check for code block style consistency
778        let lines: Vec<&str> = ctx.content.lines().collect();
779        let mut warnings = Vec::new();
780
781        // Check if we're in MkDocs mode
782        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
783
784        // Pre-compute list and tab contexts once for all checks
785        let in_list_context = self.precompute_block_continuation_context(&lines);
786        let in_tab_context = if is_mkdocs {
787            self.precompute_mkdocs_tab_context(&lines)
788        } else {
789            vec![false; lines.len()]
790        };
791
792        // Determine the target style from the detected style in the document
793        let target_style = match self.config.style {
794            CodeBlockStyle::Consistent => self
795                .detect_style(ctx.content, is_mkdocs)
796                .unwrap_or(CodeBlockStyle::Fenced),
797            _ => self.config.style,
798        };
799
800        // Process each line to find style inconsistencies
801        // Pre-compute which lines are inside FENCED code blocks (not indented)
802        // Use pre-computed code blocks from context
803        let mut in_fenced_block = vec![false; lines.len()];
804        for &(start, end) in &ctx.code_blocks {
805            // Check if this block is fenced by examining its content
806            if start < ctx.content.len() && end <= ctx.content.len() {
807                let block_content = &ctx.content[start..end];
808                let is_fenced = block_content.starts_with("```") || block_content.starts_with("~~~");
809
810                if is_fenced {
811                    // Mark all lines in this fenced block
812                    for (line_idx, line_info) in ctx.lines.iter().enumerate() {
813                        if line_info.byte_offset >= start && line_info.byte_offset < end {
814                            in_fenced_block[line_idx] = true;
815                        }
816                    }
817                }
818            }
819        }
820
821        let mut in_fence = false;
822        for (i, line) in lines.iter().enumerate() {
823            let trimmed = line.trim_start();
824
825            // Skip lines that are in HTML blocks - they shouldn't be treated as indented code
826            if ctx.line_info(i + 1).is_some_and(|info| info.in_html_block) {
827                continue;
828            }
829
830            // Skip lines inside HTML comments - code block examples in comments are not real code blocks
831            if ctx.line_info(i + 1).is_some_and(|info| info.in_html_comment) {
832                continue;
833            }
834
835            // Skip if this line is in a mkdocstrings block (but not other skip contexts,
836            // since MD046 needs to detect regular code blocks)
837            if ctx.lines[i].in_mkdocstrings {
838                continue;
839            }
840
841            // Check for fenced code block markers (for style checking)
842            // Per CommonMark: fence must have 0-3 spaces of indentation
843            if Self::has_valid_fence_indent(line) && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
844                if target_style == CodeBlockStyle::Indented && !in_fence {
845                    // This is an opening fence marker but we want indented style
846                    // Only flag the opening marker, not the closing one
847                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
848                    warnings.push(LintWarning {
849                        rule_name: Some(self.name().to_string()),
850                        line: start_line,
851                        column: start_col,
852                        end_line,
853                        end_column: end_col,
854                        message: "Use indented code blocks".to_string(),
855                        severity: Severity::Warning,
856                        fix: Some(Fix {
857                            range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
858                            replacement: String::new(),
859                        }),
860                    });
861                }
862                // Toggle fence state
863                in_fence = !in_fence;
864                continue;
865            }
866
867            // Skip content lines inside fenced blocks
868            // This prevents false positives like flagging ~~~~ inside bash output
869            if in_fenced_block[i] {
870                continue;
871            }
872
873            // Check for indented code blocks (when not inside a fenced block)
874            if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
875                && target_style == CodeBlockStyle::Fenced
876            {
877                // Check if this is the start of a new indented block
878                let prev_line_is_indented = i > 0
879                    && self.is_indented_code_block_with_context(
880                        &lines,
881                        i - 1,
882                        is_mkdocs,
883                        &in_list_context,
884                        &in_tab_context,
885                    );
886
887                if !prev_line_is_indented {
888                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
889                    warnings.push(LintWarning {
890                        rule_name: Some(self.name().to_string()),
891                        line: start_line,
892                        column: start_col,
893                        end_line,
894                        end_column: end_col,
895                        message: "Use fenced code blocks".to_string(),
896                        severity: Severity::Warning,
897                        fix: Some(Fix {
898                            range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
899                            replacement: format!("```\n{}", line.trim_start()),
900                        }),
901                    });
902                }
903            }
904        }
905
906        Ok(warnings)
907    }
908
909    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
910        let content = ctx.content;
911        if content.is_empty() {
912            return Ok(String::new());
913        }
914
915        // First check if we have nested fence issues that need special handling
916        let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
917
918        // If we have nested fence warnings, apply those fixes first
919        if !unclosed_warnings.is_empty() {
920            // Check if any warnings are about nested fences (not just unclosed blocks)
921            for warning in &unclosed_warnings {
922                if warning
923                    .message
924                    .contains("should be closed before starting new one at line")
925                {
926                    // Apply the nested fence fix
927                    if let Some(fix) = &warning.fix {
928                        let mut result = String::new();
929                        result.push_str(&content[..fix.range.start]);
930                        result.push_str(&fix.replacement);
931                        result.push_str(&content[fix.range.start..]);
932                        return Ok(result);
933                    }
934                }
935            }
936        }
937
938        let lines: Vec<&str> = content.lines().collect();
939
940        // Determine target style
941        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
942        let target_style = match self.config.style {
943            CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
944            _ => self.config.style,
945        };
946
947        // Pre-compute list and tab contexts for efficiency
948        let in_list_context = self.precompute_block_continuation_context(&lines);
949        let in_tab_context = if is_mkdocs {
950            self.precompute_mkdocs_tab_context(&lines)
951        } else {
952            vec![false; lines.len()]
953        };
954
955        // Categorize indented blocks:
956        // - misplaced_fence_lines: complete fenced blocks that were over-indented (safe to dedent)
957        // - unsafe_fence_lines: contain fence markers but aren't complete (skip fixing to avoid broken output)
958        let (misplaced_fence_lines, unsafe_fence_lines) =
959            self.categorize_indented_blocks(&lines, is_mkdocs, &in_list_context, &in_tab_context);
960
961        let mut result = String::with_capacity(content.len());
962        let mut in_fenced_block = false;
963        let mut fenced_fence_type = None;
964        let mut in_indented_block = false;
965
966        for (i, line) in lines.iter().enumerate() {
967            let trimmed = line.trim_start();
968
969            // Handle fenced code blocks
970            // Per CommonMark: fence must have 0-3 spaces of indentation
971            if !in_fenced_block
972                && Self::has_valid_fence_indent(line)
973                && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
974            {
975                in_fenced_block = true;
976                fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
977
978                if target_style == CodeBlockStyle::Indented {
979                    // Skip the opening fence
980                    in_indented_block = true;
981                } else {
982                    // Keep the fenced block
983                    result.push_str(line);
984                    result.push('\n');
985                }
986            } else if in_fenced_block && fenced_fence_type.is_some() {
987                let fence = fenced_fence_type.unwrap();
988                if trimmed.starts_with(fence) {
989                    in_fenced_block = false;
990                    fenced_fence_type = None;
991                    in_indented_block = false;
992
993                    if target_style == CodeBlockStyle::Indented {
994                        // Skip the closing fence
995                    } else {
996                        // Keep the fenced block
997                        result.push_str(line);
998                        result.push('\n');
999                    }
1000                } else if target_style == CodeBlockStyle::Indented {
1001                    // Convert content inside fenced block to indented
1002                    result.push_str("    ");
1003                    result.push_str(trimmed);
1004                    result.push('\n');
1005                } else {
1006                    // Keep fenced block content as is
1007                    result.push_str(line);
1008                    result.push('\n');
1009                }
1010            } else if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
1011            {
1012                // This is an indented code block
1013
1014                // Check if we need to start a new fenced block
1015                let prev_line_is_indented = i > 0
1016                    && self.is_indented_code_block_with_context(
1017                        &lines,
1018                        i - 1,
1019                        is_mkdocs,
1020                        &in_list_context,
1021                        &in_tab_context,
1022                    );
1023
1024                if target_style == CodeBlockStyle::Fenced {
1025                    let trimmed_content = line.trim_start();
1026
1027                    // Check if this line is part of a misplaced fenced block
1028                    // (pre-computed block-level analysis, not per-line)
1029                    if misplaced_fence_lines[i] {
1030                        // Just remove the indentation - this is a complete misplaced fenced block
1031                        result.push_str(trimmed_content);
1032                        result.push('\n');
1033                    } else if unsafe_fence_lines[i] {
1034                        // This block contains fence markers but isn't a complete fenced block
1035                        // Wrapping would create invalid nested fences - keep as-is (don't fix)
1036                        result.push_str(line);
1037                        result.push('\n');
1038                    } else if !prev_line_is_indented && !in_indented_block {
1039                        // Start of a new indented block that should be fenced
1040                        result.push_str("```\n");
1041                        result.push_str(trimmed_content);
1042                        result.push('\n');
1043                        in_indented_block = true;
1044                    } else {
1045                        // Inside an indented block
1046                        result.push_str(trimmed_content);
1047                        result.push('\n');
1048                    }
1049
1050                    // Check if this is the end of the indented block
1051                    let next_line_is_indented = i < lines.len() - 1
1052                        && self.is_indented_code_block_with_context(
1053                            &lines,
1054                            i + 1,
1055                            is_mkdocs,
1056                            &in_list_context,
1057                            &in_tab_context,
1058                        );
1059                    // Don't close if this is an unsafe block (kept as-is)
1060                    if !next_line_is_indented
1061                        && in_indented_block
1062                        && !misplaced_fence_lines[i]
1063                        && !unsafe_fence_lines[i]
1064                    {
1065                        result.push_str("```\n");
1066                        in_indented_block = false;
1067                    }
1068                } else {
1069                    // Keep indented block as is
1070                    result.push_str(line);
1071                    result.push('\n');
1072                }
1073            } else {
1074                // Regular line
1075                if in_indented_block && target_style == CodeBlockStyle::Fenced {
1076                    result.push_str("```\n");
1077                    in_indented_block = false;
1078                }
1079
1080                result.push_str(line);
1081                result.push('\n');
1082            }
1083        }
1084
1085        // Close any remaining blocks
1086        if in_indented_block && target_style == CodeBlockStyle::Fenced {
1087            result.push_str("```\n");
1088        }
1089
1090        // Close any unclosed fenced blocks
1091        if let Some(fence_type) = fenced_fence_type
1092            && in_fenced_block
1093        {
1094            result.push_str(fence_type);
1095            result.push('\n');
1096        }
1097
1098        // Remove trailing newline if original didn't have one
1099        if !content.ends_with('\n') && result.ends_with('\n') {
1100            result.pop();
1101        }
1102
1103        Ok(result)
1104    }
1105
1106    /// Get the category of this rule for selective processing
1107    fn category(&self) -> RuleCategory {
1108        RuleCategory::CodeBlock
1109    }
1110
1111    /// Check if this rule should be skipped
1112    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1113        // Skip if content is empty or unlikely to contain code blocks
1114        // Note: indented code blocks use 4 spaces, can't optimize that easily
1115        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains("    "))
1116    }
1117
1118    fn as_any(&self) -> &dyn std::any::Any {
1119        self
1120    }
1121
1122    fn default_config_section(&self) -> Option<(String, toml::Value)> {
1123        let json_value = serde_json::to_value(&self.config).ok()?;
1124        Some((
1125            self.name().to_string(),
1126            crate::rule_config_serde::json_to_toml_value(&json_value)?,
1127        ))
1128    }
1129
1130    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1131    where
1132        Self: Sized,
1133    {
1134        let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1135        Box::new(Self::from_config_struct(rule_config))
1136    }
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142    use crate::lint_context::LintContext;
1143
1144    #[test]
1145    fn test_fenced_code_block_detection() {
1146        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1147        assert!(rule.is_fenced_code_block_start("```"));
1148        assert!(rule.is_fenced_code_block_start("```rust"));
1149        assert!(rule.is_fenced_code_block_start("~~~"));
1150        assert!(rule.is_fenced_code_block_start("~~~python"));
1151        assert!(rule.is_fenced_code_block_start("  ```"));
1152        assert!(!rule.is_fenced_code_block_start("``"));
1153        assert!(!rule.is_fenced_code_block_start("~~"));
1154        assert!(!rule.is_fenced_code_block_start("Regular text"));
1155    }
1156
1157    #[test]
1158    fn test_consistent_style_with_fenced_blocks() {
1159        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1160        let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1161        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1162        let result = rule.check(&ctx).unwrap();
1163
1164        // All blocks are fenced, so consistent style should be OK
1165        assert_eq!(result.len(), 0);
1166    }
1167
1168    #[test]
1169    fn test_consistent_style_with_indented_blocks() {
1170        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1171        let content = "Text\n\n    code\n    more code\n\nMore text\n\n    another block";
1172        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1173        let result = rule.check(&ctx).unwrap();
1174
1175        // All blocks are indented, so consistent style should be OK
1176        assert_eq!(result.len(), 0);
1177    }
1178
1179    #[test]
1180    fn test_consistent_style_mixed() {
1181        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1182        let content = "```\nfenced code\n```\n\nText\n\n    indented code\n\nMore";
1183        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184        let result = rule.check(&ctx).unwrap();
1185
1186        // Mixed styles should be flagged
1187        assert!(!result.is_empty());
1188    }
1189
1190    #[test]
1191    fn test_fenced_style_with_indented_blocks() {
1192        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1193        let content = "Text\n\n    indented code\n    more code\n\nMore text";
1194        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195        let result = rule.check(&ctx).unwrap();
1196
1197        // Indented blocks should be flagged when fenced style is required
1198        assert!(!result.is_empty());
1199        assert!(result[0].message.contains("Use fenced code blocks"));
1200    }
1201
1202    #[test]
1203    fn test_fenced_style_with_tab_indented_blocks() {
1204        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1205        let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1206        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1207        let result = rule.check(&ctx).unwrap();
1208
1209        // Tab-indented blocks should also be flagged when fenced style is required
1210        assert!(!result.is_empty());
1211        assert!(result[0].message.contains("Use fenced code blocks"));
1212    }
1213
1214    #[test]
1215    fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1216        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1217        // 2 spaces + tab = 4 columns due to tab expansion (tab goes to column 4)
1218        let content = "Text\n\n  \tmixed indent code\n  \tmore code\n\nMore text";
1219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220        let result = rule.check(&ctx).unwrap();
1221
1222        // Mixed whitespace indented blocks should also be flagged
1223        assert!(
1224            !result.is_empty(),
1225            "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1226        );
1227        assert!(result[0].message.contains("Use fenced code blocks"));
1228    }
1229
1230    #[test]
1231    fn test_fenced_style_with_one_space_tab_indent() {
1232        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1233        // 1 space + tab = 4 columns (tab expands to next tab stop at column 4)
1234        let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1235        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236        let result = rule.check(&ctx).unwrap();
1237
1238        assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1239        assert!(result[0].message.contains("Use fenced code blocks"));
1240    }
1241
1242    #[test]
1243    fn test_indented_style_with_fenced_blocks() {
1244        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1245        let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1246        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1247        let result = rule.check(&ctx).unwrap();
1248
1249        // Fenced blocks should be flagged when indented style is required
1250        assert!(!result.is_empty());
1251        assert!(result[0].message.contains("Use indented code blocks"));
1252    }
1253
1254    #[test]
1255    fn test_unclosed_code_block() {
1256        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1257        let content = "```\ncode without closing fence";
1258        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1259        let result = rule.check(&ctx).unwrap();
1260
1261        assert_eq!(result.len(), 1);
1262        assert!(result[0].message.contains("never closed"));
1263    }
1264
1265    #[test]
1266    fn test_nested_code_blocks() {
1267        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1268        let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1269        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1270        let result = rule.check(&ctx).unwrap();
1271
1272        // This should parse as two separate code blocks
1273        assert_eq!(result.len(), 0);
1274    }
1275
1276    #[test]
1277    fn test_fix_indented_to_fenced() {
1278        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1279        let content = "Text\n\n    code line 1\n    code line 2\n\nMore text";
1280        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281        let fixed = rule.fix(&ctx).unwrap();
1282
1283        assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1284    }
1285
1286    #[test]
1287    fn test_fix_fenced_to_indented() {
1288        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1289        let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1290        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1291        let fixed = rule.fix(&ctx).unwrap();
1292
1293        assert!(fixed.contains("    code line 1\n    code line 2"));
1294        assert!(!fixed.contains("```"));
1295    }
1296
1297    #[test]
1298    fn test_fix_unclosed_block() {
1299        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1300        let content = "```\ncode without closing";
1301        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1302        let fixed = rule.fix(&ctx).unwrap();
1303
1304        // Should add closing fence
1305        assert!(fixed.ends_with("```"));
1306    }
1307
1308    #[test]
1309    fn test_code_block_in_list() {
1310        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1311        let content = "- List item\n    code in list\n    more code\n- Next item";
1312        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313        let result = rule.check(&ctx).unwrap();
1314
1315        // Code in lists should not be flagged
1316        assert_eq!(result.len(), 0);
1317    }
1318
1319    #[test]
1320    fn test_detect_style_fenced() {
1321        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1322        let content = "```\ncode\n```";
1323        let style = rule.detect_style(content, false);
1324
1325        assert_eq!(style, Some(CodeBlockStyle::Fenced));
1326    }
1327
1328    #[test]
1329    fn test_detect_style_indented() {
1330        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1331        let content = "Text\n\n    code\n\nMore";
1332        let style = rule.detect_style(content, false);
1333
1334        assert_eq!(style, Some(CodeBlockStyle::Indented));
1335    }
1336
1337    #[test]
1338    fn test_detect_style_none() {
1339        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1340        let content = "No code blocks here";
1341        let style = rule.detect_style(content, false);
1342
1343        assert_eq!(style, None);
1344    }
1345
1346    #[test]
1347    fn test_tilde_fence() {
1348        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1349        let content = "~~~\ncode\n~~~";
1350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351        let result = rule.check(&ctx).unwrap();
1352
1353        // Tilde fences should be accepted as fenced blocks
1354        assert_eq!(result.len(), 0);
1355    }
1356
1357    #[test]
1358    fn test_language_specification() {
1359        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1360        let content = "```rust\nfn main() {}\n```";
1361        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1362        let result = rule.check(&ctx).unwrap();
1363
1364        assert_eq!(result.len(), 0);
1365    }
1366
1367    #[test]
1368    fn test_empty_content() {
1369        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1370        let content = "";
1371        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1372        let result = rule.check(&ctx).unwrap();
1373
1374        assert_eq!(result.len(), 0);
1375    }
1376
1377    #[test]
1378    fn test_default_config() {
1379        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1380        let (name, _config) = rule.default_config_section().unwrap();
1381        assert_eq!(name, "MD046");
1382    }
1383
1384    #[test]
1385    fn test_markdown_documentation_block() {
1386        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1387        let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1389        let result = rule.check(&ctx).unwrap();
1390
1391        // Nested code blocks in markdown documentation should be allowed
1392        assert_eq!(result.len(), 0);
1393    }
1394
1395    #[test]
1396    fn test_preserve_trailing_newline() {
1397        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1398        let content = "```\ncode\n```\n";
1399        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400        let fixed = rule.fix(&ctx).unwrap();
1401
1402        assert_eq!(fixed, content);
1403    }
1404
1405    #[test]
1406    fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1407        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1408        let content = r#"# Document
1409
1410=== "Python"
1411
1412    This is tab content
1413    Not an indented code block
1414
1415    ```python
1416    def hello():
1417        print("Hello")
1418    ```
1419
1420=== "JavaScript"
1421
1422    More tab content here
1423    Also not an indented code block"#;
1424
1425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1426        let result = rule.check(&ctx).unwrap();
1427
1428        // Should not flag tab content as indented code blocks
1429        assert_eq!(result.len(), 0);
1430    }
1431
1432    #[test]
1433    fn test_mkdocs_tabs_with_actual_indented_code() {
1434        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1435        let content = r#"# Document
1436
1437=== "Tab 1"
1438
1439    This is tab content
1440
1441Regular text
1442
1443    This is an actual indented code block
1444    Should be flagged"#;
1445
1446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1447        let result = rule.check(&ctx).unwrap();
1448
1449        // Should flag the actual indented code block but not the tab content
1450        assert_eq!(result.len(), 1);
1451        assert!(result[0].message.contains("Use fenced code blocks"));
1452    }
1453
1454    #[test]
1455    fn test_mkdocs_tabs_detect_style() {
1456        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1457        let content = r#"=== "Tab 1"
1458
1459    Content in tab
1460    More content
1461
1462=== "Tab 2"
1463
1464    Content in second tab"#;
1465
1466        // In MkDocs mode, tab content should not be detected as indented code blocks
1467        let style = rule.detect_style(content, true);
1468        assert_eq!(style, None); // No code blocks detected
1469
1470        // In standard mode, it would detect indented code blocks
1471        let style = rule.detect_style(content, false);
1472        assert_eq!(style, Some(CodeBlockStyle::Indented));
1473    }
1474
1475    #[test]
1476    fn test_mkdocs_nested_tabs() {
1477        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1478        let content = r#"# Document
1479
1480=== "Outer Tab"
1481
1482    Some content
1483
1484    === "Nested Tab"
1485
1486        Nested tab content
1487        Should not be flagged"#;
1488
1489        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1490        let result = rule.check(&ctx).unwrap();
1491
1492        // Nested tabs should not be flagged
1493        assert_eq!(result.len(), 0);
1494    }
1495
1496    #[test]
1497    fn test_footnote_indented_paragraphs_not_flagged() {
1498        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1499        let content = r#"# Test Document with Footnotes
1500
1501This is some text with a footnote[^1].
1502
1503Here's some code:
1504
1505```bash
1506echo "fenced code block"
1507```
1508
1509More text with another footnote[^2].
1510
1511[^1]: Really interesting footnote text.
1512
1513    Even more interesting second paragraph.
1514
1515[^2]: Another footnote.
1516
1517    With a second paragraph too.
1518
1519    And even a third paragraph!"#;
1520
1521        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1522        let result = rule.check(&ctx).unwrap();
1523
1524        // Indented paragraphs in footnotes should not be flagged as code blocks
1525        assert_eq!(result.len(), 0);
1526    }
1527
1528    #[test]
1529    fn test_footnote_definition_detection() {
1530        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1531
1532        // Valid footnote definitions (per CommonMark footnote extension spec)
1533        // Reference: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/footnotes.md
1534        assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1535        assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1536        assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1537        assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1538        assert!(rule.is_footnote_definition("    [^1]: Indented footnote"));
1539        assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1540        assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1541        assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1542        assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1543
1544        // Invalid: empty or whitespace-only labels (spec violation)
1545        assert!(!rule.is_footnote_definition("[^]: No label"));
1546        assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1547        assert!(!rule.is_footnote_definition("[^  ]: Multiple spaces"));
1548        assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1549
1550        // Invalid: malformed syntax
1551        assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1552        assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1553        assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1554        assert!(!rule.is_footnote_definition("[^")); // Too short
1555        assert!(!rule.is_footnote_definition("[^1:")); // Missing closing bracket
1556        assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1557
1558        // Invalid: disallowed characters in label
1559        assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1560        assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1561        assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1562        assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1563        assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1564
1565        // Edge case: line breaks not allowed in labels
1566        // (This is a string test, actual multiline would need different testing)
1567        assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1568    }
1569
1570    #[test]
1571    fn test_footnote_with_blank_lines() {
1572        // Spec requirement: blank lines within footnotes don't terminate them
1573        // if next content is indented (matches GitHub's implementation)
1574        // Reference: commonmark-hs footnote extension behavior
1575        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1576        let content = r#"# Document
1577
1578Text with footnote[^1].
1579
1580[^1]: First paragraph.
1581
1582    Second paragraph after blank line.
1583
1584    Third paragraph after another blank line.
1585
1586Regular text at column 0 ends the footnote."#;
1587
1588        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1589        let result = rule.check(&ctx).unwrap();
1590
1591        // The indented paragraphs in the footnote should not be flagged as code blocks
1592        assert_eq!(
1593            result.len(),
1594            0,
1595            "Indented content within footnotes should not trigger MD046"
1596        );
1597    }
1598
1599    #[test]
1600    fn test_footnote_multiple_consecutive_blank_lines() {
1601        // Edge case: multiple consecutive blank lines within a footnote
1602        // Should still work if next content is indented
1603        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1604        let content = r#"Text[^1].
1605
1606[^1]: First paragraph.
1607
1608
1609
1610    Content after three blank lines (still part of footnote).
1611
1612Not indented, so footnote ends here."#;
1613
1614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615        let result = rule.check(&ctx).unwrap();
1616
1617        // The indented content should not be flagged
1618        assert_eq!(
1619            result.len(),
1620            0,
1621            "Multiple blank lines shouldn't break footnote continuation"
1622        );
1623    }
1624
1625    #[test]
1626    fn test_footnote_terminated_by_non_indented_content() {
1627        // Spec requirement: non-indented content always terminates the footnote
1628        // Reference: commonmark-hs footnote extension
1629        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1630        let content = r#"[^1]: Footnote content.
1631
1632    More indented content in footnote.
1633
1634This paragraph is not indented, so footnote ends.
1635
1636    This should be flagged as indented code block."#;
1637
1638        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639        let result = rule.check(&ctx).unwrap();
1640
1641        // The last indented block should be flagged (it's after the footnote ended)
1642        assert_eq!(
1643            result.len(),
1644            1,
1645            "Indented code after footnote termination should be flagged"
1646        );
1647        assert!(
1648            result[0].message.contains("Use fenced code blocks"),
1649            "Expected MD046 warning for indented code block"
1650        );
1651        assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1652    }
1653
1654    #[test]
1655    fn test_footnote_terminated_by_structural_elements() {
1656        // Spec requirement: headings and horizontal rules terminate footnotes
1657        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1658        let content = r#"[^1]: Footnote content.
1659
1660    More content.
1661
1662## Heading terminates footnote
1663
1664    This indented content should be flagged.
1665
1666---
1667
1668    This should also be flagged (after horizontal rule)."#;
1669
1670        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1671        let result = rule.check(&ctx).unwrap();
1672
1673        // Both indented blocks after structural elements should be flagged
1674        assert_eq!(
1675            result.len(),
1676            2,
1677            "Both indented blocks after termination should be flagged"
1678        );
1679    }
1680
1681    #[test]
1682    fn test_footnote_with_code_block_inside() {
1683        // Spec behavior: footnotes can contain fenced code blocks
1684        // The fenced code must be properly indented within the footnote
1685        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1686        let content = r#"Text[^1].
1687
1688[^1]: Footnote with code:
1689
1690    ```python
1691    def hello():
1692        print("world")
1693    ```
1694
1695    More footnote text after code."#;
1696
1697        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1698        let result = rule.check(&ctx).unwrap();
1699
1700        // Should have no warnings - the fenced code block is valid
1701        assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1702    }
1703
1704    #[test]
1705    fn test_footnote_with_8_space_indented_code() {
1706        // Edge case: code blocks within footnotes need 8 spaces (4 for footnote + 4 for code)
1707        // This should NOT be flagged as it's properly nested indented code
1708        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1709        let content = r#"Text[^1].
1710
1711[^1]: Footnote with nested code.
1712
1713        code block
1714        more code"#;
1715
1716        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1717        let result = rule.check(&ctx).unwrap();
1718
1719        // The 8-space indented code is valid within footnote
1720        assert_eq!(
1721            result.len(),
1722            0,
1723            "8-space indented code within footnotes represents nested code blocks"
1724        );
1725    }
1726
1727    #[test]
1728    fn test_multiple_footnotes() {
1729        // Spec behavior: each footnote definition starts a new block context
1730        // Previous footnote ends when new footnote begins
1731        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1732        let content = r#"Text[^1] and more[^2].
1733
1734[^1]: First footnote.
1735
1736    Continuation of first.
1737
1738[^2]: Second footnote starts here, ending the first.
1739
1740    Continuation of second."#;
1741
1742        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1743        let result = rule.check(&ctx).unwrap();
1744
1745        // All indented content is part of footnotes
1746        assert_eq!(
1747            result.len(),
1748            0,
1749            "Multiple footnotes should each maintain their continuation context"
1750        );
1751    }
1752
1753    #[test]
1754    fn test_list_item_ends_footnote_context() {
1755        // Spec behavior: list items and footnotes are mutually exclusive contexts
1756        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1757        let content = r#"[^1]: Footnote.
1758
1759    Content in footnote.
1760
1761- List item starts here (ends footnote context).
1762
1763    This indented content is part of the list, not the footnote."#;
1764
1765        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1766        let result = rule.check(&ctx).unwrap();
1767
1768        // List continuation should not be flagged
1769        assert_eq!(
1770            result.len(),
1771            0,
1772            "List items should end footnote context and start their own"
1773        );
1774    }
1775
1776    #[test]
1777    fn test_footnote_vs_actual_indented_code() {
1778        // Critical test: verify we can still detect actual indented code blocks outside footnotes
1779        // This ensures the fix doesn't cause false negatives
1780        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1781        let content = r#"# Heading
1782
1783Text with footnote[^1].
1784
1785[^1]: Footnote content.
1786
1787    Part of footnote (should not be flagged).
1788
1789Regular paragraph ends footnote context.
1790
1791    This is actual indented code (MUST be flagged)
1792    Should be detected as code block"#;
1793
1794        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795        let result = rule.check(&ctx).unwrap();
1796
1797        // Should flag the indented code after the regular paragraph
1798        assert_eq!(
1799            result.len(),
1800            1,
1801            "Must still detect indented code blocks outside footnotes"
1802        );
1803        assert!(
1804            result[0].message.contains("Use fenced code blocks"),
1805            "Expected MD046 warning for indented code"
1806        );
1807        assert!(
1808            result[0].line >= 11,
1809            "Warning should be on the actual indented code line"
1810        );
1811    }
1812
1813    #[test]
1814    fn test_spec_compliant_label_characters() {
1815        // Spec requirement: labels must contain only alphanumerics, hyphens, underscores
1816        // Reference: commonmark-hs footnote extension
1817        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1818
1819        // Valid according to spec
1820        assert!(rule.is_footnote_definition("[^test]: text"));
1821        assert!(rule.is_footnote_definition("[^TEST]: text"));
1822        assert!(rule.is_footnote_definition("[^test-name]: text"));
1823        assert!(rule.is_footnote_definition("[^test_name]: text"));
1824        assert!(rule.is_footnote_definition("[^test123]: text"));
1825        assert!(rule.is_footnote_definition("[^123]: text"));
1826        assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
1827
1828        // Invalid characters (spec violations)
1829        assert!(!rule.is_footnote_definition("[^test.name]: text")); // Period
1830        assert!(!rule.is_footnote_definition("[^test name]: text")); // Space
1831        assert!(!rule.is_footnote_definition("[^test@name]: text")); // At sign
1832        assert!(!rule.is_footnote_definition("[^test#name]: text")); // Hash
1833        assert!(!rule.is_footnote_definition("[^test$name]: text")); // Dollar
1834        assert!(!rule.is_footnote_definition("[^test%name]: text")); // Percent
1835    }
1836
1837    #[test]
1838    fn test_code_block_inside_html_comment() {
1839        // Regression test: code blocks inside HTML comments should not be flagged
1840        // Found in denoland/deno test fixture during sanity testing
1841        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1842        let content = r#"# Document
1843
1844Some text.
1845
1846<!--
1847Example code block in comment:
1848
1849```typescript
1850console.log("Hello");
1851```
1852
1853More comment text.
1854-->
1855
1856More content."#;
1857
1858        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1859        let result = rule.check(&ctx).unwrap();
1860
1861        assert_eq!(
1862            result.len(),
1863            0,
1864            "Code blocks inside HTML comments should not be flagged as unclosed"
1865        );
1866    }
1867
1868    #[test]
1869    fn test_unclosed_fence_inside_html_comment() {
1870        // Even an unclosed fence inside an HTML comment should be ignored
1871        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1872        let content = r#"# Document
1873
1874<!--
1875Example with intentionally unclosed fence:
1876
1877```
1878code without closing
1879-->
1880
1881More content."#;
1882
1883        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1884        let result = rule.check(&ctx).unwrap();
1885
1886        assert_eq!(
1887            result.len(),
1888            0,
1889            "Unclosed fences inside HTML comments should be ignored"
1890        );
1891    }
1892
1893    #[test]
1894    fn test_multiline_html_comment_with_indented_code() {
1895        // Indented code inside HTML comments should also be ignored
1896        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1897        let content = r#"# Document
1898
1899<!--
1900Example:
1901
1902    indented code
1903    more code
1904
1905End of comment.
1906-->
1907
1908Regular text."#;
1909
1910        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1911        let result = rule.check(&ctx).unwrap();
1912
1913        assert_eq!(
1914            result.len(),
1915            0,
1916            "Indented code inside HTML comments should not be flagged"
1917        );
1918    }
1919
1920    #[test]
1921    fn test_code_block_after_html_comment() {
1922        // Code blocks after HTML comments should still be detected
1923        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1924        let content = r#"# Document
1925
1926<!-- comment -->
1927
1928Text before.
1929
1930    indented code should be flagged
1931
1932More text."#;
1933
1934        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1935        let result = rule.check(&ctx).unwrap();
1936
1937        assert_eq!(
1938            result.len(),
1939            1,
1940            "Code blocks after HTML comments should still be detected"
1941        );
1942        assert!(result[0].message.contains("Use fenced code blocks"));
1943    }
1944
1945    #[test]
1946    fn test_four_space_indented_fence_is_not_valid_fence() {
1947        // Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces."
1948        // 4+ spaces means it's NOT a valid fence opener - it becomes an indented code block
1949        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1950
1951        // Valid fences (0-3 spaces)
1952        assert!(rule.is_fenced_code_block_start("```"));
1953        assert!(rule.is_fenced_code_block_start(" ```"));
1954        assert!(rule.is_fenced_code_block_start("  ```"));
1955        assert!(rule.is_fenced_code_block_start("   ```"));
1956
1957        // Invalid fences (4+ spaces) - these are indented code blocks instead
1958        assert!(!rule.is_fenced_code_block_start("    ```"));
1959        assert!(!rule.is_fenced_code_block_start("     ```"));
1960        assert!(!rule.is_fenced_code_block_start("        ```"));
1961
1962        // Tab counts as 4 spaces per CommonMark
1963        assert!(!rule.is_fenced_code_block_start("\t```"));
1964    }
1965
1966    #[test]
1967    fn test_issue_237_indented_fenced_block_detected_as_indented() {
1968        // Issue #237: User has fenced code block indented by 4 spaces
1969        // Per CommonMark, this should be detected as an INDENTED code block
1970        // because 4+ spaces of indentation makes the fence invalid
1971        //
1972        // Reference: https://github.com/rvben/rumdl/issues/237
1973        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1974
1975        // This is the exact test case from issue #237
1976        let content = r#"## Test
1977
1978    ```js
1979    var foo = "hello";
1980    ```
1981"#;
1982
1983        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1984        let result = rule.check(&ctx).unwrap();
1985
1986        // Should flag this as an indented code block that should use fenced style
1987        assert_eq!(
1988            result.len(),
1989            1,
1990            "4-space indented fence should be detected as indented code block"
1991        );
1992        assert!(
1993            result[0].message.contains("Use fenced code blocks"),
1994            "Expected 'Use fenced code blocks' message"
1995        );
1996    }
1997
1998    #[test]
1999    fn test_three_space_indented_fence_is_valid() {
2000        // 3 spaces is the maximum allowed per CommonMark - should be recognized as fenced
2001        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2002
2003        let content = r#"## Test
2004
2005   ```js
2006   var foo = "hello";
2007   ```
2008"#;
2009
2010        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2011        let result = rule.check(&ctx).unwrap();
2012
2013        // 3-space indent is valid for fenced blocks - should pass
2014        assert_eq!(
2015            result.len(),
2016            0,
2017            "3-space indented fence should be recognized as valid fenced code block"
2018        );
2019    }
2020
2021    #[test]
2022    fn test_indented_style_with_deeply_indented_fenced() {
2023        // When style=indented, a 4-space indented "fenced" block should still be detected
2024        // as an indented code block (which is what we want!)
2025        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2026
2027        let content = r#"Text
2028
2029    ```js
2030    var foo = "hello";
2031    ```
2032
2033More text
2034"#;
2035
2036        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2037        let result = rule.check(&ctx).unwrap();
2038
2039        // When target style is "indented", 4-space indented content is correct
2040        // The fence markers become literal content in the indented code block
2041        assert_eq!(
2042            result.len(),
2043            0,
2044            "4-space indented content should be valid when style=indented"
2045        );
2046    }
2047
2048    #[test]
2049    fn test_fix_misplaced_fenced_block() {
2050        // Issue #237: When a fenced code block is accidentally indented 4+ spaces,
2051        // the fix should just remove the indentation, not wrap in more fences
2052        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2053
2054        let content = r#"## Test
2055
2056    ```js
2057    var foo = "hello";
2058    ```
2059"#;
2060
2061        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2062        let fixed = rule.fix(&ctx).unwrap();
2063
2064        // The fix should just remove the 4-space indentation
2065        let expected = r#"## Test
2066
2067```js
2068var foo = "hello";
2069```
2070"#;
2071
2072        assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2073    }
2074
2075    #[test]
2076    fn test_fix_regular_indented_block() {
2077        // Regular indented code blocks (without fence markers) should still be
2078        // wrapped in fences when converted
2079        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2080
2081        let content = r#"Text
2082
2083    var foo = "hello";
2084    console.log(foo);
2085
2086More text
2087"#;
2088
2089        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2090        let fixed = rule.fix(&ctx).unwrap();
2091
2092        // Should wrap in fences
2093        assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2094        assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2095    }
2096
2097    #[test]
2098    fn test_fix_indented_block_with_fence_like_content() {
2099        // If an indented block contains fence-like content but doesn't form a
2100        // complete fenced block, we should NOT autofix it because wrapping would
2101        // create invalid nested fences. The block is left unchanged.
2102        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2103
2104        let content = r#"Text
2105
2106    some code
2107    ```not a fence opener
2108    more code
2109"#;
2110
2111        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2112        let fixed = rule.fix(&ctx).unwrap();
2113
2114        // Block should be left unchanged to avoid creating invalid nested fences
2115        assert!(fixed.contains("    some code"), "Unsafe block should be left unchanged");
2116        assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2117    }
2118
2119    #[test]
2120    fn test_fix_mixed_indented_and_misplaced_blocks() {
2121        // Mixed blocks: regular indented code followed by misplaced fenced block
2122        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2123
2124        let content = r#"Text
2125
2126    regular indented code
2127
2128More text
2129
2130    ```python
2131    print("hello")
2132    ```
2133"#;
2134
2135        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136        let fixed = rule.fix(&ctx).unwrap();
2137
2138        // First block should be wrapped
2139        assert!(
2140            fixed.contains("```\nregular indented code\n```"),
2141            "First block should be wrapped in fences"
2142        );
2143
2144        // Second block should be dedented (not wrapped)
2145        assert!(
2146            fixed.contains("\n```python\nprint(\"hello\")\n```"),
2147            "Second block should be dedented, not double-wrapped"
2148        );
2149        // Should NOT have nested fences
2150        assert!(
2151            !fixed.contains("```\n```python"),
2152            "Should not have nested fence openers"
2153        );
2154    }
2155}