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