Skip to main content

rumdl_lib/lint_context/
mod.rs

1pub mod types;
2pub(crate) use heading_detection::is_paragraph_text_line;
3pub(crate) use link_parser::{image_pattern, link_pattern};
4pub use types::*;
5
6mod element_parsers;
7mod flavor_detection;
8mod heading_detection;
9mod line_computation;
10mod link_parser;
11mod list_blocks;
12#[cfg(test)]
13mod tests;
14
15use crate::config::MarkdownFlavor;
16use crate::inline_config::InlineConfig;
17use crate::rules::front_matter_utils::FrontMatterUtils;
18use crate::utils::code_block_utils::{CodeBlockDetail, CodeBlockUtils};
19use crate::utils::range_utils::byte_to_char_count;
20use std::collections::HashMap;
21use std::ops::Range;
22use std::path::{Path, PathBuf};
23
24/// Run-scoped document paths that filesystem-aware link rules should treat as
25/// present even when their content did not come from disk.
26#[derive(Debug, Clone)]
27pub struct LinkTargetPolicy {
28    supplied_paths: Arc<std::collections::HashSet<PathBuf>>,
29    allow_disk_fallback: bool,
30}
31
32impl LinkTargetPolicy {
33    /// Supplied documents override disk, while targets outside the supplied set
34    /// continue to use normal filesystem resolution.
35    pub fn open_world<I, P>(paths: I) -> Self
36    where
37        I: IntoIterator<Item = P>,
38        P: AsRef<Path>,
39    {
40        Self::from_paths(paths, true)
41    }
42
43    /// Only supplied documents are valid relative link targets.
44    pub fn closed_world<I, P>(paths: I) -> Self
45    where
46        I: IntoIterator<Item = P>,
47        P: AsRef<Path>,
48    {
49        Self::from_paths(paths, false)
50    }
51
52    fn from_paths<I, P>(paths: I, allow_disk_fallback: bool) -> Self
53    where
54        I: IntoIterator<Item = P>,
55        P: AsRef<Path>,
56    {
57        let mut roots = Vec::new();
58        if let Ok(cwd) = std::env::current_dir() {
59            if let Ok(canonical_cwd) = cwd.canonicalize()
60                && canonical_cwd != cwd
61            {
62                roots.push(canonical_cwd);
63            }
64            roots.push(cwd);
65        }
66        Self::from_paths_with_roots(paths, allow_disk_fallback, roots)
67    }
68
69    fn from_paths_with_roots<I, P, R, Q>(paths: I, allow_disk_fallback: bool, roots: R) -> Self
70    where
71        I: IntoIterator<Item = P>,
72        P: AsRef<Path>,
73        R: IntoIterator<Item = Q>,
74        Q: AsRef<Path>,
75    {
76        let roots: Vec<PathBuf> = roots
77            .into_iter()
78            .map(|root| crate::workspace_index::normalize_relative_path(root.as_ref()))
79            .collect();
80        let mut supplied_paths = std::collections::HashSet::new();
81        for path in paths {
82            let path = path.as_ref();
83            supplied_paths.insert(crate::workspace_index::normalize_relative_path(path));
84
85            if path.is_relative() {
86                for root in &roots {
87                    supplied_paths.insert(crate::workspace_index::normalize_relative_path(&root.join(path)));
88                }
89            } else {
90                for source_root in &roots {
91                    if let Ok(relative) = path.strip_prefix(source_root) {
92                        for root in &roots {
93                            supplied_paths
94                                .insert(crate::workspace_index::normalize_relative_path(&root.join(relative)));
95                        }
96                    }
97                }
98            }
99        }
100        Self {
101            supplied_paths: Arc::new(supplied_paths),
102            allow_disk_fallback,
103        }
104    }
105
106    pub fn contains(&self, path: &Path) -> bool {
107        self.supplied_paths
108            .contains(&crate::workspace_index::normalize_relative_path(path))
109    }
110
111    pub(crate) fn resolve_supplied(&self, path: &Path) -> Option<PathBuf> {
112        let normalized = crate::workspace_index::normalize_relative_path(path);
113        if self.supplied_paths.contains(&normalized) {
114            return Some(normalized);
115        }
116
117        if path.extension().is_none() {
118            for extension in ["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"] {
119                let candidate = crate::workspace_index::normalize_relative_path(&path.with_extension(extension));
120                if self.supplied_paths.contains(&candidate) {
121                    return Some(candidate);
122                }
123            }
124        }
125
126        None
127    }
128
129    pub fn contains_with_markdown_extension(&self, path: &Path) -> bool {
130        self.resolve_supplied(path).is_some()
131    }
132
133    pub fn allow_disk_fallback(&self) -> bool {
134        self.allow_disk_fallback
135    }
136}
137
138/// Macro for profiling sections - only active in non-WASM builds
139#[cfg(not(target_arch = "wasm32"))]
140macro_rules! profile_section {
141    ($name:expr, $profile:expr, $code:expr) => {{
142        let start = std::time::Instant::now();
143        let result = $code;
144        if $profile {
145            eprintln!("[PROFILE] {}: {:?}", $name, start.elapsed());
146        }
147        result
148    }};
149}
150
151fn build_commonmark_ordered_lists(
152    lines: &[LineInfo],
153    line_to_list: &crate::utils::code_block_utils::LineToListMap,
154    list_start_values: &crate::utils::code_block_utils::ListStartValues,
155) -> Vec<CommonMarkOrderedListInfo> {
156    let mut grouped_lines: HashMap<usize, Vec<usize>> = HashMap::new();
157
158    for (&line_num, &list_id) in line_to_list {
159        let is_ordered_item = line_num
160            .checked_sub(1)
161            .and_then(|index| lines.get(index))
162            .and_then(|line| line.list_item.as_deref())
163            .is_some_and(|item| item.is_ordered);
164        if is_ordered_item {
165            grouped_lines.entry(list_id).or_default().push(line_num);
166        }
167    }
168
169    let mut lists: Vec<_> = grouped_lines
170        .into_iter()
171        .map(|(list_id, mut item_lines)| {
172            item_lines.sort_unstable();
173            CommonMarkOrderedListInfo {
174                start_value: list_start_values.get(&list_id).copied().unwrap_or(1),
175                item_lines,
176            }
177        })
178        .collect();
179    lists.sort_by_key(|list| list.item_lines.first().copied().unwrap_or(0));
180    lists
181}
182
183#[cfg(target_arch = "wasm32")]
184macro_rules! profile_section {
185    ($name:expr, $profile:expr, $code:expr) => {{ $code }};
186}
187
188/// Grouped byte ranges for skip context detection
189/// Used to reduce parameter count in internal functions
190pub(super) struct SkipByteRanges<'a> {
191    pub(super) html_comment_ranges: &'a [crate::utils::skip_context::ByteRange],
192    pub(super) autodoc_ranges: &'a [crate::utils::skip_context::ByteRange],
193    pub(super) pandoc_div_ranges: &'a [crate::utils::skip_context::ByteRange],
194    pub(super) pymdown_block_ranges: &'a [crate::utils::skip_context::ByteRange],
195}
196
197use std::sync::{Arc, OnceLock};
198
199/// Map from line byte offset to list item data: (is_ordered, marker, marker_column, content_column, number)
200pub(super) type ListItemMap = std::collections::HashMap<usize, (bool, String, usize, usize, Option<usize>)>;
201
202/// Type alias for byte ranges used in JSX expression and MDX comment detection
203pub(super) type ByteRanges = Vec<(usize, usize)>;
204
205pub struct LintContext<'a> {
206    pub content: &'a str,
207    content_lines: Vec<&'a str>, // Pre-split lines from content (avoids repeated allocations)
208    pub line_offsets: Vec<usize>,
209    pub code_blocks: Vec<(usize, usize)>, // Cached code block ranges (not including inline code spans)
210    pub code_block_details: Vec<CodeBlockDetail>, // Per-block metadata (fenced/indented, info string)
211    pub strong_spans: Vec<crate::utils::code_block_utils::StrongSpanDetail>, // Pre-computed strong emphasis spans
212    line_to_list: crate::utils::code_block_utils::LineToListMap, // Private CommonMark membership input
213    list_start_values: crate::utils::code_block_utils::ListStartValues, // Private CommonMark start-value input
214    commonmark_ordered_lists_cache: OnceLock<Vec<CommonMarkOrderedListInfo>>, // Lazy source-ordered view
215    pub lines: Vec<LineInfo>,             // Pre-computed line information
216    blockquote_headings: Vec<Option<Box<HeadingInfo>>>, // Container headings, parallel to `lines`
217    links: Vec<ParsedLink<'a>>,           // Pre-parsed links
218    images: Vec<ParsedImage<'a>>,         // Pre-parsed images
219    broken_links: Vec<BrokenLinkInfo>,    // Broken/undefined references
220    footnote_refs: Vec<FootnoteRef>,      // Pre-parsed footnote references
221    reference_defs: Vec<ReferenceDef>,    // Reference definitions
222    reference_defs_map: HashMap<String, usize>, // O(1) lookup by lowercase ID -> index in reference_defs
223    code_spans_cache: OnceLock<Arc<Vec<CodeSpan>>>, // Lazy-loaded inline code spans
224    math_spans_cache: OnceLock<Arc<Vec<MathSpan>>>, // Lazy-loaded math spans ($...$ and $$...$$)
225    math_byte_ranges_cache: OnceLock<Vec<(usize, usize)>>, // Lazy-loaded math byte ranges for is_in_math_context
226    pub list_blocks: Vec<ListBlock>,      // Pre-parsed list blocks
227    pub char_frequency: CharFrequency,    // Character frequency analysis
228    html_tags_cache: OnceLock<Arc<Vec<HtmlTag>>>, // Lazy-loaded HTML tags
229    jsx_component_tags_cache: OnceLock<Arc<Vec<HtmlTag>>>, // Lazy-loaded JSX component tags (shares the html_tags parse)
230    emphasis_spans_cache: OnceLock<Arc<Vec<EmphasisSpan>>>, // Lazy-loaded emphasis spans
231    bare_urls_cache: OnceLock<Arc<Vec<BareUrl>>>,          // Lazy-loaded bare URLs
232    has_mixed_list_nesting_cache: OnceLock<bool>, // Cached result for mixed ordered/unordered list nesting detection
233    html_comment_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed HTML comment ranges
234    pub table_blocks: Vec<crate::utils::table_utils::TableBlock>, // Pre-computed table blocks
235    line_index: crate::utils::range_utils::LineIndex<'a>, // Pre-computed source-location index
236    jinja_ranges: Vec<(usize, usize)>,            // Pre-computed Jinja template ranges ({{ }}, {% %})
237    pub flavor: MarkdownFlavor,                   // Markdown flavor being used
238    source_file: Option<PathBuf>,                 // Source file path (capability exposed through `source_file()`)
239    link_target_policy: Option<LinkTargetPolicy>, // Run-scoped virtual link targets
240    jsx_expression_ranges: Vec<(usize, usize)>,   // Pre-computed JSX expression ranges (MDX: {expression})
241    mdx_comment_ranges: Vec<(usize, usize)>,      // Pre-computed MDX comment ranges ({/* ... */})
242    citation_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc/Quarto citation ranges (@key, [@key])
243    pandoc_div_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc/Quarto div block ranges (::: ... :::)
244    colon_fence_details: Vec<CodeBlockDetail>, // Pre-computed Azure DevOps colon code fences (:::lang ... :::)
245    inline_footnote_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc inline footnote ranges (^[...])
246    pandoc_header_slugs: std::collections::HashSet<String>, // Pre-computed Pandoc implicit header reference slugs
247    example_list_marker_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc example-list marker ranges (@) / (@label)
248    example_reference_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc example reference ranges (@label) inline
249    sub_super_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc subscript (~x~) and superscript (^x^) ranges
250    inline_code_attr_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc inline code attribute ranges (`code`{.lang})
251    bracketed_span_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc bracketed span ranges ([text]{attrs})
252    line_block_ranges: Vec<crate::utils::skip_context::ByteRange>,     // Pre-computed Pandoc line block ranges (| text)
253    pipe_table_caption_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc pipe-table caption ranges (: caption)
254    pandoc_metadata_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc YAML metadata block ranges (--- ... --- or ...)
255    grid_table_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc grid-table ranges (+---+---+)
256    multi_line_table_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc multi-line table ranges
257    shortcode_ranges: Vec<(usize, usize)>, // Pre-computed Hugo/Quarto shortcode ranges ({{< ... >}} and {{% ... %}})
258    link_title_ranges: Vec<(usize, usize)>, // Pre-computed sorted link title byte ranges
259    code_span_byte_ranges: Vec<(usize, usize)>, // Pre-computed code span byte ranges from pulldown-cmark
260    inline_config: InlineConfig,           // Parsed inline configuration comments for rule disabling
261    obsidian_comment_ranges: Vec<(usize, usize)>, // Pre-computed Obsidian comment ranges (%%...%%)
262    unterminated_html_comment: Option<usize>, // Byte offset of a `<!--` with no `-->`
263    unterminated_obsidian_comment: Option<usize>, // Byte offset of a `%%` with no closing `%%`
264    lazy_cont_lines_cache: OnceLock<Arc<Vec<LazyContLine>>>, // Lazy-loaded lazy continuation lines
265    myst_directive_ranges: Vec<(usize, usize)>, // Pre-computed MyST colon directive byte ranges (:::{name} ... :::)
266    myst_comment_ranges: Vec<(usize, usize)>, // Pre-computed MyST comment byte ranges (% comment)
267    myst_role_ranges: Vec<(usize, usize)>, // Pre-computed MyST role byte ranges ({role}`content`)
268    front_matter_end: usize,               // 1-indexed line where front matter ends, 0 if none
269}
270
271/// The byte ranges this document's flavor really holds as code.
272///
273/// An inline directive written inside one of these configures nothing, so there
274/// is nothing to report about it. The answer is read off a full context rather
275/// than scanned out of the text, which is what keeps it identical to the one
276/// `InlineConfig` was built from: a MkDocs admonition body is indented but is
277/// structure, and a scan of the text alone reads it as an indented code block.
278///
279/// Building a context costs a parse, so callers filter with it only once they
280/// hold something to filter.
281pub fn code_block_ranges(content: &str, flavor: MarkdownFlavor) -> Vec<(usize, usize)> {
282    LintContext::new(content, flavor, None).code_blocks
283}
284
285impl<'a> LintContext<'a> {
286    /// The native source path available to filesystem-aware rules.
287    ///
288    /// Virtual adapters intentionally leave this unset even when they provide a
289    /// logical path for configuration matching.
290    pub fn source_file(&self) -> Option<&Path> {
291        self.source_file.as_deref()
292    }
293
294    pub fn link_target_policy(&self) -> Option<&LinkTargetPolicy> {
295        self.link_target_policy.as_ref()
296    }
297
298    pub fn with_link_target_policy(mut self, policy: LinkTargetPolicy) -> Self {
299        self.link_target_policy = Some(policy);
300        self
301    }
302
303    pub fn new(content: &'a str, flavor: MarkdownFlavor, source_file: Option<PathBuf>) -> Self {
304        #[cfg(not(target_arch = "wasm32"))]
305        let profile = std::env::var("RUMDL_PROFILE_QUADRATIC").is_ok();
306
307        let line_offsets = profile_section!("Line offsets", profile, {
308            let mut offsets = vec![0];
309            for (i, c) in content.char_indices() {
310                if c == '\n' {
311                    offsets.push(i + 1);
312                }
313            }
314            offsets
315        });
316
317        // Compute content_lines once for all functions that need it
318        let content_lines: Vec<&str> = content.lines().collect();
319
320        // Detect front matter boundaries once for all functions that need it.
321        // This is the single allowed call site; rules read the cached value
322        // via front_matter_end_line().
323        #[allow(clippy::disallowed_methods)]
324        let front_matter_end = FrontMatterUtils::get_front_matter_end_line(content);
325
326        // Detect code blocks and code spans once and cache them
327        let parse_result = profile_section!(
328            "Code blocks",
329            profile,
330            CodeBlockUtils::detect_code_blocks_and_spans(content)
331        );
332        let mut code_blocks = parse_result.code_blocks;
333        let code_span_ranges = parse_result.code_spans;
334        let code_block_details = parse_result.code_block_details;
335        let strong_spans = parse_result.strong_spans;
336        let line_to_list = parse_result.line_to_list;
337        let list_start_values = parse_result.list_start_values;
338        let html_blocks = parse_result.html_blocks;
339
340        // Container structure the parser cannot see. Computed from the line text
341        // alone, so it is available here, before the line info it corrects.
342        let containers = profile_section!(
343            "Container lines",
344            profile,
345            flavor_detection::detect_container_lines(&content_lines, flavor)
346        );
347
348        // Pre-compute HTML comment ranges ONCE for all operations.
349        // Code-span and code-block ranges are passed so `<!--`/`-->` inside code
350        // are treated as literal text, not comment delimiters that could pair
351        // across code regions on different lines. An indented block over
352        // container content is the parser reading a MkDocs admonition or a
353        // `<div markdown>` body as code, and a comment written there is a real
354        // comment, so only the parts of such a block that a fence really does
355        // hold as code are kept.
356        let comment_code_block_ranges: Vec<(usize, usize)> = code_block_details
357            .iter()
358            .flat_map(|detail| {
359                if detail.is_fenced {
360                    return vec![(detail.start, detail.end)];
361                }
362                let start_line = line_offsets
363                    .partition_point(|&offset| offset <= detail.start)
364                    .saturating_sub(1);
365                let end_line = line_offsets.partition_point(|&offset| offset < detail.end);
366                containers
367                    .code_line_spans_in(start_line..end_line)
368                    .into_iter()
369                    .map(|span| {
370                        let start = line_offsets[span.start].max(detail.start);
371                        let end = line_offsets
372                            .get(span.end)
373                            .copied()
374                            .unwrap_or(content.len())
375                            .min(detail.end);
376                        (start, end)
377                    })
378                    .collect()
379            })
380            .collect();
381        // Front matter is data, not markdown: a `<!--` in a YAML value would
382        // otherwise pair with a `-->` in the body and hide everything between
383        // them from every rule. `front_matter_end` is the 1-indexed closing
384        // delimiter line, so the body starts at the line after it, and a
385        // document without front matter starts at byte 0.
386        let body_start = line_offsets.get(front_matter_end).copied().unwrap_or(content.len());
387        let html_comment_scan = profile_section!(
388            "HTML comment ranges",
389            profile,
390            crate::utils::skip_context::scan_html_comments(
391                content,
392                &code_span_ranges,
393                &comment_code_block_ranges,
394                body_start
395            )
396        );
397        let mut html_comment_ranges = html_comment_scan.ranges;
398        let unterminated_html_comment = html_comment_scan.unterminated;
399
400        // Pre-compute autodoc block ranges (avoids O(n^2) scaling)
401        // Detected for all flavors except AzureDevOps, where `:::` denotes code fences
402        // rather than autodoc directives.
403        let autodoc_ranges = profile_section!("Autodoc block ranges", profile, {
404            if flavor.supports_colon_code_fences() || flavor.supports_myst_directives() {
405                Vec::new()
406            } else {
407                crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
408            }
409        });
410
411        // Pre-compute Pandoc/Quarto div block ranges for Pandoc-compatible flavors
412        let pandoc_div_ranges = profile_section!("Pandoc div ranges", profile, {
413            if flavor.is_pandoc_compatible() {
414                crate::utils::pandoc::detect_div_block_ranges(content)
415            } else {
416                Vec::new()
417            }
418        });
419
420        // Pre-compute PyMdown Blocks ranges for MkDocs flavor (/// ... ///)
421        let pymdown_block_ranges = profile_section!("PyMdown block ranges", profile, {
422            if flavor == MarkdownFlavor::MkDocs {
423                crate::utils::pymdown_blocks::detect_block_ranges(content)
424            } else {
425                Vec::new()
426            }
427        });
428
429        // Pre-compute line information AND emphasis spans (without headings/blockquotes yet)
430        // Emphasis spans are captured during the same pulldown-cmark parse as list detection
431        let skip_ranges = SkipByteRanges {
432            html_comment_ranges: &html_comment_ranges,
433            autodoc_ranges: &autodoc_ranges,
434            pandoc_div_ranges: &pandoc_div_ranges,
435            pymdown_block_ranges: &pymdown_block_ranges,
436        };
437        let (mut lines, emphasis_spans) = profile_section!(
438            "Basic line info",
439            profile,
440            line_computation::compute_basic_line_info(
441                content,
442                &content_lines,
443                &line_offsets,
444                &code_blocks,
445                flavor,
446                &skip_ranges,
447                front_matter_end,
448            )
449        );
450
451        // Detect HTML blocks BEFORE heading detection
452        profile_section!(
453            "HTML blocks",
454            profile,
455            heading_detection::detect_html_blocks(content, &mut lines)
456        );
457
458        // Detect ESM import/export blocks in MDX files BEFORE heading detection
459        profile_section!(
460            "ESM blocks",
461            profile,
462            flavor_detection::detect_esm_blocks(content, &mut lines, flavor)
463        );
464
465        // Detect JSX component blocks in MDX files (e.g. <Tabs>...</Tabs>)
466        profile_section!(
467            "JSX block detection",
468            profile,
469            flavor_detection::detect_jsx_blocks(content, &mut lines, flavor)
470        );
471
472        // Detect JSX expressions and MDX comments in MDX files
473        let (jsx_expression_ranges, mdx_comment_ranges) = profile_section!(
474            "JSX/MDX detection",
475            profile,
476            flavor_detection::detect_jsx_and_mdx_comments(content, &mut lines, flavor, &code_blocks)
477        );
478
479        // Detect `<div markdown>`-style HTML blocks (grid cards, etc.) regardless of flavor.
480        // The `markdown` attribute is an explicit, author-supplied signal; recognizing it
481        // in all flavors keeps `rumdl fmt` from mangling Material grid cards when the
482        // MkDocs flavor isn't active.
483        profile_section!(
484            "Markdown-in-HTML blocks",
485            profile,
486            flavor_detection::detect_markdown_html_blocks(&mut lines, &containers)
487        );
488
489        // Detect MkDocs-specific constructs (admonitions, tabs, definition lists)
490        profile_section!(
491            "MkDocs constructs",
492            profile,
493            flavor_detection::detect_mkdocs_line_info(&content_lines, &mut lines, flavor, &containers)
494        );
495
496        // Detect footnote definitions and correct false code block detection.
497        // With ENABLE_FOOTNOTES, pulldown-cmark correctly parses multi-line
498        // footnotes, but the code block detector may still mark 4-space-indented
499        // footnote continuation lines as indented code blocks.
500        profile_section!(
501            "Footnote definitions",
502            profile,
503            detect_footnote_definitions(content, &mut lines, &line_offsets)
504        );
505
506        // Filter code_blocks to remove false positives from footnote continuation content.
507        // Same pattern as MkDocs/JSX corrections below.
508        {
509            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
510            for &(start, end) in &code_blocks {
511                let start_line = line_offsets
512                    .partition_point(|&offset| offset <= start)
513                    .saturating_sub(1);
514                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
515
516                let mut sub_start: Option<usize> = None;
517                for (i, &offset) in line_offsets[start_line..end_line]
518                    .iter()
519                    .enumerate()
520                    .map(|(j, o)| (j + start_line, o))
521                {
522                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
523                    if is_real_code && sub_start.is_none() {
524                        let byte_start = if i == start_line { start } else { offset };
525                        sub_start = Some(byte_start);
526                    } else if !is_real_code && sub_start.is_some() {
527                        new_code_blocks.push((sub_start.unwrap(), offset));
528                        sub_start = None;
529                    }
530                }
531                if let Some(s) = sub_start {
532                    new_code_blocks.push((s, end));
533                }
534            }
535            code_blocks = new_code_blocks;
536        }
537
538        // Filter code_blocks to remove false positives from MkDocs admonition/tab content
539        // and `<div markdown>` HTML blocks (grid cards).
540        // pulldown-cmark treats 4-space-indented content as indented code blocks, but inside
541        // these containers this is regular markdown content. detect_mkdocs_line_info and
542        // detect_markdown_html_blocks already corrected LineInfo.in_code_block for these lines,
543        // but the code_blocks byte ranges are still stale. We split ranges rather than using
544        // all-or-nothing removal, so fenced code blocks within the containers are preserved.
545        let has_markdown_html = lines.iter().any(|l| l.in_mkdocs_html_markdown);
546        if flavor == MarkdownFlavor::MkDocs || has_markdown_html {
547            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
548            for &(start, end) in &code_blocks {
549                let start_line = line_offsets
550                    .partition_point(|&offset| offset <= start)
551                    .saturating_sub(1);
552                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
553
554                // Walk lines in this range, collecting sub-ranges where in_code_block is true
555                let mut sub_start: Option<usize> = None;
556                for (i, &offset) in line_offsets[start_line..end_line]
557                    .iter()
558                    .enumerate()
559                    .map(|(j, o)| (j + start_line, o))
560                {
561                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
562                    if is_real_code && sub_start.is_none() {
563                        let byte_start = if i == start_line { start } else { offset };
564                        sub_start = Some(byte_start);
565                    } else if !is_real_code && sub_start.is_some() {
566                        new_code_blocks.push((sub_start.unwrap(), offset));
567                        sub_start = None;
568                    }
569                }
570                if let Some(s) = sub_start {
571                    new_code_blocks.push((s, end));
572                }
573            }
574            code_blocks = new_code_blocks;
575        }
576
577        // Filter code_blocks for MDX JSX blocks (same pattern as MkDocs above).
578        // detect_jsx_blocks already corrected LineInfo.in_code_block for indented content
579        // inside JSX component blocks, but code_blocks byte ranges need updating too.
580        if flavor.supports_jsx() {
581            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
582            for &(start, end) in &code_blocks {
583                let start_line = line_offsets
584                    .partition_point(|&offset| offset <= start)
585                    .saturating_sub(1);
586                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
587
588                let mut sub_start: Option<usize> = None;
589                for (i, &offset) in line_offsets[start_line..end_line]
590                    .iter()
591                    .enumerate()
592                    .map(|(j, o)| (j + start_line, o))
593                {
594                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
595                    if is_real_code && sub_start.is_none() {
596                        let byte_start = if i == start_line { start } else { offset };
597                        sub_start = Some(byte_start);
598                    } else if !is_real_code && sub_start.is_some() {
599                        new_code_blocks.push((sub_start.unwrap(), offset));
600                        sub_start = None;
601                    }
602                }
603                if let Some(s) = sub_start {
604                    new_code_blocks.push((s, end));
605                }
606            }
607            code_blocks = new_code_blocks;
608
609            // Add byte ranges for fenced code blocks nested inside a JSX component.
610            // pulldown-cmark classifies the whole component as one HTML block and
611            // emits no code-block range for the fence, so the split loop above
612            // (which can only narrow existing ranges) never adds it. Derive the
613            // ranges from the per-line in_code_block flags detect_jsx_blocks set,
614            // so byte-range consumers (e.g. MD011, MD044) skip the fence content.
615            let mut jsx_fence_ranges: Vec<(usize, usize)> = Vec::new();
616            let mut run: Option<(usize, usize)> = None;
617            for line in &lines {
618                if line.in_jsx_block && line.in_code_block {
619                    let line_end = line.byte_offset + line.byte_len;
620                    match &mut run {
621                        Some((_, end)) => *end = line_end,
622                        None => run = Some((line.byte_offset, line_end)),
623                    }
624                } else if let Some(r) = run.take() {
625                    jsx_fence_ranges.push(r);
626                }
627            }
628            if let Some(r) = run.take() {
629                jsx_fence_ranges.push(r);
630            }
631            if !jsx_fence_ranges.is_empty() {
632                code_blocks.extend(jsx_fence_ranges);
633                code_blocks.sort_by_key(|&(start, _)| start);
634            }
635        }
636
637        // Detect Azure DevOps colon code fences and extend code_blocks so that
638        // all byte-range consumers correctly skip their content.
639        let colon_fence_details = profile_section!(
640            "Azure colon fence detection",
641            profile,
642            flavor_detection::detect_azure_colon_fences(content, &mut lines, flavor)
643        );
644        if !colon_fence_details.is_empty() {
645            code_blocks.extend(colon_fence_details.iter().map(|fence| (fence.start, fence.end)));
646            code_blocks.sort_by_key(|&(start, _)| start);
647        }
648
649        // Detect MyST colon directives (:::{name} ... :::) — these are structural
650        // containers, NOT code blocks. Content inside is linted as markdown.
651        let myst_directive_ranges = profile_section!(
652            "MyST colon directives",
653            profile,
654            flavor_detection::detect_myst_colon_directives(content, &mut lines, flavor)
655        );
656
657        // Detect MyST % comments
658        let myst_comment_ranges = profile_section!(
659            "MyST comments",
660            profile,
661            flavor_detection::detect_myst_comments(content, &mut lines, flavor)
662        );
663
664        // Detect MyST backtick directives (```{name}) and clear in_code_block for
665        // content-bearing directives so their body is linted as markdown.
666        profile_section!(
667            "MyST backtick directives",
668            profile,
669            flavor_detection::detect_myst_backtick_directives(
670                content,
671                &mut lines,
672                flavor,
673                &code_block_details,
674                &line_offsets
675            )
676        );
677
678        // Filter code_blocks to remove false positives from MyST content-bearing directives.
679        // Same pattern as MkDocs admonition filtering.
680        if flavor.supports_myst_directives() {
681            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
682            for &(start, end) in &code_blocks {
683                let start_line = line_offsets
684                    .partition_point(|&offset| offset <= start)
685                    .saturating_sub(1);
686                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
687
688                let mut sub_start: Option<usize> = None;
689                for (i, &offset) in line_offsets[start_line..end_line]
690                    .iter()
691                    .enumerate()
692                    .map(|(j, o)| (j + start_line, o))
693                {
694                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
695                    if is_real_code && sub_start.is_none() {
696                        let byte_start = if i == start_line { start } else { offset };
697                        sub_start = Some(byte_start);
698                    } else if !is_real_code && sub_start.is_some() {
699                        new_code_blocks.push((sub_start.unwrap(), offset));
700                        sub_start = None;
701                    }
702                }
703                if let Some(s) = sub_start {
704                    new_code_blocks.push((s, end));
705                }
706            }
707            code_blocks = new_code_blocks;
708        }
709
710        // Detect kramdown constructs (extension blocks, IALs, ALDs) in kramdown flavor
711        profile_section!(
712            "Kramdown constructs",
713            profile,
714            flavor_detection::detect_kramdown_line_info(content, &mut lines, flavor)
715        );
716
717        // Layer 1: Sanitize content-derived fields inside kramdown extension blocks
718        // so downstream heading detection and collection builders never see them.
719        // This must run BEFORE detect_headings_and_blockquotes to prevent headings
720        // from being populated inside extension blocks.
721        for line in &mut lines {
722            if line.in_kramdown_extension_block {
723                line.list_item = None;
724                line.is_horizontal_rule = false;
725                line.blockquote = None;
726                line.is_kramdown_block_ial = false;
727            }
728        }
729
730        // Detect Obsidian comments (%%...%%) in Obsidian flavor
731        let obsidian_comment_scan = profile_section!(
732            "Obsidian comments",
733            profile,
734            flavor_detection::detect_obsidian_comments(
735                content,
736                &mut lines,
737                flavor,
738                &code_span_ranges,
739                &html_comment_ranges,
740                body_start
741            )
742        );
743        let mut obsidian_comment_ranges = obsidian_comment_scan.ranges;
744        let mut unterminated_obsidian_comment = obsidian_comment_scan.unterminated;
745
746        // An Obsidian comment hides the text it wraps, so a `<!--` inside one is
747        // not a comment opener. The HTML scan cannot know that yet - detecting
748        // Obsidian comments needs its ranges - so the opener it reported is
749        // re-resolved here, now that the comments that hide it are known.
750        let unterminated_html_comment = crate::utils::skip_context::unterminated_html_comment_outside(
751            unterminated_html_comment,
752            &obsidian_comment_ranges,
753            content,
754            &code_span_ranges,
755            &comment_code_block_ranges,
756            body_start,
757        );
758
759        // An unclosed `<!--` that opens an HTML block comments out the rest of
760        // that block, so the text below it is not content any rule should judge.
761        // Without this the block-structure rules and the comment-aware rules
762        // disagree about the same lines: the parser reports no list inside the
763        // block, while a bare URL there is still flagged.
764        //
765        // The opener stays reported either way. This governs what the rest of
766        // the linter sees, not whether the missing closer is raised.
767        //
768        // It waits for the re-resolution above because an opener a `%%` pair
769        // hides is not an opener, and giving that one a range would hide the
770        // rest of the note from every rule.
771        if let Some(range) = unterminated_html_comment.and_then(|opener| {
772            crate::utils::skip_context::unterminated_comment_range(opener, &html_blocks)
773                .or_else(|| container_comment_range(opener, &containers, &lines, content))
774        }) {
775            // Every complete comment starts before the unclosed opener, so this
776            // keeps the ranges sorted for the binary searches over them.
777            html_comment_ranges.push(range);
778
779            // The line flags are computed before the Obsidian comments are
780            // known, so they predate this range. Recomputing them through the
781            // same helper keeps `is_in_html_comment` and the per-line flag
782            // answering alike, which is the agreement this range exists to
783            // create.
784            for line in &mut lines {
785                let text = line.content(content);
786                let content_start = line.byte_offset + line.indent;
787                let content_end = line.byte_offset + text.trim_end().len();
788                line.in_html_comment = crate::utils::skip_context::is_line_entirely_in_html_comment(
789                    &html_comment_ranges,
790                    content_start,
791                    content_end,
792                );
793                line.in_obsidian_comment = false;
794            }
795
796            // The `%%` delimiters the block covers are comment text, so a
797            // delimiter below the block opens a comment rather than closing the
798            // one those appeared to open. Only a rescan pairs them correctly;
799            // dropping the ranges that start inside the block would leave the
800            // delimiter below it paired with nothing and unreported.
801            //
802            // This is the mirror of the re-resolution above, and it needs no
803            // second round: the block starts at or after the opener, so the
804            // pairing before the opener is what it already was, and the opener
805            // resolved against it cannot change.
806            let obsidian_rescan = flavor_detection::detect_obsidian_comments(
807                content,
808                &mut lines,
809                flavor,
810                &code_span_ranges,
811                &html_comment_ranges,
812                body_start,
813            );
814            obsidian_comment_ranges = obsidian_rescan.ranges;
815            unterminated_obsidian_comment = obsidian_rescan.unterminated;
816        }
817
818        // Detect MyST role syntax ({role}`content`)
819        let myst_role_ranges = profile_section!(
820            "MyST roles",
821            profile,
822            flavor_detection::detect_myst_role_ranges(content, &lines, flavor, &code_blocks)
823        );
824
825        // Run pulldown-cmark parse for links, images, and link byte ranges in a single pass.
826        // Link byte ranges are needed for heading detection; links/images are finalized later
827        // after code_spans are available.
828        let pulldown_result = profile_section!(
829            "Links, images & link ranges",
830            profile,
831            link_parser::parse_links_images_pulldown(content, &lines, &code_blocks, flavor, &html_comment_ranges)
832        );
833
834        // Now detect headings and blockquotes
835        let mut blockquote_headings = profile_section!(
836            "Headings & blockquotes",
837            profile,
838            heading_detection::detect_headings_and_blockquotes(
839                &content_lines,
840                &mut lines,
841                flavor,
842                &html_comment_ranges,
843                &pulldown_result.link_byte_ranges,
844                front_matter_end,
845            )
846        );
847
848        // Clear headings that were detected inside kramdown extension blocks
849        for line in &mut lines {
850            if line.in_kramdown_extension_block {
851                line.heading = None;
852            }
853        }
854        for (line, heading) in lines.iter().zip(&mut blockquote_headings) {
855            if line.in_kramdown_extension_block {
856                *heading = None;
857            }
858        }
859
860        // A run of `-`, `*` or `_` is a thematic break only because of the block it
861        // sits in, and that block is known only now: the passes above are what mark
862        // an HTML comment, an HTML block, a math block, an MDX or Obsidian comment,
863        // and the colon fences a flavor reads as code. The flag was computed from the
864        // line text before any of them ran, so it is settled here against the answers
865        // they produced, the way the kramdown sanitization above settles its own.
866        //
867        // Left alone deliberately: containers whose body IS markdown (Pandoc divs,
868        // MkDocs admonitions and tabs, PyMdown blocks, MyST directives) render a
869        // thematic break written in them.
870        for line in &mut lines {
871            if line.is_horizontal_rule
872                && (line.in_code_block
873                    || line.in_html_block
874                    || line.in_html_comment
875                    || line.in_math_block
876                    || line.in_mdx_comment
877                    || line.in_obsidian_comment)
878            {
879                line.is_horizontal_rule = false;
880            }
881        }
882
883        // Parse code spans early so we can exclude them from link/image parsing
884        let mut code_spans = profile_section!(
885            "Code spans",
886            profile,
887            element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
888        );
889
890        // Supplement code spans for MkDocs container content that pulldown-cmark missed.
891        // pulldown-cmark treats 4-space-indented MkDocs content as indented code blocks,
892        // so backtick code spans within admonitions/tabs/markdown HTML are invisible to it.
893        if flavor == MarkdownFlavor::MkDocs {
894            let extra = profile_section!(
895                "MkDocs code spans",
896                profile,
897                element_parsers::scan_mkdocs_container_code_spans(content, &lines, &code_span_ranges,)
898            );
899            if !extra.is_empty() {
900                code_spans.extend(extra);
901                code_spans.sort_by_key(|span| span.byte_offset);
902            }
903        }
904
905        // Supplement code spans for MDX JSX component body content that pulldown-cmark missed.
906        // pulldown-cmark treats JSX component opening tags (e.g. `<ParamField>`) as HTML block
907        // starters, so backtick code spans within component bodies are invisible to the initial
908        // parse.
909        if flavor == MarkdownFlavor::MDX {
910            let extra = profile_section!(
911                "MDX JSX code spans",
912                profile,
913                element_parsers::scan_jsx_block_code_spans(content, &lines, &code_span_ranges)
914            );
915            if !extra.is_empty() {
916                code_spans.extend(extra);
917                code_spans.sort_by_key(|span| span.byte_offset);
918            }
919        }
920
921        // Mark lines that are continuations of multi-line code spans
922        // This is needed for parse_list_blocks to correctly handle list items with multi-line code spans
923        for span in &code_spans {
924            if span.end_line > span.line {
925                // Mark lines after the first line as continuations
926                for line_num in (span.line + 1)..=span.end_line {
927                    if let Some(line_info) = lines.get_mut(line_num - 1) {
928                        line_info.in_code_span_continuation = true;
929                    }
930                }
931            }
932        }
933
934        // Finalize links and images: filter by code_spans and run regex fallbacks
935        let (links, images, broken_links, footnote_refs) = profile_section!(
936            "Links & images finalize",
937            profile,
938            link_parser::finalize_links_and_images(
939                content,
940                &lines,
941                &code_blocks,
942                &code_spans,
943                flavor,
944                &html_comment_ranges,
945                pulldown_result
946            )
947        );
948
949        let reference_defs = profile_section!(
950            "Reference defs",
951            profile,
952            link_parser::parse_reference_defs(content, &lines)
953        );
954
955        let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
956
957        // Compute character frequency for fast content analysis
958        let char_frequency = profile_section!(
959            "Char frequency",
960            profile,
961            line_computation::compute_char_frequency(content)
962        );
963
964        // Pre-compute table blocks for rules that need them (MD013, MD055, MD056, MD058, MD060)
965        let table_blocks = profile_section!(
966            "Table blocks",
967            profile,
968            crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
969                content,
970                &code_blocks,
971                &code_spans,
972                &html_comment_ranges,
973                flavor,
974            )
975        );
976
977        // Layer 2: Filter pre-computed collections to exclude items inside kramdown extension blocks.
978        // Rules that iterate these collections automatically skip kramdown content.
979        let links = links
980            .into_iter()
981            .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
982            .collect::<Vec<_>>();
983        let images = images
984            .into_iter()
985            .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
986            .collect::<Vec<_>>();
987        let broken_links = broken_links
988            .into_iter()
989            .filter(|bl| {
990                // BrokenLinkInfo has span but no line field; find line from byte offset
991                let line_idx = line_offsets
992                    .partition_point(|&offset| offset <= bl.span.start)
993                    .saturating_sub(1);
994                !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
995            })
996            .collect::<Vec<_>>();
997        let footnote_refs = footnote_refs
998            .into_iter()
999            .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1000            .collect::<Vec<_>>();
1001        let reference_defs = reference_defs
1002            .into_iter()
1003            .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1004            .collect::<Vec<_>>();
1005        let list_blocks = list_blocks
1006            .into_iter()
1007            .filter(|block| {
1008                !lines
1009                    .get(block.start_line - 1)
1010                    .is_some_and(|l| l.in_kramdown_extension_block)
1011            })
1012            .collect::<Vec<_>>();
1013        let table_blocks = table_blocks
1014            .into_iter()
1015            .filter(|block| {
1016                // TableBlock.start_line is 0-indexed
1017                !lines
1018                    .get(block.start_line)
1019                    .is_some_and(|l| l.in_kramdown_extension_block)
1020            })
1021            .collect::<Vec<_>>();
1022        let emphasis_spans = emphasis_spans
1023            .into_iter()
1024            .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
1025            .collect::<Vec<_>>();
1026
1027        // Mark lines covered by a list or table block so is_in_list_block /
1028        // is_in_table_block are O(1) reads (mirrors in_html_block) instead of
1029        // scanning the whole block vector on every call.
1030        for block in &list_blocks {
1031            // ListBlock line numbers are 1-indexed.
1032            for line_num in block.start_line..=block.end_line {
1033                if let Some(li) = lines.get_mut(line_num - 1) {
1034                    li.in_list_block = true;
1035                }
1036            }
1037        }
1038        for block in &table_blocks {
1039            // TableBlock line numbers are 0-indexed.
1040            for idx in block.start_line..=block.end_line {
1041                if let Some(li) = lines.get_mut(idx) {
1042                    li.in_table_block = true;
1043                }
1044            }
1045        }
1046
1047        // Rebuild reference_defs_map after filtering
1048        let reference_defs_map: HashMap<String, usize> = reference_defs
1049            .iter()
1050            .enumerate()
1051            .map(|(idx, def)| (def.id.to_lowercase(), idx))
1052            .collect();
1053
1054        // Pre-compute sorted link title byte ranges for binary search
1055        let link_title_ranges: Vec<(usize, usize)> = reference_defs
1056            .iter()
1057            .filter_map(|def| match (def.title_byte_start, def.title_byte_end) {
1058                (Some(start), Some(end)) => Some((start, end)),
1059                _ => None,
1060            })
1061            .collect();
1062
1063        // Reuse already-computed line_offsets and code_blocks instead of re-detecting
1064        let line_index = profile_section!(
1065            "Line index",
1066            profile,
1067            crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
1068                content,
1069                line_offsets.clone(),
1070                &code_blocks,
1071            )
1072        );
1073
1074        // Pre-compute Jinja template ranges once for all rules (eliminates O(n*m) in MD011)
1075        let jinja_ranges = profile_section!(
1076            "Jinja ranges",
1077            profile,
1078            crate::utils::jinja_utils::find_jinja_ranges(content)
1079        );
1080
1081        // Pre-compute Pandoc/Quarto citation ranges for Pandoc-compatible flavors
1082        let citation_ranges = profile_section!("Citation ranges", profile, {
1083            if flavor.is_pandoc_compatible() {
1084                crate::utils::pandoc::find_citation_ranges(content)
1085            } else {
1086                Vec::new()
1087            }
1088        });
1089
1090        // Pre-compute Pandoc inline footnote ranges for Pandoc-compatible flavors
1091        let inline_footnote_ranges = profile_section!("Inline footnote ranges", profile, {
1092            if flavor.is_pandoc_compatible() {
1093                crate::utils::pandoc::detect_inline_footnote_ranges(content)
1094            } else {
1095                Vec::new()
1096            }
1097        });
1098
1099        // Pre-compute Pandoc implicit header reference slugs for Pandoc-compatible flavors
1100        let pandoc_header_slugs = profile_section!("Pandoc header slugs", profile, {
1101            if flavor.is_pandoc_compatible() {
1102                crate::utils::pandoc::collect_pandoc_header_slugs(content)
1103            } else {
1104                std::collections::HashSet::new()
1105            }
1106        });
1107
1108        // Pre-compute Pandoc example-list marker ranges for Pandoc-compatible flavors
1109        let example_list_marker_ranges = profile_section!("Example list markers", profile, {
1110            if flavor.is_pandoc_compatible() {
1111                crate::utils::pandoc::detect_example_list_marker_ranges(content)
1112            } else {
1113                Vec::new()
1114            }
1115        });
1116
1117        // Pre-compute Pandoc example reference ranges for Pandoc-compatible flavors
1118        let example_reference_ranges = profile_section!("Example references", profile, {
1119            if flavor.is_pandoc_compatible() {
1120                crate::utils::pandoc::detect_example_reference_ranges(content, &example_list_marker_ranges)
1121            } else {
1122                Vec::new()
1123            }
1124        });
1125
1126        // Pre-compute Pandoc subscript (~x~) and superscript (^x^) ranges
1127        let sub_super_ranges = profile_section!("Subscript/superscript ranges", profile, {
1128            if flavor.is_pandoc_compatible() {
1129                crate::utils::pandoc::detect_subscript_superscript_ranges(content)
1130            } else {
1131                Vec::new()
1132            }
1133        });
1134
1135        // Pre-compute Pandoc inline code attribute ranges (`code`{.lang}) for Pandoc-compatible flavors
1136        let inline_code_attr_ranges = profile_section!("Inline code attribute ranges", profile, {
1137            if flavor.is_pandoc_compatible() {
1138                crate::utils::pandoc::detect_inline_code_attr_ranges(content)
1139            } else {
1140                Vec::new()
1141            }
1142        });
1143
1144        // Pre-compute Pandoc bracketed span ranges ([text]{attrs}) for Pandoc-compatible flavors
1145        let bracketed_span_ranges = profile_section!("Bracketed span ranges", profile, {
1146            if flavor.is_pandoc_compatible() {
1147                crate::utils::pandoc::detect_bracketed_span_ranges(content)
1148            } else {
1149                Vec::new()
1150            }
1151        });
1152
1153        // Pre-compute Pandoc line block ranges (| text) for Pandoc-compatible flavors
1154        let line_block_ranges = profile_section!("Line block ranges", profile, {
1155            if flavor.is_pandoc_compatible() {
1156                crate::utils::pandoc::detect_line_block_ranges(content)
1157            } else {
1158                Vec::new()
1159            }
1160        });
1161
1162        // Pre-compute Pandoc pipe-table caption ranges (: caption) for Pandoc-compatible flavors
1163        let pipe_table_caption_ranges = profile_section!("Pipe-table caption ranges", profile, {
1164            if flavor.is_pandoc_compatible() {
1165                crate::utils::pandoc::detect_pipe_table_caption_ranges(content)
1166            } else {
1167                Vec::new()
1168            }
1169        });
1170
1171        // Pre-compute Pandoc YAML metadata block ranges (--- ... --- or ...) for Pandoc-compatible flavors
1172        let pandoc_metadata_ranges = profile_section!("Pandoc metadata ranges", profile, {
1173            if flavor.is_pandoc_compatible() {
1174                crate::utils::pandoc::detect_yaml_metadata_block_ranges(content)
1175            } else {
1176                Vec::new()
1177            }
1178        });
1179
1180        // Pre-compute Pandoc grid-table ranges (+---+---+) for Pandoc-compatible flavors
1181        let grid_table_ranges = profile_section!("Grid table ranges", profile, {
1182            if flavor.is_pandoc_compatible() {
1183                crate::utils::pandoc::detect_grid_table_ranges(content)
1184            } else {
1185                Vec::new()
1186            }
1187        });
1188
1189        // Pre-compute Pandoc multi-line table ranges for Pandoc-compatible flavors
1190        let multi_line_table_ranges = profile_section!("Multi-line table ranges", profile, {
1191            if flavor.is_pandoc_compatible() {
1192                crate::utils::pandoc::detect_multi_line_table_ranges(content)
1193            } else {
1194                Vec::new()
1195            }
1196        });
1197
1198        // Pre-compute Hugo/Quarto shortcode ranges ({{< ... >}} and {{% ... %}})
1199        let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
1200            use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
1201            let mut ranges = Vec::new();
1202            for mat in HUGO_SHORTCODE_REGEX.find_iter(content) {
1203                ranges.push((mat.start(), mat.end()));
1204            }
1205            ranges
1206        });
1207
1208        let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
1209        Self {
1210            content,
1211            content_lines,
1212            line_offsets,
1213            code_blocks,
1214            code_block_details,
1215            strong_spans,
1216            line_to_list,
1217            list_start_values,
1218            commonmark_ordered_lists_cache: OnceLock::new(),
1219            lines,
1220            blockquote_headings,
1221            links,
1222            images,
1223            broken_links,
1224            footnote_refs,
1225            reference_defs,
1226            reference_defs_map,
1227            code_spans_cache: OnceLock::from(Arc::new(code_spans)),
1228            math_spans_cache: OnceLock::new(),       // Lazy-loaded on first access
1229            math_byte_ranges_cache: OnceLock::new(), // Lazy-loaded on first access
1230            list_blocks,
1231            char_frequency,
1232            html_tags_cache: OnceLock::new(),
1233            jsx_component_tags_cache: OnceLock::new(),
1234            emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
1235            bare_urls_cache: OnceLock::new(),
1236            has_mixed_list_nesting_cache: OnceLock::new(),
1237            html_comment_ranges,
1238            table_blocks,
1239            line_index,
1240            jinja_ranges,
1241            flavor,
1242            source_file,
1243            link_target_policy: None,
1244            jsx_expression_ranges,
1245            mdx_comment_ranges,
1246            citation_ranges,
1247            pandoc_div_ranges,
1248            colon_fence_details,
1249            inline_footnote_ranges,
1250            pandoc_header_slugs,
1251            example_list_marker_ranges,
1252            example_reference_ranges,
1253            sub_super_ranges,
1254            inline_code_attr_ranges,
1255            bracketed_span_ranges,
1256            line_block_ranges,
1257            pipe_table_caption_ranges,
1258            pandoc_metadata_ranges,
1259            grid_table_ranges,
1260            multi_line_table_ranges,
1261            shortcode_ranges,
1262            link_title_ranges,
1263            code_span_byte_ranges: code_span_ranges,
1264            inline_config,
1265            obsidian_comment_ranges,
1266            unterminated_html_comment,
1267            unterminated_obsidian_comment,
1268            lazy_cont_lines_cache: OnceLock::new(),
1269            myst_directive_ranges,
1270            myst_comment_ranges,
1271            myst_role_ranges,
1272            front_matter_end,
1273        }
1274    }
1275
1276    /// The 1-indexed line number where front matter ends (the closing
1277    /// delimiter line), or 0 when the document has no front matter.
1278    /// Computed once in `new()`; rules must use this instead of re-scanning
1279    /// the content with `FrontMatterUtils`.
1280    pub fn front_matter_end_line(&self) -> usize {
1281        self.front_matter_end
1282    }
1283
1284    /// Binary search for whether `pos` falls inside any range in a sorted, non-overlapping
1285    /// slice of `(start, end)` byte ranges. O(log n) instead of O(n).
1286    #[inline]
1287    fn binary_search_ranges(ranges: &[(usize, usize)], pos: usize) -> bool {
1288        // Find the rightmost range whose start <= pos
1289        let idx = ranges.partition_point(|&(start, _)| start <= pos);
1290        // If idx == 0, no range starts at or before pos
1291        idx > 0 && pos < ranges[idx - 1].1
1292    }
1293
1294    /// Check if a byte position is within a code span. O(log n).
1295    pub fn is_in_code_span_byte(&self, pos: usize) -> bool {
1296        Self::binary_search_ranges(&self.code_span_byte_ranges, pos)
1297    }
1298
1299    /// Check if `pos` is inside any link byte range. O(log n).
1300    pub fn is_in_link(&self, pos: usize) -> bool {
1301        self.link_containing(pos).is_some() || self.image_containing(pos).is_some() || self.is_in_reference_def(pos)
1302    }
1303
1304    /// Check if `pos`` is within a bare URL
1305    pub fn is_in_bare_url(&self, pos: usize) -> bool {
1306        let bare_urls = self.bare_urls();
1307        // Binary search (sorted by byte_offset) for the candidate containing byte_pos
1308        let idx = bare_urls.partition_point(|url| url.byte_offset <= pos);
1309        idx > 0 && pos < bare_urls[idx - 1].byte_end
1310    }
1311
1312    /// Get parsed inline configuration state.
1313    pub fn inline_config(&self) -> &InlineConfig {
1314        &self.inline_config
1315    }
1316
1317    /// Azure DevOps colon code fences (`:::lang … :::`), each with its byte range
1318    /// and the opener's info string. These are detected outside the CommonMark
1319    /// parse, so they never appear in `code_block_details`. Empty for all other
1320    /// flavors.
1321    pub fn colon_fence_details(&self) -> &[CodeBlockDetail] {
1322        &self.colon_fence_details
1323    }
1324
1325    /// Get pre-split content lines, avoiding repeated `content.lines().collect()` allocations.
1326    ///
1327    /// Lines are 0-indexed (line 0 corresponds to line number 1 in the document).
1328    pub fn raw_lines(&self) -> &[&'a str] {
1329        &self.content_lines
1330    }
1331
1332    /// Check if a rule is disabled at a specific line number (1-indexed)
1333    ///
1334    /// This method checks both persistent disable comments (<!-- rumdl-disable -->)
1335    /// and line-specific comments (<!-- rumdl-disable-line -->, <!-- rumdl-disable-next-line -->).
1336    pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
1337        self.inline_config.is_rule_disabled(rule_name, line_number)
1338    }
1339
1340    /// Get code spans - computed lazily on first access
1341    pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
1342        Arc::clone(
1343            self.code_spans_cache
1344                .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
1345        )
1346    }
1347
1348    /// Math byte ranges (`$...$` inline and `$$...$$` display), computed once and
1349    /// cached. Used by `is_in_math_context`; without the cache that helper
1350    /// rescanned the whole document on every call.
1351    pub fn math_byte_ranges(&self) -> &[(usize, usize)] {
1352        self.math_byte_ranges_cache
1353            .get_or_init(|| crate::utils::skip_context::math_byte_ranges(self.content))
1354    }
1355
1356    /// Get math spans - computed lazily on first access
1357    pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
1358        Arc::clone(
1359            self.math_spans_cache
1360                .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
1361        )
1362    }
1363
1364    /// Check if a byte position is within a math span (inline $...$ or display $$...$$)
1365    pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
1366        let math_spans = self.math_spans();
1367        // Binary search: find the last span whose byte_offset <= byte_pos
1368        let idx = math_spans.partition_point(|span| span.byte_offset <= byte_pos);
1369        idx > 0 && byte_pos < math_spans[idx - 1].byte_end
1370    }
1371
1372    /// Get HTML comment ranges - pre-computed during LintContext construction
1373    pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1374        &self.html_comment_ranges
1375    }
1376
1377    /// Byte offset of a `<!--` that no `-->` closes, if the document has one.
1378    ///
1379    /// Everything after it is inside the comment as far as the parser is
1380    /// concerned, so no rule sees that text.
1381    pub fn unterminated_html_comment(&self) -> Option<usize> {
1382        self.unterminated_html_comment
1383    }
1384
1385    /// Byte offset of a `%%` that no second `%%` closes, if the document has
1386    /// one. Always `None` outside the Obsidian flavor, where `%%` is ordinary
1387    /// text rather than a comment delimiter.
1388    pub fn unterminated_obsidian_comment(&self) -> Option<usize> {
1389        self.unterminated_obsidian_comment
1390    }
1391
1392    /// Check if a byte position is inside an Obsidian comment
1393    ///
1394    /// Returns false for non-Obsidian flavors.
1395    pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
1396        Self::binary_search_ranges(&self.obsidian_comment_ranges, byte_pos)
1397    }
1398
1399    /// Check if a line/column position is inside an Obsidian comment
1400    ///
1401    /// Line number is 1-indexed, column is 1-indexed.
1402    /// Returns false for non-Obsidian flavors.
1403    pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
1404        if self.obsidian_comment_ranges.is_empty() {
1405            return false;
1406        }
1407
1408        // Convert line/column (1-indexed, char-based) to byte position
1409        let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
1410        self.is_in_obsidian_comment(byte_pos)
1411    }
1412
1413    /// Get byte ranges of MyST colon directive blocks
1414    pub fn myst_directive_ranges(&self) -> &[(usize, usize)] {
1415        &self.myst_directive_ranges
1416    }
1417
1418    /// Check if a byte position is inside a MyST role (`{role}`content``)
1419    pub fn is_in_myst_role(&self, byte_pos: usize) -> bool {
1420        Self::binary_search_ranges(&self.myst_role_ranges, byte_pos)
1421    }
1422
1423    /// Check if a byte position is inside a MyST comment (`% comment`)
1424    pub fn is_in_myst_comment(&self, byte_pos: usize) -> bool {
1425        Self::binary_search_ranges(&self.myst_comment_ranges, byte_pos)
1426    }
1427
1428    /// Check if a line (1-indexed) is a MyST colon-fence directive opener (`:::{name} ...`).
1429    ///
1430    /// The text after `{name}` on an opener is the directive's argument (an opaque
1431    /// path, URL, or label), not markdown prose. Rules that reformat prose should
1432    /// skip these lines. Returns false for non-MyST flavors and for directive body
1433    /// or closer lines.
1434    pub fn is_myst_colon_directive_opener_line(&self, line_num: usize) -> bool {
1435        if !self.flavor.supports_myst_directives() {
1436            return false;
1437        }
1438        self.lines.get(line_num.wrapping_sub(1)).is_some_and(|info| {
1439            info.in_myst_directive
1440                && flavor_detection::myst_colon_directive_opener(info.content(self.content)).is_some()
1441        })
1442    }
1443
1444    /// Drop tags that live inside kramdown extension blocks, preserving order.
1445    fn filter_kramdown_tags(&self, tags: Vec<HtmlTag>) -> Vec<HtmlTag> {
1446        tags.into_iter()
1447            .filter(|tag| {
1448                !self
1449                    .lines
1450                    .get(tag.line - 1)
1451                    .is_some_and(|l| l.in_kramdown_extension_block)
1452            })
1453            .collect()
1454    }
1455
1456    /// Get HTML tags - computed lazily on first access.
1457    ///
1458    /// JSX component tags (e.g. `<Card .../>`) are excluded so HTML-specific rules
1459    /// keep ignoring them; use [`Self::jsx_component_tags`] to access those. The
1460    /// single underlying parse populates both caches at once.
1461    pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
1462        Arc::clone(self.html_tags_cache.get_or_init(|| {
1463            let (html_tags, jsx_component_tags) =
1464                element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
1465            // Populate the JSX-component cache from the same parse so it is built once.
1466            let _ = self
1467                .jsx_component_tags_cache
1468                .set(Arc::new(self.filter_kramdown_tags(jsx_component_tags)));
1469            Arc::new(self.filter_kramdown_tags(html_tags))
1470        }))
1471    }
1472
1473    /// Get JSX component tags (e.g. `<Card .../>`) - computed lazily, sharing the
1474    /// HTML-tag parse. Always empty for flavors without JSX support.
1475    pub fn jsx_component_tags(&self) -> Arc<Vec<HtmlTag>> {
1476        if let Some(cached) = self.jsx_component_tags_cache.get() {
1477            return Arc::clone(cached);
1478        }
1479        // Trigger the shared parse, which also fills jsx_component_tags_cache.
1480        let _ = self.html_tags();
1481        Arc::clone(
1482            self.jsx_component_tags_cache
1483                .get()
1484                .expect("html_tags() populates jsx_component_tags_cache"),
1485        )
1486    }
1487
1488    /// Get emphasis spans - pre-computed during construction
1489    pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
1490        Arc::clone(
1491            self.emphasis_spans_cache
1492                .get()
1493                .expect("emphasis_spans_cache initialized during construction"),
1494        )
1495    }
1496
1497    /// Get bare URLs - computed lazily on first access
1498    pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
1499        Arc::clone(self.bare_urls_cache.get_or_init(|| {
1500            Arc::new(element_parsers::parse_bare_urls(
1501                self.content,
1502                &self.lines,
1503                &self.code_blocks,
1504            ))
1505        }))
1506    }
1507
1508    /// Get lazy continuation lines - computed lazily on first access
1509    pub fn lazy_continuation_lines(&self) -> Arc<Vec<LazyContLine>> {
1510        Arc::clone(self.lazy_cont_lines_cache.get_or_init(|| {
1511            Arc::new(element_parsers::detect_lazy_continuation_lines(
1512                self.content,
1513                &self.lines,
1514                &self.line_offsets,
1515            ))
1516        }))
1517    }
1518
1519    /// Check if document has mixed ordered/unordered list nesting.
1520    /// Result is cached after first computation (document-level invariant).
1521    /// This is used by MD007 for smart style auto-detection.
1522    pub fn has_mixed_list_nesting(&self) -> bool {
1523        *self
1524            .has_mixed_list_nesting_cache
1525            .get_or_init(|| self.compute_mixed_list_nesting())
1526    }
1527
1528    /// Internal computation for mixed list nesting (only called once per LintContext).
1529    fn compute_mixed_list_nesting(&self) -> bool {
1530        // Track parent list items by their marker position and type
1531        // Using marker_column instead of indent because it works correctly
1532        // for blockquoted content where indent doesn't account for the prefix
1533        // Stack stores: (marker_column, is_ordered)
1534        let mut stack: Vec<(usize, bool)> = Vec::new();
1535        let mut last_was_blank = false;
1536
1537        for line_info in &self.lines {
1538            // Skip non-content lines (code blocks, frontmatter, HTML comments, etc.)
1539            if line_info.in_code_block
1540                || line_info.in_front_matter
1541                || line_info.in_mkdocstrings
1542                || line_info.in_html_comment
1543                || line_info.in_mdx_comment
1544                || line_info.in_esm_block
1545            {
1546                continue;
1547            }
1548
1549            // OPTIMIZATION: Use pre-computed is_blank instead of content().trim()
1550            if line_info.is_blank {
1551                last_was_blank = true;
1552                continue;
1553            }
1554
1555            if let Some(list_item) = &line_info.list_item {
1556                // Normalize column 1 to column 0 (consistent with MD007 check function)
1557                let current_pos = if list_item.marker_column == 1 {
1558                    0
1559                } else {
1560                    list_item.marker_column
1561                };
1562
1563                // If there was a blank line and this item is at root level, reset stack
1564                if last_was_blank && current_pos == 0 {
1565                    stack.clear();
1566                }
1567                last_was_blank = false;
1568
1569                // Pop items at same or greater position (they're siblings or deeper, not parents)
1570                while let Some(&(pos, _)) = stack.last() {
1571                    if pos >= current_pos {
1572                        stack.pop();
1573                    } else {
1574                        break;
1575                    }
1576                }
1577
1578                // Check if immediate parent has different type - this is mixed nesting
1579                if let Some(&(_, parent_is_ordered)) = stack.last()
1580                    && parent_is_ordered != list_item.is_ordered
1581                {
1582                    return true; // Found mixed nesting - early exit
1583                }
1584
1585                stack.push((current_pos, list_item.is_ordered));
1586            } else {
1587                // Non-list line (but not blank) - could be paragraph or other content
1588                last_was_blank = false;
1589            }
1590        }
1591
1592        false
1593    }
1594
1595    /// Map a byte offset to (line, column).
1596    ///
1597    /// The column is a 1-indexed *character* offset within the line (rumdl's
1598    /// diagnostic convention), not a byte offset, so it is correct on lines
1599    /// containing multi-byte UTF-8 characters.
1600    pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
1601        match self.line_offsets.binary_search(&offset) {
1602            Ok(line) => (line + 1, 1),
1603            Err(line) => {
1604                let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
1605                // Convert the byte offset within the line to a character column.
1606                let col = byte_to_char_count(&self.content[line_start..], offset.saturating_sub(line_start));
1607                (line, col)
1608            }
1609        }
1610    }
1611
1612    /// Return the byte offset at which a 1-indexed source line starts.
1613    ///
1614    /// This is the inverse-facing half of [`Self::offset_to_line_col`]. Keeping
1615    /// both conversions on the document prevents rules from depending on the
1616    /// line-index representation or reconstructing it independently.
1617    pub fn line_start_byte(&self, line_number: usize) -> Option<usize> {
1618        self.line_index.get_line_start_byte(line_number)
1619    }
1620
1621    /// Return an empty byte range at a 1-indexed line and character column.
1622    ///
1623    /// Columns are character offsets, not UTF-8 byte offsets. Positions past
1624    /// the end of a line clamp to the end of its content; missing lines clamp to
1625    /// the end of the document.
1626    pub fn line_column_byte_range(&self, line_number: usize, column: usize) -> Range<usize> {
1627        self.line_index.line_col_to_byte_range(line_number, column)
1628    }
1629
1630    /// Return a byte range beginning at a 1-indexed line and character column.
1631    ///
1632    /// `length` is measured in characters. The result never crosses the line's
1633    /// content boundary and excludes its line ending.
1634    pub fn line_column_byte_range_with_length(&self, line_number: usize, column: usize, length: usize) -> Range<usize> {
1635        self.line_index
1636            .line_col_to_byte_range_with_length(line_number, column, length)
1637    }
1638
1639    /// Return the byte range of a complete 1-indexed line, including its line
1640    /// ending when one is present.
1641    pub fn whole_line_byte_range(&self, line_number: usize) -> Range<usize> {
1642        self.line_index.whole_line_range(line_number)
1643    }
1644
1645    /// Return the byte range between two 1-indexed character columns on a line.
1646    ///
1647    /// The range excludes the line ending and clamps both columns to valid
1648    /// character boundaries in the line content.
1649    pub fn line_text_byte_range(&self, line_number: usize, start_column: usize, end_column: usize) -> Range<usize> {
1650        self.line_index.line_text_range(line_number, start_column, end_column)
1651    }
1652
1653    /// Return the byte range of a 1-indexed line's content, excluding its line
1654    /// ending.
1655    pub fn line_content_byte_range(&self, line_number: usize) -> Range<usize> {
1656        self.line_index.line_content_range(line_number)
1657    }
1658
1659    /// Return the byte range spanning complete 1-indexed lines, inclusive.
1660    pub fn line_span_byte_range(&self, start_line: usize, end_line: usize) -> Range<usize> {
1661        self.line_index.multi_line_range(start_line, end_line)
1662    }
1663
1664    /// Check if a position is within a code block or code span. O(log n).
1665    pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
1666        // Check code blocks first (already uses binary search internally)
1667        if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
1668            return true;
1669        }
1670
1671        // Check inline code spans via binary search
1672        self.is_byte_offset_in_code_span(pos)
1673    }
1674
1675    /// Get line information by line number (1-indexed)
1676    pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
1677        if line_num > 0 {
1678            self.lines.get(line_num - 1)
1679        } else {
1680            None
1681        }
1682    }
1683
1684    /// Parsed links in document order.
1685    pub fn links(&self) -> &[ParsedLink<'a>] {
1686        &self.links
1687    }
1688
1689    /// Parsed images in document order.
1690    pub fn images(&self) -> &[ParsedImage<'a>] {
1691        &self.images
1692    }
1693
1694    /// Broken or undefined reference links in document order.
1695    pub fn broken_links(&self) -> &[BrokenLinkInfo] {
1696        &self.broken_links
1697    }
1698
1699    /// Parsed footnote references in document order.
1700    pub fn footnote_references(&self) -> &[FootnoteRef] {
1701        &self.footnote_refs
1702    }
1703
1704    /// Parsed reference definitions in document order.
1705    pub fn reference_definitions(&self) -> &[ReferenceDef] {
1706        &self.reference_defs
1707    }
1708
1709    /// Links whose opening delimiter starts on `line_number` (1-indexed).
1710    pub fn links_on_line(&self, line_number: usize) -> &[ParsedLink<'a>] {
1711        let start = self.links.partition_point(|link| link.line < line_number);
1712        let end = self.links.partition_point(|link| link.line <= line_number);
1713        &self.links[start..end]
1714    }
1715
1716    /// Images whose opening delimiter starts on `line_number` (1-indexed).
1717    pub fn images_on_line(&self, line_number: usize) -> &[ParsedImage<'a>] {
1718        let start = self.images.partition_point(|image| image.line < line_number);
1719        let end = self.images.partition_point(|image| image.line <= line_number);
1720        &self.images[start..end]
1721    }
1722
1723    /// Find the link that starts at an exact byte offset. O(log n).
1724    pub fn link_starting_at(&self, byte_offset: usize) -> Option<&ParsedLink<'a>> {
1725        self.links
1726            .binary_search_by_key(&byte_offset, |link| link.byte_offset)
1727            .ok()
1728            .map(|index| &self.links[index])
1729    }
1730
1731    /// Find the image that starts at an exact byte offset. O(log n).
1732    pub fn image_starting_at(&self, byte_offset: usize) -> Option<&ParsedImage<'a>> {
1733        self.images
1734            .binary_search_by_key(&byte_offset, |image| image.byte_offset)
1735            .ok()
1736            .map(|index| &self.images[index])
1737    }
1738
1739    /// Find the parsed link containing `byte_offset`. O(log n).
1740    pub fn link_containing(&self, byte_offset: usize) -> Option<&ParsedLink<'a>> {
1741        let index = self.links.partition_point(|link| link.byte_offset <= byte_offset);
1742        self.links
1743            .get(index.checked_sub(1)?)
1744            .filter(|link| byte_offset < link.byte_end)
1745    }
1746
1747    /// Find the parsed image containing `byte_offset`. O(log n).
1748    pub fn image_containing(&self, byte_offset: usize) -> Option<&ParsedImage<'a>> {
1749        let index = self.images.partition_point(|image| image.byte_offset <= byte_offset);
1750        self.images
1751            .get(index.checked_sub(1)?)
1752            .filter(|image| byte_offset < image.byte_end)
1753    }
1754
1755    /// Links that start at or before `byte_offset`, in document order. O(log n).
1756    pub fn links_starting_before_or_at(&self, byte_offset: usize) -> &[ParsedLink<'a>] {
1757        let end = self.links.partition_point(|link| link.byte_offset <= byte_offset);
1758        &self.links[..end]
1759    }
1760
1761    /// Find a reference definition by its case-insensitive identifier.
1762    pub fn reference_definition(&self, ref_id: &str) -> Option<&ReferenceDef> {
1763        let normalized_id = ref_id.to_lowercase();
1764        self.reference_defs_map
1765            .get(&normalized_id)
1766            .map(|&index| &self.reference_defs[index])
1767    }
1768
1769    /// Get URL for a reference link/image by its ID (O(1) lookup via HashMap)
1770    pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
1771        self.reference_definition(ref_id)
1772            .map(|definition| definition.url.as_str())
1773    }
1774
1775    /// Check if a line is part of a list block
1776    pub fn is_in_list_block(&self, line_num: usize) -> bool {
1777        if line_num == 0 || line_num > self.lines.len() {
1778            return false;
1779        }
1780        self.lines[line_num - 1].in_list_block
1781    }
1782
1783    /// Check if a line is within an HTML block
1784    pub fn is_in_html_block(&self, line_num: usize) -> bool {
1785        if line_num == 0 || line_num > self.lines.len() {
1786            return false;
1787        }
1788        self.lines[line_num - 1].in_html_block
1789    }
1790
1791    /// Check if a 1-indexed line number is inside a GFM table block.
1792    ///
1793    /// Returns `true` for the header line, delimiter line, and all body rows.
1794    /// `TableBlock` spans are stored 0-indexed; this helper accepts the
1795    /// 1-indexed line numbers used elsewhere in the rule API.
1796    pub fn is_in_table_block(&self, line_num: usize) -> bool {
1797        if line_num == 0 || line_num > self.lines.len() {
1798            return false;
1799        }
1800        self.lines[line_num - 1].in_table_block
1801    }
1802
1803    /// Check if a line and column is within a code span
1804    pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
1805        if line_num == 0 || line_num > self.lines.len() {
1806            return false;
1807        }
1808
1809        // Use the code spans cache to check
1810        // Note: col is 1-indexed from caller, but span.start_col and span.end_col are 0-indexed
1811        // Convert col to 0-indexed for comparison
1812        let col_0indexed = if col > 0 { col - 1 } else { 0 };
1813        let code_spans = self.code_spans();
1814        code_spans.iter().any(|span| {
1815            // Check if line is within the span's line range
1816            if line_num < span.line || line_num > span.end_line {
1817                return false;
1818            }
1819
1820            if span.line == span.end_line {
1821                // Single-line span: check column bounds
1822                col_0indexed >= span.start_col && col_0indexed < span.end_col
1823            } else if line_num == span.line {
1824                // First line of multi-line span: anything after start_col is in span
1825                col_0indexed >= span.start_col
1826            } else if line_num == span.end_line {
1827                // Last line of multi-line span: anything before end_col is in span
1828                col_0indexed < span.end_col
1829            } else {
1830                // Middle line of multi-line span: entire line is in span
1831                true
1832            }
1833        })
1834    }
1835
1836    /// Check if a byte offset is within a code span. O(log n).
1837    #[inline]
1838    pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
1839        let code_spans = self.code_spans();
1840        let idx = code_spans.partition_point(|span| span.byte_offset <= byte_offset);
1841        idx > 0 && byte_offset < code_spans[idx - 1].byte_end
1842    }
1843
1844    /// Check if a byte position is within a reference definition. O(log n).
1845    #[inline]
1846    pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
1847        let idx = self.reference_defs.partition_point(|rd| rd.byte_offset <= byte_pos);
1848        idx > 0 && byte_pos < self.reference_defs[idx - 1].byte_end
1849    }
1850
1851    /// Check if a byte position is within an HTML comment. O(log n).
1852    #[inline]
1853    pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
1854        let idx = self.html_comment_ranges.partition_point(|r| r.start <= byte_pos);
1855        idx > 0 && byte_pos < self.html_comment_ranges[idx - 1].end
1856    }
1857
1858    /// Check if a byte position is within an HTML tag (including multiline tags).
1859    /// Uses the pre-parsed html_tags which correctly handles tags spanning multiple lines. O(log n).
1860    #[inline]
1861    pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
1862        let tags = self.html_tags();
1863        let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1864        idx > 0 && byte_pos < tags[idx - 1].byte_end
1865    }
1866
1867    /// Check if a byte position is within a JSX component tag (e.g. `<Card .../>`),
1868    /// including its attribute values and multiline tags. Always false for flavors
1869    /// without JSX support. O(log n).
1870    #[inline]
1871    pub fn is_in_jsx_component_tag(&self, byte_pos: usize) -> bool {
1872        if !self.flavor.supports_jsx() {
1873            return false;
1874        }
1875        let tags = self.jsx_component_tags();
1876        let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1877        idx > 0 && byte_pos < tags[idx - 1].byte_end
1878    }
1879
1880    /// Check if a byte position is within a Jinja template ({{ }} or {% %}). O(log n).
1881    pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
1882        Self::binary_search_ranges(&self.jinja_ranges, byte_pos)
1883    }
1884
1885    /// Check if a byte position is within a JSX expression (MDX: {expression}). O(log n).
1886    #[inline]
1887    pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
1888        Self::binary_search_ranges(&self.jsx_expression_ranges, byte_pos)
1889    }
1890
1891    /// Check if a byte position is within an MDX comment ({/* ... */}). O(log n).
1892    #[inline]
1893    pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
1894        Self::binary_search_ranges(&self.mdx_comment_ranges, byte_pos)
1895    }
1896
1897    /// Check if a byte position is within a Pandoc/Quarto citation (`@key` or `[@key]`).
1898    /// Active for Pandoc-compatible flavors. O(log n).
1899    #[inline]
1900    pub fn is_in_citation(&self, byte_pos: usize) -> bool {
1901        let idx = self.citation_ranges.partition_point(|r| r.start <= byte_pos);
1902        idx > 0 && byte_pos < self.citation_ranges[idx - 1].end
1903    }
1904
1905    /// Pre-computed Pandoc/Quarto citation ranges.
1906    #[inline]
1907    pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1908        &self.citation_ranges
1909    }
1910
1911    /// Check if a byte position is within a Pandoc/Quarto div block (`::: ... :::`).
1912    /// Active for Pandoc-compatible flavors. O(log n) via binary search over sorted ranges.
1913    #[inline]
1914    pub fn is_in_div_block(&self, byte_pos: usize) -> bool {
1915        let idx = self.pandoc_div_ranges.partition_point(|r| r.start <= byte_pos);
1916        idx > 0 && byte_pos < self.pandoc_div_ranges[idx - 1].end
1917    }
1918
1919    /// Check if a byte position is within a Pandoc inline footnote (`^[note text]`).
1920    /// Active for Pandoc-compatible flavors. O(log n).
1921    #[inline]
1922    pub fn is_in_inline_footnote(&self, byte_pos: usize) -> bool {
1923        let idx = self.inline_footnote_ranges.partition_point(|r| r.start <= byte_pos);
1924        idx > 0 && byte_pos < self.inline_footnote_ranges[idx - 1].end
1925    }
1926
1927    /// Check if a byte position is within a Pandoc example-list marker (`(@)` /
1928    /// `(@label)` at line start). Active for Pandoc-compatible flavors. O(log n).
1929    #[inline]
1930    pub fn is_in_example_list_marker(&self, byte_pos: usize) -> bool {
1931        let idx = self.example_list_marker_ranges.partition_point(|r| r.start <= byte_pos);
1932        idx > 0 && byte_pos < self.example_list_marker_ranges[idx - 1].end
1933    }
1934
1935    /// Check if a byte position is within a Pandoc example reference (`(@label)`
1936    /// inline). Active for Pandoc-compatible flavors. O(log n).
1937    #[inline]
1938    pub fn is_in_example_reference(&self, byte_pos: usize) -> bool {
1939        let idx = self.example_reference_ranges.partition_point(|r| r.start <= byte_pos);
1940        idx > 0 && byte_pos < self.example_reference_ranges[idx - 1].end
1941    }
1942
1943    /// Check if a byte position is within a Pandoc subscript (`~x~`) or
1944    /// superscript (`^x^`) span. Active for Pandoc-compatible flavors. O(log n).
1945    #[inline]
1946    pub fn is_in_subscript_or_superscript(&self, byte_pos: usize) -> bool {
1947        let idx = self.sub_super_ranges.partition_point(|r| r.start <= byte_pos);
1948        idx > 0 && byte_pos < self.sub_super_ranges[idx - 1].end
1949    }
1950
1951    /// Check if a byte position is within a Pandoc inline-code attribute block
1952    /// (`{.lang}` immediately following `` `code` ``). Active for Pandoc-compatible
1953    /// flavors. O(log n).
1954    #[inline]
1955    pub fn is_in_inline_code_attr(&self, byte_pos: usize) -> bool {
1956        let idx = self.inline_code_attr_ranges.partition_point(|r| r.start <= byte_pos);
1957        idx > 0 && byte_pos < self.inline_code_attr_ranges[idx - 1].end
1958    }
1959
1960    /// Check if a byte position is within a Pandoc bracketed span (`[text]{attrs}`).
1961    /// Active for Pandoc-compatible flavors. O(log n).
1962    #[inline]
1963    pub fn is_in_bracketed_span(&self, byte_pos: usize) -> bool {
1964        let idx = self.bracketed_span_ranges.partition_point(|r| r.start <= byte_pos);
1965        idx > 0 && byte_pos < self.bracketed_span_ranges[idx - 1].end
1966    }
1967
1968    /// Returns true if `byte_pos` falls inside a Pandoc line block (`| text`).
1969    /// Active for Pandoc-compatible flavors. O(log n).
1970    #[inline]
1971    pub fn is_in_line_block(&self, byte_pos: usize) -> bool {
1972        let idx = self.line_block_ranges.partition_point(|r| r.start <= byte_pos);
1973        idx > 0 && byte_pos < self.line_block_ranges[idx - 1].end
1974    }
1975
1976    /// Returns true if `byte_pos` falls inside a Pandoc pipe-table caption
1977    /// (`: caption` adjacent to a pipe table). Active for Pandoc-compatible
1978    /// flavors. O(log n).
1979    #[inline]
1980    pub fn is_in_pipe_table_caption(&self, byte_pos: usize) -> bool {
1981        let idx = self.pipe_table_caption_ranges.partition_point(|r| r.start <= byte_pos);
1982        idx > 0 && byte_pos < self.pipe_table_caption_ranges[idx - 1].end
1983    }
1984
1985    /// Returns true if `byte_pos` falls inside a Pandoc YAML metadata block.
1986    /// Active for Pandoc-compatible flavors. O(log n).
1987    #[inline]
1988    pub fn is_in_pandoc_metadata(&self, byte_pos: usize) -> bool {
1989        let idx = self.pandoc_metadata_ranges.partition_point(|r| r.start <= byte_pos);
1990        idx > 0 && byte_pos < self.pandoc_metadata_ranges[idx - 1].end
1991    }
1992
1993    /// Returns true if `byte_pos` falls inside a Pandoc grid table.
1994    /// Active for Pandoc-compatible flavors. O(log n).
1995    #[inline]
1996    pub fn is_in_grid_table(&self, byte_pos: usize) -> bool {
1997        let idx = self.grid_table_ranges.partition_point(|r| r.start <= byte_pos);
1998        idx > 0 && byte_pos < self.grid_table_ranges[idx - 1].end
1999    }
2000
2001    /// Returns true if `byte_pos` falls inside a Pandoc multi-line table.
2002    /// Active for Pandoc-compatible flavors. O(log n).
2003    #[inline]
2004    pub fn is_in_multi_line_table(&self, byte_pos: usize) -> bool {
2005        let idx = self.multi_line_table_ranges.partition_point(|r| r.start <= byte_pos);
2006        idx > 0 && byte_pos < self.multi_line_table_ranges[idx - 1].end
2007    }
2008
2009    /// Returns true if `link_text`, after Pandoc slugification, matches a heading
2010    /// in the document. Returns false for non-Pandoc-compatible flavors because
2011    /// the `pandoc_header_slugs` set is empty when the pre-pass detector is gated
2012    /// off. Use this when the caller has raw bracketed text (`[Section name]`).
2013    pub fn matches_implicit_header_reference(&self, link_text: &str) -> bool {
2014        let slug = crate::utils::pandoc::pandoc_header_slug(link_text);
2015        self.pandoc_header_slugs.contains(&slug)
2016    }
2017
2018    /// Returns true if `slug` (already in Pandoc-slug form) matches a heading
2019    /// in the document. Returns false for non-Pandoc-compatible flavors because
2020    /// the `pandoc_header_slugs` set is empty when the pre-pass detector is gated
2021    /// off. Use this when the caller already has a slug (e.g. the fragment of a
2022    /// URL after `#`). O(1).
2023    #[inline]
2024    pub fn has_pandoc_slug(&self, slug: &str) -> bool {
2025        self.pandoc_header_slugs.contains(slug)
2026    }
2027
2028    /// Check if a byte position is within a Hugo/Quarto shortcode ({{< ... >}} or {{% ... %}}). O(log n).
2029    #[inline]
2030    pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
2031        Self::binary_search_ranges(&self.shortcode_ranges, byte_pos)
2032    }
2033
2034    /// Pre-computed Hugo/Quarto shortcode ranges.
2035    #[inline]
2036    pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
2037        &self.shortcode_ranges
2038    }
2039
2040    /// Check if a byte position is within a link reference definition title. O(log n).
2041    pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
2042        Self::binary_search_ranges(&self.link_title_ranges, byte_pos)
2043    }
2044
2045    /// Check if content has any instances of a specific character (fast)
2046    pub fn has_char(&self, ch: char) -> bool {
2047        match ch {
2048            '#' => self.char_frequency.hash_count > 0,
2049            '*' => self.char_frequency.asterisk_count > 0,
2050            '_' => self.char_frequency.underscore_count > 0,
2051            '-' => self.char_frequency.hyphen_count > 0,
2052            '+' => self.char_frequency.plus_count > 0,
2053            '>' => self.char_frequency.gt_count > 0,
2054            '|' => self.char_frequency.pipe_count > 0,
2055            '[' => self.char_frequency.bracket_count > 0,
2056            '`' => self.char_frequency.backtick_count > 0,
2057            '<' => self.char_frequency.lt_count > 0,
2058            '!' => self.char_frequency.exclamation_count > 0,
2059            '\n' => self.char_frequency.newline_count > 0,
2060            _ => self.content.contains(ch), // Fallback for other characters
2061        }
2062    }
2063
2064    /// Get count of a specific character (fast)
2065    pub fn char_count(&self, ch: char) -> usize {
2066        match ch {
2067            '#' => self.char_frequency.hash_count,
2068            '*' => self.char_frequency.asterisk_count,
2069            '_' => self.char_frequency.underscore_count,
2070            '-' => self.char_frequency.hyphen_count,
2071            '+' => self.char_frequency.plus_count,
2072            '>' => self.char_frequency.gt_count,
2073            '|' => self.char_frequency.pipe_count,
2074            '[' => self.char_frequency.bracket_count,
2075            '`' => self.char_frequency.backtick_count,
2076            '<' => self.char_frequency.lt_count,
2077            '!' => self.char_frequency.exclamation_count,
2078            '\n' => self.char_frequency.newline_count,
2079            _ => self.content.matches(ch).count(), // Fallback for other characters
2080        }
2081    }
2082
2083    /// Check if content likely contains headings (fast)
2084    pub fn likely_has_headings(&self) -> bool {
2085        self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 || self.content.contains('=') // Setext H1 underlines use '='
2086    }
2087
2088    /// Check if content likely contains unordered lists (fast). Only bullet
2089    /// characters are counted, so an ordered-only document answers false;
2090    /// rules about ordered lists read `commonmark_ordered_lists` instead.
2091    pub fn likely_has_lists(&self) -> bool {
2092        self.char_frequency.asterisk_count > 0
2093            || self.char_frequency.hyphen_count > 0
2094            || self.char_frequency.plus_count > 0
2095    }
2096
2097    /// Check if content likely contains emphasis (fast)
2098    pub fn likely_has_emphasis(&self) -> bool {
2099        self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
2100    }
2101
2102    /// Check if content likely contains tables (fast)
2103    pub fn likely_has_tables(&self) -> bool {
2104        self.char_frequency.pipe_count > 2
2105    }
2106
2107    /// Check if content likely contains blockquotes (fast)
2108    pub fn likely_has_blockquotes(&self) -> bool {
2109        self.char_frequency.gt_count > 0
2110    }
2111
2112    /// Check if content likely contains code (fast)
2113    pub fn likely_has_code(&self) -> bool {
2114        self.char_frequency.backtick_count > 0
2115    }
2116
2117    /// Check if content likely contains links or images (fast)
2118    pub fn likely_has_links_or_images(&self) -> bool {
2119        self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
2120    }
2121
2122    /// Check if content likely contains HTML (fast)
2123    pub fn likely_has_html(&self) -> bool {
2124        self.char_frequency.lt_count > 0
2125    }
2126
2127    /// Get the blockquote prefix for inserting a blank line at the given line index.
2128    /// Returns the prefix without trailing content (e.g., ">" or ">>").
2129    /// This is needed because blank lines inside blockquotes must preserve the blockquote structure.
2130    /// Returns an empty string if the line is not inside a blockquote.
2131    pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
2132        if let Some(line_info) = self.lines.get(line_idx)
2133            && let Some(ref bq) = line_info.blockquote
2134        {
2135            bq.prefix.trim_end().to_string()
2136        } else {
2137            String::new()
2138        }
2139    }
2140
2141    /// Find the line index for a given byte offset using binary search.
2142    /// Returns (line_index, line_number, column) where:
2143    /// - line_index is the 0-based index in the lines array
2144    /// - line_number is the 1-based line number
2145    /// - column is the 0-based *character* offset within that line
2146    ///
2147    /// The column is a character offset rather than a byte offset so that the
2148    /// `start_col`/`end_col` it feeds into match rumdl's diagnostic convention
2149    /// (columns are character positions). On lines with multi-byte UTF-8
2150    /// characters the two differ; reporting bytes would mis-position highlights.
2151    #[inline]
2152    fn find_line_for_offset(lines: &[LineInfo], content: &str, byte_offset: usize) -> (usize, usize, usize) {
2153        // Binary search to find the line containing this byte offset
2154        let idx = match lines.binary_search_by(|line| {
2155            if byte_offset < line.byte_offset {
2156                std::cmp::Ordering::Greater
2157            } else if byte_offset > line.byte_offset + line.byte_len {
2158                std::cmp::Ordering::Less
2159            } else {
2160                std::cmp::Ordering::Equal
2161            }
2162        }) {
2163            Ok(idx) => idx,
2164            Err(idx) => idx.saturating_sub(1),
2165        };
2166
2167        let line = &lines[idx];
2168        let line_num = idx + 1;
2169        let byte_col = byte_offset.saturating_sub(line.byte_offset);
2170        // Convert the byte offset within the line to a 0-based character column.
2171        // `byte_to_char_count` returns a 1-based value, so subtract 1.
2172        let col = byte_to_char_count(line.content(content), byte_col) - 1;
2173
2174        (idx, line_num, col)
2175    }
2176
2177    /// Check if a byte offset is within a code span using binary search
2178    #[inline]
2179    fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
2180        // Since spans are sorted by byte_offset, use partition_point for binary search
2181        let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
2182
2183        // Check the span that starts at or before our offset
2184        if idx > 0 {
2185            let span = &code_spans[idx - 1];
2186            if offset >= span.byte_offset && offset < span.byte_end {
2187                return true;
2188            }
2189        }
2190
2191        false
2192    }
2193
2194    /// Get an iterator over valid headings (skipping invalid ones like `#NoSpace`)
2195    ///
2196    /// Valid headings have proper spacing after the `#` markers (or are level > 1).
2197    /// This is the standard iterator for rules that need to process headings.
2198    ///
2199    /// # Examples
2200    ///
2201    /// ```
2202    /// use rumdl_lib::lint_context::LintContext;
2203    /// use rumdl_lib::config::MarkdownFlavor;
2204    ///
2205    /// let content = "# Valid Heading\n#NoSpace\n## Another Valid";
2206    /// let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2207    ///
2208    /// for heading in ctx.valid_headings() {
2209    ///     println!("Line {}: {} (level {})", heading.line_num, heading.heading.text, heading.heading.level);
2210    /// }
2211    /// // Only prints valid headings, skips `#NoSpace`
2212    /// ```
2213    #[must_use]
2214    pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
2215        ValidHeadingsIter::new(&self.lines)
2216    }
2217
2218    /// Check if the document contains any valid CommonMark headings
2219    ///
2220    /// Returns `true` if there is at least one heading with proper space after `#`.
2221    #[must_use]
2222    pub fn has_valid_headings(&self) -> bool {
2223        self.lines
2224            .iter()
2225            .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
2226    }
2227
2228    /// Iterate over every parsed list item in source order.
2229    #[must_use]
2230    pub fn list_items(&self) -> ParsedListItemsIter<'_> {
2231        ParsedListItemsIter::new(&self.lines)
2232    }
2233
2234    /// Return the parsed list item on a 1-indexed source line, if any.
2235    #[must_use]
2236    pub fn list_item_on_line(&self, line_num: usize) -> Option<ParsedListItem<'_>> {
2237        let line_info = self.lines.get(line_num.checked_sub(1)?)?;
2238        Some(ParsedListItem::new(
2239            line_num,
2240            line_info.list_item.as_deref()?,
2241            line_info,
2242        ))
2243    }
2244
2245    /// Borrow the document's parsed list blocks and their item iterators.
2246    #[must_use]
2247    pub fn parsed_list_blocks(&self) -> ParsedListBlocks<'_> {
2248        ParsedListBlocks::new(&self.list_blocks, &self.lines)
2249    }
2250
2251    /// The item lines of `block` grouped into the lists they form, one group
2252    /// per list as CommonMark nests them, in source order, so siblings can be
2253    /// compared without the nested items that sit between them.
2254    #[must_use]
2255    pub fn list_block_item_groups(&self, block: &ListBlock) -> Vec<Vec<usize>> {
2256        list_blocks::item_lines_by_list(self.content, &self.lines, block)
2257    }
2258
2259    /// Whether the document contains any parsed list items.
2260    #[must_use]
2261    pub fn has_list_items(&self) -> bool {
2262        self.lines.iter().any(|line| line.list_item.is_some())
2263    }
2264
2265    /// Whether the document contains any parsed unordered-list items.
2266    #[must_use]
2267    pub fn has_unordered_list_items(&self) -> bool {
2268        self.lines
2269            .iter()
2270            .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
2271    }
2272
2273    /// Borrow ordered lists using the membership and start values determined by CommonMark.
2274    #[must_use]
2275    pub fn commonmark_ordered_lists(&self) -> CommonMarkOrderedLists<'_> {
2276        let lists = self
2277            .commonmark_ordered_lists_cache
2278            .get_or_init(|| build_commonmark_ordered_lists(&self.lines, &self.line_to_list, &self.list_start_values));
2279        CommonMarkOrderedLists::new(lists, &self.lines)
2280    }
2281
2282    /// Iterate over every heading recognized in the rendered document.
2283    ///
2284    /// This includes top-level ATX and Setext headings, ATX headings nested in
2285    /// blockquotes, and malformed top-level ATX headings retained for
2286    /// diagnostics. Code blocks, front matter, raw HTML blocks, and
2287    /// flavor-specific non-Markdown regions are excluded during parsing;
2288    /// explicitly Markdown-enabled HTML containers remain eligible.
2289    #[must_use]
2290    pub fn headings(&self) -> ParsedHeadingsIter<'_> {
2291        ParsedHeadingsIter::new(&self.lines, &self.blockquote_headings)
2292    }
2293
2294    /// Return the parsed heading on a 1-indexed source line, if any.
2295    #[must_use]
2296    pub fn heading_on_line(&self, line_num: usize) -> Option<ParsedHeading<'_>> {
2297        let idx = line_num.checked_sub(1)?;
2298        let line_info = self.lines.get(idx)?;
2299        let (heading, blockquote_depth) = match line_info.heading.as_deref() {
2300            Some(heading) => (heading, 0),
2301            None => (
2302                self.blockquote_headings.get(idx)?.as_deref()?,
2303                line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level),
2304            ),
2305        };
2306        Some(ParsedHeading {
2307            line_num,
2308            heading,
2309            line_info,
2310            blockquote_depth,
2311        })
2312    }
2313}
2314
2315/// The range an unclosed `<!--` hides when it opens a block the parser missed.
2316///
2317/// A MkDocs admonition or a `<div markdown>` body is rendered as markdown in its
2318/// own right, so a `<!--` starting one of its lines opens an HTML block there
2319/// just as it would at the top level. The parser has no notion of either
2320/// container, reads the body as indented code or as a lazy paragraph
2321/// continuation, and so reports no block for the opener to run to the end of.
2322///
2323/// The block ends where the container's body ends, which is what CommonMark
2324/// gives an unclosed comment in any other container. An opener that is not the
2325/// first thing on its line is inline HTML and opens nothing, here as anywhere.
2326fn container_comment_range(
2327    opener: usize,
2328    containers: &flavor_detection::ContainerLines,
2329    lines: &[types::LineInfo],
2330    content: &str,
2331) -> Option<crate::utils::skip_context::ByteRange> {
2332    let line_index = lines
2333        .partition_point(|line| line.byte_offset <= opener)
2334        .checked_sub(1)?;
2335    let line = lines.get(line_index)?;
2336    if line.byte_offset + line.indent != opener {
2337        return None;
2338    }
2339    if !containers.is_container_body(line_index) {
2340        return None;
2341    }
2342    let end_line = lines.get(containers.body_end_line(line_index)?)?;
2343    Some(crate::utils::skip_context::ByteRange {
2344        start: opener,
2345        end: (end_line.byte_offset + end_line.byte_len).min(content.len()),
2346    })
2347}
2348
2349/// Detect footnote definitions and mark their continuation lines.
2350///
2351/// Uses pulldown-cmark to find footnote definition ranges and fenced code
2352/// blocks within them, then:
2353/// 1. Sets `in_footnote_definition = true` on all lines within
2354/// 2. Clears `in_code_block = false` on continuation lines that were
2355///    misidentified as indented code blocks (but preserves real fenced
2356///    code blocks within footnotes)
2357fn detect_footnote_definitions(content: &str, lines: &mut [types::LineInfo], line_offsets: &[usize]) {
2358    use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
2359
2360    let options = crate::utils::rumdl_parser_options();
2361    let parser = Parser::new_ext(content, options).into_offset_iter();
2362
2363    // Collect footnote ranges and fenced code block ranges within them
2364    let mut footnote_ranges: Vec<(usize, usize)> = Vec::new();
2365    let mut fenced_code_ranges: Vec<(usize, usize)> = Vec::new();
2366    let mut in_footnote = false;
2367
2368    for (event, range) in parser {
2369        match event {
2370            Event::Start(Tag::FootnoteDefinition(_)) => {
2371                in_footnote = true;
2372                footnote_ranges.push((range.start, range.end));
2373            }
2374            Event::End(TagEnd::FootnoteDefinition) => {
2375                in_footnote = false;
2376            }
2377            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) if in_footnote => {
2378                fenced_code_ranges.push((range.start, range.end));
2379            }
2380            _ => {}
2381        }
2382    }
2383
2384    let byte_to_line = |byte_offset: usize| -> usize {
2385        line_offsets
2386            .partition_point(|&offset| offset <= byte_offset)
2387            .saturating_sub(1)
2388    };
2389
2390    // Mark footnote definition lines
2391    for &(start, end) in &footnote_ranges {
2392        let start_line = byte_to_line(start);
2393        let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
2394
2395        for line in &mut lines[start_line..end_line] {
2396            line.in_footnote_definition = true;
2397            line.in_code_block = false;
2398        }
2399    }
2400
2401    // Restore in_code_block for fenced code blocks within footnotes
2402    for &(start, end) in &fenced_code_ranges {
2403        let start_line = byte_to_line(start);
2404        let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
2405
2406        for line in &mut lines[start_line..end_line] {
2407            line.in_code_block = true;
2408        }
2409    }
2410}