Skip to main content

docgen_core/
frontmatter.rs

1use serde_yml::Value;
2
3/// Result of splitting frontmatter from a markdown document.
4#[derive(Debug, Clone, PartialEq)]
5pub struct Parsed {
6    pub frontmatter: Value,
7    pub body: String,
8}
9
10/// Split an optional leading `---`-delimited YAML frontmatter block from the body.
11/// On malformed YAML, frontmatter is `Value::Null` and the whole input is the body.
12/// Handles both LF and CRLF line endings, empty frontmatter blocks, and requires the
13/// closing fence to be a line containing exactly `---` (ignoring trailing whitespace).
14pub fn parse_frontmatter(raw: &str) -> Parsed {
15    parse_frontmatter_checked(raw).0
16}
17
18/// Like [`parse_frontmatter`], but also reports the YAML error message when a
19/// frontmatter block *exists* but fails to parse (the `Parsed` still carries
20/// `Value::Null`, so behavior is identical for callers that ignore the error).
21/// An absent or empty frontmatter block is not an error.
22pub fn parse_frontmatter_checked(raw: &str) -> (Parsed, Option<String>) {
23    let input = raw.strip_prefix('\u{feff}').unwrap_or(raw);
24
25    // Match an opening `---` fence followed by a line break (LF or CRLF).
26    let after_open = input
27        .strip_prefix("---\n")
28        .or_else(|| input.strip_prefix("---\r\n"));
29
30    if let Some(rest) = after_open {
31        // Walk line by line looking for a closing fence that is exactly `---`.
32        let mut offset = 0usize;
33        for line in rest.split_inclusive('\n') {
34            let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
35            if trimmed.trim_end() == "---" {
36                let yaml = &rest[..offset];
37                let after = &rest[offset + line.len()..];
38                let (frontmatter, err) = parse_yaml(yaml);
39                return (
40                    Parsed {
41                        frontmatter,
42                        body: after.to_string(),
43                    },
44                    err,
45                );
46            }
47            offset += line.len();
48        }
49        // Also handle a closing fence with no trailing newline (EOF).
50        let last = &rest[offset..];
51        if last.trim_end_matches('\r').trim_end() == "---" {
52            let yaml = &rest[..offset];
53            let (frontmatter, err) = parse_yaml(yaml);
54            return (
55                Parsed {
56                    frontmatter,
57                    body: String::new(),
58                },
59                err,
60            );
61        }
62    }
63
64    (
65        Parsed {
66            frontmatter: Value::Null,
67            body: input.to_string(),
68        },
69        None,
70    )
71}
72
73/// Parse a frontmatter YAML block. On failure: `(Null, Some(error message))`.
74/// An empty/whitespace-only block is `Null` with no error.
75fn parse_yaml(yaml: &str) -> (Value, Option<String>) {
76    if yaml.trim().is_empty() {
77        return (Value::Null, None);
78    }
79    match serde_yml::from_str(yaml) {
80        Ok(v) => (v, None),
81        Err(e) => (Value::Null, Some(e.to_string())),
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn parses_yaml_frontmatter_and_body() {
91        let raw = "---\ntitle: Hello\n---\n# Body\n";
92        let parsed = parse_frontmatter(raw);
93        assert_eq!(parsed.frontmatter["title"].as_str(), Some("Hello"));
94        assert_eq!(parsed.body, "# Body\n");
95    }
96
97    #[test]
98    fn no_frontmatter_returns_null_and_full_body() {
99        let raw = "# Just body\n";
100        let parsed = parse_frontmatter(raw);
101        assert!(parsed.frontmatter.is_null());
102        assert_eq!(parsed.body, "# Just body\n");
103    }
104
105    #[test]
106    fn parses_crlf_frontmatter() {
107        let raw = "---\r\ntitle: X\r\n---\r\nbody\r\n";
108        let parsed = parse_frontmatter(raw);
109        assert_eq!(parsed.frontmatter["title"].as_str(), Some("X"));
110        assert_eq!(parsed.body, "body\r\n");
111    }
112
113    #[test]
114    fn parses_empty_frontmatter_block() {
115        let raw = "---\n---\nbody\n";
116        let parsed = parse_frontmatter(raw);
117        assert!(parsed.frontmatter.is_null());
118        assert_eq!(parsed.body, "body\n");
119    }
120
121    #[test]
122    fn longer_dash_run_is_not_a_closing_fence() {
123        // A `----` line is not a bare `---` fence; it must not be treated as the close,
124        // and no stray dash should leak into the body.
125        let raw = "---\ntitle: X\n----\nbody\n";
126        let parsed = parse_frontmatter(raw);
127        // No valid closing fence -> whole input is body, frontmatter null.
128        assert!(parsed.frontmatter.is_null());
129        assert_eq!(parsed.body, raw);
130    }
131
132    #[test]
133    fn malformed_yaml_falls_back_to_null_with_body() {
134        let raw = "---\n: not: valid: yaml\n---\nbody\n";
135        let parsed = parse_frontmatter(raw);
136        assert!(parsed.frontmatter.is_null());
137        assert_eq!(parsed.body, "body\n");
138    }
139
140    #[test]
141    fn unterminated_block_returns_full_input_as_body() {
142        let raw = "---\ntitle: X\n";
143        let parsed = parse_frontmatter(raw);
144        assert!(parsed.frontmatter.is_null());
145        assert_eq!(parsed.body, raw);
146    }
147
148    #[test]
149    fn checked_reports_no_error_for_valid_frontmatter() {
150        let (parsed, err) = parse_frontmatter_checked("---\ntitle: Hello\n---\nbody\n");
151        assert_eq!(parsed.frontmatter["title"].as_str(), Some("Hello"));
152        assert!(err.is_none());
153    }
154
155    #[test]
156    fn checked_reports_yaml_error_for_malformed_frontmatter() {
157        let (parsed, err) = parse_frontmatter_checked("---\n: not: valid: yaml\n---\nbody\n");
158        assert!(parsed.frontmatter.is_null());
159        assert_eq!(parsed.body, "body\n");
160        assert!(
161            err.is_some(),
162            "malformed YAML must surface an error message"
163        );
164        assert!(!err.unwrap().is_empty());
165    }
166
167    #[test]
168    fn checked_reports_no_error_when_frontmatter_absent_or_empty() {
169        let (parsed, err) = parse_frontmatter_checked("# Just body\n");
170        assert!(parsed.frontmatter.is_null());
171        assert!(err.is_none());
172
173        let (parsed, err) = parse_frontmatter_checked("---\n---\nbody\n");
174        assert!(parsed.frontmatter.is_null());
175        assert!(err.is_none());
176    }
177
178    #[test]
179    fn strips_leading_bom() {
180        let raw = "\u{feff}---\ntitle: X\n---\nbody\n";
181        let parsed = parse_frontmatter(raw);
182        assert_eq!(parsed.frontmatter["title"].as_str(), Some("X"));
183        assert_eq!(parsed.body, "body\n");
184    }
185}