Skip to main content

rumdl_lib/lint_context/
mod.rs

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