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//! 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}