Skip to main content

rumdl_lib/lint_context/
mod.rs

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