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