1use super::{ParseResult, Parser};
2use pulldown_cmark::{Event, LinkType, Options, Parser as CmarkParser, Tag, TagEnd};
3
4pub struct MarkdownParser {
6 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, }
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 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 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 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("");
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}