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