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