Skip to main content

mdlint/markdown/
parser.rs

1use pulldown_cmark::{BrokenLink, CowStr, Event, Options, Parser, Tag, TagEnd};
2use std::collections::{HashMap, HashSet};
3use std::ops::Range;
4
5pub struct MarkdownParser<'a> {
6    content: &'a str,
7    lines: Vec<&'a str>,
8    /// Byte offset of the start of each line (0-indexed).
9    /// Enables O(log n) offset → (line, column) lookup via binary search.
10    line_offsets: Vec<usize>,
11    /// Lines (1-indexed) that fall inside a fenced/indented code block.
12    code_block_lines: HashSet<usize>,
13    /// Lines (1-indexed) inside any code (blocks + inline spans).
14    code_lines: HashSet<usize>,
15    /// Byte ranges of all code blocks and inline code spans.
16    code_ranges: Vec<Range<usize>>,
17    /// Lines (1-indexed) that are part of a link reference definition (`[label]: url`).
18    ref_def_lines: HashSet<usize>,
19    /// Map from normalised (lowercase) label to its 1-indexed line number.
20    ref_defs: HashMap<String, usize>,
21}
22
23impl<'a> MarkdownParser<'a> {
24    pub fn new(content: &'a str) -> Self {
25        let lines: Vec<&'a str> = content.lines().collect();
26        let line_offsets = build_line_offsets(content);
27        let (code_block_lines, code_lines, code_ranges) = build_code_info(content, &line_offsets);
28        let (ref_def_lines, ref_defs) = build_ref_def_info(content, &line_offsets);
29        Self {
30            content,
31            lines,
32            line_offsets,
33            code_block_lines,
34            code_lines,
35            code_ranges,
36            ref_def_lines,
37            ref_defs,
38        }
39    }
40
41    pub fn content(&self) -> &'a str {
42        self.content
43    }
44
45    pub fn lines(&self) -> &[&'a str] {
46        &self.lines
47    }
48
49    pub fn line_count(&self) -> usize {
50        self.lines.len()
51    }
52
53    pub fn get_line(&self, line_num: usize) -> Option<&'a str> {
54        if line_num > 0 && line_num <= self.lines.len() {
55            Some(self.lines[line_num - 1])
56        } else {
57            None
58        }
59    }
60
61    pub fn parse(&self) -> impl Iterator<Item = Event<'a>> + 'a {
62        Parser::new_ext(self.content, mk_options())
63    }
64
65    pub fn parse_with_offsets(&self) -> impl Iterator<Item = (Event<'a>, Range<usize>)> {
66        Parser::new_ext(self.content, mk_options()).into_offset_iter()
67    }
68
69    /// Like `parse_with_offsets`, but resolves otherwise-broken reference links
70    /// (undefined labels) by flagging their `LinkType` as the corresponding
71    /// `*Unknown` variant instead of silently dropping the event as plain text.
72    /// Used by rules that need to detect undefined reference links/images.
73    pub fn parse_with_broken_links(&self) -> impl Iterator<Item = (Event<'a>, Range<usize>)> + 'a {
74        Parser::new_with_broken_link_callback(
75            self.content,
76            mk_options(),
77            Some(|_broken: BrokenLink| Some((CowStr::from(""), CowStr::from("")))),
78        )
79        .into_offset_iter()
80    }
81
82    pub fn offset_to_line(&self, offset: usize) -> usize {
83        self.offset_to_position(offset).0
84    }
85
86    pub fn offset_to_position(&self, offset: usize) -> (usize, usize) {
87        // partition_point returns the count of elements for which the predicate holds —
88        // i.e. the index of the first line whose start offset exceeds `offset`.
89        let i = self.line_offsets.partition_point(|&start| start <= offset);
90        if i == 0 {
91            return (1, 1);
92        }
93        let line_idx = i - 1; // 0-indexed
94        let column = offset - self.line_offsets[line_idx] + 1;
95        (line_idx + 1, column) // 1-indexed
96    }
97
98    /// Returns the 1-indexed line numbers inside code blocks or inline code.
99    /// Result is precomputed in `new()` — O(1) to access.
100    pub fn get_code_line_numbers(&self) -> &HashSet<usize> {
101        &self.code_lines
102    }
103
104    /// Returns the 1-indexed line numbers inside code blocks only (not inline spans).
105    /// Result is precomputed in `new()` — O(1) to access.
106    pub fn get_code_block_line_numbers(&self) -> &HashSet<usize> {
107        &self.code_block_lines
108    }
109
110    /// Returns byte ranges (into the original content) for all code blocks and
111    /// inline code spans. Result is precomputed in `new()` — O(1) to access.
112    pub fn get_code_ranges(&self) -> &[Range<usize>] {
113        &self.code_ranges
114    }
115
116    /// Returns the 1-indexed line numbers that form link reference definitions
117    /// (`[label]: url`). Result is precomputed in `new()` — O(1) to access.
118    pub fn get_ref_def_line_numbers(&self) -> &HashSet<usize> {
119        &self.ref_def_lines
120    }
121
122    /// Returns a map of normalised (lowercase) label → 1-indexed line number for
123    /// every link reference definition in the document.
124    pub fn get_ref_defs(&self) -> &HashMap<String, usize> {
125        &self.ref_defs
126    }
127
128    /// Converts a (1-indexed) line number and 0-indexed byte offset within that
129    /// line to an absolute byte offset in the content.
130    pub fn line_offset_to_absolute(&self, line_num: usize, byte_offset_in_line: usize) -> usize {
131        if line_num == 0 || line_num > self.line_offsets.len() {
132            return self.content.len();
133        }
134        self.line_offsets[line_num - 1] + byte_offset_in_line
135    }
136
137    pub fn is_heading(&self, event: &Event) -> bool {
138        matches!(event, Event::Start(Tag::Heading { .. }))
139    }
140
141    pub fn is_code_block(&self, event: &Event) -> bool {
142        matches!(event, Event::Start(Tag::CodeBlock(_)))
143    }
144
145    pub fn is_list(&self, event: &Event) -> bool {
146        matches!(event, Event::Start(Tag::List(_)))
147    }
148}
149
150fn mk_options() -> Options {
151    let mut options = Options::empty();
152    options.insert(Options::ENABLE_TABLES);
153    options.insert(Options::ENABLE_FOOTNOTES);
154    options.insert(Options::ENABLE_STRIKETHROUGH);
155    options.insert(Options::ENABLE_TASKLISTS);
156    options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
157    options
158}
159
160/// Builds a table of byte offsets for the start of each line (entry `i` = byte
161/// offset where line `i+1` begins).  Handles both LF and CRLF correctly because
162/// it scans the raw bytes rather than relying on `str::lines` lengths.
163fn build_line_offsets(content: &str) -> Vec<usize> {
164    let mut offsets = vec![0usize];
165    for (i, byte) in content.bytes().enumerate() {
166        if byte == b'\n' {
167            let next = i + 1;
168            if next < content.len() {
169                offsets.push(next);
170            }
171        }
172    }
173    offsets
174}
175
176/// Map a byte offset to a 1-indexed line number using the precomputed offset
177/// table.  O(log n) via binary search.
178fn line_from_offset(offset: usize, line_offsets: &[usize]) -> usize {
179    let i = line_offsets.partition_point(|&start| start <= offset);
180    i.max(1)
181}
182
183/// Single parse pass that builds all three code-location caches simultaneously.
184/// Called once in `MarkdownParser::new()`.
185fn build_code_info(
186    content: &str,
187    line_offsets: &[usize],
188) -> (HashSet<usize>, HashSet<usize>, Vec<Range<usize>>) {
189    let mut code_block_lines: HashSet<usize> = HashSet::new();
190    let mut code_lines: HashSet<usize> = HashSet::new();
191    let mut code_ranges: Vec<Range<usize>> = Vec::new();
192
193    let mut in_code_block = false;
194    let mut code_block_start = 0usize;
195
196    for (event, range) in Parser::new_ext(content, mk_options()).into_offset_iter() {
197        match event {
198            Event::Start(Tag::CodeBlock(_)) => {
199                in_code_block = true;
200                code_block_start = range.start;
201                let start_line = line_from_offset(range.start, line_offsets);
202                let end_line = line_from_offset(range.end, line_offsets);
203                for line in start_line..=end_line {
204                    code_block_lines.insert(line);
205                    code_lines.insert(line);
206                }
207            }
208            Event::End(TagEnd::CodeBlock) => {
209                if in_code_block {
210                    code_ranges.push(code_block_start..range.end);
211                    in_code_block = false;
212                }
213            }
214            Event::Code(_) => {
215                // Inline code span
216                code_ranges.push(range.clone());
217                let start_line = line_from_offset(range.start, line_offsets);
218                let end_line = line_from_offset(range.end, line_offsets);
219                for line in start_line..=end_line {
220                    code_lines.insert(line);
221                }
222            }
223            _ => {
224                if in_code_block {
225                    let start_line = line_from_offset(range.start, line_offsets);
226                    let end_line = line_from_offset(range.end, line_offsets);
227                    for line in start_line..=end_line {
228                        code_block_lines.insert(line);
229                        code_lines.insert(line);
230                    }
231                }
232            }
233        }
234    }
235
236    (code_block_lines, code_lines, code_ranges)
237}
238
239/// Collects link reference definition metadata in one pass over the parser's
240/// `reference_definitions()` map (populated before the first event is consumed).
241/// Returns (line-number set, label→line map); both use 1-indexed line numbers and
242/// normalised (lowercase) labels.
243fn build_ref_def_info(
244    content: &str,
245    line_offsets: &[usize],
246) -> (HashSet<usize>, HashMap<String, usize>) {
247    let parser = Parser::new_ext(content, mk_options());
248    let mut line_set = HashSet::new();
249    let mut label_map = HashMap::new();
250    for (label, link_def) in parser.reference_definitions().iter() {
251        let start = line_from_offset(link_def.span.start, line_offsets);
252        let end = line_from_offset(link_def.span.end.saturating_sub(1), line_offsets);
253        for line in start..=end {
254            line_set.insert(line);
255        }
256        label_map.insert(label.to_string(), start);
257    }
258    (line_set, label_map)
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_basic_parsing() {
267        let content = "# Heading\n\nSome **bold** text.";
268        let parser = MarkdownParser::new(content);
269
270        assert_eq!(parser.content(), content);
271        assert_eq!(parser.line_count(), 3);
272    }
273
274    #[test]
275    fn test_get_line() {
276        let content = "Line 1\nLine 2\nLine 3";
277        let parser = MarkdownParser::new(content);
278
279        assert_eq!(parser.get_line(1), Some("Line 1"));
280        assert_eq!(parser.get_line(2), Some("Line 2"));
281        assert_eq!(parser.get_line(3), Some("Line 3"));
282        assert_eq!(parser.get_line(0), None);
283        assert_eq!(parser.get_line(4), None);
284    }
285
286    #[test]
287    fn test_offset_to_line() {
288        let content = "Line 1\nLine 2\nLine 3";
289        let parser = MarkdownParser::new(content);
290
291        assert_eq!(parser.offset_to_line(0), 1);
292        assert_eq!(parser.offset_to_line(3), 1);
293        assert_eq!(parser.offset_to_line(7), 2);
294        assert_eq!(parser.offset_to_line(14), 3);
295    }
296
297    #[test]
298    fn test_offset_to_position() {
299        let content = "Line 1\nLine 2\nLine 3";
300        let parser = MarkdownParser::new(content);
301
302        assert_eq!(parser.offset_to_position(0), (1, 1));
303        assert_eq!(parser.offset_to_position(3), (1, 4));
304        assert_eq!(parser.offset_to_position(7), (2, 1));
305    }
306
307    #[test]
308    fn test_parse_events() {
309        let content = "# Heading";
310        let parser = MarkdownParser::new(content);
311
312        let events: Vec<_> = parser.parse().collect();
313        assert!(!events.is_empty());
314        assert!(parser.is_heading(&events[0]));
315    }
316
317    #[test]
318    fn test_parse_with_offsets() {
319        let content = "# Heading\n\nParagraph";
320        let parser = MarkdownParser::new(content);
321
322        let events: Vec<_> = parser.parse_with_offsets().collect();
323        assert!(!events.is_empty());
324    }
325
326    #[test]
327    fn test_event_type_checks() {
328        let content = "# Heading\n\n```rust\ncode\n```\n\n- item";
329        let parser = MarkdownParser::new(content);
330
331        let events: Vec<_> = parser.parse().collect();
332
333        let has_heading = events.iter().any(|e| parser.is_heading(e));
334        let has_code = events.iter().any(|e| parser.is_code_block(e));
335        let has_list = events.iter().any(|e| parser.is_list(e));
336
337        assert!(has_heading);
338        assert!(has_code);
339        assert!(has_list);
340    }
341
342    #[test]
343    fn test_code_line_numbers_fenced() {
344        let content = "Normal text\n\n```sql\nSELECT * FROM table_name\nWHERE user_id = 123\n```\n\nMore text";
345        let parser = MarkdownParser::new(content);
346        let code_lines = parser.get_code_line_numbers();
347
348        // Lines 3-6 should be marked as code (the ``` markers and content)
349        assert!(
350            code_lines.contains(&3),
351            "Line 3 (opening ```) should be code"
352        );
353        assert!(
354            code_lines.contains(&4),
355            "Line 4 (code content) should be code"
356        );
357        assert!(
358            code_lines.contains(&5),
359            "Line 5 (code content) should be code"
360        );
361        assert!(
362            code_lines.contains(&6),
363            "Line 6 (closing ```) should be code"
364        );
365
366        // Other lines should not be marked
367        assert!(!code_lines.contains(&1), "Line 1 should not be code");
368        assert!(!code_lines.contains(&2), "Line 2 should not be code");
369        assert!(!code_lines.contains(&8), "Line 8 should not be code");
370    }
371
372    #[test]
373    fn test_code_line_numbers_inline() {
374        let content = "This is `inline_code_with_underscores` in text";
375        let parser = MarkdownParser::new(content);
376        let code_lines = parser.get_code_line_numbers();
377
378        // Line 1 should be marked because it contains inline code
379        assert!(
380            code_lines.contains(&1),
381            "Line with inline code should be marked"
382        );
383    }
384
385    #[test]
386    fn test_code_line_numbers_mixed() {
387        let content =
388            "Normal text\n\nText with `inline_code` here\n\n```\nCode block\n```\n\nFinal text";
389        let parser = MarkdownParser::new(content);
390        let code_lines = parser.get_code_line_numbers();
391
392        // Line 3 has inline code
393        assert!(
394            code_lines.contains(&3),
395            "Line with inline code should be marked"
396        );
397
398        // Lines 5-7 are in code block
399        assert!(code_lines.contains(&5), "Code block line should be marked");
400        assert!(code_lines.contains(&6), "Code block line should be marked");
401        assert!(code_lines.contains(&7), "Code block line should be marked");
402
403        // Lines 1, 2, 9 are normal text
404        assert!(
405            !code_lines.contains(&1),
406            "Normal text line should not be marked"
407        );
408        assert!(!code_lines.contains(&2), "Empty line should not be marked");
409        assert!(
410            !code_lines.contains(&9),
411            "Normal text line should not be marked"
412        );
413    }
414
415    #[test]
416    fn test_build_line_offsets() {
417        // LF line endings
418        let offsets = build_line_offsets("abc\ndef\nghi");
419        assert_eq!(offsets, vec![0, 4, 8]);
420
421        // CRLF line endings
422        let offsets = build_line_offsets("abc\r\ndef\r\nghi");
423        assert_eq!(offsets, vec![0, 5, 10]);
424
425        // Single line (no newline)
426        let offsets = build_line_offsets("abc");
427        assert_eq!(offsets, vec![0]);
428
429        // Empty content
430        let offsets = build_line_offsets("");
431        assert_eq!(offsets, vec![0]);
432
433        // Trailing newline does not add a spurious extra entry
434        let offsets = build_line_offsets("abc\n");
435        assert_eq!(offsets, vec![0]);
436    }
437
438    #[test]
439    fn test_offset_to_position_crlf() {
440        // CRLF: "abc\r\ndef" — 'a'=0,'b'=1,'c'=2,'\r'=3,'\n'=4,'d'=5,'e'=6,'f'=7
441        let content = "abc\r\ndef";
442        let parser = MarkdownParser::new(content);
443        assert_eq!(parser.offset_to_position(0), (1, 1));
444        assert_eq!(parser.offset_to_position(2), (1, 3));
445        assert_eq!(parser.offset_to_position(5), (2, 1));
446        assert_eq!(parser.offset_to_position(7), (2, 3));
447    }
448
449    #[test]
450    fn test_ref_def_line_numbers() {
451        let content = "Text\n\n[foo]: https://example.com\n\nMore text";
452        let parser = MarkdownParser::new(content);
453        let ref_def_lines = parser.get_ref_def_line_numbers();
454
455        assert!(ref_def_lines.contains(&3), "ref def line should be marked");
456        assert!(!ref_def_lines.contains(&1), "prose should not be marked");
457        assert!(!ref_def_lines.contains(&5), "prose should not be marked");
458    }
459}