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_count = 0;
561        let mut indented_count = 0;
562
563        // Pre-compute list and tab contexts for efficiency
564        let in_list_context = self.precompute_block_continuation_context(&lines);
565        let in_tab_context = if is_mkdocs {
566            self.precompute_mkdocs_tab_context(&lines)
567        } else {
568            vec![false; lines.len()]
569        };
570
571        // Count all code block occurrences (prevalence-based approach)
572        let mut in_fenced = false;
573        let mut prev_was_indented = false;
574
575        for (i, line) in lines.iter().enumerate() {
576            if self.is_fenced_code_block_start(line) {
577                if !in_fenced {
578                    // Opening fence
579                    fenced_count += 1;
580                    in_fenced = true;
581                } else {
582                    // Closing fence
583                    in_fenced = false;
584                }
585            } else if !in_fenced
586                && self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
587            {
588                // Count each continuous indented block once
589                if !prev_was_indented {
590                    indented_count += 1;
591                }
592                prev_was_indented = true;
593            } else {
594                prev_was_indented = false;
595            }
596        }
597
598        if fenced_count == 0 && indented_count == 0 {
599            // No code blocks found
600            None
601        } else if fenced_count > 0 && indented_count == 0 {
602            // Only fenced blocks found
603            Some(CodeBlockStyle::Fenced)
604        } else if fenced_count == 0 && indented_count > 0 {
605            // Only indented blocks found
606            Some(CodeBlockStyle::Indented)
607        } else {
608            // Both types found - use most prevalent
609            // In case of tie, prefer fenced (more common, widely supported)
610            if fenced_count >= indented_count {
611                Some(CodeBlockStyle::Fenced)
612            } else {
613                Some(CodeBlockStyle::Indented)
614            }
615        }
616    }
617}
618
619impl Rule for MD046CodeBlockStyle {
620    fn name(&self) -> &'static str {
621        "MD046"
622    }
623
624    fn description(&self) -> &'static str {
625        "Code blocks should use a consistent style"
626    }
627
628    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
629        // Early return for empty content
630        if ctx.content.is_empty() {
631            return Ok(Vec::new());
632        }
633
634        // Quick check for code blocks before processing
635        if !ctx.content.contains("```") && !ctx.content.contains("~~~") && !ctx.content.contains("    ") {
636            return Ok(Vec::new());
637        }
638
639        // First, always check for unclosed code blocks
640        let line_index = LineIndex::new(ctx.content.to_string());
641        let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
642
643        // If we found unclosed blocks, return those warnings first
644        if !unclosed_warnings.is_empty() {
645            return Ok(unclosed_warnings);
646        }
647
648        // Check for code block style consistency
649        let lines: Vec<&str> = ctx.content.lines().collect();
650        let mut warnings = Vec::new();
651
652        // Check if we're in MkDocs mode
653        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
654
655        // Pre-compute list and tab contexts once for all checks
656        let in_list_context = self.precompute_block_continuation_context(&lines);
657        let in_tab_context = if is_mkdocs {
658            self.precompute_mkdocs_tab_context(&lines)
659        } else {
660            vec![false; lines.len()]
661        };
662
663        // Determine the target style from the detected style in the document
664        let target_style = match self.config.style {
665            CodeBlockStyle::Consistent => self
666                .detect_style(ctx.content, is_mkdocs)
667                .unwrap_or(CodeBlockStyle::Fenced),
668            _ => self.config.style,
669        };
670
671        // Process each line to find style inconsistencies
672        let line_index = LineIndex::new(ctx.content.to_string());
673
674        // Pre-compute which lines are inside FENCED code blocks (not indented)
675        // Use pre-computed code blocks from context
676        let mut in_fenced_block = vec![false; lines.len()];
677        for &(start, end) in &ctx.code_blocks {
678            // Check if this block is fenced by examining its content
679            if start < ctx.content.len() && end <= ctx.content.len() {
680                let block_content = &ctx.content[start..end];
681                let is_fenced = block_content.starts_with("```") || block_content.starts_with("~~~");
682
683                if is_fenced {
684                    // Mark all lines in this fenced block
685                    for (line_idx, line_info) in ctx.lines.iter().enumerate() {
686                        if line_info.byte_offset >= start && line_info.byte_offset < end {
687                            in_fenced_block[line_idx] = true;
688                        }
689                    }
690                }
691            }
692        }
693
694        let mut in_fence = false;
695        for (i, line) in lines.iter().enumerate() {
696            let trimmed = line.trim_start();
697
698            // Skip lines that are in HTML blocks - they shouldn't be treated as indented code
699            if ctx.line_info(i + 1).is_some_and(|info| info.in_html_block) {
700                continue;
701            }
702
703            // Skip if this line is in a mkdocstrings block (but not other skip contexts,
704            // since MD046 needs to detect regular code blocks)
705            if ctx.lines[i].in_mkdocstrings {
706                continue;
707            }
708
709            // Check for fenced code block markers (for style checking)
710            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
711                if target_style == CodeBlockStyle::Indented && !in_fence {
712                    // This is an opening fence marker but we want indented style
713                    // Only flag the opening marker, not the closing one
714                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
715                    warnings.push(LintWarning {
716                        rule_name: Some(self.name().to_string()),
717                        line: start_line,
718                        column: start_col,
719                        end_line,
720                        end_column: end_col,
721                        message: "Use indented code blocks".to_string(),
722                        severity: Severity::Warning,
723                        fix: Some(Fix {
724                            range: line_index.line_col_to_byte_range(i + 1, 1),
725                            replacement: String::new(),
726                        }),
727                    });
728                }
729                // Toggle fence state
730                in_fence = !in_fence;
731                continue;
732            }
733
734            // Skip content lines inside fenced blocks
735            // This prevents false positives like flagging ~~~~ inside bash output
736            if in_fenced_block[i] {
737                continue;
738            }
739
740            // Check for indented code blocks (when not inside a fenced block)
741            if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
742                && target_style == CodeBlockStyle::Fenced
743            {
744                // Check if this is the start of a new indented block
745                let prev_line_is_indented = i > 0
746                    && self.is_indented_code_block_with_context(
747                        &lines,
748                        i - 1,
749                        is_mkdocs,
750                        &in_list_context,
751                        &in_tab_context,
752                    );
753
754                if !prev_line_is_indented {
755                    let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
756                    warnings.push(LintWarning {
757                        rule_name: Some(self.name().to_string()),
758                        line: start_line,
759                        column: start_col,
760                        end_line,
761                        end_column: end_col,
762                        message: "Use fenced code blocks".to_string(),
763                        severity: Severity::Warning,
764                        fix: Some(Fix {
765                            range: line_index.line_col_to_byte_range(i + 1, 1),
766                            replacement: format!("```\n{}", line.trim_start()),
767                        }),
768                    });
769                }
770            }
771        }
772
773        Ok(warnings)
774    }
775
776    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
777        let content = ctx.content;
778        if content.is_empty() {
779            return Ok(String::new());
780        }
781
782        // First check if we have nested fence issues that need special handling
783        let line_index = LineIndex::new(ctx.content.to_string());
784        let unclosed_warnings = self.check_unclosed_code_blocks(ctx, &line_index)?;
785
786        // If we have nested fence warnings, apply those fixes first
787        if !unclosed_warnings.is_empty() {
788            // Check if any warnings are about nested fences (not just unclosed blocks)
789            for warning in &unclosed_warnings {
790                if warning
791                    .message
792                    .contains("should be closed before starting new one at line")
793                {
794                    // Apply the nested fence fix
795                    if let Some(fix) = &warning.fix {
796                        let mut result = String::new();
797                        result.push_str(&content[..fix.range.start]);
798                        result.push_str(&fix.replacement);
799                        result.push_str(&content[fix.range.start..]);
800                        return Ok(result);
801                    }
802                }
803            }
804        }
805
806        let lines: Vec<&str> = content.lines().collect();
807
808        // Determine target style
809        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
810        let target_style = match self.config.style {
811            CodeBlockStyle::Consistent => self.detect_style(content, is_mkdocs).unwrap_or(CodeBlockStyle::Fenced),
812            _ => self.config.style,
813        };
814
815        // Pre-compute list and tab contexts for efficiency
816        let in_list_context = self.precompute_block_continuation_context(&lines);
817        let in_tab_context = if is_mkdocs {
818            self.precompute_mkdocs_tab_context(&lines)
819        } else {
820            vec![false; lines.len()]
821        };
822
823        let mut result = String::with_capacity(content.len());
824        let mut in_fenced_block = false;
825        let mut fenced_fence_type = None;
826        let mut in_indented_block = false;
827
828        for (i, line) in lines.iter().enumerate() {
829            let trimmed = line.trim_start();
830
831            // Handle fenced code blocks
832            if !in_fenced_block && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
833                in_fenced_block = true;
834                fenced_fence_type = Some(if trimmed.starts_with("```") { "```" } else { "~~~" });
835
836                if target_style == CodeBlockStyle::Indented {
837                    // Skip the opening fence
838                    in_indented_block = true;
839                } else {
840                    // Keep the fenced block
841                    result.push_str(line);
842                    result.push('\n');
843                }
844            } else if in_fenced_block && fenced_fence_type.is_some() {
845                let fence = fenced_fence_type.unwrap();
846                if trimmed.starts_with(fence) {
847                    in_fenced_block = false;
848                    fenced_fence_type = None;
849                    in_indented_block = false;
850
851                    if target_style == CodeBlockStyle::Indented {
852                        // Skip the closing fence
853                    } else {
854                        // Keep the fenced block
855                        result.push_str(line);
856                        result.push('\n');
857                    }
858                } else if target_style == CodeBlockStyle::Indented {
859                    // Convert content inside fenced block to indented
860                    result.push_str("    ");
861                    result.push_str(trimmed);
862                    result.push('\n');
863                } else {
864                    // Keep fenced block content as is
865                    result.push_str(line);
866                    result.push('\n');
867                }
868            } else if self.is_indented_code_block_with_context(&lines, i, is_mkdocs, &in_list_context, &in_tab_context)
869            {
870                // This is an indented code block
871
872                // Check if we need to start a new fenced block
873                let prev_line_is_indented = i > 0
874                    && self.is_indented_code_block_with_context(
875                        &lines,
876                        i - 1,
877                        is_mkdocs,
878                        &in_list_context,
879                        &in_tab_context,
880                    );
881
882                if target_style == CodeBlockStyle::Fenced {
883                    if !prev_line_is_indented && !in_indented_block {
884                        // Start of a new indented block that should be fenced
885                        result.push_str("```\n");
886                        result.push_str(line.trim_start());
887                        result.push('\n');
888                        in_indented_block = true;
889                    } else {
890                        // Inside an indented block
891                        result.push_str(line.trim_start());
892                        result.push('\n');
893                    }
894
895                    // Check if this is the end of the indented block
896                    let _next_line_is_indented = i < lines.len() - 1
897                        && self.is_indented_code_block_with_context(
898                            &lines,
899                            i + 1,
900                            is_mkdocs,
901                            &in_list_context,
902                            &in_tab_context,
903                        );
904                    if !_next_line_is_indented && in_indented_block {
905                        result.push_str("```\n");
906                        in_indented_block = false;
907                    }
908                } else {
909                    // Keep indented block as is
910                    result.push_str(line);
911                    result.push('\n');
912                }
913            } else {
914                // Regular line
915                if in_indented_block && target_style == CodeBlockStyle::Fenced {
916                    result.push_str("```\n");
917                    in_indented_block = false;
918                }
919
920                result.push_str(line);
921                result.push('\n');
922            }
923        }
924
925        // Close any remaining blocks
926        if in_indented_block && target_style == CodeBlockStyle::Fenced {
927            result.push_str("```\n");
928        }
929
930        // Close any unclosed fenced blocks
931        if let Some(fence_type) = fenced_fence_type
932            && in_fenced_block
933        {
934            result.push_str(fence_type);
935            result.push('\n');
936        }
937
938        // Remove trailing newline if original didn't have one
939        if !content.ends_with('\n') && result.ends_with('\n') {
940            result.pop();
941        }
942
943        Ok(result)
944    }
945
946    /// Get the category of this rule for selective processing
947    fn category(&self) -> RuleCategory {
948        RuleCategory::CodeBlock
949    }
950
951    /// Check if this rule should be skipped
952    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
953        // Skip if content is empty or unlikely to contain code blocks
954        // Note: indented code blocks use 4 spaces, can't optimize that easily
955        ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~') && !ctx.content.contains("    "))
956    }
957
958    fn as_any(&self) -> &dyn std::any::Any {
959        self
960    }
961
962    fn default_config_section(&self) -> Option<(String, toml::Value)> {
963        let json_value = serde_json::to_value(&self.config).ok()?;
964        Some((
965            self.name().to_string(),
966            crate::rule_config_serde::json_to_toml_value(&json_value)?,
967        ))
968    }
969
970    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
971    where
972        Self: Sized,
973    {
974        let rule_config = crate::rule_config_serde::load_rule_config::<MD046Config>(config);
975        Box::new(Self::from_config_struct(rule_config))
976    }
977}
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982    use crate::lint_context::LintContext;
983
984    #[test]
985    fn test_fenced_code_block_detection() {
986        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
987        assert!(rule.is_fenced_code_block_start("```"));
988        assert!(rule.is_fenced_code_block_start("```rust"));
989        assert!(rule.is_fenced_code_block_start("~~~"));
990        assert!(rule.is_fenced_code_block_start("~~~python"));
991        assert!(rule.is_fenced_code_block_start("  ```"));
992        assert!(!rule.is_fenced_code_block_start("``"));
993        assert!(!rule.is_fenced_code_block_start("~~"));
994        assert!(!rule.is_fenced_code_block_start("Regular text"));
995    }
996
997    #[test]
998    fn test_consistent_style_with_fenced_blocks() {
999        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1000        let content = "```\ncode\n```\n\nMore text\n\n```\nmore code\n```";
1001        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1002        let result = rule.check(&ctx).unwrap();
1003
1004        // All blocks are fenced, so consistent style should be OK
1005        assert_eq!(result.len(), 0);
1006    }
1007
1008    #[test]
1009    fn test_consistent_style_with_indented_blocks() {
1010        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1011        let content = "Text\n\n    code\n    more code\n\nMore text\n\n    another block";
1012        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1013        let result = rule.check(&ctx).unwrap();
1014
1015        // All blocks are indented, so consistent style should be OK
1016        assert_eq!(result.len(), 0);
1017    }
1018
1019    #[test]
1020    fn test_consistent_style_mixed() {
1021        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1022        let content = "```\nfenced code\n```\n\nText\n\n    indented code\n\nMore";
1023        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1024        let result = rule.check(&ctx).unwrap();
1025
1026        // Mixed styles should be flagged
1027        assert!(!result.is_empty());
1028    }
1029
1030    #[test]
1031    fn test_fenced_style_with_indented_blocks() {
1032        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1033        let content = "Text\n\n    indented code\n    more code\n\nMore text";
1034        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1035        let result = rule.check(&ctx).unwrap();
1036
1037        // Indented blocks should be flagged when fenced style is required
1038        assert!(!result.is_empty());
1039        assert!(result[0].message.contains("Use fenced code blocks"));
1040    }
1041
1042    #[test]
1043    fn test_indented_style_with_fenced_blocks() {
1044        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1045        let content = "Text\n\n```\nfenced code\n```\n\nMore text";
1046        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1047        let result = rule.check(&ctx).unwrap();
1048
1049        // Fenced blocks should be flagged when indented style is required
1050        assert!(!result.is_empty());
1051        assert!(result[0].message.contains("Use indented code blocks"));
1052    }
1053
1054    #[test]
1055    fn test_unclosed_code_block() {
1056        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1057        let content = "```\ncode without closing fence";
1058        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1059        let result = rule.check(&ctx).unwrap();
1060
1061        assert_eq!(result.len(), 1);
1062        assert!(result[0].message.contains("never closed"));
1063    }
1064
1065    #[test]
1066    fn test_nested_code_blocks() {
1067        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1068        let content = "```\nouter\n```\n\ninner text\n\n```\ncode\n```";
1069        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1070        let result = rule.check(&ctx).unwrap();
1071
1072        // This should parse as two separate code blocks
1073        assert_eq!(result.len(), 0);
1074    }
1075
1076    #[test]
1077    fn test_fix_indented_to_fenced() {
1078        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1079        let content = "Text\n\n    code line 1\n    code line 2\n\nMore text";
1080        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1081        let fixed = rule.fix(&ctx).unwrap();
1082
1083        assert!(fixed.contains("```\ncode line 1\ncode line 2\n```"));
1084    }
1085
1086    #[test]
1087    fn test_fix_fenced_to_indented() {
1088        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Indented);
1089        let content = "Text\n\n```\ncode line 1\ncode line 2\n```\n\nMore text";
1090        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1091        let fixed = rule.fix(&ctx).unwrap();
1092
1093        assert!(fixed.contains("    code line 1\n    code line 2"));
1094        assert!(!fixed.contains("```"));
1095    }
1096
1097    #[test]
1098    fn test_fix_unclosed_block() {
1099        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1100        let content = "```\ncode without closing";
1101        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1102        let fixed = rule.fix(&ctx).unwrap();
1103
1104        // Should add closing fence
1105        assert!(fixed.ends_with("```"));
1106    }
1107
1108    #[test]
1109    fn test_code_block_in_list() {
1110        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1111        let content = "- List item\n    code in list\n    more code\n- Next item";
1112        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1113        let result = rule.check(&ctx).unwrap();
1114
1115        // Code in lists should not be flagged
1116        assert_eq!(result.len(), 0);
1117    }
1118
1119    #[test]
1120    fn test_detect_style_fenced() {
1121        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1122        let content = "```\ncode\n```";
1123        let style = rule.detect_style(content, false);
1124
1125        assert_eq!(style, Some(CodeBlockStyle::Fenced));
1126    }
1127
1128    #[test]
1129    fn test_detect_style_indented() {
1130        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1131        let content = "Text\n\n    code\n\nMore";
1132        let style = rule.detect_style(content, false);
1133
1134        assert_eq!(style, Some(CodeBlockStyle::Indented));
1135    }
1136
1137    #[test]
1138    fn test_detect_style_none() {
1139        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1140        let content = "No code blocks here";
1141        let style = rule.detect_style(content, false);
1142
1143        assert_eq!(style, None);
1144    }
1145
1146    #[test]
1147    fn test_tilde_fence() {
1148        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1149        let content = "~~~\ncode\n~~~";
1150        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1151        let result = rule.check(&ctx).unwrap();
1152
1153        // Tilde fences should be accepted as fenced blocks
1154        assert_eq!(result.len(), 0);
1155    }
1156
1157    #[test]
1158    fn test_language_specification() {
1159        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1160        let content = "```rust\nfn main() {}\n```";
1161        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1162        let result = rule.check(&ctx).unwrap();
1163
1164        assert_eq!(result.len(), 0);
1165    }
1166
1167    #[test]
1168    fn test_empty_content() {
1169        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1170        let content = "";
1171        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1172        let result = rule.check(&ctx).unwrap();
1173
1174        assert_eq!(result.len(), 0);
1175    }
1176
1177    #[test]
1178    fn test_default_config() {
1179        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1180        let (name, _config) = rule.default_config_section().unwrap();
1181        assert_eq!(name, "MD046");
1182    }
1183
1184    #[test]
1185    fn test_markdown_documentation_block() {
1186        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1187        let content = "```markdown\n# Example\n\n```\ncode\n```\n\nText\n```";
1188        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1189        let result = rule.check(&ctx).unwrap();
1190
1191        // Nested code blocks in markdown documentation should be allowed
1192        assert_eq!(result.len(), 0);
1193    }
1194
1195    #[test]
1196    fn test_preserve_trailing_newline() {
1197        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1198        let content = "```\ncode\n```\n";
1199        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1200        let fixed = rule.fix(&ctx).unwrap();
1201
1202        assert_eq!(fixed, content);
1203    }
1204
1205    #[test]
1206    fn test_mkdocs_tabs_not_flagged_as_indented_code() {
1207        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1208        let content = r#"# Document
1209
1210=== "Python"
1211
1212    This is tab content
1213    Not an indented code block
1214
1215    ```python
1216    def hello():
1217        print("Hello")
1218    ```
1219
1220=== "JavaScript"
1221
1222    More tab content here
1223    Also not an indented code block"#;
1224
1225        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1226        let result = rule.check(&ctx).unwrap();
1227
1228        // Should not flag tab content as indented code blocks
1229        assert_eq!(result.len(), 0);
1230    }
1231
1232    #[test]
1233    fn test_mkdocs_tabs_with_actual_indented_code() {
1234        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1235        let content = r#"# Document
1236
1237=== "Tab 1"
1238
1239    This is tab content
1240
1241Regular text
1242
1243    This is an actual indented code block
1244    Should be flagged"#;
1245
1246        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1247        let result = rule.check(&ctx).unwrap();
1248
1249        // Should flag the actual indented code block but not the tab content
1250        assert_eq!(result.len(), 1);
1251        assert!(result[0].message.contains("Use fenced code blocks"));
1252    }
1253
1254    #[test]
1255    fn test_mkdocs_tabs_detect_style() {
1256        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent);
1257        let content = r#"=== "Tab 1"
1258
1259    Content in tab
1260    More content
1261
1262=== "Tab 2"
1263
1264    Content in second tab"#;
1265
1266        // In MkDocs mode, tab content should not be detected as indented code blocks
1267        let style = rule.detect_style(content, true);
1268        assert_eq!(style, None); // No code blocks detected
1269
1270        // In standard mode, it would detect indented code blocks
1271        let style = rule.detect_style(content, false);
1272        assert_eq!(style, Some(CodeBlockStyle::Indented));
1273    }
1274
1275    #[test]
1276    fn test_mkdocs_nested_tabs() {
1277        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1278        let content = r#"# Document
1279
1280=== "Outer Tab"
1281
1282    Some content
1283
1284    === "Nested Tab"
1285
1286        Nested tab content
1287        Should not be flagged"#;
1288
1289        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1290        let result = rule.check(&ctx).unwrap();
1291
1292        // Nested tabs should not be flagged
1293        assert_eq!(result.len(), 0);
1294    }
1295
1296    #[test]
1297    fn test_footnote_indented_paragraphs_not_flagged() {
1298        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1299        let content = r#"# Test Document with Footnotes
1300
1301This is some text with a footnote[^1].
1302
1303Here's some code:
1304
1305```bash
1306echo "fenced code block"
1307```
1308
1309More text with another footnote[^2].
1310
1311[^1]: Really interesting footnote text.
1312
1313    Even more interesting second paragraph.
1314
1315[^2]: Another footnote.
1316
1317    With a second paragraph too.
1318
1319    And even a third paragraph!"#;
1320
1321        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1322        let result = rule.check(&ctx).unwrap();
1323
1324        // Indented paragraphs in footnotes should not be flagged as code blocks
1325        assert_eq!(result.len(), 0);
1326    }
1327
1328    #[test]
1329    fn test_footnote_definition_detection() {
1330        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1331
1332        // Valid footnote definitions (per CommonMark footnote extension spec)
1333        // Reference: https://github.com/jgm/commonmark-hs/blob/master/commonmark-extensions/test/footnotes.md
1334        assert!(rule.is_footnote_definition("[^1]: Footnote text"));
1335        assert!(rule.is_footnote_definition("[^foo]: Footnote text"));
1336        assert!(rule.is_footnote_definition("[^long-name]: Footnote text"));
1337        assert!(rule.is_footnote_definition("[^test_123]: Mixed chars"));
1338        assert!(rule.is_footnote_definition("    [^1]: Indented footnote"));
1339        assert!(rule.is_footnote_definition("[^a]: Minimal valid footnote"));
1340        assert!(rule.is_footnote_definition("[^123]: Numeric label"));
1341        assert!(rule.is_footnote_definition("[^_]: Single underscore"));
1342        assert!(rule.is_footnote_definition("[^-]: Single hyphen"));
1343
1344        // Invalid: empty or whitespace-only labels (spec violation)
1345        assert!(!rule.is_footnote_definition("[^]: No label"));
1346        assert!(!rule.is_footnote_definition("[^ ]: Whitespace only"));
1347        assert!(!rule.is_footnote_definition("[^  ]: Multiple spaces"));
1348        assert!(!rule.is_footnote_definition("[^\t]: Tab only"));
1349
1350        // Invalid: malformed syntax
1351        assert!(!rule.is_footnote_definition("[^]]: Extra bracket"));
1352        assert!(!rule.is_footnote_definition("Regular text [^1]:"));
1353        assert!(!rule.is_footnote_definition("[1]: Not a footnote"));
1354        assert!(!rule.is_footnote_definition("[^")); // Too short
1355        assert!(!rule.is_footnote_definition("[^1:")); // Missing closing bracket
1356        assert!(!rule.is_footnote_definition("^1]: Missing opening bracket"));
1357
1358        // Invalid: disallowed characters in label
1359        assert!(!rule.is_footnote_definition("[^test.name]: Period"));
1360        assert!(!rule.is_footnote_definition("[^test name]: Space in label"));
1361        assert!(!rule.is_footnote_definition("[^test@name]: Special char"));
1362        assert!(!rule.is_footnote_definition("[^test/name]: Slash"));
1363        assert!(!rule.is_footnote_definition("[^test\\name]: Backslash"));
1364
1365        // Edge case: line breaks not allowed in labels
1366        // (This is a string test, actual multiline would need different testing)
1367        assert!(!rule.is_footnote_definition("[^test\r]: Carriage return"));
1368    }
1369
1370    #[test]
1371    fn test_footnote_with_blank_lines() {
1372        // Spec requirement: blank lines within footnotes don't terminate them
1373        // if next content is indented (matches GitHub's implementation)
1374        // Reference: commonmark-hs footnote extension behavior
1375        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1376        let content = r#"# Document
1377
1378Text with footnote[^1].
1379
1380[^1]: First paragraph.
1381
1382    Second paragraph after blank line.
1383
1384    Third paragraph after another blank line.
1385
1386Regular text at column 0 ends the footnote."#;
1387
1388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1389        let result = rule.check(&ctx).unwrap();
1390
1391        // The indented paragraphs in the footnote should not be flagged as code blocks
1392        assert_eq!(
1393            result.len(),
1394            0,
1395            "Indented content within footnotes should not trigger MD046"
1396        );
1397    }
1398
1399    #[test]
1400    fn test_footnote_multiple_consecutive_blank_lines() {
1401        // Edge case: multiple consecutive blank lines within a footnote
1402        // Should still work if next content is indented
1403        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1404        let content = r#"Text[^1].
1405
1406[^1]: First paragraph.
1407
1408
1409
1410    Content after three blank lines (still part of footnote).
1411
1412Not indented, so footnote ends here."#;
1413
1414        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1415        let result = rule.check(&ctx).unwrap();
1416
1417        // The indented content should not be flagged
1418        assert_eq!(
1419            result.len(),
1420            0,
1421            "Multiple blank lines shouldn't break footnote continuation"
1422        );
1423    }
1424
1425    #[test]
1426    fn test_footnote_terminated_by_non_indented_content() {
1427        // Spec requirement: non-indented content always terminates the footnote
1428        // Reference: commonmark-hs footnote extension
1429        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1430        let content = r#"[^1]: Footnote content.
1431
1432    More indented content in footnote.
1433
1434This paragraph is not indented, so footnote ends.
1435
1436    This should be flagged as indented code block."#;
1437
1438        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1439        let result = rule.check(&ctx).unwrap();
1440
1441        // The last indented block should be flagged (it's after the footnote ended)
1442        assert_eq!(
1443            result.len(),
1444            1,
1445            "Indented code after footnote termination should be flagged"
1446        );
1447        assert!(
1448            result[0].message.contains("Use fenced code blocks"),
1449            "Expected MD046 warning for indented code block"
1450        );
1451        assert!(result[0].line >= 7, "Warning should be on the indented code block line");
1452    }
1453
1454    #[test]
1455    fn test_footnote_terminated_by_structural_elements() {
1456        // Spec requirement: headings and horizontal rules terminate footnotes
1457        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1458        let content = r#"[^1]: Footnote content.
1459
1460    More content.
1461
1462## Heading terminates footnote
1463
1464    This indented content should be flagged.
1465
1466---
1467
1468    This should also be flagged (after horizontal rule)."#;
1469
1470        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1471        let result = rule.check(&ctx).unwrap();
1472
1473        // Both indented blocks after structural elements should be flagged
1474        assert_eq!(
1475            result.len(),
1476            2,
1477            "Both indented blocks after termination should be flagged"
1478        );
1479    }
1480
1481    #[test]
1482    fn test_footnote_with_code_block_inside() {
1483        // Spec behavior: footnotes can contain fenced code blocks
1484        // The fenced code must be properly indented within the footnote
1485        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1486        let content = r#"Text[^1].
1487
1488[^1]: Footnote with code:
1489
1490    ```python
1491    def hello():
1492        print("world")
1493    ```
1494
1495    More footnote text after code."#;
1496
1497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1498        let result = rule.check(&ctx).unwrap();
1499
1500        // Should have no warnings - the fenced code block is valid
1501        assert_eq!(result.len(), 0, "Fenced code blocks within footnotes should be allowed");
1502    }
1503
1504    #[test]
1505    fn test_footnote_with_8_space_indented_code() {
1506        // Edge case: code blocks within footnotes need 8 spaces (4 for footnote + 4 for code)
1507        // This should NOT be flagged as it's properly nested indented code
1508        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1509        let content = r#"Text[^1].
1510
1511[^1]: Footnote with nested code.
1512
1513        code block
1514        more code"#;
1515
1516        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1517        let result = rule.check(&ctx).unwrap();
1518
1519        // The 8-space indented code is valid within footnote
1520        assert_eq!(
1521            result.len(),
1522            0,
1523            "8-space indented code within footnotes represents nested code blocks"
1524        );
1525    }
1526
1527    #[test]
1528    fn test_multiple_footnotes() {
1529        // Spec behavior: each footnote definition starts a new block context
1530        // Previous footnote ends when new footnote begins
1531        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1532        let content = r#"Text[^1] and more[^2].
1533
1534[^1]: First footnote.
1535
1536    Continuation of first.
1537
1538[^2]: Second footnote starts here, ending the first.
1539
1540    Continuation of second."#;
1541
1542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1543        let result = rule.check(&ctx).unwrap();
1544
1545        // All indented content is part of footnotes
1546        assert_eq!(
1547            result.len(),
1548            0,
1549            "Multiple footnotes should each maintain their continuation context"
1550        );
1551    }
1552
1553    #[test]
1554    fn test_list_item_ends_footnote_context() {
1555        // Spec behavior: list items and footnotes are mutually exclusive contexts
1556        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1557        let content = r#"[^1]: Footnote.
1558
1559    Content in footnote.
1560
1561- List item starts here (ends footnote context).
1562
1563    This indented content is part of the list, not the footnote."#;
1564
1565        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1566        let result = rule.check(&ctx).unwrap();
1567
1568        // List continuation should not be flagged
1569        assert_eq!(
1570            result.len(),
1571            0,
1572            "List items should end footnote context and start their own"
1573        );
1574    }
1575
1576    #[test]
1577    fn test_footnote_vs_actual_indented_code() {
1578        // Critical test: verify we can still detect actual indented code blocks outside footnotes
1579        // This ensures the fix doesn't cause false negatives
1580        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1581        let content = r#"# Heading
1582
1583Text with footnote[^1].
1584
1585[^1]: Footnote content.
1586
1587    Part of footnote (should not be flagged).
1588
1589Regular paragraph ends footnote context.
1590
1591    This is actual indented code (MUST be flagged)
1592    Should be detected as code block"#;
1593
1594        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1595        let result = rule.check(&ctx).unwrap();
1596
1597        // Should flag the indented code after the regular paragraph
1598        assert_eq!(
1599            result.len(),
1600            1,
1601            "Must still detect indented code blocks outside footnotes"
1602        );
1603        assert!(
1604            result[0].message.contains("Use fenced code blocks"),
1605            "Expected MD046 warning for indented code"
1606        );
1607        assert!(
1608            result[0].line >= 11,
1609            "Warning should be on the actual indented code line"
1610        );
1611    }
1612
1613    #[test]
1614    fn test_spec_compliant_label_characters() {
1615        // Spec requirement: labels must contain only alphanumerics, hyphens, underscores
1616        // Reference: commonmark-hs footnote extension
1617        let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
1618
1619        // Valid according to spec
1620        assert!(rule.is_footnote_definition("[^test]: text"));
1621        assert!(rule.is_footnote_definition("[^TEST]: text"));
1622        assert!(rule.is_footnote_definition("[^test-name]: text"));
1623        assert!(rule.is_footnote_definition("[^test_name]: text"));
1624        assert!(rule.is_footnote_definition("[^test123]: text"));
1625        assert!(rule.is_footnote_definition("[^123]: text"));
1626        assert!(rule.is_footnote_definition("[^a1b2c3]: text"));
1627
1628        // Invalid characters (spec violations)
1629        assert!(!rule.is_footnote_definition("[^test.name]: text")); // Period
1630        assert!(!rule.is_footnote_definition("[^test name]: text")); // Space
1631        assert!(!rule.is_footnote_definition("[^test@name]: text")); // At sign
1632        assert!(!rule.is_footnote_definition("[^test#name]: text")); // Hash
1633        assert!(!rule.is_footnote_definition("[^test$name]: text")); // Dollar
1634        assert!(!rule.is_footnote_definition("[^test%name]: text")); // Percent
1635    }
1636}