Skip to main content

rumdl_lib/rules/
md046_code_block_style.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::calculate_indentation_width_default;
3use crate::utils::mkdocs_admonitions;
4use crate::utils::mkdocs_tabs;
5use crate::utils::range_utils::calculate_line_range;
6use toml;
7
8mod md046_config;
9pub use md046_config::CodeBlockStyle;
10use md046_config::MD046Config;
11
12/// Pre-computed context arrays for indented code block detection.
13struct IndentContext<'a> {
14    in_list_context: &'a [bool],
15    in_tab_context: &'a [bool],
16    in_admonition_context: &'a [bool],
17    /// Lines belonging to a non-code container whose body can legitimately be
18    /// indented by 4+ spaces or contain verbatim fence markers: HTML/MDX
19    /// comments, raw HTML blocks, JSX blocks, mkdocstrings blocks, footnote
20    /// definitions, and blockquotes.
21    ///
22    /// These lines are excluded from `detect_style`'s style tally, from
23    /// `is_indented_code_block_with_context`, and from
24    /// `categorize_indented_blocks`'s fence rewriting — keeping detection in
25    /// lockstep with the warning-side skip list used in `check`.
26    in_comment_or_html: &'a [bool],
27}
28
29/// Rule MD046: Code block style
30///
31/// See [docs/md046.md](../../docs/md046.md) for full documentation, configuration, and examples.
32///
33/// This rule is triggered when code blocks do not use a consistent style (either fenced or indented).
34#[derive(Clone)]
35pub struct MD046CodeBlockStyle {
36    config: MD046Config,
37}
38
39impl MD046CodeBlockStyle {
40    pub fn new(style: CodeBlockStyle) -> Self {
41        Self {
42            config: MD046Config { style },
43        }
44    }
45
46    pub fn from_config_struct(config: MD046Config) -> Self {
47        Self { config }
48    }
49
50    /// Check if line has valid fence indentation per CommonMark spec (0-3 spaces)
51    ///
52    /// Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces."
53    /// 4+ spaces of indentation makes it an indented code block instead.
54    fn has_valid_fence_indent(line: &str) -> bool {
55        calculate_indentation_width_default(line) < 4
56    }
57
58    /// Check if a line is a valid fenced code block start per CommonMark spec
59    ///
60    /// Per CommonMark 0.31.2: "A code fence is a sequence of at least three consecutive
61    /// backtick characters (`) or tilde characters (~). An opening code fence may be
62    /// indented 0-3 spaces."
63    ///
64    /// This means 4+ spaces of indentation makes it an indented code block instead,
65    /// where the fence characters become literal content.
66    fn is_fenced_code_block_start(&self, line: &str) -> bool {
67        if !Self::has_valid_fence_indent(line) {
68            return false;
69        }
70
71        let trimmed = line.trim_start();
72        trimmed.starts_with("```") || trimmed.starts_with("~~~")
73    }
74
75    fn is_list_item(&self, line: &str) -> bool {
76        let trimmed = line.trim_start();
77        (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
78            || (trimmed.len() > 2
79                && trimmed.chars().next().unwrap().is_numeric()
80                && (trimmed.contains(". ") || trimmed.contains(") ")))
81    }
82
83    /// Check if a line is a footnote definition according to CommonMark footnote extension spec
84    ///
85    /// # Specification Compliance
86    /// Based on commonmark-hs footnote extension and GitHub's implementation:
87    /// - Format: `[^label]: content`
88    /// - Labels cannot be empty or whitespace-only
89    /// - Labels cannot contain line breaks (unlike regular link references)
90    /// - Labels typically contain alphanumerics, hyphens, underscores (though some parsers are more permissive)
91    ///
92    /// # Examples
93    /// Valid:
94    /// - `[^1]: Footnote text`
95    /// - `[^foo-bar]: Content`
96    /// - `[^test_123]: More content`
97    ///
98    /// Invalid:
99    /// - `[^]: No label`
100    /// - `[^ ]: Whitespace only`
101    /// - `[^]]: Extra bracket`
102    fn is_footnote_definition(&self, line: &str) -> bool {
103        let trimmed = line.trim_start();
104        if !trimmed.starts_with("[^") || trimmed.len() < 5 {
105            return false;
106        }
107
108        if let Some(close_bracket_pos) = trimmed.find("]:")
109            && close_bracket_pos > 2
110        {
111            let label = &trimmed[2..close_bracket_pos];
112
113            if label.trim().is_empty() {
114                return false;
115            }
116
117            // Per spec: labels cannot contain line breaks (check for \r since \n can't appear in a single line)
118            if label.contains('\r') {
119                return false;
120            }
121
122            // Validate characters per GitHub's behavior: alphanumeric, hyphens, underscores only
123            if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
124                return true;
125            }
126        }
127
128        false
129    }
130
131    /// Pre-compute which lines are in block continuation context (lists, footnotes) with a single forward pass
132    ///
133    /// # Specification-Based Context Tracking
134    /// This function implements CommonMark-style block continuation semantics:
135    ///
136    /// ## List Items
137    /// - List items can contain multiple paragraphs and blocks
138    /// - Content continues if indented appropriately
139    /// - Context ends at structural boundaries (headings, horizontal rules) or column-0 paragraphs
140    ///
141    /// ## Footnotes
142    /// Per commonmark-hs footnote extension and GitHub's implementation:
143    /// - Footnote content continues as long as it's indented
144    /// - Blank lines within footnotes don't terminate them (if next content is indented)
145    /// - Non-indented content terminates the footnote
146    /// - Similar to list items but can span more content
147    ///
148    /// # Performance
149    /// O(n) single forward pass, replacing O(n²) backward scanning
150    ///
151    /// # Returns
152    /// Boolean vector where `true` indicates the line is part of a list/footnote continuation
153    fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
154        let mut in_continuation_context = vec![false; lines.len()];
155        let mut last_list_item_line: Option<usize> = None;
156        let mut last_footnote_line: Option<usize> = None;
157        let mut blank_line_count = 0;
158
159        for (i, line) in lines.iter().enumerate() {
160            let trimmed = line.trim_start();
161            let indent_len = line.len() - trimmed.len();
162
163            // Check if this is a list item
164            if self.is_list_item(line) {
165                last_list_item_line = Some(i);
166                last_footnote_line = None; // List item ends any footnote context
167                blank_line_count = 0;
168                in_continuation_context[i] = true;
169                continue;
170            }
171
172            // Check if this is a footnote definition
173            if self.is_footnote_definition(line) {
174                last_footnote_line = Some(i);
175                last_list_item_line = None; // Footnote ends any list context
176                blank_line_count = 0;
177                in_continuation_context[i] = true;
178                continue;
179            }
180
181            // Handle empty lines
182            if line.trim().is_empty() {
183                // Blank lines within continuations are allowed
184                if last_list_item_line.is_some() || last_footnote_line.is_some() {
185                    blank_line_count += 1;
186                    in_continuation_context[i] = true;
187
188                    // Per spec: multiple consecutive blank lines might terminate context
189                    // GitHub allows multiple blank lines within footnotes if next content is indented
190                    // We'll check on the next non-blank line
191                }
192                continue;
193            }
194
195            // Non-empty line - check for structural breaks or continuation
196            if indent_len == 0 && !trimmed.is_empty() {
197                // Content at column 0 (not indented)
198
199                // Headings definitely end all contexts
200                if trimmed.starts_with('#') {
201                    last_list_item_line = None;
202                    last_footnote_line = None;
203                    blank_line_count = 0;
204                    continue;
205                }
206
207                // Horizontal rules end all contexts
208                if trimmed.starts_with("---") || trimmed.starts_with("***") {
209                    last_list_item_line = None;
210                    last_footnote_line = None;
211                    blank_line_count = 0;
212                    continue;
213                }
214
215                // Non-indented paragraph/content terminates contexts
216                // But be conservative: allow some distance for lists
217                if let Some(list_line) = last_list_item_line
218                    && (i - list_line > 5 || blank_line_count > 1)
219                {
220                    last_list_item_line = None;
221                }
222
223                // For footnotes, non-indented content always terminates
224                if last_footnote_line.is_some() {
225                    last_footnote_line = None;
226                }
227
228                blank_line_count = 0;
229
230                // If no active context, this is a regular line
231                if last_list_item_line.is_none() && last_footnote_line.is_some() {
232                    last_footnote_line = None;
233                }
234                continue;
235            }
236
237            // Indented content - part of continuation if we have active context
238            if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
239                in_continuation_context[i] = true;
240                blank_line_count = 0;
241            }
242        }
243
244        in_continuation_context
245    }
246
247    /// Check if a line is an indented code block using pre-computed context arrays
248    fn is_indented_code_block_with_context(
249        &self,
250        lines: &[&str],
251        i: usize,
252        is_mkdocs: bool,
253        ctx: &IndentContext,
254    ) -> bool {
255        if i >= lines.len() {
256            return false;
257        }
258
259        let line = lines[i];
260
261        // Check if indented by at least 4 columns (accounting for tab expansion)
262        let indent = calculate_indentation_width_default(line);
263        if indent < 4 {
264            return false;
265        }
266
267        // Check if this is part of a list structure (pre-computed)
268        if ctx.in_list_context[i] {
269            return false;
270        }
271
272        // Skip if this is MkDocs tab content (pre-computed)
273        if is_mkdocs && ctx.in_tab_context[i] {
274            return false;
275        }
276
277        // Skip if this is MkDocs admonition content (pre-computed)
278        // Admonitions are supported in MkDocs and other extended Markdown processors
279        if is_mkdocs && ctx.in_admonition_context[i] {
280            return false;
281        }
282
283        // Skip if inside an HTML/MDX comment, raw HTML block, JSX block,
284        // mkdocstrings block, footnote definition, or blockquote. These
285        // containers can legitimately hold 4+ space indented text that is
286        // not a code block. Counting them would desync style detection from
287        // the warning-side skip list in `check`.
288        if ctx.in_comment_or_html.get(i).copied().unwrap_or(false) {
289            return false;
290        }
291
292        // Check if preceded by a blank line (typical for code blocks)
293        // OR if the previous line is also an indented code block (continuation)
294        let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
295        let prev_is_indented_code = i > 0
296            && calculate_indentation_width_default(lines[i - 1]) >= 4
297            && !ctx.in_list_context[i - 1]
298            && !(is_mkdocs && ctx.in_tab_context[i - 1])
299            && !(is_mkdocs && ctx.in_admonition_context[i - 1])
300            && !ctx.in_comment_or_html.get(i - 1).copied().unwrap_or(false);
301
302        // If no blank line before and previous line is not indented code,
303        // it's likely list continuation, not a code block
304        if !has_blank_line_before && !prev_is_indented_code {
305            return false;
306        }
307
308        true
309    }
310
311    /// Pre-compute which lines sit inside a non-code container whose body may
312    /// legitimately be indented by 4+ spaces without being an indented code
313    /// block: HTML comments, raw HTML blocks, JSX blocks, MDX comments,
314    /// mkdocstrings blocks, footnote definitions, and blockquotes.
315    ///
316    /// This mirrors the skip list used in `check` when emitting indented
317    /// code-block warnings, keeping style detection and warning emission in
318    /// lockstep.
319    fn precompute_comment_or_html_context(ctx: &crate::lint_context::LintContext, line_count: usize) -> Vec<bool> {
320        (0..line_count)
321            .map(|i| {
322                ctx.line_info(i + 1).is_some_and(|info| {
323                    info.in_html_comment
324                        || info.in_mdx_comment
325                        || info.in_html_block
326                        || info.in_jsx_block
327                        || info.in_mkdocstrings
328                        || info.in_footnote_definition
329                        || info.blockquote.is_some()
330                })
331            })
332            .collect()
333    }
334
335    /// Pre-compute which lines are in MkDocs tab context with a single forward pass
336    fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
337        let mut in_tab_context = vec![false; lines.len()];
338        let mut current_tab_indent: Option<usize> = None;
339
340        for (i, line) in lines.iter().enumerate() {
341            // Check if this is a tab marker
342            if mkdocs_tabs::is_tab_marker(line) {
343                let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
344                current_tab_indent = Some(tab_indent);
345                in_tab_context[i] = true;
346                continue;
347            }
348
349            // If we have a current tab, check if this line is tab content
350            if let Some(tab_indent) = current_tab_indent {
351                if mkdocs_tabs::is_tab_content(line, tab_indent) {
352                    in_tab_context[i] = true;
353                } else if !line.trim().is_empty() && calculate_indentation_width_default(line) < 4 {
354                    // Non-indented, non-empty line ends tab context
355                    current_tab_indent = None;
356                } else {
357                    // Empty or indented line maintains tab context
358                    in_tab_context[i] = true;
359                }
360            }
361        }
362
363        in_tab_context
364    }
365
366    /// Pre-compute which lines are in MkDocs admonition context with a single forward pass
367    ///
368    /// MkDocs admonitions use `!!!` or `???` markers followed by a type, and their content
369    /// is indented by 4 spaces. This function marks all admonition markers and their
370    /// indented content as being in an admonition context, preventing them from being
371    /// incorrectly flagged as indented code blocks.
372    ///
373    /// Supports nested admonitions by maintaining a stack of active admonition contexts.
374    fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
375        let mut in_admonition_context = vec![false; lines.len()];
376        // Stack of active admonition indentation levels (supports nesting)
377        let mut admonition_stack: Vec<usize> = Vec::new();
378
379        for (i, line) in lines.iter().enumerate() {
380            let line_indent = calculate_indentation_width_default(line);
381
382            // Check if this is an admonition marker
383            if mkdocs_admonitions::is_admonition_start(line) {
384                let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
385
386                // Pop any admonitions that this one is not nested within
387                while let Some(&top_indent) = admonition_stack.last() {
388                    // New admonition must be indented more than parent to be nested
389                    if adm_indent <= top_indent {
390                        admonition_stack.pop();
391                    } else {
392                        break;
393                    }
394                }
395
396                // Push this admonition onto the stack
397                admonition_stack.push(adm_indent);
398                in_admonition_context[i] = true;
399                continue;
400            }
401
402            // Handle empty lines - they're valid within admonitions
403            if line.trim().is_empty() {
404                if !admonition_stack.is_empty() {
405                    in_admonition_context[i] = true;
406                }
407                continue;
408            }
409
410            // For non-empty lines, check if we're still in any admonition context
411            // Pop admonitions where the content indent requirement is not met
412            while let Some(&top_indent) = admonition_stack.last() {
413                // Content must be indented at least 4 spaces from the admonition marker
414                if line_indent >= top_indent + 4 {
415                    // This line is valid content for the top admonition (or one below)
416                    break;
417                } else {
418                    // Not indented enough for this admonition - pop it
419                    admonition_stack.pop();
420                }
421            }
422
423            // If we're still in any admonition context, mark this line
424            if !admonition_stack.is_empty() {
425                in_admonition_context[i] = true;
426            }
427        }
428
429        in_admonition_context
430    }
431
432    /// Categorize indented blocks for fix behavior
433    ///
434    /// Returns two vectors:
435    /// - `is_misplaced`: Lines that are part of a complete misplaced fenced block (dedent only)
436    /// - `contains_fences`: Lines that contain fence markers but aren't a complete block (skip fixing)
437    ///
438    /// A misplaced fenced block is a contiguous indented block that:
439    /// 1. Starts with a valid fence opener (``` or ~~~)
440    /// 2. Ends with a matching fence closer
441    ///
442    /// An unsafe block contains fence markers but isn't complete - wrapping would create invalid markdown.
443    fn categorize_indented_blocks(
444        &self,
445        lines: &[&str],
446        is_mkdocs: bool,
447        ictx: &IndentContext<'_>,
448    ) -> (Vec<bool>, Vec<bool>) {
449        let mut is_misplaced = vec![false; lines.len()];
450        let mut contains_fences = vec![false; lines.len()];
451
452        // Find contiguous indented blocks and categorize them
453        let mut i = 0;
454        while i < lines.len() {
455            // Find the start of an indented block
456            if !self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx) {
457                i += 1;
458                continue;
459            }
460
461            // Found start of an indented block - collect all contiguous lines
462            let block_start = i;
463            let mut block_end = i;
464
465            while block_end < lines.len() && self.is_indented_code_block_with_context(lines, block_end, is_mkdocs, ictx)
466            {
467                block_end += 1;
468            }
469
470            // Now we have an indented block from block_start to block_end (exclusive)
471            if block_end > block_start {
472                let first_line = lines[block_start].trim_start();
473                let last_line = lines[block_end - 1].trim_start();
474
475                // Check if first line is a fence opener
476                let is_backtick_fence = first_line.starts_with("```");
477                let is_tilde_fence = first_line.starts_with("~~~");
478
479                if is_backtick_fence || is_tilde_fence {
480                    let fence_char = if is_backtick_fence { '`' } else { '~' };
481                    let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
482
483                    // Check if last line is a matching fence closer
484                    let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
485                    let after_closer = &last_line[closer_fence_len..];
486
487                    if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
488                        // Complete misplaced fenced block - safe to dedent
489                        is_misplaced[block_start..block_end].fill(true);
490                    } else {
491                        // Incomplete fenced block - unsafe to wrap (would create nested fences)
492                        contains_fences[block_start..block_end].fill(true);
493                    }
494                } else {
495                    // Check if ANY line in the block contains fence markers
496                    // If so, wrapping would create invalid markdown
497                    let has_fence_markers = (block_start..block_end).any(|j| {
498                        let trimmed = lines[j].trim_start();
499                        trimmed.starts_with("```") || trimmed.starts_with("~~~")
500                    });
501
502                    if has_fence_markers {
503                        contains_fences[block_start..block_end].fill(true);
504                    }
505                }
506            }
507
508            i = block_end;
509        }
510
511        (is_misplaced, contains_fences)
512    }
513
514    fn check_unclosed_code_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
515        let mut warnings = Vec::new();
516        let lines = ctx.raw_lines();
517
518        // Check if any fenced block has a markdown/md language tag
519        let has_markdown_doc_block = ctx.code_block_details.iter().any(|d| {
520            if !d.is_fenced {
521                return false;
522            }
523            let lang = d.info_string.to_lowercase();
524            lang.starts_with("markdown") || lang.starts_with("md")
525        });
526
527        // Skip unclosed block detection if document contains markdown documentation blocks
528        // (they have nested fence examples that pulldown-cmark misparses)
529        if has_markdown_doc_block {
530            return warnings;
531        }
532
533        for detail in &ctx.code_block_details {
534            if !detail.is_fenced {
535                continue;
536            }
537
538            // Only check blocks that extend to EOF
539            if detail.end != ctx.content.len() {
540                continue;
541            }
542
543            // Find the line index for this block's start
544            let opening_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
545                Ok(idx) => idx,
546                Err(idx) => idx.saturating_sub(1),
547            };
548
549            // Determine fence marker from the actual line content
550            let line = lines.get(opening_line_idx).unwrap_or(&"");
551            let trimmed = line.trim();
552            let fence_marker = if let Some(pos) = trimmed.find("```") {
553                let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
554                "`".repeat(count)
555            } else if let Some(pos) = trimmed.find("~~~") {
556                let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
557                "~".repeat(count)
558            } else {
559                "```".to_string()
560            };
561
562            // Check if the last non-empty line is a valid closing fence
563            let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
564            let last_trimmed = last_non_empty_line.trim();
565            let fence_char = fence_marker.chars().next().unwrap_or('`');
566
567            let has_closing_fence = if fence_char == '`' {
568                last_trimmed.starts_with("```") && {
569                    let fence_len = last_trimmed.chars().take_while(|&c| c == '`').count();
570                    last_trimmed[fence_len..].trim().is_empty()
571                }
572            } else {
573                last_trimmed.starts_with("~~~") && {
574                    let fence_len = last_trimmed.chars().take_while(|&c| c == '~').count();
575                    last_trimmed[fence_len..].trim().is_empty()
576                }
577            };
578
579            if !has_closing_fence {
580                // Skip if inside HTML comment
581                if ctx
582                    .lines
583                    .get(opening_line_idx)
584                    .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
585                {
586                    continue;
587                }
588
589                let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
590
591                warnings.push(LintWarning {
592                    rule_name: Some(self.name().to_string()),
593                    line: start_line,
594                    column: start_col,
595                    end_line,
596                    end_column: end_col,
597                    message: format!("Code block opened with '{fence_marker}' but never closed"),
598                    severity: Severity::Warning,
599                    fix: Some(Fix {
600                        range: (ctx.content.len()..ctx.content.len()),
601                        replacement: format!("\n{fence_marker}"),
602                    }),
603                });
604            }
605        }
606
607        warnings
608    }
609
610    fn detect_style(&self, lines: &[&str], is_mkdocs: bool, ictx: &IndentContext) -> Option<CodeBlockStyle> {
611        if lines.is_empty() {
612            return None;
613        }
614
615        let mut fenced_count = 0;
616        let mut indented_count = 0;
617
618        // Count all code block occurrences (prevalence-based approach).
619        //
620        // Both counts must ignore fence markers and indented text that live
621        // inside a non-code container (HTML/MDX comments, raw HTML/JSX
622        // blocks, mkdocstrings, footnote definitions, blockquotes) so that
623        // the detected style stays in lockstep with the warning-side skip
624        // list in `check`. Without this, a document that contains a single
625        // real code block plus a fake fence or indented paragraph nested in
626        // a comment is wrongly classified and the real block gets flagged.
627        let mut in_fenced = false;
628        let mut prev_was_indented = false;
629
630        for (i, line) in lines.iter().enumerate() {
631            let in_container = ictx.in_comment_or_html.get(i).copied().unwrap_or(false);
632
633            if self.is_fenced_code_block_start(line) {
634                if in_container {
635                    // Fence marker inside a container — not a real fence,
636                    // don't flip state or count it.
637                    prev_was_indented = false;
638                    continue;
639                }
640                if !in_fenced {
641                    // Opening fence
642                    fenced_count += 1;
643                    in_fenced = true;
644                } else {
645                    // Closing fence
646                    in_fenced = false;
647                }
648                prev_was_indented = false;
649            } else if !in_fenced && self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx) {
650                // Count each continuous indented block once
651                if !prev_was_indented {
652                    indented_count += 1;
653                }
654                prev_was_indented = true;
655            } else {
656                prev_was_indented = false;
657            }
658        }
659
660        if fenced_count == 0 && indented_count == 0 {
661            None
662        } else if fenced_count > 0 && indented_count == 0 {
663            Some(CodeBlockStyle::Fenced)
664        } else if fenced_count == 0 && indented_count > 0 {
665            Some(CodeBlockStyle::Indented)
666        } else if fenced_count >= indented_count {
667            Some(CodeBlockStyle::Fenced)
668        } else {
669            Some(CodeBlockStyle::Indented)
670        }
671    }
672}
673
674impl Rule for MD046CodeBlockStyle {
675    fn name(&self) -> &'static str {
676        "MD046"
677    }
678
679    fn description(&self) -> &'static str {
680        "Code blocks should use a consistent style"
681    }
682
683    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
684        // Early return for empty content
685        if ctx.content.is_empty() {
686            return Ok(Vec::new());
687        }
688
689        // Quick check for code blocks before processing
690        if !ctx.content.contains("```")
691            && !ctx.content.contains("~~~")
692            && !ctx.content.contains("    ")
693            && !ctx.content.contains('\t')
694        {
695            return Ok(Vec::new());
696        }
697
698        // First, always check for unclosed code blocks
699        let unclosed_warnings = self.check_unclosed_code_blocks(ctx);
700
701        // If we found unclosed blocks, return those warnings first
702        if !unclosed_warnings.is_empty() {
703            return Ok(unclosed_warnings);
704        }
705
706        // Check for code block style consistency
707        let lines = ctx.raw_lines();
708        let mut warnings = Vec::new();
709
710        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
711
712        // Determine the target style
713        let target_style = match self.config.style {
714            CodeBlockStyle::Consistent => {
715                let in_list_context = self.precompute_block_continuation_context(lines);
716                let in_comment_or_html = Self::precompute_comment_or_html_context(ctx, lines.len());
717                let in_tab_context = if is_mkdocs {
718                    self.precompute_mkdocs_tab_context(lines)
719                } else {
720                    vec![false; lines.len()]
721                };
722                let in_admonition_context = if is_mkdocs {
723                    self.precompute_mkdocs_admonition_context(lines)
724                } else {
725                    vec![false; lines.len()]
726                };
727                let ictx = IndentContext {
728                    in_list_context: &in_list_context,
729                    in_tab_context: &in_tab_context,
730                    in_admonition_context: &in_admonition_context,
731                    in_comment_or_html: &in_comment_or_html,
732                };
733                self.detect_style(lines, is_mkdocs, &ictx)
734                    .unwrap_or(CodeBlockStyle::Fenced)
735            }
736            _ => self.config.style,
737        };
738
739        // Iterate code_block_details directly (O(k) where k is number of blocks)
740        let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
741
742        for detail in &ctx.code_block_details {
743            if detail.start >= ctx.content.len() || detail.end > ctx.content.len() {
744                continue;
745            }
746
747            let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
748                Ok(idx) => idx,
749                Err(idx) => idx.saturating_sub(1),
750            };
751
752            if detail.is_fenced {
753                if target_style == CodeBlockStyle::Indented {
754                    let line = lines.get(start_line_idx).unwrap_or(&"");
755
756                    if ctx
757                        .lines
758                        .get(start_line_idx)
759                        .is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
760                    {
761                        continue;
762                    }
763
764                    let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
765                    warnings.push(LintWarning {
766                        rule_name: Some(self.name().to_string()),
767                        line: start_line,
768                        column: start_col,
769                        end_line,
770                        end_column: end_col,
771                        message: "Use indented code blocks".to_string(),
772                        severity: Severity::Warning,
773                        fix: None,
774                    });
775                }
776            } else {
777                // Indented code block
778                if target_style == CodeBlockStyle::Fenced && !reported_indented_lines.contains(&start_line_idx) {
779                    let line = lines.get(start_line_idx).unwrap_or(&"");
780
781                    // Skip blocks in contexts that aren't real indented code blocks
782                    if ctx.lines.get(start_line_idx).is_some_and(|info| {
783                        info.in_html_comment
784                            || info.in_mdx_comment
785                            || info.in_html_block
786                            || info.in_jsx_block
787                            || info.in_mkdocstrings
788                            || info.in_footnote_definition
789                            || info.blockquote.is_some()
790                    }) {
791                        continue;
792                    }
793
794                    // Use pre-computed LineInfo for MkDocs container context
795                    if is_mkdocs
796                        && ctx
797                            .lines
798                            .get(start_line_idx)
799                            .is_some_and(|info| info.in_admonition || info.in_content_tab)
800                    {
801                        continue;
802                    }
803
804                    reported_indented_lines.insert(start_line_idx);
805
806                    let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
807                    warnings.push(LintWarning {
808                        rule_name: Some(self.name().to_string()),
809                        line: start_line,
810                        column: start_col,
811                        end_line,
812                        end_column: end_col,
813                        message: "Use fenced code blocks".to_string(),
814                        severity: Severity::Warning,
815                        fix: None,
816                    });
817                }
818            }
819        }
820
821        // Sort warnings by line number for consistent output
822        warnings.sort_by_key(|w| (w.line, w.column));
823
824        Ok(warnings)
825    }
826
827    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
828        let content = ctx.content;
829        if content.is_empty() {
830            return Ok(String::new());
831        }
832
833        let lines = ctx.raw_lines();
834
835        // Determine target style
836        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
837
838        let in_comment_or_html = Self::precompute_comment_or_html_context(ctx, lines.len());
839
840        // Pre-compute list, tab, and admonition contexts once
841        let in_list_context = self.precompute_block_continuation_context(lines);
842        let in_tab_context = if is_mkdocs {
843            self.precompute_mkdocs_tab_context(lines)
844        } else {
845            vec![false; lines.len()]
846        };
847        let in_admonition_context = if is_mkdocs {
848            self.precompute_mkdocs_admonition_context(lines)
849        } else {
850            vec![false; lines.len()]
851        };
852
853        let ictx = IndentContext {
854            in_list_context: &in_list_context,
855            in_tab_context: &in_tab_context,
856            in_admonition_context: &in_admonition_context,
857            in_comment_or_html: &in_comment_or_html,
858        };
859
860        let target_style = match self.config.style {
861            CodeBlockStyle::Consistent => self
862                .detect_style(lines, is_mkdocs, &ictx)
863                .unwrap_or(CodeBlockStyle::Fenced),
864            _ => self.config.style,
865        };
866
867        // Categorize indented blocks:
868        // - misplaced_fence_lines: complete fenced blocks that were over-indented (safe to dedent)
869        // - unsafe_fence_lines: contain fence markers but aren't complete (skip fixing to avoid broken output)
870        let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(lines, is_mkdocs, &ictx);
871
872        let mut result = String::with_capacity(content.len());
873        let mut in_fenced_block = false;
874        // Tracks the opening fence: (fence_char, opener_length).
875        // Per CommonMark spec, the closing fence must use the same character and have
876        // at least as many characters as the opener, with no info string.
877        let mut fenced_fence_opener: Option<(char, usize)> = None;
878        let mut in_indented_block = false;
879
880        // Track which code block opening lines are disabled by inline config
881        let mut current_block_disabled = false;
882
883        for (i, line) in lines.iter().enumerate() {
884            let line_num = i + 1;
885            let trimmed = line.trim_start();
886
887            // Handle fenced code blocks
888            // Per CommonMark: fence must have 0-3 spaces of indentation
889            if !in_fenced_block
890                && Self::has_valid_fence_indent(line)
891                && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
892            {
893                // Check if inline config disables this rule for the opening fence
894                current_block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
895                in_fenced_block = true;
896                let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
897                let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
898                fenced_fence_opener = Some((fence_char, opener_len));
899
900                if current_block_disabled {
901                    // Inline config disables this rule — preserve original
902                    result.push_str(line);
903                    result.push('\n');
904                } else if target_style == CodeBlockStyle::Indented {
905                    // Skip the opening fence
906                    in_indented_block = true;
907                } else {
908                    // Keep the fenced block
909                    result.push_str(line);
910                    result.push('\n');
911                }
912            } else if in_fenced_block && fenced_fence_opener.is_some() {
913                let (fence_char, opener_len) = fenced_fence_opener.unwrap();
914                // Per CommonMark: closing fence uses the same character, has at least as
915                // many characters as the opener, and has no info string (only optional trailing spaces).
916                let closer_len = trimmed.chars().take_while(|&c| c == fence_char).count();
917                let after_closer = &trimmed[closer_len..];
918                let is_closer = closer_len >= opener_len && after_closer.trim().is_empty() && closer_len > 0;
919                if is_closer {
920                    in_fenced_block = false;
921                    fenced_fence_opener = None;
922                    in_indented_block = false;
923
924                    if current_block_disabled {
925                        result.push_str(line);
926                        result.push('\n');
927                    } else if target_style == CodeBlockStyle::Indented {
928                        // Skip the closing fence
929                    } else {
930                        // Keep the fenced block
931                        result.push_str(line);
932                        result.push('\n');
933                    }
934                    current_block_disabled = false;
935                } else if current_block_disabled {
936                    // Inline config disables this rule — preserve original
937                    result.push_str(line);
938                    result.push('\n');
939                } else if target_style == CodeBlockStyle::Indented {
940                    // Convert content inside fenced block to indented
941                    // IMPORTANT: Preserve the original line content (including internal indentation)
942                    // Don't use trimmed, as that would strip internal code indentation
943                    result.push_str("    ");
944                    result.push_str(line);
945                    result.push('\n');
946                } else {
947                    // Keep fenced block content as is
948                    result.push_str(line);
949                    result.push('\n');
950                }
951            } else if self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) {
952                // This is an indented code block
953
954                // Respect inline disable comments
955                if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
956                    result.push_str(line);
957                    result.push('\n');
958                    continue;
959                }
960
961                // Check if we need to start a new fenced block
962                let prev_line_is_indented =
963                    i > 0 && self.is_indented_code_block_with_context(lines, i - 1, is_mkdocs, &ictx);
964
965                if target_style == CodeBlockStyle::Fenced {
966                    let trimmed_content = line.trim_start();
967
968                    // Check if this line is part of a misplaced fenced block
969                    // (pre-computed block-level analysis, not per-line)
970                    if misplaced_fence_lines[i] {
971                        // Just remove the indentation - this is a complete misplaced fenced block
972                        result.push_str(trimmed_content);
973                        result.push('\n');
974                    } else if unsafe_fence_lines[i] {
975                        // This block contains fence markers but isn't a complete fenced block
976                        // Wrapping would create invalid nested fences - keep as-is (don't fix)
977                        result.push_str(line);
978                        result.push('\n');
979                    } else if !prev_line_is_indented && !in_indented_block {
980                        // Start of a new indented block that should be fenced
981                        result.push_str("```\n");
982                        result.push_str(trimmed_content);
983                        result.push('\n');
984                        in_indented_block = true;
985                    } else {
986                        // Inside an indented block
987                        result.push_str(trimmed_content);
988                        result.push('\n');
989                    }
990
991                    // Check if this is the end of the indented block
992                    let next_line_is_indented =
993                        i < lines.len() - 1 && self.is_indented_code_block_with_context(lines, i + 1, is_mkdocs, &ictx);
994                    // Don't close if this is an unsafe block (kept as-is)
995                    if !next_line_is_indented
996                        && in_indented_block
997                        && !misplaced_fence_lines[i]
998                        && !unsafe_fence_lines[i]
999                    {
1000                        result.push_str("```\n");
1001                        in_indented_block = false;
1002                    }
1003                } else {
1004                    // Keep indented block as is
1005                    result.push_str(line);
1006                    result.push('\n');
1007                }
1008            } else {
1009                // Regular line
1010                if in_indented_block && target_style == CodeBlockStyle::Fenced {
1011                    result.push_str("```\n");
1012                    in_indented_block = false;
1013                }
1014
1015                result.push_str(line);
1016                result.push('\n');
1017            }
1018        }
1019
1020        // Close any remaining blocks
1021        if in_indented_block && target_style == CodeBlockStyle::Fenced {
1022            result.push_str("```\n");
1023        }
1024
1025        // Close any unclosed fenced blocks.
1026        // Only close if check() also confirms this block is unclosed. The line-by-line
1027        // fence scanner in fix() can disagree with pulldown-cmark on block boundaries
1028        // (e.g., markdown documentation blocks with nested fence examples), so we use
1029        // check_unclosed_code_blocks() as the authoritative source of truth.
1030        if let Some((fence_char, opener_len)) = fenced_fence_opener
1031            && in_fenced_block
1032        {
1033            let has_unclosed_violation = !self.check_unclosed_code_blocks(ctx).is_empty();
1034            if has_unclosed_violation {
1035                let closer: String = std::iter::repeat_n(fence_char, opener_len).collect();
1036                result.push_str(&closer);
1037                result.push('\n');
1038            }
1039        }
1040
1041        // Remove trailing newline if original didn't have one
1042        if !content.ends_with('\n') && result.ends_with('\n') {
1043            result.pop();
1044        }
1045
1046        Ok(result)
1047    }
1048
1049    /// Get the category of this rule for selective processing
1050    fn category(&self) -> RuleCategory {
1051        RuleCategory::CodeBlock
1052    }
1053
1054    /// Check if this rule should be skipped
1055    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1056        // Skip if content is empty or unlikely to contain code blocks
1057        // Note: indented code blocks use 4 spaces, can't optimize that easily
1058        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains("    "))
1059    }
1060
1061    fn as_any(&self) -> &dyn std::any::Any {
1062        self
1063    }
1064
1065    fn default_config_section(&self) -> Option<(String, toml::Value)> {
1066        let json_value = serde_json::to_value(&self.config).ok()?;
1067        Some((
1068            self.name().to_string(),
1069            crate::rule_config_serde::json_to_toml_value(&json_value)?,
1070        ))
1071    }
1072
1073    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1074    where
1075        Self: Sized,
1076    {
1077        let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1078        Box::new(Self::from_config_struct(rule_config))
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use crate::lint_context::LintContext;
1086
1087    /// Test helper: detect_style with automatic context computation.
1088    ///
1089    /// The container context (HTML/MDX comments, HTML/JSX blocks,
1090    /// mkdocstrings, footnote definitions, blockquotes) is not populated by
1091    /// this helper — callers that need to exercise those paths should go
1092    /// through the full `rule.check(&ctx)` entry point so the real LineInfo
1093    /// is computed from a `LintContext`.
1094    fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
1095        let lines: Vec<&str> = content.lines().collect();
1096        let in_list_context = rule.precompute_block_continuation_context(&lines);
1097        let in_tab_context = if is_mkdocs {
1098            rule.precompute_mkdocs_tab_context(&lines)
1099        } else {
1100            vec![false; lines.len()]
1101        };
1102        let in_admonition_context = if is_mkdocs {
1103            rule.precompute_mkdocs_admonition_context(&lines)
1104        } else {
1105            vec![false; lines.len()]
1106        };
1107        let in_comment_or_html = vec![false; lines.len()];
1108        let ictx = IndentContext {
1109            in_list_context: &in_list_context,
1110            in_tab_context: &in_tab_context,
1111            in_admonition_context: &in_admonition_context,
1112            in_comment_or_html: &in_comment_or_html,
1113        };
1114        rule.detect_style(&lines, is_mkdocs, &ictx)
1115    }
1116
1117    #[test]
1118    fn test_fenced_code_block_detection() {
1119        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1120        assert!(rule.is_fenced_code_block_start("```"));
1121        assert!(rule.is_fenced_code_block_start("```rust"));
1122        assert!(rule.is_fenced_code_block_start("~~~"));
1123        assert!(rule.is_fenced_code_block_start("~~~python"));
1124        assert!(rule.is_fenced_code_block_start("  ```"));
1125        assert!(!rule.is_fenced_code_block_start("``"));
1126        assert!(!rule.is_fenced_code_block_start("~~"));
1127        assert!(!rule.is_fenced_code_block_start("Regular text"));
1128    }
1129
1130    #[test]
1131    fn test_consistent_style_with_fenced_blocks() {
1132        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1133        let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1134        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1135        let result = rule.check(&ctx).unwrap();
1136
1137        // All blocks are fenced, so consistent style should be OK
1138        assert_eq!(result.len(), 0);
1139    }
1140
1141    #[test]
1142    fn test_consistent_style_with_indented_blocks() {
1143        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1144        let content = "Text\n\n    code\n    more code\n\nMore text\n\n    another block";
1145        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146        let result = rule.check(&ctx).unwrap();
1147
1148        // All blocks are indented, so consistent style should be OK
1149        assert_eq!(result.len(), 0);
1150    }
1151
1152    #[test]
1153    fn test_consistent_style_mixed() {
1154        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1155        let content = "```\nfenced code\n```\n\nText\n\n    indented code\n\nMore";
1156        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157        let result = rule.check(&ctx).unwrap();
1158
1159        // Mixed styles should be flagged
1160        assert!(!result.is_empty());
1161    }
1162
1163    #[test]
1164    fn test_fenced_style_with_indented_blocks() {
1165        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1166        let content = "Text\n\n    indented code\n    more code\n\nMore text";
1167        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1168        let result = rule.check(&ctx).unwrap();
1169
1170        // Indented blocks should be flagged when fenced style is required
1171        assert!(!result.is_empty());
1172        assert!(result[0].message.contains("Use fenced code blocks"));
1173    }
1174
1175    #[test]
1176    fn test_fenced_style_with_tab_indented_blocks() {
1177        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1178        let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1179        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180        let result = rule.check(&ctx).unwrap();
1181
1182        // Tab-indented blocks should also be flagged when fenced style is required
1183        assert!(!result.is_empty());
1184        assert!(result[0].message.contains("Use fenced code blocks"));
1185    }
1186
1187    #[test]
1188    fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1189        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1190        // 2 spaces + tab = 4 columns due to tab expansion (tab goes to column 4)
1191        let content = "Text\n\n  \tmixed indent code\n  \tmore code\n\nMore text";
1192        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193        let result = rule.check(&ctx).unwrap();
1194
1195        // Mixed whitespace indented blocks should also be flagged
1196        assert!(
1197            !result.is_empty(),
1198            "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1199        );
1200        assert!(result[0].message.contains("Use fenced code blocks"));
1201    }
1202
1203    #[test]
1204    fn test_fenced_style_with_one_space_tab_indent() {
1205        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1206        // 1 space + tab = 4 columns (tab expands to next tab stop at column 4)
1207        let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1208        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209        let result = rule.check(&ctx).unwrap();
1210
1211        assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1212        assert!(result[0].message.contains("Use fenced code blocks"));
1213    }
1214
1215    #[test]
1216    fn test_indented_style_with_fenced_blocks() {
1217        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1218        let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220        let result = rule.check(&ctx).unwrap();
1221
1222        // Fenced blocks should be flagged when indented style is required
1223        assert!(!result.is_empty());
1224        assert!(result[0].message.contains("Use indented code blocks"));
1225    }
1226
1227    #[test]
1228    fn test_unclosed_code_block() {
1229        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1230        let content = "```\ncode without closing fence";
1231        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1232        let result = rule.check(&ctx).unwrap();
1233
1234        assert_eq!(result.len(), 1);
1235        assert!(result[0].message.contains("never closed"));
1236    }
1237
1238    #[test]
1239    fn test_nested_code_blocks() {
1240        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1241        let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1242        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1243        let result = rule.check(&ctx).unwrap();
1244
1245        // This should parse as two separate code blocks
1246        assert_eq!(result.len(), 0);
1247    }
1248
1249    #[test]
1250    fn test_fix_indented_to_fenced() {
1251        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1252        let content = "Text\n\n    code line 1\n    code line 2\n\nMore text";
1253        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254        let fixed = rule.fix(&ctx).unwrap();
1255
1256        assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1257    }
1258
1259    #[test]
1260    fn test_fix_fenced_to_indented() {
1261        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1262        let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1263        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264        let fixed = rule.fix(&ctx).unwrap();
1265
1266        assert!(fixed.contains("    code line 1\n    code line 2"));
1267        assert!(!fixed.contains("```"));
1268    }
1269
1270    #[test]
1271    fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1272        // Issue #270: When converting fenced code to indented, internal indentation must be preserved
1273        // HTML templates, Python, etc. rely on proper indentation
1274        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1275        let content = r#"# Test
1276
1277```html
1278<!doctype html>
1279<html>
1280  <head>
1281    <title>Test</title>
1282  </head>
1283</html>
1284```
1285"#;
1286        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287        let fixed = rule.fix(&ctx).unwrap();
1288
1289        // The internal indentation (2 spaces for <head>, 4 for <title>) must be preserved
1290        // Each line gets 4 spaces prepended for the indented code block
1291        assert!(
1292            fixed.contains("      <head>"),
1293            "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1294        );
1295        assert!(
1296            fixed.contains("        <title>"),
1297            "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1298        );
1299        assert!(!fixed.contains("```"), "Fenced markers should be removed");
1300    }
1301
1302    #[test]
1303    fn test_fix_fenced_to_indented_preserves_python_indentation() {
1304        // Issue #270: Python is indentation-sensitive - must preserve internal structure
1305        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1306        let content = r#"# Python Example
1307
1308```python
1309def greet(name):
1310    if name:
1311        print(f"Hello, {name}!")
1312    else:
1313        print("Hello, World!")
1314```
1315"#;
1316        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1317        let fixed = rule.fix(&ctx).unwrap();
1318
1319        // Python indentation must be preserved exactly
1320        assert!(
1321            fixed.contains("    def greet(name):"),
1322            "Function def should have 4 spaces (code block indent)"
1323        );
1324        assert!(
1325            fixed.contains("        if name:"),
1326            "if statement should have 8 spaces (4 code + 4 Python)"
1327        );
1328        assert!(
1329            fixed.contains("            print"),
1330            "print should have 12 spaces (4 code + 8 Python)"
1331        );
1332    }
1333
1334    #[test]
1335    fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1336        // Issue #270: YAML is also indentation-sensitive
1337        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1338        let content = r#"# Config
1339
1340```yaml
1341server:
1342  host: localhost
1343  port: 8080
1344  ssl:
1345    enabled: true
1346    cert: /path/to/cert
1347```
1348"#;
1349        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1350        let fixed = rule.fix(&ctx).unwrap();
1351
1352        assert!(fixed.contains("    server:"), "Root key should have 4 spaces");
1353        assert!(fixed.contains("      host:"), "First level should have 6 spaces");
1354        assert!(fixed.contains("      ssl:"), "ssl key should have 6 spaces");
1355        assert!(fixed.contains("        enabled:"), "Nested ssl should have 8 spaces");
1356    }
1357
1358    #[test]
1359    fn test_fix_fenced_to_indented_preserves_empty_lines() {
1360        // Empty lines within code blocks should also get the 4-space prefix
1361        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1362        let content = "```\nline1\n\nline2\n```\n";
1363        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364        let fixed = rule.fix(&ctx).unwrap();
1365
1366        // The fixed content should have proper structure
1367        assert!(fixed.contains("    line1"), "line1 should be indented");
1368        assert!(fixed.contains("    line2"), "line2 should be indented");
1369        // Empty line between them is preserved (may or may not have spaces)
1370    }
1371
1372    #[test]
1373    fn test_fix_fenced_to_indented_multiple_blocks() {
1374        // Multiple fenced blocks should all preserve their indentation
1375        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1376        let content = r#"# Doc
1377
1378```python
1379def foo():
1380    pass
1381```
1382
1383Text between.
1384
1385```yaml
1386key:
1387  value: 1
1388```
1389"#;
1390        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391        let fixed = rule.fix(&ctx).unwrap();
1392
1393        assert!(fixed.contains("    def foo():"), "Python def should be indented");
1394        assert!(fixed.contains("        pass"), "Python body should have 8 spaces");
1395        assert!(fixed.contains("    key:"), "YAML root should have 4 spaces");
1396        assert!(fixed.contains("      value:"), "YAML nested should have 6 spaces");
1397        assert!(!fixed.contains("```"), "No fence markers should remain");
1398    }
1399
1400    #[test]
1401    fn test_fix_unclosed_block() {
1402        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1403        let content = "```\ncode without closing";
1404        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1405        let fixed = rule.fix(&ctx).unwrap();
1406
1407        // Should add closing fence
1408        assert!(fixed.ends_with("```"));
1409    }
1410
1411    #[test]
1412    fn test_code_block_in_list() {
1413        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1414        let content = "- List item\n    code in list\n    more code\n- Next item";
1415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416        let result = rule.check(&ctx).unwrap();
1417
1418        // Code in lists should not be flagged
1419        assert_eq!(result.len(), 0);
1420    }
1421
1422    #[test]
1423    fn test_detect_style_fenced() {
1424        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1425        let content = "```\ncode\n```";
1426        let style = detect_style_from_content(&rule, content, false);
1427
1428        assert_eq!(style, Some(CodeBlockStyle::Fenced));
1429    }
1430
1431    #[test]
1432    fn test_detect_style_indented() {
1433        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1434        let content = "Text\n\n    code\n\nMore";
1435        let style = detect_style_from_content(&rule, content, false);
1436
1437        assert_eq!(style, Some(CodeBlockStyle::Indented));
1438    }
1439
1440    #[test]
1441    fn test_detect_style_none() {
1442        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1443        let content = "No code blocks here";
1444        let style = detect_style_from_content(&rule, content, false);
1445
1446        assert_eq!(style, None);
1447    }
1448
1449    #[test]
1450    fn test_tilde_fence() {
1451        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1452        let content = "~~~\ncode\n~~~";
1453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1454        let result = rule.check(&ctx).unwrap();
1455
1456        // Tilde fences should be accepted as fenced blocks
1457        assert_eq!(result.len(), 0);
1458    }
1459
1460    #[test]
1461    fn test_language_specification() {
1462        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1463        let content = "```rust\nfn main() {}\n```";
1464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1465        let result = rule.check(&ctx).unwrap();
1466
1467        assert_eq!(result.len(), 0);
1468    }
1469
1470    #[test]
1471    fn test_empty_content() {
1472        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1473        let content = "";
1474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475        let result = rule.check(&ctx).unwrap();
1476
1477        assert_eq!(result.len(), 0);
1478    }
1479
1480    #[test]
1481    fn test_default_config() {
1482        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1483        let (name, _config) = rule.default_config_section().unwrap();
1484        assert_eq!(name, "MD046");
1485    }
1486
1487    #[test]
1488    fn test_markdown_documentation_block() {
1489        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1490        let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1491        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1492        let result = rule.check(&ctx).unwrap();
1493
1494        // Nested code blocks in markdown documentation should be allowed
1495        assert_eq!(result.len(), 0);
1496    }
1497
1498    #[test]
1499    fn test_preserve_trailing_newline() {
1500        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1501        let content = "```\ncode\n```\n";
1502        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1503        let fixed = rule.fix(&ctx).unwrap();
1504
1505        assert_eq!(fixed, content);
1506    }
1507
1508    #[test]
1509    fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1510        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1511        let content = r#"# Document
1512
1513=== "Python"
1514
1515    This is tab content
1516    Not an indented code block
1517
1518    ```python
1519    def hello():
1520        print("Hello")
1521    ```
1522
1523=== "JavaScript"
1524
1525    More tab content here
1526    Also not an indented code block"#;
1527
1528        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1529        let result = rule.check(&ctx).unwrap();
1530
1531        // Should not flag tab content as indented code blocks
1532        assert_eq!(result.len(), 0);
1533    }
1534
1535    #[test]
1536    fn test_mkdocs_tabs_with_actual_indented_code() {
1537        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1538        let content = r#"# Document
1539
1540=== "Tab 1"
1541
1542    This is tab content
1543
1544Regular text
1545
1546    This is an actual indented code block
1547    Should be flagged"#;
1548
1549        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1550        let result = rule.check(&ctx).unwrap();
1551
1552        // Should flag the actual indented code block but not the tab content
1553        assert_eq!(result.len(), 1);
1554        assert!(result[0].message.contains("Use fenced code blocks"));
1555    }
1556
1557    #[test]
1558    fn test_mkdocs_tabs_detect_style() {
1559        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1560        let content = r#"=== "Tab 1"
1561
1562    Content in tab
1563    More content
1564
1565=== "Tab 2"
1566
1567    Content in second tab"#;
1568
1569        // In MkDocs mode, tab content should not be detected as indented code blocks
1570        let style = detect_style_from_content(&rule, content, true);
1571        assert_eq!(style, None); // No code blocks detected
1572
1573        // In standard mode, it would detect indented code blocks
1574        let style = detect_style_from_content(&rule, content, false);
1575        assert_eq!(style, Some(CodeBlockStyle::Indented));
1576    }
1577
1578    #[test]
1579    fn test_mkdocs_nested_tabs() {
1580        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1581        let content = r#"# Document
1582
1583=== "Outer Tab"
1584
1585    Some content
1586
1587    === "Nested Tab"
1588
1589        Nested tab content
1590        Should not be flagged"#;
1591
1592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1593        let result = rule.check(&ctx).unwrap();
1594
1595        // Nested tabs should not be flagged
1596        assert_eq!(result.len(), 0);
1597    }
1598
1599    #[test]
1600    fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1601        // Issue #269: MkDocs admonitions have indented bodies that should NOT be
1602        // treated as indented code blocks when style = "fenced"
1603        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1604        let content = r#"# Document
1605
1606!!! note
1607    This is normal admonition content, not a code block.
1608    It spans multiple lines.
1609
1610??? warning "Collapsible Warning"
1611    This is also admonition content.
1612
1613???+ tip "Expanded Tip"
1614    And this one too.
1615
1616Regular text outside admonitions."#;
1617
1618        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1619        let result = rule.check(&ctx).unwrap();
1620
1621        // Admonition content should not be flagged
1622        assert_eq!(
1623            result.len(),
1624            0,
1625            "Admonition content in MkDocs mode should not trigger MD046"
1626        );
1627    }
1628
1629    #[test]
1630    fn test_mkdocs_admonition_with_actual_indented_code() {
1631        // After an admonition ends, regular indented code blocks SHOULD be flagged
1632        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1633        let content = r#"# Document
1634
1635!!! note
1636    This is admonition content.
1637
1638Regular text ends the admonition.
1639
1640    This is actual indented code (should be flagged)"#;
1641
1642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1643        let result = rule.check(&ctx).unwrap();
1644
1645        // Should only flag the actual indented code block
1646        assert_eq!(result.len(), 1);
1647        assert!(result[0].message.contains("Use fenced code blocks"));
1648    }
1649
1650    #[test]
1651    fn test_admonition_in_standard_mode_flagged() {
1652        // In standard Markdown mode, admonitions are not recognized, so the
1653        // indented content should be flagged as indented code
1654        // Note: A blank line is required before indented code blocks per CommonMark
1655        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1656        let content = r#"# Document
1657
1658!!! note
1659
1660    This looks like code in standard mode.
1661
1662Regular text."#;
1663
1664        // In Standard mode, admonitions are not recognized
1665        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1666        let result = rule.check(&ctx).unwrap();
1667
1668        // The indented content should be flagged in standard mode
1669        assert_eq!(
1670            result.len(),
1671            1,
1672            "Admonition content in Standard mode should be flagged as indented code"
1673        );
1674    }
1675
1676    #[test]
1677    fn test_mkdocs_admonition_with_fenced_code_inside() {
1678        // Issue #269: Admonitions can contain fenced code blocks - must handle correctly
1679        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1680        let content = r#"# Document
1681
1682!!! note "Code Example"
1683    Here's some code:
1684
1685    ```python
1686    def hello():
1687        print("world")
1688    ```
1689
1690    More text after code.
1691
1692Regular text."#;
1693
1694        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1695        let result = rule.check(&ctx).unwrap();
1696
1697        // Should not flag anything - the fenced block inside admonition is valid
1698        assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1699    }
1700
1701    #[test]
1702    fn test_mkdocs_nested_admonitions() {
1703        // Nested admonitions are valid MkDocs syntax
1704        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1705        let content = r#"# Document
1706
1707!!! note "Outer"
1708    Outer content.
1709
1710    !!! warning "Inner"
1711        Inner content.
1712        More inner content.
1713
1714    Back to outer.
1715
1716Regular text."#;
1717
1718        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1719        let result = rule.check(&ctx).unwrap();
1720
1721        // Nested admonitions should not trigger MD046
1722        assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1723    }
1724
1725    #[test]
1726    fn test_mkdocs_admonition_fix_does_not_wrap() {
1727        // The fix function should not wrap admonition content in fences
1728        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1729        let content = r#"!!! note
1730    Content that should stay as admonition content.
1731    Not be wrapped in code fences.
1732"#;
1733
1734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1735        let fixed = rule.fix(&ctx).unwrap();
1736
1737        // Fix should not add fence markers to admonition content
1738        assert!(
1739            !fixed.contains("```\n    Content"),
1740            "Admonition content should not be wrapped in fences"
1741        );
1742        assert_eq!(fixed, content, "Content should remain unchanged");
1743    }
1744
1745    #[test]
1746    fn test_mkdocs_empty_admonition() {
1747        // Empty admonitions (marker only) should not cause issues
1748        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1749        let content = r#"!!! note
1750
1751Regular paragraph after empty admonition.
1752
1753    This IS an indented code block (after blank + non-indented line)."#;
1754
1755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1756        let result = rule.check(&ctx).unwrap();
1757
1758        // The indented code block after the paragraph should be flagged
1759        assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1760    }
1761
1762    #[test]
1763    fn test_mkdocs_indented_admonition() {
1764        // Admonitions can themselves be indented (e.g., inside list items)
1765        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1766        let content = r#"- List item
1767
1768    !!! note
1769        Indented admonition content.
1770        More content.
1771
1772- Next item"#;
1773
1774        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1775        let result = rule.check(&ctx).unwrap();
1776
1777        // Admonition inside list should not be flagged
1778        assert_eq!(
1779            result.len(),
1780            0,
1781            "Indented admonitions (e.g., in lists) should not be flagged"
1782        );
1783    }
1784
1785    #[test]
1786    fn test_footnote_indented_paragraphs_not_flagged() {
1787        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1788        let content = r#"# Test Document with Footnotes
1789
1790This is some text with a footnote[^1].
1791
1792Here's some code:
1793
1794```bash
1795echo "fenced code block"
1796```
1797
1798More text with another footnote[^2].
1799
1800[^1]: Really interesting footnote text.
1801
1802    Even more interesting second paragraph.
1803
1804[^2]: Another footnote.
1805
1806    With a second paragraph too.
1807
1808    And even a third paragraph!"#;
1809
1810        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1811        let result = rule.check(&ctx).unwrap();
1812
1813        // Indented paragraphs in footnotes should not be flagged as code blocks
1814        assert_eq!(result.len(), 0);
1815    }
1816
1817    #[test]
1818    fn test_footnote_definition_detection() {
1819        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1820
1821        // Valid footnote definitions (per CommonMark footnote extension spec)
1822        // Reference: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/footnotes.md
1823        assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1824        assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1825        assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1826        assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1827        assert!(rule.is_footnote_definition("    [^1]: Indented footnote"));
1828        assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1829        assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1830        assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1831        assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1832
1833        // Invalid: empty or whitespace-only labels (spec violation)
1834        assert!(!rule.is_footnote_definition("[^]: No label"));
1835        assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1836        assert!(!rule.is_footnote_definition("[^  ]: Multiple spaces"));
1837        assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1838
1839        // Invalid: malformed syntax
1840        assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1841        assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1842        assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1843        assert!(!rule.is_footnote_definition("[^")); // Too short
1844        assert!(!rule.is_footnote_definition("[^1:")); // Missing closing bracket
1845        assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1846
1847        // Invalid: disallowed characters in label
1848        assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1849        assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1850        assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1851        assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1852        assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1853
1854        // Edge case: line breaks not allowed in labels
1855        // (This is a string test, actual multiline would need different testing)
1856        assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1857    }
1858
1859    #[test]
1860    fn test_footnote_with_blank_lines() {
1861        // Spec requirement: blank lines within footnotes don't terminate them
1862        // if next content is indented (matches GitHub's implementation)
1863        // Reference: commonmark-hs footnote extension behavior
1864        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1865        let content = r#"# Document
1866
1867Text with footnote[^1].
1868
1869[^1]: First paragraph.
1870
1871    Second paragraph after blank line.
1872
1873    Third paragraph after another blank line.
1874
1875Regular text at column 0 ends the footnote."#;
1876
1877        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1878        let result = rule.check(&ctx).unwrap();
1879
1880        // The indented paragraphs in the footnote should not be flagged as code blocks
1881        assert_eq!(
1882            result.len(),
1883            0,
1884            "Indented content within footnotes should not trigger MD046"
1885        );
1886    }
1887
1888    #[test]
1889    fn test_footnote_multiple_consecutive_blank_lines() {
1890        // Edge case: multiple consecutive blank lines within a footnote
1891        // Should still work if next content is indented
1892        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1893        let content = r#"Text[^1].
1894
1895[^1]: First paragraph.
1896
1897
1898
1899    Content after three blank lines (still part of footnote).
1900
1901Not indented, so footnote ends here."#;
1902
1903        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1904        let result = rule.check(&ctx).unwrap();
1905
1906        // The indented content should not be flagged
1907        assert_eq!(
1908            result.len(),
1909            0,
1910            "Multiple blank lines shouldn't break footnote continuation"
1911        );
1912    }
1913
1914    #[test]
1915    fn test_footnote_terminated_by_non_indented_content() {
1916        // Spec requirement: non-indented content always terminates the footnote
1917        // Reference: commonmark-hs footnote extension
1918        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1919        let content = r#"[^1]: Footnote content.
1920
1921    More indented content in footnote.
1922
1923This paragraph is not indented, so footnote ends.
1924
1925    This should be flagged as indented code block."#;
1926
1927        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1928        let result = rule.check(&ctx).unwrap();
1929
1930        // The last indented block should be flagged (it's after the footnote ended)
1931        assert_eq!(
1932            result.len(),
1933            1,
1934            "Indented code after footnote termination should be flagged"
1935        );
1936        assert!(
1937            result[0].message.contains("Use fenced code blocks"),
1938            "Expected MD046 warning for indented code block"
1939        );
1940        assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1941    }
1942
1943    #[test]
1944    fn test_footnote_terminated_by_structural_elements() {
1945        // Spec requirement: headings and horizontal rules terminate footnotes
1946        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1947        let content = r#"[^1]: Footnote content.
1948
1949    More content.
1950
1951## Heading terminates footnote
1952
1953    This indented content should be flagged.
1954
1955---
1956
1957    This should also be flagged (after horizontal rule)."#;
1958
1959        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1960        let result = rule.check(&ctx).unwrap();
1961
1962        // Both indented blocks after structural elements should be flagged
1963        assert_eq!(
1964            result.len(),
1965            2,
1966            "Both indented blocks after termination should be flagged"
1967        );
1968    }
1969
1970    #[test]
1971    fn test_footnote_with_code_block_inside() {
1972        // Spec behavior: footnotes can contain fenced code blocks
1973        // The fenced code must be properly indented within the footnote
1974        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1975        let content = r#"Text[^1].
1976
1977[^1]: Footnote with code:
1978
1979    ```python
1980    def hello():
1981        print("world")
1982    ```
1983
1984    More footnote text after code."#;
1985
1986        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1987        let result = rule.check(&ctx).unwrap();
1988
1989        // Should have no warnings - the fenced code block is valid
1990        assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1991    }
1992
1993    #[test]
1994    fn test_footnote_with_8_space_indented_code() {
1995        // Edge case: code blocks within footnotes need 8 spaces (4 for footnote + 4 for code)
1996        // This should NOT be flagged as it's properly nested indented code
1997        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1998        let content = r#"Text[^1].
1999
2000[^1]: Footnote with nested code.
2001
2002        code block
2003        more code"#;
2004
2005        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2006        let result = rule.check(&ctx).unwrap();
2007
2008        // The 8-space indented code is valid within footnote
2009        assert_eq!(
2010            result.len(),
2011            0,
2012            "8-space indented code within footnotes represents nested code blocks"
2013        );
2014    }
2015
2016    #[test]
2017    fn test_multiple_footnotes() {
2018        // Spec behavior: each footnote definition starts a new block context
2019        // Previous footnote ends when new footnote begins
2020        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2021        let content = r#"Text[^1] and more[^2].
2022
2023[^1]: First footnote.
2024
2025    Continuation of first.
2026
2027[^2]: Second footnote starts here, ending the first.
2028
2029    Continuation of second."#;
2030
2031        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2032        let result = rule.check(&ctx).unwrap();
2033
2034        // All indented content is part of footnotes
2035        assert_eq!(
2036            result.len(),
2037            0,
2038            "Multiple footnotes should each maintain their continuation context"
2039        );
2040    }
2041
2042    #[test]
2043    fn test_list_item_ends_footnote_context() {
2044        // Spec behavior: list items and footnotes are mutually exclusive contexts
2045        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2046        let content = r#"[^1]: Footnote.
2047
2048    Content in footnote.
2049
2050- List item starts here (ends footnote context).
2051
2052    This indented content is part of the list, not the footnote."#;
2053
2054        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2055        let result = rule.check(&ctx).unwrap();
2056
2057        // List continuation should not be flagged
2058        assert_eq!(
2059            result.len(),
2060            0,
2061            "List items should end footnote context and start their own"
2062        );
2063    }
2064
2065    #[test]
2066    fn test_footnote_vs_actual_indented_code() {
2067        // Critical test: verify we can still detect actual indented code blocks outside footnotes
2068        // This ensures the fix doesn't cause false negatives
2069        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2070        let content = r#"# Heading
2071
2072Text with footnote[^1].
2073
2074[^1]: Footnote content.
2075
2076    Part of footnote (should not be flagged).
2077
2078Regular paragraph ends footnote context.
2079
2080    This is actual indented code (MUST be flagged)
2081    Should be detected as code block"#;
2082
2083        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2084        let result = rule.check(&ctx).unwrap();
2085
2086        // Should flag the indented code after the regular paragraph
2087        assert_eq!(
2088            result.len(),
2089            1,
2090            "Must still detect indented code blocks outside footnotes"
2091        );
2092        assert!(
2093            result[0].message.contains("Use fenced code blocks"),
2094            "Expected MD046 warning for indented code"
2095        );
2096        assert!(
2097            result[0].line >= 11,
2098            "Warning should be on the actual indented code line"
2099        );
2100    }
2101
2102    #[test]
2103    fn test_spec_compliant_label_characters() {
2104        // Spec requirement: labels must contain only alphanumerics, hyphens, underscores
2105        // Reference: commonmark-hs footnote extension
2106        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2107
2108        // Valid according to spec
2109        assert!(rule.is_footnote_definition("[^test]: text"));
2110        assert!(rule.is_footnote_definition("[^TEST]: text"));
2111        assert!(rule.is_footnote_definition("[^test-name]: text"));
2112        assert!(rule.is_footnote_definition("[^test_name]: text"));
2113        assert!(rule.is_footnote_definition("[^test123]: text"));
2114        assert!(rule.is_footnote_definition("[^123]: text"));
2115        assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2116
2117        // Invalid characters (spec violations)
2118        assert!(!rule.is_footnote_definition("[^test.name]: text")); // Period
2119        assert!(!rule.is_footnote_definition("[^test name]: text")); // Space
2120        assert!(!rule.is_footnote_definition("[^test@name]: text")); // At sign
2121        assert!(!rule.is_footnote_definition("[^test#name]: text")); // Hash
2122        assert!(!rule.is_footnote_definition("[^test$name]: text")); // Dollar
2123        assert!(!rule.is_footnote_definition("[^test%name]: text")); // Percent
2124    }
2125
2126    #[test]
2127    fn test_code_block_inside_html_comment() {
2128        // Regression test: code blocks inside HTML comments should not be flagged
2129        // Found in denoland/deno test fixture during sanity testing
2130        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2131        let content = r#"# Document
2132
2133Some text.
2134
2135<!--
2136Example code block in comment:
2137
2138```typescript
2139console.log("Hello");
2140```
2141
2142More comment text.
2143-->
2144
2145More content."#;
2146
2147        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2148        let result = rule.check(&ctx).unwrap();
2149
2150        assert_eq!(
2151            result.len(),
2152            0,
2153            "Code blocks inside HTML comments should not be flagged as unclosed"
2154        );
2155    }
2156
2157    #[test]
2158    fn test_unclosed_fence_inside_html_comment() {
2159        // Even an unclosed fence inside an HTML comment should be ignored
2160        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2161        let content = r#"# Document
2162
2163<!--
2164Example with intentionally unclosed fence:
2165
2166```
2167code without closing
2168-->
2169
2170More content."#;
2171
2172        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2173        let result = rule.check(&ctx).unwrap();
2174
2175        assert_eq!(
2176            result.len(),
2177            0,
2178            "Unclosed fences inside HTML comments should be ignored"
2179        );
2180    }
2181
2182    #[test]
2183    fn test_multiline_html_comment_with_indented_code() {
2184        // Indented code inside HTML comments should also be ignored
2185        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2186        let content = r#"# Document
2187
2188<!--
2189Example:
2190
2191    indented code
2192    more code
2193
2194End of comment.
2195-->
2196
2197Regular text."#;
2198
2199        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2200        let result = rule.check(&ctx).unwrap();
2201
2202        assert_eq!(
2203            result.len(),
2204            0,
2205            "Indented code inside HTML comments should not be flagged"
2206        );
2207    }
2208
2209    #[test]
2210    fn test_code_block_after_html_comment() {
2211        // Code blocks after HTML comments should still be detected
2212        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2213        let content = r#"# Document
2214
2215<!-- comment -->
2216
2217Text before.
2218
2219    indented code should be flagged
2220
2221More text."#;
2222
2223        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2224        let result = rule.check(&ctx).unwrap();
2225
2226        assert_eq!(
2227            result.len(),
2228            1,
2229            "Code blocks after HTML comments should still be detected"
2230        );
2231        assert!(result[0].message.contains("Use fenced code blocks"));
2232    }
2233
2234    #[test]
2235    fn test_consistent_style_indented_html_comment() {
2236        // Under the default `Consistent` style, indented content inside an
2237        // HTML comment must not contribute to the document's code-block style
2238        // tally. Otherwise a single fenced block alongside an indented HTML
2239        // comment flips the detected style to `Indented`, emitting a spurious
2240        // "Use indented code blocks" warning against the only real code block.
2241        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2242        let content = "# MD046 false-positive reproduction\n\
2243                       \n\
2244                       <!--\n    \
2245                       This is just an indented comment, not a code block.\n\
2246                       \n    \
2247                       A second line is required to trigger the false-positive.\n\
2248                       \n    \
2249                       Actually, three lines are required.\n\
2250                       -->\n\
2251                       \n\
2252                       ```md\n\
2253                       This should be fine, since it's the only code block and therefore consistent.\n\
2254                       ```\n";
2255
2256        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2257        let result = rule.check(&ctx).unwrap();
2258
2259        assert_eq!(
2260            result,
2261            vec![],
2262            "A single fenced block and an indented HTML comment must produce no MD046 warnings",
2263        );
2264    }
2265
2266    #[test]
2267    fn test_consistent_style_indented_html_block() {
2268        // Indented content inside a raw HTML block (e.g. a `<div>` tag pair)
2269        // must not count as an indented code block when `detect_style` picks
2270        // the document's predominant style.
2271        //
2272        // Per CommonMark, a type-6 HTML block is terminated by a blank line,
2273        // so the content here is kept contiguous to remain inside the block.
2274        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2275        let content = "# Heading\n\
2276                       \n\
2277                       <div class=\"note\">\n    \
2278                       line one of indented html content\n    \
2279                       line two of indented html content\n    \
2280                       line three of indented html content\n\
2281                       </div>\n\
2282                       \n\
2283                       ```md\n\
2284                       real fenced block\n\
2285                       ```\n";
2286
2287        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2288        let result = rule.check(&ctx).unwrap();
2289
2290        assert_eq!(
2291            result,
2292            vec![],
2293            "Indented content inside a raw HTML block must not influence MD046 style detection",
2294        );
2295    }
2296
2297    #[test]
2298    fn test_consistent_style_fake_fence_inside_html_comment() {
2299        // Fence markers inside an HTML comment must not contribute to the
2300        // fenced count during style detection. Otherwise a document whose
2301        // only real code block is indented gets flagged "Use fenced code
2302        // blocks" under `Consistent` because the verbatim ``` inside the
2303        // comment ties the count.
2304        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2305        let content = "# Title\n\
2306                       \n\
2307                       <!--\n\
2308                       ```\n\
2309                       fake fence inside comment\n\
2310                       ```\n\
2311                       -->\n\
2312                       \n    \
2313                       real indented code block line 1\n    \
2314                       real indented code block line 2\n";
2315
2316        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2317        let result = rule.check(&ctx).unwrap();
2318
2319        assert_eq!(
2320            result,
2321            vec![],
2322            "Fence markers inside an HTML comment must not influence MD046 style detection",
2323        );
2324    }
2325
2326    #[test]
2327    fn test_consistent_style_indented_footnote_definition() {
2328        // Footnote-definition continuation lines are commonly indented by 4+
2329        // spaces. They must not be counted as indented code blocks during
2330        // style detection under `Consistent`.
2331        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2332        let content = "# Heading\n\
2333                       \n\
2334                       Reference to a footnote[^note].\n\
2335                       \n\
2336                       [^note]: First line of the footnote.\n    \
2337                       Second indented continuation line.\n    \
2338                       Third indented continuation line.\n    \
2339                       Fourth indented continuation line.\n\
2340                       \n\
2341                       ```md\n\
2342                       real fenced block\n\
2343                       ```\n";
2344
2345        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2346        let result = rule.check(&ctx).unwrap();
2347
2348        assert_eq!(
2349            result,
2350            vec![],
2351            "Footnote-definition continuation content must not influence MD046 style detection",
2352        );
2353    }
2354
2355    #[test]
2356    fn test_consistent_style_indented_blockquote() {
2357        // Indented content inside a blockquote (`>     foo`) must not be
2358        // counted as an indented code block by `detect_style`. The check-side
2359        // skip list already excludes `blockquote.is_some()` for indented
2360        // warnings, so detection must match to keep `Consistent` stable.
2361        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2362        let content = "# Heading\n\
2363                       \n\
2364                       >     line one of quoted indented content\n\
2365                       >\n\
2366                       >     line two of quoted indented content\n\
2367                       >\n\
2368                       >     line three of quoted indented content\n\
2369                       \n\
2370                       ```md\n\
2371                       real fenced block\n\
2372                       ```\n";
2373
2374        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2375        let result = rule.check(&ctx).unwrap();
2376
2377        assert_eq!(
2378            result,
2379            vec![],
2380            "Indented content inside a blockquote must not influence MD046 style detection",
2381        );
2382    }
2383
2384    #[test]
2385    fn test_consistent_style_genuine_indented_block_detected_as_indented() {
2386        // A top-level indented code block that is not inside any container
2387        // must still count toward the Indented tally under `Consistent` style.
2388        // This guards against over-filtering: the `in_comment_or_html` skip
2389        // must not suppress real indented code blocks.
2390        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2391        let content = "# Heading\n\
2392                       \n\
2393                       Some prose.\n\
2394                       \n    \
2395                       real indented code line 1\n    \
2396                       real indented code line 2\n";
2397
2398        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2399        let result = rule.check(&ctx).unwrap();
2400
2401        // Only one indented block exists; Consistent must detect it as Indented and
2402        // produce no warnings (the detected style matches the only real block).
2403        assert_eq!(
2404            result,
2405            vec![],
2406            "A genuine top-level indented block must be detected as Indented style under Consistent",
2407        );
2408    }
2409
2410    #[test]
2411    fn test_consistent_style_skipped_lines_dont_override_real_block() {
2412        // Two indented-but-skipped regions (inside HTML comments) plus one
2413        // genuine indented code block and no fenced blocks: the skipped lines
2414        // must be excluded from the tally, leaving indented_count=1, fenced_count=0,
2415        // so Consistent still selects Indented and emits no warnings.
2416        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2417        let content = "# Heading\n\
2418                       \n\
2419                       <!--\n    \
2420                       skipped indented comment line 1\n    \
2421                       skipped indented comment line 2\n\
2422                       -->\n\
2423                       \n\
2424                       <!--\n    \
2425                       second skipped region\n    \
2426                       also skipped\n\
2427                       -->\n\
2428                       \n    \
2429                       real indented code line\n";
2430
2431        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2432        let result = rule.check(&ctx).unwrap();
2433
2434        assert_eq!(
2435            result,
2436            vec![],
2437            "Skipped container lines must not outweigh the single real indented block",
2438        );
2439    }
2440
2441    #[test]
2442    fn test_consistent_style_fenced_wins_over_skipped_indented() {
2443        // One real fenced block plus two indented-but-skipped regions: after
2444        // filtering the skipped lines the tally is fenced=1, indented=0, so
2445        // Consistent selects Fenced and emits no warnings.
2446        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2447        let content = "# Heading\n\
2448                       \n\
2449                       <!--\n    \
2450                       skipped indented region one\n    \
2451                       more of region one\n\
2452                       -->\n\
2453                       \n\
2454                       <!--\n    \
2455                       skipped indented region two\n    \
2456                       more of region two\n\
2457                       -->\n\
2458                       \n\
2459                       ```md\n\
2460                       real fenced block\n\
2461                       ```\n";
2462
2463        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2464        let result = rule.check(&ctx).unwrap();
2465
2466        assert_eq!(
2467            result,
2468            vec![],
2469            "Fenced block must win when all indented lines are inside skipped containers",
2470        );
2471    }
2472
2473    #[test]
2474    fn test_four_space_indented_fence_is_not_valid_fence() {
2475        // Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces."
2476        // 4+ spaces means it's NOT a valid fence opener - it becomes an indented code block
2477        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2478
2479        // Valid fences (0-3 spaces)
2480        assert!(rule.is_fenced_code_block_start("```"));
2481        assert!(rule.is_fenced_code_block_start(" ```"));
2482        assert!(rule.is_fenced_code_block_start("  ```"));
2483        assert!(rule.is_fenced_code_block_start("   ```"));
2484
2485        // Invalid fences (4+ spaces) - these are indented code blocks instead
2486        assert!(!rule.is_fenced_code_block_start("    ```"));
2487        assert!(!rule.is_fenced_code_block_start("     ```"));
2488        assert!(!rule.is_fenced_code_block_start("        ```"));
2489
2490        // Tab counts as 4 spaces per CommonMark
2491        assert!(!rule.is_fenced_code_block_start("\t```"));
2492    }
2493
2494    #[test]
2495    fn test_issue_237_indented_fenced_block_detected_as_indented() {
2496        // Issue #237: User has fenced code block indented by 4 spaces
2497        // Per CommonMark, this should be detected as an INDENTED code block
2498        // because 4+ spaces of indentation makes the fence invalid
2499        //
2500        // Reference: https://github.com/rvben/rumdl/issues/237
2501        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2502
2503        // This is the exact test case from issue #237
2504        let content = r#"## Test
2505
2506    ```js
2507    var foo = "hello";
2508    ```
2509"#;
2510
2511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2512        let result = rule.check(&ctx).unwrap();
2513
2514        // Should flag this as an indented code block that should use fenced style
2515        assert_eq!(
2516            result.len(),
2517            1,
2518            "4-space indented fence should be detected as indented code block"
2519        );
2520        assert!(
2521            result[0].message.contains("Use fenced code blocks"),
2522            "Expected 'Use fenced code blocks' message"
2523        );
2524    }
2525
2526    #[test]
2527    fn test_issue_276_indented_code_in_list() {
2528        // Issue #276: Indented code blocks inside lists should be detected
2529        // Reference: https://github.com/rvben/rumdl/issues/276
2530        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2531
2532        let content = r#"1. First item
25332. Second item with code:
2534
2535        # This is a code block in a list
2536        print("Hello, world!")
2537
25384. Third item"#;
2539
2540        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2541        let result = rule.check(&ctx).unwrap();
2542
2543        // Should flag the indented code block inside the list
2544        assert!(
2545            !result.is_empty(),
2546            "Indented code block inside list should be flagged when style=fenced"
2547        );
2548        assert!(
2549            result[0].message.contains("Use fenced code blocks"),
2550            "Expected 'Use fenced code blocks' message"
2551        );
2552    }
2553
2554    #[test]
2555    fn test_three_space_indented_fence_is_valid() {
2556        // 3 spaces is the maximum allowed per CommonMark - should be recognized as fenced
2557        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2558
2559        let content = r#"## Test
2560
2561   ```js
2562   var foo = "hello";
2563   ```
2564"#;
2565
2566        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2567        let result = rule.check(&ctx).unwrap();
2568
2569        // 3-space indent is valid for fenced blocks - should pass
2570        assert_eq!(
2571            result.len(),
2572            0,
2573            "3-space indented fence should be recognized as valid fenced code block"
2574        );
2575    }
2576
2577    #[test]
2578    fn test_indented_style_with_deeply_indented_fenced() {
2579        // When style=indented, a 4-space indented "fenced" block should still be detected
2580        // as an indented code block (which is what we want!)
2581        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2582
2583        let content = r#"Text
2584
2585    ```js
2586    var foo = "hello";
2587    ```
2588
2589More text
2590"#;
2591
2592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2593        let result = rule.check(&ctx).unwrap();
2594
2595        // When target style is "indented", 4-space indented content is correct
2596        // The fence markers become literal content in the indented code block
2597        assert_eq!(
2598            result.len(),
2599            0,
2600            "4-space indented content should be valid when style=indented"
2601        );
2602    }
2603
2604    #[test]
2605    fn test_fix_misplaced_fenced_block() {
2606        // Issue #237: When a fenced code block is accidentally indented 4+ spaces,
2607        // the fix should just remove the indentation, not wrap in more fences
2608        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2609
2610        let content = r#"## Test
2611
2612    ```js
2613    var foo = "hello";
2614    ```
2615"#;
2616
2617        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2618        let fixed = rule.fix(&ctx).unwrap();
2619
2620        // The fix should just remove the 4-space indentation
2621        let expected = r#"## Test
2622
2623```js
2624var foo = "hello";
2625```
2626"#;
2627
2628        assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2629    }
2630
2631    #[test]
2632    fn test_fix_regular_indented_block() {
2633        // Regular indented code blocks (without fence markers) should still be
2634        // wrapped in fences when converted
2635        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2636
2637        let content = r#"Text
2638
2639    var foo = "hello";
2640    console.log(foo);
2641
2642More text
2643"#;
2644
2645        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2646        let fixed = rule.fix(&ctx).unwrap();
2647
2648        // Should wrap in fences
2649        assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2650        assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2651    }
2652
2653    #[test]
2654    fn test_fix_indented_block_with_fence_like_content() {
2655        // If an indented block contains fence-like content but doesn't form a
2656        // complete fenced block, we should NOT autofix it because wrapping would
2657        // create invalid nested fences. The block is left unchanged.
2658        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2659
2660        let content = r#"Text
2661
2662    some code
2663    ```not a fence opener
2664    more code
2665"#;
2666
2667        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2668        let fixed = rule.fix(&ctx).unwrap();
2669
2670        // Block should be left unchanged to avoid creating invalid nested fences
2671        assert!(fixed.contains("    some code"), "Unsafe block should be left unchanged");
2672        assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2673    }
2674
2675    #[test]
2676    fn test_fix_mixed_indented_and_misplaced_blocks() {
2677        // Mixed blocks: regular indented code followed by misplaced fenced block
2678        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2679
2680        let content = r#"Text
2681
2682    regular indented code
2683
2684More text
2685
2686    ```python
2687    print("hello")
2688    ```
2689"#;
2690
2691        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2692        let fixed = rule.fix(&ctx).unwrap();
2693
2694        // First block should be wrapped
2695        assert!(
2696            fixed.contains("```\nregular indented code\n```"),
2697            "First block should be wrapped in fences"
2698        );
2699
2700        // Second block should be dedented (not wrapped)
2701        assert!(
2702            fixed.contains("\n```python\nprint(\"hello\")\n```"),
2703            "Second block should be dedented, not double-wrapped"
2704        );
2705        // Should NOT have nested fences
2706        assert!(
2707            !fixed.contains("```\n```python"),
2708            "Should not have nested fence openers"
2709        );
2710    }
2711}