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