Skip to main content

rakata_formats/schema/
route.rs

1//! One address for a field of a view, from the view's root.
2//!
3//! # What wanted this
4//!
5//! Four things need to name the same place and none of them could. A tree
6//! showing a raw GFF needs "reveal this node" from a typed panel. A panel
7//! showing a creature's hit points needs somewhere to write back to, because
8//! a typed view hands over a value and not an address. A change-review row
9//! needs a key that two runs agree on. A lint finding needs to anchor to the
10//! field it is about.
11//!
12//! [`GffPath`] already addresses a node in a tree, and a schema already
13//! describes what a view holds. What was missing is the join: the route from a
14//! view's root to one of its fields, which is a property of the schema rather
15//! than of any one file.
16//!
17//! # A route is not a path
18//!
19//! A [`GffPath`] names one node in one file: `Creature List[3].HitPoints`. A
20//! [`FieldRoute`] names the same field in every file: `Creature List[].HitPoints`.
21//! The bracket is empty because which element is a fact about the file, and
22//! the route is a fact about the format.
23//!
24//! So the two are used at different moments. A lint rule anchors on the route,
25//! because the rule is about the field wherever it occurs. A diff row and a
26//! write-back use [`FieldRoute::at`] with the indices in hand, which produces
27//! a path and from there [`GffDocument::get`](crate::GffDocument::get) and
28//! [`set`](crate::GffDocument::set) take over.
29//!
30//! # Resolution is what makes an anchor real
31//!
32//! A route is a claim that a view has a field there, and
33//! [`FieldRoute::field_in`] checks it against the schema. Without that a typo
34//! makes an anchor that names nothing, points at no node, and reports no
35//! error: the failure [`GffLabel`] exists to stop one layer down, at the layer
36//! above it. A consumer holding a resolved route holds the field's whole
37//! entry, which is how a panel knows what it is allowed to write.
38//!
39//! # Two kinds of ambiguity, and they are unrelated
40//!
41//! **Schema-side**: one location, two declarations. Both arms of a split can
42//! declare a label, as `List[].TemplateResRef` is declared by the templated
43//! arm and by the saved one with different write rules.
44//! [`FieldRoute::entries_in`] hands back both and
45//! [`FieldRoute::field_in`] narrows to one. This is a fact about the
46//! declarations.
47//!
48//! **File-side**: one label, two fields in an actual struct. Every `.dlg`
49//! node carries `SoundExists` six times and nothing on record says why, so
50//! `GffStruct::set` refuses the path outright. This is a fact about one file.
51//!
52//! Neither implies the other. A route resolving to two entries says nothing
53//! about whether `set` will refuse the path it produces, and a struct
54//! carrying a label twice says nothing about how many entries declare it. The
55//! two are checked in different places against different things, and folding
56//! them together would make each answer a question it cannot see.
57//!
58//! # This layer knows about schemas and [`GffPath`] does not
59//!
60//! Deliberately, and the split is the same one `set` already draws.
61//! `GffStruct::set` refuses a type change and an ambiguous label and asks no
62//! schema, so it edits a file nobody has modelled. Resolution belongs here
63//! instead, where a caller opts into it by naming a view.
64//!
65//! The rule, so it survives a tidy-up: **schema-free addressing below,
66//! schema-aware routing above.** Anything needing a schema to resolve cannot
67//! live beside `set`, which is why this is not in `gff`.
68
69use std::fmt;
70use std::str::FromStr;
71
72use thiserror::Error;
73
74use super::{Field, HasSchema, Shape};
75use crate::gff::{GffLabel, GffLabelError, GffPath, GffPathSegment};
76
77/// One step of a [`FieldRoute`].
78#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
79pub enum RouteStep {
80    /// Descend into the field carrying this label.
81    Field(GffLabel),
82    /// Take an element of the list in hand.
83    ///
84    /// Which element is not part of the route. A caller supplies one index per
85    /// `Element` when it turns the route into a path.
86    Element,
87}
88
89/// Where a field sits, from the root of the view that declares it.
90///
91/// Ordered and hashable, so a set of findings or changed locations can be
92/// keyed on one without a string in the middle.
93#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
94pub struct FieldRoute {
95    steps: Vec<RouteStep>,
96}
97
98/// Why a route could not be used.
99#[derive(Debug, Clone, PartialEq, Eq, Error)]
100pub enum RouteError {
101    /// The route wants a different number of indices than it was given.
102    #[error("`{route}` takes {wanted} list index/indices, and {given} were given")]
103    IndexCount {
104        /// The route.
105        route: String,
106        /// How many `Element` steps it has.
107        wanted: usize,
108        /// How many indices arrived.
109        given: usize,
110    },
111    /// The schema has no such field at that point in the route.
112    #[error("`{route}` names `{label}` at `{at}`, which the schema does not declare")]
113    NoSuchField {
114        /// The route.
115        route: String,
116        /// The label that was not found.
117        label: String,
118        /// How far the walk got.
119        at: String,
120    },
121    /// The route indexes something that is not a list.
122    #[error("`{route}` indexes `{at}`, which is not a list")]
123    NotAList {
124        /// The route.
125        route: String,
126        /// How far the walk got.
127        at: String,
128    },
129    /// The route descends into something with no fields under it.
130    #[error("`{route}` descends past `{at}`, which holds a single value")]
131    NotAStruct {
132        /// The route.
133        route: String,
134        /// How far the walk got.
135        at: String,
136    },
137    /// The route ends without naming a field.
138    ///
139    /// An empty route, or one ending at a list element rather than at
140    /// something the element holds.
141    #[error("`{route}` names no field")]
142    NamesNoField {
143        /// The route.
144        route: String,
145    },
146    /// The label is declared more than once at that point, so no single entry
147    /// describes it.
148    ///
149    /// Not a defect on its own. Both arms of a split may declare a label with
150    /// different write rules, and which applies depends on the selector's
151    /// value in one file. The address is the same either way, so a caller that
152    /// wants the location rather than the entry uses
153    /// [`FieldRoute::entries_in`] and gets both.
154    #[error("`{route}` names `{label}` at `{at}`, which carries {count} declarations")]
155    AmbiguousArm {
156        /// How many entries declare it.
157        count: usize,
158        /// The route.
159        route: String,
160        /// The label declared twice.
161        label: String,
162        /// How far the walk got.
163        at: String,
164    },
165}
166
167impl FieldRoute {
168    /// Builds a route from its steps.
169    pub fn new(steps: Vec<RouteStep>) -> Self {
170        Self { steps }
171    }
172
173    /// The steps this route takes, in order.
174    pub fn steps(&self) -> &[RouteStep] {
175        &self.steps
176    }
177
178    /// Whether this route names nothing.
179    pub fn is_empty(&self) -> bool {
180        self.steps.is_empty()
181    }
182
183    /// How many list indices [`Self::at`] wants.
184    pub fn holes(&self) -> usize {
185        self.steps
186            .iter()
187            .filter(|step| matches!(step, RouteStep::Element))
188            .count()
189    }
190
191    /// Extends this route by one step, which is how a walk builds one.
192    pub fn then(&self, step: RouteStep) -> Self {
193        let mut steps = self.steps.clone();
194        steps.push(step);
195        Self { steps }
196    }
197
198    /// Turns this route into an address in one file.
199    ///
200    /// `indices` supplies one element index per [`RouteStep::Element`], in the
201    /// order they appear. A route through no list takes an empty slice.
202    ///
203    /// # Errors
204    ///
205    /// [`RouteError::IndexCount`] when the count does not match. Taking a
206    /// slice rather than one index is what lets a route through two nested
207    /// lists work, and mismatching the count is the mistake that makes.
208    pub fn at(&self, indices: &[usize]) -> Result<GffPath, RouteError> {
209        let wanted = self.holes();
210        if wanted != indices.len() {
211            return Err(RouteError::IndexCount {
212                route: self.to_string(),
213                wanted,
214                given: indices.len(),
215            });
216        }
217        let mut supplied = indices.iter();
218        let segments = self
219            .steps
220            .iter()
221            .map(|step| match step {
222                RouteStep::Field(label) => GffPathSegment::Field(*label),
223                RouteStep::Element => GffPathSegment::Index(
224                    *supplied
225                        .next()
226                        .expect("the count was checked against `holes` above"),
227                ),
228            })
229            .collect();
230        Ok(GffPath::new(segments))
231    }
232
233    /// The field this route names in `T`'s schema.
234    ///
235    /// What turns a route from a claim into an anchor. A route naming a label
236    /// no view declares is a typo that would otherwise point at nothing and
237    /// say nothing, and the field it hands back carries the entry a caller
238    /// needs anyway: what the field holds, whether the engine reads it, and
239    /// what its absence means.
240    ///
241    /// # Errors
242    ///
243    /// [`RouteError`] naming how far the walk got, so a long route says which
244    /// step was wrong rather than that the whole thing was.
245    pub fn field_in<T: HasSchema>(&self) -> Result<&'static Field, RouteError> {
246        let entries = self.entries_in::<T>()?;
247        match entries.as_slice() {
248            [only] => Ok(only),
249            _ => Err(RouteError::AmbiguousArm {
250                route: self.to_string(),
251                label: match self.steps.last() {
252                    Some(RouteStep::Field(label)) => label.to_string(),
253                    _ => self.to_string(),
254                },
255                at: self.to_string(),
256                count: entries.len(),
257            }),
258        }
259    }
260
261    /// Every entry declared for the field this route names.
262    ///
263    /// One, ordinarily. Two when the route ends inside a split and both arms
264    /// declare the label: `List[].TemplateResRef` is declared by the templated
265    /// arm and by the saved one, with different write rules, and which applies
266    /// depends on the selector's value in a given file. The address is the
267    /// same either way, so a tree, a diff row and a write-back can use the
268    /// route while only a consumer wanting the entry has to choose.
269    ///
270    /// # Errors
271    ///
272    /// [`RouteError`] naming how far the walk got.
273    pub fn entries_in<T: HasSchema>(&self) -> Result<Vec<&'static Field>, RouteError> {
274        self.field_under(T::parts())
275    }
276
277    /// The field this route names under an explicit set of schema parts.
278    ///
279    /// Parts rather than one slice throughout, because that is how a schema
280    /// holds a type that flattens a shared block: the block stays one
281    /// declaration and the container points at it.
282    ///
283    /// A list's elements are looked up across the element schema and both
284    /// arms of a [`Split`](super::Split) together. Which arm a file takes
285    /// depends on a sibling's value, which a route does not carry, so a label
286    /// declared by one arm resolves and one declared by both is refused.
287    ///
288    /// # Errors
289    ///
290    /// [`RouteError::NoSuchField`] when `parts` declares no such label at that
291    /// point, [`RouteError::NotAList`] when the route indexes something that
292    /// is not one, [`RouteError::NotAStruct`] when it descends into something
293    /// with no fields under it, and [`RouteError::NamesNoField`] when the
294    /// route ends at a list element rather than at something the element
295    /// holds. An empty route is that last one.
296    ///
297    /// [`RouteError::AmbiguousArm`] when both arms of a [`Split`](super::Split)
298    /// declare the label. Which arm a file takes turns on a sibling's value,
299    /// which a route does not carry, so this is refused rather than guessed.
300    ///
301    /// [`RouteError::IndexCount`] when the route's index count does not match
302    /// what its steps need. That is a malformed route rather than a schema
303    /// that disagrees with it.
304    pub fn field_under(
305        &self,
306        parts: &[&'static [Field]],
307    ) -> Result<Vec<&'static Field>, RouteError> {
308        // Two states, alternating. Either the walk is standing on a set of
309        // parts with a label to find, or it is holding the entries it just
310        // named and the next step says what to do with them.
311        let mut here: Vec<&'static [Field]> = parts.to_vec();
312        let mut found: Option<Vec<&'static Field>> = None;
313
314        for (depth, step) in self.steps.iter().enumerate() {
315            match step {
316                RouteStep::Field(label) => {
317                    if let Some(previous) = found.take() {
318                        let Shape::Struct { fields } = self.descend(&previous, depth)?.shape else {
319                            // A list and a scalar both need something before a
320                            // label can be named under them: an index, or
321                            // nothing at all.
322                            return Err(RouteError::NotAStruct {
323                                route: self.to_string(),
324                                at: self.upto(depth),
325                            });
326                        };
327                        here = fields.to_vec();
328                    }
329                    found = Some(self.look_up(&here, label, depth)?);
330                }
331                RouteStep::Element => {
332                    let entries = found.take().ok_or_else(|| RouteError::NotAList {
333                        route: self.to_string(),
334                        at: self.upto(depth),
335                    })?;
336                    let Shape::List { element, split, .. } = self.descend(&entries, depth)?.shape
337                    else {
338                        return Err(RouteError::NotAList {
339                            route: self.to_string(),
340                            at: self.upto(depth),
341                        });
342                    };
343                    // Both arms are in scope at once. Which one a file takes is
344                    // decided by a sibling's value, and a route addresses the
345                    // field in every file rather than in one.
346                    here = element.to_vec();
347                    if let Some(split) = split {
348                        here.extend_from_slice(split.when_clear);
349                        here.extend_from_slice(split.when_set);
350                    }
351                }
352            }
353        }
354
355        found.ok_or_else(|| RouteError::NamesNoField {
356            route: self.to_string(),
357        })
358    }
359
360    /// The entry a walk descends through, when the candidates agree on where
361    /// they lead.
362    ///
363    /// Both arms of a split can declare the same container: a trigger's
364    /// `Geometry` is declared by the templated arm and the saved one, with the
365    /// same element schema under it, so continuing through either reaches the
366    /// same place and the route is not ambiguous at all. What cannot be
367    /// descended is a label whose entries name different shapes, because then
368    /// picking one guesses which file the route is about.
369    fn descend(&self, hits: &[&'static Field], depth: usize) -> Result<&'static Field, RouteError> {
370        match hits {
371            [only] => Ok(only),
372            [first, rest @ ..] if rest.iter().all(|other| other.shape == first.shape) => Ok(first),
373            _ => Err(RouteError::AmbiguousArm {
374                route: self.to_string(),
375                label: hits
376                    .first()
377                    .map_or_else(String::new, |f| f.label.to_string()),
378                at: self.upto(depth),
379                count: hits.len(),
380            }),
381        }
382    }
383
384    /// Every field carrying `label` across `parts`, which for a list's
385    /// elements includes both arms of its split.
386    fn look_up(
387        &self,
388        parts: &[&'static [Field]],
389        label: &GffLabel,
390        depth: usize,
391    ) -> Result<Vec<&'static Field>, RouteError> {
392        let hits: Vec<&'static Field> = parts
393            .iter()
394            .flat_map(|part| part.iter())
395            .filter(|field| field.label == *label)
396            .collect();
397        if hits.is_empty() {
398            return Err(RouteError::NoSuchField {
399                route: self.to_string(),
400                label: label.to_string(),
401                at: self.upto(depth),
402            });
403        }
404        Ok(hits)
405    }
406
407    /// The route rendered up to `upto` steps, so an error says where it broke.
408    fn upto(&self, upto: usize) -> String {
409        FieldRoute::new(self.steps[..upto.min(self.steps.len())].to_vec()).to_string()
410    }
411}
412
413/// Every route a view declares, with the field each one names.
414///
415/// The other direction from [`FieldRoute::field_in`]: that one checks a route
416/// somebody wrote, this one produces the set. A tree wanting to label its
417/// nodes with what the schema says about them, or a lint pass wanting to visit
418/// every modelled field, reads this rather than growing its own walk.
419///
420/// Depth-first in declaration order, so two runs agree and a diff between two
421/// views lines up.
422pub fn routes_in<T: HasSchema>() -> Vec<(FieldRoute, &'static Field)> {
423    routes_under(T::parts())
424}
425
426/// Every route under an explicit set of schema parts.
427///
428/// A schema that reached itself would enumerate forever, so a part already on
429/// the current path is not descended into again. Nothing in the fourteen views
430/// does that today; the guard is here because a walk that hangs reports
431/// nothing at all, which is the failure that looks like no failure.
432pub fn routes_under(parts: &[&'static [Field]]) -> Vec<(FieldRoute, &'static Field)> {
433    let mut out = Vec::new();
434    let mut seen = Vec::new();
435    walk(parts, &FieldRoute::default(), &mut seen, &mut out);
436    out
437}
438
439fn walk(
440    parts: &[&'static [Field]],
441    prefix: &FieldRoute,
442    seen: &mut Vec<*const Field>,
443    out: &mut Vec<(FieldRoute, &'static Field)>,
444) {
445    for part in parts {
446        let identity = part.as_ptr();
447        if seen.contains(&identity) {
448            continue;
449        }
450        seen.push(identity);
451        for field in *part {
452            let route = prefix.then(RouteStep::Field(field.label));
453            out.push((route.clone(), field));
454            match field.shape {
455                Shape::Scalar(_) => {}
456                Shape::Struct { fields } => walk(fields, &route, seen, out),
457                Shape::List { element, split, .. } => {
458                    let inside = route.then(RouteStep::Element);
459                    walk(element, &inside, seen, out);
460                    if let Some(split) = split {
461                        walk(split.when_clear, &inside, seen, out);
462                        walk(split.when_set, &inside, seen, out);
463                    }
464                }
465            }
466        }
467        seen.pop();
468    }
469}
470
471impl From<&GffPath> for FieldRoute {
472    /// The route a path in one file takes through every file.
473    ///
474    /// The inverse of [`FieldRoute::at`], and lossy in the direction it has to
475    /// be: `Creature List[3].HitPoints` becomes `Creature List[].HitPoints`,
476    /// because which element was in hand is a fact about the file and a route
477    /// carries only facts about the format.
478    ///
479    /// Total, and there is nothing here that could fail. Every segment maps to
480    /// exactly one step, so a path that addresses nothing at all still projects
481    /// to the route it would have taken. Whether the schema declares anything
482    /// there is the separate question [`FieldRoute::field_in`] answers, and
483    /// keeping the two apart is what lets a caller ask about a node the file
484    /// carries and no view models.
485    fn from(path: &GffPath) -> Self {
486        Self::new(
487            path.segments()
488                .iter()
489                .map(|segment| match segment {
490                    GffPathSegment::Field(label) => RouteStep::Field(*label),
491                    GffPathSegment::Index(_) => RouteStep::Element,
492                })
493                .collect(),
494        )
495    }
496}
497
498impl fmt::Display for FieldRoute {
499    /// Dot notation with empty brackets: `Creature List[].HitPoints`.
500    ///
501    /// The empty bracket is the point. A reader seeing `[0]` would take it for
502    /// one element rather than every one.
503    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
504        let mut first = true;
505        for step in &self.steps {
506            match step {
507                RouteStep::Field(label) => {
508                    if !first {
509                        write!(f, ".")?;
510                    }
511                    write!(f, "{label}")?;
512                }
513                RouteStep::Element => write!(f, "[]")?,
514            }
515            first = false;
516        }
517        Ok(())
518    }
519}
520
521/// Why a rendered route would not parse back.
522#[derive(Debug, Clone, PartialEq, Eq, Error)]
523pub enum RouteParseError {
524    /// A step between the dots is not a label a GFF can carry.
525    #[error("`{segment}` is not a usable label: {source}")]
526    Label {
527        /// The offending text.
528        segment: String,
529        /// What the label rejected it for.
530        source: GffLabelError,
531    },
532    /// A bracket carries something, and a route's brackets are always empty.
533    #[error("`[{inside}]` carries an index, and a route addresses every element")]
534    IndexedBracket {
535        /// What was between the brackets.
536        inside: String,
537    },
538    /// A bracket was opened and not closed.
539    #[error("`{route}` opens a bracket it does not close")]
540    UnclosedBracket {
541        /// The input.
542        route: String,
543    },
544}
545
546impl FromStr for FieldRoute {
547    type Err = RouteParseError;
548
549    /// Parses what [`Display`](fmt::Display) renders.
550    ///
551    /// A bracket has to be empty. `Creature List[0].HitPoints` is a
552    /// [`GffPath`] and naming one element is what a route does not do, so it
553    /// is refused rather than read as if the index were decoration.
554    fn from_str(route: &str) -> Result<Self, Self::Err> {
555        let mut steps = Vec::new();
556        for piece in route.split('.') {
557            let (name, mut rest) = match piece.find('[') {
558                Some(at) => (&piece[..at], &piece[at..]),
559                None => (piece, ""),
560            };
561            if !name.is_empty() {
562                steps.push(RouteStep::Field(GffLabel::new(name).map_err(|source| {
563                    RouteParseError::Label {
564                        segment: name.to_owned(),
565                        source,
566                    }
567                })?));
568            }
569            while !rest.is_empty() {
570                let close = rest
571                    .find(']')
572                    .ok_or_else(|| RouteParseError::UnclosedBracket {
573                        route: route.to_owned(),
574                    })?;
575                let inside = &rest[1..close];
576                if !inside.is_empty() {
577                    return Err(RouteParseError::IndexedBracket {
578                        inside: inside.to_owned(),
579                    });
580                }
581                steps.push(RouteStep::Element);
582                rest = &rest[close + 1..];
583            }
584        }
585        Ok(Self::new(steps))
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::gff_label;
593
594    fn route(text: &str) -> FieldRoute {
595        text.parse().expect("the test's own route parses")
596    }
597
598    #[test]
599    fn a_route_renders_its_list_levels_as_empty_brackets() {
600        let route = FieldRoute::new(vec![
601            RouteStep::Field(gff_label!("Creature List")),
602            RouteStep::Element,
603            RouteStep::Field(gff_label!("HitPoints")),
604        ]);
605        assert_eq!(route.to_string(), "Creature List[].HitPoints");
606        assert_eq!(route.holes(), 1);
607    }
608
609    #[test]
610    fn rendering_and_parsing_are_the_same_shape() {
611        for text in [
612            "HitPoints",
613            "Creature List[].HitPoints",
614            "AreaProperties.AmbientSndDay",
615            "EntryList[].RepliesList[].Active",
616        ] {
617            assert_eq!(route(text).to_string(), text);
618        }
619    }
620
621    /// Projecting a path keeps its labels and drops which element was in hand.
622    #[test]
623    fn a_path_projects_to_the_route_it_took() {
624        let path: GffPath = "Creature List[3].HitPoints"
625            .parse()
626            .expect("the test's own address parses");
627        assert_eq!(
628            FieldRoute::from(&path),
629            route("Creature List[].HitPoints"),
630            "an index survived the projection"
631        );
632    }
633
634    /// `at` and the projection are inverses, which is the pair's whole claim.
635    ///
636    /// One direction only: supplying indices and taking them away again is the
637    /// identity, while going the other way cannot be, since the indices are
638    /// gone by then.
639    #[test]
640    fn supplying_indices_and_projecting_them_back_off_is_the_identity() {
641        for (text, indices) in [
642            ("HitPoints", &[][..]),
643            ("Creature List[].HitPoints", &[3][..]),
644            ("EntryList[].RepliesList[].Active", &[0, 7][..]),
645        ] {
646            let route = route(text);
647            let path = route.at(indices).expect("the index count is the route's");
648            assert_eq!(FieldRoute::from(&path), route, "`{text}` did not survive");
649        }
650    }
651
652    /// Projection is not the string round trip, and could not be.
653    ///
654    /// A rendered path with an index in it is refused by the route parser on
655    /// purpose, so a caller holding a path has no way through text and the
656    /// conversion is the only door.
657    #[test]
658    fn a_path_projects_even_though_its_text_will_not_parse_as_a_route() {
659        let path: GffPath = "Creature List[3].HitPoints"
660            .parse()
661            .expect("the test's own address parses");
662        assert!(path.to_string().parse::<FieldRoute>().is_err());
663        assert_eq!(FieldRoute::from(&path).holes(), 1);
664    }
665
666    /// A route addresses the field in every file, so a bracket carrying an
667    /// index is a [`GffPath`] rather than a route and is refused instead of
668    /// being read with the index thrown away.
669    #[test]
670    fn a_bracket_with_an_index_in_it_is_not_a_route() {
671        assert!(matches!(
672            "Creature List[3].HitPoints".parse::<FieldRoute>(),
673            Err(RouteParseError::IndexedBracket { .. })
674        ));
675        assert!(matches!(
676            "Creature List[.HitPoints".parse::<FieldRoute>(),
677            Err(RouteParseError::UnclosedBracket { .. })
678        ));
679    }
680
681    #[test]
682    fn supplying_the_indices_turns_a_route_into_a_path() {
683        let path = route("Creature List[].HitPoints")
684            .at(&[3])
685            .expect("one index for one list level");
686        assert_eq!(path.to_string(), "Creature List[3].HitPoints");
687
688        let nested = route("EntryList[].RepliesList[].Active")
689            .at(&[2, 7])
690            .expect("two indices for two list levels");
691        assert_eq!(nested.to_string(), "EntryList[2].RepliesList[7].Active");
692    }
693
694    /// The count is the mistake a two-list route makes, so it is an error
695    /// rather than a silent truncation or a zero.
696    #[test]
697    fn the_wrong_number_of_indices_is_refused_in_both_directions() {
698        let one = route("Creature List[].HitPoints");
699        assert!(matches!(
700            one.at(&[]),
701            Err(RouteError::IndexCount {
702                wanted: 1,
703                given: 0,
704                ..
705            })
706        ));
707        assert!(matches!(
708            one.at(&[1, 2]),
709            Err(RouteError::IndexCount {
710                wanted: 1,
711                given: 2,
712                ..
713            })
714        ));
715        assert!(route("HitPoints").at(&[]).is_ok());
716    }
717}