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