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