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