use std::path::{Path, PathBuf};
pub use oxc_resolver::ResolveError;
use oxc_resolver::{ResolveOptions, Resolver};
use serde::de::DeserializeOwned;
pub const FILE_NAME: &str = "atypical.toml";
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
Toml(toml::de::Error),
Cycle(PathBuf),
Extends(PathBuf),
Resolve(String, ResolveError),
Scheme(String),
Before(PathBuf, String),
Duplicate(PathBuf, String),
}
enum Conflict {
Before(String),
Duplicate(String),
}
impl Conflict {
fn at(self, path: &Path) -> Error {
let path = path.to_path_buf();
match self {
Conflict::Before(name) => Error::Before(path, name),
Conflict::Duplicate(name) => Error::Duplicate(path, name),
}
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Io(error) => error.fmt(f),
Error::Toml(error) => error.fmt(f),
Error::Cycle(path) => {
write!(f, "cyclic `extends` involving {}", path.display())
}
Error::Extends(path) => write!(
f,
"`extends` in {} must be a string or an array of strings",
path.display()
),
Error::Resolve(base, error) => {
write!(f, "cannot resolve `extends = \"{base}\"`: {error}")
}
Error::Scheme(scheme) => {
write!(f, "`extends` scheme `{scheme}:` is not supported yet")
}
Error::Before(path, name) => write!(
f,
"`before = \"{name}\"` in {} names no other entry",
path.display()
),
Error::Duplicate(path, name) => write!(
f,
"two entries named \"{name}\" in one array in {}",
path.display()
),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io(error) => Some(error),
Error::Toml(error) => Some(error),
Error::Resolve(_, error) => Some(error),
Error::Cycle(_)
| Error::Extends(_)
| Error::Scheme(_)
| Error::Before(..)
| Error::Duplicate(..) => None,
}
}
}
pub fn find(start: impl AsRef<Path>) -> Option<PathBuf> {
start
.as_ref()
.ancestors()
.map(|dir| dir.join(FILE_NAME))
.find(|path| path.is_file())
}
pub fn section<T: DeserializeOwned>(
document: &str,
key: &str,
) -> Result<Option<T>, toml::de::Error> {
let table: toml::Table = document.parse()?;
let Some(value) = table.get(key) else {
return Ok(None);
};
value.clone().try_into().map(Some)
}
pub fn resolve(path: impl AsRef<Path>) -> Result<toml::Table, Error> {
resolve_into(path.as_ref(), &mut Vec::new())
}
fn resolve_into(
path: &Path,
stack: &mut Vec<PathBuf>,
) -> Result<toml::Table, Error> {
let path = path.canonicalize().map_err(Error::Io)?;
if stack.contains(&path) {
return Err(Error::Cycle(path));
}
let document = std::fs::read_to_string(&path).map_err(Error::Io)?;
let mut table: toml::Table = document.parse().map_err(Error::Toml)?;
let bases = match table.remove("extends") {
None => Vec::new(),
Some(toml::Value::String(base)) => vec![base],
Some(toml::Value::Array(bases)) => bases
.into_iter()
.map(|base| match base {
toml::Value::String(base) => Ok(base),
_ => Err(Error::Extends(path.clone())),
})
.collect::<Result<_, _>>()?,
Some(_) => return Err(Error::Extends(path)),
};
let dir = path.parent().unwrap_or(Path::new("")).to_path_buf();
stack.push(path.clone());
let mut merged = toml::Table::new();
for base in bases {
let base = resolve_into(&locate(&dir, &base)?, stack)?;
merge(&mut merged, base).map_err(|it| it.at(&path))?;
}
stack.pop();
merge(&mut merged, table).map_err(|it| it.at(&path))?;
Ok(merged)
}
fn locate(dir: &Path, base: &str) -> Result<PathBuf, Error> {
match base.split_once(':') {
Some((scheme, spec))
if scheme.len() > 1
&& scheme.chars().all(|c| c.is_ascii_lowercase()) =>
{
match scheme {
"npm" => resolve_package(dir, base, spec),
_ => Err(Error::Scheme(scheme.to_owned())),
}
}
_ => Ok(dir.join(base)),
}
}
fn resolve_package(
dir: &Path,
label: &str,
spec: &str,
) -> Result<PathBuf, Error> {
Resolver::new(ResolveOptions::default())
.resolve(dir, spec)
.map(|resolution| resolution.path().to_path_buf())
.map_err(|error| Error::Resolve(label.to_owned(), error))
}
fn merge(base: &mut toml::Table, layer: toml::Table) -> Result<(), Conflict> {
for (key, value) in layer {
match (base.get_mut(&key), value) {
(Some(base), value) => merge_value(base, value)?,
(None, value) => {
base.insert(key, normalised(value)?);
}
}
}
Ok(())
}
fn merge_value(
base: &mut toml::Value,
layer: toml::Value,
) -> Result<(), Conflict> {
match (base, layer) {
(toml::Value::Table(base), toml::Value::Table(layer)) => {
merge(base, layer)
}
(toml::Value::Array(base), toml::Value::Array(layer))
if named(base) && named(&layer) =>
{
merge_named(base, layer)
}
(base, layer) => {
*base = normalised(layer)?;
Ok(())
}
}
}
fn normalised(layer: toml::Value) -> Result<toml::Value, Conflict> {
match layer {
toml::Value::Table(layer) => {
let mut onto = toml::Table::new();
merge(&mut onto, layer)?;
Ok(toml::Value::Table(onto))
}
toml::Value::Array(layer) if named(&layer) => {
let mut onto = Vec::new();
merge_named(&mut onto, layer)?;
Ok(toml::Value::Array(onto))
}
layer => Ok(layer),
}
}
fn name_of(entry: &toml::Value) -> Option<&str> {
match entry {
toml::Value::Table(fields) => {
fields.get("name").and_then(toml::Value::as_str)
}
_ => None,
}
}
fn named(array: &[toml::Value]) -> bool {
!array.is_empty() && array.iter().all(|entry| name_of(entry).is_some())
}
fn position(base: &[toml::Value], name: &str) -> Option<usize> {
base.iter().position(|entry| name_of(entry) == Some(name))
}
fn dropped(entry: &mut toml::Value) -> bool {
let flag = entry
.as_table()
.and_then(|fields| fields.get("drop"))
.and_then(toml::Value::as_bool);
if let (Some(flag), Some(fields)) = (flag, entry.as_table_mut()) {
fields.remove("drop");
return flag;
}
false
}
fn before(entry: &mut toml::Value) -> Option<String> {
let name = entry
.as_table()
.and_then(|fields| fields.get("before"))
.and_then(toml::Value::as_str)
.map(str::to_owned)?;
entry.as_table_mut()?.remove("before");
Some(name)
}
fn duplicate(array: &[toml::Value]) -> Option<&str> {
array.iter().enumerate().find_map(|(at, entry)| {
let name = name_of(entry)?;
array[..at]
.iter()
.any(|earlier| name_of(earlier) == Some(name))
.then_some(name)
})
}
fn merge_named(
base: &mut Vec<toml::Value>,
layer: Vec<toml::Value>,
) -> Result<(), Conflict> {
if let Some(name) = duplicate(&layer) {
return Err(Conflict::Duplicate(name.to_owned()));
}
let anchored = layer.iter().any(|entry| {
let kept =
entry.get("drop").and_then(toml::Value::as_bool) != Some(true);
kept && name_of(entry)
.is_some_and(|name| position(base, name).is_some())
});
let mut last: Option<String> = None;
for mut entry in layer {
let dropped = dropped(&mut entry);
let before = before(&mut entry);
let name = name_of(&entry).unwrap_or_default().to_owned();
let to = match before {
None => None,
Some(before) if before == name => {
return Err(Conflict::Before(before));
}
Some(before) => {
Some(position(base, &before).ok_or(Conflict::Before(before))?)
}
};
match (position(base, &name), dropped) {
(Some(at), true) => {
base.remove(at);
}
(Some(at), false) => {
merge_value(&mut base[at], entry)?;
if let Some(to) = to {
let entry = base.remove(at);
base.insert(to - usize::from(to > at), entry);
}
last = Some(name);
}
(None, true) => {}
(None, false) => {
let entry = normalised(entry)?;
let after_last = last
.as_deref()
.and_then(|last| position(base, last))
.map(|at| at + 1);
let to = match (to, after_last) {
(Some(to), _) | (None, Some(to)) => to,
(None, None) if anchored => 0,
(None, None) => base.len(),
};
base.insert(to, entry);
last = Some(name);
}
}
}
Ok(())
}
pub fn load<T: DeserializeOwned>(
path: impl AsRef<Path>,
key: &str,
) -> Result<Option<T>, Error> {
let mut table = resolve(path)?;
let Some(value) = table.remove(key) else {
return Ok(None);
};
value.try_into().map(Some).map_err(Error::Toml)
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq, serde::Deserialize)]
struct Section {
name: String,
}
#[test]
fn test_section() {
let document = indoc::indoc! {r#"
[commit]
name = "value"
"#};
assert_eq!(
section::<Section>(document, "commit").unwrap(),
Some(Section {
name: "value".into()
})
);
assert_eq!(section::<Section>(document, "branch").unwrap(), None);
assert_eq!(section::<Section>("", "commit").unwrap(), None);
assert!(section::<Section>("not toml", "commit").is_err());
assert!(
section::<Section>(
indoc::indoc! {r#"
[commit]
name = 1
"#},
"commit"
)
.is_err()
);
}
}