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 /// Line the link ends on (1-indexed). A link can span lines, so `end_col` is
159 /// a column of *this* line, not of `line`.
160 pub end_line: usize,
161 /// Start column (0-indexed) in the line
162 pub start_col: usize,
163 /// End column (0-indexed) in `end_line`
164 pub end_col: usize,
165 /// Byte offset in document
166 pub byte_offset: usize,
167 /// End byte offset in document
168 pub byte_end: usize,
169 /// Link text
170 pub text: Cow<'a, str>,
171 /// Link URL or reference
172 pub url: Cow<'a, str>,
173 /// Inline title (without surrounding delimiters), as produced by pulldown-cmark
174 /// after backslash-escape handling. `None` when the link has no title or is a
175 /// reference style without a matched definition.
176 pub title: Option<Cow<'a, str>>,
177 /// Whether this is a reference link `[text][ref]` vs inline `[text](url)`
178 pub is_reference: bool,
179 /// Reference ID for reference links
180 pub reference_id: Option<Cow<'a, str>>,
181 /// Link type from pulldown-cmark
182 pub link_type: LinkType,
183}
184
185/// Information about a broken link reported by pulldown-cmark
186#[derive(Debug, Clone)]
187pub struct BrokenLinkInfo {
188 /// The reference text that couldn't be resolved
189 pub reference: String,
190 /// Byte span in the source document
191 pub span: std::ops::Range<usize>,
192 /// The type of the broken link
193 pub link_type: LinkType,
194}
195
196/// Parsed footnote reference (e.g., `[^1]`, `[^note]`)
197#[derive(Debug, Clone)]
198pub struct FootnoteRef {
199 /// The footnote ID (without the ^ prefix)
200 pub id: String,
201 /// Line number (1-indexed)
202 pub line: usize,
203 /// Start byte offset in document
204 pub byte_offset: usize,
205}
206
207/// Parsed image information
208#[derive(Debug, Clone)]
209pub struct ParsedImage<'a> {
210 /// Line number (1-indexed)
211 pub line: usize,
212 /// Line the image ends on (1-indexed). An image can span lines, so `end_col`
213 /// is a column of *this* line, not of `line`.
214 pub end_line: usize,
215 /// Start column (0-indexed) in the line
216 pub start_col: usize,
217 /// End column (0-indexed) in `end_line`
218 pub end_col: usize,
219 /// Byte offset in document
220 pub byte_offset: usize,
221 /// End byte offset in document
222 pub byte_end: usize,
223 /// Alt text
224 pub alt_text: Cow<'a, str>,
225 /// Image URL or reference
226 pub url: Cow<'a, str>,
227 /// Inline title (without surrounding delimiters), as produced by pulldown-cmark
228 /// after backslash-escape handling. `None` when the image has no title or is a
229 /// reference style without a matched definition.
230 pub title: Option<Cow<'a, str>>,
231 /// Whether this is a reference image ![alt][ref] vs inline 
232 pub is_reference: bool,
233 /// Reference ID for reference images
234 pub reference_id: Option<Cow<'a, str>>,
235 /// Link type from pulldown-cmark
236 pub link_type: LinkType,
237}
238
239/// Reference definition `[ref]: url "title"`
240#[derive(Debug, Clone)]
241pub struct ReferenceDef {
242 /// Line number (1-indexed)
243 pub line: usize,
244 /// Reference ID (normalized to lowercase)
245 pub id: String,
246 /// URL
247 pub url: String,
248 /// Optional title
249 pub title: Option<String>,
250 /// Byte offset where the reference definition starts
251 pub byte_offset: usize,
252 /// Byte offset where the reference definition ends
253 pub byte_end: usize,
254 /// Byte offset where the title starts (if present, includes quote)
255 pub title_byte_start: Option<usize>,
256 /// Byte offset where the title ends (if present, includes quote)
257 pub title_byte_end: Option<usize>,
258}
259
260/// Parsed code span information
261#[derive(Debug, Clone)]
262pub struct CodeSpan {
263 /// Line number where the code span starts (1-indexed)
264 pub line: usize,
265 /// Line number where the code span ends (1-indexed)
266 pub end_line: usize,
267 /// Start column (0-indexed) in the line
268 pub start_col: usize,
269 /// End column (0-indexed) in the line
270 pub end_col: usize,
271 /// Byte offset in document
272 pub byte_offset: usize,
273 /// End byte offset in document
274 pub byte_end: usize,
275 /// Number of backticks used (1, 2, 3, etc.)
276 pub backtick_count: usize,
277 /// Content inside the code span (without backticks)
278 pub content: String,
279}
280
281/// Parsed math span information (inline $...$ or display $$...$$)
282#[derive(Debug, Clone)]
283pub struct MathSpan {
284 /// Line number where the math span starts (1-indexed)
285 pub line: usize,
286 /// Line number where the math span ends (1-indexed)
287 pub end_line: usize,
288 /// Start column (0-indexed) in the line
289 pub start_col: usize,
290 /// End column (0-indexed) in the line
291 pub end_col: usize,
292 /// Byte offset in document
293 pub byte_offset: usize,
294 /// End byte offset in document
295 pub byte_end: usize,
296 /// Whether this is display math ($$...$$) vs inline ($...$)
297 pub is_display: bool,
298 /// Content inside the math delimiters
299 pub content: String,
300}
301
302/// Information about a heading
303#[derive(Debug, Clone)]
304pub struct HeadingInfo {
305 /// Heading level (1-6 for ATX, 1-2 for Setext)
306 pub level: u8,
307 /// Style of heading
308 pub style: HeadingStyle,
309 /// The heading marker (# characters or underline)
310 pub marker: String,
311 /// Column where the marker starts (0-based)
312 pub marker_column: usize,
313 /// Column where heading text starts
314 pub content_column: usize,
315 /// The heading text (without markers and without custom ID syntax)
316 pub text: String,
317 /// Custom header ID if present (e.g., from {#custom-id} syntax)
318 pub custom_id: Option<String>,
319 /// Original heading text including custom ID syntax
320 pub raw_text: String,
321 /// Whether it has a closing sequence (for ATX)
322 pub has_closing_sequence: bool,
323 /// The closing sequence if present
324 pub closing_sequence: String,
325 /// Whether this is a valid CommonMark heading (ATX headings require space after #)
326 /// False for malformed headings like `#NoSpace` that MD018 should flag
327 pub is_valid: bool,
328}
329
330/// A valid heading from a filtered iteration
331///
332/// Only includes headings that are CommonMark-compliant (have space after #).
333/// Hashtag-like patterns (`#tag`, `#123`) are excluded.
334#[derive(Debug, Clone)]
335pub struct ValidHeading<'a> {
336 /// The 1-indexed line number in the document
337 pub line_num: usize,
338 /// Reference to the heading information
339 pub heading: &'a HeadingInfo,
340 /// Reference to the full line info (for rules that need additional context)
341 pub line_info: &'a LineInfo,
342}
343
344/// Iterator over valid CommonMark headings in a document
345///
346/// Filters out malformed headings like `#NoSpace` that should be flagged by MD018
347/// but should not be processed by other heading rules.
348pub struct ValidHeadingsIter<'a> {
349 lines: &'a [LineInfo],
350 current_index: usize,
351}
352
353impl<'a> ValidHeadingsIter<'a> {
354 pub(super) fn new(lines: &'a [LineInfo]) -> Self {
355 Self {
356 lines,
357 current_index: 0,
358 }
359 }
360}
361
362impl<'a> Iterator for ValidHeadingsIter<'a> {
363 type Item = ValidHeading<'a>;
364
365 fn next(&mut self) -> Option<Self::Item> {
366 while self.current_index < self.lines.len() {
367 let idx = self.current_index;
368 self.current_index += 1;
369
370 let line_info = &self.lines[idx];
371 if let Some(heading) = line_info.heading.as_deref()
372 && heading.is_valid
373 {
374 return Some(ValidHeading {
375 line_num: idx + 1, // Convert 0-indexed to 1-indexed
376 heading,
377 line_info,
378 });
379 }
380 }
381 None
382 }
383}
384
385/// Information about a blockquote line
386#[derive(Debug, Clone)]
387pub struct BlockquoteInfo {
388 /// Nesting level (1 for >, 2 for >>, etc.)
389 pub nesting_level: usize,
390 /// Column where the first > starts (0-based)
391 pub marker_column: usize,
392 /// The blockquote prefix (e.g., "> ", ">> ", etc.)
393 pub prefix: String,
394 /// Content after the blockquote marker(s)
395 pub content: String,
396 /// Whether the line has multiple spaces after the marker
397 pub has_multiple_spaces_after_marker: bool,
398}
399
400/// Information about a list block
401#[derive(Debug, Clone)]
402pub struct ListBlock {
403 /// Line number where the list starts (1-indexed)
404 pub start_line: usize,
405 /// Line number where the list ends (1-indexed)
406 pub end_line: usize,
407 /// Whether it's ordered or unordered
408 pub is_ordered: bool,
409 /// The consistent marker for unordered lists (if any)
410 pub marker: Option<String>,
411 /// Blockquote prefix for this list (empty if not in blockquote)
412 pub blockquote_prefix: String,
413 /// Lines that are list items within this block
414 pub item_lines: Vec<usize>,
415 /// Nesting level (0 for top-level lists)
416 pub nesting_level: usize,
417 /// Maximum marker width seen in this block (e.g., 3 for "1. ", 4 for "10. ")
418 pub max_marker_width: usize,
419}
420
421/// Character frequency data for fast content analysis
422#[derive(Debug, Clone, Default)]
423pub struct CharFrequency {
424 /// Count of # characters (headings)
425 pub hash_count: usize,
426 /// Count of * characters (emphasis, lists, horizontal rules)
427 pub asterisk_count: usize,
428 /// Count of _ characters (emphasis, horizontal rules)
429 pub underscore_count: usize,
430 /// Count of - characters (lists, horizontal rules, setext headings)
431 pub hyphen_count: usize,
432 /// Count of + characters (lists)
433 pub plus_count: usize,
434 /// Count of > characters (blockquotes)
435 pub gt_count: usize,
436 /// Count of | characters (tables)
437 pub pipe_count: usize,
438 /// Count of [ characters (links, images)
439 pub bracket_count: usize,
440 /// Count of ` characters (code spans, code blocks)
441 pub backtick_count: usize,
442 /// Count of < characters (HTML tags, autolinks)
443 pub lt_count: usize,
444 /// Count of ! characters (images)
445 pub exclamation_count: usize,
446 /// Count of newline characters
447 pub newline_count: usize,
448}
449
450/// Pre-parsed HTML tag information
451#[derive(Debug, Clone)]
452pub struct HtmlTag {
453 /// Line number (1-indexed)
454 pub line: usize,
455 /// Start column (0-indexed) in the line
456 pub start_col: usize,
457 /// End column (0-indexed) in the line
458 pub end_col: usize,
459 /// Byte offset in document
460 pub byte_offset: usize,
461 /// End byte offset in document
462 pub byte_end: usize,
463 /// Tag name (e.g., "div", "img", "br")
464 pub tag_name: String,
465 /// Whether it's a closing tag (`</tag>`)
466 pub is_closing: bool,
467 /// Whether it's self-closing (`<tag />`)
468 pub is_self_closing: bool,
469}
470
471/// Pre-parsed emphasis span information
472#[derive(Debug, Clone)]
473pub struct EmphasisSpan {
474 /// Line number (1-indexed)
475 pub line: usize,
476 /// Start column (0-indexed) in the line
477 pub start_col: usize,
478 /// End column (0-indexed) in the line
479 pub end_col: usize,
480 /// Byte offset in document
481 pub byte_offset: usize,
482 /// End byte offset in document
483 pub byte_end: usize,
484 /// Type of emphasis ('*' or '_')
485 pub marker: char,
486 /// Whether this span is strong emphasis (`**`/`__`) rather than ordinary emphasis (`*`/`_`)
487 pub is_strong: bool,
488 /// Content inside the emphasis
489 pub content: String,
490}
491
492/// Pre-parsed bare URL information (not in links)
493#[derive(Debug, Clone)]
494pub struct BareUrl {
495 /// Line number (1-indexed)
496 pub line: usize,
497 /// Start column (0-indexed) in the line
498 pub start_col: usize,
499 /// End column (0-indexed) in the line
500 pub end_col: usize,
501 /// Byte offset in document
502 pub byte_offset: usize,
503 /// End byte offset in document
504 pub byte_end: usize,
505 /// The URL string
506 pub url: String,
507}
508
509/// A lazy continuation line detected by pulldown-cmark.
510///
511/// Lazy continuation occurs when text continues a list item paragraph but with less
512/// indentation than expected.
513#[derive(Debug, Clone)]
514pub struct LazyContLine {
515 /// 1-indexed line number
516 pub line_num: usize,
517 /// Expected indentation
518 pub expected_indent: usize,
519 /// Current indentation
520 pub current_indent: usize,
521 /// Blockquote nesting level
522 pub blockquote_level: usize,
523}
524
525/// Check if a line is a horizontal rule (---, ***, ___) per CommonMark spec.
526/// CommonMark rules for thematic breaks (horizontal rules):
527/// - May have 0-3 spaces of leading indentation (but NOT tabs)
528/// - Must have 3+ of the same character (-, *, or _)
529/// - May have spaces between characters
530/// - No other characters allowed
531pub fn is_horizontal_rule_line(line: &str) -> bool {
532 // CommonMark: HRs can have 0-3 spaces of leading indentation, not tabs
533 let leading_spaces = line.len() - line.trim_start_matches(' ').len();
534 if leading_spaces > 3 || line.starts_with('\t') {
535 return false;
536 }
537
538 is_horizontal_rule_content(line.trim())
539}
540
541/// Check if trimmed content matches horizontal rule pattern.
542/// Use `is_horizontal_rule_line` for full CommonMark compliance including indentation check.
543pub fn is_horizontal_rule_content(trimmed: &str) -> bool {
544 if trimmed.len() < 3 {
545 return false;
546 }
547
548 let mut chars = trimmed.chars();
549 let Some(first_char @ ('-' | '*' | '_')) = chars.next() else {
550 return false;
551 };
552
553 // Count occurrences of the rule character, rejecting non-whitespace interlopers
554 let mut count = 1; // Already matched the first character
555 for ch in chars {
556 if ch == first_char {
557 count += 1;
558 } else if ch != ' ' && ch != '\t' {
559 return false;
560 }
561 }
562 count >= 3
563}