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