use miette::miette;
#[cfg_attr(test, derive(Debug))]
pub(super) struct ParsedPkgSpec {
pub(super) alias: Option<String>,
pub(super) name: String,
pub(super) jsr_name: Option<String>,
pub(super) range: String,
pub(super) has_explicit_range: bool,
pub(super) git_spec: Option<String>,
pub(super) local_spec: Option<String>,
pub(super) linked_workspace_version: Option<String>,
}
pub(super) fn parse_pkg_spec(spec: &str) -> miette::Result<ParsedPkgSpec> {
if aube_lockfile::parse_git_spec(spec).is_some() {
return parse_git_pkg_spec(spec, None);
}
if let Some((alias, rest)) = split_git_alias(spec)
&& aube_lockfile::parse_git_spec(rest).is_some()
{
return parse_git_pkg_spec(rest, Some(alias.to_string()));
}
if let Some((alias, rest)) = split_scoped_alias(spec) {
if aube_lockfile::parse_git_spec(rest).is_some() {
return parse_git_pkg_spec(rest, Some(alias.to_string()));
}
if is_local_path_spec(rest) {
return parse_local_pkg_spec(rest, Some(alias.to_string()));
}
}
if is_local_path_spec(spec) {
return parse_local_pkg_spec(spec, None);
}
if let Some((alias, rest)) = split_local_alias(spec)
&& is_local_path_spec(rest)
{
return parse_local_pkg_spec(rest, Some(alias.to_string()));
}
if let Some(jsr_idx) = spec.find("@jsr:") {
let before = &spec[..jsr_idx];
let after_jsr = &spec[jsr_idx + 5..]; let alias = if before.is_empty() {
None
} else {
Some(before.to_string())
};
return parse_jsr_name_range(after_jsr, alias);
}
if let Some(rest) = spec.strip_prefix("jsr:") {
return parse_jsr_name_range(rest, None);
}
if let Some(npm_idx) = spec.find("@npm:") {
let before = &spec[..npm_idx];
let after_npm = &spec[npm_idx + 5..];
let alias = if before.is_empty() {
None
} else {
Some(before.to_string())
};
return Ok(parse_name_range(after_npm, alias));
}
if let Some(rest) = spec.strip_prefix("npm:") {
return Ok(parse_name_range(rest, None));
}
Ok(parse_name_range(spec, None))
}
fn split_git_alias(spec: &str) -> Option<(&str, &str)> {
split_protocol_alias(spec)
}
fn is_local_path_spec(spec: &str) -> bool {
if spec.starts_with("link:") {
return true;
}
if spec.starts_with("file:") {
return aube_lockfile::parse_git_spec(spec).is_none();
}
looks_like_path(spec)
}
fn looks_like_path(s: &str) -> bool {
if s.starts_with("./")
|| s.starts_with("../")
|| s.starts_with('/')
|| s.starts_with("~/")
|| s.starts_with("~\\")
|| s.starts_with('\\')
|| s.starts_with(".\\")
|| s.starts_with("..\\")
{
return true;
}
let bytes = s.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'/' || bytes[2] == b'\\')
}
fn is_tarball_suffix(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
lower.ends_with(".tgz") || lower.ends_with(".tar.gz") || lower.ends_with(".tar")
}
fn expand_tilde(s: &str) -> miette::Result<String> {
let Some(rest) = s.strip_prefix("~/").or_else(|| s.strip_prefix("~\\")) else {
return Ok(s.to_string());
};
let home = aube_util::env::home_dir().ok_or_else(|| {
miette!(
"cannot expand `~/` in `{s}` — $HOME is not set; \
pass an absolute path or set $HOME"
)
})?;
Ok(home.join(rest).to_string_lossy().into_owned())
}
fn prefix_bare_local_path(spec: &str) -> miette::Result<String> {
if spec.starts_with("file:") || spec.starts_with("link:") {
return Ok(spec.to_string());
}
let expanded = expand_tilde(spec)?;
let prefix = if is_tarball_suffix(&expanded) {
"file:"
} else {
"link:"
};
Ok(format!("{prefix}{expanded}"))
}
fn split_local_alias(spec: &str) -> Option<(&str, &str)> {
split_protocol_alias(spec)
}
fn split_protocol_alias(spec: &str) -> Option<(&str, &str)> {
let at = spec.find('@')?;
if at == 0 {
return None;
}
let alias = &spec[..at];
if alias.contains(':') {
return None;
}
Some((alias, &spec[at + 1..]))
}
fn split_scoped_alias(spec: &str) -> Option<(&str, &str)> {
if !spec.starts_with('@') {
return None;
}
let slash = spec.find('/')?;
let after_slash = &spec[slash + 1..];
let at_in_after = after_slash.find('@')?;
let alias_end = slash + 1 + at_in_after;
if alias_end == 0 {
return None;
}
Some((&spec[..alias_end], &spec[alias_end + 1..]))
}
fn parse_git_pkg_spec(verbatim: &str, alias: Option<String>) -> miette::Result<ParsedPkgSpec> {
let (clone_url, _committish, _subpath) = aube_lockfile::parse_git_spec(verbatim)
.ok_or_else(|| miette!("expected git spec, got `{verbatim}`"))?;
let name = match &alias {
Some(a) => a.clone(),
None => repo_name_from_clone_url(&clone_url).ok_or_else(|| {
miette!(
"could not derive a package name from git URL `{clone_url}`; \
pass an alias (e.g. `my-name@{verbatim}`)"
)
})?,
};
Ok(ParsedPkgSpec {
alias,
name,
jsr_name: None,
range: verbatim.to_string(),
has_explicit_range: true,
git_spec: Some(verbatim.to_string()),
local_spec: None,
linked_workspace_version: None,
})
}
fn parse_local_pkg_spec(input: &str, alias: Option<String>) -> miette::Result<ParsedPkgSpec> {
let verbatim = prefix_bare_local_path(input)?;
let path = verbatim
.strip_prefix("file:")
.or_else(|| verbatim.strip_prefix("link:"))
.ok_or_else(|| miette!("expected file:/link: spec, got `{verbatim}`"))?;
let name = match &alias {
Some(a) => a.clone(),
None => basename_from_local_path(path).ok_or_else(|| {
miette!(
"could not derive a package name from local spec `{verbatim}`; \
pass an alias (e.g. `my-name@{verbatim}`)"
)
})?,
};
Ok(ParsedPkgSpec {
alias,
name,
jsr_name: None,
range: verbatim.clone(),
has_explicit_range: true,
git_spec: None,
local_spec: Some(verbatim),
linked_workspace_version: None,
})
}
fn repo_name_from_clone_url(url: &str) -> Option<String> {
let body = url.split_once('?').map(|(b, _)| b).unwrap_or(url);
let body = body.split_once('#').map(|(b, _)| b).unwrap_or(body);
let last = body.rsplit('/').next()?;
let stripped = last.strip_suffix(".git").unwrap_or(last);
if stripped.is_empty() {
return None;
}
Some(stripped.to_string())
}
fn basename_from_local_path(path: &str) -> Option<String> {
let trimmed = path.trim_end_matches(['/', '\\']);
if trimmed.is_empty() {
return None;
}
let last = trimmed.rsplit(['/', '\\']).next()?;
let stripped = last
.strip_suffix(".tar.gz")
.or_else(|| last.strip_suffix(".tgz"))
.or_else(|| last.strip_suffix(".tar"))
.unwrap_or(last);
if stripped.is_empty() || stripped == "." || stripped == ".." {
return None;
}
Some(stripped.to_string())
}
fn parse_name_range(s: &str, alias: Option<String>) -> ParsedPkgSpec {
if s.starts_with('@') {
if let Some(slash_idx) = s.find('/') {
let after_slash = &s[slash_idx + 1..];
if let Some(at_idx) = after_slash.find('@') {
return ParsedPkgSpec {
alias,
name: s[..slash_idx + 1 + at_idx].to_string(),
jsr_name: None,
range: after_slash[at_idx + 1..].to_string(),
has_explicit_range: true,
git_spec: None,
local_spec: None,
linked_workspace_version: None,
};
}
}
return ParsedPkgSpec {
alias,
name: s.to_string(),
jsr_name: None,
range: "latest".to_string(),
has_explicit_range: false,
git_spec: None,
local_spec: None,
linked_workspace_version: None,
};
}
if let Some(at_idx) = s.find('@') {
ParsedPkgSpec {
alias,
name: s[..at_idx].to_string(),
jsr_name: None,
range: s[at_idx + 1..].to_string(),
has_explicit_range: true,
git_spec: None,
local_spec: None,
linked_workspace_version: None,
}
} else {
ParsedPkgSpec {
alias,
name: s.to_string(),
jsr_name: None,
range: "latest".to_string(),
has_explicit_range: false,
git_spec: None,
local_spec: None,
linked_workspace_version: None,
}
}
}
fn parse_jsr_name_range(s: &str, alias: Option<String>) -> miette::Result<ParsedPkgSpec> {
let inner = parse_name_range(s, None);
let jsr_name = inner.name.clone();
let npm_name = aube_registry::jsr::jsr_to_npm_name(&jsr_name).ok_or_else(|| {
miette!(
"invalid jsr: spec — expected `jsr:@scope/name[@range]`, got `jsr:{s}` \
(JSR packages must be scoped, e.g. `jsr:@std/collections`)"
)
})?;
let final_alias = alias.or_else(|| Some(jsr_name.clone()));
Ok(ParsedPkgSpec {
alias: final_alias,
name: npm_name,
jsr_name: Some(jsr_name),
range: inner.range,
has_explicit_range: inner.has_explicit_range,
git_spec: None,
local_spec: None,
linked_workspace_version: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pkg_spec_name_only() {
let s = parse_pkg_spec("lodash").unwrap();
assert_eq!(s.name, "lodash");
assert_eq!(s.range, "latest");
assert!(s.alias.is_none());
assert!(s.jsr_name.is_none());
}
#[test]
fn test_parse_pkg_spec_with_version() {
let s = parse_pkg_spec("lodash@^4.17.0").unwrap();
assert_eq!(s.name, "lodash");
assert_eq!(s.range, "^4.17.0");
assert!(s.alias.is_none());
}
#[test]
fn test_parse_pkg_spec_exact_version() {
let s = parse_pkg_spec("lodash@4.17.21").unwrap();
assert_eq!(s.name, "lodash");
assert_eq!(s.range, "4.17.21");
}
#[test]
fn test_parse_pkg_spec_scoped() {
let s = parse_pkg_spec("@babel/core").unwrap();
assert_eq!(s.name, "@babel/core");
assert_eq!(s.range, "latest");
}
#[test]
fn test_parse_pkg_spec_scoped_with_version() {
let s = parse_pkg_spec("@babel/core@^7.24.0").unwrap();
assert_eq!(s.name, "@babel/core");
assert_eq!(s.range, "^7.24.0");
}
#[test]
fn test_parse_pkg_spec_dist_tag() {
let s = parse_pkg_spec("lodash@latest").unwrap();
assert_eq!(s.name, "lodash");
assert_eq!(s.range, "latest");
}
#[test]
fn test_parse_pkg_spec_npm_bare() {
let s = parse_pkg_spec("npm:string-width@^4.2.0").unwrap();
assert_eq!(s.name, "string-width");
assert_eq!(s.range, "^4.2.0");
assert!(s.alias.is_none());
}
#[test]
fn test_parse_pkg_spec_npm_alias_full() {
let s = parse_pkg_spec("string-width-cjs@npm:string-width@^4.2.0").unwrap();
assert_eq!(s.alias.as_deref(), Some("string-width-cjs"));
assert_eq!(s.name, "string-width");
assert_eq!(s.range, "^4.2.0");
}
#[test]
fn test_parse_pkg_spec_npm_alias_scoped() {
let s = parse_pkg_spec("my-react@npm:@preact/compat@^17.0.0").unwrap();
assert_eq!(s.alias.as_deref(), Some("my-react"));
assert_eq!(s.name, "@preact/compat");
assert_eq!(s.range, "^17.0.0");
}
#[test]
fn test_parse_pkg_spec_npm_alias_no_version() {
let s = parse_pkg_spec("my-lodash@npm:lodash").unwrap();
assert_eq!(s.alias.as_deref(), Some("my-lodash"));
assert_eq!(s.name, "lodash");
assert_eq!(s.range, "latest");
}
#[test]
fn test_parse_pkg_spec_jsr_bare_no_range() {
let s = parse_pkg_spec("jsr:@std/collections").unwrap();
assert_eq!(s.alias.as_deref(), Some("@std/collections"));
assert_eq!(s.name, "@jsr/std__collections");
assert_eq!(s.jsr_name.as_deref(), Some("@std/collections"));
assert_eq!(s.range, "latest");
assert!(!s.has_explicit_range);
}
#[test]
fn test_parse_pkg_spec_jsr_bare_with_range() {
let s = parse_pkg_spec("jsr:@std/collections@^1.0.0").unwrap();
assert_eq!(s.alias.as_deref(), Some("@std/collections"));
assert_eq!(s.name, "@jsr/std__collections");
assert_eq!(s.jsr_name.as_deref(), Some("@std/collections"));
assert_eq!(s.range, "^1.0.0");
assert!(s.has_explicit_range);
}
#[test]
fn test_parse_pkg_spec_jsr_aliased() {
let s = parse_pkg_spec("collections@jsr:@std/collections@^1.0.0").unwrap();
assert_eq!(s.alias.as_deref(), Some("collections"));
assert_eq!(s.name, "@jsr/std__collections");
assert_eq!(s.jsr_name.as_deref(), Some("@std/collections"));
assert_eq!(s.range, "^1.0.0");
}
#[test]
fn test_parse_pkg_spec_jsr_rejects_unscoped() {
let err = parse_pkg_spec("jsr:collections").unwrap_err();
assert!(
err.to_string().contains("JSR packages must be scoped"),
"unexpected error: {err}"
);
}
#[test]
fn test_parse_pkg_spec_git_bare_github_shorthand() {
let s = parse_pkg_spec("kevva/is-negative").unwrap();
assert_eq!(s.git_spec.as_deref(), Some("kevva/is-negative"));
assert_eq!(s.name, "is-negative");
assert_eq!(s.range, "kevva/is-negative");
assert!(s.alias.is_none());
assert!(s.has_explicit_range);
}
#[test]
fn test_parse_pkg_spec_git_github_protocol() {
let s = parse_pkg_spec("github:user/repo").unwrap();
assert_eq!(s.git_spec.as_deref(), Some("github:user/repo"));
assert_eq!(s.name, "repo");
assert_eq!(s.range, "github:user/repo");
assert!(s.alias.is_none());
}
#[test]
fn test_parse_pkg_spec_git_url_with_committish() {
let spec = "git+https://github.com/owner/repo.git#tag/with/slash";
let s = parse_pkg_spec(spec).unwrap();
assert_eq!(s.git_spec.as_deref(), Some(spec));
assert_eq!(s.name, "repo");
assert_eq!(s.range, spec);
}
#[test]
fn test_parse_pkg_spec_git_alias() {
let s = parse_pkg_spec("my-alias@kevva/is-negative").unwrap();
assert_eq!(s.git_spec.as_deref(), Some("kevva/is-negative"));
assert_eq!(s.alias.as_deref(), Some("my-alias"));
assert_eq!(s.name, "my-alias");
assert_eq!(s.range, "kevva/is-negative");
}
#[test]
fn test_parse_pkg_spec_git_alias_skips_url_derivation() {
let s = parse_pkg_spec("my-alias@git+https://example.com/").unwrap();
assert_eq!(s.git_spec.as_deref(), Some("git+https://example.com/"));
assert_eq!(s.alias.as_deref(), Some("my-alias"));
assert_eq!(s.name, "my-alias");
}
#[test]
fn test_parse_pkg_spec_file_relative() {
let s = parse_pkg_spec("file:./local/pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:./local/pkg"));
assert_eq!(s.name, "pkg");
assert_eq!(s.range, "file:./local/pkg");
assert!(s.alias.is_none());
assert!(s.has_explicit_range);
}
#[test]
fn test_parse_pkg_spec_link_relative() {
let s = parse_pkg_spec("link:./local/pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("link:./local/pkg"));
assert_eq!(s.name, "pkg");
assert_eq!(s.range, "link:./local/pkg");
assert!(s.alias.is_none());
}
#[test]
fn test_parse_pkg_spec_file_absolute() {
let s = parse_pkg_spec("file:/abs/pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:/abs/pkg"));
assert_eq!(s.name, "pkg");
}
#[test]
fn test_parse_pkg_spec_file_tarball_strips_extension() {
let s = parse_pkg_spec("file:./bundle.tgz").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:./bundle.tgz"));
assert_eq!(s.name, "bundle");
}
#[test]
fn test_parse_pkg_spec_file_alias() {
let s = parse_pkg_spec("my-alias@file:./pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:./pkg"));
assert_eq!(s.alias.as_deref(), Some("my-alias"));
assert_eq!(s.name, "my-alias");
assert_eq!(s.range, "file:./pkg");
}
#[test]
fn test_parse_pkg_spec_link_alias() {
let s = parse_pkg_spec("my-alias@link:./pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("link:./pkg"));
assert_eq!(s.alias.as_deref(), Some("my-alias"));
assert_eq!(s.name, "my-alias");
assert_eq!(s.range, "link:./pkg");
}
#[test]
fn test_parse_pkg_spec_local_alias_skips_basename_derivation() {
let s = parse_pkg_spec("my-alias@file:").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:"));
assert_eq!(s.alias.as_deref(), Some("my-alias"));
assert_eq!(s.name, "my-alias");
}
#[test]
fn test_parse_pkg_spec_scoped_not_git_or_local() {
let s = parse_pkg_spec("@scope/pkg").unwrap();
assert!(s.git_spec.is_none());
assert!(s.local_spec.is_none());
assert_eq!(s.name, "@scope/pkg");
assert_eq!(s.range, "latest");
}
#[test]
fn test_parse_pkg_spec_bare_user_repo_not_local() {
let s = parse_pkg_spec("kevva/is-negative").unwrap();
assert!(s.local_spec.is_none());
assert!(s.git_spec.is_some());
}
#[test]
fn test_parse_pkg_spec_scoped_alias_for_local() {
let s = parse_pkg_spec("@my-scope/alias@file:./pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:./pkg"));
assert_eq!(s.alias.as_deref(), Some("@my-scope/alias"));
assert_eq!(s.name, "@my-scope/alias");
}
#[test]
fn test_parse_pkg_spec_scoped_alias_for_git() {
let s = parse_pkg_spec("@my-scope/alias@kevva/is-negative").unwrap();
assert_eq!(s.git_spec.as_deref(), Some("kevva/is-negative"));
assert_eq!(s.alias.as_deref(), Some("@my-scope/alias"));
assert_eq!(s.name, "@my-scope/alias");
}
#[test]
fn test_parse_pkg_spec_file_uncompressed_tarball_strips_extension() {
let s = parse_pkg_spec("file:./bundle.tar").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:./bundle.tar"));
assert_eq!(s.name, "bundle");
}
#[test]
fn test_parse_pkg_spec_bare_absolute_path() {
let s = parse_pkg_spec("/path/to/library-foo/").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("link:/path/to/library-foo/"));
assert_eq!(s.name, "library-foo");
}
#[test]
fn test_parse_pkg_spec_bare_relative_path() {
for input in ["./lib", "../lib", "../../foo/bar"] {
let s = parse_pkg_spec(input).unwrap();
let local = s.local_spec.expect("relative path should detect as local");
assert_eq!(local, format!("link:{input}"));
}
}
#[test]
fn test_parse_pkg_spec_bare_tilde_path_expands() {
let s = parse_pkg_spec("~/proj/lib").unwrap();
let local = s.local_spec.expect("~/ path should detect as local");
assert!(
local.starts_with("link:"),
"expected link: prefix in `{local}`"
);
assert!(
!local.contains('~'),
"tilde must be expanded eagerly, got `{local}`"
);
assert_eq!(s.name, "lib");
}
#[test]
fn test_parse_pkg_spec_bare_tarball_uses_file_protocol() {
let s = parse_pkg_spec("./vendor/local-helper-1.0.0.tgz").unwrap();
assert_eq!(
s.local_spec.as_deref(),
Some("file:./vendor/local-helper-1.0.0.tgz")
);
assert_eq!(s.name, "local-helper-1.0.0");
}
#[test]
fn test_parse_pkg_spec_short_alias_not_drive_letter() {
let s = parse_pkg_spec("a:1.0.0").unwrap();
assert!(s.local_spec.is_none());
assert!(s.git_spec.is_none());
}
#[test]
fn test_parse_pkg_spec_windows_drive_letter() {
for input in ["C:/projects/lib", "c:\\projects\\lib"] {
let s = parse_pkg_spec(input).unwrap();
let local = s
.local_spec
.expect("drive-letter path should detect as local");
assert_eq!(local, format!("link:{input}"));
assert_eq!(s.name, "lib");
}
}
#[test]
fn test_parse_pkg_spec_windows_backslash_relative() {
for input in ["..\\lib", ".\\lib"] {
let s = parse_pkg_spec(input).unwrap();
assert!(s.local_spec.is_some(), "`{input}` should detect as local");
assert_eq!(s.name, "lib", "wrong basename for `{input}`");
}
}
}