Skip to main content

rumdl_lib/rules/
md046_code_block_style.rs

1use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::{FlavorOverrideNotice, option_is_explicit};
3use crate::utils::calculate_indentation_width_default;
4use crate::utils::mdg;
5use crate::utils::mkdocs_admonitions;
6use crate::utils::mkdocs_tabs;
7use crate::utils::range_utils::calculate_line_range;
8use toml;
9
10mod md046_config;
11pub use md046_config::CodeBlockStyle;
12use md046_config::MD046Config;
13
14/// Reports the MDG style override once per process; see [`FlavorOverrideNotice`].
15static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
16
17/// Pre-computed context arrays for indented code block detection.
18struct IndentContext<'a> {
19    in_list_context: &'a [bool],
20    in_tab_context: &'a [bool],
21    in_admonition_context: &'a [bool],
22    /// Lines belonging to a non-code container whose body can legitimately be
23    /// indented by 4+ spaces or contain verbatim fence markers: HTML/MDX
24    /// comments, raw HTML blocks, JSX blocks, mkdocstrings blocks, footnote
25    /// definitions, and blockquotes.
26    ///
27    /// These lines are excluded from `detect_style`'s style tally, from
28    /// `is_indented_code_block_with_context`, and from
29    /// `categorize_indented_blocks`'s fence rewriting — keeping detection in
30    /// lockstep with the warning-side skip list used in `check`.
31    in_comment_or_html: &'a [bool],
32    /// Per-line content column of the most recent list item this line
33    /// belongs to (in list continuation), or None if not in list context.
34    ///
35    /// CommonMark places an indented code block within a list item only when
36    /// the line's indent is at least `baseline + 4`. Without this, every
37    /// continuation line gets the conservative "skip in list context" treatment
38    /// — silently turning real list-internal code blocks into fmt no-ops.
39    /// With this, the rule recognizes them, and the fence converter can emit
40    /// fences at `baseline` spaces so the block stays attached to the bullet.
41    list_item_baseline: &'a [Option<usize>],
42}
43
44/// Owned backing storage for [`IndentContext`], built once per `check`/`fix`
45/// invocation by [`MD046CodeBlockStyle::build_indent_context`].
46struct OwnedIndentContext {
47    in_list_context: Vec<bool>,
48    in_tab_context: Vec<bool>,
49    in_admonition_context: Vec<bool>,
50    in_comment_or_html: Vec<bool>,
51    list_item_baseline: Vec<Option<usize>>,
52}
53
54impl OwnedIndentContext {
55    fn borrow(&self) -> IndentContext<'_> {
56        IndentContext {
57            in_list_context: &self.in_list_context,
58            in_tab_context: &self.in_tab_context,
59            in_admonition_context: &self.in_admonition_context,
60            in_comment_or_html: &self.in_comment_or_html,
61            list_item_baseline: &self.list_item_baseline,
62        }
63    }
64}
65
66/// Rule MD046: Code block style
67///
68/// See [docs/md046.md](../../docs/md046.md) for full documentation, configuration, and examples.
69///
70/// This rule is triggered when code blocks do not use a consistent style (either fenced or indented).
71#[derive(Clone)]
72pub struct MD046CodeBlockStyle {
73    config: MD046Config,
74    /// Whether `style` came from the configuration rather than from the
75    /// default. MDG enforces fenced either way; this only decides whether the
76    /// user is told that the style they asked for was not adopted.
77    style_explicit: bool,
78}
79
80impl MD046CodeBlockStyle {
81    /// The fence `fix` opens when it converts an indented block.
82    const FENCE: &'static str = "```";
83
84    pub fn new(style: CodeBlockStyle) -> Self {
85        Self {
86            config: MD046Config { style },
87            style_explicit: true,
88        }
89    }
90
91    pub fn from_config_struct(config: MD046Config) -> Self {
92        Self {
93            config,
94            style_explicit: false,
95        }
96    }
97
98    /// Check if line has valid fence indentation per CommonMark spec (0-3 spaces)
99    ///
100    /// Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces."
101    /// 4+ spaces of indentation makes it an indented code block instead.
102    fn has_valid_fence_indent(line: &str) -> bool {
103        calculate_indentation_width_default(line) < 4
104    }
105
106    /// Check fence indentation relative to an enclosing list item's content
107    /// column. CommonMark applies the container prefix before its 0-3-space
108    /// fence rule, so a nested fence can have 4+ leading spaces in the source.
109    fn has_valid_fence_indent_at(line: &str, baseline: usize) -> bool {
110        let indent = calculate_indentation_width_default(line);
111        indent >= baseline && indent - baseline < 4
112    }
113
114    /// Check if a line is a valid fenced code block start per CommonMark spec
115    ///
116    /// Per CommonMark 0.31.2: "A code fence is a sequence of at least three consecutive
117    /// backtick characters (`) or tilde characters (~). An opening code fence may be
118    /// indented 0-3 spaces."
119    ///
120    /// This means 4+ spaces of indentation makes it an indented code block instead,
121    /// where the fence characters become literal content.
122    fn is_fenced_code_block_start(&self, line: &str) -> bool {
123        if !Self::has_valid_fence_indent(line) {
124            return false;
125        }
126
127        let trimmed = line.trim_start();
128        trimmed.starts_with("```") || trimmed.starts_with("~~~")
129    }
130
131    fn is_fenced_code_block_start_at(&self, line: &str, baseline: usize) -> bool {
132        if baseline == 0 {
133            return self.is_fenced_code_block_start(line);
134        }
135
136        Self::has_valid_fence_indent_at(line, baseline)
137            && (line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~"))
138    }
139
140    fn is_closing_fence(line: &str, fence_char: char, opener_len: usize, baseline: usize) -> bool {
141        if !Self::has_valid_fence_indent_at(line, baseline) {
142            return false;
143        }
144
145        let trimmed = line.trim_start();
146        let closer_len = trimmed.chars().take_while(|&ch| ch == fence_char).count();
147        closer_len >= opener_len && closer_len > 0 && trimmed[closer_len..].trim().is_empty()
148    }
149
150    /// Remove up to `columns` visual columns of leading Markdown indentation.
151    /// Tabs advance to four-column tab stops; when the boundary falls inside a
152    /// tab, retain the unconsumed part as spaces so the payload column stays
153    /// unchanged.
154    fn strip_indentation_columns(line: &str, columns: usize) -> String {
155        if columns == 0 {
156            return line.to_string();
157        }
158
159        let mut width = 0usize;
160        let mut consumed = 0usize;
161
162        for (byte_index, ch) in line.char_indices() {
163            let next_width = match ch {
164                ' ' => width + 1,
165                '\t' => ((width / 4) + 1) * 4,
166                _ => break,
167            };
168            consumed = byte_index + ch.len_utf8();
169
170            if next_width >= columns {
171                let remainder = next_width - columns;
172                let mut stripped = String::with_capacity(remainder + line.len() - consumed);
173                stripped.extend(std::iter::repeat_n(' ', remainder));
174                stripped.push_str(&line[consumed..]);
175                return stripped;
176            }
177
178            width = next_width;
179        }
180
181        line[consumed..].to_string()
182    }
183
184    fn is_list_item(&self, line: &str) -> bool {
185        let trimmed = line.trim_start();
186        if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
187            return true;
188        }
189        // Ordered list item: one or more leading digits immediately followed by
190        // ". " or ") ". Checking the delimiter right after the digit run avoids
191        // misclassifying prose like "2 results. More info." (which merely
192        // contains ". ") as a list item.
193        let after_digits = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
194        after_digits.len() < trimmed.len() && (after_digits.starts_with(". ") || after_digits.starts_with(") "))
195    }
196
197    /// Check if a line is a footnote definition according to CommonMark footnote extension spec
198    ///
199    /// # Specification Compliance
200    /// Based on commonmark-hs footnote extension and GitHub's implementation:
201    /// - Format: `[^label]: content`
202    /// - Labels cannot be empty or whitespace-only
203    /// - Labels cannot contain line breaks (unlike regular link references)
204    /// - Labels typically contain alphanumerics, hyphens, underscores (though some parsers are more permissive)
205    ///
206    /// # Examples
207    /// Valid:
208    /// - `[^1]: Footnote text`
209    /// - `[^foo-bar]: Content`
210    /// - `[^test_123]: More content`
211    ///
212    /// Invalid:
213    /// - `[^]: No label`
214    /// - `[^ ]: Whitespace only`
215    /// - `[^]]: Extra bracket`
216    fn is_footnote_definition(&self, line: &str) -> bool {
217        let trimmed = line.trim_start();
218        if !trimmed.starts_with("[^") || trimmed.len() < 5 {
219            return false;
220        }
221
222        if let Some(close_bracket_pos) = trimmed.find("]:")
223            && close_bracket_pos > 2
224        {
225            let label = &trimmed[2..close_bracket_pos];
226
227            if label.trim().is_empty() {
228                return false;
229            }
230
231            // Per spec: labels cannot contain line breaks (check for \r since \n can't appear in a single line)
232            if label.contains('\r') {
233                return false;
234            }
235
236            // Validate characters per GitHub's behavior: alphanumeric, hyphens, underscores only
237            if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
238                return true;
239            }
240        }
241
242        false
243    }
244
245    /// Pre-compute which lines are in block continuation context (lists, footnotes) with a single forward pass
246    ///
247    /// # Specification-Based Context Tracking
248    /// This function implements CommonMark-style block continuation semantics:
249    ///
250    /// ## List Items
251    /// - List items can contain multiple paragraphs and blocks
252    /// - Content continues if indented appropriately
253    /// - Context ends at structural boundaries (headings, horizontal rules) or column-0 paragraphs
254    ///
255    /// ## Footnotes
256    /// Per commonmark-hs footnote extension and GitHub's implementation:
257    /// - Footnote content continues as long as it's indented
258    /// - Blank lines within footnotes don't terminate them (if next content is indented)
259    /// - Non-indented content terminates the footnote
260    /// - Similar to list items but can span more content
261    ///
262    /// # Performance
263    /// O(n) single forward pass, replacing O(n²) backward scanning
264    ///
265    /// # Returns
266    /// Boolean vector where `true` indicates the line is part of a list/footnote continuation
267    fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
268        let mut in_continuation_context = vec![false; lines.len()];
269        let mut last_list_item_line: Option<usize> = None;
270        let mut last_footnote_line: Option<usize> = None;
271        let mut blank_line_count = 0;
272
273        for (i, line) in lines.iter().enumerate() {
274            let trimmed = line.trim_start();
275            let indent_len = line.len() - trimmed.len();
276
277            // Check if this is a list item
278            if self.is_list_item(line) {
279                last_list_item_line = Some(i);
280                last_footnote_line = None; // List item ends any footnote context
281                blank_line_count = 0;
282                in_continuation_context[i] = true;
283                continue;
284            }
285
286            // Check if this is a footnote definition
287            if self.is_footnote_definition(line) {
288                last_footnote_line = Some(i);
289                last_list_item_line = None; // Footnote ends any list context
290                blank_line_count = 0;
291                in_continuation_context[i] = true;
292                continue;
293            }
294
295            // Handle empty lines
296            if line.trim().is_empty() {
297                // Blank lines within continuations are allowed
298                if last_list_item_line.is_some() || last_footnote_line.is_some() {
299                    blank_line_count += 1;
300                    in_continuation_context[i] = true;
301
302                    // Per spec: multiple consecutive blank lines might terminate context
303                    // GitHub allows multiple blank lines within footnotes if next content is indented
304                    // We'll check on the next non-blank line
305                }
306                continue;
307            }
308
309            // Non-empty line - check for structural breaks or continuation
310            if indent_len == 0 && !trimmed.is_empty() {
311                // Content at column 0 (not indented)
312
313                // Headings definitely end all contexts
314                if trimmed.starts_with('#') {
315                    last_list_item_line = None;
316                    last_footnote_line = None;
317                    blank_line_count = 0;
318                    continue;
319                }
320
321                // Horizontal rules end all contexts
322                if trimmed.starts_with("---") || trimmed.starts_with("***") {
323                    last_list_item_line = None;
324                    last_footnote_line = None;
325                    blank_line_count = 0;
326                    continue;
327                }
328
329                // Non-indented paragraph/content terminates contexts
330                // But be conservative: allow some distance for lists
331                if let Some(list_line) = last_list_item_line
332                    && (i - list_line > 5 || blank_line_count > 1)
333                {
334                    last_list_item_line = None;
335                }
336
337                // For footnotes, non-indented content always terminates
338                if last_footnote_line.is_some() {
339                    last_footnote_line = None;
340                }
341
342                blank_line_count = 0;
343
344                // If no active context, this is a regular line
345                if last_list_item_line.is_none() && last_footnote_line.is_some() {
346                    last_footnote_line = None;
347                }
348                continue;
349            }
350
351            // Indented content - part of continuation if we have active context
352            if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
353                in_continuation_context[i] = true;
354                blank_line_count = 0;
355            }
356        }
357
358        in_continuation_context
359    }
360
361    /// Per-line content column of the most recent list item this line
362    /// belongs to (in list continuation), or None.
363    ///
364    /// Mirrors the iteration in `precompute_block_continuation_context` but
365    /// captures the parsed list item's `content_column` from `LineInfo`.
366    /// `is_indented_code_block_with_context` consults this so list-internal
367    /// indented blocks are recognized iff their indent crosses
368    /// `baseline + 4` — the CommonMark threshold for an indented code block
369    /// inside a list item. The fix loop reuses the baseline to anchor the
370    /// generated fences at the list-item content column.
371    fn precompute_list_item_baseline(
372        &self,
373        ctx: &crate::lint_context::LintContext,
374        lines: &[&str],
375    ) -> Vec<Option<usize>> {
376        let mut baselines = vec![None; lines.len()];
377        let mut last_baseline: Option<usize> = None;
378        let mut last_list_item_line: Option<usize> = None;
379        let mut blank_line_count = 0usize;
380
381        for (i, line) in lines.iter().enumerate() {
382            let trimmed = line.trim_start();
383            let indent_len = line.len() - trimmed.len();
384
385            // List item line — read the parsed content column directly.
386            if let Some(item) = ctx.line_info(i + 1).and_then(|li| li.list_item.as_ref()) {
387                last_baseline = Some(item.content_column);
388                last_list_item_line = Some(i);
389                blank_line_count = 0;
390                baselines[i] = last_baseline;
391                continue;
392            }
393
394            // Blank line within continuation — propagate baseline.
395            if line.trim().is_empty() {
396                if last_baseline.is_some() {
397                    blank_line_count += 1;
398                    baselines[i] = last_baseline;
399                }
400                continue;
401            }
402
403            // Non-empty unindented content. Headings/HRs always end the list;
404            // otherwise mirror the >5-line / >1-blank heuristic from
405            // `precompute_block_continuation_context`.
406            if indent_len == 0 {
407                if trimmed.starts_with('#') || trimmed.starts_with("---") || trimmed.starts_with("***") {
408                    last_baseline = None;
409                    last_list_item_line = None;
410                } else if let Some(list_line) = last_list_item_line
411                    && (i - list_line > 5 || blank_line_count > 1)
412                {
413                    last_baseline = None;
414                    last_list_item_line = None;
415                }
416                blank_line_count = 0;
417                continue;
418            }
419
420            // Indented continuation — keep the baseline.
421            if last_baseline.is_some() {
422                baselines[i] = last_baseline;
423                blank_line_count = 0;
424            }
425        }
426
427        baselines
428    }
429
430    /// Check if a line is an indented code line using pre-computed context
431    /// arrays. `prev_is_code` says whether the line above was classified as
432    /// one, which is what lets a block continue past its first line.
433    fn is_indented_code_block_with_context(
434        &self,
435        lines: &[&str],
436        i: usize,
437        is_mkdocs: bool,
438        ctx: &IndentContext,
439        prev_is_code: bool,
440    ) -> bool {
441        if i >= lines.len() {
442            return false;
443        }
444
445        let line = lines[i];
446
447        // A blank line is blank however wide its whitespace is: CommonMark
448        // never opens an indented code block on one. Whether it sits INSIDE a
449        // block is decided by `indented_block_lines`, from the code lines
450        // around it.
451        if line.trim().is_empty() {
452            return false;
453        }
454
455        // Check if indented by at least 4 columns (accounting for tab expansion)
456        let indent = calculate_indentation_width_default(line);
457        if indent < 4 {
458            return false;
459        }
460
461        // List/footnote continuation: only treat as a code block when the
462        // indent crosses the list-item content baseline + 4. Without a
463        // baseline (e.g. footnote definition continuation), keep the
464        // conservative skip — those containers don't expose a column we can
465        // anchor a fence to.
466        if ctx.in_list_context[i] {
467            let crosses_baseline = ctx
468                .list_item_baseline
469                .get(i)
470                .copied()
471                .flatten()
472                .is_some_and(|base| indent >= base + 4);
473            if !crosses_baseline {
474                return false;
475            }
476        }
477
478        // Skip if this is MkDocs tab content (pre-computed)
479        if is_mkdocs && ctx.in_tab_context[i] {
480            return false;
481        }
482
483        // Skip if this is MkDocs admonition content (pre-computed)
484        // Admonitions are supported in MkDocs and other extended Markdown processors
485        if is_mkdocs && ctx.in_admonition_context[i] {
486            return false;
487        }
488
489        // Skip if inside an HTML/MDX comment, raw HTML block, JSX block,
490        // mkdocstrings block, footnote definition, or blockquote. These
491        // containers can legitimately hold 4+ space indented text that is
492        // not a code block. Counting them would desync style detection from
493        // the warning-side skip list in `check`.
494        if ctx.in_comment_or_html.get(i).copied().unwrap_or(false) {
495            return false;
496        }
497
498        // An indented code block starts after a blank line or continues from
499        // a code line directly above. An indented line straight after a
500        // paragraph line is a lazy continuation of that paragraph, however
501        // deep its indent, and so is every indented line that follows it:
502        // the answer for the line above has to be the classified one, not its
503        // raw indent, or a run of continuation lines turns into code from its
504        // second line on.
505        let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
506        has_blank_line_before || prev_is_code
507    }
508
509    /// First line at or after `start` that `block_lines` still counts as
510    /// indented code, bounded by the byte offset `block_end`, or `None` when
511    /// the block holds no such line.
512    ///
513    /// Under MDG a Gherkin table row is dropped from the block even though
514    /// CommonMark counts it as indented code, so a block reported by the
515    /// parser can start on a line the fix will leave alone. `check` reports
516    /// the first line the fix actually converts, which keeps the two in step.
517    fn first_code_block_line(
518        ctx: &crate::lint_context::LintContext,
519        block_lines: &[bool],
520        start: usize,
521        block_end: usize,
522    ) -> Option<usize> {
523        (start..block_lines.len())
524            .take_while(|&idx| ctx.line_offsets.get(idx).is_some_and(|&offset| offset < block_end))
525            .find(|&idx| block_lines[idx])
526    }
527
528    /// Per-line membership of the indented code blocks that style detection,
529    /// block categorization and the fix all operate on.
530    ///
531    /// A code line is one `is_indented_code_block_with_context` accepts. A
532    /// blank line belongs to a block only when a code line of that block
533    /// precedes it and another follows it, with nothing but blank lines in
534    /// between: CommonMark keeps interior blank lines inside an indented code
535    /// block and leaves the blank lines before and after it outside. So
536    /// `    a`, an empty line and `    b` form one block, and a whitespace-only
537    /// line on its own is no block at all.
538    ///
539    /// Under MDG, Gherkin tables are dropped from the result. This is the only
540    /// place that decision is made: `check`, `detect_style` and `fix` all read
541    /// the array returned here, so they cannot disagree about what MD046
542    /// converts.
543    fn indented_block_lines(
544        &self,
545        lines: &[&str],
546        is_mkdocs: bool,
547        ictx: &IndentContext<'_>,
548        ctx: &crate::lint_context::LintContext,
549    ) -> Vec<bool> {
550        let mut member = vec![false; lines.len()];
551        for i in 0..lines.len() {
552            let prev_is_code = i > 0 && member[i - 1];
553            member[i] = self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx, prev_is_code);
554        }
555
556        // Fencing a run of Gherkin table rows would delete the table from the
557        // Gherkin document, so they are withheld from indented-code detection.
558        // That happens before blank lines are folded in, so each
559        // blank-line-delimited run is judged on its own rows: an Examples table
560        // followed by a blank line and a paragraph must keep the table out of
561        // the block instead of being outvoted by the paragraph.
562        if ctx.flavor == crate::config::MarkdownFlavor::MDG {
563            let mut i = 0;
564            while i < member.len() {
565                if !member[i] {
566                    i += 1;
567                    continue;
568                }
569                let start = i;
570                while i < member.len() && member[i] {
571                    i += 1;
572                }
573                if lines[start..i].iter().all(|line| mdg::is_table_row(line)) {
574                    member[start..i].fill(false);
575                }
576            }
577        }
578
579        let mut i = 0;
580        while i < lines.len() {
581            if !member[i] {
582                i += 1;
583                continue;
584            }
585            let mut next = i + 1;
586            while next < lines.len() && lines[next].trim().is_empty() {
587                next += 1;
588            }
589            if next < lines.len() && member[next] {
590                member[i + 1..next].fill(true);
591            }
592            i = next;
593        }
594
595        member
596    }
597
598    /// Pre-compute which lines sit inside a non-code container whose body may
599    /// legitimately be indented by 4+ spaces without being an indented code
600    /// block: HTML comments, raw HTML blocks, JSX blocks, MDX comments,
601    /// mkdocstrings blocks, footnote definitions, blockquotes, and front-matter.
602    ///
603    /// This mirrors the skip list used in `check` when emitting indented
604    /// code-block warnings, keeping style detection and warning emission in
605    /// lockstep.
606    fn precompute_comment_or_html_context(ctx: &crate::lint_context::LintContext, line_count: usize) -> Vec<bool> {
607        (0..line_count)
608            .map(|i| {
609                ctx.line_info(i + 1).is_some_and(|info| {
610                    info.in_html_comment
611                        || info.in_mdx_comment
612                        || info.in_html_block
613                        || info.in_jsx_block
614                        || info.in_mkdocstrings
615                        || info.in_footnote_definition
616                        || info.blockquote.is_some()
617                        || info.in_front_matter
618                })
619            })
620            .collect()
621    }
622
623    /// Pre-compute which lines are in MkDocs tab context with a single forward pass
624    fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
625        let mut in_tab_context = vec![false; lines.len()];
626        let mut current_tab_indent: Option<usize> = None;
627
628        for (i, line) in lines.iter().enumerate() {
629            // Check if this is a tab marker
630            if mkdocs_tabs::is_tab_marker(line) {
631                let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
632                current_tab_indent = Some(tab_indent);
633                in_tab_context[i] = true;
634                continue;
635            }
636
637            // If we have a current tab, check if this line is tab content
638            if let Some(tab_indent) = current_tab_indent {
639                if mkdocs_tabs::is_tab_content(line, tab_indent) {
640                    in_tab_context[i] = true;
641                } else if !line.trim().is_empty() && calculate_indentation_width_default(line) < 4 {
642                    // Non-indented, non-empty line ends tab context
643                    current_tab_indent = None;
644                } else {
645                    // Empty or indented line maintains tab context
646                    in_tab_context[i] = true;
647                }
648            }
649        }
650
651        in_tab_context
652    }
653
654    /// Pre-compute which lines are in MkDocs admonition context with a single forward pass
655    ///
656    /// MkDocs admonitions use `!!!` or `???` markers followed by a type, and their content
657    /// is indented by 4 spaces. This function marks all admonition markers and their
658    /// indented content as being in an admonition context, preventing them from being
659    /// incorrectly flagged as indented code blocks.
660    ///
661    /// Supports nested admonitions by maintaining a stack of active admonition contexts.
662    fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
663        let mut in_admonition_context = vec![false; lines.len()];
664        // Stack of active admonition indentation levels (supports nesting)
665        let mut admonition_stack: Vec<usize> = Vec::new();
666
667        for (i, line) in lines.iter().enumerate() {
668            let line_indent = calculate_indentation_width_default(line);
669
670            // Check if this is an admonition marker
671            if mkdocs_admonitions::is_admonition_start(line) {
672                let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
673
674                // Pop any admonitions that this one is not nested within
675                while let Some(&top_indent) = admonition_stack.last() {
676                    // New admonition must be indented more than parent to be nested
677                    if adm_indent <= top_indent {
678                        admonition_stack.pop();
679                    } else {
680                        break;
681                    }
682                }
683
684                // Push this admonition onto the stack
685                admonition_stack.push(adm_indent);
686                in_admonition_context[i] = true;
687                continue;
688            }
689
690            // Handle empty lines - they're valid within admonitions
691            if line.trim().is_empty() {
692                if !admonition_stack.is_empty() {
693                    in_admonition_context[i] = true;
694                }
695                continue;
696            }
697
698            // For non-empty lines, check if we're still in any admonition context
699            // Pop admonitions where the content indent requirement is not met
700            while let Some(&top_indent) = admonition_stack.last() {
701                // Content must be indented at least 4 spaces from the admonition marker
702                if line_indent >= top_indent + 4 {
703                    // This line is valid content for the top admonition (or one below)
704                    break;
705                } else {
706                    // Not indented enough for this admonition - pop it
707                    admonition_stack.pop();
708                }
709            }
710
711            // If we're still in any admonition context, mark this line
712            if !admonition_stack.is_empty() {
713                in_admonition_context[i] = true;
714            }
715        }
716
717        in_admonition_context
718    }
719
720    /// Build the pre-computed per-line context arrays that indented code
721    /// block detection consults. One call per `check`/`fix` invocation.
722    ///
723    /// The list, tab, and admonition trackers here intentionally differ from
724    /// the `LineInfo` flags (`in_list_block`, `in_content_tab`,
725    /// `in_admonition`) that `LintContext` computes: the admonition tracker
726    /// supports nesting via an indent stack, and the list tracker applies a
727    /// conservative continuation heuristic (a list context survives up to 5
728    /// unindented lines or one blank) tuned to avoid rewriting list
729    /// continuations as code blocks. Only `in_comment_or_html` and the list
730    /// item baselines project straight from `LintContext`.
731    fn build_indent_context(
732        &self,
733        ctx: &crate::lint_context::LintContext,
734        lines: &[&str],
735        is_mkdocs: bool,
736    ) -> OwnedIndentContext {
737        OwnedIndentContext {
738            in_list_context: self.precompute_block_continuation_context(lines),
739            in_tab_context: if is_mkdocs {
740                self.precompute_mkdocs_tab_context(lines)
741            } else {
742                vec![false; lines.len()]
743            },
744            in_admonition_context: if is_mkdocs {
745                self.precompute_mkdocs_admonition_context(lines)
746            } else {
747                vec![false; lines.len()]
748            },
749            in_comment_or_html: Self::precompute_comment_or_html_context(ctx, lines.len()),
750            list_item_baseline: self.precompute_list_item_baseline(ctx, lines),
751        }
752    }
753
754    /// Categorize indented blocks for fix behavior
755    ///
756    /// Returns two vectors:
757    /// - `is_misplaced`: Lines that are part of a complete misplaced fenced block (dedent only)
758    /// - `contains_fences`: Lines that contain fence markers but aren't a complete block (skip fixing)
759    ///
760    /// A misplaced fenced block is a contiguous indented block that:
761    /// 1. Starts with a valid fence opener (``` or ~~~)
762    /// 2. Ends with a matching fence closer
763    ///
764    /// An unsafe block contains fence markers but isn't complete - wrapping would create invalid markdown.
765    fn categorize_indented_blocks(&self, lines: &[&str], block_lines: &[bool]) -> (Vec<bool>, Vec<bool>) {
766        let mut is_misplaced = vec![false; lines.len()];
767        let mut contains_fences = vec![false; lines.len()];
768
769        // Find contiguous indented blocks and categorize them
770        let mut i = 0;
771        while i < lines.len() {
772            // Find the start of an indented block
773            if !block_lines[i] {
774                i += 1;
775                continue;
776            }
777
778            // Found start of an indented block - collect all contiguous lines
779            let block_start = i;
780            let mut block_end = i;
781
782            while block_end < lines.len() && block_lines[block_end] {
783                block_end += 1;
784            }
785
786            // Now we have an indented block from block_start to block_end (exclusive)
787            if block_end > block_start {
788                let first_line = lines[block_start].trim_start();
789                let last_line = lines[block_end - 1].trim_start();
790
791                // Check if first line is a fence opener
792                let is_backtick_fence = first_line.starts_with("```");
793                let is_tilde_fence = first_line.starts_with("~~~");
794
795                if is_backtick_fence || is_tilde_fence {
796                    let fence_char = if is_backtick_fence { '`' } else { '~' };
797                    let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
798
799                    // Check if last line is a matching fence closer
800                    let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
801                    let after_closer = &last_line[closer_fence_len..];
802
803                    if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
804                        // Complete misplaced fenced block - safe to dedent
805                        is_misplaced[block_start..block_end].fill(true);
806                    } else {
807                        // Incomplete fenced block - unsafe to wrap (would create nested fences)
808                        contains_fences[block_start..block_end].fill(true);
809                    }
810                } else {
811                    // Check if ANY line in the block contains fence markers
812                    // If so, wrapping would create invalid markdown
813                    let has_fence_markers = (block_start..block_end).any(|j| {
814                        let trimmed = lines[j].trim_start();
815                        trimmed.starts_with("```") || trimmed.starts_with("~~~")
816                    });
817
818                    if has_fence_markers {
819                        contains_fences[block_start..block_end].fill(true);
820                    }
821                }
822            }
823
824            i = block_end;
825        }
826
827        (is_misplaced, contains_fences)
828    }
829
830    fn check_unclosed_code_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
831        let mut warnings = Vec::new();
832        let lines = ctx.raw_lines();
833
834        // Check if any fenced block has a markdown/md language tag
835        let has_markdown_doc_block = ctx.code_block_details.iter().any(|d| {
836            if !d.is_fenced {
837                return false;
838            }
839            let lang = d.info_string.to_lowercase();
840            lang.starts_with("markdown") || lang.starts_with("md")
841        });
842
843        // Skip unclosed block detection if document contains markdown documentation blocks
844        // (they have nested fence examples that pulldown-cmark misparses)
845        if has_markdown_doc_block {
846            return warnings;
847        }
848
849        for detail in &ctx.code_block_details {
850            if !detail.is_fenced {
851                continue;
852            }
853
854            // Only check blocks that extend to EOF
855            if detail.end != ctx.content.len() {
856                continue;
857            }
858
859            // Find the line index for this block's start
860            let opening_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
861                Ok(idx) => idx,
862                Err(idx) => idx.saturating_sub(1),
863            };
864
865            let line = lines.get(opening_line_idx).unwrap_or(&"");
866            let fence_pos = line.find("```").into_iter().chain(line.find("~~~")).min().unwrap_or(0);
867            let fence_char = line[fence_pos..].chars().next().unwrap_or('`');
868            let fence_marker: String = line[fence_pos..].chars().take_while(|&ch| ch == fence_char).collect();
869            let opening_quote = crate::utils::blockquote::parse_blockquote_prefix(line);
870            let quote_level = opening_quote.map_or(0, |quote| quote.nesting_level);
871            let owned = self.build_indent_context(ctx, lines, ctx.flavor == crate::config::MarkdownFlavor::MkDocs);
872            let baseline = owned
873                .list_item_baseline
874                .get(opening_line_idx)
875                .copied()
876                .flatten()
877                .unwrap_or(0);
878
879            // A closer must follow the opener, match its container and marker,
880            // and be at least as long. Quote-only blank lines are not closers.
881            let has_closing_fence = lines
882                .iter()
883                .enumerate()
884                .rev()
885                .find_map(|(idx, candidate)| {
886                    let quote = crate::utils::blockquote::parse_blockquote_prefix(candidate);
887                    let body = quote.map_or(*candidate, |quote| quote.content);
888                    if body.trim().is_empty() {
889                        return None;
890                    }
891                    Some(
892                        idx > opening_line_idx
893                            && quote.map_or(0, |quote| quote.nesting_level) == quote_level
894                            && Self::is_closing_fence(body, fence_char, fence_marker.len(), baseline),
895                    )
896                })
897                .unwrap_or(false);
898
899            if !has_closing_fence {
900                // Skip if inside HTML comment
901                if ctx
902                    .lines
903                    .get(opening_line_idx)
904                    .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
905                {
906                    continue;
907                }
908
909                let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
910
911                warnings.push(LintWarning {
912                    rule_name: Some(self.name().to_string()),
913                    line: start_line,
914                    column: start_col,
915                    end_line,
916                    end_column: end_col,
917                    message: format!("Code block opened with '{fence_marker}' but never closed"),
918                    severity: Severity::Warning,
919                    fix: Some(Fix::new(ctx.content.len()..ctx.content.len(), {
920                        // Replace a list marker with equal-width indentation;
921                        // retain quote markers and the opener's indentation.
922                        let prefix: String = line[..fence_pos]
923                            .chars()
924                            .map(|ch| if ch == '>' || ch.is_whitespace() { ch } else { ' ' })
925                            .collect();
926                        let newline = crate::utils::detect_line_ending(ctx.content);
927                        if ctx.content.ends_with('\n') {
928                            format!("{prefix}{fence_marker}{newline}")
929                        } else {
930                            format!("{newline}{prefix}{fence_marker}")
931                        }
932                    })),
933                });
934            }
935        }
936
937        warnings
938    }
939
940    /// Resolve the style MD046 should converge on.
941    ///
942    /// A Gherkin Doc String is only ever a backtick fence, so an indented block
943    /// can never be one, and a configuration demanding indented code cannot be
944    /// satisfied in this flavor. MDG therefore always converges on fenced:
945    /// `consistent` resolves to fenced rather than to whichever style happens
946    /// to be more common, and an explicit `indented` is not adopted.
947    fn effective_target_style(
948        &self,
949        ctx: &crate::lint_context::LintContext,
950        detect: impl FnOnce() -> CodeBlockStyle,
951    ) -> CodeBlockStyle {
952        if ctx.flavor == crate::config::MarkdownFlavor::MDG {
953            self.warn_once_about_overridden_style();
954            return CodeBlockStyle::Fenced;
955        }
956
957        match self.config.style {
958            CodeBlockStyle::Consistent => {
959                let detected = detect();
960                if detected == CodeBlockStyle::Indented
961                    && ctx.code_block_details.iter().any(|detail| {
962                        detail.is_fenced
963                            && !detail.info_string.trim().is_empty()
964                            && Self::code_block_is_style_eligible(ctx, detail)
965                    })
966                {
967                    // Indented blocks cannot carry a fence's info string. In
968                    // consistent mode, choose the lossless direction even when
969                    // indented blocks are more prevalent.
970                    CodeBlockStyle::Fenced
971                } else {
972                    detected
973                }
974            }
975            style => style,
976        }
977    }
978
979    /// Whether a parsed code block participates in MD046 style selection.
980    /// Keep this aligned with the container exclusions in `detect_style` and
981    /// `check` so metadata in an ignored block cannot steer unrelated blocks.
982    fn code_block_is_style_eligible(
983        ctx: &crate::lint_context::LintContext,
984        detail: &crate::utils::code_block_utils::CodeBlockDetail,
985    ) -> bool {
986        let Some(line_idx) = Self::code_block_start_line(ctx, detail) else {
987            return false;
988        };
989
990        !ctx.lines.get(line_idx).is_some_and(|info| {
991            info.in_html_comment
992                || info.in_mdx_comment
993                || info.in_html_block
994                || info.in_jsx_block
995                || info.in_mkdocstrings
996                || info.in_footnote_definition
997                || info.blockquote.is_some()
998                || info.in_front_matter
999        })
1000    }
1001
1002    fn code_block_start_line(
1003        ctx: &crate::lint_context::LintContext,
1004        detail: &crate::utils::code_block_utils::CodeBlockDetail,
1005    ) -> Option<usize> {
1006        if detail.start >= ctx.content.len() {
1007            return None;
1008        }
1009
1010        Some(match ctx.line_offsets.binary_search(&detail.start) {
1011            Ok(idx) => idx,
1012            Err(idx) => idx.saturating_sub(1),
1013        })
1014    }
1015
1016    /// Fences that must remain as separators between otherwise adjacent code
1017    /// blocks. Converting every block in such a pair to indented form would
1018    /// merge two semantic blocks into one.
1019    fn fenced_separator_lines(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
1020        let mut lines = std::collections::HashSet::new();
1021
1022        for pair in ctx.code_block_details.windows(2) {
1023            let [previous, next] = pair else {
1024                continue;
1025            };
1026            if previous.end > next.start || next.start > ctx.content.len() {
1027                continue;
1028            }
1029            if !ctx.content[previous.end..next.start].trim().is_empty() {
1030                continue;
1031            }
1032
1033            for detail in [previous, next] {
1034                if detail.is_fenced
1035                    && let Some(line) = Self::code_block_start_line(ctx, detail)
1036                {
1037                    lines.insert(line);
1038                }
1039            }
1040        }
1041
1042        lines
1043    }
1044
1045    /// Empty fenced blocks and blocks whose first or last payload line is
1046    /// blank. Indented code blocks cannot represent either shape: Markdown
1047    /// treats boundary blanks as ordinary whitespace outside the block, so
1048    /// these fences must remain.
1049    fn fenced_boundary_blank_lines(
1050        ctx: &crate::lint_context::LintContext,
1051        lines: &[&str],
1052        ictx: &IndentContext,
1053    ) -> std::collections::HashSet<usize> {
1054        let mut boundary_blank_lines = std::collections::HashSet::new();
1055
1056        for detail in ctx.code_block_details.iter().filter(|detail| detail.is_fenced) {
1057            let Some(start) = Self::code_block_start_line(ctx, detail) else {
1058                continue;
1059            };
1060            let Some(opener) = lines.get(start) else {
1061                continue;
1062            };
1063            let baseline = ictx.list_item_baseline.get(start).copied().flatten().unwrap_or(0);
1064            let trimmed = opener.trim_start();
1065            if !Self::has_valid_fence_indent_at(opener, baseline) {
1066                continue;
1067            }
1068            let fence_char = if trimmed.starts_with("```") {
1069                '`'
1070            } else if trimmed.starts_with("~~~") {
1071                '~'
1072            } else {
1073                // A fence on the list-marker line is deliberately left alone
1074                // by the converter; it needs no boundary-blank preflight.
1075                continue;
1076            };
1077            let opener_len = trimmed.chars().take_while(|&ch| ch == fence_char).count();
1078
1079            let mut block_end = start + 1;
1080            let mut closer = None;
1081            while block_end < lines.len()
1082                && ctx
1083                    .line_offsets
1084                    .get(block_end)
1085                    .is_some_and(|&offset| offset < detail.end)
1086            {
1087                if Self::is_closing_fence(lines[block_end], fence_char, opener_len, baseline) {
1088                    closer = Some(block_end);
1089                    break;
1090                }
1091                block_end += 1;
1092            }
1093
1094            let payload_end = closer.unwrap_or(block_end);
1095            if start + 1 == payload_end
1096                || (start + 1 < payload_end
1097                    && (lines[start + 1].trim().is_empty() || lines[payload_end - 1].trim().is_empty()))
1098            {
1099                boundary_blank_lines.insert(start);
1100            }
1101        }
1102
1103        boundary_blank_lines
1104    }
1105
1106    /// Tell the user once that MDG did not adopt the style they configured.
1107    ///
1108    /// Only `indented` is worth reporting: it is the one setting MDG cannot
1109    /// satisfy. `consistent` asks for no particular form, and fenced is what
1110    /// MDG picks for it anyway.
1111    fn warn_once_about_overridden_style(&self) {
1112        if !self.style_explicit || self.config.style != CodeBlockStyle::Indented {
1113            return;
1114        }
1115
1116        MDG_STYLE_OVERRIDE.report(
1117            "MD046",
1118            "style",
1119            "indented",
1120            "fenced",
1121            "a Gherkin Doc String is only ever a backtick fence",
1122        );
1123    }
1124
1125    fn detect_style(
1126        &self,
1127        ctx: &crate::lint_context::LintContext,
1128        lines: &[&str],
1129        is_mkdocs: bool,
1130        ictx: &IndentContext,
1131    ) -> Option<CodeBlockStyle> {
1132        if lines.is_empty() {
1133            return None;
1134        }
1135
1136        let block_lines = self.indented_block_lines(lines, is_mkdocs, ictx, ctx);
1137
1138        let mut fenced_count = 0;
1139        let mut indented_count = 0;
1140
1141        // Count all code block occurrences (prevalence-based approach).
1142        //
1143        // Both counts must ignore fence markers and indented text that live
1144        // inside a non-code container (HTML/MDX comments, raw HTML/JSX
1145        // blocks, mkdocstrings, footnote definitions, blockquotes) so that
1146        // the detected style stays in lockstep with the warning-side skip
1147        // list in `check`. Without this, a document that contains a single
1148        // real code block plus a fake fence or indented paragraph nested in
1149        // a comment is wrongly classified and the real block gets flagged.
1150        let mut in_fenced = false;
1151        let mut prev_was_indented = false;
1152
1153        for (i, line) in lines.iter().enumerate() {
1154            let in_container = ictx.in_comment_or_html.get(i).copied().unwrap_or(false);
1155
1156            // Lines inside Azure DevOps colon code fences are verbatim content.
1157            // Any fence markers they contain are not real block delimiters and
1158            // must not influence the fenced/indented style tally.
1159            if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|l| l.in_code_block) {
1160                prev_was_indented = false;
1161                continue;
1162            }
1163
1164            // Lines inside MyST colon directives are structural containers, not code blocks.
1165            if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|l| l.in_myst_directive) {
1166                prev_was_indented = false;
1167                continue;
1168            }
1169
1170            let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1171            if self.is_fenced_code_block_start_at(line, baseline) {
1172                if in_container {
1173                    // Fence marker inside a container — not a real fence,
1174                    // don't flip state or count it.
1175                    prev_was_indented = false;
1176                    continue;
1177                }
1178                if !in_fenced {
1179                    // Opening fence
1180                    fenced_count += 1;
1181                    in_fenced = true;
1182                } else {
1183                    // Closing fence
1184                    in_fenced = false;
1185                }
1186                prev_was_indented = false;
1187            } else if !in_fenced && block_lines[i] {
1188                // Count each continuous indented block once
1189                if !prev_was_indented {
1190                    indented_count += 1;
1191                }
1192                prev_was_indented = true;
1193            } else {
1194                prev_was_indented = false;
1195            }
1196        }
1197
1198        if fenced_count == 0 && indented_count == 0 {
1199            None
1200        } else if fenced_count > 0 && indented_count == 0 {
1201            Some(CodeBlockStyle::Fenced)
1202        } else if fenced_count == 0 && indented_count > 0 {
1203            Some(CodeBlockStyle::Indented)
1204        } else if fenced_count >= indented_count {
1205            Some(CodeBlockStyle::Fenced)
1206        } else {
1207            Some(CodeBlockStyle::Indented)
1208        }
1209    }
1210}
1211
1212impl Rule for MD046CodeBlockStyle {
1213    fn name(&self) -> &'static str {
1214        "MD046"
1215    }
1216
1217    fn description(&self) -> &'static str {
1218        "Code blocks should use a consistent style"
1219    }
1220
1221    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1222        // Early return for empty content
1223        if ctx.content.is_empty() {
1224            return Ok(Vec::new());
1225        }
1226
1227        // Quick check for code blocks before processing
1228        if !ctx.content.contains("```")
1229            && !ctx.content.contains("~~~")
1230            && !ctx.content.contains("    ")
1231            && !ctx.content.contains('\t')
1232        {
1233            return Ok(Vec::new());
1234        }
1235
1236        // First, always check for unclosed code blocks
1237        let mut unclosed_warnings = self.check_unclosed_code_blocks(ctx);
1238
1239        // If we found unclosed blocks, return those warnings first
1240        if !unclosed_warnings.is_empty() {
1241            let fixed = self.fix(ctx)?;
1242            for warning in &mut unclosed_warnings {
1243                warning.fix = if fixed == ctx.content {
1244                    None
1245                } else if let Some(suffix) = fixed.strip_prefix(ctx.content) {
1246                    Some(Fix::new(ctx.content.len()..ctx.content.len(), suffix.to_string()))
1247                } else {
1248                    Some(Fix::new(0..ctx.content.len(), fixed.clone()))
1249                };
1250            }
1251            return Ok(unclosed_warnings);
1252        }
1253
1254        // Check for code block style consistency
1255        let lines = ctx.raw_lines();
1256        let mut warnings = Vec::new();
1257
1258        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1259
1260        // Determine the target style
1261        let target_style = self.effective_target_style(ctx, || {
1262            let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1263            let detected = self.detect_style(ctx, lines, is_mkdocs, &owned.borrow());
1264            detected.unwrap_or(CodeBlockStyle::Fenced)
1265        });
1266
1267        // Under MDG, `indented_block_lines` is the single source of truth for
1268        // which indented lines are code and which are Gherkin Data/Examples
1269        // table rows. Reading the array `fix` converts from — rather than
1270        // re-deciding it here — is what keeps the two paths in agreement.
1271        let mdg_block_lines = (ctx.flavor == crate::config::MarkdownFlavor::MDG
1272            && ctx.code_block_details.iter().any(|detail| !detail.is_fenced))
1273        .then(|| {
1274            let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1275            self.indented_block_lines(lines, is_mkdocs, &owned.borrow(), ctx)
1276        });
1277
1278        // Iterate code_block_details directly (O(k) where k is number of blocks)
1279        let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
1280
1281        for detail in &ctx.code_block_details {
1282            if detail.start >= ctx.content.len() || detail.end > ctx.content.len() {
1283                continue;
1284            }
1285
1286            let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
1287                Ok(idx) => idx,
1288                Err(idx) => idx.saturating_sub(1),
1289            };
1290
1291            if detail.is_fenced {
1292                if target_style == CodeBlockStyle::Indented {
1293                    let line = lines.get(start_line_idx).unwrap_or(&"");
1294
1295                    if ctx
1296                        .lines
1297                        .get(start_line_idx)
1298                        .is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
1299                    {
1300                        continue;
1301                    }
1302
1303                    let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1304                    warnings.push(LintWarning {
1305                        rule_name: Some(self.name().to_string()),
1306                        line: start_line,
1307                        column: start_col,
1308                        end_line,
1309                        end_column: end_col,
1310                        message: "Use indented code blocks".to_string(),
1311                        severity: Severity::Warning,
1312                        fix: None,
1313                    });
1314                }
1315            } else {
1316                // Indented code block
1317                if target_style == CodeBlockStyle::Fenced {
1318                    // Under MDG the block may open on Gherkin table rows that
1319                    // are not code; the line to report is the first one the fix
1320                    // will fence, and a block of nothing but rows is no code
1321                    // block at all.
1322                    let start_line_idx = match &mdg_block_lines {
1323                        Some(block_lines) => {
1324                            match Self::first_code_block_line(ctx, block_lines, start_line_idx, detail.end) {
1325                                Some(idx) => idx,
1326                                None => continue,
1327                            }
1328                        }
1329                        None => start_line_idx,
1330                    };
1331
1332                    if reported_indented_lines.contains(&start_line_idx) {
1333                        continue;
1334                    }
1335
1336                    let line = lines.get(start_line_idx).unwrap_or(&"");
1337
1338                    // Skip blocks in contexts that aren't real indented code blocks
1339                    if ctx.lines.get(start_line_idx).is_some_and(|info| {
1340                        info.in_html_comment
1341                            || info.in_mdx_comment
1342                            || info.in_html_block
1343                            || info.in_jsx_block
1344                            || info.in_mkdocstrings
1345                            || info.in_footnote_definition
1346                            || info.blockquote.is_some()
1347                            || info.in_front_matter
1348                    }) {
1349                        continue;
1350                    }
1351
1352                    // Use pre-computed LineInfo for MkDocs container context
1353                    if is_mkdocs
1354                        && ctx
1355                            .lines
1356                            .get(start_line_idx)
1357                            .is_some_and(|info| info.in_admonition || info.in_content_tab)
1358                    {
1359                        continue;
1360                    }
1361
1362                    reported_indented_lines.insert(start_line_idx);
1363
1364                    let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1365                    warnings.push(LintWarning {
1366                        rule_name: Some(self.name().to_string()),
1367                        line: start_line,
1368                        column: start_col,
1369                        end_line,
1370                        end_column: end_col,
1371                        message: "Use fenced code blocks".to_string(),
1372                        severity: Severity::Warning,
1373                        fix: None,
1374                    });
1375                }
1376            }
1377        }
1378
1379        // Sort warnings by line number for consistent output
1380        warnings.sort_by_key(|w| (w.line, w.column));
1381
1382        Ok(warnings)
1383    }
1384
1385    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1386        let content = ctx.content;
1387        if content.is_empty() {
1388            return Ok(String::new());
1389        }
1390
1391        let unclosed = crate::utils::fix_utils::filter_warnings_by_inline_config(
1392            self.check_unclosed_code_blocks(ctx),
1393            ctx.inline_config(),
1394            self.name(),
1395        );
1396        if !unclosed.is_empty() {
1397            let repaired =
1398                crate::utils::fix_utils::apply_warning_fixes(content, &unclosed).map_err(LintError::FixFailed)?;
1399            let repaired_ctx = crate::lint_context::LintContext::new(
1400                &repaired,
1401                ctx.flavor,
1402                ctx.source_file().map(std::path::Path::to_path_buf),
1403            );
1404            // Resolve the style on the repaired block, so a single diagnostic
1405            // fix and document formatting converge on the same final output.
1406            return self.fix_closed_blocks(&repaired_ctx);
1407        }
1408
1409        self.fix_closed_blocks(ctx)
1410    }
1411
1412    /// Get the category of this rule for selective processing
1413    fn category(&self) -> RuleCategory {
1414        RuleCategory::CodeBlock
1415    }
1416
1417    fn fix_capability(&self) -> FixCapability {
1418        // Tagged fences and conversions that would change CommonMark block
1419        // structure are intentionally retained rather than fixed lossily.
1420        FixCapability::ConditionallyFixable
1421    }
1422
1423    /// Check if this rule should be skipped
1424    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1425        // Skip if content is empty or unlikely to contain code blocks
1426        // Note: indented code blocks use 4 spaces, can't optimize that easily
1427        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains("    "))
1428    }
1429
1430    fn as_any(&self) -> &dyn std::any::Any {
1431        self
1432    }
1433
1434    crate::impl_rule_config_sections!(MD046Config);
1435
1436    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1437    where
1438        Self: Sized,
1439    {
1440        let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1441        let style_explicit = option_is_explicit(config, "MD046", "style");
1442
1443        Box::new(Self {
1444            config: rule_config,
1445            style_explicit,
1446        })
1447    }
1448}
1449
1450impl MD046CodeBlockStyle {
1451    // The caller repairs missing closers before resolving style conversions.
1452    fn fix_closed_blocks(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1453        let content = ctx.content;
1454        let lines = ctx.raw_lines();
1455
1456        // Determine target style
1457        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1458
1459        let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1460        let ictx = owned.borrow();
1461
1462        let target_style = self.effective_target_style(ctx, || {
1463            self.detect_style(ctx, lines, is_mkdocs, &ictx)
1464                .unwrap_or(CodeBlockStyle::Fenced)
1465        });
1466
1467        let block_lines = self.indented_block_lines(lines, is_mkdocs, &ictx, ctx);
1468        let fenced_separator_lines = if target_style == CodeBlockStyle::Indented {
1469            Self::fenced_separator_lines(ctx)
1470        } else {
1471            std::collections::HashSet::new()
1472        };
1473        let fenced_boundary_blank_lines = if target_style == CodeBlockStyle::Indented {
1474            Self::fenced_boundary_blank_lines(ctx, lines, &ictx)
1475        } else {
1476            std::collections::HashSet::new()
1477        };
1478        // Trust the parser for opener identity. In particular, a fence may
1479        // open on a list-marker line (`- ```); its later closer must never be
1480        // mistaken for a fresh opener merely because it starts with backticks.
1481        let fenced_start_lines: std::collections::HashSet<usize> = ctx
1482            .code_block_details
1483            .iter()
1484            .filter(|detail| detail.is_fenced)
1485            .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1486            .collect();
1487        let has_unsupported_fence_opener = ctx
1488            .code_block_details
1489            .iter()
1490            .filter(|detail| detail.is_fenced && Self::code_block_is_style_eligible(ctx, detail))
1491            .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1492            .any(|line_index| {
1493                let Some(line) = lines.get(line_index) else {
1494                    return true;
1495                };
1496                let baseline = ictx.list_item_baseline.get(line_index).copied().flatten().unwrap_or(0);
1497                !self.is_fenced_code_block_start_at(line, baseline)
1498            });
1499
1500        // Categorize indented blocks:
1501        // - misplaced_fence_lines: complete fenced blocks that were over-indented (safe to dedent)
1502        // - unsafe_fence_lines: contain fence markers but aren't complete (skip fixing to avoid broken output)
1503        let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(lines, &block_lines);
1504
1505        let mut result = String::with_capacity(content.len());
1506        let mut in_fenced_block = false;
1507        // Tracks the opening fence: (fence_char, opener_length).
1508        // Per CommonMark spec, the closing fence must use the same character and have
1509        // at least as many characters as the opener, with no info string.
1510        let mut fenced_fence_opener: Option<(char, usize)> = None;
1511        let mut in_indented_block = false;
1512        // Indent string emitted on the opening fence of the current
1513        // indented→fenced conversion (e.g. "  " for an indented block inside
1514        // a `- ` list item, "" at top level). Reused on close so the closing
1515        // fence sits at the same column as the opener.
1516        let mut current_block_fence_indent = String::new();
1517
1518        // Track whether the current fenced block must be preserved. Inline
1519        // config can disable the rule, and indented code blocks have no
1520        // representation for a fence's info string.
1521        let mut current_block_must_stay_fenced = false;
1522        let mut current_fence_indent = 0usize;
1523        let mut current_fence_baseline = 0usize;
1524        let mut current_block_indented_prefix = String::from("    ");
1525        let mut converted_fenced_to_indented = false;
1526        let mut retained_structurally_unsafe_fence =
1527            target_style == CodeBlockStyle::Indented && has_unsupported_fence_opener;
1528
1529        for (i, line) in lines.iter().enumerate() {
1530            let line_num = i + 1;
1531            let trimmed = line.trim_start();
1532            let list_baseline = ictx.list_item_baseline.get(i).copied().flatten();
1533            let fence_baseline = list_baseline.unwrap_or(0);
1534
1535            // Handle fenced code blocks
1536            // Per CommonMark: fence must have 0-3 spaces of indentation
1537            if !in_fenced_block
1538                && fenced_start_lines.contains(&i)
1539                && Self::has_valid_fence_indent_at(line, fence_baseline)
1540                && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1541            {
1542                // Check if inline config disables this rule for the opening fence
1543                let block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
1544                in_fenced_block = true;
1545                let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
1546                let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
1547                fenced_fence_opener = Some((fence_char, opener_len));
1548                current_fence_indent = calculate_indentation_width_default(line);
1549                current_fence_baseline = fence_baseline;
1550                current_block_indented_prefix = " ".repeat(fence_baseline + 4);
1551                let follows_list_item = i
1552                    .checked_sub(1)
1553                    .and_then(|previous| ictx.list_item_baseline.get(previous))
1554                    .copied()
1555                    .flatten()
1556                    .is_some();
1557                let would_become_list_prose = target_style == CodeBlockStyle::Indented
1558                    && list_baseline.is_none()
1559                    && (ictx.in_list_context.get(i).copied().unwrap_or(false) || follows_list_item);
1560                let would_interrupt_paragraph = target_style == CodeBlockStyle::Indented
1561                    && i > 0
1562                    && !lines[i - 1].trim().is_empty()
1563                    && ctx
1564                        .lines
1565                        .get(i - 1)
1566                        .is_some_and(crate::lint_context::LineInfo::is_paragraph_context)
1567                    && crate::lint_context::is_paragraph_text_line(lines[i - 1]);
1568                let would_merge_code_blocks = fenced_separator_lines.contains(&i);
1569                let would_lose_boundary_blanks = fenced_boundary_blank_lines.contains(&i);
1570                current_block_must_stay_fenced = block_disabled
1571                    || !trimmed[opener_len..].trim().is_empty()
1572                    || would_become_list_prose
1573                    || would_interrupt_paragraph
1574                    || would_merge_code_blocks
1575                    || would_lose_boundary_blanks;
1576                retained_structurally_unsafe_fence |= would_become_list_prose
1577                    || would_interrupt_paragraph
1578                    || would_merge_code_blocks
1579                    || would_lose_boundary_blanks;
1580
1581                if current_block_must_stay_fenced {
1582                    // Inline config disables this rule, or converting would
1583                    // discard the fence's info string — preserve original.
1584                    result.push_str(line);
1585                    result.push('\n');
1586                } else if target_style == CodeBlockStyle::Indented {
1587                    // Skip the opening fence
1588                    in_indented_block = true;
1589                    converted_fenced_to_indented = true;
1590                } else {
1591                    // Keep the fenced block
1592                    result.push_str(line);
1593                    result.push('\n');
1594                }
1595            } else if in_fenced_block && fenced_fence_opener.is_some() {
1596                let (fence_char, opener_len) = fenced_fence_opener.unwrap();
1597                // Per CommonMark: closing fence uses the same character, has at least as
1598                // many characters as the opener, and has no info string (only optional trailing spaces).
1599                let is_closer = Self::is_closing_fence(line, fence_char, opener_len, current_fence_baseline);
1600                if is_closer {
1601                    in_fenced_block = false;
1602                    fenced_fence_opener = None;
1603                    in_indented_block = false;
1604
1605                    if current_block_must_stay_fenced {
1606                        result.push_str(line);
1607                        result.push('\n');
1608                    } else if target_style == CodeBlockStyle::Indented {
1609                        // Skip the closing fence
1610                    } else {
1611                        // Keep the fenced block
1612                        result.push_str(line);
1613                        result.push('\n');
1614                    }
1615                    current_block_must_stay_fenced = false;
1616                    current_fence_indent = 0;
1617                    current_fence_baseline = 0;
1618                    current_block_indented_prefix.clear();
1619                } else if current_block_must_stay_fenced {
1620                    // Preserve every line of a block whose opener was kept.
1621                    result.push_str(line);
1622                    result.push('\n');
1623                } else if target_style == CodeBlockStyle::Indented {
1624                    // Convert content inside fenced block to indented.
1625                    // IMPORTANT: Preserve the original line content (including internal indentation);
1626                    // don't use trimmed, as that would strip internal code indentation.
1627                    // Leave blank lines empty so we don't emit "    " (trailing
1628                    // whitespace), which MD009 would flag and which would break
1629                    // idempotency on a second fix pass.
1630                    if !line.is_empty() {
1631                        // CommonMark removes up to the opening fence's indent
1632                        // from each body line. Remove the same source prefix
1633                        // before adding the indented-code prefix so parsed code
1634                        // content remains byte-for-byte equivalent.
1635                        let body = Self::strip_indentation_columns(line, current_fence_indent);
1636                        result.push_str(&current_block_indented_prefix);
1637                        result.push_str(&body);
1638                    }
1639                    result.push('\n');
1640                } else {
1641                    // Keep fenced block content as is
1642                    result.push_str(line);
1643                    result.push('\n');
1644                }
1645            } else if block_lines[i] {
1646                // This is an indented code block
1647
1648                // Respect inline disable comments
1649                if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
1650                    result.push_str(line);
1651                    result.push('\n');
1652                    continue;
1653                }
1654
1655                // Check if we need to start a new fenced block
1656                let prev_line_is_indented = i > 0 && block_lines[i - 1];
1657
1658                if target_style == CodeBlockStyle::Fenced {
1659                    // Anchor fences at the list-item content baseline when
1660                    // converting a list-internal indented block (e.g. column
1661                    // 2 for `- `), so the new fenced block stays attached
1662                    // to the bullet. Top-level indented blocks have no
1663                    // baseline → fences sit at column 0.
1664                    let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1665                    // Per CommonMark, the indented-code prefix is exactly 4
1666                    // spaces past the surrounding container's content
1667                    // column. Strip those 4 spaces (not all leading
1668                    // whitespace) so any internal indentation past that
1669                    // point is preserved verbatim in the fenced body. An
1670                    // interior blank line carries no content, so it is
1671                    // emitted empty rather than as leftover whitespace.
1672                    let body = if line.trim().is_empty() {
1673                        String::new()
1674                    } else {
1675                        Self::strip_indentation_columns(line, 4)
1676                    };
1677
1678                    // Check if this line is part of a misplaced fenced block
1679                    // (pre-computed block-level analysis, not per-line)
1680                    if misplaced_fence_lines[i] {
1681                        // Just remove the indentation - this is a complete misplaced fenced block
1682                        result.push_str(line.trim_start());
1683                        result.push('\n');
1684                    } else if unsafe_fence_lines[i] {
1685                        // This block contains fence markers but isn't a complete fenced block
1686                        // Wrapping would create invalid nested fences - keep as-is (don't fix)
1687                        result.push_str(line);
1688                        result.push('\n');
1689                    } else if !prev_line_is_indented && !in_indented_block {
1690                        // Start of a new indented block that should be fenced
1691                        current_block_fence_indent = " ".repeat(baseline);
1692                        result.push_str(&current_block_fence_indent);
1693                        result.push_str(Self::FENCE);
1694                        result.push('\n');
1695                        result.push_str(&body);
1696                        result.push('\n');
1697                        in_indented_block = true;
1698                    } else {
1699                        // Inside an indented block
1700                        result.push_str(&body);
1701                        result.push('\n');
1702                    }
1703
1704                    // Check if this is the end of the indented block
1705                    let next_line_is_indented = i < lines.len() - 1 && block_lines[i + 1];
1706                    // Don't close if this is an unsafe block (kept as-is)
1707                    if !next_line_is_indented
1708                        && in_indented_block
1709                        && !misplaced_fence_lines[i]
1710                        && !unsafe_fence_lines[i]
1711                    {
1712                        result.push_str(&current_block_fence_indent);
1713                        result.push_str(Self::FENCE);
1714                        result.push('\n');
1715                        in_indented_block = false;
1716                        current_block_fence_indent.clear();
1717                    }
1718                } else {
1719                    // Keep indented block as is
1720                    result.push_str(line);
1721                    result.push('\n');
1722                }
1723            } else {
1724                // Regular line
1725                if in_indented_block && target_style == CodeBlockStyle::Fenced {
1726                    result.push_str(&current_block_fence_indent);
1727                    result.push_str(Self::FENCE);
1728                    result.push('\n');
1729                    in_indented_block = false;
1730                    current_block_fence_indent.clear();
1731                }
1732
1733                result.push_str(line);
1734                result.push('\n');
1735            }
1736        }
1737
1738        // Close any remaining blocks
1739        if in_indented_block && target_style == CodeBlockStyle::Fenced {
1740            result.push_str(&current_block_fence_indent);
1741            result.push_str(Self::FENCE);
1742            result.push('\n');
1743        }
1744
1745        // Remove trailing newline if original didn't have one
1746        if !content.ends_with('\n') && result.ends_with('\n') {
1747            result.pop();
1748        }
1749
1750        if retained_structurally_unsafe_fence && self.config.style == CodeBlockStyle::Consistent {
1751            return Self::new(CodeBlockStyle::Fenced).fix(ctx);
1752        }
1753
1754        if converted_fenced_to_indented {
1755            let reparsed_block_count = crate::utils::CodeBlockUtils::detect_code_blocks(&result).len();
1756            if reparsed_block_count != ctx.code_block_details.len() {
1757                // A fenced block can interrupt structures that an indented
1758                // block cannot. In consistent mode, fenced is the only
1759                // lossless way to converge; an explicit indented preference
1760                // is instead left unchanged.
1761                if self.config.style == CodeBlockStyle::Consistent {
1762                    return Self::new(CodeBlockStyle::Fenced).fix(ctx);
1763                }
1764
1765                return Ok(content.to_string());
1766            }
1767        }
1768
1769        if result == content || (content.contains('\r') && result == content.replace("\r\n", "\n")) {
1770            Ok(content.to_string())
1771        } else {
1772            Ok(crate::utils::ensure_consistent_line_endings(content, &result))
1773        }
1774    }
1775}
1776
1777#[cfg(test)]
1778mod tests {
1779    use super::*;
1780    use crate::lint_context::LintContext;
1781
1782    /// Test helper: detect_style with automatic context computation.
1783    ///
1784    /// The container context (HTML/MDX comments, HTML/JSX blocks,
1785    /// mkdocstrings, footnote definitions, blockquotes) is not populated by
1786    /// this helper — callers that need to exercise those paths should go
1787    /// through the full `rule.check(&ctx)` entry point so the real LineInfo
1788    /// is computed from a `LintContext`.
1789    ///
1790    /// Colon fence exclusion is also not active here: tests that need Azure
1791    /// DevOps colon fence skipping must use the full `check` entry point with
1792    /// an `AzureDevOps` flavor `LintContext`.
1793    fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
1794        let flavor = if is_mkdocs {
1795            crate::config::MarkdownFlavor::MkDocs
1796        } else {
1797            crate::config::MarkdownFlavor::Standard
1798        };
1799        let ctx = LintContext::new(content, flavor, None);
1800        let lines: Vec<&str> = content.lines().collect();
1801        let in_list_context = rule.precompute_block_continuation_context(&lines);
1802        let in_tab_context = if is_mkdocs {
1803            rule.precompute_mkdocs_tab_context(&lines)
1804        } else {
1805            vec![false; lines.len()]
1806        };
1807        let in_admonition_context = if is_mkdocs {
1808            rule.precompute_mkdocs_admonition_context(&lines)
1809        } else {
1810            vec![false; lines.len()]
1811        };
1812        let in_comment_or_html = vec![false; lines.len()];
1813        // List baseline is None for every line: this helper preserves the
1814        // pre-baseline behavior where any list-context line is conservatively
1815        // skipped. Tests that need list-internal indented code blocks
1816        // recognized must drive the rule through `check`/`fix` with a real
1817        // `LintContext`.
1818        let list_item_baseline: Vec<Option<usize>> = vec![None; lines.len()];
1819        let ictx = IndentContext {
1820            in_list_context: &in_list_context,
1821            in_tab_context: &in_tab_context,
1822            in_admonition_context: &in_admonition_context,
1823            in_comment_or_html: &in_comment_or_html,
1824            list_item_baseline: &list_item_baseline,
1825        };
1826        rule.detect_style(&ctx, &lines, is_mkdocs, &ictx)
1827    }
1828
1829    #[test]
1830    fn test_unclosed_fence_diagnostic_matches_document_fix() {
1831        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1832        for (content, expected) in [
1833            ("```", "```\n```"),
1834            ("```\ncode\n", "```\ncode\n```\n"),
1835            ("````\ncode\n```\n", "````\ncode\n```\n````\n"),
1836            ("> ```rust\n> code\n", "> ```rust\n> code\n> ```\n"),
1837            ("> > ~~~~\n> > code", "> > ~~~~\n> > code\n> > ~~~~"),
1838            ("- ```\n  code\n", "- ```\n  code\n  ```\n"),
1839            ("  ```\n  code\n", "  ```\n  code\n  ```\n"),
1840        ] {
1841            for newline in ["\n", "\r\n"] {
1842                let content = content.replace('\n', newline);
1843                let expected = if content.contains('\n') {
1844                    expected.replace('\n', newline)
1845                } else {
1846                    expected.to_string()
1847                };
1848                let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1849                let warnings = rule.check(&ctx).unwrap();
1850                assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1851                let edited = crate::utils::fix_utils::apply_warning_fixes(&content, &warnings).unwrap();
1852                assert_eq!(edited, expected, "{content:?}");
1853                assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
1854                let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1855                assert!(rule.check(&fixed_ctx).unwrap().is_empty(), "{expected:?}");
1856                assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1857            }
1858        }
1859    }
1860
1861    #[test]
1862    fn test_closed_quote_fence_at_eof_is_unchanged() {
1863        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1864        for content in [
1865            "> - item\n> ```\n> code\n> ```",
1866            "> > ~~~~\n> > code\n> > ~~~~",
1867            "```\r\ncode\r\n```\r\n",
1868            "Text\r\n\n- item\r\n",
1869        ] {
1870            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1871            let warnings = rule.check(&ctx).unwrap();
1872            assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1873            assert_eq!(rule.fix(&ctx).unwrap(), content);
1874        }
1875    }
1876
1877    #[test]
1878    fn test_unclosed_quote_repair_keeps_unsupported_style_warning() {
1879        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1880        let content = "> ```\n> code\n";
1881        let expected = "> ```\n> code\n> ```\n";
1882        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1883        let warnings = rule.check(&ctx).unwrap();
1884        assert_eq!(warnings.len(), 1);
1885        assert_eq!(
1886            crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
1887            expected
1888        );
1889        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1890        let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
1891        let remaining = rule.check(&fixed_ctx).unwrap();
1892        assert_eq!(remaining.len(), 1);
1893        assert_eq!(remaining[0].message, "Use indented code blocks");
1894        assert!(
1895            remaining[0].fix.is_none(),
1896            "Container conversion is intentionally unsupported"
1897        );
1898        assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1899    }
1900
1901    #[test]
1902    fn test_unclosed_fence_diagnostic_includes_indented_conversion() {
1903        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1904        let content = "```\ncode\n";
1905        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1906        let warnings = rule.check(&ctx).unwrap();
1907        assert_eq!(warnings.len(), 1);
1908        let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1909        assert_eq!(edited, "    code\n");
1910        assert_eq!(rule.fix(&ctx).unwrap(), edited);
1911        let fixed_ctx = LintContext::new(&edited, crate::config::MarkdownFlavor::Standard, None);
1912        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1913        assert_eq!(rule.fix(&fixed_ctx).unwrap(), edited);
1914    }
1915
1916    #[test]
1917    fn test_fenced_code_block_detection() {
1918        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1919        assert!(rule.is_fenced_code_block_start("```"));
1920        assert!(rule.is_fenced_code_block_start("```rust"));
1921        assert!(rule.is_fenced_code_block_start("~~~"));
1922        assert!(rule.is_fenced_code_block_start("~~~python"));
1923        assert!(rule.is_fenced_code_block_start("  ```"));
1924        assert!(!rule.is_fenced_code_block_start("``"));
1925        assert!(!rule.is_fenced_code_block_start("~~"));
1926        assert!(!rule.is_fenced_code_block_start("Regular text"));
1927    }
1928
1929    #[test]
1930    fn test_fix_capability_is_conditional() {
1931        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1932        assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
1933    }
1934
1935    #[test]
1936    fn test_consistent_style_with_fenced_blocks() {
1937        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1938        let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1939        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1940        let result = rule.check(&ctx).unwrap();
1941
1942        // All blocks are fenced, so consistent style should be OK
1943        assert_eq!(result.len(), 0);
1944    }
1945
1946    #[test]
1947    fn test_consistent_style_with_indented_blocks() {
1948        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1949        let content = "Text\n\n    code\n    more code\n\nMore text\n\n    another block";
1950        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951        let result = rule.check(&ctx).unwrap();
1952
1953        // All blocks are indented, so consistent style should be OK
1954        assert_eq!(result.len(), 0);
1955    }
1956
1957    #[test]
1958    fn test_consistent_style_mixed() {
1959        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1960        let content = "```\nfenced code\n```\n\nText\n\n    indented code\n\nMore";
1961        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1962        let result = rule.check(&ctx).unwrap();
1963
1964        // Mixed styles should be flagged
1965        assert!(!result.is_empty());
1966    }
1967
1968    #[test]
1969    fn test_fenced_style_with_indented_blocks() {
1970        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1971        let content = "Text\n\n    indented code\n    more code\n\nMore text";
1972        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1973        let result = rule.check(&ctx).unwrap();
1974
1975        // Indented blocks should be flagged when fenced style is required
1976        assert!(!result.is_empty());
1977        assert!(result[0].message.contains("Use fenced code blocks"));
1978    }
1979
1980    #[test]
1981    fn test_fenced_style_with_tab_indented_blocks() {
1982        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1983        let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1984        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1985        let result = rule.check(&ctx).unwrap();
1986
1987        // Tab-indented blocks should also be flagged when fenced style is required
1988        assert!(!result.is_empty());
1989        assert!(result[0].message.contains("Use fenced code blocks"));
1990    }
1991
1992    #[test]
1993    fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1994        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1995        // 2 spaces + tab = 4 columns due to tab expansion (tab goes to column 4)
1996        let content = "Text\n\n  \tmixed indent code\n  \tmore code\n\nMore text";
1997        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998        let result = rule.check(&ctx).unwrap();
1999
2000        // Mixed whitespace indented blocks should also be flagged
2001        assert!(
2002            !result.is_empty(),
2003            "Mixed whitespace (2 spaces + tab) should be detected as indented code"
2004        );
2005        assert!(result[0].message.contains("Use fenced code blocks"));
2006    }
2007
2008    #[test]
2009    fn test_fenced_style_with_one_space_tab_indent() {
2010        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2011        // 1 space + tab = 4 columns (tab expands to next tab stop at column 4)
2012        let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
2013        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014        let result = rule.check(&ctx).unwrap();
2015
2016        assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
2017        assert!(result[0].message.contains("Use fenced code blocks"));
2018    }
2019
2020    #[test]
2021    fn test_indented_style_with_fenced_blocks() {
2022        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2023        let content = "Text\n\n```\nfenced code\n```\n\nMore text";
2024        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2025        let result = rule.check(&ctx).unwrap();
2026
2027        // Fenced blocks should be flagged when indented style is required
2028        assert!(!result.is_empty());
2029        assert!(result[0].message.contains("Use indented code blocks"));
2030    }
2031
2032    #[test]
2033    fn test_unclosed_code_block() {
2034        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2035        let content = "```\ncode without closing fence";
2036        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2037        let result = rule.check(&ctx).unwrap();
2038
2039        assert_eq!(result.len(), 1);
2040        assert!(result[0].message.contains("never closed"));
2041    }
2042
2043    #[test]
2044    fn test_nested_code_blocks() {
2045        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2046        let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
2047        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2048        let result = rule.check(&ctx).unwrap();
2049
2050        // This should parse as two separate code blocks
2051        assert_eq!(result.len(), 0);
2052    }
2053
2054    #[test]
2055    fn test_fix_indented_to_fenced() {
2056        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2057        let content = "Text\n\n    code line 1\n    code line 2\n\nMore text";
2058        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2059        let fixed = rule.fix(&ctx).unwrap();
2060
2061        assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
2062    }
2063
2064    #[test]
2065    fn test_fix_fenced_to_indented() {
2066        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2067        let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
2068        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2069        let fixed = rule.fix(&ctx).unwrap();
2070
2071        assert!(fixed.contains("    code line 1\n    code line 2"));
2072        assert!(!fixed.contains("```"));
2073    }
2074
2075    #[test]
2076    fn test_fix_fenced_to_indented_blank_lines_have_no_trailing_spaces() {
2077        // A blank line inside a fenced block must become an empty line, not
2078        // "    " (four trailing spaces), which would violate MD009 and break
2079        // idempotency on the second fix pass.
2080        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2081        let content = "Text\n\n```\ncode line 1\n\ncode line 2\n```\n\nMore text";
2082        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2083        let fixed = rule.fix(&ctx).unwrap();
2084
2085        for line in fixed.lines() {
2086            assert!(
2087                line.is_empty() || !line.trim_end().is_empty() || line == line.trim_end(),
2088                "no line may have trailing whitespace, got {line:?}"
2089            );
2090            assert_ne!(line, "    ", "blank line was indented to trailing spaces");
2091        }
2092        // The blank line between the two code lines is preserved as empty.
2093        assert!(fixed.contains("    code line 1\n\n    code line 2"));
2094    }
2095
2096    #[test]
2097    fn test_is_list_item_requires_delimiter_after_digits() {
2098        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2099        // Real ordered list items.
2100        assert!(rule.is_list_item("1. First"));
2101        assert!(rule.is_list_item("42) Item"));
2102        assert!(rule.is_list_item("  3. Indented item"));
2103        // Bullet list items.
2104        assert!(rule.is_list_item("- bullet"));
2105        assert!(rule.is_list_item("* bullet"));
2106        // Prose starting with a digit but containing ". " or ") " mid-sentence
2107        // is NOT a list item.
2108        assert!(!rule.is_list_item("2 results. More info."));
2109        assert!(!rule.is_list_item("3 options (a, b) here"));
2110        assert!(!rule.is_list_item("100 items in stock. Buy now"));
2111    }
2112
2113    #[test]
2114    fn test_fix_fenced_to_indented_preserves_internal_indentation() {
2115        // Issue #270: When converting fenced code to indented, internal indentation must be preserved
2116        // HTML templates, Python, etc. rely on proper indentation
2117        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2118        let content = r#"# Test
2119
2120```
2121<!doctype html>
2122<html>
2123  <head>
2124    <title>Test</title>
2125  </head>
2126</html>
2127```
2128"#;
2129        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2130        let fixed = rule.fix(&ctx).unwrap();
2131
2132        // The internal indentation (2 spaces for <head>, 4 for <title>) must be preserved
2133        // Each line gets 4 spaces prepended for the indented code block
2134        assert!(
2135            fixed.contains("      <head>"),
2136            "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
2137        );
2138        assert!(
2139            fixed.contains("        <title>"),
2140            "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
2141        );
2142        assert!(!fixed.contains("```"), "Fenced markers should be removed");
2143    }
2144
2145    #[test]
2146    fn test_fix_fenced_to_indented_preserves_python_indentation() {
2147        // Issue #270: Python is indentation-sensitive - must preserve internal structure
2148        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2149        let content = r#"# Python Example
2150
2151```
2152def greet(name):
2153    if name:
2154        print(f"Hello, {name}!")
2155    else:
2156        print("Hello, World!")
2157```
2158"#;
2159        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2160        let fixed = rule.fix(&ctx).unwrap();
2161
2162        // Python indentation must be preserved exactly
2163        assert!(
2164            fixed.contains("    def greet(name):"),
2165            "Function def should have 4 spaces (code block indent)"
2166        );
2167        assert!(
2168            fixed.contains("        if name:"),
2169            "if statement should have 8 spaces (4 code + 4 Python)"
2170        );
2171        assert!(
2172            fixed.contains("            print"),
2173            "print should have 12 spaces (4 code + 8 Python)"
2174        );
2175    }
2176
2177    #[test]
2178    fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
2179        // Issue #270: YAML is also indentation-sensitive
2180        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2181        let content = r#"# Config
2182
2183```
2184server:
2185  host: localhost
2186  port: 8080
2187  ssl:
2188    enabled: true
2189    cert: /path/to/cert
2190```
2191"#;
2192        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2193        let fixed = rule.fix(&ctx).unwrap();
2194
2195        assert!(fixed.contains("    server:"), "Root key should have 4 spaces");
2196        assert!(fixed.contains("      host:"), "First level should have 6 spaces");
2197        assert!(fixed.contains("      ssl:"), "ssl key should have 6 spaces");
2198        assert!(fixed.contains("        enabled:"), "Nested ssl should have 8 spaces");
2199    }
2200
2201    #[test]
2202    fn test_fix_fenced_to_indented_preserves_empty_lines() {
2203        // Blank lines within a converted code block stay blank: they keep their
2204        // place but must not gain the 4-space prefix (that would be trailing
2205        // whitespace).
2206        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2207        let content = "```\nline1\n\nline2\n```\n";
2208        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2209        let fixed = rule.fix(&ctx).unwrap();
2210
2211        // Content lines are indented; the blank line between them stays empty.
2212        assert!(fixed.contains("    line1"), "line1 should be indented");
2213        assert!(fixed.contains("    line2"), "line2 should be indented");
2214        assert!(
2215            fixed.contains("    line1\n\n    line2"),
2216            "blank line must stay empty, got {fixed:?}"
2217        );
2218    }
2219
2220    #[test]
2221    fn test_fix_fenced_to_indented_multiple_blocks() {
2222        // Multiple fenced blocks should all preserve their indentation
2223        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2224        let content = r#"# Doc
2225
2226```
2227def foo():
2228    pass
2229```
2230
2231Text between.
2232
2233```
2234key:
2235  value: 1
2236```
2237"#;
2238        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2239        let fixed = rule.fix(&ctx).unwrap();
2240
2241        assert!(fixed.contains("    def foo():"), "Python def should be indented");
2242        assert!(fixed.contains("        pass"), "Python body should have 8 spaces");
2243        assert!(fixed.contains("    key:"), "YAML root should have 4 spaces");
2244        assert!(fixed.contains("      value:"), "YAML nested should have 6 spaces");
2245        assert!(!fixed.contains("```"), "No fence markers should remain");
2246    }
2247
2248    #[test]
2249    fn test_fix_unclosed_block() {
2250        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2251        let content = "```\ncode without closing";
2252        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2253        let fixed = rule.fix(&ctx).unwrap();
2254
2255        // Should add closing fence
2256        assert!(fixed.ends_with("```"));
2257    }
2258
2259    #[test]
2260    fn test_code_block_in_list() {
2261        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2262        let content = "- List item\n    code in list\n    more code\n- Next item";
2263        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2264        let result = rule.check(&ctx).unwrap();
2265
2266        // Code in lists should not be flagged
2267        assert_eq!(result.len(), 0);
2268    }
2269
2270    #[test]
2271    fn test_detect_style_fenced() {
2272        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2273        let content = "```\ncode\n```";
2274        let style = detect_style_from_content(&rule, content, false);
2275
2276        assert_eq!(style, Some(CodeBlockStyle::Fenced));
2277    }
2278
2279    #[test]
2280    fn test_detect_style_indented() {
2281        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2282        let content = "Text\n\n    code\n\nMore";
2283        let style = detect_style_from_content(&rule, content, false);
2284
2285        assert_eq!(style, Some(CodeBlockStyle::Indented));
2286    }
2287
2288    #[test]
2289    fn test_detect_style_none() {
2290        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2291        let content = "No code blocks here";
2292        let style = detect_style_from_content(&rule, content, false);
2293
2294        assert_eq!(style, None);
2295    }
2296
2297    #[test]
2298    fn test_tilde_fence() {
2299        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2300        let content = "~~~\ncode\n~~~";
2301        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2302        let result = rule.check(&ctx).unwrap();
2303
2304        // Tilde fences should be accepted as fenced blocks
2305        assert_eq!(result.len(), 0);
2306    }
2307
2308    #[test]
2309    fn test_language_specification() {
2310        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2311        let content = "```rust\nfn main() {}\n```";
2312        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2313        let result = rule.check(&ctx).unwrap();
2314
2315        assert_eq!(result.len(), 0);
2316    }
2317
2318    #[test]
2319    fn test_empty_content() {
2320        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2321        let content = "";
2322        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2323        let result = rule.check(&ctx).unwrap();
2324
2325        assert_eq!(result.len(), 0);
2326    }
2327
2328    #[test]
2329    fn test_default_config() {
2330        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2331        let (name, _config) = rule.default_config_section().unwrap();
2332        assert_eq!(name, "MD046");
2333    }
2334
2335    #[test]
2336    fn test_markdown_documentation_block() {
2337        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2338        let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
2339        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2340        let result = rule.check(&ctx).unwrap();
2341
2342        // Nested code blocks in markdown documentation should be allowed
2343        assert_eq!(result.len(), 0);
2344    }
2345
2346    #[test]
2347    fn test_preserve_trailing_newline() {
2348        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2349        let content = "```\ncode\n```\n";
2350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2351        let fixed = rule.fix(&ctx).unwrap();
2352
2353        assert_eq!(fixed, content);
2354    }
2355
2356    #[test]
2357    fn test_mkdocs_tabs_not_flagged_as_indented_code() {
2358        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2359        let content = r#"# Document
2360
2361=== "Python"
2362
2363    This is tab content
2364    Not an indented code block
2365
2366    ```python
2367    def hello():
2368        print("Hello")
2369    ```
2370
2371=== "JavaScript"
2372
2373    More tab content here
2374    Also not an indented code block"#;
2375
2376        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2377        let result = rule.check(&ctx).unwrap();
2378
2379        // Should not flag tab content as indented code blocks
2380        assert_eq!(result.len(), 0);
2381    }
2382
2383    #[test]
2384    fn test_mkdocs_tabs_with_actual_indented_code() {
2385        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2386        let content = r#"# Document
2387
2388=== "Tab 1"
2389
2390    This is tab content
2391
2392Regular text
2393
2394    This is an actual indented code block
2395    Should be flagged"#;
2396
2397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2398        let result = rule.check(&ctx).unwrap();
2399
2400        // Should flag the actual indented code block but not the tab content
2401        assert_eq!(result.len(), 1);
2402        assert!(result[0].message.contains("Use fenced code blocks"));
2403    }
2404
2405    #[test]
2406    fn test_mkdocs_tabs_detect_style() {
2407        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2408        let content = r#"=== "Tab 1"
2409
2410    Content in tab
2411    More content
2412
2413=== "Tab 2"
2414
2415    Content in second tab"#;
2416
2417        // In MkDocs mode, tab content should not be detected as indented code blocks
2418        let style = detect_style_from_content(&rule, content, true);
2419        assert_eq!(style, None); // No code blocks detected
2420
2421        // In standard mode, it would detect indented code blocks
2422        let style = detect_style_from_content(&rule, content, false);
2423        assert_eq!(style, Some(CodeBlockStyle::Indented));
2424    }
2425
2426    #[test]
2427    fn test_mkdocs_nested_tabs() {
2428        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2429        let content = r#"# Document
2430
2431=== "Outer Tab"
2432
2433    Some content
2434
2435    === "Nested Tab"
2436
2437        Nested tab content
2438        Should not be flagged"#;
2439
2440        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2441        let result = rule.check(&ctx).unwrap();
2442
2443        // Nested tabs should not be flagged
2444        assert_eq!(result.len(), 0);
2445    }
2446
2447    #[test]
2448    fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
2449        // Issue #269: MkDocs admonitions have indented bodies that should NOT be
2450        // treated as indented code blocks when style = "fenced"
2451        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2452        let content = r#"# Document
2453
2454!!! note
2455    This is normal admonition content, not a code block.
2456    It spans multiple lines.
2457
2458??? warning "Collapsible Warning"
2459    This is also admonition content.
2460
2461???+ tip "Expanded Tip"
2462    And this one too.
2463
2464Regular text outside admonitions."#;
2465
2466        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2467        let result = rule.check(&ctx).unwrap();
2468
2469        // Admonition content should not be flagged
2470        assert_eq!(
2471            result.len(),
2472            0,
2473            "Admonition content in MkDocs mode should not trigger MD046"
2474        );
2475    }
2476
2477    #[test]
2478    fn test_mkdocs_admonition_with_actual_indented_code() {
2479        // After an admonition ends, regular indented code blocks SHOULD be flagged
2480        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2481        let content = r#"# Document
2482
2483!!! note
2484    This is admonition content.
2485
2486Regular text ends the admonition.
2487
2488    This is actual indented code (should be flagged)"#;
2489
2490        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2491        let result = rule.check(&ctx).unwrap();
2492
2493        // Should only flag the actual indented code block
2494        assert_eq!(result.len(), 1);
2495        assert!(result[0].message.contains("Use fenced code blocks"));
2496    }
2497
2498    #[test]
2499    fn test_admonition_in_standard_mode_flagged() {
2500        // In standard Markdown mode, admonitions are not recognized, so the
2501        // indented content should be flagged as indented code
2502        // Note: A blank line is required before indented code blocks per CommonMark
2503        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2504        let content = r#"# Document
2505
2506!!! note
2507
2508    This looks like code in standard mode.
2509
2510Regular text."#;
2511
2512        // In Standard mode, admonitions are not recognized
2513        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2514        let result = rule.check(&ctx).unwrap();
2515
2516        // The indented content should be flagged in standard mode
2517        assert_eq!(
2518            result.len(),
2519            1,
2520            "Admonition content in Standard mode should be flagged as indented code"
2521        );
2522    }
2523
2524    #[test]
2525    fn test_mkdocs_admonition_with_fenced_code_inside() {
2526        // Issue #269: Admonitions can contain fenced code blocks - must handle correctly
2527        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2528        let content = r#"# Document
2529
2530!!! note "Code Example"
2531    Here's some code:
2532
2533    ```python
2534    def hello():
2535        print("world")
2536    ```
2537
2538    More text after code.
2539
2540Regular text."#;
2541
2542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2543        let result = rule.check(&ctx).unwrap();
2544
2545        // Should not flag anything - the fenced block inside admonition is valid
2546        assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
2547    }
2548
2549    #[test]
2550    fn test_mkdocs_nested_admonitions() {
2551        // Nested admonitions are valid MkDocs syntax
2552        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2553        let content = r#"# Document
2554
2555!!! note "Outer"
2556    Outer content.
2557
2558    !!! warning "Inner"
2559        Inner content.
2560        More inner content.
2561
2562    Back to outer.
2563
2564Regular text."#;
2565
2566        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2567        let result = rule.check(&ctx).unwrap();
2568
2569        // Nested admonitions should not trigger MD046
2570        assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
2571    }
2572
2573    #[test]
2574    fn test_mkdocs_admonition_fix_does_not_wrap() {
2575        // The fix function should not wrap admonition content in fences
2576        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2577        let content = r#"!!! note
2578    Content that should stay as admonition content.
2579    Not be wrapped in code fences.
2580"#;
2581
2582        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2583        let fixed = rule.fix(&ctx).unwrap();
2584
2585        // Fix should not add fence markers to admonition content
2586        assert!(
2587            !fixed.contains("```\n    Content"),
2588            "Admonition content should not be wrapped in fences"
2589        );
2590        assert_eq!(fixed, content, "Content should remain unchanged");
2591    }
2592
2593    #[test]
2594    fn test_mkdocs_empty_admonition() {
2595        // Empty admonitions (marker only) should not cause issues
2596        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2597        let content = r#"!!! note
2598
2599Regular paragraph after empty admonition.
2600
2601    This IS an indented code block (after blank + non-indented line)."#;
2602
2603        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2604        let result = rule.check(&ctx).unwrap();
2605
2606        // The indented code block after the paragraph should be flagged
2607        assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
2608    }
2609
2610    #[test]
2611    fn test_mkdocs_indented_admonition() {
2612        // Admonitions can themselves be indented (e.g., inside list items)
2613        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2614        let content = r#"- List item
2615
2616    !!! note
2617        Indented admonition content.
2618        More content.
2619
2620- Next item"#;
2621
2622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2623        let result = rule.check(&ctx).unwrap();
2624
2625        // Admonition inside list should not be flagged
2626        assert_eq!(
2627            result.len(),
2628            0,
2629            "Indented admonitions (e.g., in lists) should not be flagged"
2630        );
2631    }
2632
2633    #[test]
2634    fn test_footnote_indented_paragraphs_not_flagged() {
2635        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2636        let content = r#"# Test Document with Footnotes
2637
2638This is some text with a footnote[^1].
2639
2640Here's some code:
2641
2642```bash
2643echo "fenced code block"
2644```
2645
2646More text with another footnote[^2].
2647
2648[^1]: Really interesting footnote text.
2649
2650    Even more interesting second paragraph.
2651
2652[^2]: Another footnote.
2653
2654    With a second paragraph too.
2655
2656    And even a third paragraph!"#;
2657
2658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2659        let result = rule.check(&ctx).unwrap();
2660
2661        // Indented paragraphs in footnotes should not be flagged as code blocks
2662        assert_eq!(result.len(), 0);
2663    }
2664
2665    #[test]
2666    fn test_footnote_definition_detection() {
2667        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2668
2669        // Valid footnote definitions (per CommonMark footnote extension spec)
2670        // Reference: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/footnotes.md
2671        assert!(rule.is_footnote_definition("[^1]: Footnote text"));
2672        assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
2673        assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
2674        assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
2675        assert!(rule.is_footnote_definition("    [^1]: Indented footnote"));
2676        assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
2677        assert!(rule.is_footnote_definition("[^123]: Numeric label"));
2678        assert!(rule.is_footnote_definition("[^_]: Single underscore"));
2679        assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
2680
2681        // Invalid: empty or whitespace-only labels (spec violation)
2682        assert!(!rule.is_footnote_definition("[^]: No label"));
2683        assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
2684        assert!(!rule.is_footnote_definition("[^  ]: Multiple spaces"));
2685        assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
2686
2687        // Invalid: malformed syntax
2688        assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
2689        assert!(!rule.is_footnote_definition("Regular text [^1]:"));
2690        assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
2691        assert!(!rule.is_footnote_definition("[^")); // Too short
2692        assert!(!rule.is_footnote_definition("[^1:")); // Missing closing bracket
2693        assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
2694
2695        // Invalid: disallowed characters in label
2696        assert!(!rule.is_footnote_definition("[^test.name]: Period"));
2697        assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
2698        assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
2699        assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
2700        assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
2701
2702        // Edge case: line breaks not allowed in labels
2703        // (This is a string test, actual multiline would need different testing)
2704        assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
2705    }
2706
2707    #[test]
2708    fn test_footnote_with_blank_lines() {
2709        // Spec requirement: blank lines within footnotes don't terminate them
2710        // if next content is indented (matches GitHub's implementation)
2711        // Reference: commonmark-hs footnote extension behavior
2712        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2713        let content = r#"# Document
2714
2715Text with footnote[^1].
2716
2717[^1]: First paragraph.
2718
2719    Second paragraph after blank line.
2720
2721    Third paragraph after another blank line.
2722
2723Regular text at column 0 ends the footnote."#;
2724
2725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2726        let result = rule.check(&ctx).unwrap();
2727
2728        // The indented paragraphs in the footnote should not be flagged as code blocks
2729        assert_eq!(
2730            result.len(),
2731            0,
2732            "Indented content within footnotes should not trigger MD046"
2733        );
2734    }
2735
2736    #[test]
2737    fn test_footnote_multiple_consecutive_blank_lines() {
2738        // Edge case: multiple consecutive blank lines within a footnote
2739        // Should still work if next content is indented
2740        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2741        let content = r#"Text[^1].
2742
2743[^1]: First paragraph.
2744
2745
2746
2747    Content after three blank lines (still part of footnote).
2748
2749Not indented, so footnote ends here."#;
2750
2751        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2752        let result = rule.check(&ctx).unwrap();
2753
2754        // The indented content should not be flagged
2755        assert_eq!(
2756            result.len(),
2757            0,
2758            "Multiple blank lines shouldn't break footnote continuation"
2759        );
2760    }
2761
2762    #[test]
2763    fn test_footnote_terminated_by_non_indented_content() {
2764        // Spec requirement: non-indented content always terminates the footnote
2765        // Reference: commonmark-hs footnote extension
2766        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2767        let content = r#"[^1]: Footnote content.
2768
2769    More indented content in footnote.
2770
2771This paragraph is not indented, so footnote ends.
2772
2773    This should be flagged as indented code block."#;
2774
2775        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2776        let result = rule.check(&ctx).unwrap();
2777
2778        // The last indented block should be flagged (it's after the footnote ended)
2779        assert_eq!(
2780            result.len(),
2781            1,
2782            "Indented code after footnote termination should be flagged"
2783        );
2784        assert!(
2785            result[0].message.contains("Use fenced code blocks"),
2786            "Expected MD046 warning for indented code block"
2787        );
2788        assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2789    }
2790
2791    #[test]
2792    fn test_footnote_terminated_by_structural_elements() {
2793        // Spec requirement: headings and horizontal rules terminate footnotes
2794        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2795        let content = r#"[^1]: Footnote content.
2796
2797    More content.
2798
2799## Heading terminates footnote
2800
2801    This indented content should be flagged.
2802
2803---
2804
2805    This should also be flagged (after horizontal rule)."#;
2806
2807        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2808        let result = rule.check(&ctx).unwrap();
2809
2810        // Both indented blocks after structural elements should be flagged
2811        assert_eq!(
2812            result.len(),
2813            2,
2814            "Both indented blocks after termination should be flagged"
2815        );
2816    }
2817
2818    #[test]
2819    fn test_footnote_with_code_block_inside() {
2820        // Spec behavior: footnotes can contain fenced code blocks
2821        // The fenced code must be properly indented within the footnote
2822        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2823        let content = r#"Text[^1].
2824
2825[^1]: Footnote with code:
2826
2827    ```python
2828    def hello():
2829        print("world")
2830    ```
2831
2832    More footnote text after code."#;
2833
2834        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2835        let result = rule.check(&ctx).unwrap();
2836
2837        // Should have no warnings - the fenced code block is valid
2838        assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2839    }
2840
2841    #[test]
2842    fn test_footnote_with_8_space_indented_code() {
2843        // Edge case: code blocks within footnotes need 8 spaces (4 for footnote + 4 for code)
2844        // This should NOT be flagged as it's properly nested indented code
2845        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2846        let content = r#"Text[^1].
2847
2848[^1]: Footnote with nested code.
2849
2850        code block
2851        more code"#;
2852
2853        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2854        let result = rule.check(&ctx).unwrap();
2855
2856        // The 8-space indented code is valid within footnote
2857        assert_eq!(
2858            result.len(),
2859            0,
2860            "8-space indented code within footnotes represents nested code blocks"
2861        );
2862    }
2863
2864    #[test]
2865    fn test_multiple_footnotes() {
2866        // Spec behavior: each footnote definition starts a new block context
2867        // Previous footnote ends when new footnote begins
2868        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2869        let content = r#"Text[^1] and more[^2].
2870
2871[^1]: First footnote.
2872
2873    Continuation of first.
2874
2875[^2]: Second footnote starts here, ending the first.
2876
2877    Continuation of second."#;
2878
2879        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2880        let result = rule.check(&ctx).unwrap();
2881
2882        // All indented content is part of footnotes
2883        assert_eq!(
2884            result.len(),
2885            0,
2886            "Multiple footnotes should each maintain their continuation context"
2887        );
2888    }
2889
2890    #[test]
2891    fn test_list_item_ends_footnote_context() {
2892        // Spec behavior: list items and footnotes are mutually exclusive contexts
2893        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2894        let content = r#"[^1]: Footnote.
2895
2896    Content in footnote.
2897
2898- List item starts here (ends footnote context).
2899
2900    This indented content is part of the list, not the footnote."#;
2901
2902        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2903        let result = rule.check(&ctx).unwrap();
2904
2905        // List continuation should not be flagged
2906        assert_eq!(
2907            result.len(),
2908            0,
2909            "List items should end footnote context and start their own"
2910        );
2911    }
2912
2913    #[test]
2914    fn test_footnote_vs_actual_indented_code() {
2915        // Critical test: verify we can still detect actual indented code blocks outside footnotes
2916        // This ensures the fix doesn't cause false negatives
2917        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2918        let content = r#"# Heading
2919
2920Text with footnote[^1].
2921
2922[^1]: Footnote content.
2923
2924    Part of footnote (should not be flagged).
2925
2926Regular paragraph ends footnote context.
2927
2928    This is actual indented code (MUST be flagged)
2929    Should be detected as code block"#;
2930
2931        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2932        let result = rule.check(&ctx).unwrap();
2933
2934        // Should flag the indented code after the regular paragraph
2935        assert_eq!(
2936            result.len(),
2937            1,
2938            "Must still detect indented code blocks outside footnotes"
2939        );
2940        assert!(
2941            result[0].message.contains("Use fenced code blocks"),
2942            "Expected MD046 warning for indented code"
2943        );
2944        assert!(
2945            result[0].line >= 11,
2946            "Warning should be on the actual indented code line"
2947        );
2948    }
2949
2950    #[test]
2951    fn test_spec_compliant_label_characters() {
2952        // Spec requirement: labels must contain only alphanumerics, hyphens, underscores
2953        // Reference: commonmark-hs footnote extension
2954        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2955
2956        // Valid according to spec
2957        assert!(rule.is_footnote_definition("[^test]: text"));
2958        assert!(rule.is_footnote_definition("[^TEST]: text"));
2959        assert!(rule.is_footnote_definition("[^test-name]: text"));
2960        assert!(rule.is_footnote_definition("[^test_name]: text"));
2961        assert!(rule.is_footnote_definition("[^test123]: text"));
2962        assert!(rule.is_footnote_definition("[^123]: text"));
2963        assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2964
2965        // Invalid characters (spec violations)
2966        assert!(!rule.is_footnote_definition("[^test.name]: text")); // Period
2967        assert!(!rule.is_footnote_definition("[^test name]: text")); // Space
2968        assert!(!rule.is_footnote_definition("[^test@name]: text")); // At sign
2969        assert!(!rule.is_footnote_definition("[^test#name]: text")); // Hash
2970        assert!(!rule.is_footnote_definition("[^test$name]: text")); // Dollar
2971        assert!(!rule.is_footnote_definition("[^test%name]: text")); // Percent
2972    }
2973
2974    #[test]
2975    fn test_code_block_inside_html_comment() {
2976        // Regression test: code blocks inside HTML comments should not be flagged
2977        // Found in denoland/deno test fixture during sanity testing
2978        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2979        let content = r#"# Document
2980
2981Some text.
2982
2983<!--
2984Example code block in comment:
2985
2986```typescript
2987console.log("Hello");
2988```
2989
2990More comment text.
2991-->
2992
2993More content."#;
2994
2995        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2996        let result = rule.check(&ctx).unwrap();
2997
2998        assert_eq!(
2999            result.len(),
3000            0,
3001            "Code blocks inside HTML comments should not be flagged as unclosed"
3002        );
3003    }
3004
3005    #[test]
3006    fn test_unclosed_fence_inside_html_comment() {
3007        // Even an unclosed fence inside an HTML comment should be ignored
3008        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3009        let content = r#"# Document
3010
3011<!--
3012Example with intentionally unclosed fence:
3013
3014```
3015code without closing
3016-->
3017
3018More content."#;
3019
3020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3021        let result = rule.check(&ctx).unwrap();
3022
3023        assert_eq!(
3024            result.len(),
3025            0,
3026            "Unclosed fences inside HTML comments should be ignored"
3027        );
3028    }
3029
3030    #[test]
3031    fn test_multiline_html_comment_with_indented_code() {
3032        // Indented code inside HTML comments should also be ignored
3033        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3034        let content = r#"# Document
3035
3036<!--
3037Example:
3038
3039    indented code
3040    more code
3041
3042End of comment.
3043-->
3044
3045Regular text."#;
3046
3047        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3048        let result = rule.check(&ctx).unwrap();
3049
3050        assert_eq!(
3051            result.len(),
3052            0,
3053            "Indented code inside HTML comments should not be flagged"
3054        );
3055    }
3056
3057    #[test]
3058    fn test_code_block_after_html_comment() {
3059        // Code blocks after HTML comments should still be detected
3060        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3061        let content = r#"# Document
3062
3063<!-- comment -->
3064
3065Text before.
3066
3067    indented code should be flagged
3068
3069More text."#;
3070
3071        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3072        let result = rule.check(&ctx).unwrap();
3073
3074        assert_eq!(
3075            result.len(),
3076            1,
3077            "Code blocks after HTML comments should still be detected"
3078        );
3079        assert!(result[0].message.contains("Use fenced code blocks"));
3080    }
3081
3082    #[test]
3083    fn test_consistent_style_indented_html_comment() {
3084        // Under the default `Consistent` style, indented content inside an
3085        // HTML comment must not contribute to the document's code-block style
3086        // tally. Otherwise a single fenced block alongside an indented HTML
3087        // comment flips the detected style to `Indented`, emitting a spurious
3088        // "Use indented code blocks" warning against the only real code block.
3089        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3090        let content = "# MD046 false-positive reproduction\n\
3091                       \n\
3092                       <!--\n    \
3093                       This is just an indented comment, not a code block.\n\
3094                       \n    \
3095                       A second line is required to trigger the false-positive.\n\
3096                       \n    \
3097                       Actually, three lines are required.\n\
3098                       -->\n\
3099                       \n\
3100                       ```md\n\
3101                       This should be fine, since it's the only code block and therefore consistent.\n\
3102                       ```\n";
3103
3104        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3105        let result = rule.check(&ctx).unwrap();
3106
3107        assert_eq!(
3108            result,
3109            vec![],
3110            "A single fenced block and an indented HTML comment must produce no MD046 warnings",
3111        );
3112    }
3113
3114    #[test]
3115    fn test_consistent_style_indented_html_block() {
3116        // Indented content inside a raw HTML block (e.g. a `<div>` tag pair)
3117        // must not count as an indented code block when `detect_style` picks
3118        // the document's predominant style.
3119        //
3120        // Per CommonMark, a type-6 HTML block is terminated by a blank line,
3121        // so the content here is kept contiguous to remain inside the block.
3122        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3123        let content = "# Heading\n\
3124                       \n\
3125                       <div class=\"note\">\n    \
3126                       line one of indented html content\n    \
3127                       line two of indented html content\n    \
3128                       line three of indented html content\n\
3129                       </div>\n\
3130                       \n\
3131                       ```md\n\
3132                       real fenced block\n\
3133                       ```\n";
3134
3135        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3136        let result = rule.check(&ctx).unwrap();
3137
3138        assert_eq!(
3139            result,
3140            vec![],
3141            "Indented content inside a raw HTML block must not influence MD046 style detection",
3142        );
3143    }
3144
3145    #[test]
3146    fn test_consistent_style_fake_fence_inside_html_comment() {
3147        // Fence markers inside an HTML comment must not contribute to the
3148        // fenced count during style detection. Otherwise a document whose
3149        // only real code block is indented gets flagged "Use fenced code
3150        // blocks" under `Consistent` because the verbatim ``` inside the
3151        // comment ties the count.
3152        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3153        let content = "# Title\n\
3154                       \n\
3155                       <!--\n\
3156                       ```\n\
3157                       fake fence inside comment\n\
3158                       ```\n\
3159                       -->\n\
3160                       \n    \
3161                       real indented code block line 1\n    \
3162                       real indented code block line 2\n";
3163
3164        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3165        let result = rule.check(&ctx).unwrap();
3166
3167        assert_eq!(
3168            result,
3169            vec![],
3170            "Fence markers inside an HTML comment must not influence MD046 style detection",
3171        );
3172    }
3173
3174    #[test]
3175    fn test_consistent_style_indented_footnote_definition() {
3176        // Footnote-definition continuation lines are commonly indented by 4+
3177        // spaces. They must not be counted as indented code blocks during
3178        // style detection under `Consistent`.
3179        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3180        let content = "# Heading\n\
3181                       \n\
3182                       Reference to a footnote[^note].\n\
3183                       \n\
3184                       [^note]: First line of the footnote.\n    \
3185                       Second indented continuation line.\n    \
3186                       Third indented continuation line.\n    \
3187                       Fourth indented continuation line.\n\
3188                       \n\
3189                       ```md\n\
3190                       real fenced block\n\
3191                       ```\n";
3192
3193        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3194        let result = rule.check(&ctx).unwrap();
3195
3196        assert_eq!(
3197            result,
3198            vec![],
3199            "Footnote-definition continuation content must not influence MD046 style detection",
3200        );
3201    }
3202
3203    #[test]
3204    fn test_consistent_style_indented_blockquote() {
3205        // Indented content inside a blockquote (`>     foo`) must not be
3206        // counted as an indented code block by `detect_style`. The check-side
3207        // skip list already excludes `blockquote.is_some()` for indented
3208        // warnings, so detection must match to keep `Consistent` stable.
3209        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3210        let content = "# Heading\n\
3211                       \n\
3212                       >     line one of quoted indented content\n\
3213                       >\n\
3214                       >     line two of quoted indented content\n\
3215                       >\n\
3216                       >     line three of quoted indented content\n\
3217                       \n\
3218                       ```md\n\
3219                       real fenced block\n\
3220                       ```\n";
3221
3222        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3223        let result = rule.check(&ctx).unwrap();
3224
3225        assert_eq!(
3226            result,
3227            vec![],
3228            "Indented content inside a blockquote must not influence MD046 style detection",
3229        );
3230    }
3231
3232    #[test]
3233    fn test_consistent_style_genuine_indented_block_detected_as_indented() {
3234        // A top-level indented code block that is not inside any container
3235        // must still count toward the Indented tally under `Consistent` style.
3236        // This guards against over-filtering: the `in_comment_or_html` skip
3237        // must not suppress real indented code blocks.
3238        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3239        let content = "# Heading\n\
3240                       \n\
3241                       Some prose.\n\
3242                       \n    \
3243                       real indented code line 1\n    \
3244                       real indented code line 2\n";
3245
3246        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3247        let result = rule.check(&ctx).unwrap();
3248
3249        // Only one indented block exists; Consistent must detect it as Indented and
3250        // produce no warnings (the detected style matches the only real block).
3251        assert_eq!(
3252            result,
3253            vec![],
3254            "A genuine top-level indented block must be detected as Indented style under Consistent",
3255        );
3256    }
3257
3258    #[test]
3259    fn test_consistent_style_skipped_lines_dont_override_real_block() {
3260        // Two indented-but-skipped regions (inside HTML comments) plus one
3261        // genuine indented code block and no fenced blocks: the skipped lines
3262        // must be excluded from the tally, leaving indented_count=1, fenced_count=0,
3263        // so Consistent still selects Indented and emits no warnings.
3264        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3265        let content = "# Heading\n\
3266                       \n\
3267                       <!--\n    \
3268                       skipped indented comment line 1\n    \
3269                       skipped indented comment line 2\n\
3270                       -->\n\
3271                       \n\
3272                       <!--\n    \
3273                       second skipped region\n    \
3274                       also skipped\n\
3275                       -->\n\
3276                       \n    \
3277                       real indented code line\n";
3278
3279        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3280        let result = rule.check(&ctx).unwrap();
3281
3282        assert_eq!(
3283            result,
3284            vec![],
3285            "Skipped container lines must not outweigh the single real indented block",
3286        );
3287    }
3288
3289    #[test]
3290    fn test_consistent_style_fenced_wins_over_skipped_indented() {
3291        // One real fenced block plus two indented-but-skipped regions: after
3292        // filtering the skipped lines the tally is fenced=1, indented=0, so
3293        // Consistent selects Fenced and emits no warnings.
3294        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3295        let content = "# Heading\n\
3296                       \n\
3297                       <!--\n    \
3298                       skipped indented region one\n    \
3299                       more of region one\n\
3300                       -->\n\
3301                       \n\
3302                       <!--\n    \
3303                       skipped indented region two\n    \
3304                       more of region two\n\
3305                       -->\n\
3306                       \n\
3307                       ```md\n\
3308                       real fenced block\n\
3309                       ```\n";
3310
3311        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3312        let result = rule.check(&ctx).unwrap();
3313
3314        assert_eq!(
3315            result,
3316            vec![],
3317            "Fenced block must win when all indented lines are inside skipped containers",
3318        );
3319    }
3320
3321    #[test]
3322    fn test_four_space_indented_fence_is_not_valid_fence() {
3323        // Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces."
3324        // 4+ spaces means it's NOT a valid fence opener - it becomes an indented code block
3325        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3326
3327        // Valid fences (0-3 spaces)
3328        assert!(rule.is_fenced_code_block_start("```"));
3329        assert!(rule.is_fenced_code_block_start(" ```"));
3330        assert!(rule.is_fenced_code_block_start("  ```"));
3331        assert!(rule.is_fenced_code_block_start("   ```"));
3332
3333        // Invalid fences (4+ spaces) - these are indented code blocks instead
3334        assert!(!rule.is_fenced_code_block_start("    ```"));
3335        assert!(!rule.is_fenced_code_block_start("     ```"));
3336        assert!(!rule.is_fenced_code_block_start("        ```"));
3337
3338        // Tab counts as 4 spaces per CommonMark
3339        assert!(!rule.is_fenced_code_block_start("\t```"));
3340    }
3341
3342    #[test]
3343    fn test_issue_237_indented_fenced_block_detected_as_indented() {
3344        // Issue #237: User has fenced code block indented by 4 spaces
3345        // Per CommonMark, this should be detected as an INDENTED code block
3346        // because 4+ spaces of indentation makes the fence invalid
3347        //
3348        // Reference: https://github.com/rvben/rumdl/issues/237
3349        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3350
3351        // This is the exact test case from issue #237
3352        let content = r#"## Test
3353
3354    ```js
3355    var foo = "hello";
3356    ```
3357"#;
3358
3359        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3360        let result = rule.check(&ctx).unwrap();
3361
3362        // Should flag this as an indented code block that should use fenced style
3363        assert_eq!(
3364            result.len(),
3365            1,
3366            "4-space indented fence should be detected as indented code block"
3367        );
3368        assert!(
3369            result[0].message.contains("Use fenced code blocks"),
3370            "Expected 'Use fenced code blocks' message"
3371        );
3372    }
3373
3374    #[test]
3375    fn test_issue_276_indented_code_in_list() {
3376        // Issue #276: Indented code blocks inside lists should be detected
3377        // Reference: https://github.com/rvben/rumdl/issues/276
3378        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3379
3380        let content = r#"1. First item
33812. Second item with code:
3382
3383        # This is a code block in a list
3384        print("Hello, world!")
3385
33864. Third item"#;
3387
3388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3389        let result = rule.check(&ctx).unwrap();
3390
3391        // Should flag the indented code block inside the list
3392        assert!(
3393            !result.is_empty(),
3394            "Indented code block inside list should be flagged when style=fenced"
3395        );
3396        assert!(
3397            result[0].message.contains("Use fenced code blocks"),
3398            "Expected 'Use fenced code blocks' message"
3399        );
3400    }
3401
3402    #[test]
3403    fn test_three_space_indented_fence_is_valid() {
3404        // 3 spaces is the maximum allowed per CommonMark - should be recognized as fenced
3405        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3406
3407        let content = r#"## Test
3408
3409   ```js
3410   var foo = "hello";
3411   ```
3412"#;
3413
3414        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3415        let result = rule.check(&ctx).unwrap();
3416
3417        // 3-space indent is valid for fenced blocks - should pass
3418        assert_eq!(
3419            result.len(),
3420            0,
3421            "3-space indented fence should be recognized as valid fenced code block"
3422        );
3423    }
3424
3425    #[test]
3426    fn test_indented_style_with_deeply_indented_fenced() {
3427        // When style=indented, a 4-space indented "fenced" block should still be detected
3428        // as an indented code block (which is what we want!)
3429        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3430
3431        let content = r#"Text
3432
3433    ```js
3434    var foo = "hello";
3435    ```
3436
3437More text
3438"#;
3439
3440        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3441        let result = rule.check(&ctx).unwrap();
3442
3443        // When target style is "indented", 4-space indented content is correct
3444        // The fence markers become literal content in the indented code block
3445        assert_eq!(
3446            result.len(),
3447            0,
3448            "4-space indented content should be valid when style=indented"
3449        );
3450    }
3451
3452    #[test]
3453    fn test_fix_misplaced_fenced_block() {
3454        // Issue #237: When a fenced code block is accidentally indented 4+ spaces,
3455        // the fix should just remove the indentation, not wrap in more fences
3456        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3457
3458        let content = r#"## Test
3459
3460    ```js
3461    var foo = "hello";
3462    ```
3463"#;
3464
3465        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3466        let fixed = rule.fix(&ctx).unwrap();
3467
3468        // The fix should just remove the 4-space indentation
3469        let expected = r#"## Test
3470
3471```js
3472var foo = "hello";
3473```
3474"#;
3475
3476        assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
3477    }
3478
3479    #[test]
3480    fn test_fix_regular_indented_block() {
3481        // Regular indented code blocks (without fence markers) should still be
3482        // wrapped in fences when converted
3483        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3484
3485        let content = r#"Text
3486
3487    var foo = "hello";
3488    console.log(foo);
3489
3490More text
3491"#;
3492
3493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3494        let fixed = rule.fix(&ctx).unwrap();
3495
3496        // Should wrap in fences
3497        assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
3498        assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
3499    }
3500
3501    #[test]
3502    fn test_fix_indented_block_with_fence_like_content() {
3503        // If an indented block contains fence-like content but doesn't form a
3504        // complete fenced block, we should NOT autofix it because wrapping would
3505        // create invalid nested fences. The block is left unchanged.
3506        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3507
3508        let content = r#"Text
3509
3510    some code
3511    ```not a fence opener
3512    more code
3513"#;
3514
3515        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3516        let fixed = rule.fix(&ctx).unwrap();
3517
3518        // Block should be left unchanged to avoid creating invalid nested fences
3519        assert!(fixed.contains("    some code"), "Unsafe block should be left unchanged");
3520        assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
3521    }
3522
3523    #[test]
3524    fn test_fix_mixed_indented_and_misplaced_blocks() {
3525        // Mixed blocks: regular indented code followed by misplaced fenced block
3526        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3527
3528        let content = r#"Text
3529
3530    regular indented code
3531
3532More text
3533
3534    ```python
3535    print("hello")
3536    ```
3537"#;
3538
3539        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3540        let fixed = rule.fix(&ctx).unwrap();
3541
3542        // First block should be wrapped
3543        assert!(
3544            fixed.contains("```\nregular indented code\n```"),
3545            "First block should be wrapped in fences"
3546        );
3547
3548        // Second block should be dedented (not wrapped)
3549        assert!(
3550            fixed.contains("\n```python\nprint(\"hello\")\n```"),
3551            "Second block should be dedented, not double-wrapped"
3552        );
3553        // Should NOT have nested fences
3554        assert!(
3555            !fixed.contains("```\n```python"),
3556            "Should not have nested fence openers"
3557        );
3558    }
3559
3560    #[test]
3561    fn test_md046_front_matter() {
3562        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3563        let content = "---\nmetadata:\n\n    description: Indented\n---\n";
3564        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3565        let result = rule.check(&ctx).unwrap();
3566        assert_eq!(result.len(), 0);
3567    }
3568
3569    #[test]
3570    fn test_md046_fix_front_matter() {
3571        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3572        let content = "---\nmetadata:\n\n    description: Indented\n---\n";
3573        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3574        let fixed = rule.fix(&ctx).unwrap();
3575        assert_eq!(fixed, content);
3576    }
3577
3578    #[test]
3579    fn test_whitespace_only_line_is_not_an_indented_code_block() {
3580        // A line holding four spaces and nothing else is a blank line to
3581        // CommonMark. The fix used to wrap it in a fence of its own, so a
3582        // document with one real indented block elsewhere gained an empty
3583        // fenced block where a blank line stood.
3584        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3585        let content = "# T\n\nPara\n\n    \nMore\n\n    real code\n\nEnd\n";
3586        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3587        let fixed = rule.fix(&ctx).unwrap();
3588        assert_eq!(fixed, "# T\n\nPara\n\n    \nMore\n\n```\nreal code\n```\n\nEnd\n");
3589    }
3590
3591    #[test]
3592    fn test_interior_blank_line_keeps_indented_block_together() {
3593        // CommonMark keeps a blank line between two indented code lines inside
3594        // the block, so `a`, the blank and `b` are one block and convert to one
3595        // fence with an empty line in it, not two fences.
3596        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3597        let content = "# T\n\nPara\n\n    a\n\n    b\n\nAfter\n";
3598        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3599        let fixed = rule.fix(&ctx).unwrap();
3600        assert_eq!(fixed, "# T\n\nPara\n\n```\na\n\nb\n```\n\nAfter\n");
3601    }
3602
3603    #[test]
3604    fn test_consistent_style_counts_a_block_with_interior_blank_once() {
3605        // Style detection counts blocks. Splitting `a` / blank / `b` in two made
3606        // one indented block outvote one fenced block, and the fenced block was
3607        // reported instead of the indented one.
3608        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3609        let content = "# T\n\n```\nfenced\n```\n\nPara\n\n    a\n\n    b\n\nEnd\n";
3610        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3611        let result = rule.check(&ctx).unwrap();
3612        let reported: Vec<(usize, &str)> = result.iter().map(|w| (w.line, w.message.as_str())).collect();
3613        assert_eq!(reported, vec![(9, "Use fenced code blocks")]);
3614    }
3615
3616    #[test]
3617    fn test_indented_lazy_continuation_lines_are_not_code() {
3618        // Indented lines directly under a paragraph line continue that
3619        // paragraph, and so does every indented line after them. Classifying
3620        // the second line by the raw indent of the first turned the run into
3621        // code from its second line on, and the fix fenced the tail of a
3622        // paragraph.
3623        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3624        let content = "# T\n\nPara\n    lazy one\n    lazy two\n    lazy three\n\n    real code\n\nEnd\n";
3625        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3626        let fixed = rule.fix(&ctx).unwrap();
3627        assert_eq!(
3628            fixed,
3629            "# T\n\nPara\n    lazy one\n    lazy two\n    lazy three\n\n```\nreal code\n```\n\nEnd\n"
3630        );
3631    }
3632
3633    #[test]
3634    fn test_misplaced_fence_with_interior_blank_dedents_as_one_block() {
3635        // An over-indented fenced block whose body has a blank line is still
3636        // one complete fenced block, so it is dedented as a whole. Split at the
3637        // blank, neither half had both fences and the block was left alone.
3638        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3639        let content = "# T\n\nPara\n\n    ```python\n    x = 1\n\n    y = 2\n    ```\n\nAfter\n";
3640        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3641        let fixed = rule.fix(&ctx).unwrap();
3642        assert_eq!(fixed, "# T\n\nPara\n\n```python\nx = 1\n\ny = 2\n```\n\nAfter\n");
3643    }
3644    #[test]
3645    fn test_mdg_overrides_indented_style_to_fenced() {
3646        // A Gherkin Doc String is only ever a backtick fence, so a
3647        // configuration demanding indented code cannot be satisfied in this
3648        // flavor. MDG does not adopt it: the Doc String keeps its fence instead
3649        // of being unwrapped into an indented block that deletes it.
3650        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3651        let content = "# Feature: Payloads\n\n## Scenario: JSON payload\n\n* Given this payload\n\n  ```json\n  {\"ok\": true}\n  ```\n";
3652
3653        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3654        assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3655        assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3656
3657        // Standard still reports the configured style mismatch, but cannot
3658        // apply it without discarding the JSON info string.
3659        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3660        let standard_warnings = rule.check(&standard_ctx).unwrap();
3661        assert_eq!(standard_warnings.len(), 1);
3662        assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3663        assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3664    }
3665
3666    #[test]
3667    fn test_mdg_indented_style_still_fences_indented_blocks() {
3668        // The override is not merely a refusal to unwrap fences: MDG enforces
3669        // fenced, so an indented block is converted even though the
3670        // configuration asked for indented code.
3671        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3672        let content =
3673            "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n      ordinary indented code\n";
3674
3675        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3676        let warnings = rule.check(&mdg_ctx).unwrap();
3677        assert_eq!(warnings.len(), 1);
3678        assert_eq!(warnings[0].message, "Use fenced code blocks");
3679
3680        let fixed = rule.fix(&mdg_ctx).unwrap();
3681        assert_eq!(
3682            fixed,
3683            "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n```\n  ordinary indented code\n```\n"
3684        );
3685
3686        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3687        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3688        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3689
3690        // Standard honours `indented`: the block is already indented, so there
3691        // is nothing to report and nothing to change.
3692        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3693        assert!(rule.check(&standard_ctx).unwrap().is_empty());
3694        assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3695    }
3696
3697    #[test]
3698    fn test_mdg_steers_indented_code_to_fenced() {
3699        // Under MDG a code block is expected to be a backtick fence, so an
3700        // indented block is corrected rather than preserved — whichever style
3701        // the configuration names.
3702        let content = "# Feature: Payloads\n\n## Scenario: Plain payload\n\n* Given this payload\n\n      ordinary indented code\n";
3703
3704        for rule in [
3705            MD046CodeBlockStyle::new(CodeBlockStyle::Fenced),
3706            MD046CodeBlockStyle::new(CodeBlockStyle::Consistent),
3707            MD046CodeBlockStyle::new(CodeBlockStyle::Indented),
3708        ] {
3709            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3710            let warnings = rule.check(&ctx).unwrap();
3711            assert_eq!(warnings.len(), 1);
3712            assert_eq!(warnings[0].message, "Use fenced code blocks");
3713
3714            let fixed = rule.fix(&ctx).unwrap();
3715            assert!(fixed.contains("```"), "MDG must fence the block: {fixed:?}");
3716
3717            let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3718            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3719            assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3720        }
3721    }
3722
3723    #[test]
3724    fn test_mdg_consistent_style_ignores_indented_prevalence() {
3725        // Standard resolves `consistent` by prevalence; MDG always resolves it
3726        // to fenced because only a backtick fence can be a Doc String.
3727        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3728        let indented_majority = "# Feature: Payloads\n\n## Scenario: Mixed payloads\n\n* Given this payload\n\n```\n{\"ok\": true}\n```\n\nFirst ordinary example:\n\n    one\n\nSecond ordinary example:\n\n    two\n";
3729
3730        let standard_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::Standard, None);
3731        let standard_warnings = rule.check(&standard_ctx).unwrap();
3732        assert_eq!(standard_warnings.len(), 1);
3733        assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3734
3735        let mdg_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::MDG, None);
3736        let mdg_warnings = rule.check(&mdg_ctx).unwrap();
3737        assert_eq!(mdg_warnings.len(), 2);
3738        assert!(
3739            mdg_warnings
3740                .iter()
3741                .all(|warning| warning.message == "Use fenced code blocks")
3742        );
3743    }
3744
3745    #[test]
3746    fn test_mdg_repairs_unclosed_fence_like_standard() {
3747        // The unclosed-fence repair is flavor independent now that MDG no
3748        // longer takes a bespoke fix path.
3749        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3750        let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3751
3752        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3753        let warnings = rule.check(&mdg_ctx).unwrap();
3754        assert_eq!(warnings.len(), 1);
3755        assert!(warnings[0].message.contains("never closed"));
3756
3757        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3758        assert_eq!(
3759            rule.fix(&mdg_ctx).unwrap(),
3760            rule.fix(&standard_ctx).unwrap(),
3761            "MDG must not differ from Standard"
3762        );
3763    }
3764
3765    #[test]
3766    fn test_mdg_table_above_prose_is_never_fenced() {
3767        // The Examples table and the paragraph below it sit in one CommonMark
3768        // indented code block, split by a blank line. `check` and `fix` read
3769        // the same per-line membership, so the table stays a table and only the
3770        // paragraph is fenced — reporting the block and fencing all of it (or
3771        // skipping the block and fencing it anyway) would delete the table.
3772        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3773        let content = "# Feature: Eating\n\n#### Examples:\n\n    | start | eat | left |\n    | ----- | --- | ---- |\n\n    a note about the data\n\n## Scenario: Other\n\n      unrelated indented code\n";
3774
3775        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3776        let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3777        assert_eq!(reported, vec![8, 12]);
3778
3779        let fixed = rule.fix(&mdg_ctx).unwrap();
3780        assert_eq!(
3781            fixed,
3782            "# Feature: Eating\n\n#### Examples:\n\n    | start | eat | left |\n    | ----- | --- | ---- |\n\n```\na note about the data\n```\n\n## Scenario: Other\n\n```\n  unrelated indented code\n```\n"
3783        );
3784
3785        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3786        assert!(
3787            rule.check(&fixed_ctx).unwrap().is_empty(),
3788            "MDG check must have nothing left to report after its own fix"
3789        );
3790        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3791
3792        // Standard has no Gherkin tables, so the whole block is code there.
3793        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3794        let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3795        assert_eq!(standard_reported, vec![5, 12]);
3796        assert!(rule.fix(&standard_ctx).unwrap().contains("```\n| start | eat | left |"));
3797    }
3798
3799    #[test]
3800    fn test_mdg_repairs_unclosed_fence_under_indented_style() {
3801        // MDG does not adopt the configured `indented` style, but closing an
3802        // unclosed fence is a repair rather than a conversion: `check` reports
3803        // it before any style is resolved, so `fix` has to resolve it too.
3804        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3805        let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3806
3807        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3808        let warnings = rule.check(&mdg_ctx).unwrap();
3809        assert_eq!(warnings.len(), 1);
3810        assert!(warnings[0].message.contains("never closed"));
3811
3812        let fixed = rule.fix(&mdg_ctx).unwrap();
3813        assert_eq!(fixed, "# Feature: Payloads\n\n```json\n{\"ok\": true}\n```\n");
3814
3815        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3816        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3817
3818        // Standard also preserves the tagged fence because conversion would
3819        // discard its info string, while still repairing the missing closer.
3820        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3821        assert_eq!(rule.fix(&standard_ctx).unwrap(), fixed);
3822    }
3823
3824    #[test]
3825    fn test_mdg_tab_indented_table_is_not_code() {
3826        // Gherkin matches table rows on `\s`, so two tabs — or a space and a
3827        // tab — indent a table just as two spaces do, even though both expand
3828        // past the 4-column indented-code threshold.
3829        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3830        for indent in ["\t\t", " \t"] {
3831            let content = format!(
3832                "# Feature: Eating\n\n#### Examples:\n\n{indent}| start | eat |\n{indent}| ----- | --- |\n\n## Scenario: Other\n\n      code here\n"
3833            );
3834
3835            let mdg_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
3836            let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3837            assert_eq!(reported, vec![10], "tab-indented rows are a table, not code");
3838
3839            let fixed = rule.fix(&mdg_ctx).unwrap();
3840            assert!(
3841                fixed.contains(&format!("{indent}| start | eat |\n{indent}| ----- | --- |")),
3842                "MDG must leave the tab-indented table alone: {fixed:?}"
3843            );
3844
3845            let standard_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3846            let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3847            assert_eq!(standard_reported, vec![5, 10]);
3848        }
3849    }
3850
3851    #[test]
3852    fn test_from_config_records_whether_style_was_configured() {
3853        // The MDG override applies either way, but the warning is only for a
3854        // style the user actually asked for, so a configured style has to be
3855        // told apart from a defaulted one.
3856        use crate::config::Config;
3857        use std::collections::BTreeMap;
3858
3859        let mut values = BTreeMap::new();
3860        values.insert("style".to_string(), toml::Value::String("indented".to_string()));
3861        let mut config = Config::default();
3862        config.rules.insert(
3863            "MD046".to_string(),
3864            crate::config::RuleConfig { severity: None, values },
3865        );
3866
3867        let configured = MD046CodeBlockStyle::from_config(&config);
3868        let configured = configured.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
3869        assert_eq!(configured.config.style, CodeBlockStyle::Indented);
3870        assert!(configured.style_explicit);
3871
3872        let defaulted = MD046CodeBlockStyle::from_config(&Config::default());
3873        let defaulted = defaulted.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
3874        assert!(!defaulted.style_explicit);
3875
3876        // The override does not depend on the warning: a defaulted `indented`
3877        // is enforced as fenced just the same.
3878        let indented = MD046CodeBlockStyle::from_config_struct(MD046Config {
3879            style: CodeBlockStyle::Indented,
3880        });
3881        let content = "# Feature: F\n\nText.\n\n      code here\n";
3882        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3883        assert!(indented.fix(&mdg_ctx).unwrap().contains("```\n  code here\n```"));
3884    }
3885
3886    #[test]
3887    fn test_mdg_indented_style_keeps_tables_out_of_code() {
3888        // Enforcing fenced does not widen what MDG counts as code: a
3889        // Data/Examples table is still not an indented code block.
3890        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3891        let content = "# Feature: Eating\n\n#### Examples:\n\n    | start | eat | left |\n    | ----- | --- | ---- |\n";
3892
3893        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3894        assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3895        assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3896
3897        // Standard has no Gherkin tables, so the rows are code — and `indented`
3898        // is honoured there, so they are already in the requested form.
3899        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3900        assert!(rule.check(&standard_ctx).unwrap().is_empty());
3901        assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3902    }
3903}