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