Skip to main content

tanzim_parse/
yaml.rs

1//! YAML parser (`yaml` feature).
2//!
3//! **Formats:** `yml`, `yaml`
4//!
5//! # Example
6//!
7//! ```
8//! use tanzim_parse::{Deserialize, Yaml};
9//!
10//! let value = Yaml::new().parse("file", "config.yaml", b"host: 127.0.0.1\n").unwrap();
11//! assert_eq!(
12//!     value.value.as_map().unwrap().get("host").unwrap().value.as_string().unwrap(),
13//!     "127.0.0.1"
14//! );
15//! ```
16
17use crate::Deserialize;
18use crate::span::is_single_line;
19use cfg_if::cfg_if;
20use saphyr::{LoadableYamlNode, MarkedYaml, Scalar, YamlData};
21use tanzim_value::{Error, LocatedValue, Location, Map, Value};
22
23#[derive(Default, Copy, Clone)]
24pub struct Yaml;
25
26impl Yaml {
27    pub fn new() -> Self {
28        Self
29    }
30}
31
32impl Deserialize for Yaml {
33    fn name(&self) -> &str {
34        "YAML"
35    }
36
37    fn supported_format_list(&self) -> Vec<String> {
38        vec!["yml".into(), "yaml".into()]
39    }
40
41    fn parse(&self, source: &str, resource: &str, bytes: &[u8]) -> Result<LocatedValue, Error> {
42        cfg_if! {
43            if #[cfg(feature = "tracing")] {
44                tracing::debug!(msg = "Parsing YAML configuration", source = source, resource = resource, bytes = bytes.len());
45            } else if #[cfg(feature = "logging")] {
46                log::debug!("msg=\"Parsing YAML configuration\" source={source} resource={resource} bytes={}", bytes.len());
47            }
48        }
49        let text = match std::str::from_utf8(bytes) {
50            Ok(value) => value,
51            Err(_) => {
52                return Err(Error::InvalidUtf8 {
53                    location: Location::at(source, resource, None, None, None),
54                });
55            }
56        };
57        let single_line = is_single_line(bytes);
58        let docs = match MarkedYaml::load_from_str(text) {
59            Ok(value) => value,
60            Err(error) => {
61                let marker = error.marker();
62                return Err(Error::Parse {
63                    text: text.to_string(),
64                    location: Some(Location::at(
65                        source,
66                        resource,
67                        Some(marker.line()),
68                        Some(marker.col() + 1),
69                        None,
70                    )),
71                    message: error.info().to_string(),
72                });
73            }
74        };
75        if docs.is_empty() {
76            cfg_if! {
77                if #[cfg(feature = "tracing")] {
78                    tracing::trace!(msg = "Parsed YAML configuration (empty document)", source = source, resource = resource);
79                } else if #[cfg(feature = "logging")] {
80                    log::trace!("msg=\"Parsed YAML configuration (empty document)\" source={source} resource={resource}");
81                }
82            }
83            return Ok(LocatedValue {
84                value: Value::Map(Map::new()),
85                location: Location::at(source, resource, None, None, None),
86            });
87        }
88        let result = convert_node(source, resource, text, single_line, &docs[0]);
89        if result.is_ok() {
90            cfg_if! {
91                if #[cfg(feature = "tracing")] {
92                    tracing::trace!(msg = "Parsed YAML configuration", source = source, resource = resource);
93                } else if #[cfg(feature = "logging")] {
94                    log::trace!("msg=\"Parsed YAML configuration\" source={source} resource={resource}");
95                }
96            }
97        }
98        result
99    }
100
101    fn is_format_supported(&self, bytes: &[u8]) -> Option<bool> {
102        match std::str::from_utf8(bytes) {
103            Ok(text) => Some(MarkedYaml::load_from_str(text).is_ok()),
104            Err(_) => Some(false),
105        }
106    }
107}
108
109fn convert_node(
110    source: &str,
111    resource: &str,
112    text: &str,
113    single_line: bool,
114    node: &MarkedYaml<'_>,
115) -> Result<LocatedValue, Error> {
116    let location = if single_line {
117        Location::at(source, resource, None, None, None)
118    } else {
119        let marker = node.span.start;
120        let length = if !node.span.is_empty() {
121            Some(node.span.len())
122        } else {
123            None
124        };
125        Location::at(
126            source,
127            resource,
128            Some(marker.line()),
129            Some(marker.col() + 1),
130            length,
131        )
132    };
133    match &node.data {
134        YamlData::Value(scalar) => match scalar {
135            Scalar::Null => Err(Error::UnsupportedNull {
136                text: text.to_string(),
137                location,
138            }),
139            Scalar::Boolean(value) => Ok(LocatedValue {
140                value: Value::Bool(*value),
141                location,
142            }),
143            Scalar::Integer(value) => Ok(LocatedValue {
144                value: Value::Int(*value as isize),
145                location,
146            }),
147            Scalar::FloatingPoint(value) => Ok(LocatedValue {
148                value: Value::Float(value.into_inner()),
149                location,
150            }),
151            Scalar::String(value) => Ok(LocatedValue {
152                value: Value::String(value.to_string()),
153                location,
154            }),
155        },
156        YamlData::Sequence(sequence) => {
157            let mut list = Vec::new();
158            for node in sequence {
159                list.push(convert_node(source, resource, text, single_line, node)?);
160            }
161            Ok(LocatedValue {
162                value: Value::List(list),
163                location,
164            })
165        }
166        YamlData::Mapping(mapping) => {
167            let mut map = Map::new();
168            for (key_node, value_node) in mapping {
169                let key = match &key_node.data {
170                    YamlData::Value(Scalar::String(value)) => value.to_string(),
171                    YamlData::Representation(value, _, _) => value.to_string(),
172                    _ => {
173                        return Err(Error::Parse {
174                            text: String::new(),
175                            location: None,
176                            message: "yaml map key must be a string".to_string(),
177                        });
178                    }
179                };
180                let value = convert_node(source, resource, text, single_line, value_node)?;
181                map.insert(key, value);
182            }
183            Ok(LocatedValue {
184                value: Value::Map(map),
185                location,
186            })
187        }
188        YamlData::Tagged(_, inner) => convert_node(source, resource, text, single_line, inner),
189        YamlData::Representation(representation, _, _) => {
190            if representation == "~" || representation == "null" || representation == "Null" {
191                return Err(Error::UnsupportedNull {
192                    text: text.to_string(),
193                    location,
194                });
195            }
196            Ok(LocatedValue {
197                value: Value::String(representation.to_string()),
198                location,
199            })
200        }
201        YamlData::Alias(_) | YamlData::BadValue => Err(Error::Parse {
202            text: text.to_string(),
203            location: Some(location),
204            message: "unsupported yaml node".to_string(),
205        }),
206    }
207}
208
209#[cfg(all(test, feature = "yaml"))]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn parses_yaml_map() {
215        let parsed = Yaml::new()
216            .parse("file", "config.yaml", b"hello: world\n")
217            .unwrap();
218        assert_eq!(
219            parsed
220                .value
221                .as_map()
222                .unwrap()
223                .get("hello")
224                .unwrap()
225                .value
226                .as_string()
227                .unwrap(),
228            "world"
229        );
230    }
231
232    #[test]
233    fn parses_yaml_map_with_lines() {
234        let root = Yaml::new()
235            .parse("file", "config.yaml", b"foo: bar\nbaz: qux\n")
236            .unwrap();
237        let map = root.value.as_map().unwrap();
238        let foo = map.get("foo").unwrap();
239        assert_eq!(foo.value.as_string().unwrap(), "bar");
240        assert_eq!(foo.location.line, std::num::NonZeroU32::new(1));
241        let baz = map.get("baz").unwrap();
242        assert_eq!(baz.location.line, std::num::NonZeroU32::new(2));
243    }
244
245    #[test]
246    fn rejects_yaml_null_at_correct_column() {
247        let text = "foo: bar\n\nbaz:\n\n  qux: ~\n";
248        let error = Yaml::new()
249            .parse("file", "config.yaml", text.as_bytes())
250            .unwrap_err();
251        if let Error::UnsupportedNull { location, .. } = &error {
252            assert_eq!(location.line, std::num::NonZeroU32::new(5));
253            assert_eq!(location.column, std::num::NonZeroU32::new(8));
254            assert_eq!(location.length, std::num::NonZeroU32::new(1));
255        } else {
256            panic!("expected unsupported null");
257        }
258        let message = format!("{error:#}");
259        let mut source_line = "";
260        for line in message.split('\n') {
261            if line.contains("qux: ~") {
262                source_line = line;
263                break;
264            }
265        }
266        let mut caret_line = "";
267        for line in message.split('\n') {
268            if line.contains('^') {
269                caret_line = line;
270                break;
271            }
272        }
273        let mut tilde_column = 0usize;
274        if let Some(after_pipe) = source_line.split('|').nth(1) {
275            let mut index = 0usize;
276            let mut byte_index = 0usize;
277            while byte_index < after_pipe.len() {
278                let ch = after_pipe[byte_index..]
279                    .chars()
280                    .next()
281                    .expect("valid utf-8");
282                if ch == '~' {
283                    tilde_column = index;
284                    break;
285                }
286                index += 1;
287                byte_index += ch.len_utf8();
288            }
289        }
290        let mut caret_column = 0usize;
291        if let Some(after_pipe) = caret_line.split('|').nth(1) {
292            let mut index = 0usize;
293            let mut byte_index = 0usize;
294            while byte_index < after_pipe.len() {
295                let ch = after_pipe[byte_index..]
296                    .chars()
297                    .next()
298                    .expect("valid utf-8");
299                if ch == '^' {
300                    caret_column = index;
301                    break;
302                }
303                index += 1;
304                byte_index += ch.len_utf8();
305            }
306        }
307        assert_eq!(caret_column, tilde_column);
308    }
309
310    #[test]
311    fn syntax_error_has_location() {
312        let error = Yaml::new()
313            .parse("file", "config.yaml", b"foo: [\n")
314            .unwrap_err();
315        if let Error::Parse { location, .. } = &error {
316            let location = location.as_ref().expect("syntax error location");
317            assert!(location.line.is_some());
318            assert!(location.column.is_some());
319        } else {
320            panic!("expected parse error");
321        }
322        let message = format!("{error:#}");
323        assert!(message.contains('^'));
324    }
325}