Skip to main content

rumdl_lib/lint_context/
mod.rs

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