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