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