knf/path.rs
1//! The path vocabulary: one step type, one parsed spelling, one witness.
2//!
3//! [`Seg`] is the single step every path in the workspace is built from — an
4//! object key or an array index — and [`render_path`] the one display for a
5//! chain of them. Over `Vec<Seg>` there is one parsed spelling and one witness:
6//!
7//! - [`RefPath`] parses `a.b[2].c` — dotted keys plus `[n]` steps. It is the
8//! only spelling a `${...}` reference uses, and the grammar `--set`'s path
9//! parses too.
10//! - A bare `Vec<Seg>` is the *witness*: built by walking a document, never
11//! parsed, and free to hold [`Index`](Seg::Index) — a value can live inside
12//! an array, and an error must still be able to say so.
13//!
14//! Writers take keys only. Reading an array element has one obvious meaning;
15//! writing one conflicts with arrays replacing wholesale on the merge side, so
16//! an [`Index`](Seg::Index) step can never address a merge location. That
17//! predicate is enforced once, at the boundary, by [`RefPath::try_into_keys`] —
18//! a path that reaches a writer has been through it, and
19//! [`IndexInKeyPath`](PathError::IndexInKeyPath) is the failure. A key
20//! literally spelled `a[0]` is consequently unwritable from the command line
21//! and unreferenceable from `${...}` — the same accepted loss as keys
22//! containing a literal dot, which the dotted grammar has always split.
23//!
24//! Parsing is pure text: nothing here reads a [`Value`](crate::Value), and the
25//! walkers that do — interpolation, `merge_at` — build
26//! or consume these types rather than living in them. Provenance is the
27//! caller's job too: a [`PathError`] carries the path text and never a flag
28//! name or a filename.
29//!
30//! Two renderers, because there are two representations and they disagree
31//! about the empty case. [`render_path`] takes a witness and renders nothing
32//! for an empty one — a [`RefPath`] cannot be empty, so the case never reaches
33//! it from a parse. [`render_keys`] takes the merge side's `&[String]` key
34//! paths, where empty is reachable and means the document root.
35
36use std::fmt;
37use std::str::FromStr;
38
39use crate::Value;
40
41/// Why a path expression was rejected.
42///
43/// Carries the path text and nothing else — no `--set`, no `--interpolate`,
44/// no filenames. Provenance is the caller's job.
45///
46/// [`IndexInKeyPath`](PathError::IndexInKeyPath) is raised by
47/// [`RefPath::try_into_keys`] alone, never by parsing.
48/// [`MissingEquals`](PathError::MissingEquals) has no producer in this module
49/// — a bare [`RefPath`] has no `=` to miss. It belongs to
50/// [`PathLeaf`](crate::PathLeaf), which parses `key.path=value` over this grammar and shares this
51/// error rather than wrapping it, so one spelling of a bad path reads the same
52/// wherever it was typed.
53#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
54pub enum PathError {
55 /// No `=` in a `key.path=value` expression.
56 #[error("expected KEY.PATH=VALUE")]
57 MissingEquals,
58 /// The path is empty (`=1`, an empty `${...}` body, an empty vec).
59 #[error("empty path")]
60 EmptyPath,
61 /// A path segment is empty (`a..b`, `.a`, `a.`).
62 #[error("empty segment in path `{path}`")]
63 EmptySegment {
64 /// The path that contained an empty segment.
65 path: String,
66 },
67 /// A bracket step is malformed: empty, not a number, too big, or unclosed.
68 #[error("malformed index in path `{path}`")]
69 BadIndex {
70 /// The path that contained the bad bracket.
71 path: String,
72 },
73 /// A path that arrived at a writer contained an array index.
74 ///
75 /// Raised by [`RefPath::try_into_keys`] only, never by parsing: writers
76 /// take keys one per segment because arrays replace wholesale on the
77 /// merge side, so there is no meaning to writing element `n`.
78 #[error("`{path}` contains an array index; merge paths take keys only")]
79 IndexInKeyPath {
80 /// The full path, rendered.
81 path: String,
82 },
83}
84
85/// One step of a path into a document.
86///
87/// The single step vocabulary every path in the workspace is built from.
88/// [`RefPath`] parses a chain of these from text; a bare `Vec<Seg>` is the
89/// witness a walker builds by descending a document, free to hold
90/// [`Index`](Seg::Index) because a value can live inside an array.
91#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
92pub enum Seg {
93 /// An object key.
94 Key(String),
95 /// An array position.
96 Index(usize),
97}
98
99/// Renders a path for display: `servers.primary.host`, `tags[0]`.
100pub fn render_path(path: &[Seg]) -> String {
101 let mut out = String::new();
102 for seg in path {
103 match seg {
104 Seg::Key(k) => {
105 if !out.is_empty() {
106 out.push('.');
107 }
108 out.push_str(k);
109 }
110 Seg::Index(i) => out.push_str(&format!("[{i}]")),
111 }
112 }
113 out
114}
115
116/// A parsed path: dotted keys plus bracket array indices, `a.b[2].c`.
117///
118/// The one spelling over [`Seg`]. References parse it directly; `--set`'s
119/// path parses it too, and its write-side caller then runs
120/// [`try_into_keys`](RefPath::try_into_keys), which rejects any
121/// [`Index`](Seg::Index) step — reading an array element has one obvious
122/// meaning, writing one does not. Empty paths and empty segments are
123/// unrepresentable: both [`from_str`](RefPath::from_str) and
124/// [`from_keys`](RefPath::from_keys) reject them.
125///
126/// `Display` is [`render_path`]. Not injective with respect to document keys:
127/// a key literally spelled `a[0]` exists, but this grammar reads it as a key
128/// and an index — the same accepted loss as keys containing a literal dot.
129#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
130pub struct RefPath {
131 path: Vec<Seg>,
132}
133
134impl RefPath {
135 /// Build from key strings. Rejects an empty path or any empty segment.
136 pub fn from_keys(keys: Vec<String>) -> Result<Self, PathError> {
137 if keys.is_empty() {
138 return Err(PathError::EmptyPath);
139 }
140 if keys.iter().any(|k| k.is_empty()) {
141 return Err(PathError::EmptySegment {
142 path: keys.join("."),
143 });
144 }
145 Ok(Self {
146 path: keys.into_iter().map(Seg::Key).collect(),
147 })
148 }
149
150 /// The segments as steps.
151 pub fn segs(&self) -> &[Seg] {
152 &self.path
153 }
154
155 /// The segments as steps, zero-copy.
156 pub fn into_segs(self) -> Vec<Seg> {
157 self.path
158 }
159
160 /// The owned key strings, checked all-key.
161 ///
162 /// The one write-side predicate: a caller that assigns into a document
163 /// takes keys one per segment, so an [`Index`](Seg::Index) step fails
164 /// here rather than at the merge — before any file is read, and with the
165 /// whole path rendered into the error.
166 pub fn try_into_keys(self) -> Result<Vec<String>, PathError> {
167 if self.path.iter().any(|seg| matches!(seg, Seg::Index(_))) {
168 return Err(PathError::IndexInKeyPath {
169 path: render_path(&self.path),
170 });
171 }
172 Ok(self
173 .path
174 .into_iter()
175 .map(|seg| match seg {
176 Seg::Key(key) => key,
177 Seg::Index(_) => unreachable!("index steps were rejected above"),
178 })
179 .collect())
180 }
181}
182
183impl FromStr for RefPath {
184 type Err = PathError;
185
186 /// Grammar: a leading key, then any mix of `.key` and `[N]` steps, where
187 /// a key is a run of anything but `.[]` and `N` a bare `usize`.
188 fn from_str(body: &str) -> Result<Self, Self::Err> {
189 if body.is_empty() {
190 return Err(PathError::EmptyPath);
191 }
192 let bad_index = || PathError::BadIndex {
193 path: body.to_string(),
194 };
195 let empty_segment = || PathError::EmptySegment {
196 path: body.to_string(),
197 };
198
199 let bytes = body.as_bytes();
200 let mut path = Vec::new();
201 let mut cursor = 0;
202
203 // The first step must be a key: there is no `[0]` into the root.
204 let (key, next) = take_key(body, cursor);
205 if key.is_empty() {
206 // `.a` and `[0].a` open with no key; a stray `]` opens with none.
207 return Err(if bytes[cursor] == b']' {
208 bad_index()
209 } else {
210 empty_segment()
211 });
212 }
213 path.push(Seg::Key(key));
214 cursor = next;
215
216 while cursor < body.len() {
217 match bytes[cursor] {
218 b'.' => {
219 let (key, next) = take_key(body, cursor + 1);
220 if key.is_empty() {
221 return Err(empty_segment());
222 }
223 path.push(Seg::Key(key));
224 cursor = next;
225 }
226 b'[' => {
227 let digits_start = cursor + 1;
228 let mut end = digits_start;
229 while end < body.len() && bytes[end].is_ascii_digit() {
230 end += 1;
231 }
232 if end == digits_start || bytes.get(end) != Some(&b']') {
233 return Err(bad_index());
234 }
235 let index: usize = body[digits_start..end].parse().map_err(|_| bad_index())?;
236 path.push(Seg::Index(index));
237 cursor = end + 1;
238 }
239 // A key run ends at `.[` or `]`; anything left over is a stray
240 // bracket.
241 _ => return Err(bad_index()),
242 }
243 }
244
245 Ok(Self { path })
246 }
247}
248
249/// A maximal run containing none of `.[]` — the delimiters are ASCII, so byte
250/// scanning never splits a multibyte key.
251fn take_key(body: &str, from: usize) -> (String, usize) {
252 let mut end = from;
253 while end < body.len() && !matches!(body.as_bytes()[end], b'.' | b'[' | b']') {
254 end += 1;
255 }
256 (body[from..end].to_string(), end)
257}
258
259impl fmt::Display for RefPath {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 f.write_str(&render_path(&self.path))
262 }
263}
264
265/// Renders a key path for display. An empty path is the document root.
266///
267/// The merge side's paths are `&[String]`: keys only, by the invariant
268/// [`RefPath::try_into_keys`] enforces, and reachable empty because the fold
269/// starts at the root. [`render_path`] is the same rendering over a witness
270/// that may hold indices.
271pub(crate) fn render_keys(path: &[String]) -> String {
272 if path.is_empty() {
273 "<root>".to_string()
274 } else {
275 path.join(".")
276 }
277}
278
279/// The node at `path`, or `None` if nothing lives there.
280///
281/// Only interpolation reads a document by path — the merge descends by
282/// recursion — and it indexes in both directions: where a reference *lives*
283/// and where it *points*, since `${servers[0]}` parses to an [`Seg::Index`].
284pub(crate) fn lookup<'a>(root: &'a Value, path: &[Seg]) -> Option<&'a Value> {
285 let mut node = root;
286 for seg in path {
287 node = match (seg, node) {
288 (Seg::Key(k), Value::Object(map)) => map.get(k)?,
289 (Seg::Index(i), Value::Array(items)) => items.get(*i)?,
290 _ => return None,
291 };
292 }
293 Some(node)
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::{Map, Number};
300
301 /// A witness may mix keys and indices.
302 #[test]
303 fn render_path_mixes_keys_and_indices() {
304 assert_eq!(render_path(&[]), "");
305 assert_eq!(
306 render_path(&[Seg::Key("a".into()), Seg::Key("b".into())]),
307 "a.b"
308 );
309 assert_eq!(
310 render_path(&[Seg::Key("tags".into()), Seg::Index(0)]),
311 "tags[0]"
312 );
313 }
314
315 fn seg(key: &str) -> Seg {
316 Seg::Key(key.into())
317 }
318
319 #[test]
320 fn ref_path_parses_mixed_steps() {
321 let cases: &[(&str, &[Seg])] = &[
322 ("a", &[seg("a")]),
323 ("a.b", &[seg("a"), seg("b")]),
324 ("a[0]", &[seg("a"), Seg::Index(0)]),
325 ("a.b[2].c", &[seg("a"), seg("b"), Seg::Index(2), seg("c")]),
326 ("a[0][1]", &[seg("a"), Seg::Index(0), Seg::Index(1)]),
327 // A `:` is an ordinary key character; namespaces are the caller's.
328 ("a:b[3]", &[seg("a:b"), Seg::Index(3)]),
329 ];
330 for (body, want) in cases {
331 let parsed: RefPath = body.parse().unwrap();
332 assert_eq!(parsed.segs(), *want, "{body}");
333 }
334 }
335
336 #[test]
337 fn ref_path_rejects_malformed_bodies() {
338 let cases: &[(&str, PathError)] = &[
339 (".a", empty_segment(".a")),
340 ("a..b", empty_segment("a..b")),
341 ("a[0]..b", empty_segment("a[0]..b")),
342 ("[0].a", empty_segment("[0].a")),
343 ("a[]", bad_index("a[]")),
344 ("a[x]", bad_index("a[x]")),
345 ("a[1", bad_index("a[1")),
346 ("a[-1]", bad_index("a[-1]")),
347 ("a]", bad_index("a]")),
348 (
349 "a[99999999999999999999999999]",
350 bad_index("a[99999999999999999999999999]"),
351 ),
352 ];
353 for (body, want) in cases {
354 assert_eq!(&body.parse::<RefPath>().unwrap_err(), want, "{body}");
355 }
356 assert_eq!("".parse::<RefPath>().unwrap_err(), PathError::EmptyPath);
357 }
358
359 fn empty_segment(path: &str) -> PathError {
360 PathError::EmptySegment { path: path.into() }
361 }
362
363 fn bad_index(path: &str) -> PathError {
364 PathError::BadIndex { path: path.into() }
365 }
366
367 #[test]
368 fn ref_path_displays_as_rendered_witness() {
369 let parsed: RefPath = "a.b[2].c".parse().unwrap();
370 assert_eq!(parsed.to_string(), "a.b[2].c");
371 assert_eq!("a.b".parse::<RefPath>().unwrap().to_string(), "a.b");
372 }
373
374 #[test]
375 fn from_keys_rejects_empty_path_and_empty_segments() {
376 assert_eq!(
377 RefPath::from_keys(vec![]).unwrap_err(),
378 PathError::EmptyPath
379 );
380 assert_eq!(
381 RefPath::from_keys(vec!["".into()]).unwrap_err(),
382 PathError::EmptySegment { path: "".into() }
383 );
384 assert_eq!(
385 RefPath::from_keys(vec!["a".into(), "".into()]).unwrap_err(),
386 PathError::EmptySegment { path: "a.".into() }
387 );
388 // from_keys and parsing agree on the dotted all-key grammar.
389 assert_eq!(
390 RefPath::from_keys(vec!["db".into(), "plugins".into()]).unwrap(),
391 "db.plugins".parse().unwrap()
392 );
393 }
394
395 #[test]
396 fn try_into_keys_passes_all_key_paths_whole() {
397 let parsed: RefPath = "db.plugins".parse().unwrap();
398 assert_eq!(parsed.try_into_keys().unwrap(), ["db", "plugins"]);
399 }
400
401 #[test]
402 fn try_into_keys_rejects_index_steps_with_the_full_path() {
403 for (indexed, want) in [("a[0]", "a[0]"), ("a.b[2].c", "a.b[2].c")] {
404 let err = indexed
405 .parse::<RefPath>()
406 .unwrap()
407 .try_into_keys()
408 .unwrap_err();
409 assert_eq!(err, PathError::IndexInKeyPath { path: want.into() });
410 }
411 let err = "a[0]"
412 .parse::<RefPath>()
413 .unwrap()
414 .try_into_keys()
415 .unwrap_err();
416 assert_eq!(
417 err.to_string(),
418 "`a[0]` contains an array index; merge paths take keys only"
419 );
420 }
421
422 /// A `=` has no special meaning in a bare path: just part of a (weird)
423 /// key rather than a `MissingEquals`-shaped hole.
424 #[test]
425 fn equals_is_an_ordinary_key_character() {
426 let parsed: RefPath = "a=b".parse().unwrap();
427 assert_eq!(parsed.try_into_keys().unwrap(), ["a=b"]);
428 }
429
430 fn doc() -> Value {
431 let mut inner = Map::new();
432 inner.insert("host".into(), Value::String("h".into()));
433 let mut root = Map::new();
434 root.insert("db".into(), Value::Object(inner));
435 root.insert(
436 "tags".into(),
437 Value::Array(vec![Value::Number(Number::I64(1))]),
438 );
439 Value::Object(root)
440 }
441
442 #[test]
443 fn lookup_walks_objects_and_arrays() {
444 let doc = doc();
445 assert_eq!(lookup(&doc, &[]), Some(&doc));
446 assert_eq!(
447 lookup(&doc, &[Seg::Key("db".into()), Seg::Key("host".into())]),
448 Some(&Value::String("h".into()))
449 );
450 assert_eq!(
451 lookup(&doc, &[Seg::Key("tags".into()), Seg::Index(0)]),
452 Some(&Value::Number(Number::I64(1)))
453 );
454 }
455
456 /// A missing key and a segment of the wrong shape are both simply absent —
457 /// the caller reports one unresolved reference either way.
458 #[test]
459 fn lookup_misses_are_none() {
460 let doc = doc();
461 assert_eq!(lookup(&doc, &[Seg::Key("nope".into())]), None);
462 assert_eq!(lookup(&doc, &[Seg::Key("db".into()), Seg::Index(0)]), None);
463 assert_eq!(
464 lookup(&doc, &[Seg::Key("tags".into()), Seg::Index(9)]),
465 None
466 );
467 }
468}