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