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: Vec<&str> = ctx.content.lines().collect();
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: Vec<&str> = ctx.content.lines().collect();
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: Some(Fix {
924                                range: ctx.line_index.line_col_to_byte_range(start_line_idx + 1, 1),
925                                replacement: String::new(),
926                            }),
927                        });
928                    }
929                }
930                Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
931                    // This is an indented code block (per pulldown-cmark's CommonMark parsing)
932                    // This includes 4-space indented fences which are invalid per CommonMark
933                    // Flag when we want fenced style
934                    if target_style == CodeBlockStyle::Fenced && !reported_indented_lines.contains(&start_line_idx) {
935                        let line = lines.get(start_line_idx).unwrap_or(&"");
936
937                        // Skip if inside HTML comment, mkdocstrings, or blockquote
938                        // Indented content inside blockquotes is NOT an indented code block
939                        if ctx.lines.get(start_line_idx).is_some_and(|info| {
940                            info.in_html_comment || info.in_mkdocstrings || info.blockquote.is_some()
941                        }) {
942                            continue;
943                        }
944
945                        // Skip if inside a footnote definition
946                        if mkdocs_footnotes::is_within_footnote_definition(ctx.content, start) {
947                            continue;
948                        }
949
950                        // Skip if inside MkDocs tab content
951                        if is_mkdocs && in_tab_context.get(start_line_idx).copied().unwrap_or(false) {
952                            continue;
953                        }
954
955                        // Skip if inside MkDocs admonition content
956                        if is_mkdocs && in_admonition_context.get(start_line_idx).copied().unwrap_or(false) {
957                            continue;
958                        }
959
960                        reported_indented_lines.insert(start_line_idx);
961
962                        let (start_line, start_col, end_line, end_col) = calculate_line_range(start_line_idx + 1, line);
963                        warnings.push(LintWarning {
964                            rule_name: Some(self.name().to_string()),
965                            line: start_line,
966                            column: start_col,
967                            end_line,
968                            end_column: end_col,
969                            message: "Use fenced code blocks".to_string(),
970                            severity: Severity::Warning,
971                            fix: Some(Fix {
972                                range: ctx.line_index.line_col_to_byte_range(start_line_idx + 1, 1),
973                                replacement: format!("```\n{}", line.trim_start()),
974                            }),
975                        });
976                    }
977                }
978                _ => {}
979            }
980        }
981
982        // Sort warnings by line number for consistent output
983        warnings.sort_by_key(|w| (w.line, w.column));
984
985        Ok(warnings)
986    }
987
988    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
989        let content = ctx.content;
990        if content.is_empty() {
991            return Ok(String::new());
992        }
993
994        // First check if we have nested fence issues that need special handling
995        let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
996
997        // If we have nested fence warnings, apply those fixes first
998        if !unclosed_warnings.is_empty() {
999            // Check if any warnings are about nested fences (not just unclosed blocks)
1000            for warning in &unclosed_warnings {
1001                if warning
1002                    .message
1003                    .contains("should be closed before starting new one at line")
1004                {
1005                    // Apply the nested fence fix
1006                    if let Some(fix) = &warning.fix {
1007                        let mut result = String::new();
1008                        result.push_str(&content[..fix.range.start]);
1009                        result.push_str(&fix.replacement);
1010                        result.push_str(&content[fix.range.start..]);
1011                        return Ok(result);
1012                    }
1013                }
1014            }
1015        }
1016
1017        let lines: Vec<&str> = content.lines().collect();
1018
1019        // Determine target style
1020        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
1021        let target_style = match self.config.style {
1022            CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
1023            _ => self.config.style,
1024        };
1025
1026        // Pre-compute list, tab, and admonition contexts for efficiency
1027        let in_list_context = self.precompute_block_continuation_context(&lines);
1028        let in_tab_context = if is_mkdocs {
1029            self.precompute_mkdocs_tab_context(&lines)
1030        } else {
1031            vec![false; lines.len()]
1032        };
1033        let in_admonition_context = if is_mkdocs {
1034            self.precompute_mkdocs_admonition_context(&lines)
1035        } else {
1036            vec![false; lines.len()]
1037        };
1038
1039        // Categorize indented blocks:
1040        // - misplaced_fence_lines: complete fenced blocks that were over-indented (safe to dedent)
1041        // - unsafe_fence_lines: contain fence markers but aren't complete (skip fixing to avoid broken output)
1042        let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(
1043            &lines,
1044            is_mkdocs,
1045            &in_list_context,
1046            &in_tab_context,
1047            &in_admonition_context,
1048        );
1049
1050        let mut result = String::with_capacity(content.len());
1051        let mut in_fenced_block = false;
1052        let mut fenced_fence_type = None;
1053        let mut in_indented_block = false;
1054
1055        for (i, line) in lines.iter().enumerate() {
1056            let trimmed = line.trim_start();
1057
1058            // Handle fenced code blocks
1059            // Per CommonMark: fence must have 0-3 spaces of indentation
1060            if !in_fenced_block
1061                && Self::has_valid_fence_indent(line)
1062                && (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
1063            {
1064                in_fenced_block = true;
1065                fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
1066
1067                if target_style == CodeBlockStyle::Indented {
1068                    // Skip the opening fence
1069                    in_indented_block = true;
1070                } else {
1071                    // Keep the fenced block
1072                    result.push_str(line);
1073                    result.push('\n');
1074                }
1075            } else if in_fenced_block && fenced_fence_type.is_some() {
1076                let fence = fenced_fence_type.unwrap();
1077                if trimmed.starts_with(fence) {
1078                    in_fenced_block = false;
1079                    fenced_fence_type = None;
1080                    in_indented_block = false;
1081
1082                    if target_style == CodeBlockStyle::Indented {
1083                        // Skip the closing fence
1084                    } else {
1085                        // Keep the fenced block
1086                        result.push_str(line);
1087                        result.push('\n');
1088                    }
1089                } else if target_style == CodeBlockStyle::Indented {
1090                    // Convert content inside fenced block to indented
1091                    // IMPORTANT: Preserve the original line content (including internal indentation)
1092                    // Don't use trimmed, as that would strip internal code indentation
1093                    result.push_str("    ");
1094                    result.push_str(line);
1095                    result.push('\n');
1096                } else {
1097                    // Keep fenced block content as is
1098                    result.push_str(line);
1099                    result.push('\n');
1100                }
1101            } else if self.is_indented_code_block_with_context(
1102                &lines,
1103                i,
1104                is_mkdocs,
1105                &in_list_context,
1106                &in_tab_context,
1107                &in_admonition_context,
1108            ) {
1109                // This is an indented code block
1110
1111                // Check if we need to start a new fenced block
1112                let prev_line_is_indented = i > 0
1113                    && self.is_indented_code_block_with_context(
1114                        &lines,
1115                        i - 1,
1116                        is_mkdocs,
1117                        &in_list_context,
1118                        &in_tab_context,
1119                        &in_admonition_context,
1120                    );
1121
1122                if target_style == CodeBlockStyle::Fenced {
1123                    let trimmed_content = line.trim_start();
1124
1125                    // Check if this line is part of a misplaced fenced block
1126                    // (pre-computed block-level analysis, not per-line)
1127                    if misplaced_fence_lines[i] {
1128                        // Just remove the indentation - this is a complete misplaced fenced block
1129                        result.push_str(trimmed_content);
1130                        result.push('\n');
1131                    } else if unsafe_fence_lines[i] {
1132                        // This block contains fence markers but isn't a complete fenced block
1133                        // Wrapping would create invalid nested fences - keep as-is (don't fix)
1134                        result.push_str(line);
1135                        result.push('\n');
1136                    } else if !prev_line_is_indented && !in_indented_block {
1137                        // Start of a new indented block that should be fenced
1138                        result.push_str("```\n");
1139                        result.push_str(trimmed_content);
1140                        result.push('\n');
1141                        in_indented_block = true;
1142                    } else {
1143                        // Inside an indented block
1144                        result.push_str(trimmed_content);
1145                        result.push('\n');
1146                    }
1147
1148                    // Check if this is the end of the indented block
1149                    let next_line_is_indented = i < lines.len() - 1
1150                        && self.is_indented_code_block_with_context(
1151                            &lines,
1152                            i + 1,
1153                            is_mkdocs,
1154                            &in_list_context,
1155                            &in_tab_context,
1156                            &in_admonition_context,
1157                        );
1158                    // Don't close if this is an unsafe block (kept as-is)
1159                    if !next_line_is_indented
1160                        && in_indented_block
1161                        && !misplaced_fence_lines[i]
1162                        && !unsafe_fence_lines[i]
1163                    {
1164                        result.push_str("```\n");
1165                        in_indented_block = false;
1166                    }
1167                } else {
1168                    // Keep indented block as is
1169                    result.push_str(line);
1170                    result.push('\n');
1171                }
1172            } else {
1173                // Regular line
1174                if in_indented_block && target_style == CodeBlockStyle::Fenced {
1175                    result.push_str("```\n");
1176                    in_indented_block = false;
1177                }
1178
1179                result.push_str(line);
1180                result.push('\n');
1181            }
1182        }
1183
1184        // Close any remaining blocks
1185        if in_indented_block && target_style == CodeBlockStyle::Fenced {
1186            result.push_str("```\n");
1187        }
1188
1189        // Close any unclosed fenced blocks
1190        if let Some(fence_type) = fenced_fence_type
1191            && in_fenced_block
1192        {
1193            result.push_str(fence_type);
1194            result.push('\n');
1195        }
1196
1197        // Remove trailing newline if original didn't have one
1198        if !content.ends_with('\n') && result.ends_with('\n') {
1199            result.pop();
1200        }
1201
1202        Ok(result)
1203    }
1204
1205    /// Get the category of this rule for selective processing
1206    fn category(&self) -> RuleCategory {
1207        RuleCategory::CodeBlock
1208    }
1209
1210    /// Check if this rule should be skipped
1211    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1212        // Skip if content is empty or unlikely to contain code blocks
1213        // Note: indented code blocks use 4 spaces, can't optimize that easily
1214        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains("    "))
1215    }
1216
1217    fn as_any(&self) -> &dyn std::any::Any {
1218        self
1219    }
1220
1221    fn default_config_section(&self) -> Option<(String, toml::Value)> {
1222        let json_value = serde_json::to_value(&self.config).ok()?;
1223        Some((
1224            self.name().to_string(),
1225            crate::rule_config_serde::json_to_toml_value(&json_value)?,
1226        ))
1227    }
1228
1229    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1230    where
1231        Self: Sized,
1232    {
1233        let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
1234        Box::new(Self::from_config_struct(rule_config))
1235    }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240    use super::*;
1241    use crate::lint_context::LintContext;
1242
1243    #[test]
1244    fn test_fenced_code_block_detection() {
1245        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1246        assert!(rule.is_fenced_code_block_start("```"));
1247        assert!(rule.is_fenced_code_block_start("```rust"));
1248        assert!(rule.is_fenced_code_block_start("~~~"));
1249        assert!(rule.is_fenced_code_block_start("~~~python"));
1250        assert!(rule.is_fenced_code_block_start("  ```"));
1251        assert!(!rule.is_fenced_code_block_start("``"));
1252        assert!(!rule.is_fenced_code_block_start("~~"));
1253        assert!(!rule.is_fenced_code_block_start("Regular text"));
1254    }
1255
1256    #[test]
1257    fn test_consistent_style_with_fenced_blocks() {
1258        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1259        let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1260        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1261        let result = rule.check(&ctx).unwrap();
1262
1263        // All blocks are fenced, so consistent style should be OK
1264        assert_eq!(result.len(), 0);
1265    }
1266
1267    #[test]
1268    fn test_consistent_style_with_indented_blocks() {
1269        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1270        let content = "Text\n\n    code\n    more code\n\nMore text\n\n    another block";
1271        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272        let result = rule.check(&ctx).unwrap();
1273
1274        // All blocks are indented, so consistent style should be OK
1275        assert_eq!(result.len(), 0);
1276    }
1277
1278    #[test]
1279    fn test_consistent_style_mixed() {
1280        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1281        let content = "```\nfenced code\n```\n\nText\n\n    indented code\n\nMore";
1282        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1283        let result = rule.check(&ctx).unwrap();
1284
1285        // Mixed styles should be flagged
1286        assert!(!result.is_empty());
1287    }
1288
1289    #[test]
1290    fn test_fenced_style_with_indented_blocks() {
1291        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1292        let content = "Text\n\n    indented code\n    more code\n\nMore text";
1293        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294        let result = rule.check(&ctx).unwrap();
1295
1296        // Indented blocks should be flagged when fenced style is required
1297        assert!(!result.is_empty());
1298        assert!(result[0].message.contains("Use fenced code blocks"));
1299    }
1300
1301    #[test]
1302    fn test_fenced_style_with_tab_indented_blocks() {
1303        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1304        let content = "Text\n\n\ttab indented code\n\tmore code\n\nMore text";
1305        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306        let result = rule.check(&ctx).unwrap();
1307
1308        // Tab-indented blocks should also be flagged when fenced style is required
1309        assert!(!result.is_empty());
1310        assert!(result[0].message.contains("Use fenced code blocks"));
1311    }
1312
1313    #[test]
1314    fn test_fenced_style_with_mixed_whitespace_indented_blocks() {
1315        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1316        // 2 spaces + tab = 4 columns due to tab expansion (tab goes to column 4)
1317        let content = "Text\n\n  \tmixed indent code\n  \tmore code\n\nMore text";
1318        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319        let result = rule.check(&ctx).unwrap();
1320
1321        // Mixed whitespace indented blocks should also be flagged
1322        assert!(
1323            !result.is_empty(),
1324            "Mixed whitespace (2 spaces + tab) should be detected as indented code"
1325        );
1326        assert!(result[0].message.contains("Use fenced code blocks"));
1327    }
1328
1329    #[test]
1330    fn test_fenced_style_with_one_space_tab_indent() {
1331        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1332        // 1 space + tab = 4 columns (tab expands to next tab stop at column 4)
1333        let content = "Text\n\n \ttab after one space\n \tmore code\n\nMore text";
1334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335        let result = rule.check(&ctx).unwrap();
1336
1337        assert!(!result.is_empty(), "1 space + tab should be detected as indented code");
1338        assert!(result[0].message.contains("Use fenced code blocks"));
1339    }
1340
1341    #[test]
1342    fn test_indented_style_with_fenced_blocks() {
1343        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1344        let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1345        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1346        let result = rule.check(&ctx).unwrap();
1347
1348        // Fenced blocks should be flagged when indented style is required
1349        assert!(!result.is_empty());
1350        assert!(result[0].message.contains("Use indented code blocks"));
1351    }
1352
1353    #[test]
1354    fn test_unclosed_code_block() {
1355        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1356        let content = "```\ncode without closing fence";
1357        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358        let result = rule.check(&ctx).unwrap();
1359
1360        assert_eq!(result.len(), 1);
1361        assert!(result[0].message.contains("never closed"));
1362    }
1363
1364    #[test]
1365    fn test_nested_code_blocks() {
1366        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1367        let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1368        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369        let result = rule.check(&ctx).unwrap();
1370
1371        // This should parse as two separate code blocks
1372        assert_eq!(result.len(), 0);
1373    }
1374
1375    #[test]
1376    fn test_fix_indented_to_fenced() {
1377        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1378        let content = "Text\n\n    code line 1\n    code line 2\n\nMore text";
1379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380        let fixed = rule.fix(&ctx).unwrap();
1381
1382        assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1383    }
1384
1385    #[test]
1386    fn test_fix_fenced_to_indented() {
1387        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1388        let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1389        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1390        let fixed = rule.fix(&ctx).unwrap();
1391
1392        assert!(fixed.contains("    code line 1\n    code line 2"));
1393        assert!(!fixed.contains("```"));
1394    }
1395
1396    #[test]
1397    fn test_fix_fenced_to_indented_preserves_internal_indentation() {
1398        // Issue #270: When converting fenced code to indented, internal indentation must be preserved
1399        // HTML templates, Python, etc. rely on proper indentation
1400        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1401        let content = r#"# Test
1402
1403```html
1404<!doctype html>
1405<html>
1406  <head>
1407    <title>Test</title>
1408  </head>
1409</html>
1410```
1411"#;
1412        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1413        let fixed = rule.fix(&ctx).unwrap();
1414
1415        // The internal indentation (2 spaces for <head>, 4 for <title>) must be preserved
1416        // Each line gets 4 spaces prepended for the indented code block
1417        assert!(
1418            fixed.contains("      <head>"),
1419            "Expected 6 spaces before <head> (4 for code block + 2 original), got:\n{fixed}"
1420        );
1421        assert!(
1422            fixed.contains("        <title>"),
1423            "Expected 8 spaces before <title> (4 for code block + 4 original), got:\n{fixed}"
1424        );
1425        assert!(!fixed.contains("```"), "Fenced markers should be removed");
1426    }
1427
1428    #[test]
1429    fn test_fix_fenced_to_indented_preserves_python_indentation() {
1430        // Issue #270: Python is indentation-sensitive - must preserve internal structure
1431        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1432        let content = r#"# Python Example
1433
1434```python
1435def greet(name):
1436    if name:
1437        print(f"Hello, {name}!")
1438    else:
1439        print("Hello, World!")
1440```
1441"#;
1442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1443        let fixed = rule.fix(&ctx).unwrap();
1444
1445        // Python indentation must be preserved exactly
1446        assert!(
1447            fixed.contains("    def greet(name):"),
1448            "Function def should have 4 spaces (code block indent)"
1449        );
1450        assert!(
1451            fixed.contains("        if name:"),
1452            "if statement should have 8 spaces (4 code + 4 Python)"
1453        );
1454        assert!(
1455            fixed.contains("            print"),
1456            "print should have 12 spaces (4 code + 8 Python)"
1457        );
1458    }
1459
1460    #[test]
1461    fn test_fix_fenced_to_indented_preserves_yaml_indentation() {
1462        // Issue #270: YAML is also indentation-sensitive
1463        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1464        let content = r#"# Config
1465
1466```yaml
1467server:
1468  host: localhost
1469  port: 8080
1470  ssl:
1471    enabled: true
1472    cert: /path/to/cert
1473```
1474"#;
1475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476        let fixed = rule.fix(&ctx).unwrap();
1477
1478        assert!(fixed.contains("    server:"), "Root key should have 4 spaces");
1479        assert!(fixed.contains("      host:"), "First level should have 6 spaces");
1480        assert!(fixed.contains("      ssl:"), "ssl key should have 6 spaces");
1481        assert!(fixed.contains("        enabled:"), "Nested ssl should have 8 spaces");
1482    }
1483
1484    #[test]
1485    fn test_fix_fenced_to_indented_preserves_empty_lines() {
1486        // Empty lines within code blocks should also get the 4-space prefix
1487        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1488        let content = "```\nline1\n\nline2\n```\n";
1489        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490        let fixed = rule.fix(&ctx).unwrap();
1491
1492        // The fixed content should have proper structure
1493        assert!(fixed.contains("    line1"), "line1 should be indented");
1494        assert!(fixed.contains("    line2"), "line2 should be indented");
1495        // Empty line between them is preserved (may or may not have spaces)
1496    }
1497
1498    #[test]
1499    fn test_fix_fenced_to_indented_multiple_blocks() {
1500        // Multiple fenced blocks should all preserve their indentation
1501        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1502        let content = r#"# Doc
1503
1504```python
1505def foo():
1506    pass
1507```
1508
1509Text between.
1510
1511```yaml
1512key:
1513  value: 1
1514```
1515"#;
1516        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1517        let fixed = rule.fix(&ctx).unwrap();
1518
1519        assert!(fixed.contains("    def foo():"), "Python def should be indented");
1520        assert!(fixed.contains("        pass"), "Python body should have 8 spaces");
1521        assert!(fixed.contains("    key:"), "YAML root should have 4 spaces");
1522        assert!(fixed.contains("      value:"), "YAML nested should have 6 spaces");
1523        assert!(!fixed.contains("```"), "No fence markers should remain");
1524    }
1525
1526    #[test]
1527    fn test_fix_unclosed_block() {
1528        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1529        let content = "```\ncode without closing";
1530        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531        let fixed = rule.fix(&ctx).unwrap();
1532
1533        // Should add closing fence
1534        assert!(fixed.ends_with("```"));
1535    }
1536
1537    #[test]
1538    fn test_code_block_in_list() {
1539        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1540        let content = "- List item\n    code in list\n    more code\n- Next item";
1541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542        let result = rule.check(&ctx).unwrap();
1543
1544        // Code in lists should not be flagged
1545        assert_eq!(result.len(), 0);
1546    }
1547
1548    #[test]
1549    fn test_detect_style_fenced() {
1550        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1551        let content = "```\ncode\n```";
1552        let style = rule.detect_style(content, false);
1553
1554        assert_eq!(style, Some(CodeBlockStyle::Fenced));
1555    }
1556
1557    #[test]
1558    fn test_detect_style_indented() {
1559        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1560        let content = "Text\n\n    code\n\nMore";
1561        let style = rule.detect_style(content, false);
1562
1563        assert_eq!(style, Some(CodeBlockStyle::Indented));
1564    }
1565
1566    #[test]
1567    fn test_detect_style_none() {
1568        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1569        let content = "No code blocks here";
1570        let style = rule.detect_style(content, false);
1571
1572        assert_eq!(style, None);
1573    }
1574
1575    #[test]
1576    fn test_tilde_fence() {
1577        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1578        let content = "~~~\ncode\n~~~";
1579        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1580        let result = rule.check(&ctx).unwrap();
1581
1582        // Tilde fences should be accepted as fenced blocks
1583        assert_eq!(result.len(), 0);
1584    }
1585
1586    #[test]
1587    fn test_language_specification() {
1588        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1589        let content = "```rust\nfn main() {}\n```";
1590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1591        let result = rule.check(&ctx).unwrap();
1592
1593        assert_eq!(result.len(), 0);
1594    }
1595
1596    #[test]
1597    fn test_empty_content() {
1598        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1599        let content = "";
1600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1601        let result = rule.check(&ctx).unwrap();
1602
1603        assert_eq!(result.len(), 0);
1604    }
1605
1606    #[test]
1607    fn test_default_config() {
1608        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1609        let (name, _config) = rule.default_config_section().unwrap();
1610        assert_eq!(name, "MD046");
1611    }
1612
1613    #[test]
1614    fn test_markdown_documentation_block() {
1615        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1616        let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1617        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618        let result = rule.check(&ctx).unwrap();
1619
1620        // Nested code blocks in markdown documentation should be allowed
1621        assert_eq!(result.len(), 0);
1622    }
1623
1624    #[test]
1625    fn test_preserve_trailing_newline() {
1626        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1627        let content = "```\ncode\n```\n";
1628        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629        let fixed = rule.fix(&ctx).unwrap();
1630
1631        assert_eq!(fixed, content);
1632    }
1633
1634    #[test]
1635    fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1636        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1637        let content = r#"# Document
1638
1639=== "Python"
1640
1641    This is tab content
1642    Not an indented code block
1643
1644    ```python
1645    def hello():
1646        print("Hello")
1647    ```
1648
1649=== "JavaScript"
1650
1651    More tab content here
1652    Also not an indented code block"#;
1653
1654        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1655        let result = rule.check(&ctx).unwrap();
1656
1657        // Should not flag tab content as indented code blocks
1658        assert_eq!(result.len(), 0);
1659    }
1660
1661    #[test]
1662    fn test_mkdocs_tabs_with_actual_indented_code() {
1663        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1664        let content = r#"# Document
1665
1666=== "Tab 1"
1667
1668    This is tab content
1669
1670Regular text
1671
1672    This is an actual indented code block
1673    Should be flagged"#;
1674
1675        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1676        let result = rule.check(&ctx).unwrap();
1677
1678        // Should flag the actual indented code block but not the tab content
1679        assert_eq!(result.len(), 1);
1680        assert!(result[0].message.contains("Use fenced code blocks"));
1681    }
1682
1683    #[test]
1684    fn test_mkdocs_tabs_detect_style() {
1685        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1686        let content = r#"=== "Tab 1"
1687
1688    Content in tab
1689    More content
1690
1691=== "Tab 2"
1692
1693    Content in second tab"#;
1694
1695        // In MkDocs mode, tab content should not be detected as indented code blocks
1696        let style = rule.detect_style(content, true);
1697        assert_eq!(style, None); // No code blocks detected
1698
1699        // In standard mode, it would detect indented code blocks
1700        let style = rule.detect_style(content, false);
1701        assert_eq!(style, Some(CodeBlockStyle::Indented));
1702    }
1703
1704    #[test]
1705    fn test_mkdocs_nested_tabs() {
1706        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1707        let content = r#"# Document
1708
1709=== "Outer Tab"
1710
1711    Some content
1712
1713    === "Nested Tab"
1714
1715        Nested tab content
1716        Should not be flagged"#;
1717
1718        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1719        let result = rule.check(&ctx).unwrap();
1720
1721        // Nested tabs should not be flagged
1722        assert_eq!(result.len(), 0);
1723    }
1724
1725    #[test]
1726    fn test_mkdocs_admonitions_not_flagged_as_indented_code() {
1727        // Issue #269: MkDocs admonitions have indented bodies that should NOT be
1728        // treated as indented code blocks when style = "fenced"
1729        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1730        let content = r#"# Document
1731
1732!!! note
1733    This is normal admonition content, not a code block.
1734    It spans multiple lines.
1735
1736??? warning "Collapsible Warning"
1737    This is also admonition content.
1738
1739???+ tip "Expanded Tip"
1740    And this one too.
1741
1742Regular text outside admonitions."#;
1743
1744        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1745        let result = rule.check(&ctx).unwrap();
1746
1747        // Admonition content should not be flagged
1748        assert_eq!(
1749            result.len(),
1750            0,
1751            "Admonition content in MkDocs mode should not trigger MD046"
1752        );
1753    }
1754
1755    #[test]
1756    fn test_mkdocs_admonition_with_actual_indented_code() {
1757        // After an admonition ends, regular indented code blocks SHOULD be flagged
1758        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1759        let content = r#"# Document
1760
1761!!! note
1762    This is admonition content.
1763
1764Regular text ends the admonition.
1765
1766    This is actual indented code (should be flagged)"#;
1767
1768        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1769        let result = rule.check(&ctx).unwrap();
1770
1771        // Should only flag the actual indented code block
1772        assert_eq!(result.len(), 1);
1773        assert!(result[0].message.contains("Use fenced code blocks"));
1774    }
1775
1776    #[test]
1777    fn test_admonition_in_standard_mode_flagged() {
1778        // In standard Markdown mode, admonitions are not recognized, so the
1779        // indented content should be flagged as indented code
1780        // Note: A blank line is required before indented code blocks per CommonMark
1781        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1782        let content = r#"# Document
1783
1784!!! note
1785
1786    This looks like code in standard mode.
1787
1788Regular text."#;
1789
1790        // In Standard mode, admonitions are not recognized
1791        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1792        let result = rule.check(&ctx).unwrap();
1793
1794        // The indented content should be flagged in standard mode
1795        assert_eq!(
1796            result.len(),
1797            1,
1798            "Admonition content in Standard mode should be flagged as indented code"
1799        );
1800    }
1801
1802    #[test]
1803    fn test_mkdocs_admonition_with_fenced_code_inside() {
1804        // Issue #269: Admonitions can contain fenced code blocks - must handle correctly
1805        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1806        let content = r#"# Document
1807
1808!!! note "Code Example"
1809    Here's some code:
1810
1811    ```python
1812    def hello():
1813        print("world")
1814    ```
1815
1816    More text after code.
1817
1818Regular text."#;
1819
1820        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1821        let result = rule.check(&ctx).unwrap();
1822
1823        // Should not flag anything - the fenced block inside admonition is valid
1824        assert_eq!(result.len(), 0, "Fenced code blocks inside admonitions should be valid");
1825    }
1826
1827    #[test]
1828    fn test_mkdocs_nested_admonitions() {
1829        // Nested admonitions are valid MkDocs syntax
1830        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1831        let content = r#"# Document
1832
1833!!! note "Outer"
1834    Outer content.
1835
1836    !!! warning "Inner"
1837        Inner content.
1838        More inner content.
1839
1840    Back to outer.
1841
1842Regular text."#;
1843
1844        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1845        let result = rule.check(&ctx).unwrap();
1846
1847        // Nested admonitions should not trigger MD046
1848        assert_eq!(result.len(), 0, "Nested admonitions should not be flagged");
1849    }
1850
1851    #[test]
1852    fn test_mkdocs_admonition_fix_does_not_wrap() {
1853        // The fix function should not wrap admonition content in fences
1854        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1855        let content = r#"!!! note
1856    Content that should stay as admonition content.
1857    Not be wrapped in code fences.
1858"#;
1859
1860        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1861        let fixed = rule.fix(&ctx).unwrap();
1862
1863        // Fix should not add fence markers to admonition content
1864        assert!(
1865            !fixed.contains("```\n    Content"),
1866            "Admonition content should not be wrapped in fences"
1867        );
1868        assert_eq!(fixed, content, "Content should remain unchanged");
1869    }
1870
1871    #[test]
1872    fn test_mkdocs_empty_admonition() {
1873        // Empty admonitions (marker only) should not cause issues
1874        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1875        let content = r#"!!! note
1876
1877Regular paragraph after empty admonition.
1878
1879    This IS an indented code block (after blank + non-indented line)."#;
1880
1881        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1882        let result = rule.check(&ctx).unwrap();
1883
1884        // The indented code block after the paragraph should be flagged
1885        assert_eq!(result.len(), 1, "Indented code after admonition ends should be flagged");
1886    }
1887
1888    #[test]
1889    fn test_mkdocs_indented_admonition() {
1890        // Admonitions can themselves be indented (e.g., inside list items)
1891        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1892        let content = r#"- List item
1893
1894    !!! note
1895        Indented admonition content.
1896        More content.
1897
1898- Next item"#;
1899
1900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1901        let result = rule.check(&ctx).unwrap();
1902
1903        // Admonition inside list should not be flagged
1904        assert_eq!(
1905            result.len(),
1906            0,
1907            "Indented admonitions (e.g., in lists) should not be flagged"
1908        );
1909    }
1910
1911    #[test]
1912    fn test_footnote_indented_paragraphs_not_flagged() {
1913        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1914        let content = r#"# Test Document with Footnotes
1915
1916This is some text with a footnote[^1].
1917
1918Here's some code:
1919
1920```bash
1921echo "fenced code block"
1922```
1923
1924More text with another footnote[^2].
1925
1926[^1]: Really interesting footnote text.
1927
1928    Even more interesting second paragraph.
1929
1930[^2]: Another footnote.
1931
1932    With a second paragraph too.
1933
1934    And even a third paragraph!"#;
1935
1936        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1937        let result = rule.check(&ctx).unwrap();
1938
1939        // Indented paragraphs in footnotes should not be flagged as code blocks
1940        assert_eq!(result.len(), 0);
1941    }
1942
1943    #[test]
1944    fn test_footnote_definition_detection() {
1945        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1946
1947        // Valid footnote definitions (per CommonMark footnote extension spec)
1948        // Reference: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/footnotes.md
1949        assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1950        assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1951        assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1952        assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1953        assert!(rule.is_footnote_definition("    [^1]: Indented footnote"));
1954        assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1955        assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1956        assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1957        assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1958
1959        // Invalid: empty or whitespace-only labels (spec violation)
1960        assert!(!rule.is_footnote_definition("[^]: No label"));
1961        assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1962        assert!(!rule.is_footnote_definition("[^  ]: Multiple spaces"));
1963        assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1964
1965        // Invalid: malformed syntax
1966        assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1967        assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1968        assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1969        assert!(!rule.is_footnote_definition("[^")); // Too short
1970        assert!(!rule.is_footnote_definition("[^1:")); // Missing closing bracket
1971        assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1972
1973        // Invalid: disallowed characters in label
1974        assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1975        assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1976        assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1977        assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1978        assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1979
1980        // Edge case: line breaks not allowed in labels
1981        // (This is a string test, actual multiline would need different testing)
1982        assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1983    }
1984
1985    #[test]
1986    fn test_footnote_with_blank_lines() {
1987        // Spec requirement: blank lines within footnotes don't terminate them
1988        // if next content is indented (matches GitHub's implementation)
1989        // Reference: commonmark-hs footnote extension behavior
1990        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1991        let content = r#"# Document
1992
1993Text with footnote[^1].
1994
1995[^1]: First paragraph.
1996
1997    Second paragraph after blank line.
1998
1999    Third paragraph after another blank line.
2000
2001Regular text at column 0 ends the footnote."#;
2002
2003        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2004        let result = rule.check(&ctx).unwrap();
2005
2006        // The indented paragraphs in the footnote should not be flagged as code blocks
2007        assert_eq!(
2008            result.len(),
2009            0,
2010            "Indented content within footnotes should not trigger MD046"
2011        );
2012    }
2013
2014    #[test]
2015    fn test_footnote_multiple_consecutive_blank_lines() {
2016        // Edge case: multiple consecutive blank lines within a footnote
2017        // Should still work if next content is indented
2018        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2019        let content = r#"Text[^1].
2020
2021[^1]: First paragraph.
2022
2023
2024
2025    Content after three blank lines (still part of footnote).
2026
2027Not indented, so footnote ends here."#;
2028
2029        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2030        let result = rule.check(&ctx).unwrap();
2031
2032        // The indented content should not be flagged
2033        assert_eq!(
2034            result.len(),
2035            0,
2036            "Multiple blank lines shouldn't break footnote continuation"
2037        );
2038    }
2039
2040    #[test]
2041    fn test_footnote_terminated_by_non_indented_content() {
2042        // Spec requirement: non-indented content always terminates the footnote
2043        // Reference: commonmark-hs footnote extension
2044        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2045        let content = r#"[^1]: Footnote content.
2046
2047    More indented content in footnote.
2048
2049This paragraph is not indented, so footnote ends.
2050
2051    This should be flagged as indented code block."#;
2052
2053        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054        let result = rule.check(&ctx).unwrap();
2055
2056        // The last indented block should be flagged (it's after the footnote ended)
2057        assert_eq!(
2058            result.len(),
2059            1,
2060            "Indented code after footnote termination should be flagged"
2061        );
2062        assert!(
2063            result[0].message.contains("Use fenced code blocks"),
2064            "Expected MD046 warning for indented code block"
2065        );
2066        assert!(result[0].line >= 7, "Warning should be on the indented code block line");
2067    }
2068
2069    #[test]
2070    fn test_footnote_terminated_by_structural_elements() {
2071        // Spec requirement: headings and horizontal rules terminate footnotes
2072        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2073        let content = r#"[^1]: Footnote content.
2074
2075    More content.
2076
2077## Heading terminates footnote
2078
2079    This indented content should be flagged.
2080
2081---
2082
2083    This should also be flagged (after horizontal rule)."#;
2084
2085        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2086        let result = rule.check(&ctx).unwrap();
2087
2088        // Both indented blocks after structural elements should be flagged
2089        assert_eq!(
2090            result.len(),
2091            2,
2092            "Both indented blocks after termination should be flagged"
2093        );
2094    }
2095
2096    #[test]
2097    fn test_footnote_with_code_block_inside() {
2098        // Spec behavior: footnotes can contain fenced code blocks
2099        // The fenced code must be properly indented within the footnote
2100        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2101        let content = r#"Text[^1].
2102
2103[^1]: Footnote with code:
2104
2105    ```python
2106    def hello():
2107        print("world")
2108    ```
2109
2110    More footnote text after code."#;
2111
2112        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2113        let result = rule.check(&ctx).unwrap();
2114
2115        // Should have no warnings - the fenced code block is valid
2116        assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
2117    }
2118
2119    #[test]
2120    fn test_footnote_with_8_space_indented_code() {
2121        // Edge case: code blocks within footnotes need 8 spaces (4 for footnote + 4 for code)
2122        // This should NOT be flagged as it's properly nested indented code
2123        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2124        let content = r#"Text[^1].
2125
2126[^1]: Footnote with nested code.
2127
2128        code block
2129        more code"#;
2130
2131        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2132        let result = rule.check(&ctx).unwrap();
2133
2134        // The 8-space indented code is valid within footnote
2135        assert_eq!(
2136            result.len(),
2137            0,
2138            "8-space indented code within footnotes represents nested code blocks"
2139        );
2140    }
2141
2142    #[test]
2143    fn test_multiple_footnotes() {
2144        // Spec behavior: each footnote definition starts a new block context
2145        // Previous footnote ends when new footnote begins
2146        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2147        let content = r#"Text[^1] and more[^2].
2148
2149[^1]: First footnote.
2150
2151    Continuation of first.
2152
2153[^2]: Second footnote starts here, ending the first.
2154
2155    Continuation of second."#;
2156
2157        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2158        let result = rule.check(&ctx).unwrap();
2159
2160        // All indented content is part of footnotes
2161        assert_eq!(
2162            result.len(),
2163            0,
2164            "Multiple footnotes should each maintain their continuation context"
2165        );
2166    }
2167
2168    #[test]
2169    fn test_list_item_ends_footnote_context() {
2170        // Spec behavior: list items and footnotes are mutually exclusive contexts
2171        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2172        let content = r#"[^1]: Footnote.
2173
2174    Content in footnote.
2175
2176- List item starts here (ends footnote context).
2177
2178    This indented content is part of the list, not the footnote."#;
2179
2180        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2181        let result = rule.check(&ctx).unwrap();
2182
2183        // List continuation should not be flagged
2184        assert_eq!(
2185            result.len(),
2186            0,
2187            "List items should end footnote context and start their own"
2188        );
2189    }
2190
2191    #[test]
2192    fn test_footnote_vs_actual_indented_code() {
2193        // Critical test: verify we can still detect actual indented code blocks outside footnotes
2194        // This ensures the fix doesn't cause false negatives
2195        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2196        let content = r#"# Heading
2197
2198Text with footnote[^1].
2199
2200[^1]: Footnote content.
2201
2202    Part of footnote (should not be flagged).
2203
2204Regular paragraph ends footnote context.
2205
2206    This is actual indented code (MUST be flagged)
2207    Should be detected as code block"#;
2208
2209        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2210        let result = rule.check(&ctx).unwrap();
2211
2212        // Should flag the indented code after the regular paragraph
2213        assert_eq!(
2214            result.len(),
2215            1,
2216            "Must still detect indented code blocks outside footnotes"
2217        );
2218        assert!(
2219            result[0].message.contains("Use fenced code blocks"),
2220            "Expected MD046 warning for indented code"
2221        );
2222        assert!(
2223            result[0].line >= 11,
2224            "Warning should be on the actual indented code line"
2225        );
2226    }
2227
2228    #[test]
2229    fn test_spec_compliant_label_characters() {
2230        // Spec requirement: labels must contain only alphanumerics, hyphens, underscores
2231        // Reference: commonmark-hs footnote extension
2232        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2233
2234        // Valid according to spec
2235        assert!(rule.is_footnote_definition("[^test]: text"));
2236        assert!(rule.is_footnote_definition("[^TEST]: text"));
2237        assert!(rule.is_footnote_definition("[^test-name]: text"));
2238        assert!(rule.is_footnote_definition("[^test_name]: text"));
2239        assert!(rule.is_footnote_definition("[^test123]: text"));
2240        assert!(rule.is_footnote_definition("[^123]: text"));
2241        assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
2242
2243        // Invalid characters (spec violations)
2244        assert!(!rule.is_footnote_definition("[^test.name]: text")); // Period
2245        assert!(!rule.is_footnote_definition("[^test name]: text")); // Space
2246        assert!(!rule.is_footnote_definition("[^test@name]: text")); // At sign
2247        assert!(!rule.is_footnote_definition("[^test#name]: text")); // Hash
2248        assert!(!rule.is_footnote_definition("[^test$name]: text")); // Dollar
2249        assert!(!rule.is_footnote_definition("[^test%name]: text")); // Percent
2250    }
2251
2252    #[test]
2253    fn test_code_block_inside_html_comment() {
2254        // Regression test: code blocks inside HTML comments should not be flagged
2255        // Found in denoland/deno test fixture during sanity testing
2256        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2257        let content = r#"# Document
2258
2259Some text.
2260
2261<!--
2262Example code block in comment:
2263
2264```typescript
2265console.log("Hello");
2266```
2267
2268More comment text.
2269-->
2270
2271More content."#;
2272
2273        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2274        let result = rule.check(&ctx).unwrap();
2275
2276        assert_eq!(
2277            result.len(),
2278            0,
2279            "Code blocks inside HTML comments should not be flagged as unclosed"
2280        );
2281    }
2282
2283    #[test]
2284    fn test_unclosed_fence_inside_html_comment() {
2285        // Even an unclosed fence inside an HTML comment should be ignored
2286        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2287        let content = r#"# Document
2288
2289<!--
2290Example with intentionally unclosed fence:
2291
2292```
2293code without closing
2294-->
2295
2296More content."#;
2297
2298        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2299        let result = rule.check(&ctx).unwrap();
2300
2301        assert_eq!(
2302            result.len(),
2303            0,
2304            "Unclosed fences inside HTML comments should be ignored"
2305        );
2306    }
2307
2308    #[test]
2309    fn test_multiline_html_comment_with_indented_code() {
2310        // Indented code inside HTML comments should also be ignored
2311        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2312        let content = r#"# Document
2313
2314<!--
2315Example:
2316
2317    indented code
2318    more code
2319
2320End of comment.
2321-->
2322
2323Regular text."#;
2324
2325        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2326        let result = rule.check(&ctx).unwrap();
2327
2328        assert_eq!(
2329            result.len(),
2330            0,
2331            "Indented code inside HTML comments should not be flagged"
2332        );
2333    }
2334
2335    #[test]
2336    fn test_code_block_after_html_comment() {
2337        // Code blocks after HTML comments should still be detected
2338        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2339        let content = r#"# Document
2340
2341<!-- comment -->
2342
2343Text before.
2344
2345    indented code should be flagged
2346
2347More text."#;
2348
2349        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2350        let result = rule.check(&ctx).unwrap();
2351
2352        assert_eq!(
2353            result.len(),
2354            1,
2355            "Code blocks after HTML comments should still be detected"
2356        );
2357        assert!(result[0].message.contains("Use fenced code blocks"));
2358    }
2359
2360    #[test]
2361    fn test_four_space_indented_fence_is_not_valid_fence() {
2362        // Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces."
2363        // 4+ spaces means it's NOT a valid fence opener - it becomes an indented code block
2364        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2365
2366        // Valid fences (0-3 spaces)
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        assert!(rule.is_fenced_code_block_start("   ```"));
2371
2372        // Invalid fences (4+ spaces) - these are indented code blocks instead
2373        assert!(!rule.is_fenced_code_block_start("    ```"));
2374        assert!(!rule.is_fenced_code_block_start("     ```"));
2375        assert!(!rule.is_fenced_code_block_start("        ```"));
2376
2377        // Tab counts as 4 spaces per CommonMark
2378        assert!(!rule.is_fenced_code_block_start("\t```"));
2379    }
2380
2381    #[test]
2382    fn test_issue_237_indented_fenced_block_detected_as_indented() {
2383        // Issue #237: User has fenced code block indented by 4 spaces
2384        // Per CommonMark, this should be detected as an INDENTED code block
2385        // because 4+ spaces of indentation makes the fence invalid
2386        //
2387        // Reference: https://github.com/rvben/rumdl/issues/237
2388        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2389
2390        // This is the exact test case from issue #237
2391        let content = r#"## Test
2392
2393    ```js
2394    var foo = "hello";
2395    ```
2396"#;
2397
2398        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2399        let result = rule.check(&ctx).unwrap();
2400
2401        // Should flag this as an indented code block that should use fenced style
2402        assert_eq!(
2403            result.len(),
2404            1,
2405            "4-space indented fence should be detected as indented code block"
2406        );
2407        assert!(
2408            result[0].message.contains("Use fenced code blocks"),
2409            "Expected 'Use fenced code blocks' message"
2410        );
2411    }
2412
2413    #[test]
2414    fn test_issue_276_indented_code_in_list() {
2415        // Issue #276: Indented code blocks inside lists should be detected
2416        // Reference: https://github.com/rvben/rumdl/issues/276
2417        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2418
2419        let content = r#"1. First item
24202. Second item with code:
2421
2422        # This is a code block in a list
2423        print("Hello, world!")
2424
24254. Third item"#;
2426
2427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2428        let result = rule.check(&ctx).unwrap();
2429
2430        // Should flag the indented code block inside the list
2431        assert!(
2432            !result.is_empty(),
2433            "Indented code block inside list should be flagged when style=fenced"
2434        );
2435        assert!(
2436            result[0].message.contains("Use fenced code blocks"),
2437            "Expected 'Use fenced code blocks' message"
2438        );
2439    }
2440
2441    #[test]
2442    fn test_three_space_indented_fence_is_valid() {
2443        // 3 spaces is the maximum allowed per CommonMark - should be recognized as fenced
2444        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2445
2446        let content = r#"## Test
2447
2448   ```js
2449   var foo = "hello";
2450   ```
2451"#;
2452
2453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2454        let result = rule.check(&ctx).unwrap();
2455
2456        // 3-space indent is valid for fenced blocks - should pass
2457        assert_eq!(
2458            result.len(),
2459            0,
2460            "3-space indented fence should be recognized as valid fenced code block"
2461        );
2462    }
2463
2464    #[test]
2465    fn test_indented_style_with_deeply_indented_fenced() {
2466        // When style=indented, a 4-space indented "fenced" block should still be detected
2467        // as an indented code block (which is what we want!)
2468        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
2469
2470        let content = r#"Text
2471
2472    ```js
2473    var foo = "hello";
2474    ```
2475
2476More text
2477"#;
2478
2479        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2480        let result = rule.check(&ctx).unwrap();
2481
2482        // When target style is "indented", 4-space indented content is correct
2483        // The fence markers become literal content in the indented code block
2484        assert_eq!(
2485            result.len(),
2486            0,
2487            "4-space indented content should be valid when style=indented"
2488        );
2489    }
2490
2491    #[test]
2492    fn test_fix_misplaced_fenced_block() {
2493        // Issue #237: When a fenced code block is accidentally indented 4+ spaces,
2494        // the fix should just remove the indentation, not wrap in more fences
2495        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2496
2497        let content = r#"## Test
2498
2499    ```js
2500    var foo = "hello";
2501    ```
2502"#;
2503
2504        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2505        let fixed = rule.fix(&ctx).unwrap();
2506
2507        // The fix should just remove the 4-space indentation
2508        let expected = r#"## Test
2509
2510```js
2511var foo = "hello";
2512```
2513"#;
2514
2515        assert_eq!(fixed, expected, "Fix should remove indentation, not add more fences");
2516    }
2517
2518    #[test]
2519    fn test_fix_regular_indented_block() {
2520        // Regular indented code blocks (without fence markers) should still be
2521        // wrapped in fences when converted
2522        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2523
2524        let content = r#"Text
2525
2526    var foo = "hello";
2527    console.log(foo);
2528
2529More text
2530"#;
2531
2532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2533        let fixed = rule.fix(&ctx).unwrap();
2534
2535        // Should wrap in fences
2536        assert!(fixed.contains("```\nvar foo"), "Should add opening fence");
2537        assert!(fixed.contains("console.log(foo);\n```"), "Should add closing fence");
2538    }
2539
2540    #[test]
2541    fn test_fix_indented_block_with_fence_like_content() {
2542        // If an indented block contains fence-like content but doesn't form a
2543        // complete fenced block, we should NOT autofix it because wrapping would
2544        // create invalid nested fences. The block is left unchanged.
2545        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2546
2547        let content = r#"Text
2548
2549    some code
2550    ```not a fence opener
2551    more code
2552"#;
2553
2554        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2555        let fixed = rule.fix(&ctx).unwrap();
2556
2557        // Block should be left unchanged to avoid creating invalid nested fences
2558        assert!(fixed.contains("    some code"), "Unsafe block should be left unchanged");
2559        assert!(!fixed.contains("```\nsome code"), "Should NOT wrap unsafe block");
2560    }
2561
2562    #[test]
2563    fn test_fix_mixed_indented_and_misplaced_blocks() {
2564        // Mixed blocks: regular indented code followed by misplaced fenced block
2565        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
2566
2567        let content = r#"Text
2568
2569    regular indented code
2570
2571More text
2572
2573    ```python
2574    print("hello")
2575    ```
2576"#;
2577
2578        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2579        let fixed = rule.fix(&ctx).unwrap();
2580
2581        // First block should be wrapped
2582        assert!(
2583            fixed.contains("```\nregular indented code\n```"),
2584            "First block should be wrapped in fences"
2585        );
2586
2587        // Second block should be dedented (not wrapped)
2588        assert!(
2589            fixed.contains("\n```python\nprint(\"hello\")\n```"),
2590            "Second block should be dedented, not double-wrapped"
2591        );
2592        // Should NOT have nested fences
2593        assert!(
2594            !fixed.contains("```\n```python"),
2595            "Should not have nested fence openers"
2596        );
2597    }
2598}