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