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