rumdl_lib/rules/
md046_code_block_style.rs

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