skif-js 0.1.0

JavaScript and TypeScript module graph and dependency rules for skif, built on oxc.
Documentation
//! Static validation of the `[js]` configuration: globs and regular expressions that do not compile (plugin ids that
//! do not exist are rejected by [`Config::validate_plugins`]). Settings that depend on the workspace (a `[js.workspaces]` key that selects no package, an
//! ignore entry that matches nothing) are reported by the `stale-js-config` rule instead.

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 })
}

/// Whether `pattern` compiles as a glob, with the `!` negation and production markers and the empty alternatives
/// (`{,a}`) the plugin dialect allows.
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()))
}

fn globs(key: &str, patterns: &[String]) -> Problem {
    for (index, pattern) in patterns.iter().enumerate() {
        if let Some(message) = glob_problem(pattern) {
            return invalid(format!("{key}[{index}]: invalid glob `{pattern}`: {message}"));
        }
    }
    Ok(())
}

/// Entries of an ignore list: exact names, globs or regular expressions, optionally ending in `!`.
fn names(key: &str, entries: &[String]) -> Problem {
    for (index, entry) in entries.iter().enumerate() {
        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(())
}

/// The globs of the options of the plugins in `table`.
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(())
}

/// Validates every `[js]` setting that can be checked without the workspace.
///
/// # Errors
/// [`ConfigError::Invalid`] naming the first key whose value is not a valid glob, not a valid regular expression, or
/// is empty where a value is required.
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)?;
    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)?;
    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)?;
        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"
                ));
            }
        }
        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 empty_path_aliases_are_reported() {
        assert!(check("[js.workspaces.\"a\".paths]\n\"@/*\" = []\n").contains("paths.\"@/*\""));
    }
}