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