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