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