Skip to main content

hocon/adapters/
yaml.rs

1//! YAML documents as HOCON config.
2//!
3//! This is a HOCON library, not a YAML implementation, and the API keeps that
4//! boundary. What this module owns is the decoded-tree → HOCON step, exposed
5//! directly as [`from_value`]: root must be a mapping, `${...}` stays literal,
6//! NaN and infinity are refused, a multi-document stream is refused. How YAML
7//! *text* becomes a tree — whether `010` is 8 or 10, whether `no` is a boolean
8//! — is the YAML library's answer, not a contract here.
9//!
10//! [`parse`] is a convenience front on `yaml-rust2`. A caller who needs a
11//! different library, version or schema decodes the text themselves and hands
12//! the tree to [`from_value`]; that is the supported way to swap parsers, and
13//! it keeps the choice, and its consequences, in the caller's hands.
14
15use indexmap::IndexMap;
16use yaml_rust2::{Yaml, YamlLoader};
17
18use super::{config_from_object, AdapterError};
19use crate::value::{HoconValue, ScalarValue};
20use crate::Config;
21
22/// Read YAML text with this module's default library.
23pub fn parse(input: &str, origin: Option<&str>) -> Result<Config, AdapterError> {
24    let docs = YamlLoader::load_from_str(super::strip_bom(input))
25        .map_err(|e| AdapterError::new(format!("yaml: {e}")))?;
26    if docs.len() > 1 {
27        return Err(AdapterError::new(
28            "yaml: multi-document streams are not supported (spec F5.7); a config is one document",
29        ));
30    }
31    match docs.into_iter().next() {
32        // An empty document is the empty object, as an empty HOCON document is
33        // (S3.1), rather than a root-type failure (spec F5.9).
34        None => Ok(config_from_object(
35            HoconValue::Object(IndexMap::new()),
36            origin,
37        )),
38        Some(doc) => from_value(&doc, origin),
39    }
40}
41
42/// Read a YAML file, using its path as the origin description.
43pub fn parse_file(path: impl AsRef<std::path::Path>) -> Result<Config, AdapterError> {
44    let path = path.as_ref();
45    let text = std::fs::read_to_string(path)
46        .map_err(|e| AdapterError::new(format!("yaml: {}: {e}", path.display())))?;
47    parse(&text, Some(&path.display().to_string()))
48}
49
50/// Build a `Config` from an already-decoded YAML tree, produced by whatever
51/// library and settings the caller chose. This is the tree-level boundary this
52/// module owns; [`parse`] is just a default decoder in front of it.
53pub fn from_value(doc: &Yaml, origin: Option<&str>) -> Result<Config, AdapterError> {
54    if matches!(doc, Yaml::Null | Yaml::BadValue) {
55        return Ok(config_from_object(
56            HoconValue::Object(IndexMap::new()),
57            origin,
58        ));
59    }
60    let Yaml::Hash(_) = doc else {
61        return Err(AdapterError::new(format!(
62            "yaml: document root is {}, but a config root must be a mapping (spec F0.3)",
63            describe(doc)
64        )));
65    };
66    let value = convert(doc, "")?;
67    Ok(config_from_object(value, origin))
68}
69
70fn describe(v: &Yaml) -> &'static str {
71    match v {
72        Yaml::Array(_) => "a sequence",
73        Yaml::Hash(_) => "a mapping",
74        Yaml::Null => "null",
75        _ => "a scalar",
76    }
77}
78
79fn convert(v: &Yaml, at: &str) -> Result<HoconValue, AdapterError> {
80    match v {
81        Yaml::Hash(h) => {
82            let mut out: IndexMap<String, HoconValue> = IndexMap::new();
83            // `yaml-rust2` leaves a merge key as an ordinary `<<` entry, so
84            // merging is ours to do — a `<<` leaking through as a field would
85            // be a structural difference, which this spec does own (F5.2).
86            // Explicit keys win over merged ones.
87            let mut merged: IndexMap<String, HoconValue> = IndexMap::new();
88            for (k, e) in h {
89                if matches!(k, Yaml::String(s) if s == "<<") {
90                    collect_merge(e, at, &mut merged)?;
91                    continue;
92                }
93                let ks = key_string(k, at)?;
94                let path = if at.is_empty() {
95                    ks.clone()
96                } else {
97                    format!("{at}.{ks}")
98                };
99                out.insert(ks, convert(e, &path)?);
100            }
101            for (k, e) in merged {
102                out.entry(k).or_insert(e);
103            }
104            Ok(HoconValue::Object(out))
105        }
106        Yaml::Array(items) => {
107            let mut out = Vec::with_capacity(items.len());
108            for (i, e) in items.iter().enumerate() {
109                out.push(convert(e, &format!("{at}[{i}]"))?);
110            }
111            Ok(HoconValue::Array(out))
112        }
113        Yaml::String(s) => Ok(HoconValue::Scalar(ScalarValue::string(s.clone()))),
114        Yaml::Boolean(b) => Ok(HoconValue::Scalar(ScalarValue::boolean(*b))),
115        Yaml::Integer(i) => Ok(HoconValue::Scalar(ScalarValue::number(i.to_string()))),
116        Yaml::Real(raw) => {
117            // YAML spells these `.nan`, `.inf`, `-.inf`, none of which Rust's
118            // f64 parser accepts, so check the text before parsing (F0.6).
119            let lower = raw.to_ascii_lowercase();
120            if lower.ends_with(".nan") || lower.ends_with(".inf") {
121                return Err(AdapterError::new(format!(
122                    "yaml: at {at}: {raw} is not representable in HOCON (spec F0.6)"
123                )));
124            }
125            let f: f64 = raw.parse().map_err(|_| {
126                AdapterError::new(format!("yaml: at {at}: {raw:?} is not a number"))
127            })?;
128            if f.is_nan() || f.is_infinite() {
129                return Err(AdapterError::new(format!(
130                    "yaml: at {at}: {raw} is not representable in HOCON (spec F0.6)"
131                )));
132            }
133            Ok(HoconValue::Scalar(ScalarValue::number(raw.clone())))
134        }
135        Yaml::Null => Ok(HoconValue::Scalar(ScalarValue::null())),
136        Yaml::Alias(_) | Yaml::BadValue => Err(AdapterError::new(format!(
137            "yaml: at {at}: unresolved node in the decoded tree"
138        ))),
139    }
140}
141
142fn collect_merge(
143    e: &Yaml,
144    at: &str,
145    into: &mut IndexMap<String, HoconValue>,
146) -> Result<(), AdapterError> {
147    match e {
148        Yaml::Hash(_) => {
149            if let HoconValue::Object(fields) = convert(e, at)? {
150                for (k, v) in fields {
151                    into.entry(k).or_insert(v);
152                }
153            }
154            Ok(())
155        }
156        // A sequence of mappings merges left to right, earlier winning.
157        Yaml::Array(items) => {
158            for item in items {
159                collect_merge(item, at, into)?;
160            }
161            Ok(())
162        }
163        _ => Err(AdapterError::new(format!(
164            "yaml: at {at}: a merge key must reference a mapping (spec F5.2)"
165        ))),
166    }
167}
168
169/// Non-string scalar keys map to their string forms; a collection key is an
170/// error (spec F5.3).
171fn key_string(k: &Yaml, at: &str) -> Result<String, AdapterError> {
172    match k {
173        Yaml::String(s) => Ok(s.clone()),
174        Yaml::Integer(i) => Ok(i.to_string()),
175        Yaml::Real(r) => Ok(r.clone()),
176        Yaml::Boolean(b) => Ok(b.to_string()),
177        Yaml::Null => Ok("null".to_string()),
178        _ => Err(AdapterError::new(format!(
179            "yaml: at {at}: a collection key is not usable as an object key (spec F5.3)"
180        ))),
181    }
182}