use indexmap::{IndexMap, IndexSet};
use serde_json::Value;
use skif_js_plugins::scripts::{FileTree, ScriptContext, ScriptInfo, is_ignored_binary, parse_script};
use skif_workspace::Package;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct ScriptBinary {
pub name: String,
pub script: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct ForwardedBinary {
pub targets: Vec<String>,
pub binary: ScriptBinary,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct RelocatedBinary {
pub dir: String,
pub binary: ScriptBinary,
pub ephemeral: bool,
pub filtered: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct RelocatedDependency {
pub dir: String,
pub name: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ScriptFacts {
pub binaries: Vec<ScriptBinary>,
pub global_binaries: Vec<ScriptBinary>,
pub ephemeral_binaries: Vec<ScriptBinary>,
pub forwarded: Vec<ForwardedBinary>,
pub relocated_binaries: Vec<RelocatedBinary>,
pub relocated_dependencies: Vec<RelocatedDependency>,
pub package_managers: Vec<String>,
pub files: Vec<String>,
pub production_files: Vec<String>,
pub file_trees: IndexMap<String, FileTree>,
pub dependencies: Vec<String>,
}
pub(crate) const PRODUCTION_SCRIPT: &str = "start";
fn is_comment_key(name: &str) -> bool {
name.starts_with('#') || name.starts_with("//")
}
fn merge_tree(trees: &mut IndexMap<String, FileTree>, path: &str, tree: FileTree) {
let Some(known) = trees.get_mut(path) else {
trees.insert(path.to_owned(), tree);
return;
};
known.recursive |= tree.recursive;
for extension in tree.extensions {
if !known.extensions.contains(&extension) {
known.extensions.push(extension);
}
}
}
pub(crate) fn analyze(package: &Package) -> ScriptFacts {
analyze_manifest(package.manifest.value())
}
fn commands(manifest: &Value) -> Vec<(&String, &str)> {
manifest
.get("scripts")
.and_then(Value::as_object)
.into_iter()
.flatten()
.filter(|(name, _)| !is_comment_key(name))
.filter_map(|(name, body)| Some((name, body.as_str()?)))
.collect()
}
pub(crate) fn script_context(manifest: &Value) -> ScriptContext {
commands(manifest)
.into_iter()
.fold(ScriptContext::new(), |context, (name, body)| {
context.with_script(name, body)
})
}
pub(crate) fn analyze_manifest(manifest: &Value) -> ScriptFacts {
let commands = commands(manifest);
let context = script_context(manifest);
let mut collected = Collected::default();
for (name, body) in commands {
collected.add(name, parse_script(body, &context.clone().with_current_script(name)));
}
collected.into_facts()
}
#[derive(Default)]
struct Collected {
binaries: IndexSet<ScriptBinary>,
ephemeral_binaries: IndexSet<ScriptBinary>,
global_binaries: IndexSet<ScriptBinary>,
forwarded: IndexSet<ForwardedBinary>,
relocated_binaries: IndexSet<RelocatedBinary>,
relocated_dependencies: IndexSet<RelocatedDependency>,
package_managers: IndexSet<String>,
files: IndexSet<String>,
production_files: IndexSet<String>,
file_trees: IndexMap<String, FileTree>,
dependencies: IndexSet<String>,
}
impl Collected {
fn add(&mut self, name: &str, info: ScriptInfo) {
let binary = |binary: String| ScriptBinary {
name: binary,
script: name.to_owned(),
};
for used in info.binaries {
if is_ignored_binary(&used.name) {
continue;
}
if used.filtered && !used.workspaces.is_empty() {
self.forwarded.insert(ForwardedBinary {
targets: used.workspaces,
binary: binary(used.name),
});
continue;
}
if let Some(dir) = used.relocation_dir().map(str::to_owned) {
self.relocated_binaries.insert(RelocatedBinary {
dir,
binary: binary(used.name),
ephemeral: used.via.is_ephemeral(),
filtered: used.filtered,
});
continue;
}
if used.filtered {
continue;
}
let target = if used.via.is_ephemeral() {
&mut self.ephemeral_binaries
} else {
&mut self.binaries
};
target.insert(binary(used.name));
}
for used in info.global_binaries {
if !used.filtered && !used.via.is_ephemeral() {
self.global_binaries.insert(binary(used.name));
}
}
self.package_managers.extend(info.package_managers);
for file in info.files {
if let Some(tree) = file.tree {
merge_tree(&mut self.file_trees, &file.path, tree);
}
if name == PRODUCTION_SCRIPT {
self.production_files.insert(file.path.clone());
}
self.files.insert(file.path);
}
for dependency in info.dependencies.into_iter().filter(|dependency| !dependency.ephemeral) {
match dependency.dir {
Some(dir) => {
self.relocated_dependencies.insert(RelocatedDependency {
dir,
name: dependency.name,
});
}
None => {
self.dependencies.insert(dependency.name);
}
}
}
}
fn into_facts(self) -> ScriptFacts {
ScriptFacts {
binaries: self.binaries.into_iter().collect(),
ephemeral_binaries: self.ephemeral_binaries.into_iter().collect(),
global_binaries: self.global_binaries.into_iter().collect(),
forwarded: self.forwarded.into_iter().collect(),
relocated_binaries: self.relocated_binaries.into_iter().collect(),
relocated_dependencies: self.relocated_dependencies.into_iter().collect(),
package_managers: self.package_managers.into_iter().collect(),
files: self.files.into_iter().collect(),
production_files: self.production_files.into_iter().collect(),
file_trees: self.file_trees,
dependencies: self.dependencies.into_iter().collect(),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::sync::Arc;
use super::*;
fn package(json: &str) -> Package {
Package::parse("pkg".into(), Arc::from(json), false).unwrap()
}
#[test]
fn binaries_files_and_dependencies() {
let facts = analyze(&package(
r#"{"name":"p","scripts":{"build":"tsc -p . && node scripts/build.js","test":"vitest run","dlx":"npx cowsay hi"}}"#,
));
let names: Vec<&str> = facts.binaries.iter().map(|b| b.name.as_str()).collect();
assert!(names.contains(&"tsc") && names.contains(&"vitest"), "{names:?}");
assert!(!names.contains(&"cowsay"));
assert_eq!(facts.ephemeral_binaries.len(), 1);
assert_eq!(facts.ephemeral_binaries[0].name, "cowsay");
assert!(
facts.files.iter().any(|f| f.ends_with("scripts/build.js")),
"{:?}",
facts.files
);
}
#[test]
fn the_files_of_the_start_script_are_production_files() {
let facts = analyze(&package(
r#"{"name":"p","scripts":{"start":"node server.js","build":"node scripts/build.js","prestart":"node scripts/check.js"}}"#,
));
assert_eq!(facts.files, ["server.js", "scripts/build.js", "scripts/check.js"]);
assert_eq!(facts.production_files, ["server.js"]);
}
#[test]
fn binaries_run_in_named_workspaces_are_forwarded() {
let facts = analyze(&package(
r#"{"name":"p","scripts":{"a":"pnpm --filter web exec vitest","b":"yarn workspace sub run biome check","c":"pnpm -r exec tsc"}}"#,
));
let own: Vec<&str> = facts.binaries.iter().map(|b| b.name.as_str()).collect();
assert_eq!(own, ["tsc"]);
let forwarded: Vec<(&str, &[String])> = facts
.forwarded
.iter()
.map(|f| (f.binary.name.as_str(), f.targets.as_slice()))
.collect();
assert_eq!(
forwarded,
[("vitest", &["web".to_owned()][..]), ("biome", &["sub".to_owned()][..])]
);
}
#[test]
fn commands_run_in_another_directory_are_relocated() {
let facts = analyze(&package(
r#"{"name":"p","scripts":{"a":"cd sub && rspack serve && node -r @x/hook ./x.js","b":"pnpm -C web exec vite","c":"tsc"}}"#,
));
let own: Vec<&str> = facts.binaries.iter().map(|b| b.name.as_str()).collect();
assert_eq!(own, ["tsc"]);
let relocated: Vec<(&str, &str, bool)> = facts
.relocated_binaries
.iter()
.map(|r| (r.dir.as_str(), r.binary.name.as_str(), r.filtered))
.collect();
assert_eq!(relocated, [("sub", "rspack", false), ("web", "vite", true)]);
let dependencies: Vec<(&str, &str)> = facts
.relocated_dependencies
.iter()
.map(|r| (r.dir.as_str(), r.name.as_str()))
.collect();
assert_eq!(dependencies, [("sub", "@x/hook")]);
assert!(facts.dependencies.is_empty(), "{:?}", facts.dependencies);
assert_eq!(facts.files, ["sub/x.js"]);
}
#[test]
fn comment_keys_are_not_commands() {
let facts = analyze(&package(
r##"{"name":"p","scripts":{"#note":"The build runs first","//test":"Ensure the suite passes","test":"vitest run"}}"##,
));
let names: Vec<&str> = facts.binaries.iter().map(|b| b.name.as_str()).collect();
assert_eq!(names, ["vitest"]);
}
#[test]
fn no_scripts() {
assert_eq!(analyze(&package(r#"{"name":"p"}"#)), ScriptFacts::default());
}
}