Skip to main content

dre_core/
yaml.rs

1//! YAML loading with file/line locations for diagnostics.
2
3use std::path::{Path, PathBuf};
4
5use serde_yaml_ng::Value;
6
7use crate::diag::Diagnostics;
8
9/// A parsed YAML file, kept with its source text so keys can be located for diagnostics.
10#[derive(Debug, Clone)]
11pub struct YamlFile {
12    /// Path as shown in diagnostics (relative to the project root when inside it).
13    pub display: PathBuf,
14    pub text: String,
15    pub value: Value,
16}
17
18impl YamlFile {
19    /// Parse `path`, recording a diagnostic (with line) and returning `None` on failure.
20    pub fn load(path: &Path, display: PathBuf, diags: &mut Diagnostics) -> Option<YamlFile> {
21        let text = match std::fs::read_to_string(path) {
22            Ok(t) => t,
23            Err(e) => {
24                diags.error("io-error", Some(display), None, format!("cannot read file: {e}"));
25                return None;
26            }
27        };
28        Self::parse(text, display, diags)
29    }
30
31    pub fn parse(text: String, display: PathBuf, diags: &mut Diagnostics) -> Option<YamlFile> {
32        match serde_yaml_ng::from_str::<Value>(&text) {
33            Ok(mut value) => {
34                if let Err(e) = merge_keys(&mut value) {
35                    diags.error(
36                        "yaml-syntax",
37                        Some(display),
38                        None,
39                        format!("invalid YAML merge key (`<<`): {e}"),
40                    );
41                    return None;
42                }
43                if !string_keys(&mut value) {
44                    diags.error(
45                        "yaml-syntax",
46                        Some(display),
47                        None,
48                        "a map key is a list or a map; keys must be names",
49                    );
50                    return None;
51                }
52                Some(YamlFile { display, text, value })
53            }
54            Err(e) => {
55                let line = e.location().map(|l| l.line());
56                let msg = strip_location(&e.to_string());
57                diags.error("yaml-syntax", Some(display), line, format!("invalid YAML: {msg}"));
58                None
59            }
60        }
61    }
62
63    /// Best-effort line of `key:` in this file. With `after`, searches from that line on,
64    /// which is enough to find nested keys under a located parent.
65    pub fn line_of(&self, key: &str, after: Option<usize>) -> Option<usize> {
66        let start = after.unwrap_or(1);
67        self.text.lines().enumerate().skip(start - 1).find_map(|(i, l)| {
68            let t = l.trim_start().trim_start_matches("- ").trim_start();
69            let t = t
70                .strip_prefix('"')
71                .and_then(|t| t.strip_prefix(key).and_then(|r| r.strip_prefix('"')))
72                .or_else(|| {
73                    t.strip_prefix('\'')
74                        .and_then(|t| t.strip_prefix(key).and_then(|r| r.strip_prefix('\'')))
75                })
76                .or_else(|| t.strip_prefix(key));
77            match t {
78                Some(rest) if rest.trim_start().starts_with(':') => Some(i + 1),
79                _ => None,
80            }
81        })
82    }
83
84    /// Best-effort line of the first occurrence of `needle` anywhere in the file.
85    pub fn line_containing(&self, needle: &str) -> Option<usize> {
86        self.text.lines().position(|l| l.contains(needle)).map(|i| i + 1)
87    }
88}
89
90/// Apply YAML merge keys (`<<: *base`, `<<: [*a, *b]`): keys written out win, then earlier
91/// maps in a list. A merged map's own `<<` is resolved first, so merges can chain
92/// (`prod: {<<: *verify}` where `verify: &verify {<<: *dev, ...}`).
93fn merge_keys(v: &mut Value) -> Result<(), &'static str> {
94    match v {
95        Value::Mapping(m) => {
96            if let Some(src) = m.remove("<<") {
97                let sources = match src {
98                    Value::Mapping(_) => vec![src],
99                    Value::Sequence(list) => list,
100                    _ => return Err("`<<` must be a map or a list of maps"),
101                };
102                for mut s in sources {
103                    merge_keys(&mut s)?;
104                    let Value::Mapping(s) = s else {
105                        return Err("`<<` must be a map or a list of maps");
106                    };
107                    for (k, v) in s {
108                        m.entry(k).or_insert(v);
109                    }
110                }
111            }
112            m.values_mut().try_for_each(merge_keys)
113        }
114        Value::Sequence(list) => list.iter_mut().try_for_each(merge_keys),
115        Value::Tagged(t) => merge_keys(&mut t.value),
116        _ => Ok(()),
117    }
118}
119
120/// Turn scalar mapping keys into strings. Unquoted `null:`, `true:` or `2024:` are YAML
121/// null/bool/number keys, but every key DRE reads is a name: `null: "NULL"` means the
122/// option `null`, not a missing key. False when a key is a list or a map.
123fn string_keys(v: &mut Value) -> bool {
124    match v {
125        Value::Mapping(m) => {
126            if m.keys().any(|k| !k.is_string()) {
127                let old = std::mem::take(m);
128                for (k, v) in old {
129                    let k = match k {
130                        Value::Null => Value::String("null".into()),
131                        Value::Bool(b) => Value::String(b.to_string()),
132                        Value::Number(n) => Value::String(n.to_string()),
133                        Value::String(s) => Value::String(s),
134                        _ => return false,
135                    };
136                    m.insert(k, v);
137                }
138            }
139            m.values_mut().all(string_keys)
140        }
141        Value::Sequence(s) => s.iter_mut().all(string_keys),
142        Value::Tagged(t) => string_keys(&mut t.value),
143        _ => true,
144    }
145}
146
147fn strip_location(msg: &str) -> String {
148    // serde_yaml_ng appends " at line X column Y"; the line is reported separately.
149    match msg.find(" at line ") {
150        Some(i) => msg[..i].to_string(),
151        None => msg.to_string(),
152    }
153}
154
155/// Render a YAML scalar as a short string for messages.
156pub fn scalar_str(v: &Value) -> Option<String> {
157    match v {
158        Value::String(s) => Some(s.clone()),
159        Value::Number(n) => Some(n.to_string()),
160        Value::Bool(b) => Some(b.to_string()),
161        _ => None,
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn parse(text: &str) -> (Option<YamlFile>, Diagnostics) {
170        let mut d = Diagnostics::default();
171        (
172            YamlFile::parse(text.to_string(), PathBuf::from("t.yml"), &mut d),
173            d,
174        )
175    }
176
177    #[test]
178    fn merge_keys_are_applied_and_explicit_keys_win() {
179        let (yf, _) = parse(
180            "base: &b {type: postgres, host: h, database: shop}\n\
181             other: &o {port: 5433, host: other}\n\
182             prod:\n  <<: *b\n  database: shop_prod\n\
183             both:\n  <<: [*b, *o]\n",
184        );
185        let v = yf.unwrap().value;
186        assert_eq!(v["prod"]["type"], "postgres");
187        assert_eq!(v["prod"]["database"], "shop_prod");
188        assert!(v["prod"].get("<<").is_none());
189        // With a list, earlier maps win over later ones.
190        assert_eq!(v["both"]["host"], "h");
191        assert_eq!(v["both"]["port"], 5433);
192    }
193
194    #[test]
195    fn merges_chain() {
196        let (yf, _) = parse(
197            "dev: &pg {type: postgres, host: h, sslmode: prefer}\n\
198             verify: &v {<<: *pg, sslmode: verify-full}\n\
199             wrong: {<<: *v, host: other}\n",
200        );
201        let v = yf.unwrap().value;
202        assert_eq!(v["wrong"]["type"], "postgres");
203        assert_eq!(v["wrong"]["sslmode"], "verify-full");
204        assert_eq!(v["wrong"]["host"], "other");
205        assert!(v["wrong"].get("<<").is_none());
206        let (yf, d) = parse("a: {<<: 3}\n");
207        assert!(yf.is_none() && format!("{d:?}").contains("must be a map"));
208    }
209
210    #[test]
211    fn scalar_keys_become_names() {
212        let (yf, _) = parse("output: {format: csv, null: NULL, true: 1, 2024: x}\n");
213        let v = yf.unwrap().value;
214        assert!(v["output"]["null"].is_null());
215        assert_eq!(v["output"]["true"], 1);
216        assert_eq!(v["output"]["2024"], "x");
217        let (yf, _) = parse("output: {null: \"NULL\"}\n");
218        assert_eq!(yf.unwrap().value["output"]["null"], "NULL");
219    }
220
221    #[test]
222    fn complex_keys_are_an_error() {
223        let (yf, d) = parse("? [a, b]\n: 1\n");
224        assert!(yf.is_none());
225        assert!(format!("{d:?}").contains("keys must be names"));
226    }
227}