Skip to main content

quillmark_core/document/
frontmatter.rs

1//! Ordered frontmatter representation.
2//!
3//! A [`Frontmatter`] is the typed representation of a YAML fence body with the
4//! sentinel key already stripped. Unlike a plain `IndexMap`, it preserves
5//! YAML comments as first-class ordered items and carries a `fill: bool`
6//! marker on each field (for `!fill` tags).
7//!
8//! It provides both ordered iteration (over [`FrontmatterItem`]s) and
9//! map-keyed access (`get`, `contains_key`, `insert`, `remove`) so existing
10//! callers that treat the frontmatter as a map keep working. The map-keyed
11//! accessors walk the item vec; field count is small enough that a linear
12//! scan is fine.
13
14use indexmap::IndexMap;
15use serde::{Deserialize, Serialize};
16
17use super::prescan::NestedComment;
18use crate::value::QuillValue;
19
20/// One entry in a [`Frontmatter`]: a field or a comment line.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(tag = "kind", rename_all = "lowercase")]
23pub enum FrontmatterItem {
24    /// A YAML field (key-value pair), optionally tagged `!fill`.
25    Field {
26        key: String,
27        value: QuillValue,
28        /// `true` when the field was written as `key: !fill <value>` or
29        /// `key: !fill` in source.
30        #[serde(default)]
31        fill: bool,
32    },
33    /// An own-line YAML comment. Text excludes the leading `#` and one
34    /// optional space.
35    Comment { text: String },
36}
37
38impl FrontmatterItem {
39    /// Build a plain (non-fill) field entry.
40    pub fn field(key: impl Into<String>, value: QuillValue) -> Self {
41        FrontmatterItem::Field {
42            key: key.into(),
43            value,
44            fill: false,
45        }
46    }
47
48    /// Build a comment item.
49    pub fn comment(text: impl Into<String>) -> Self {
50        FrontmatterItem::Comment { text: text.into() }
51    }
52}
53
54/// Ordered list of frontmatter items with map-keyed convenience accessors.
55///
56/// Top-level YAML comments live in `items` as [`FrontmatterItem::Comment`].
57/// Comments inside nested mappings/sequences live in `nested_comments`,
58/// keyed by structural path; the emitter re-injects them at the matching
59/// position when serialising the value tree.
60#[derive(Debug, Clone, PartialEq, Default)]
61pub struct Frontmatter {
62    items: Vec<FrontmatterItem>,
63    nested_comments: Vec<NestedComment>,
64}
65
66impl Frontmatter {
67    /// Create an empty `Frontmatter`.
68    pub fn new() -> Self {
69        Self {
70            items: Vec::new(),
71            nested_comments: Vec::new(),
72        }
73    }
74
75    /// Build from an `IndexMap` of fields (no comments, no fill markers).
76    pub fn from_index_map(map: IndexMap<String, QuillValue>) -> Self {
77        let items = map
78            .into_iter()
79            .map(|(key, value)| FrontmatterItem::Field {
80                key,
81                value,
82                fill: false,
83            })
84            .collect();
85        Self {
86            items,
87            nested_comments: Vec::new(),
88        }
89    }
90
91    /// Build from a pre-computed item list.
92    pub fn from_items(items: Vec<FrontmatterItem>) -> Self {
93        Self {
94            items,
95            nested_comments: Vec::new(),
96        }
97    }
98
99    /// Build from a pre-computed item list and a set of nested comments.
100    pub fn from_items_with_nested(
101        items: Vec<FrontmatterItem>,
102        nested_comments: Vec<NestedComment>,
103    ) -> Self {
104        Self {
105            items,
106            nested_comments,
107        }
108    }
109
110    /// Comments captured inside nested mappings/sequences. The emitter
111    /// re-injects these at the matching position when serialising the
112    /// value tree.
113    pub fn nested_comments(&self) -> &[NestedComment] {
114        &self.nested_comments
115    }
116
117    /// Ordered iterator over raw items (including comments).
118    pub fn items(&self) -> &[FrontmatterItem] {
119        &self.items
120    }
121
122    /// Iterator over `(key, value)` pairs, skipping comments. Preserves order.
123    pub fn iter(&self) -> impl Iterator<Item = (&String, &QuillValue)> + '_ {
124        self.items.iter().filter_map(|item| match item {
125            FrontmatterItem::Field { key, value, .. } => Some((key, value)),
126            FrontmatterItem::Comment { .. } => None,
127        })
128    }
129
130    /// Iterator over field keys, skipping comments. Preserves order.
131    pub fn keys(&self) -> impl Iterator<Item = &String> + '_ {
132        self.items.iter().filter_map(|item| match item {
133            FrontmatterItem::Field { key, .. } => Some(key),
134            FrontmatterItem::Comment { .. } => None,
135        })
136    }
137
138    /// Number of *field* items (comments excluded).
139    pub fn len(&self) -> usize {
140        self.items
141            .iter()
142            .filter(|item| matches!(item, FrontmatterItem::Field { .. }))
143            .count()
144    }
145
146    /// Returns `true` if there are no field items (comments are ignored).
147    pub fn is_empty(&self) -> bool {
148        self.len() == 0
149    }
150
151    /// Look up a field value by key.
152    pub fn get(&self, key: &str) -> Option<&QuillValue> {
153        self.items.iter().find_map(|item| match item {
154            FrontmatterItem::Field { key: k, value, .. } if k == key => Some(value),
155            _ => None,
156        })
157    }
158
159    /// Returns `true` if a field with this key is present.
160    pub fn contains_key(&self, key: &str) -> bool {
161        self.get(key).is_some()
162    }
163
164    /// Insert or update a field. Always clears the `fill` marker (field is no
165    /// longer a placeholder). Preserves position for existing keys; appends
166    /// new keys at the end. Adjacent comments are untouched.
167    pub fn insert(&mut self, key: impl Into<String>, value: QuillValue) -> Option<QuillValue> {
168        let key = key.into();
169        for item in self.items.iter_mut() {
170            if let FrontmatterItem::Field {
171                key: k,
172                value: v,
173                fill,
174            } = item
175            {
176                if k == &key {
177                    let old = std::mem::replace(v, value);
178                    *fill = false;
179                    return Some(old);
180                }
181            }
182        }
183        self.items.push(FrontmatterItem::Field {
184            key,
185            value,
186            fill: false,
187        });
188        None
189    }
190
191    /// Insert or update a field and mark it as a `!fill` placeholder. Preserves
192    /// position for existing keys; appends new keys at the end.
193    pub fn insert_fill(&mut self, key: impl Into<String>, value: QuillValue) -> Option<QuillValue> {
194        let key = key.into();
195        for item in self.items.iter_mut() {
196            if let FrontmatterItem::Field {
197                key: k,
198                value: v,
199                fill,
200            } = item
201            {
202                if k == &key {
203                    let old = std::mem::replace(v, value);
204                    *fill = true;
205                    return Some(old);
206                }
207            }
208        }
209        self.items.push(FrontmatterItem::Field {
210            key,
211            value,
212            fill: true,
213        });
214        None
215    }
216
217    /// Remove a field by key and return its value. Adjacent comments stay
218    /// where they are.
219    pub fn remove(&mut self, key: &str) -> Option<QuillValue> {
220        let pos = self
221            .items
222            .iter()
223            .position(|item| matches!(item, FrontmatterItem::Field { key: k, .. } if k == key))?;
224        match self.items.remove(pos) {
225            FrontmatterItem::Field { value, .. } => Some(value),
226            FrontmatterItem::Comment { .. } => unreachable!(),
227        }
228    }
229
230    /// Returns `true` if a field with this key is marked `!fill`.
231    pub fn is_fill(&self, key: &str) -> bool {
232        self.items.iter().any(|item| match item {
233            FrontmatterItem::Field { key: k, fill, .. } => k == key && *fill,
234            _ => false,
235        })
236    }
237
238    /// Project the field portion into an `IndexMap<String, QuillValue>`.
239    /// Comments are dropped; fill markers are lost. Preserves order.
240    pub fn to_index_map(&self) -> IndexMap<String, QuillValue> {
241        let mut map = IndexMap::new();
242        for item in &self.items {
243            if let FrontmatterItem::Field { key, value, .. } = item {
244                map.insert(key.clone(), value.clone());
245            }
246        }
247        map
248    }
249}
250
251impl<'a> IntoIterator for &'a Frontmatter {
252    type Item = (&'a String, &'a QuillValue);
253    type IntoIter = std::iter::FilterMap<
254        std::slice::Iter<'a, FrontmatterItem>,
255        fn(&'a FrontmatterItem) -> Option<(&'a String, &'a QuillValue)>,
256    >;
257
258    fn into_iter(self) -> Self::IntoIter {
259        fn filter<'a>(item: &'a FrontmatterItem) -> Option<(&'a String, &'a QuillValue)> {
260            match item {
261                FrontmatterItem::Field { key, value, .. } => Some((key, value)),
262                FrontmatterItem::Comment { .. } => None,
263            }
264        }
265        self.items.iter().filter_map(filter)
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn qv(s: &str) -> QuillValue {
274        QuillValue::from_json(serde_json::json!(s))
275    }
276
277    #[test]
278    fn insert_new_appends() {
279        let mut fm = Frontmatter::new();
280        fm.insert("title", qv("Hello"));
281        fm.insert("author", qv("Alice"));
282        assert_eq!(fm.len(), 2);
283        let keys: Vec<&String> = fm.keys().collect();
284        assert_eq!(keys, vec!["title", "author"]);
285    }
286
287    #[test]
288    fn insert_existing_preserves_position() {
289        let mut fm = Frontmatter::new();
290        fm.insert("a", qv("1"));
291        fm.insert("b", qv("2"));
292        fm.insert("a", qv("updated"));
293        let keys: Vec<&String> = fm.keys().collect();
294        assert_eq!(keys, vec!["a", "b"]);
295        assert_eq!(fm.get("a").unwrap().as_str(), Some("updated"));
296    }
297
298    #[test]
299    fn insert_clears_fill() {
300        let mut fm = Frontmatter::new();
301        fm.insert_fill("k", qv("placeholder"));
302        assert!(fm.is_fill("k"));
303        fm.insert("k", qv("user value"));
304        assert!(!fm.is_fill("k"));
305    }
306
307    #[test]
308    fn insert_fill_preserves_position_and_sets_flag() {
309        let mut fm = Frontmatter::new();
310        fm.insert("k", qv("v"));
311        fm.insert_fill("k", qv("placeholder"));
312        assert!(fm.is_fill("k"));
313        assert_eq!(fm.get("k").unwrap().as_str(), Some("placeholder"));
314    }
315
316    #[test]
317    fn remove_leaves_comments_alone() {
318        let items = vec![
319            FrontmatterItem::comment("header"),
320            FrontmatterItem::field("a", qv("1")),
321            FrontmatterItem::comment("mid"),
322            FrontmatterItem::field("b", qv("2")),
323        ];
324        let mut fm = Frontmatter::from_items(items);
325        let removed = fm.remove("a").unwrap();
326        assert_eq!(removed.as_str(), Some("1"));
327        let comments: Vec<&str> = fm
328            .items()
329            .iter()
330            .filter_map(|item| match item {
331                FrontmatterItem::Comment { text } => Some(text.as_str()),
332                FrontmatterItem::Field { .. } => None,
333            })
334            .collect();
335        assert_eq!(comments, vec!["header", "mid"]);
336    }
337
338    #[test]
339    fn map_style_iter_skips_comments() {
340        let items = vec![
341            FrontmatterItem::comment("c"),
342            FrontmatterItem::field("a", qv("1")),
343            FrontmatterItem::field("b", qv("2")),
344        ];
345        let fm = Frontmatter::from_items(items);
346        let pairs: Vec<(String, String)> = fm
347            .iter()
348            .map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string()))
349            .collect();
350        assert_eq!(
351            pairs,
352            vec![
353                ("a".to_string(), "1".to_string()),
354                ("b".to_string(), "2".to_string())
355            ]
356        );
357    }
358}