1use std::path::{Path, PathBuf};
4
5use serde_yaml_ng::Value;
6
7use crate::diag::Diagnostics;
8
9#[derive(Debug, Clone)]
11pub struct YamlFile {
12 pub display: PathBuf,
14 pub text: String,
15 pub value: Value,
16}
17
18impl YamlFile {
19 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 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 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
90fn 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
120fn 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 match msg.find(" at line ") {
150 Some(i) => msg[..i].to_string(),
151 None => msg.to_string(),
152 }
153}
154
155pub 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 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}