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