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