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            if self.is_fenced_code_block_start(line) {
753                if in_container {
754                    // Fence marker inside a container — not a real fence,
755                    // don't flip state or count it.
756                    prev_was_indented = false;
757                    continue;
758                }
759                if !in_fenced {
760                    // Opening fence
761                    fenced_count += 1;
762                    in_fenced = true;
763                } else {
764                    // Closing fence
765                    in_fenced = false;
766                }
767                prev_was_indented = false;
768            } else if !in_fenced && self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx) {
769                // Count each continuous indented block once
770                if !prev_was_indented {
771                    indented_count += 1;
772                }
773                prev_was_indented = true;
774            } else {
775                prev_was_indented = false;
776            }
777        }
778
779        if fenced_count == 0 && indented_count == 0 {
780            None
781        } else if fenced_count > 0 && indented_count == 0 {
782            Some(CodeBlockStyle::Fenced)
783        } else if fenced_count == 0 && indented_count > 0 {
784            Some(CodeBlockStyle::Indented)
785        } else if fenced_count >= indented_count {
786            Some(CodeBlockStyle::Fenced)
787        } else {
788            Some(CodeBlockStyle::Indented)
789        }
790    }
791}
792
793impl Rule for MD046CodeBlockStyle {
794    fn name(&self) -> &'static str {
795        "MD046"
796    }
797
798    fn description(&self) -> &'static str {
799        "Code blocks should use a consistent style"
800    }
801
802    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
803        // Early return for empty content
804        if ctx.content.is_empty() {
805            return Ok(Vec::new());
806        }
807
808        // Quick check for code blocks before processing
809        if !ctx.content.contains("```")
810            && !ctx.content.contains("~~~")
811            && !ctx.content.contains("    ")
812            && !ctx.content.contains('\t')
813        {
814            return Ok(Vec::new());
815        }
816
817        // First, always check for unclosed code blocks
818        let unclosed_warnings = self.check_unclosed_code_blocks(ctx);
819
820        // If we found unclosed blocks, return those warnings first
821        if !unclosed_warnings.is_empty() {
822            return Ok(unclosed_warnings);
823        }
824
825        // Check for code block style consistency
826        let lines = ctx.raw_lines();
827        let mut warnings = Vec::new();
828
829        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
830
831        // Determine the target style
832        let target_style = match self.config.style {
833            CodeBlockStyle::Consistent => {
834                let in_list_context = self.precompute_block_continuation_context(lines);
835                let list_item_baseline = self.precompute_list_item_baseline(ctx, lines);
836                let in_comment_or_html = Self::precompute_comment_or_html_context(ctx, lines.len());
837                let in_tab_context = if is_mkdocs {
838                    self.precompute_mkdocs_tab_context(lines)
839                } else {
840                    vec![false; lines.len()]
841                };
842                let in_admonition_context = if is_mkdocs {
843                    self.precompute_mkdocs_admonition_context(lines)
844                } else {
845                    vec![false; lines.len()]
846                };
847                let ictx = IndentContext {
848                    in_list_context: &in_list_context,
849                    in_tab_context: &in_tab_context,
850                    in_admonition_context: &in_admonition_context,
851                    in_comment_or_html: &in_comment_or_html,
852                    list_item_baseline: &list_item_baseline,
853                };
854                self.detect_style(ctx, lines, is_mkdocs, &ictx)
855                    .unwrap_or(CodeBlockStyle::Fenced)
856            }
857            _ => self.config.style,
858        };
859
860        // Iterate code_block_details directly (O(k) where k is number of blocks)
861        let mut reported_indented_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
862
863        for detail in &ctx.code_block_details {
864            if detail.start >= ctx.content.len() || detail.end > ctx.content.len() {
865                continue;
866            }
867
868            let start_line_idx = match ctx.line_offsets.binary_search(&detail.start) {
869                Ok(idx) => idx,
870                Err(idx) => idx.saturating_sub(1),
871            };
872
873            if detail.is_fenced {
874                if target_style == CodeBlockStyle::Indented {
875                    let line = lines.get(start_line_idx).unwrap_or(&"");
876
877                    if ctx
878                        .lines
879                        .get(start_line_idx)
880                        .is_some_and(|info| info.in_html_comment || info.in_mdx_comment || info.in_footnote_definition)
881                    {
882                        continue;
883                    }
884
885                    let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
886                    warnings.push(LintWarning {
887                        rule_name: Some(self.name().to_string()),
888                        line: start_line,
889                        column: start_col,
890                        end_line,
891                        end_column: end_col,
892                        message: "Use indented code blocks".to_string(),
893                        severity: Severity::Warning,
894                        fix: None,
895                    });
896                }
897            } else {
898                // Indented code block
899                if target_style == CodeBlockStyle::Fenced && !reported_indented_lines.contains(&start_line_idx) {
900                    let line = lines.get(start_line_idx).unwrap_or(&"");
901
902                    // Skip blocks in contexts that aren't real indented code blocks
903                    if ctx.lines.get(start_line_idx).is_some_and(|info| {
904                        info.in_html_comment
905                            || info.in_mdx_comment
906                            || info.in_html_block
907                            || info.in_jsx_block
908                            || info.in_mkdocstrings
909                            || info.in_footnote_definition
910                            || info.blockquote.is_some()
911                    }) {
912                        continue;
913                    }
914
915                    // Use pre-computed LineInfo for MkDocs container context
916                    if is_mkdocs
917                        && ctx
918                            .lines
919                            .get(start_line_idx)
920                            .is_some_and(|info| info.in_admonition || info.in_content_tab)
921                    {
922                        continue;
923                    }
924
925                    reported_indented_lines.insert(start_line_idx);
926
927                    let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
928                    warnings.push(LintWarning {
929                        rule_name: Some(self.name().to_string()),
930                        line: start_line,
931                        column: start_col,
932                        end_line,
933                        end_column: end_col,
934                        message: "Use fenced code blocks".to_string(),
935                        severity: Severity::Warning,
936                        fix: None,
937                    });
938                }
939            }
940        }
941
942        // Sort warnings by line number for consistent output
943        warnings.sort_by_key(|w| (w.line, w.column));
944
945        Ok(warnings)
946    }
947
948    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
949        let content = ctx.content;
950        if content.is_empty() {
951            return Ok(String::new());
952        }
953
954        let lines = ctx.raw_lines();
955
956        // Determine target style
957        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
958
959        let in_comment_or_html = Self::precompute_comment_or_html_context(ctx, lines.len());
960
961        // Pre-compute list, tab, and admonition contexts once
962        let in_list_context = self.precompute_block_continuation_context(lines);
963        let list_item_baseline = self.precompute_list_item_baseline(ctx, lines);
964        let in_tab_context = if is_mkdocs {
965            self.precompute_mkdocs_tab_context(lines)
966        } else {
967            vec![false; lines.len()]
968        };
969        let in_admonition_context = if is_mkdocs {
970            self.precompute_mkdocs_admonition_context(lines)
971        } else {
972            vec![false; lines.len()]
973        };
974
975        let ictx = IndentContext {
976            in_list_context: &in_list_context,
977            in_tab_context: &in_tab_context,
978            in_admonition_context: &in_admonition_context,
979            in_comment_or_html: &in_comment_or_html,
980            list_item_baseline: &list_item_baseline,
981        };
982
983        let target_style = match self.config.style {
984            CodeBlockStyle::Consistent => self
985                .detect_style(ctx, lines, is_mkdocs, &ictx)
986                .unwrap_or(CodeBlockStyle::Fenced),
987            _ => self.config.style,
988        };
989
990        // Categorize indented blocks:
991        // - misplaced_fence_lines: complete fenced blocks that were over-indented (safe to dedent)
992        // - unsafe_fence_lines: contain fence markers but aren't complete (skip fixing to avoid broken output)
993        let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(lines, is_mkdocs, &ictx);
994
995        let mut result = String::with_capacity(content.len());
996        let mut in_fenced_block = false;
997        // Tracks the opening fence: (fence_char, opener_length).
998        // Per CommonMark spec, the closing fence must use the same character and have
999        // at least as many characters as the opener, with no info string.
1000        let mut fenced_fence_opener: Option<(char, usize)> = None;
1001        let mut in_indented_block = false;
1002        // Indent string emitted on the opening fence of the current
1003        // indented→fenced conversion (e.g. "  " for an indented block inside
1004        // a `- ` list item, "" at top level). Reused on close so the closing
1005        // fence sits at the same column as the opener.
1006        let mut current_block_fence_indent = String::new();
1007
1008        // Track which code block opening lines are disabled by inline config
1009        let mut current_block_disabled = false;
1010
1011        for (i, line) in lines.iter().enumerate() {
1012            let line_num = i + 1;
1013            let trimmed = line.trim_start();
1014
1015            // Handle fenced code blocks
1016            // Per CommonMark: fence must have 0-3 spaces of indentation
1017            if !in_fenced_block
1018                && Self::has_valid_fence_indent(line)
1019                && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1020            {
1021                // Check if inline config disables this rule for the opening fence
1022                current_block_disabled = ctx.inline_config().is_rule_disabled(self.name(), line_num);
1023                in_fenced_block = true;
1024                let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
1025                let opener_len = trimmed.chars().take_while(|&c| c == fence_char).count();
1026                fenced_fence_opener = Some((fence_char, opener_len));
1027
1028                if current_block_disabled {
1029                    // Inline config disables this rule — preserve original
1030                    result.push_str(line);
1031                    result.push('\n');
1032                } else if target_style == CodeBlockStyle::Indented {
1033                    // Skip the opening fence
1034                    in_indented_block = true;
1035                } else {
1036                    // Keep the fenced block
1037                    result.push_str(line);
1038                    result.push('\n');
1039                }
1040            } else if in_fenced_block && fenced_fence_opener.is_some() {
1041                let (fence_char, opener_len) = fenced_fence_opener.unwrap();
1042                // Per CommonMark: closing fence uses the same character, has at least as
1043                // many characters as the opener, and has no info string (only optional trailing spaces).
1044                let closer_len = trimmed.chars().take_while(|&c| c == fence_char).count();
1045                let after_closer = &trimmed[closer_len..];
1046                let is_closer = closer_len >= opener_len && after_closer.trim().is_empty() && closer_len > 0;
1047                if is_closer {
1048                    in_fenced_block = false;
1049                    fenced_fence_opener = None;
1050                    in_indented_block = false;
1051
1052                    if current_block_disabled {
1053                        result.push_str(line);
1054                        result.push('\n');
1055                    } else if target_style == CodeBlockStyle::Indented {
1056                        // Skip the closing fence
1057                    } else {
1058                        // Keep the fenced block
1059                        result.push_str(line);
1060                        result.push('\n');
1061                    }
1062                    current_block_disabled = false;
1063                } else if current_block_disabled {
1064                    // Inline config disables this rule — preserve original
1065                    result.push_str(line);
1066                    result.push('\n');
1067                } else if target_style == CodeBlockStyle::Indented {
1068                    // Convert content inside fenced block to indented
1069                    // IMPORTANT: Preserve the original line content (including internal indentation)
1070                    // Don't use trimmed, as that would strip internal code indentation
1071                    result.push_str("    ");
1072                    result.push_str(line);
1073                    result.push('\n');
1074                } else {
1075                    // Keep fenced block content as is
1076                    result.push_str(line);
1077                    result.push('\n');
1078                }
1079            } else if self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) {
1080                // This is an indented code block
1081
1082                // Respect inline disable comments
1083                if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
1084                    result.push_str(line);
1085                    result.push('\n');
1086                    continue;
1087                }
1088
1089                // Check if we need to start a new fenced block
1090                let prev_line_is_indented =
1091                    i > 0 && self.is_indented_code_block_with_context(lines, i - 1, is_mkdocs, &ictx);
1092
1093                if target_style == CodeBlockStyle::Fenced {
1094                    // Anchor fences at the list-item content baseline when
1095                    // converting a list-internal indented block (e.g. column
1096                    // 2 for `- `), so the new fenced block stays attached
1097                    // to the bullet. Top-level indented blocks have no
1098                    // baseline → fences sit at column 0.
1099                    let baseline = list_item_baseline.get(i).copied().flatten().unwrap_or(0);
1100                    // Per CommonMark, the indented-code prefix is exactly 4
1101                    // spaces past the surrounding container's content
1102                    // column. Strip those 4 spaces (not all leading
1103                    // whitespace) so any internal indentation past that
1104                    // point is preserved verbatim in the fenced body.
1105                    let body = line.strip_prefix("    ").unwrap_or(line);
1106
1107                    // Check if this line is part of a misplaced fenced block
1108                    // (pre-computed block-level analysis, not per-line)
1109                    if misplaced_fence_lines[i] {
1110                        // Just remove the indentation - this is a complete misplaced fenced block
1111                        result.push_str(line.trim_start());
1112                        result.push('\n');
1113                    } else if unsafe_fence_lines[i] {
1114                        // This block contains fence markers but isn't a complete fenced block
1115                        // Wrapping would create invalid nested fences - keep as-is (don't fix)
1116                        result.push_str(line);
1117                        result.push('\n');
1118                    } else if !prev_line_is_indented && !in_indented_block {
1119                        // Start of a new indented block that should be fenced
1120                        current_block_fence_indent = " ".repeat(baseline);
1121                        result.push_str(&current_block_fence_indent);
1122                        result.push_str("```\n");
1123                        result.push_str(body);
1124                        result.push('\n');
1125                        in_indented_block = true;
1126                    } else {
1127                        // Inside an indented block
1128                        result.push_str(body);
1129                        result.push('\n');
1130                    }
1131
1132                    // Check if this is the end of the indented block
1133                    let next_line_is_indented =
1134                        i < lines.len() - 1 && self.is_indented_code_block_with_context(lines, i + 1, is_mkdocs, &ictx);
1135                    // Don't close if this is an unsafe block (kept as-is)
1136                    if !next_line_is_indented
1137                        && in_indented_block
1138                        && !misplaced_fence_lines[i]
1139                        && !unsafe_fence_lines[i]
1140                    {
1141                        result.push_str(&current_block_fence_indent);
1142                        result.push_str("```\n");
1143                        in_indented_block = false;
1144                        current_block_fence_indent.clear();
1145                    }
1146                } else {
1147                    // Keep indented block as is
1148                    result.push_str(line);
1149                    result.push('\n');
1150                }
1151            } else {
1152                // Regular line
1153                if in_indented_block && target_style == CodeBlockStyle::Fenced {
1154                    result.push_str(&current_block_fence_indent);
1155                    result.push_str("```\n");
1156                    in_indented_block = false;
1157                    current_block_fence_indent.clear();
1158                }
1159
1160                result.push_str(line);
1161                result.push('\n');
1162            }
1163        }
1164
1165        // Close any remaining blocks
1166        if in_indented_block && target_style == CodeBlockStyle::Fenced {
1167            result.push_str(&current_block_fence_indent);
1168            result.push_str("```\n");
1169        }
1170
1171        // Close any unclosed fenced blocks.
1172        // Only close if check() also confirms this block is unclosed. The line-by-line
1173        // fence scanner in fix() can disagree with pulldown-cmark on block boundaries
1174        // (e.g., markdown documentation blocks with nested fence examples), so we use
1175        // check_unclosed_code_blocks() as the authoritative source of truth.
1176        if let Some((fence_char, opener_len)) = fenced_fence_opener
1177            && in_fenced_block
1178        {
1179            let has_unclosed_violation = !self.check_unclosed_code_blocks(ctx).is_empty();
1180            if has_unclosed_violation {
1181                let closer: String = std::iter::repeat_n(fence_char, opener_len).collect();
1182                result.push_str(&closer);
1183                result.push('\n');
1184            }
1185        }
1186
1187        // Remove trailing newline if original didn't have one
1188        if !content.ends_with('\n') && result.ends_with('\n') {
1189            result.pop();
1190        }
1191
1192        Ok(result)
1193    }
1194
1195    /// Get the category of this rule for selective processing
1196    fn category(&self) -> RuleCategory {
1197        RuleCategory::CodeBlock
1198    }
1199
1200    /// Check if this rule should be skipped
1201    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1202        // Skip if content is empty or unlikely to contain code blocks
1203        // Note: indented code blocks use 4 spaces, can't optimize that easily
1204        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains("    "))
1205    }
1206
1207    fn as_any(&self) -> &dyn std::any::Any {
1208        self
1209    }
1210
1211    fn default_config_section(&self) -> Option<(String, toml::Value)> {
1212        let json_value = serde_json::to_value(&self.config).ok()?;
1213        Some((
1214            self.name().to_string(),
1215            crate::rule_config_serde::json_to_toml_value(&json_value)?,
1216        ))
1217    }
1218
1219    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1220    where
1221        Self: Sized,
1222    {
1223        let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1224        Box::new(Self::from_config_struct(rule_config))
1225    }
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230    use super::*;
1231    use crate::lint_context::LintContext;
1232
1233    /// Test helper: detect_style with automatic context computation.
1234    ///
1235    /// The container context (HTML/MDX comments, HTML/JSX blocks,
1236    /// mkdocstrings, footnote definitions, blockquotes) is not populated by
1237    /// this helper — callers that need to exercise those paths should go
1238    /// through the full `rule.check(&ctx)` entry point so the real LineInfo
1239    /// is computed from a `LintContext`.
1240    ///
1241    /// Colon fence exclusion is also not active here: tests that need Azure
1242    /// DevOps colon fence skipping must use the full `check` entry point with
1243    /// an `AzureDevOps` flavor `LintContext`.
1244    fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
1245        let flavor = if is_mkdocs {
1246            crate::config::MarkdownFlavor::MkDocs
1247        } else {
1248            crate::config::MarkdownFlavor::Standard
1249        };
1250        let ctx = LintContext::new(content, flavor, None);
1251        let lines: Vec<&str> = content.lines().collect();
1252        let in_list_context = rule.precompute_block_continuation_context(&lines);
1253        let in_tab_context = if is_mkdocs {
1254            rule.precompute_mkdocs_tab_context(&lines)
1255        } else {
1256            vec![false; lines.len()]
1257        };
1258        let in_admonition_context = if is_mkdocs {
1259            rule.precompute_mkdocs_admonition_context(&lines)
1260        } else {
1261            vec![false; lines.len()]
1262        };
1263        let in_comment_or_html = vec![false; lines.len()];
1264        // List baseline is None for every line: this helper preserves the
1265        // pre-baseline behavior where any list-context line is conservatively
1266        // skipped. Tests that need list-internal indented code blocks
1267        // recognized must drive the rule through `check`/`fix` with a real
1268        // `LintContext`.
1269        let list_item_baseline: Vec<Option<usize>> = vec![None; lines.len()];
1270        let ictx = IndentContext {
1271            in_list_context: &in_list_context,
1272            in_tab_context: &in_tab_context,
1273            in_admonition_context: &in_admonition_context,
1274            in_comment_or_html: &in_comment_or_html,
1275            list_item_baseline: &list_item_baseline,
1276        };
1277        rule.detect_style(&ctx, &lines, is_mkdocs, &ictx)
1278    }
1279
1280    #[test]
1281    fn test_fenced_code_block_detection() {
1282        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1283        assert!(rule.is_fenced_code_block_start("```"));
1284        assert!(rule.is_fenced_code_block_start("```rust"));
1285        assert!(rule.is_fenced_code_block_start("~~~"));
1286        assert!(rule.is_fenced_code_block_start("~~~python"));
1287        assert!(rule.is_fenced_code_block_start("  ```"));
1288        assert!(!rule.is_fenced_code_block_start("``"));
1289        assert!(!rule.is_fenced_code_block_start("~~"));
1290        assert!(!rule.is_fenced_code_block_start("Regular text"));
1291    }
1292
1293    #[test]
1294    fn test_consistent_style_with_fenced_blocks() {
1295        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1296        let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298        let result = rule.check(&ctx).unwrap();
1299
1300        // All blocks are fenced, so consistent style should be OK
1301        assert_eq!(result.len(), 0);
1302    }
1303
1304    #[test]
1305    fn test_consistent_style_with_indented_blocks() {
1306        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1307        let content = "Text\n\n    code\n    more code\n\nMore text\n\n    another block";
1308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309        let result = rule.check(&ctx).unwrap();
1310
1311        // All blocks are indented, so consistent style should be OK
1312        assert_eq!(result.len(), 0);
1313    }
1314
1315    #[test]
1316    fn test_consistent_style_mixed() {
1317        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1318        let content = "```\nfenced code\n```\n\nText\n\n    indented code\n\nMore";
1319        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1320        let result = rule.check(&ctx).unwrap();
1321
1322        // Mixed styles should be flagged
1323        assert!(!result.is_empty());
1324    }
1325
1326    #[test]
1327    fn test_fenced_style_with_indented_blocks() {
1328        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1329        let content = "Text\n\n    indented code\n    more code\n\nMore text";
1330        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1331        let result = rule.check(&ctx).unwrap();
1332
1333        // Indented blocks should be flagged when fenced style is required
1334        assert!(!result.is_empty());
1335        assert!(result[0].message.contains("Use fenced code blocks"));
1336    }
1337
1338    #[test]
1339    fn test_fenced_style_with_tab_indented_blocks() {
1340        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1341        let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1342        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1343        let result = rule.check(&ctx).unwrap();
1344
1345        // Tab-indented blocks should also be flagged when fenced style is required
1346        assert!(!result.is_empty());
1347        assert!(result[0].message.contains("Use fenced code blocks"));
1348    }
1349
1350    #[test]
1351    fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1352        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1353        // 2 spaces + tab = 4 columns due to tab expansion (tab goes to column 4)
1354        let content = "Text\n\n  \tmixed indent code\n  \tmore code\n\nMore text";
1355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356        let result = rule.check(&ctx).unwrap();
1357
1358        // Mixed whitespace indented blocks should also be flagged
1359        assert!(
1360            !result.is_empty(),
1361            "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1362        );
1363        assert!(result[0].message.contains("Use fenced code blocks"));
1364    }
1365
1366    #[test]
1367    fn test_fenced_style_with_one_space_tab_indent() {
1368        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1369        // 1 space + tab = 4 columns (tab expands to next tab stop at column 4)
1370        let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1371        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1372        let result = rule.check(&ctx).unwrap();
1373
1374        assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1375        assert!(result[0].message.contains("Use fenced code blocks"));
1376    }
1377
1378    #[test]
1379    fn test_indented_style_with_fenced_blocks() {
1380        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1381        let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1382        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1383        let result = rule.check(&ctx).unwrap();
1384
1385        // Fenced blocks should be flagged when indented style is required
1386        assert!(!result.is_empty());
1387        assert!(result[0].message.contains("Use indented code blocks"));
1388    }
1389
1390    #[test]
1391    fn test_unclosed_code_block() {
1392        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1393        let content = "```\ncode without closing fence";
1394        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1395        let result = rule.check(&ctx).unwrap();
1396
1397        assert_eq!(result.len(), 1);
1398        assert!(result[0].message.contains("never closed"));
1399    }
1400
1401    #[test]
1402    fn test_nested_code_blocks() {
1403        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1404        let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1405        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406        let result = rule.check(&ctx).unwrap();
1407
1408        // This should parse as two separate code blocks
1409        assert_eq!(result.len(), 0);
1410    }
1411
1412    #[test]
1413    fn test_fix_indented_to_fenced() {
1414        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1415        let content = "Text\n\n    code line 1\n    code line 2\n\nMore text";
1416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417        let fixed = rule.fix(&ctx).unwrap();
1418
1419        assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1420    }
1421
1422    #[test]
1423    fn test_fix_fenced_to_indented() {
1424        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1425        let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1426        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1427        let fixed = rule.fix(&ctx).unwrap();
1428
1429        assert!(fixed.contains("    code line 1\n    code line 2"));
1430        assert!(!fixed.contains("```"));
1431    }
1432
1433    #[test]
1434    fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1435        // Issue #270: When converting fenced code to indented, internal indentation must be preserved
1436        // HTML templates, Python, etc. rely on proper indentation
1437        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1438        let content = r#"# Test
1439
1440```html
1441<!doctype html>
1442<html>
1443  <head>
1444    <title>Test</title>
1445  </head>
1446</html>
1447```
1448"#;
1449        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1450        let fixed = rule.fix(&ctx).unwrap();
1451
1452        // The internal indentation (2 spaces for <head>, 4 for <title>) must be preserved
1453        // Each line gets 4 spaces prepended for the indented code block
1454        assert!(
1455            fixed.contains("      <head>"),
1456            "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1457        );
1458        assert!(
1459            fixed.contains("        <title>"),
1460            "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1461        );
1462        assert!(!fixed.contains("```"), "Fenced markers should be removed");
1463    }
1464
1465    #[test]
1466    fn test_fix_fenced_to_indented_preserves_python_indentation() {
1467        // Issue #270: Python is indentation-sensitive - must preserve internal structure
1468        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1469        let content = r#"# Python Example
1470
1471```python
1472def greet(name):
1473    if name:
1474        print(f"Hello, {name}!")
1475    else:
1476        print("Hello, World!")
1477```
1478"#;
1479        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1480        let fixed = rule.fix(&ctx).unwrap();
1481
1482        // Python indentation must be preserved exactly
1483        assert!(
1484            fixed.contains("    def greet(name):"),
1485            "Function def should have 4 spaces (code block indent)"
1486        );
1487        assert!(
1488            fixed.contains("        if name:"),
1489            "if statement should have 8 spaces (4 code + 4 Python)"
1490        );
1491        assert!(
1492            fixed.contains("            print"),
1493            "print should have 12 spaces (4 code + 8 Python)"
1494        );
1495    }
1496
1497    #[test]
1498    fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1499        // Issue #270: YAML is also indentation-sensitive
1500        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1501        let content = r#"# Config
1502
1503```yaml
1504server:
1505  host: localhost
1506  port: 8080
1507  ssl:
1508    enabled: true
1509    cert: /path/to/cert
1510```
1511"#;
1512        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1513        let fixed = rule.fix(&ctx).unwrap();
1514
1515        assert!(fixed.contains("    server:"), "Root key should have 4 spaces");
1516        assert!(fixed.contains("      host:"), "First level should have 6 spaces");
1517        assert!(fixed.contains("      ssl:"), "ssl key should have 6 spaces");
1518        assert!(fixed.contains("        enabled:"), "Nested ssl should have 8 spaces");
1519    }
1520
1521    #[test]
1522    fn test_fix_fenced_to_indented_preserves_empty_lines() {
1523        // Empty lines within code blocks should also get the 4-space prefix
1524        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1525        let content = "```\nline1\n\nline2\n```\n";
1526        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1527        let fixed = rule.fix(&ctx).unwrap();
1528
1529        // The fixed content should have proper structure
1530        assert!(fixed.contains("    line1"), "line1 should be indented");
1531        assert!(fixed.contains("    line2"), "line2 should be indented");
1532        // Empty line between them is preserved (may or may not have spaces)
1533    }
1534
1535    #[test]
1536    fn test_fix_fenced_to_indented_multiple_blocks() {
1537        // Multiple fenced blocks should all preserve their indentation
1538        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1539        let content = r#"# Doc
1540
1541```python
1542def foo():
1543    pass
1544```
1545
1546Text between.
1547
1548```yaml
1549key:
1550  value: 1
1551```
1552"#;
1553        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1554        let fixed = rule.fix(&ctx).unwrap();
1555
1556        assert!(fixed.contains("    def foo():"), "Python def should be indented");
1557        assert!(fixed.contains("        pass"), "Python body should have 8 spaces");
1558        assert!(fixed.contains("    key:"), "YAML root should have 4 spaces");
1559        assert!(fixed.contains("      value:"), "YAML nested should have 6 spaces");
1560        assert!(!fixed.contains("```"), "No fence markers should remain");
1561    }
1562
1563    #[test]
1564    fn test_fix_unclosed_block() {
1565        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1566        let content = "```\ncode without closing";
1567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568        let fixed = rule.fix(&ctx).unwrap();
1569
1570        // Should add closing fence
1571        assert!(fixed.ends_with("```"));
1572    }
1573
1574    #[test]
1575    fn test_code_block_in_list() {
1576        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1577        let content = "- List item\n    code in list\n    more code\n- Next item";
1578        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1579        let result = rule.check(&ctx).unwrap();
1580
1581        // Code in lists should not be flagged
1582        assert_eq!(result.len(), 0);
1583    }
1584
1585    #[test]
1586    fn test_detect_style_fenced() {
1587        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1588        let content = "```\ncode\n```";
1589        let style = detect_style_from_content(&rule, content, false);
1590
1591        assert_eq!(style, Some(CodeBlockStyle::Fenced));
1592    }
1593
1594    #[test]
1595    fn test_detect_style_indented() {
1596        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1597        let content = "Text\n\n    code\n\nMore";
1598        let style = detect_style_from_content(&rule, content, false);
1599
1600        assert_eq!(style, Some(CodeBlockStyle::Indented));
1601    }
1602
1603    #[test]
1604    fn test_detect_style_none() {
1605        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1606        let content = "No code blocks here";
1607        let style = detect_style_from_content(&rule, content, false);
1608
1609        assert_eq!(style, None);
1610    }
1611
1612    #[test]
1613    fn test_tilde_fence() {
1614        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1615        let content = "~~~\ncode\n~~~";
1616        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1617        let result = rule.check(&ctx).unwrap();
1618
1619        // Tilde fences should be accepted as fenced blocks
1620        assert_eq!(result.len(), 0);
1621    }
1622
1623    #[test]
1624    fn test_language_specification() {
1625        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1626        let content = "```rust\nfn main() {}\n```";
1627        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628        let result = rule.check(&ctx).unwrap();
1629
1630        assert_eq!(result.len(), 0);
1631    }
1632
1633    #[test]
1634    fn test_empty_content() {
1635        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1636        let content = "";
1637        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1638        let result = rule.check(&ctx).unwrap();
1639
1640        assert_eq!(result.len(), 0);
1641    }
1642
1643    #[test]
1644    fn test_default_config() {
1645        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1646        let (name, _config) = rule.default_config_section().unwrap();
1647        assert_eq!(name, "MD046");
1648    }
1649
1650    #[test]
1651    fn test_markdown_documentation_block() {
1652        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1653        let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1654        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655        let result = rule.check(&ctx).unwrap();
1656
1657        // Nested code blocks in markdown documentation should be allowed
1658        assert_eq!(result.len(), 0);
1659    }
1660
1661    #[test]
1662    fn test_preserve_trailing_newline() {
1663        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1664        let content = "```\ncode\n```\n";
1665        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1666        let fixed = rule.fix(&ctx).unwrap();
1667
1668        assert_eq!(fixed, content);
1669    }
1670
1671    #[test]
1672    fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1673        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1674        let content = r#"# Document
1675
1676=== "Python"
1677
1678    This is tab content
1679    Not an indented code block
1680
1681    ```python
1682    def hello():
1683        print("Hello")
1684    ```
1685
1686=== "JavaScript"
1687
1688    More tab content here
1689    Also not an indented code block"#;
1690
1691        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1692        let result = rule.check(&ctx).unwrap();
1693
1694        // Should not flag tab content as indented code blocks
1695        assert_eq!(result.len(), 0);
1696    }
1697
1698    #[test]
1699    fn test_mkdocs_tabs_with_actual_indented_code() {
1700        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1701        let content = r#"# Document
1702
1703=== "Tab 1"
1704
1705    This is tab content
1706
1707Regular text
1708
1709    This is an actual indented code block
1710    Should be flagged"#;
1711
1712        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1713        let result = rule.check(&ctx).unwrap();
1714
1715        // Should flag the actual indented code block but not the tab content
1716        assert_eq!(result.len(), 1);
1717        assert!(result[0].message.contains("Use fenced code blocks"));
1718    }
1719
1720    #[test]
1721    fn test_mkdocs_tabs_detect_style() {
1722        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1723        let content = r#"=== "Tab 1"
1724
1725    Content in tab
1726    More content
1727
1728=== "Tab 2"
1729
1730    Content in second tab"#;
1731
1732        // In MkDocs mode, tab content should not be detected as indented code blocks
1733        let style = detect_style_from_content(&rule, content, true);
1734        assert_eq!(style, None); // No code blocks detected
1735
1736        // In standard mode, it would detect indented code blocks
1737        let style = detect_style_from_content(&rule, content, false);
1738        assert_eq!(style, Some(CodeBlockStyle::Indented));
1739    }
1740
1741    #[test]
1742    fn test_mkdocs_nested_tabs() {
1743        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1744        let content = r#"# Document
1745
1746=== "Outer Tab"
1747
1748    Some content
1749
1750    === "Nested Tab"
1751
1752        Nested tab content
1753        Should not be flagged"#;
1754
1755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1756        let result = rule.check(&ctx).unwrap();
1757
1758        // Nested tabs should not be flagged
1759        assert_eq!(result.len(), 0);
1760    }
1761
1762    #[test]
1763    fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1764        // Issue #269: MkDocs admonitions have indented bodies that should NOT be
1765        // treated as indented code blocks when style = "fenced"
1766        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1767        let content = r#"# Document
1768
1769!!! note
1770    This is normal admonition content, not a code block.
1771    It spans multiple lines.
1772
1773??? warning "Collapsible Warning"
1774    This is also admonition content.
1775
1776???+ tip "Expanded Tip"
1777    And this one too.
1778
1779Regular text outside admonitions."#;
1780
1781        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1782        let result = rule.check(&ctx).unwrap();
1783
1784        // Admonition content should not be flagged
1785        assert_eq!(
1786            result.len(),
1787            0,
1788            "Admonition content in MkDocs mode should not trigger MD046"
1789        );
1790    }
1791
1792    #[test]
1793    fn test_mkdocs_admonition_with_actual_indented_code() {
1794        // After an admonition ends, regular indented code blocks SHOULD be flagged
1795        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1796        let content = r#"# Document
1797
1798!!! note
1799    This is admonition content.
1800
1801Regular text ends the admonition.
1802
1803    This is actual indented code (should be flagged)"#;
1804
1805        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1806        let result = rule.check(&ctx).unwrap();
1807
1808        // Should only flag the actual indented code block
1809        assert_eq!(result.len(), 1);
1810        assert!(result[0].message.contains("Use fenced code blocks"));
1811    }
1812
1813    #[test]
1814    fn test_admonition_in_standard_mode_flagged() {
1815        // In standard Markdown mode, admonitions are not recognized, so the
1816        // indented content should be flagged as indented code
1817        // Note: A blank line is required before indented code blocks per CommonMark
1818        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1819        let content = r#"# Document
1820
1821!!! note
1822
1823    This looks like code in standard mode.
1824
1825Regular text."#;
1826
1827        // In Standard mode, admonitions are not recognized
1828        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1829        let result = rule.check(&ctx).unwrap();
1830
1831        // The indented content should be flagged in standard mode
1832        assert_eq!(
1833            result.len(),
1834            1,
1835            "Admonition content in Standard mode should be flagged as indented code"
1836        );
1837    }
1838
1839    #[test]
1840    fn test_mkdocs_admonition_with_fenced_code_inside() {
1841        // Issue #269: Admonitions can contain fenced code blocks - must handle correctly
1842        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1843        let content = r#"# Document
1844
1845!!! note "Code Example"
1846    Here's some code:
1847
1848    ```python
1849    def hello():
1850        print("world")
1851    ```
1852
1853    More text after code.
1854
1855Regular text."#;
1856
1857        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1858        let result = rule.check(&ctx).unwrap();
1859
1860        // Should not flag anything - the fenced block inside admonition is valid
1861        assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1862    }
1863
1864    #[test]
1865    fn test_mkdocs_nested_admonitions() {
1866        // Nested admonitions are valid MkDocs syntax
1867        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1868        let content = r#"# Document
1869
1870!!! note "Outer"
1871    Outer content.
1872
1873    !!! warning "Inner"
1874        Inner content.
1875        More inner content.
1876
1877    Back to outer.
1878
1879Regular text."#;
1880
1881        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1882        let result = rule.check(&ctx).unwrap();
1883
1884        // Nested admonitions should not trigger MD046
1885        assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1886    }
1887
1888    #[test]
1889    fn test_mkdocs_admonition_fix_does_not_wrap() {
1890        // The fix function should not wrap admonition content in fences
1891        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1892        let content = r#"!!! note
1893    Content that should stay as admonition content.
1894    Not be wrapped in code fences.
1895"#;
1896
1897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1898        let fixed = rule.fix(&ctx).unwrap();
1899
1900        // Fix should not add fence markers to admonition content
1901        assert!(
1902            !fixed.contains("```\n    Content"),
1903            "Admonition content should not be wrapped in fences"
1904        );
1905        assert_eq!(fixed, content, "Content should remain unchanged");
1906    }
1907
1908    #[test]
1909    fn test_mkdocs_empty_admonition() {
1910        // Empty admonitions (marker only) should not cause issues
1911        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1912        let content = r#"!!! note
1913
1914Regular paragraph after empty admonition.
1915
1916    This IS an indented code block (after blank + non-indented line)."#;
1917
1918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1919        let result = rule.check(&ctx).unwrap();
1920
1921        // The indented code block after the paragraph should be flagged
1922        assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1923    }
1924
1925    #[test]
1926    fn test_mkdocs_indented_admonition() {
1927        // Admonitions can themselves be indented (e.g., inside list items)
1928        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1929        let content = r#"- List item
1930
1931    !!! note
1932        Indented admonition content.
1933        More content.
1934
1935- Next item"#;
1936
1937        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1938        let result = rule.check(&ctx).unwrap();
1939
1940        // Admonition inside list should not be flagged
1941        assert_eq!(
1942            result.len(),
1943            0,
1944            "Indented admonitions (e.g., in lists) should not be flagged"
1945        );
1946    }
1947
1948    #[test]
1949    fn test_footnote_indented_paragraphs_not_flagged() {
1950        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1951        let content = r#"# Test Document with Footnotes
1952
1953This is some text with a footnote[^1].
1954
1955Here's some code:
1956
1957```bash
1958echo "fenced code block"
1959```
1960
1961More text with another footnote[^2].
1962
1963[^1]: Really interesting footnote text.
1964
1965    Even more interesting second paragraph.
1966
1967[^2]: Another footnote.
1968
1969    With a second paragraph too.
1970
1971    And even a third paragraph!"#;
1972
1973        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1974        let result = rule.check(&ctx).unwrap();
1975
1976        // Indented paragraphs in footnotes should not be flagged as code blocks
1977        assert_eq!(result.len(), 0);
1978    }
1979
1980    #[test]
1981    fn test_footnote_definition_detection() {
1982        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1983
1984        // Valid footnote definitions (per CommonMark footnote extension spec)
1985        // Reference: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/footnotes.md
1986        assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1987        assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1988        assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1989        assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1990        assert!(rule.is_footnote_definition("    [^1]: Indented footnote"));
1991        assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1992        assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1993        assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1994        assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1995
1996        // Invalid: empty or whitespace-only labels (spec violation)
1997        assert!(!rule.is_footnote_definition("[^]: No label"));
1998        assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1999        assert!(!rule.is_footnote_definition("[^  ]: Multiple spaces"));
2000        assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
2001
2002        // Invalid: malformed syntax
2003        assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
2004        assert!(!rule.is_footnote_definition("Regular text [^1]:"));
2005        assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
2006        assert!(!rule.is_footnote_definition("[^")); // Too short
2007        assert!(!rule.is_footnote_definition("[^1:")); // Missing closing bracket
2008        assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
2009
2010        // Invalid: disallowed characters in label
2011        assert!(!rule.is_footnote_definition("[^test.name]: Period"));
2012        assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
2013        assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
2014        assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
2015        assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
2016
2017        // Edge case: line breaks not allowed in labels
2018        // (This is a string test, actual multiline would need different testing)
2019        assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
2020    }
2021
2022    #[test]
2023    fn test_footnote_with_blank_lines() {
2024        // Spec requirement: blank lines within footnotes don't terminate them
2025        // if next content is indented (matches GitHub's implementation)
2026        // Reference: commonmark-hs footnote extension behavior
2027        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2028        let content = r#"# Document
2029
2030Text with footnote[^1].
2031
2032[^1]: First paragraph.
2033
2034    Second paragraph after blank line.
2035
2036    Third paragraph after another blank line.
2037
2038Regular text at column 0 ends the footnote."#;
2039
2040        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2041        let result = rule.check(&ctx).unwrap();
2042
2043        // The indented paragraphs in the footnote should not be flagged as code blocks
2044        assert_eq!(
2045            result.len(),
2046            0,
2047            "Indented content within footnotes should not trigger MD046"
2048        );
2049    }
2050
2051    #[test]
2052    fn test_footnote_multiple_consecutive_blank_lines() {
2053        // Edge case: multiple consecutive blank lines within a footnote
2054        // Should still work if next content is indented
2055        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2056        let content = r#"Text[^1].
2057
2058[^1]: First paragraph.
2059
2060
2061
2062    Content after three blank lines (still part of footnote).
2063
2064Not indented, so footnote ends here."#;
2065
2066        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2067        let result = rule.check(&ctx).unwrap();
2068
2069        // The indented content should not be flagged
2070        assert_eq!(
2071            result.len(),
2072            0,
2073            "Multiple blank lines shouldn't break footnote continuation"
2074        );
2075    }
2076
2077    #[test]
2078    fn test_footnote_terminated_by_non_indented_content() {
2079        // Spec requirement: non-indented content always terminates the footnote
2080        // Reference: commonmark-hs footnote extension
2081        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2082        let content = r#"[^1]: Footnote content.
2083
2084    More indented content in footnote.
2085
2086This paragraph is not indented, so footnote ends.
2087
2088    This should be flagged as indented code block."#;
2089
2090        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2091        let result = rule.check(&ctx).unwrap();
2092
2093        // The last indented block should be flagged (it's after the footnote ended)
2094        assert_eq!(
2095            result.len(),
2096            1,
2097            "Indented code after footnote termination should be flagged"
2098        );
2099        assert!(
2100            result[0].message.contains("Use fenced code blocks"),
2101            "Expected MD046 warning for indented code block"
2102        );
2103        assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2104    }
2105
2106    #[test]
2107    fn test_footnote_terminated_by_structural_elements() {
2108        // Spec requirement: headings and horizontal rules terminate footnotes
2109        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2110        let content = r#"[^1]: Footnote content.
2111
2112    More content.
2113
2114## Heading terminates footnote
2115
2116    This indented content should be flagged.
2117
2118---
2119
2120    This should also be flagged (after horizontal rule)."#;
2121
2122        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2123        let result = rule.check(&ctx).unwrap();
2124
2125        // Both indented blocks after structural elements should be flagged
2126        assert_eq!(
2127            result.len(),
2128            2,
2129            "Both indented blocks after termination should be flagged"
2130        );
2131    }
2132
2133    #[test]
2134    fn test_footnote_with_code_block_inside() {
2135        // Spec behavior: footnotes can contain fenced code blocks
2136        // The fenced code must be properly indented within the footnote
2137        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2138        let content = r#"Text[^1].
2139
2140[^1]: Footnote with code:
2141
2142    ```python
2143    def hello():
2144        print("world")
2145    ```
2146
2147    More footnote text after code."#;
2148
2149        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2150        let result = rule.check(&ctx).unwrap();
2151
2152        // Should have no warnings - the fenced code block is valid
2153        assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2154    }
2155
2156    #[test]
2157    fn test_footnote_with_8_space_indented_code() {
2158        // Edge case: code blocks within footnotes need 8 spaces (4 for footnote + 4 for code)
2159        // This should NOT be flagged as it's properly nested indented code
2160        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2161        let content = r#"Text[^1].
2162
2163[^1]: Footnote with nested code.
2164
2165        code block
2166        more code"#;
2167
2168        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2169        let result = rule.check(&ctx).unwrap();
2170
2171        // The 8-space indented code is valid within footnote
2172        assert_eq!(
2173            result.len(),
2174            0,
2175            "8-space indented code within footnotes represents nested code blocks"
2176        );
2177    }
2178
2179    #[test]
2180    fn test_multiple_footnotes() {
2181        // Spec behavior: each footnote definition starts a new block context
2182        // Previous footnote ends when new footnote begins
2183        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2184        let content = r#"Text[^1] and more[^2].
2185
2186[^1]: First footnote.
2187
2188    Continuation of first.
2189
2190[^2]: Second footnote starts here, ending the first.
2191
2192    Continuation of second."#;
2193
2194        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2195        let result = rule.check(&ctx).unwrap();
2196
2197        // All indented content is part of footnotes
2198        assert_eq!(
2199            result.len(),
2200            0,
2201            "Multiple footnotes should each maintain their continuation context"
2202        );
2203    }
2204
2205    #[test]
2206    fn test_list_item_ends_footnote_context() {
2207        // Spec behavior: list items and footnotes are mutually exclusive contexts
2208        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2209        let content = r#"[^1]: Footnote.
2210
2211    Content in footnote.
2212
2213- List item starts here (ends footnote context).
2214
2215    This indented content is part of the list, not the footnote."#;
2216
2217        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2218        let result = rule.check(&ctx).unwrap();
2219
2220        // List continuation should not be flagged
2221        assert_eq!(
2222            result.len(),
2223            0,
2224            "List items should end footnote context and start their own"
2225        );
2226    }
2227
2228    #[test]
2229    fn test_footnote_vs_actual_indented_code() {
2230        // Critical test: verify we can still detect actual indented code blocks outside footnotes
2231        // This ensures the fix doesn't cause false negatives
2232        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2233        let content = r#"# Heading
2234
2235Text with footnote[^1].
2236
2237[^1]: Footnote content.
2238
2239    Part of footnote (should not be flagged).
2240
2241Regular paragraph ends footnote context.
2242
2243    This is actual indented code (MUST be flagged)
2244    Should be detected as code block"#;
2245
2246        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2247        let result = rule.check(&ctx).unwrap();
2248
2249        // Should flag the indented code after the regular paragraph
2250        assert_eq!(
2251            result.len(),
2252            1,
2253            "Must still detect indented code blocks outside footnotes"
2254        );
2255        assert!(
2256            result[0].message.contains("Use fenced code blocks"),
2257            "Expected MD046 warning for indented code"
2258        );
2259        assert!(
2260            result[0].line >= 11,
2261            "Warning should be on the actual indented code line"
2262        );
2263    }
2264
2265    #[test]
2266    fn test_spec_compliant_label_characters() {
2267        // Spec requirement: labels must contain only alphanumerics, hyphens, underscores
2268        // Reference: commonmark-hs footnote extension
2269        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2270
2271        // Valid according to spec
2272        assert!(rule.is_footnote_definition("[^test]: text"));
2273        assert!(rule.is_footnote_definition("[^TEST]: text"));
2274        assert!(rule.is_footnote_definition("[^test-name]: text"));
2275        assert!(rule.is_footnote_definition("[^test_name]: text"));
2276        assert!(rule.is_footnote_definition("[^test123]: text"));
2277        assert!(rule.is_footnote_definition("[^123]: text"));
2278        assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2279
2280        // Invalid characters (spec violations)
2281        assert!(!rule.is_footnote_definition("[^test.name]: text")); // Period
2282        assert!(!rule.is_footnote_definition("[^test name]: text")); // Space
2283        assert!(!rule.is_footnote_definition("[^test@name]: text")); // At sign
2284        assert!(!rule.is_footnote_definition("[^test#name]: text")); // Hash
2285        assert!(!rule.is_footnote_definition("[^test$name]: text")); // Dollar
2286        assert!(!rule.is_footnote_definition("[^test%name]: text")); // Percent
2287    }
2288
2289    #[test]
2290    fn test_code_block_inside_html_comment() {
2291        // Regression test: code blocks inside HTML comments should not be flagged
2292        // Found in denoland/deno test fixture during sanity testing
2293        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2294        let content = r#"# Document
2295
2296Some text.
2297
2298<!--
2299Example code block in comment:
2300
2301```typescript
2302console.log("Hello");
2303```
2304
2305More comment text.
2306-->
2307
2308More content."#;
2309
2310        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2311        let result = rule.check(&ctx).unwrap();
2312
2313        assert_eq!(
2314            result.len(),
2315            0,
2316            "Code blocks inside HTML comments should not be flagged as unclosed"
2317        );
2318    }
2319
2320    #[test]
2321    fn test_unclosed_fence_inside_html_comment() {
2322        // Even an unclosed fence inside an HTML comment should be ignored
2323        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2324        let content = r#"# Document
2325
2326<!--
2327Example with intentionally unclosed fence:
2328
2329```
2330code without closing
2331-->
2332
2333More content."#;
2334
2335        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2336        let result = rule.check(&ctx).unwrap();
2337
2338        assert_eq!(
2339            result.len(),
2340            0,
2341            "Unclosed fences inside HTML comments should be ignored"
2342        );
2343    }
2344
2345    #[test]
2346    fn test_multiline_html_comment_with_indented_code() {
2347        // Indented code inside HTML comments should also be ignored
2348        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2349        let content = r#"# Document
2350
2351<!--
2352Example:
2353
2354    indented code
2355    more code
2356
2357End of comment.
2358-->
2359
2360Regular text."#;
2361
2362        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2363        let result = rule.check(&ctx).unwrap();
2364
2365        assert_eq!(
2366            result.len(),
2367            0,
2368            "Indented code inside HTML comments should not be flagged"
2369        );
2370    }
2371
2372    #[test]
2373    fn test_code_block_after_html_comment() {
2374        // Code blocks after HTML comments should still be detected
2375        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2376        let content = r#"# Document
2377
2378<!-- comment -->
2379
2380Text before.
2381
2382    indented code should be flagged
2383
2384More text."#;
2385
2386        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2387        let result = rule.check(&ctx).unwrap();
2388
2389        assert_eq!(
2390            result.len(),
2391            1,
2392            "Code blocks after HTML comments should still be detected"
2393        );
2394        assert!(result[0].message.contains("Use fenced code blocks"));
2395    }
2396
2397    #[test]
2398    fn test_consistent_style_indented_html_comment() {
2399        // Under the default `Consistent` style, indented content inside an
2400        // HTML comment must not contribute to the document's code-block style
2401        // tally. Otherwise a single fenced block alongside an indented HTML
2402        // comment flips the detected style to `Indented`, emitting a spurious
2403        // "Use indented code blocks" warning against the only real code block.
2404        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2405        let content = "# MD046 false-positive reproduction\n\
2406                       \n\
2407                       <!--\n    \
2408                       This is just an indented comment, not a code block.\n\
2409                       \n    \
2410                       A second line is required to trigger the false-positive.\n\
2411                       \n    \
2412                       Actually, three lines are required.\n\
2413                       -->\n\
2414                       \n\
2415                       ```md\n\
2416                       This should be fine, since it's the only code block and therefore consistent.\n\
2417                       ```\n";
2418
2419        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2420        let result = rule.check(&ctx).unwrap();
2421
2422        assert_eq!(
2423            result,
2424            vec![],
2425            "A single fenced block and an indented HTML comment must produce no MD046 warnings",
2426        );
2427    }
2428
2429    #[test]
2430    fn test_consistent_style_indented_html_block() {
2431        // Indented content inside a raw HTML block (e.g. a `<div>` tag pair)
2432        // must not count as an indented code block when `detect_style` picks
2433        // the document's predominant style.
2434        //
2435        // Per CommonMark, a type-6 HTML block is terminated by a blank line,
2436        // so the content here is kept contiguous to remain inside the block.
2437        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2438        let content = "# Heading\n\
2439                       \n\
2440                       <div class=\"note\">\n    \
2441                       line one of indented html content\n    \
2442                       line two of indented html content\n    \
2443                       line three of indented html content\n\
2444                       </div>\n\
2445                       \n\
2446                       ```md\n\
2447                       real fenced block\n\
2448                       ```\n";
2449
2450        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2451        let result = rule.check(&ctx).unwrap();
2452
2453        assert_eq!(
2454            result,
2455            vec![],
2456            "Indented content inside a raw HTML block must not influence MD046 style detection",
2457        );
2458    }
2459
2460    #[test]
2461    fn test_consistent_style_fake_fence_inside_html_comment() {
2462        // Fence markers inside an HTML comment must not contribute to the
2463        // fenced count during style detection. Otherwise a document whose
2464        // only real code block is indented gets flagged "Use fenced code
2465        // blocks" under `Consistent` because the verbatim ``` inside the
2466        // comment ties the count.
2467        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2468        let content = "# Title\n\
2469                       \n\
2470                       <!--\n\
2471                       ```\n\
2472                       fake fence inside comment\n\
2473                       ```\n\
2474                       -->\n\
2475                       \n    \
2476                       real indented code block line 1\n    \
2477                       real indented code block line 2\n";
2478
2479        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2480        let result = rule.check(&ctx).unwrap();
2481
2482        assert_eq!(
2483            result,
2484            vec![],
2485            "Fence markers inside an HTML comment must not influence MD046 style detection",
2486        );
2487    }
2488
2489    #[test]
2490    fn test_consistent_style_indented_footnote_definition() {
2491        // Footnote-definition continuation lines are commonly indented by 4+
2492        // spaces. They must not be counted as indented code blocks during
2493        // style detection under `Consistent`.
2494        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2495        let content = "# Heading\n\
2496                       \n\
2497                       Reference to a footnote[^note].\n\
2498                       \n\
2499                       [^note]: First line of the footnote.\n    \
2500                       Second indented continuation line.\n    \
2501                       Third indented continuation line.\n    \
2502                       Fourth indented continuation line.\n\
2503                       \n\
2504                       ```md\n\
2505                       real fenced block\n\
2506                       ```\n";
2507
2508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2509        let result = rule.check(&ctx).unwrap();
2510
2511        assert_eq!(
2512            result,
2513            vec![],
2514            "Footnote-definition continuation content must not influence MD046 style detection",
2515        );
2516    }
2517
2518    #[test]
2519    fn test_consistent_style_indented_blockquote() {
2520        // Indented content inside a blockquote (`>     foo`) must not be
2521        // counted as an indented code block by `detect_style`. The check-side
2522        // skip list already excludes `blockquote.is_some()` for indented
2523        // warnings, so detection must match to keep `Consistent` stable.
2524        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2525        let content = "# Heading\n\
2526                       \n\
2527                       >     line one of quoted indented content\n\
2528                       >\n\
2529                       >     line two of quoted indented content\n\
2530                       >\n\
2531                       >     line three of quoted indented content\n\
2532                       \n\
2533                       ```md\n\
2534                       real fenced block\n\
2535                       ```\n";
2536
2537        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2538        let result = rule.check(&ctx).unwrap();
2539
2540        assert_eq!(
2541            result,
2542            vec![],
2543            "Indented content inside a blockquote must not influence MD046 style detection",
2544        );
2545    }
2546
2547    #[test]
2548    fn test_consistent_style_genuine_indented_block_detected_as_indented() {
2549        // A top-level indented code block that is not inside any container
2550        // must still count toward the Indented tally under `Consistent` style.
2551        // This guards against over-filtering: the `in_comment_or_html` skip
2552        // must not suppress real indented code blocks.
2553        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2554        let content = "# Heading\n\
2555                       \n\
2556                       Some prose.\n\
2557                       \n    \
2558                       real indented code line 1\n    \
2559                       real indented code line 2\n";
2560
2561        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2562        let result = rule.check(&ctx).unwrap();
2563
2564        // Only one indented block exists; Consistent must detect it as Indented and
2565        // produce no warnings (the detected style matches the only real block).
2566        assert_eq!(
2567            result,
2568            vec![],
2569            "A genuine top-level indented block must be detected as Indented style under Consistent",
2570        );
2571    }
2572
2573    #[test]
2574    fn test_consistent_style_skipped_lines_dont_override_real_block() {
2575        // Two indented-but-skipped regions (inside HTML comments) plus one
2576        // genuine indented code block and no fenced blocks: the skipped lines
2577        // must be excluded from the tally, leaving indented_count=1, fenced_count=0,
2578        // so Consistent still selects Indented and emits no warnings.
2579        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2580        let content = "# Heading\n\
2581                       \n\
2582                       <!--\n    \
2583                       skipped indented comment line 1\n    \
2584                       skipped indented comment line 2\n\
2585                       -->\n\
2586                       \n\
2587                       <!--\n    \
2588                       second skipped region\n    \
2589                       also skipped\n\
2590                       -->\n\
2591                       \n    \
2592                       real indented code line\n";
2593
2594        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2595        let result = rule.check(&ctx).unwrap();
2596
2597        assert_eq!(
2598            result,
2599            vec![],
2600            "Skipped container lines must not outweigh the single real indented block",
2601        );
2602    }
2603
2604    #[test]
2605    fn test_consistent_style_fenced_wins_over_skipped_indented() {
2606        // One real fenced block plus two indented-but-skipped regions: after
2607        // filtering the skipped lines the tally is fenced=1, indented=0, so
2608        // Consistent selects Fenced and emits no warnings.
2609        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
2610        let content = "# Heading\n\
2611                       \n\
2612                       <!--\n    \
2613                       skipped indented region one\n    \
2614                       more of region one\n\
2615                       -->\n\
2616                       \n\
2617                       <!--\n    \
2618                       skipped indented region two\n    \
2619                       more of region two\n\
2620                       -->\n\
2621                       \n\
2622                       ```md\n\
2623                       real fenced block\n\
2624                       ```\n";
2625
2626        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2627        let result = rule.check(&ctx).unwrap();
2628
2629        assert_eq!(
2630            result,
2631            vec![],
2632            "Fenced block must win when all indented lines are inside skipped containers",
2633        );
2634    }
2635
2636    #[test]
2637    fn test_four_space_indented_fence_is_not_valid_fence() {
2638        // Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces."
2639        // 4+ spaces means it's NOT a valid fence opener - it becomes an indented code block
2640        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2641
2642        // Valid fences (0-3 spaces)
2643        assert!(rule.is_fenced_code_block_start("```"));
2644        assert!(rule.is_fenced_code_block_start(" ```"));
2645        assert!(rule.is_fenced_code_block_start("  ```"));
2646        assert!(rule.is_fenced_code_block_start("   ```"));
2647
2648        // Invalid fences (4+ spaces) - these are indented code blocks instead
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
2653        // Tab counts as 4 spaces per CommonMark
2654        assert!(!rule.is_fenced_code_block_start("\t```"));
2655    }
2656
2657    #[test]
2658    fn test_issue_237_indented_fenced_block_detected_as_indented() {
2659        // Issue #237: User has fenced code block indented by 4 spaces
2660        // Per CommonMark, this should be detected as an INDENTED code block
2661        // because 4+ spaces of indentation makes the fence invalid
2662        //
2663        // Reference: https://github.com/rvben/rumdl/issues/237
2664        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2665
2666        // This is the exact test case from issue #237
2667        let content = r#"## Test
2668
2669    ```js
2670    var foo = "hello";
2671    ```
2672"#;
2673
2674        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2675        let result = rule.check(&ctx).unwrap();
2676
2677        // Should flag this as an indented code block that should use fenced style
2678        assert_eq!(
2679            result.len(),
2680            1,
2681            "4-space indented fence should be detected as indented code block"
2682        );
2683        assert!(
2684            result[0].message.contains("Use fenced code blocks"),
2685            "Expected 'Use fenced code blocks' message"
2686        );
2687    }
2688
2689    #[test]
2690    fn test_issue_276_indented_code_in_list() {
2691        // Issue #276: Indented code blocks inside lists should be detected
2692        // Reference: https://github.com/rvben/rumdl/issues/276
2693        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2694
2695        let content = r#"1. First item
26962. Second item with code:
2697
2698        # This is a code block in a list
2699        print("Hello, world!")
2700
27014. Third item"#;
2702
2703        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2704        let result = rule.check(&ctx).unwrap();
2705
2706        // Should flag the indented code block inside the list
2707        assert!(
2708            !result.is_empty(),
2709            "Indented code block inside list should be flagged when style=fenced"
2710        );
2711        assert!(
2712            result[0].message.contains("Use fenced code blocks"),
2713            "Expected 'Use fenced code blocks' message"
2714        );
2715    }
2716
2717    #[test]
2718    fn test_three_space_indented_fence_is_valid() {
2719        // 3 spaces is the maximum allowed per CommonMark - should be recognized as fenced
2720        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2721
2722        let content = r#"## Test
2723
2724   ```js
2725   var foo = "hello";
2726   ```
2727"#;
2728
2729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2730        let result = rule.check(&ctx).unwrap();
2731
2732        // 3-space indent is valid for fenced blocks - should pass
2733        assert_eq!(
2734            result.len(),
2735            0,
2736            "3-space indented fence should be recognized as valid fenced code block"
2737        );
2738    }
2739
2740    #[test]
2741    fn test_indented_style_with_deeply_indented_fenced() {
2742        // When style=indented, a 4-space indented "fenced" block should still be detected
2743        // as an indented code block (which is what we want!)
2744        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2745
2746        let content = r#"Text
2747
2748    ```js
2749    var foo = "hello";
2750    ```
2751
2752More text
2753"#;
2754
2755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2756        let result = rule.check(&ctx).unwrap();
2757
2758        // When target style is "indented", 4-space indented content is correct
2759        // The fence markers become literal content in the indented code block
2760        assert_eq!(
2761            result.len(),
2762            0,
2763            "4-space indented content should be valid when style=indented"
2764        );
2765    }
2766
2767    #[test]
2768    fn test_fix_misplaced_fenced_block() {
2769        // Issue #237: When a fenced code block is accidentally indented 4+ spaces,
2770        // the fix should just remove the indentation, not wrap in more fences
2771        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2772
2773        let content = r#"## Test
2774
2775    ```js
2776    var foo = "hello";
2777    ```
2778"#;
2779
2780        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2781        let fixed = rule.fix(&ctx).unwrap();
2782
2783        // The fix should just remove the 4-space indentation
2784        let expected = r#"## Test
2785
2786```js
2787var foo = "hello";
2788```
2789"#;
2790
2791        assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2792    }
2793
2794    #[test]
2795    fn test_fix_regular_indented_block() {
2796        // Regular indented code blocks (without fence markers) should still be
2797        // wrapped in fences when converted
2798        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2799
2800        let content = r#"Text
2801
2802    var foo = "hello";
2803    console.log(foo);
2804
2805More text
2806"#;
2807
2808        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2809        let fixed = rule.fix(&ctx).unwrap();
2810
2811        // Should wrap in fences
2812        assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2813        assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2814    }
2815
2816    #[test]
2817    fn test_fix_indented_block_with_fence_like_content() {
2818        // If an indented block contains fence-like content but doesn't form a
2819        // complete fenced block, we should NOT autofix it because wrapping would
2820        // create invalid nested fences. The block is left unchanged.
2821        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2822
2823        let content = r#"Text
2824
2825    some code
2826    ```not a fence opener
2827    more code
2828"#;
2829
2830        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2831        let fixed = rule.fix(&ctx).unwrap();
2832
2833        // Block should be left unchanged to avoid creating invalid nested fences
2834        assert!(fixed.contains("    some code"), "Unsafe block should be left unchanged");
2835        assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2836    }
2837
2838    #[test]
2839    fn test_fix_mixed_indented_and_misplaced_blocks() {
2840        // Mixed blocks: regular indented code followed by misplaced fenced block
2841        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2842
2843        let content = r#"Text
2844
2845    regular indented code
2846
2847More text
2848
2849    ```python
2850    print("hello")
2851    ```
2852"#;
2853
2854        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2855        let fixed = rule.fix(&ctx).unwrap();
2856
2857        // First block should be wrapped
2858        assert!(
2859            fixed.contains("```\nregular indented code\n```"),
2860            "First block should be wrapped in fences"
2861        );
2862
2863        // Second block should be dedented (not wrapped)
2864        assert!(
2865            fixed.contains("\n```python\nprint(\"hello\")\n```"),
2866            "Second block should be dedented, not double-wrapped"
2867        );
2868        // Should NOT have nested fences
2869        assert!(
2870            !fixed.contains("```\n```python"),
2871            "Should not have nested fence openers"
2872        );
2873    }
2874}