rumdl_lib/rules/
md046_code_block_style.rs

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