Skip to main content

rumdl_lib/rules/
md046_code_block_style.rs

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