Skip to main content

rumdl_lib/rules/
md046_code_block_style.rs

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