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