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