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}
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        // `stripped` is owned and outlives the borrowed marked AST below.
124        let stripped = strip_code(content);
125        let Some(yaml_str) = frontmatter_block(&stripped) else {
126            return ParseResult::default();
127        };
128        // Malformed YAML contributes nothing — drft is not a YAML linter.
129        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
154/// Extract the YAML frontmatter block from code-stripped content — the text
155/// between the opening `---` and the next `\n---`, or `None` when there is no
156/// well-formed block. The slice keeps the newline that follows the opening
157/// fence, so a node's line within the block equals its line within the file.
158fn 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
168/// Collect string leaf *values* (not keys) with their 1-based source line — the
169/// frontmatter link candidates. Mirrors the metadata walk but keeps only strings.
170fn 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
188/// Convert a marked YAML node to `serde_json::Value` for the `@frontmatter`
189/// metadata namespace.
190fn 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
208/// Render a mapping key as a JSON object key: a string scalar verbatim, anything
209/// else as its JSON serialization (matching the prior behavior).
210fn 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        // Lines are 1-based and file-accurate: the opening `---` is line 1.
261        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        // Invalid YAML yields no links and no metadata — drft is not a linter,
280        // and there is no stderr warning (the `eprintln` is gone).
281        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        // Regression: `- name: foo bar bazz` was split on `- ` to get
391        // `name: foo bar bazz`, which the old is_uri matched as scheme `name:`
392        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}