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