use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Ecosystem {
Cargo,
Ci,
Go,
Npm,
Python,
}
impl Ecosystem {
pub(crate) fn name(self) -> &'static str {
match self {
Self::Cargo => "cargo",
Self::Ci => "ci",
Self::Go => "go",
Self::Npm => "npm",
Self::Python => "python",
}
}
pub(crate) fn parse(text: &str) -> Option<Self> {
match text {
"cargo" => Some(Self::Cargo),
"ci" => Some(Self::Ci),
"go" => Some(Self::Go),
"npm" => Some(Self::Npm),
"python" => Some(Self::Python),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ManifestKind {
PackageJson,
CargoToml,
PyprojectToml,
GoMod,
Workflow,
}
impl ManifestKind {
pub(crate) fn ecosystem(self) -> Ecosystem {
match self {
Self::PackageJson => Ecosystem::Npm,
Self::CargoToml => Ecosystem::Cargo,
Self::PyprojectToml => Ecosystem::Python,
Self::GoMod => Ecosystem::Go,
Self::Workflow => Ecosystem::Ci,
}
}
}
pub(crate) fn basename(filepath: &str) -> &str {
filepath.rsplit(['/', '\\']).next().unwrap_or(filepath)
}
pub(crate) fn manifest_kind(filepath: &str) -> Option<ManifestKind> {
let path = filepath.replace('\\', "/");
if is_workflow(&path) {
return Some(ManifestKind::Workflow);
}
match basename(&path) {
"package.json" => Some(ManifestKind::PackageJson),
"Cargo.toml" => Some(ManifestKind::CargoToml),
"pyproject.toml" => Some(ManifestKind::PyprojectToml),
"go.mod" => Some(ManifestKind::GoMod),
_ => None,
}
}
fn is_workflow(path: &str) -> bool {
let inside = path.starts_with(".github/workflows/") || path.contains("/.github/workflows/");
inside && (path.ends_with(".yml") || path.ends_with(".yaml"))
}
pub(crate) fn is_vendored(name: &str) -> bool {
matches!(name, "node_modules" | ".git" | "vendor")
}
#[derive(Debug, Default)]
pub(crate) struct Excludes {
paths: GlobSet,
names: GlobSet,
}
impl Excludes {
pub(crate) fn new(patterns: &[String]) -> Self {
let mut paths = GlobSetBuilder::new();
let mut names = GlobSetBuilder::new();
for pattern in patterns {
let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() else {
continue;
};
if pattern.contains('/') {
paths.add(glob);
} else {
names.add(glob);
}
}
Self {
paths: paths.build().unwrap_or_else(|_| GlobSet::empty()),
names: names.build().unwrap_or_else(|_| GlobSet::empty()),
}
}
pub(crate) fn matches(&self, filepath: &str) -> bool {
let path = filepath.replace('\\', "/");
self.paths.is_match(&path) || self.names.is_match(basename(&path))
}
}
pub(crate) fn looks_like_a_version(text: &str) -> bool {
let text = text.trim();
if text.is_empty() || text.contains("${{") || text.contains(',') || text.starts_with('[') {
return false;
}
if let Some(rest) = text.strip_prefix(['v', 'V']) {
return rest.starts_with(|c: char| c.is_ascii_digit());
}
if text.starts_with(|c: char| c.is_ascii_digit())
|| text.starts_with(['^', '~', '>', '<', '=', '*'])
{
return true;
}
matches!(
text,
"latest" | "stable" | "nightly" | "beta" | "current" | "node" | "lts"
) || text.starts_with("lts/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_manifest_names_are_recognised() {
for (path, kind) in [
("package.json", ManifestKind::PackageJson),
("crates/api/Cargo.toml", ManifestKind::CargoToml),
("pyproject.toml", ManifestKind::PyprojectToml),
("services/go.mod", ManifestKind::GoMod),
(".github/workflows/ci.yml", ManifestKind::Workflow),
(
"repo/.github/workflows/release.yaml",
ManifestKind::Workflow,
),
] {
assert_eq!(manifest_kind(path), Some(kind), "{path}");
}
}
#[test]
fn yaml_outside_the_workflow_directory_is_not_a_manifest() {
for path in [
"deploy/k8s.yml",
"docker-compose.yaml",
".github/dependabot.yml",
"Cargo.lock",
"package-lock.json",
"requirements.txt",
] {
assert_eq!(manifest_kind(path), None, "{path}");
}
}
#[test]
fn a_manifest_name_is_matched_exactly() {
for path in ["package.json.bak", "my-package.json", "Cargo.toml.orig"] {
assert_eq!(manifest_kind(path), None, "{path}");
}
}
#[test]
fn every_ecosystem_round_trips_through_its_name() {
for ecosystem in [
Ecosystem::Cargo,
Ecosystem::Ci,
Ecosystem::Go,
Ecosystem::Npm,
Ecosystem::Python,
] {
assert_eq!(Ecosystem::parse(ecosystem.name()), Some(ecosystem));
}
assert_eq!(Ecosystem::parse("maven"), None);
}
#[test]
fn the_resolver_output_directories_are_never_walked() {
assert!(is_vendored("node_modules"));
assert!(!is_vendored("packages"));
}
fn excludes(patterns: &[&str], filepath: &str) -> bool {
let patterns: Vec<String> = patterns.iter().map(|p| (*p).to_string()).collect();
Excludes::new(&patterns).matches(filepath)
}
#[test]
fn a_bare_pattern_matches_the_basename_anywhere() {
assert!(excludes(&["package.json"], "examples/package.json"));
assert!(!excludes(&["package.json"], "Cargo.toml"));
}
#[test]
fn a_pattern_with_a_slash_matches_the_path() {
assert!(excludes(&["examples/**"], "examples/a/package.json"));
assert!(!excludes(&["examples/**"], "src/package.json"));
}
#[test]
fn an_uncompilable_pattern_excludes_nothing() {
assert!(!excludes(&["["], "package.json"));
assert!(!excludes(&["["], "["));
assert!(excludes(&["[", "Cargo.toml"], "Cargo.toml"));
}
#[test]
fn the_glob_syntax_is_the_one_ripgrep_uses() {
assert!(excludes(&["*.json"], "a/package.json"));
assert!(!excludes(&["src/*.json"], "src/a/package.json"));
assert!(excludes(&["src/**/*.json"], "src/a/b/package.json"));
assert!(excludes(&["**/vendor/**"], "a/vendor/b/Cargo.toml"));
assert!(excludes(&["Cargo.???l"], "Cargo.toml"));
assert!(!excludes(&["Cargo.?"], "Cargo.toml"));
assert!(excludes(&["[Cc]argo.toml"], "Cargo.toml"));
assert!(excludes(
&["{examples,fixtures}/**"],
"fixtures/a/Cargo.toml"
));
assert!(!excludes(&["{examples,fixtures}/**"], "src/a/Cargo.toml"));
}
#[test]
fn a_version_shaped_string_passes_the_evidence_gate() {
for text in [
"20", "1.1.0", "v4", ">=18", "^1.2.3", "*", "stable", "lts/*",
] {
assert!(looks_like_a_version(text), "{text}");
}
}
#[test]
fn anything_else_is_not_evidence_of_a_version() {
for text in [
"${{ matrix.node }}",
".nvmrc",
"[18, 20, 22]",
"18, 20",
"",
"file",
] {
assert!(!looks_like_a_version(text), "{text}");
}
}
}