Skip to main content

yaml_schema/
lib.rs

1//! yaml-schema is a library for validating YAML data against a JSON Schema.
2
3use hashlink::LinkedHashMap;
4use saphyr::MarkedYaml;
5use saphyr::Scalar;
6use saphyr::YamlData;
7
8#[macro_use]
9pub mod error;
10pub mod engine;
11pub mod loader;
12pub mod reference;
13pub mod schemas;
14pub mod utils;
15pub mod validation;
16
17pub use engine::Engine;
18pub use error::Error;
19pub use reference::RefUri;
20pub use reference::Reference;
21pub use schemas::RootSchema;
22pub use schemas::YamlSchema;
23pub use validation::Context;
24pub use validation::Validator;
25
26use utils::format_marker;
27
28use crate::loader::marked_yaml_to_string;
29
30// Returns the library version, which reflects the crate version
31pub fn version() -> String {
32    clap::crate_version!().to_string()
33}
34
35// Alias for std::result::Result<T, yaml_schema::Error>
36pub type Result<T> = std::result::Result<T, Error>;
37
38/// A Number is either an integer or a float
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub enum Number {
41    Integer(i64),
42    Float(f64),
43}
44
45impl Number {
46    /// Create a new integer Number
47    pub fn integer(value: i64) -> Number {
48        Number::Integer(value)
49    }
50
51    /// Create a new float Number
52    pub fn float(value: f64) -> Number {
53        Number::Float(value)
54    }
55
56    pub fn to_f64(self) -> f64 {
57        match self {
58            Number::Integer(i) => i as f64,
59            Number::Float(f) => f,
60        }
61    }
62
63    pub fn is_multiple_of(self, divisor: Number) -> bool {
64        match (self, divisor) {
65            (Number::Integer(a), Number::Integer(b)) => b != 0 && a % b == 0,
66            _ => {
67                let d = divisor.to_f64();
68                d != 0.0 && self.to_f64() % d == 0.0
69            }
70        }
71    }
72}
73
74impl PartialOrd for Number {
75    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
76        match (self, other) {
77            (Number::Integer(a), Number::Integer(b)) => a.partial_cmp(b),
78            _ => self.to_f64().partial_cmp(&other.to_f64()),
79        }
80    }
81}
82
83impl TryFrom<&MarkedYaml<'_>> for Number {
84    type Error = Error;
85    fn try_from(value: &MarkedYaml) -> Result<Number> {
86        if let YamlData::Value(scalar) = &value.data {
87            match scalar {
88                Scalar::Integer(i) => Ok(Number::integer(*i)),
89                Scalar::FloatingPoint(o) => Ok(Number::float(o.into_inner())),
90                _ => Err(generic_error!(
91                    "{} Expected type: integer or float, but got: {:?}",
92                    format_marker(&value.span.start),
93                    value
94                )),
95            }
96        } else {
97            Err(generic_error!(
98                "{} Expected scalar, but got: {:?}",
99                format_marker(&value.span.start),
100                value
101            ))
102        }
103    }
104}
105
106impl std::fmt::Display for Number {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        match self {
109            Number::Integer(v) => write!(f, "{v}"),
110            Number::Float(v) => write!(f, "{v}"),
111        }
112    }
113}
114
115/// A ConstValue represents a constant value for the `const` keyword.
116/// Per JSON Schema, `const` can be any JSON value: null, boolean, number,
117/// string, array, or object.
118#[derive(Debug, PartialEq)]
119pub enum ConstValue {
120    Null,
121    Boolean(bool),
122    Number(Number),
123    String(String),
124    Array(Vec<ConstValue>),
125    Object(LinkedHashMap<String, ConstValue>),
126}
127
128impl ConstValue {
129    pub fn null() -> ConstValue {
130        ConstValue::Null
131    }
132    pub fn boolean(value: bool) -> ConstValue {
133        ConstValue::Boolean(value)
134    }
135    pub fn integer(value: i64) -> ConstValue {
136        ConstValue::Number(Number::integer(value))
137    }
138    pub fn float(value: f64) -> ConstValue {
139        ConstValue::Number(Number::float(value))
140    }
141    pub fn string<V: Into<String>>(value: V) -> ConstValue {
142        ConstValue::String(value.into())
143    }
144
145    pub fn accepts(&self, value: &saphyr::MarkedYaml) -> bool {
146        match self {
147            ConstValue::Null => matches!(&value.data, YamlData::Value(Scalar::Null)),
148            ConstValue::Boolean(expected) => {
149                matches!(&value.data, YamlData::Value(Scalar::Boolean(actual)) if *expected == *actual)
150            }
151            ConstValue::Number(number) => match (number, &value.data) {
152                (Number::Integer(expected), YamlData::Value(Scalar::Integer(actual))) => {
153                    *actual == *expected
154                }
155                (Number::Float(expected), YamlData::Value(Scalar::FloatingPoint(of))) => {
156                    of.into_inner() == *expected
157                }
158                _ => false,
159            },
160            ConstValue::String(expected) => {
161                matches!(&value.data, YamlData::Value(Scalar::String(actual)) if expected == actual.as_ref())
162            }
163            ConstValue::Array(expected) => {
164                if let YamlData::Sequence(actual) = &value.data {
165                    expected.len() == actual.len()
166                        && expected
167                            .iter()
168                            .zip(actual.iter())
169                            .all(|(exp, act)| exp.accepts(act))
170                } else {
171                    false
172                }
173            }
174            ConstValue::Object(expected) => {
175                if let YamlData::Mapping(actual) = &value.data {
176                    expected.len() == actual.len()
177                        && expected.iter().all(|(key, exp_val)| {
178                            let key_yaml = MarkedYaml::value_from_str(key);
179                            actual
180                                .get(&key_yaml)
181                                .is_some_and(|act_yaml| exp_val.accepts(act_yaml))
182                        })
183                } else {
184                    false
185                }
186            }
187        }
188    }
189}
190
191impl TryFrom<&Scalar<'_>> for ConstValue {
192    type Error = crate::Error;
193
194    fn try_from(scalar: &Scalar) -> std::result::Result<ConstValue, Self::Error> {
195        match scalar {
196            Scalar::Null => Ok(ConstValue::Null),
197            Scalar::Boolean(b) => Ok(ConstValue::Boolean(*b)),
198            Scalar::Integer(i) => Ok(ConstValue::Number(Number::integer(*i))),
199            Scalar::FloatingPoint(o) => Ok(ConstValue::Number(Number::float(o.into_inner()))),
200            Scalar::String(s) => Ok(ConstValue::String(s.to_string())),
201        }
202    }
203}
204
205impl<'a> TryFrom<&YamlData<'a, MarkedYaml<'a>>> for ConstValue {
206    type Error = crate::Error;
207
208    fn try_from(value: &YamlData<'a, MarkedYaml<'a>>) -> Result<Self> {
209        match value {
210            YamlData::Value(scalar) => scalar.try_into(),
211            YamlData::Sequence(seq) => {
212                let arr = seq
213                    .iter()
214                    .map(|item| item.try_into())
215                    .collect::<Result<Vec<_>>>()?;
216                Ok(ConstValue::Array(arr))
217            }
218            YamlData::Mapping(mapping) => {
219                let mut obj = LinkedHashMap::new();
220                for (key, val) in mapping.iter() {
221                    let key_str = marked_yaml_to_string(key, "const object key must be a string")?;
222                    let val_cv: ConstValue = val.try_into()?;
223                    obj.insert(key_str, val_cv);
224                }
225                Ok(ConstValue::Object(obj))
226            }
227            YamlData::Tagged(_, inner) => (&inner.data).try_into(),
228            YamlData::Representation(_, _, _) | YamlData::Alias(_) | YamlData::BadValue => Err(
229                generic_error!("Unsupported YamlData variant for const: {:?}", value),
230            ),
231        }
232    }
233}
234
235impl<'a> TryFrom<&MarkedYaml<'a>> for ConstValue {
236    type Error = crate::Error;
237    fn try_from(value: &MarkedYaml<'a>) -> Result<ConstValue> {
238        (&value.data).try_into()
239    }
240}
241
242impl std::fmt::Display for ConstValue {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            ConstValue::Boolean(b) => write!(f, "{b} (bool)"),
246            ConstValue::Null => write!(f, "null"),
247            ConstValue::Number(n) => write!(f, "{n} (number)"),
248            ConstValue::String(s) => write!(f, "\"{s}\""),
249            ConstValue::Array(arr) => {
250                write!(f, "[")?;
251                for (i, v) in arr.iter().enumerate() {
252                    if i > 0 {
253                        write!(f, ", ")?;
254                    }
255                    write!(f, "{v}")?;
256                }
257                write!(f, "]")
258            }
259            ConstValue::Object(obj) => {
260                write!(f, "{{")?;
261                for (i, (k, v)) in obj.iter().enumerate() {
262                    if i > 0 {
263                        write!(f, ", ")?;
264                    }
265                    write!(f, "\"{k}\": {v}")?;
266                }
267                write!(f, "}}")
268            }
269        }
270    }
271}
272
273/// Use the ctor crate to initialize the logger for tests
274#[cfg(test)]
275#[ctor::ctor]
276fn init() {
277    env_logger::builder()
278        .filter_level(log::LevelFilter::Trace)
279        .format_target(false)
280        .format_timestamp_secs()
281        .target(env_logger::Target::Stdout)
282        .init();
283}
284
285#[cfg(test)]
286mod tests {
287    use saphyr::LoadableYamlNode;
288
289    use super::*;
290    use ordered_float::OrderedFloat;
291
292    #[test]
293    fn test_const_equality() {
294        let i1 = ConstValue::integer(42);
295        let i2 = ConstValue::integer(42);
296        assert_eq!(i1, i2);
297
298        let s1 = ConstValue::string("NW");
299        let s2 = ConstValue::string("NW");
300        assert_eq!(s1, s2);
301    }
302
303    #[test]
304    #[allow(clippy::approx_constant)]
305    fn test_scalar_to_constvalue() -> Result<()> {
306        let scalars = [
307            Scalar::Null,
308            Scalar::Boolean(true),
309            Scalar::Boolean(false),
310            Scalar::Integer(42),
311            Scalar::Integer(-1),
312            Scalar::FloatingPoint(OrderedFloat::from(3.14)),
313            Scalar::String("foo".into()),
314        ];
315
316        let expected = [
317            ConstValue::Null,
318            ConstValue::Boolean(true),
319            ConstValue::Boolean(false),
320            ConstValue::Number(Number::Integer(42)),
321            ConstValue::Number(Number::Integer(-1)),
322            ConstValue::Number(Number::Float(3.14)),
323            ConstValue::String("foo".to_string()),
324        ];
325
326        for (scalar, expected) in scalars.iter().zip(expected.iter()) {
327            let actual: ConstValue = scalar.try_into()?;
328            assert_eq!(*expected, actual);
329        }
330
331        Ok(())
332    }
333
334    #[test]
335    fn test_const_value_array_try_from() -> Result<()> {
336        let docs = MarkedYaml::load_from_str("[1, 2, 3]").unwrap();
337        let cv: ConstValue = docs.first().unwrap().try_into()?;
338        assert_eq!(
339            cv,
340            ConstValue::Array(vec![
341                ConstValue::integer(1),
342                ConstValue::integer(2),
343                ConstValue::integer(3),
344            ])
345        );
346        Ok(())
347    }
348
349    #[test]
350    fn test_const_value_object_try_from() -> Result<()> {
351        let docs = MarkedYaml::load_from_str("a: 1\nb: two").unwrap();
352        let cv: ConstValue = docs.first().unwrap().try_into()?;
353        let mut expected = LinkedHashMap::new();
354        expected.insert("a".into(), ConstValue::integer(1));
355        expected.insert("b".into(), ConstValue::string("two"));
356        assert_eq!(cv, ConstValue::Object(expected));
357        Ok(())
358    }
359
360    #[test]
361    fn test_const_value_accepts_array() -> Result<()> {
362        let cv = ConstValue::Array(vec![ConstValue::integer(1), ConstValue::string("foo")]);
363        let matching = MarkedYaml::load_from_str("[1, \"foo\"]").unwrap();
364        let not_matching = MarkedYaml::load_from_str("[1, \"bar\"]").unwrap();
365        assert!(cv.accepts(matching.first().unwrap()));
366        assert!(!cv.accepts(not_matching.first().unwrap()));
367        Ok(())
368    }
369
370    #[test]
371    fn test_const_value_accepts_object() -> Result<()> {
372        let mut obj = LinkedHashMap::new();
373        obj.insert("x".into(), ConstValue::integer(42));
374        obj.insert("y".into(), ConstValue::string("hi"));
375        let cv = ConstValue::Object(obj);
376        let matching = MarkedYaml::load_from_str("x: 42\ny: hi").unwrap();
377        let not_matching = MarkedYaml::load_from_str("x: 43\ny: hi").unwrap();
378        assert!(cv.accepts(matching.first().unwrap()));
379        assert!(!cv.accepts(not_matching.first().unwrap()));
380        Ok(())
381    }
382}