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