Skip to main content

rumdl_lib/lint_context/
mod.rs

1pub mod types;
2pub use types::*;
3
4mod element_parsers;
5mod flavor_detection;
6mod heading_detection;
7mod line_computation;
8mod link_parser;
9mod list_blocks;
10#[cfg(test)]
11mod tests;
12
13use crate::config::MarkdownFlavor;
14use crate::inline_config::InlineConfig;
15use crate::rules::front_matter_utils::FrontMatterUtils;
16use crate::utils::code_block_utils::{CodeBlockDetail, CodeBlockUtils};
17use std::collections::HashMap;
18use std::path::PathBuf;
19
20/// Macro for profiling sections - only active in non-WASM builds
21#[cfg(not(target_arch = "wasm32"))]
22macro_rules! profile_section {
23    ($name:expr, $profile:expr, $code:expr) => {{
24        let start = std::time::Instant::now();
25        let result = $code;
26        if $profile {
27            eprintln!("[PROFILE] {}: {:?}", $name, start.elapsed());
28        }
29        result
30    }};
31}
32
33#[cfg(target_arch = "wasm32")]
34macro_rules! profile_section {
35    ($name:expr, $profile:expr, $code:expr) => {{ $code }};
36}
37
38/// Grouped byte ranges for skip context detection
39/// Used to reduce parameter count in internal functions
40pub(super) struct SkipByteRanges<'a> {
41    pub(super) html_comment_ranges: &'a [crate::utils::skip_context::ByteRange],
42    pub(super) autodoc_ranges: &'a [crate::utils::skip_context::ByteRange],
43    pub(super) pandoc_div_ranges: &'a [crate::utils::skip_context::ByteRange],
44    pub(super) pymdown_block_ranges: &'a [crate::utils::skip_context::ByteRange],
45}
46
47use std::sync::{Arc, OnceLock};
48
49/// Map from line byte offset to list item data: (is_ordered, marker, marker_column, content_column, number)
50pub(super) type ListItemMap = std::collections::HashMap<usize, (bool, String, usize, usize, Option<usize>)>;
51
52/// Type alias for byte ranges used in JSX expression and MDX comment detection
53pub(super) type ByteRanges = Vec<(usize, usize)>;
54
55pub struct LintContext<'a> {
56    pub content: &'a str,
57    content_lines: Vec<&'a str>, // Pre-split lines from content (avoids repeated allocations)
58    pub line_offsets: Vec<usize>,
59    pub code_blocks: Vec<(usize, usize)>, // Cached code block ranges (not including inline code spans)
60    pub code_block_details: Vec<CodeBlockDetail>, // Per-block metadata (fenced/indented, info string)
61    pub strong_spans: Vec<crate::utils::code_block_utils::StrongSpanDetail>, // Pre-computed strong emphasis spans
62    pub line_to_list: crate::utils::code_block_utils::LineToListMap, // Ordered list membership by line
63    pub list_start_values: crate::utils::code_block_utils::ListStartValues, // Start values per list ID
64    pub lines: Vec<LineInfo>,             // Pre-computed line information
65    pub links: Vec<ParsedLink<'a>>,       // Pre-parsed links
66    pub images: Vec<ParsedImage<'a>>,     // Pre-parsed images
67    pub broken_links: Vec<BrokenLinkInfo>, // Broken/undefined references
68    pub footnote_refs: Vec<FootnoteRef>,  // Pre-parsed footnote references
69    pub reference_defs: Vec<ReferenceDef>, // Reference definitions
70    reference_defs_map: HashMap<String, usize>, // O(1) lookup by lowercase ID -> index in reference_defs
71    code_spans_cache: OnceLock<Arc<Vec<CodeSpan>>>, // Lazy-loaded inline code spans
72    math_spans_cache: OnceLock<Arc<Vec<MathSpan>>>, // Lazy-loaded math spans ($...$ and $$...$$)
73    math_byte_ranges_cache: OnceLock<Vec<(usize, usize)>>, // Lazy-loaded math byte ranges for is_in_math_context
74    pub list_blocks: Vec<ListBlock>,      // Pre-parsed list blocks
75    pub char_frequency: CharFrequency,    // Character frequency analysis
76    html_tags_cache: OnceLock<Arc<Vec<HtmlTag>>>, // Lazy-loaded HTML tags
77    jsx_component_tags_cache: OnceLock<Arc<Vec<HtmlTag>>>, // Lazy-loaded JSX component tags (shares the html_tags parse)
78    emphasis_spans_cache: OnceLock<Arc<Vec<EmphasisSpan>>>, // Lazy-loaded emphasis spans
79    table_rows_cache: OnceLock<Arc<Vec<TableRow>>>,        // Lazy-loaded table rows
80    bare_urls_cache: OnceLock<Arc<Vec<BareUrl>>>,          // Lazy-loaded bare URLs
81    has_mixed_list_nesting_cache: OnceLock<bool>, // Cached result for mixed ordered/unordered list nesting detection
82    html_comment_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed HTML comment ranges
83    pub table_blocks: Vec<crate::utils::table_utils::TableBlock>, // Pre-computed table blocks
84    pub line_index: crate::utils::range_utils::LineIndex<'a>, // Pre-computed line index for byte position calculations
85    jinja_ranges: Vec<(usize, usize)>,            // Pre-computed Jinja template ranges ({{ }}, {% %})
86    pub flavor: MarkdownFlavor,                   // Markdown flavor being used
87    pub source_file: Option<PathBuf>,             // Source file path (for rules that need file context)
88    jsx_expression_ranges: Vec<(usize, usize)>,   // Pre-computed JSX expression ranges (MDX: {expression})
89    mdx_comment_ranges: Vec<(usize, usize)>,      // Pre-computed MDX comment ranges ({/* ... */})
90    citation_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc/Quarto citation ranges (@key, [@key])
91    pandoc_div_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc/Quarto div block ranges (::: ... :::)
92    colon_fence_ranges: Vec<(usize, usize)>, // Pre-computed Azure DevOps colon code fence ranges (:::lang ... :::)
93    inline_footnote_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc inline footnote ranges (^[...])
94    pandoc_header_slugs: std::collections::HashSet<String>, // Pre-computed Pandoc implicit header reference slugs
95    example_list_marker_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc example-list marker ranges (@) / (@label)
96    example_reference_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc example reference ranges (@label) inline
97    sub_super_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc subscript (~x~) and superscript (^x^) ranges
98    inline_code_attr_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc inline code attribute ranges (`code`{.lang})
99    bracketed_span_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc bracketed span ranges ([text]{attrs})
100    line_block_ranges: Vec<crate::utils::skip_context::ByteRange>,     // Pre-computed Pandoc line block ranges (| text)
101    pipe_table_caption_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc pipe-table caption ranges (: caption)
102    pandoc_metadata_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc YAML metadata block ranges (--- ... --- or ...)
103    grid_table_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc grid-table ranges (+---+---+)
104    multi_line_table_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc multi-line table ranges
105    shortcode_ranges: Vec<(usize, usize)>, // Pre-computed Hugo/Quarto shortcode ranges ({{< ... >}} and {{% ... %}})
106    link_title_ranges: Vec<(usize, usize)>, // Pre-computed sorted link title byte ranges
107    code_span_byte_ranges: Vec<(usize, usize)>, // Pre-computed code span byte ranges from pulldown-cmark
108    inline_config: InlineConfig,           // Parsed inline configuration comments for rule disabling
109    obsidian_comment_ranges: Vec<(usize, usize)>, // Pre-computed Obsidian comment ranges (%%...%%)
110    lazy_cont_lines_cache: OnceLock<Arc<Vec<LazyContLine>>>, // Lazy-loaded lazy continuation lines
111    myst_directive_ranges: Vec<(usize, usize)>, // Pre-computed MyST colon directive byte ranges (:::{name} ... :::)
112    myst_comment_ranges: Vec<(usize, usize)>, // Pre-computed MyST comment byte ranges (% comment)
113    myst_role_ranges: Vec<(usize, usize)>, // Pre-computed MyST role byte ranges ({role}`content`)
114    front_matter_end: usize,               // 1-indexed line where front matter ends, 0 if none
115}
116
117impl<'a> LintContext<'a> {
118    pub fn new(content: &'a str, flavor: MarkdownFlavor, source_file: Option<PathBuf>) -> Self {
119        #[cfg(not(target_arch = "wasm32"))]
120        let profile = std::env::var("RUMDL_PROFILE_QUADRATIC").is_ok();
121
122        let line_offsets = profile_section!("Line offsets", profile, {
123            let mut offsets = vec![0];
124            for (i, c) in content.char_indices() {
125                if c == '\n' {
126                    offsets.push(i + 1);
127                }
128            }
129            offsets
130        });
131
132        // Compute content_lines once for all functions that need it
133        let content_lines: Vec<&str> = content.lines().collect();
134
135        // Detect front matter boundaries once for all functions that need it.
136        // This is the single allowed call site; rules read the cached value
137        // via front_matter_end_line().
138        #[allow(clippy::disallowed_methods)]
139        let front_matter_end = FrontMatterUtils::get_front_matter_end_line(content);
140
141        // Detect code blocks and code spans once and cache them
142        let parse_result = profile_section!(
143            "Code blocks",
144            profile,
145            CodeBlockUtils::detect_code_blocks_and_spans(content)
146        );
147        let mut code_blocks = parse_result.code_blocks;
148        let code_span_ranges = parse_result.code_spans;
149        let code_block_details = parse_result.code_block_details;
150        let strong_spans = parse_result.strong_spans;
151        let line_to_list = parse_result.line_to_list;
152        let list_start_values = parse_result.list_start_values;
153
154        // Pre-compute HTML comment ranges ONCE for all operations
155        let html_comment_ranges = profile_section!(
156            "HTML comment ranges",
157            profile,
158            crate::utils::skip_context::compute_html_comment_ranges(content)
159        );
160
161        // Pre-compute autodoc block ranges (avoids O(n^2) scaling)
162        // Detected for all flavors except AzureDevOps, where `:::` denotes code fences
163        // rather than autodoc directives.
164        let autodoc_ranges = profile_section!("Autodoc block ranges", profile, {
165            if flavor.supports_colon_code_fences() || flavor.supports_myst_directives() {
166                Vec::new()
167            } else {
168                crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
169            }
170        });
171
172        // Pre-compute Pandoc/Quarto div block ranges for Pandoc-compatible flavors
173        let pandoc_div_ranges = profile_section!("Pandoc div ranges", profile, {
174            if flavor.is_pandoc_compatible() {
175                crate::utils::pandoc::detect_div_block_ranges(content)
176            } else {
177                Vec::new()
178            }
179        });
180
181        // Pre-compute PyMdown Blocks ranges for MkDocs flavor (/// ... ///)
182        let pymdown_block_ranges = profile_section!("PyMdown block ranges", profile, {
183            if flavor == MarkdownFlavor::MkDocs {
184                crate::utils::pymdown_blocks::detect_block_ranges(content)
185            } else {
186                Vec::new()
187            }
188        });
189
190        // Pre-compute line information AND emphasis spans (without headings/blockquotes yet)
191        // Emphasis spans are captured during the same pulldown-cmark parse as list detection
192        let skip_ranges = SkipByteRanges {
193            html_comment_ranges: &html_comment_ranges,
194            autodoc_ranges: &autodoc_ranges,
195            pandoc_div_ranges: &pandoc_div_ranges,
196            pymdown_block_ranges: &pymdown_block_ranges,
197        };
198        let (mut lines, emphasis_spans) = profile_section!(
199            "Basic line info",
200            profile,
201            line_computation::compute_basic_line_info(
202                content,
203                &content_lines,
204                &line_offsets,
205                &code_blocks,
206                flavor,
207                &skip_ranges,
208                front_matter_end,
209            )
210        );
211
212        // Detect HTML blocks BEFORE heading detection
213        profile_section!(
214            "HTML blocks",
215            profile,
216            heading_detection::detect_html_blocks(content, &mut lines)
217        );
218
219        // Detect ESM import/export blocks in MDX files BEFORE heading detection
220        profile_section!(
221            "ESM blocks",
222            profile,
223            flavor_detection::detect_esm_blocks(content, &mut lines, flavor)
224        );
225
226        // Detect JSX component blocks in MDX files (e.g. <Tabs>...</Tabs>)
227        profile_section!(
228            "JSX block detection",
229            profile,
230            flavor_detection::detect_jsx_blocks(content, &mut lines, flavor)
231        );
232
233        // Detect JSX expressions and MDX comments in MDX files
234        let (jsx_expression_ranges, mdx_comment_ranges) = profile_section!(
235            "JSX/MDX detection",
236            profile,
237            flavor_detection::detect_jsx_and_mdx_comments(content, &mut lines, flavor, &code_blocks)
238        );
239
240        // Detect `<div markdown>`-style HTML blocks (grid cards, etc.) regardless of flavor.
241        // The `markdown` attribute is an explicit, author-supplied signal; recognizing it
242        // in all flavors keeps `rumdl fmt` from mangling Material grid cards when the
243        // MkDocs flavor isn't active.
244        profile_section!(
245            "Markdown-in-HTML blocks",
246            profile,
247            flavor_detection::detect_markdown_html_blocks(&content_lines, &mut lines)
248        );
249
250        // Detect MkDocs-specific constructs (admonitions, tabs, definition lists)
251        profile_section!(
252            "MkDocs constructs",
253            profile,
254            flavor_detection::detect_mkdocs_line_info(&content_lines, &mut lines, flavor)
255        );
256
257        // Detect footnote definitions and correct false code block detection.
258        // With ENABLE_FOOTNOTES, pulldown-cmark correctly parses multi-line
259        // footnotes, but the code block detector may still mark 4-space-indented
260        // footnote continuation lines as indented code blocks.
261        profile_section!(
262            "Footnote definitions",
263            profile,
264            detect_footnote_definitions(content, &mut lines, &line_offsets)
265        );
266
267        // Filter code_blocks to remove false positives from footnote continuation content.
268        // Same pattern as MkDocs/JSX corrections below.
269        {
270            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
271            for &(start, end) in &code_blocks {
272                let start_line = line_offsets
273                    .partition_point(|&offset| offset <= start)
274                    .saturating_sub(1);
275                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
276
277                let mut sub_start: Option<usize> = None;
278                for (i, &offset) in line_offsets[start_line..end_line]
279                    .iter()
280                    .enumerate()
281                    .map(|(j, o)| (j + start_line, o))
282                {
283                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
284                    if is_real_code && sub_start.is_none() {
285                        let byte_start = if i == start_line { start } else { offset };
286                        sub_start = Some(byte_start);
287                    } else if !is_real_code && sub_start.is_some() {
288                        new_code_blocks.push((sub_start.unwrap(), offset));
289                        sub_start = None;
290                    }
291                }
292                if let Some(s) = sub_start {
293                    new_code_blocks.push((s, end));
294                }
295            }
296            code_blocks = new_code_blocks;
297        }
298
299        // Filter code_blocks to remove false positives from MkDocs admonition/tab content
300        // and `<div markdown>` HTML blocks (grid cards).
301        // pulldown-cmark treats 4-space-indented content as indented code blocks, but inside
302        // these containers this is regular markdown content. detect_mkdocs_line_info and
303        // detect_markdown_html_blocks already corrected LineInfo.in_code_block for these lines,
304        // but the code_blocks byte ranges are still stale. We split ranges rather than using
305        // all-or-nothing removal, so fenced code blocks within the containers are preserved.
306        let has_markdown_html = lines.iter().any(|l| l.in_mkdocs_html_markdown);
307        if flavor == MarkdownFlavor::MkDocs || has_markdown_html {
308            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
309            for &(start, end) in &code_blocks {
310                let start_line = line_offsets
311                    .partition_point(|&offset| offset <= start)
312                    .saturating_sub(1);
313                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
314
315                // Walk lines in this range, collecting sub-ranges where in_code_block is true
316                let mut sub_start: Option<usize> = None;
317                for (i, &offset) in line_offsets[start_line..end_line]
318                    .iter()
319                    .enumerate()
320                    .map(|(j, o)| (j + start_line, o))
321                {
322                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
323                    if is_real_code && sub_start.is_none() {
324                        let byte_start = if i == start_line { start } else { offset };
325                        sub_start = Some(byte_start);
326                    } else if !is_real_code && sub_start.is_some() {
327                        new_code_blocks.push((sub_start.unwrap(), offset));
328                        sub_start = None;
329                    }
330                }
331                if let Some(s) = sub_start {
332                    new_code_blocks.push((s, end));
333                }
334            }
335            code_blocks = new_code_blocks;
336        }
337
338        // Filter code_blocks for MDX JSX blocks (same pattern as MkDocs above).
339        // detect_jsx_blocks already corrected LineInfo.in_code_block for indented content
340        // inside JSX component blocks, but code_blocks byte ranges need updating too.
341        if flavor.supports_jsx() {
342            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
343            for &(start, end) in &code_blocks {
344                let start_line = line_offsets
345                    .partition_point(|&offset| offset <= start)
346                    .saturating_sub(1);
347                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
348
349                let mut sub_start: Option<usize> = None;
350                for (i, &offset) in line_offsets[start_line..end_line]
351                    .iter()
352                    .enumerate()
353                    .map(|(j, o)| (j + start_line, o))
354                {
355                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
356                    if is_real_code && sub_start.is_none() {
357                        let byte_start = if i == start_line { start } else { offset };
358                        sub_start = Some(byte_start);
359                    } else if !is_real_code && sub_start.is_some() {
360                        new_code_blocks.push((sub_start.unwrap(), offset));
361                        sub_start = None;
362                    }
363                }
364                if let Some(s) = sub_start {
365                    new_code_blocks.push((s, end));
366                }
367            }
368            code_blocks = new_code_blocks;
369        }
370
371        // Detect Azure DevOps colon code fences and extend code_blocks so that
372        // all byte-range consumers correctly skip their content.
373        let colon_fence_ranges = profile_section!(
374            "Azure colon fence detection",
375            profile,
376            flavor_detection::detect_azure_colon_fences(content, &mut lines, flavor)
377        );
378        if !colon_fence_ranges.is_empty() {
379            code_blocks.extend(colon_fence_ranges.iter().copied());
380            code_blocks.sort_by_key(|&(start, _)| start);
381        }
382
383        // Detect MyST colon directives (:::{name} ... :::) — these are structural
384        // containers, NOT code blocks. Content inside is linted as markdown.
385        let myst_directive_ranges = profile_section!(
386            "MyST colon directives",
387            profile,
388            flavor_detection::detect_myst_colon_directives(content, &mut lines, flavor)
389        );
390
391        // Detect MyST % comments
392        let myst_comment_ranges = profile_section!(
393            "MyST comments",
394            profile,
395            flavor_detection::detect_myst_comments(content, &mut lines, flavor)
396        );
397
398        // Detect MyST backtick directives (```{name}) and clear in_code_block for
399        // content-bearing directives so their body is linted as markdown.
400        profile_section!(
401            "MyST backtick directives",
402            profile,
403            flavor_detection::detect_myst_backtick_directives(
404                content,
405                &mut lines,
406                flavor,
407                &code_block_details,
408                &line_offsets
409            )
410        );
411
412        // Filter code_blocks to remove false positives from MyST content-bearing directives.
413        // Same pattern as MkDocs admonition filtering.
414        if flavor.supports_myst_directives() {
415            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
416            for &(start, end) in &code_blocks {
417                let start_line = line_offsets
418                    .partition_point(|&offset| offset <= start)
419                    .saturating_sub(1);
420                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
421
422                let mut sub_start: Option<usize> = None;
423                for (i, &offset) in line_offsets[start_line..end_line]
424                    .iter()
425                    .enumerate()
426                    .map(|(j, o)| (j + start_line, o))
427                {
428                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
429                    if is_real_code && sub_start.is_none() {
430                        let byte_start = if i == start_line { start } else { offset };
431                        sub_start = Some(byte_start);
432                    } else if !is_real_code && sub_start.is_some() {
433                        new_code_blocks.push((sub_start.unwrap(), offset));
434                        sub_start = None;
435                    }
436                }
437                if let Some(s) = sub_start {
438                    new_code_blocks.push((s, end));
439                }
440            }
441            code_blocks = new_code_blocks;
442        }
443
444        // Detect kramdown constructs (extension blocks, IALs, ALDs) in kramdown flavor
445        profile_section!(
446            "Kramdown constructs",
447            profile,
448            flavor_detection::detect_kramdown_line_info(content, &mut lines, flavor)
449        );
450
451        // Layer 1: Sanitize content-derived fields inside kramdown extension blocks
452        // so downstream heading detection and collection builders never see them.
453        // This must run BEFORE detect_headings_and_blockquotes to prevent headings
454        // from being populated inside extension blocks.
455        for line in &mut lines {
456            if line.in_kramdown_extension_block {
457                line.list_item = None;
458                line.is_horizontal_rule = false;
459                line.blockquote = None;
460                line.is_kramdown_block_ial = false;
461            }
462        }
463
464        // Detect Obsidian comments (%%...%%) in Obsidian flavor
465        let obsidian_comment_ranges = profile_section!(
466            "Obsidian comments",
467            profile,
468            flavor_detection::detect_obsidian_comments(content, &mut lines, flavor, &code_span_ranges)
469        );
470
471        // Detect MyST role syntax ({role}`content`)
472        let myst_role_ranges = profile_section!(
473            "MyST roles",
474            profile,
475            flavor_detection::detect_myst_role_ranges(content, &lines, flavor, &code_blocks)
476        );
477
478        // Run pulldown-cmark parse for links, images, and link byte ranges in a single pass.
479        // Link byte ranges are needed for heading detection; links/images are finalized later
480        // after code_spans are available.
481        let pulldown_result = profile_section!(
482            "Links, images & link ranges",
483            profile,
484            link_parser::parse_links_images_pulldown(content, &lines, &code_blocks, flavor, &html_comment_ranges)
485        );
486
487        // Now detect headings and blockquotes
488        profile_section!(
489            "Headings & blockquotes",
490            profile,
491            heading_detection::detect_headings_and_blockquotes(
492                &content_lines,
493                &mut lines,
494                flavor,
495                &html_comment_ranges,
496                &pulldown_result.link_byte_ranges,
497                front_matter_end,
498            )
499        );
500
501        // Clear headings that were detected inside kramdown extension blocks
502        for line in &mut lines {
503            if line.in_kramdown_extension_block {
504                line.heading = None;
505            }
506        }
507
508        // Parse code spans early so we can exclude them from link/image parsing
509        let mut code_spans = profile_section!(
510            "Code spans",
511            profile,
512            element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
513        );
514
515        // Supplement code spans for MkDocs container content that pulldown-cmark missed.
516        // pulldown-cmark treats 4-space-indented MkDocs content as indented code blocks,
517        // so backtick code spans within admonitions/tabs/markdown HTML are invisible to it.
518        if flavor == MarkdownFlavor::MkDocs {
519            let extra = profile_section!(
520                "MkDocs code spans",
521                profile,
522                element_parsers::scan_mkdocs_container_code_spans(content, &lines, &code_span_ranges,)
523            );
524            if !extra.is_empty() {
525                code_spans.extend(extra);
526                code_spans.sort_by_key(|span| span.byte_offset);
527            }
528        }
529
530        // Supplement code spans for MDX JSX component body content that pulldown-cmark missed.
531        // pulldown-cmark treats JSX component opening tags (e.g. `<ParamField>`) as HTML block
532        // starters, so backtick code spans within component bodies are invisible to the initial
533        // parse.
534        if flavor == MarkdownFlavor::MDX {
535            let extra = profile_section!(
536                "MDX JSX code spans",
537                profile,
538                element_parsers::scan_jsx_block_code_spans(content, &lines, &code_span_ranges)
539            );
540            if !extra.is_empty() {
541                code_spans.extend(extra);
542                code_spans.sort_by_key(|span| span.byte_offset);
543            }
544        }
545
546        // Mark lines that are continuations of multi-line code spans
547        // This is needed for parse_list_blocks to correctly handle list items with multi-line code spans
548        for span in &code_spans {
549            if span.end_line > span.line {
550                // Mark lines after the first line as continuations
551                for line_num in (span.line + 1)..=span.end_line {
552                    if let Some(line_info) = lines.get_mut(line_num - 1) {
553                        line_info.in_code_span_continuation = true;
554                    }
555                }
556            }
557        }
558
559        // Finalize links and images: filter by code_spans and run regex fallbacks
560        let (links, images, broken_links, footnote_refs) = profile_section!(
561            "Links & images finalize",
562            profile,
563            link_parser::finalize_links_and_images(
564                content,
565                &lines,
566                &code_blocks,
567                &code_spans,
568                flavor,
569                &html_comment_ranges,
570                pulldown_result
571            )
572        );
573
574        let reference_defs = profile_section!(
575            "Reference defs",
576            profile,
577            link_parser::parse_reference_defs(content, &lines)
578        );
579
580        let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
581
582        // Compute character frequency for fast content analysis
583        let char_frequency = profile_section!(
584            "Char frequency",
585            profile,
586            line_computation::compute_char_frequency(content)
587        );
588
589        // Pre-compute table blocks for rules that need them (MD013, MD055, MD056, MD058, MD060)
590        let table_blocks = profile_section!(
591            "Table blocks",
592            profile,
593            crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
594                content,
595                &code_blocks,
596                &code_spans,
597                &html_comment_ranges,
598            )
599        );
600
601        // Layer 2: Filter pre-computed collections to exclude items inside kramdown extension blocks.
602        // Rules that iterate these collections automatically skip kramdown content.
603        let links = links
604            .into_iter()
605            .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
606            .collect::<Vec<_>>();
607        let images = images
608            .into_iter()
609            .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
610            .collect::<Vec<_>>();
611        let broken_links = broken_links
612            .into_iter()
613            .filter(|bl| {
614                // BrokenLinkInfo has span but no line field; find line from byte offset
615                let line_idx = line_offsets
616                    .partition_point(|&offset| offset <= bl.span.start)
617                    .saturating_sub(1);
618                !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
619            })
620            .collect::<Vec<_>>();
621        let footnote_refs = footnote_refs
622            .into_iter()
623            .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
624            .collect::<Vec<_>>();
625        let reference_defs = reference_defs
626            .into_iter()
627            .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
628            .collect::<Vec<_>>();
629        let list_blocks = list_blocks
630            .into_iter()
631            .filter(|block| {
632                !lines
633                    .get(block.start_line - 1)
634                    .is_some_and(|l| l.in_kramdown_extension_block)
635            })
636            .collect::<Vec<_>>();
637        let table_blocks = table_blocks
638            .into_iter()
639            .filter(|block| {
640                // TableBlock.start_line is 0-indexed
641                !lines
642                    .get(block.start_line)
643                    .is_some_and(|l| l.in_kramdown_extension_block)
644            })
645            .collect::<Vec<_>>();
646        let emphasis_spans = emphasis_spans
647            .into_iter()
648            .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
649            .collect::<Vec<_>>();
650
651        // Mark lines covered by a list or table block so is_in_list_block /
652        // is_in_table_block are O(1) reads (mirrors in_html_block) instead of
653        // scanning the whole block vector on every call.
654        for block in &list_blocks {
655            // ListBlock line numbers are 1-indexed.
656            for line_num in block.start_line..=block.end_line {
657                if let Some(li) = lines.get_mut(line_num - 1) {
658                    li.in_list_block = true;
659                }
660            }
661        }
662        for block in &table_blocks {
663            // TableBlock line numbers are 0-indexed.
664            for idx in block.start_line..=block.end_line {
665                if let Some(li) = lines.get_mut(idx) {
666                    li.in_table_block = true;
667                }
668            }
669        }
670
671        // Rebuild reference_defs_map after filtering
672        let reference_defs_map: HashMap<String, usize> = reference_defs
673            .iter()
674            .enumerate()
675            .map(|(idx, def)| (def.id.to_lowercase(), idx))
676            .collect();
677
678        // Pre-compute sorted link title byte ranges for binary search
679        let link_title_ranges: Vec<(usize, usize)> = reference_defs
680            .iter()
681            .filter_map(|def| match (def.title_byte_start, def.title_byte_end) {
682                (Some(start), Some(end)) => Some((start, end)),
683                _ => None,
684            })
685            .collect();
686
687        // Reuse already-computed line_offsets and code_blocks instead of re-detecting
688        let line_index = profile_section!(
689            "Line index",
690            profile,
691            crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
692                content,
693                line_offsets.clone(),
694                &code_blocks,
695            )
696        );
697
698        // Pre-compute Jinja template ranges once for all rules (eliminates O(n*m) in MD011)
699        let jinja_ranges = profile_section!(
700            "Jinja ranges",
701            profile,
702            crate::utils::jinja_utils::find_jinja_ranges(content)
703        );
704
705        // Pre-compute Pandoc/Quarto citation ranges for Pandoc-compatible flavors
706        let citation_ranges = profile_section!("Citation ranges", profile, {
707            if flavor.is_pandoc_compatible() {
708                crate::utils::pandoc::find_citation_ranges(content)
709            } else {
710                Vec::new()
711            }
712        });
713
714        // Pre-compute Pandoc inline footnote ranges for Pandoc-compatible flavors
715        let inline_footnote_ranges = profile_section!("Inline footnote ranges", profile, {
716            if flavor.is_pandoc_compatible() {
717                crate::utils::pandoc::detect_inline_footnote_ranges(content)
718            } else {
719                Vec::new()
720            }
721        });
722
723        // Pre-compute Pandoc implicit header reference slugs for Pandoc-compatible flavors
724        let pandoc_header_slugs = profile_section!("Pandoc header slugs", profile, {
725            if flavor.is_pandoc_compatible() {
726                crate::utils::pandoc::collect_pandoc_header_slugs(content)
727            } else {
728                std::collections::HashSet::new()
729            }
730        });
731
732        // Pre-compute Pandoc example-list marker ranges for Pandoc-compatible flavors
733        let example_list_marker_ranges = profile_section!("Example list markers", profile, {
734            if flavor.is_pandoc_compatible() {
735                crate::utils::pandoc::detect_example_list_marker_ranges(content)
736            } else {
737                Vec::new()
738            }
739        });
740
741        // Pre-compute Pandoc example reference ranges for Pandoc-compatible flavors
742        let example_reference_ranges = profile_section!("Example references", profile, {
743            if flavor.is_pandoc_compatible() {
744                crate::utils::pandoc::detect_example_reference_ranges(content, &example_list_marker_ranges)
745            } else {
746                Vec::new()
747            }
748        });
749
750        // Pre-compute Pandoc subscript (~x~) and superscript (^x^) ranges
751        let sub_super_ranges = profile_section!("Subscript/superscript ranges", profile, {
752            if flavor.is_pandoc_compatible() {
753                crate::utils::pandoc::detect_subscript_superscript_ranges(content)
754            } else {
755                Vec::new()
756            }
757        });
758
759        // Pre-compute Pandoc inline code attribute ranges (`code`{.lang}) for Pandoc-compatible flavors
760        let inline_code_attr_ranges = profile_section!("Inline code attribute ranges", profile, {
761            if flavor.is_pandoc_compatible() {
762                crate::utils::pandoc::detect_inline_code_attr_ranges(content)
763            } else {
764                Vec::new()
765            }
766        });
767
768        // Pre-compute Pandoc bracketed span ranges ([text]{attrs}) for Pandoc-compatible flavors
769        let bracketed_span_ranges = profile_section!("Bracketed span ranges", profile, {
770            if flavor.is_pandoc_compatible() {
771                crate::utils::pandoc::detect_bracketed_span_ranges(content)
772            } else {
773                Vec::new()
774            }
775        });
776
777        // Pre-compute Pandoc line block ranges (| text) for Pandoc-compatible flavors
778        let line_block_ranges = profile_section!("Line block ranges", profile, {
779            if flavor.is_pandoc_compatible() {
780                crate::utils::pandoc::detect_line_block_ranges(content)
781            } else {
782                Vec::new()
783            }
784        });
785
786        // Pre-compute Pandoc pipe-table caption ranges (: caption) for Pandoc-compatible flavors
787        let pipe_table_caption_ranges = profile_section!("Pipe-table caption ranges", profile, {
788            if flavor.is_pandoc_compatible() {
789                crate::utils::pandoc::detect_pipe_table_caption_ranges(content)
790            } else {
791                Vec::new()
792            }
793        });
794
795        // Pre-compute Pandoc YAML metadata block ranges (--- ... --- or ...) for Pandoc-compatible flavors
796        let pandoc_metadata_ranges = profile_section!("Pandoc metadata ranges", profile, {
797            if flavor.is_pandoc_compatible() {
798                crate::utils::pandoc::detect_yaml_metadata_block_ranges(content)
799            } else {
800                Vec::new()
801            }
802        });
803
804        // Pre-compute Pandoc grid-table ranges (+---+---+) for Pandoc-compatible flavors
805        let grid_table_ranges = profile_section!("Grid table ranges", profile, {
806            if flavor.is_pandoc_compatible() {
807                crate::utils::pandoc::detect_grid_table_ranges(content)
808            } else {
809                Vec::new()
810            }
811        });
812
813        // Pre-compute Pandoc multi-line table ranges for Pandoc-compatible flavors
814        let multi_line_table_ranges = profile_section!("Multi-line table ranges", profile, {
815            if flavor.is_pandoc_compatible() {
816                crate::utils::pandoc::detect_multi_line_table_ranges(content)
817            } else {
818                Vec::new()
819            }
820        });
821
822        // Pre-compute Hugo/Quarto shortcode ranges ({{< ... >}} and {{% ... %}})
823        let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
824            use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
825            let mut ranges = Vec::new();
826            for mat in HUGO_SHORTCODE_REGEX.find_iter(content) {
827                ranges.push((mat.start(), mat.end()));
828            }
829            ranges
830        });
831
832        let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
833
834        Self {
835            content,
836            content_lines,
837            line_offsets,
838            code_blocks,
839            code_block_details,
840            strong_spans,
841            line_to_list,
842            list_start_values,
843            lines,
844            links,
845            images,
846            broken_links,
847            footnote_refs,
848            reference_defs,
849            reference_defs_map,
850            code_spans_cache: OnceLock::from(Arc::new(code_spans)),
851            math_spans_cache: OnceLock::new(),       // Lazy-loaded on first access
852            math_byte_ranges_cache: OnceLock::new(), // Lazy-loaded on first access
853            list_blocks,
854            char_frequency,
855            html_tags_cache: OnceLock::new(),
856            jsx_component_tags_cache: OnceLock::new(),
857            emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
858            table_rows_cache: OnceLock::new(),
859            bare_urls_cache: OnceLock::new(),
860            has_mixed_list_nesting_cache: OnceLock::new(),
861            html_comment_ranges,
862            table_blocks,
863            line_index,
864            jinja_ranges,
865            flavor,
866            source_file,
867            jsx_expression_ranges,
868            mdx_comment_ranges,
869            citation_ranges,
870            pandoc_div_ranges,
871            colon_fence_ranges,
872            inline_footnote_ranges,
873            pandoc_header_slugs,
874            example_list_marker_ranges,
875            example_reference_ranges,
876            sub_super_ranges,
877            inline_code_attr_ranges,
878            bracketed_span_ranges,
879            line_block_ranges,
880            pipe_table_caption_ranges,
881            pandoc_metadata_ranges,
882            grid_table_ranges,
883            multi_line_table_ranges,
884            shortcode_ranges,
885            link_title_ranges,
886            code_span_byte_ranges: code_span_ranges,
887            inline_config,
888            obsidian_comment_ranges,
889            lazy_cont_lines_cache: OnceLock::new(),
890            myst_directive_ranges,
891            myst_comment_ranges,
892            myst_role_ranges,
893            front_matter_end,
894        }
895    }
896
897    /// The 1-indexed line number where front matter ends (the closing
898    /// delimiter line), or 0 when the document has no front matter.
899    /// Computed once in `new()`; rules must use this instead of re-scanning
900    /// the content with `FrontMatterUtils`.
901    pub fn front_matter_end_line(&self) -> usize {
902        self.front_matter_end
903    }
904
905    /// Binary search for whether `pos` falls inside any range in a sorted, non-overlapping
906    /// slice of `(start, end)` byte ranges. O(log n) instead of O(n).
907    #[inline]
908    fn binary_search_ranges(ranges: &[(usize, usize)], pos: usize) -> bool {
909        // Find the rightmost range whose start <= pos
910        let idx = ranges.partition_point(|&(start, _)| start <= pos);
911        // If idx == 0, no range starts at or before pos
912        idx > 0 && pos < ranges[idx - 1].1
913    }
914
915    /// Check if a byte position is within a code span. O(log n).
916    pub fn is_in_code_span_byte(&self, pos: usize) -> bool {
917        Self::binary_search_ranges(&self.code_span_byte_ranges, pos)
918    }
919
920    /// Check if `pos` is inside any link byte range. O(log n).
921    pub fn is_in_link(&self, pos: usize) -> bool {
922        let idx = self.links.partition_point(|link| link.byte_offset <= pos);
923        if idx > 0 && pos < self.links[idx - 1].byte_end {
924            return true;
925        }
926        let idx = self.images.partition_point(|img| img.byte_offset <= pos);
927        if idx > 0 && pos < self.images[idx - 1].byte_end {
928            return true;
929        }
930        self.is_in_reference_def(pos)
931    }
932
933    /// Get parsed inline configuration state.
934    pub fn inline_config(&self) -> &InlineConfig {
935        &self.inline_config
936    }
937
938    /// Byte ranges of Azure DevOps colon code fences (`:::lang … :::`).
939    /// Empty for all other flavors.
940    pub fn colon_fence_ranges(&self) -> &[(usize, usize)] {
941        &self.colon_fence_ranges
942    }
943
944    /// Get pre-split content lines, avoiding repeated `content.lines().collect()` allocations.
945    ///
946    /// Lines are 0-indexed (line 0 corresponds to line number 1 in the document).
947    pub fn raw_lines(&self) -> &[&'a str] {
948        &self.content_lines
949    }
950
951    /// Check if a rule is disabled at a specific line number (1-indexed)
952    ///
953    /// This method checks both persistent disable comments (<!-- rumdl-disable -->)
954    /// and line-specific comments (<!-- rumdl-disable-line -->, <!-- rumdl-disable-next-line -->).
955    pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
956        self.inline_config.is_rule_disabled(rule_name, line_number)
957    }
958
959    /// Get code spans - computed lazily on first access
960    pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
961        Arc::clone(
962            self.code_spans_cache
963                .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
964        )
965    }
966
967    /// Math byte ranges (`$...$` inline and `$$...$$` display), computed once and
968    /// cached. Used by `is_in_math_context`; without the cache that helper
969    /// rescanned the whole document on every call.
970    pub fn math_byte_ranges(&self) -> &[(usize, usize)] {
971        self.math_byte_ranges_cache
972            .get_or_init(|| crate::utils::skip_context::math_byte_ranges(self.content))
973    }
974
975    /// Get math spans - computed lazily on first access
976    pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
977        Arc::clone(
978            self.math_spans_cache
979                .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
980        )
981    }
982
983    /// Check if a byte position is within a math span (inline $...$ or display $$...$$)
984    pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
985        let math_spans = self.math_spans();
986        // Binary search: find the last span whose byte_offset <= byte_pos
987        let idx = math_spans.partition_point(|span| span.byte_offset <= byte_pos);
988        idx > 0 && byte_pos < math_spans[idx - 1].byte_end
989    }
990
991    /// Get HTML comment ranges - pre-computed during LintContext construction
992    pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
993        &self.html_comment_ranges
994    }
995
996    /// Check if a byte position is inside an Obsidian comment
997    ///
998    /// Returns false for non-Obsidian flavors.
999    pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
1000        Self::binary_search_ranges(&self.obsidian_comment_ranges, byte_pos)
1001    }
1002
1003    /// Check if a line/column position is inside an Obsidian comment
1004    ///
1005    /// Line number is 1-indexed, column is 1-indexed.
1006    /// Returns false for non-Obsidian flavors.
1007    pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
1008        if self.obsidian_comment_ranges.is_empty() {
1009            return false;
1010        }
1011
1012        // Convert line/column (1-indexed, char-based) to byte position
1013        let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
1014        self.is_in_obsidian_comment(byte_pos)
1015    }
1016
1017    /// Get byte ranges of MyST colon directive blocks
1018    pub fn myst_directive_ranges(&self) -> &[(usize, usize)] {
1019        &self.myst_directive_ranges
1020    }
1021
1022    /// Check if a byte position is inside a MyST role (`{role}`content``)
1023    pub fn is_in_myst_role(&self, byte_pos: usize) -> bool {
1024        Self::binary_search_ranges(&self.myst_role_ranges, byte_pos)
1025    }
1026
1027    /// Check if a byte position is inside a MyST comment (`% comment`)
1028    pub fn is_in_myst_comment(&self, byte_pos: usize) -> bool {
1029        Self::binary_search_ranges(&self.myst_comment_ranges, byte_pos)
1030    }
1031
1032    /// Check if a line (1-indexed) is a MyST colon-fence directive opener (`:::{name} ...`).
1033    ///
1034    /// The text after `{name}` on an opener is the directive's argument (an opaque
1035    /// path, URL, or label), not markdown prose. Rules that reformat prose should
1036    /// skip these lines. Returns false for non-MyST flavors and for directive body
1037    /// or closer lines.
1038    pub fn is_myst_colon_directive_opener_line(&self, line_num: usize) -> bool {
1039        if !self.flavor.supports_myst_directives() {
1040            return false;
1041        }
1042        self.lines.get(line_num.wrapping_sub(1)).is_some_and(|info| {
1043            info.in_myst_directive
1044                && flavor_detection::myst_colon_directive_opener(info.content(self.content)).is_some()
1045        })
1046    }
1047
1048    /// Drop tags that live inside kramdown extension blocks, preserving order.
1049    fn filter_kramdown_tags(&self, tags: Vec<HtmlTag>) -> Vec<HtmlTag> {
1050        tags.into_iter()
1051            .filter(|tag| {
1052                !self
1053                    .lines
1054                    .get(tag.line - 1)
1055                    .is_some_and(|l| l.in_kramdown_extension_block)
1056            })
1057            .collect()
1058    }
1059
1060    /// Get HTML tags - computed lazily on first access.
1061    ///
1062    /// JSX component tags (e.g. `<Card .../>`) are excluded so HTML-specific rules
1063    /// keep ignoring them; use [`Self::jsx_component_tags`] to access those. The
1064    /// single underlying parse populates both caches at once.
1065    pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
1066        Arc::clone(self.html_tags_cache.get_or_init(|| {
1067            let (html_tags, jsx_component_tags) =
1068                element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
1069            // Populate the JSX-component cache from the same parse so it is built once.
1070            let _ = self
1071                .jsx_component_tags_cache
1072                .set(Arc::new(self.filter_kramdown_tags(jsx_component_tags)));
1073            Arc::new(self.filter_kramdown_tags(html_tags))
1074        }))
1075    }
1076
1077    /// Get JSX component tags (e.g. `<Card .../>`) - computed lazily, sharing the
1078    /// HTML-tag parse. Always empty for flavors without JSX support.
1079    pub fn jsx_component_tags(&self) -> Arc<Vec<HtmlTag>> {
1080        if let Some(cached) = self.jsx_component_tags_cache.get() {
1081            return Arc::clone(cached);
1082        }
1083        // Trigger the shared parse, which also fills jsx_component_tags_cache.
1084        let _ = self.html_tags();
1085        Arc::clone(
1086            self.jsx_component_tags_cache
1087                .get()
1088                .expect("html_tags() populates jsx_component_tags_cache"),
1089        )
1090    }
1091
1092    /// Get emphasis spans - pre-computed during construction
1093    pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
1094        Arc::clone(
1095            self.emphasis_spans_cache
1096                .get()
1097                .expect("emphasis_spans_cache initialized during construction"),
1098        )
1099    }
1100
1101    /// Get table rows - computed lazily on first access
1102    pub fn table_rows(&self) -> Arc<Vec<TableRow>> {
1103        Arc::clone(
1104            self.table_rows_cache
1105                .get_or_init(|| Arc::new(element_parsers::parse_table_rows(self.content, &self.lines))),
1106        )
1107    }
1108
1109    /// Get bare URLs - computed lazily on first access
1110    pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
1111        Arc::clone(self.bare_urls_cache.get_or_init(|| {
1112            Arc::new(element_parsers::parse_bare_urls(
1113                self.content,
1114                &self.lines,
1115                &self.code_blocks,
1116            ))
1117        }))
1118    }
1119
1120    /// Get lazy continuation lines - computed lazily on first access
1121    pub fn lazy_continuation_lines(&self) -> Arc<Vec<LazyContLine>> {
1122        Arc::clone(self.lazy_cont_lines_cache.get_or_init(|| {
1123            Arc::new(element_parsers::detect_lazy_continuation_lines(
1124                self.content,
1125                &self.lines,
1126                &self.line_offsets,
1127            ))
1128        }))
1129    }
1130
1131    /// Check if document has mixed ordered/unordered list nesting.
1132    /// Result is cached after first computation (document-level invariant).
1133    /// This is used by MD007 for smart style auto-detection.
1134    pub fn has_mixed_list_nesting(&self) -> bool {
1135        *self
1136            .has_mixed_list_nesting_cache
1137            .get_or_init(|| self.compute_mixed_list_nesting())
1138    }
1139
1140    /// Internal computation for mixed list nesting (only called once per LintContext).
1141    fn compute_mixed_list_nesting(&self) -> bool {
1142        // Track parent list items by their marker position and type
1143        // Using marker_column instead of indent because it works correctly
1144        // for blockquoted content where indent doesn't account for the prefix
1145        // Stack stores: (marker_column, is_ordered)
1146        let mut stack: Vec<(usize, bool)> = Vec::new();
1147        let mut last_was_blank = false;
1148
1149        for line_info in &self.lines {
1150            // Skip non-content lines (code blocks, frontmatter, HTML comments, etc.)
1151            if line_info.in_code_block
1152                || line_info.in_front_matter
1153                || line_info.in_mkdocstrings
1154                || line_info.in_html_comment
1155                || line_info.in_mdx_comment
1156                || line_info.in_esm_block
1157            {
1158                continue;
1159            }
1160
1161            // OPTIMIZATION: Use pre-computed is_blank instead of content().trim()
1162            if line_info.is_blank {
1163                last_was_blank = true;
1164                continue;
1165            }
1166
1167            if let Some(list_item) = &line_info.list_item {
1168                // Normalize column 1 to column 0 (consistent with MD007 check function)
1169                let current_pos = if list_item.marker_column == 1 {
1170                    0
1171                } else {
1172                    list_item.marker_column
1173                };
1174
1175                // If there was a blank line and this item is at root level, reset stack
1176                if last_was_blank && current_pos == 0 {
1177                    stack.clear();
1178                }
1179                last_was_blank = false;
1180
1181                // Pop items at same or greater position (they're siblings or deeper, not parents)
1182                while let Some(&(pos, _)) = stack.last() {
1183                    if pos >= current_pos {
1184                        stack.pop();
1185                    } else {
1186                        break;
1187                    }
1188                }
1189
1190                // Check if immediate parent has different type - this is mixed nesting
1191                if let Some(&(_, parent_is_ordered)) = stack.last()
1192                    && parent_is_ordered != list_item.is_ordered
1193                {
1194                    return true; // Found mixed nesting - early exit
1195                }
1196
1197                stack.push((current_pos, list_item.is_ordered));
1198            } else {
1199                // Non-list line (but not blank) - could be paragraph or other content
1200                last_was_blank = false;
1201            }
1202        }
1203
1204        false
1205    }
1206
1207    /// Map a byte offset to (line, column)
1208    pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
1209        match self.line_offsets.binary_search(&offset) {
1210            Ok(line) => (line + 1, 1),
1211            Err(line) => {
1212                let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
1213                (line, offset - line_start + 1)
1214            }
1215        }
1216    }
1217
1218    /// Check if a position is within a code block or code span. O(log n).
1219    pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
1220        // Check code blocks first (already uses binary search internally)
1221        if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
1222            return true;
1223        }
1224
1225        // Check inline code spans via binary search
1226        self.is_byte_offset_in_code_span(pos)
1227    }
1228
1229    /// Get line information by line number (1-indexed)
1230    pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
1231        if line_num > 0 {
1232            self.lines.get(line_num - 1)
1233        } else {
1234            None
1235        }
1236    }
1237
1238    /// Get URL for a reference link/image by its ID (O(1) lookup via HashMap)
1239    pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
1240        let normalized_id = ref_id.to_lowercase();
1241        self.reference_defs_map
1242            .get(&normalized_id)
1243            .map(|&idx| self.reference_defs[idx].url.as_str())
1244    }
1245
1246    /// Check if a line is part of a list block
1247    pub fn is_in_list_block(&self, line_num: usize) -> bool {
1248        if line_num == 0 || line_num > self.lines.len() {
1249            return false;
1250        }
1251        self.lines[line_num - 1].in_list_block
1252    }
1253
1254    /// Check if a line is within an HTML block
1255    pub fn is_in_html_block(&self, line_num: usize) -> bool {
1256        if line_num == 0 || line_num > self.lines.len() {
1257            return false;
1258        }
1259        self.lines[line_num - 1].in_html_block
1260    }
1261
1262    /// Check if a 1-indexed line number is inside a GFM table block.
1263    ///
1264    /// Returns `true` for the header line, delimiter line, and all body rows.
1265    /// `TableBlock` spans are stored 0-indexed; this helper accepts the
1266    /// 1-indexed line numbers used elsewhere in the rule API.
1267    pub fn is_in_table_block(&self, line_num: usize) -> bool {
1268        if line_num == 0 || line_num > self.lines.len() {
1269            return false;
1270        }
1271        self.lines[line_num - 1].in_table_block
1272    }
1273
1274    /// Check if a line and column is within a code span
1275    pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
1276        if line_num == 0 || line_num > self.lines.len() {
1277            return false;
1278        }
1279
1280        // Use the code spans cache to check
1281        // Note: col is 1-indexed from caller, but span.start_col and span.end_col are 0-indexed
1282        // Convert col to 0-indexed for comparison
1283        let col_0indexed = if col > 0 { col - 1 } else { 0 };
1284        let code_spans = self.code_spans();
1285        code_spans.iter().any(|span| {
1286            // Check if line is within the span's line range
1287            if line_num < span.line || line_num > span.end_line {
1288                return false;
1289            }
1290
1291            if span.line == span.end_line {
1292                // Single-line span: check column bounds
1293                col_0indexed >= span.start_col && col_0indexed < span.end_col
1294            } else if line_num == span.line {
1295                // First line of multi-line span: anything after start_col is in span
1296                col_0indexed >= span.start_col
1297            } else if line_num == span.end_line {
1298                // Last line of multi-line span: anything before end_col is in span
1299                col_0indexed < span.end_col
1300            } else {
1301                // Middle line of multi-line span: entire line is in span
1302                true
1303            }
1304        })
1305    }
1306
1307    /// Check if a byte offset is within a code span. O(log n).
1308    #[inline]
1309    pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
1310        let code_spans = self.code_spans();
1311        let idx = code_spans.partition_point(|span| span.byte_offset <= byte_offset);
1312        idx > 0 && byte_offset < code_spans[idx - 1].byte_end
1313    }
1314
1315    /// Check if a byte position is within a reference definition. O(log n).
1316    #[inline]
1317    pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
1318        let idx = self.reference_defs.partition_point(|rd| rd.byte_offset <= byte_pos);
1319        idx > 0 && byte_pos < self.reference_defs[idx - 1].byte_end
1320    }
1321
1322    /// Check if a byte position is within an HTML comment. O(log n).
1323    #[inline]
1324    pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
1325        let idx = self.html_comment_ranges.partition_point(|r| r.start <= byte_pos);
1326        idx > 0 && byte_pos < self.html_comment_ranges[idx - 1].end
1327    }
1328
1329    /// Check if a byte position is within an HTML tag (including multiline tags).
1330    /// Uses the pre-parsed html_tags which correctly handles tags spanning multiple lines. O(log n).
1331    #[inline]
1332    pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
1333        let tags = self.html_tags();
1334        let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1335        idx > 0 && byte_pos < tags[idx - 1].byte_end
1336    }
1337
1338    /// Check if a byte position is within a JSX component tag (e.g. `<Card .../>`),
1339    /// including its attribute values and multiline tags. Always false for flavors
1340    /// without JSX support. O(log n).
1341    #[inline]
1342    pub fn is_in_jsx_component_tag(&self, byte_pos: usize) -> bool {
1343        if !self.flavor.supports_jsx() {
1344            return false;
1345        }
1346        let tags = self.jsx_component_tags();
1347        let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1348        idx > 0 && byte_pos < tags[idx - 1].byte_end
1349    }
1350
1351    /// Check if a byte position is within a Jinja template ({{ }} or {% %}). O(log n).
1352    pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
1353        Self::binary_search_ranges(&self.jinja_ranges, byte_pos)
1354    }
1355
1356    /// Check if a byte position is within a JSX expression (MDX: {expression}). O(log n).
1357    #[inline]
1358    pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
1359        Self::binary_search_ranges(&self.jsx_expression_ranges, byte_pos)
1360    }
1361
1362    /// Check if a byte position is within an MDX comment ({/* ... */}). O(log n).
1363    #[inline]
1364    pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
1365        Self::binary_search_ranges(&self.mdx_comment_ranges, byte_pos)
1366    }
1367
1368    /// Check if a byte position is within a Pandoc/Quarto citation (`@key` or `[@key]`).
1369    /// Active for Pandoc-compatible flavors. O(log n).
1370    #[inline]
1371    pub fn is_in_citation(&self, byte_pos: usize) -> bool {
1372        let idx = self.citation_ranges.partition_point(|r| r.start <= byte_pos);
1373        idx > 0 && byte_pos < self.citation_ranges[idx - 1].end
1374    }
1375
1376    /// Pre-computed Pandoc/Quarto citation ranges.
1377    #[inline]
1378    pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1379        &self.citation_ranges
1380    }
1381
1382    /// Check if a byte position is within a Pandoc/Quarto div block (`::: ... :::`).
1383    /// Active for Pandoc-compatible flavors. O(log n) via binary search over sorted ranges.
1384    #[inline]
1385    pub fn is_in_div_block(&self, byte_pos: usize) -> bool {
1386        let idx = self.pandoc_div_ranges.partition_point(|r| r.start <= byte_pos);
1387        idx > 0 && byte_pos < self.pandoc_div_ranges[idx - 1].end
1388    }
1389
1390    /// Check if a byte position is within a Pandoc inline footnote (`^[note text]`).
1391    /// Active for Pandoc-compatible flavors. O(log n).
1392    #[inline]
1393    pub fn is_in_inline_footnote(&self, byte_pos: usize) -> bool {
1394        let idx = self.inline_footnote_ranges.partition_point(|r| r.start <= byte_pos);
1395        idx > 0 && byte_pos < self.inline_footnote_ranges[idx - 1].end
1396    }
1397
1398    /// Check if a byte position is within a Pandoc example-list marker (`(@)` /
1399    /// `(@label)` at line start). Active for Pandoc-compatible flavors. O(log n).
1400    #[inline]
1401    pub fn is_in_example_list_marker(&self, byte_pos: usize) -> bool {
1402        let idx = self.example_list_marker_ranges.partition_point(|r| r.start <= byte_pos);
1403        idx > 0 && byte_pos < self.example_list_marker_ranges[idx - 1].end
1404    }
1405
1406    /// Check if a byte position is within a Pandoc example reference (`(@label)`
1407    /// inline). Active for Pandoc-compatible flavors. O(log n).
1408    #[inline]
1409    pub fn is_in_example_reference(&self, byte_pos: usize) -> bool {
1410        let idx = self.example_reference_ranges.partition_point(|r| r.start <= byte_pos);
1411        idx > 0 && byte_pos < self.example_reference_ranges[idx - 1].end
1412    }
1413
1414    /// Check if a byte position is within a Pandoc subscript (`~x~`) or
1415    /// superscript (`^x^`) span. Active for Pandoc-compatible flavors. O(log n).
1416    #[inline]
1417    pub fn is_in_subscript_or_superscript(&self, byte_pos: usize) -> bool {
1418        let idx = self.sub_super_ranges.partition_point(|r| r.start <= byte_pos);
1419        idx > 0 && byte_pos < self.sub_super_ranges[idx - 1].end
1420    }
1421
1422    /// Check if a byte position is within a Pandoc inline-code attribute block
1423    /// (`{.lang}` immediately following `` `code` ``). Active for Pandoc-compatible
1424    /// flavors. O(log n).
1425    #[inline]
1426    pub fn is_in_inline_code_attr(&self, byte_pos: usize) -> bool {
1427        let idx = self.inline_code_attr_ranges.partition_point(|r| r.start <= byte_pos);
1428        idx > 0 && byte_pos < self.inline_code_attr_ranges[idx - 1].end
1429    }
1430
1431    /// Check if a byte position is within a Pandoc bracketed span (`[text]{attrs}`).
1432    /// Active for Pandoc-compatible flavors. O(log n).
1433    #[inline]
1434    pub fn is_in_bracketed_span(&self, byte_pos: usize) -> bool {
1435        let idx = self.bracketed_span_ranges.partition_point(|r| r.start <= byte_pos);
1436        idx > 0 && byte_pos < self.bracketed_span_ranges[idx - 1].end
1437    }
1438
1439    /// Returns true if `byte_pos` falls inside a Pandoc line block (`| text`).
1440    /// Active for Pandoc-compatible flavors. O(log n).
1441    #[inline]
1442    pub fn is_in_line_block(&self, byte_pos: usize) -> bool {
1443        let idx = self.line_block_ranges.partition_point(|r| r.start <= byte_pos);
1444        idx > 0 && byte_pos < self.line_block_ranges[idx - 1].end
1445    }
1446
1447    /// Returns true if `byte_pos` falls inside a Pandoc pipe-table caption
1448    /// (`: caption` adjacent to a pipe table). Active for Pandoc-compatible
1449    /// flavors. O(log n).
1450    #[inline]
1451    pub fn is_in_pipe_table_caption(&self, byte_pos: usize) -> bool {
1452        let idx = self.pipe_table_caption_ranges.partition_point(|r| r.start <= byte_pos);
1453        idx > 0 && byte_pos < self.pipe_table_caption_ranges[idx - 1].end
1454    }
1455
1456    /// Returns true if `byte_pos` falls inside a Pandoc YAML metadata block.
1457    /// Active for Pandoc-compatible flavors. O(log n).
1458    #[inline]
1459    pub fn is_in_pandoc_metadata(&self, byte_pos: usize) -> bool {
1460        let idx = self.pandoc_metadata_ranges.partition_point(|r| r.start <= byte_pos);
1461        idx > 0 && byte_pos < self.pandoc_metadata_ranges[idx - 1].end
1462    }
1463
1464    /// Returns true if `byte_pos` falls inside a Pandoc grid table.
1465    /// Active for Pandoc-compatible flavors. O(log n).
1466    #[inline]
1467    pub fn is_in_grid_table(&self, byte_pos: usize) -> bool {
1468        let idx = self.grid_table_ranges.partition_point(|r| r.start <= byte_pos);
1469        idx > 0 && byte_pos < self.grid_table_ranges[idx - 1].end
1470    }
1471
1472    /// Returns true if `byte_pos` falls inside a Pandoc multi-line table.
1473    /// Active for Pandoc-compatible flavors. O(log n).
1474    #[inline]
1475    pub fn is_in_multi_line_table(&self, byte_pos: usize) -> bool {
1476        let idx = self.multi_line_table_ranges.partition_point(|r| r.start <= byte_pos);
1477        idx > 0 && byte_pos < self.multi_line_table_ranges[idx - 1].end
1478    }
1479
1480    /// Returns true if `link_text`, after Pandoc slugification, matches a heading
1481    /// in the document. Returns false for non-Pandoc-compatible flavors because
1482    /// the `pandoc_header_slugs` set is empty when the pre-pass detector is gated
1483    /// off. Use this when the caller has raw bracketed text (`[Section name]`).
1484    pub fn matches_implicit_header_reference(&self, link_text: &str) -> bool {
1485        let slug = crate::utils::pandoc::pandoc_header_slug(link_text);
1486        self.pandoc_header_slugs.contains(&slug)
1487    }
1488
1489    /// Returns true if `slug` (already in Pandoc-slug form) matches a heading
1490    /// in the document. Returns false for non-Pandoc-compatible flavors because
1491    /// the `pandoc_header_slugs` set is empty when the pre-pass detector is gated
1492    /// off. Use this when the caller already has a slug (e.g. the fragment of a
1493    /// URL after `#`). O(1).
1494    #[inline]
1495    pub fn has_pandoc_slug(&self, slug: &str) -> bool {
1496        self.pandoc_header_slugs.contains(slug)
1497    }
1498
1499    /// Check if a byte position is within a Hugo/Quarto shortcode ({{< ... >}} or {{% ... %}}). O(log n).
1500    #[inline]
1501    pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
1502        Self::binary_search_ranges(&self.shortcode_ranges, byte_pos)
1503    }
1504
1505    /// Pre-computed Hugo/Quarto shortcode ranges.
1506    #[inline]
1507    pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
1508        &self.shortcode_ranges
1509    }
1510
1511    /// Check if a byte position is within a link reference definition title. O(log n).
1512    pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
1513        Self::binary_search_ranges(&self.link_title_ranges, byte_pos)
1514    }
1515
1516    /// Check if content has any instances of a specific character (fast)
1517    pub fn has_char(&self, ch: char) -> bool {
1518        match ch {
1519            '#' => self.char_frequency.hash_count > 0,
1520            '*' => self.char_frequency.asterisk_count > 0,
1521            '_' => self.char_frequency.underscore_count > 0,
1522            '-' => self.char_frequency.hyphen_count > 0,
1523            '+' => self.char_frequency.plus_count > 0,
1524            '>' => self.char_frequency.gt_count > 0,
1525            '|' => self.char_frequency.pipe_count > 0,
1526            '[' => self.char_frequency.bracket_count > 0,
1527            '`' => self.char_frequency.backtick_count > 0,
1528            '<' => self.char_frequency.lt_count > 0,
1529            '!' => self.char_frequency.exclamation_count > 0,
1530            '\n' => self.char_frequency.newline_count > 0,
1531            _ => self.content.contains(ch), // Fallback for other characters
1532        }
1533    }
1534
1535    /// Get count of a specific character (fast)
1536    pub fn char_count(&self, ch: char) -> usize {
1537        match ch {
1538            '#' => self.char_frequency.hash_count,
1539            '*' => self.char_frequency.asterisk_count,
1540            '_' => self.char_frequency.underscore_count,
1541            '-' => self.char_frequency.hyphen_count,
1542            '+' => self.char_frequency.plus_count,
1543            '>' => self.char_frequency.gt_count,
1544            '|' => self.char_frequency.pipe_count,
1545            '[' => self.char_frequency.bracket_count,
1546            '`' => self.char_frequency.backtick_count,
1547            '<' => self.char_frequency.lt_count,
1548            '!' => self.char_frequency.exclamation_count,
1549            '\n' => self.char_frequency.newline_count,
1550            _ => self.content.matches(ch).count(), // Fallback for other characters
1551        }
1552    }
1553
1554    /// Check if content likely contains headings (fast)
1555    pub fn likely_has_headings(&self) -> bool {
1556        self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 || self.content.contains('=') // Setext H1 underlines use '='
1557    }
1558
1559    /// Check if content likely contains lists (fast)
1560    pub fn likely_has_lists(&self) -> bool {
1561        self.char_frequency.asterisk_count > 0
1562            || self.char_frequency.hyphen_count > 0
1563            || self.char_frequency.plus_count > 0
1564    }
1565
1566    /// Check if content likely contains emphasis (fast)
1567    pub fn likely_has_emphasis(&self) -> bool {
1568        self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
1569    }
1570
1571    /// Check if content likely contains tables (fast)
1572    pub fn likely_has_tables(&self) -> bool {
1573        self.char_frequency.pipe_count > 2
1574    }
1575
1576    /// Check if content likely contains blockquotes (fast)
1577    pub fn likely_has_blockquotes(&self) -> bool {
1578        self.char_frequency.gt_count > 0
1579    }
1580
1581    /// Check if content likely contains code (fast)
1582    pub fn likely_has_code(&self) -> bool {
1583        self.char_frequency.backtick_count > 0
1584    }
1585
1586    /// Check if content likely contains links or images (fast)
1587    pub fn likely_has_links_or_images(&self) -> bool {
1588        self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
1589    }
1590
1591    /// Check if content likely contains HTML (fast)
1592    pub fn likely_has_html(&self) -> bool {
1593        self.char_frequency.lt_count > 0
1594    }
1595
1596    /// Get the blockquote prefix for inserting a blank line at the given line index.
1597    /// Returns the prefix without trailing content (e.g., ">" or ">>").
1598    /// This is needed because blank lines inside blockquotes must preserve the blockquote structure.
1599    /// Returns an empty string if the line is not inside a blockquote.
1600    pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
1601        if let Some(line_info) = self.lines.get(line_idx)
1602            && let Some(ref bq) = line_info.blockquote
1603        {
1604            bq.prefix.trim_end().to_string()
1605        } else {
1606            String::new()
1607        }
1608    }
1609
1610    /// Find the line index for a given byte offset using binary search.
1611    /// Returns (line_index, line_number, column) where:
1612    /// - line_index is the 0-based index in the lines array
1613    /// - line_number is the 1-based line number
1614    /// - column is the byte offset within that line
1615    #[inline]
1616    fn find_line_for_offset(lines: &[LineInfo], byte_offset: usize) -> (usize, usize, usize) {
1617        // Binary search to find the line containing this byte offset
1618        let idx = match lines.binary_search_by(|line| {
1619            if byte_offset < line.byte_offset {
1620                std::cmp::Ordering::Greater
1621            } else if byte_offset > line.byte_offset + line.byte_len {
1622                std::cmp::Ordering::Less
1623            } else {
1624                std::cmp::Ordering::Equal
1625            }
1626        }) {
1627            Ok(idx) => idx,
1628            Err(idx) => idx.saturating_sub(1),
1629        };
1630
1631        let line = &lines[idx];
1632        let line_num = idx + 1;
1633        let col = byte_offset.saturating_sub(line.byte_offset);
1634
1635        (idx, line_num, col)
1636    }
1637
1638    /// Check if a byte offset is within a code span using binary search
1639    #[inline]
1640    fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
1641        // Since spans are sorted by byte_offset, use partition_point for binary search
1642        let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
1643
1644        // Check the span that starts at or before our offset
1645        if idx > 0 {
1646            let span = &code_spans[idx - 1];
1647            if offset >= span.byte_offset && offset < span.byte_end {
1648                return true;
1649            }
1650        }
1651
1652        false
1653    }
1654
1655    /// Get an iterator over valid headings (skipping invalid ones like `#NoSpace`)
1656    ///
1657    /// Valid headings have proper spacing after the `#` markers (or are level > 1).
1658    /// This is the standard iterator for rules that need to process headings.
1659    ///
1660    /// # Examples
1661    ///
1662    /// ```
1663    /// use rumdl_lib::lint_context::LintContext;
1664    /// use rumdl_lib::config::MarkdownFlavor;
1665    ///
1666    /// let content = "# Valid Heading\n#NoSpace\n## Another Valid";
1667    /// let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1668    ///
1669    /// for heading in ctx.valid_headings() {
1670    ///     println!("Line {}: {} (level {})", heading.line_num, heading.heading.text, heading.heading.level);
1671    /// }
1672    /// // Only prints valid headings, skips `#NoSpace`
1673    /// ```
1674    #[must_use]
1675    pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
1676        ValidHeadingsIter::new(&self.lines)
1677    }
1678
1679    /// Check if the document contains any valid CommonMark headings
1680    ///
1681    /// Returns `true` if there is at least one heading with proper space after `#`.
1682    #[must_use]
1683    pub fn has_valid_headings(&self) -> bool {
1684        self.lines
1685            .iter()
1686            .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
1687    }
1688}
1689
1690/// Detect footnote definitions and mark their continuation lines.
1691///
1692/// Uses pulldown-cmark to find footnote definition ranges and fenced code
1693/// blocks within them, then:
1694/// 1. Sets `in_footnote_definition = true` on all lines within
1695/// 2. Clears `in_code_block = false` on continuation lines that were
1696///    misidentified as indented code blocks (but preserves real fenced
1697///    code blocks within footnotes)
1698fn detect_footnote_definitions(content: &str, lines: &mut [types::LineInfo], line_offsets: &[usize]) {
1699    use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
1700
1701    let options = crate::utils::rumdl_parser_options();
1702    let parser = Parser::new_ext(content, options).into_offset_iter();
1703
1704    // Collect footnote ranges and fenced code block ranges within them
1705    let mut footnote_ranges: Vec<(usize, usize)> = Vec::new();
1706    let mut fenced_code_ranges: Vec<(usize, usize)> = Vec::new();
1707    let mut in_footnote = false;
1708
1709    for (event, range) in parser {
1710        match event {
1711            Event::Start(Tag::FootnoteDefinition(_)) => {
1712                in_footnote = true;
1713                footnote_ranges.push((range.start, range.end));
1714            }
1715            Event::End(TagEnd::FootnoteDefinition) => {
1716                in_footnote = false;
1717            }
1718            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) if in_footnote => {
1719                fenced_code_ranges.push((range.start, range.end));
1720            }
1721            _ => {}
1722        }
1723    }
1724
1725    let byte_to_line = |byte_offset: usize| -> usize {
1726        line_offsets
1727            .partition_point(|&offset| offset <= byte_offset)
1728            .saturating_sub(1)
1729    };
1730
1731    // Mark footnote definition lines
1732    for &(start, end) in &footnote_ranges {
1733        let start_line = byte_to_line(start);
1734        let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
1735
1736        for line in &mut lines[start_line..end_line] {
1737            line.in_footnote_definition = true;
1738            line.in_code_block = false;
1739        }
1740    }
1741
1742    // Restore in_code_block for fenced code blocks within footnotes
1743    for &(start, end) in &fenced_code_ranges {
1744        let start_line = byte_to_line(start);
1745        let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
1746
1747        for line in &mut lines[start_line..end_line] {
1748            line.in_code_block = true;
1749        }
1750    }
1751}