quillmark_core/path.rs
1//! Canonical document-model paths.
2//!
3//! [`DocPath`] is the workspace's one serializer and parser for
4//! [`Diagnostic::path`](crate::error::Diagnostic::path) — the anchor into a
5//! typed [`Document`](crate::document::Document). Every emit site (schema
6//! validation, `!must_fill` collection, coercion) constructs a `DocPath` and
7//! renders it once through [`Display`](std::fmt::Display); no site assembles a path with
8//! `format!`, and no consumer regexes one back apart — the exported
9//! [`FromStr`] parser is the inverse.
10//!
11//! # Grammar
12//!
13//! ```text
14//! path := root segment*
15//! root := "main" // the main card
16//! | "cards" "." kind "[" index "]" // typed card
17//! | "cards" "[" index "]" // unknown-kind card (the only bare-index root)
18//! segment:= "." field | "[" index "]" | ".body"
19//! kind := [a-z_][a-z0-9_]*
20//! field := [A-Za-z_][A-Za-z0-9_]*
21//! ```
22//!
23//! Every document-model path is **rooted**: a main field is `main.<field>`
24//! (`main.title`, `main.recipients[0].name`), the main body `main.body`. A card
25//! field is kind-qualified — `cards.<kind>[<i>].<field>` — so a consumer
26//! receives kind and array index without a second lookup; a card whose `$kind`
27//! has no schema — absent, or present but not a declared card kind — stays
28//! `cards[<i>]`. Field names and card kinds exclude `.`, `[`, `]`, so the
29//! rendered form round-trips.
30//!
31//! Rooting makes the grammar total against a field named for a root: a main
32//! field literally named `cards` or `main` is `main.cards` / `main.main`, which
33//! no longer collides. One residual: a field literally named `body` renders
34//! `<root>.body` and collides with the body terminal — accepted, not guarded (no
35//! fixture field uses the name).
36//!
37//! This is the **document-model** namespace, distinct from the plate-JSON
38//! `data.$cards` array template authors see (`prose/canon/CARDS.md`): sigiled
39//! `$cards` is glue delivered to the backend, unsigiled `cards` is a path into
40//! the document. Config-space anchors (`$seed.<kind>.<field>`, Quill.yaml
41//! schema-literal owner labels) ride the same serializer with their prefix as a
42//! leading [`field`](DocPath::field) segment — the one **unrooted** form,
43//! config-space not document-model, verbatim and never parsed.
44
45use crate::value::PathSegment;
46use std::fmt;
47use std::str::FromStr;
48
49/// One segment of a [`DocPath`].
50///
51/// Serde-tagged (`{ "seg": "field", "name": "x" }`) so the WASM parser hands
52/// the editor a structured array it routes on, never a string it splits.
53#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
54#[serde(tag = "seg", rename_all = "lowercase")]
55pub enum DocSeg {
56 /// The main-card root — heads every main-card address (`main.title`,
57 /// `main.body`).
58 Main,
59 /// A composable card by document-array index. `kind: None` is the
60 /// unknown-kind whole-card form (`cards[<i>]`), the only bare-index root.
61 Card { kind: Option<String>, index: usize },
62 /// An object field or map key.
63 Field { name: String },
64 /// An array index.
65 Index { index: usize },
66 /// A card or main body (`.body`), always terminal.
67 Body,
68}
69
70/// A canonical document-model path — an ordered [`DocSeg`] list with one
71/// [`Display`](std::fmt::Display) serializer and one [`FromStr`] parser. See the [module
72/// docs](self) for the grammar.
73#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
74#[serde(transparent)]
75pub struct DocPath {
76 segs: Vec<DocSeg>,
77}
78
79impl DocPath {
80 /// The empty base for a config-space / opaque-prefix path (`$seed.<kind>`, a
81 /// Quill.yaml schema-literal owner label) — the one unrooted form, not a
82 /// document-model address. A document-model path roots at [`main`](Self::main)
83 /// or [`card`](Self::card).
84 pub fn new() -> Self {
85 Self::default()
86 }
87
88 /// The main-card root, `main` — the base every main-card address extends
89 /// (`main.title`, `main.recipients[0].name`, `main.body`).
90 pub fn main() -> Self {
91 Self {
92 segs: vec![DocSeg::Main],
93 }
94 }
95
96 /// The main body anchor, `main.body`.
97 pub fn main_body() -> Self {
98 Self {
99 segs: vec![DocSeg::Main, DocSeg::Body],
100 }
101 }
102
103 /// A composable card root. `kind: None` is the unknown-kind whole-card
104 /// form `cards[<i>]`; `Some(k)` is `cards.<k>[<i>]`.
105 pub fn card(kind: Option<&str>, index: usize) -> Self {
106 Self {
107 segs: vec![DocSeg::Card {
108 kind: kind.map(str::to_owned),
109 index,
110 }],
111 }
112 }
113
114 /// This path extended by a field segment. The name is stored verbatim —
115 /// callers pass validated field names, or a config-space prefix
116 /// (`$seed.<kind>`) as an opaque head.
117 pub fn field(&self, name: &str) -> Self {
118 self.pushing(DocSeg::Field {
119 name: name.to_owned(),
120 })
121 }
122
123 /// This path extended by an array index segment.
124 pub fn index(&self, index: usize) -> Self {
125 self.pushing(DocSeg::Index { index })
126 }
127
128 /// This path extended by the terminal body segment.
129 pub fn body(&self) -> Self {
130 self.pushing(DocSeg::Body)
131 }
132
133 /// This path extended by a value-relative [`PathSegment`] — the bridge
134 /// from the value-tree walk (`!must_fill` collection): [`Key`] becomes a
135 /// field, [`Index`] an index.
136 ///
137 /// [`Key`]: PathSegment::Key
138 /// [`Index`]: PathSegment::Index
139 pub fn segment(&self, seg: &PathSegment) -> Self {
140 match seg {
141 PathSegment::Key(k) => self.field(k),
142 PathSegment::Index(i) => self.index(*i),
143 }
144 }
145
146 /// The segments, head first.
147 pub fn segs(&self) -> &[DocSeg] {
148 &self.segs
149 }
150
151 fn pushing(&self, seg: DocSeg) -> Self {
152 let mut segs = self.segs.clone();
153 segs.push(seg);
154 Self { segs }
155 }
156}
157
158impl fmt::Display for DocPath {
159 /// The one document-model path serializer. A `Field` takes a leading `.`
160 /// unless it heads the path; `Index` and `Body` never do; the card and
161 /// main roots are self-contained heads.
162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163 for (i, seg) in self.segs.iter().enumerate() {
164 match seg {
165 DocSeg::Main => f.write_str("main")?,
166 DocSeg::Card { kind: Some(k), index } => write!(f, "cards.{k}[{index}]")?,
167 DocSeg::Card { kind: None, index } => write!(f, "cards[{index}]")?,
168 DocSeg::Field { name } => {
169 if i != 0 {
170 f.write_str(".")?;
171 }
172 f.write_str(name)?;
173 }
174 DocSeg::Index { index } => write!(f, "[{index}]")?,
175 DocSeg::Body => f.write_str(".body")?,
176 }
177 }
178 Ok(())
179 }
180}
181
182/// A [`DocPath`] parse failure. Carries the offending input for a diagnostic
183/// message; the parser is total over every path [`Display`](std::fmt::Display) emits.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct DocPathParseError {
186 pub input: String,
187 pub reason: &'static str,
188}
189
190impl fmt::Display for DocPathParseError {
191 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192 write!(f, "invalid document path '{}': {}", self.input, self.reason)
193 }
194}
195
196impl std::error::Error for DocPathParseError {}
197
198impl FromStr for DocPath {
199 type Err = DocPathParseError;
200
201 /// The inverse of [`Display`](std::fmt::Display), total over every emitted path. A
202 /// `main` head is the main root — `main.body` the body, `main` alone the bare
203 /// root, otherwise a main field chain; a `cards`-headed shape matching a card
204 /// root becomes a [`Card`](DocSeg::Card); a trailing `.body` under a root is
205 /// [`Body`](DocSeg::Body); an unrooted chain is a config-space anchor
206 /// (`$seed.<kind>`).
207 fn from_str(s: &str) -> Result<Self, Self::Err> {
208 let err = |reason: &'static str| DocPathParseError {
209 input: s.to_owned(),
210 reason,
211 };
212 if s.is_empty() {
213 return Err(err("empty path"));
214 }
215
216 // The head word scans as a `Field`; a `main`/`cards` head is reclassed
217 // into its root below, otherwise it stays the field it names.
218 let segs = scan(s).map_err(err)?;
219
220 // A `main` head is the main root. `main.body` is the body; `main` alone
221 // the bare root; otherwise a main field chain (`main.recipients[0].name`).
222 // A main field literally named `body` renders `main.body` and reads back
223 // as the body — the accepted residual collision.
224 if matches!(segs.first(), Some(DocSeg::Field { name }) if name == "main") {
225 let rest = &segs[1..];
226 if matches!(rest, [DocSeg::Field { name }] if name == "body") {
227 return Ok(DocPath::main_body());
228 }
229 let mut out = vec![DocSeg::Main];
230 out.extend_from_slice(rest);
231 return Ok(DocPath { segs: out });
232 }
233
234 // A `cards` head that matches a card-root shape is a Card; the tail
235 // (a lone `body`, or fields/indices) follows. A `cards` word that does
236 // not fit — no index — is an ordinary field named `cards`.
237 if matches!(segs.first(), Some(DocSeg::Field { name }) if name == "cards") {
238 if let Some((card, rest)) = parse_card_root(&segs) {
239 let mut segs = vec![card];
240 segs.extend(tail_segs(rest));
241 return Ok(DocPath { segs });
242 }
243 }
244
245 // An unrooted field chain — a config-space anchor (`$seed.<kind>`, an
246 // owner label), never a document-model address.
247 Ok(DocPath { segs })
248 }
249}
250
251/// Scan a path into segments: a leading word, then a run of `.word` (a `Field`)
252/// or `[index]` (an `Index`). Root/terminal words (`main`/`cards`/`body`) scan
253/// as fields and are reclassed by the caller. The round-trip charsets are
254/// enforced here only as "no empty word, digits inside brackets".
255fn scan(s: &str) -> Result<Vec<DocSeg>, &'static str> {
256 let mut segs = Vec::new();
257 let bytes = s.as_bytes();
258 let mut i = 0;
259 // Head word (paths never open with `.` or `[`).
260 if bytes[0] == b'.' || bytes[0] == b'[' {
261 return Err("path must start with a name");
262 }
263 while i < bytes.len() {
264 match bytes[i] {
265 b'[' => {
266 let end = s[i..].find(']').map(|o| i + o).ok_or("unclosed '['")?;
267 let digits = &s[i + 1..end];
268 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
269 return Err("index is not a number");
270 }
271 let index = digits.parse().map_err(|_| "index out of range")?;
272 segs.push(DocSeg::Index { index });
273 i = end + 1;
274 }
275 b'.' => {
276 let start = i + 1;
277 i = word_end(bytes, start);
278 if i == start {
279 return Err("empty segment after '.'");
280 }
281 segs.push(DocSeg::Field { name: s[start..i].to_owned() });
282 }
283 _ => {
284 let start = i;
285 i = word_end(bytes, start);
286 segs.push(DocSeg::Field { name: s[start..i].to_owned() });
287 }
288 }
289 }
290 Ok(segs)
291}
292
293/// The index just past a word — the run up to the next `.` or `[`.
294fn word_end(bytes: &[u8], start: usize) -> usize {
295 let mut i = start;
296 while i < bytes.len() && bytes[i] != b'.' && bytes[i] != b'[' {
297 i += 1;
298 }
299 i
300}
301
302/// Match a `cards` head against the two card-root shapes, returning the root
303/// segment and the remaining segments. `None` when the shape does not fit —
304/// then `cards` is a field, not a root.
305fn parse_card_root(segs: &[DocSeg]) -> Option<(DocSeg, &[DocSeg])> {
306 match segs {
307 // cards[<i>] …
308 [DocSeg::Field { .. }, DocSeg::Index { index }, rest @ ..] => {
309 Some((DocSeg::Card { kind: None, index: *index }, rest))
310 }
311 // cards.<kind>[<i>] …
312 [DocSeg::Field { .. }, DocSeg::Field { name: kind }, DocSeg::Index { index }, rest @ ..] => {
313 Some((
314 DocSeg::Card {
315 kind: Some(kind.clone()),
316 index: *index,
317 },
318 rest,
319 ))
320 }
321 _ => None,
322 }
323}
324
325/// A card-root tail: a lone `body` is the card body; otherwise the scanned
326/// field/index chain stands (`.signature_block`, `.recipients[0].name`).
327fn tail_segs(rest: &[DocSeg]) -> Vec<DocSeg> {
328 match rest {
329 [DocSeg::Field { name }] if name == "body" => vec![DocSeg::Body],
330 _ => rest.to_vec(),
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 /// Every form [`Display`](std::fmt::Display) emits round-trips through [`FromStr`].
339 fn round_trip(path: DocPath, rendered: &str) {
340 assert_eq!(path.to_string(), rendered, "serialize");
341 assert_eq!(
342 rendered.parse::<DocPath>().expect("parse"),
343 path,
344 "parse back"
345 );
346 }
347
348 #[test]
349 fn main_field_and_nested() {
350 round_trip(DocPath::main(), "main");
351 round_trip(DocPath::main().field("title"), "main.title");
352 round_trip(
353 DocPath::main().field("recipients").index(0).field("name"),
354 "main.recipients[0].name",
355 );
356 }
357
358 #[test]
359 fn main_body() {
360 round_trip(DocPath::main_body(), "main.body");
361 }
362
363 #[test]
364 fn card_roots() {
365 round_trip(DocPath::card(Some("indorsement"), 0), "cards.indorsement[0]");
366 round_trip(DocPath::card(None, 3), "cards[3]");
367 }
368
369 #[test]
370 fn card_field_and_body() {
371 round_trip(
372 DocPath::card(Some("indorsement"), 0).field("signature_block"),
373 "cards.indorsement[0].signature_block",
374 );
375 round_trip(
376 DocPath::card(Some("skills"), 2).body(),
377 "cards.skills[2].body",
378 );
379 round_trip(
380 DocPath::card(Some("indorsement"), 0)
381 .field("recipients")
382 .index(1)
383 .field("name"),
384 "cards.indorsement[0].recipients[1].name",
385 );
386 }
387
388 #[test]
389 fn body_is_reserved_only_as_a_root_terminal() {
390 // A non-terminal `body` under a card is an ordinary field named body.
391 round_trip(
392 DocPath::card(Some("k"), 0).field("body").field("x"),
393 "cards.k[0].body.x",
394 );
395 // A main field chain that is not `main.body` roots at `main`.
396 round_trip(DocPath::main().field("x"), "main.x");
397 }
398
399 #[test]
400 fn main_field_named_for_a_root_no_longer_collides() {
401 // Rooting makes `cards` / `main` field names total — they were the
402 // bare-form collisions the old grammar could not round-trip.
403 round_trip(DocPath::main().field("cards"), "main.cards");
404 round_trip(DocPath::main().field("main"), "main.main");
405 // A bare `cards.foo` (no index) is a config-space chain, not a card.
406 round_trip(DocPath::new().field("cards").field("foo"), "cards.foo");
407 }
408
409 #[test]
410 fn config_space_anchor_is_the_unrooted_form() {
411 // Config-space paths (`$seed` overlays, owner labels) are the one
412 // unrooted shape — a leading field, never reclassed to a root.
413 round_trip(
414 DocPath::new()
415 .field("$seed")
416 .field("indorsement")
417 .field("author"),
418 "$seed.indorsement.author",
419 );
420 }
421
422 #[test]
423 fn segment_bridge() {
424 let base = DocPath::card(Some("k"), 0);
425 assert_eq!(
426 base.segment(&PathSegment::Key("addr".into()))
427 .segment(&PathSegment::Index(2))
428 .to_string(),
429 "cards.k[0].addr[2]",
430 );
431 }
432
433 #[test]
434 fn parse_rejects_malformed() {
435 for bad in ["", ".foo", "[0]", "foo[", "foo[a]", "foo[]", "a..b", "a."] {
436 assert!(bad.parse::<DocPath>().is_err(), "expected error for {bad:?}");
437 }
438 }
439
440 #[test]
441 fn serde_round_trips_as_tagged_array() {
442 let path = DocPath::card(Some("indorsement"), 0).field("sig");
443 let json = serde_json::to_string(&path).unwrap();
444 assert_eq!(
445 json,
446 r#"[{"seg":"card","kind":"indorsement","index":0},{"seg":"field","name":"sig"}]"#
447 );
448 assert_eq!(serde_json::from_str::<DocPath>(&json).unwrap(), path);
449 }
450}