drft/parsers/
frontmatter.rs1use saphyr::{LoadableYamlNode, MarkedYaml, Scalar, YamlData};
2
3use super::{Link, ParseResult, Parser};
4
5fn is_link_candidate(value: &str) -> bool {
7 if crate::util::is_uri(value) {
9 return true;
10 }
11 if value.starts_with("./") || value.starts_with("../") || value.starts_with('/') {
14 return true;
15 }
16 if value.contains(' ') {
18 return false;
19 }
20 let basename = value.rsplit('/').next().unwrap_or(value);
23 if let Some(dot_pos) = basename.rfind('.') {
24 let ext = &basename[dot_pos + 1..];
25 !ext.is_empty()
26 && ext.len() <= 6
27 && ext.chars().all(|c| c.is_ascii_alphanumeric())
28 && !ext.chars().all(|c| c.is_ascii_digit())
29 } else {
30 false
31 }
32}
33
34fn strip_code(content: &str) -> String {
37 let mut result = String::with_capacity(content.len());
39 let mut in_code_block = false;
40 let mut fence_marker = "";
41
42 for line in content.lines() {
43 let trimmed = line.trim_start();
44 if !in_code_block {
45 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
46 in_code_block = true;
47 fence_marker = if trimmed.starts_with("```") {
48 "```"
49 } else {
50 "~~~"
51 };
52 result.push_str(&" ".repeat(line.len()));
53 } else {
54 result.push_str(line);
55 }
56 } else if trimmed.starts_with(fence_marker) && trimmed.trim() == fence_marker {
57 in_code_block = false;
58 result.push_str(&" ".repeat(line.len()));
59 } else {
60 result.push_str(&" ".repeat(line.len()));
61 }
62 result.push('\n');
63 }
64
65 let mut cleaned = String::with_capacity(result.len());
67 let chars: Vec<char> = result.chars().collect();
68 let mut i = 0;
69 while i < chars.len() {
70 if chars[i] == '`' {
71 let mut ticks = 0;
73 while i + ticks < chars.len() && chars[i + ticks] == '`' {
74 ticks += 1;
75 }
76 let after = i + ticks;
78 let mut found = None;
79 let mut j = after;
80 while j + ticks <= chars.len() {
81 if chars[j..j + ticks].iter().all(|c| *c == '`') {
82 found = Some(j);
83 break;
84 }
85 j += 1;
86 }
87 if let Some(close_start) = found {
88 let total = close_start + ticks - i;
90 for _ in 0..total {
91 cleaned.push(' ');
92 }
93 i += total;
94 } else {
95 cleaned.push(chars[i]);
97 i += 1;
98 }
99 } else {
100 cleaned.push(chars[i]);
101 i += 1;
102 }
103 }
104
105 cleaned
106}
107
108pub struct FrontmatterParser {
110 pub file_filter: Option<globset::GlobSet>,
112}
113
114impl Parser for FrontmatterParser {
115 fn matches(&self, path: &str) -> bool {
116 match &self.file_filter {
117 Some(set) => set.is_match(path),
118 None => true,
119 }
120 }
121
122 fn parse(&self, _path: &str, content: &str) -> ParseResult {
123 let stripped = strip_code(content);
125 let Some(yaml_str) = frontmatter_block(&stripped) else {
126 return ParseResult::default();
127 };
128 let Ok(docs) = MarkedYaml::load_from_str(yaml_str) else {
130 return ParseResult::default();
131 };
132 let Some(root) = docs.first() else {
133 return ParseResult::default();
134 };
135
136 let mut candidates = Vec::new();
137 collect_links(root, &mut candidates);
138 let links = candidates
139 .into_iter()
140 .filter(|(value, _)| is_link_candidate(value))
141 .map(|(target, line)| Link {
142 target,
143 line: Some(line),
144 })
145 .collect();
146
147 ParseResult {
148 links,
149 metadata: Some(to_json(root)),
150 }
151 }
152}
153
154fn frontmatter_block(stripped: &str) -> Option<&str> {
159 let rest = stripped.strip_prefix("---")?;
160 let end = rest.find("\n---")?;
161 let yaml_str = &rest[..end];
162 if yaml_str.trim().is_empty() {
163 return None;
164 }
165 Some(yaml_str)
166}
167
168fn collect_links(node: &MarkedYaml, out: &mut Vec<(String, usize)>) {
171 match &node.data {
172 YamlData::Value(Scalar::String(s)) => out.push((s.to_string(), node.span.start.line())),
173 YamlData::Sequence(items) => {
174 for item in items {
175 collect_links(item, out);
176 }
177 }
178 YamlData::Mapping(map) => {
179 for (_key, value) in map {
180 collect_links(value, out);
181 }
182 }
183 YamlData::Tagged(_, inner) => collect_links(inner, out),
184 _ => {}
185 }
186}
187
188fn to_json(node: &MarkedYaml) -> serde_json::Value {
191 use serde_json::Value as J;
192 match &node.data {
193 YamlData::Value(scalar) => scalar_to_json(scalar),
194 YamlData::Representation(raw, _, _) => J::String(raw.to_string()),
195 YamlData::Sequence(items) => J::Array(items.iter().map(to_json).collect()),
196 YamlData::Mapping(map) => {
197 let obj: serde_json::Map<String, J> = map
198 .iter()
199 .filter_map(|(k, v)| Some((json_key(k)?, to_json(v))))
200 .collect();
201 J::Object(obj)
202 }
203 YamlData::Tagged(_, inner) => to_json(inner),
204 YamlData::Alias(_) | YamlData::BadValue => J::Null,
205 }
206}
207
208fn json_key(node: &MarkedYaml) -> Option<String> {
211 match &node.data {
212 YamlData::Value(Scalar::String(s)) => Some(s.to_string()),
213 _ => serde_json::to_string(&to_json(node)).ok(),
214 }
215}
216
217fn scalar_to_json(scalar: &Scalar) -> serde_json::Value {
218 use serde_json::Value as J;
219 match scalar {
220 Scalar::Null => J::Null,
221 Scalar::Boolean(b) => J::Bool(*b),
222 Scalar::Integer(i) => J::Number((*i).into()),
223 Scalar::FloatingPoint(f) => serde_json::Number::from_f64(f.0)
224 .map(J::Number)
225 .unwrap_or(J::Null),
226 Scalar::String(s) => J::String(s.to_string()),
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 fn parse(content: &str) -> ParseResult {
235 let parser = FrontmatterParser { file_filter: None };
236 parser.parse("test.md", content)
237 }
238
239 #[test]
240 fn extracts_frontmatter_links() {
241 let content =
242 "---\nsources:\n - ../shared/glossary.md\n - ./prior-art.md\n---\n\n# Hello\n";
243 let result = parse(content);
244 assert_eq!(result.links.len(), 2);
245 assert_eq!(result.links[0].target, "../shared/glossary.md");
246 assert_eq!(result.links[1].target, "./prior-art.md");
247 }
248
249 #[test]
250 fn extracts_same_directory_links() {
251 let content = "---\nsources:\n - setup.md\n - config.rs\n---\n";
252 let result = parse(content);
253 assert_eq!(result.links.len(), 2);
254 assert_eq!(result.links[0].target, "setup.md");
255 assert_eq!(result.links[1].target, "config.rs");
256 }
257
258 #[test]
259 fn records_link_line_numbers() {
260 let content = "---\ntitle: Doc\nsources:\n - setup.md\n - other.md\n---\n";
262 let result = parse(content);
263 let setup = result
264 .links
265 .iter()
266 .find(|l| l.target == "setup.md")
267 .unwrap();
268 assert_eq!(setup.line, Some(4));
269 let other = result
270 .links
271 .iter()
272 .find(|l| l.target == "other.md")
273 .unwrap();
274 assert_eq!(other.line, Some(5));
275 }
276
277 #[test]
278 fn malformed_yaml_contributes_nothing() {
279 let result = parse("---\nsources: [a, b\n---\n");
282 assert!(result.links.is_empty());
283 assert!(result.metadata.is_none());
284 }
285
286 #[test]
287 fn frontmatter_skips_non_paths() {
288 let content = "---\ntitle: My Document\nversion: 1.0\ntags:\n - rust\n - cli\n---\n";
289 let result = parse(content);
290 assert!(result.links.is_empty());
291 }
292
293 #[test]
294 fn frontmatter_skips_code_block_examples() {
295 let content = "# Doc\n\n```markdown\n---\nsources:\n - ./fake.md\n---\n```\n";
296 let result = parse(content);
297 assert!(
298 result.links.is_empty(),
299 "frontmatter inside code block should be ignored"
300 );
301 assert!(result.metadata.is_none());
302 }
303
304 #[test]
305 fn extracts_metadata() {
306 let content =
307 "---\ntitle: My Doc\nstatus: draft\ntags:\n - rust\n - cli\n---\n\n# Hello\n";
308 let result = parse(content);
309 let meta = result.metadata.unwrap();
310 assert_eq!(meta["title"], "My Doc");
311 assert_eq!(meta["status"], "draft");
312 assert_eq!(meta["tags"], serde_json::json!(["rust", "cli"]));
313 }
314
315 #[test]
316 fn no_metadata_without_frontmatter() {
317 let result = parse("# Just a heading\n");
318 assert!(result.metadata.is_none());
319 }
320
321 #[test]
322 fn metadata_handles_nested_yaml() {
323 let content = "---\ntitle: Test\nauthor:\n name: Alice\n role: dev\n---\n";
324 let result = parse(content);
325 let meta = result.metadata.unwrap();
326 assert_eq!(meta["author"]["name"], "Alice");
327 assert_eq!(meta["author"]["role"], "dev");
328 }
329
330 #[test]
331 fn no_filter_matches_everything() {
332 let parser = FrontmatterParser { file_filter: None };
333 assert!(parser.matches("index.md"));
334 assert!(parser.matches("main.rs"));
335 }
336
337 #[test]
338 fn file_filter_restricts_matching() {
339 let mut builder = globset::GlobSetBuilder::new();
340 builder.add(globset::Glob::new("*.md").unwrap());
341 let parser = FrontmatterParser {
342 file_filter: Some(builder.build().unwrap()),
343 };
344 assert!(parser.matches("index.md"));
345 assert!(!parser.matches("main.rs"));
346 }
347
348 #[test]
349 fn extracts_uris() {
350 let content = "---\nsources:\n - https://example.com\n - ./local.md\n---\n";
351 let result = parse(content);
352 assert_eq!(result.links.len(), 2);
353 assert_eq!(result.links[0].target, "https://example.com");
354 assert_eq!(result.links[1].target, "./local.md");
355 }
356
357 #[test]
358 fn skips_prose_with_spaces() {
359 let content = "---\npurpose: configuration reference\nstatus: needs review\n---\n";
360 let result = parse(content);
361 assert!(result.links.is_empty());
362 }
363
364 #[test]
365 fn skips_abbreviations_and_versions() {
366 let content = "---\nnote: e.g.\nversion: v2.0\nauthor: Dr.\n---\n";
367 let result = parse(content);
368 assert!(result.links.is_empty());
369 }
370
371 #[test]
372 fn accepts_paths_without_prefix() {
373 let content = "---\nsources:\n - config.rs\n - docs/setup.md\n---\n";
374 let result = parse(content);
375 assert_eq!(result.links.len(), 2);
376 assert_eq!(result.links[0].target, "config.rs");
377 assert_eq!(result.links[1].target, "docs/setup.md");
378 }
379
380 #[test]
381 fn emits_absolute_paths() {
382 let content = "---\nsource: /usr/local/config.toml\n---\n";
383 let result = parse(content);
384 assert_eq!(result.links.len(), 1);
385 assert_eq!(result.links[0].target, "/usr/local/config.toml");
386 }
387
388 #[test]
389 fn yaml_list_values_not_parsed_as_uris() {
390 let content = "---\ntags:\n - name: foo bar bazz\n - status: draft\n---\n";
393 let result = parse(content);
394 assert!(result.links.is_empty());
395 }
396}