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