Skip to main content

rumdl_lib/rules/
md046_code_block_style.rs

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