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