Skip to main content

rumdl_lib/rules/
md046_code_block_style.rs

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