Skip to main content

drft/parsers/
markdown.rs

1use super::{Link, ParseResult, Parser};
2use pulldown_cmark::{Event, LinkType, Options, Parser as CmarkParser, Tag, TagEnd};
3
4/// Built-in markdown parser. Extracts inline/reference/autolinks and images.
5pub struct MarkdownParser {
6    /// File routing filter. None = receives all File nodes.
7    pub file_filter: Option<globset::GlobSet>,
8}
9
10impl Parser for MarkdownParser {
11    fn matches(&self, path: &str) -> bool {
12        match &self.file_filter {
13            Some(set) => set.is_match(path),
14            None => true, // No filter = receives all File nodes
15        }
16    }
17
18    fn parse(&self, _path: &str, content: &str) -> ParseResult {
19        ParseResult {
20            links: extract_markdown_links(content),
21            metadata: None,
22        }
23    }
24}
25
26fn extract_markdown_links(content: &str) -> Vec<Link> {
27    let newlines = newline_offsets(content);
28    let mut links = Vec::new();
29    let mut options = Options::empty();
30    options.insert(Options::ENABLE_STRIKETHROUGH);
31    // The offset iterator yields each event's byte range so we can locate links.
32    let parser = CmarkParser::new_ext(content, options).into_offset_iter();
33
34    let mut in_code_block = false;
35
36    for (event, range) in parser {
37        match event {
38            Event::Start(Tag::CodeBlock(_)) => in_code_block = true,
39            Event::End(TagEnd::CodeBlock) => in_code_block = false,
40            Event::Start(Tag::Link {
41                link_type,
42                dest_url,
43                ..
44            }) if !in_code_block => {
45                if link_type == LinkType::Email {
46                    continue;
47                }
48                let link = dest_url.trim();
49                if !link.is_empty() {
50                    links.push(Link {
51                        target: link.to_string(),
52                        line: Some(line_at(range.start, &newlines)),
53                    });
54                }
55            }
56            Event::Start(Tag::Image { dest_url, .. }) if !in_code_block => {
57                let link = dest_url.trim();
58                if !link.is_empty() {
59                    links.push(Link {
60                        target: link.to_string(),
61                        line: Some(line_at(range.start, &newlines)),
62                    });
63                }
64            }
65            _ => {}
66        }
67    }
68
69    links
70}
71
72/// Byte offsets of every `\n` in `content`, ascending — a reusable index for
73/// turning a byte offset into a line number.
74fn newline_offsets(content: &str) -> Vec<usize> {
75    content
76        .bytes()
77        .enumerate()
78        .filter(|(_, b)| *b == b'\n')
79        .map(|(i, _)| i)
80        .collect()
81}
82
83/// 1-based line containing byte `offset`, via the precomputed newline index.
84fn line_at(offset: usize, newlines: &[usize]) -> usize {
85    newlines.partition_point(|&nl| nl < offset) + 1
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    fn parse(content: &str) -> Vec<String> {
93        let parser = MarkdownParser { file_filter: None };
94        parser
95            .parse("test.md", content)
96            .links
97            .into_iter()
98            .map(|l| l.target)
99            .collect()
100    }
101
102    #[test]
103    fn records_link_line_numbers() {
104        let parser = MarkdownParser { file_filter: None };
105        let content = "# Title\n\nSee [setup](setup.md).\n\nThen [faq](faq.md).\n";
106        let links = parser.parse("t.md", content).links;
107        assert_eq!(links[0].target, "setup.md");
108        assert_eq!(links[0].line, Some(3));
109        assert_eq!(links[1].target, "faq.md");
110        assert_eq!(links[1].line, Some(5));
111    }
112
113    #[test]
114    fn extracts_inline_links() {
115        let links = parse("[setup](setup.md) and [faq](faq.md)");
116        assert_eq!(links.len(), 2);
117        assert_eq!(links[0], "setup.md");
118        assert_eq!(links[1], "faq.md");
119    }
120
121    #[test]
122    fn preserves_fragments() {
123        // Parser emits raw targets; graph builder strips fragments
124        let links = parse("[setup](setup.md#installation)");
125        assert_eq!(links.len(), 1);
126        assert_eq!(links[0], "setup.md#installation");
127    }
128
129    #[test]
130    fn emits_external_urls() {
131        let links = parse("[google](https://google.com) and [local](setup.md)");
132        assert_eq!(links.len(), 2);
133        assert_eq!(links[0], "https://google.com");
134        assert_eq!(links[1], "setup.md");
135    }
136
137    #[test]
138    fn emits_anchor_only() {
139        // Parser emits raw targets; graph builder filters anchor-only links
140        let links = parse("[section](#heading) and [local](setup.md)");
141        assert_eq!(links.len(), 2);
142        assert_eq!(links[0], "#heading");
143        assert_eq!(links[1], "setup.md");
144    }
145
146    #[test]
147    fn skips_email_links() {
148        let links = parse("Contact (<user@example.com>)");
149        assert!(links.is_empty());
150    }
151
152    #[test]
153    fn emits_mailto_links() {
154        // mailto: from inline syntax is emitted raw; graph builder filters
155        let links = parse("[email](mailto:user@example.com) and [local](setup.md)");
156        assert_eq!(links.len(), 2);
157        assert_eq!(links[0], "mailto:user@example.com");
158        assert_eq!(links[1], "setup.md");
159    }
160
161    #[test]
162    fn extracts_image_links() {
163        let links = parse("![diagram](assets/arch.png)");
164        assert_eq!(links.len(), 1);
165        assert_eq!(links[0], "assets/arch.png");
166    }
167
168    #[test]
169    fn extracts_reference_links() {
170        let links = parse("[setup][ref]\n\n[ref]: setup.md\n");
171        assert_eq!(links.len(), 1);
172        assert_eq!(links[0], "setup.md");
173    }
174
175    #[test]
176    fn no_filter_matches_everything() {
177        let parser = MarkdownParser { file_filter: None };
178        assert!(parser.matches("index.md"));
179        assert!(parser.matches("main.rs"));
180        assert!(parser.matches("docs/guide.md"));
181    }
182
183    #[test]
184    fn file_filter_restricts_matching() {
185        let mut builder = globset::GlobSetBuilder::new();
186        builder.add(globset::Glob::new("*.md").unwrap());
187        builder.add(globset::Glob::new("*.mdx").unwrap());
188        let parser = MarkdownParser {
189            file_filter: Some(builder.build().unwrap()),
190        };
191        assert!(parser.matches("index.md"));
192        assert!(parser.matches("page.mdx"));
193        assert!(!parser.matches("main.rs"));
194    }
195}