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_json::Value;
6
7use crate::codes::Code;
8use crate::config::node::{self, Kind, Node};
9use crate::diag::Diagnostics;
10
11/// A parsed YAML file, kept with its source text so keys can be located for diagnostics.
12#[derive(Debug, Clone)]
13pub struct YamlFile {
14    /// Path as shown in diagnostics (relative to the project root when inside it).
15    pub display: PathBuf,
16    pub text: String,
17    /// The file as the value config not yet read into typed structs works on.
18    pub value: Value,
19    /// The parsed file, with every value's line.
20    pub node: Node,
21}
22
23impl YamlFile {
24    /// Parse `path`, recording a diagnostic (with line) and returning `None` on failure.
25    pub fn load(path: &Path, display: PathBuf, diags: &mut Diagnostics) -> Option<YamlFile> {
26        let text = match std::fs::read_to_string(path) {
27            Ok(t) => t,
28            Err(e) => {
29                diags.error(
30                    Code::IoError,
31                    Some(display),
32                    None,
33                    format!("cannot read file: {e}"),
34                );
35                return None;
36            }
37        };
38        Self::parse(text, display, diags)
39    }
40
41    pub fn parse(text: String, display: PathBuf, diags: &mut Diagnostics) -> Option<YamlFile> {
42        match node::parse(&text) {
43            Ok(node) => {
44                let value = node.to_json();
45                Some(YamlFile {
46                    display,
47                    text,
48                    value,
49                    node,
50                })
51            }
52            Err(e) => {
53                diags.error(
54                    Code::YamlSyntax,
55                    Some(display),
56                    e.line,
57                    format!("invalid YAML: {}", e.message),
58                );
59                None
60            }
61        }
62    }
63
64    /// Best-effort line of `key:` in this file. With `after`, searches from that line on,
65    /// which is enough to find nested keys under a located parent.
66    pub fn line_of(&self, key: &str, after: Option<usize>) -> Option<usize> {
67        let start = after.unwrap_or(1);
68        self.text.lines().enumerate().skip(start - 1).find_map(|(i, l)| {
69            let t = l.trim_start().trim_start_matches("- ").trim_start();
70            let t = t
71                .strip_prefix('"')
72                .and_then(|t| t.strip_prefix(key).and_then(|r| r.strip_prefix('"')))
73                .or_else(|| {
74                    t.strip_prefix('\'')
75                        .and_then(|t| t.strip_prefix(key).and_then(|r| r.strip_prefix('\'')))
76                })
77                .or_else(|| t.strip_prefix(key));
78            match t {
79                Some(rest) if rest.trim_start().starts_with(':') => Some(i + 1),
80                _ => None,
81            }
82        })
83    }
84
85    /// Best-effort line of the first occurrence of `needle` anywhere in the file.
86    pub fn line_containing(&self, needle: &str) -> Option<usize> {
87        self.text.lines().position(|l| l.contains(needle)).map(|i| i + 1)
88    }
89}
90
91/// A value as a node with no lines, to read typed config from values merged from several files
92/// (outputs).
93pub fn to_node(v: &Value) -> Node {
94    let kind = match v {
95        Value::Null => Kind::Null,
96        Value::Bool(b) => Kind::Bool(*b),
97        Value::Number(n) => match (n.as_i64(), n.as_u64(), n.as_f64()) {
98            (Some(i), _, _) => Kind::Int(i),
99            (None, Some(u), _) => Kind::UInt(u),
100            (_, _, f) => Kind::Float(f.unwrap_or(f64::NAN)),
101        },
102        Value::String(s) => Kind::Str(s.clone()),
103        Value::Array(s) => Kind::Seq(s.iter().map(to_node).collect()),
104        Value::Object(m) => Kind::Map(
105            m.iter()
106                .map(|(k, v)| {
107                    (
108                        node::Key {
109                            name: k.clone(),
110                            line: 0,
111                        },
112                        to_node(v),
113                    )
114                })
115                .collect(),
116        ),
117    };
118    Node {
119        kind,
120        line: 0,
121        column: 0,
122    }
123}
124
125/// Render a YAML scalar as a short string for messages.
126pub fn scalar_str(v: &Value) -> Option<String> {
127    match v {
128        Value::String(s) => Some(s.clone()),
129        Value::Number(n) => Some(n.to_string()),
130        Value::Bool(b) => Some(b.to_string()),
131        _ => None,
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn parse(text: &str) -> (Option<YamlFile>, Diagnostics) {
140        let mut d = Diagnostics::default();
141        (
142            YamlFile::parse(text.to_string(), PathBuf::from("t.yml"), &mut d),
143            d,
144        )
145    }
146
147    #[test]
148    fn merge_keys_are_applied_and_explicit_keys_win() {
149        let (yf, _) = parse(
150            "base: &b {type: postgres, host: h, database: shop}\n\
151             other: &o {port: 5433, host: other}\n\
152             prod:\n  <<: *b\n  database: shop_prod\n\
153             both:\n  <<: [*b, *o]\n",
154        );
155        let v = yf.unwrap().value;
156        assert_eq!(v["prod"]["type"], "postgres");
157        assert_eq!(v["prod"]["database"], "shop_prod");
158        assert!(v["prod"].get("<<").is_none());
159        // With a list, earlier maps win over later ones.
160        assert_eq!(v["both"]["host"], "h");
161        assert_eq!(v["both"]["port"], 5433);
162    }
163
164    #[test]
165    fn merges_chain() {
166        let (yf, _) = parse(
167            "dev: &pg {type: postgres, host: h, sslmode: prefer}\n\
168             verify: &v {<<: *pg, sslmode: verify-full}\n\
169             wrong: {<<: *v, host: other}\n",
170        );
171        let v = yf.unwrap().value;
172        assert_eq!(v["wrong"]["type"], "postgres");
173        assert_eq!(v["wrong"]["sslmode"], "verify-full");
174        assert_eq!(v["wrong"]["host"], "other");
175        assert!(v["wrong"].get("<<").is_none());
176        let (yf, d) = parse("a: {<<: 3}\n");
177        assert!(yf.is_none() && format!("{d:?}").contains("must be a map"));
178    }
179
180    #[test]
181    fn scalar_keys_become_names() {
182        let (yf, _) = parse("output: {format: csv, null: NULL, true: 1, 2024: x}\n");
183        let v = yf.unwrap().value;
184        assert!(v["output"]["null"].is_null());
185        assert_eq!(v["output"]["true"], 1);
186        assert_eq!(v["output"]["2024"], "x");
187        let (yf, _) = parse("output: {null: \"NULL\"}\n");
188        assert_eq!(yf.unwrap().value["output"]["null"], "NULL");
189    }
190
191    #[test]
192    fn complex_keys_are_an_error() {
193        let (yf, d) = parse("? [a, b]\n: 1\n");
194        assert!(yf.is_none());
195        assert!(format!("{d:?}").contains("keys must be names"));
196    }
197}