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