Skip to main content

miwa/
frontmatter.rs

1//! YAML front matter parsing for doc / req markdown files.
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct FrontMatter {
5    pub kind: Option<String>,
6    pub id: Option<String>,
7    pub title: Option<String>,
8    pub description: Option<String>,
9    pub body: String,
10}
11
12#[derive(Debug, thiserror::Error)]
13pub enum FrontMatterError {
14    #[error("invalid yaml: {0}")]
15    InvalidYaml(String),
16    #[error("unterminated front matter")]
17    Unterminated,
18}
19
20/// Splits `text` into the front matter block (the lines between the opening
21/// and closing `---`, without the fences) and the body, without parsing the
22/// YAML. Returns `Ok(None)` if the first line is not `---` (the whole text is
23/// the body), and `Err(Unterminated)` if no closing `---` line follows.
24/// A trailing CR on the fence lines is ignored.
25pub fn split(text: &str) -> Result<Option<(&str, &str)>, FrontMatterError> {
26    let first_end = text.find('\n').unwrap_or(text.len());
27    if text[..first_end].trim_end_matches('\r') != "---" {
28        return Ok(None);
29    }
30    if first_end == text.len() {
31        return Err(FrontMatterError::Unterminated);
32    }
33    let yaml_start = first_end + 1;
34    let mut pos = yaml_start;
35    loop {
36        let end = text[pos..]
37            .find('\n')
38            .map(|i| pos + i)
39            .unwrap_or(text.len());
40        if text[pos..end].trim_end_matches('\r') == "---" {
41            let yaml = if pos == yaml_start {
42                ""
43            } else {
44                &text[yaml_start..pos - 1]
45            };
46            let body = if end < text.len() {
47                &text[end + 1..]
48            } else {
49                ""
50            };
51            return Ok(Some((yaml, body)));
52        }
53        if end == text.len() {
54            return Err(FrontMatterError::Unterminated);
55        }
56        pos = end + 1;
57    }
58}
59
60/// Parse the front matter of `text`. Returns `Ok(None)` if `text` does not start
61/// with a `---` line.
62pub fn parse(text: &str) -> Result<Option<FrontMatter>, FrontMatterError> {
63    let Some((yaml_text, body)) = split(text)? else {
64        return Ok(None);
65    };
66    let body = body.to_string();
67
68    let map: std::collections::BTreeMap<String, serde_yaml_ng::Value> =
69        if yaml_text.trim().is_empty() {
70            std::collections::BTreeMap::new()
71        } else {
72            serde_yaml_ng::from_str(yaml_text)
73                .map_err(|e| FrontMatterError::InvalidYaml(e.to_string()))?
74        };
75
76    let kind = map.get("type").map(value_to_string);
77    let id = map.get("id").map(value_to_string);
78
79    let title = map.get("title").map(value_to_string);
80    let description = map.get("description").map(value_to_string);
81
82    Ok(Some(FrontMatter {
83        kind,
84        id,
85        title,
86        description,
87        body,
88    }))
89}
90
91fn value_to_string(value: &serde_yaml_ng::Value) -> String {
92    match value {
93        serde_yaml_ng::Value::String(s) => s.clone(),
94        serde_yaml_ng::Value::Null => String::new(),
95        other => {
96            // Numbers, bools, etc. serialize back to their scalar text form.
97            serde_yaml_ng::to_string(other)
98                .unwrap_or_default()
99                .trim()
100                .to_string()
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    // [FR-002]
110    #[test]
111    fn id_だけを変えても本文bodyは変わらない() {
112        let before = "---\ntype: Requirement\nid: FR-001\n---\n本文です。\n";
113        let after = "---\ntype: Requirement\nid: FR-002\n---\n本文です。\n";
114        let fm_before = parse(before).unwrap().unwrap();
115        let fm_after = parse(after).unwrap().unwrap();
116        assert_eq!(fm_before.body, fm_after.body);
117        assert_ne!(fm_before.id, fm_after.id);
118    }
119
120    // [FR-002]
121    #[test]
122    fn bodyにはフロントマターのブロックが含まれない() {
123        let text = "---\ntype: Requirement\nid: FR-001\n---\n本文です。\n";
124        let fm = parse(text).unwrap().unwrap();
125        assert!(!fm.body.contains("id: FR-001"));
126        assert!(!fm.body.contains("type: Requirement"));
127        assert!(fm.body.contains("本文です。"));
128    }
129
130    // [FR-005]
131    #[test]
132    fn ハイフン3つで始まらないテキストはnoneになる() {
133        let text = "# ただのMarkdown\n本文です。\n";
134        assert_eq!(parse(text).unwrap(), None);
135    }
136
137    // [FR-005]
138    #[test]
139    fn typeフィールドがない場合はkindがnoneになる() {
140        let text = "---\nid: FR-001\n---\n本文です。\n";
141        let fm = parse(text).unwrap().unwrap();
142        assert_eq!(fm.kind, None);
143    }
144
145    // [FR-102]
146    #[test]
147    fn splitは閉じ無しや空など境界の入力を正しく分ける() {
148        fn sp(t: &str) -> Result<Option<(&str, &str)>, ()> {
149            split(t).map_err(|_| ())
150        }
151        assert_eq!(sp("---"), Err(()));
152        assert_eq!(sp("---\n"), Err(()));
153        assert_eq!(sp("---\na: 1\n"), Err(()));
154        assert_eq!(sp("---\n---\n"), Ok(Some(("", ""))));
155        assert_eq!(sp("---\na: 1\n---"), Ok(Some(("a: 1", ""))));
156        assert_eq!(
157            sp("---\r\na: 1\r\n---\r\nbody\r\n"),
158            Ok(Some(("a: 1\r", "body\r\n")))
159        );
160        assert_eq!(
161            sp("---\na: 1\n---\nx\n---\ny\n"),
162            Ok(Some(("a: 1", "x\n---\ny\n")))
163        );
164        assert_eq!(sp("本文\n---\n"), Ok(None));
165    }
166
167    // [FR-102]
168    #[test]
169    fn parseのbodyはsplitの本文と一致する() {
170        for text in [
171            "---\n---\n",
172            "---\na: 1\n---",
173            "---\r\na: 1\r\n---\r\nbody\r\n",
174            "---\na: 1\n---\nx\n---\ny\n",
175        ] {
176            let (_, body) = split(text).unwrap().unwrap();
177            assert_eq!(parse(text).unwrap().unwrap().body, body, "{text:?}");
178        }
179    }
180}