Skip to main content

rumdl_lib/lint_context/
types.rs

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