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