Skip to main content

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}