use std::path::{Path, PathBuf};
use anyhow::{Result, bail};
use super::local_file::{LocalDispatch, bare_file_in};
use crate::resolver::ResolutionOverrides;
use crate::types::ProjectContext;
#[derive(serde::Deserialize)]
struct Manifest {
name: Option<String>,
bin: Option<serde_json::Value>,
}
pub(super) struct ResolvedBin {
pub(super) dispatch: LocalDispatch,
pub(super) describe: String,
}
pub(super) fn try_installed_package(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
token: &str,
args: &[String],
) -> Result<Option<ResolvedBin>> {
if !is_package_name(token) {
return Ok(None);
}
let Some(dir) = installed_dir(&ctx.root, token) else {
return Ok(None);
};
let manifest: Manifest = match std::fs::read_to_string(dir.join("package.json")) {
Ok(raw) => serde_json::from_str(&raw)?,
Err(_) => return Ok(None),
};
let (bin_name, bin_path) = select_bin(token, &manifest)?;
let Some(dispatch) = bare_file_in(ctx, overrides, &dir, &bin_path, args)? else {
bail!(
"{token} declares a `{bin_name}` binary at {}, but nothing is there.\nhint: reinstall \
dependencies.",
dir.join(&bin_path).display(),
);
};
Ok(Some(ResolvedBin {
describe: format!("{bin_name} from {} (local dependency)", dir.display()),
dispatch,
}))
}
fn is_package_name(token: &str) -> bool {
let scoped = token.starts_with('@');
let body = token.strip_prefix('@').unwrap_or(token);
!body.is_empty()
&& !body.contains('@')
&& !body.contains('#')
&& !body.contains('\\')
&& body.matches('/').count() == usize::from(scoped)
}
fn installed_dir(root: &Path, token: &str) -> Option<PathBuf> {
root.ancestors()
.map(|ancestor| ancestor.join("node_modules").join(token))
.find(|dir| dir.join("package.json").is_file())
}
fn select_bin(token: &str, manifest: &Manifest) -> Result<(String, String)> {
let declared = manifest.name.as_deref().map(unscoped);
let requested = unscoped(token);
match &manifest.bin {
Some(serde_json::Value::String(path)) => {
Ok((declared.unwrap_or(requested).to_string(), path.clone()))
}
Some(serde_json::Value::Object(map)) if map.len() == 1 => {
let (name, path) = map.iter().next().expect("one entry");
Ok((name.clone(), string_path(token, name, path)?))
}
Some(serde_json::Value::Object(map)) if !map.is_empty() => {
let matched = map
.iter()
.find(|(name, _)| *name == requested || Some(name.as_str()) == declared);
let Some((name, path)) = matched else {
let mut names: Vec<&str> = map.keys().map(String::as_str).collect();
names.sort_unstable();
bail!(
"{token} exposes {} binaries ({}); none is named after the package.\nhint: \
run the binary directly, e.g. `runner run {}`.",
map.len(),
names.join(", "),
names[0],
);
};
Ok((name.clone(), string_path(token, name, path)?))
}
_ => bail!("{token} is installed but declares no binary in its package.json"),
}
}
fn string_path(token: &str, name: &str, path: &serde_json::Value) -> Result<String> {
match path.as_str() {
Some(path) => Ok(path.to_string()),
None => bail!("{token} declares a non-string path for its `{name}` binary"),
}
}
fn unscoped(name: &str) -> &str {
name.rsplit('/').next().unwrap_or(name)
}
#[cfg(test)]
mod tests {
use super::{Manifest, is_package_name, select_bin, unscoped};
fn manifest(json: &str) -> Manifest {
serde_json::from_str(json).expect("manifest should parse")
}
#[test]
fn package_names_accept_plain_and_scoped_forms() {
assert!(is_package_name("typescript"));
assert!(is_package_name("@typescript/native"));
}
#[test]
fn package_names_reject_remote_specs() {
assert!(!is_package_name("typescript@7"));
assert!(!is_package_name("@scope/pkg@1.2.3"));
assert!(!is_package_name("user/repo#ref"));
assert!(!is_package_name("github.com/foo/tool"));
assert!(!is_package_name(""));
assert!(!is_package_name("@"));
}
#[test]
fn string_bin_is_named_after_the_package() {
let got = select_bin(
"@typescript/native",
&manifest(r#"{ "name": "typescript", "bin": "./bin/tsc" }"#),
)
.expect("string bin resolves");
assert_eq!(got, ("typescript".to_string(), "./bin/tsc".to_string()));
}
#[test]
fn single_object_bin_needs_no_selector() {
let got = select_bin(
"@typescript/native",
&manifest(r#"{ "name": "typescript", "bin": { "tsc": "./bin/tsc" } }"#),
)
.expect("single bin resolves");
assert_eq!(got, ("tsc".to_string(), "./bin/tsc".to_string()));
}
#[test]
fn multi_bin_prefers_the_entry_named_after_the_package() {
let got = select_bin(
"eslint",
&manifest(
r#"{ "name": "eslint", "bin": { "eslint": "./bin/eslint.js", "x": "./x.js" } }"#,
),
)
.expect("named bin resolves");
assert_eq!(got.0, "eslint");
}
#[test]
fn multi_bin_without_a_match_is_an_error_naming_the_options() {
let err = select_bin(
"typescript",
&manifest(r#"{ "name": "typescript", "bin": { "tsc": "a", "tsserver": "b" } }"#),
)
.expect_err("ambiguous bins must not guess");
let msg = format!("{err:#}");
assert!(msg.contains("tsc"), "msg: {msg}");
assert!(msg.contains("tsserver"), "msg: {msg}");
}
#[test]
fn zero_bin_package_reports_that_specifically() {
let err = select_bin("left-pad", &manifest(r#"{ "name": "left-pad" }"#))
.expect_err("a library has nothing to run");
assert!(format!("{err:#}").contains("declares no binary"));
}
#[test]
fn unscoped_strips_the_scope() {
assert_eq!(unscoped("@typescript/native"), "native");
assert_eq!(unscoped("typescript"), "typescript");
}
}