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            // Determine fence marker from the actual line content
866            let line = lines.get(opening_line_idx).unwrap_or(&"");
867            let trimmed = line.trim();
868            let fence_marker = if let Some(pos) = trimmed.find("```") {
869                let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
870                "`".repeat(count)
871            } else if let Some(pos) = trimmed.find("~~~") {
872                let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
873                "~".repeat(count)
874            } else {
875                "```".to_string()
876            };
877
878            // Check if the last non-empty line is a valid closing fence
879            let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
880            let last_trimmed = last_non_empty_line.trim();
881            let fence_char = fence_marker.chars().next().unwrap_or('`');
882
883            let has_closing_fence = if fence_char == '`' {
884                last_trimmed.starts_with("```") && {
885                    let fence_len = last_trimmed.chars().take_while(|&c| c == '`').count();
886                    last_trimmed[fence_len..].trim().is_empty()
887                }
888            } else {
889                last_trimmed.starts_with("~~~") && {
890                    let fence_len = last_trimmed.chars().take_while(|&c| c == '~').count();
891                    last_trimmed[fence_len..].trim().is_empty()
892                }
893            };
894
895            if !has_closing_fence {
896                // Skip if inside HTML comment
897                if ctx
898                    .lines
899                    .get(opening_line_idx)
900                    .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
901                {
902                    continue;
903                }
904
905                let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
906
907                warnings.push(LintWarning {
908                    rule_name: Some(self.name().to_string()),
909                    line: start_line,
910                    column: start_col,
911                    end_line,
912                    end_column: end_col,
913                    message: format!("Code block opened with '{fence_marker}' but never closed"),
914                    severity: Severity::Warning,
915                    fix: Some(Fix::new(
916                        ctx.content.len()..ctx.content.len(),
917                        format!("\n{fence_marker}"),
918                    )),
919                });
920            }
921        }
922
923        warnings
924    }
925
926    /// Resolve the style MD046 should converge on.
927    ///
928    /// A Gherkin Doc String is only ever a backtick fence, so an indented block
929    /// can never be one, and a configuration demanding indented code cannot be
930    /// satisfied in this flavor. MDG therefore always converges on fenced:
931    /// `consistent` resolves to fenced rather than to whichever style happens
932    /// to be more common, and an explicit `indented` is not adopted.
933    fn effective_target_style(
934        &self,
935        ctx: &crate::lint_context::LintContext,
936        detect: impl FnOnce() -> CodeBlockStyle,
937    ) -> CodeBlockStyle {
938        if ctx.flavor == crate::config::MarkdownFlavor::MDG {
939            self.warn_once_about_overridden_style();
940            return CodeBlockStyle::Fenced;
941        }
942
943        match self.config.style {
944            CodeBlockStyle::Consistent => {
945                let detected = detect();
946                if detected == CodeBlockStyle::Indented
947                    && ctx.code_block_details.iter().any(|detail| {
948                        detail.is_fenced
949                            && !detail.info_string.trim().is_empty()
950                            && Self::code_block_is_style_eligible(ctx, detail)
951                    })
952                {
953                    // Indented blocks cannot carry a fence's info string. In
954                    // consistent mode, choose the lossless direction even when
955                    // indented blocks are more prevalent.
956                    CodeBlockStyle::Fenced
957                } else {
958                    detected
959                }
960            }
961            style => style,
962        }
963    }
964
965    /// Whether a parsed code block participates in MD046 style selection.
966    /// Keep this aligned with the container exclusions in `detect_style` and
967    /// `check` so metadata in an ignored block cannot steer unrelated blocks.
968    fn code_block_is_style_eligible(
969        ctx: &crate::lint_context::LintContext,
970        detail: &crate::utils::code_block_utils::CodeBlockDetail,
971    ) -> bool {
972        let Some(line_idx) = Self::code_block_start_line(ctx, detail) else {
973            return false;
974        };
975
976        !ctx.lines.get(line_idx).is_some_and(|info| {
977            info.in_html_comment
978                || info.in_mdx_comment
979                || info.in_html_block
980                || info.in_jsx_block
981                || info.in_mkdocstrings
982                || info.in_footnote_definition
983                || info.blockquote.is_some()
984                || info.in_front_matter
985        })
986    }
987
988    fn code_block_start_line(
989        ctx: &crate::lint_context::LintContext,
990        detail: &crate::utils::code_block_utils::CodeBlockDetail,
991    ) -> Option<usize> {
992        if detail.start >= ctx.content.len() {
993            return None;
994        }
995
996        Some(match ctx.line_offsets.binary_search(&detail.start) {
997            Ok(idx) => idx,
998            Err(idx) => idx.saturating_sub(1),
999        })
1000    }
1001
1002    /// Fences that must remain as separators between otherwise adjacent code
1003    /// blocks. Converting every block in such a pair to indented form would
1004    /// merge two semantic blocks into one.
1005    fn fenced_separator_lines(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
1006        let mut lines = std::collections::HashSet::new();
1007
1008        for pair in ctx.code_block_details.windows(2) {
1009            let [previous, next] = pair else {
1010                continue;
1011            };
1012            if previous.end > next.start || next.start > ctx.content.len() {
1013                continue;
1014            }
1015            if !ctx.content[previous.end..next.start].trim().is_empty() {
1016                continue;
1017            }
1018
1019            for detail in [previous, next] {
1020                if detail.is_fenced
1021                    && let Some(line) = Self::code_block_start_line(ctx, detail)
1022                {
1023                    lines.insert(line);
1024                }
1025            }
1026        }
1027
1028        lines
1029    }
1030
1031    /// Empty fenced blocks and blocks whose first or last payload line is
1032    /// blank. Indented code blocks cannot represent either shape: Markdown
1033    /// treats boundary blanks as ordinary whitespace outside the block, so
1034    /// these fences must remain.
1035    fn fenced_boundary_blank_lines(
1036        ctx: &crate::lint_context::LintContext,
1037        lines: &[&str],
1038        ictx: &IndentContext,
1039    ) -> std::collections::HashSet<usize> {
1040        let mut boundary_blank_lines = std::collections::HashSet::new();
1041
1042        for detail in ctx.code_block_details.iter().filter(|detail| detail.is_fenced) {
1043            let Some(start) = Self::code_block_start_line(ctx, detail) else {
1044                continue;
1045            };
1046            let Some(opener) = lines.get(start) else {
1047                continue;
1048            };
1049            let baseline = ictx.list_item_baseline.get(start).copied().flatten().unwrap_or(0);
1050            let trimmed = opener.trim_start();
1051            if !Self::has_valid_fence_indent_at(opener, baseline) {
1052                continue;
1053            }
1054            let fence_char = if trimmed.starts_with("```") {
1055                '`'
1056            } else if trimmed.starts_with("~~~") {
1057                '~'
1058            } else {
1059                // A fence on the list-marker line is deliberately left alone
1060                // by the converter; it needs no boundary-blank preflight.
1061                continue;
1062            };
1063            let opener_len = trimmed.chars().take_while(|&ch| ch == fence_char).count();
1064
1065            let mut block_end = start + 1;
1066            let mut closer = None;
1067            while block_end < lines.len()
1068                && ctx
1069                    .line_offsets
1070                    .get(block_end)
1071                    .is_some_and(|&offset| offset < detail.end)
1072            {
1073                if Self::is_closing_fence(lines[block_end], fence_char, opener_len, baseline) {
1074                    closer = Some(block_end);
1075                    break;
1076                }
1077                block_end += 1;
1078            }
1079
1080            let payload_end = closer.unwrap_or(block_end);
1081            if start + 1 == payload_end
1082                || (start + 1 < payload_end
1083                    && (lines[start + 1].trim().is_empty() || lines[payload_end - 1].trim().is_empty()))
1084            {
1085                boundary_blank_lines.insert(start);
1086            }
1087        }
1088
1089        boundary_blank_lines
1090    }
1091
1092    /// Tell the user once that MDG did not adopt the style they configured.
1093    ///
1094    /// Only `indented` is worth reporting: it is the one setting MDG cannot
1095    /// satisfy. `consistent` asks for no particular form, and fenced is what
1096    /// MDG picks for it anyway.
1097    fn warn_once_about_overridden_style(&self) {
1098        if !self.style_explicit || self.config.style != CodeBlockStyle::Indented {
1099            return;
1100        }
1101
1102        MDG_STYLE_OVERRIDE.report(
1103            "MD046",
1104            "style",
1105            "indented",
1106            "fenced",
1107            "a Gherkin Doc String is only ever a backtick fence",
1108        );
1109    }
1110
1111    fn detect_style(
1112        &self,
1113        ctx: &crate::lint_context::LintContext,
1114        lines: &[&str],
1115        is_mkdocs: bool,
1116        ictx: &IndentContext,
1117    ) -> Option<CodeBlockStyle> {
1118        if lines.is_empty() {
1119            return None;
1120        }
1121
1122        let block_lines = self.indented_block_lines(lines, is_mkdocs, ictx, ctx);
1123
1124        let mut fenced_count = 0;
1125        let mut indented_count = 0;
1126
1127        // Count all code block occurrences (prevalence-based approach).
1128        //
1129        // Both counts must ignore fence markers and indented text that live
1130        // inside a non-code container (HTML/MDX comments, raw HTML/JSX
1131        // blocks, mkdocstrings, footnote definitions, blockquotes) so that
1132        // the detected style stays in lockstep with the warning-side skip
1133        // list in `check`. Without this, a document that contains a single
1134        // real code block plus a fake fence or indented paragraph nested in
1135        // a comment is wrongly classified and the real block gets flagged.
1136        let mut in_fenced = false;
1137        let mut prev_was_indented = false;
1138
1139        for (i, line) in lines.iter().enumerate() {
1140            let in_container = ictx.in_comment_or_html.get(i).copied().unwrap_or(false);
1141
1142            // Lines inside Azure DevOps colon code fences are verbatim content.
1143            // Any fence markers they contain are not real block delimiters and
1144            // must not influence the fenced/indented style tally.
1145            if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|l| l.in_code_block) {
1146                prev_was_indented = false;
1147                continue;
1148            }
1149
1150            // Lines inside MyST colon directives are structural containers, not code blocks.
1151            if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|l| l.in_myst_directive) {
1152                prev_was_indented = false;
1153                continue;
1154            }
1155
1156            let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1157            if self.is_fenced_code_block_start_at(line, baseline) {
1158                if in_container {
1159                    // Fence marker inside a container — not a real fence,
1160                    // don't flip state or count it.
1161                    prev_was_indented = false;
1162                    continue;
1163                }
1164                if !in_fenced {
1165                    // Opening fence
1166                    fenced_count += 1;
1167                    in_fenced = true;
1168                } else {
1169                    // Closing fence
1170                    in_fenced = false;
1171                }
1172                prev_was_indented = false;
1173            } else if !in_fenced && block_lines[i] {
1174                // Count each continuous indented block once
1175                if !prev_was_indented {
1176                    indented_count += 1;
1177                }
1178                prev_was_indented = true;
1179            } else {
1180                prev_was_indented = false;
1181            }
1182        }
1183
1184        if fenced_count == 0 && indented_count == 0 {
1185            None
1186        } else if fenced_count > 0 && indented_count == 0 {
1187            Some(CodeBlockStyle::Fenced)
1188        } else if fenced_count == 0 && indented_count > 0 {
1189            Some(CodeBlockStyle::Indented)
1190        } else if fenced_count >= indented_count {
1191            Some(CodeBlockStyle::Fenced)
1192        } else {
1193            Some(CodeBlockStyle::Indented)
1194        }
1195    }
1196}
1197
1198impl Rule for MD046CodeBlockStyle {
1199    fn name(&self) -> &'static str {
1200        "MD046"
1201    }
1202
1203    fn description(&self) -> &'static str {
1204        "Code blocks should use a consistent style"
1205    }
1206
1207    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1208        // Early return for empty content
1209        if ctx.content.is_empty() {
1210            return Ok(Vec::new());
1211        }
1212
1213        // Quick check for code blocks before processing
1214        if !ctx.content.contains("```")
1215            && !ctx.content.contains("~~~")
1216            && !ctx.content.contains("    ")
1217            && !ctx.content.contains('\t')
1218        {
1219            return Ok(Vec::new());
1220        }
1221
1222        // First, always check for unclosed code blocks
1223        let unclosed_warnings = self.check_unclosed_code_blocks(ctx);
1224
1225        // If we found unclosed blocks, return those warnings first
1226        if !unclosed_warnings.is_empty() {
1227            return Ok(unclosed_warnings);
1228        }
1229
1230        // Check for code block style consistency
1231        let lines = ctx.raw_lines();
1232        let mut warnings = Vec::new();
1233
1234        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1235
1236        // Determine the target style
1237        let target_style = self.effective_target_style(ctx, || {
1238            let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1239            let detected = self.detect_style(ctx, lines, is_mkdocs, &owned.borrow());
1240            detected.unwrap_or(CodeBlockStyle::Fenced)
1241        });
1242
1243        // Under MDG, `indented_block_lines` is the single source of truth for
1244        // which indented lines are code and which are Gherkin Data/Examples
1245        // table rows. Reading the array `fix` converts from — rather than
1246        // re-deciding it here — is what keeps the two paths in agreement.
1247        let mdg_block_lines = (ctx.flavor == crate::config::MarkdownFlavor::MDG
1248            && ctx.code_block_details.iter().any(|detail| !detail.is_fenced))
1249        .then(|| {
1250            let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1251            self.indented_block_lines(lines, is_mkdocs, &owned.borrow(), ctx)
1252        });
1253
1254        // Iterate code_block_details directly (O(k) where k is number of blocks)
1255        let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
1256
1257        for detail in &ctx.code_block_details {
1258            if detail.start >= ctx.content.len() || detail.end > ctx.content.len() {
1259                continue;
1260            }
1261
1262            let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
1263                Ok(idx) => idx,
1264                Err(idx) => idx.saturating_sub(1),
1265            };
1266
1267            if detail.is_fenced {
1268                if target_style == CodeBlockStyle::Indented {
1269                    let line = lines.get(start_line_idx).unwrap_or(&"");
1270
1271                    if ctx
1272                        .lines
1273                        .get(start_line_idx)
1274                        .is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
1275                    {
1276                        continue;
1277                    }
1278
1279                    let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1280                    warnings.push(LintWarning {
1281                        rule_name: Some(self.name().to_string()),
1282                        line: start_line,
1283                        column: start_col,
1284                        end_line,
1285                        end_column: end_col,
1286                        message: "Use indented code blocks".to_string(),
1287                        severity: Severity::Warning,
1288                        fix: None,
1289                    });
1290                }
1291            } else {
1292                // Indented code block
1293                if target_style == CodeBlockStyle::Fenced {
1294                    // Under MDG the block may open on Gherkin table rows that
1295                    // are not code; the line to report is the first one the fix
1296                    // will fence, and a block of nothing but rows is no code
1297                    // block at all.
1298                    let start_line_idx = match &mdg_block_lines {
1299                        Some(block_lines) => {
1300                            match Self::first_code_block_line(ctx, block_lines, start_line_idx, detail.end) {
1301                                Some(idx) => idx,
1302                                None => continue,
1303                            }
1304                        }
1305                        None => start_line_idx,
1306                    };
1307
1308                    if reported_indented_lines.contains(&start_line_idx) {
1309                        continue;
1310                    }
1311
1312                    let line = lines.get(start_line_idx).unwrap_or(&"");
1313
1314                    // Skip blocks in contexts that aren't real indented code blocks
1315                    if ctx.lines.get(start_line_idx).is_some_and(|info| {
1316                        info.in_html_comment
1317                            || info.in_mdx_comment
1318                            || info.in_html_block
1319                            || info.in_jsx_block
1320                            || info.in_mkdocstrings
1321                            || info.in_footnote_definition
1322                            || info.blockquote.is_some()
1323                            || info.in_front_matter
1324                    }) {
1325                        continue;
1326                    }
1327
1328                    // Use pre-computed LineInfo for MkDocs container context
1329                    if is_mkdocs
1330                        && ctx
1331                            .lines
1332                            .get(start_line_idx)
1333                            .is_some_and(|info| info.in_admonition || info.in_content_tab)
1334                    {
1335                        continue;
1336                    }
1337
1338                    reported_indented_lines.insert(start_line_idx);
1339
1340                    let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1341                    warnings.push(LintWarning {
1342                        rule_name: Some(self.name().to_string()),
1343                        line: start_line,
1344                        column: start_col,
1345                        end_line,
1346                        end_column: end_col,
1347                        message: "Use fenced code blocks".to_string(),
1348                        severity: Severity::Warning,
1349                        fix: None,
1350                    });
1351                }
1352            }
1353        }
1354
1355        // Sort warnings by line number for consistent output
1356        warnings.sort_by_key(|w| (w.line, w.column));
1357
1358        Ok(warnings)
1359    }
1360
1361    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1362        let content = ctx.content;
1363        if content.is_empty() {
1364            return Ok(String::new());
1365        }
1366
1367        let lines = ctx.raw_lines();
1368
1369        // Determine target style
1370        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1371
1372        let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1373        let ictx = owned.borrow();
1374
1375        // The unclosed-fence repair at the end of this function is a repair
1376        // rather than a style conversion: `check` reports it before any style is
1377        // resolved, so the loop below has to run even when no block needs
1378        // converting.
1379        let target_style = self.effective_target_style(ctx, || {
1380            self.detect_style(ctx, lines, is_mkdocs, &ictx)
1381                .unwrap_or(CodeBlockStyle::Fenced)
1382        });
1383
1384        let block_lines = self.indented_block_lines(lines, is_mkdocs, &ictx, ctx);
1385        let fenced_separator_lines = if target_style == CodeBlockStyle::Indented {
1386            Self::fenced_separator_lines(ctx)
1387        } else {
1388            std::collections::HashSet::new()
1389        };
1390        let fenced_boundary_blank_lines = if target_style == CodeBlockStyle::Indented {
1391            Self::fenced_boundary_blank_lines(ctx, lines, &ictx)
1392        } else {
1393            std::collections::HashSet::new()
1394        };
1395        // Trust the parser for opener identity. In particular, a fence may
1396        // open on a list-marker line (`- ```); its later closer must never be
1397        // mistaken for a fresh opener merely because it starts with backticks.
1398        let fenced_start_lines: std::collections::HashSet<usize> = ctx
1399            .code_block_details
1400            .iter()
1401            .filter(|detail| detail.is_fenced)
1402            .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1403            .collect();
1404        let has_unsupported_fence_opener = ctx
1405            .code_block_details
1406            .iter()
1407            .filter(|detail| detail.is_fenced && Self::code_block_is_style_eligible(ctx, detail))
1408            .filter_map(|detail| Self::code_block_start_line(ctx, detail))
1409            .any(|line_index| {
1410                let Some(line) = lines.get(line_index) else {
1411                    return true;
1412                };
1413                let baseline = ictx.list_item_baseline.get(line_index).copied().flatten().unwrap_or(0);
1414                !self.is_fenced_code_block_start_at(line, baseline)
1415            });
1416
1417        // Categorize indented blocks:
1418        // - misplaced_fence_lines: complete fenced blocks that were over-indented (safe to dedent)
1419        // - unsafe_fence_lines: contain fence markers but aren't complete (skip fixing to avoid broken output)
1420        let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(lines, &block_lines);
1421
1422        let mut result = String::with_capacity(content.len());
1423        let mut in_fenced_block = false;
1424        // Tracks the opening fence: (fence_char, opener_length).
1425        // Per CommonMark spec, the closing fence must use the same character and have
1426        // at least as many characters as the opener, with no info string.
1427        let mut fenced_fence_opener: Option<(char, usize)> = None;
1428        let mut in_indented_block = false;
1429        // Indent string emitted on the opening fence of the current
1430        // indented→fenced conversion (e.g. "  " for an indented block inside
1431        // a `- ` list item, "" at top level). Reused on close so the closing
1432        // fence sits at the same column as the opener.
1433        let mut current_block_fence_indent = String::new();
1434
1435        // Track whether the current fenced block must be preserved. Inline
1436        // config can disable the rule, and indented code blocks have no
1437        // representation for a fence's info string.
1438        let mut current_block_must_stay_fenced = false;
1439        let mut current_fence_indent = 0usize;
1440        let mut current_fence_baseline = 0usize;
1441        let mut current_block_indented_prefix = String::from("    ");
1442        let mut converted_fenced_to_indented = false;
1443        let mut retained_structurally_unsafe_fence =
1444            target_style == CodeBlockStyle::Indented && has_unsupported_fence_opener;
1445
1446        for (i, line) in lines.iter().enumerate() {
1447            let line_num = i + 1;
1448            let trimmed = line.trim_start();
1449            let list_baseline = ictx.list_item_baseline.get(i).copied().flatten();
1450            let fence_baseline = list_baseline.unwrap_or(0);
1451
1452            // Handle fenced code blocks
1453            // Per CommonMark: fence must have 0-3 spaces of indentation
1454            if !in_fenced_block
1455                && fenced_start_lines.contains(&i)
1456                && Self::has_valid_fence_indent_at(line, fence_baseline)
1457                && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1458            {
1459                // Check if inline config disables this rule for the opening fence
1460                let block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
1461                in_fenced_block = true;
1462                let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
1463                let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
1464                fenced_fence_opener = Some((fence_char, opener_len));
1465                current_fence_indent = calculate_indentation_width_default(line);
1466                current_fence_baseline = fence_baseline;
1467                current_block_indented_prefix = " ".repeat(fence_baseline + 4);
1468                let follows_list_item = i
1469                    .checked_sub(1)
1470                    .and_then(|previous| ictx.list_item_baseline.get(previous))
1471                    .copied()
1472                    .flatten()
1473                    .is_some();
1474                let would_become_list_prose = target_style == CodeBlockStyle::Indented
1475                    && list_baseline.is_none()
1476                    && (ictx.in_list_context.get(i).copied().unwrap_or(false) || follows_list_item);
1477                let would_interrupt_paragraph = target_style == CodeBlockStyle::Indented
1478                    && i > 0
1479                    && !lines[i - 1].trim().is_empty()
1480                    && ctx
1481                        .lines
1482                        .get(i - 1)
1483                        .is_some_and(crate::lint_context::LineInfo::is_paragraph_context)
1484                    && crate::lint_context::is_paragraph_text_line(lines[i - 1]);
1485                let would_merge_code_blocks = fenced_separator_lines.contains(&i);
1486                let would_lose_boundary_blanks = fenced_boundary_blank_lines.contains(&i);
1487                current_block_must_stay_fenced = block_disabled
1488                    || !trimmed[opener_len..].trim().is_empty()
1489                    || would_become_list_prose
1490                    || would_interrupt_paragraph
1491                    || would_merge_code_blocks
1492                    || would_lose_boundary_blanks;
1493                retained_structurally_unsafe_fence |= would_become_list_prose
1494                    || would_interrupt_paragraph
1495                    || would_merge_code_blocks
1496                    || would_lose_boundary_blanks;
1497
1498                if current_block_must_stay_fenced {
1499                    // Inline config disables this rule, or converting would
1500                    // discard the fence's info string — preserve original.
1501                    result.push_str(line);
1502                    result.push('\n');
1503                } else if target_style == CodeBlockStyle::Indented {
1504                    // Skip the opening fence
1505                    in_indented_block = true;
1506                    converted_fenced_to_indented = true;
1507                } else {
1508                    // Keep the fenced block
1509                    result.push_str(line);
1510                    result.push('\n');
1511                }
1512            } else if in_fenced_block && fenced_fence_opener.is_some() {
1513                let (fence_char, opener_len) = fenced_fence_opener.unwrap();
1514                // Per CommonMark: closing fence uses the same character, has at least as
1515                // many characters as the opener, and has no info string (only optional trailing spaces).
1516                let is_closer = Self::is_closing_fence(line, fence_char, opener_len, current_fence_baseline);
1517                if is_closer {
1518                    in_fenced_block = false;
1519                    fenced_fence_opener = None;
1520                    in_indented_block = false;
1521
1522                    if current_block_must_stay_fenced {
1523                        result.push_str(line);
1524                        result.push('\n');
1525                    } else if target_style == CodeBlockStyle::Indented {
1526                        // Skip the closing fence
1527                    } else {
1528                        // Keep the fenced block
1529                        result.push_str(line);
1530                        result.push('\n');
1531                    }
1532                    current_block_must_stay_fenced = false;
1533                    current_fence_indent = 0;
1534                    current_fence_baseline = 0;
1535                    current_block_indented_prefix.clear();
1536                } else if current_block_must_stay_fenced {
1537                    // Preserve every line of a block whose opener was kept.
1538                    result.push_str(line);
1539                    result.push('\n');
1540                } else if target_style == CodeBlockStyle::Indented {
1541                    // Convert content inside fenced block to indented.
1542                    // IMPORTANT: Preserve the original line content (including internal indentation);
1543                    // don't use trimmed, as that would strip internal code indentation.
1544                    // Leave blank lines empty so we don't emit "    " (trailing
1545                    // whitespace), which MD009 would flag and which would break
1546                    // idempotency on a second fix pass.
1547                    if !line.is_empty() {
1548                        // CommonMark removes up to the opening fence's indent
1549                        // from each body line. Remove the same source prefix
1550                        // before adding the indented-code prefix so parsed code
1551                        // content remains byte-for-byte equivalent.
1552                        let body = Self::strip_indentation_columns(line, current_fence_indent);
1553                        result.push_str(&current_block_indented_prefix);
1554                        result.push_str(&body);
1555                    }
1556                    result.push('\n');
1557                } else {
1558                    // Keep fenced block content as is
1559                    result.push_str(line);
1560                    result.push('\n');
1561                }
1562            } else if block_lines[i] {
1563                // This is an indented code block
1564
1565                // Respect inline disable comments
1566                if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
1567                    result.push_str(line);
1568                    result.push('\n');
1569                    continue;
1570                }
1571
1572                // Check if we need to start a new fenced block
1573                let prev_line_is_indented = i > 0 && block_lines[i - 1];
1574
1575                if target_style == CodeBlockStyle::Fenced {
1576                    // Anchor fences at the list-item content baseline when
1577                    // converting a list-internal indented block (e.g. column
1578                    // 2 for `- `), so the new fenced block stays attached
1579                    // to the bullet. Top-level indented blocks have no
1580                    // baseline → fences sit at column 0.
1581                    let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1582                    // Per CommonMark, the indented-code prefix is exactly 4
1583                    // spaces past the surrounding container's content
1584                    // column. Strip those 4 spaces (not all leading
1585                    // whitespace) so any internal indentation past that
1586                    // point is preserved verbatim in the fenced body. An
1587                    // interior blank line carries no content, so it is
1588                    // emitted empty rather than as leftover whitespace.
1589                    let body = if line.trim().is_empty() {
1590                        String::new()
1591                    } else {
1592                        Self::strip_indentation_columns(line, 4)
1593                    };
1594
1595                    // Check if this line is part of a misplaced fenced block
1596                    // (pre-computed block-level analysis, not per-line)
1597                    if misplaced_fence_lines[i] {
1598                        // Just remove the indentation - this is a complete misplaced fenced block
1599                        result.push_str(line.trim_start());
1600                        result.push('\n');
1601                    } else if unsafe_fence_lines[i] {
1602                        // This block contains fence markers but isn't a complete fenced block
1603                        // Wrapping would create invalid nested fences - keep as-is (don't fix)
1604                        result.push_str(line);
1605                        result.push('\n');
1606                    } else if !prev_line_is_indented && !in_indented_block {
1607                        // Start of a new indented block that should be fenced
1608                        current_block_fence_indent = " ".repeat(baseline);
1609                        result.push_str(&current_block_fence_indent);
1610                        result.push_str(Self::FENCE);
1611                        result.push('\n');
1612                        result.push_str(&body);
1613                        result.push('\n');
1614                        in_indented_block = true;
1615                    } else {
1616                        // Inside an indented block
1617                        result.push_str(&body);
1618                        result.push('\n');
1619                    }
1620
1621                    // Check if this is the end of the indented block
1622                    let next_line_is_indented = i < lines.len() - 1 && block_lines[i + 1];
1623                    // Don't close if this is an unsafe block (kept as-is)
1624                    if !next_line_is_indented
1625                        && in_indented_block
1626                        && !misplaced_fence_lines[i]
1627                        && !unsafe_fence_lines[i]
1628                    {
1629                        result.push_str(&current_block_fence_indent);
1630                        result.push_str(Self::FENCE);
1631                        result.push('\n');
1632                        in_indented_block = false;
1633                        current_block_fence_indent.clear();
1634                    }
1635                } else {
1636                    // Keep indented block as is
1637                    result.push_str(line);
1638                    result.push('\n');
1639                }
1640            } else {
1641                // Regular line
1642                if in_indented_block && target_style == CodeBlockStyle::Fenced {
1643                    result.push_str(&current_block_fence_indent);
1644                    result.push_str(Self::FENCE);
1645                    result.push('\n');
1646                    in_indented_block = false;
1647                    current_block_fence_indent.clear();
1648                }
1649
1650                result.push_str(line);
1651                result.push('\n');
1652            }
1653        }
1654
1655        // Close any remaining blocks
1656        if in_indented_block && target_style == CodeBlockStyle::Fenced {
1657            result.push_str(&current_block_fence_indent);
1658            result.push_str(Self::FENCE);
1659            result.push('\n');
1660        }
1661
1662        // Close any unclosed fenced blocks.
1663        // Only close if check() also confirms this block is unclosed. The line-by-line
1664        // fence scanner in fix() can disagree with pulldown-cmark on block boundaries
1665        // (e.g., markdown documentation blocks with nested fence examples), so we use
1666        // check_unclosed_code_blocks() as the authoritative source of truth.
1667        if let Some((fence_char, opener_len)) = fenced_fence_opener
1668            && in_fenced_block
1669        {
1670            let has_unclosed_violation = !self.check_unclosed_code_blocks(ctx).is_empty();
1671            // A converted untagged block needs no closer: the indentation is
1672            // its complete delimiter. Preserved/tagged fences still need the
1673            // missing closer repaired.
1674            if has_unclosed_violation && (target_style != CodeBlockStyle::Indented || current_block_must_stay_fenced) {
1675                let closer: String = std::iter::repeat_n(fence_char, opener_len).collect();
1676                result.push_str(&closer);
1677                result.push('\n');
1678            }
1679        }
1680
1681        // Remove trailing newline if original didn't have one
1682        if !content.ends_with('\n') && result.ends_with('\n') {
1683            result.pop();
1684        }
1685
1686        if retained_structurally_unsafe_fence && self.config.style == CodeBlockStyle::Consistent {
1687            return Self::new(CodeBlockStyle::Fenced).fix(ctx);
1688        }
1689
1690        if converted_fenced_to_indented {
1691            let reparsed_block_count = crate::utils::CodeBlockUtils::detect_code_blocks(&result).len();
1692            if reparsed_block_count != ctx.code_block_details.len() {
1693                // A fenced block can interrupt structures that an indented
1694                // block cannot. In consistent mode, fenced is the only
1695                // lossless way to converge; an explicit indented preference
1696                // is instead left unchanged.
1697                if self.config.style == CodeBlockStyle::Consistent {
1698                    return Self::new(CodeBlockStyle::Fenced).fix(ctx);
1699                }
1700
1701                return Ok(content.to_string());
1702            }
1703        }
1704
1705        Ok(result)
1706    }
1707
1708    /// Get the category of this rule for selective processing
1709    fn category(&self) -> RuleCategory {
1710        RuleCategory::CodeBlock
1711    }
1712
1713    fn fix_capability(&self) -> FixCapability {
1714        // Tagged fences and conversions that would change CommonMark block
1715        // structure are intentionally retained rather than fixed lossily.
1716        FixCapability::ConditionallyFixable
1717    }
1718
1719    /// Check if this rule should be skipped
1720    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1721        // Skip if content is empty or unlikely to contain code blocks
1722        // Note: indented code blocks use 4 spaces, can't optimize that easily
1723        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains("    "))
1724    }
1725
1726    fn as_any(&self) -> &dyn std::any::Any {
1727        self
1728    }
1729
1730    crate::impl_rule_config_sections!(MD046Config);
1731
1732    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1733    where
1734        Self: Sized,
1735    {
1736        let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1737        let style_explicit = option_is_explicit(config, "MD046", "style");
1738
1739        Box::new(Self {
1740            config: rule_config,
1741            style_explicit,
1742        })
1743    }
1744}
1745
1746#[cfg(test)]
1747mod tests {
1748    use super::*;
1749    use crate::lint_context::LintContext;
1750
1751    /// Test helper: detect_style with automatic context computation.
1752    ///
1753    /// The container context (HTML/MDX comments, HTML/JSX blocks,
1754    /// mkdocstrings, footnote definitions, blockquotes) is not populated by
1755    /// this helper — callers that need to exercise those paths should go
1756    /// through the full `rule.check(&ctx)` entry point so the real LineInfo
1757    /// is computed from a `LintContext`.
1758    ///
1759    /// Colon fence exclusion is also not active here: tests that need Azure
1760    /// DevOps colon fence skipping must use the full `check` entry point with
1761    /// an `AzureDevOps` flavor `LintContext`.
1762    fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
1763        let flavor = if is_mkdocs {
1764            crate::config::MarkdownFlavor::MkDocs
1765        } else {
1766            crate::config::MarkdownFlavor::Standard
1767        };
1768        let ctx = LintContext::new(content, flavor, None);
1769        let lines: Vec<&str> = content.lines().collect();
1770        let in_list_context = rule.precompute_block_continuation_context(&lines);
1771        let in_tab_context = if is_mkdocs {
1772            rule.precompute_mkdocs_tab_context(&lines)
1773        } else {
1774            vec![false; lines.len()]
1775        };
1776        let in_admonition_context = if is_mkdocs {
1777            rule.precompute_mkdocs_admonition_context(&lines)
1778        } else {
1779            vec![false; lines.len()]
1780        };
1781        let in_comment_or_html = vec![false; lines.len()];
1782        // List baseline is None for every line: this helper preserves the
1783        // pre-baseline behavior where any list-context line is conservatively
1784        // skipped. Tests that need list-internal indented code blocks
1785        // recognized must drive the rule through `check`/`fix` with a real
1786        // `LintContext`.
1787        let list_item_baseline: Vec<Option<usize>> = vec![None; lines.len()];
1788        let ictx = IndentContext {
1789            in_list_context: &in_list_context,
1790            in_tab_context: &in_tab_context,
1791            in_admonition_context: &in_admonition_context,
1792            in_comment_or_html: &in_comment_or_html,
1793            list_item_baseline: &list_item_baseline,
1794        };
1795        rule.detect_style(&ctx, &lines, is_mkdocs, &ictx)
1796    }
1797
1798    #[test]
1799    fn test_fenced_code_block_detection() {
1800        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1801        assert!(rule.is_fenced_code_block_start("```"));
1802        assert!(rule.is_fenced_code_block_start("```rust"));
1803        assert!(rule.is_fenced_code_block_start("~~~"));
1804        assert!(rule.is_fenced_code_block_start("~~~python"));
1805        assert!(rule.is_fenced_code_block_start("  ```"));
1806        assert!(!rule.is_fenced_code_block_start("``"));
1807        assert!(!rule.is_fenced_code_block_start("~~"));
1808        assert!(!rule.is_fenced_code_block_start("Regular text"));
1809    }
1810
1811    #[test]
1812    fn test_fix_capability_is_conditional() {
1813        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1814        assert_eq!(rule.fix_capability(), FixCapability::ConditionallyFixable);
1815    }
1816
1817    #[test]
1818    fn test_consistent_style_with_fenced_blocks() {
1819        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1820        let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1821        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1822        let result = rule.check(&ctx).unwrap();
1823
1824        // All blocks are fenced, so consistent style should be OK
1825        assert_eq!(result.len(), 0);
1826    }
1827
1828    #[test]
1829    fn test_consistent_style_with_indented_blocks() {
1830        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1831        let content = "Text\n\n    code\n    more code\n\nMore text\n\n    another block";
1832        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1833        let result = rule.check(&ctx).unwrap();
1834
1835        // All blocks are indented, so consistent style should be OK
1836        assert_eq!(result.len(), 0);
1837    }
1838
1839    #[test]
1840    fn test_consistent_style_mixed() {
1841        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1842        let content = "```\nfenced code\n```\n\nText\n\n    indented code\n\nMore";
1843        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1844        let result = rule.check(&ctx).unwrap();
1845
1846        // Mixed styles should be flagged
1847        assert!(!result.is_empty());
1848    }
1849
1850    #[test]
1851    fn test_fenced_style_with_indented_blocks() {
1852        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1853        let content = "Text\n\n    indented code\n    more code\n\nMore text";
1854        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1855        let result = rule.check(&ctx).unwrap();
1856
1857        // Indented blocks should be flagged when fenced style is required
1858        assert!(!result.is_empty());
1859        assert!(result[0].message.contains("Use fenced code blocks"));
1860    }
1861
1862    #[test]
1863    fn test_fenced_style_with_tab_indented_blocks() {
1864        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1865        let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1866        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1867        let result = rule.check(&ctx).unwrap();
1868
1869        // Tab-indented blocks should also be flagged when fenced style is required
1870        assert!(!result.is_empty());
1871        assert!(result[0].message.contains("Use fenced code blocks"));
1872    }
1873
1874    #[test]
1875    fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1876        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1877        // 2 spaces + tab = 4 columns due to tab expansion (tab goes to column 4)
1878        let content = "Text\n\n  \tmixed indent code\n  \tmore code\n\nMore text";
1879        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880        let result = rule.check(&ctx).unwrap();
1881
1882        // Mixed whitespace indented blocks should also be flagged
1883        assert!(
1884            !result.is_empty(),
1885            "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1886        );
1887        assert!(result[0].message.contains("Use fenced code blocks"));
1888    }
1889
1890    #[test]
1891    fn test_fenced_style_with_one_space_tab_indent() {
1892        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1893        // 1 space + tab = 4 columns (tab expands to next tab stop at column 4)
1894        let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1895        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896        let result = rule.check(&ctx).unwrap();
1897
1898        assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1899        assert!(result[0].message.contains("Use fenced code blocks"));
1900    }
1901
1902    #[test]
1903    fn test_indented_style_with_fenced_blocks() {
1904        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1905        let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1906        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1907        let result = rule.check(&ctx).unwrap();
1908
1909        // Fenced blocks should be flagged when indented style is required
1910        assert!(!result.is_empty());
1911        assert!(result[0].message.contains("Use indented code blocks"));
1912    }
1913
1914    #[test]
1915    fn test_unclosed_code_block() {
1916        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1917        let content = "```\ncode without closing fence";
1918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1919        let result = rule.check(&ctx).unwrap();
1920
1921        assert_eq!(result.len(), 1);
1922        assert!(result[0].message.contains("never closed"));
1923    }
1924
1925    #[test]
1926    fn test_nested_code_blocks() {
1927        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1928        let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1930        let result = rule.check(&ctx).unwrap();
1931
1932        // This should parse as two separate code blocks
1933        assert_eq!(result.len(), 0);
1934    }
1935
1936    #[test]
1937    fn test_fix_indented_to_fenced() {
1938        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1939        let content = "Text\n\n    code line 1\n    code line 2\n\nMore text";
1940        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1941        let fixed = rule.fix(&ctx).unwrap();
1942
1943        assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1944    }
1945
1946    #[test]
1947    fn test_fix_fenced_to_indented() {
1948        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1949        let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1950        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951        let fixed = rule.fix(&ctx).unwrap();
1952
1953        assert!(fixed.contains("    code line 1\n    code line 2"));
1954        assert!(!fixed.contains("```"));
1955    }
1956
1957    #[test]
1958    fn test_fix_fenced_to_indented_blank_lines_have_no_trailing_spaces() {
1959        // A blank line inside a fenced block must become an empty line, not
1960        // "    " (four trailing spaces), which would violate MD009 and break
1961        // idempotency on the second fix pass.
1962        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1963        let content = "Text\n\n```\ncode line 1\n\ncode line 2\n```\n\nMore text";
1964        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1965        let fixed = rule.fix(&ctx).unwrap();
1966
1967        for line in fixed.lines() {
1968            assert!(
1969                line.is_empty() || !line.trim_end().is_empty() || line == line.trim_end(),
1970                "no line may have trailing whitespace, got {line:?}"
1971            );
1972            assert_ne!(line, "    ", "blank line was indented to trailing spaces");
1973        }
1974        // The blank line between the two code lines is preserved as empty.
1975        assert!(fixed.contains("    code line 1\n\n    code line 2"));
1976    }
1977
1978    #[test]
1979    fn test_is_list_item_requires_delimiter_after_digits() {
1980        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1981        // Real ordered list items.
1982        assert!(rule.is_list_item("1. First"));
1983        assert!(rule.is_list_item("42) Item"));
1984        assert!(rule.is_list_item("  3. Indented item"));
1985        // Bullet list items.
1986        assert!(rule.is_list_item("- bullet"));
1987        assert!(rule.is_list_item("* bullet"));
1988        // Prose starting with a digit but containing ". " or ") " mid-sentence
1989        // is NOT a list item.
1990        assert!(!rule.is_list_item("2 results. More info."));
1991        assert!(!rule.is_list_item("3 options (a, b) here"));
1992        assert!(!rule.is_list_item("100 items in stock. Buy now"));
1993    }
1994
1995    #[test]
1996    fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1997        // Issue #270: When converting fenced code to indented, internal indentation must be preserved
1998        // HTML templates, Python, etc. rely on proper indentation
1999        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2000        let content = r#"# Test
2001
2002```
2003<!doctype html>
2004<html>
2005  <head>
2006    <title>Test</title>
2007  </head>
2008</html>
2009```
2010"#;
2011        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2012        let fixed = rule.fix(&ctx).unwrap();
2013
2014        // The internal indentation (2 spaces for <head>, 4 for <title>) must be preserved
2015        // Each line gets 4 spaces prepended for the indented code block
2016        assert!(
2017            fixed.contains("      <head>"),
2018            "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
2019        );
2020        assert!(
2021            fixed.contains("        <title>"),
2022            "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
2023        );
2024        assert!(!fixed.contains("```"), "Fenced markers should be removed");
2025    }
2026
2027    #[test]
2028    fn test_fix_fenced_to_indented_preserves_python_indentation() {
2029        // Issue #270: Python is indentation-sensitive - must preserve internal structure
2030        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2031        let content = r#"# Python Example
2032
2033```
2034def greet(name):
2035    if name:
2036        print(f"Hello, {name}!")
2037    else:
2038        print("Hello, World!")
2039```
2040"#;
2041        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2042        let fixed = rule.fix(&ctx).unwrap();
2043
2044        // Python indentation must be preserved exactly
2045        assert!(
2046            fixed.contains("    def greet(name):"),
2047            "Function def should have 4 spaces (code block indent)"
2048        );
2049        assert!(
2050            fixed.contains("        if name:"),
2051            "if statement should have 8 spaces (4 code + 4 Python)"
2052        );
2053        assert!(
2054            fixed.contains("            print"),
2055            "print should have 12 spaces (4 code + 8 Python)"
2056        );
2057    }
2058
2059    #[test]
2060    fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
2061        // Issue #270: YAML is also indentation-sensitive
2062        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2063        let content = r#"# Config
2064
2065```
2066server:
2067  host: localhost
2068  port: 8080
2069  ssl:
2070    enabled: true
2071    cert: /path/to/cert
2072```
2073"#;
2074        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2075        let fixed = rule.fix(&ctx).unwrap();
2076
2077        assert!(fixed.contains("    server:"), "Root key should have 4 spaces");
2078        assert!(fixed.contains("      host:"), "First level should have 6 spaces");
2079        assert!(fixed.contains("      ssl:"), "ssl key should have 6 spaces");
2080        assert!(fixed.contains("        enabled:"), "Nested ssl should have 8 spaces");
2081    }
2082
2083    #[test]
2084    fn test_fix_fenced_to_indented_preserves_empty_lines() {
2085        // Blank lines within a converted code block stay blank: they keep their
2086        // place but must not gain the 4-space prefix (that would be trailing
2087        // whitespace).
2088        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2089        let content = "```\nline1\n\nline2\n```\n";
2090        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2091        let fixed = rule.fix(&ctx).unwrap();
2092
2093        // Content lines are indented; the blank line between them stays empty.
2094        assert!(fixed.contains("    line1"), "line1 should be indented");
2095        assert!(fixed.contains("    line2"), "line2 should be indented");
2096        assert!(
2097            fixed.contains("    line1\n\n    line2"),
2098            "blank line must stay empty, got {fixed:?}"
2099        );
2100    }
2101
2102    #[test]
2103    fn test_fix_fenced_to_indented_multiple_blocks() {
2104        // Multiple fenced blocks should all preserve their indentation
2105        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2106        let content = r#"# Doc
2107
2108```
2109def foo():
2110    pass
2111```
2112
2113Text between.
2114
2115```
2116key:
2117  value: 1
2118```
2119"#;
2120        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2121        let fixed = rule.fix(&ctx).unwrap();
2122
2123        assert!(fixed.contains("    def foo():"), "Python def should be indented");
2124        assert!(fixed.contains("        pass"), "Python body should have 8 spaces");
2125        assert!(fixed.contains("    key:"), "YAML root should have 4 spaces");
2126        assert!(fixed.contains("      value:"), "YAML nested should have 6 spaces");
2127        assert!(!fixed.contains("```"), "No fence markers should remain");
2128    }
2129
2130    #[test]
2131    fn test_fix_unclosed_block() {
2132        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2133        let content = "```\ncode without closing";
2134        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2135        let fixed = rule.fix(&ctx).unwrap();
2136
2137        // Should add closing fence
2138        assert!(fixed.ends_with("```"));
2139    }
2140
2141    #[test]
2142    fn test_code_block_in_list() {
2143        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2144        let content = "- List item\n    code in list\n    more code\n- Next item";
2145        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2146        let result = rule.check(&ctx).unwrap();
2147
2148        // Code in lists should not be flagged
2149        assert_eq!(result.len(), 0);
2150    }
2151
2152    #[test]
2153    fn test_detect_style_fenced() {
2154        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2155        let content = "```\ncode\n```";
2156        let style = detect_style_from_content(&rule, content, false);
2157
2158        assert_eq!(style, Some(CodeBlockStyle::Fenced));
2159    }
2160
2161    #[test]
2162    fn test_detect_style_indented() {
2163        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2164        let content = "Text\n\n    code\n\nMore";
2165        let style = detect_style_from_content(&rule, content, false);
2166
2167        assert_eq!(style, Some(CodeBlockStyle::Indented));
2168    }
2169
2170    #[test]
2171    fn test_detect_style_none() {
2172        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2173        let content = "No code blocks here";
2174        let style = detect_style_from_content(&rule, content, false);
2175
2176        assert_eq!(style, None);
2177    }
2178
2179    #[test]
2180    fn test_tilde_fence() {
2181        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2182        let content = "~~~\ncode\n~~~";
2183        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2184        let result = rule.check(&ctx).unwrap();
2185
2186        // Tilde fences should be accepted as fenced blocks
2187        assert_eq!(result.len(), 0);
2188    }
2189
2190    #[test]
2191    fn test_language_specification() {
2192        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2193        let content = "```rust\nfn main() {}\n```";
2194        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2195        let result = rule.check(&ctx).unwrap();
2196
2197        assert_eq!(result.len(), 0);
2198    }
2199
2200    #[test]
2201    fn test_empty_content() {
2202        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2203        let content = "";
2204        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2205        let result = rule.check(&ctx).unwrap();
2206
2207        assert_eq!(result.len(), 0);
2208    }
2209
2210    #[test]
2211    fn test_default_config() {
2212        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2213        let (name, _config) = rule.default_config_section().unwrap();
2214        assert_eq!(name, "MD046");
2215    }
2216
2217    #[test]
2218    fn test_markdown_documentation_block() {
2219        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2220        let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
2221        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2222        let result = rule.check(&ctx).unwrap();
2223
2224        // Nested code blocks in markdown documentation should be allowed
2225        assert_eq!(result.len(), 0);
2226    }
2227
2228    #[test]
2229    fn test_preserve_trailing_newline() {
2230        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2231        let content = "```\ncode\n```\n";
2232        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2233        let fixed = rule.fix(&ctx).unwrap();
2234
2235        assert_eq!(fixed, content);
2236    }
2237
2238    #[test]
2239    fn test_mkdocs_tabs_not_flagged_as_indented_code() {
2240        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2241        let content = r#"# Document
2242
2243=== "Python"
2244
2245    This is tab content
2246    Not an indented code block
2247
2248    ```python
2249    def hello():
2250        print("Hello")
2251    ```
2252
2253=== "JavaScript"
2254
2255    More tab content here
2256    Also not an indented code block"#;
2257
2258        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2259        let result = rule.check(&ctx).unwrap();
2260
2261        // Should not flag tab content as indented code blocks
2262        assert_eq!(result.len(), 0);
2263    }
2264
2265    #[test]
2266    fn test_mkdocs_tabs_with_actual_indented_code() {
2267        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2268        let content = r#"# Document
2269
2270=== "Tab 1"
2271
2272    This is tab content
2273
2274Regular text
2275
2276    This is an actual indented code block
2277    Should be flagged"#;
2278
2279        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2280        let result = rule.check(&ctx).unwrap();
2281
2282        // Should flag the actual indented code block but not the tab content
2283        assert_eq!(result.len(), 1);
2284        assert!(result[0].message.contains("Use fenced code blocks"));
2285    }
2286
2287    #[test]
2288    fn test_mkdocs_tabs_detect_style() {
2289        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2290        let content = r#"=== "Tab 1"
2291
2292    Content in tab
2293    More content
2294
2295=== "Tab 2"
2296
2297    Content in second tab"#;
2298
2299        // In MkDocs mode, tab content should not be detected as indented code blocks
2300        let style = detect_style_from_content(&rule, content, true);
2301        assert_eq!(style, None); // No code blocks detected
2302
2303        // In standard mode, it would detect indented code blocks
2304        let style = detect_style_from_content(&rule, content, false);
2305        assert_eq!(style, Some(CodeBlockStyle::Indented));
2306    }
2307
2308    #[test]
2309    fn test_mkdocs_nested_tabs() {
2310        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2311        let content = r#"# Document
2312
2313=== "Outer Tab"
2314
2315    Some content
2316
2317    === "Nested Tab"
2318
2319        Nested tab content
2320        Should not be flagged"#;
2321
2322        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2323        let result = rule.check(&ctx).unwrap();
2324
2325        // Nested tabs should not be flagged
2326        assert_eq!(result.len(), 0);
2327    }
2328
2329    #[test]
2330    fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
2331        // Issue #269: MkDocs admonitions have indented bodies that should NOT be
2332        // treated as indented code blocks when style = "fenced"
2333        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2334        let content = r#"# Document
2335
2336!!! note
2337    This is normal admonition content, not a code block.
2338    It spans multiple lines.
2339
2340??? warning "Collapsible Warning"
2341    This is also admonition content.
2342
2343???+ tip "Expanded Tip"
2344    And this one too.
2345
2346Regular text outside admonitions."#;
2347
2348        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2349        let result = rule.check(&ctx).unwrap();
2350
2351        // Admonition content should not be flagged
2352        assert_eq!(
2353            result.len(),
2354            0,
2355            "Admonition content in MkDocs mode should not trigger MD046"
2356        );
2357    }
2358
2359    #[test]
2360    fn test_mkdocs_admonition_with_actual_indented_code() {
2361        // After an admonition ends, regular indented code blocks SHOULD be flagged
2362        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2363        let content = r#"# Document
2364
2365!!! note
2366    This is admonition content.
2367
2368Regular text ends the admonition.
2369
2370    This is actual indented code (should be flagged)"#;
2371
2372        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2373        let result = rule.check(&ctx).unwrap();
2374
2375        // Should only flag the actual indented code block
2376        assert_eq!(result.len(), 1);
2377        assert!(result[0].message.contains("Use fenced code blocks"));
2378    }
2379
2380    #[test]
2381    fn test_admonition_in_standard_mode_flagged() {
2382        // In standard Markdown mode, admonitions are not recognized, so the
2383        // indented content should be flagged as indented code
2384        // Note: A blank line is required before indented code blocks per CommonMark
2385        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2386        let content = r#"# Document
2387
2388!!! note
2389
2390    This looks like code in standard mode.
2391
2392Regular text."#;
2393
2394        // In Standard mode, admonitions are not recognized
2395        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2396        let result = rule.check(&ctx).unwrap();
2397
2398        // The indented content should be flagged in standard mode
2399        assert_eq!(
2400            result.len(),
2401            1,
2402            "Admonition content in Standard mode should be flagged as indented code"
2403        );
2404    }
2405
2406    #[test]
2407    fn test_mkdocs_admonition_with_fenced_code_inside() {
2408        // Issue #269: Admonitions can contain fenced code blocks - must handle correctly
2409        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2410        let content = r#"# Document
2411
2412!!! note "Code Example"
2413    Here's some code:
2414
2415    ```python
2416    def hello():
2417        print("world")
2418    ```
2419
2420    More text after code.
2421
2422Regular text."#;
2423
2424        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2425        let result = rule.check(&ctx).unwrap();
2426
2427        // Should not flag anything - the fenced block inside admonition is valid
2428        assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
2429    }
2430
2431    #[test]
2432    fn test_mkdocs_nested_admonitions() {
2433        // Nested admonitions are valid MkDocs syntax
2434        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2435        let content = r#"# Document
2436
2437!!! note "Outer"
2438    Outer content.
2439
2440    !!! warning "Inner"
2441        Inner content.
2442        More inner content.
2443
2444    Back to outer.
2445
2446Regular text."#;
2447
2448        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2449        let result = rule.check(&ctx).unwrap();
2450
2451        // Nested admonitions should not trigger MD046
2452        assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
2453    }
2454
2455    #[test]
2456    fn test_mkdocs_admonition_fix_does_not_wrap() {
2457        // The fix function should not wrap admonition content in fences
2458        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2459        let content = r#"!!! note
2460    Content that should stay as admonition content.
2461    Not be wrapped in code fences.
2462"#;
2463
2464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2465        let fixed = rule.fix(&ctx).unwrap();
2466
2467        // Fix should not add fence markers to admonition content
2468        assert!(
2469            !fixed.contains("```\n    Content"),
2470            "Admonition content should not be wrapped in fences"
2471        );
2472        assert_eq!(fixed, content, "Content should remain unchanged");
2473    }
2474
2475    #[test]
2476    fn test_mkdocs_empty_admonition() {
2477        // Empty admonitions (marker only) should not cause issues
2478        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2479        let content = r#"!!! note
2480
2481Regular paragraph after empty admonition.
2482
2483    This IS an indented code block (after blank + non-indented line)."#;
2484
2485        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2486        let result = rule.check(&ctx).unwrap();
2487
2488        // The indented code block after the paragraph should be flagged
2489        assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
2490    }
2491
2492    #[test]
2493    fn test_mkdocs_indented_admonition() {
2494        // Admonitions can themselves be indented (e.g., inside list items)
2495        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2496        let content = r#"- List item
2497
2498    !!! note
2499        Indented admonition content.
2500        More content.
2501
2502- Next item"#;
2503
2504        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2505        let result = rule.check(&ctx).unwrap();
2506
2507        // Admonition inside list should not be flagged
2508        assert_eq!(
2509            result.len(),
2510            0,
2511            "Indented admonitions (e.g., in lists) should not be flagged"
2512        );
2513    }
2514
2515    #[test]
2516    fn test_footnote_indented_paragraphs_not_flagged() {
2517        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2518        let content = r#"# Test Document with Footnotes
2519
2520This is some text with a footnote[^1].
2521
2522Here's some code:
2523
2524```bash
2525echo "fenced code block"
2526```
2527
2528More text with another footnote[^2].
2529
2530[^1]: Really interesting footnote text.
2531
2532    Even more interesting second paragraph.
2533
2534[^2]: Another footnote.
2535
2536    With a second paragraph too.
2537
2538    And even a third paragraph!"#;
2539
2540        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2541        let result = rule.check(&ctx).unwrap();
2542
2543        // Indented paragraphs in footnotes should not be flagged as code blocks
2544        assert_eq!(result.len(), 0);
2545    }
2546
2547    #[test]
2548    fn test_footnote_definition_detection() {
2549        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2550
2551        // Valid footnote definitions (per CommonMark footnote extension spec)
2552        // Reference: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/footnotes.md
2553        assert!(rule.is_footnote_definition("[^1]: Footnote text"));
2554        assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
2555        assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
2556        assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
2557        assert!(rule.is_footnote_definition("    [^1]: Indented footnote"));
2558        assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
2559        assert!(rule.is_footnote_definition("[^123]: Numeric label"));
2560        assert!(rule.is_footnote_definition("[^_]: Single underscore"));
2561        assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
2562
2563        // Invalid: empty or whitespace-only labels (spec violation)
2564        assert!(!rule.is_footnote_definition("[^]: No label"));
2565        assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
2566        assert!(!rule.is_footnote_definition("[^  ]: Multiple spaces"));
2567        assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
2568
2569        // Invalid: malformed syntax
2570        assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
2571        assert!(!rule.is_footnote_definition("Regular text [^1]:"));
2572        assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
2573        assert!(!rule.is_footnote_definition("[^")); // Too short
2574        assert!(!rule.is_footnote_definition("[^1:")); // Missing closing bracket
2575        assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
2576
2577        // Invalid: disallowed characters in label
2578        assert!(!rule.is_footnote_definition("[^test.name]: Period"));
2579        assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
2580        assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
2581        assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
2582        assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
2583
2584        // Edge case: line breaks not allowed in labels
2585        // (This is a string test, actual multiline would need different testing)
2586        assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
2587    }
2588
2589    #[test]
2590    fn test_footnote_with_blank_lines() {
2591        // Spec requirement: blank lines within footnotes don't terminate them
2592        // if next content is indented (matches GitHub's implementation)
2593        // Reference: commonmark-hs footnote extension behavior
2594        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2595        let content = r#"# Document
2596
2597Text with footnote[^1].
2598
2599[^1]: First paragraph.
2600
2601    Second paragraph after blank line.
2602
2603    Third paragraph after another blank line.
2604
2605Regular text at column 0 ends the footnote."#;
2606
2607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2608        let result = rule.check(&ctx).unwrap();
2609
2610        // The indented paragraphs in the footnote should not be flagged as code blocks
2611        assert_eq!(
2612            result.len(),
2613            0,
2614            "Indented content within footnotes should not trigger MD046"
2615        );
2616    }
2617
2618    #[test]
2619    fn test_footnote_multiple_consecutive_blank_lines() {
2620        // Edge case: multiple consecutive blank lines within a footnote
2621        // Should still work if next content is indented
2622        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2623        let content = r#"Text[^1].
2624
2625[^1]: First paragraph.
2626
2627
2628
2629    Content after three blank lines (still part of footnote).
2630
2631Not indented, so footnote ends here."#;
2632
2633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2634        let result = rule.check(&ctx).unwrap();
2635
2636        // The indented content should not be flagged
2637        assert_eq!(
2638            result.len(),
2639            0,
2640            "Multiple blank lines shouldn't break footnote continuation"
2641        );
2642    }
2643
2644    #[test]
2645    fn test_footnote_terminated_by_non_indented_content() {
2646        // Spec requirement: non-indented content always terminates the footnote
2647        // Reference: commonmark-hs footnote extension
2648        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2649        let content = r#"[^1]: Footnote content.
2650
2651    More indented content in footnote.
2652
2653This paragraph is not indented, so footnote ends.
2654
2655    This should be flagged as indented code block."#;
2656
2657        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2658        let result = rule.check(&ctx).unwrap();
2659
2660        // The last indented block should be flagged (it's after the footnote ended)
2661        assert_eq!(
2662            result.len(),
2663            1,
2664            "Indented code after footnote termination should be flagged"
2665        );
2666        assert!(
2667            result[0].message.contains("Use fenced code blocks"),
2668            "Expected MD046 warning for indented code block"
2669        );
2670        assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2671    }
2672
2673    #[test]
2674    fn test_footnote_terminated_by_structural_elements() {
2675        // Spec requirement: headings and horizontal rules terminate footnotes
2676        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2677        let content = r#"[^1]: Footnote content.
2678
2679    More content.
2680
2681## Heading terminates footnote
2682
2683    This indented content should be flagged.
2684
2685---
2686
2687    This should also be flagged (after horizontal rule)."#;
2688
2689        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2690        let result = rule.check(&ctx).unwrap();
2691
2692        // Both indented blocks after structural elements should be flagged
2693        assert_eq!(
2694            result.len(),
2695            2,
2696            "Both indented blocks after termination should be flagged"
2697        );
2698    }
2699
2700    #[test]
2701    fn test_footnote_with_code_block_inside() {
2702        // Spec behavior: footnotes can contain fenced code blocks
2703        // The fenced code must be properly indented within the footnote
2704        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2705        let content = r#"Text[^1].
2706
2707[^1]: Footnote with code:
2708
2709    ```python
2710    def hello():
2711        print("world")
2712    ```
2713
2714    More footnote text after code."#;
2715
2716        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2717        let result = rule.check(&ctx).unwrap();
2718
2719        // Should have no warnings - the fenced code block is valid
2720        assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2721    }
2722
2723    #[test]
2724    fn test_footnote_with_8_space_indented_code() {
2725        // Edge case: code blocks within footnotes need 8 spaces (4 for footnote + 4 for code)
2726        // This should NOT be flagged as it's properly nested indented code
2727        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2728        let content = r#"Text[^1].
2729
2730[^1]: Footnote with nested code.
2731
2732        code block
2733        more code"#;
2734
2735        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2736        let result = rule.check(&ctx).unwrap();
2737
2738        // The 8-space indented code is valid within footnote
2739        assert_eq!(
2740            result.len(),
2741            0,
2742            "8-space indented code within footnotes represents nested code blocks"
2743        );
2744    }
2745
2746    #[test]
2747    fn test_multiple_footnotes() {
2748        // Spec behavior: each footnote definition starts a new block context
2749        // Previous footnote ends when new footnote begins
2750        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2751        let content = r#"Text[^1] and more[^2].
2752
2753[^1]: First footnote.
2754
2755    Continuation of first.
2756
2757[^2]: Second footnote starts here, ending the first.
2758
2759    Continuation of second."#;
2760
2761        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2762        let result = rule.check(&ctx).unwrap();
2763
2764        // All indented content is part of footnotes
2765        assert_eq!(
2766            result.len(),
2767            0,
2768            "Multiple footnotes should each maintain their continuation context"
2769        );
2770    }
2771
2772    #[test]
2773    fn test_list_item_ends_footnote_context() {
2774        // Spec behavior: list items and footnotes are mutually exclusive contexts
2775        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2776        let content = r#"[^1]: Footnote.
2777
2778    Content in footnote.
2779
2780- List item starts here (ends footnote context).
2781
2782    This indented content is part of the list, not the footnote."#;
2783
2784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2785        let result = rule.check(&ctx).unwrap();
2786
2787        // List continuation should not be flagged
2788        assert_eq!(
2789            result.len(),
2790            0,
2791            "List items should end footnote context and start their own"
2792        );
2793    }
2794
2795    #[test]
2796    fn test_footnote_vs_actual_indented_code() {
2797        // Critical test: verify we can still detect actual indented code blocks outside footnotes
2798        // This ensures the fix doesn't cause false negatives
2799        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2800        let content = r#"# Heading
2801
2802Text with footnote[^1].
2803
2804[^1]: Footnote content.
2805
2806    Part of footnote (should not be flagged).
2807
2808Regular paragraph ends footnote context.
2809
2810    This is actual indented code (MUST be flagged)
2811    Should be detected as code block"#;
2812
2813        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2814        let result = rule.check(&ctx).unwrap();
2815
2816        // Should flag the indented code after the regular paragraph
2817        assert_eq!(
2818            result.len(),
2819            1,
2820            "Must still detect indented code blocks outside footnotes"
2821        );
2822        assert!(
2823            result[0].message.contains("Use fenced code blocks"),
2824            "Expected MD046 warning for indented code"
2825        );
2826        assert!(
2827            result[0].line >= 11,
2828            "Warning should be on the actual indented code line"
2829        );
2830    }
2831
2832    #[test]
2833    fn test_spec_compliant_label_characters() {
2834        // Spec requirement: labels must contain only alphanumerics, hyphens, underscores
2835        // Reference: commonmark-hs footnote extension
2836        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2837
2838        // Valid according to spec
2839        assert!(rule.is_footnote_definition("[^test]: text"));
2840        assert!(rule.is_footnote_definition("[^TEST]: text"));
2841        assert!(rule.is_footnote_definition("[^test-name]: text"));
2842        assert!(rule.is_footnote_definition("[^test_name]: text"));
2843        assert!(rule.is_footnote_definition("[^test123]: text"));
2844        assert!(rule.is_footnote_definition("[^123]: text"));
2845        assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2846
2847        // Invalid characters (spec violations)
2848        assert!(!rule.is_footnote_definition("[^test.name]: text")); // Period
2849        assert!(!rule.is_footnote_definition("[^test name]: text")); // Space
2850        assert!(!rule.is_footnote_definition("[^test@name]: text")); // At sign
2851        assert!(!rule.is_footnote_definition("[^test#name]: text")); // Hash
2852        assert!(!rule.is_footnote_definition("[^test$name]: text")); // Dollar
2853        assert!(!rule.is_footnote_definition("[^test%name]: text")); // Percent
2854    }
2855
2856    #[test]
2857    fn test_code_block_inside_html_comment() {
2858        // Regression test: code blocks inside HTML comments should not be flagged
2859        // Found in denoland/deno test fixture during sanity testing
2860        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2861        let content = r#"# Document
2862
2863Some text.
2864
2865<!--
2866Example code block in comment:
2867
2868```typescript
2869console.log("Hello");
2870```
2871
2872More comment text.
2873-->
2874
2875More content."#;
2876
2877        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2878        let result = rule.check(&ctx).unwrap();
2879
2880        assert_eq!(
2881            result.len(),
2882            0,
2883            "Code blocks inside HTML comments should not be flagged as unclosed"
2884        );
2885    }
2886
2887    #[test]
2888    fn test_unclosed_fence_inside_html_comment() {
2889        // Even an unclosed fence inside an HTML comment should be ignored
2890        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2891        let content = r#"# Document
2892
2893<!--
2894Example with intentionally unclosed fence:
2895
2896```
2897code without closing
2898-->
2899
2900More content."#;
2901
2902        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2903        let result = rule.check(&ctx).unwrap();
2904
2905        assert_eq!(
2906            result.len(),
2907            0,
2908            "Unclosed fences inside HTML comments should be ignored"
2909        );
2910    }
2911
2912    #[test]
2913    fn test_multiline_html_comment_with_indented_code() {
2914        // Indented code inside HTML comments should also be ignored
2915        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2916        let content = r#"# Document
2917
2918<!--
2919Example:
2920
2921    indented code
2922    more code
2923
2924End of comment.
2925-->
2926
2927Regular text."#;
2928
2929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2930        let result = rule.check(&ctx).unwrap();
2931
2932        assert_eq!(
2933            result.len(),
2934            0,
2935            "Indented code inside HTML comments should not be flagged"
2936        );
2937    }
2938
2939    #[test]
2940    fn test_code_block_after_html_comment() {
2941        // Code blocks after HTML comments should still be detected
2942        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2943        let content = r#"# Document
2944
2945<!-- comment -->
2946
2947Text before.
2948
2949    indented code should be flagged
2950
2951More text."#;
2952
2953        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2954        let result = rule.check(&ctx).unwrap();
2955
2956        assert_eq!(
2957            result.len(),
2958            1,
2959            "Code blocks after HTML comments should still be detected"
2960        );
2961        assert!(result[0].message.contains("Use fenced code blocks"));
2962    }
2963
2964    #[test]
2965    fn test_consistent_style_indented_html_comment() {
2966        // Under the default `Consistent` style, indented content inside an
2967        // HTML comment must not contribute to the document's code-block style
2968        // tally. Otherwise a single fenced block alongside an indented HTML
2969        // comment flips the detected style to `Indented`, emitting a spurious
2970        // "Use indented code blocks" warning against the only real code block.
2971        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2972        let content = "# MD046 false-positive reproduction\n\
2973                       \n\
2974                       <!--\n    \
2975                       This is just an indented comment, not a code block.\n\
2976                       \n    \
2977                       A second line is required to trigger the false-positive.\n\
2978                       \n    \
2979                       Actually, three lines are required.\n\
2980                       -->\n\
2981                       \n\
2982                       ```md\n\
2983                       This should be fine, since it's the only code block and therefore consistent.\n\
2984                       ```\n";
2985
2986        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2987        let result = rule.check(&ctx).unwrap();
2988
2989        assert_eq!(
2990            result,
2991            vec![],
2992            "A single fenced block and an indented HTML comment must produce no MD046 warnings",
2993        );
2994    }
2995
2996    #[test]
2997    fn test_consistent_style_indented_html_block() {
2998        // Indented content inside a raw HTML block (e.g. a `<div>` tag pair)
2999        // must not count as an indented code block when `detect_style` picks
3000        // the document's predominant style.
3001        //
3002        // Per CommonMark, a type-6 HTML block is terminated by a blank line,
3003        // so the content here is kept contiguous to remain inside the block.
3004        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3005        let content = "# Heading\n\
3006                       \n\
3007                       <div class=\"note\">\n    \
3008                       line one of indented html content\n    \
3009                       line two of indented html content\n    \
3010                       line three of indented html content\n\
3011                       </div>\n\
3012                       \n\
3013                       ```md\n\
3014                       real fenced block\n\
3015                       ```\n";
3016
3017        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3018        let result = rule.check(&ctx).unwrap();
3019
3020        assert_eq!(
3021            result,
3022            vec![],
3023            "Indented content inside a raw HTML block must not influence MD046 style detection",
3024        );
3025    }
3026
3027    #[test]
3028    fn test_consistent_style_fake_fence_inside_html_comment() {
3029        // Fence markers inside an HTML comment must not contribute to the
3030        // fenced count during style detection. Otherwise a document whose
3031        // only real code block is indented gets flagged "Use fenced code
3032        // blocks" under `Consistent` because the verbatim ``` inside the
3033        // comment ties the count.
3034        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3035        let content = "# Title\n\
3036                       \n\
3037                       <!--\n\
3038                       ```\n\
3039                       fake fence inside comment\n\
3040                       ```\n\
3041                       -->\n\
3042                       \n    \
3043                       real indented code block line 1\n    \
3044                       real indented code block line 2\n";
3045
3046        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3047        let result = rule.check(&ctx).unwrap();
3048
3049        assert_eq!(
3050            result,
3051            vec![],
3052            "Fence markers inside an HTML comment must not influence MD046 style detection",
3053        );
3054    }
3055
3056    #[test]
3057    fn test_consistent_style_indented_footnote_definition() {
3058        // Footnote-definition continuation lines are commonly indented by 4+
3059        // spaces. They must not be counted as indented code blocks during
3060        // style detection under `Consistent`.
3061        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3062        let content = "# Heading\n\
3063                       \n\
3064                       Reference to a footnote[^note].\n\
3065                       \n\
3066                       [^note]: First line of the footnote.\n    \
3067                       Second indented continuation line.\n    \
3068                       Third indented continuation line.\n    \
3069                       Fourth indented continuation line.\n\
3070                       \n\
3071                       ```md\n\
3072                       real fenced block\n\
3073                       ```\n";
3074
3075        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3076        let result = rule.check(&ctx).unwrap();
3077
3078        assert_eq!(
3079            result,
3080            vec![],
3081            "Footnote-definition continuation content must not influence MD046 style detection",
3082        );
3083    }
3084
3085    #[test]
3086    fn test_consistent_style_indented_blockquote() {
3087        // Indented content inside a blockquote (`>     foo`) must not be
3088        // counted as an indented code block by `detect_style`. The check-side
3089        // skip list already excludes `blockquote.is_some()` for indented
3090        // warnings, so detection must match to keep `Consistent` stable.
3091        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3092        let content = "# Heading\n\
3093                       \n\
3094                       >     line one of quoted indented content\n\
3095                       >\n\
3096                       >     line two of quoted indented content\n\
3097                       >\n\
3098                       >     line three of quoted indented content\n\
3099                       \n\
3100                       ```md\n\
3101                       real fenced block\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            "Indented content inside a blockquote must not influence MD046 style detection",
3111        );
3112    }
3113
3114    #[test]
3115    fn test_consistent_style_genuine_indented_block_detected_as_indented() {
3116        // A top-level indented code block that is not inside any container
3117        // must still count toward the Indented tally under `Consistent` style.
3118        // This guards against over-filtering: the `in_comment_or_html` skip
3119        // must not suppress real indented code blocks.
3120        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3121        let content = "# Heading\n\
3122                       \n\
3123                       Some prose.\n\
3124                       \n    \
3125                       real indented code line 1\n    \
3126                       real indented code line 2\n";
3127
3128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3129        let result = rule.check(&ctx).unwrap();
3130
3131        // Only one indented block exists; Consistent must detect it as Indented and
3132        // produce no warnings (the detected style matches the only real block).
3133        assert_eq!(
3134            result,
3135            vec![],
3136            "A genuine top-level indented block must be detected as Indented style under Consistent",
3137        );
3138    }
3139
3140    #[test]
3141    fn test_consistent_style_skipped_lines_dont_override_real_block() {
3142        // Two indented-but-skipped regions (inside HTML comments) plus one
3143        // genuine indented code block and no fenced blocks: the skipped lines
3144        // must be excluded from the tally, leaving indented_count=1, fenced_count=0,
3145        // so Consistent still selects Indented and emits no warnings.
3146        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3147        let content = "# Heading\n\
3148                       \n\
3149                       <!--\n    \
3150                       skipped indented comment line 1\n    \
3151                       skipped indented comment line 2\n\
3152                       -->\n\
3153                       \n\
3154                       <!--\n    \
3155                       second skipped region\n    \
3156                       also skipped\n\
3157                       -->\n\
3158                       \n    \
3159                       real indented code line\n";
3160
3161        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3162        let result = rule.check(&ctx).unwrap();
3163
3164        assert_eq!(
3165            result,
3166            vec![],
3167            "Skipped container lines must not outweigh the single real indented block",
3168        );
3169    }
3170
3171    #[test]
3172    fn test_consistent_style_fenced_wins_over_skipped_indented() {
3173        // One real fenced block plus two indented-but-skipped regions: after
3174        // filtering the skipped lines the tally is fenced=1, indented=0, so
3175        // Consistent selects Fenced and emits no warnings.
3176        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3177        let content = "# Heading\n\
3178                       \n\
3179                       <!--\n    \
3180                       skipped indented region one\n    \
3181                       more of region one\n\
3182                       -->\n\
3183                       \n\
3184                       <!--\n    \
3185                       skipped indented region two\n    \
3186                       more of region two\n\
3187                       -->\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            "Fenced block must win when all indented lines are inside skipped containers",
3200        );
3201    }
3202
3203    #[test]
3204    fn test_four_space_indented_fence_is_not_valid_fence() {
3205        // Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces."
3206        // 4+ spaces means it's NOT a valid fence opener - it becomes an indented code block
3207        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3208
3209        // Valid fences (0-3 spaces)
3210        assert!(rule.is_fenced_code_block_start("```"));
3211        assert!(rule.is_fenced_code_block_start(" ```"));
3212        assert!(rule.is_fenced_code_block_start("  ```"));
3213        assert!(rule.is_fenced_code_block_start("   ```"));
3214
3215        // Invalid fences (4+ spaces) - these are indented code blocks instead
3216        assert!(!rule.is_fenced_code_block_start("    ```"));
3217        assert!(!rule.is_fenced_code_block_start("     ```"));
3218        assert!(!rule.is_fenced_code_block_start("        ```"));
3219
3220        // Tab counts as 4 spaces per CommonMark
3221        assert!(!rule.is_fenced_code_block_start("\t```"));
3222    }
3223
3224    #[test]
3225    fn test_issue_237_indented_fenced_block_detected_as_indented() {
3226        // Issue #237: User has fenced code block indented by 4 spaces
3227        // Per CommonMark, this should be detected as an INDENTED code block
3228        // because 4+ spaces of indentation makes the fence invalid
3229        //
3230        // Reference: https://github.com/rvben/rumdl/issues/237
3231        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3232
3233        // This is the exact test case from issue #237
3234        let content = r#"## Test
3235
3236    ```js
3237    var foo = "hello";
3238    ```
3239"#;
3240
3241        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3242        let result = rule.check(&ctx).unwrap();
3243
3244        // Should flag this as an indented code block that should use fenced style
3245        assert_eq!(
3246            result.len(),
3247            1,
3248            "4-space indented fence should be detected as indented code block"
3249        );
3250        assert!(
3251            result[0].message.contains("Use fenced code blocks"),
3252            "Expected 'Use fenced code blocks' message"
3253        );
3254    }
3255
3256    #[test]
3257    fn test_issue_276_indented_code_in_list() {
3258        // Issue #276: Indented code blocks inside lists should be detected
3259        // Reference: https://github.com/rvben/rumdl/issues/276
3260        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3261
3262        let content = r#"1. First item
32632. Second item with code:
3264
3265        # This is a code block in a list
3266        print("Hello, world!")
3267
32684. Third item"#;
3269
3270        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3271        let result = rule.check(&ctx).unwrap();
3272
3273        // Should flag the indented code block inside the list
3274        assert!(
3275            !result.is_empty(),
3276            "Indented code block inside list should be flagged when style=fenced"
3277        );
3278        assert!(
3279            result[0].message.contains("Use fenced code blocks"),
3280            "Expected 'Use fenced code blocks' message"
3281        );
3282    }
3283
3284    #[test]
3285    fn test_three_space_indented_fence_is_valid() {
3286        // 3 spaces is the maximum allowed per CommonMark - should be recognized as fenced
3287        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3288
3289        let content = r#"## Test
3290
3291   ```js
3292   var foo = "hello";
3293   ```
3294"#;
3295
3296        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3297        let result = rule.check(&ctx).unwrap();
3298
3299        // 3-space indent is valid for fenced blocks - should pass
3300        assert_eq!(
3301            result.len(),
3302            0,
3303            "3-space indented fence should be recognized as valid fenced code block"
3304        );
3305    }
3306
3307    #[test]
3308    fn test_indented_style_with_deeply_indented_fenced() {
3309        // When style=indented, a 4-space indented "fenced" block should still be detected
3310        // as an indented code block (which is what we want!)
3311        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3312
3313        let content = r#"Text
3314
3315    ```js
3316    var foo = "hello";
3317    ```
3318
3319More text
3320"#;
3321
3322        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3323        let result = rule.check(&ctx).unwrap();
3324
3325        // When target style is "indented", 4-space indented content is correct
3326        // The fence markers become literal content in the indented code block
3327        assert_eq!(
3328            result.len(),
3329            0,
3330            "4-space indented content should be valid when style=indented"
3331        );
3332    }
3333
3334    #[test]
3335    fn test_fix_misplaced_fenced_block() {
3336        // Issue #237: When a fenced code block is accidentally indented 4+ spaces,
3337        // the fix should just remove the indentation, not wrap in more fences
3338        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3339
3340        let content = r#"## Test
3341
3342    ```js
3343    var foo = "hello";
3344    ```
3345"#;
3346
3347        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3348        let fixed = rule.fix(&ctx).unwrap();
3349
3350        // The fix should just remove the 4-space indentation
3351        let expected = r#"## Test
3352
3353```js
3354var foo = "hello";
3355```
3356"#;
3357
3358        assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
3359    }
3360
3361    #[test]
3362    fn test_fix_regular_indented_block() {
3363        // Regular indented code blocks (without fence markers) should still be
3364        // wrapped in fences when converted
3365        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3366
3367        let content = r#"Text
3368
3369    var foo = "hello";
3370    console.log(foo);
3371
3372More text
3373"#;
3374
3375        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3376        let fixed = rule.fix(&ctx).unwrap();
3377
3378        // Should wrap in fences
3379        assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
3380        assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
3381    }
3382
3383    #[test]
3384    fn test_fix_indented_block_with_fence_like_content() {
3385        // If an indented block contains fence-like content but doesn't form a
3386        // complete fenced block, we should NOT autofix it because wrapping would
3387        // create invalid nested fences. The block is left unchanged.
3388        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3389
3390        let content = r#"Text
3391
3392    some code
3393    ```not a fence opener
3394    more code
3395"#;
3396
3397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3398        let fixed = rule.fix(&ctx).unwrap();
3399
3400        // Block should be left unchanged to avoid creating invalid nested fences
3401        assert!(fixed.contains("    some code"), "Unsafe block should be left unchanged");
3402        assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
3403    }
3404
3405    #[test]
3406    fn test_fix_mixed_indented_and_misplaced_blocks() {
3407        // Mixed blocks: regular indented code followed by misplaced fenced block
3408        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3409
3410        let content = r#"Text
3411
3412    regular indented code
3413
3414More text
3415
3416    ```python
3417    print("hello")
3418    ```
3419"#;
3420
3421        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3422        let fixed = rule.fix(&ctx).unwrap();
3423
3424        // First block should be wrapped
3425        assert!(
3426            fixed.contains("```\nregular indented code\n```"),
3427            "First block should be wrapped in fences"
3428        );
3429
3430        // Second block should be dedented (not wrapped)
3431        assert!(
3432            fixed.contains("\n```python\nprint(\"hello\")\n```"),
3433            "Second block should be dedented, not double-wrapped"
3434        );
3435        // Should NOT have nested fences
3436        assert!(
3437            !fixed.contains("```\n```python"),
3438            "Should not have nested fence openers"
3439        );
3440    }
3441
3442    #[test]
3443    fn test_md046_front_matter() {
3444        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3445        let content = "---\nmetadata:\n\n    description: Indented\n---\n";
3446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3447        let result = rule.check(&ctx).unwrap();
3448        assert_eq!(result.len(), 0);
3449    }
3450
3451    #[test]
3452    fn test_md046_fix_front_matter() {
3453        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3454        let content = "---\nmetadata:\n\n    description: Indented\n---\n";
3455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3456        let fixed = rule.fix(&ctx).unwrap();
3457        assert_eq!(fixed, content);
3458    }
3459
3460    #[test]
3461    fn test_whitespace_only_line_is_not_an_indented_code_block() {
3462        // A line holding four spaces and nothing else is a blank line to
3463        // CommonMark. The fix used to wrap it in a fence of its own, so a
3464        // document with one real indented block elsewhere gained an empty
3465        // fenced block where a blank line stood.
3466        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3467        let content = "# T\n\nPara\n\n    \nMore\n\n    real code\n\nEnd\n";
3468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3469        let fixed = rule.fix(&ctx).unwrap();
3470        assert_eq!(fixed, "# T\n\nPara\n\n    \nMore\n\n```\nreal code\n```\n\nEnd\n");
3471    }
3472
3473    #[test]
3474    fn test_interior_blank_line_keeps_indented_block_together() {
3475        // CommonMark keeps a blank line between two indented code lines inside
3476        // the block, so `a`, the blank and `b` are one block and convert to one
3477        // fence with an empty line in it, not two fences.
3478        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3479        let content = "# T\n\nPara\n\n    a\n\n    b\n\nAfter\n";
3480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3481        let fixed = rule.fix(&ctx).unwrap();
3482        assert_eq!(fixed, "# T\n\nPara\n\n```\na\n\nb\n```\n\nAfter\n");
3483    }
3484
3485    #[test]
3486    fn test_consistent_style_counts_a_block_with_interior_blank_once() {
3487        // Style detection counts blocks. Splitting `a` / blank / `b` in two made
3488        // one indented block outvote one fenced block, and the fenced block was
3489        // reported instead of the indented one.
3490        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3491        let content = "# T\n\n```\nfenced\n```\n\nPara\n\n    a\n\n    b\n\nEnd\n";
3492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3493        let result = rule.check(&ctx).unwrap();
3494        let reported: Vec<(usize, &str)> = result.iter().map(|w| (w.line, w.message.as_str())).collect();
3495        assert_eq!(reported, vec![(9, "Use fenced code blocks")]);
3496    }
3497
3498    #[test]
3499    fn test_indented_lazy_continuation_lines_are_not_code() {
3500        // Indented lines directly under a paragraph line continue that
3501        // paragraph, and so does every indented line after them. Classifying
3502        // the second line by the raw indent of the first turned the run into
3503        // code from its second line on, and the fix fenced the tail of a
3504        // paragraph.
3505        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3506        let content = "# T\n\nPara\n    lazy one\n    lazy two\n    lazy three\n\n    real code\n\nEnd\n";
3507        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3508        let fixed = rule.fix(&ctx).unwrap();
3509        assert_eq!(
3510            fixed,
3511            "# T\n\nPara\n    lazy one\n    lazy two\n    lazy three\n\n```\nreal code\n```\n\nEnd\n"
3512        );
3513    }
3514
3515    #[test]
3516    fn test_misplaced_fence_with_interior_blank_dedents_as_one_block() {
3517        // An over-indented fenced block whose body has a blank line is still
3518        // one complete fenced block, so it is dedented as a whole. Split at the
3519        // blank, neither half had both fences and the block was left alone.
3520        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3521        let content = "# T\n\nPara\n\n    ```python\n    x = 1\n\n    y = 2\n    ```\n\nAfter\n";
3522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3523        let fixed = rule.fix(&ctx).unwrap();
3524        assert_eq!(fixed, "# T\n\nPara\n\n```python\nx = 1\n\ny = 2\n```\n\nAfter\n");
3525    }
3526    #[test]
3527    fn test_mdg_overrides_indented_style_to_fenced() {
3528        // A Gherkin Doc String is only ever a backtick fence, so a
3529        // configuration demanding indented code cannot be satisfied in this
3530        // flavor. MDG does not adopt it: the Doc String keeps its fence instead
3531        // of being unwrapped into an indented block that deletes it.
3532        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3533        let content = "# Feature: Payloads\n\n## Scenario: JSON payload\n\n* Given this payload\n\n  ```json\n  {\"ok\": true}\n  ```\n";
3534
3535        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3536        assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3537        assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3538
3539        // Standard still reports the configured style mismatch, but cannot
3540        // apply it without discarding the JSON info string.
3541        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3542        let standard_warnings = rule.check(&standard_ctx).unwrap();
3543        assert_eq!(standard_warnings.len(), 1);
3544        assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3545        assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3546    }
3547
3548    #[test]
3549    fn test_mdg_indented_style_still_fences_indented_blocks() {
3550        // The override is not merely a refusal to unwrap fences: MDG enforces
3551        // fenced, so an indented block is converted even though the
3552        // configuration asked for indented code.
3553        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3554        let content =
3555            "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n      ordinary indented code\n";
3556
3557        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3558        let warnings = rule.check(&mdg_ctx).unwrap();
3559        assert_eq!(warnings.len(), 1);
3560        assert_eq!(warnings[0].message, "Use fenced code blocks");
3561
3562        let fixed = rule.fix(&mdg_ctx).unwrap();
3563        assert_eq!(
3564            fixed,
3565            "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n```\n  ordinary indented code\n```\n"
3566        );
3567
3568        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3569        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3570        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3571
3572        // Standard honours `indented`: the block is already indented, so there
3573        // is nothing to report and nothing to change.
3574        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3575        assert!(rule.check(&standard_ctx).unwrap().is_empty());
3576        assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3577    }
3578
3579    #[test]
3580    fn test_mdg_steers_indented_code_to_fenced() {
3581        // Under MDG a code block is expected to be a backtick fence, so an
3582        // indented block is corrected rather than preserved — whichever style
3583        // the configuration names.
3584        let content = "# Feature: Payloads\n\n## Scenario: Plain payload\n\n* Given this payload\n\n      ordinary indented code\n";
3585
3586        for rule in [
3587            MD046CodeBlockStyle::new(CodeBlockStyle::Fenced),
3588            MD046CodeBlockStyle::new(CodeBlockStyle::Consistent),
3589            MD046CodeBlockStyle::new(CodeBlockStyle::Indented),
3590        ] {
3591            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3592            let warnings = rule.check(&ctx).unwrap();
3593            assert_eq!(warnings.len(), 1);
3594            assert_eq!(warnings[0].message, "Use fenced code blocks");
3595
3596            let fixed = rule.fix(&ctx).unwrap();
3597            assert!(fixed.contains("```"), "MDG must fence the block: {fixed:?}");
3598
3599            let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3600            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3601            assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3602        }
3603    }
3604
3605    #[test]
3606    fn test_mdg_consistent_style_ignores_indented_prevalence() {
3607        // Standard resolves `consistent` by prevalence; MDG always resolves it
3608        // to fenced because only a backtick fence can be a Doc String.
3609        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3610        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";
3611
3612        let standard_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::Standard, None);
3613        let standard_warnings = rule.check(&standard_ctx).unwrap();
3614        assert_eq!(standard_warnings.len(), 1);
3615        assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3616
3617        let mdg_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::MDG, None);
3618        let mdg_warnings = rule.check(&mdg_ctx).unwrap();
3619        assert_eq!(mdg_warnings.len(), 2);
3620        assert!(
3621            mdg_warnings
3622                .iter()
3623                .all(|warning| warning.message == "Use fenced code blocks")
3624        );
3625    }
3626
3627    #[test]
3628    fn test_mdg_repairs_unclosed_fence_like_standard() {
3629        // The unclosed-fence repair is flavor independent now that MDG no
3630        // longer takes a bespoke fix path.
3631        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3632        let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3633
3634        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3635        let warnings = rule.check(&mdg_ctx).unwrap();
3636        assert_eq!(warnings.len(), 1);
3637        assert!(warnings[0].message.contains("never closed"));
3638
3639        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3640        assert_eq!(
3641            rule.fix(&mdg_ctx).unwrap(),
3642            rule.fix(&standard_ctx).unwrap(),
3643            "MDG must not differ from Standard"
3644        );
3645    }
3646
3647    #[test]
3648    fn test_mdg_table_above_prose_is_never_fenced() {
3649        // The Examples table and the paragraph below it sit in one CommonMark
3650        // indented code block, split by a blank line. `check` and `fix` read
3651        // the same per-line membership, so the table stays a table and only the
3652        // paragraph is fenced — reporting the block and fencing all of it (or
3653        // skipping the block and fencing it anyway) would delete the table.
3654        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3655        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";
3656
3657        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3658        let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3659        assert_eq!(reported, vec![8, 12]);
3660
3661        let fixed = rule.fix(&mdg_ctx).unwrap();
3662        assert_eq!(
3663            fixed,
3664            "# 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"
3665        );
3666
3667        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3668        assert!(
3669            rule.check(&fixed_ctx).unwrap().is_empty(),
3670            "MDG check must have nothing left to report after its own fix"
3671        );
3672        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3673
3674        // Standard has no Gherkin tables, so the whole block is code there.
3675        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3676        let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3677        assert_eq!(standard_reported, vec![5, 12]);
3678        assert!(rule.fix(&standard_ctx).unwrap().contains("```\n| start | eat | left |"));
3679    }
3680
3681    #[test]
3682    fn test_mdg_repairs_unclosed_fence_under_indented_style() {
3683        // MDG does not adopt the configured `indented` style, but closing an
3684        // unclosed fence is a repair rather than a conversion: `check` reports
3685        // it before any style is resolved, so `fix` has to resolve it too.
3686        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3687        let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3688
3689        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3690        let warnings = rule.check(&mdg_ctx).unwrap();
3691        assert_eq!(warnings.len(), 1);
3692        assert!(warnings[0].message.contains("never closed"));
3693
3694        let fixed = rule.fix(&mdg_ctx).unwrap();
3695        assert_eq!(fixed, "# Feature: Payloads\n\n```json\n{\"ok\": true}\n```\n");
3696
3697        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3698        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3699
3700        // Standard also preserves the tagged fence because conversion would
3701        // discard its info string, while still repairing the missing closer.
3702        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3703        assert_eq!(rule.fix(&standard_ctx).unwrap(), fixed);
3704    }
3705
3706    #[test]
3707    fn test_mdg_tab_indented_table_is_not_code() {
3708        // Gherkin matches table rows on `\s`, so two tabs — or a space and a
3709        // tab — indent a table just as two spaces do, even though both expand
3710        // past the 4-column indented-code threshold.
3711        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3712        for indent in ["\t\t", " \t"] {
3713            let content = format!(
3714                "# Feature: Eating\n\n#### Examples:\n\n{indent}| start | eat |\n{indent}| ----- | --- |\n\n## Scenario: Other\n\n      code here\n"
3715            );
3716
3717            let mdg_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
3718            let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3719            assert_eq!(reported, vec![10], "tab-indented rows are a table, not code");
3720
3721            let fixed = rule.fix(&mdg_ctx).unwrap();
3722            assert!(
3723                fixed.contains(&format!("{indent}| start | eat |\n{indent}| ----- | --- |")),
3724                "MDG must leave the tab-indented table alone: {fixed:?}"
3725            );
3726
3727            let standard_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3728            let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3729            assert_eq!(standard_reported, vec![5, 10]);
3730        }
3731    }
3732
3733    #[test]
3734    fn test_from_config_records_whether_style_was_configured() {
3735        // The MDG override applies either way, but the warning is only for a
3736        // style the user actually asked for, so a configured style has to be
3737        // told apart from a defaulted one.
3738        use crate::config::Config;
3739        use std::collections::BTreeMap;
3740
3741        let mut values = BTreeMap::new();
3742        values.insert("style".to_string(), toml::Value::String("indented".to_string()));
3743        let mut config = Config::default();
3744        config.rules.insert(
3745            "MD046".to_string(),
3746            crate::config::RuleConfig { severity: None, values },
3747        );
3748
3749        let configured = MD046CodeBlockStyle::from_config(&config);
3750        let configured = configured.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
3751        assert_eq!(configured.config.style, CodeBlockStyle::Indented);
3752        assert!(configured.style_explicit);
3753
3754        let defaulted = MD046CodeBlockStyle::from_config(&Config::default());
3755        let defaulted = defaulted.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
3756        assert!(!defaulted.style_explicit);
3757
3758        // The override does not depend on the warning: a defaulted `indented`
3759        // is enforced as fenced just the same.
3760        let indented = MD046CodeBlockStyle::from_config_struct(MD046Config {
3761            style: CodeBlockStyle::Indented,
3762        });
3763        let content = "# Feature: F\n\nText.\n\n      code here\n";
3764        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3765        assert!(indented.fix(&mdg_ctx).unwrap().contains("```\n  code here\n```"));
3766    }
3767
3768    #[test]
3769    fn test_mdg_indented_style_keeps_tables_out_of_code() {
3770        // Enforcing fenced does not widen what MDG counts as code: a
3771        // Data/Examples table is still not an indented code block.
3772        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3773        let content = "# Feature: Eating\n\n#### Examples:\n\n    | start | eat | left |\n    | ----- | --- | ---- |\n";
3774
3775        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3776        assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3777        assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3778
3779        // Standard has no Gherkin tables, so the rows are code — and `indented`
3780        // is honoured there, so they are already in the requested form.
3781        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3782        assert!(rule.check(&standard_ctx).unwrap().is_empty());
3783        assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3784    }
3785}