Skip to main content

drft/parsers/
frontmatter.rs

1use saphyr::{LoadableYamlNode, MarkedYaml, Scalar, YamlData};
2
3use super::{Link, ParseResult, Parser};
4
5/// Check whether a frontmatter value looks like a link target (file path or URI).
6fn is_link_candidate(value: &str) -> bool {
7    // URIs are always candidates — graph builder classifies them as External(Remote)
8    if crate::util::is_uri(value) {
9        return true;
10    }
11    // Explicit path prefixes are always candidates.
12    // The graph builder gates all filesystem access for out-of-root targets.
13    if value.starts_with("./") || value.starts_with("../") || value.starts_with('/') {
14        return true;
15    }
16    // Prose contains spaces — file paths don't
17    if value.contains(' ') {
18        return false;
19    }
20    // Must have a plausible file extension: dot followed by 1-6 alphanumeric
21    // chars that aren't all digits (rejects v2.0, e.g., Dr.)
22    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
34/// Strip all code content (fenced blocks and inline backtick spans),
35/// replacing with spaces to preserve offsets.
36fn strip_code(content: &str) -> String {
37    // First strip fenced code blocks (``` and ~~~)
38    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    // Then strip inline code spans (single and double backticks)
66    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            // Count opening backticks
72            let mut ticks = 0;
73            while i + ticks < chars.len() && chars[i + ticks] == '`' {
74                ticks += 1;
75            }
76            // Find matching closing backticks in the char array
77            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                // Replace entire span (backticks + content + backticks) with spaces
89                let total = close_start + ticks - i;
90                for _ in 0..total {
91                    cleaned.push(' ');
92                }
93                i += total;
94            } else {
95                // No closing — keep the backtick as-is
96                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
108/// Built-in frontmatter parser. Extracts YAML frontmatter as links and metadata.
109pub struct FrontmatterParser {
110    /// File routing filter. None = receives all File nodes.
111    pub file_filter: Option<globset::GlobSet>,
112    /// Keys whose values yield edges. `None` falls back to shape detection over
113    /// the whole block, which cannot tell a derivation (`sources:`) from a value
114    /// that merely looks like a path (`route: /customers`). Naming the keys lets
115    /// the config say what the graph tracks. Scopes edges only — metadata always
116    /// captures the entire block.
117    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        // `stripped` is owned and outlives the borrowed marked AST below.
130        let stripped = strip_code(content);
131        let Some(yaml_str) = frontmatter_block(&stripped) else {
132            return ParseResult::default();
133        };
134        // Malformed YAML contributes nothing — drft is not a YAML linter.
135        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
167/// Extract the YAML frontmatter block from code-stripped content — the text
168/// between the opening `---` and the next `\n---`, or `None` when there is no
169/// well-formed block. The slice keeps the newline that follows the opening
170/// fence, so a node's line within the block equals its line within the file.
171fn 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
181/// Collect string leaf *values* (not keys) with their 1-based source line — the
182/// frontmatter link candidates. Mirrors the metadata walk but keeps only strings.
183fn 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
201/// Collect scalars reachable only through one of `keys`. A matched key hands its
202/// whole subtree to `collect_links`, so a nested map or list under `sources:`
203/// still yields every path beneath it. Unmatched keys are still descended into,
204/// so a key nested under an unrelated one is found — the key is what scopes the
205/// walk, not its depth. A scalar reached under no matched key yields nothing.
206fn 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
232/// Convert a marked YAML node to `serde_json::Value` for the `@frontmatter`
233/// metadata namespace.
234fn 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
252/// Render a mapping key as a JSON object key: a string scalar verbatim, anything
253/// else as its JSON serialization (matching the prior behavior).
254fn 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        // Lines are 1-based and file-accurate: the opening `---` is line 1.
308        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        // Invalid YAML yields no links and no metadata — drft is not a linter,
327        // and there is no stderr warning (the `eprintln` is gone).
328        let result = parse("---\nsources: [a, b\n---\n");
329        assert!(result.links.is_empty());
330        assert!(result.metadata.is_none());
331    }
332
333    /// Parse with `keys` scoping, returning the edge targets.
334    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        // The two real collisions from #73: an API route and a rule's glob scope,
350        // both path-shaped, neither a derivation.
351        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        // A matched key hands its entire subtree over, so nesting under it still
358        // yields every path beneath.
359        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        // The key scopes the walk, not its depth — `sources` under an unrelated
368        // parent is still found.
369        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        // Scoping picks the key; `is_link_candidate` still rejects prose under it.
387        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        // `keys` scopes edges only — the metadata namespace keeps the full block.
394        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        // The default is unchanged: every path-shaped value is a candidate.
407        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        // Regression: `- name: foo bar bazz` was split on `- ` to get
522        // `name: foo bar bazz`, which the old is_uri matched as scheme `name:`
523        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}