Skip to main content

rumdl_lib/lint_context/
types.rs

1use pulldown_cmark::LinkType;
2use std::borrow::Cow;
3
4/// Pre-computed information about a line
5#[derive(Debug, Clone)]
6pub struct LineInfo {
7    /// Byte offset where this line starts in the document
8    pub byte_offset: usize,
9    /// Length of the line in bytes (without newline)
10    pub byte_len: usize,
11    /// Number of bytes of leading whitespace (for substring extraction)
12    pub indent: usize,
13    /// Visual column width of leading whitespace (with proper tab expansion)
14    /// Per CommonMark, tabs expand to the next column that is a multiple of 4.
15    /// Use this for numeric comparisons like checking for indented code blocks (>= 4).
16    pub visual_indent: usize,
17    /// Whether the line is blank (empty or only whitespace)
18    pub is_blank: bool,
19    /// Whether this line is inside a code block
20    pub in_code_block: bool,
21    /// Whether this line is inside front matter
22    pub in_front_matter: bool,
23    /// Whether this line is inside an HTML block
24    pub in_html_block: bool,
25    /// Whether this line is part of a list block (precomputed for O(1) lookup)
26    pub in_list_block: bool,
27    /// Whether this line is part of a table block (precomputed for O(1) lookup)
28    pub in_table_block: bool,
29    /// Whether this line is inside an HTML comment
30    pub in_html_comment: bool,
31    /// List item information if this line starts a list item
32    /// Boxed to reduce LineInfo size: most lines are not list items
33    pub list_item: Option<Box<ListItemInfo>>,
34    /// Heading information if this line is a heading
35    /// Boxed to reduce LineInfo size: most lines are not headings
36    pub heading: Option<Box<HeadingInfo>>,
37    /// Blockquote information if this line is a blockquote
38    /// Boxed to reduce LineInfo size: most lines are not blockquotes
39    pub blockquote: Option<Box<BlockquoteInfo>>,
40    /// Whether this line is inside a mkdocstrings autodoc block
41    pub in_mkdocstrings: bool,
42    /// Whether this line is part of an ESM import/export block (MDX only)
43    pub in_esm_block: bool,
44    /// Whether this line is a continuation of a multi-line code span from a previous line
45    pub in_code_span_continuation: bool,
46    /// Whether this line is a horizontal rule (---, ***, ___, etc.)
47    /// Pre-computed for consistent detection across all rules
48    pub is_horizontal_rule: bool,
49    /// Whether this line is inside a math block ($$ ... $$)
50    pub in_math_block: bool,
51    /// Whether this line is inside a Pandoc/Quarto div block (::: ... :::)
52    pub in_pandoc_div: bool,
53    /// Whether this line is a Quarto/Pandoc div marker (opening ::: {.class} or closing :::)
54    /// Analogous to `is_horizontal_rule` — marks structural delimiters that are not paragraph text
55    pub is_div_marker: bool,
56    /// Whether this line contains or is inside a JSX expression (MDX only)
57    pub in_jsx_expression: bool,
58    /// Whether this line is inside an MDX comment {/* ... */} (MDX only)
59    pub in_mdx_comment: bool,
60    /// Whether this line is inside an MkDocs admonition block (!!! or ???)
61    pub in_admonition: bool,
62    /// Whether this line is inside an MkDocs content tab block (===)
63    pub in_content_tab: bool,
64    /// Whether this line is inside an HTML block with markdown attribute (MkDocs grid cards, etc.)
65    pub in_mkdocs_html_markdown: bool,
66    /// Whether this line is a definition list item (: definition)
67    pub in_definition_list: bool,
68    /// Whether this line is inside an Obsidian comment (%%...%% syntax, Obsidian flavor only)
69    pub in_obsidian_comment: bool,
70    /// Whether this line is inside a PyMdown Blocks region (/// ... ///, MkDocs flavor only)
71    pub in_pymdown_block: bool,
72    /// Whether this line is inside a kramdown extension block ({::comment}...{:/comment}, {::nomarkdown}...{:/nomarkdown})
73    pub in_kramdown_extension_block: bool,
74    /// Whether this line is a kramdown block IAL ({:.class #id}) or ALD ({:ref: .class})
75    pub is_kramdown_block_ial: bool,
76    /// Whether this line is inside a JSX component block (MDX only, e.g. `<Tabs>...</Tabs>`)
77    pub in_jsx_block: bool,
78    /// Whether this line is inside a footnote definition body (continuation lines)
79    pub in_footnote_definition: bool,
80    /// Whether this line is inside a MyST directive block (colon or backtick fence with `{name}`)
81    pub in_myst_directive: bool,
82    /// Whether this line is a MyST comment (`% comment`)
83    pub is_myst_comment: bool,
84}
85
86impl LineInfo {
87    /// Get the line content as a string slice from the source document
88    pub fn content<'a>(&self, source: &'a str) -> &'a str {
89        &source[self.byte_offset..self.byte_offset + self.byte_len]
90    }
91
92    /// Check if this line is inside MkDocs-specific indented content (admonitions, tabs, or markdown HTML).
93    /// This content uses 4-space indentation which pulldown-cmark would interpret as code blocks,
94    /// but in MkDocs flavor it's actually container content that should be preserved.
95    #[inline]
96    pub fn in_mkdocs_container(&self) -> bool {
97        self.in_admonition || self.in_content_tab || self.in_mkdocs_html_markdown
98    }
99
100    /// Whether this line could be part of a paragraph block (CommonMark `paragraph` token).
101    ///
102    /// Returns true for ordinary prose lines, including those inside blockquotes and list items.
103    /// Returns false for lines that belong to non-paragraph blocks: headings, code blocks,
104    /// HTML blocks, math blocks, horizontal rules, front matter, structural div markers, and
105    /// flavor-specific extension blocks. This is the per-line view; cross-line constructs like
106    /// setext underlines aren't visible here and need additional context to detect.
107    ///
108    /// Used by rules (e.g. MD009 strict mode) that need to distinguish "trailing whitespace
109    /// could produce a meaningful `<br>`" from "trailing whitespace is on a structural boundary."
110    #[inline]
111    pub fn is_paragraph_context(&self) -> bool {
112        !self.in_code_block
113            && !self.in_front_matter
114            && !self.in_html_block
115            && !self.in_html_comment
116            && !self.in_math_block
117            && !self.is_horizontal_rule
118            && !self.is_div_marker
119            && !self.in_pymdown_block
120            && !self.in_kramdown_extension_block
121            && !self.is_kramdown_block_ial
122            && !self.is_myst_comment
123            && self.heading.is_none()
124    }
125}
126
127/// Information about a list item
128#[derive(Debug, Clone)]
129pub struct ListItemInfo {
130    /// The marker used (*, -, +, or number with . or ))
131    pub marker: String,
132    /// Whether it's ordered (true) or unordered (false)
133    pub is_ordered: bool,
134    /// The number for ordered lists
135    pub number: Option<usize>,
136    /// Column where the marker starts (0-based)
137    pub marker_column: usize,
138    /// Column where content after marker starts
139    pub content_column: usize,
140}
141
142/// Heading style type
143#[derive(Debug, Clone, PartialEq)]
144pub enum HeadingStyle {
145    /// ATX style heading (# Heading)
146    ATX,
147    /// Setext style heading with = underline
148    Setext1,
149    /// Setext style heading with - underline
150    Setext2,
151}
152
153/// Parsed link information
154#[derive(Debug, Clone)]
155pub struct ParsedLink<'a> {
156    /// Line number (1-indexed)
157    pub line: usize,
158    /// Start column (0-indexed) in the line
159    pub start_col: usize,
160    /// End column (0-indexed) in the line
161    pub end_col: usize,
162    /// Byte offset in document
163    pub byte_offset: usize,
164    /// End byte offset in document
165    pub byte_end: usize,
166    /// Link text
167    pub text: Cow<'a, str>,
168    /// Link URL or reference
169    pub url: Cow<'a, str>,
170    /// Inline title (without surrounding delimiters), as produced by pulldown-cmark
171    /// after backslash-escape handling. `None` when the link has no title or is a
172    /// reference style without a matched definition.
173    pub title: Option<Cow<'a, str>>,
174    /// Whether this is a reference link `[text][ref]` vs inline `[text](url)`
175    pub is_reference: bool,
176    /// Reference ID for reference links
177    pub reference_id: Option<Cow<'a, str>>,
178    /// Link type from pulldown-cmark
179    pub link_type: LinkType,
180}
181
182/// Information about a broken link reported by pulldown-cmark
183#[derive(Debug, Clone)]
184pub struct BrokenLinkInfo {
185    /// The reference text that couldn't be resolved
186    pub reference: String,
187    /// Byte span in the source document
188    pub span: std::ops::Range<usize>,
189}
190
191/// Parsed footnote reference (e.g., `[^1]`, `[^note]`)
192#[derive(Debug, Clone)]
193pub struct FootnoteRef {
194    /// The footnote ID (without the ^ prefix)
195    pub id: String,
196    /// Line number (1-indexed)
197    pub line: usize,
198    /// Start byte offset in document
199    pub byte_offset: usize,
200}
201
202/// Parsed image information
203#[derive(Debug, Clone)]
204pub struct ParsedImage<'a> {
205    /// Line number (1-indexed)
206    pub line: usize,
207    /// Start column (0-indexed) in the line
208    pub start_col: usize,
209    /// End column (0-indexed) in the line
210    pub end_col: usize,
211    /// Byte offset in document
212    pub byte_offset: usize,
213    /// End byte offset in document
214    pub byte_end: usize,
215    /// Alt text
216    pub alt_text: Cow<'a, str>,
217    /// Image URL or reference
218    pub url: Cow<'a, str>,
219    /// Inline title (without surrounding delimiters), as produced by pulldown-cmark
220    /// after backslash-escape handling. `None` when the image has no title or is a
221    /// reference style without a matched definition.
222    pub title: Option<Cow<'a, str>>,
223    /// Whether this is a reference image ![alt][ref] vs inline ![alt](url)
224    pub is_reference: bool,
225    /// Reference ID for reference images
226    pub reference_id: Option<Cow<'a, str>>,
227    /// Link type from pulldown-cmark
228    pub link_type: LinkType,
229}
230
231/// Reference definition `[ref]: url "title"`
232#[derive(Debug, Clone)]
233pub struct ReferenceDef {
234    /// Line number (1-indexed)
235    pub line: usize,
236    /// Reference ID (normalized to lowercase)
237    pub id: String,
238    /// URL
239    pub url: String,
240    /// Optional title
241    pub title: Option<String>,
242    /// Byte offset where the reference definition starts
243    pub byte_offset: usize,
244    /// Byte offset where the reference definition ends
245    pub byte_end: usize,
246    /// Byte offset where the title starts (if present, includes quote)
247    pub title_byte_start: Option<usize>,
248    /// Byte offset where the title ends (if present, includes quote)
249    pub title_byte_end: Option<usize>,
250}
251
252/// Parsed code span information
253#[derive(Debug, Clone)]
254pub struct CodeSpan {
255    /// Line number where the code span starts (1-indexed)
256    pub line: usize,
257    /// Line number where the code span ends (1-indexed)
258    pub end_line: usize,
259    /// Start column (0-indexed) in the line
260    pub start_col: usize,
261    /// End column (0-indexed) in the line
262    pub end_col: usize,
263    /// Byte offset in document
264    pub byte_offset: usize,
265    /// End byte offset in document
266    pub byte_end: usize,
267    /// Number of backticks used (1, 2, 3, etc.)
268    pub backtick_count: usize,
269    /// Content inside the code span (without backticks)
270    pub content: String,
271}
272
273/// Parsed math span information (inline $...$ or display $$...$$)
274#[derive(Debug, Clone)]
275pub struct MathSpan {
276    /// Line number where the math span starts (1-indexed)
277    pub line: usize,
278    /// Line number where the math span ends (1-indexed)
279    pub end_line: usize,
280    /// Start column (0-indexed) in the line
281    pub start_col: usize,
282    /// End column (0-indexed) in the line
283    pub end_col: usize,
284    /// Byte offset in document
285    pub byte_offset: usize,
286    /// End byte offset in document
287    pub byte_end: usize,
288    /// Whether this is display math ($$...$$) vs inline ($...$)
289    pub is_display: bool,
290    /// Content inside the math delimiters
291    pub content: String,
292}
293
294/// Information about a heading
295#[derive(Debug, Clone)]
296pub struct HeadingInfo {
297    /// Heading level (1-6 for ATX, 1-2 for Setext)
298    pub level: u8,
299    /// Style of heading
300    pub style: HeadingStyle,
301    /// The heading marker (# characters or underline)
302    pub marker: String,
303    /// Column where the marker starts (0-based)
304    pub marker_column: usize,
305    /// Column where heading text starts
306    pub content_column: usize,
307    /// The heading text (without markers and without custom ID syntax)
308    pub text: String,
309    /// Custom header ID if present (e.g., from {#custom-id} syntax)
310    pub custom_id: Option<String>,
311    /// Original heading text including custom ID syntax
312    pub raw_text: String,
313    /// Whether it has a closing sequence (for ATX)
314    pub has_closing_sequence: bool,
315    /// The closing sequence if present
316    pub closing_sequence: String,
317    /// Whether this is a valid CommonMark heading (ATX headings require space after #)
318    /// False for malformed headings like `#NoSpace` that MD018 should flag
319    pub is_valid: bool,
320}
321
322/// A valid heading from a filtered iteration
323///
324/// Only includes headings that are CommonMark-compliant (have space after #).
325/// Hashtag-like patterns (`#tag`, `#123`) are excluded.
326#[derive(Debug, Clone)]
327pub struct ValidHeading<'a> {
328    /// The 1-indexed line number in the document
329    pub line_num: usize,
330    /// Reference to the heading information
331    pub heading: &'a HeadingInfo,
332    /// Reference to the full line info (for rules that need additional context)
333    pub line_info: &'a LineInfo,
334}
335
336/// Iterator over valid CommonMark headings in a document
337///
338/// Filters out malformed headings like `#NoSpace` that should be flagged by MD018
339/// but should not be processed by other heading rules.
340pub struct ValidHeadingsIter<'a> {
341    lines: &'a [LineInfo],
342    current_index: usize,
343}
344
345impl<'a> ValidHeadingsIter<'a> {
346    pub(super) fn new(lines: &'a [LineInfo]) -> Self {
347        Self {
348            lines,
349            current_index: 0,
350        }
351    }
352}
353
354impl<'a> Iterator for ValidHeadingsIter<'a> {
355    type Item = ValidHeading<'a>;
356
357    fn next(&mut self) -> Option<Self::Item> {
358        while self.current_index < self.lines.len() {
359            let idx = self.current_index;
360            self.current_index += 1;
361
362            let line_info = &self.lines[idx];
363            if let Some(heading) = line_info.heading.as_deref()
364                && heading.is_valid
365            {
366                return Some(ValidHeading {
367                    line_num: idx + 1, // Convert 0-indexed to 1-indexed
368                    heading,
369                    line_info,
370                });
371            }
372        }
373        None
374    }
375}
376
377/// Information about a blockquote line
378#[derive(Debug, Clone)]
379pub struct BlockquoteInfo {
380    /// Nesting level (1 for >, 2 for >>, etc.)
381    pub nesting_level: usize,
382    /// Column where the first > starts (0-based)
383    pub marker_column: usize,
384    /// The blockquote prefix (e.g., "> ", ">> ", etc.)
385    pub prefix: String,
386    /// Content after the blockquote marker(s)
387    pub content: String,
388    /// Whether the line has multiple spaces after the marker
389    pub has_multiple_spaces_after_marker: bool,
390}
391
392/// Information about a list block
393#[derive(Debug, Clone)]
394pub struct ListBlock {
395    /// Line number where the list starts (1-indexed)
396    pub start_line: usize,
397    /// Line number where the list ends (1-indexed)
398    pub end_line: usize,
399    /// Whether it's ordered or unordered
400    pub is_ordered: bool,
401    /// The consistent marker for unordered lists (if any)
402    pub marker: Option<String>,
403    /// Blockquote prefix for this list (empty if not in blockquote)
404    pub blockquote_prefix: String,
405    /// Lines that are list items within this block
406    pub item_lines: Vec<usize>,
407    /// Nesting level (0 for top-level lists)
408    pub nesting_level: usize,
409    /// Maximum marker width seen in this block (e.g., 3 for "1. ", 4 for "10. ")
410    pub max_marker_width: usize,
411}
412
413/// Character frequency data for fast content analysis
414#[derive(Debug, Clone, Default)]
415pub struct CharFrequency {
416    /// Count of # characters (headings)
417    pub hash_count: usize,
418    /// Count of * characters (emphasis, lists, horizontal rules)
419    pub asterisk_count: usize,
420    /// Count of _ characters (emphasis, horizontal rules)
421    pub underscore_count: usize,
422    /// Count of - characters (lists, horizontal rules, setext headings)
423    pub hyphen_count: usize,
424    /// Count of + characters (lists)
425    pub plus_count: usize,
426    /// Count of > characters (blockquotes)
427    pub gt_count: usize,
428    /// Count of | characters (tables)
429    pub pipe_count: usize,
430    /// Count of [ characters (links, images)
431    pub bracket_count: usize,
432    /// Count of ` characters (code spans, code blocks)
433    pub backtick_count: usize,
434    /// Count of < characters (HTML tags, autolinks)
435    pub lt_count: usize,
436    /// Count of ! characters (images)
437    pub exclamation_count: usize,
438    /// Count of newline characters
439    pub newline_count: usize,
440}
441
442/// Pre-parsed HTML tag information
443#[derive(Debug, Clone)]
444pub struct HtmlTag {
445    /// Line number (1-indexed)
446    pub line: usize,
447    /// Start column (0-indexed) in the line
448    pub start_col: usize,
449    /// End column (0-indexed) in the line
450    pub end_col: usize,
451    /// Byte offset in document
452    pub byte_offset: usize,
453    /// End byte offset in document
454    pub byte_end: usize,
455    /// Tag name (e.g., "div", "img", "br")
456    pub tag_name: String,
457    /// Whether it's a closing tag (`</tag>`)
458    pub is_closing: bool,
459    /// Whether it's self-closing (`<tag />`)
460    pub is_self_closing: bool,
461}
462
463/// Pre-parsed emphasis span information
464#[derive(Debug, Clone)]
465pub struct EmphasisSpan {
466    /// Line number (1-indexed)
467    pub line: usize,
468    /// Start column (0-indexed) in the line
469    pub start_col: usize,
470    /// End column (0-indexed) in the line
471    pub end_col: usize,
472    /// Byte offset in document
473    pub byte_offset: usize,
474    /// End byte offset in document
475    pub byte_end: usize,
476    /// Type of emphasis ('*' or '_')
477    pub marker: char,
478    /// Whether this span is strong emphasis (`**`/`__`) rather than ordinary emphasis (`*`/`_`)
479    pub is_strong: bool,
480    /// Content inside the emphasis
481    pub content: String,
482}
483
484/// Pre-parsed table row information
485#[derive(Debug, Clone)]
486pub struct TableRow {
487    /// Line number (1-indexed)
488    pub line: usize,
489    /// Whether this is a separator row (contains only |, -, :, and spaces)
490    pub is_separator: bool,
491    /// Number of columns (pipe-separated cells)
492    pub column_count: usize,
493    /// Alignment info from separator row
494    pub column_alignments: Vec<String>, // "left", "center", "right", "none"
495}
496
497/// Pre-parsed bare URL information (not in links)
498#[derive(Debug, Clone)]
499pub struct BareUrl {
500    /// Line number (1-indexed)
501    pub line: usize,
502    /// Start column (0-indexed) in the line
503    pub start_col: usize,
504    /// End column (0-indexed) in the line
505    pub end_col: usize,
506    /// Byte offset in document
507    pub byte_offset: usize,
508    /// End byte offset in document
509    pub byte_end: usize,
510    /// The URL string
511    pub url: String,
512}
513
514/// A lazy continuation line detected by pulldown-cmark.
515///
516/// Lazy continuation occurs when text continues a list item paragraph but with less
517/// indentation than expected.
518#[derive(Debug, Clone)]
519pub struct LazyContLine {
520    /// 1-indexed line number
521    pub line_num: usize,
522    /// Expected indentation
523    pub expected_indent: usize,
524    /// Current indentation
525    pub current_indent: usize,
526    /// Blockquote nesting level
527    pub blockquote_level: usize,
528}
529
530/// Check if a line is a horizontal rule (---, ***, ___) per CommonMark spec.
531/// CommonMark rules for thematic breaks (horizontal rules):
532/// - May have 0-3 spaces of leading indentation (but NOT tabs)
533/// - Must have 3+ of the same character (-, *, or _)
534/// - May have spaces between characters
535/// - No other characters allowed
536pub fn is_horizontal_rule_line(line: &str) -> bool {
537    // CommonMark: HRs can have 0-3 spaces of leading indentation, not tabs
538    let leading_spaces = line.len() - line.trim_start_matches(' ').len();
539    if leading_spaces > 3 || line.starts_with('\t') {
540        return false;
541    }
542
543    is_horizontal_rule_content(line.trim())
544}
545
546/// Check if trimmed content matches horizontal rule pattern.
547/// Use `is_horizontal_rule_line` for full CommonMark compliance including indentation check.
548pub fn is_horizontal_rule_content(trimmed: &str) -> bool {
549    if trimmed.len() < 3 {
550        return false;
551    }
552
553    let mut chars = trimmed.chars();
554    let Some(first_char @ ('-' | '*' | '_')) = chars.next() else {
555        return false;
556    };
557
558    // Count occurrences of the rule character, rejecting non-whitespace interlopers
559    let mut count = 1; // Already matched the first character
560    for ch in chars {
561        if ch == first_char {
562            count += 1;
563        } else if ch != ' ' && ch != '\t' {
564            return false;
565        }
566    }
567    count >= 3
568}