use std::path::{Path, PathBuf};
use serde_yaml_ng::Value;
use crate::diag::Diagnostics;
#[derive(Debug, Clone)]
pub struct YamlFile {
pub display: PathBuf,
pub text: String,
pub value: Value,
}
impl YamlFile {
pub fn load(path: &Path, display: PathBuf, diags: &mut Diagnostics) -> Option<YamlFile> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
diags.error("io-error", Some(display), None, format!("cannot read file: {e}"));
return None;
}
};
Self::parse(text, display, diags)
}
pub fn parse(text: String, display: PathBuf, diags: &mut Diagnostics) -> Option<YamlFile> {
match serde_yaml_ng::from_str::<Value>(&text) {
Ok(mut value) => {
if let Err(e) = merge_keys(&mut value) {
diags.error(
"yaml-syntax",
Some(display),
None,
format!("invalid YAML merge key (`<<`): {e}"),
);
return None;
}
if !string_keys(&mut value) {
diags.error(
"yaml-syntax",
Some(display),
None,
"a map key is a list or a map; keys must be names",
);
return None;
}
Some(YamlFile { display, text, value })
}
Err(e) => {
let line = e.location().map(|l| l.line());
let msg = strip_location(&e.to_string());
diags.error("yaml-syntax", Some(display), line, format!("invalid YAML: {msg}"));
None
}
}
}
pub fn line_of(&self, key: &str, after: Option<usize>) -> Option<usize> {
let start = after.unwrap_or(1);
self.text.lines().enumerate().skip(start - 1).find_map(|(i, l)| {
let t = l.trim_start().trim_start_matches("- ").trim_start();
let t = t
.strip_prefix('"')
.and_then(|t| t.strip_prefix(key).and_then(|r| r.strip_prefix('"')))
.or_else(|| {
t.strip_prefix('\'')
.and_then(|t| t.strip_prefix(key).and_then(|r| r.strip_prefix('\'')))
})
.or_else(|| t.strip_prefix(key));
match t {
Some(rest) if rest.trim_start().starts_with(':') => Some(i + 1),
_ => None,
}
})
}
pub fn line_containing(&self, needle: &str) -> Option<usize> {
self.text.lines().position(|l| l.contains(needle)).map(|i| i + 1)
}
}
fn merge_keys(v: &mut Value) -> Result<(), &'static str> {
match v {
Value::Mapping(m) => {
if let Some(src) = m.remove("<<") {
let sources = match src {
Value::Mapping(_) => vec![src],
Value::Sequence(list) => list,
_ => return Err("`<<` must be a map or a list of maps"),
};
for mut s in sources {
merge_keys(&mut s)?;
let Value::Mapping(s) = s else {
return Err("`<<` must be a map or a list of maps");
};
for (k, v) in s {
m.entry(k).or_insert(v);
}
}
}
m.values_mut().try_for_each(merge_keys)
}
Value::Sequence(list) => list.iter_mut().try_for_each(merge_keys),
Value::Tagged(t) => merge_keys(&mut t.value),
_ => Ok(()),
}
}
fn string_keys(v: &mut Value) -> bool {
match v {
Value::Mapping(m) => {
if m.keys().any(|k| !k.is_string()) {
let old = std::mem::take(m);
for (k, v) in old {
let k = match k {
Value::Null => Value::String("null".into()),
Value::Bool(b) => Value::String(b.to_string()),
Value::Number(n) => Value::String(n.to_string()),
Value::String(s) => Value::String(s),
_ => return false,
};
m.insert(k, v);
}
}
m.values_mut().all(string_keys)
}
Value::Sequence(s) => s.iter_mut().all(string_keys),
Value::Tagged(t) => string_keys(&mut t.value),
_ => true,
}
}
fn strip_location(msg: &str) -> String {
match msg.find(" at line ") {
Some(i) => msg[..i].to_string(),
None => msg.to_string(),
}
}
pub fn scalar_str(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> (Option<YamlFile>, Diagnostics) {
let mut d = Diagnostics::default();
(
YamlFile::parse(text.to_string(), PathBuf::from("t.yml"), &mut d),
d,
)
}
#[test]
fn merge_keys_are_applied_and_explicit_keys_win() {
let (yf, _) = parse(
"base: &b {type: postgres, host: h, database: shop}\n\
other: &o {port: 5433, host: other}\n\
prod:\n <<: *b\n database: shop_prod\n\
both:\n <<: [*b, *o]\n",
);
let v = yf.unwrap().value;
assert_eq!(v["prod"]["type"], "postgres");
assert_eq!(v["prod"]["database"], "shop_prod");
assert!(v["prod"].get("<<").is_none());
assert_eq!(v["both"]["host"], "h");
assert_eq!(v["both"]["port"], 5433);
}
#[test]
fn merges_chain() {
let (yf, _) = parse(
"dev: &pg {type: postgres, host: h, sslmode: prefer}\n\
verify: &v {<<: *pg, sslmode: verify-full}\n\
wrong: {<<: *v, host: other}\n",
);
let v = yf.unwrap().value;
assert_eq!(v["wrong"]["type"], "postgres");
assert_eq!(v["wrong"]["sslmode"], "verify-full");
assert_eq!(v["wrong"]["host"], "other");
assert!(v["wrong"].get("<<").is_none());
let (yf, d) = parse("a: {<<: 3}\n");
assert!(yf.is_none() && format!("{d:?}").contains("must be a map"));
}
#[test]
fn scalar_keys_become_names() {
let (yf, _) = parse("output: {format: csv, null: NULL, true: 1, 2024: x}\n");
let v = yf.unwrap().value;
assert!(v["output"]["null"].is_null());
assert_eq!(v["output"]["true"], 1);
assert_eq!(v["output"]["2024"], "x");
let (yf, _) = parse("output: {null: \"NULL\"}\n");
assert_eq!(yf.unwrap().value["output"]["null"], "NULL");
}
#[test]
fn complex_keys_are_an_error() {
let (yf, d) = parse("? [a, b]\n: 1\n");
assert!(yf.is_none());
assert!(format!("{d:?}").contains("keys must be names"));
}
}