use globset::Glob;
use indexmap::IndexMap;
use skif_config::{Config, ConfigError, PluginConfig, PluginOptions};
use crate::listed;
type Problem = Result<(), ConfigError>;
fn invalid(message: String) -> Problem {
Err(ConfigError::Invalid { path: None, message })
}
fn glob_problem(pattern: &str) -> Option<String> {
let body = pattern.strip_prefix('!').unwrap_or(pattern);
let body = body.strip_suffix('!').unwrap_or(body);
skif_js_plugins::glob::expand_empty_alternatives(body)
.iter()
.find_map(|variant| Glob::new(variant).err().map(|err| err.kind().to_string()))
}
#[must_use]
pub fn relative_tsconfig(path: &str) -> Option<String> {
let path = path.trim();
if path.is_empty() || path.starts_with(['/', '\\']) || std::path::Path::new(path).is_absolute() {
return None;
}
let mut segments: Vec<&str> = Vec::new();
for segment in path.split(['/', '\\']) {
match segment {
"" | "." => {}
".." => {
segments.pop()?;
}
other => segments.push(other),
}
}
if segments.is_empty() || segments[0].contains(':') {
return None;
}
Some(segments.join("/"))
}
fn globs<S: AsRef<str>>(key: &str, patterns: &[S]) -> Problem {
for (index, pattern) in patterns.iter().enumerate() {
let pattern = pattern.as_ref();
if let Some(message) = glob_problem(pattern) {
return invalid(format!("{key}[{index}]: invalid glob `{pattern}`: {message}"));
}
}
Ok(())
}
fn names<S: AsRef<str>>(key: &str, entries: &[S]) -> Problem {
for (index, entry) in entries.iter().enumerate() {
let entry = entry.as_ref();
let body = entry.strip_suffix('!').unwrap_or(entry);
if let Err(message) = listed::check(body) {
return invalid(format!("{key}[{index}]: invalid pattern `{entry}`: {message}"));
}
}
Ok(())
}
fn plugin_globs(key: &str, table: &IndexMap<String, PluginConfig>) -> Problem {
for (id, plugin) in table {
if let PluginConfig::Options(PluginOptions {
config, entry, project, ..
}) = plugin
{
globs(&format!("{key}.{id}.config"), config)?;
globs(&format!("{key}.{id}.entry"), entry)?;
globs(&format!("{key}.{id}.project"), project)?;
}
}
Ok(())
}
pub(crate) fn validate(config: &Config) -> Problem {
let js = &config.js;
globs("js.entry", &js.entry)?;
globs("js.project", &js.project)?;
globs("js.ignore", &js.ignore)?;
globs("js.extend-ignore", &js.extend_ignore)?;
globs("js.ignore-files", &js.ignore_files)?;
names("js.ignore-dependencies", &js.ignore_dependencies)?;
names("js.extend-ignore-dependencies", &js.extend_ignore_dependencies)?;
names("js.ignore-binaries", &js.ignore_binaries)?;
names("js.extend-ignore-binaries", &js.extend_ignore_binaries)?;
names("js.ignore-unresolved", &js.ignore_unresolved)?;
names("js.extend-ignore-unresolved", &js.extend_ignore_unresolved)?;
names("js.ignore-members", &js.ignore_members)?;
names("js.ignore-workspaces", &js.ignore_workspaces)?;
plugin_globs("js.plugins", &js.plugins)?;
if let Some(path) = &js.tsconfig
&& relative_tsconfig(path).is_none()
{
return invalid(format!(
"js.tsconfig: `{path}` is not a path relative to the workspace root that stays inside it"
));
}
for (index, cycle) in js.cycles.allow.iter().enumerate() {
if cycle.is_empty() || cycle.iter().any(|file| file.trim().is_empty()) {
return invalid(format!(
"js.cycles.allow[{index}]: an allowed cycle needs at least one file and no empty path"
));
}
}
for (dir, workspace) in &js.workspaces {
let key = format!("js.workspaces.\"{dir}\"");
if let Some(message) = glob_problem(dir) {
return invalid(format!("{key}: invalid workspace glob: {message}"));
}
globs(&format!("{key}.entry"), &workspace.entry)?;
globs(&format!("{key}.project"), &workspace.project)?;
globs(&format!("{key}.ignore"), &workspace.ignore)?;
globs(&format!("{key}.ignore-files"), &workspace.ignore_files)?;
names(&format!("{key}.ignore-dependencies"), &workspace.ignore_dependencies)?;
names(&format!("{key}.ignore-binaries"), &workspace.ignore_binaries)?;
names(&format!("{key}.ignore-unresolved"), &workspace.ignore_unresolved)?;
names(&format!("{key}.ignore-members"), &workspace.ignore_members)?;
for (alias, targets) in &workspace.paths {
if alias.trim().is_empty() || targets.is_empty() || targets.iter().any(|target| target.trim().is_empty()) {
return invalid(format!(
"{key}.paths.\"{alias}\": an alias needs a name and at least one non-empty target"
));
}
if let Some(pattern) = std::iter::once(alias)
.chain(targets)
.find(|pattern| pattern.matches('*').count() > 1)
{
return invalid(format!(
"{key}.paths.\"{alias}\": `{pattern}` has more than one `*`; a pattern and each target may hold \
at most one"
));
}
}
plugin_globs(&format!("{key}.plugins"), &workspace.plugins)?;
}
for (index, scoped) in config.overrides.iter().enumerate() {
let key = format!("overrides[{index}].js");
let scoped = &scoped.js;
globs(&format!("{key}.entry"), scoped.entry.as_deref().unwrap_or_default())?;
globs(&format!("{key}.project"), scoped.project.as_deref().unwrap_or_default())?;
globs(&format!("{key}.ignore"), scoped.ignore.as_deref().unwrap_or_default())?;
globs(&format!("{key}.extend-ignore"), &scoped.extend_ignore)?;
names(
&format!("{key}.ignore-dependencies"),
scoped.ignore_dependencies.as_deref().unwrap_or_default(),
)?;
names(
&format!("{key}.extend-ignore-dependencies"),
&scoped.extend_ignore_dependencies,
)?;
names(
&format!("{key}.ignore-binaries"),
scoped.ignore_binaries.as_deref().unwrap_or_default(),
)?;
names(&format!("{key}.extend-ignore-binaries"), &scoped.extend_ignore_binaries)?;
names(
&format!("{key}.ignore-unresolved"),
scoped.ignore_unresolved.as_deref().unwrap_or_default(),
)?;
names(
&format!("{key}.extend-ignore-unresolved"),
&scoped.extend_ignore_unresolved,
)?;
plugin_globs(&format!("{key}.plugins"), &scoped.plugins)?;
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn check(toml: &str) -> String {
let config = Config::from_toml_str(toml).unwrap();
validate(&config).unwrap_err().to_string()
}
#[test]
fn a_valid_configuration_passes() {
let config = Config::from_toml_str(
"[js]\nentry = [\"src/{,sub/}main.ts!\", \"!skip/**\"]\nignore-dependencies = [\"@types/*\", \"/^@org\\\\/.+$/\", \"x!\"]\nignore-unresolved = [\"#internal/*\"]\n[js.plugins]\nvitest = true\n[js.workspaces.\".\"]\nentry = [\"a.ts\"]\n[js.workspaces.\"packages/*\".paths]\n\"@/*\" = [\"./src/*\"]\n",
)
.unwrap();
assert!(validate(&config).is_ok());
}
#[test]
fn invalid_globs_are_reported_with_their_key() {
let message = check("[js]\nentry = [\"ok.ts\", \"src/[bad\"]\n");
assert!(message.contains("js.entry[1]: invalid glob `src/[bad`: "), "{message}");
assert!(check("[js.workspaces.\"apps/[x\"]\nentry = []\n").contains("js.workspaces.\"apps/[x\""));
assert!(
check("[[overrides]]\npackages = [\"a\"]\n[overrides.js]\nignore = [\"[\"]\n")
.contains("overrides[0].js.ignore[0]")
);
}
#[test]
fn invalid_ignore_patterns_are_reported() {
let message = check("[js]\nignore-dependencies = [\"/(/\"]\n");
assert!(
message.contains("js.ignore-dependencies[0]: invalid pattern `/(/`"),
"{message}"
);
assert!(check("[js]\nignore-unresolved = [\"[bad\"]\n").contains("js.ignore-unresolved[0]"));
assert!(check("[js]\nignore-workspaces = [\"a(\"]\n").contains("js.ignore-workspaces[0]"));
}
#[test]
fn tsconfig_paths_stay_inside_the_workspace_root() {
assert_eq!(
relative_tsconfig("./config/../tsconfig.app.json").as_deref(),
Some("tsconfig.app.json")
);
assert_eq!(
relative_tsconfig("config\\tsconfig.json").as_deref(),
Some("config/tsconfig.json")
);
for bad in [
"",
" ",
".",
"/abs/tsconfig.json",
"\\abs.json",
"C:/x.json",
"../tsconfig.json",
"a/../../b.json",
] {
assert_eq!(relative_tsconfig(bad), None, "{bad}");
}
let message = check("[js]\ntsconfig = \"../tsconfig.json\"\n");
assert!(message.contains("js.tsconfig: `../tsconfig.json`"), "{message}");
assert!(check("[js]\ntsconfig = \"/etc/tsconfig.json\"\n").contains("js.tsconfig"));
}
#[test]
fn empty_path_aliases_are_reported() {
assert!(check("[js.workspaces.\"a\".paths]\n\"@/*\" = []\n").contains("paths.\"@/*\""));
}
#[test]
fn path_patterns_with_more_than_one_wildcard_are_reported() {
assert_eq!(
check("[js.workspaces.\"a\".paths]\n\"*/*\" = [\"./src/*\"]\n"),
check_message("js.workspaces.\"a\".paths.\"*/*\": `*/*` has more than one `*`")
);
assert_eq!(
check("[js.workspaces.\"a\".paths]\n\"@/*\" = [\"./src/*\", \"./*/x/*\"]\n"),
check_message("js.workspaces.\"a\".paths.\"@/*\": `./*/x/*` has more than one `*`")
);
for valid in ["*", "serv*", "services/*", "*.svg", "services"] {
let toml = format!("[js.workspaces.\"a\".paths]\n\"{valid}\" = [\"./src/*\"]\n");
assert!(validate(&Config::from_toml_str(&toml).unwrap()).is_ok(), "{valid}");
}
}
fn check_message(start: &str) -> String {
ConfigError::Invalid {
path: None,
message: format!("{start}; a pattern and each target may hold at most one"),
}
.to_string()
}
}