use skif_core::{HashMap, HashSet};
use std::io;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use rayon::prelude::*;
use skif_core::{FileSystem, OsFs};
use toml::Value;
use crate::error::ConfigError;
use crate::layer::ParsedFile;
use crate::merge::{Merged, Merger};
use crate::model::{Config, OverrideScope};
use crate::overrides::ConfigOverrides;
use crate::package_file;
use crate::provenance::{Assignment, ConfigSource, Op, Origins, ResolvedConfig, SourceKind};
const MAX_EXTENDS_DEPTH: usize = 16;
pub const CONFIG_FILE_NAMES: [&str; 2] = ["skif.toml", ".skif.toml"];
pub fn discover(root: &Path) -> Result<Option<(PathBuf, Config)>, ConfigError> {
discover_with_fs(root, &OsFs)
}
pub fn discover_with_fs(root: &Path, fs: &dyn FileSystem) -> Result<Option<(PathBuf, Config)>, ConfigError> {
skif_core::timing::phase(format_args!("config {}", root.display()), || {
let found: Vec<PathBuf> = CONFIG_FILE_NAMES
.iter()
.map(|name| root.join(name))
.filter(|path| fs.is_file(path))
.collect();
match found.as_slice() {
[] => Ok(None),
[path] => Ok(Some((path.clone(), Config::from_toml_file_with_fs(path, fs)?))),
[first, second, ..] => Err(ConfigError::Ambiguous {
first: first.clone(),
second: second.clone(),
}),
}
})
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Ceiling {
#[default]
Vcs,
VcsOr(PathBuf),
Dir(PathBuf),
RootOnly,
}
#[derive(Default)]
struct DirInfo {
configs: OnceLock<Arc<[PathBuf]>>,
has_git: OnceLock<bool>,
}
impl DirInfo {
fn configs(&self, dir: &Path, fs: &dyn FileSystem) -> Arc<[PathBuf]> {
Arc::clone(self.configs.get_or_init(|| {
CONFIG_FILE_NAMES
.iter()
.map(|name| dir.join(name))
.filter(|path| fs.is_file(path))
.collect()
}))
}
fn has_git(&self, dir: &Path, fs: &dyn FileSystem) -> bool {
*self.has_git.get_or_init(|| {
let git = dir.join(".git");
fs.is_dir(&git) || fs.is_file(&git)
})
}
}
type FileCell = Arc<OnceLock<Result<Arc<ParsedFile>, ConfigError>>>;
type DirCell = Arc<DirInfo>;
#[derive(Default)]
pub struct ConfigCache {
files: Mutex<HashMap<PathBuf, FileCell>>,
dirs: Mutex<HashMap<PathBuf, DirCell>>,
}
impl ConfigCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn file(&self, path: &Path, fs: &dyn FileSystem) -> Result<Arc<ParsedFile>, ConfigError> {
let path = normalize(path);
let cell = cell_for(&self.files, &path);
cell.get_or_init(|| ParsedFile::read(&path, fs).map(Arc::new))
.as_ref()
.map(Arc::clone)
.map_err(ConfigError::duplicate)
}
fn dir(&self, dir: &Path) -> (PathBuf, DirCell) {
let dir = normalize(dir);
let cell = cell_for(&self.dirs, &dir);
(dir, cell)
}
fn configs(&self, dir: &Path, fs: &dyn FileSystem) -> Arc<[PathBuf]> {
let (dir, info) = self.dir(dir);
info.configs(&dir, fs)
}
fn has_git(&self, dir: &Path, fs: &dyn FileSystem) -> bool {
let (dir, info) = self.dir(dir);
info.has_git(&dir, fs)
}
}
fn cell_for<T: Default>(cells: &Mutex<HashMap<PathBuf, Arc<T>>>, key: &Path) -> Arc<T> {
let mut cells = cells.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(cell) = cells.get(key) {
return Arc::clone(cell);
}
Arc::clone(cells.entry(key.to_path_buf()).or_default())
}
pub struct ConfigLoader<'a> {
fs: &'a dyn FileSystem,
cache: &'a ConfigCache,
sequential: bool,
}
impl<'a> ConfigLoader<'a> {
#[must_use]
pub const fn new(fs: &'a dyn FileSystem, cache: &'a ConfigCache) -> Self {
Self {
fs,
cache,
sequential: false,
}
}
#[must_use]
pub const fn with_sequential(mut self, sequential: bool) -> Self {
self.sequential = sequential;
self
}
pub fn resolve(&self, root: &Path, ceiling: &Ceiling) -> Result<ResolvedConfig, ConfigError> {
let root = &checked_root(root)?;
skif_core::timing::phase(format_args!("config {}", root.display()), || {
let files = self.discover(root, ceiling)?;
self.resolve_files(root, files)
})
}
pub fn load(
&self,
root: &Path,
ceiling: &Ceiling,
overrides: &ConfigOverrides,
) -> Result<ResolvedConfig, ConfigError> {
let root = &checked_root(root)?;
let mut resolved = if overrides.no_config {
ResolvedConfig::default()
} else if let Some(file) = &overrides.config_file {
self.resolve_files(root, vec![(file.clone(), SourceKind::Explicit)])?
} else {
self.resolve(root, ceiling)?
};
resolved.config.apply(overrides)?;
Ok(resolved)
}
pub fn add_package_files(
&self,
resolved: &mut ResolvedConfig,
root: &Path,
members: &[PathBuf],
) -> Result<(), ConfigError> {
let root = &checked_root(root)?;
let mut members: Vec<&PathBuf> = members.iter().collect();
members.sort_by_cached_key(|dir| (dir.components().count(), *dir));
let probe = |dir: &&PathBuf| self.cache.configs(dir, self.fs);
let found: Vec<Arc<[PathBuf]>> = if self.sequential {
members.iter().map(probe).collect()
} else {
members.par_iter().map(probe).collect()
};
for (dir, configs) in members.into_iter().zip(found) {
let path = match &*configs {
[] => continue,
[path] => path.clone(),
[first, second, ..] => {
return Err(ConfigError::Ambiguous {
first: first.clone(),
second: second.clone(),
});
}
};
let file = self.cache.file(&path, self.fs)?;
let mut scoped = package_file::to_override(&file, &path)?;
let source = resolved.sources.len();
scoped.scope = Some(OverrideScope::new(&scoped, dir, source, root)?);
resolved.sources.push(ConfigSource {
path,
kind: SourceKind::Package,
});
resolved.origins.push(
format!("overrides[{}]", resolved.config.overrides.len()),
Assignment {
source,
line: 1,
column: 1,
op: Op::Append,
},
);
resolved.config.overrides.push(scoped);
}
Ok(())
}
fn repository_root(&self, start: &Path) -> Option<PathBuf> {
start
.ancestors()
.find(|dir| self.cache.has_git(dir, self.fs))
.map(Path::to_path_buf)
}
fn discover(&self, root: &Path, ceiling: &Ceiling) -> Result<Vec<(PathBuf, SourceKind)>, ConfigError> {
let limit = match ceiling {
Ceiling::Vcs => self.repository_root(root).unwrap_or_else(|| root.to_path_buf()),
Ceiling::VcsOr(fallback) => self.repository_root(root).unwrap_or_else(|| normalize(fallback)),
Ceiling::Dir(dir) => normalize(dir),
Ceiling::RootOnly => root.to_path_buf(),
};
if !root.starts_with(&limit) {
return Err(ConfigError::invalid(
None,
format!(
"the configuration ceiling {} is not the root {} or a directory above it",
limit.display(),
root.display()
),
));
}
let mut chain = Vec::new();
for dir in root.ancestors().take_while(|dir| dir.starts_with(&limit)) {
match &*self.cache.configs(dir, self.fs) {
[] => {}
[path] => {
let kind = if dir == root {
SourceKind::Root
} else {
SourceKind::Ancestor
};
chain.push((path.clone(), kind));
if self.cache.file(path, self.fs)?.root {
break;
}
}
[first, second, ..] => {
return Err(ConfigError::Ambiguous {
first: first.clone(),
second: second.clone(),
});
}
}
}
chain.reverse();
Ok(chain)
}
fn resolve_files(&self, root: &Path, files: Vec<(PathBuf, SourceKind)>) -> Result<ResolvedConfig, ConfigError> {
let mut walk = Walk {
loader: self,
seen: HashSet::default(),
sources: Vec::new(),
files: Vec::new(),
};
for (path, kind) in files {
walk.visit(&path, kind, &mut Vec::new())?;
}
walk.merge(root)
}
}
struct Walk<'l, 'a> {
loader: &'l ConfigLoader<'a>,
seen: HashSet<PathBuf>,
sources: Vec<ConfigSource>,
files: Vec<Arc<ParsedFile>>,
}
impl Walk<'_, '_> {
fn visit(&mut self, path: &Path, kind: SourceKind, open: &mut Vec<PathBuf>) -> Result<(), ConfigError> {
let key = normalize(path);
if let Some(at) = open.iter().position(|file| *file == key) {
let mut chain = open[at..].to_vec();
chain.push(key);
return Err(ConfigError::ExtendsCycle { chain });
}
if self.seen.contains(&key) {
return Ok(());
}
let file = self.loader.cache.file(&key, self.loader.fs)?;
if matches!(kind, SourceKind::Extends { .. }) && file.root {
return Err(ConfigError::scope(
"root",
Some(key.as_path()),
file.position("root"),
"`root` is only valid in a configuration file found by discovery, not in one that is extended",
));
}
if open.len() >= MAX_EXTENDS_DEPTH {
return Err(ConfigError::invalid(
Some(key.as_path()),
format!("`extends` is nested more than {MAX_EXTENDS_DEPTH} files deep"),
));
}
open.push(key.clone());
for (index, entry) in file.extends.iter().enumerate() {
let target = normalize(&file.dir.join(entry));
if !self.loader.fs.is_file(&target) {
let (line, column) = file.position(&format!("extends[{index}]")).unwrap_or((0, 0));
return Err(ConfigError::Io {
path: target,
source: io::Error::new(
io::ErrorKind::NotFound,
format!("no such file (extended from {}:{line}:{column})", key.display()),
),
});
}
self.visit(&target, SourceKind::Extends { by: key.clone() }, open)?;
}
open.pop();
self.seen.insert(key.clone());
self.sources.push(ConfigSource { path: key, kind });
self.files.push(file);
Ok(())
}
fn merge(self, root: &Path) -> Result<ResolvedConfig, ConfigError> {
let Self { sources, files, .. } = self;
let mut merger = Merger::default();
for (index, file) in files.iter().enumerate() {
merger.apply(index, &file.table, &file.spans);
}
let Merged {
table,
origins,
overrides,
} = merger.finish();
let mut config: Config = Value::Table(table)
.try_into()
.map_err(|error: toml::de::Error| ConfigError::invalid(None, error.message()))?;
for (index, scoped) in config.overrides.iter_mut().enumerate() {
let source = overrides.get(index).copied().unwrap_or_default();
let declared_in = sources.get(source).and_then(|file| file.path.parent()).unwrap_or(root);
let scope = OverrideScope::new(scoped, declared_in, source, root)?;
scoped.scope = Some(scope);
}
config.validate().map_err(|error| located(error, &origins, &sources))?;
Ok(ResolvedConfig {
config,
sources,
origins,
})
}
}
fn located(error: ConfigError, origins: &Origins, sources: &[ConfigSource]) -> ConfigError {
let message = match &error {
ConfigError::Invalid { path: None, message } => message.clone(),
_ => return error,
};
let mut key = message.split([':', ' ']).next().unwrap_or_default();
while !key.is_empty() {
if let Some(assignment) = origins.last(key)
&& let Some(source) = sources.get(assignment.source)
{
let line = if assignment.line > 0 {
format!(" (line {})", assignment.line)
} else {
String::new()
};
return ConfigError::invalid(Some(&source.path), format!("{message}{line}"));
}
key = match key.rfind(['.', '[']) {
Some(cut) => &key[..cut],
None => break,
};
}
error
}
fn checked_root(root: &Path) -> Result<PathBuf, ConfigError> {
if root.has_root() {
Ok(normalize(root))
} else {
Err(ConfigError::invalid(
None,
format!("the root {} is not an absolute path", root.display()),
))
}
}
fn normalize(path: &Path) -> PathBuf {
if is_normal(path) {
return path.to_path_buf();
}
rebuild(path)
}
fn is_normal(path: &Path) -> bool {
!cfg!(windows)
&& path
.as_os_str()
.as_encoded_bytes()
.split(|&byte| byte == b'/')
.enumerate()
.all(|(index, part)| (index == 0 || !part.is_empty()) && part != b"." && part != b"..")
}
fn rebuild(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => match normalized.components().next_back() {
Some(Component::Normal(_)) => {
normalized.pop();
}
Some(Component::RootDir | Component::Prefix(_)) => {}
_ => normalized.push(".."),
},
other => normalized.push(other.as_os_str()),
}
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn a_normal_path_is_kept_as_written() {
for path in ["/repo/packages/a", "/repo", "repo/a", "a", ""] {
let path = Path::new(path);
assert!(is_normal(path), "{path:?}");
assert_eq!(normalize(path).as_os_str(), rebuild(path).as_os_str(), "{path:?}");
}
for path in [
"/",
"/repo//a",
"/repo/a/",
"/repo/./a",
"/repo/a/..",
"./a",
"../a",
"a/.",
] {
assert!(!is_normal(Path::new(path)), "{path}");
}
}
}