Skip to main content

drft/parsers/
markdown.rs

1use super::{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<String> {
27    let mut links = Vec::new();
28    let mut options = Options::empty();
29    options.insert(Options::ENABLE_STRIKETHROUGH);
30    let parser = CmarkParser::new_ext(content, options);
31
32    let mut in_code_block = false;
33
34    for event in parser {
35        match event {
36            Event::Start(Tag::CodeBlock(_)) => in_code_block = true,
37            Event::End(TagEnd::CodeBlock) => in_code_block = false,
38            Event::Start(Tag::Link {
39                link_type,
40                dest_url,
41                ..
42            }) if !in_code_block => {
43                if link_type == LinkType::Email {
44                    continue;
45                }
46                let link = dest_url.trim();
47                if !link.is_empty() {
48                    links.push(link.to_string());
49                }
50            }
51            Event::Start(Tag::Image { dest_url, .. }) if !in_code_block => {
52                let link = dest_url.trim();
53                if !link.is_empty() {
54                    links.push(link.to_string());
55                }
56            }
57            _ => {}
58        }
59    }
60
61    links
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    fn parse(content: &str) -> Vec<String> {
69        let parser = MarkdownParser { file_filter: None };
70        parser.parse("test.md", content).links
71    }
72
73    #[test]
74    fn extracts_inline_links() {
75        let links = parse("[setup](setup.md) and [faq](faq.md)");
76        assert_eq!(links.len(), 2);
77        assert_eq!(links[0], "setup.md");
78        assert_eq!(links[1], "faq.md");
79    }
80
81    #[test]
82    fn preserves_fragments() {
83        // Parser emits raw targets; graph builder strips fragments
84        let links = parse("[setup](setup.md#installation)");
85        assert_eq!(links.len(), 1);
86        assert_eq!(links[0], "setup.md#installation");
87    }
88
89    #[test]
90    fn emits_external_urls() {
91        let links = parse("[google](https://google.com) and [local](setup.md)");
92        assert_eq!(links.len(), 2);
93        assert_eq!(links[0], "https://google.com");
94        assert_eq!(links[1], "setup.md");
95    }
96
97    #[test]
98    fn emits_anchor_only() {
99        // Parser emits raw targets; graph builder filters anchor-only links
100        let links = parse("[section](#heading) and [local](setup.md)");
101        assert_eq!(links.len(), 2);
102        assert_eq!(links[0], "#heading");
103        assert_eq!(links[1], "setup.md");
104    }
105
106    #[test]
107    fn skips_email_links() {
108        let links = parse("Contact (<user@example.com>)");
109        assert!(links.is_empty());
110    }
111
112    #[test]
113    fn emits_mailto_links() {
114        // mailto: from inline syntax is emitted raw; graph builder filters
115        let links = parse("[email](mailto:user@example.com) and [local](setup.md)");
116        assert_eq!(links.len(), 2);
117        assert_eq!(links[0], "mailto:user@example.com");
118        assert_eq!(links[1], "setup.md");
119    }
120
121    #[test]
122    fn extracts_image_links() {
123        let links = parse("![diagram](assets/arch.png)");
124        assert_eq!(links.len(), 1);
125        assert_eq!(links[0], "assets/arch.png");
126    }
127
128    #[test]
129    fn extracts_reference_links() {
130        let links = parse("[setup][ref]\n\n[ref]: setup.md\n");
131        assert_eq!(links.len(), 1);
132        assert_eq!(links[0], "setup.md");
133    }
134
135    #[test]
136    fn no_filter_matches_everything() {
137        let parser = MarkdownParser { file_filter: None };
138        assert!(parser.matches("index.md"));
139        assert!(parser.matches("main.rs"));
140        assert!(parser.matches("docs/guide.md"));
141    }
142
143    #[test]
144    fn file_filter_restricts_matching() {
145        let mut builder = globset::GlobSetBuilder::new();
146        builder.add(globset::Glob::new("*.md").unwrap());
147        builder.add(globset::Glob::new("*.mdx").unwrap());
148        let parser = MarkdownParser {
149            file_filter: Some(builder.build().unwrap()),
150        };
151        assert!(parser.matches("index.md"));
152        assert!(parser.matches("page.mdx"));
153        assert!(!parser.matches("main.rs"));
154    }
155}