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