Skip to main content

rumdl_lib/rules/
md046_code_block_style.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::{FlavorOverrideNotice, option_is_explicit};
3use crate::utils::calculate_indentation_width_default;
4use crate::utils::mdg;
5use crate::utils::mkdocs_admonitions;
6use crate::utils::mkdocs_tabs;
7use crate::utils::range_utils::calculate_line_range;
8use toml;
9
10mod md046_config;
11pub use md046_config::CodeBlockStyle;
12use md046_config::MD046Config;
13
14/// Reports the MDG style override once per process; see [`FlavorOverrideNotice`].
15static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
16
17/// Pre-computed context arrays for indented code block detection.
18struct IndentContext<'a> {
19    in_list_context: &'a [bool],
20    in_tab_context: &'a [bool],
21    in_admonition_context: &'a [bool],
22    /// Lines belonging to a non-code container whose body can legitimately be
23    /// indented by 4+ spaces or contain verbatim fence markers: HTML/MDX
24    /// comments, raw HTML blocks, JSX blocks, mkdocstrings blocks, footnote
25    /// definitions, and blockquotes.
26    ///
27    /// These lines are excluded from `detect_style`'s style tally, from
28    /// `is_indented_code_block_with_context`, and from
29    /// `categorize_indented_blocks`'s fence rewriting — keeping detection in
30    /// lockstep with the warning-side skip list used in `check`.
31    in_comment_or_html: &'a [bool],
32    /// Per-line content column of the most recent list item this line
33    /// belongs to (in list continuation), or None if not in list context.
34    ///
35    /// CommonMark places an indented code block within a list item only when
36    /// the line's indent is at least `baseline + 4`. Without this, every
37    /// continuation line gets the conservative "skip in list context" treatment
38    /// — silently turning real list-internal code blocks into fmt no-ops.
39    /// With this, the rule recognizes them, and the fence converter can emit
40    /// fences at `baseline` spaces so the block stays attached to the bullet.
41    list_item_baseline: &'a [Option<usize>],
42}
43
44/// Owned backing storage for [`IndentContext`], built once per `check`/`fix`
45/// invocation by [`MD046CodeBlockStyle::build_indent_context`].
46struct OwnedIndentContext {
47    in_list_context: Vec<bool>,
48    in_tab_context: Vec<bool>,
49    in_admonition_context: Vec<bool>,
50    in_comment_or_html: Vec<bool>,
51    list_item_baseline: Vec<Option<usize>>,
52}
53
54impl OwnedIndentContext {
55    fn borrow(&self) -> IndentContext<'_> {
56        IndentContext {
57            in_list_context: &self.in_list_context,
58            in_tab_context: &self.in_tab_context,
59            in_admonition_context: &self.in_admonition_context,
60            in_comment_or_html: &self.in_comment_or_html,
61            list_item_baseline: &self.list_item_baseline,
62        }
63    }
64}
65
66/// Rule MD046: Code block style
67///
68/// See [docs/md046.md](../../docs/md046.md) for full documentation, configuration, and examples.
69///
70/// This rule is triggered when code blocks do not use a consistent style (either fenced or indented).
71#[derive(Clone)]
72pub struct MD046CodeBlockStyle {
73    config: MD046Config,
74    /// Whether `style` came from the configuration rather than from the
75    /// default. MDG enforces fenced either way; this only decides whether the
76    /// user is told that the style they asked for was not adopted.
77    style_explicit: bool,
78}
79
80impl MD046CodeBlockStyle {
81    /// The fence `fix` opens when it converts an indented block.
82    const FENCE: &'static str = "```";
83
84    pub fn new(style: CodeBlockStyle) -> Self {
85        Self {
86            config: MD046Config { style },
87            style_explicit: true,
88        }
89    }
90
91    pub fn from_config_struct(config: MD046Config) -> Self {
92        Self {
93            config,
94            style_explicit: false,
95        }
96    }
97
98    /// Check if line has valid fence indentation per CommonMark spec (0-3 spaces)
99    ///
100    /// Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces."
101    /// 4+ spaces of indentation makes it an indented code block instead.
102    fn has_valid_fence_indent(line: &str) -> bool {
103        calculate_indentation_width_default(line) < 4
104    }
105
106    /// Check if a line is a valid fenced code block start per CommonMark spec
107    ///
108    /// Per CommonMark 0.31.2: "A code fence is a sequence of at least three consecutive
109    /// backtick characters (`) or tilde characters (~). An opening code fence may be
110    /// indented 0-3 spaces."
111    ///
112    /// This means 4+ spaces of indentation makes it an indented code block instead,
113    /// where the fence characters become literal content.
114    fn is_fenced_code_block_start(&self, line: &str) -> bool {
115        if !Self::has_valid_fence_indent(line) {
116            return false;
117        }
118
119        let trimmed = line.trim_start();
120        trimmed.starts_with("```") || trimmed.starts_with("~~~")
121    }
122
123    fn is_list_item(&self, line: &str) -> bool {
124        let trimmed = line.trim_start();
125        if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
126            return true;
127        }
128        // Ordered list item: one or more leading digits immediately followed by
129        // ". " or ") ". Checking the delimiter right after the digit run avoids
130        // misclassifying prose like "2 results. More info." (which merely
131        // contains ". ") as a list item.
132        let after_digits = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
133        after_digits.len() < trimmed.len() && (after_digits.starts_with(". ") || after_digits.starts_with(") "))
134    }
135
136    /// Check if a line is a footnote definition according to CommonMark footnote extension spec
137    ///
138    /// # Specification Compliance
139    /// Based on commonmark-hs footnote extension and GitHub's implementation:
140    /// - Format: `[^label]: content`
141    /// - Labels cannot be empty or whitespace-only
142    /// - Labels cannot contain line breaks (unlike regular link references)
143    /// - Labels typically contain alphanumerics, hyphens, underscores (though some parsers are more permissive)
144    ///
145    /// # Examples
146    /// Valid:
147    /// - `[^1]: Footnote text`
148    /// - `[^foo-bar]: Content`
149    /// - `[^test_123]: More content`
150    ///
151    /// Invalid:
152    /// - `[^]: No label`
153    /// - `[^ ]: Whitespace only`
154    /// - `[^]]: Extra bracket`
155    fn is_footnote_definition(&self, line: &str) -> bool {
156        let trimmed = line.trim_start();
157        if !trimmed.starts_with("[^") || trimmed.len() < 5 {
158            return false;
159        }
160
161        if let Some(close_bracket_pos) = trimmed.find("]:")
162            && close_bracket_pos > 2
163        {
164            let label = &trimmed[2..close_bracket_pos];
165
166            if label.trim().is_empty() {
167                return false;
168            }
169
170            // Per spec: labels cannot contain line breaks (check for \r since \n can't appear in a single line)
171            if label.contains('\r') {
172                return false;
173            }
174
175            // Validate characters per GitHub's behavior: alphanumeric, hyphens, underscores only
176            if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
177                return true;
178            }
179        }
180
181        false
182    }
183
184    /// Pre-compute which lines are in block continuation context (lists, footnotes) with a single forward pass
185    ///
186    /// # Specification-Based Context Tracking
187    /// This function implements CommonMark-style block continuation semantics:
188    ///
189    /// ## List Items
190    /// - List items can contain multiple paragraphs and blocks
191    /// - Content continues if indented appropriately
192    /// - Context ends at structural boundaries (headings, horizontal rules) or column-0 paragraphs
193    ///
194    /// ## Footnotes
195    /// Per commonmark-hs footnote extension and GitHub's implementation:
196    /// - Footnote content continues as long as it's indented
197    /// - Blank lines within footnotes don't terminate them (if next content is indented)
198    /// - Non-indented content terminates the footnote
199    /// - Similar to list items but can span more content
200    ///
201    /// # Performance
202    /// O(n) single forward pass, replacing O(n²) backward scanning
203    ///
204    /// # Returns
205    /// Boolean vector where `true` indicates the line is part of a list/footnote continuation
206    fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
207        let mut in_continuation_context = vec![false; lines.len()];
208        let mut last_list_item_line: Option<usize> = None;
209        let mut last_footnote_line: Option<usize> = None;
210        let mut blank_line_count = 0;
211
212        for (i, line) in lines.iter().enumerate() {
213            let trimmed = line.trim_start();
214            let indent_len = line.len() - trimmed.len();
215
216            // Check if this is a list item
217            if self.is_list_item(line) {
218                last_list_item_line = Some(i);
219                last_footnote_line = None; // List item ends any footnote context
220                blank_line_count = 0;
221                in_continuation_context[i] = true;
222                continue;
223            }
224
225            // Check if this is a footnote definition
226            if self.is_footnote_definition(line) {
227                last_footnote_line = Some(i);
228                last_list_item_line = None; // Footnote ends any list context
229                blank_line_count = 0;
230                in_continuation_context[i] = true;
231                continue;
232            }
233
234            // Handle empty lines
235            if line.trim().is_empty() {
236                // Blank lines within continuations are allowed
237                if last_list_item_line.is_some() || last_footnote_line.is_some() {
238                    blank_line_count += 1;
239                    in_continuation_context[i] = true;
240
241                    // Per spec: multiple consecutive blank lines might terminate context
242                    // GitHub allows multiple blank lines within footnotes if next content is indented
243                    // We'll check on the next non-blank line
244                }
245                continue;
246            }
247
248            // Non-empty line - check for structural breaks or continuation
249            if indent_len == 0 && !trimmed.is_empty() {
250                // Content at column 0 (not indented)
251
252                // Headings definitely end all contexts
253                if trimmed.starts_with('#') {
254                    last_list_item_line = None;
255                    last_footnote_line = None;
256                    blank_line_count = 0;
257                    continue;
258                }
259
260                // Horizontal rules end all contexts
261                if trimmed.starts_with("---") || trimmed.starts_with("***") {
262                    last_list_item_line = None;
263                    last_footnote_line = None;
264                    blank_line_count = 0;
265                    continue;
266                }
267
268                // Non-indented paragraph/content terminates contexts
269                // But be conservative: allow some distance for lists
270                if let Some(list_line) = last_list_item_line
271                    && (i - list_line > 5 || blank_line_count > 1)
272                {
273                    last_list_item_line = None;
274                }
275
276                // For footnotes, non-indented content always terminates
277                if last_footnote_line.is_some() {
278                    last_footnote_line = None;
279                }
280
281                blank_line_count = 0;
282
283                // If no active context, this is a regular line
284                if last_list_item_line.is_none() && last_footnote_line.is_some() {
285                    last_footnote_line = None;
286                }
287                continue;
288            }
289
290            // Indented content - part of continuation if we have active context
291            if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
292                in_continuation_context[i] = true;
293                blank_line_count = 0;
294            }
295        }
296
297        in_continuation_context
298    }
299
300    /// Per-line content column of the most recent list item this line
301    /// belongs to (in list continuation), or None.
302    ///
303    /// Mirrors the iteration in `precompute_block_continuation_context` but
304    /// captures the parsed list item's `content_column` from `LineInfo`.
305    /// `is_indented_code_block_with_context` consults this so list-internal
306    /// indented blocks are recognized iff their indent crosses
307    /// `baseline + 4` — the CommonMark threshold for an indented code block
308    /// inside a list item. The fix loop reuses the baseline to anchor the
309    /// generated fences at the list-item content column.
310    fn precompute_list_item_baseline(
311        &self,
312        ctx: &crate::lint_context::LintContext,
313        lines: &[&str],
314    ) -> Vec<Option<usize>> {
315        let mut baselines = vec![None; lines.len()];
316        let mut last_baseline: Option<usize> = None;
317        let mut last_list_item_line: Option<usize> = None;
318        let mut blank_line_count = 0usize;
319
320        for (i, line) in lines.iter().enumerate() {
321            let trimmed = line.trim_start();
322            let indent_len = line.len() - trimmed.len();
323
324            // List item line — read the parsed content column directly.
325            if let Some(item) = ctx.line_info(i + 1).and_then(|li| li.list_item.as_ref()) {
326                last_baseline = Some(item.content_column);
327                last_list_item_line = Some(i);
328                blank_line_count = 0;
329                baselines[i] = last_baseline;
330                continue;
331            }
332
333            // Blank line within continuation — propagate baseline.
334            if line.trim().is_empty() {
335                if last_baseline.is_some() {
336                    blank_line_count += 1;
337                    baselines[i] = last_baseline;
338                }
339                continue;
340            }
341
342            // Non-empty unindented content. Headings/HRs always end the list;
343            // otherwise mirror the >5-line / >1-blank heuristic from
344            // `precompute_block_continuation_context`.
345            if indent_len == 0 {
346                if trimmed.starts_with('#') || trimmed.starts_with("---") || trimmed.starts_with("***") {
347                    last_baseline = None;
348                    last_list_item_line = None;
349                } else if let Some(list_line) = last_list_item_line
350                    && (i - list_line > 5 || blank_line_count > 1)
351                {
352                    last_baseline = None;
353                    last_list_item_line = None;
354                }
355                blank_line_count = 0;
356                continue;
357            }
358
359            // Indented continuation — keep the baseline.
360            if last_baseline.is_some() {
361                baselines[i] = last_baseline;
362                blank_line_count = 0;
363            }
364        }
365
366        baselines
367    }
368
369    /// Check if a line is an indented code line using pre-computed context
370    /// arrays. `prev_is_code` says whether the line above was classified as
371    /// one, which is what lets a block continue past its first line.
372    fn is_indented_code_block_with_context(
373        &self,
374        lines: &[&str],
375        i: usize,
376        is_mkdocs: bool,
377        ctx: &IndentContext,
378        prev_is_code: bool,
379    ) -> bool {
380        if i >= lines.len() {
381            return false;
382        }
383
384        let line = lines[i];
385
386        // A blank line is blank however wide its whitespace is: CommonMark
387        // never opens an indented code block on one. Whether it sits INSIDE a
388        // block is decided by `indented_block_lines`, from the code lines
389        // around it.
390        if line.trim().is_empty() {
391            return false;
392        }
393
394        // Check if indented by at least 4 columns (accounting for tab expansion)
395        let indent = calculate_indentation_width_default(line);
396        if indent < 4 {
397            return false;
398        }
399
400        // List/footnote continuation: only treat as a code block when the
401        // indent crosses the list-item content baseline + 4. Without a
402        // baseline (e.g. footnote definition continuation), keep the
403        // conservative skip — those containers don't expose a column we can
404        // anchor a fence to.
405        if ctx.in_list_context[i] {
406            let crosses_baseline = ctx
407                .list_item_baseline
408                .get(i)
409                .copied()
410                .flatten()
411                .is_some_and(|base| indent >= base + 4);
412            if !crosses_baseline {
413                return false;
414            }
415        }
416
417        // Skip if this is MkDocs tab content (pre-computed)
418        if is_mkdocs && ctx.in_tab_context[i] {
419            return false;
420        }
421
422        // Skip if this is MkDocs admonition content (pre-computed)
423        // Admonitions are supported in MkDocs and other extended Markdown processors
424        if is_mkdocs && ctx.in_admonition_context[i] {
425            return false;
426        }
427
428        // Skip if inside an HTML/MDX comment, raw HTML block, JSX block,
429        // mkdocstrings block, footnote definition, or blockquote. These
430        // containers can legitimately hold 4+ space indented text that is
431        // not a code block. Counting them would desync style detection from
432        // the warning-side skip list in `check`.
433        if ctx.in_comment_or_html.get(i).copied().unwrap_or(false) {
434            return false;
435        }
436
437        // An indented code block starts after a blank line or continues from
438        // a code line directly above. An indented line straight after a
439        // paragraph line is a lazy continuation of that paragraph, however
440        // deep its indent, and so is every indented line that follows it:
441        // the answer for the line above has to be the classified one, not its
442        // raw indent, or a run of continuation lines turns into code from its
443        // second line on.
444        let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
445        has_blank_line_before || prev_is_code
446    }
447
448    /// First line at or after `start` that `block_lines` still counts as
449    /// indented code, bounded by the byte offset `block_end`, or `None` when
450    /// the block holds no such line.
451    ///
452    /// Under MDG a Gherkin table row is dropped from the block even though
453    /// CommonMark counts it as indented code, so a block reported by the
454    /// parser can start on a line the fix will leave alone. `check` reports
455    /// the first line the fix actually converts, which keeps the two in step.
456    fn first_code_block_line(
457        ctx: &crate::lint_context::LintContext,
458        block_lines: &[bool],
459        start: usize,
460        block_end: usize,
461    ) -> Option<usize> {
462        (start..block_lines.len())
463            .take_while(|&idx| ctx.line_offsets.get(idx).is_some_and(|&offset| offset < block_end))
464            .find(|&idx| block_lines[idx])
465    }
466
467    /// Per-line membership of the indented code blocks that style detection,
468    /// block categorization and the fix all operate on.
469    ///
470    /// A code line is one `is_indented_code_block_with_context` accepts. A
471    /// blank line belongs to a block only when a code line of that block
472    /// precedes it and another follows it, with nothing but blank lines in
473    /// between: CommonMark keeps interior blank lines inside an indented code
474    /// block and leaves the blank lines before and after it outside. So
475    /// `    a`, an empty line and `    b` form one block, and a whitespace-only
476    /// line on its own is no block at all.
477    ///
478    /// Under MDG, Gherkin tables are dropped from the result. This is the only
479    /// place that decision is made: `check`, `detect_style` and `fix` all read
480    /// the array returned here, so they cannot disagree about what MD046
481    /// converts.
482    fn indented_block_lines(
483        &self,
484        lines: &[&str],
485        is_mkdocs: bool,
486        ictx: &IndentContext<'_>,
487        flavor: crate::config::MarkdownFlavor,
488    ) -> Vec<bool> {
489        let mut member = vec![false; lines.len()];
490        for i in 0..lines.len() {
491            let prev_is_code = i > 0 && member[i - 1];
492            member[i] = self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx, prev_is_code);
493        }
494
495        // Fencing a run of Gherkin table rows would delete the table from the
496        // Gherkin document, so they are withheld from indented-code detection.
497        // That happens before blank lines are folded in, so each
498        // blank-line-delimited run is judged on its own rows: an Examples table
499        // followed by a blank line and a paragraph must keep the table out of
500        // the block instead of being outvoted by the paragraph.
501        if flavor == crate::config::MarkdownFlavor::MDG {
502            let mut i = 0;
503            while i < member.len() {
504                if !member[i] {
505                    i += 1;
506                    continue;
507                }
508                let start = i;
509                while i < member.len() && member[i] {
510                    i += 1;
511                }
512                if lines[start..i].iter().all(|line| mdg::is_table_row(line)) {
513                    member[start..i].fill(false);
514                }
515            }
516        }
517
518        let mut i = 0;
519        while i < lines.len() {
520            if !member[i] {
521                i += 1;
522                continue;
523            }
524            let mut next = i + 1;
525            while next < lines.len() && lines[next].trim().is_empty() {
526                next += 1;
527            }
528            if next < lines.len() && member[next] {
529                member[i + 1..next].fill(true);
530            }
531            i = next;
532        }
533
534        member
535    }
536
537    /// Pre-compute which lines sit inside a non-code container whose body may
538    /// legitimately be indented by 4+ spaces without being an indented code
539    /// block: HTML comments, raw HTML blocks, JSX blocks, MDX comments,
540    /// mkdocstrings blocks, footnote definitions, blockquotes, and front-matter.
541    ///
542    /// This mirrors the skip list used in `check` when emitting indented
543    /// code-block warnings, keeping style detection and warning emission in
544    /// lockstep.
545    fn precompute_comment_or_html_context(ctx: &crate::lint_context::LintContext, line_count: usize) -> Vec<bool> {
546        (0..line_count)
547            .map(|i| {
548                ctx.line_info(i + 1).is_some_and(|info| {
549                    info.in_html_comment
550                        || info.in_mdx_comment
551                        || info.in_html_block
552                        || info.in_jsx_block
553                        || info.in_mkdocstrings
554                        || info.in_footnote_definition
555                        || info.blockquote.is_some()
556                        || info.in_front_matter
557                })
558            })
559            .collect()
560    }
561
562    /// Pre-compute which lines are in MkDocs tab context with a single forward pass
563    fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
564        let mut in_tab_context = vec![false; lines.len()];
565        let mut current_tab_indent: Option<usize> = None;
566
567        for (i, line) in lines.iter().enumerate() {
568            // Check if this is a tab marker
569            if mkdocs_tabs::is_tab_marker(line) {
570                let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
571                current_tab_indent = Some(tab_indent);
572                in_tab_context[i] = true;
573                continue;
574            }
575
576            // If we have a current tab, check if this line is tab content
577            if let Some(tab_indent) = current_tab_indent {
578                if mkdocs_tabs::is_tab_content(line, tab_indent) {
579                    in_tab_context[i] = true;
580                } else if !line.trim().is_empty() && calculate_indentation_width_default(line) < 4 {
581                    // Non-indented, non-empty line ends tab context
582                    current_tab_indent = None;
583                } else {
584                    // Empty or indented line maintains tab context
585                    in_tab_context[i] = true;
586                }
587            }
588        }
589
590        in_tab_context
591    }
592
593    /// Pre-compute which lines are in MkDocs admonition context with a single forward pass
594    ///
595    /// MkDocs admonitions use `!!!` or `???` markers followed by a type, and their content
596    /// is indented by 4 spaces. This function marks all admonition markers and their
597    /// indented content as being in an admonition context, preventing them from being
598    /// incorrectly flagged as indented code blocks.
599    ///
600    /// Supports nested admonitions by maintaining a stack of active admonition contexts.
601    fn precompute_mkdocs_admonition_context(&self, lines: &[&str]) -> Vec<bool> {
602        let mut in_admonition_context = vec![false; lines.len()];
603        // Stack of active admonition indentation levels (supports nesting)
604        let mut admonition_stack: Vec<usize> = Vec::new();
605
606        for (i, line) in lines.iter().enumerate() {
607            let line_indent = calculate_indentation_width_default(line);
608
609            // Check if this is an admonition marker
610            if mkdocs_admonitions::is_admonition_start(line) {
611                let adm_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
612
613                // Pop any admonitions that this one is not nested within
614                while let Some(&top_indent) = admonition_stack.last() {
615                    // New admonition must be indented more than parent to be nested
616                    if adm_indent <= top_indent {
617                        admonition_stack.pop();
618                    } else {
619                        break;
620                    }
621                }
622
623                // Push this admonition onto the stack
624                admonition_stack.push(adm_indent);
625                in_admonition_context[i] = true;
626                continue;
627            }
628
629            // Handle empty lines - they're valid within admonitions
630            if line.trim().is_empty() {
631                if !admonition_stack.is_empty() {
632                    in_admonition_context[i] = true;
633                }
634                continue;
635            }
636
637            // For non-empty lines, check if we're still in any admonition context
638            // Pop admonitions where the content indent requirement is not met
639            while let Some(&top_indent) = admonition_stack.last() {
640                // Content must be indented at least 4 spaces from the admonition marker
641                if line_indent >= top_indent + 4 {
642                    // This line is valid content for the top admonition (or one below)
643                    break;
644                } else {
645                    // Not indented enough for this admonition - pop it
646                    admonition_stack.pop();
647                }
648            }
649
650            // If we're still in any admonition context, mark this line
651            if !admonition_stack.is_empty() {
652                in_admonition_context[i] = true;
653            }
654        }
655
656        in_admonition_context
657    }
658
659    /// Build the pre-computed per-line context arrays that indented code
660    /// block detection consults. One call per `check`/`fix` invocation.
661    ///
662    /// The list, tab, and admonition trackers here intentionally differ from
663    /// the `LineInfo` flags (`in_list_block`, `in_content_tab`,
664    /// `in_admonition`) that `LintContext` computes: the admonition tracker
665    /// supports nesting via an indent stack, and the list tracker applies a
666    /// conservative continuation heuristic (a list context survives up to 5
667    /// unindented lines or one blank) tuned to avoid rewriting list
668    /// continuations as code blocks. Only `in_comment_or_html` and the list
669    /// item baselines project straight from `LintContext`.
670    fn build_indent_context(
671        &self,
672        ctx: &crate::lint_context::LintContext,
673        lines: &[&str],
674        is_mkdocs: bool,
675    ) -> OwnedIndentContext {
676        OwnedIndentContext {
677            in_list_context: self.precompute_block_continuation_context(lines),
678            in_tab_context: if is_mkdocs {
679                self.precompute_mkdocs_tab_context(lines)
680            } else {
681                vec![false; lines.len()]
682            },
683            in_admonition_context: if is_mkdocs {
684                self.precompute_mkdocs_admonition_context(lines)
685            } else {
686                vec![false; lines.len()]
687            },
688            in_comment_or_html: Self::precompute_comment_or_html_context(ctx, lines.len()),
689            list_item_baseline: self.precompute_list_item_baseline(ctx, lines),
690        }
691    }
692
693    /// Categorize indented blocks for fix behavior
694    ///
695    /// Returns two vectors:
696    /// - `is_misplaced`: Lines that are part of a complete misplaced fenced block (dedent only)
697    /// - `contains_fences`: Lines that contain fence markers but aren't a complete block (skip fixing)
698    ///
699    /// A misplaced fenced block is a contiguous indented block that:
700    /// 1. Starts with a valid fence opener (``` or ~~~)
701    /// 2. Ends with a matching fence closer
702    ///
703    /// An unsafe block contains fence markers but isn't complete - wrapping would create invalid markdown.
704    fn categorize_indented_blocks(&self, lines: &[&str], block_lines: &[bool]) -> (Vec<bool>, Vec<bool>) {
705        let mut is_misplaced = vec![false; lines.len()];
706        let mut contains_fences = vec![false; lines.len()];
707
708        // Find contiguous indented blocks and categorize them
709        let mut i = 0;
710        while i < lines.len() {
711            // Find the start of an indented block
712            if !block_lines[i] {
713                i += 1;
714                continue;
715            }
716
717            // Found start of an indented block - collect all contiguous lines
718            let block_start = i;
719            let mut block_end = i;
720
721            while block_end < lines.len() && block_lines[block_end] {
722                block_end += 1;
723            }
724
725            // Now we have an indented block from block_start to block_end (exclusive)
726            if block_end > block_start {
727                let first_line = lines[block_start].trim_start();
728                let last_line = lines[block_end - 1].trim_start();
729
730                // Check if first line is a fence opener
731                let is_backtick_fence = first_line.starts_with("```");
732                let is_tilde_fence = first_line.starts_with("~~~");
733
734                if is_backtick_fence || is_tilde_fence {
735                    let fence_char = if is_backtick_fence { '`' } else { '~' };
736                    let opener_len = first_line.chars().take_while(|&c| c == fence_char).count();
737
738                    // Check if last line is a matching fence closer
739                    let closer_fence_len = last_line.chars().take_while(|&c| c == fence_char).count();
740                    let after_closer = &last_line[closer_fence_len..];
741
742                    if closer_fence_len >= opener_len && after_closer.trim().is_empty() {
743                        // Complete misplaced fenced block - safe to dedent
744                        is_misplaced[block_start..block_end].fill(true);
745                    } else {
746                        // Incomplete fenced block - unsafe to wrap (would create nested fences)
747                        contains_fences[block_start..block_end].fill(true);
748                    }
749                } else {
750                    // Check if ANY line in the block contains fence markers
751                    // If so, wrapping would create invalid markdown
752                    let has_fence_markers = (block_start..block_end).any(|j| {
753                        let trimmed = lines[j].trim_start();
754                        trimmed.starts_with("```") || trimmed.starts_with("~~~")
755                    });
756
757                    if has_fence_markers {
758                        contains_fences[block_start..block_end].fill(true);
759                    }
760                }
761            }
762
763            i = block_end;
764        }
765
766        (is_misplaced, contains_fences)
767    }
768
769    fn check_unclosed_code_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
770        let mut warnings = Vec::new();
771        let lines = ctx.raw_lines();
772
773        // Check if any fenced block has a markdown/md language tag
774        let has_markdown_doc_block = ctx.code_block_details.iter().any(|d| {
775            if !d.is_fenced {
776                return false;
777            }
778            let lang = d.info_string.to_lowercase();
779            lang.starts_with("markdown") || lang.starts_with("md")
780        });
781
782        // Skip unclosed block detection if document contains markdown documentation blocks
783        // (they have nested fence examples that pulldown-cmark misparses)
784        if has_markdown_doc_block {
785            return warnings;
786        }
787
788        for detail in &ctx.code_block_details {
789            if !detail.is_fenced {
790                continue;
791            }
792
793            // Only check blocks that extend to EOF
794            if detail.end != ctx.content.len() {
795                continue;
796            }
797
798            // Find the line index for this block's start
799            let opening_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
800                Ok(idx) => idx,
801                Err(idx) => idx.saturating_sub(1),
802            };
803
804            // Determine fence marker from the actual line content
805            let line = lines.get(opening_line_idx).unwrap_or(&"");
806            let trimmed = line.trim();
807            let fence_marker = if let Some(pos) = trimmed.find("```") {
808                let count = trimmed[pos..].chars().take_while(|&c| c == '`').count();
809                "`".repeat(count)
810            } else if let Some(pos) = trimmed.find("~~~") {
811                let count = trimmed[pos..].chars().take_while(|&c| c == '~').count();
812                "~".repeat(count)
813            } else {
814                "```".to_string()
815            };
816
817            // Check if the last non-empty line is a valid closing fence
818            let last_non_empty_line = lines.iter().rev().find(|l| !l.trim().is_empty()).unwrap_or(&"");
819            let last_trimmed = last_non_empty_line.trim();
820            let fence_char = fence_marker.chars().next().unwrap_or('`');
821
822            let has_closing_fence = if fence_char == '`' {
823                last_trimmed.starts_with("```") && {
824                    let fence_len = last_trimmed.chars().take_while(|&c| c == '`').count();
825                    last_trimmed[fence_len..].trim().is_empty()
826                }
827            } else {
828                last_trimmed.starts_with("~~~") && {
829                    let fence_len = last_trimmed.chars().take_while(|&c| c == '~').count();
830                    last_trimmed[fence_len..].trim().is_empty()
831                }
832            };
833
834            if !has_closing_fence {
835                // Skip if inside HTML comment
836                if ctx
837                    .lines
838                    .get(opening_line_idx)
839                    .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
840                {
841                    continue;
842                }
843
844                let (start_line, start_col, end_line, end_col) = calculate_line_range(opening_line_idx + 1, line);
845
846                warnings.push(LintWarning {
847                    rule_name: Some(self.name().to_string()),
848                    line: start_line,
849                    column: start_col,
850                    end_line,
851                    end_column: end_col,
852                    message: format!("Code block opened with '{fence_marker}' but never closed"),
853                    severity: Severity::Warning,
854                    fix: Some(Fix::new(
855                        ctx.content.len()..ctx.content.len(),
856                        format!("\n{fence_marker}"),
857                    )),
858                });
859            }
860        }
861
862        warnings
863    }
864
865    /// Resolve the style MD046 should converge on.
866    ///
867    /// A Gherkin Doc String is only ever a backtick fence, so an indented block
868    /// can never be one, and a configuration demanding indented code cannot be
869    /// satisfied in this flavor. MDG therefore always converges on fenced:
870    /// `consistent` resolves to fenced rather than to whichever style happens
871    /// to be more common, and an explicit `indented` is not adopted.
872    fn effective_target_style(
873        &self,
874        flavor: crate::config::MarkdownFlavor,
875        detect: impl FnOnce() -> CodeBlockStyle,
876    ) -> CodeBlockStyle {
877        if flavor == crate::config::MarkdownFlavor::MDG {
878            self.warn_once_about_overridden_style();
879            return CodeBlockStyle::Fenced;
880        }
881
882        match self.config.style {
883            CodeBlockStyle::Consistent => detect(),
884            style => style,
885        }
886    }
887
888    /// Tell the user once that MDG did not adopt the style they configured.
889    ///
890    /// Only `indented` is worth reporting: it is the one setting MDG cannot
891    /// satisfy. `consistent` asks for no particular form, and fenced is what
892    /// MDG picks for it anyway.
893    fn warn_once_about_overridden_style(&self) {
894        if !self.style_explicit || self.config.style != CodeBlockStyle::Indented {
895            return;
896        }
897
898        MDG_STYLE_OVERRIDE.report(
899            "MD046",
900            "style",
901            "indented",
902            "fenced",
903            "a Gherkin Doc String is only ever a backtick fence",
904        );
905    }
906
907    fn detect_style(
908        &self,
909        ctx: &crate::lint_context::LintContext,
910        lines: &[&str],
911        is_mkdocs: bool,
912        ictx: &IndentContext,
913    ) -> Option<CodeBlockStyle> {
914        if lines.is_empty() {
915            return None;
916        }
917
918        let block_lines = self.indented_block_lines(lines, is_mkdocs, ictx, ctx.flavor);
919
920        let mut fenced_count = 0;
921        let mut indented_count = 0;
922
923        // Count all code block occurrences (prevalence-based approach).
924        //
925        // Both counts must ignore fence markers and indented text that live
926        // inside a non-code container (HTML/MDX comments, raw HTML/JSX
927        // blocks, mkdocstrings, footnote definitions, blockquotes) so that
928        // the detected style stays in lockstep with the warning-side skip
929        // list in `check`. Without this, a document that contains a single
930        // real code block plus a fake fence or indented paragraph nested in
931        // a comment is wrongly classified and the real block gets flagged.
932        let mut in_fenced = false;
933        let mut prev_was_indented = false;
934
935        for (i, line) in lines.iter().enumerate() {
936            let in_container = ictx.in_comment_or_html.get(i).copied().unwrap_or(false);
937
938            // Lines inside Azure DevOps colon code fences are verbatim content.
939            // Any fence markers they contain are not real block delimiters and
940            // must not influence the fenced/indented style tally.
941            if ctx.flavor.supports_colon_code_fences() && ctx.lines.get(i).is_some_and(|l| l.in_code_block) {
942                prev_was_indented = false;
943                continue;
944            }
945
946            // Lines inside MyST colon directives are structural containers, not code blocks.
947            if ctx.flavor.supports_myst_directives() && ctx.lines.get(i).is_some_and(|l| l.in_myst_directive) {
948                prev_was_indented = false;
949                continue;
950            }
951
952            if self.is_fenced_code_block_start(line) {
953                if in_container {
954                    // Fence marker inside a container — not a real fence,
955                    // don't flip state or count it.
956                    prev_was_indented = false;
957                    continue;
958                }
959                if !in_fenced {
960                    // Opening fence
961                    fenced_count += 1;
962                    in_fenced = true;
963                } else {
964                    // Closing fence
965                    in_fenced = false;
966                }
967                prev_was_indented = false;
968            } else if !in_fenced && block_lines[i] {
969                // Count each continuous indented block once
970                if !prev_was_indented {
971                    indented_count += 1;
972                }
973                prev_was_indented = true;
974            } else {
975                prev_was_indented = false;
976            }
977        }
978
979        if fenced_count == 0 && indented_count == 0 {
980            None
981        } else if fenced_count > 0 && indented_count == 0 {
982            Some(CodeBlockStyle::Fenced)
983        } else if fenced_count == 0 && indented_count > 0 {
984            Some(CodeBlockStyle::Indented)
985        } else if fenced_count >= indented_count {
986            Some(CodeBlockStyle::Fenced)
987        } else {
988            Some(CodeBlockStyle::Indented)
989        }
990    }
991}
992
993impl Rule for MD046CodeBlockStyle {
994    fn name(&self) -> &'static str {
995        "MD046"
996    }
997
998    fn description(&self) -> &'static str {
999        "Code blocks should use a consistent style"
1000    }
1001
1002    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1003        // Early return for empty content
1004        if ctx.content.is_empty() {
1005            return Ok(Vec::new());
1006        }
1007
1008        // Quick check for code blocks before processing
1009        if !ctx.content.contains("```")
1010            && !ctx.content.contains("~~~")
1011            && !ctx.content.contains("    ")
1012            && !ctx.content.contains('\t')
1013        {
1014            return Ok(Vec::new());
1015        }
1016
1017        // First, always check for unclosed code blocks
1018        let unclosed_warnings = self.check_unclosed_code_blocks(ctx);
1019
1020        // If we found unclosed blocks, return those warnings first
1021        if !unclosed_warnings.is_empty() {
1022            return Ok(unclosed_warnings);
1023        }
1024
1025        // Check for code block style consistency
1026        let lines = ctx.raw_lines();
1027        let mut warnings = Vec::new();
1028
1029        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1030
1031        // Determine the target style
1032        let target_style = self.effective_target_style(ctx.flavor, || {
1033            let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1034            let detected = self.detect_style(ctx, lines, is_mkdocs, &owned.borrow());
1035            detected.unwrap_or(CodeBlockStyle::Fenced)
1036        });
1037
1038        // Under MDG, `indented_block_lines` is the single source of truth for
1039        // which indented lines are code and which are Gherkin Data/Examples
1040        // table rows. Reading the array `fix` converts from — rather than
1041        // re-deciding it here — is what keeps the two paths in agreement.
1042        let mdg_block_lines = (ctx.flavor == crate::config::MarkdownFlavor::MDG
1043            && ctx.code_block_details.iter().any(|detail| !detail.is_fenced))
1044        .then(|| {
1045            let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1046            self.indented_block_lines(lines, is_mkdocs, &owned.borrow(), ctx.flavor)
1047        });
1048
1049        // Iterate code_block_details directly (O(k) where k is number of blocks)
1050        let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
1051
1052        for detail in &ctx.code_block_details {
1053            if detail.start >= ctx.content.len() || detail.end > ctx.content.len() {
1054                continue;
1055            }
1056
1057            let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
1058                Ok(idx) => idx,
1059                Err(idx) => idx.saturating_sub(1),
1060            };
1061
1062            if detail.is_fenced {
1063                if target_style == CodeBlockStyle::Indented {
1064                    let line = lines.get(start_line_idx).unwrap_or(&"");
1065
1066                    if ctx
1067                        .lines
1068                        .get(start_line_idx)
1069                        .is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
1070                    {
1071                        continue;
1072                    }
1073
1074                    let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1075                    warnings.push(LintWarning {
1076                        rule_name: Some(self.name().to_string()),
1077                        line: start_line,
1078                        column: start_col,
1079                        end_line,
1080                        end_column: end_col,
1081                        message: "Use indented code blocks".to_string(),
1082                        severity: Severity::Warning,
1083                        fix: None,
1084                    });
1085                }
1086            } else {
1087                // Indented code block
1088                if target_style == CodeBlockStyle::Fenced {
1089                    // Under MDG the block may open on Gherkin table rows that
1090                    // are not code; the line to report is the first one the fix
1091                    // will fence, and a block of nothing but rows is no code
1092                    // block at all.
1093                    let start_line_idx = match &mdg_block_lines {
1094                        Some(block_lines) => {
1095                            match Self::first_code_block_line(ctx, block_lines, start_line_idx, detail.end) {
1096                                Some(idx) => idx,
1097                                None => continue,
1098                            }
1099                        }
1100                        None => start_line_idx,
1101                    };
1102
1103                    if reported_indented_lines.contains(&start_line_idx) {
1104                        continue;
1105                    }
1106
1107                    let line = lines.get(start_line_idx).unwrap_or(&"");
1108
1109                    // Skip blocks in contexts that aren't real indented code blocks
1110                    if ctx.lines.get(start_line_idx).is_some_and(|info| {
1111                        info.in_html_comment
1112                            || info.in_mdx_comment
1113                            || info.in_html_block
1114                            || info.in_jsx_block
1115                            || info.in_mkdocstrings
1116                            || info.in_footnote_definition
1117                            || info.blockquote.is_some()
1118                            || info.in_front_matter
1119                    }) {
1120                        continue;
1121                    }
1122
1123                    // Use pre-computed LineInfo for MkDocs container context
1124                    if is_mkdocs
1125                        && ctx
1126                            .lines
1127                            .get(start_line_idx)
1128                            .is_some_and(|info| info.in_admonition || info.in_content_tab)
1129                    {
1130                        continue;
1131                    }
1132
1133                    reported_indented_lines.insert(start_line_idx);
1134
1135                    let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
1136                    warnings.push(LintWarning {
1137                        rule_name: Some(self.name().to_string()),
1138                        line: start_line,
1139                        column: start_col,
1140                        end_line,
1141                        end_column: end_col,
1142                        message: "Use fenced code blocks".to_string(),
1143                        severity: Severity::Warning,
1144                        fix: None,
1145                    });
1146                }
1147            }
1148        }
1149
1150        // Sort warnings by line number for consistent output
1151        warnings.sort_by_key(|w| (w.line, w.column));
1152
1153        Ok(warnings)
1154    }
1155
1156    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1157        let content = ctx.content;
1158        if content.is_empty() {
1159            return Ok(String::new());
1160        }
1161
1162        let lines = ctx.raw_lines();
1163
1164        // Determine target style
1165        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1166
1167        let owned = self.build_indent_context(ctx, lines, is_mkdocs);
1168        let ictx = owned.borrow();
1169
1170        // The unclosed-fence repair at the end of this function is a repair
1171        // rather than a style conversion: `check` reports it before any style is
1172        // resolved, so the loop below has to run even when no block needs
1173        // converting.
1174        let target_style = self.effective_target_style(ctx.flavor, || {
1175            self.detect_style(ctx, lines, is_mkdocs, &ictx)
1176                .unwrap_or(CodeBlockStyle::Fenced)
1177        });
1178
1179        let block_lines = self.indented_block_lines(lines, is_mkdocs, &ictx, ctx.flavor);
1180
1181        // Categorize indented blocks:
1182        // - misplaced_fence_lines: complete fenced blocks that were over-indented (safe to dedent)
1183        // - unsafe_fence_lines: contain fence markers but aren't complete (skip fixing to avoid broken output)
1184        let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(lines, &block_lines);
1185
1186        let mut result = String::with_capacity(content.len());
1187        let mut in_fenced_block = false;
1188        // Tracks the opening fence: (fence_char, opener_length).
1189        // Per CommonMark spec, the closing fence must use the same character and have
1190        // at least as many characters as the opener, with no info string.
1191        let mut fenced_fence_opener: Option<(char, usize)> = None;
1192        let mut in_indented_block = false;
1193        // Indent string emitted on the opening fence of the current
1194        // indented→fenced conversion (e.g. "  " for an indented block inside
1195        // a `- ` list item, "" at top level). Reused on close so the closing
1196        // fence sits at the same column as the opener.
1197        let mut current_block_fence_indent = String::new();
1198
1199        // Track which code block opening lines are disabled by inline config
1200        let mut current_block_disabled = false;
1201
1202        for (i, line) in lines.iter().enumerate() {
1203            let line_num = i + 1;
1204            let trimmed = line.trim_start();
1205
1206            // Handle fenced code blocks
1207            // Per CommonMark: fence must have 0-3 spaces of indentation
1208            if !in_fenced_block
1209                && Self::has_valid_fence_indent(line)
1210                && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1211            {
1212                // Check if inline config disables this rule for the opening fence
1213                current_block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
1214                in_fenced_block = true;
1215                let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
1216                let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
1217                fenced_fence_opener = Some((fence_char, opener_len));
1218
1219                if current_block_disabled {
1220                    // Inline config disables this rule — preserve original
1221                    result.push_str(line);
1222                    result.push('\n');
1223                } else if target_style == CodeBlockStyle::Indented {
1224                    // Skip the opening fence
1225                    in_indented_block = true;
1226                } else {
1227                    // Keep the fenced block
1228                    result.push_str(line);
1229                    result.push('\n');
1230                }
1231            } else if in_fenced_block && fenced_fence_opener.is_some() {
1232                let (fence_char, opener_len) = fenced_fence_opener.unwrap();
1233                // Per CommonMark: closing fence uses the same character, has at least as
1234                // many characters as the opener, and has no info string (only optional trailing spaces).
1235                let closer_len = trimmed.chars().take_while(|&c| c == fence_char).count();
1236                let after_closer = &trimmed[closer_len..];
1237                let is_closer = closer_len >= opener_len && after_closer.trim().is_empty() && closer_len > 0;
1238                if is_closer {
1239                    in_fenced_block = false;
1240                    fenced_fence_opener = None;
1241                    in_indented_block = false;
1242
1243                    if current_block_disabled {
1244                        result.push_str(line);
1245                        result.push('\n');
1246                    } else if target_style == CodeBlockStyle::Indented {
1247                        // Skip the closing fence
1248                    } else {
1249                        // Keep the fenced block
1250                        result.push_str(line);
1251                        result.push('\n');
1252                    }
1253                    current_block_disabled = false;
1254                } else if current_block_disabled {
1255                    // Inline config disables this rule — preserve original
1256                    result.push_str(line);
1257                    result.push('\n');
1258                } else if target_style == CodeBlockStyle::Indented {
1259                    // Convert content inside fenced block to indented.
1260                    // IMPORTANT: Preserve the original line content (including internal indentation);
1261                    // don't use trimmed, as that would strip internal code indentation.
1262                    // Leave blank lines empty so we don't emit "    " (trailing
1263                    // whitespace), which MD009 would flag and which would break
1264                    // idempotency on a second fix pass.
1265                    if !line.is_empty() {
1266                        result.push_str("    ");
1267                        result.push_str(line);
1268                    }
1269                    result.push('\n');
1270                } else {
1271                    // Keep fenced block content as is
1272                    result.push_str(line);
1273                    result.push('\n');
1274                }
1275            } else if block_lines[i] {
1276                // This is an indented code block
1277
1278                // Respect inline disable comments
1279                if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
1280                    result.push_str(line);
1281                    result.push('\n');
1282                    continue;
1283                }
1284
1285                // Check if we need to start a new fenced block
1286                let prev_line_is_indented = i > 0 && block_lines[i - 1];
1287
1288                if target_style == CodeBlockStyle::Fenced {
1289                    // Anchor fences at the list-item content baseline when
1290                    // converting a list-internal indented block (e.g. column
1291                    // 2 for `- `), so the new fenced block stays attached
1292                    // to the bullet. Top-level indented blocks have no
1293                    // baseline → fences sit at column 0.
1294                    let baseline = ictx.list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1295                    // Per CommonMark, the indented-code prefix is exactly 4
1296                    // spaces past the surrounding container's content
1297                    // column. Strip those 4 spaces (not all leading
1298                    // whitespace) so any internal indentation past that
1299                    // point is preserved verbatim in the fenced body. An
1300                    // interior blank line carries no content, so it is
1301                    // emitted empty rather than as leftover whitespace.
1302                    let body = if line.trim().is_empty() {
1303                        ""
1304                    } else {
1305                        line.strip_prefix("    ").unwrap_or(line)
1306                    };
1307
1308                    // Check if this line is part of a misplaced fenced block
1309                    // (pre-computed block-level analysis, not per-line)
1310                    if misplaced_fence_lines[i] {
1311                        // Just remove the indentation - this is a complete misplaced fenced block
1312                        result.push_str(line.trim_start());
1313                        result.push('\n');
1314                    } else if unsafe_fence_lines[i] {
1315                        // This block contains fence markers but isn't a complete fenced block
1316                        // Wrapping would create invalid nested fences - keep as-is (don't fix)
1317                        result.push_str(line);
1318                        result.push('\n');
1319                    } else if !prev_line_is_indented && !in_indented_block {
1320                        // Start of a new indented block that should be fenced
1321                        current_block_fence_indent = " ".repeat(baseline);
1322                        result.push_str(&current_block_fence_indent);
1323                        result.push_str(Self::FENCE);
1324                        result.push('\n');
1325                        result.push_str(body);
1326                        result.push('\n');
1327                        in_indented_block = true;
1328                    } else {
1329                        // Inside an indented block
1330                        result.push_str(body);
1331                        result.push('\n');
1332                    }
1333
1334                    // Check if this is the end of the indented block
1335                    let next_line_is_indented = i < lines.len() - 1 && block_lines[i + 1];
1336                    // Don't close if this is an unsafe block (kept as-is)
1337                    if !next_line_is_indented
1338                        && in_indented_block
1339                        && !misplaced_fence_lines[i]
1340                        && !unsafe_fence_lines[i]
1341                    {
1342                        result.push_str(&current_block_fence_indent);
1343                        result.push_str(Self::FENCE);
1344                        result.push('\n');
1345                        in_indented_block = false;
1346                        current_block_fence_indent.clear();
1347                    }
1348                } else {
1349                    // Keep indented block as is
1350                    result.push_str(line);
1351                    result.push('\n');
1352                }
1353            } else {
1354                // Regular line
1355                if in_indented_block && target_style == CodeBlockStyle::Fenced {
1356                    result.push_str(&current_block_fence_indent);
1357                    result.push_str(Self::FENCE);
1358                    result.push('\n');
1359                    in_indented_block = false;
1360                    current_block_fence_indent.clear();
1361                }
1362
1363                result.push_str(line);
1364                result.push('\n');
1365            }
1366        }
1367
1368        // Close any remaining blocks
1369        if in_indented_block && target_style == CodeBlockStyle::Fenced {
1370            result.push_str(&current_block_fence_indent);
1371            result.push_str(Self::FENCE);
1372            result.push('\n');
1373        }
1374
1375        // Close any unclosed fenced blocks.
1376        // Only close if check() also confirms this block is unclosed. The line-by-line
1377        // fence scanner in fix() can disagree with pulldown-cmark on block boundaries
1378        // (e.g., markdown documentation blocks with nested fence examples), so we use
1379        // check_unclosed_code_blocks() as the authoritative source of truth.
1380        if let Some((fence_char, opener_len)) = fenced_fence_opener
1381            && in_fenced_block
1382        {
1383            let has_unclosed_violation = !self.check_unclosed_code_blocks(ctx).is_empty();
1384            if has_unclosed_violation {
1385                let closer: String = std::iter::repeat_n(fence_char, opener_len).collect();
1386                result.push_str(&closer);
1387                result.push('\n');
1388            }
1389        }
1390
1391        // Remove trailing newline if original didn't have one
1392        if !content.ends_with('\n') && result.ends_with('\n') {
1393            result.pop();
1394        }
1395
1396        Ok(result)
1397    }
1398
1399    /// Get the category of this rule for selective processing
1400    fn category(&self) -> RuleCategory {
1401        RuleCategory::CodeBlock
1402    }
1403
1404    /// Check if this rule should be skipped
1405    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1406        // Skip if content is empty or unlikely to contain code blocks
1407        // Note: indented code blocks use 4 spaces, can't optimize that easily
1408        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains("    "))
1409    }
1410
1411    fn as_any(&self) -> &dyn std::any::Any {
1412        self
1413    }
1414
1415    crate::impl_rule_config_sections!(MD046Config);
1416
1417    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1418    where
1419        Self: Sized,
1420    {
1421        let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1422        let style_explicit = option_is_explicit(config, "MD046", "style");
1423
1424        Box::new(Self {
1425            config: rule_config,
1426            style_explicit,
1427        })
1428    }
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433    use super::*;
1434    use crate::lint_context::LintContext;
1435
1436    /// Test helper: detect_style with automatic context computation.
1437    ///
1438    /// The container context (HTML/MDX comments, HTML/JSX blocks,
1439    /// mkdocstrings, footnote definitions, blockquotes) is not populated by
1440    /// this helper — callers that need to exercise those paths should go
1441    /// through the full `rule.check(&ctx)` entry point so the real LineInfo
1442    /// is computed from a `LintContext`.
1443    ///
1444    /// Colon fence exclusion is also not active here: tests that need Azure
1445    /// DevOps colon fence skipping must use the full `check` entry point with
1446    /// an `AzureDevOps` flavor `LintContext`.
1447    fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
1448        let flavor = if is_mkdocs {
1449            crate::config::MarkdownFlavor::MkDocs
1450        } else {
1451            crate::config::MarkdownFlavor::Standard
1452        };
1453        let ctx = LintContext::new(content, flavor, None);
1454        let lines: Vec<&str> = content.lines().collect();
1455        let in_list_context = rule.precompute_block_continuation_context(&lines);
1456        let in_tab_context = if is_mkdocs {
1457            rule.precompute_mkdocs_tab_context(&lines)
1458        } else {
1459            vec![false; lines.len()]
1460        };
1461        let in_admonition_context = if is_mkdocs {
1462            rule.precompute_mkdocs_admonition_context(&lines)
1463        } else {
1464            vec![false; lines.len()]
1465        };
1466        let in_comment_or_html = vec![false; lines.len()];
1467        // List baseline is None for every line: this helper preserves the
1468        // pre-baseline behavior where any list-context line is conservatively
1469        // skipped. Tests that need list-internal indented code blocks
1470        // recognized must drive the rule through `check`/`fix` with a real
1471        // `LintContext`.
1472        let list_item_baseline: Vec<Option<usize>> = vec![None; lines.len()];
1473        let ictx = IndentContext {
1474            in_list_context: &in_list_context,
1475            in_tab_context: &in_tab_context,
1476            in_admonition_context: &in_admonition_context,
1477            in_comment_or_html: &in_comment_or_html,
1478            list_item_baseline: &list_item_baseline,
1479        };
1480        rule.detect_style(&ctx, &lines, is_mkdocs, &ictx)
1481    }
1482
1483    #[test]
1484    fn test_fenced_code_block_detection() {
1485        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1486        assert!(rule.is_fenced_code_block_start("```"));
1487        assert!(rule.is_fenced_code_block_start("```rust"));
1488        assert!(rule.is_fenced_code_block_start("~~~"));
1489        assert!(rule.is_fenced_code_block_start("~~~python"));
1490        assert!(rule.is_fenced_code_block_start("  ```"));
1491        assert!(!rule.is_fenced_code_block_start("``"));
1492        assert!(!rule.is_fenced_code_block_start("~~"));
1493        assert!(!rule.is_fenced_code_block_start("Regular text"));
1494    }
1495
1496    #[test]
1497    fn test_consistent_style_with_fenced_blocks() {
1498        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1499        let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1500        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1501        let result = rule.check(&ctx).unwrap();
1502
1503        // All blocks are fenced, so consistent style should be OK
1504        assert_eq!(result.len(), 0);
1505    }
1506
1507    #[test]
1508    fn test_consistent_style_with_indented_blocks() {
1509        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1510        let content = "Text\n\n    code\n    more code\n\nMore text\n\n    another block";
1511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512        let result = rule.check(&ctx).unwrap();
1513
1514        // All blocks are indented, so consistent style should be OK
1515        assert_eq!(result.len(), 0);
1516    }
1517
1518    #[test]
1519    fn test_consistent_style_mixed() {
1520        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1521        let content = "```\nfenced code\n```\n\nText\n\n    indented code\n\nMore";
1522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1523        let result = rule.check(&ctx).unwrap();
1524
1525        // Mixed styles should be flagged
1526        assert!(!result.is_empty());
1527    }
1528
1529    #[test]
1530    fn test_fenced_style_with_indented_blocks() {
1531        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1532        let content = "Text\n\n    indented code\n    more code\n\nMore text";
1533        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534        let result = rule.check(&ctx).unwrap();
1535
1536        // Indented blocks should be flagged when fenced style is required
1537        assert!(!result.is_empty());
1538        assert!(result[0].message.contains("Use fenced code blocks"));
1539    }
1540
1541    #[test]
1542    fn test_fenced_style_with_tab_indented_blocks() {
1543        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1544        let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1545        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1546        let result = rule.check(&ctx).unwrap();
1547
1548        // Tab-indented blocks should also be flagged when fenced style is required
1549        assert!(!result.is_empty());
1550        assert!(result[0].message.contains("Use fenced code blocks"));
1551    }
1552
1553    #[test]
1554    fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1555        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1556        // 2 spaces + tab = 4 columns due to tab expansion (tab goes to column 4)
1557        let content = "Text\n\n  \tmixed indent code\n  \tmore code\n\nMore text";
1558        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1559        let result = rule.check(&ctx).unwrap();
1560
1561        // Mixed whitespace indented blocks should also be flagged
1562        assert!(
1563            !result.is_empty(),
1564            "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1565        );
1566        assert!(result[0].message.contains("Use fenced code blocks"));
1567    }
1568
1569    #[test]
1570    fn test_fenced_style_with_one_space_tab_indent() {
1571        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1572        // 1 space + tab = 4 columns (tab expands to next tab stop at column 4)
1573        let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1574        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1575        let result = rule.check(&ctx).unwrap();
1576
1577        assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1578        assert!(result[0].message.contains("Use fenced code blocks"));
1579    }
1580
1581    #[test]
1582    fn test_indented_style_with_fenced_blocks() {
1583        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1584        let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1585        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1586        let result = rule.check(&ctx).unwrap();
1587
1588        // Fenced blocks should be flagged when indented style is required
1589        assert!(!result.is_empty());
1590        assert!(result[0].message.contains("Use indented code blocks"));
1591    }
1592
1593    #[test]
1594    fn test_unclosed_code_block() {
1595        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1596        let content = "```\ncode without closing fence";
1597        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1598        let result = rule.check(&ctx).unwrap();
1599
1600        assert_eq!(result.len(), 1);
1601        assert!(result[0].message.contains("never closed"));
1602    }
1603
1604    #[test]
1605    fn test_nested_code_blocks() {
1606        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1607        let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1608        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1609        let result = rule.check(&ctx).unwrap();
1610
1611        // This should parse as two separate code blocks
1612        assert_eq!(result.len(), 0);
1613    }
1614
1615    #[test]
1616    fn test_fix_indented_to_fenced() {
1617        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1618        let content = "Text\n\n    code line 1\n    code line 2\n\nMore text";
1619        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620        let fixed = rule.fix(&ctx).unwrap();
1621
1622        assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1623    }
1624
1625    #[test]
1626    fn test_fix_fenced_to_indented() {
1627        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1628        let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1629        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1630        let fixed = rule.fix(&ctx).unwrap();
1631
1632        assert!(fixed.contains("    code line 1\n    code line 2"));
1633        assert!(!fixed.contains("```"));
1634    }
1635
1636    #[test]
1637    fn test_fix_fenced_to_indented_blank_lines_have_no_trailing_spaces() {
1638        // A blank line inside a fenced block must become an empty line, not
1639        // "    " (four trailing spaces), which would violate MD009 and break
1640        // idempotency on the second fix pass.
1641        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1642        let content = "Text\n\n```\ncode line 1\n\ncode line 2\n```\n\nMore text";
1643        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1644        let fixed = rule.fix(&ctx).unwrap();
1645
1646        for line in fixed.lines() {
1647            assert!(
1648                line.is_empty() || !line.trim_end().is_empty() || line == line.trim_end(),
1649                "no line may have trailing whitespace, got {line:?}"
1650            );
1651            assert_ne!(line, "    ", "blank line was indented to trailing spaces");
1652        }
1653        // The blank line between the two code lines is preserved as empty.
1654        assert!(fixed.contains("    code line 1\n\n    code line 2"));
1655    }
1656
1657    #[test]
1658    fn test_is_list_item_requires_delimiter_after_digits() {
1659        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1660        // Real ordered list items.
1661        assert!(rule.is_list_item("1. First"));
1662        assert!(rule.is_list_item("42) Item"));
1663        assert!(rule.is_list_item("  3. Indented item"));
1664        // Bullet list items.
1665        assert!(rule.is_list_item("- bullet"));
1666        assert!(rule.is_list_item("* bullet"));
1667        // Prose starting with a digit but containing ". " or ") " mid-sentence
1668        // is NOT a list item.
1669        assert!(!rule.is_list_item("2 results. More info."));
1670        assert!(!rule.is_list_item("3 options (a, b) here"));
1671        assert!(!rule.is_list_item("100 items in stock. Buy now"));
1672    }
1673
1674    #[test]
1675    fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1676        // Issue #270: When converting fenced code to indented, internal indentation must be preserved
1677        // HTML templates, Python, etc. rely on proper indentation
1678        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1679        let content = r#"# Test
1680
1681```html
1682<!doctype html>
1683<html>
1684  <head>
1685    <title>Test</title>
1686  </head>
1687</html>
1688```
1689"#;
1690        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1691        let fixed = rule.fix(&ctx).unwrap();
1692
1693        // The internal indentation (2 spaces for <head>, 4 for <title>) must be preserved
1694        // Each line gets 4 spaces prepended for the indented code block
1695        assert!(
1696            fixed.contains("      <head>"),
1697            "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1698        );
1699        assert!(
1700            fixed.contains("        <title>"),
1701            "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1702        );
1703        assert!(!fixed.contains("```"), "Fenced markers should be removed");
1704    }
1705
1706    #[test]
1707    fn test_fix_fenced_to_indented_preserves_python_indentation() {
1708        // Issue #270: Python is indentation-sensitive - must preserve internal structure
1709        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1710        let content = r#"# Python Example
1711
1712```python
1713def greet(name):
1714    if name:
1715        print(f"Hello, {name}!")
1716    else:
1717        print("Hello, World!")
1718```
1719"#;
1720        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1721        let fixed = rule.fix(&ctx).unwrap();
1722
1723        // Python indentation must be preserved exactly
1724        assert!(
1725            fixed.contains("    def greet(name):"),
1726            "Function def should have 4 spaces (code block indent)"
1727        );
1728        assert!(
1729            fixed.contains("        if name:"),
1730            "if statement should have 8 spaces (4 code + 4 Python)"
1731        );
1732        assert!(
1733            fixed.contains("            print"),
1734            "print should have 12 spaces (4 code + 8 Python)"
1735        );
1736    }
1737
1738    #[test]
1739    fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1740        // Issue #270: YAML is also indentation-sensitive
1741        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1742        let content = r#"# Config
1743
1744```yaml
1745server:
1746  host: localhost
1747  port: 8080
1748  ssl:
1749    enabled: true
1750    cert: /path/to/cert
1751```
1752"#;
1753        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1754        let fixed = rule.fix(&ctx).unwrap();
1755
1756        assert!(fixed.contains("    server:"), "Root key should have 4 spaces");
1757        assert!(fixed.contains("      host:"), "First level should have 6 spaces");
1758        assert!(fixed.contains("      ssl:"), "ssl key should have 6 spaces");
1759        assert!(fixed.contains("        enabled:"), "Nested ssl should have 8 spaces");
1760    }
1761
1762    #[test]
1763    fn test_fix_fenced_to_indented_preserves_empty_lines() {
1764        // Blank lines within a converted code block stay blank: they keep their
1765        // place but must not gain the 4-space prefix (that would be trailing
1766        // whitespace).
1767        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1768        let content = "```\nline1\n\nline2\n```\n";
1769        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1770        let fixed = rule.fix(&ctx).unwrap();
1771
1772        // Content lines are indented; the blank line between them stays empty.
1773        assert!(fixed.contains("    line1"), "line1 should be indented");
1774        assert!(fixed.contains("    line2"), "line2 should be indented");
1775        assert!(
1776            fixed.contains("    line1\n\n    line2"),
1777            "blank line must stay empty, got {fixed:?}"
1778        );
1779    }
1780
1781    #[test]
1782    fn test_fix_fenced_to_indented_multiple_blocks() {
1783        // Multiple fenced blocks should all preserve their indentation
1784        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1785        let content = r#"# Doc
1786
1787```python
1788def foo():
1789    pass
1790```
1791
1792Text between.
1793
1794```yaml
1795key:
1796  value: 1
1797```
1798"#;
1799        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1800        let fixed = rule.fix(&ctx).unwrap();
1801
1802        assert!(fixed.contains("    def foo():"), "Python def should be indented");
1803        assert!(fixed.contains("        pass"), "Python body should have 8 spaces");
1804        assert!(fixed.contains("    key:"), "YAML root should have 4 spaces");
1805        assert!(fixed.contains("      value:"), "YAML nested should have 6 spaces");
1806        assert!(!fixed.contains("```"), "No fence markers should remain");
1807    }
1808
1809    #[test]
1810    fn test_fix_unclosed_block() {
1811        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1812        let content = "```\ncode without closing";
1813        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1814        let fixed = rule.fix(&ctx).unwrap();
1815
1816        // Should add closing fence
1817        assert!(fixed.ends_with("```"));
1818    }
1819
1820    #[test]
1821    fn test_code_block_in_list() {
1822        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1823        let content = "- List item\n    code in list\n    more code\n- Next item";
1824        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1825        let result = rule.check(&ctx).unwrap();
1826
1827        // Code in lists should not be flagged
1828        assert_eq!(result.len(), 0);
1829    }
1830
1831    #[test]
1832    fn test_detect_style_fenced() {
1833        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1834        let content = "```\ncode\n```";
1835        let style = detect_style_from_content(&rule, content, false);
1836
1837        assert_eq!(style, Some(CodeBlockStyle::Fenced));
1838    }
1839
1840    #[test]
1841    fn test_detect_style_indented() {
1842        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1843        let content = "Text\n\n    code\n\nMore";
1844        let style = detect_style_from_content(&rule, content, false);
1845
1846        assert_eq!(style, Some(CodeBlockStyle::Indented));
1847    }
1848
1849    #[test]
1850    fn test_detect_style_none() {
1851        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1852        let content = "No code blocks here";
1853        let style = detect_style_from_content(&rule, content, false);
1854
1855        assert_eq!(style, None);
1856    }
1857
1858    #[test]
1859    fn test_tilde_fence() {
1860        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1861        let content = "~~~\ncode\n~~~";
1862        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1863        let result = rule.check(&ctx).unwrap();
1864
1865        // Tilde fences should be accepted as fenced blocks
1866        assert_eq!(result.len(), 0);
1867    }
1868
1869    #[test]
1870    fn test_language_specification() {
1871        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1872        let content = "```rust\nfn main() {}\n```";
1873        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874        let result = rule.check(&ctx).unwrap();
1875
1876        assert_eq!(result.len(), 0);
1877    }
1878
1879    #[test]
1880    fn test_empty_content() {
1881        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1882        let content = "";
1883        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1884        let result = rule.check(&ctx).unwrap();
1885
1886        assert_eq!(result.len(), 0);
1887    }
1888
1889    #[test]
1890    fn test_default_config() {
1891        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1892        let (name, _config) = rule.default_config_section().unwrap();
1893        assert_eq!(name, "MD046");
1894    }
1895
1896    #[test]
1897    fn test_markdown_documentation_block() {
1898        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1899        let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1901        let result = rule.check(&ctx).unwrap();
1902
1903        // Nested code blocks in markdown documentation should be allowed
1904        assert_eq!(result.len(), 0);
1905    }
1906
1907    #[test]
1908    fn test_preserve_trailing_newline() {
1909        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1910        let content = "```\ncode\n```\n";
1911        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1912        let fixed = rule.fix(&ctx).unwrap();
1913
1914        assert_eq!(fixed, content);
1915    }
1916
1917    #[test]
1918    fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1919        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1920        let content = r#"# Document
1921
1922=== "Python"
1923
1924    This is tab content
1925    Not an indented code block
1926
1927    ```python
1928    def hello():
1929        print("Hello")
1930    ```
1931
1932=== "JavaScript"
1933
1934    More tab content here
1935    Also not an indented code block"#;
1936
1937        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1938        let result = rule.check(&ctx).unwrap();
1939
1940        // Should not flag tab content as indented code blocks
1941        assert_eq!(result.len(), 0);
1942    }
1943
1944    #[test]
1945    fn test_mkdocs_tabs_with_actual_indented_code() {
1946        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1947        let content = r#"# Document
1948
1949=== "Tab 1"
1950
1951    This is tab content
1952
1953Regular text
1954
1955    This is an actual indented code block
1956    Should be flagged"#;
1957
1958        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1959        let result = rule.check(&ctx).unwrap();
1960
1961        // Should flag the actual indented code block but not the tab content
1962        assert_eq!(result.len(), 1);
1963        assert!(result[0].message.contains("Use fenced code blocks"));
1964    }
1965
1966    #[test]
1967    fn test_mkdocs_tabs_detect_style() {
1968        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1969        let content = r#"=== "Tab 1"
1970
1971    Content in tab
1972    More content
1973
1974=== "Tab 2"
1975
1976    Content in second tab"#;
1977
1978        // In MkDocs mode, tab content should not be detected as indented code blocks
1979        let style = detect_style_from_content(&rule, content, true);
1980        assert_eq!(style, None); // No code blocks detected
1981
1982        // In standard mode, it would detect indented code blocks
1983        let style = detect_style_from_content(&rule, content, false);
1984        assert_eq!(style, Some(CodeBlockStyle::Indented));
1985    }
1986
1987    #[test]
1988    fn test_mkdocs_nested_tabs() {
1989        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1990        let content = r#"# Document
1991
1992=== "Outer Tab"
1993
1994    Some content
1995
1996    === "Nested Tab"
1997
1998        Nested tab content
1999        Should not be flagged"#;
2000
2001        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2002        let result = rule.check(&ctx).unwrap();
2003
2004        // Nested tabs should not be flagged
2005        assert_eq!(result.len(), 0);
2006    }
2007
2008    #[test]
2009    fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
2010        // Issue #269: MkDocs admonitions have indented bodies that should NOT be
2011        // treated as indented code blocks when style = "fenced"
2012        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2013        let content = r#"# Document
2014
2015!!! note
2016    This is normal admonition content, not a code block.
2017    It spans multiple lines.
2018
2019??? warning "Collapsible Warning"
2020    This is also admonition content.
2021
2022???+ tip "Expanded Tip"
2023    And this one too.
2024
2025Regular text outside admonitions."#;
2026
2027        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2028        let result = rule.check(&ctx).unwrap();
2029
2030        // Admonition content should not be flagged
2031        assert_eq!(
2032            result.len(),
2033            0,
2034            "Admonition content in MkDocs mode should not trigger MD046"
2035        );
2036    }
2037
2038    #[test]
2039    fn test_mkdocs_admonition_with_actual_indented_code() {
2040        // After an admonition ends, regular indented code blocks SHOULD be flagged
2041        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2042        let content = r#"# Document
2043
2044!!! note
2045    This is admonition content.
2046
2047Regular text ends the admonition.
2048
2049    This is actual indented code (should be flagged)"#;
2050
2051        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2052        let result = rule.check(&ctx).unwrap();
2053
2054        // Should only flag the actual indented code block
2055        assert_eq!(result.len(), 1);
2056        assert!(result[0].message.contains("Use fenced code blocks"));
2057    }
2058
2059    #[test]
2060    fn test_admonition_in_standard_mode_flagged() {
2061        // In standard Markdown mode, admonitions are not recognized, so the
2062        // indented content should be flagged as indented code
2063        // Note: A blank line is required before indented code blocks per CommonMark
2064        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2065        let content = r#"# Document
2066
2067!!! note
2068
2069    This looks like code in standard mode.
2070
2071Regular text."#;
2072
2073        // In Standard mode, admonitions are not recognized
2074        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2075        let result = rule.check(&ctx).unwrap();
2076
2077        // The indented content should be flagged in standard mode
2078        assert_eq!(
2079            result.len(),
2080            1,
2081            "Admonition content in Standard mode should be flagged as indented code"
2082        );
2083    }
2084
2085    #[test]
2086    fn test_mkdocs_admonition_with_fenced_code_inside() {
2087        // Issue #269: Admonitions can contain fenced code blocks - must handle correctly
2088        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2089        let content = r#"# Document
2090
2091!!! note "Code Example"
2092    Here's some code:
2093
2094    ```python
2095    def hello():
2096        print("world")
2097    ```
2098
2099    More text after code.
2100
2101Regular text."#;
2102
2103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2104        let result = rule.check(&ctx).unwrap();
2105
2106        // Should not flag anything - the fenced block inside admonition is valid
2107        assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
2108    }
2109
2110    #[test]
2111    fn test_mkdocs_nested_admonitions() {
2112        // Nested admonitions are valid MkDocs syntax
2113        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2114        let content = r#"# Document
2115
2116!!! note "Outer"
2117    Outer content.
2118
2119    !!! warning "Inner"
2120        Inner content.
2121        More inner content.
2122
2123    Back to outer.
2124
2125Regular text."#;
2126
2127        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2128        let result = rule.check(&ctx).unwrap();
2129
2130        // Nested admonitions should not trigger MD046
2131        assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
2132    }
2133
2134    #[test]
2135    fn test_mkdocs_admonition_fix_does_not_wrap() {
2136        // The fix function should not wrap admonition content in fences
2137        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2138        let content = r#"!!! note
2139    Content that should stay as admonition content.
2140    Not be wrapped in code fences.
2141"#;
2142
2143        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2144        let fixed = rule.fix(&ctx).unwrap();
2145
2146        // Fix should not add fence markers to admonition content
2147        assert!(
2148            !fixed.contains("```\n    Content"),
2149            "Admonition content should not be wrapped in fences"
2150        );
2151        assert_eq!(fixed, content, "Content should remain unchanged");
2152    }
2153
2154    #[test]
2155    fn test_mkdocs_empty_admonition() {
2156        // Empty admonitions (marker only) should not cause issues
2157        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2158        let content = r#"!!! note
2159
2160Regular paragraph after empty admonition.
2161
2162    This IS an indented code block (after blank + non-indented line)."#;
2163
2164        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2165        let result = rule.check(&ctx).unwrap();
2166
2167        // The indented code block after the paragraph should be flagged
2168        assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
2169    }
2170
2171    #[test]
2172    fn test_mkdocs_indented_admonition() {
2173        // Admonitions can themselves be indented (e.g., inside list items)
2174        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2175        let content = r#"- List item
2176
2177    !!! note
2178        Indented admonition content.
2179        More content.
2180
2181- Next item"#;
2182
2183        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2184        let result = rule.check(&ctx).unwrap();
2185
2186        // Admonition inside list should not be flagged
2187        assert_eq!(
2188            result.len(),
2189            0,
2190            "Indented admonitions (e.g., in lists) should not be flagged"
2191        );
2192    }
2193
2194    #[test]
2195    fn test_footnote_indented_paragraphs_not_flagged() {
2196        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2197        let content = r#"# Test Document with Footnotes
2198
2199This is some text with a footnote[^1].
2200
2201Here's some code:
2202
2203```bash
2204echo "fenced code block"
2205```
2206
2207More text with another footnote[^2].
2208
2209[^1]: Really interesting footnote text.
2210
2211    Even more interesting second paragraph.
2212
2213[^2]: Another footnote.
2214
2215    With a second paragraph too.
2216
2217    And even a third paragraph!"#;
2218
2219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2220        let result = rule.check(&ctx).unwrap();
2221
2222        // Indented paragraphs in footnotes should not be flagged as code blocks
2223        assert_eq!(result.len(), 0);
2224    }
2225
2226    #[test]
2227    fn test_footnote_definition_detection() {
2228        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2229
2230        // Valid footnote definitions (per CommonMark footnote extension spec)
2231        // Reference: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/footnotes.md
2232        assert!(rule.is_footnote_definition("[^1]: Footnote text"));
2233        assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
2234        assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
2235        assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
2236        assert!(rule.is_footnote_definition("    [^1]: Indented footnote"));
2237        assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
2238        assert!(rule.is_footnote_definition("[^123]: Numeric label"));
2239        assert!(rule.is_footnote_definition("[^_]: Single underscore"));
2240        assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
2241
2242        // Invalid: empty or whitespace-only labels (spec violation)
2243        assert!(!rule.is_footnote_definition("[^]: No label"));
2244        assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
2245        assert!(!rule.is_footnote_definition("[^  ]: Multiple spaces"));
2246        assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
2247
2248        // Invalid: malformed syntax
2249        assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
2250        assert!(!rule.is_footnote_definition("Regular text [^1]:"));
2251        assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
2252        assert!(!rule.is_footnote_definition("[^")); // Too short
2253        assert!(!rule.is_footnote_definition("[^1:")); // Missing closing bracket
2254        assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
2255
2256        // Invalid: disallowed characters in label
2257        assert!(!rule.is_footnote_definition("[^test.name]: Period"));
2258        assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
2259        assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
2260        assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
2261        assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
2262
2263        // Edge case: line breaks not allowed in labels
2264        // (This is a string test, actual multiline would need different testing)
2265        assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
2266    }
2267
2268    #[test]
2269    fn test_footnote_with_blank_lines() {
2270        // Spec requirement: blank lines within footnotes don't terminate them
2271        // if next content is indented (matches GitHub's implementation)
2272        // Reference: commonmark-hs footnote extension behavior
2273        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2274        let content = r#"# Document
2275
2276Text with footnote[^1].
2277
2278[^1]: First paragraph.
2279
2280    Second paragraph after blank line.
2281
2282    Third paragraph after another blank line.
2283
2284Regular text at column 0 ends the footnote."#;
2285
2286        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2287        let result = rule.check(&ctx).unwrap();
2288
2289        // The indented paragraphs in the footnote should not be flagged as code blocks
2290        assert_eq!(
2291            result.len(),
2292            0,
2293            "Indented content within footnotes should not trigger MD046"
2294        );
2295    }
2296
2297    #[test]
2298    fn test_footnote_multiple_consecutive_blank_lines() {
2299        // Edge case: multiple consecutive blank lines within a footnote
2300        // Should still work if next content is indented
2301        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2302        let content = r#"Text[^1].
2303
2304[^1]: First paragraph.
2305
2306
2307
2308    Content after three blank lines (still part of footnote).
2309
2310Not indented, so footnote ends here."#;
2311
2312        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2313        let result = rule.check(&ctx).unwrap();
2314
2315        // The indented content should not be flagged
2316        assert_eq!(
2317            result.len(),
2318            0,
2319            "Multiple blank lines shouldn't break footnote continuation"
2320        );
2321    }
2322
2323    #[test]
2324    fn test_footnote_terminated_by_non_indented_content() {
2325        // Spec requirement: non-indented content always terminates the footnote
2326        // Reference: commonmark-hs footnote extension
2327        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2328        let content = r#"[^1]: Footnote content.
2329
2330    More indented content in footnote.
2331
2332This paragraph is not indented, so footnote ends.
2333
2334    This should be flagged as indented code block."#;
2335
2336        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2337        let result = rule.check(&ctx).unwrap();
2338
2339        // The last indented block should be flagged (it's after the footnote ended)
2340        assert_eq!(
2341            result.len(),
2342            1,
2343            "Indented code after footnote termination should be flagged"
2344        );
2345        assert!(
2346            result[0].message.contains("Use fenced code blocks"),
2347            "Expected MD046 warning for indented code block"
2348        );
2349        assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2350    }
2351
2352    #[test]
2353    fn test_footnote_terminated_by_structural_elements() {
2354        // Spec requirement: headings and horizontal rules terminate footnotes
2355        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2356        let content = r#"[^1]: Footnote content.
2357
2358    More content.
2359
2360## Heading terminates footnote
2361
2362    This indented content should be flagged.
2363
2364---
2365
2366    This should also be flagged (after horizontal rule)."#;
2367
2368        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2369        let result = rule.check(&ctx).unwrap();
2370
2371        // Both indented blocks after structural elements should be flagged
2372        assert_eq!(
2373            result.len(),
2374            2,
2375            "Both indented blocks after termination should be flagged"
2376        );
2377    }
2378
2379    #[test]
2380    fn test_footnote_with_code_block_inside() {
2381        // Spec behavior: footnotes can contain fenced code blocks
2382        // The fenced code must be properly indented within the footnote
2383        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2384        let content = r#"Text[^1].
2385
2386[^1]: Footnote with code:
2387
2388    ```python
2389    def hello():
2390        print("world")
2391    ```
2392
2393    More footnote text after code."#;
2394
2395        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2396        let result = rule.check(&ctx).unwrap();
2397
2398        // Should have no warnings - the fenced code block is valid
2399        assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2400    }
2401
2402    #[test]
2403    fn test_footnote_with_8_space_indented_code() {
2404        // Edge case: code blocks within footnotes need 8 spaces (4 for footnote + 4 for code)
2405        // This should NOT be flagged as it's properly nested indented code
2406        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2407        let content = r#"Text[^1].
2408
2409[^1]: Footnote with nested code.
2410
2411        code block
2412        more code"#;
2413
2414        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2415        let result = rule.check(&ctx).unwrap();
2416
2417        // The 8-space indented code is valid within footnote
2418        assert_eq!(
2419            result.len(),
2420            0,
2421            "8-space indented code within footnotes represents nested code blocks"
2422        );
2423    }
2424
2425    #[test]
2426    fn test_multiple_footnotes() {
2427        // Spec behavior: each footnote definition starts a new block context
2428        // Previous footnote ends when new footnote begins
2429        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2430        let content = r#"Text[^1] and more[^2].
2431
2432[^1]: First footnote.
2433
2434    Continuation of first.
2435
2436[^2]: Second footnote starts here, ending the first.
2437
2438    Continuation of second."#;
2439
2440        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2441        let result = rule.check(&ctx).unwrap();
2442
2443        // All indented content is part of footnotes
2444        assert_eq!(
2445            result.len(),
2446            0,
2447            "Multiple footnotes should each maintain their continuation context"
2448        );
2449    }
2450
2451    #[test]
2452    fn test_list_item_ends_footnote_context() {
2453        // Spec behavior: list items and footnotes are mutually exclusive contexts
2454        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2455        let content = r#"[^1]: Footnote.
2456
2457    Content in footnote.
2458
2459- List item starts here (ends footnote context).
2460
2461    This indented content is part of the list, not the footnote."#;
2462
2463        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2464        let result = rule.check(&ctx).unwrap();
2465
2466        // List continuation should not be flagged
2467        assert_eq!(
2468            result.len(),
2469            0,
2470            "List items should end footnote context and start their own"
2471        );
2472    }
2473
2474    #[test]
2475    fn test_footnote_vs_actual_indented_code() {
2476        // Critical test: verify we can still detect actual indented code blocks outside footnotes
2477        // This ensures the fix doesn't cause false negatives
2478        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2479        let content = r#"# Heading
2480
2481Text with footnote[^1].
2482
2483[^1]: Footnote content.
2484
2485    Part of footnote (should not be flagged).
2486
2487Regular paragraph ends footnote context.
2488
2489    This is actual indented code (MUST be flagged)
2490    Should be detected as code block"#;
2491
2492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2493        let result = rule.check(&ctx).unwrap();
2494
2495        // Should flag the indented code after the regular paragraph
2496        assert_eq!(
2497            result.len(),
2498            1,
2499            "Must still detect indented code blocks outside footnotes"
2500        );
2501        assert!(
2502            result[0].message.contains("Use fenced code blocks"),
2503            "Expected MD046 warning for indented code"
2504        );
2505        assert!(
2506            result[0].line >= 11,
2507            "Warning should be on the actual indented code line"
2508        );
2509    }
2510
2511    #[test]
2512    fn test_spec_compliant_label_characters() {
2513        // Spec requirement: labels must contain only alphanumerics, hyphens, underscores
2514        // Reference: commonmark-hs footnote extension
2515        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2516
2517        // Valid according to spec
2518        assert!(rule.is_footnote_definition("[^test]: text"));
2519        assert!(rule.is_footnote_definition("[^TEST]: text"));
2520        assert!(rule.is_footnote_definition("[^test-name]: text"));
2521        assert!(rule.is_footnote_definition("[^test_name]: text"));
2522        assert!(rule.is_footnote_definition("[^test123]: text"));
2523        assert!(rule.is_footnote_definition("[^123]: text"));
2524        assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2525
2526        // Invalid characters (spec violations)
2527        assert!(!rule.is_footnote_definition("[^test.name]: text")); // Period
2528        assert!(!rule.is_footnote_definition("[^test name]: text")); // Space
2529        assert!(!rule.is_footnote_definition("[^test@name]: text")); // At sign
2530        assert!(!rule.is_footnote_definition("[^test#name]: text")); // Hash
2531        assert!(!rule.is_footnote_definition("[^test$name]: text")); // Dollar
2532        assert!(!rule.is_footnote_definition("[^test%name]: text")); // Percent
2533    }
2534
2535    #[test]
2536    fn test_code_block_inside_html_comment() {
2537        // Regression test: code blocks inside HTML comments should not be flagged
2538        // Found in denoland/deno test fixture during sanity testing
2539        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2540        let content = r#"# Document
2541
2542Some text.
2543
2544<!--
2545Example code block in comment:
2546
2547```typescript
2548console.log("Hello");
2549```
2550
2551More comment text.
2552-->
2553
2554More content."#;
2555
2556        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2557        let result = rule.check(&ctx).unwrap();
2558
2559        assert_eq!(
2560            result.len(),
2561            0,
2562            "Code blocks inside HTML comments should not be flagged as unclosed"
2563        );
2564    }
2565
2566    #[test]
2567    fn test_unclosed_fence_inside_html_comment() {
2568        // Even an unclosed fence inside an HTML comment should be ignored
2569        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2570        let content = r#"# Document
2571
2572<!--
2573Example with intentionally unclosed fence:
2574
2575```
2576code without closing
2577-->
2578
2579More content."#;
2580
2581        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2582        let result = rule.check(&ctx).unwrap();
2583
2584        assert_eq!(
2585            result.len(),
2586            0,
2587            "Unclosed fences inside HTML comments should be ignored"
2588        );
2589    }
2590
2591    #[test]
2592    fn test_multiline_html_comment_with_indented_code() {
2593        // Indented code inside HTML comments should also be ignored
2594        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2595        let content = r#"# Document
2596
2597<!--
2598Example:
2599
2600    indented code
2601    more code
2602
2603End of comment.
2604-->
2605
2606Regular text."#;
2607
2608        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2609        let result = rule.check(&ctx).unwrap();
2610
2611        assert_eq!(
2612            result.len(),
2613            0,
2614            "Indented code inside HTML comments should not be flagged"
2615        );
2616    }
2617
2618    #[test]
2619    fn test_code_block_after_html_comment() {
2620        // Code blocks after HTML comments should still be detected
2621        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2622        let content = r#"# Document
2623
2624<!-- comment -->
2625
2626Text before.
2627
2628    indented code should be flagged
2629
2630More text."#;
2631
2632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2633        let result = rule.check(&ctx).unwrap();
2634
2635        assert_eq!(
2636            result.len(),
2637            1,
2638            "Code blocks after HTML comments should still be detected"
2639        );
2640        assert!(result[0].message.contains("Use fenced code blocks"));
2641    }
2642
2643    #[test]
2644    fn test_consistent_style_indented_html_comment() {
2645        // Under the default `Consistent` style, indented content inside an
2646        // HTML comment must not contribute to the document's code-block style
2647        // tally. Otherwise a single fenced block alongside an indented HTML
2648        // comment flips the detected style to `Indented`, emitting a spurious
2649        // "Use indented code blocks" warning against the only real code block.
2650        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2651        let content = "# MD046 false-positive reproduction\n\
2652                       \n\
2653                       <!--\n    \
2654                       This is just an indented comment, not a code block.\n\
2655                       \n    \
2656                       A second line is required to trigger the false-positive.\n\
2657                       \n    \
2658                       Actually, three lines are required.\n\
2659                       -->\n\
2660                       \n\
2661                       ```md\n\
2662                       This should be fine, since it's the only code block and therefore consistent.\n\
2663                       ```\n";
2664
2665        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2666        let result = rule.check(&ctx).unwrap();
2667
2668        assert_eq!(
2669            result,
2670            vec![],
2671            "A single fenced block and an indented HTML comment must produce no MD046 warnings",
2672        );
2673    }
2674
2675    #[test]
2676    fn test_consistent_style_indented_html_block() {
2677        // Indented content inside a raw HTML block (e.g. a `<div>` tag pair)
2678        // must not count as an indented code block when `detect_style` picks
2679        // the document's predominant style.
2680        //
2681        // Per CommonMark, a type-6 HTML block is terminated by a blank line,
2682        // so the content here is kept contiguous to remain inside the block.
2683        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2684        let content = "# Heading\n\
2685                       \n\
2686                       <div class=\"note\">\n    \
2687                       line one of indented html content\n    \
2688                       line two of indented html content\n    \
2689                       line three of indented html content\n\
2690                       </div>\n\
2691                       \n\
2692                       ```md\n\
2693                       real fenced block\n\
2694                       ```\n";
2695
2696        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2697        let result = rule.check(&ctx).unwrap();
2698
2699        assert_eq!(
2700            result,
2701            vec![],
2702            "Indented content inside a raw HTML block must not influence MD046 style detection",
2703        );
2704    }
2705
2706    #[test]
2707    fn test_consistent_style_fake_fence_inside_html_comment() {
2708        // Fence markers inside an HTML comment must not contribute to the
2709        // fenced count during style detection. Otherwise a document whose
2710        // only real code block is indented gets flagged "Use fenced code
2711        // blocks" under `Consistent` because the verbatim ``` inside the
2712        // comment ties the count.
2713        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2714        let content = "# Title\n\
2715                       \n\
2716                       <!--\n\
2717                       ```\n\
2718                       fake fence inside comment\n\
2719                       ```\n\
2720                       -->\n\
2721                       \n    \
2722                       real indented code block line 1\n    \
2723                       real indented code block line 2\n";
2724
2725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2726        let result = rule.check(&ctx).unwrap();
2727
2728        assert_eq!(
2729            result,
2730            vec![],
2731            "Fence markers inside an HTML comment must not influence MD046 style detection",
2732        );
2733    }
2734
2735    #[test]
2736    fn test_consistent_style_indented_footnote_definition() {
2737        // Footnote-definition continuation lines are commonly indented by 4+
2738        // spaces. They must not be counted as indented code blocks during
2739        // style detection under `Consistent`.
2740        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2741        let content = "# Heading\n\
2742                       \n\
2743                       Reference to a footnote[^note].\n\
2744                       \n\
2745                       [^note]: First line of the footnote.\n    \
2746                       Second indented continuation line.\n    \
2747                       Third indented continuation line.\n    \
2748                       Fourth indented continuation line.\n\
2749                       \n\
2750                       ```md\n\
2751                       real fenced block\n\
2752                       ```\n";
2753
2754        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2755        let result = rule.check(&ctx).unwrap();
2756
2757        assert_eq!(
2758            result,
2759            vec![],
2760            "Footnote-definition continuation content must not influence MD046 style detection",
2761        );
2762    }
2763
2764    #[test]
2765    fn test_consistent_style_indented_blockquote() {
2766        // Indented content inside a blockquote (`>     foo`) must not be
2767        // counted as an indented code block by `detect_style`. The check-side
2768        // skip list already excludes `blockquote.is_some()` for indented
2769        // warnings, so detection must match to keep `Consistent` stable.
2770        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2771        let content = "# Heading\n\
2772                       \n\
2773                       >     line one of quoted indented content\n\
2774                       >\n\
2775                       >     line two of quoted indented content\n\
2776                       >\n\
2777                       >     line three of quoted indented content\n\
2778                       \n\
2779                       ```md\n\
2780                       real fenced block\n\
2781                       ```\n";
2782
2783        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2784        let result = rule.check(&ctx).unwrap();
2785
2786        assert_eq!(
2787            result,
2788            vec![],
2789            "Indented content inside a blockquote must not influence MD046 style detection",
2790        );
2791    }
2792
2793    #[test]
2794    fn test_consistent_style_genuine_indented_block_detected_as_indented() {
2795        // A top-level indented code block that is not inside any container
2796        // must still count toward the Indented tally under `Consistent` style.
2797        // This guards against over-filtering: the `in_comment_or_html` skip
2798        // must not suppress real indented code blocks.
2799        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2800        let content = "# Heading\n\
2801                       \n\
2802                       Some prose.\n\
2803                       \n    \
2804                       real indented code line 1\n    \
2805                       real indented code line 2\n";
2806
2807        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2808        let result = rule.check(&ctx).unwrap();
2809
2810        // Only one indented block exists; Consistent must detect it as Indented and
2811        // produce no warnings (the detected style matches the only real block).
2812        assert_eq!(
2813            result,
2814            vec![],
2815            "A genuine top-level indented block must be detected as Indented style under Consistent",
2816        );
2817    }
2818
2819    #[test]
2820    fn test_consistent_style_skipped_lines_dont_override_real_block() {
2821        // Two indented-but-skipped regions (inside HTML comments) plus one
2822        // genuine indented code block and no fenced blocks: the skipped lines
2823        // must be excluded from the tally, leaving indented_count=1, fenced_count=0,
2824        // so Consistent still selects Indented and emits no warnings.
2825        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2826        let content = "# Heading\n\
2827                       \n\
2828                       <!--\n    \
2829                       skipped indented comment line 1\n    \
2830                       skipped indented comment line 2\n\
2831                       -->\n\
2832                       \n\
2833                       <!--\n    \
2834                       second skipped region\n    \
2835                       also skipped\n\
2836                       -->\n\
2837                       \n    \
2838                       real indented code line\n";
2839
2840        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2841        let result = rule.check(&ctx).unwrap();
2842
2843        assert_eq!(
2844            result,
2845            vec![],
2846            "Skipped container lines must not outweigh the single real indented block",
2847        );
2848    }
2849
2850    #[test]
2851    fn test_consistent_style_fenced_wins_over_skipped_indented() {
2852        // One real fenced block plus two indented-but-skipped regions: after
2853        // filtering the skipped lines the tally is fenced=1, indented=0, so
2854        // Consistent selects Fenced and emits no warnings.
2855        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2856        let content = "# Heading\n\
2857                       \n\
2858                       <!--\n    \
2859                       skipped indented region one\n    \
2860                       more of region one\n\
2861                       -->\n\
2862                       \n\
2863                       <!--\n    \
2864                       skipped indented region two\n    \
2865                       more of region two\n\
2866                       -->\n\
2867                       \n\
2868                       ```md\n\
2869                       real fenced block\n\
2870                       ```\n";
2871
2872        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2873        let result = rule.check(&ctx).unwrap();
2874
2875        assert_eq!(
2876            result,
2877            vec![],
2878            "Fenced block must win when all indented lines are inside skipped containers",
2879        );
2880    }
2881
2882    #[test]
2883    fn test_four_space_indented_fence_is_not_valid_fence() {
2884        // Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces."
2885        // 4+ spaces means it's NOT a valid fence opener - it becomes an indented code block
2886        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2887
2888        // Valid fences (0-3 spaces)
2889        assert!(rule.is_fenced_code_block_start("```"));
2890        assert!(rule.is_fenced_code_block_start(" ```"));
2891        assert!(rule.is_fenced_code_block_start("  ```"));
2892        assert!(rule.is_fenced_code_block_start("   ```"));
2893
2894        // Invalid fences (4+ spaces) - these are indented code blocks instead
2895        assert!(!rule.is_fenced_code_block_start("    ```"));
2896        assert!(!rule.is_fenced_code_block_start("     ```"));
2897        assert!(!rule.is_fenced_code_block_start("        ```"));
2898
2899        // Tab counts as 4 spaces per CommonMark
2900        assert!(!rule.is_fenced_code_block_start("\t```"));
2901    }
2902
2903    #[test]
2904    fn test_issue_237_indented_fenced_block_detected_as_indented() {
2905        // Issue #237: User has fenced code block indented by 4 spaces
2906        // Per CommonMark, this should be detected as an INDENTED code block
2907        // because 4+ spaces of indentation makes the fence invalid
2908        //
2909        // Reference: https://github.com/rvben/rumdl/issues/237
2910        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2911
2912        // This is the exact test case from issue #237
2913        let content = r#"## Test
2914
2915    ```js
2916    var foo = "hello";
2917    ```
2918"#;
2919
2920        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2921        let result = rule.check(&ctx).unwrap();
2922
2923        // Should flag this as an indented code block that should use fenced style
2924        assert_eq!(
2925            result.len(),
2926            1,
2927            "4-space indented fence should be detected as indented code block"
2928        );
2929        assert!(
2930            result[0].message.contains("Use fenced code blocks"),
2931            "Expected 'Use fenced code blocks' message"
2932        );
2933    }
2934
2935    #[test]
2936    fn test_issue_276_indented_code_in_list() {
2937        // Issue #276: Indented code blocks inside lists should be detected
2938        // Reference: https://github.com/rvben/rumdl/issues/276
2939        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2940
2941        let content = r#"1. First item
29422. Second item with code:
2943
2944        # This is a code block in a list
2945        print("Hello, world!")
2946
29474. Third item"#;
2948
2949        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2950        let result = rule.check(&ctx).unwrap();
2951
2952        // Should flag the indented code block inside the list
2953        assert!(
2954            !result.is_empty(),
2955            "Indented code block inside list should be flagged when style=fenced"
2956        );
2957        assert!(
2958            result[0].message.contains("Use fenced code blocks"),
2959            "Expected 'Use fenced code blocks' message"
2960        );
2961    }
2962
2963    #[test]
2964    fn test_three_space_indented_fence_is_valid() {
2965        // 3 spaces is the maximum allowed per CommonMark - should be recognized as fenced
2966        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2967
2968        let content = r#"## Test
2969
2970   ```js
2971   var foo = "hello";
2972   ```
2973"#;
2974
2975        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2976        let result = rule.check(&ctx).unwrap();
2977
2978        // 3-space indent is valid for fenced blocks - should pass
2979        assert_eq!(
2980            result.len(),
2981            0,
2982            "3-space indented fence should be recognized as valid fenced code block"
2983        );
2984    }
2985
2986    #[test]
2987    fn test_indented_style_with_deeply_indented_fenced() {
2988        // When style=indented, a 4-space indented "fenced" block should still be detected
2989        // as an indented code block (which is what we want!)
2990        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2991
2992        let content = r#"Text
2993
2994    ```js
2995    var foo = "hello";
2996    ```
2997
2998More text
2999"#;
3000
3001        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3002        let result = rule.check(&ctx).unwrap();
3003
3004        // When target style is "indented", 4-space indented content is correct
3005        // The fence markers become literal content in the indented code block
3006        assert_eq!(
3007            result.len(),
3008            0,
3009            "4-space indented content should be valid when style=indented"
3010        );
3011    }
3012
3013    #[test]
3014    fn test_fix_misplaced_fenced_block() {
3015        // Issue #237: When a fenced code block is accidentally indented 4+ spaces,
3016        // the fix should just remove the indentation, not wrap in more fences
3017        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3018
3019        let content = r#"## Test
3020
3021    ```js
3022    var foo = "hello";
3023    ```
3024"#;
3025
3026        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3027        let fixed = rule.fix(&ctx).unwrap();
3028
3029        // The fix should just remove the 4-space indentation
3030        let expected = r#"## Test
3031
3032```js
3033var foo = "hello";
3034```
3035"#;
3036
3037        assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
3038    }
3039
3040    #[test]
3041    fn test_fix_regular_indented_block() {
3042        // Regular indented code blocks (without fence markers) should still be
3043        // wrapped in fences when converted
3044        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3045
3046        let content = r#"Text
3047
3048    var foo = "hello";
3049    console.log(foo);
3050
3051More text
3052"#;
3053
3054        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3055        let fixed = rule.fix(&ctx).unwrap();
3056
3057        // Should wrap in fences
3058        assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
3059        assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
3060    }
3061
3062    #[test]
3063    fn test_fix_indented_block_with_fence_like_content() {
3064        // If an indented block contains fence-like content but doesn't form a
3065        // complete fenced block, we should NOT autofix it because wrapping would
3066        // create invalid nested fences. The block is left unchanged.
3067        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3068
3069        let content = r#"Text
3070
3071    some code
3072    ```not a fence opener
3073    more code
3074"#;
3075
3076        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3077        let fixed = rule.fix(&ctx).unwrap();
3078
3079        // Block should be left unchanged to avoid creating invalid nested fences
3080        assert!(fixed.contains("    some code"), "Unsafe block should be left unchanged");
3081        assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
3082    }
3083
3084    #[test]
3085    fn test_fix_mixed_indented_and_misplaced_blocks() {
3086        // Mixed blocks: regular indented code followed by misplaced fenced block
3087        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3088
3089        let content = r#"Text
3090
3091    regular indented code
3092
3093More text
3094
3095    ```python
3096    print("hello")
3097    ```
3098"#;
3099
3100        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3101        let fixed = rule.fix(&ctx).unwrap();
3102
3103        // First block should be wrapped
3104        assert!(
3105            fixed.contains("```\nregular indented code\n```"),
3106            "First block should be wrapped in fences"
3107        );
3108
3109        // Second block should be dedented (not wrapped)
3110        assert!(
3111            fixed.contains("\n```python\nprint(\"hello\")\n```"),
3112            "Second block should be dedented, not double-wrapped"
3113        );
3114        // Should NOT have nested fences
3115        assert!(
3116            !fixed.contains("```\n```python"),
3117            "Should not have nested fence openers"
3118        );
3119    }
3120
3121    #[test]
3122    fn test_md046_front_matter() {
3123        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3124        let content = "---\nmetadata:\n\n    description: Indented\n---\n";
3125        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3126        let result = rule.check(&ctx).unwrap();
3127        assert_eq!(result.len(), 0);
3128    }
3129
3130    #[test]
3131    fn test_md046_fix_front_matter() {
3132        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3133        let content = "---\nmetadata:\n\n    description: Indented\n---\n";
3134        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3135        let fixed = rule.fix(&ctx).unwrap();
3136        assert_eq!(fixed, content);
3137    }
3138
3139    #[test]
3140    fn test_whitespace_only_line_is_not_an_indented_code_block() {
3141        // A line holding four spaces and nothing else is a blank line to
3142        // CommonMark. The fix used to wrap it in a fence of its own, so a
3143        // document with one real indented block elsewhere gained an empty
3144        // fenced block where a blank line stood.
3145        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3146        let content = "# T\n\nPara\n\n    \nMore\n\n    real code\n\nEnd\n";
3147        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3148        let fixed = rule.fix(&ctx).unwrap();
3149        assert_eq!(fixed, "# T\n\nPara\n\n    \nMore\n\n```\nreal code\n```\n\nEnd\n");
3150    }
3151
3152    #[test]
3153    fn test_interior_blank_line_keeps_indented_block_together() {
3154        // CommonMark keeps a blank line between two indented code lines inside
3155        // the block, so `a`, the blank and `b` are one block and convert to one
3156        // fence with an empty line in it, not two fences.
3157        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3158        let content = "# T\n\nPara\n\n    a\n\n    b\n\nAfter\n";
3159        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3160        let fixed = rule.fix(&ctx).unwrap();
3161        assert_eq!(fixed, "# T\n\nPara\n\n```\na\n\nb\n```\n\nAfter\n");
3162    }
3163
3164    #[test]
3165    fn test_consistent_style_counts_a_block_with_interior_blank_once() {
3166        // Style detection counts blocks. Splitting `a` / blank / `b` in two made
3167        // one indented block outvote one fenced block, and the fenced block was
3168        // reported instead of the indented one.
3169        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3170        let content = "# T\n\n```\nfenced\n```\n\nPara\n\n    a\n\n    b\n\nEnd\n";
3171        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3172        let result = rule.check(&ctx).unwrap();
3173        let reported: Vec<(usize, &str)> = result.iter().map(|w| (w.line, w.message.as_str())).collect();
3174        assert_eq!(reported, vec![(9, "Use fenced code blocks")]);
3175    }
3176
3177    #[test]
3178    fn test_indented_lazy_continuation_lines_are_not_code() {
3179        // Indented lines directly under a paragraph line continue that
3180        // paragraph, and so does every indented line after them. Classifying
3181        // the second line by the raw indent of the first turned the run into
3182        // code from its second line on, and the fix fenced the tail of a
3183        // paragraph.
3184        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3185        let content = "# T\n\nPara\n    lazy one\n    lazy two\n    lazy three\n\n    real code\n\nEnd\n";
3186        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3187        let fixed = rule.fix(&ctx).unwrap();
3188        assert_eq!(
3189            fixed,
3190            "# T\n\nPara\n    lazy one\n    lazy two\n    lazy three\n\n```\nreal code\n```\n\nEnd\n"
3191        );
3192    }
3193
3194    #[test]
3195    fn test_misplaced_fence_with_interior_blank_dedents_as_one_block() {
3196        // An over-indented fenced block whose body has a blank line is still
3197        // one complete fenced block, so it is dedented as a whole. Split at the
3198        // blank, neither half had both fences and the block was left alone.
3199        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3200        let content = "# T\n\nPara\n\n    ```python\n    x = 1\n\n    y = 2\n    ```\n\nAfter\n";
3201        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3202        let fixed = rule.fix(&ctx).unwrap();
3203        assert_eq!(fixed, "# T\n\nPara\n\n```python\nx = 1\n\ny = 2\n```\n\nAfter\n");
3204    }
3205    #[test]
3206    fn test_mdg_overrides_indented_style_to_fenced() {
3207        // A Gherkin Doc String is only ever a backtick fence, so a
3208        // configuration demanding indented code cannot be satisfied in this
3209        // flavor. MDG does not adopt it: the Doc String keeps its fence instead
3210        // of being unwrapped into an indented block that deletes it.
3211        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3212        let content = "# Feature: Payloads\n\n## Scenario: JSON payload\n\n* Given this payload\n\n  ```json\n  {\"ok\": true}\n  ```\n";
3213
3214        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3215        assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3216        assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3217
3218        // Standard adopts `indented` exactly as before and unwraps the fence.
3219        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3220        let standard_warnings = rule.check(&standard_ctx).unwrap();
3221        assert_eq!(standard_warnings.len(), 1);
3222        assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3223        assert!(!rule.fix(&standard_ctx).unwrap().contains("```"));
3224    }
3225
3226    #[test]
3227    fn test_mdg_indented_style_still_fences_indented_blocks() {
3228        // The override is not merely a refusal to unwrap fences: MDG enforces
3229        // fenced, so an indented block is converted even though the
3230        // configuration asked for indented code.
3231        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3232        let content =
3233            "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n      ordinary indented code\n";
3234
3235        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3236        let warnings = rule.check(&mdg_ctx).unwrap();
3237        assert_eq!(warnings.len(), 1);
3238        assert_eq!(warnings[0].message, "Use fenced code blocks");
3239
3240        let fixed = rule.fix(&mdg_ctx).unwrap();
3241        assert_eq!(
3242            fixed,
3243            "# Feature: Payloads\n\n## Scenario: Plain payload\n\nSome description.\n\n```\n  ordinary indented code\n```\n"
3244        );
3245
3246        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3247        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3248        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3249
3250        // Standard honours `indented`: the block is already indented, so there
3251        // is nothing to report and nothing to change.
3252        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3253        assert!(rule.check(&standard_ctx).unwrap().is_empty());
3254        assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3255    }
3256
3257    #[test]
3258    fn test_mdg_steers_indented_code_to_fenced() {
3259        // Under MDG a code block is expected to be a backtick fence, so an
3260        // indented block is corrected rather than preserved — whichever style
3261        // the configuration names.
3262        let content = "# Feature: Payloads\n\n## Scenario: Plain payload\n\n* Given this payload\n\n      ordinary indented code\n";
3263
3264        for rule in [
3265            MD046CodeBlockStyle::new(CodeBlockStyle::Fenced),
3266            MD046CodeBlockStyle::new(CodeBlockStyle::Consistent),
3267            MD046CodeBlockStyle::new(CodeBlockStyle::Indented),
3268        ] {
3269            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3270            let warnings = rule.check(&ctx).unwrap();
3271            assert_eq!(warnings.len(), 1);
3272            assert_eq!(warnings[0].message, "Use fenced code blocks");
3273
3274            let fixed = rule.fix(&ctx).unwrap();
3275            assert!(fixed.contains("```"), "MDG must fence the block: {fixed:?}");
3276
3277            let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3278            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3279            assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3280        }
3281    }
3282
3283    #[test]
3284    fn test_mdg_consistent_style_ignores_indented_prevalence() {
3285        // Standard resolves `consistent` by prevalence; MDG always resolves it
3286        // to fenced because only a backtick fence can be a Doc String.
3287        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
3288        let indented_majority = "# Feature: Payloads\n\n## Scenario: Mixed payloads\n\n* Given this payload\n\n```json\n{\"ok\": true}\n```\n\nFirst ordinary example:\n\n    one\n\nSecond ordinary example:\n\n    two\n";
3289
3290        let standard_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::Standard, None);
3291        let standard_warnings = rule.check(&standard_ctx).unwrap();
3292        assert_eq!(standard_warnings.len(), 1);
3293        assert_eq!(standard_warnings[0].message, "Use indented code blocks");
3294
3295        let mdg_ctx = LintContext::new(indented_majority, crate::config::MarkdownFlavor::MDG, None);
3296        let mdg_warnings = rule.check(&mdg_ctx).unwrap();
3297        assert_eq!(mdg_warnings.len(), 2);
3298        assert!(
3299            mdg_warnings
3300                .iter()
3301                .all(|warning| warning.message == "Use fenced code blocks")
3302        );
3303    }
3304
3305    #[test]
3306    fn test_mdg_repairs_unclosed_fence_like_standard() {
3307        // The unclosed-fence repair is flavor independent now that MDG no
3308        // longer takes a bespoke fix path.
3309        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3310        let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3311
3312        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3313        let warnings = rule.check(&mdg_ctx).unwrap();
3314        assert_eq!(warnings.len(), 1);
3315        assert!(warnings[0].message.contains("never closed"));
3316
3317        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3318        assert_eq!(
3319            rule.fix(&mdg_ctx).unwrap(),
3320            rule.fix(&standard_ctx).unwrap(),
3321            "MDG must not differ from Standard"
3322        );
3323    }
3324
3325    #[test]
3326    fn test_mdg_table_above_prose_is_never_fenced() {
3327        // The Examples table and the paragraph below it sit in one CommonMark
3328        // indented code block, split by a blank line. `check` and `fix` read
3329        // the same per-line membership, so the table stays a table and only the
3330        // paragraph is fenced — reporting the block and fencing all of it (or
3331        // skipping the block and fencing it anyway) would delete the table.
3332        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3333        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";
3334
3335        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3336        let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3337        assert_eq!(reported, vec![8, 12]);
3338
3339        let fixed = rule.fix(&mdg_ctx).unwrap();
3340        assert_eq!(
3341            fixed,
3342            "# 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"
3343        );
3344
3345        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3346        assert!(
3347            rule.check(&fixed_ctx).unwrap().is_empty(),
3348            "MDG check must have nothing left to report after its own fix"
3349        );
3350        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3351
3352        // Standard has no Gherkin tables, so the whole block is code there.
3353        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3354        let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3355        assert_eq!(standard_reported, vec![5, 12]);
3356        assert!(rule.fix(&standard_ctx).unwrap().contains("```\n| start | eat | left |"));
3357    }
3358
3359    #[test]
3360    fn test_mdg_repairs_unclosed_fence_under_indented_style() {
3361        // MDG does not adopt the configured `indented` style, but closing an
3362        // unclosed fence is a repair rather than a conversion: `check` reports
3363        // it before any style is resolved, so `fix` has to resolve it too.
3364        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3365        let content = "# Feature: Payloads\n\n```json\n{\"ok\": true}\n";
3366
3367        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3368        let warnings = rule.check(&mdg_ctx).unwrap();
3369        assert_eq!(warnings.len(), 1);
3370        assert!(warnings[0].message.contains("never closed"));
3371
3372        let fixed = rule.fix(&mdg_ctx).unwrap();
3373        assert_eq!(fixed, "# Feature: Payloads\n\n```json\n{\"ok\": true}\n```\n");
3374
3375        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3376        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3377
3378        // Standard converts the block instead of preserving the Doc String.
3379        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3380        assert!(rule.fix(&standard_ctx).unwrap().contains("\n    {\"ok\": true}\n"));
3381    }
3382
3383    #[test]
3384    fn test_mdg_tab_indented_table_is_not_code() {
3385        // Gherkin matches table rows on `\s`, so two tabs — or a space and a
3386        // tab — indent a table just as two spaces do, even though both expand
3387        // past the 4-column indented-code threshold.
3388        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
3389        for indent in ["\t\t", " \t"] {
3390            let content = format!(
3391                "# Feature: Eating\n\n#### Examples:\n\n{indent}| start | eat |\n{indent}| ----- | --- |\n\n## Scenario: Other\n\n      code here\n"
3392            );
3393
3394            let mdg_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
3395            let reported: Vec<usize> = rule.check(&mdg_ctx).unwrap().iter().map(|w| w.line).collect();
3396            assert_eq!(reported, vec![10], "tab-indented rows are a table, not code");
3397
3398            let fixed = rule.fix(&mdg_ctx).unwrap();
3399            assert!(
3400                fixed.contains(&format!("{indent}| start | eat |\n{indent}| ----- | --- |")),
3401                "MDG must leave the tab-indented table alone: {fixed:?}"
3402            );
3403
3404            let standard_ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3405            let standard_reported: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
3406            assert_eq!(standard_reported, vec![5, 10]);
3407        }
3408    }
3409
3410    #[test]
3411    fn test_from_config_records_whether_style_was_configured() {
3412        // The MDG override applies either way, but the warning is only for a
3413        // style the user actually asked for, so a configured style has to be
3414        // told apart from a defaulted one.
3415        use crate::config::Config;
3416        use std::collections::BTreeMap;
3417
3418        let mut values = BTreeMap::new();
3419        values.insert("style".to_string(), toml::Value::String("indented".to_string()));
3420        let mut config = Config::default();
3421        config.rules.insert(
3422            "MD046".to_string(),
3423            crate::config::RuleConfig { severity: None, values },
3424        );
3425
3426        let configured = MD046CodeBlockStyle::from_config(&config);
3427        let configured = configured.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
3428        assert_eq!(configured.config.style, CodeBlockStyle::Indented);
3429        assert!(configured.style_explicit);
3430
3431        let defaulted = MD046CodeBlockStyle::from_config(&Config::default());
3432        let defaulted = defaulted.as_any().downcast_ref::<MD046CodeBlockStyle>().unwrap();
3433        assert!(!defaulted.style_explicit);
3434
3435        // The override does not depend on the warning: a defaulted `indented`
3436        // is enforced as fenced just the same.
3437        let indented = MD046CodeBlockStyle::from_config_struct(MD046Config {
3438            style: CodeBlockStyle::Indented,
3439        });
3440        let content = "# Feature: F\n\nText.\n\n      code here\n";
3441        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3442        assert!(indented.fix(&mdg_ctx).unwrap().contains("```\n  code here\n```"));
3443    }
3444
3445    #[test]
3446    fn test_mdg_indented_style_keeps_tables_out_of_code() {
3447        // Enforcing fenced does not widen what MDG counts as code: a
3448        // Data/Examples table is still not an indented code block.
3449        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
3450        let content = "# Feature: Eating\n\n#### Examples:\n\n    | start | eat | left |\n    | ----- | --- | ---- |\n";
3451
3452        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3453        assert!(rule.check(&mdg_ctx).unwrap().is_empty());
3454        assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
3455
3456        // Standard has no Gherkin tables, so the rows are code — and `indented`
3457        // is honoured there, so they are already in the requested form.
3458        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3459        assert!(rule.check(&standard_ctx).unwrap().is_empty());
3460        assert_eq!(rule.fix(&standard_ctx).unwrap(), content);
3461    }
3462}