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