Skip to main content

rumdl_lib/lint_context/
types.rs

1use pulldown_cmark::LinkType;
2use std::borrow::Cow;
3use std::ops::Range;
4
5/// Pre-computed information about a line
6#[derive(Debug, Clone)]
7pub struct LineInfo {
8    /// Byte offset where this line starts in the document
9    pub byte_offset: usize,
10    /// Length of the line in bytes (without newline)
11    pub byte_len: usize,
12    /// Number of bytes of leading whitespace (for substring extraction)
13    pub indent: usize,
14    /// Visual column width of leading whitespace (with proper tab expansion)
15    /// Per CommonMark, tabs expand to the next column that is a multiple of 4.
16    /// Use this for numeric comparisons like checking for indented code blocks (>= 4).
17    pub visual_indent: usize,
18    /// Whether the line is blank (empty or only whitespace)
19    pub is_blank: bool,
20    /// Whether this line is inside a code block
21    pub in_code_block: bool,
22    /// Whether this line is inside front matter
23    pub in_front_matter: bool,
24    /// Whether this line is inside an HTML block
25    pub in_html_block: bool,
26    /// Whether this line is part of a list block (precomputed for O(1) lookup)
27    pub in_list_block: bool,
28    /// Whether this line is part of a table block (precomputed for O(1) lookup)
29    pub in_table_block: bool,
30    /// Whether this line is inside an HTML comment
31    pub in_html_comment: bool,
32    /// List item information if this line starts a list item
33    /// Boxed to reduce LineInfo size: most lines are not list items
34    pub list_item: Option<Box<ListItemInfo>>,
35    /// Heading information if this line is a heading: an ATX heading line, or
36    /// the last text line of a setext heading, whose underline is the line after
37    /// Boxed to reduce LineInfo size: most lines are not headings
38    ///
39    /// Only CommonMark headings are recorded. A line like `#Heading`, with no
40    /// space after its `#`s, renders as paragraph text and is recorded in
41    /// `atx_missing_space` instead.
42    pub heading: Option<Box<HeadingInfo>>,
43    /// Set when the line is shaped like an ATX heading but has no space after
44    /// its `#`s (`#Heading`, `##x`, `#tag`). CommonMark reads it as paragraph
45    /// text, and so does every rule except the ones reporting the missing
46    /// space. Unset on a line that is text of a setext heading, where the `#`
47    /// is part of the heading text.
48    pub atx_missing_space: Option<AtxMissingSpace>,
49    /// Whether the line holds text of a setext heading: one of the lines of the
50    /// paragraph the underline below them makes a heading of. The heading is
51    /// recorded in `heading` on the last of those lines, with `text_lines`
52    /// counting them. A setext heading inside a blockquote is reported through
53    /// `headings()` and leaves this unset, as it leaves `heading` unset.
54    pub is_setext_heading_text: bool,
55    /// Blockquote information if this line is a blockquote
56    /// Boxed to reduce LineInfo size: most lines are not blockquotes
57    pub blockquote: Option<Box<BlockquoteInfo>>,
58    /// Whether this line is inside a mkdocstrings autodoc block
59    pub in_mkdocstrings: bool,
60    /// Whether this line is part of an ESM import/export block (MDX only)
61    pub in_esm_block: bool,
62    /// Whether this line is a continuation of a multi-line code span from a previous line
63    pub in_code_span_continuation: bool,
64    /// Whether this line is a horizontal rule (---, ***, ___, etc.)
65    /// Pre-computed for consistent detection across all rules
66    pub is_horizontal_rule: bool,
67    /// Whether this line is inside a math block ($$ ... $$)
68    pub in_math_block: bool,
69    /// Whether this line is inside a Pandoc/Quarto div block (::: ... :::)
70    pub in_pandoc_div: bool,
71    /// Whether this line is a Quarto/Pandoc div marker (opening ::: {.class} or closing :::)
72    /// Analogous to `is_horizontal_rule` — marks structural delimiters that are not paragraph text
73    pub is_div_marker: bool,
74    /// Whether the line is the marker of a container whose body is Markdown: a
75    /// MkDocs admonition or content tab opener, a PyMdown block fence, or a MyST
76    /// colon fence. Like `is_div_marker`, a structural delimiter rather than
77    /// paragraph text: the body below the marker is the container's own content,
78    /// not a continuation of the marker line.
79    pub is_container_marker: bool,
80    /// Whether this line contains or is inside a JSX expression (MDX only)
81    pub in_jsx_expression: bool,
82    /// Whether this line is inside an MDX comment {/* ... */} (MDX only)
83    pub in_mdx_comment: bool,
84    /// Whether this line is inside an MkDocs admonition block (!!! or ???)
85    pub in_admonition: bool,
86    /// Whether this line is inside an MkDocs content tab block (===)
87    pub in_content_tab: bool,
88    /// Whether this line is inside an HTML block with markdown attribute (MkDocs grid cards, etc.)
89    pub in_mkdocs_html_markdown: bool,
90    /// Whether this line is a definition list item (: definition)
91    pub in_definition_list: bool,
92    /// Whether this line is inside an Obsidian comment (%%...%% syntax, Obsidian flavor only)
93    pub in_obsidian_comment: bool,
94    /// Whether this line is inside a PyMdown Blocks region (/// ... ///, MkDocs flavor only)
95    pub in_pymdown_block: bool,
96    /// Whether this line is inside a kramdown extension block ({::comment}...{:/comment}, {::nomarkdown}...{:/nomarkdown})
97    pub in_kramdown_extension_block: bool,
98    /// Whether this line is a kramdown block IAL ({:.class #id}) or ALD ({:ref: .class})
99    pub is_kramdown_block_ial: bool,
100    /// Whether this line is inside a JSX component block (MDX only, e.g. `<Tabs>...</Tabs>`)
101    pub in_jsx_block: bool,
102    /// Whether this line is inside a footnote definition body (continuation lines)
103    pub in_footnote_definition: bool,
104    /// Whether this line is inside a MyST directive block (colon or backtick fence with `{name}`)
105    pub in_myst_directive: bool,
106    /// Whether this line is a MyST comment (`% comment`)
107    pub is_myst_comment: bool,
108}
109
110impl LineInfo {
111    /// Get the line content as a string slice from the source document
112    pub fn content<'a>(&self, source: &'a str) -> &'a str {
113        &source[self.byte_offset..self.byte_offset + self.byte_len]
114    }
115
116    /// Check if this line is inside MkDocs-specific indented content (admonitions, tabs, or markdown HTML).
117    /// This content uses 4-space indentation which pulldown-cmark would interpret as code blocks,
118    /// but in MkDocs flavor it's actually container content that should be preserved.
119    #[inline]
120    pub fn in_mkdocs_container(&self) -> bool {
121        self.in_admonition || self.in_content_tab || self.in_mkdocs_html_markdown
122    }
123
124    /// Whether this line could be part of a paragraph block (CommonMark `paragraph` token).
125    ///
126    /// Returns true for ordinary prose lines, including those inside blockquotes and list items.
127    /// Returns false for lines that belong to non-paragraph blocks: headings, code blocks,
128    /// HTML blocks, math blocks, horizontal rules, front matter, structural div markers, and
129    /// flavor-specific extension blocks. This is the per-line view; cross-line constructs like
130    /// setext underlines aren't visible here and need additional context to detect.
131    ///
132    /// Used by rules (e.g. MD009 strict mode) that need to distinguish "trailing whitespace
133    /// could produce a meaningful `<br>`" from "trailing whitespace is on a structural boundary."
134    #[inline]
135    pub fn is_paragraph_context(&self) -> bool {
136        !self.in_code_block
137            && !self.in_front_matter
138            && !self.in_html_block
139            && !self.in_html_comment
140            && !self.in_math_block
141            && !self.is_horizontal_rule
142            && !self.is_div_marker
143            && !self.in_pymdown_block
144            && !self.in_kramdown_extension_block
145            && !self.is_kramdown_block_ial
146            && !self.is_myst_comment
147            && self.heading.is_none()
148    }
149
150    /// Whether the line sits in a flavor container whose body is ordinary
151    /// Markdown: a fenced div, a MyST directive, a MkDocs admonition or
152    /// content tab, an mkdocstrings block, a PyMdown block, a kramdown
153    /// extension block, or an HTML block opted in with `markdown="1"`.
154    ///
155    /// Each flag is populated by the flavor's own detection, so a marker
156    /// written in a flavor that gives it no meaning sets none of them and the
157    /// line stays ordinary paragraph text. A container's opening line carries
158    /// its own container's flag, so this is true on the opener as well as on
159    /// the body, at every nesting depth.
160    #[inline]
161    pub fn in_flavor_container(&self) -> bool {
162        self.in_pandoc_div
163            || self.in_myst_directive
164            || self.in_admonition
165            || self.in_content_tab
166            || self.in_mkdocstrings
167            || self.in_pymdown_block
168            || self.in_kramdown_extension_block
169            || self.in_mkdocs_html_markdown
170    }
171}
172
173/// Information about a list item
174/// Text a definition-list definition holds directly, in lines
175///
176/// One of the definition's paragraphs, or the text of a tight definition.
177/// Terms, and blocks nested in the definition, are not definition text.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct DefinitionText {
180    /// First line of the text (1-indexed)
181    pub start_line: usize,
182    /// Last line of the text (1-indexed, inclusive)
183    pub end_line: usize,
184    /// Byte length of what precedes the text on its first line when that line
185    /// is the definition's marker line: 4 for `:   text`, counting any
186    /// indentation before the colon. `None` when the text starts on a later
187    /// line, as a definition's second paragraph does.
188    pub marker_prefix_len: Option<usize>,
189}
190
191#[derive(Debug, Clone)]
192pub struct ListItemInfo {
193    /// The marker used (*, -, +, or number with . or ))
194    pub marker: String,
195    /// Whether it's ordered (true) or unordered (false)
196    pub is_ordered: bool,
197    /// The number for ordered lists
198    pub number: Option<usize>,
199    /// Column where the marker starts (0-based)
200    pub marker_column: usize,
201    /// Column where content after marker starts
202    pub content_column: usize,
203}
204
205/// Heading style type
206#[derive(Debug, Clone, PartialEq)]
207pub enum HeadingStyle {
208    /// ATX style heading (# Heading)
209    ATX,
210    /// Setext style heading with = underline
211    Setext1,
212    /// Setext style heading with - underline
213    Setext2,
214}
215
216/// Parsed link information
217#[derive(Debug, Clone)]
218pub struct ParsedLink<'a> {
219    /// Line number (1-indexed)
220    pub line: usize,
221    /// Line the link ends on (1-indexed). A link can span lines, so `end_col` is
222    /// a column of *this* line, not of `line`.
223    pub end_line: usize,
224    /// Start column (0-indexed) in the line
225    pub start_col: usize,
226    /// End column (0-indexed) in `end_line`
227    pub end_col: usize,
228    /// Byte offset in document
229    pub byte_offset: usize,
230    /// End byte offset in document
231    pub byte_end: usize,
232    /// Link text
233    pub text: Cow<'a, str>,
234    /// Link URL or reference
235    pub url: Cow<'a, str>,
236    /// Inline title (without surrounding delimiters), as produced by pulldown-cmark
237    /// after backslash-escape handling. `None` when the link has no title or is a
238    /// reference style without a matched definition.
239    pub title: Option<Cow<'a, str>>,
240    /// Whether this is a reference link `[text][ref]` vs inline `[text](url)`
241    pub is_reference: bool,
242    /// Reference ID for reference links
243    pub reference_id: Option<Cow<'a, str>>,
244    /// Link type from pulldown-cmark
245    pub link_type: LinkType,
246}
247
248/// Information about a broken link reported by pulldown-cmark
249#[derive(Debug, Clone)]
250pub struct BrokenLinkInfo {
251    /// The reference text that couldn't be resolved
252    pub reference: String,
253    /// Byte span in the source document
254    pub span: std::ops::Range<usize>,
255    /// The type of the broken link
256    pub link_type: LinkType,
257}
258
259/// Parsed footnote reference (e.g., `[^1]`, `[^note]`)
260#[derive(Debug, Clone)]
261pub struct FootnoteRef {
262    /// The footnote ID (without the ^ prefix)
263    pub id: String,
264    /// Line number (1-indexed)
265    pub line: usize,
266    /// Start byte offset in document
267    pub byte_offset: usize,
268}
269
270/// Parsed image information
271#[derive(Debug, Clone)]
272pub struct ParsedImage<'a> {
273    /// Line number (1-indexed)
274    pub line: usize,
275    /// Line the image ends on (1-indexed). An image can span lines, so `end_col`
276    /// is a column of *this* line, not of `line`.
277    pub end_line: usize,
278    /// Start column (0-indexed) in the line
279    pub start_col: usize,
280    /// End column (0-indexed) in `end_line`
281    pub end_col: usize,
282    /// Byte offset in document
283    pub byte_offset: usize,
284    /// End byte offset in document
285    pub byte_end: usize,
286    /// Alt text
287    pub alt_text: Cow<'a, str>,
288    /// Image URL or reference
289    pub url: Cow<'a, str>,
290    /// Inline title (without surrounding delimiters), as produced by pulldown-cmark
291    /// after backslash-escape handling. `None` when the image has no title or is a
292    /// reference style without a matched definition.
293    pub title: Option<Cow<'a, str>>,
294    /// Whether this is a reference image ![alt][ref] vs inline ![alt](url)
295    pub is_reference: bool,
296    /// Reference ID for reference images
297    pub reference_id: Option<Cow<'a, str>>,
298    /// Link type from pulldown-cmark
299    pub link_type: LinkType,
300}
301
302/// Reference definition `[ref]: url "title"`
303#[derive(Debug, Clone)]
304pub struct ReferenceDef {
305    /// Line number (1-indexed)
306    pub line: usize,
307    /// Reference ID (normalized to lowercase)
308    pub id: String,
309    /// URL
310    pub url: String,
311    /// Optional title
312    pub title: Option<String>,
313    /// Byte offset where the reference definition starts
314    pub byte_offset: usize,
315    /// Byte offset where the reference definition ends
316    pub byte_end: usize,
317    /// Byte offset where the title starts (if present, includes quote)
318    pub title_byte_start: Option<usize>,
319    /// Byte offset where the title ends (if present, includes quote)
320    pub title_byte_end: Option<usize>,
321}
322
323/// Parsed code span information
324#[derive(Debug, Clone)]
325pub struct CodeSpan {
326    /// Line number where the code span starts (1-indexed)
327    pub line: usize,
328    /// Line number where the code span ends (1-indexed)
329    pub end_line: usize,
330    /// Start column (0-indexed) in the line
331    pub start_col: usize,
332    /// End column (0-indexed) in the line
333    pub end_col: usize,
334    /// Byte offset in document
335    pub byte_offset: usize,
336    /// End byte offset in document
337    pub byte_end: usize,
338    /// Number of backticks used (1, 2, 3, etc.)
339    pub backtick_count: usize,
340    /// Content inside the code span (without backticks)
341    pub content: String,
342}
343
344/// Parsed math span information (inline $...$ or display $$...$$)
345#[derive(Debug, Clone)]
346pub struct MathSpan {
347    /// Line number where the math span starts (1-indexed)
348    pub line: usize,
349    /// Line number where the math span ends (1-indexed)
350    pub end_line: usize,
351    /// Start column (0-indexed) in the line
352    pub start_col: usize,
353    /// End column (0-indexed) in the line
354    pub end_col: usize,
355    /// Byte offset in document
356    pub byte_offset: usize,
357    /// End byte offset in document
358    pub byte_end: usize,
359    /// Whether this is display math ($$...$$) vs inline ($...$)
360    pub is_display: bool,
361    /// Content inside the math delimiters
362    pub content: String,
363}
364
365/// Information about a heading
366#[derive(Debug, Clone)]
367pub struct HeadingInfo {
368    /// Heading level (1-6 for ATX, 1-2 for Setext)
369    pub level: u8,
370    /// Style of heading
371    pub style: HeadingStyle,
372    /// The heading marker (# characters or underline)
373    pub marker: String,
374    /// Column where the marker starts (0-based)
375    pub marker_column: usize,
376    /// Column where heading text starts
377    pub content_column: usize,
378    /// The heading text (without markers and without custom ID syntax)
379    pub text: String,
380    /// The text a slug is generated from: `text` with every space an anchor
381    /// element left behind, for the anchor styles that slug it
382    /// (`## Alpha <a id="x"></a>` is `#alpha-` on GitHub, and
383    /// `## Foo <a id="x"></a> Bar` is `#foo--bar`). See
384    /// `header_id_utils::HeadingText`.
385    pub slug_text: String,
386    /// Custom header ID if present (e.g., from {#custom-id} syntax)
387    pub custom_id: Option<String>,
388    /// Original heading text including custom ID syntax. A setext heading's is
389    /// the whole paragraph its underline ends, on one line: each soft line break
390    /// is the space it renders as, and a hard line break's backslash goes with
391    /// its line ending.
392    pub raw_text: String,
393    /// How many source lines hold the heading text: one for an ATX heading, and
394    /// every line of the paragraph a setext underline makes a heading of. The
395    /// heading is recorded on the last of them, so the first is `text_lines - 1`
396    /// lines above it.
397    pub text_lines: usize,
398    /// Whether it has a closing sequence (for ATX)
399    pub has_closing_sequence: bool,
400    /// The closing sequence if present
401    pub closing_sequence: String,
402}
403
404/// An ATX-shaped line with no space after its opening `#`s. See
405/// [`LineInfo::atx_missing_space`].
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub struct AtxMissingSpace {
408    /// Number of opening `#`s (1-6)
409    pub level: u8,
410}
411
412/// A heading recognized in the rendered Markdown document.
413///
414/// Unlike [`ValidHeading`], this view includes headings inside blockquotes and
415/// malformed ATX headings retained for diagnostics such as MD018. Consumers
416/// can select the semantics they need without reparsing source lines.
417#[derive(Debug, Clone, Copy)]
418pub struct ParsedHeading<'a> {
419    /// The 1-indexed number of the line the heading is recorded on: the ATX
420    /// line, or the last text line of a setext heading, whose underline is the
421    /// line after it.
422    pub line_num: usize,
423    /// Parsed heading metadata.
424    pub heading: &'a HeadingInfo,
425    /// Full source-line metadata of the line the heading is recorded on.
426    pub line_info: &'a LineInfo,
427    /// Metadata of every line holding the heading text, first to last:
428    /// `line_info` alone, unless the heading is a setext heading whose
429    /// paragraph spans lines.
430    pub text_line_infos: &'a [LineInfo],
431    /// Blockquote nesting depth, or zero for a top-level heading.
432    pub blockquote_depth: usize,
433}
434
435impl<'a> ParsedHeading<'a> {
436    /// Whether this heading is inside a blockquote.
437    #[inline]
438    pub fn is_blockquote(&self) -> bool {
439        self.blockquote_depth > 0
440    }
441
442    /// Whether this is a Setext-style heading.
443    #[inline]
444    pub fn is_setext(&self) -> bool {
445        matches!(self.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2)
446    }
447
448    /// The 1-indexed number of the first line holding the heading text.
449    #[inline]
450    pub fn first_line_num(&self) -> usize {
451        self.line_num + 1 - self.heading.text_lines
452    }
453
454    /// Metadata of the first line holding the heading text.
455    #[inline]
456    pub fn first_line_info(&self) -> &'a LineInfo {
457        &self.text_line_infos[0]
458    }
459
460    /// Byte range of the heading text in the document.
461    ///
462    /// Markers, closing ATX sequences and custom-ID syntax are excluded. The
463    /// text of a setext heading is the whole paragraph its underline ends, so
464    /// the range runs from the text on the paragraph's first line to the text
465    /// on its last, across the line breaks and container prefixes between them.
466    #[must_use]
467    pub fn text_byte_range(&self, source: &str) -> Range<usize> {
468        text_byte_range(self.heading, self.text_line_infos, source)
469    }
470
471    /// Position of the heading text as `(line, column, end_line, end_column)`,
472    /// 1-indexed with character columns and an exclusive end.
473    ///
474    /// A warning about a heading spans this range, which covers every text line
475    /// of a setext heading whose paragraph spans more than one.
476    #[must_use]
477    pub fn text_position_range(&self, ctx: &super::LintContext) -> (usize, usize, usize, usize) {
478        let range = self.text_byte_range(ctx.content);
479        let (line, column) = ctx.offset_to_line_col(range.start);
480        let (end_line, end_column) = ctx.offset_to_line_col(range.end);
481        (line, column, end_line, end_column)
482    }
483}
484
485/// See [`ParsedHeading::text_byte_range`].
486fn text_byte_range(heading: &HeadingInfo, text_lines: &[LineInfo], source: &str) -> Range<usize> {
487    let first_line = &text_lines[0];
488    let first = first_line.content(source);
489    let content_start = heading.content_column.min(first.len());
490    // An ATX heading's text is written on its line after the marker and ends
491    // before its closing sequence, the attribute list a custom ID sits in and
492    // an anchor element, each of which the source is walked back over. The
493    // display text locates the start when it is written as one piece; markup
494    // inside it keeps the range on the whole source, which ends on a character
495    // boundary whatever that markup holds.
496    if matches!(heading.style, HeadingStyle::ATX) {
497        let region = &first[content_start..];
498        let mut end = crate::utils::header_id_utils::heading_text_end(region);
499        if heading.has_closing_sequence
500            && let Some(text) = region[..end].trim_end().strip_suffix(heading.closing_sequence.as_str())
501        {
502            end = crate::utils::header_id_utils::heading_text_end(text);
503        }
504        let start = region[..end].find(&heading.text).unwrap_or(0);
505        let offset = first_line.byte_offset + content_start;
506        return offset + start..offset + end;
507    }
508    // A setext heading's text ends where the display text of the last line
509    // holding any ends: past that line's container prefix and before the
510    // attribute list a custom ID sits in or an anchor element. A line holding
511    // only those is not text, so the end moves up past it, never above the
512    // first line.
513    let start = first_line.byte_offset + content_start;
514    let end = text_lines
515        .iter()
516        .rev()
517        .find_map(|line| {
518            let content = line.content(source);
519            let (text_start, text) = match line.blockquote.as_deref() {
520                Some(quote) => (quote.prefix.len().min(content.len()), quote.content.as_str()),
521                None => (line.indent, &content[line.indent..]),
522            };
523            let text_end = crate::utils::header_id_utils::heading_text_end(text);
524            (text_end > 0).then(|| line.byte_offset + text_start + text_end)
525        })
526        .unwrap_or(start);
527    start..end.max(start)
528}
529
530/// Iterator over all headings recognized in the rendered document.
531pub struct ParsedHeadingsIter<'a> {
532    lines: &'a [LineInfo],
533    blockquote_headings: &'a [Option<Box<HeadingInfo>>],
534    current_index: usize,
535}
536
537impl<'a> ParsedHeadingsIter<'a> {
538    pub(super) fn new(lines: &'a [LineInfo], blockquote_headings: &'a [Option<Box<HeadingInfo>>]) -> Self {
539        debug_assert_eq!(lines.len(), blockquote_headings.len());
540        Self {
541            lines,
542            blockquote_headings,
543            current_index: 0,
544        }
545    }
546}
547
548impl<'a> Iterator for ParsedHeadingsIter<'a> {
549    type Item = ParsedHeading<'a>;
550
551    fn next(&mut self) -> Option<Self::Item> {
552        while self.current_index < self.lines.len() {
553            let idx = self.current_index;
554            self.current_index += 1;
555
556            let line_info = &self.lines[idx];
557            let (heading, blockquote_depth) = if let Some(heading) = line_info.heading.as_deref() {
558                (heading, 0)
559            } else if let Some(heading) = self.blockquote_headings[idx].as_deref() {
560                (heading, line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level))
561            } else {
562                continue;
563            };
564            return Some(ParsedHeading {
565                line_num: idx + 1,
566                heading,
567                line_info,
568                text_line_infos: &self.lines[idx + 1 - heading.text_lines..=idx],
569                blockquote_depth,
570            });
571        }
572        None
573    }
574}
575
576/// A valid heading from a filtered iteration
577///
578/// Every recorded heading is CommonMark-compliant; paragraph text such as
579/// `#tag` or `#123` is never recorded as one.
580#[derive(Debug, Clone)]
581pub struct ValidHeading<'a> {
582    /// The 1-indexed number of the line the heading is recorded on: the ATX
583    /// line, or the last text line of a setext heading, whose underline is the
584    /// line after it
585    pub line_num: usize,
586    /// Reference to the heading information
587    pub heading: &'a HeadingInfo,
588    /// Reference to the full line info of the line the heading is recorded on
589    /// (for rules that need additional context)
590    pub line_info: &'a LineInfo,
591    /// Metadata of every line holding the heading text, first to last:
592    /// `line_info` alone, unless the heading is a setext heading whose
593    /// paragraph spans lines.
594    pub text_line_infos: &'a [LineInfo],
595}
596
597impl<'a> ValidHeading<'a> {
598    /// The 1-indexed number of the first line holding the heading text.
599    #[inline]
600    pub fn first_line_num(&self) -> usize {
601        self.line_num + 1 - self.heading.text_lines
602    }
603
604    /// Metadata of the first line holding the heading text.
605    #[inline]
606    pub fn first_line_info(&self) -> &'a LineInfo {
607        &self.text_line_infos[0]
608    }
609}
610
611/// Iterator over the headings recorded on a document's lines, in order.
612///
613/// Every recorded heading is a CommonMark heading; a line like `#NoSpace` is
614/// paragraph text and never appears here (MD018 reads it from
615/// [`LineInfo::atx_missing_space`]).
616pub struct ValidHeadingsIter<'a> {
617    lines: &'a [LineInfo],
618    current_index: usize,
619}
620
621impl<'a> ValidHeadingsIter<'a> {
622    pub(super) fn new(lines: &'a [LineInfo]) -> Self {
623        Self {
624            lines,
625            current_index: 0,
626        }
627    }
628}
629
630impl<'a> Iterator for ValidHeadingsIter<'a> {
631    type Item = ValidHeading<'a>;
632
633    fn next(&mut self) -> Option<Self::Item> {
634        while self.current_index < self.lines.len() {
635            let idx = self.current_index;
636            self.current_index += 1;
637
638            let line_info = &self.lines[idx];
639            if let Some(heading) = line_info.heading.as_deref() {
640                return Some(ValidHeading {
641                    line_num: idx + 1, // Convert 0-indexed to 1-indexed
642                    heading,
643                    line_info,
644                    text_line_infos: &self.lines[idx + 1 - heading.text_lines..=idx],
645                });
646            }
647        }
648        None
649    }
650}
651
652/// Information about a blockquote line
653#[derive(Debug, Clone)]
654pub struct BlockquoteInfo {
655    /// Nesting level (1 for >, 2 for >>, etc.)
656    pub nesting_level: usize,
657    /// Column where the first > starts (0-based)
658    pub marker_column: usize,
659    /// The blockquote prefix (e.g., "> ", ">> ", etc.)
660    pub prefix: String,
661    /// Content after the blockquote marker(s)
662    pub content: String,
663    /// Whether the line has multiple spaces after the marker
664    pub has_multiple_spaces_after_marker: bool,
665}
666
667/// Information about a list block
668#[derive(Debug, Clone)]
669pub struct ListBlock {
670    /// Line number where the list starts (1-indexed)
671    pub start_line: usize,
672    /// Line number where the list ends (1-indexed)
673    pub end_line: usize,
674    /// Whether it's ordered or unordered
675    pub is_ordered: bool,
676    /// The consistent marker for unordered lists (if any)
677    pub marker: Option<String>,
678    /// Blockquote prefix for this list (empty if not in blockquote)
679    pub blockquote_prefix: String,
680    /// Lines that are list items within this block
681    pub item_lines: Vec<usize>,
682    /// Nesting level (0 for top-level lists)
683    pub nesting_level: usize,
684    /// Maximum marker width seen in this block (e.g., 3 for "1. ", 4 for "10. ")
685    pub max_marker_width: usize,
686}
687
688/// A borrowed list item recognized in the parsed document.
689///
690/// This view gives rules stable access to list syntax and its source line
691/// without exposing how list items are stored inside [`LineInfo`]. Columns are
692/// the parser's existing source columns; rules that need visual columns must
693/// continue to apply their established tab and container policy.
694#[derive(Debug, Clone, Copy)]
695pub struct ParsedListItem<'a> {
696    line_num: usize,
697    item: &'a ListItemInfo,
698    line_info: &'a LineInfo,
699}
700
701impl<'a> ParsedListItem<'a> {
702    pub(super) fn new(line_num: usize, item: &'a ListItemInfo, line_info: &'a LineInfo) -> Self {
703        Self {
704            line_num,
705            item,
706            line_info,
707        }
708    }
709
710    /// The 1-indexed source line containing this item.
711    #[inline]
712    pub fn line_num(self) -> usize {
713        self.line_num
714    }
715
716    /// Full metadata for the source line containing this item.
717    #[inline]
718    pub fn line_info(self) -> &'a LineInfo {
719        self.line_info
720    }
721
722    /// The marker as parsed (`*`, `-`, `+`, or an ordered-list marker).
723    #[inline]
724    pub fn marker(self) -> &'a str {
725        &self.item.marker
726    }
727
728    /// The first character of the marker, if present.
729    #[inline]
730    pub fn marker_char(self) -> Option<char> {
731        self.item.marker.chars().next()
732    }
733
734    /// Whether this is an ordered-list item.
735    #[inline]
736    pub fn is_ordered(self) -> bool {
737        self.item.is_ordered
738    }
739
740    /// The parsed ordered-list number, when applicable.
741    #[inline]
742    pub fn number(self) -> Option<usize> {
743        self.item.number
744    }
745
746    /// Source column where the marker starts.
747    #[inline]
748    pub fn marker_column(self) -> usize {
749        self.item.marker_column
750    }
751
752    /// Source column where content after the marker starts.
753    #[inline]
754    pub fn content_column(self) -> usize {
755        self.item.content_column
756    }
757
758    /// Absolute byte offset where the marker starts.
759    #[inline]
760    pub fn marker_byte_offset(self) -> usize {
761        self.line_info.byte_offset + self.item.marker_column
762    }
763
764    /// Blockquote nesting depth, or zero outside a blockquote.
765    #[inline]
766    pub fn blockquote_depth(self) -> usize {
767        self.line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level)
768    }
769
770    /// Length in bytes of the normalized blockquote prefix, or zero outside a blockquote.
771    #[inline]
772    pub fn blockquote_prefix_len(self) -> usize {
773        self.line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len())
774    }
775}
776
777/// A borrowed parsed list block and its items.
778#[derive(Debug, Clone, Copy)]
779pub struct ParsedListBlock<'a> {
780    block: &'a ListBlock,
781    lines: &'a [LineInfo],
782}
783
784impl<'a> ParsedListBlock<'a> {
785    pub(super) fn new(block: &'a ListBlock, lines: &'a [LineInfo]) -> Self {
786        Self { block, lines }
787    }
788
789    /// First source line in the block (1-indexed).
790    #[inline]
791    pub fn start_line(self) -> usize {
792        self.block.start_line
793    }
794
795    /// Last source line in the block (1-indexed, inclusive).
796    #[inline]
797    pub fn end_line(self) -> usize {
798        self.block.end_line
799    }
800
801    /// Whether the block's primary list type is ordered.
802    #[inline]
803    pub fn is_ordered(self) -> bool {
804        self.block.is_ordered
805    }
806
807    /// Consistent unordered marker for the block, when one exists.
808    #[inline]
809    pub fn marker(self) -> Option<&'a str> {
810        self.block.marker.as_deref()
811    }
812
813    /// Blockquote prefix shared by the block.
814    #[inline]
815    pub fn blockquote_prefix(self) -> &'a str {
816        &self.block.blockquote_prefix
817    }
818
819    /// Parser-computed nesting level for the block.
820    #[inline]
821    pub fn nesting_level(self) -> usize {
822        self.block.nesting_level
823    }
824
825    /// Maximum marker width in the block.
826    #[inline]
827    pub fn max_marker_width(self) -> usize {
828        self.block.max_marker_width
829    }
830
831    /// Iterate over parsed items belonging to this block, in source order.
832    pub fn items(self) -> ParsedListBlockItemsIter<'a> {
833        ParsedListBlockItemsIter {
834            item_lines: &self.block.item_lines,
835            lines: self.lines,
836            current_index: 0,
837        }
838    }
839}
840
841/// Borrowed collection of parsed list blocks.
842#[derive(Debug, Clone, Copy)]
843pub struct ParsedListBlocks<'a> {
844    blocks: &'a [ListBlock],
845    lines: &'a [LineInfo],
846}
847
848impl<'a> ParsedListBlocks<'a> {
849    pub(super) fn new(blocks: &'a [ListBlock], lines: &'a [LineInfo]) -> Self {
850        Self { blocks, lines }
851    }
852
853    #[inline]
854    pub fn is_empty(self) -> bool {
855        self.blocks.is_empty()
856    }
857
858    #[inline]
859    pub fn len(self) -> usize {
860        self.blocks.len()
861    }
862
863    pub fn get(self, index: usize) -> Option<ParsedListBlock<'a>> {
864        self.blocks
865            .get(index)
866            .map(|block| ParsedListBlock::new(block, self.lines))
867    }
868
869    pub fn iter(self) -> ParsedListBlocksIter<'a> {
870        ParsedListBlocksIter {
871            blocks: self.blocks.iter(),
872            lines: self.lines,
873        }
874    }
875}
876
877impl<'a> IntoIterator for ParsedListBlocks<'a> {
878    type Item = ParsedListBlock<'a>;
879    type IntoIter = ParsedListBlocksIter<'a>;
880
881    fn into_iter(self) -> Self::IntoIter {
882        self.iter()
883    }
884}
885
886pub struct ParsedListBlocksIter<'a> {
887    blocks: std::slice::Iter<'a, ListBlock>,
888    lines: &'a [LineInfo],
889}
890
891impl<'a> Iterator for ParsedListBlocksIter<'a> {
892    type Item = ParsedListBlock<'a>;
893
894    fn next(&mut self) -> Option<Self::Item> {
895        self.blocks.next().map(|block| ParsedListBlock::new(block, self.lines))
896    }
897
898    fn size_hint(&self) -> (usize, Option<usize>) {
899        self.blocks.size_hint()
900    }
901}
902
903impl ExactSizeIterator for ParsedListBlocksIter<'_> {}
904
905pub struct ParsedListBlockItemsIter<'a> {
906    item_lines: &'a [usize],
907    lines: &'a [LineInfo],
908    current_index: usize,
909}
910
911impl<'a> Iterator for ParsedListBlockItemsIter<'a> {
912    type Item = ParsedListItem<'a>;
913
914    fn next(&mut self) -> Option<Self::Item> {
915        while let Some(&line_num) = self.item_lines.get(self.current_index) {
916            self.current_index += 1;
917            let Some(line_index) = line_num.checked_sub(1) else {
918                continue;
919            };
920            let Some(line_info) = self.lines.get(line_index) else {
921                continue;
922            };
923            if let Some(item) = line_info.list_item.as_deref() {
924                return Some(ParsedListItem::new(line_num, item, line_info));
925            }
926        }
927        None
928    }
929}
930
931pub struct ParsedListItemsIter<'a> {
932    lines: &'a [LineInfo],
933    current_index: usize,
934}
935
936impl<'a> ParsedListItemsIter<'a> {
937    pub(super) fn new(lines: &'a [LineInfo]) -> Self {
938        Self {
939            lines,
940            current_index: 0,
941        }
942    }
943}
944
945impl<'a> Iterator for ParsedListItemsIter<'a> {
946    type Item = ParsedListItem<'a>;
947
948    fn next(&mut self) -> Option<Self::Item> {
949        while self.current_index < self.lines.len() {
950            let idx = self.current_index;
951            self.current_index += 1;
952            let line_info = &self.lines[idx];
953            if let Some(item) = line_info.list_item.as_deref() {
954                return Some(ParsedListItem::new(idx + 1, item, line_info));
955            }
956        }
957        None
958    }
959}
960
961/// Cached CommonMark membership for one ordered list.
962#[derive(Debug, Clone)]
963pub(super) struct CommonMarkOrderedListInfo {
964    pub(super) start_value: u64,
965    pub(super) item_lines: Vec<usize>,
966}
967
968/// A borrowed ordered list as grouped by the CommonMark parser.
969///
970/// This grouping is independent of visual list blocks: nested ordered lists
971/// have their own membership and start value even when their source lines are
972/// interleaved with the parent list.
973#[derive(Debug, Clone, Copy)]
974pub struct CommonMarkOrderedList<'a> {
975    list: &'a CommonMarkOrderedListInfo,
976    lines: &'a [LineInfo],
977}
978
979impl<'a> CommonMarkOrderedList<'a> {
980    pub(super) fn new(list: &'a CommonMarkOrderedListInfo, lines: &'a [LineInfo]) -> Self {
981        Self { list, lines }
982    }
983
984    /// The number on the first item, as interpreted by CommonMark.
985    #[inline]
986    pub fn start_value(self) -> u64 {
987        self.list.start_value
988    }
989
990    /// Iterate over this list's ordered items in source order.
991    pub fn items(self) -> CommonMarkOrderedListItemsIter<'a> {
992        CommonMarkOrderedListItemsIter {
993            item_lines: &self.list.item_lines,
994            lines: self.lines,
995            current_index: 0,
996        }
997    }
998}
999
1000/// Borrowed collection of CommonMark-grouped ordered lists in source order.
1001#[derive(Debug, Clone, Copy)]
1002pub struct CommonMarkOrderedLists<'a> {
1003    lists: &'a [CommonMarkOrderedListInfo],
1004    lines: &'a [LineInfo],
1005}
1006
1007impl<'a> CommonMarkOrderedLists<'a> {
1008    pub(super) fn new(lists: &'a [CommonMarkOrderedListInfo], lines: &'a [LineInfo]) -> Self {
1009        Self { lists, lines }
1010    }
1011
1012    /// Whether the document has no CommonMark-grouped ordered lists.
1013    #[inline]
1014    pub fn is_empty(self) -> bool {
1015        self.lists.is_empty()
1016    }
1017
1018    /// Number of CommonMark-grouped ordered lists in the document.
1019    #[inline]
1020    pub fn len(self) -> usize {
1021        self.lists.len()
1022    }
1023
1024    /// Return a list by source-order index.
1025    pub fn get(self, index: usize) -> Option<CommonMarkOrderedList<'a>> {
1026        self.lists
1027            .get(index)
1028            .map(|list| CommonMarkOrderedList::new(list, self.lines))
1029    }
1030
1031    /// Iterate over ordered lists in the order of their first source item.
1032    pub fn iter(self) -> CommonMarkOrderedListsIter<'a> {
1033        CommonMarkOrderedListsIter {
1034            lists: self.lists.iter(),
1035            lines: self.lines,
1036        }
1037    }
1038}
1039
1040impl<'a> IntoIterator for CommonMarkOrderedLists<'a> {
1041    type Item = CommonMarkOrderedList<'a>;
1042    type IntoIter = CommonMarkOrderedListsIter<'a>;
1043
1044    fn into_iter(self) -> Self::IntoIter {
1045        self.iter()
1046    }
1047}
1048
1049/// Iterator over CommonMark-grouped ordered lists.
1050pub struct CommonMarkOrderedListsIter<'a> {
1051    lists: std::slice::Iter<'a, CommonMarkOrderedListInfo>,
1052    lines: &'a [LineInfo],
1053}
1054
1055impl<'a> Iterator for CommonMarkOrderedListsIter<'a> {
1056    type Item = CommonMarkOrderedList<'a>;
1057
1058    fn next(&mut self) -> Option<Self::Item> {
1059        self.lists
1060            .next()
1061            .map(|list| CommonMarkOrderedList::new(list, self.lines))
1062    }
1063
1064    fn size_hint(&self) -> (usize, Option<usize>) {
1065        self.lists.size_hint()
1066    }
1067}
1068
1069impl ExactSizeIterator for CommonMarkOrderedListsIter<'_> {}
1070
1071/// Iterator over the parsed items in one CommonMark ordered list.
1072pub struct CommonMarkOrderedListItemsIter<'a> {
1073    item_lines: &'a [usize],
1074    lines: &'a [LineInfo],
1075    current_index: usize,
1076}
1077
1078impl<'a> Iterator for CommonMarkOrderedListItemsIter<'a> {
1079    type Item = ParsedListItem<'a>;
1080
1081    fn next(&mut self) -> Option<Self::Item> {
1082        while let Some(&line_num) = self.item_lines.get(self.current_index) {
1083            self.current_index += 1;
1084            let Some(line_index) = line_num.checked_sub(1) else {
1085                continue;
1086            };
1087            let Some(line_info) = self.lines.get(line_index) else {
1088                continue;
1089            };
1090            let Some(item) = line_info.list_item.as_deref() else {
1091                continue;
1092            };
1093            if item.is_ordered {
1094                return Some(ParsedListItem::new(line_num, item, line_info));
1095            }
1096        }
1097        None
1098    }
1099}
1100
1101/// Character frequency data for fast content analysis
1102#[derive(Debug, Clone, Default)]
1103pub struct CharFrequency {
1104    /// Count of # characters (headings)
1105    pub hash_count: usize,
1106    /// Count of * characters (emphasis, lists, horizontal rules)
1107    pub asterisk_count: usize,
1108    /// Count of _ characters (emphasis, horizontal rules)
1109    pub underscore_count: usize,
1110    /// Count of - characters (lists, horizontal rules, setext headings)
1111    pub hyphen_count: usize,
1112    /// Count of + characters (lists)
1113    pub plus_count: usize,
1114    /// Count of > characters (blockquotes)
1115    pub gt_count: usize,
1116    /// Count of | characters (tables)
1117    pub pipe_count: usize,
1118    /// Count of [ characters (links, images)
1119    pub bracket_count: usize,
1120    /// Count of ` characters (code spans, code blocks)
1121    pub backtick_count: usize,
1122    /// Count of < characters (HTML tags, autolinks)
1123    pub lt_count: usize,
1124    /// Count of ! characters (images)
1125    pub exclamation_count: usize,
1126    /// Count of newline characters
1127    pub newline_count: usize,
1128}
1129
1130/// Pre-parsed HTML tag information
1131#[derive(Debug, Clone)]
1132pub struct HtmlTag {
1133    /// Line number (1-indexed)
1134    pub line: usize,
1135    /// Start column (0-indexed) in the line
1136    pub start_col: usize,
1137    /// End column (0-indexed) in the line
1138    pub end_col: usize,
1139    /// Byte offset in document
1140    pub byte_offset: usize,
1141    /// End byte offset in document
1142    pub byte_end: usize,
1143    /// Tag name (e.g., "div", "img", "br")
1144    pub tag_name: String,
1145    /// Whether it's a closing tag (`</tag>`)
1146    pub is_closing: bool,
1147    /// Whether it's self-closing (`<tag />`)
1148    pub is_self_closing: bool,
1149}
1150
1151/// Pre-parsed emphasis span information
1152#[derive(Debug, Clone)]
1153pub struct EmphasisSpan {
1154    /// Line number (1-indexed)
1155    pub line: usize,
1156    /// Start column (0-indexed) in the line
1157    pub start_col: usize,
1158    /// End column (0-indexed) in the line
1159    pub end_col: usize,
1160    /// Byte offset in document
1161    pub byte_offset: usize,
1162    /// End byte offset in document
1163    pub byte_end: usize,
1164    /// Type of emphasis ('*' or '_')
1165    pub marker: char,
1166    /// Whether this span is strong emphasis (`**`/`__`) rather than ordinary emphasis (`*`/`_`)
1167    pub is_strong: bool,
1168    /// Content inside the emphasis
1169    pub content: String,
1170}
1171
1172/// Pre-parsed bare URL information (not in links)
1173#[derive(Debug, Clone)]
1174pub struct BareUrl {
1175    /// Line number (1-indexed)
1176    pub line: usize,
1177    /// Start column (0-indexed) in the line
1178    pub start_col: usize,
1179    /// End column (0-indexed) in the line
1180    pub end_col: usize,
1181    /// Byte offset in document
1182    pub byte_offset: usize,
1183    /// End byte offset in document
1184    pub byte_end: usize,
1185    /// The URL string
1186    pub url: String,
1187}
1188
1189/// A lazy continuation line detected by pulldown-cmark.
1190///
1191/// Lazy continuation occurs when text continues a list item paragraph but with less
1192/// indentation than expected.
1193#[derive(Debug, Clone)]
1194pub struct LazyContLine {
1195    /// 1-indexed line number
1196    pub line_num: usize,
1197    /// Expected indentation
1198    pub expected_indent: usize,
1199    /// Current indentation
1200    pub current_indent: usize,
1201    /// Blockquote nesting level
1202    pub blockquote_level: usize,
1203}
1204
1205/// Check if a line is a horizontal rule (---, ***, ___) per CommonMark spec.
1206/// CommonMark rules for thematic breaks (horizontal rules):
1207/// - May have 0-3 spaces of leading indentation (but NOT tabs)
1208/// - Must have 3+ of the same character (-, *, or _)
1209/// - May have spaces between characters
1210/// - No other characters allowed
1211pub fn is_horizontal_rule_line(line: &str) -> bool {
1212    // CommonMark: HRs can have 0-3 spaces of leading indentation, not tabs
1213    let leading_spaces = line.len() - line.trim_start_matches(' ').len();
1214    if leading_spaces > 3 || line.starts_with('\t') {
1215        return false;
1216    }
1217
1218    is_horizontal_rule_content(line.trim())
1219}
1220
1221/// Check if trimmed content matches horizontal rule pattern.
1222/// Use `is_horizontal_rule_line` for full CommonMark compliance including indentation check.
1223pub fn is_horizontal_rule_content(trimmed: &str) -> bool {
1224    if trimmed.len() < 3 {
1225        return false;
1226    }
1227
1228    let mut chars = trimmed.chars();
1229    let Some(first_char @ ('-' | '*' | '_')) = chars.next() else {
1230        return false;
1231    };
1232
1233    // Count occurrences of the rule character, rejecting non-whitespace interlopers
1234    let mut count = 1; // Already matched the first character
1235    for ch in chars {
1236        if ch == first_char {
1237            count += 1;
1238        } else if ch != ' ' && ch != '\t' {
1239            return false;
1240        }
1241    }
1242    count >= 3
1243}
1244
1245/// Check if content is a setext underline: a run of `=` or of `-`, leading and
1246/// trailing whitespace allowed, no internal spaces and no mixing of the two
1247/// markers. `= = =` is paragraph text, not an underline.
1248///
1249/// Callers working inside a container pass the content with the container's
1250/// prefix already stripped, so a blockquoted underline is recognized too.
1251pub fn is_setext_underline_content(content: &str) -> bool {
1252    let trimmed = content.trim();
1253    let mut chars = trimmed.chars();
1254    let Some(marker @ ('=' | '-')) = chars.next() else {
1255        return false;
1256    };
1257    chars.all(|c| c == marker)
1258}