1use super::{Link, 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<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 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
72fn 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
83fn 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 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 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 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("");
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}