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::mkdocs_tabs;
4use crate::utils::range_utils::calculate_line_range;
5use toml;
6
7mod md046_config;
8use md046_config::MD046Config;
9
10/// Rule MD046: Code block style
11///
12/// See [docs/md046.md](../../docs/md046.md) for full documentation, configuration, and examples.
13///
14/// This rule is triggered when code blocks do not use a consistent style (either fenced or indented).
15#[derive(Clone)]
16pub struct MD046CodeBlockStyle {
17    config: MD046Config,
18}
19
20impl MD046CodeBlockStyle {
21    pub fn new(style: CodeBlockStyle) -> Self {
22        Self {
23            config: MD046Config { style },
24        }
25    }
26
27    pub fn from_config_struct(config: MD046Config) -> Self {
28        Self { config }
29    }
30
31    fn is_fenced_code_block_start(&self, line: &str) -> bool {
32        let trimmed = line.trim_start();
33        trimmed.starts_with("```") || trimmed.starts_with("~~~")
34    }
35
36    fn is_list_item(&self, line: &str) -> bool {
37        let trimmed = line.trim_start();
38        (trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ "))
39            || (trimmed.len() > 2
40                && trimmed.chars().next().unwrap().is_numeric()
41                && (trimmed.contains(". ") || trimmed.contains(") ")))
42    }
43
44    /// Check if a line is a footnote definition according to CommonMark footnote extension spec
45    ///
46    /// # Specification Compliance
47    /// Based on commonmark-hs footnote extension and GitHub's implementation:
48    /// - Format: `[^label]: content`
49    /// - Labels cannot be empty or whitespace-only
50    /// - Labels cannot contain line breaks (unlike regular link references)
51    /// - Labels typically contain alphanumerics, hyphens, underscores (though some parsers are more permissive)
52    ///
53    /// # Examples
54    /// Valid:
55    /// - `[^1]: Footnote text`
56    /// - `[^foo-bar]: Content`
57    /// - `[^test_123]: More content`
58    ///
59    /// Invalid:
60    /// - `[^]: No label`
61    /// - `[^ ]: Whitespace only`
62    /// - `[^]]: Extra bracket`
63    fn is_footnote_definition(&self, line: &str) -> bool {
64        let trimmed = line.trim_start();
65        if !trimmed.starts_with("[^") || trimmed.len() < 5 {
66            return false;
67        }
68
69        if let Some(close_bracket_pos) = trimmed.find("]:")
70            && close_bracket_pos > 2
71        {
72            let label = &trimmed[2..close_bracket_pos];
73
74            if label.trim().is_empty() {
75                return false;
76            }
77
78            // Per spec: labels cannot contain line breaks (check for \r since \n can't appear in a single line)
79            if label.contains('\r') {
80                return false;
81            }
82
83            // Validate characters per GitHub's behavior: alphanumeric, hyphens, underscores only
84            if label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
85                return true;
86            }
87        }
88
89        false
90    }
91
92    /// Pre-compute which lines are in block continuation context (lists, footnotes) with a single forward pass
93    ///
94    /// # Specification-Based Context Tracking
95    /// This function implements CommonMark-style block continuation semantics:
96    ///
97    /// ## List Items
98    /// - List items can contain multiple paragraphs and blocks
99    /// - Content continues if indented appropriately
100    /// - Context ends at structural boundaries (headings, horizontal rules) or column-0 paragraphs
101    ///
102    /// ## Footnotes
103    /// Per commonmark-hs footnote extension and GitHub's implementation:
104    /// - Footnote content continues as long as it's indented
105    /// - Blank lines within footnotes don't terminate them (if next content is indented)
106    /// - Non-indented content terminates the footnote
107    /// - Similar to list items but can span more content
108    ///
109    /// # Performance
110    /// O(n) single forward pass, replacing O(n²) backward scanning
111    ///
112    /// # Returns
113    /// Boolean vector where `true` indicates the line is part of a list/footnote continuation
114    fn precompute_block_continuation_context(&self, lines: &[&str]) -> Vec<bool> {
115        let mut in_continuation_context = vec![false; lines.len()];
116        let mut last_list_item_line: Option<usize> = None;
117        let mut last_footnote_line: Option<usize> = None;
118        let mut blank_line_count = 0;
119
120        for (i, line) in lines.iter().enumerate() {
121            let trimmed = line.trim_start();
122            let indent_len = line.len() - trimmed.len();
123
124            // Check if this is a list item
125            if self.is_list_item(line) {
126                last_list_item_line = Some(i);
127                last_footnote_line = None; // List item ends any footnote context
128                blank_line_count = 0;
129                in_continuation_context[i] = true;
130                continue;
131            }
132
133            // Check if this is a footnote definition
134            if self.is_footnote_definition(line) {
135                last_footnote_line = Some(i);
136                last_list_item_line = None; // Footnote ends any list context
137                blank_line_count = 0;
138                in_continuation_context[i] = true;
139                continue;
140            }
141
142            // Handle empty lines
143            if line.trim().is_empty() {
144                // Blank lines within continuations are allowed
145                if last_list_item_line.is_some() || last_footnote_line.is_some() {
146                    blank_line_count += 1;
147                    in_continuation_context[i] = true;
148
149                    // Per spec: multiple consecutive blank lines might terminate context
150                    // GitHub allows multiple blank lines within footnotes if next content is indented
151                    // We'll check on the next non-blank line
152                }
153                continue;
154            }
155
156            // Non-empty line - check for structural breaks or continuation
157            if indent_len == 0 && !trimmed.is_empty() {
158                // Content at column 0 (not indented)
159
160                // Headings definitely end all contexts
161                if trimmed.starts_with('#') {
162                    last_list_item_line = None;
163                    last_footnote_line = None;
164                    blank_line_count = 0;
165                    continue;
166                }
167
168                // Horizontal rules end all contexts
169                if trimmed.starts_with("---") || trimmed.starts_with("***") {
170                    last_list_item_line = None;
171                    last_footnote_line = None;
172                    blank_line_count = 0;
173                    continue;
174                }
175
176                // Non-indented paragraph/content terminates contexts
177                // But be conservative: allow some distance for lists
178                if let Some(list_line) = last_list_item_line
179                    && (i - list_line > 5 || blank_line_count > 1)
180                {
181                    last_list_item_line = None;
182                }
183
184                // For footnotes, non-indented content always terminates
185                if last_footnote_line.is_some() {
186                    last_footnote_line = None;
187                }
188
189                blank_line_count = 0;
190
191                // If no active context, this is a regular line
192                if last_list_item_line.is_none() && last_footnote_line.is_some() {
193                    last_footnote_line = None;
194                }
195                continue;
196            }
197
198            // Indented content - part of continuation if we have active context
199            if indent_len > 0 && (last_list_item_line.is_some() || last_footnote_line.is_some()) {
200                in_continuation_context[i] = true;
201                blank_line_count = 0;
202            }
203        }
204
205        in_continuation_context
206    }
207
208    /// Check if a line is an indented code block using pre-computed context arrays
209    fn is_indented_code_block_with_context(
210        &self,
211        lines: &[&str],
212        i: usize,
213        is_mkdocs: bool,
214        in_list_context: &[bool],
215        in_tab_context: &[bool],
216    ) -> bool {
217        if i >= lines.len() {
218            return false;
219        }
220
221        let line = lines[i];
222
223        // Check if indented by at least 4 spaces or tab
224        if !(line.starts_with("    ") || line.starts_with("\t")) {
225            return false;
226        }
227
228        // Check if this is part of a list structure (pre-computed)
229        if in_list_context[i] {
230            return false;
231        }
232
233        // Skip if this is MkDocs tab content (pre-computed)
234        if is_mkdocs && in_tab_context[i] {
235            return false;
236        }
237
238        // Check if preceded by a blank line (typical for code blocks)
239        // OR if the previous line is also an indented code block (continuation)
240        let has_blank_line_before = i == 0 || lines[i - 1].trim().is_empty();
241        let prev_is_indented_code = i > 0
242            && (lines[i - 1].starts_with("    ") || lines[i - 1].starts_with("\t"))
243            && !in_list_context[i - 1]
244            && !(is_mkdocs && in_tab_context[i - 1]);
245
246        // If no blank line before and previous line is not indented code,
247        // it's likely list continuation, not a code block
248        if !has_blank_line_before && !prev_is_indented_code {
249            return false;
250        }
251
252        true
253    }
254
255    /// Pre-compute which lines are in MkDocs tab context with a single forward pass
256    fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> {
257        let mut in_tab_context = vec![false; lines.len()];
258        let mut current_tab_indent: Option<usize> = None;
259
260        for (i, line) in lines.iter().enumerate() {
261            // Check if this is a tab marker
262            if mkdocs_tabs::is_tab_marker(line) {
263                let tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
264                current_tab_indent = Some(tab_indent);
265                in_tab_context[i] = true;
266                continue;
267            }
268
269            // If we have a current tab, check if this line is tab content
270            if let Some(tab_indent) = current_tab_indent {
271                if mkdocs_tabs::is_tab_content(line, tab_indent) {
272                    in_tab_context[i] = true;
273                } else if !line.trim().is_empty() && !line.starts_with("    ") {
274                    // Non-indented, non-empty line ends tab context
275                    current_tab_indent = None;
276                } else {
277                    // Empty or indented line maintains tab context
278                    in_tab_context[i] = true;
279                }
280            }
281        }
282
283        in_tab_context
284    }
285
286    fn check_unclosed_code_blocks(
287        &self,
288        ctx: &crate::lint_context::LintContext,
289    ) -> Result<Vec<LintWarning>, LintError> {
290        let mut warnings = Vec::new();
291        let lines: Vec<&str> = ctx.content.lines().collect();
292        let mut fence_stack: Vec<(String, usize, usize, bool, bool)> = Vec::new(); // (fence_marker, fence_length, opening_line, flagged_for_nested, is_markdown_example)
293
294        // Track if we're inside a markdown code block (for documentation examples)
295        // This is used to allow nested code blocks in markdown documentation
296        let mut inside_markdown_documentation_block = false;
297
298        for (i, line) in lines.iter().enumerate() {
299            let trimmed = line.trim_start();
300
301            // Check for fence markers (``` or ~~~)
302            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
303                let fence_char = if trimmed.starts_with("```") { '`' } else { '~' };
304
305                // Count the fence length
306                let fence_length = trimmed.chars().take_while(|&c| c == fence_char).count();
307
308                // Check what comes after the fence characters
309                let after_fence = &trimmed[fence_length..];
310
311                // CommonMark spec: "If the info string comes after a backtick fence,
312                // it may not contain any backtick characters."
313                // This means ```something``` is NOT a valid fence - the backticks are inline code.
314                if fence_char == '`' && after_fence.contains('`') {
315                    continue;
316                }
317
318                // Check if this is a valid fence pattern
319                // Valid markdown code fence syntax:
320                // - ``` or ~~~ (just fence)
321                // - ``` language or ~~~ language (fence with space then language)
322                // - ```language (without space) is accepted by many parsers but only for actual languages
323                let is_valid_fence_pattern = if after_fence.is_empty() {
324                    // Empty after fence is always valid (e.g., ``` or ~~~)
325                    true
326                } else if after_fence.starts_with(' ') || after_fence.starts_with('\t') {
327                    // Space after fence - anything following is valid as info string
328                    true
329                } else {
330                    // No space after fence - must be a valid language identifier
331                    // Be strict to avoid false positives on content that looks like fences
332                    let identifier = after_fence.trim().to_lowercase();
333
334                    // Reject obvious non-language patterns
335                    if identifier.contains("fence") || identifier.contains("still") {
336                        false
337                    } else if identifier.len() > 20 {
338                        // Most language identifiers are short
339                        false
340                    } else if let Some(first_char) = identifier.chars().next() {
341                        // Must start with letter or # (for C#, F#)
342                        if !first_char.is_alphabetic() && first_char != '#' {
343                            false
344                        } else {
345                            // Check all characters are valid for a language identifier
346                            // Also check it's not just random text
347                            let valid_chars = identifier.chars().all(|c| {
348                                c.is_alphanumeric() || c == '-' || c == '_' || c == '+' || c == '#' || c == '.'
349                            });
350
351                            // Additional check: at least 2 chars and not all consonants (helps filter random words)
352                            valid_chars && identifier.len() >= 2
353                        }
354                    } else {
355                        false
356                    }
357                };
358
359                // When inside a code block, be conservative about what we treat as a fence
360                if !fence_stack.is_empty() {
361                    // Skip if not a valid fence pattern to begin with
362                    if !is_valid_fence_pattern {
363                        continue;
364                    }
365
366                    // Check if this could be a closing fence for the current block
367                    if let Some((open_marker, open_length, _, _, _)) = fence_stack.last() {
368                        if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
369                            // Potential closing fence - check if it has content after
370                            if !after_fence.trim().is_empty() {
371                                // Has content after - likely not a closing fence
372                                // Apply structural validation to determine if it's a nested fence
373
374                                // Skip patterns that are clearly decorative or content
375                                // 1. Contains special characters not typical in language identifiers
376                                let has_special_chars = after_fence.chars().any(|c| {
377                                    !c.is_alphanumeric()
378                                        && c != '-'
379                                        && c != '_'
380                                        && c != '+'
381                                        && c != '#'
382                                        && c != '.'
383                                        && c != ' '
384                                        && c != '\t'
385                                });
386
387                                if has_special_chars {
388                                    continue; // e.g., ~~~!@#$%, ~~~~~~~~^^^^
389                                }
390
391                                // 2. Check for repetitive non-alphanumeric patterns
392                                if fence_length > 4 && after_fence.chars().take(4).all(|c| !c.is_alphanumeric()) {
393                                    continue; // e.g., ~~~~~~~~~~ or ````````
394                                }
395
396                                // 3. If no space after fence, must look like a valid language identifier
397                                if !after_fence.starts_with(' ') && !after_fence.starts_with('\t') {
398                                    let identifier = after_fence.trim();
399
400                                    // Must start with letter or # (for C#, F#)
401                                    if let Some(first) = identifier.chars().next()
402                                        && !first.is_alphabetic()
403                                        && first != '#'
404                                    {
405                                        continue;
406                                    }
407
408                                    // Reasonable length for a language identifier
409                                    if identifier.len() > 30 {
410                                        continue;
411                                    }
412                                }
413                            }
414                            // Otherwise, could be a closing fence - let it through
415                        } else {
416                            // Different fence type or insufficient length
417                            // Only treat as nested if it looks like a real fence with language
418
419                            // Must have proper spacing or no content after fence
420                            if !after_fence.is_empty()
421                                && !after_fence.starts_with(' ')
422                                && !after_fence.starts_with('\t')
423                            {
424                                // No space after fence - be very strict
425                                let identifier = after_fence.trim();
426
427                                // Skip if contains any special characters beyond common ones
428                                if identifier.chars().any(|c| {
429                                    !c.is_alphanumeric() && c != '-' && c != '_' && c != '+' && c != '#' && c != '.'
430                                }) {
431                                    continue;
432                                }
433
434                                // Skip if doesn't start with letter or #
435                                if let Some(first) = identifier.chars().next()
436                                    && !first.is_alphabetic()
437                                    && first != '#'
438                                {
439                                    continue;
440                                }
441                            }
442                        }
443                    }
444                }
445
446                // We'll check if this is a markdown block after determining if it's an opening fence
447
448                // Check if this is a closing fence for the current open fence
449                if let Some((open_marker, open_length, _open_line, _flagged, _is_md)) = fence_stack.last() {
450                    // Must match fence character and have at least as many characters
451                    if fence_char == open_marker.chars().next().unwrap() && fence_length >= *open_length {
452                        // Check if this line has only whitespace after the fence marker
453                        let after_fence = &trimmed[fence_length..];
454                        if after_fence.trim().is_empty() {
455                            // This is a valid closing fence
456                            let _popped = fence_stack.pop();
457
458                            // Check if we're exiting a markdown documentation block
459                            if let Some((_, _, _, _, is_md)) = _popped
460                                && is_md
461                            {
462                                inside_markdown_documentation_block = false;
463                            }
464                            continue;
465                        }
466                    }
467                }
468
469                // This is an opening fence (has content after marker or no matching open fence)
470                // Note: after_fence was already calculated above during validation
471                if !after_fence.trim().is_empty() || fence_stack.is_empty() {
472                    // Only flag as problematic if we're opening a new fence while another is still open
473                    // AND they use the same fence character (indicating potential confusion)
474                    // AND we're not inside a markdown documentation block
475                    let has_nested_issue =
476                        if let Some((open_marker, open_length, open_line, _, _)) = fence_stack.last_mut() {
477                            if fence_char == open_marker.chars().next().unwrap()
478                                && fence_length >= *open_length
479                                && !inside_markdown_documentation_block
480                            {
481                                // This is problematic - same fence character used with equal or greater length while another is open
482                                let (opening_start_line, opening_start_col, opening_end_line, opening_end_col) =
483                                    calculate_line_range(*open_line, lines[*open_line - 1]);
484
485                                // Calculate the byte position to insert closing fence before this line
486                                let line_start_byte = ctx.line_index.get_line_start_byte(i + 1).unwrap_or(0);
487
488                                warnings.push(LintWarning {
489                                    rule_name: Some(self.name().to_string()),
490                                    line: opening_start_line,
491                                    column: opening_start_col,
492                                    end_line: opening_end_line,
493                                    end_column: opening_end_col,
494                                    message: format!(
495                                        "Code block '{}' should be closed before starting new one at line {}",
496                                        open_marker,
497                                        i + 1
498                                    ),
499                                    severity: Severity::Warning,
500                                    fix: Some(Fix {
501                                        range: (line_start_byte..line_start_byte),
502                                        replacement: format!("{open_marker}\n\n"),
503                                    }),
504                                });
505
506                                // Mark the current fence as flagged for nested issue
507                                fence_stack.last_mut().unwrap().3 = true;
508                                true // We flagged a nested issue for this fence
509                            } else {
510                                false
511                            }
512                        } else {
513                            false
514                        };
515
516                    // Check if this opening fence is a markdown code block
517                    let after_fence_for_lang = &trimmed[fence_length..];
518                    let lang_info = after_fence_for_lang.trim().to_lowercase();
519                    let is_markdown_fence = lang_info.starts_with("markdown") || lang_info.starts_with("md");
520
521                    // If we're opening a markdown documentation block, mark that we're inside one
522                    if is_markdown_fence && !inside_markdown_documentation_block {
523                        inside_markdown_documentation_block = true;
524                    }
525
526                    // Add this fence to the stack
527                    let fence_marker = fence_char.to_string().repeat(fence_length);
528                    fence_stack.push((fence_marker, fence_length, i + 1, has_nested_issue, is_markdown_fence));
529                }
530            }
531        }
532
533        // Check for unclosed fences at end of file
534        // Only flag unclosed if we haven't already flagged for nested issues
535        for (fence_marker, _, opening_line, flagged_for_nested, _) in fence_stack {
536            if !flagged_for_nested {
537                let (start_line, start_col, end_line, end_col) =
538                    calculate_line_range(opening_line, lines[opening_line - 1]);
539
540                warnings.push(LintWarning {
541                    rule_name: Some(self.name().to_string()),
542                    line: start_line,
543                    column: start_col,
544                    end_line,
545                    end_column: end_col,
546                    message: format!("Code block opened with '{fence_marker}' but never closed"),
547                    severity: Severity::Warning,
548                    fix: Some(Fix {
549                        range: (ctx.content.len()..ctx.content.len()),
550                        replacement: format!("\n{fence_marker}"),
551                    }),
552                });
553            }
554        }
555
556        Ok(warnings)
557    }
558
559    fn detect_style(&self, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> {
560        // Empty content has no style
561        if content.is_empty() {
562            return None;
563        }
564
565        let lines: Vec<&str> = content.lines().collect();
566        let mut fenced_count = 0;
567        let mut indented_count = 0;
568
569        // Pre-compute list and tab contexts for efficiency
570        let in_list_context = self.precompute_block_continuation_context(&lines);
571        let in_tab_context = if is_mkdocs {
572            self.precompute_mkdocs_tab_context(&lines)
573        } else {
574            vec![false; lines.len()]
575        };
576
577        // Count all code block occurrences (prevalence-based approach)
578        let mut in_fenced = false;
579        let mut prev_was_indented = false;
580
581        for (i, line) in lines.iter().enumerate() {
582            if self.is_fenced_code_block_start(line) {
583                if !in_fenced {
584                    // Opening fence
585                    fenced_count += 1;
586                    in_fenced = true;
587                } else {
588                    // Closing fence
589                    in_fenced = false;
590                }
591            } else if !in_fenced
592                && self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
593            {
594                // Count each continuous indented block once
595                if !prev_was_indented {
596                    indented_count += 1;
597                }
598                prev_was_indented = true;
599            } else {
600                prev_was_indented = false;
601            }
602        }
603
604        if fenced_count == 0 && indented_count == 0 {
605            // No code blocks found
606            None
607        } else if fenced_count > 0 && indented_count == 0 {
608            // Only fenced blocks found
609            Some(CodeBlockStyle::Fenced)
610        } else if fenced_count == 0 && indented_count > 0 {
611            // Only indented blocks found
612            Some(CodeBlockStyle::Indented)
613        } else {
614            // Both types found - use most prevalent
615            // In case of tie, prefer fenced (more common, widely supported)
616            if fenced_count >= indented_count {
617                Some(CodeBlockStyle::Fenced)
618            } else {
619                Some(CodeBlockStyle::Indented)
620            }
621        }
622    }
623}
624
625impl Rule for MD046CodeBlockStyle {
626    fn name(&self) -> &'static str {
627        "MD046"
628    }
629
630    fn description(&self) -> &'static str {
631        "Code blocks should use a consistent style"
632    }
633
634    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
635        // Early return for empty content
636        if ctx.content.is_empty() {
637            return Ok(Vec::new());
638        }
639
640        // Quick check for code blocks before processing
641        if !ctx.content.contains("```") && !ctx.content.contains("~~~") && !ctx.content.contains("    ") {
642            return Ok(Vec::new());
643        }
644
645        // First, always check for unclosed code blocks
646        let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
647
648        // If we found unclosed blocks, return those warnings first
649        if !unclosed_warnings.is_empty() {
650            return Ok(unclosed_warnings);
651        }
652
653        // Check for code block style consistency
654        let lines: Vec<&str> = ctx.content.lines().collect();
655        let mut warnings = Vec::new();
656
657        // Check if we're in MkDocs mode
658        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
659
660        // Pre-compute list and tab contexts once for all checks
661        let in_list_context = self.precompute_block_continuation_context(&lines);
662        let in_tab_context = if is_mkdocs {
663            self.precompute_mkdocs_tab_context(&lines)
664        } else {
665            vec![false; lines.len()]
666        };
667
668        // Determine the target style from the detected style in the document
669        let target_style = match self.config.style {
670            CodeBlockStyle::Consistent => self
671                .detect_style(ctx.content, is_mkdocs)
672                .unwrap_or(CodeBlockStyle::Fenced),
673            _ => self.config.style,
674        };
675
676        // Process each line to find style inconsistencies
677        // Pre-compute which lines are inside FENCED code blocks (not indented)
678        // Use pre-computed code blocks from context
679        let mut in_fenced_block = vec![false; lines.len()];
680        for &(start, end) in &ctx.code_blocks {
681            // Check if this block is fenced by examining its content
682            if start < ctx.content.len() && end <= ctx.content.len() {
683                let block_content = &ctx.content[start..end];
684                let is_fenced = block_content.starts_with("```") || block_content.starts_with("~~~");
685
686                if is_fenced {
687                    // Mark all lines in this fenced block
688                    for (line_idx, line_info) in ctx.lines.iter().enumerate() {
689                        if line_info.byte_offset >= start && line_info.byte_offset < end {
690                            in_fenced_block[line_idx] = true;
691                        }
692                    }
693                }
694            }
695        }
696
697        let mut in_fence = false;
698        for (i, line) in lines.iter().enumerate() {
699            let trimmed = line.trim_start();
700
701            // Skip lines that are in HTML blocks - they shouldn't be treated as indented code
702            if ctx.line_info(i + 1).is_some_and(|info| info.in_html_block) {
703                continue;
704            }
705
706            // Skip if this line is in a mkdocstrings block (but not other skip contexts,
707            // since MD046 needs to detect regular code blocks)
708            if ctx.lines[i].in_mkdocstrings {
709                continue;
710            }
711
712            // Check for fenced code block markers (for style checking)
713            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
714                if target_style == CodeBlockStyle::Indented && !in_fence {
715                    // This is an opening fence marker but we want indented style
716                    // Only flag the opening marker, not the closing one
717                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
718                    warnings.push(LintWarning {
719                        rule_name: Some(self.name().to_string()),
720                        line: start_line,
721                        column: start_col,
722                        end_line,
723                        end_column: end_col,
724                        message: "Use indented code blocks".to_string(),
725                        severity: Severity::Warning,
726                        fix: Some(Fix {
727                            range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
728                            replacement: String::new(),
729                        }),
730                    });
731                }
732                // Toggle fence state
733                in_fence = !in_fence;
734                continue;
735            }
736
737            // Skip content lines inside fenced blocks
738            // This prevents false positives like flagging ~~~~ inside bash output
739            if in_fenced_block[i] {
740                continue;
741            }
742
743            // Check for indented code blocks (when not inside a fenced block)
744            if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
745                && target_style == CodeBlockStyle::Fenced
746            {
747                // Check if this is the start of a new indented block
748                let prev_line_is_indented = i > 0
749                    && self.is_indented_code_block_with_context(
750                        &lines,
751                        i - 1,
752                        is_mkdocs,
753                        &in_list_context,
754                        &in_tab_context,
755                    );
756
757                if !prev_line_is_indented {
758                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
759                    warnings.push(LintWarning {
760                        rule_name: Some(self.name().to_string()),
761                        line: start_line,
762                        column: start_col,
763                        end_line,
764                        end_column: end_col,
765                        message: "Use fenced code blocks".to_string(),
766                        severity: Severity::Warning,
767                        fix: Some(Fix {
768                            range: ctx.line_index.line_col_to_byte_range(i + 1, 1),
769                            replacement: format!("```\n{}", line.trim_start()),
770                        }),
771                    });
772                }
773            }
774        }
775
776        Ok(warnings)
777    }
778
779    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
780        let content = ctx.content;
781        if content.is_empty() {
782            return Ok(String::new());
783        }
784
785        // First check if we have nested fence issues that need special handling
786        let unclosed_warnings = self.check_unclosed_code_blocks(ctx)?;
787
788        // If we have nested fence warnings, apply those fixes first
789        if !unclosed_warnings.is_empty() {
790            // Check if any warnings are about nested fences (not just unclosed blocks)
791            for warning in &unclosed_warnings {
792                if warning
793                    .message
794                    .contains("should be closed before starting new one at line")
795                {
796                    // Apply the nested fence fix
797                    if let Some(fix) = &warning.fix {
798                        let mut result = String::new();
799                        result.push_str(&content[..fix.range.start]);
800                        result.push_str(&fix.replacement);
801                        result.push_str(&content[fix.range.start..]);
802                        return Ok(result);
803                    }
804                }
805            }
806        }
807
808        let lines: Vec<&str> = content.lines().collect();
809
810        // Determine target style
811        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
812        let target_style = match self.config.style {
813            CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
814            _ => self.config.style,
815        };
816
817        // Pre-compute list and tab contexts for efficiency
818        let in_list_context = self.precompute_block_continuation_context(&lines);
819        let in_tab_context = if is_mkdocs {
820            self.precompute_mkdocs_tab_context(&lines)
821        } else {
822            vec![false; lines.len()]
823        };
824
825        let mut result = String::with_capacity(content.len());
826        let mut in_fenced_block = false;
827        let mut fenced_fence_type = None;
828        let mut in_indented_block = false;
829
830        for (i, line) in lines.iter().enumerate() {
831            let trimmed = line.trim_start();
832
833            // Handle fenced code blocks
834            if !in_fenced_block && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
835                in_fenced_block = true;
836                fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
837
838                if target_style == CodeBlockStyle::Indented {
839                    // Skip the opening fence
840                    in_indented_block = true;
841                } else {
842                    // Keep the fenced block
843                    result.push_str(line);
844                    result.push('\n');
845                }
846            } else if in_fenced_block && fenced_fence_type.is_some() {
847                let fence = fenced_fence_type.unwrap();
848                if trimmed.starts_with(fence) {
849                    in_fenced_block = false;
850                    fenced_fence_type = None;
851                    in_indented_block = false;
852
853                    if target_style == CodeBlockStyle::Indented {
854                        // Skip the closing fence
855                    } else {
856                        // Keep the fenced block
857                        result.push_str(line);
858                        result.push('\n');
859                    }
860                } else if target_style == CodeBlockStyle::Indented {
861                    // Convert content inside fenced block to indented
862                    result.push_str("    ");
863                    result.push_str(trimmed);
864                    result.push('\n');
865                } else {
866                    // Keep fenced block content as is
867                    result.push_str(line);
868                    result.push('\n');
869                }
870            } else if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
871            {
872                // This is an indented code block
873
874                // Check if we need to start a new fenced block
875                let prev_line_is_indented = i > 0
876                    && self.is_indented_code_block_with_context(
877                        &lines,
878                        i - 1,
879                        is_mkdocs,
880                        &in_list_context,
881                        &in_tab_context,
882                    );
883
884                if target_style == CodeBlockStyle::Fenced {
885                    if !prev_line_is_indented && !in_indented_block {
886                        // Start of a new indented block that should be fenced
887                        result.push_str("```\n");
888                        result.push_str(line.trim_start());
889                        result.push('\n');
890                        in_indented_block = true;
891                    } else {
892                        // Inside an indented block
893                        result.push_str(line.trim_start());
894                        result.push('\n');
895                    }
896
897                    // Check if this is the end of the indented block
898                    let _next_line_is_indented = i < lines.len() - 1
899                        && self.is_indented_code_block_with_context(
900                            &lines,
901                            i + 1,
902                            is_mkdocs,
903                            &in_list_context,
904                            &in_tab_context,
905                        );
906                    if !_next_line_is_indented && in_indented_block {
907                        result.push_str("```\n");
908                        in_indented_block = false;
909                    }
910                } else {
911                    // Keep indented block as is
912                    result.push_str(line);
913                    result.push('\n');
914                }
915            } else {
916                // Regular line
917                if in_indented_block && target_style == CodeBlockStyle::Fenced {
918                    result.push_str("```\n");
919                    in_indented_block = false;
920                }
921
922                result.push_str(line);
923                result.push('\n');
924            }
925        }
926
927        // Close any remaining blocks
928        if in_indented_block && target_style == CodeBlockStyle::Fenced {
929            result.push_str("```\n");
930        }
931
932        // Close any unclosed fenced blocks
933        if let Some(fence_type) = fenced_fence_type
934            && in_fenced_block
935        {
936            result.push_str(fence_type);
937            result.push('\n');
938        }
939
940        // Remove trailing newline if original didn't have one
941        if !content.ends_with('\n') && result.ends_with('\n') {
942            result.pop();
943        }
944
945        Ok(result)
946    }
947
948    /// Get the category of this rule for selective processing
949    fn category(&self) -> RuleCategory {
950        RuleCategory::CodeBlock
951    }
952
953    /// Check if this rule should be skipped
954    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
955        // Skip if content is empty or unlikely to contain code blocks
956        // Note: indented code blocks use 4 spaces, can't optimize that easily
957        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains("    "))
958    }
959
960    fn as_any(&self) -> &dyn std::any::Any {
961        self
962    }
963
964    fn default_config_section(&self) -> Option<(String, toml::Value)> {
965        let json_value = serde_json::to_value(&self.config).ok()?;
966        Some((
967            self.name().to_string(),
968            crate::rule_config_serde::json_to_toml_value(&json_value)?,
969        ))
970    }
971
972    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
973    where
974        Self: Sized,
975    {
976        let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
977        Box::new(Self::from_config_struct(rule_config))
978    }
979}
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984    use crate::lint_context::LintContext;
985
986    #[test]
987    fn test_fenced_code_block_detection() {
988        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
989        assert!(rule.is_fenced_code_block_start("```"));
990        assert!(rule.is_fenced_code_block_start("```rust"));
991        assert!(rule.is_fenced_code_block_start("~~~"));
992        assert!(rule.is_fenced_code_block_start("~~~python"));
993        assert!(rule.is_fenced_code_block_start("  ```"));
994        assert!(!rule.is_fenced_code_block_start("``"));
995        assert!(!rule.is_fenced_code_block_start("~~"));
996        assert!(!rule.is_fenced_code_block_start("Regular text"));
997    }
998
999    #[test]
1000    fn test_consistent_style_with_fenced_blocks() {
1001        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1002        let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1003        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004        let result = rule.check(&ctx).unwrap();
1005
1006        // All blocks are fenced, so consistent style should be OK
1007        assert_eq!(result.len(), 0);
1008    }
1009
1010    #[test]
1011    fn test_consistent_style_with_indented_blocks() {
1012        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1013        let content = "Text\n\n    code\n    more code\n\nMore text\n\n    another block";
1014        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015        let result = rule.check(&ctx).unwrap();
1016
1017        // All blocks are indented, so consistent style should be OK
1018        assert_eq!(result.len(), 0);
1019    }
1020
1021    #[test]
1022    fn test_consistent_style_mixed() {
1023        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1024        let content = "```\nfenced code\n```\n\nText\n\n    indented code\n\nMore";
1025        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026        let result = rule.check(&ctx).unwrap();
1027
1028        // Mixed styles should be flagged
1029        assert!(!result.is_empty());
1030    }
1031
1032    #[test]
1033    fn test_fenced_style_with_indented_blocks() {
1034        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1035        let content = "Text\n\n    indented code\n    more code\n\nMore text";
1036        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037        let result = rule.check(&ctx).unwrap();
1038
1039        // Indented blocks should be flagged when fenced style is required
1040        assert!(!result.is_empty());
1041        assert!(result[0].message.contains("Use fenced code blocks"));
1042    }
1043
1044    #[test]
1045    fn test_indented_style_with_fenced_blocks() {
1046        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1047        let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1048        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049        let result = rule.check(&ctx).unwrap();
1050
1051        // Fenced blocks should be flagged when indented style is required
1052        assert!(!result.is_empty());
1053        assert!(result[0].message.contains("Use indented code blocks"));
1054    }
1055
1056    #[test]
1057    fn test_unclosed_code_block() {
1058        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1059        let content = "```\ncode without closing fence";
1060        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1061        let result = rule.check(&ctx).unwrap();
1062
1063        assert_eq!(result.len(), 1);
1064        assert!(result[0].message.contains("never closed"));
1065    }
1066
1067    #[test]
1068    fn test_nested_code_blocks() {
1069        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1070        let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1071        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072        let result = rule.check(&ctx).unwrap();
1073
1074        // This should parse as two separate code blocks
1075        assert_eq!(result.len(), 0);
1076    }
1077
1078    #[test]
1079    fn test_fix_indented_to_fenced() {
1080        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1081        let content = "Text\n\n    code line 1\n    code line 2\n\nMore text";
1082        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1083        let fixed = rule.fix(&ctx).unwrap();
1084
1085        assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1086    }
1087
1088    #[test]
1089    fn test_fix_fenced_to_indented() {
1090        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1091        let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1092        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093        let fixed = rule.fix(&ctx).unwrap();
1094
1095        assert!(fixed.contains("    code line 1\n    code line 2"));
1096        assert!(!fixed.contains("```"));
1097    }
1098
1099    #[test]
1100    fn test_fix_unclosed_block() {
1101        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1102        let content = "```\ncode without closing";
1103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104        let fixed = rule.fix(&ctx).unwrap();
1105
1106        // Should add closing fence
1107        assert!(fixed.ends_with("```"));
1108    }
1109
1110    #[test]
1111    fn test_code_block_in_list() {
1112        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1113        let content = "- List item\n    code in list\n    more code\n- Next item";
1114        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1115        let result = rule.check(&ctx).unwrap();
1116
1117        // Code in lists should not be flagged
1118        assert_eq!(result.len(), 0);
1119    }
1120
1121    #[test]
1122    fn test_detect_style_fenced() {
1123        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1124        let content = "```\ncode\n```";
1125        let style = rule.detect_style(content, false);
1126
1127        assert_eq!(style, Some(CodeBlockStyle::Fenced));
1128    }
1129
1130    #[test]
1131    fn test_detect_style_indented() {
1132        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1133        let content = "Text\n\n    code\n\nMore";
1134        let style = rule.detect_style(content, false);
1135
1136        assert_eq!(style, Some(CodeBlockStyle::Indented));
1137    }
1138
1139    #[test]
1140    fn test_detect_style_none() {
1141        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1142        let content = "No code blocks here";
1143        let style = rule.detect_style(content, false);
1144
1145        assert_eq!(style, None);
1146    }
1147
1148    #[test]
1149    fn test_tilde_fence() {
1150        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1151        let content = "~~~\ncode\n~~~";
1152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153        let result = rule.check(&ctx).unwrap();
1154
1155        // Tilde fences should be accepted as fenced blocks
1156        assert_eq!(result.len(), 0);
1157    }
1158
1159    #[test]
1160    fn test_language_specification() {
1161        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1162        let content = "```rust\nfn main() {}\n```";
1163        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1164        let result = rule.check(&ctx).unwrap();
1165
1166        assert_eq!(result.len(), 0);
1167    }
1168
1169    #[test]
1170    fn test_empty_content() {
1171        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1172        let content = "";
1173        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1174        let result = rule.check(&ctx).unwrap();
1175
1176        assert_eq!(result.len(), 0);
1177    }
1178
1179    #[test]
1180    fn test_default_config() {
1181        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1182        let (name, _config) = rule.default_config_section().unwrap();
1183        assert_eq!(name, "MD046");
1184    }
1185
1186    #[test]
1187    fn test_markdown_documentation_block() {
1188        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1189        let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1190        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1191        let result = rule.check(&ctx).unwrap();
1192
1193        // Nested code blocks in markdown documentation should be allowed
1194        assert_eq!(result.len(), 0);
1195    }
1196
1197    #[test]
1198    fn test_preserve_trailing_newline() {
1199        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1200        let content = "```\ncode\n```\n";
1201        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202        let fixed = rule.fix(&ctx).unwrap();
1203
1204        assert_eq!(fixed, content);
1205    }
1206
1207    #[test]
1208    fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1209        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1210        let content = r#"# Document
1211
1212=== "Python"
1213
1214    This is tab content
1215    Not an indented code block
1216
1217    ```python
1218    def hello():
1219        print("Hello")
1220    ```
1221
1222=== "JavaScript"
1223
1224    More tab content here
1225    Also not an indented code block"#;
1226
1227        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1228        let result = rule.check(&ctx).unwrap();
1229
1230        // Should not flag tab content as indented code blocks
1231        assert_eq!(result.len(), 0);
1232    }
1233
1234    #[test]
1235    fn test_mkdocs_tabs_with_actual_indented_code() {
1236        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1237        let content = r#"# Document
1238
1239=== "Tab 1"
1240
1241    This is tab content
1242
1243Regular text
1244
1245    This is an actual indented code block
1246    Should be flagged"#;
1247
1248        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1249        let result = rule.check(&ctx).unwrap();
1250
1251        // Should flag the actual indented code block but not the tab content
1252        assert_eq!(result.len(), 1);
1253        assert!(result[0].message.contains("Use fenced code blocks"));
1254    }
1255
1256    #[test]
1257    fn test_mkdocs_tabs_detect_style() {
1258        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1259        let content = r#"=== "Tab 1"
1260
1261    Content in tab
1262    More content
1263
1264=== "Tab 2"
1265
1266    Content in second tab"#;
1267
1268        // In MkDocs mode, tab content should not be detected as indented code blocks
1269        let style = rule.detect_style(content, true);
1270        assert_eq!(style, None); // No code blocks detected
1271
1272        // In standard mode, it would detect indented code blocks
1273        let style = rule.detect_style(content, false);
1274        assert_eq!(style, Some(CodeBlockStyle::Indented));
1275    }
1276
1277    #[test]
1278    fn test_mkdocs_nested_tabs() {
1279        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1280        let content = r#"# Document
1281
1282=== "Outer Tab"
1283
1284    Some content
1285
1286    === "Nested Tab"
1287
1288        Nested tab content
1289        Should not be flagged"#;
1290
1291        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1292        let result = rule.check(&ctx).unwrap();
1293
1294        // Nested tabs should not be flagged
1295        assert_eq!(result.len(), 0);
1296    }
1297
1298    #[test]
1299    fn test_footnote_indented_paragraphs_not_flagged() {
1300        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1301        let content = r#"# Test Document with Footnotes
1302
1303This is some text with a footnote[^1].
1304
1305Here's some code:
1306
1307```bash
1308echo "fenced code block"
1309```
1310
1311More text with another footnote[^2].
1312
1313[^1]: Really interesting footnote text.
1314
1315    Even more interesting second paragraph.
1316
1317[^2]: Another footnote.
1318
1319    With a second paragraph too.
1320
1321    And even a third paragraph!"#;
1322
1323        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1324        let result = rule.check(&ctx).unwrap();
1325
1326        // Indented paragraphs in footnotes should not be flagged as code blocks
1327        assert_eq!(result.len(), 0);
1328    }
1329
1330    #[test]
1331    fn test_footnote_definition_detection() {
1332        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1333
1334        // Valid footnote definitions (per CommonMark footnote extension spec)
1335        // Reference: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/footnotes.md
1336        assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1337        assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1338        assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1339        assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1340        assert!(rule.is_footnote_definition("    [^1]: Indented footnote"));
1341        assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1342        assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1343        assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1344        assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1345
1346        // Invalid: empty or whitespace-only labels (spec violation)
1347        assert!(!rule.is_footnote_definition("[^]: No label"));
1348        assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1349        assert!(!rule.is_footnote_definition("[^  ]: Multiple spaces"));
1350        assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1351
1352        // Invalid: malformed syntax
1353        assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1354        assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1355        assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1356        assert!(!rule.is_footnote_definition("[^")); // Too short
1357        assert!(!rule.is_footnote_definition("[^1:")); // Missing closing bracket
1358        assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1359
1360        // Invalid: disallowed characters in label
1361        assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1362        assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1363        assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1364        assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1365        assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1366
1367        // Edge case: line breaks not allowed in labels
1368        // (This is a string test, actual multiline would need different testing)
1369        assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1370    }
1371
1372    #[test]
1373    fn test_footnote_with_blank_lines() {
1374        // Spec requirement: blank lines within footnotes don't terminate them
1375        // if next content is indented (matches GitHub's implementation)
1376        // Reference: commonmark-hs footnote extension behavior
1377        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1378        let content = r#"# Document
1379
1380Text with footnote[^1].
1381
1382[^1]: First paragraph.
1383
1384    Second paragraph after blank line.
1385
1386    Third paragraph after another blank line.
1387
1388Regular text at column 0 ends the footnote."#;
1389
1390        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391        let result = rule.check(&ctx).unwrap();
1392
1393        // The indented paragraphs in the footnote should not be flagged as code blocks
1394        assert_eq!(
1395            result.len(),
1396            0,
1397            "Indented content within footnotes should not trigger MD046"
1398        );
1399    }
1400
1401    #[test]
1402    fn test_footnote_multiple_consecutive_blank_lines() {
1403        // Edge case: multiple consecutive blank lines within a footnote
1404        // Should still work if next content is indented
1405        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1406        let content = r#"Text[^1].
1407
1408[^1]: First paragraph.
1409
1410
1411
1412    Content after three blank lines (still part of footnote).
1413
1414Not indented, so footnote ends here."#;
1415
1416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417        let result = rule.check(&ctx).unwrap();
1418
1419        // The indented content should not be flagged
1420        assert_eq!(
1421            result.len(),
1422            0,
1423            "Multiple blank lines shouldn't break footnote continuation"
1424        );
1425    }
1426
1427    #[test]
1428    fn test_footnote_terminated_by_non_indented_content() {
1429        // Spec requirement: non-indented content always terminates the footnote
1430        // Reference: commonmark-hs footnote extension
1431        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1432        let content = r#"[^1]: Footnote content.
1433
1434    More indented content in footnote.
1435
1436This paragraph is not indented, so footnote ends.
1437
1438    This should be flagged as indented code block."#;
1439
1440        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1441        let result = rule.check(&ctx).unwrap();
1442
1443        // The last indented block should be flagged (it's after the footnote ended)
1444        assert_eq!(
1445            result.len(),
1446            1,
1447            "Indented code after footnote termination should be flagged"
1448        );
1449        assert!(
1450            result[0].message.contains("Use fenced code blocks"),
1451            "Expected MD046 warning for indented code block"
1452        );
1453        assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1454    }
1455
1456    #[test]
1457    fn test_footnote_terminated_by_structural_elements() {
1458        // Spec requirement: headings and horizontal rules terminate footnotes
1459        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1460        let content = r#"[^1]: Footnote content.
1461
1462    More content.
1463
1464## Heading terminates footnote
1465
1466    This indented content should be flagged.
1467
1468---
1469
1470    This should also be flagged (after horizontal rule)."#;
1471
1472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473        let result = rule.check(&ctx).unwrap();
1474
1475        // Both indented blocks after structural elements should be flagged
1476        assert_eq!(
1477            result.len(),
1478            2,
1479            "Both indented blocks after termination should be flagged"
1480        );
1481    }
1482
1483    #[test]
1484    fn test_footnote_with_code_block_inside() {
1485        // Spec behavior: footnotes can contain fenced code blocks
1486        // The fenced code must be properly indented within the footnote
1487        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1488        let content = r#"Text[^1].
1489
1490[^1]: Footnote with code:
1491
1492    ```python
1493    def hello():
1494        print("world")
1495    ```
1496
1497    More footnote text after code."#;
1498
1499        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1500        let result = rule.check(&ctx).unwrap();
1501
1502        // Should have no warnings - the fenced code block is valid
1503        assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1504    }
1505
1506    #[test]
1507    fn test_footnote_with_8_space_indented_code() {
1508        // Edge case: code blocks within footnotes need 8 spaces (4 for footnote + 4 for code)
1509        // This should NOT be flagged as it's properly nested indented code
1510        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1511        let content = r#"Text[^1].
1512
1513[^1]: Footnote with nested code.
1514
1515        code block
1516        more code"#;
1517
1518        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519        let result = rule.check(&ctx).unwrap();
1520
1521        // The 8-space indented code is valid within footnote
1522        assert_eq!(
1523            result.len(),
1524            0,
1525            "8-space indented code within footnotes represents nested code blocks"
1526        );
1527    }
1528
1529    #[test]
1530    fn test_multiple_footnotes() {
1531        // Spec behavior: each footnote definition starts a new block context
1532        // Previous footnote ends when new footnote begins
1533        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1534        let content = r#"Text[^1] and more[^2].
1535
1536[^1]: First footnote.
1537
1538    Continuation of first.
1539
1540[^2]: Second footnote starts here, ending the first.
1541
1542    Continuation of second."#;
1543
1544        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545        let result = rule.check(&ctx).unwrap();
1546
1547        // All indented content is part of footnotes
1548        assert_eq!(
1549            result.len(),
1550            0,
1551            "Multiple footnotes should each maintain their continuation context"
1552        );
1553    }
1554
1555    #[test]
1556    fn test_list_item_ends_footnote_context() {
1557        // Spec behavior: list items and footnotes are mutually exclusive contexts
1558        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1559        let content = r#"[^1]: Footnote.
1560
1561    Content in footnote.
1562
1563- List item starts here (ends footnote context).
1564
1565    This indented content is part of the list, not the footnote."#;
1566
1567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568        let result = rule.check(&ctx).unwrap();
1569
1570        // List continuation should not be flagged
1571        assert_eq!(
1572            result.len(),
1573            0,
1574            "List items should end footnote context and start their own"
1575        );
1576    }
1577
1578    #[test]
1579    fn test_footnote_vs_actual_indented_code() {
1580        // Critical test: verify we can still detect actual indented code blocks outside footnotes
1581        // This ensures the fix doesn't cause false negatives
1582        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1583        let content = r#"# Heading
1584
1585Text with footnote[^1].
1586
1587[^1]: Footnote content.
1588
1589    Part of footnote (should not be flagged).
1590
1591Regular paragraph ends footnote context.
1592
1593    This is actual indented code (MUST be flagged)
1594    Should be detected as code block"#;
1595
1596        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597        let result = rule.check(&ctx).unwrap();
1598
1599        // Should flag the indented code after the regular paragraph
1600        assert_eq!(
1601            result.len(),
1602            1,
1603            "Must still detect indented code blocks outside footnotes"
1604        );
1605        assert!(
1606            result[0].message.contains("Use fenced code blocks"),
1607            "Expected MD046 warning for indented code"
1608        );
1609        assert!(
1610            result[0].line >= 11,
1611            "Warning should be on the actual indented code line"
1612        );
1613    }
1614
1615    #[test]
1616    fn test_spec_compliant_label_characters() {
1617        // Spec requirement: labels must contain only alphanumerics, hyphens, underscores
1618        // Reference: commonmark-hs footnote extension
1619        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1620
1621        // Valid according to spec
1622        assert!(rule.is_footnote_definition("[^test]: text"));
1623        assert!(rule.is_footnote_definition("[^TEST]: text"));
1624        assert!(rule.is_footnote_definition("[^test-name]: text"));
1625        assert!(rule.is_footnote_definition("[^test_name]: text"));
1626        assert!(rule.is_footnote_definition("[^test123]: text"));
1627        assert!(rule.is_footnote_definition("[^123]: text"));
1628        assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
1629
1630        // Invalid characters (spec violations)
1631        assert!(!rule.is_footnote_definition("[^test.name]: text")); // Period
1632        assert!(!rule.is_footnote_definition("[^test name]: text")); // Space
1633        assert!(!rule.is_footnote_definition("[^test@name]: text")); // At sign
1634        assert!(!rule.is_footnote_definition("[^test#name]: text")); // Hash
1635        assert!(!rule.is_footnote_definition("[^test$name]: text")); // Dollar
1636        assert!(!rule.is_footnote_definition("[^test%name]: text")); // Percent
1637    }
1638}