Skip to main content

yaml_schema/
utils.rs

1//! Various utility functions
2
3use crate::Result;
4use hashlink::linked_hash_map;
5use saphyr::{MarkedYaml, Scalar, YamlData};
6use std::borrow::Cow;
7use std::collections::HashMap;
8use std::hash::Hash;
9
10/// Create and return a HashMap with a single key & value
11pub fn hash_map<K, V>(key: K, value: V) -> HashMap<K, V>
12where
13    K: Hash + Eq + Clone,
14{
15    let mut hash_map = HashMap::with_capacity(1);
16    hash_map.insert(key, value);
17    hash_map
18}
19
20/// Create and return a LinkedHashMap with a single key & value
21pub fn linked_hash_map<K, V>(key: K, value: V) -> linked_hash_map::LinkedHashMap<K, V>
22where
23    K: Hash + Eq + Clone,
24{
25    let mut linked_hash_map = linked_hash_map::LinkedHashMap::new();
26    linked_hash_map.insert(key, value);
27    linked_hash_map
28}
29
30/// Construct a saphyr::Yaml scalar value from a &str
31pub const fn saphyr_yaml_string(s: &str) -> saphyr::Yaml<'_> {
32    saphyr::Yaml::Value(saphyr::Scalar::String(Cow::Borrowed(s)))
33}
34
35/// Try to unwrap a saphyr::Scalar from a saphyr::Yaml
36pub fn try_unwrap_saphyr_scalar<'a>(yaml: &'a saphyr::Yaml) -> Result<&'a saphyr::Scalar<'a>> {
37    if let saphyr::Yaml::Value(scalar) = yaml {
38        Ok(scalar)
39    } else {
40        Err(expected_scalar!("Expected a scalar, got: {:?}", yaml))
41    }
42}
43
44/// Converts a saphyr::Scalar value to a String. Does NOT enclose Scalar::String values in
45/// double-quotes.
46pub fn scalar_to_string(scalar: &saphyr::Scalar) -> String {
47    match scalar {
48        saphyr::Scalar::Null => "null".to_string(),
49        saphyr::Scalar::Boolean(b) => b.to_string(),
50        saphyr::Scalar::Integer(i) => i.to_string(),
51        saphyr::Scalar::FloatingPoint(o) => o.to_string(),
52        saphyr::Scalar::String(s) => s.to_string(),
53    }
54}
55
56/// Formats a saphyr::Scalar as a string. Encloses Scalar::String values in double quotes (`"`)
57pub fn format_scalar(scalar: &saphyr::Scalar) -> String {
58    match scalar {
59        saphyr::Scalar::String(s) => format!("\"{s}\""),
60        _ => scalar_to_string(scalar),
61    }
62}
63
64pub fn format_marked_yaml(marked_yaml: &saphyr::MarkedYaml) -> String {
65    format!(
66        "{} {}",
67        format_marker(&marked_yaml.span.start),
68        format_yaml_data(&marked_yaml.data)
69    )
70}
71
72pub fn format_annotated_mapping(
73    mapping: &saphyr::AnnotatedMapping<'_, saphyr::MarkedYaml<'_>>,
74) -> String {
75    let items: Vec<String> = mapping
76        .iter()
77        .map(|(k, v)| format!("{}: {}", format_yaml_data(&k.data), format_marked_yaml(v)))
78        .collect();
79    format!("{{ {} }}", items.join(", "))
80}
81
82/// Formats a saphyr::YamlData as a string
83pub fn format_yaml_data<'a>(data: &saphyr::YamlData<'a, saphyr::MarkedYaml<'a>>) -> String {
84    match data {
85        saphyr::YamlData::Value(scalar) => format_scalar(scalar),
86        saphyr::YamlData::Sequence(seq) => {
87            let items: Vec<String> = seq
88                .iter()
89                .map(|marked_yaml| format_marked_yaml(marked_yaml))
90                .collect();
91            format!("[{}]", items.join(", "))
92        }
93        saphyr::YamlData::Mapping(mapping) => format_annotated_mapping(mapping),
94        _ => format!("<unsupported type: {data:?}>"),
95    }
96}
97
98/// Formats a saphyr::Marker as a string. Displays the line and column as a pair of numbers, separated by a comma.
99pub fn format_marker(marker: &saphyr::Marker) -> String {
100    format!("[{}, {}]", marker.line(), marker.col())
101}
102
103/// Formats [`YamlData`] for human-readable type-mismatch messages in validation errors. Scalar
104/// kinds get a short type suffix; other shapes use [`Debug`] like the previous `{:?}` output.
105///
106/// # Examples
107///
108/// ```
109/// use std::borrow::Cow;
110/// use ordered_float::OrderedFloat;
111/// use saphyr::Scalar;
112/// use saphyr::YamlData;
113/// use yaml_schema::utils::humanize_yaml_data;
114///
115/// let data = YamlData::Value(Scalar::Integer(42));
116/// assert_eq!(humanize_yaml_data(&data), "42 (int)");
117///
118/// let data = YamlData::Value(Scalar::FloatingPoint(OrderedFloat::from(3.14)));
119/// assert_eq!(humanize_yaml_data(&data), "3.14 (float)");
120///
121/// let data = YamlData::Value(Scalar::Boolean(true));
122/// assert_eq!(humanize_yaml_data(&data), "true (bool)");
123///
124/// // Strings are JSON-encoded (quoted, with escapes) and suffixed with `(string)`:
125///
126/// let data = YamlData::Value(Scalar::String(Cow::Borrowed("hello")));
127/// assert_eq!(humanize_yaml_data(&data), r#""hello" (string)"#);
128///
129/// let data = YamlData::Value(Scalar::String(Cow::Borrowed("a\"b")));
130/// assert_eq!(humanize_yaml_data(&data), r#""a\"b" (string)"#);
131///
132/// // `Scalar::Null` and other shapes not given a custom format fall back to [`Debug`]:
133///
134/// let data = YamlData::Value(Scalar::Null);
135/// let s = humanize_yaml_data(&data);
136/// assert!(s.contains("Null"), "{s}");
137/// ```
138pub fn humanize_yaml_data<'input>(data: &YamlData<'input, MarkedYaml<'input>>) -> String {
139    match data {
140        YamlData::Value(Scalar::String(s)) => format!(
141            "{} (string)",
142            serde_json::to_string(s.as_ref()).unwrap_or_else(|_| format!("{:?}", s.as_ref()))
143        ),
144        YamlData::Value(Scalar::Integer(i)) => format!("{i} (int)"),
145        YamlData::Value(Scalar::FloatingPoint(f)) => {
146            let x = f.into_inner();
147            format!(
148                "{} (float)",
149                serde_json::to_string(&x).unwrap_or_else(|_| format!("{x}"))
150            )
151        }
152        YamlData::Value(Scalar::Boolean(b)) => format!("{b} (bool)"),
153        _ => format!("{data:?}"),
154    }
155}
156
157/// Formats a vector of values as a string, by joining them with commas
158pub fn format_vec<V>(vec: &[V]) -> String
159where
160    V: std::fmt::Display,
161{
162    let items: Vec<String> = vec.iter().map(|v| format!("{v}")).collect();
163    format!("[{}]", items.join(", "))
164}
165
166/// Formats a LinkedHashMap as a string, ala JSON
167pub fn format_linked_hash_map<K, V>(
168    linked_hash_map: &linked_hash_map::LinkedHashMap<K, V>,
169) -> String
170where
171    K: AsRef<str>,
172    V: std::fmt::Display,
173{
174    let items: Vec<String> = linked_hash_map
175        .iter()
176        .map(|(k, v)| format!("{}: {}", k.as_ref(), v))
177        .collect();
178    format!("{{ {} }}", items.join(", "))
179}
180
181/// Formats a HashMap as a string, ala JSON
182pub fn format_hash_map<K, V>(hash_map: &HashMap<K, V>) -> String
183where
184    K: AsRef<str>,
185    V: std::fmt::Display,
186{
187    if hash_map.is_empty() {
188        return "{}".to_string();
189    }
190    let items: Vec<String> = hash_map
191        .iter()
192        .map(|(k, v)| format!("\"{}\": {}", k.as_ref(), v))
193        .collect();
194    format!("{{ {} }}", items.join(", "))
195}
196/// Collects the keys of a list of SchemaMetadata implementations into a single slice of strings.
197pub fn collect_keys(a: &'static [&'static str], b: &'static [&'static str]) -> Vec<&'static str> {
198    let mut keys = Vec::with_capacity(a.len() + b.len());
199    keys.extend_from_slice(a);
200    keys.extend_from_slice(b);
201    keys.sort();
202    keys.dedup();
203    keys
204}
205
206/// Filters a saphyr::Mapping and returns a new mapping with only the keys that are in the list.
207pub fn filter_mapping<'a>(
208    mapping: &saphyr::AnnotatedMapping<'a, saphyr::MarkedYaml<'a>>,
209    keys: Vec<&'static str>,
210    override_type: &'a str,
211) -> Result<saphyr::AnnotatedMapping<'a, saphyr::MarkedYaml<'a>>> {
212    let mut filtered_mapping = saphyr::AnnotatedMapping::new();
213    for (k, v) in mapping.iter() {
214        if let YamlData::Value(Scalar::String(key)) = &k.data {
215            if keys.contains(&key.as_ref()) {
216                match key.as_ref() {
217                    "type" => {
218                        filtered_mapping
219                            .insert(k.clone(), MarkedYaml::value_from_str(override_type));
220                    }
221                    _ => {
222                        filtered_mapping.insert(k.clone(), v.clone());
223                    }
224                }
225            }
226        } else {
227            return Err(expected_scalar!("Expected a string key, got: {:?}", k.data));
228        }
229    }
230    Ok(filtered_mapping.into_iter().collect())
231}
232
233#[cfg(test)]
234mod tests {
235    use ordered_float::OrderedFloat;
236    use saphyr::LoadableYamlNode as _;
237    use std::collections::HashMap;
238
239    use super::*;
240
241    #[test]
242    fn test_hash_map() {
243        let expected = vec![("foo".to_string(), "bar".to_string())]
244            .into_iter()
245            .collect::<HashMap<String, String>>();
246
247        let actual = hash_map("foo".to_string(), "bar".to_string());
248        assert_eq!(expected, actual);
249    }
250
251    #[test]
252    #[allow(clippy::approx_constant)]
253    fn test_scalar_to_string() {
254        assert_eq!("null", scalar_to_string(&saphyr::Scalar::Null));
255        assert_eq!("true", scalar_to_string(&saphyr::Scalar::Boolean(true)));
256        assert_eq!("false", scalar_to_string(&saphyr::Scalar::Boolean(false)));
257        assert_eq!("42", scalar_to_string(&saphyr::Scalar::Integer(42)));
258        assert_eq!("-1", scalar_to_string(&saphyr::Scalar::Integer(-1)));
259        assert_eq!(
260            "3.14",
261            scalar_to_string(&saphyr::Scalar::FloatingPoint(OrderedFloat::from(3.14)))
262        );
263        assert_eq!(
264            "foo",
265            scalar_to_string(&saphyr::Scalar::String("foo".into()))
266        );
267    }
268
269    #[test]
270    #[allow(clippy::approx_constant)]
271    fn test_format_scalar() {
272        assert_eq!("null", format_scalar(&saphyr::Scalar::Null));
273        assert_eq!("true", format_scalar(&saphyr::Scalar::Boolean(true)));
274        assert_eq!("false", format_scalar(&saphyr::Scalar::Boolean(false)));
275        assert_eq!("42", format_scalar(&saphyr::Scalar::Integer(42)));
276        assert_eq!("-1", format_scalar(&saphyr::Scalar::Integer(-1)));
277        assert_eq!(
278            "3.14",
279            format_scalar(&saphyr::Scalar::FloatingPoint(OrderedFloat::from(3.14)))
280        );
281        assert_eq!(
282            "\"foo\"",
283            format_scalar(&saphyr::Scalar::String("foo".into()))
284        );
285    }
286
287    #[test]
288    fn test_format_linked_hash_map() {
289        let docs = MarkedYaml::load_from_str("foo: bar").unwrap();
290        let doc = docs.first().expect("Expected a document");
291        let mapping = doc.data.as_mapping().expect("Expected a mapping");
292        let linked_hash_map = mapping
293            .into_iter()
294            .map(|(k, v)| (format_yaml_data(&k.data), format_yaml_data(&v.data)))
295            .collect::<linked_hash_map::LinkedHashMap<String, String>>();
296        assert_eq!(
297            "{ \"foo\": \"bar\" }",
298            format_linked_hash_map(&linked_hash_map)
299        );
300    }
301
302    #[test]
303    fn humanize_yaml_data_integer() {
304        let docs = MarkedYaml::load_from_str("42").unwrap();
305        assert_eq!(humanize_yaml_data(&docs.first().unwrap().data), "42 (int)");
306    }
307
308    #[test]
309    fn humanize_yaml_data_float() {
310        let docs = MarkedYaml::load_from_str("3.14").unwrap();
311        assert_eq!(
312            humanize_yaml_data(&docs.first().unwrap().data),
313            "3.14 (float)"
314        );
315    }
316
317    #[test]
318    fn humanize_yaml_data_boolean() {
319        let docs = MarkedYaml::load_from_str("true").unwrap();
320        assert_eq!(
321            humanize_yaml_data(&docs.first().unwrap().data),
322            "true (bool)"
323        );
324        let docs = MarkedYaml::load_from_str("false").unwrap();
325        assert_eq!(
326            humanize_yaml_data(&docs.first().unwrap().data),
327            "false (bool)"
328        );
329    }
330
331    #[test]
332    fn humanize_yaml_data_string_plain() {
333        let docs = MarkedYaml::load_from_str("hello").unwrap();
334        assert_eq!(
335            humanize_yaml_data(&docs.first().unwrap().data),
336            r#""hello" (string)"#
337        );
338    }
339
340    #[test]
341    fn humanize_yaml_data_string_with_quotes_escaped() {
342        let docs = MarkedYaml::load_from_str(r#""str\" ing""#).unwrap();
343        assert_eq!(
344            humanize_yaml_data(&docs.first().unwrap().data),
345            r#""str\" ing" (string)"#
346        );
347    }
348
349    #[test]
350    fn humanize_yaml_data_non_scalar_uses_debug() {
351        let docs = MarkedYaml::load_from_str("a: 1").unwrap();
352        let s = humanize_yaml_data(&docs.first().unwrap().data);
353        assert!(
354            s.starts_with("Mapping("),
355            "expected Debug fallback for mapping, got {s:?}"
356        );
357    }
358}