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