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