Skip to main content

rumdl_lib/lint_context/
mod.rs

1pub mod types;
2pub use types::*;
3
4mod element_parsers;
5mod flavor_detection;
6mod heading_detection;
7mod line_computation;
8mod link_parser;
9mod list_blocks;
10#[cfg(test)]
11mod tests;
12
13use crate::config::MarkdownFlavor;
14use crate::inline_config::InlineConfig;
15use crate::rules::front_matter_utils::FrontMatterUtils;
16use crate::utils::code_block_utils::{CodeBlockDetail, CodeBlockUtils};
17use crate::utils::range_utils::byte_to_char_count;
18use std::collections::HashMap;
19use std::path::PathBuf;
20
21/// Macro for profiling sections - only active in non-WASM builds
22#[cfg(not(target_arch = "wasm32"))]
23macro_rules! profile_section {
24    ($name:expr, $profile:expr, $code:expr) => {{
25        let start = std::time::Instant::now();
26        let result = $code;
27        if $profile {
28            eprintln!("[PROFILE] {}: {:?}", $name, start.elapsed());
29        }
30        result
31    }};
32}
33
34#[cfg(target_arch = "wasm32")]
35macro_rules! profile_section {
36    ($name:expr, $profile:expr, $code:expr) => {{ $code }};
37}
38
39/// Grouped byte ranges for skip context detection
40/// Used to reduce parameter count in internal functions
41pub(super) struct SkipByteRanges<'a> {
42    pub(super) html_comment_ranges: &'a [crate::utils::skip_context::ByteRange],
43    pub(super) autodoc_ranges: &'a [crate::utils::skip_context::ByteRange],
44    pub(super) pandoc_div_ranges: &'a [crate::utils::skip_context::ByteRange],
45    pub(super) pymdown_block_ranges: &'a [crate::utils::skip_context::ByteRange],
46}
47
48use std::sync::{Arc, OnceLock};
49
50/// Map from line byte offset to list item data: (is_ordered, marker, marker_column, content_column, number)
51pub(super) type ListItemMap = std::collections::HashMap<usize, (bool, String, usize, usize, Option<usize>)>;
52
53/// Type alias for byte ranges used in JSX expression and MDX comment detection
54pub(super) type ByteRanges = Vec<(usize, usize)>;
55
56pub struct LintContext<'a> {
57    pub content: &'a str,
58    content_lines: Vec<&'a str>, // Pre-split lines from content (avoids repeated allocations)
59    pub line_offsets: Vec<usize>,
60    pub code_blocks: Vec<(usize, usize)>, // Cached code block ranges (not including inline code spans)
61    pub code_block_details: Vec<CodeBlockDetail>, // Per-block metadata (fenced/indented, info string)
62    pub strong_spans: Vec<crate::utils::code_block_utils::StrongSpanDetail>, // Pre-computed strong emphasis spans
63    pub line_to_list: crate::utils::code_block_utils::LineToListMap, // Ordered list membership by line
64    pub list_start_values: crate::utils::code_block_utils::ListStartValues, // Start values per list ID
65    pub lines: Vec<LineInfo>,             // Pre-computed line information
66    pub links: Vec<ParsedLink<'a>>,       // Pre-parsed links
67    pub images: Vec<ParsedImage<'a>>,     // Pre-parsed images
68    pub broken_links: Vec<BrokenLinkInfo>, // Broken/undefined references
69    pub footnote_refs: Vec<FootnoteRef>,  // Pre-parsed footnote references
70    pub reference_defs: Vec<ReferenceDef>, // Reference definitions
71    reference_defs_map: HashMap<String, usize>, // O(1) lookup by lowercase ID -> index in reference_defs
72    code_spans_cache: OnceLock<Arc<Vec<CodeSpan>>>, // Lazy-loaded inline code spans
73    math_spans_cache: OnceLock<Arc<Vec<MathSpan>>>, // Lazy-loaded math spans ($...$ and $$...$$)
74    math_byte_ranges_cache: OnceLock<Vec<(usize, usize)>>, // Lazy-loaded math byte ranges for is_in_math_context
75    pub list_blocks: Vec<ListBlock>,      // Pre-parsed list blocks
76    pub char_frequency: CharFrequency,    // Character frequency analysis
77    html_tags_cache: OnceLock<Arc<Vec<HtmlTag>>>, // Lazy-loaded HTML tags
78    jsx_component_tags_cache: OnceLock<Arc<Vec<HtmlTag>>>, // Lazy-loaded JSX component tags (shares the html_tags parse)
79    emphasis_spans_cache: OnceLock<Arc<Vec<EmphasisSpan>>>, // Lazy-loaded emphasis spans
80    bare_urls_cache: OnceLock<Arc<Vec<BareUrl>>>,          // Lazy-loaded bare URLs
81    has_mixed_list_nesting_cache: OnceLock<bool>, // Cached result for mixed ordered/unordered list nesting detection
82    html_comment_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed HTML comment ranges
83    pub table_blocks: Vec<crate::utils::table_utils::TableBlock>, // Pre-computed table blocks
84    pub line_index: crate::utils::range_utils::LineIndex<'a>, // Pre-computed line index for byte position calculations
85    jinja_ranges: Vec<(usize, usize)>,            // Pre-computed Jinja template ranges ({{ }}, {% %})
86    pub flavor: MarkdownFlavor,                   // Markdown flavor being used
87    pub source_file: Option<PathBuf>,             // Source file path (for rules that need file context)
88    jsx_expression_ranges: Vec<(usize, usize)>,   // Pre-computed JSX expression ranges (MDX: {expression})
89    mdx_comment_ranges: Vec<(usize, usize)>,      // Pre-computed MDX comment ranges ({/* ... */})
90    citation_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc/Quarto citation ranges (@key, [@key])
91    pandoc_div_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc/Quarto div block ranges (::: ... :::)
92    colon_fence_ranges: Vec<(usize, usize)>, // Pre-computed Azure DevOps colon code fence ranges (:::lang ... :::)
93    inline_footnote_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc inline footnote ranges (^[...])
94    pandoc_header_slugs: std::collections::HashSet<String>, // Pre-computed Pandoc implicit header reference slugs
95    example_list_marker_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc example-list marker ranges (@) / (@label)
96    example_reference_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc example reference ranges (@label) inline
97    sub_super_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc subscript (~x~) and superscript (^x^) ranges
98    inline_code_attr_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc inline code attribute ranges (`code`{.lang})
99    bracketed_span_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc bracketed span ranges ([text]{attrs})
100    line_block_ranges: Vec<crate::utils::skip_context::ByteRange>,     // Pre-computed Pandoc line block ranges (| text)
101    pipe_table_caption_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc pipe-table caption ranges (: caption)
102    pandoc_metadata_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc YAML metadata block ranges (--- ... --- or ...)
103    grid_table_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc grid-table ranges (+---+---+)
104    multi_line_table_ranges: Vec<crate::utils::skip_context::ByteRange>, // Pre-computed Pandoc multi-line table ranges
105    shortcode_ranges: Vec<(usize, usize)>, // Pre-computed Hugo/Quarto shortcode ranges ({{< ... >}} and {{% ... %}})
106    link_title_ranges: Vec<(usize, usize)>, // Pre-computed sorted link title byte ranges
107    code_span_byte_ranges: Vec<(usize, usize)>, // Pre-computed code span byte ranges from pulldown-cmark
108    inline_config: InlineConfig,           // Parsed inline configuration comments for rule disabling
109    obsidian_comment_ranges: Vec<(usize, usize)>, // Pre-computed Obsidian comment ranges (%%...%%)
110    unterminated_html_comment: Option<usize>, // Byte offset of a `<!--` with no `-->`
111    unterminated_obsidian_comment: Option<usize>, // Byte offset of a `%%` with no closing `%%`
112    lazy_cont_lines_cache: OnceLock<Arc<Vec<LazyContLine>>>, // Lazy-loaded lazy continuation lines
113    myst_directive_ranges: Vec<(usize, usize)>, // Pre-computed MyST colon directive byte ranges (:::{name} ... :::)
114    myst_comment_ranges: Vec<(usize, usize)>, // Pre-computed MyST comment byte ranges (% comment)
115    myst_role_ranges: Vec<(usize, usize)>, // Pre-computed MyST role byte ranges ({role}`content`)
116    front_matter_end: usize,               // 1-indexed line where front matter ends, 0 if none
117}
118
119impl<'a> LintContext<'a> {
120    pub fn new(content: &'a str, flavor: MarkdownFlavor, source_file: Option<PathBuf>) -> Self {
121        #[cfg(not(target_arch = "wasm32"))]
122        let profile = std::env::var("RUMDL_PROFILE_QUADRATIC").is_ok();
123
124        let line_offsets = profile_section!("Line offsets", profile, {
125            let mut offsets = vec![0];
126            for (i, c) in content.char_indices() {
127                if c == '\n' {
128                    offsets.push(i + 1);
129                }
130            }
131            offsets
132        });
133
134        // Compute content_lines once for all functions that need it
135        let content_lines: Vec<&str> = content.lines().collect();
136
137        // Detect front matter boundaries once for all functions that need it.
138        // This is the single allowed call site; rules read the cached value
139        // via front_matter_end_line().
140        #[allow(clippy::disallowed_methods)]
141        let front_matter_end = FrontMatterUtils::get_front_matter_end_line(content);
142
143        // Detect code blocks and code spans once and cache them
144        let parse_result = profile_section!(
145            "Code blocks",
146            profile,
147            CodeBlockUtils::detect_code_blocks_and_spans(content)
148        );
149        let mut code_blocks = parse_result.code_blocks;
150        let code_span_ranges = parse_result.code_spans;
151        let code_block_details = parse_result.code_block_details;
152        let strong_spans = parse_result.strong_spans;
153        let line_to_list = parse_result.line_to_list;
154        let list_start_values = parse_result.list_start_values;
155        let html_blocks = parse_result.html_blocks;
156
157        // Container structure the parser cannot see. Computed from the line text
158        // alone, so it is available here, before the line info it corrects.
159        let containers = profile_section!(
160            "Container lines",
161            profile,
162            flavor_detection::detect_container_lines(&content_lines, flavor)
163        );
164
165        // Pre-compute HTML comment ranges ONCE for all operations.
166        // Code-span and code-block ranges are passed so `<!--`/`-->` inside code
167        // are treated as literal text, not comment delimiters that could pair
168        // across code regions on different lines. An indented block over
169        // container content is the parser reading a MkDocs admonition or a
170        // `<div markdown>` body as code, and a comment written there is a real
171        // comment, so only the parts of such a block that a fence really does
172        // hold as code are kept.
173        let comment_code_block_ranges: Vec<(usize, usize)> = code_block_details
174            .iter()
175            .flat_map(|detail| {
176                if detail.is_fenced {
177                    return vec![(detail.start, detail.end)];
178                }
179                let start_line = line_offsets
180                    .partition_point(|&offset| offset <= detail.start)
181                    .saturating_sub(1);
182                let end_line = line_offsets.partition_point(|&offset| offset < detail.end);
183                containers
184                    .code_line_spans_in(start_line..end_line)
185                    .into_iter()
186                    .map(|span| {
187                        let start = line_offsets[span.start].max(detail.start);
188                        let end = line_offsets
189                            .get(span.end)
190                            .copied()
191                            .unwrap_or(content.len())
192                            .min(detail.end);
193                        (start, end)
194                    })
195                    .collect()
196            })
197            .collect();
198        // Front matter is data, not markdown: a `<!--` in a YAML value would
199        // otherwise pair with a `-->` in the body and hide everything between
200        // them from every rule. `front_matter_end` is the 1-indexed closing
201        // delimiter line, so the body starts at the line after it, and a
202        // document without front matter starts at byte 0.
203        let body_start = line_offsets.get(front_matter_end).copied().unwrap_or(content.len());
204        let html_comment_scan = profile_section!(
205            "HTML comment ranges",
206            profile,
207            crate::utils::skip_context::scan_html_comments(
208                content,
209                &code_span_ranges,
210                &comment_code_block_ranges,
211                body_start
212            )
213        );
214        let mut html_comment_ranges = html_comment_scan.ranges;
215        let unterminated_html_comment = html_comment_scan.unterminated;
216
217        // Pre-compute autodoc block ranges (avoids O(n^2) scaling)
218        // Detected for all flavors except AzureDevOps, where `:::` denotes code fences
219        // rather than autodoc directives.
220        let autodoc_ranges = profile_section!("Autodoc block ranges", profile, {
221            if flavor.supports_colon_code_fences() || flavor.supports_myst_directives() {
222                Vec::new()
223            } else {
224                crate::utils::mkdocstrings_refs::detect_autodoc_block_ranges(content)
225            }
226        });
227
228        // Pre-compute Pandoc/Quarto div block ranges for Pandoc-compatible flavors
229        let pandoc_div_ranges = profile_section!("Pandoc div ranges", profile, {
230            if flavor.is_pandoc_compatible() {
231                crate::utils::pandoc::detect_div_block_ranges(content)
232            } else {
233                Vec::new()
234            }
235        });
236
237        // Pre-compute PyMdown Blocks ranges for MkDocs flavor (/// ... ///)
238        let pymdown_block_ranges = profile_section!("PyMdown block ranges", profile, {
239            if flavor == MarkdownFlavor::MkDocs {
240                crate::utils::pymdown_blocks::detect_block_ranges(content)
241            } else {
242                Vec::new()
243            }
244        });
245
246        // Pre-compute line information AND emphasis spans (without headings/blockquotes yet)
247        // Emphasis spans are captured during the same pulldown-cmark parse as list detection
248        let skip_ranges = SkipByteRanges {
249            html_comment_ranges: &html_comment_ranges,
250            autodoc_ranges: &autodoc_ranges,
251            pandoc_div_ranges: &pandoc_div_ranges,
252            pymdown_block_ranges: &pymdown_block_ranges,
253        };
254        let (mut lines, emphasis_spans) = profile_section!(
255            "Basic line info",
256            profile,
257            line_computation::compute_basic_line_info(
258                content,
259                &content_lines,
260                &line_offsets,
261                &code_blocks,
262                flavor,
263                &skip_ranges,
264                front_matter_end,
265            )
266        );
267
268        // Detect HTML blocks BEFORE heading detection
269        profile_section!(
270            "HTML blocks",
271            profile,
272            heading_detection::detect_html_blocks(content, &mut lines)
273        );
274
275        // Detect ESM import/export blocks in MDX files BEFORE heading detection
276        profile_section!(
277            "ESM blocks",
278            profile,
279            flavor_detection::detect_esm_blocks(content, &mut lines, flavor)
280        );
281
282        // Detect JSX component blocks in MDX files (e.g. <Tabs>...</Tabs>)
283        profile_section!(
284            "JSX block detection",
285            profile,
286            flavor_detection::detect_jsx_blocks(content, &mut lines, flavor)
287        );
288
289        // Detect JSX expressions and MDX comments in MDX files
290        let (jsx_expression_ranges, mdx_comment_ranges) = profile_section!(
291            "JSX/MDX detection",
292            profile,
293            flavor_detection::detect_jsx_and_mdx_comments(content, &mut lines, flavor, &code_blocks)
294        );
295
296        // Detect `<div markdown>`-style HTML blocks (grid cards, etc.) regardless of flavor.
297        // The `markdown` attribute is an explicit, author-supplied signal; recognizing it
298        // in all flavors keeps `rumdl fmt` from mangling Material grid cards when the
299        // MkDocs flavor isn't active.
300        profile_section!(
301            "Markdown-in-HTML blocks",
302            profile,
303            flavor_detection::detect_markdown_html_blocks(&mut lines, &containers)
304        );
305
306        // Detect MkDocs-specific constructs (admonitions, tabs, definition lists)
307        profile_section!(
308            "MkDocs constructs",
309            profile,
310            flavor_detection::detect_mkdocs_line_info(&content_lines, &mut lines, flavor, &containers)
311        );
312
313        // Detect footnote definitions and correct false code block detection.
314        // With ENABLE_FOOTNOTES, pulldown-cmark correctly parses multi-line
315        // footnotes, but the code block detector may still mark 4-space-indented
316        // footnote continuation lines as indented code blocks.
317        profile_section!(
318            "Footnote definitions",
319            profile,
320            detect_footnote_definitions(content, &mut lines, &line_offsets)
321        );
322
323        // Filter code_blocks to remove false positives from footnote continuation content.
324        // Same pattern as MkDocs/JSX corrections below.
325        {
326            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
327            for &(start, end) in &code_blocks {
328                let start_line = line_offsets
329                    .partition_point(|&offset| offset <= start)
330                    .saturating_sub(1);
331                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
332
333                let mut sub_start: Option<usize> = None;
334                for (i, &offset) in line_offsets[start_line..end_line]
335                    .iter()
336                    .enumerate()
337                    .map(|(j, o)| (j + start_line, o))
338                {
339                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
340                    if is_real_code && sub_start.is_none() {
341                        let byte_start = if i == start_line { start } else { offset };
342                        sub_start = Some(byte_start);
343                    } else if !is_real_code && sub_start.is_some() {
344                        new_code_blocks.push((sub_start.unwrap(), offset));
345                        sub_start = None;
346                    }
347                }
348                if let Some(s) = sub_start {
349                    new_code_blocks.push((s, end));
350                }
351            }
352            code_blocks = new_code_blocks;
353        }
354
355        // Filter code_blocks to remove false positives from MkDocs admonition/tab content
356        // and `<div markdown>` HTML blocks (grid cards).
357        // pulldown-cmark treats 4-space-indented content as indented code blocks, but inside
358        // these containers this is regular markdown content. detect_mkdocs_line_info and
359        // detect_markdown_html_blocks already corrected LineInfo.in_code_block for these lines,
360        // but the code_blocks byte ranges are still stale. We split ranges rather than using
361        // all-or-nothing removal, so fenced code blocks within the containers are preserved.
362        let has_markdown_html = lines.iter().any(|l| l.in_mkdocs_html_markdown);
363        if flavor == MarkdownFlavor::MkDocs || has_markdown_html {
364            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
365            for &(start, end) in &code_blocks {
366                let start_line = line_offsets
367                    .partition_point(|&offset| offset <= start)
368                    .saturating_sub(1);
369                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
370
371                // Walk lines in this range, collecting sub-ranges where in_code_block is true
372                let mut sub_start: Option<usize> = None;
373                for (i, &offset) in line_offsets[start_line..end_line]
374                    .iter()
375                    .enumerate()
376                    .map(|(j, o)| (j + start_line, o))
377                {
378                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
379                    if is_real_code && sub_start.is_none() {
380                        let byte_start = if i == start_line { start } else { offset };
381                        sub_start = Some(byte_start);
382                    } else if !is_real_code && sub_start.is_some() {
383                        new_code_blocks.push((sub_start.unwrap(), offset));
384                        sub_start = None;
385                    }
386                }
387                if let Some(s) = sub_start {
388                    new_code_blocks.push((s, end));
389                }
390            }
391            code_blocks = new_code_blocks;
392        }
393
394        // Filter code_blocks for MDX JSX blocks (same pattern as MkDocs above).
395        // detect_jsx_blocks already corrected LineInfo.in_code_block for indented content
396        // inside JSX component blocks, but code_blocks byte ranges need updating too.
397        if flavor.supports_jsx() {
398            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
399            for &(start, end) in &code_blocks {
400                let start_line = line_offsets
401                    .partition_point(|&offset| offset <= start)
402                    .saturating_sub(1);
403                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
404
405                let mut sub_start: Option<usize> = None;
406                for (i, &offset) in line_offsets[start_line..end_line]
407                    .iter()
408                    .enumerate()
409                    .map(|(j, o)| (j + start_line, o))
410                {
411                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
412                    if is_real_code && sub_start.is_none() {
413                        let byte_start = if i == start_line { start } else { offset };
414                        sub_start = Some(byte_start);
415                    } else if !is_real_code && sub_start.is_some() {
416                        new_code_blocks.push((sub_start.unwrap(), offset));
417                        sub_start = None;
418                    }
419                }
420                if let Some(s) = sub_start {
421                    new_code_blocks.push((s, end));
422                }
423            }
424            code_blocks = new_code_blocks;
425
426            // Add byte ranges for fenced code blocks nested inside a JSX component.
427            // pulldown-cmark classifies the whole component as one HTML block and
428            // emits no code-block range for the fence, so the split loop above
429            // (which can only narrow existing ranges) never adds it. Derive the
430            // ranges from the per-line in_code_block flags detect_jsx_blocks set,
431            // so byte-range consumers (e.g. MD011, MD044) skip the fence content.
432            let mut jsx_fence_ranges: Vec<(usize, usize)> = Vec::new();
433            let mut run: Option<(usize, usize)> = None;
434            for line in &lines {
435                if line.in_jsx_block && line.in_code_block {
436                    let line_end = line.byte_offset + line.byte_len;
437                    match &mut run {
438                        Some((_, end)) => *end = line_end,
439                        None => run = Some((line.byte_offset, line_end)),
440                    }
441                } else if let Some(r) = run.take() {
442                    jsx_fence_ranges.push(r);
443                }
444            }
445            if let Some(r) = run.take() {
446                jsx_fence_ranges.push(r);
447            }
448            if !jsx_fence_ranges.is_empty() {
449                code_blocks.extend(jsx_fence_ranges);
450                code_blocks.sort_by_key(|&(start, _)| start);
451            }
452        }
453
454        // Detect Azure DevOps colon code fences and extend code_blocks so that
455        // all byte-range consumers correctly skip their content.
456        let colon_fence_ranges = profile_section!(
457            "Azure colon fence detection",
458            profile,
459            flavor_detection::detect_azure_colon_fences(content, &mut lines, flavor)
460        );
461        if !colon_fence_ranges.is_empty() {
462            code_blocks.extend(colon_fence_ranges.iter().copied());
463            code_blocks.sort_by_key(|&(start, _)| start);
464        }
465
466        // Detect MyST colon directives (:::{name} ... :::) — these are structural
467        // containers, NOT code blocks. Content inside is linted as markdown.
468        let myst_directive_ranges = profile_section!(
469            "MyST colon directives",
470            profile,
471            flavor_detection::detect_myst_colon_directives(content, &mut lines, flavor)
472        );
473
474        // Detect MyST % comments
475        let myst_comment_ranges = profile_section!(
476            "MyST comments",
477            profile,
478            flavor_detection::detect_myst_comments(content, &mut lines, flavor)
479        );
480
481        // Detect MyST backtick directives (```{name}) and clear in_code_block for
482        // content-bearing directives so their body is linted as markdown.
483        profile_section!(
484            "MyST backtick directives",
485            profile,
486            flavor_detection::detect_myst_backtick_directives(
487                content,
488                &mut lines,
489                flavor,
490                &code_block_details,
491                &line_offsets
492            )
493        );
494
495        // Filter code_blocks to remove false positives from MyST content-bearing directives.
496        // Same pattern as MkDocs admonition filtering.
497        if flavor.supports_myst_directives() {
498            let mut new_code_blocks = Vec::with_capacity(code_blocks.len());
499            for &(start, end) in &code_blocks {
500                let start_line = line_offsets
501                    .partition_point(|&offset| offset <= start)
502                    .saturating_sub(1);
503                let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
504
505                let mut sub_start: Option<usize> = None;
506                for (i, &offset) in line_offsets[start_line..end_line]
507                    .iter()
508                    .enumerate()
509                    .map(|(j, o)| (j + start_line, o))
510                {
511                    let is_real_code = lines.get(i).is_some_and(|info| info.in_code_block);
512                    if is_real_code && sub_start.is_none() {
513                        let byte_start = if i == start_line { start } else { offset };
514                        sub_start = Some(byte_start);
515                    } else if !is_real_code && sub_start.is_some() {
516                        new_code_blocks.push((sub_start.unwrap(), offset));
517                        sub_start = None;
518                    }
519                }
520                if let Some(s) = sub_start {
521                    new_code_blocks.push((s, end));
522                }
523            }
524            code_blocks = new_code_blocks;
525        }
526
527        // Detect kramdown constructs (extension blocks, IALs, ALDs) in kramdown flavor
528        profile_section!(
529            "Kramdown constructs",
530            profile,
531            flavor_detection::detect_kramdown_line_info(content, &mut lines, flavor)
532        );
533
534        // Layer 1: Sanitize content-derived fields inside kramdown extension blocks
535        // so downstream heading detection and collection builders never see them.
536        // This must run BEFORE detect_headings_and_blockquotes to prevent headings
537        // from being populated inside extension blocks.
538        for line in &mut lines {
539            if line.in_kramdown_extension_block {
540                line.list_item = None;
541                line.is_horizontal_rule = false;
542                line.blockquote = None;
543                line.is_kramdown_block_ial = false;
544            }
545        }
546
547        // Detect Obsidian comments (%%...%%) in Obsidian flavor
548        let obsidian_comment_scan = profile_section!(
549            "Obsidian comments",
550            profile,
551            flavor_detection::detect_obsidian_comments(
552                content,
553                &mut lines,
554                flavor,
555                &code_span_ranges,
556                &html_comment_ranges,
557                body_start
558            )
559        );
560        let mut obsidian_comment_ranges = obsidian_comment_scan.ranges;
561        let mut unterminated_obsidian_comment = obsidian_comment_scan.unterminated;
562
563        // An Obsidian comment hides the text it wraps, so a `<!--` inside one is
564        // not a comment opener. The HTML scan cannot know that yet - detecting
565        // Obsidian comments needs its ranges - so the opener it reported is
566        // re-resolved here, now that the comments that hide it are known.
567        let unterminated_html_comment = crate::utils::skip_context::unterminated_html_comment_outside(
568            unterminated_html_comment,
569            &obsidian_comment_ranges,
570            content,
571            &code_span_ranges,
572            &comment_code_block_ranges,
573            body_start,
574        );
575
576        // An unclosed `<!--` that opens an HTML block comments out the rest of
577        // that block, so the text below it is not content any rule should judge.
578        // Without this the block-structure rules and the comment-aware rules
579        // disagree about the same lines: the parser reports no list inside the
580        // block, while a bare URL there is still flagged.
581        //
582        // The opener stays reported either way. This governs what the rest of
583        // the linter sees, not whether the missing closer is raised.
584        //
585        // It waits for the re-resolution above because an opener a `%%` pair
586        // hides is not an opener, and giving that one a range would hide the
587        // rest of the note from every rule.
588        if let Some(range) = unterminated_html_comment.and_then(|opener| {
589            crate::utils::skip_context::unterminated_comment_range(opener, &html_blocks)
590                .or_else(|| container_comment_range(opener, &containers, &lines, content))
591        }) {
592            // Every complete comment starts before the unclosed opener, so this
593            // keeps the ranges sorted for the binary searches over them.
594            html_comment_ranges.push(range);
595
596            // The line flags are computed before the Obsidian comments are
597            // known, so they predate this range. Recomputing them through the
598            // same helper keeps `is_in_html_comment` and the per-line flag
599            // answering alike, which is the agreement this range exists to
600            // create.
601            for line in &mut lines {
602                let text = line.content(content);
603                let content_start = line.byte_offset + line.indent;
604                let content_end = line.byte_offset + text.trim_end().len();
605                line.in_html_comment = crate::utils::skip_context::is_line_entirely_in_html_comment(
606                    &html_comment_ranges,
607                    content_start,
608                    content_end,
609                );
610                line.in_obsidian_comment = false;
611            }
612
613            // The `%%` delimiters the block covers are comment text, so a
614            // delimiter below the block opens a comment rather than closing the
615            // one those appeared to open. Only a rescan pairs them correctly;
616            // dropping the ranges that start inside the block would leave the
617            // delimiter below it paired with nothing and unreported.
618            //
619            // This is the mirror of the re-resolution above, and it needs no
620            // second round: the block starts at or after the opener, so the
621            // pairing before the opener is what it already was, and the opener
622            // resolved against it cannot change.
623            let obsidian_rescan = flavor_detection::detect_obsidian_comments(
624                content,
625                &mut lines,
626                flavor,
627                &code_span_ranges,
628                &html_comment_ranges,
629                body_start,
630            );
631            obsidian_comment_ranges = obsidian_rescan.ranges;
632            unterminated_obsidian_comment = obsidian_rescan.unterminated;
633        }
634
635        // Detect MyST role syntax ({role}`content`)
636        let myst_role_ranges = profile_section!(
637            "MyST roles",
638            profile,
639            flavor_detection::detect_myst_role_ranges(content, &lines, flavor, &code_blocks)
640        );
641
642        // Run pulldown-cmark parse for links, images, and link byte ranges in a single pass.
643        // Link byte ranges are needed for heading detection; links/images are finalized later
644        // after code_spans are available.
645        let pulldown_result = profile_section!(
646            "Links, images & link ranges",
647            profile,
648            link_parser::parse_links_images_pulldown(content, &lines, &code_blocks, flavor, &html_comment_ranges)
649        );
650
651        // Now detect headings and blockquotes
652        profile_section!(
653            "Headings & blockquotes",
654            profile,
655            heading_detection::detect_headings_and_blockquotes(
656                &content_lines,
657                &mut lines,
658                flavor,
659                &html_comment_ranges,
660                &pulldown_result.link_byte_ranges,
661                front_matter_end,
662            )
663        );
664
665        // Clear headings that were detected inside kramdown extension blocks
666        for line in &mut lines {
667            if line.in_kramdown_extension_block {
668                line.heading = None;
669            }
670        }
671
672        // Parse code spans early so we can exclude them from link/image parsing
673        let mut code_spans = profile_section!(
674            "Code spans",
675            profile,
676            element_parsers::build_code_spans_from_ranges(content, &lines, &code_span_ranges)
677        );
678
679        // Supplement code spans for MkDocs container content that pulldown-cmark missed.
680        // pulldown-cmark treats 4-space-indented MkDocs content as indented code blocks,
681        // so backtick code spans within admonitions/tabs/markdown HTML are invisible to it.
682        if flavor == MarkdownFlavor::MkDocs {
683            let extra = profile_section!(
684                "MkDocs code spans",
685                profile,
686                element_parsers::scan_mkdocs_container_code_spans(content, &lines, &code_span_ranges,)
687            );
688            if !extra.is_empty() {
689                code_spans.extend(extra);
690                code_spans.sort_by_key(|span| span.byte_offset);
691            }
692        }
693
694        // Supplement code spans for MDX JSX component body content that pulldown-cmark missed.
695        // pulldown-cmark treats JSX component opening tags (e.g. `<ParamField>`) as HTML block
696        // starters, so backtick code spans within component bodies are invisible to the initial
697        // parse.
698        if flavor == MarkdownFlavor::MDX {
699            let extra = profile_section!(
700                "MDX JSX code spans",
701                profile,
702                element_parsers::scan_jsx_block_code_spans(content, &lines, &code_span_ranges)
703            );
704            if !extra.is_empty() {
705                code_spans.extend(extra);
706                code_spans.sort_by_key(|span| span.byte_offset);
707            }
708        }
709
710        // Mark lines that are continuations of multi-line code spans
711        // This is needed for parse_list_blocks to correctly handle list items with multi-line code spans
712        for span in &code_spans {
713            if span.end_line > span.line {
714                // Mark lines after the first line as continuations
715                for line_num in (span.line + 1)..=span.end_line {
716                    if let Some(line_info) = lines.get_mut(line_num - 1) {
717                        line_info.in_code_span_continuation = true;
718                    }
719                }
720            }
721        }
722
723        // Finalize links and images: filter by code_spans and run regex fallbacks
724        let (links, images, broken_links, footnote_refs) = profile_section!(
725            "Links & images finalize",
726            profile,
727            link_parser::finalize_links_and_images(
728                content,
729                &lines,
730                &code_blocks,
731                &code_spans,
732                flavor,
733                &html_comment_ranges,
734                pulldown_result
735            )
736        );
737
738        let reference_defs = profile_section!(
739            "Reference defs",
740            profile,
741            link_parser::parse_reference_defs(content, &lines)
742        );
743
744        let list_blocks = profile_section!("List blocks", profile, list_blocks::parse_list_blocks(content, &lines));
745
746        // Compute character frequency for fast content analysis
747        let char_frequency = profile_section!(
748            "Char frequency",
749            profile,
750            line_computation::compute_char_frequency(content)
751        );
752
753        // Pre-compute table blocks for rules that need them (MD013, MD055, MD056, MD058, MD060)
754        let table_blocks = profile_section!(
755            "Table blocks",
756            profile,
757            crate::utils::table_utils::TableUtils::find_table_blocks_with_code_info(
758                content,
759                &code_blocks,
760                &code_spans,
761                &html_comment_ranges,
762            )
763        );
764
765        // Layer 2: Filter pre-computed collections to exclude items inside kramdown extension blocks.
766        // Rules that iterate these collections automatically skip kramdown content.
767        let links = links
768            .into_iter()
769            .filter(|link| !lines.get(link.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
770            .collect::<Vec<_>>();
771        let images = images
772            .into_iter()
773            .filter(|img| !lines.get(img.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
774            .collect::<Vec<_>>();
775        let broken_links = broken_links
776            .into_iter()
777            .filter(|bl| {
778                // BrokenLinkInfo has span but no line field; find line from byte offset
779                let line_idx = line_offsets
780                    .partition_point(|&offset| offset <= bl.span.start)
781                    .saturating_sub(1);
782                !lines.get(line_idx).is_some_and(|l| l.in_kramdown_extension_block)
783            })
784            .collect::<Vec<_>>();
785        let footnote_refs = footnote_refs
786            .into_iter()
787            .filter(|fr| !lines.get(fr.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
788            .collect::<Vec<_>>();
789        let reference_defs = reference_defs
790            .into_iter()
791            .filter(|def| !lines.get(def.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
792            .collect::<Vec<_>>();
793        let list_blocks = list_blocks
794            .into_iter()
795            .filter(|block| {
796                !lines
797                    .get(block.start_line - 1)
798                    .is_some_and(|l| l.in_kramdown_extension_block)
799            })
800            .collect::<Vec<_>>();
801        let table_blocks = table_blocks
802            .into_iter()
803            .filter(|block| {
804                // TableBlock.start_line is 0-indexed
805                !lines
806                    .get(block.start_line)
807                    .is_some_and(|l| l.in_kramdown_extension_block)
808            })
809            .collect::<Vec<_>>();
810        let emphasis_spans = emphasis_spans
811            .into_iter()
812            .filter(|span| !lines.get(span.line - 1).is_some_and(|l| l.in_kramdown_extension_block))
813            .collect::<Vec<_>>();
814
815        // Mark lines covered by a list or table block so is_in_list_block /
816        // is_in_table_block are O(1) reads (mirrors in_html_block) instead of
817        // scanning the whole block vector on every call.
818        for block in &list_blocks {
819            // ListBlock line numbers are 1-indexed.
820            for line_num in block.start_line..=block.end_line {
821                if let Some(li) = lines.get_mut(line_num - 1) {
822                    li.in_list_block = true;
823                }
824            }
825        }
826        for block in &table_blocks {
827            // TableBlock line numbers are 0-indexed.
828            for idx in block.start_line..=block.end_line {
829                if let Some(li) = lines.get_mut(idx) {
830                    li.in_table_block = true;
831                }
832            }
833        }
834
835        // Rebuild reference_defs_map after filtering
836        let reference_defs_map: HashMap<String, usize> = reference_defs
837            .iter()
838            .enumerate()
839            .map(|(idx, def)| (def.id.to_lowercase(), idx))
840            .collect();
841
842        // Pre-compute sorted link title byte ranges for binary search
843        let link_title_ranges: Vec<(usize, usize)> = reference_defs
844            .iter()
845            .filter_map(|def| match (def.title_byte_start, def.title_byte_end) {
846                (Some(start), Some(end)) => Some((start, end)),
847                _ => None,
848            })
849            .collect();
850
851        // Reuse already-computed line_offsets and code_blocks instead of re-detecting
852        let line_index = profile_section!(
853            "Line index",
854            profile,
855            crate::utils::range_utils::LineIndex::with_line_starts_and_code_blocks(
856                content,
857                line_offsets.clone(),
858                &code_blocks,
859            )
860        );
861
862        // Pre-compute Jinja template ranges once for all rules (eliminates O(n*m) in MD011)
863        let jinja_ranges = profile_section!(
864            "Jinja ranges",
865            profile,
866            crate::utils::jinja_utils::find_jinja_ranges(content)
867        );
868
869        // Pre-compute Pandoc/Quarto citation ranges for Pandoc-compatible flavors
870        let citation_ranges = profile_section!("Citation ranges", profile, {
871            if flavor.is_pandoc_compatible() {
872                crate::utils::pandoc::find_citation_ranges(content)
873            } else {
874                Vec::new()
875            }
876        });
877
878        // Pre-compute Pandoc inline footnote ranges for Pandoc-compatible flavors
879        let inline_footnote_ranges = profile_section!("Inline footnote ranges", profile, {
880            if flavor.is_pandoc_compatible() {
881                crate::utils::pandoc::detect_inline_footnote_ranges(content)
882            } else {
883                Vec::new()
884            }
885        });
886
887        // Pre-compute Pandoc implicit header reference slugs for Pandoc-compatible flavors
888        let pandoc_header_slugs = profile_section!("Pandoc header slugs", profile, {
889            if flavor.is_pandoc_compatible() {
890                crate::utils::pandoc::collect_pandoc_header_slugs(content)
891            } else {
892                std::collections::HashSet::new()
893            }
894        });
895
896        // Pre-compute Pandoc example-list marker ranges for Pandoc-compatible flavors
897        let example_list_marker_ranges = profile_section!("Example list markers", profile, {
898            if flavor.is_pandoc_compatible() {
899                crate::utils::pandoc::detect_example_list_marker_ranges(content)
900            } else {
901                Vec::new()
902            }
903        });
904
905        // Pre-compute Pandoc example reference ranges for Pandoc-compatible flavors
906        let example_reference_ranges = profile_section!("Example references", profile, {
907            if flavor.is_pandoc_compatible() {
908                crate::utils::pandoc::detect_example_reference_ranges(content, &example_list_marker_ranges)
909            } else {
910                Vec::new()
911            }
912        });
913
914        // Pre-compute Pandoc subscript (~x~) and superscript (^x^) ranges
915        let sub_super_ranges = profile_section!("Subscript/superscript ranges", profile, {
916            if flavor.is_pandoc_compatible() {
917                crate::utils::pandoc::detect_subscript_superscript_ranges(content)
918            } else {
919                Vec::new()
920            }
921        });
922
923        // Pre-compute Pandoc inline code attribute ranges (`code`{.lang}) for Pandoc-compatible flavors
924        let inline_code_attr_ranges = profile_section!("Inline code attribute ranges", profile, {
925            if flavor.is_pandoc_compatible() {
926                crate::utils::pandoc::detect_inline_code_attr_ranges(content)
927            } else {
928                Vec::new()
929            }
930        });
931
932        // Pre-compute Pandoc bracketed span ranges ([text]{attrs}) for Pandoc-compatible flavors
933        let bracketed_span_ranges = profile_section!("Bracketed span ranges", profile, {
934            if flavor.is_pandoc_compatible() {
935                crate::utils::pandoc::detect_bracketed_span_ranges(content)
936            } else {
937                Vec::new()
938            }
939        });
940
941        // Pre-compute Pandoc line block ranges (| text) for Pandoc-compatible flavors
942        let line_block_ranges = profile_section!("Line block ranges", profile, {
943            if flavor.is_pandoc_compatible() {
944                crate::utils::pandoc::detect_line_block_ranges(content)
945            } else {
946                Vec::new()
947            }
948        });
949
950        // Pre-compute Pandoc pipe-table caption ranges (: caption) for Pandoc-compatible flavors
951        let pipe_table_caption_ranges = profile_section!("Pipe-table caption ranges", profile, {
952            if flavor.is_pandoc_compatible() {
953                crate::utils::pandoc::detect_pipe_table_caption_ranges(content)
954            } else {
955                Vec::new()
956            }
957        });
958
959        // Pre-compute Pandoc YAML metadata block ranges (--- ... --- or ...) for Pandoc-compatible flavors
960        let pandoc_metadata_ranges = profile_section!("Pandoc metadata ranges", profile, {
961            if flavor.is_pandoc_compatible() {
962                crate::utils::pandoc::detect_yaml_metadata_block_ranges(content)
963            } else {
964                Vec::new()
965            }
966        });
967
968        // Pre-compute Pandoc grid-table ranges (+---+---+) for Pandoc-compatible flavors
969        let grid_table_ranges = profile_section!("Grid table ranges", profile, {
970            if flavor.is_pandoc_compatible() {
971                crate::utils::pandoc::detect_grid_table_ranges(content)
972            } else {
973                Vec::new()
974            }
975        });
976
977        // Pre-compute Pandoc multi-line table ranges for Pandoc-compatible flavors
978        let multi_line_table_ranges = profile_section!("Multi-line table ranges", profile, {
979            if flavor.is_pandoc_compatible() {
980                crate::utils::pandoc::detect_multi_line_table_ranges(content)
981            } else {
982                Vec::new()
983            }
984        });
985
986        // Pre-compute Hugo/Quarto shortcode ranges ({{< ... >}} and {{% ... %}})
987        let shortcode_ranges = profile_section!("Shortcode ranges", profile, {
988            use crate::utils::regex_cache::HUGO_SHORTCODE_REGEX;
989            let mut ranges = Vec::new();
990            for mat in HUGO_SHORTCODE_REGEX.find_iter(content) {
991                ranges.push((mat.start(), mat.end()));
992            }
993            ranges
994        });
995
996        let inline_config = InlineConfig::from_content_with_code_blocks(content, &code_blocks);
997
998        Self {
999            content,
1000            content_lines,
1001            line_offsets,
1002            code_blocks,
1003            code_block_details,
1004            strong_spans,
1005            line_to_list,
1006            list_start_values,
1007            lines,
1008            links,
1009            images,
1010            broken_links,
1011            footnote_refs,
1012            reference_defs,
1013            reference_defs_map,
1014            code_spans_cache: OnceLock::from(Arc::new(code_spans)),
1015            math_spans_cache: OnceLock::new(),       // Lazy-loaded on first access
1016            math_byte_ranges_cache: OnceLock::new(), // Lazy-loaded on first access
1017            list_blocks,
1018            char_frequency,
1019            html_tags_cache: OnceLock::new(),
1020            jsx_component_tags_cache: OnceLock::new(),
1021            emphasis_spans_cache: OnceLock::from(Arc::new(emphasis_spans)),
1022            bare_urls_cache: OnceLock::new(),
1023            has_mixed_list_nesting_cache: OnceLock::new(),
1024            html_comment_ranges,
1025            table_blocks,
1026            line_index,
1027            jinja_ranges,
1028            flavor,
1029            source_file,
1030            jsx_expression_ranges,
1031            mdx_comment_ranges,
1032            citation_ranges,
1033            pandoc_div_ranges,
1034            colon_fence_ranges,
1035            inline_footnote_ranges,
1036            pandoc_header_slugs,
1037            example_list_marker_ranges,
1038            example_reference_ranges,
1039            sub_super_ranges,
1040            inline_code_attr_ranges,
1041            bracketed_span_ranges,
1042            line_block_ranges,
1043            pipe_table_caption_ranges,
1044            pandoc_metadata_ranges,
1045            grid_table_ranges,
1046            multi_line_table_ranges,
1047            shortcode_ranges,
1048            link_title_ranges,
1049            code_span_byte_ranges: code_span_ranges,
1050            inline_config,
1051            obsidian_comment_ranges,
1052            unterminated_html_comment,
1053            unterminated_obsidian_comment,
1054            lazy_cont_lines_cache: OnceLock::new(),
1055            myst_directive_ranges,
1056            myst_comment_ranges,
1057            myst_role_ranges,
1058            front_matter_end,
1059        }
1060    }
1061
1062    /// The 1-indexed line number where front matter ends (the closing
1063    /// delimiter line), or 0 when the document has no front matter.
1064    /// Computed once in `new()`; rules must use this instead of re-scanning
1065    /// the content with `FrontMatterUtils`.
1066    pub fn front_matter_end_line(&self) -> usize {
1067        self.front_matter_end
1068    }
1069
1070    /// Binary search for whether `pos` falls inside any range in a sorted, non-overlapping
1071    /// slice of `(start, end)` byte ranges. O(log n) instead of O(n).
1072    #[inline]
1073    fn binary_search_ranges(ranges: &[(usize, usize)], pos: usize) -> bool {
1074        // Find the rightmost range whose start <= pos
1075        let idx = ranges.partition_point(|&(start, _)| start <= pos);
1076        // If idx == 0, no range starts at or before pos
1077        idx > 0 && pos < ranges[idx - 1].1
1078    }
1079
1080    /// Check if a byte position is within a code span. O(log n).
1081    pub fn is_in_code_span_byte(&self, pos: usize) -> bool {
1082        Self::binary_search_ranges(&self.code_span_byte_ranges, pos)
1083    }
1084
1085    /// Check if `pos` is inside any link byte range. O(log n).
1086    pub fn is_in_link(&self, pos: usize) -> bool {
1087        let idx = self.links.partition_point(|link| link.byte_offset <= pos);
1088        if idx > 0 && pos < self.links[idx - 1].byte_end {
1089            return true;
1090        }
1091        let idx = self.images.partition_point(|img| img.byte_offset <= pos);
1092        if idx > 0 && pos < self.images[idx - 1].byte_end {
1093            return true;
1094        }
1095        self.is_in_reference_def(pos)
1096    }
1097
1098    /// Get parsed inline configuration state.
1099    pub fn inline_config(&self) -> &InlineConfig {
1100        &self.inline_config
1101    }
1102
1103    /// Byte ranges of Azure DevOps colon code fences (`:::lang … :::`).
1104    /// Empty for all other flavors.
1105    pub fn colon_fence_ranges(&self) -> &[(usize, usize)] {
1106        &self.colon_fence_ranges
1107    }
1108
1109    /// Get pre-split content lines, avoiding repeated `content.lines().collect()` allocations.
1110    ///
1111    /// Lines are 0-indexed (line 0 corresponds to line number 1 in the document).
1112    pub fn raw_lines(&self) -> &[&'a str] {
1113        &self.content_lines
1114    }
1115
1116    /// Check if a rule is disabled at a specific line number (1-indexed)
1117    ///
1118    /// This method checks both persistent disable comments (<!-- rumdl-disable -->)
1119    /// and line-specific comments (<!-- rumdl-disable-line -->, <!-- rumdl-disable-next-line -->).
1120    pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
1121        self.inline_config.is_rule_disabled(rule_name, line_number)
1122    }
1123
1124    /// Get code spans - computed lazily on first access
1125    pub fn code_spans(&self) -> Arc<Vec<CodeSpan>> {
1126        Arc::clone(
1127            self.code_spans_cache
1128                .get_or_init(|| Arc::new(element_parsers::parse_code_spans(self.content, &self.lines))),
1129        )
1130    }
1131
1132    /// Math byte ranges (`$...$` inline and `$$...$$` display), computed once and
1133    /// cached. Used by `is_in_math_context`; without the cache that helper
1134    /// rescanned the whole document on every call.
1135    pub fn math_byte_ranges(&self) -> &[(usize, usize)] {
1136        self.math_byte_ranges_cache
1137            .get_or_init(|| crate::utils::skip_context::math_byte_ranges(self.content))
1138    }
1139
1140    /// Get math spans - computed lazily on first access
1141    pub fn math_spans(&self) -> Arc<Vec<MathSpan>> {
1142        Arc::clone(
1143            self.math_spans_cache
1144                .get_or_init(|| Arc::new(element_parsers::parse_math_spans(self.content, &self.lines))),
1145        )
1146    }
1147
1148    /// Check if a byte position is within a math span (inline $...$ or display $$...$$)
1149    pub fn is_in_math_span(&self, byte_pos: usize) -> bool {
1150        let math_spans = self.math_spans();
1151        // Binary search: find the last span whose byte_offset <= byte_pos
1152        let idx = math_spans.partition_point(|span| span.byte_offset <= byte_pos);
1153        idx > 0 && byte_pos < math_spans[idx - 1].byte_end
1154    }
1155
1156    /// Get HTML comment ranges - pre-computed during LintContext construction
1157    pub fn html_comment_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1158        &self.html_comment_ranges
1159    }
1160
1161    /// Byte offset of a `<!--` that no `-->` closes, if the document has one.
1162    ///
1163    /// Everything after it is inside the comment as far as the parser is
1164    /// concerned, so no rule sees that text.
1165    pub fn unterminated_html_comment(&self) -> Option<usize> {
1166        self.unterminated_html_comment
1167    }
1168
1169    /// Byte offset of a `%%` that no second `%%` closes, if the document has
1170    /// one. Always `None` outside the Obsidian flavor, where `%%` is ordinary
1171    /// text rather than a comment delimiter.
1172    pub fn unterminated_obsidian_comment(&self) -> Option<usize> {
1173        self.unterminated_obsidian_comment
1174    }
1175
1176    /// Check if a byte position is inside an Obsidian comment
1177    ///
1178    /// Returns false for non-Obsidian flavors.
1179    pub fn is_in_obsidian_comment(&self, byte_pos: usize) -> bool {
1180        Self::binary_search_ranges(&self.obsidian_comment_ranges, byte_pos)
1181    }
1182
1183    /// Check if a line/column position is inside an Obsidian comment
1184    ///
1185    /// Line number is 1-indexed, column is 1-indexed.
1186    /// Returns false for non-Obsidian flavors.
1187    pub fn is_position_in_obsidian_comment(&self, line_num: usize, col: usize) -> bool {
1188        if self.obsidian_comment_ranges.is_empty() {
1189            return false;
1190        }
1191
1192        // Convert line/column (1-indexed, char-based) to byte position
1193        let byte_pos = self.line_index.line_col_to_byte_range(line_num, col).start;
1194        self.is_in_obsidian_comment(byte_pos)
1195    }
1196
1197    /// Get byte ranges of MyST colon directive blocks
1198    pub fn myst_directive_ranges(&self) -> &[(usize, usize)] {
1199        &self.myst_directive_ranges
1200    }
1201
1202    /// Check if a byte position is inside a MyST role (`{role}`content``)
1203    pub fn is_in_myst_role(&self, byte_pos: usize) -> bool {
1204        Self::binary_search_ranges(&self.myst_role_ranges, byte_pos)
1205    }
1206
1207    /// Check if a byte position is inside a MyST comment (`% comment`)
1208    pub fn is_in_myst_comment(&self, byte_pos: usize) -> bool {
1209        Self::binary_search_ranges(&self.myst_comment_ranges, byte_pos)
1210    }
1211
1212    /// Check if a line (1-indexed) is a MyST colon-fence directive opener (`:::{name} ...`).
1213    ///
1214    /// The text after `{name}` on an opener is the directive's argument (an opaque
1215    /// path, URL, or label), not markdown prose. Rules that reformat prose should
1216    /// skip these lines. Returns false for non-MyST flavors and for directive body
1217    /// or closer lines.
1218    pub fn is_myst_colon_directive_opener_line(&self, line_num: usize) -> bool {
1219        if !self.flavor.supports_myst_directives() {
1220            return false;
1221        }
1222        self.lines.get(line_num.wrapping_sub(1)).is_some_and(|info| {
1223            info.in_myst_directive
1224                && flavor_detection::myst_colon_directive_opener(info.content(self.content)).is_some()
1225        })
1226    }
1227
1228    /// Drop tags that live inside kramdown extension blocks, preserving order.
1229    fn filter_kramdown_tags(&self, tags: Vec<HtmlTag>) -> Vec<HtmlTag> {
1230        tags.into_iter()
1231            .filter(|tag| {
1232                !self
1233                    .lines
1234                    .get(tag.line - 1)
1235                    .is_some_and(|l| l.in_kramdown_extension_block)
1236            })
1237            .collect()
1238    }
1239
1240    /// Get HTML tags - computed lazily on first access.
1241    ///
1242    /// JSX component tags (e.g. `<Card .../>`) are excluded so HTML-specific rules
1243    /// keep ignoring them; use [`Self::jsx_component_tags`] to access those. The
1244    /// single underlying parse populates both caches at once.
1245    pub fn html_tags(&self) -> Arc<Vec<HtmlTag>> {
1246        Arc::clone(self.html_tags_cache.get_or_init(|| {
1247            let (html_tags, jsx_component_tags) =
1248                element_parsers::parse_html_tags(self.content, &self.lines, &self.code_blocks, self.flavor);
1249            // Populate the JSX-component cache from the same parse so it is built once.
1250            let _ = self
1251                .jsx_component_tags_cache
1252                .set(Arc::new(self.filter_kramdown_tags(jsx_component_tags)));
1253            Arc::new(self.filter_kramdown_tags(html_tags))
1254        }))
1255    }
1256
1257    /// Get JSX component tags (e.g. `<Card .../>`) - computed lazily, sharing the
1258    /// HTML-tag parse. Always empty for flavors without JSX support.
1259    pub fn jsx_component_tags(&self) -> Arc<Vec<HtmlTag>> {
1260        if let Some(cached) = self.jsx_component_tags_cache.get() {
1261            return Arc::clone(cached);
1262        }
1263        // Trigger the shared parse, which also fills jsx_component_tags_cache.
1264        let _ = self.html_tags();
1265        Arc::clone(
1266            self.jsx_component_tags_cache
1267                .get()
1268                .expect("html_tags() populates jsx_component_tags_cache"),
1269        )
1270    }
1271
1272    /// Get emphasis spans - pre-computed during construction
1273    pub fn emphasis_spans(&self) -> Arc<Vec<EmphasisSpan>> {
1274        Arc::clone(
1275            self.emphasis_spans_cache
1276                .get()
1277                .expect("emphasis_spans_cache initialized during construction"),
1278        )
1279    }
1280
1281    /// Get bare URLs - computed lazily on first access
1282    pub fn bare_urls(&self) -> Arc<Vec<BareUrl>> {
1283        Arc::clone(self.bare_urls_cache.get_or_init(|| {
1284            Arc::new(element_parsers::parse_bare_urls(
1285                self.content,
1286                &self.lines,
1287                &self.code_blocks,
1288            ))
1289        }))
1290    }
1291
1292    /// Get lazy continuation lines - computed lazily on first access
1293    pub fn lazy_continuation_lines(&self) -> Arc<Vec<LazyContLine>> {
1294        Arc::clone(self.lazy_cont_lines_cache.get_or_init(|| {
1295            Arc::new(element_parsers::detect_lazy_continuation_lines(
1296                self.content,
1297                &self.lines,
1298                &self.line_offsets,
1299            ))
1300        }))
1301    }
1302
1303    /// Check if document has mixed ordered/unordered list nesting.
1304    /// Result is cached after first computation (document-level invariant).
1305    /// This is used by MD007 for smart style auto-detection.
1306    pub fn has_mixed_list_nesting(&self) -> bool {
1307        *self
1308            .has_mixed_list_nesting_cache
1309            .get_or_init(|| self.compute_mixed_list_nesting())
1310    }
1311
1312    /// Internal computation for mixed list nesting (only called once per LintContext).
1313    fn compute_mixed_list_nesting(&self) -> bool {
1314        // Track parent list items by their marker position and type
1315        // Using marker_column instead of indent because it works correctly
1316        // for blockquoted content where indent doesn't account for the prefix
1317        // Stack stores: (marker_column, is_ordered)
1318        let mut stack: Vec<(usize, bool)> = Vec::new();
1319        let mut last_was_blank = false;
1320
1321        for line_info in &self.lines {
1322            // Skip non-content lines (code blocks, frontmatter, HTML comments, etc.)
1323            if line_info.in_code_block
1324                || line_info.in_front_matter
1325                || line_info.in_mkdocstrings
1326                || line_info.in_html_comment
1327                || line_info.in_mdx_comment
1328                || line_info.in_esm_block
1329            {
1330                continue;
1331            }
1332
1333            // OPTIMIZATION: Use pre-computed is_blank instead of content().trim()
1334            if line_info.is_blank {
1335                last_was_blank = true;
1336                continue;
1337            }
1338
1339            if let Some(list_item) = &line_info.list_item {
1340                // Normalize column 1 to column 0 (consistent with MD007 check function)
1341                let current_pos = if list_item.marker_column == 1 {
1342                    0
1343                } else {
1344                    list_item.marker_column
1345                };
1346
1347                // If there was a blank line and this item is at root level, reset stack
1348                if last_was_blank && current_pos == 0 {
1349                    stack.clear();
1350                }
1351                last_was_blank = false;
1352
1353                // Pop items at same or greater position (they're siblings or deeper, not parents)
1354                while let Some(&(pos, _)) = stack.last() {
1355                    if pos >= current_pos {
1356                        stack.pop();
1357                    } else {
1358                        break;
1359                    }
1360                }
1361
1362                // Check if immediate parent has different type - this is mixed nesting
1363                if let Some(&(_, parent_is_ordered)) = stack.last()
1364                    && parent_is_ordered != list_item.is_ordered
1365                {
1366                    return true; // Found mixed nesting - early exit
1367                }
1368
1369                stack.push((current_pos, list_item.is_ordered));
1370            } else {
1371                // Non-list line (but not blank) - could be paragraph or other content
1372                last_was_blank = false;
1373            }
1374        }
1375
1376        false
1377    }
1378
1379    /// Map a byte offset to (line, column).
1380    ///
1381    /// The column is a 1-indexed *character* offset within the line (rumdl's
1382    /// diagnostic convention), not a byte offset, so it is correct on lines
1383    /// containing multi-byte UTF-8 characters.
1384    pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
1385        match self.line_offsets.binary_search(&offset) {
1386            Ok(line) => (line + 1, 1),
1387            Err(line) => {
1388                let line_start = self.line_offsets.get(line.wrapping_sub(1)).copied().unwrap_or(0);
1389                // Convert the byte offset within the line to a character column.
1390                let col = byte_to_char_count(&self.content[line_start..], offset.saturating_sub(line_start));
1391                (line, col)
1392            }
1393        }
1394    }
1395
1396    /// Check if a position is within a code block or code span. O(log n).
1397    pub fn is_in_code_block_or_span(&self, pos: usize) -> bool {
1398        // Check code blocks first (already uses binary search internally)
1399        if CodeBlockUtils::is_in_code_block_or_span(&self.code_blocks, pos) {
1400            return true;
1401        }
1402
1403        // Check inline code spans via binary search
1404        self.is_byte_offset_in_code_span(pos)
1405    }
1406
1407    /// Get line information by line number (1-indexed)
1408    pub fn line_info(&self, line_num: usize) -> Option<&LineInfo> {
1409        if line_num > 0 {
1410            self.lines.get(line_num - 1)
1411        } else {
1412            None
1413        }
1414    }
1415
1416    /// Get URL for a reference link/image by its ID (O(1) lookup via HashMap)
1417    pub fn get_reference_url(&self, ref_id: &str) -> Option<&str> {
1418        let normalized_id = ref_id.to_lowercase();
1419        self.reference_defs_map
1420            .get(&normalized_id)
1421            .map(|&idx| self.reference_defs[idx].url.as_str())
1422    }
1423
1424    /// Check if a line is part of a list block
1425    pub fn is_in_list_block(&self, line_num: usize) -> bool {
1426        if line_num == 0 || line_num > self.lines.len() {
1427            return false;
1428        }
1429        self.lines[line_num - 1].in_list_block
1430    }
1431
1432    /// Check if a line is within an HTML block
1433    pub fn is_in_html_block(&self, line_num: usize) -> bool {
1434        if line_num == 0 || line_num > self.lines.len() {
1435            return false;
1436        }
1437        self.lines[line_num - 1].in_html_block
1438    }
1439
1440    /// Check if a 1-indexed line number is inside a GFM table block.
1441    ///
1442    /// Returns `true` for the header line, delimiter line, and all body rows.
1443    /// `TableBlock` spans are stored 0-indexed; this helper accepts the
1444    /// 1-indexed line numbers used elsewhere in the rule API.
1445    pub fn is_in_table_block(&self, line_num: usize) -> bool {
1446        if line_num == 0 || line_num > self.lines.len() {
1447            return false;
1448        }
1449        self.lines[line_num - 1].in_table_block
1450    }
1451
1452    /// Check if a line and column is within a code span
1453    pub fn is_in_code_span(&self, line_num: usize, col: usize) -> bool {
1454        if line_num == 0 || line_num > self.lines.len() {
1455            return false;
1456        }
1457
1458        // Use the code spans cache to check
1459        // Note: col is 1-indexed from caller, but span.start_col and span.end_col are 0-indexed
1460        // Convert col to 0-indexed for comparison
1461        let col_0indexed = if col > 0 { col - 1 } else { 0 };
1462        let code_spans = self.code_spans();
1463        code_spans.iter().any(|span| {
1464            // Check if line is within the span's line range
1465            if line_num < span.line || line_num > span.end_line {
1466                return false;
1467            }
1468
1469            if span.line == span.end_line {
1470                // Single-line span: check column bounds
1471                col_0indexed >= span.start_col && col_0indexed < span.end_col
1472            } else if line_num == span.line {
1473                // First line of multi-line span: anything after start_col is in span
1474                col_0indexed >= span.start_col
1475            } else if line_num == span.end_line {
1476                // Last line of multi-line span: anything before end_col is in span
1477                col_0indexed < span.end_col
1478            } else {
1479                // Middle line of multi-line span: entire line is in span
1480                true
1481            }
1482        })
1483    }
1484
1485    /// Check if a byte offset is within a code span. O(log n).
1486    #[inline]
1487    pub fn is_byte_offset_in_code_span(&self, byte_offset: usize) -> bool {
1488        let code_spans = self.code_spans();
1489        let idx = code_spans.partition_point(|span| span.byte_offset <= byte_offset);
1490        idx > 0 && byte_offset < code_spans[idx - 1].byte_end
1491    }
1492
1493    /// Check if a byte position is within a reference definition. O(log n).
1494    #[inline]
1495    pub fn is_in_reference_def(&self, byte_pos: usize) -> bool {
1496        let idx = self.reference_defs.partition_point(|rd| rd.byte_offset <= byte_pos);
1497        idx > 0 && byte_pos < self.reference_defs[idx - 1].byte_end
1498    }
1499
1500    /// Check if a byte position is within an HTML comment. O(log n).
1501    #[inline]
1502    pub fn is_in_html_comment(&self, byte_pos: usize) -> bool {
1503        let idx = self.html_comment_ranges.partition_point(|r| r.start <= byte_pos);
1504        idx > 0 && byte_pos < self.html_comment_ranges[idx - 1].end
1505    }
1506
1507    /// Check if a byte position is within an HTML tag (including multiline tags).
1508    /// Uses the pre-parsed html_tags which correctly handles tags spanning multiple lines. O(log n).
1509    #[inline]
1510    pub fn is_in_html_tag(&self, byte_pos: usize) -> bool {
1511        let tags = self.html_tags();
1512        let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1513        idx > 0 && byte_pos < tags[idx - 1].byte_end
1514    }
1515
1516    /// Check if a byte position is within a JSX component tag (e.g. `<Card .../>`),
1517    /// including its attribute values and multiline tags. Always false for flavors
1518    /// without JSX support. O(log n).
1519    #[inline]
1520    pub fn is_in_jsx_component_tag(&self, byte_pos: usize) -> bool {
1521        if !self.flavor.supports_jsx() {
1522            return false;
1523        }
1524        let tags = self.jsx_component_tags();
1525        let idx = tags.partition_point(|tag| tag.byte_offset <= byte_pos);
1526        idx > 0 && byte_pos < tags[idx - 1].byte_end
1527    }
1528
1529    /// Check if a byte position is within a Jinja template ({{ }} or {% %}). O(log n).
1530    pub fn is_in_jinja_range(&self, byte_pos: usize) -> bool {
1531        Self::binary_search_ranges(&self.jinja_ranges, byte_pos)
1532    }
1533
1534    /// Check if a byte position is within a JSX expression (MDX: {expression}). O(log n).
1535    #[inline]
1536    pub fn is_in_jsx_expression(&self, byte_pos: usize) -> bool {
1537        Self::binary_search_ranges(&self.jsx_expression_ranges, byte_pos)
1538    }
1539
1540    /// Check if a byte position is within an MDX comment ({/* ... */}). O(log n).
1541    #[inline]
1542    pub fn is_in_mdx_comment(&self, byte_pos: usize) -> bool {
1543        Self::binary_search_ranges(&self.mdx_comment_ranges, byte_pos)
1544    }
1545
1546    /// Check if a byte position is within a Pandoc/Quarto citation (`@key` or `[@key]`).
1547    /// Active for Pandoc-compatible flavors. O(log n).
1548    #[inline]
1549    pub fn is_in_citation(&self, byte_pos: usize) -> bool {
1550        let idx = self.citation_ranges.partition_point(|r| r.start <= byte_pos);
1551        idx > 0 && byte_pos < self.citation_ranges[idx - 1].end
1552    }
1553
1554    /// Pre-computed Pandoc/Quarto citation ranges.
1555    #[inline]
1556    pub fn citation_ranges(&self) -> &[crate::utils::skip_context::ByteRange] {
1557        &self.citation_ranges
1558    }
1559
1560    /// Check if a byte position is within a Pandoc/Quarto div block (`::: ... :::`).
1561    /// Active for Pandoc-compatible flavors. O(log n) via binary search over sorted ranges.
1562    #[inline]
1563    pub fn is_in_div_block(&self, byte_pos: usize) -> bool {
1564        let idx = self.pandoc_div_ranges.partition_point(|r| r.start <= byte_pos);
1565        idx > 0 && byte_pos < self.pandoc_div_ranges[idx - 1].end
1566    }
1567
1568    /// Check if a byte position is within a Pandoc inline footnote (`^[note text]`).
1569    /// Active for Pandoc-compatible flavors. O(log n).
1570    #[inline]
1571    pub fn is_in_inline_footnote(&self, byte_pos: usize) -> bool {
1572        let idx = self.inline_footnote_ranges.partition_point(|r| r.start <= byte_pos);
1573        idx > 0 && byte_pos < self.inline_footnote_ranges[idx - 1].end
1574    }
1575
1576    /// Check if a byte position is within a Pandoc example-list marker (`(@)` /
1577    /// `(@label)` at line start). Active for Pandoc-compatible flavors. O(log n).
1578    #[inline]
1579    pub fn is_in_example_list_marker(&self, byte_pos: usize) -> bool {
1580        let idx = self.example_list_marker_ranges.partition_point(|r| r.start <= byte_pos);
1581        idx > 0 && byte_pos < self.example_list_marker_ranges[idx - 1].end
1582    }
1583
1584    /// Check if a byte position is within a Pandoc example reference (`(@label)`
1585    /// inline). Active for Pandoc-compatible flavors. O(log n).
1586    #[inline]
1587    pub fn is_in_example_reference(&self, byte_pos: usize) -> bool {
1588        let idx = self.example_reference_ranges.partition_point(|r| r.start <= byte_pos);
1589        idx > 0 && byte_pos < self.example_reference_ranges[idx - 1].end
1590    }
1591
1592    /// Check if a byte position is within a Pandoc subscript (`~x~`) or
1593    /// superscript (`^x^`) span. Active for Pandoc-compatible flavors. O(log n).
1594    #[inline]
1595    pub fn is_in_subscript_or_superscript(&self, byte_pos: usize) -> bool {
1596        let idx = self.sub_super_ranges.partition_point(|r| r.start <= byte_pos);
1597        idx > 0 && byte_pos < self.sub_super_ranges[idx - 1].end
1598    }
1599
1600    /// Check if a byte position is within a Pandoc inline-code attribute block
1601    /// (`{.lang}` immediately following `` `code` ``). Active for Pandoc-compatible
1602    /// flavors. O(log n).
1603    #[inline]
1604    pub fn is_in_inline_code_attr(&self, byte_pos: usize) -> bool {
1605        let idx = self.inline_code_attr_ranges.partition_point(|r| r.start <= byte_pos);
1606        idx > 0 && byte_pos < self.inline_code_attr_ranges[idx - 1].end
1607    }
1608
1609    /// Check if a byte position is within a Pandoc bracketed span (`[text]{attrs}`).
1610    /// Active for Pandoc-compatible flavors. O(log n).
1611    #[inline]
1612    pub fn is_in_bracketed_span(&self, byte_pos: usize) -> bool {
1613        let idx = self.bracketed_span_ranges.partition_point(|r| r.start <= byte_pos);
1614        idx > 0 && byte_pos < self.bracketed_span_ranges[idx - 1].end
1615    }
1616
1617    /// Returns true if `byte_pos` falls inside a Pandoc line block (`| text`).
1618    /// Active for Pandoc-compatible flavors. O(log n).
1619    #[inline]
1620    pub fn is_in_line_block(&self, byte_pos: usize) -> bool {
1621        let idx = self.line_block_ranges.partition_point(|r| r.start <= byte_pos);
1622        idx > 0 && byte_pos < self.line_block_ranges[idx - 1].end
1623    }
1624
1625    /// Returns true if `byte_pos` falls inside a Pandoc pipe-table caption
1626    /// (`: caption` adjacent to a pipe table). Active for Pandoc-compatible
1627    /// flavors. O(log n).
1628    #[inline]
1629    pub fn is_in_pipe_table_caption(&self, byte_pos: usize) -> bool {
1630        let idx = self.pipe_table_caption_ranges.partition_point(|r| r.start <= byte_pos);
1631        idx > 0 && byte_pos < self.pipe_table_caption_ranges[idx - 1].end
1632    }
1633
1634    /// Returns true if `byte_pos` falls inside a Pandoc YAML metadata block.
1635    /// Active for Pandoc-compatible flavors. O(log n).
1636    #[inline]
1637    pub fn is_in_pandoc_metadata(&self, byte_pos: usize) -> bool {
1638        let idx = self.pandoc_metadata_ranges.partition_point(|r| r.start <= byte_pos);
1639        idx > 0 && byte_pos < self.pandoc_metadata_ranges[idx - 1].end
1640    }
1641
1642    /// Returns true if `byte_pos` falls inside a Pandoc grid table.
1643    /// Active for Pandoc-compatible flavors. O(log n).
1644    #[inline]
1645    pub fn is_in_grid_table(&self, byte_pos: usize) -> bool {
1646        let idx = self.grid_table_ranges.partition_point(|r| r.start <= byte_pos);
1647        idx > 0 && byte_pos < self.grid_table_ranges[idx - 1].end
1648    }
1649
1650    /// Returns true if `byte_pos` falls inside a Pandoc multi-line table.
1651    /// Active for Pandoc-compatible flavors. O(log n).
1652    #[inline]
1653    pub fn is_in_multi_line_table(&self, byte_pos: usize) -> bool {
1654        let idx = self.multi_line_table_ranges.partition_point(|r| r.start <= byte_pos);
1655        idx > 0 && byte_pos < self.multi_line_table_ranges[idx - 1].end
1656    }
1657
1658    /// Returns true if `link_text`, after Pandoc slugification, matches a heading
1659    /// in the document. Returns false for non-Pandoc-compatible flavors because
1660    /// the `pandoc_header_slugs` set is empty when the pre-pass detector is gated
1661    /// off. Use this when the caller has raw bracketed text (`[Section name]`).
1662    pub fn matches_implicit_header_reference(&self, link_text: &str) -> bool {
1663        let slug = crate::utils::pandoc::pandoc_header_slug(link_text);
1664        self.pandoc_header_slugs.contains(&slug)
1665    }
1666
1667    /// Returns true if `slug` (already in Pandoc-slug form) matches a heading
1668    /// in the document. Returns false for non-Pandoc-compatible flavors because
1669    /// the `pandoc_header_slugs` set is empty when the pre-pass detector is gated
1670    /// off. Use this when the caller already has a slug (e.g. the fragment of a
1671    /// URL after `#`). O(1).
1672    #[inline]
1673    pub fn has_pandoc_slug(&self, slug: &str) -> bool {
1674        self.pandoc_header_slugs.contains(slug)
1675    }
1676
1677    /// Check if a byte position is within a Hugo/Quarto shortcode ({{< ... >}} or {{% ... %}}). O(log n).
1678    #[inline]
1679    pub fn is_in_shortcode(&self, byte_pos: usize) -> bool {
1680        Self::binary_search_ranges(&self.shortcode_ranges, byte_pos)
1681    }
1682
1683    /// Pre-computed Hugo/Quarto shortcode ranges.
1684    #[inline]
1685    pub fn shortcode_ranges(&self) -> &[(usize, usize)] {
1686        &self.shortcode_ranges
1687    }
1688
1689    /// Check if a byte position is within a link reference definition title. O(log n).
1690    pub fn is_in_link_title(&self, byte_pos: usize) -> bool {
1691        Self::binary_search_ranges(&self.link_title_ranges, byte_pos)
1692    }
1693
1694    /// Check if content has any instances of a specific character (fast)
1695    pub fn has_char(&self, ch: char) -> bool {
1696        match ch {
1697            '#' => self.char_frequency.hash_count > 0,
1698            '*' => self.char_frequency.asterisk_count > 0,
1699            '_' => self.char_frequency.underscore_count > 0,
1700            '-' => self.char_frequency.hyphen_count > 0,
1701            '+' => self.char_frequency.plus_count > 0,
1702            '>' => self.char_frequency.gt_count > 0,
1703            '|' => self.char_frequency.pipe_count > 0,
1704            '[' => self.char_frequency.bracket_count > 0,
1705            '`' => self.char_frequency.backtick_count > 0,
1706            '<' => self.char_frequency.lt_count > 0,
1707            '!' => self.char_frequency.exclamation_count > 0,
1708            '\n' => self.char_frequency.newline_count > 0,
1709            _ => self.content.contains(ch), // Fallback for other characters
1710        }
1711    }
1712
1713    /// Get count of a specific character (fast)
1714    pub fn char_count(&self, ch: char) -> usize {
1715        match ch {
1716            '#' => self.char_frequency.hash_count,
1717            '*' => self.char_frequency.asterisk_count,
1718            '_' => self.char_frequency.underscore_count,
1719            '-' => self.char_frequency.hyphen_count,
1720            '+' => self.char_frequency.plus_count,
1721            '>' => self.char_frequency.gt_count,
1722            '|' => self.char_frequency.pipe_count,
1723            '[' => self.char_frequency.bracket_count,
1724            '`' => self.char_frequency.backtick_count,
1725            '<' => self.char_frequency.lt_count,
1726            '!' => self.char_frequency.exclamation_count,
1727            '\n' => self.char_frequency.newline_count,
1728            _ => self.content.matches(ch).count(), // Fallback for other characters
1729        }
1730    }
1731
1732    /// Check if content likely contains headings (fast)
1733    pub fn likely_has_headings(&self) -> bool {
1734        self.char_frequency.hash_count > 0 || self.char_frequency.hyphen_count > 2 || self.content.contains('=') // Setext H1 underlines use '='
1735    }
1736
1737    /// Check if content likely contains lists (fast)
1738    pub fn likely_has_lists(&self) -> bool {
1739        self.char_frequency.asterisk_count > 0
1740            || self.char_frequency.hyphen_count > 0
1741            || self.char_frequency.plus_count > 0
1742    }
1743
1744    /// Check if content likely contains emphasis (fast)
1745    pub fn likely_has_emphasis(&self) -> bool {
1746        self.char_frequency.asterisk_count > 1 || self.char_frequency.underscore_count > 1
1747    }
1748
1749    /// Check if content likely contains tables (fast)
1750    pub fn likely_has_tables(&self) -> bool {
1751        self.char_frequency.pipe_count > 2
1752    }
1753
1754    /// Check if content likely contains blockquotes (fast)
1755    pub fn likely_has_blockquotes(&self) -> bool {
1756        self.char_frequency.gt_count > 0
1757    }
1758
1759    /// Check if content likely contains code (fast)
1760    pub fn likely_has_code(&self) -> bool {
1761        self.char_frequency.backtick_count > 0
1762    }
1763
1764    /// Check if content likely contains links or images (fast)
1765    pub fn likely_has_links_or_images(&self) -> bool {
1766        self.char_frequency.bracket_count > 0 || self.char_frequency.exclamation_count > 0
1767    }
1768
1769    /// Check if content likely contains HTML (fast)
1770    pub fn likely_has_html(&self) -> bool {
1771        self.char_frequency.lt_count > 0
1772    }
1773
1774    /// Get the blockquote prefix for inserting a blank line at the given line index.
1775    /// Returns the prefix without trailing content (e.g., ">" or ">>").
1776    /// This is needed because blank lines inside blockquotes must preserve the blockquote structure.
1777    /// Returns an empty string if the line is not inside a blockquote.
1778    pub fn blockquote_prefix_for_blank_line(&self, line_idx: usize) -> String {
1779        if let Some(line_info) = self.lines.get(line_idx)
1780            && let Some(ref bq) = line_info.blockquote
1781        {
1782            bq.prefix.trim_end().to_string()
1783        } else {
1784            String::new()
1785        }
1786    }
1787
1788    /// Find the line index for a given byte offset using binary search.
1789    /// Returns (line_index, line_number, column) where:
1790    /// - line_index is the 0-based index in the lines array
1791    /// - line_number is the 1-based line number
1792    /// - column is the 0-based *character* offset within that line
1793    ///
1794    /// The column is a character offset rather than a byte offset so that the
1795    /// `start_col`/`end_col` it feeds into match rumdl's diagnostic convention
1796    /// (columns are character positions). On lines with multi-byte UTF-8
1797    /// characters the two differ; reporting bytes would mis-position highlights.
1798    #[inline]
1799    fn find_line_for_offset(lines: &[LineInfo], content: &str, byte_offset: usize) -> (usize, usize, usize) {
1800        // Binary search to find the line containing this byte offset
1801        let idx = match lines.binary_search_by(|line| {
1802            if byte_offset < line.byte_offset {
1803                std::cmp::Ordering::Greater
1804            } else if byte_offset > line.byte_offset + line.byte_len {
1805                std::cmp::Ordering::Less
1806            } else {
1807                std::cmp::Ordering::Equal
1808            }
1809        }) {
1810            Ok(idx) => idx,
1811            Err(idx) => idx.saturating_sub(1),
1812        };
1813
1814        let line = &lines[idx];
1815        let line_num = idx + 1;
1816        let byte_col = byte_offset.saturating_sub(line.byte_offset);
1817        // Convert the byte offset within the line to a 0-based character column.
1818        // `byte_to_char_count` returns a 1-based value, so subtract 1.
1819        let col = byte_to_char_count(line.content(content), byte_col) - 1;
1820
1821        (idx, line_num, col)
1822    }
1823
1824    /// Check if a byte offset is within a code span using binary search
1825    #[inline]
1826    fn is_offset_in_code_span(code_spans: &[CodeSpan], offset: usize) -> bool {
1827        // Since spans are sorted by byte_offset, use partition_point for binary search
1828        let idx = code_spans.partition_point(|span| span.byte_offset <= offset);
1829
1830        // Check the span that starts at or before our offset
1831        if idx > 0 {
1832            let span = &code_spans[idx - 1];
1833            if offset >= span.byte_offset && offset < span.byte_end {
1834                return true;
1835            }
1836        }
1837
1838        false
1839    }
1840
1841    /// Get an iterator over valid headings (skipping invalid ones like `#NoSpace`)
1842    ///
1843    /// Valid headings have proper spacing after the `#` markers (or are level > 1).
1844    /// This is the standard iterator for rules that need to process headings.
1845    ///
1846    /// # Examples
1847    ///
1848    /// ```
1849    /// use rumdl_lib::lint_context::LintContext;
1850    /// use rumdl_lib::config::MarkdownFlavor;
1851    ///
1852    /// let content = "# Valid Heading\n#NoSpace\n## Another Valid";
1853    /// let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1854    ///
1855    /// for heading in ctx.valid_headings() {
1856    ///     println!("Line {}: {} (level {})", heading.line_num, heading.heading.text, heading.heading.level);
1857    /// }
1858    /// // Only prints valid headings, skips `#NoSpace`
1859    /// ```
1860    #[must_use]
1861    pub fn valid_headings(&self) -> ValidHeadingsIter<'_> {
1862        ValidHeadingsIter::new(&self.lines)
1863    }
1864
1865    /// Check if the document contains any valid CommonMark headings
1866    ///
1867    /// Returns `true` if there is at least one heading with proper space after `#`.
1868    #[must_use]
1869    pub fn has_valid_headings(&self) -> bool {
1870        self.lines
1871            .iter()
1872            .any(|line| line.heading.as_ref().is_some_and(|h| h.is_valid))
1873    }
1874}
1875
1876/// The range an unclosed `<!--` hides when it opens a block the parser missed.
1877///
1878/// A MkDocs admonition or a `<div markdown>` body is rendered as markdown in its
1879/// own right, so a `<!--` starting one of its lines opens an HTML block there
1880/// just as it would at the top level. The parser has no notion of either
1881/// container, reads the body as indented code or as a lazy paragraph
1882/// continuation, and so reports no block for the opener to run to the end of.
1883///
1884/// The block ends where the container's body ends, which is what CommonMark
1885/// gives an unclosed comment in any other container. An opener that is not the
1886/// first thing on its line is inline HTML and opens nothing, here as anywhere.
1887fn container_comment_range(
1888    opener: usize,
1889    containers: &flavor_detection::ContainerLines,
1890    lines: &[types::LineInfo],
1891    content: &str,
1892) -> Option<crate::utils::skip_context::ByteRange> {
1893    let line_index = lines
1894        .partition_point(|line| line.byte_offset <= opener)
1895        .checked_sub(1)?;
1896    let line = lines.get(line_index)?;
1897    if line.byte_offset + line.indent != opener {
1898        return None;
1899    }
1900    if !containers.is_container_body(line_index) {
1901        return None;
1902    }
1903    let end_line = lines.get(containers.body_end_line(line_index)?)?;
1904    Some(crate::utils::skip_context::ByteRange {
1905        start: opener,
1906        end: (end_line.byte_offset + end_line.byte_len).min(content.len()),
1907    })
1908}
1909
1910/// Detect footnote definitions and mark their continuation lines.
1911///
1912/// Uses pulldown-cmark to find footnote definition ranges and fenced code
1913/// blocks within them, then:
1914/// 1. Sets `in_footnote_definition = true` on all lines within
1915/// 2. Clears `in_code_block = false` on continuation lines that were
1916///    misidentified as indented code blocks (but preserves real fenced
1917///    code blocks within footnotes)
1918fn detect_footnote_definitions(content: &str, lines: &mut [types::LineInfo], line_offsets: &[usize]) {
1919    use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
1920
1921    let options = crate::utils::rumdl_parser_options();
1922    let parser = Parser::new_ext(content, options).into_offset_iter();
1923
1924    // Collect footnote ranges and fenced code block ranges within them
1925    let mut footnote_ranges: Vec<(usize, usize)> = Vec::new();
1926    let mut fenced_code_ranges: Vec<(usize, usize)> = Vec::new();
1927    let mut in_footnote = false;
1928
1929    for (event, range) in parser {
1930        match event {
1931            Event::Start(Tag::FootnoteDefinition(_)) => {
1932                in_footnote = true;
1933                footnote_ranges.push((range.start, range.end));
1934            }
1935            Event::End(TagEnd::FootnoteDefinition) => {
1936                in_footnote = false;
1937            }
1938            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) if in_footnote => {
1939                fenced_code_ranges.push((range.start, range.end));
1940            }
1941            _ => {}
1942        }
1943    }
1944
1945    let byte_to_line = |byte_offset: usize| -> usize {
1946        line_offsets
1947            .partition_point(|&offset| offset <= byte_offset)
1948            .saturating_sub(1)
1949    };
1950
1951    // Mark footnote definition lines
1952    for &(start, end) in &footnote_ranges {
1953        let start_line = byte_to_line(start);
1954        let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
1955
1956        for line in &mut lines[start_line..end_line] {
1957            line.in_footnote_definition = true;
1958            line.in_code_block = false;
1959        }
1960    }
1961
1962    // Restore in_code_block for fenced code blocks within footnotes
1963    for &(start, end) in &fenced_code_ranges {
1964        let start_line = byte_to_line(start);
1965        let end_line = line_offsets.partition_point(|&offset| offset < end).min(lines.len());
1966
1967        for line in &mut lines[start_line..end_line] {
1968            line.in_code_block = true;
1969        }
1970    }
1971}