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