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 
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 /// Custom header ID if present (e.g., from {#custom-id} syntax)
332 pub custom_id: Option<String>,
333 /// Original heading text including custom ID syntax
334 pub raw_text: String,
335 /// Whether it has a closing sequence (for ATX)
336 pub has_closing_sequence: bool,
337 /// The closing sequence if present
338 pub closing_sequence: String,
339 /// Whether this is a valid CommonMark heading (ATX headings require space after #)
340 /// False for malformed headings like `#NoSpace` that MD018 should flag
341 pub is_valid: bool,
342}
343
344/// A heading recognized in the rendered Markdown document.
345///
346/// Unlike [`ValidHeading`], this view includes headings inside blockquotes and
347/// malformed ATX headings retained for diagnostics such as MD018. Consumers
348/// can select the semantics they need without reparsing source lines.
349#[derive(Debug, Clone, Copy)]
350pub struct ParsedHeading<'a> {
351 /// The 1-indexed line number in the document.
352 pub line_num: usize,
353 /// Parsed heading metadata.
354 pub heading: &'a HeadingInfo,
355 /// Full source-line metadata.
356 pub line_info: &'a LineInfo,
357 /// Blockquote nesting depth, or zero for a top-level heading.
358 pub blockquote_depth: usize,
359}
360
361impl ParsedHeading<'_> {
362 /// Whether this heading is inside a blockquote.
363 #[inline]
364 pub fn is_blockquote(&self) -> bool {
365 self.blockquote_depth > 0
366 }
367
368 /// Whether this is a Setext-style heading.
369 #[inline]
370 pub fn is_setext(&self) -> bool {
371 matches!(self.heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2)
372 }
373
374 /// Byte offsets `(start, end)` of the heading text within its source line.
375 ///
376 /// Markers, closing ATX sequences, and custom-ID syntax are excluded. The
377 /// range is line-relative so callers can convert it to their own position
378 /// representation without rescanning Markdown syntax.
379 #[must_use]
380 pub fn text_byte_range(&self, source: &str) -> (usize, usize) {
381 let line = self.line_info.content(source);
382 let content_start = self.heading.content_column.min(line.len());
383 let relative_start = line[content_start..].find(&self.heading.text).unwrap_or(0);
384 let start = content_start + relative_start;
385 (start, (start + self.heading.text.len()).min(line.len()))
386 }
387}
388
389/// Iterator over all headings recognized in the rendered document.
390pub struct ParsedHeadingsIter<'a> {
391 lines: &'a [LineInfo],
392 blockquote_headings: &'a [Option<Box<HeadingInfo>>],
393 current_index: usize,
394}
395
396impl<'a> ParsedHeadingsIter<'a> {
397 pub(super) fn new(lines: &'a [LineInfo], blockquote_headings: &'a [Option<Box<HeadingInfo>>]) -> Self {
398 debug_assert_eq!(lines.len(), blockquote_headings.len());
399 Self {
400 lines,
401 blockquote_headings,
402 current_index: 0,
403 }
404 }
405}
406
407impl<'a> Iterator for ParsedHeadingsIter<'a> {
408 type Item = ParsedHeading<'a>;
409
410 fn next(&mut self) -> Option<Self::Item> {
411 while self.current_index < self.lines.len() {
412 let idx = self.current_index;
413 self.current_index += 1;
414
415 let line_info = &self.lines[idx];
416 let (heading, blockquote_depth) = if let Some(heading) = line_info.heading.as_deref() {
417 (heading, 0)
418 } else if let Some(heading) = self.blockquote_headings[idx].as_deref() {
419 (heading, line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level))
420 } else {
421 continue;
422 };
423 return Some(ParsedHeading {
424 line_num: idx + 1,
425 heading,
426 line_info,
427 blockquote_depth,
428 });
429 }
430 None
431 }
432}
433
434/// A valid heading from a filtered iteration
435///
436/// Only includes headings that are CommonMark-compliant (have space after #).
437/// Hashtag-like patterns (`#tag`, `#123`) are excluded.
438#[derive(Debug, Clone)]
439pub struct ValidHeading<'a> {
440 /// The 1-indexed line number in the document
441 pub line_num: usize,
442 /// Reference to the heading information
443 pub heading: &'a HeadingInfo,
444 /// Reference to the full line info (for rules that need additional context)
445 pub line_info: &'a LineInfo,
446}
447
448/// Iterator over valid CommonMark headings in a document
449///
450/// Filters out malformed headings like `#NoSpace` that should be flagged by MD018
451/// but should not be processed by other heading rules.
452pub struct ValidHeadingsIter<'a> {
453 lines: &'a [LineInfo],
454 current_index: usize,
455}
456
457impl<'a> ValidHeadingsIter<'a> {
458 pub(super) fn new(lines: &'a [LineInfo]) -> Self {
459 Self {
460 lines,
461 current_index: 0,
462 }
463 }
464}
465
466impl<'a> Iterator for ValidHeadingsIter<'a> {
467 type Item = ValidHeading<'a>;
468
469 fn next(&mut self) -> Option<Self::Item> {
470 while self.current_index < self.lines.len() {
471 let idx = self.current_index;
472 self.current_index += 1;
473
474 let line_info = &self.lines[idx];
475 if let Some(heading) = line_info.heading.as_deref()
476 && heading.is_valid
477 {
478 return Some(ValidHeading {
479 line_num: idx + 1, // Convert 0-indexed to 1-indexed
480 heading,
481 line_info,
482 });
483 }
484 }
485 None
486 }
487}
488
489/// Information about a blockquote line
490#[derive(Debug, Clone)]
491pub struct BlockquoteInfo {
492 /// Nesting level (1 for >, 2 for >>, etc.)
493 pub nesting_level: usize,
494 /// Column where the first > starts (0-based)
495 pub marker_column: usize,
496 /// The blockquote prefix (e.g., "> ", ">> ", etc.)
497 pub prefix: String,
498 /// Content after the blockquote marker(s)
499 pub content: String,
500 /// Whether the line has multiple spaces after the marker
501 pub has_multiple_spaces_after_marker: bool,
502}
503
504/// Information about a list block
505#[derive(Debug, Clone)]
506pub struct ListBlock {
507 /// Line number where the list starts (1-indexed)
508 pub start_line: usize,
509 /// Line number where the list ends (1-indexed)
510 pub end_line: usize,
511 /// Whether it's ordered or unordered
512 pub is_ordered: bool,
513 /// The consistent marker for unordered lists (if any)
514 pub marker: Option<String>,
515 /// Blockquote prefix for this list (empty if not in blockquote)
516 pub blockquote_prefix: String,
517 /// Lines that are list items within this block
518 pub item_lines: Vec<usize>,
519 /// Nesting level (0 for top-level lists)
520 pub nesting_level: usize,
521 /// Maximum marker width seen in this block (e.g., 3 for "1. ", 4 for "10. ")
522 pub max_marker_width: usize,
523}
524
525/// A borrowed list item recognized in the parsed document.
526///
527/// This view gives rules stable access to list syntax and its source line
528/// without exposing how list items are stored inside [`LineInfo`]. Columns are
529/// the parser's existing source columns; rules that need visual columns must
530/// continue to apply their established tab and container policy.
531#[derive(Debug, Clone, Copy)]
532pub struct ParsedListItem<'a> {
533 line_num: usize,
534 item: &'a ListItemInfo,
535 line_info: &'a LineInfo,
536}
537
538impl<'a> ParsedListItem<'a> {
539 pub(super) fn new(line_num: usize, item: &'a ListItemInfo, line_info: &'a LineInfo) -> Self {
540 Self {
541 line_num,
542 item,
543 line_info,
544 }
545 }
546
547 /// The 1-indexed source line containing this item.
548 #[inline]
549 pub fn line_num(self) -> usize {
550 self.line_num
551 }
552
553 /// Full metadata for the source line containing this item.
554 #[inline]
555 pub fn line_info(self) -> &'a LineInfo {
556 self.line_info
557 }
558
559 /// The marker as parsed (`*`, `-`, `+`, or an ordered-list marker).
560 #[inline]
561 pub fn marker(self) -> &'a str {
562 &self.item.marker
563 }
564
565 /// The first character of the marker, if present.
566 #[inline]
567 pub fn marker_char(self) -> Option<char> {
568 self.item.marker.chars().next()
569 }
570
571 /// Whether this is an ordered-list item.
572 #[inline]
573 pub fn is_ordered(self) -> bool {
574 self.item.is_ordered
575 }
576
577 /// The parsed ordered-list number, when applicable.
578 #[inline]
579 pub fn number(self) -> Option<usize> {
580 self.item.number
581 }
582
583 /// Source column where the marker starts.
584 #[inline]
585 pub fn marker_column(self) -> usize {
586 self.item.marker_column
587 }
588
589 /// Source column where content after the marker starts.
590 #[inline]
591 pub fn content_column(self) -> usize {
592 self.item.content_column
593 }
594
595 /// Absolute byte offset where the marker starts.
596 #[inline]
597 pub fn marker_byte_offset(self) -> usize {
598 self.line_info.byte_offset + self.item.marker_column
599 }
600
601 /// Blockquote nesting depth, or zero outside a blockquote.
602 #[inline]
603 pub fn blockquote_depth(self) -> usize {
604 self.line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level)
605 }
606
607 /// Length in bytes of the normalized blockquote prefix, or zero outside a blockquote.
608 #[inline]
609 pub fn blockquote_prefix_len(self) -> usize {
610 self.line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len())
611 }
612}
613
614/// A borrowed parsed list block and its items.
615#[derive(Debug, Clone, Copy)]
616pub struct ParsedListBlock<'a> {
617 block: &'a ListBlock,
618 lines: &'a [LineInfo],
619}
620
621impl<'a> ParsedListBlock<'a> {
622 pub(super) fn new(block: &'a ListBlock, lines: &'a [LineInfo]) -> Self {
623 Self { block, lines }
624 }
625
626 /// First source line in the block (1-indexed).
627 #[inline]
628 pub fn start_line(self) -> usize {
629 self.block.start_line
630 }
631
632 /// Last source line in the block (1-indexed, inclusive).
633 #[inline]
634 pub fn end_line(self) -> usize {
635 self.block.end_line
636 }
637
638 /// Whether the block's primary list type is ordered.
639 #[inline]
640 pub fn is_ordered(self) -> bool {
641 self.block.is_ordered
642 }
643
644 /// Consistent unordered marker for the block, when one exists.
645 #[inline]
646 pub fn marker(self) -> Option<&'a str> {
647 self.block.marker.as_deref()
648 }
649
650 /// Blockquote prefix shared by the block.
651 #[inline]
652 pub fn blockquote_prefix(self) -> &'a str {
653 &self.block.blockquote_prefix
654 }
655
656 /// Parser-computed nesting level for the block.
657 #[inline]
658 pub fn nesting_level(self) -> usize {
659 self.block.nesting_level
660 }
661
662 /// Maximum marker width in the block.
663 #[inline]
664 pub fn max_marker_width(self) -> usize {
665 self.block.max_marker_width
666 }
667
668 /// Iterate over parsed items belonging to this block, in source order.
669 pub fn items(self) -> ParsedListBlockItemsIter<'a> {
670 ParsedListBlockItemsIter {
671 item_lines: &self.block.item_lines,
672 lines: self.lines,
673 current_index: 0,
674 }
675 }
676}
677
678/// Borrowed collection of parsed list blocks.
679#[derive(Debug, Clone, Copy)]
680pub struct ParsedListBlocks<'a> {
681 blocks: &'a [ListBlock],
682 lines: &'a [LineInfo],
683}
684
685impl<'a> ParsedListBlocks<'a> {
686 pub(super) fn new(blocks: &'a [ListBlock], lines: &'a [LineInfo]) -> Self {
687 Self { blocks, lines }
688 }
689
690 #[inline]
691 pub fn is_empty(self) -> bool {
692 self.blocks.is_empty()
693 }
694
695 #[inline]
696 pub fn len(self) -> usize {
697 self.blocks.len()
698 }
699
700 pub fn get(self, index: usize) -> Option<ParsedListBlock<'a>> {
701 self.blocks
702 .get(index)
703 .map(|block| ParsedListBlock::new(block, self.lines))
704 }
705
706 pub fn iter(self) -> ParsedListBlocksIter<'a> {
707 ParsedListBlocksIter {
708 blocks: self.blocks.iter(),
709 lines: self.lines,
710 }
711 }
712}
713
714impl<'a> IntoIterator for ParsedListBlocks<'a> {
715 type Item = ParsedListBlock<'a>;
716 type IntoIter = ParsedListBlocksIter<'a>;
717
718 fn into_iter(self) -> Self::IntoIter {
719 self.iter()
720 }
721}
722
723pub struct ParsedListBlocksIter<'a> {
724 blocks: std::slice::Iter<'a, ListBlock>,
725 lines: &'a [LineInfo],
726}
727
728impl<'a> Iterator for ParsedListBlocksIter<'a> {
729 type Item = ParsedListBlock<'a>;
730
731 fn next(&mut self) -> Option<Self::Item> {
732 self.blocks.next().map(|block| ParsedListBlock::new(block, self.lines))
733 }
734
735 fn size_hint(&self) -> (usize, Option<usize>) {
736 self.blocks.size_hint()
737 }
738}
739
740impl ExactSizeIterator for ParsedListBlocksIter<'_> {}
741
742pub struct ParsedListBlockItemsIter<'a> {
743 item_lines: &'a [usize],
744 lines: &'a [LineInfo],
745 current_index: usize,
746}
747
748impl<'a> Iterator for ParsedListBlockItemsIter<'a> {
749 type Item = ParsedListItem<'a>;
750
751 fn next(&mut self) -> Option<Self::Item> {
752 while let Some(&line_num) = self.item_lines.get(self.current_index) {
753 self.current_index += 1;
754 let Some(line_index) = line_num.checked_sub(1) else {
755 continue;
756 };
757 let Some(line_info) = self.lines.get(line_index) else {
758 continue;
759 };
760 if let Some(item) = line_info.list_item.as_deref() {
761 return Some(ParsedListItem::new(line_num, item, line_info));
762 }
763 }
764 None
765 }
766}
767
768pub struct ParsedListItemsIter<'a> {
769 lines: &'a [LineInfo],
770 current_index: usize,
771}
772
773impl<'a> ParsedListItemsIter<'a> {
774 pub(super) fn new(lines: &'a [LineInfo]) -> Self {
775 Self {
776 lines,
777 current_index: 0,
778 }
779 }
780}
781
782impl<'a> Iterator for ParsedListItemsIter<'a> {
783 type Item = ParsedListItem<'a>;
784
785 fn next(&mut self) -> Option<Self::Item> {
786 while self.current_index < self.lines.len() {
787 let idx = self.current_index;
788 self.current_index += 1;
789 let line_info = &self.lines[idx];
790 if let Some(item) = line_info.list_item.as_deref() {
791 return Some(ParsedListItem::new(idx + 1, item, line_info));
792 }
793 }
794 None
795 }
796}
797
798/// Cached CommonMark membership for one ordered list.
799#[derive(Debug, Clone)]
800pub(super) struct CommonMarkOrderedListInfo {
801 pub(super) start_value: u64,
802 pub(super) item_lines: Vec<usize>,
803}
804
805/// A borrowed ordered list as grouped by the CommonMark parser.
806///
807/// This grouping is independent of visual list blocks: nested ordered lists
808/// have their own membership and start value even when their source lines are
809/// interleaved with the parent list.
810#[derive(Debug, Clone, Copy)]
811pub struct CommonMarkOrderedList<'a> {
812 list: &'a CommonMarkOrderedListInfo,
813 lines: &'a [LineInfo],
814}
815
816impl<'a> CommonMarkOrderedList<'a> {
817 pub(super) fn new(list: &'a CommonMarkOrderedListInfo, lines: &'a [LineInfo]) -> Self {
818 Self { list, lines }
819 }
820
821 /// The number on the first item, as interpreted by CommonMark.
822 #[inline]
823 pub fn start_value(self) -> u64 {
824 self.list.start_value
825 }
826
827 /// Iterate over this list's ordered items in source order.
828 pub fn items(self) -> CommonMarkOrderedListItemsIter<'a> {
829 CommonMarkOrderedListItemsIter {
830 item_lines: &self.list.item_lines,
831 lines: self.lines,
832 current_index: 0,
833 }
834 }
835}
836
837/// Borrowed collection of CommonMark-grouped ordered lists in source order.
838#[derive(Debug, Clone, Copy)]
839pub struct CommonMarkOrderedLists<'a> {
840 lists: &'a [CommonMarkOrderedListInfo],
841 lines: &'a [LineInfo],
842}
843
844impl<'a> CommonMarkOrderedLists<'a> {
845 pub(super) fn new(lists: &'a [CommonMarkOrderedListInfo], lines: &'a [LineInfo]) -> Self {
846 Self { lists, lines }
847 }
848
849 /// Whether the document has no CommonMark-grouped ordered lists.
850 #[inline]
851 pub fn is_empty(self) -> bool {
852 self.lists.is_empty()
853 }
854
855 /// Number of CommonMark-grouped ordered lists in the document.
856 #[inline]
857 pub fn len(self) -> usize {
858 self.lists.len()
859 }
860
861 /// Return a list by source-order index.
862 pub fn get(self, index: usize) -> Option<CommonMarkOrderedList<'a>> {
863 self.lists
864 .get(index)
865 .map(|list| CommonMarkOrderedList::new(list, self.lines))
866 }
867
868 /// Iterate over ordered lists in the order of their first source item.
869 pub fn iter(self) -> CommonMarkOrderedListsIter<'a> {
870 CommonMarkOrderedListsIter {
871 lists: self.lists.iter(),
872 lines: self.lines,
873 }
874 }
875}
876
877impl<'a> IntoIterator for CommonMarkOrderedLists<'a> {
878 type Item = CommonMarkOrderedList<'a>;
879 type IntoIter = CommonMarkOrderedListsIter<'a>;
880
881 fn into_iter(self) -> Self::IntoIter {
882 self.iter()
883 }
884}
885
886/// Iterator over CommonMark-grouped ordered lists.
887pub struct CommonMarkOrderedListsIter<'a> {
888 lists: std::slice::Iter<'a, CommonMarkOrderedListInfo>,
889 lines: &'a [LineInfo],
890}
891
892impl<'a> Iterator for CommonMarkOrderedListsIter<'a> {
893 type Item = CommonMarkOrderedList<'a>;
894
895 fn next(&mut self) -> Option<Self::Item> {
896 self.lists
897 .next()
898 .map(|list| CommonMarkOrderedList::new(list, self.lines))
899 }
900
901 fn size_hint(&self) -> (usize, Option<usize>) {
902 self.lists.size_hint()
903 }
904}
905
906impl ExactSizeIterator for CommonMarkOrderedListsIter<'_> {}
907
908/// Iterator over the parsed items in one CommonMark ordered list.
909pub struct CommonMarkOrderedListItemsIter<'a> {
910 item_lines: &'a [usize],
911 lines: &'a [LineInfo],
912 current_index: usize,
913}
914
915impl<'a> Iterator for CommonMarkOrderedListItemsIter<'a> {
916 type Item = ParsedListItem<'a>;
917
918 fn next(&mut self) -> Option<Self::Item> {
919 while let Some(&line_num) = self.item_lines.get(self.current_index) {
920 self.current_index += 1;
921 let Some(line_index) = line_num.checked_sub(1) else {
922 continue;
923 };
924 let Some(line_info) = self.lines.get(line_index) else {
925 continue;
926 };
927 let Some(item) = line_info.list_item.as_deref() else {
928 continue;
929 };
930 if item.is_ordered {
931 return Some(ParsedListItem::new(line_num, item, line_info));
932 }
933 }
934 None
935 }
936}
937
938/// Character frequency data for fast content analysis
939#[derive(Debug, Clone, Default)]
940pub struct CharFrequency {
941 /// Count of # characters (headings)
942 pub hash_count: usize,
943 /// Count of * characters (emphasis, lists, horizontal rules)
944 pub asterisk_count: usize,
945 /// Count of _ characters (emphasis, horizontal rules)
946 pub underscore_count: usize,
947 /// Count of - characters (lists, horizontal rules, setext headings)
948 pub hyphen_count: usize,
949 /// Count of + characters (lists)
950 pub plus_count: usize,
951 /// Count of > characters (blockquotes)
952 pub gt_count: usize,
953 /// Count of | characters (tables)
954 pub pipe_count: usize,
955 /// Count of [ characters (links, images)
956 pub bracket_count: usize,
957 /// Count of ` characters (code spans, code blocks)
958 pub backtick_count: usize,
959 /// Count of < characters (HTML tags, autolinks)
960 pub lt_count: usize,
961 /// Count of ! characters (images)
962 pub exclamation_count: usize,
963 /// Count of newline characters
964 pub newline_count: usize,
965}
966
967/// Pre-parsed HTML tag information
968#[derive(Debug, Clone)]
969pub struct HtmlTag {
970 /// Line number (1-indexed)
971 pub line: usize,
972 /// Start column (0-indexed) in the line
973 pub start_col: usize,
974 /// End column (0-indexed) in the line
975 pub end_col: usize,
976 /// Byte offset in document
977 pub byte_offset: usize,
978 /// End byte offset in document
979 pub byte_end: usize,
980 /// Tag name (e.g., "div", "img", "br")
981 pub tag_name: String,
982 /// Whether it's a closing tag (`</tag>`)
983 pub is_closing: bool,
984 /// Whether it's self-closing (`<tag />`)
985 pub is_self_closing: bool,
986}
987
988/// Pre-parsed emphasis span information
989#[derive(Debug, Clone)]
990pub struct EmphasisSpan {
991 /// Line number (1-indexed)
992 pub line: usize,
993 /// Start column (0-indexed) in the line
994 pub start_col: usize,
995 /// End column (0-indexed) in the line
996 pub end_col: usize,
997 /// Byte offset in document
998 pub byte_offset: usize,
999 /// End byte offset in document
1000 pub byte_end: usize,
1001 /// Type of emphasis ('*' or '_')
1002 pub marker: char,
1003 /// Whether this span is strong emphasis (`**`/`__`) rather than ordinary emphasis (`*`/`_`)
1004 pub is_strong: bool,
1005 /// Content inside the emphasis
1006 pub content: String,
1007}
1008
1009/// Pre-parsed bare URL information (not in links)
1010#[derive(Debug, Clone)]
1011pub struct BareUrl {
1012 /// Line number (1-indexed)
1013 pub line: usize,
1014 /// Start column (0-indexed) in the line
1015 pub start_col: usize,
1016 /// End column (0-indexed) in the line
1017 pub end_col: usize,
1018 /// Byte offset in document
1019 pub byte_offset: usize,
1020 /// End byte offset in document
1021 pub byte_end: usize,
1022 /// The URL string
1023 pub url: String,
1024}
1025
1026/// A lazy continuation line detected by pulldown-cmark.
1027///
1028/// Lazy continuation occurs when text continues a list item paragraph but with less
1029/// indentation than expected.
1030#[derive(Debug, Clone)]
1031pub struct LazyContLine {
1032 /// 1-indexed line number
1033 pub line_num: usize,
1034 /// Expected indentation
1035 pub expected_indent: usize,
1036 /// Current indentation
1037 pub current_indent: usize,
1038 /// Blockquote nesting level
1039 pub blockquote_level: usize,
1040}
1041
1042/// Check if a line is a horizontal rule (---, ***, ___) per CommonMark spec.
1043/// CommonMark rules for thematic breaks (horizontal rules):
1044/// - May have 0-3 spaces of leading indentation (but NOT tabs)
1045/// - Must have 3+ of the same character (-, *, or _)
1046/// - May have spaces between characters
1047/// - No other characters allowed
1048pub fn is_horizontal_rule_line(line: &str) -> bool {
1049 // CommonMark: HRs can have 0-3 spaces of leading indentation, not tabs
1050 let leading_spaces = line.len() - line.trim_start_matches(' ').len();
1051 if leading_spaces > 3 || line.starts_with('\t') {
1052 return false;
1053 }
1054
1055 is_horizontal_rule_content(line.trim())
1056}
1057
1058/// Check if trimmed content matches horizontal rule pattern.
1059/// Use `is_horizontal_rule_line` for full CommonMark compliance including indentation check.
1060pub fn is_horizontal_rule_content(trimmed: &str) -> bool {
1061 if trimmed.len() < 3 {
1062 return false;
1063 }
1064
1065 let mut chars = trimmed.chars();
1066 let Some(first_char @ ('-' | '*' | '_')) = chars.next() else {
1067 return false;
1068 };
1069
1070 // Count occurrences of the rule character, rejecting non-whitespace interlopers
1071 let mut count = 1; // Already matched the first character
1072 for ch in chars {
1073 if ch == first_char {
1074 count += 1;
1075 } else if ch != ' ' && ch != '\t' {
1076 return false;
1077 }
1078 }
1079 count >= 3
1080}