Skip to main content

rumdl_lib/lint_context/
mod.rs

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