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}