quillmark_core/document/edit.rs
1//! # Document Editor Surface
2//!
3//! Typed mutators for [`Document`] and [`Card`] with invariant enforcement.
4//!
5//! ## Invariants
6//!
7//! Every successful mutator call leaves the document in a state that:
8//! - Contains no reserved key in any card's frontmatter (`BODY`, `CARDS`, `QUILL`, `CARD`).
9//! - Has every composable `card.tag()` passing `sentinel::is_valid_tag_name`.
10//! - Can be safely serialized via [`Document::to_plate_json`].
11//!
12//! **Mutators never modify `warnings`.** Warnings are parse-time observations
13//! and remain stable for the lifetime of the document.
14//!
15//! ## Surface
16//!
17//! After the document-rework, frontmatter and body mutators live on [`Card`]:
18//! `doc.main_mut().set_field(…)`, `doc.main_mut().replace_body(…)`,
19//! `doc.cards_mut()[i].set_field(…)`. [`Document`] keeps only document-level
20//! operations (quill-ref, push/insert/remove/move card).
21
22use unicode_normalization::UnicodeNormalization;
23
24use crate::document::sentinel::is_valid_tag_name;
25use crate::document::{Card, Document, Frontmatter, Sentinel};
26use crate::value::QuillValue;
27use crate::version::QuillReference;
28
29// ── Reserved names ──────────────────────────────────────────────────────────
30
31/// Reserved field names that may not appear in any `Card`'s frontmatter.
32/// These are the sentinel keys whose presence in user-visible fields would
33/// corrupt the plate wire format or the parser's structural invariants.
34pub const RESERVED_NAMES: &[&str] = &["BODY", "CARDS", "QUILL", "CARD"];
35
36/// Returns `true` if `name` is one of the four reserved sentinel names.
37#[inline]
38pub fn is_reserved_name(name: &str) -> bool {
39 RESERVED_NAMES.contains(&name)
40}
41
42// ── Field name validation ───────────────────────────────────────────────────
43
44/// Returns `true` if `name` is a valid frontmatter / card field name.
45///
46/// A valid field name matches `[a-z_][a-z0-9_]*` after NFC normalisation.
47/// Upper-case identifiers are intentionally excluded; they are reserved for
48/// sentinel keys (`QUILL`, `CARD`, `BODY`, `CARDS`).
49pub fn is_valid_field_name(name: &str) -> bool {
50 // NFC-normalize first so that, e.g., composed vs decomposed forms compare equal.
51 let normalized: String = name.nfc().collect();
52 if normalized.is_empty() {
53 return false;
54 }
55 let mut chars = normalized.chars();
56 let first = chars.next().unwrap();
57 if !first.is_ascii_lowercase() && first != '_' {
58 return false;
59 }
60 for ch in chars {
61 if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '_' {
62 return false;
63 }
64 }
65 true
66}
67
68// ── EditError ────────────────────────────────────────────────────────────────
69
70/// Errors returned by document and card mutators.
71///
72/// `EditError` is distinct from [`crate::error::ParseError`]: it carries no
73/// source-location information because edits happen after parsing.
74#[derive(Debug, Clone, PartialEq, thiserror::Error)]
75pub enum EditError {
76 /// The supplied name is one of the four reserved sentinel keys
77 /// (`BODY`, `CARDS`, `QUILL`, `CARD`).
78 #[error("reserved name '{0}' cannot be used as a field name")]
79 ReservedName(String),
80
81 /// The supplied name does not match `[a-z_][a-z0-9_]*`.
82 #[error("invalid field name '{0}': must match [a-z_][a-z0-9_]*")]
83 InvalidFieldName(String),
84
85 /// The supplied tag does not match `[a-z_][a-z0-9_]*`.
86 #[error("invalid tag name '{0}': must match [a-z_][a-z0-9_]*")]
87 InvalidTagName(String),
88
89 /// A card index was out of the valid range.
90 #[error("index {index} is out of range (len = {len})")]
91 IndexOutOfRange { index: usize, len: usize },
92}
93
94// ── impl Document ────────────────────────────────────────────────────────────
95
96impl Document {
97 /// Replace the QUILL reference on the main card's sentinel.
98 ///
99 /// # Invariants enforced
100 ///
101 /// The `QuillReference` type guarantees structural validity; no further
102 /// checks are needed here.
103 ///
104 /// # Warnings
105 ///
106 /// This method never modifies `warnings`.
107 pub fn set_quill_ref(&mut self, reference: QuillReference) {
108 self.main_mut().replace_sentinel(Sentinel::Main(reference));
109 }
110
111 // ── Card mutators ────────────────────────────────────────────────────────
112
113 /// Return a mutable reference to the composable card at `index`, or `None`
114 /// if out of range.
115 ///
116 /// # Warnings
117 ///
118 /// This method never modifies `warnings`.
119 pub fn card_mut(&mut self, index: usize) -> Option<&mut Card> {
120 self.cards_mut().get_mut(index)
121 }
122
123 /// Append a composable card to the end of the card list.
124 ///
125 /// # Invariants
126 ///
127 /// `card.sentinel()` must be [`Sentinel::Card`]; a main card cannot be
128 /// appended as a composable card. Debug assert.
129 ///
130 /// # Warnings
131 ///
132 /// This method never modifies `warnings`.
133 pub fn push_card(&mut self, card: Card) {
134 debug_assert!(
135 !card.sentinel().is_main(),
136 "cannot push a Main-sentinel card as a composable card"
137 );
138 self.cards_vec_mut().push(card);
139 }
140
141 /// Insert a composable card at `index`.
142 ///
143 /// # Invariants enforced
144 ///
145 /// `index` must be in `0..=len`. An `index > len` returns
146 /// [`EditError::IndexOutOfRange`].
147 ///
148 /// # Warnings
149 ///
150 /// This method never modifies `warnings`.
151 pub fn insert_card(&mut self, index: usize, card: Card) -> Result<(), EditError> {
152 debug_assert!(
153 !card.sentinel().is_main(),
154 "cannot insert a Main-sentinel card as a composable card"
155 );
156 let len = self.cards().len();
157 if index > len {
158 return Err(EditError::IndexOutOfRange { index, len });
159 }
160 self.cards_vec_mut().insert(index, card);
161 Ok(())
162 }
163
164 /// Remove and return the composable card at `index`, or `None` if out of range.
165 ///
166 /// # Warnings
167 ///
168 /// This method never modifies `warnings`.
169 pub fn remove_card(&mut self, index: usize) -> Option<Card> {
170 if index >= self.cards().len() {
171 return None;
172 }
173 Some(self.cards_vec_mut().remove(index))
174 }
175
176 /// Move the composable card at `from` to position `to`.
177 ///
178 /// If `from == to`, this is a no-op and returns `Ok(())`.
179 ///
180 /// # Invariants enforced
181 ///
182 /// Both `from` and `to` must be in `0..len`. Either being out of range
183 /// returns [`EditError::IndexOutOfRange`] with the offending index.
184 ///
185 /// # Warnings
186 ///
187 /// This method never modifies `warnings`.
188 pub fn move_card(&mut self, from: usize, to: usize) -> Result<(), EditError> {
189 let len = self.cards().len();
190 if from >= len {
191 return Err(EditError::IndexOutOfRange { index: from, len });
192 }
193 if to >= len {
194 return Err(EditError::IndexOutOfRange { index: to, len });
195 }
196 if from == to {
197 return Ok(());
198 }
199 let card = self.cards_vec_mut().remove(from);
200 self.cards_vec_mut().insert(to, card);
201 Ok(())
202 }
203}
204
205// ── impl Card ────────────────────────────────────────────────────────────────
206
207impl Card {
208 /// Create a new, empty composable card with the given tag.
209 ///
210 /// # Invariants enforced
211 ///
212 /// `tag` must match `[a-z_][a-z0-9_]*`. An invalid tag returns
213 /// [`EditError::InvalidTagName`].
214 ///
215 /// The new card has no fields and an empty body.
216 pub fn new(tag: impl Into<String>) -> Result<Self, EditError> {
217 let tag = tag.into();
218 if !is_valid_tag_name(&tag) {
219 return Err(EditError::InvalidTagName(tag));
220 }
221 Ok(Card::new_with_sentinel(
222 Sentinel::Card(tag),
223 Frontmatter::new(),
224 String::new(),
225 ))
226 }
227
228 /// Set a frontmatter field by name. Always clears the `!fill` marker for
229 /// that key — the "user filled in" path.
230 ///
231 /// # Invariants enforced
232 ///
233 /// - `name` must not be one of the reserved sentinel names.
234 /// Returns [`EditError::ReservedName`].
235 /// - `name` must match `[a-z_][a-z0-9_]*` after NFC normalisation.
236 /// Returns [`EditError::InvalidFieldName`].
237 ///
238 /// # Validity
239 ///
240 /// After a successful call the card remains valid: `frontmatter`
241 /// contains no reserved key and the value is stored at the correct key.
242 ///
243 /// # Warnings
244 ///
245 /// Card mutators never modify the parent document's `warnings`.
246 pub fn set_field(&mut self, name: &str, value: QuillValue) -> Result<(), EditError> {
247 if is_reserved_name(name) {
248 return Err(EditError::ReservedName(name.to_string()));
249 }
250 if !is_valid_field_name(name) {
251 return Err(EditError::InvalidFieldName(name.to_string()));
252 }
253 self.frontmatter_mut().insert(name.to_string(), value);
254 Ok(())
255 }
256
257 /// Set a frontmatter field AND mark it as a `!fill` placeholder — the
258 /// "reset to placeholder" path. A `Null` value emits as `key: !fill`;
259 /// a scalar or sequence value emits as `key: !fill <value>`.
260 ///
261 /// # Invariants enforced
262 ///
263 /// Same as [`Card::set_field`].
264 ///
265 /// # Warnings
266 ///
267 /// Card mutators never modify the parent document's `warnings`.
268 pub fn set_fill(&mut self, name: &str, value: QuillValue) -> Result<(), EditError> {
269 if is_reserved_name(name) {
270 return Err(EditError::ReservedName(name.to_string()));
271 }
272 if !is_valid_field_name(name) {
273 return Err(EditError::InvalidFieldName(name.to_string()));
274 }
275 self.frontmatter_mut().insert_fill(name.to_string(), value);
276 Ok(())
277 }
278
279 /// Remove a frontmatter field by name, returning the value if it existed.
280 ///
281 /// Reserved names cannot be present in the frontmatter (the parser
282 /// guarantees this), so passing a reserved name simply returns `None`.
283 ///
284 /// # Warnings
285 ///
286 /// Card mutators never modify the parent document's `warnings`.
287 pub fn remove_field(&mut self, name: &str) -> Option<QuillValue> {
288 self.frontmatter_mut().remove(name)
289 }
290
291 /// Replace the card's Markdown body.
292 ///
293 /// # Warnings
294 ///
295 /// Card mutators never modify the parent document's `warnings`.
296 pub fn replace_body(&mut self, body: impl Into<String>) {
297 self.overwrite_body(body.into());
298 }
299}