shape/
lib.rs

1mod accepts;
2mod case_enum;
3mod child_shape;
4mod display;
5mod from_json;
6mod hashing;
7mod helpers;
8
9pub mod graphql;
10pub mod location;
11#[cfg(test)]
12mod tests;
13mod visitor;
14
15pub use accepts::ShapeMismatch;
16pub use case_enum::{Error, ShapeCase};
17pub use child_shape::NamedShapePathKey;
18pub use helpers::OffsetRange;
19use std::iter::empty;
20pub use visitor::ShapeVisitor;
21
22use crate::case_enum::all::all;
23use crate::case_enum::one::one;
24use crate::location::{Located, Location};
25use helpers::Ref;
26use indexmap::IndexMap;
27
28/// The `shape::Shape` struct provides a recursive, immutable, reference-counted
29/// tree/DAG format for representing and enforcing common structures and usage
30/// patterns of JSON-like data.
31///
32/// The `Shape` system is not bound to any particular programming language, so
33/// it does not inherit a data model that it must represent and defend, yet it
34/// must adopt/assume _some_ concrete data model, since a type system without a
35/// data model to enforce is as useful as a straitjacket on a coat rack. JSON
36/// was chosen for its relative simplicity, its ubiquity as a data interchange
37/// format used across programming languages, and because JSON is often used in
38/// scenarios without a static type system to help catch errors before runtime.
39///
40/// The `Shape` system has no source syntax for denoting shapes directly, but
41/// you can use the `Shape::*` helper functions to create shapes
42/// programmatically, in Rust. `Shape::pretty_print()` provides a human-readable
43/// representation of a `Shape` for debugging and testing purposes.
44///
45/// All in all, this _Static `Shape` System_ (SSS) supports the following
46/// type-theoretic features:
47///
48/// - [x] Primitive shapes: `Bool`, `String`, `Int`, `Float`, `Null`
49/// - [x] Singleton primitive shapes: `true`, `false`, `"hello"`, `42`, `null`
50/// - [x] `Array` shapes, supporting both static tuples and dynamic lists
51/// - [x] `Object` shapes, supporting both static fields and dynamic string keys
52/// - [x] `One<S1, S2, ...>` union shapes, representing a set of shape
53///   alternatives
54/// - [x] `All<S1, S2, ...>` intersection shapes, representing a set
55///   simultaneous requirements
56/// - [x] `shape.field(name)` and `shape.item(index)` methods for accessing the
57///   shape of a subproperty of a shape
58/// - [x] `Name` shape references, with support for symbolic subproperty shape
59///   access
60/// - [x] `Error` shapes, representing a failure of shape processing, with
61///   support for chains of errors and partial shape data
62/// - [x] `None` shapes, representing the absence of a value (helpful for
63///   representing optionality of shapes)
64/// - [x] `subshape.satisfies(supershape)` and `supershape.accepts(subshape)`
65///   methods for testing shape relationships
66/// - [x] `shape.accepts_json(json)` method for testing whether concrete JSON
67///   data satisfies some expected shape
68/// - [x] `shape.pretty_print()` method for debugging and testing
69
70#[derive(Clone, Debug, Hash, PartialEq, Eq)]
71// [`Shape`] enforces the simplification of [`ShapeCase`] variants, because
72// there is no way to create a [`Shape`] without simplifying the input
73// [`ShapeCase`]. This is a very useful invariant because it allows each
74// [`ShapeCase`] to assume its immediate [`Shape`] children have already been
75// simplified.
76//
77// In addition simplification, [`Shape`] supports testing shape-shape acceptance
78// (or the equivalent inverse, satisfaction) with `super.accepts(sub)` and/or
79// `sub.satisfies(super)`. See also `shape.accepts_json(json)` for testing
80// whether concrete JSON data satisfies some expected `shape`.
81//
82// In the future, we may internalize/canonize shapes to reduce memory usage,
83// especially for well-known shapes like `Bool` and `Int` and `String`. This
84// would require either thread safety (is `type Ref<T> = std::sync::Arc<T>`
85// enough?) or maintaining per-thread canonical shape tables.
86pub struct Shape {
87    // This field is private, but if you want to match against an immutable
88    // reference to the `ShapeCase` variant, use `match shape.case() { ... }`.
89    case: Ref<ShapeCase>,
90
91    /// The combination of locations which, combined, produce this shape.
92    ///
93    /// Many cases will only have a single location, but when shapes are simplified, their locations
94    /// are all retained in the result.
95    pub locations: Vec<Location>,
96}
97
98impl Shape {
99    /// Create a `Shape` from a [`ShapeCase`] variant.
100    ///
101    /// This method is crate-private to help enforce some invariants.
102    pub(crate) fn new(case: ShapeCase, locations: impl IntoIterator<Item = Location>) -> Shape {
103        let case = Ref::new(case);
104        Shape {
105            case,
106            locations: locations.into_iter().collect(),
107        }
108    }
109
110    /// When boolean helper methods like `.is_none()` and `.is_null()` are not
111    /// enough, you can match against the underlying [`ShapeCase`] by obtaining an
112    /// immutable `&ShapeCase` reference using the `shape.case()` method.
113    #[must_use]
114    pub fn case(&self) -> &ShapeCase {
115        self.case.as_ref()
116    }
117
118    /// Returns a [`Shape`] that accepts any boolean value, `true` or `false`.
119    #[must_use]
120    pub fn bool(locations: impl IntoIterator<Item = Location>) -> Self {
121        Self::new(ShapeCase::Bool(None), locations)
122    }
123
124    /// Returns a [`Shape`] that accepts only the specified boolean value.
125    #[must_use]
126    pub fn bool_value(value: bool, locations: impl IntoIterator<Item = Location>) -> Self {
127        Self::new(ShapeCase::Bool(Some(value)), locations)
128    }
129
130    /// Returns a [`Shape`] that accepts any string value.
131    #[must_use]
132    pub fn string(locations: impl IntoIterator<Item = Location>) -> Self {
133        Self::new(ShapeCase::String(None), locations)
134    }
135
136    /// Returns a [`Shape`] that accepts only the specified string value.
137    #[must_use]
138    pub fn string_value(value: &str, locations: impl IntoIterator<Item = Location>) -> Self {
139        Self::new(ShapeCase::String(Some(value.to_string())), locations)
140    }
141
142    /// Returns a [`Shape`] that accepts any integer value.
143    #[must_use]
144    pub fn int(locations: impl IntoIterator<Item = Location>) -> Self {
145        Self::new(ShapeCase::Int(None), locations)
146    }
147
148    /// Returns a [`Shape`] that accepts only the specified integer value.
149    #[must_use]
150    pub fn int_value(value: i64, locations: impl IntoIterator<Item = Location>) -> Self {
151        Self::new(ShapeCase::Int(Some(value)), locations)
152    }
153
154    /// Returns a [`Shape`] that accepts any floating point value.
155    #[must_use]
156    pub fn float(locations: impl IntoIterator<Item = Location>) -> Self {
157        Self::new(ShapeCase::Float, locations)
158    }
159
160    /// Returns a [`Shape`] that accepts only the JSON `null` value.
161    #[must_use]
162    pub fn null(locations: impl IntoIterator<Item = Location>) -> Self {
163        Self::new(ShapeCase::Null, locations)
164    }
165
166    #[must_use]
167    pub fn is_null(&self) -> bool {
168        self.case.is_null()
169    }
170
171    /// Returns a symbolic reference to a named shape, potentially not yet
172    /// defined.
173    ///
174    /// In order to add items to the subpath of this named shape, call the
175    /// `.field(name)` and/or `.item(index)` methods.
176    ///
177    /// Note that variable shapes are represented by [`ShapeCase::Name`] where the
178    /// name string includes the initial `$` character.
179    #[must_use]
180    pub fn name(name: &str, locations: impl IntoIterator<Item = Location> + Clone) -> Self {
181        Self::new(
182            ShapeCase::Name(
183                Located::new(name.to_string(), locations.clone()),
184                Vec::new(),
185            ),
186            locations,
187        )
188    }
189
190    /// Useful for obtaining the kind of [`IndexMap`] this library uses for the
191    /// [`ShapeCase::Object`] variant.
192    #[must_use]
193    pub fn empty_map() -> IndexMap<String, Self> {
194        IndexMap::new()
195    }
196
197    /// Returns a [`Shape`] that accepts any object shape, regardless of the other
198    /// shape's `fields` or `rest` shape, because an empty object shape `{}`
199    /// imposes no expectations on other objects (except that they are objects).
200    ///
201    /// In the other direction, an empty object shape `{}` can satisfy itself or
202    /// any `Dict<V>` shape (where the `Dict` may be dynamically empty), but
203    /// cannot satisfy any object shape with non-empty `fields`.
204    #[must_use]
205    pub fn empty_object(locations: impl IntoIterator<Item = Location>) -> Self {
206        Shape::new(
207            ShapeCase::Object {
208                fields: Shape::empty_map(),
209                rest: Shape::none(),
210            },
211            locations,
212        )
213    }
214
215    /// To get a compatible empty mutable [`IndexMap`] without directly
216    /// depending on the [`indexmap`] crate yourself, use [`Shape::empty_map()`].
217    #[must_use]
218    pub fn object(
219        fields: IndexMap<String, Shape>,
220        rest: Shape,
221        locations: impl IntoIterator<Item = Location>,
222    ) -> Self {
223        Shape::new(ShapeCase::Object { fields, rest }, locations)
224    }
225
226    /// Returns a [`Shape`] that accepts any object shape with the given static
227    /// fields, with no dynamic fields considered.
228    #[must_use]
229    pub fn record(
230        fields: IndexMap<String, Shape>,
231        locations: impl IntoIterator<Item = Location>,
232    ) -> Self {
233        Shape::object(fields, Shape::none(), locations)
234    }
235
236    /// Returns a [`Shape`] that accepts any dictionary-like object with dynamic
237    /// string properties having a given value shape.
238    #[must_use]
239    pub fn dict(value_shape: Shape, locations: impl IntoIterator<Item = Location>) -> Self {
240        Shape::object(Shape::empty_map(), value_shape, locations)
241    }
242
243    /// Arrays, tuples, and lists are all manifestations of the same underlying
244    /// [`ShapeCase::Array`] representation.
245    pub fn array(
246        prefix: impl IntoIterator<Item = Shape>,
247        tail: Shape,
248        locations: impl IntoIterator<Item = Location>,
249    ) -> Self {
250        let prefix = prefix.into_iter().collect();
251        Self::new(ShapeCase::Array { prefix, tail }, locations)
252    }
253
254    /// A tuple is a [`ShapeCase::Array`] with statically known (though possibly
255    /// empty) element shapes and no dynamic tail shape.
256    pub fn tuple(
257        shapes: impl IntoIterator<Item = Shape>,
258        locations: impl IntoIterator<Item = Location>,
259    ) -> Self {
260        Shape::array(shapes, Shape::none(), locations)
261    }
262
263    /// A `List<S>` is a [`ShapeCase::Array`] with an empty static `prefix` and a
264    /// dynamic element shape `S`.
265    #[must_use]
266    pub fn list(of: Shape, locations: impl IntoIterator<Item = Location>) -> Self {
267        Shape::array(empty(), of, locations)
268    }
269
270    /// Returns a [`ShapeCase::One`] union of the given shapes, simplified.
271    ///
272    /// Note that `locations` in this case should _not_ refer to each individual inner shape, but
273    /// to the thing that caused all of these shapes to be combined, like maybe a `->match`. If
274    /// there is no obvious cause to point users to, then the location should be empty.
275    pub fn one(
276        shapes: impl IntoIterator<Item = Shape>,
277        locations: impl IntoIterator<Item = Location>,
278    ) -> Self {
279        one(shapes.into_iter(), locations.into_iter().collect())
280    }
281
282    /// Returns a [`ShapeCase::All`] intersection of the given shapes, simplified.
283    ///
284    /// Note that `locations` in this case should _not_ refer to each individual inner shape, but
285    /// to the thing that caused all of these shapes to be combined, like maybe a `IntfA & IntfB`.
286    /// If there is no obvious cause to point users to, then the location should be empty.
287    pub fn all(
288        shapes: impl IntoIterator<Item = Shape>,
289        locations: impl IntoIterator<Item = Location>,
290    ) -> Self {
291        all(shapes.into_iter(), locations.into_iter().collect())
292    }
293
294    /// Returns a shape that accepts any JSON value (including [`ShapeCase::None`]
295    /// and [`ShapeCase::Unknown`]), and is not accepted by any shape other than itself.
296    #[must_use]
297    pub fn unknown(locations: impl IntoIterator<Item = Location>) -> Self {
298        Self::new(ShapeCase::Unknown, locations)
299    }
300
301    #[must_use]
302    pub fn is_unknown(&self) -> bool {
303        matches!(self.case(), ShapeCase::Unknown)
304    }
305
306    /// Returns a shape representing the absence of a JSON value, which is
307    /// satisfied/accepted only by itself.
308    ///
309    /// Because this represents the absence of a value, it shouldn't have a location. Basically,
310    /// nothing can produce none alone, and if it were a union, that union would have its own
311    /// location.
312    #[must_use]
313    pub fn none() -> Self {
314        Self::new(ShapeCase::None, [])
315    }
316
317    #[must_use]
318    pub fn is_none(&self) -> bool {
319        self.case.is_none()
320    }
321
322    /// Report a failure of shape processing.
323    #[must_use]
324    pub fn error(
325        message: impl Into<String>,
326        locations: impl IntoIterator<Item = Location>,
327    ) -> Self {
328        Self::new(ShapeCase::error(message.into()), locations)
329    }
330
331    #[must_use]
332    pub fn is_error(&self) -> bool {
333        matches!(self.case(), ShapeCase::Error { .. })
334    }
335
336    /// Iterate over all errors within this shape, recursively
337    pub fn errors(&self) -> impl Iterator<Item = &Error> {
338        self.case.errors()
339    }
340
341    /// Report a failure of shape processing associated with a
342    /// partial/best-guess shape that may still be useful.
343    #[must_use]
344    pub fn error_with_partial(
345        message: impl Into<String>,
346        partial: Shape,
347        locations: impl IntoIterator<Item = Location>,
348    ) -> Self {
349        Self::new(
350            ShapeCase::error_with_partial(message.into(), partial),
351            locations,
352        )
353    }
354
355    /// Clone the shape, adding the provided `locations` to the existing locations.
356    #[must_use]
357    pub fn with_locations(&self, locations: impl IntoIterator<Item = Location>) -> Self {
358        let mut res = self.clone();
359        res.locations.extend(locations);
360        res
361    }
362}
363
364#[cfg(test)]
365mod test_errors {
366    use super::*;
367
368    #[test]
369    fn multiple_errors_in_array() {
370        let shape = Shape::tuple(
371            [
372                Shape::int([]),
373                Shape::error("Expected a string", []),
374                Shape::bool([]),
375                Shape::error("Expected a null", []),
376            ],
377            [],
378        );
379        let errors: Vec<_> = shape.errors().collect();
380        assert_eq!(errors.len(), 2);
381        assert_eq!(errors[0].message, "Expected a string");
382        assert_eq!(errors[1].message, "Expected a null");
383    }
384
385    #[test]
386    fn nested_errors() {
387        let shape = Shape::record(
388            [
389                ("a".to_string(), Shape::int([])),
390                ("b".to_string(), Shape::error("Expected a string", [])),
391                ("c".to_string(), Shape::bool([])),
392                (
393                    "d".to_string(),
394                    Shape::record(
395                        [
396                            ("e".to_string(), Shape::error("Expected a null", [])),
397                            ("f".to_string(), Shape::float([])),
398                        ]
399                        .into_iter()
400                        .collect(),
401                        [],
402                    ),
403                ),
404            ]
405            .into_iter()
406            .collect(),
407            [],
408        );
409
410        let errors: Vec<_> = shape.errors().collect();
411        assert_eq!(errors.len(), 2);
412        assert_eq!(errors[0].message, "Expected a string");
413        assert_eq!(errors[1].message, "Expected a null");
414    }
415}