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