use std::ffi::OsString;
use std::path::{Path, PathBuf};
use yaml_rust2::Yaml;
use super::Problem;
pub(super) struct Directories<'a> {
pub(super) base: Option<&'a Path>,
}
impl Directories<'_> {
pub(super) fn resolve(
&self,
value: &Yaml,
path: &str,
parent: Option<&Path>,
join: bool,
) -> Result<Option<PathBuf>, Problem> {
let text = match value {
Yaml::BadValue | Yaml::Null => return Ok(None),
Yaml::String(text) => text,
_ => return Err(Problem::new(path, "must be a string")),
};
if text.starts_with('~') && !(text == "~" || text.starts_with("~/")) {
return Err(Problem::new(
path,
"starts with `~name`, which is not expanded here; write the directory out",
));
}
let expanded = PathBuf::from(expand(text, path)?);
if expanded.is_absolute() {
return Ok(Some(tidy(&expanded)));
}
let anchor = if text.starts_with('.') {
parent.or(self.base)
} else if join {
parent
} else {
None
};
let anchor = match anchor {
Some(anchor) => anchor.to_owned(),
None => std::env::current_dir().map_err(|error| {
Problem::new(
path,
format!("is relative, and the current directory cannot be read: {error}"),
)
})?,
};
Ok(Some(tidy(&anchor.join(expanded))))
}
}
fn tidy(path: &Path) -> PathBuf {
path.components().collect()
}
pub(super) fn expand(text: &str, path: &str) -> Result<String, Problem> {
expand_with(text, |name| std::env::var_os(name)).map_err(|reason| Problem::new(path, reason))
}
fn expand_with(text: &str, variable: impl Fn(&str) -> Option<OsString>) -> Result<String, String> {
let text_of = |name: &str, value: OsString| {
value
.into_string()
.map_err(|_| format!("names ${name}, whose value is not UTF-8"))
};
let mut expanded = String::with_capacity(text.len());
let mut rest = text;
if let Some(tail) = text.strip_prefix('~') {
if tail.is_empty() || tail.starts_with('/') {
let home = variable("HOME").ok_or("starts with `~`, and HOME is not set")?;
expanded.push_str(text_of("HOME", home)?.trim_end_matches('/'));
if expanded.is_empty() && tail.is_empty() {
expanded.push('/');
}
rest = tail;
}
}
while let Some(at) = rest.find('$') {
expanded.push_str(&rest[..at]);
let after = &rest[at + 1..];
let (name, length) = if let Some(braced) = after.strip_prefix('{') {
braced
.find('}')
.map_or(("", 0), |end| (&braced[..end], end + 2))
} else {
let end = after
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(after.len());
(&after[..end], end)
};
let value = if name.is_empty() || name.contains(['=', '\0']) {
None
} else {
variable(name)
};
match value {
Some(value) => expanded.push_str(&text_of(name, value)?),
None => expanded.push_str(&rest[at..=at + length]),
}
rest = &after[length..];
}
expanded.push_str(rest);
Ok(expanded)
}
#[cfg(test)]
mod tests {
use super::expand_with;
#[test]
fn expansion_is_pythons_expanduser_then_expandvars() {
let expand = |text| {
expand_with(text, |name| match name {
"HOME" => Some("/home/me/".into()),
"PROJECT" => Some("tmux".into()),
_ => None,
})
};
for (text, expected) in [
("~", "/home/me"),
("~/src", "/home/me/src"),
("~nosuchuser/src", "~nosuchuser/src"),
("a~", "a~"),
("$PROJECT/x", "tmux/x"),
("${PROJECT}x", "tmuxx"),
("$PROJECTx", "$PROJECTx"),
("$UNSET and ${UNSET}", "$UNSET and ${UNSET}"),
("$ ${ ${} $-", "$ ${ ${} $-"),
("~/$PROJECT", "/home/me/tmux"),
("price: $5", "price: $5"),
] {
assert_eq!(expand(text).as_deref(), Ok(expected), "{text}");
}
let root = |text| expand_with(text, |_| Some("/".into()));
assert_eq!(root("~").as_deref(), Ok("/"));
assert_eq!(root("~/x").as_deref(), Ok("/x"));
assert!(expand_with("~", |_| None).is_err(), "no HOME, no `~`");
}
}