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