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