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