Skip to main content

rumdl_lib/rules/
md046_code_block_style.rs

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