1use 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 pub keys: Option<Vec<String>>,
118}
119
120impl Parser for FrontmatterParser {
121 fn matches(&self, path: &str) -> bool {
122 match &self.file_filter {
123 Some(set) => set.is_match(path),
124 None => true,
125 }
126 }
127
128 fn parse(&self, _path: &str, content: &str) -> ParseResult {
129 let stripped = strip_code(content);
131 let Some(yaml_str) = frontmatter_block(&stripped) else {
132 return ParseResult::default();
133 };
134 let Ok(docs) = MarkedYaml::load_from_str(yaml_str) else {
136 return ParseResult::default();
137 };
138 let Some(root) = docs.first() else {
139 return ParseResult::default();
140 };
141
142 let mut candidates = Vec::new();
143 match &self.keys {
144 Some(keys) => {
145 let wanted: std::collections::HashSet<&str> =
146 keys.iter().map(String::as_str).collect();
147 collect_scoped(root, &wanted, &mut candidates);
148 }
149 None => collect_links(root, &mut candidates),
150 }
151 let links = candidates
152 .into_iter()
153 .filter(|(value, _)| is_link_candidate(value))
154 .map(|(target, line)| Link {
155 target,
156 line: Some(line),
157 })
158 .collect();
159
160 ParseResult {
161 links,
162 metadata: Some(to_json(root)),
163 }
164 }
165}
166
167fn frontmatter_block(stripped: &str) -> Option<&str> {
172 let rest = stripped.strip_prefix("---")?;
173 let end = rest.find("\n---")?;
174 let yaml_str = &rest[..end];
175 if yaml_str.trim().is_empty() {
176 return None;
177 }
178 Some(yaml_str)
179}
180
181fn collect_links(node: &MarkedYaml, out: &mut Vec<(String, usize)>) {
184 match &node.data {
185 YamlData::Value(Scalar::String(s)) => out.push((s.to_string(), node.span.start.line())),
186 YamlData::Sequence(items) => {
187 for item in items {
188 collect_links(item, out);
189 }
190 }
191 YamlData::Mapping(map) => {
192 for (_key, value) in map {
193 collect_links(value, out);
194 }
195 }
196 YamlData::Tagged(_, inner) => collect_links(inner, out),
197 _ => {}
198 }
199}
200
201fn collect_scoped(
207 node: &MarkedYaml,
208 keys: &std::collections::HashSet<&str>,
209 out: &mut Vec<(String, usize)>,
210) {
211 match &node.data {
212 YamlData::Sequence(items) => {
213 for item in items {
214 collect_scoped(item, keys, out);
215 }
216 }
217 YamlData::Mapping(map) => {
218 for (key, value) in map {
219 match &key.data {
220 YamlData::Value(Scalar::String(k)) if keys.contains(k.as_ref()) => {
221 collect_links(value, out)
222 }
223 _ => collect_scoped(value, keys, out),
224 }
225 }
226 }
227 YamlData::Tagged(_, inner) => collect_scoped(inner, keys, out),
228 _ => {}
229 }
230}
231
232fn to_json(node: &MarkedYaml) -> serde_json::Value {
235 use serde_json::Value as J;
236 match &node.data {
237 YamlData::Value(scalar) => scalar_to_json(scalar),
238 YamlData::Representation(raw, _, _) => J::String(raw.to_string()),
239 YamlData::Sequence(items) => J::Array(items.iter().map(to_json).collect()),
240 YamlData::Mapping(map) => {
241 let obj: serde_json::Map<String, J> = map
242 .iter()
243 .filter_map(|(k, v)| Some((json_key(k)?, to_json(v))))
244 .collect();
245 J::Object(obj)
246 }
247 YamlData::Tagged(_, inner) => to_json(inner),
248 YamlData::Alias(_) | YamlData::BadValue => J::Null,
249 }
250}
251
252fn json_key(node: &MarkedYaml) -> Option<String> {
255 match &node.data {
256 YamlData::Value(Scalar::String(s)) => Some(s.to_string()),
257 _ => serde_json::to_string(&to_json(node)).ok(),
258 }
259}
260
261fn scalar_to_json(scalar: &Scalar) -> serde_json::Value {
262 use serde_json::Value as J;
263 match scalar {
264 Scalar::Null => J::Null,
265 Scalar::Boolean(b) => J::Bool(*b),
266 Scalar::Integer(i) => J::Number((*i).into()),
267 Scalar::FloatingPoint(f) => serde_json::Number::from_f64(f.0)
268 .map(J::Number)
269 .unwrap_or(J::Null),
270 Scalar::String(s) => J::String(s.to_string()),
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 fn parse(content: &str) -> ParseResult {
279 let parser = FrontmatterParser {
280 file_filter: None,
281 keys: None,
282 };
283 parser.parse("test.md", content)
284 }
285
286 #[test]
287 fn extracts_frontmatter_links() {
288 let content =
289 "---\nsources:\n - ../shared/glossary.md\n - ./prior-art.md\n---\n\n# Hello\n";
290 let result = parse(content);
291 assert_eq!(result.links.len(), 2);
292 assert_eq!(result.links[0].target, "../shared/glossary.md");
293 assert_eq!(result.links[1].target, "./prior-art.md");
294 }
295
296 #[test]
297 fn extracts_same_directory_links() {
298 let content = "---\nsources:\n - setup.md\n - config.rs\n---\n";
299 let result = parse(content);
300 assert_eq!(result.links.len(), 2);
301 assert_eq!(result.links[0].target, "setup.md");
302 assert_eq!(result.links[1].target, "config.rs");
303 }
304
305 #[test]
306 fn records_link_line_numbers() {
307 let content = "---\ntitle: Doc\nsources:\n - setup.md\n - other.md\n---\n";
309 let result = parse(content);
310 let setup = result
311 .links
312 .iter()
313 .find(|l| l.target == "setup.md")
314 .unwrap();
315 assert_eq!(setup.line, Some(4));
316 let other = result
317 .links
318 .iter()
319 .find(|l| l.target == "other.md")
320 .unwrap();
321 assert_eq!(other.line, Some(5));
322 }
323
324 #[test]
325 fn malformed_yaml_contributes_nothing() {
326 let result = parse("---\nsources: [a, b\n---\n");
329 assert!(result.links.is_empty());
330 assert!(result.metadata.is_none());
331 }
332
333 fn scoped(content: &str, keys: &[&str]) -> Vec<String> {
335 let parser = FrontmatterParser {
336 file_filter: None,
337 keys: Some(keys.iter().map(|k| k.to_string()).collect()),
338 };
339 parser
340 .parse("doc.md", content)
341 .links
342 .into_iter()
343 .map(|l| l.target)
344 .collect()
345 }
346
347 #[test]
348 fn keys_scope_excludes_other_keys() {
349 let content = "---\nsources:\n - ../src/lib.rs\nroute: /customers\npaths:\n - \"api/openapi.yaml\"\n---\n";
352 assert_eq!(scoped(content, &["sources"]), vec!["../src/lib.rs"]);
353 }
354
355 #[test]
356 fn keys_scope_takes_whole_subtree() {
357 let content = "---\nsources:\n primary:\n - ../a.rs\n secondary: ../b.rs\n---\n";
360 let mut got = scoped(content, &["sources"]);
361 got.sort();
362 assert_eq!(got, vec!["../a.rs", "../b.rs"]);
363 }
364
365 #[test]
366 fn keys_scope_finds_nested_key() {
367 let content = "---\nmeta:\n sources:\n - ../a.rs\n---\n";
370 assert_eq!(scoped(content, &["sources"]), vec!["../a.rs"]);
371 }
372
373 #[test]
374 fn keys_scope_keeps_line_numbers() {
375 let content = "---\ntitle: T\nsources:\n - ../a.rs\n---\n";
376 let parser = FrontmatterParser {
377 file_filter: None,
378 keys: Some(vec!["sources".to_string()]),
379 };
380 let links = parser.parse("doc.md", content).links;
381 assert_eq!(links[0].line, Some(4));
382 }
383
384 #[test]
385 fn keys_scope_still_shape_filters() {
386 let content = "---\nsources:\n - ../a.rs\n - not a path at all\n---\n";
388 assert_eq!(scoped(content, &["sources"]), vec!["../a.rs"]);
389 }
390
391 #[test]
392 fn keys_scope_leaves_metadata_whole() {
393 let content = "---\ntitle: T\nroute: /customers\nsources:\n - ../a.rs\n---\n";
395 let parser = FrontmatterParser {
396 file_filter: None,
397 keys: Some(vec!["sources".to_string()]),
398 };
399 let meta = parser.parse("doc.md", content).metadata.unwrap();
400 assert_eq!(meta["title"], "T");
401 assert_eq!(meta["route"], "/customers");
402 }
403
404 #[test]
405 fn no_keys_scope_is_shape_detection() {
406 let content = "---\nroute: /customers\nsources:\n - ../a.rs\n---\n";
408 let mut got: Vec<String> = parse(content).links.into_iter().map(|l| l.target).collect();
409 got.sort();
410 assert_eq!(got, vec!["../a.rs", "/customers"]);
411 }
412
413 #[test]
414 fn frontmatter_skips_non_paths() {
415 let content = "---\ntitle: My Document\nversion: 1.0\ntags:\n - rust\n - cli\n---\n";
416 let result = parse(content);
417 assert!(result.links.is_empty());
418 }
419
420 #[test]
421 fn frontmatter_skips_code_block_examples() {
422 let content = "# Doc\n\n```markdown\n---\nsources:\n - ./fake.md\n---\n```\n";
423 let result = parse(content);
424 assert!(
425 result.links.is_empty(),
426 "frontmatter inside code block should be ignored"
427 );
428 assert!(result.metadata.is_none());
429 }
430
431 #[test]
432 fn extracts_metadata() {
433 let content =
434 "---\ntitle: My Doc\nstatus: draft\ntags:\n - rust\n - cli\n---\n\n# Hello\n";
435 let result = parse(content);
436 let meta = result.metadata.unwrap();
437 assert_eq!(meta["title"], "My Doc");
438 assert_eq!(meta["status"], "draft");
439 assert_eq!(meta["tags"], serde_json::json!(["rust", "cli"]));
440 }
441
442 #[test]
443 fn no_metadata_without_frontmatter() {
444 let result = parse("# Just a heading\n");
445 assert!(result.metadata.is_none());
446 }
447
448 #[test]
449 fn metadata_handles_nested_yaml() {
450 let content = "---\ntitle: Test\nauthor:\n name: Alice\n role: dev\n---\n";
451 let result = parse(content);
452 let meta = result.metadata.unwrap();
453 assert_eq!(meta["author"]["name"], "Alice");
454 assert_eq!(meta["author"]["role"], "dev");
455 }
456
457 #[test]
458 fn no_filter_matches_everything() {
459 let parser = FrontmatterParser {
460 file_filter: None,
461 keys: None,
462 };
463 assert!(parser.matches("index.md"));
464 assert!(parser.matches("main.rs"));
465 }
466
467 #[test]
468 fn file_filter_restricts_matching() {
469 let mut builder = globset::GlobSetBuilder::new();
470 builder.add(globset::Glob::new("*.md").unwrap());
471 let parser = FrontmatterParser {
472 file_filter: Some(builder.build().unwrap()),
473 keys: None,
474 };
475 assert!(parser.matches("index.md"));
476 assert!(!parser.matches("main.rs"));
477 }
478
479 #[test]
480 fn extracts_uris() {
481 let content = "---\nsources:\n - https://example.com\n - ./local.md\n---\n";
482 let result = parse(content);
483 assert_eq!(result.links.len(), 2);
484 assert_eq!(result.links[0].target, "https://example.com");
485 assert_eq!(result.links[1].target, "./local.md");
486 }
487
488 #[test]
489 fn skips_prose_with_spaces() {
490 let content = "---\npurpose: configuration reference\nstatus: needs review\n---\n";
491 let result = parse(content);
492 assert!(result.links.is_empty());
493 }
494
495 #[test]
496 fn skips_abbreviations_and_versions() {
497 let content = "---\nnote: e.g.\nversion: v2.0\nauthor: Dr.\n---\n";
498 let result = parse(content);
499 assert!(result.links.is_empty());
500 }
501
502 #[test]
503 fn accepts_paths_without_prefix() {
504 let content = "---\nsources:\n - config.rs\n - docs/setup.md\n---\n";
505 let result = parse(content);
506 assert_eq!(result.links.len(), 2);
507 assert_eq!(result.links[0].target, "config.rs");
508 assert_eq!(result.links[1].target, "docs/setup.md");
509 }
510
511 #[test]
512 fn emits_absolute_paths() {
513 let content = "---\nsource: /usr/local/config.toml\n---\n";
514 let result = parse(content);
515 assert_eq!(result.links.len(), 1);
516 assert_eq!(result.links[0].target, "/usr/local/config.toml");
517 }
518
519 #[test]
520 fn yaml_list_values_not_parsed_as_uris() {
521 let content = "---\ntags:\n - name: foo bar bazz\n - status: draft\n---\n";
524 let result = parse(content);
525 assert!(result.links.is_empty());
526 }
527}