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}