Skip to main content

rumdl_lib/lint_context/
mod.rs

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