feat(pacquet): port workspace: protocol resolution and publish-time rewrite (#11789)

* feat(pacquet): port workspace: protocol resolution and publish-time rewrite

Ports the `workspace:` family of bare specifiers end-to-end:

- `pacquet-workspace-spec` (new) ports `workspace/spec-parser`'s
  `WorkspaceSpec` parser + `toString`.
- `pacquet-workspace-range-resolver` (new) ports
  `workspace/range-resolver`'s `resolveWorkspaceRange` — `*`/`^`/`~`/`""`
  pick the highest version with prereleases included; other inputs
  follow standard semver range rules.
- `pacquet-resolving-npm-resolver` grows two helpers from the upstream
  npm-resolver: `workspace_pref_to_npm` (port of `workspacePrefToNpm.ts`)
  and `try_resolve_from_workspace` (port of `tryResolveFromWorkspace` +
  `tryResolveFromWorkspacePackages` + `pickMatchingLocalVersionOrNull` +
  `resolveFromLocalPackage`). `NpmResolver::resolve_impl` now intercepts
  `workspace:` specs before the npm pick, deferring `workspace:./` /
  `workspace:../` to the local resolver. Emits `link:` / `file:`
  (injected) lockfile resolutions, with the matching
  `WORKSPACE_PKG_NOT_FOUND` / `NO_MATCHING_VERSION_INSIDE_WORKSPACE`
  / `CANNOT_RESOLVE_WORKSPACE_PROTOCOL` error codes preserved.
- `pacquet-exportable-manifest` (new) ports the publish-time
  `replaceWorkspaceProtocol` and `replaceWorkspaceProtocolPeerDependency`
  helpers from `releasing/exportable-manifest`. The full
  `createExportableManifest` (catalog rewrite, jsr rewrite, pre-pack
  hooks, publishConfig overrides) lands as pacquet ports the surrounding
  commands.
- `Install::run` builds a workspace-packages map via
  `find_workspace_projects` when a `pnpm-workspace.yaml` is present and
  threads it through `ResolveOptions::workspace_packages` so the
  resolver chain can satisfy `workspace:` specs from local projects in
  the no-lockfile install path.

Test ports:
- `workspace/spec-parser/test/workspace-spec.test.ts`
- `workspace/range-resolver/test/index.test.ts`
- `resolving/npm-resolver/test/workspacePrefToNpm.test.ts`
- `releasing/exportable-manifest/test/index.test.ts` (workspace cases)
- plus new unit tests for `try_resolve_from_workspace` covering
  `WORKSPACE_PKG_NOT_FOUND`, `NO_MATCHING_VERSION_INSIDE_WORKSPACE`,
  the inject branch, and the `publishConfig.directory` /
  `linkDirectory` handling.

Frozen-lockfile installs already record `link:` entries directly; the
new resolution path matters for the no-lockfile install path.

---
Written by an agent (Claude Code, claude-opus-4-7).

* fix(pacquet): dylint + doc-link nits in workspace-protocol port

- Rename single-letter params (perfectionist::single-letter-*).
- Add trailing commas in multi-line macro invocations.
- Avoid the ambiguous `crate::parse_bare_specifier` doc link and the
  cross-crate `pacquet_workspace_spec::WorkspaceSpec` /
  `pacquet_workspace_range_resolver::resolve_workspace_range` doc
  links (the crates don't have a Cargo dependency on each other, so
  rustdoc can't resolve them).

---
Written by an agent (Claude Code, claude-opus-4-7).

* fix(pacquet): address coderabbit review comments

- replace_workspace_protocol_peer_dependency: use replacen("workspace:", "", 1)
  so compound peer specs match upstream JS String.replace's first-only
  semantics; locked with a new test
  peer_workspace_strip_only_removes_first_occurrence.
- npm-resolver module docs: drop the stale "workspace: returns
  Ok(None)" bullet and explain that non-path workspace specs now route
  through try_resolve_from_workspace while path-relative forms still
  fall through to the local resolver.

The third coderabbit nit (read_workspace_manifest error swallowing in
install.rs) was already addressed by the rebase: the workspace manifest
is now read once at the top of Install::run with proper error
propagation, and build_workspace_packages_map takes the pre-loaded
Option<&WorkspaceManifest> instead of re-reading the file.

---
Written by an agent (Claude Code, claude-opus-4-7).

* fix(pacquet/resolving-npm-resolver): surface WorkspacePkgNotFound.hint

Upstream pnpm's WORKSPACE_PKG_NOT_FOUND error carries a 'hint' field
that PnpmError prints as guidance after the message ('Packages found in
the workspace: ...'). The Rust port was populating the field but the
miette diagnostic didn't reference it, so the help text never reached
the user. Add help("{hint}") to the diagnostic attribute so miette
renders it under the message — matching pnpm's output verbatim.

---
Written by an agent (Claude Code, claude-opus-4-7).
This commit is contained in:
Zoltan Kochan
2026-05-21 02:44:22 +02:00
committed by GitHub
parent b2a95fa1f7
commit 71dfccce9a
22 changed files with 1762 additions and 13 deletions

24
Cargo.lock generated
View File

@@ -2199,6 +2199,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "pacquet-exportable-manifest"
version = "0.0.1"
dependencies = [
"derive_more",
"miette 7.6.0",
"pacquet-package-manifest",
"serde_json",
"tempfile",
]
[[package]]
name = "pacquet-fs"
version = "0.0.1"
@@ -2637,6 +2648,8 @@ dependencies = [
"pacquet-resolving-jsr-specifier-parser",
"pacquet-resolving-local-resolver",
"pacquet-resolving-resolver-base",
"pacquet-workspace-range-resolver",
"pacquet-workspace-spec",
"pipe-trait",
"pretty_assertions",
"reqwest",
@@ -2763,6 +2776,17 @@ dependencies = [
"wax",
]
[[package]]
name = "pacquet-workspace-range-resolver"
version = "0.0.1"
dependencies = [
"node-semver",
]
[[package]]
name = "pacquet-workspace-spec"
version = "0.0.1"
[[package]]
name = "pacquet-workspace-state"
version = "0.0.1"

View File

@@ -38,6 +38,7 @@ pacquet-modules-yaml = { path = "pacquet/crates/modules-yam
pacquet-network = { path = "pacquet/crates/network" }
pacquet-config = { path = "pacquet/crates/config" }
pacquet-executor = { path = "pacquet/crates/executor" }
pacquet-exportable-manifest = { path = "pacquet/crates/exportable-manifest" }
pacquet-directory-fetcher = { path = "pacquet/crates/directory-fetcher" }
pacquet-git-fetcher = { path = "pacquet/crates/git-fetcher" }
pacquet-deps-path = { path = "pacquet/crates/deps-path" }
@@ -57,6 +58,8 @@ pacquet-resolving-parse-wanted-dependency = { path = "pacquet/crates/resolving-p
pacquet-resolving-resolver-base = { path = "pacquet/crates/resolving-resolver-base" }
pacquet-resolving-tarball-resolver = { path = "pacquet/crates/resolving-tarball-resolver" }
pacquet-workspace = { path = "pacquet/crates/workspace" }
pacquet-workspace-range-resolver = { path = "pacquet/crates/workspace-range-resolver" }
pacquet-workspace-spec = { path = "pacquet/crates/workspace-spec" }
pacquet-workspace-state = { path = "pacquet/crates/workspace-state" }
# Tasks

View File

@@ -0,0 +1,24 @@
[package]
name = "pacquet-exportable-manifest"
version = "0.0.1"
publish = false
authors.workspace = true
description.workspace = true
edition.workspace = true
homepage.workspace = true
keywords.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
pacquet-package-manifest = { workspace = true }
derive_more = { workspace = true }
miette = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,25 @@
//! Pacquet port of pnpm's
//! [`@pnpm/releasing.exportable-manifest`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts).
//!
//! Today only the workspace-protocol rewrite is in scope —
//! [`replace_workspace_protocol`] and
//! [`replace_workspace_protocol_peer_dependency`]. The rest of
//! `createExportableManifest` (catalog / jsr rewrite, pre-pack hooks,
//! `publishConfig` overrides, manifest serialization) lands as pacquet
//! ports the surrounding commands.
//!
//! Both functions mirror upstream's two `replaceWorkspaceProtocol*`
//! helpers verbatim — same regex shapes, same fall-through ordering,
//! same `npm:`-aliasing output rules — so when pacquet grows a
//! `publish` / `pack` command the existing call sites can be reused
//! unmodified.
mod replace;
#[cfg(test)]
mod tests;
pub use replace::{
CannotResolveWorkspaceProtocolError, ReplaceWorkspaceProtocolError, replace_workspace_protocol,
replace_workspace_protocol_peer_dependency,
};

View File

@@ -0,0 +1,331 @@
//! Workspace-protocol rewriting for [`@pnpm/releasing.exportable-manifest`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts#L139-L194).
//!
//! Two free functions match upstream's two `replaceWorkspaceProtocol*`
//! helpers:
//!
//! - [`replace_workspace_protocol`] — the regular-dependency form.
//! Resolves `workspace:` specs against the dependency's already-
//! installed `package.json` in `node_modules`.
//! - [`replace_workspace_protocol_peer_dependency`] — the
//! peer-dependency form. Accepts the broader `>=`/`<=`/`>`/`<`
//! comparators upstream allows in peer specs and rewrites every
//! `workspace:` segment in place so a compound `a || workspace:>=`
//! round-trips correctly.
use std::path::{Path, PathBuf};
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_package_manifest::{PackageManifestError, safe_read_package_json_from_dir};
use serde_json::Value;
/// Error returned when the lookup against the dependency's installed
/// `package.json` fails. Mirrors pnpm's
/// [`CANNOT_RESOLVE_WORKSPACE_PROTOCOL`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts#L117-L127)
/// error code; preserve the public message so reporters that key off
/// `ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL` keep matching.
#[derive(Debug, Display, Error, Diagnostic, Clone)]
#[display(
"Cannot resolve workspace protocol of dependency \"{dep_name}\" \
because this dependency is not installed. Try running \"pnpm install\"."
)]
#[diagnostic(code(ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL))]
pub struct CannotResolveWorkspaceProtocolError {
#[error(not(source))]
pub dep_name: String,
}
/// Error envelope for both rewrite helpers.
#[derive(Debug, Display, Error, Diagnostic)]
pub enum ReplaceWorkspaceProtocolError {
/// The dependency's directory was found but the `package.json`
/// lacked one of the required fields. Most common reason: the
/// project hasn't been installed yet.
#[diagnostic(transparent)]
CannotResolve(#[error(source)] CannotResolveWorkspaceProtocolError),
/// Reading `<dep>/package.json` itself failed (malformed JSON, IO
/// error other than ENOENT, …). Propagated so the caller can
/// surface the underlying cause.
ReadManifest(#[error(source)] PackageManifestError),
}
/// Port of upstream's
/// [`replaceWorkspaceProtocol`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts#L139-L168)
/// — rewrites a single `dependencies` / `devDependencies` /
/// `optionalDependencies` value at publish time.
///
/// Returns `dep_spec` unchanged when it doesn't start with `workspace:`
/// so the caller can fold the helper into a generic per-field rewrite
/// without branching on the protocol.
pub fn replace_workspace_protocol(
dep_name: &str,
dep_spec: &str,
dir: &Path,
modules_dir: Option<&Path>,
) -> Result<String, ReplaceWorkspaceProtocolError> {
let Some(rest) = dep_spec.strip_prefix("workspace:") else {
return Ok(dep_spec.to_string());
};
if let Some(parsed) = parse_version_alias_spec(rest) {
let modules_dir_owned: PathBuf;
let modules_dir = match modules_dir {
Some(path) => path,
None => {
modules_dir_owned = dir.join("node_modules");
&modules_dir_owned
}
};
let manifest = read_and_check_manifest(dep_name, &modules_dir.join(dep_name))?;
let semver_range_token = match parsed.sentinel {
Some('^') => "^",
Some('~') => "~",
_ => "",
};
if dep_name != manifest.name {
return Ok(format!(
"npm:{name}@{token}{version}",
name = manifest.name,
token = semver_range_token,
version = manifest.version,
));
}
return Ok(format!(
"{token}{version}",
token = semver_range_token,
version = manifest.version,
));
}
if let Some(relative) = strip_workspace_relative_prefix(dep_spec) {
let manifest = read_and_check_manifest(dep_name, &dir.join(relative))?;
if manifest.name == dep_name {
return Ok(manifest.version);
}
return Ok(format!(
"npm:{name}@{version}",
name = manifest.name,
version = manifest.version,
));
}
if rest.contains('@') {
return Ok(format!("npm:{rest}"));
}
Ok(rest.to_string())
}
/// Port of upstream's
/// [`replaceWorkspaceProtocolPeerDependency`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts#L170-L194)
/// — rewrites a `peerDependencies` value.
///
/// `peerDependencies` allows compound ranges (`workspace:>= || ^3.9.0`),
/// so this helper accepts the broader comparator set (`>=`, `<=`, `>`,
/// `<` alongside `^`, `~`, `*`) and rewrites every `workspace:`
/// segment in place rather than swapping the whole string.
pub fn replace_workspace_protocol_peer_dependency(
dep_name: &str,
dep_spec: &str,
dir: &Path,
modules_dir: Option<&Path>,
) -> Result<String, ReplaceWorkspaceProtocolError> {
if !dep_spec.contains("workspace:") {
return Ok(dep_spec.to_string());
}
// Mirror upstream's JS `.replace('workspace:', '')`, which removes
// only the first occurrence. Rust's `str::replace` is all-occurrence;
// use `replacen(_, _, 1)` so compound peer specs like
// `^1.0.0 || workspace:>=1 || workspace:>=2` keep parity with pnpm.
let Some(matched) = find_workspace_peer_segment(dep_spec) else {
return Ok(dep_spec.replacen("workspace:", "", 1));
};
if !matched.version.is_empty() {
return Ok(dep_spec.replacen("workspace:", "", 1));
}
let modules_dir_owned: PathBuf;
let modules_dir = match modules_dir {
Some(path) => path,
None => {
modules_dir_owned = dir.join("node_modules");
&modules_dir_owned
}
};
let manifest = read_and_check_manifest(dep_name, &modules_dir.join(dep_name))?;
let token = if matched.range_group == "*" { "" } else { matched.range_group };
let mut rewritten = String::with_capacity(dep_spec.len());
rewritten.push_str(&dep_spec[..matched.start]);
rewritten.push_str(token);
rewritten.push_str(&manifest.version);
rewritten.push_str(&dep_spec[matched.end..]);
Ok(rewritten)
}
/// Read `<dependency_dir>/package.json` and verify the `name` / `version`
/// fields are present. Surfaces the same
/// [`CANNOT_RESOLVE_WORKSPACE_PROTOCOL`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/src/index.ts#L117-L127)
/// error pnpm raises when the dependency hasn't been installed yet.
fn read_and_check_manifest(
dep_name: &str,
dependency_dir: &Path,
) -> Result<DependencyManifest, ReplaceWorkspaceProtocolError> {
let value = match safe_read_package_json_from_dir(dependency_dir) {
Ok(Some(value)) => value,
Ok(None) => {
return Err(ReplaceWorkspaceProtocolError::CannotResolve(
CannotResolveWorkspaceProtocolError { dep_name: dep_name.to_string() },
));
}
Err(err) => return Err(ReplaceWorkspaceProtocolError::ReadManifest(err)),
};
let Some(name) = value.get("name").and_then(Value::as_str) else {
return Err(ReplaceWorkspaceProtocolError::CannotResolve(
CannotResolveWorkspaceProtocolError { dep_name: dep_name.to_string() },
));
};
let Some(version) = value.get("version").and_then(Value::as_str) else {
return Err(ReplaceWorkspaceProtocolError::CannotResolve(
CannotResolveWorkspaceProtocolError { dep_name: dep_name.to_string() },
));
};
Ok(DependencyManifest { name: name.to_string(), version: version.to_string() })
}
/// The two fields the rewriters consult on the dependency's manifest.
/// Mirrors the slice pnpm's `tryReadProjectManifest` returns for this
/// codepath.
struct DependencyManifest {
name: String,
version: String,
}
/// Output of [`parse_version_alias_spec`]: the optional sentinel
/// character (`^`/`~`/`*`). Upstream's regex captures the alias
/// portion too, but pacquet's branch only consults the version-token
/// captured by the second group; the alias is implied by the spec
/// shape and re-read from the dependency manifest, never from the
/// regex group.
struct VersionAliasMatch {
sentinel: Option<char>,
}
/// Port of upstream's `^workspace:(?:(.+)@)?([\^~*])?$` regex.
///
/// The TS impl uses JS-regex greedy backtracking on `.+@`, so the
/// alias spans up to (and including) the **last** `@` in the suffix.
/// Returns `None` when the input has trailing characters past an
/// optional `^`/`~`/`*` sentinel — same shape as the regex failing.
fn parse_version_alias_spec(after_protocol: &str) -> Option<VersionAliasMatch> {
let after_alias = match after_protocol.rfind('@') {
Some(idx) if idx >= 1 => &after_protocol[idx + 1..],
_ => after_protocol,
};
let sentinel = match after_alias.chars().count() {
0 => None,
1 => {
let first_char = after_alias.chars().next().expect("char count == 1");
if matches!(first_char, '^' | '~' | '*') {
Some(first_char)
} else {
return None;
}
}
_ => return None,
};
Some(VersionAliasMatch { sentinel })
}
/// Strip the `workspace:` prefix and return the path portion of a
/// relative `workspace:./` or `workspace:../` spec. Mirrors upstream's
/// `depSpec.slice(10)` on this branch.
fn strip_workspace_relative_prefix(dep_spec: &str) -> Option<&str> {
if dep_spec.starts_with("workspace:./") || dep_spec.starts_with("workspace:../") {
return Some(&dep_spec["workspace:".len()..]);
}
None
}
/// One match of upstream's peer-dependency regex
/// `workspace:([\^~*]|>=|>|<=|<)?((\d+|[xX*])(\.(\d+|[xX*])){0,2})?`.
struct WorkspacePeerSegment<'a> {
/// Byte offset of the leading `workspace:` in the input.
start: usize,
/// Byte offset one past the end of the matched region.
end: usize,
/// The semver-range comparator captured by the regex's first
/// group, or the empty string when no comparator preceded the
/// version component.
range_group: &'a str,
/// The version component, if any. Empty string when the regex's
/// second group didn't match.
version: &'a str,
}
/// Locate the first `workspace:`-led peer segment in `spec` and return
/// the slice information [`replace_workspace_protocol_peer_dependency`]
/// needs to rewrite it in place.
fn find_workspace_peer_segment(spec: &str) -> Option<WorkspacePeerSegment<'_>> {
let start = spec.find("workspace:")?;
let after = start + "workspace:".len();
let bytes = spec.as_bytes();
let range_len = parse_peer_range_comparator(bytes, after);
let comparator_end = after + range_len;
let version_end = parse_peer_version(bytes, comparator_end);
Some(WorkspacePeerSegment {
start,
end: version_end,
range_group: &spec[after..comparator_end],
version: &spec[comparator_end..version_end],
})
}
/// Parse the leading comparator (`>=`, `<=`, `>`, `<`, `^`, `~`, `*`)
/// starting at `pos`. Returns the byte length of the comparator, or
/// zero when no comparator is present.
fn parse_peer_range_comparator(bytes: &[u8], pos: usize) -> usize {
match (bytes.get(pos), bytes.get(pos + 1)) {
(Some(b'>'), Some(b'=')) | (Some(b'<'), Some(b'=')) => 2,
(Some(b'>' | b'<' | b'^' | b'~' | b'*'), _) => 1,
_ => 0,
}
}
/// Consume `(\d+|[xX*])(\.(\d+|[xX*])){0,2}` starting at `pos`. Returns
/// the byte offset one past the consumed region — or `pos` when the
/// first character isn't a valid part.
fn parse_peer_version(bytes: &[u8], pos: usize) -> usize {
let Some(end) = parse_peer_version_part(bytes, pos) else {
return pos;
};
let mut cur = end;
for _ in 0..2 {
if bytes.get(cur) != Some(&b'.') {
break;
}
let Some(next) = parse_peer_version_part(bytes, cur + 1) else {
break;
};
cur = next;
}
cur
}
/// Consume one part of the version regex (`\d+` or one of `x`/`X`/`*`).
fn parse_peer_version_part(bytes: &[u8], start: usize) -> Option<usize> {
match bytes.get(start)? {
b'x' | b'X' | b'*' => Some(start + 1),
b if b.is_ascii_digit() => {
let mut end = start + 1;
while bytes.get(end).is_some_and(u8::is_ascii_digit) {
end += 1;
}
Some(end)
}
_ => None,
}
}

View File

@@ -0,0 +1,169 @@
//! Port of pnpm's
//! [`releasing/exportable-manifest/test/index.test.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/test/index.test.ts)
//! covering the workspace-protocol rewrite. The upstream test goes
//! through `createExportableManifest` (which performs a full install
//! and reads from the resulting `node_modules`); pacquet's test
//! materializes the same `node_modules` layout directly with
//! `tempfile::TempDir` so we exercise `replace_workspace_protocol`
//! and `replace_workspace_protocol_peer_dependency` in isolation.
use std::{fs, path::Path};
use tempfile::TempDir;
use super::{
CannotResolveWorkspaceProtocolError, ReplaceWorkspaceProtocolError, replace_workspace_protocol,
replace_workspace_protocol_peer_dependency,
};
/// Materialize the install tree upstream's
/// [`workspace deps are replaced`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/releasing/exportable-manifest/test/index.test.ts#L96-L197)
/// case sets up via `pnpm install`. Returns `(temp_root, project_dir)`:
/// `project_dir = <temp>/workspace-protocol-package` so the relative
/// `workspace:../xerox` resolves to a sibling at `<temp>/xerox`.
fn workspace_fixture() -> (TempDir, std::path::PathBuf) {
let temp = TempDir::new().expect("tempdir");
let project = temp.path().join("workspace-protocol-package");
let modules = project.join("node_modules");
fs::create_dir_all(&modules).unwrap();
// The local alias `bar` resolves to the workspace project named
// `@foo/bar`; `node_modules/bar/package.json` carries the resolved
// manifest (same name, copied / linked there by `pnpm install`).
write_dep(&modules.join("bar"), "@foo/bar", "3.2.1");
write_dep(&modules.join("baz"), "baz", "1.2.3");
write_dep(&modules.join("foo"), "foo", "4.5.6");
write_dep(&modules.join("qux"), "qux", "1.0.0-alpha-a.b-c-something+build.1-aef.1-its-okay");
write_dep(&modules.join("quux"), "quux", "7.8.9");
write_dep(&modules.join("waldo"), "waldo", "1.9.0");
// `workspace:../xerox` reads the sibling project directly, not from
// `node_modules`.
write_dep(&temp.path().join("xerox"), "xerox", "4.5.6");
(temp, project)
}
fn write_dep(dir: &Path, name: &str, version: &str) {
fs::create_dir_all(dir).unwrap();
let manifest = serde_json::json!({ "name": name, "version": version });
fs::write(dir.join("package.json"), serde_json::to_string(&manifest).unwrap()).unwrap();
}
fn rewrite(dep_name: &str, dep_spec: &str, dir: &Path) -> String {
replace_workspace_protocol(dep_name, dep_spec, dir, None).expect("replace succeeds")
}
fn rewrite_peer(dep_name: &str, dep_spec: &str, dir: &Path) -> String {
replace_workspace_protocol_peer_dependency(dep_name, dep_spec, dir, None)
.expect("replace succeeds")
}
#[test]
fn passes_through_non_workspace_specs() {
let dir = TempDir::new().unwrap();
assert_eq!(rewrite("foo", "^1.0.0", dir.path()), "^1.0.0");
assert_eq!(rewrite("foo", "npm:bar@1", dir.path()), "npm:bar@1");
}
/// Workspace fixture mirrors upstream's `dependencies` field in the
/// `workspace deps are replaced` test. Each line exercises one of the
/// branches inside `replaceWorkspaceProtocol`.
#[test]
fn workspace_dep_rewrites_match_upstream() {
let (_fixture, project) = workspace_fixture();
let dir = project.as_path();
assert_eq!(rewrite("bar", "workspace:@foo/bar@*", dir), "npm:@foo/bar@3.2.1");
assert_eq!(rewrite("baz", "workspace:baz@^", dir), "^1.2.3");
assert_eq!(rewrite("foo", "workspace:*", dir), "4.5.6");
assert_eq!(
rewrite("qux", "workspace:^", dir),
"^1.0.0-alpha-a.b-c-something+build.1-aef.1-its-okay",
);
assert_eq!(rewrite("quux", "workspace:", dir), "7.8.9");
assert_eq!(rewrite("waldo", "workspace:^", dir), "^1.9.0");
assert_eq!(rewrite("xerox", "workspace:../xerox", dir), "4.5.6");
assert_eq!(rewrite("xeroxAlias", "workspace:../xerox", dir), "npm:xerox@4.5.6");
assert_eq!(rewrite("corge", "workspace:1.0.0", dir), "1.0.0");
assert_eq!(rewrite("grault", "workspace:^1.0.0", dir), "^1.0.0");
assert_eq!(rewrite("garply", "workspace:plugh@2.0.0", dir), "npm:plugh@2.0.0");
}
/// Peer-dependency variant. The compound `||` cases exercise the
/// in-place rewriting branch of upstream's regex.
#[test]
fn peer_workspace_dep_rewrites_match_upstream() {
let (_fixture, project) = workspace_fixture();
let dir = project.as_path();
assert_eq!(rewrite_peer("foo", "workspace:>= || ^3.9.0", dir), ">=4.5.6 || ^3.9.0");
assert_eq!(rewrite_peer("baz", "^1.0.0 || workspace:>", dir), "^1.0.0 || >1.2.3");
assert_eq!(rewrite_peer("bar", "workspace:^3.0.0", dir), "^3.0.0");
assert_eq!(
rewrite_peer("qux", "workspace:^", dir),
"^1.0.0-alpha-a.b-c-something+build.1-aef.1-its-okay",
);
assert_eq!(rewrite_peer("waldo", "workspace:^1.x", dir), "^1.x");
}
/// Upstream uses JS `String.prototype.replace('workspace:', '')`, which
/// strips only the first occurrence. Locks the parity: a compound
/// peer spec carrying two `workspace:` segments must leave the second
/// segment untouched.
#[test]
fn peer_workspace_strip_only_removes_first_occurrence() {
let (_fixture, project) = workspace_fixture();
let dir = project.as_path();
assert_eq!(
rewrite_peer("baz", "workspace:^1.0.0 || workspace:^2.0.0", dir),
"^1.0.0 || workspace:^2.0.0",
);
}
#[test]
fn missing_dependency_surfaces_cannot_resolve_error() {
let fixture = TempDir::new().unwrap();
let dir = fixture.path();
fs::create_dir_all(dir.join("node_modules")).unwrap();
let err = replace_workspace_protocol("ghost", "workspace:*", dir, None).unwrap_err();
assert!(matches!(
err,
ReplaceWorkspaceProtocolError::CannotResolve(CannotResolveWorkspaceProtocolError {
dep_name,
}) if dep_name == "ghost"
));
}
#[test]
fn missing_dependency_surfaces_cannot_resolve_error_for_peer() {
let fixture = TempDir::new().unwrap();
let dir = fixture.path();
fs::create_dir_all(dir.join("node_modules")).unwrap();
let err =
replace_workspace_protocol_peer_dependency("ghost", "workspace:^", dir, None).unwrap_err();
assert!(matches!(
err,
ReplaceWorkspaceProtocolError::CannotResolve(CannotResolveWorkspaceProtocolError {
dep_name,
}) if dep_name == "ghost"
));
}
/// `workspace:*` doesn't reach into the manifest for the version
/// token (the version comes from the lookup), so when the dep name
/// doesn't match the resolved manifest's name the output is an
/// `npm:`-aliased reference. The unaliased case is covered by
/// [`workspace_dep_rewrites_match_upstream`]; this case locks the
/// aliased branch.
#[test]
fn dep_name_mismatch_routes_to_npm_alias() {
let fixture = TempDir::new().unwrap();
let dir = fixture.path();
let modules = dir.join("node_modules");
write_dep(&modules.join("local-name"), "actual-name", "1.2.3");
assert_eq!(rewrite("local-name", "workspace:*", dir), "npm:actual-name@1.2.3");
}

View File

@@ -180,6 +180,9 @@ pub enum InstallError {
#[diagnostic(transparent)]
InvalidCatalogsConfiguration(#[error(source)] InvalidCatalogsConfigurationError),
#[diagnostic(transparent)]
FindWorkspaceProjects(#[error(source)] pacquet_workspace::FindWorkspaceProjectsError),
/// Building the verifier list from config rejected a
/// `minimumReleaseAgeExclude` or `trustPolicyExclude` pattern.
/// Mirrors upstream's `INVALID_MINIMUM_RELEASE_AGE_EXCLUDE` /
@@ -538,6 +541,16 @@ where
// The no-lockfile path has no installability check (no
// `packages:` metadata to evaluate constraints against),
// so its skip set is empty by construction.
// Build the workspace-sibling lookup the npm resolver
// consults for `workspace:` specs. `None` when the install
// isn't inside a `pnpm-workspace.yaml` workspace (no
// workspace root was found), so the resolver errors out
// on any `workspace:` spec rather than silently skipping
// to a registry lookup. Mirrors upstream's posture at
// <https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L828-L830>.
let workspace_packages =
build_workspace_packages_map(&workspace_root, workspace_manifest.as_ref())
.map_err(InstallError::FindWorkspaceProjects)?;
let hd = InstallWithoutLockfile {
tarball_mem_cache,
resolved_packages,
@@ -549,6 +562,8 @@ where
logged_methods: &logged_methods,
requester: &prefix,
catalogs,
lockfile_dir: &workspace_root,
workspace_packages,
}
.run::<Reporter>()
.await
@@ -754,6 +769,50 @@ fn manifest_string_field(manifest: &PackageManifest, key: &str) -> Option<String
manifest.value().get(key).and_then(|v| v.as_str()).map(ToString::to_string)
}
/// Build the `name → version → WorkspacePackage` lookup the npm
/// resolver consults for `workspace:` specs. Returns `Ok(None)` when
/// no `pnpm-workspace.yaml` exists in (or above) `workspace_root` —
/// the install isn't a workspace install, so any `workspace:` spec
/// the manifest happens to carry should surface
/// [`pacquet_resolving_npm_resolver::ResolveFromWorkspaceError::WorkspacePackagesNotLoaded`].
///
/// Mirrors the slice pnpm's
/// [`getWorkspacePackagesByDirectory`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/installing/context/src/index.ts#L160)
/// passes into `resolveDependencies` — same name/version index, same
/// per-project `WorkspacePackage` shape (`{ rootDir, manifest }`).
/// Projects whose manifest lacks a name or version are silently
/// skipped; upstream's manifest reader emits a separate warning that
/// pacquet doesn't carry through here.
fn build_workspace_packages_map(
workspace_root: &std::path::Path,
workspace_manifest: Option<&pacquet_workspace::WorkspaceManifest>,
) -> Result<
Option<pacquet_resolving_resolver_base::WorkspacePackages>,
pacquet_workspace::FindWorkspaceProjectsError,
> {
// No `pnpm-workspace.yaml` → no workspace install. Skip the project
// walk entirely.
let Some(manifest) = workspace_manifest else { return Ok(None) };
let opts = pacquet_workspace::FindWorkspaceProjectsOpts { patterns: manifest.packages.clone() };
let projects = pacquet_workspace::find_workspace_projects(workspace_root, &opts)?;
let mut map: pacquet_resolving_resolver_base::WorkspacePackages =
std::collections::BTreeMap::new();
for project in projects {
let name = manifest_string_field(&project.manifest, "name");
let version = manifest_string_field(&project.manifest, "version");
let (Some(name), Some(version)) = (name, version) else { continue };
map.entry(name).or_default().insert(
version,
pacquet_resolving_resolver_base::WorkspacePackage {
root_dir: project.root_dir,
manifest: project.manifest.value().clone(),
},
);
}
Ok(Some(map))
}
/// Build the `projects` map for [`WorkspaceState`]. Mirrors upstream's
/// `Object.fromEntries(opts.allProjects.map(...))` at
/// <https://github.com/pnpm/pnpm/blob/7ff112bac6/workspace/state/src/createWorkspaceState.ts>.

View File

@@ -89,6 +89,22 @@ pub struct InstallWithoutLockfile<'a, DependencyGroupList> {
/// Catalogs parsed from `pnpm-workspace.yaml`. Empty for projects
/// without a workspace manifest.
pub catalogs: Catalogs,
/// Lockfile root for the install, used by the resolver chain to
/// compute `link:` / `file:` relative paths and to anchor
/// workspace-package resolution. Mirrors upstream's `lockfileDir`
/// argument on `resolveDependencies`. Equal to the manifest's
/// parent directory under single-project installs and to the
/// `pnpm-workspace.yaml` root under monorepos.
pub lockfile_dir: &'a Path,
/// Workspace-sibling lookup the [`NpmResolver`] consults when it
/// sees a `workspace:` spec. `None` when this install isn't inside
/// a `pnpm-workspace.yaml` workspace; the resolver then errors out
/// on any `workspace:` spec via
/// `ResolveFromWorkspaceError::WorkspacePackagesNotLoaded` —
/// matching pnpm's
/// [`Cannot resolve package from workspace because opts.workspacePackages is not defined`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L828-L830)
/// behavior.
pub workspace_packages: Option<pacquet_resolving_resolver_base::WorkspacePackages>,
}
/// Error type of [`InstallWithoutLockfile`].
@@ -175,6 +191,8 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> {
logged_methods,
requester,
catalogs,
lockfile_dir,
workspace_packages,
} = self;
let store_dir: &'static _ = &config.store_dir;
@@ -308,6 +326,12 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> {
&[manifest],
);
// Thread the manifest's directory and the lockfile root into
// the resolver's `ResolveOptions` so `workspace:` and `link:`
// resolutions can compute the right relative paths.
let project_dir =
manifest.path().parent().expect("manifest path always has a parent dir").to_path_buf();
let importer_opts = ResolveImporterOptions {
auto_install_peers: config.auto_install_peers,
auto_install_peers_from_highest_match: config.auto_install_peers_from_highest_match,
@@ -317,6 +341,9 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> {
default_tag: Some("latest".to_string()),
published_by,
published_by_exclude,
project_dir,
lockfile_dir: lockfile_dir.to_path_buf(),
workspace_packages,
..ResolveOptions::default()
},
catalogs,

View File

@@ -17,6 +17,8 @@ pacquet-network = { workspace = true }
pacquet-registry = { workspace = true }
pacquet-resolving-jsr-specifier-parser = { workspace = true }
pacquet-resolving-resolver-base = { workspace = true }
pacquet-workspace-range-resolver = { workspace = true }
pacquet-workspace-spec = { workspace = true }
chrono = { workspace = true }
dashmap = { workspace = true }

View File

@@ -32,8 +32,10 @@ mod parse_bare_specifier;
mod pick_package;
mod pick_package_from_meta;
mod registry_url;
mod resolve_from_workspace;
mod trust_checks;
mod violation_codes;
mod workspace_pref_to_npm;
pub use create_npm_resolution_verifier::{
CreateNpmResolutionVerifierOptions, NpmResolutionVerifier, create_npm_resolution_verifier,
@@ -64,7 +66,11 @@ pub use pick_package_from_meta::{
RegistryPackageSpec, RegistryPackageSpecType, filter_pkg_metadata_by_publish_date,
pick_lowest_version_by_version_range, pick_package_from_meta, pick_version_by_version_range,
};
pub use resolve_from_workspace::{
ResolveFromWorkspaceError, ResolveFromWorkspaceOptions, try_resolve_from_workspace,
};
pub use trust_checks::{
TrustCheckOptions, TrustEvidence, TrustViolation, fail_if_trust_downgraded, get_trust_evidence,
};
pub use violation_codes::{MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE};
pub use workspace_pref_to_npm::{InvalidWorkspaceSpecError, workspace_pref_to_npm};

View File

@@ -10,11 +10,16 @@
//! cache; the trait implementation parses the bare specifier, picks a
//! version, and maps the result to [`ResolveResult`].
//!
//! Workspace handling intentionally lives on the npm-resolver side
//! (mirroring upstream): non-path `workspace:` specs route through
//! [`try_resolve_from_workspace`](crate::try_resolve_from_workspace())
//! to a `link:` / `file:` resolution against the install's workspace
//! package map; the path-relative forms (`workspace:./foo`,
//! `workspace:../bar`) return `Ok(None)` so the local-resolver in the
//! chain claims them.
//!
//! Out of scope for this port:
//!
//! - **Workspace resolution.** `workspace:` specs return `Ok(None)` so
//! the dispatcher falls through to the workspace resolver when that
//! crate lands.
//! - **`peekManifestFromStore` fast path.** Upstream short-circuits a
//! registry fetch when the lockfile-pinned tarball is already in the
//! store. Pacquet today goes through the picker unconditionally;
@@ -40,6 +45,7 @@ use crate::{
parse_bare_specifier::{parse_bare_specifier, parse_jsr_specifier_to_registry_package_spec},
pick_package::{PackageMetaCache, PickPackageContext, PickPackageOptions, pick_package},
pick_package_from_meta::{RegistryPackageSpec, RegistryPackageSpecType},
resolve_from_workspace::{ResolveFromWorkspaceOptions, try_resolve_from_workspace},
violation_codes::MINIMUM_RELEASE_AGE_VIOLATION_CODE,
};
@@ -122,14 +128,34 @@ impl<Cache: PackageMetaCache + 'static> NpmResolver<Cache> {
) -> Result<Option<ResolveResult>, ResolveError> {
let default_tag = opts.default_tag.as_deref().unwrap_or("latest");
// `workspace:` is owned by the workspace resolver. Decline so
// the chain dispatches there once that crate lands.
if wanted_dependency
.bare_specifier
.as_deref()
.is_some_and(|bare| bare.starts_with("workspace:"))
// `workspace:` is intercepted before the npm pick — only the
// path-relative forms (`workspace:./foo`, `workspace:../bar`)
// fall through here so the local-resolver in the chain claims
// them. Everything else routes through
// [`try_resolve_from_workspace`], mirroring upstream's
// [`resolveNpm`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L412-L429)
// gate.
if let Some(bare) = wanted_dependency.bare_specifier.as_deref()
&& bare.starts_with("workspace:")
{
return Ok(None);
if bare.starts_with("workspace:.") {
return Ok(None);
}
let registry = pick_registry_for_package(
&self.registries,
wanted_dependency.alias.as_deref().unwrap_or_default(),
wanted_dependency.bare_specifier.as_deref(),
);
let ws_opts = ResolveFromWorkspaceOptions {
project_dir: opts.project_dir.as_path(),
lockfile_dir: opts.lockfile_dir.as_path(),
registry: &registry,
default_tag,
workspace_packages: opts.workspace_packages.as_ref(),
inject_workspace_packages: opts.inject_workspace_packages,
};
return try_resolve_from_workspace(wanted_dependency, &ws_opts)
.map_err(|err| Box::new(err) as ResolveError);
}
// `jsr:` resolves through the `@jsr` registry under the

View File

@@ -127,7 +127,25 @@ async fn range_specifier_picks_max_in_range() {
}
#[tokio::test]
async fn workspace_specifier_returns_none_for_chain_fallthrough() {
async fn workspace_path_form_falls_through_to_local_resolver() {
let server = mockito::Server::new_async().await;
let registry = format!("{}/", server.url());
let (resolver, _tempdir) = build_resolver(&registry);
// `workspace:./foo` and `workspace:../foo` are owned by the local
// resolver in the chain — `try_resolve_from_workspace` defers on
// them so the dispatcher falls through.
let wanted = WantedDependency {
alias: Some("acme".to_string()),
bare_specifier: Some("workspace:./acme".to_string()),
..WantedDependency::default()
};
let result = resolver.resolve(&wanted, &ResolveOptions::default()).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn workspace_version_without_workspace_packages_surfaces_error() {
let server = mockito::Server::new_async().await;
let registry = format!("{}/", server.url());
let (resolver, _tempdir) = build_resolver(&registry);
@@ -137,8 +155,19 @@ async fn workspace_specifier_returns_none_for_chain_fallthrough() {
bare_specifier: Some("workspace:*".to_string()),
..WantedDependency::default()
};
let result = resolver.resolve(&wanted, &ResolveOptions::default()).await.unwrap();
assert!(result.is_none());
// Mirrors pnpm's
// [`Cannot resolve package from workspace because opts.workspacePackages is not defined`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L828-L830)
// throw when the resolver receives a `workspace:` spec but the
// install caller never populated `workspace_packages`.
let err = resolver
.resolve(&wanted, &ResolveOptions::default())
.await
.expect_err("workspace_packages must be populated for workspace: specifiers");
let message = err.to_string();
assert!(
message.contains("workspace packages were not loaded"),
"unexpected error message: {message}",
);
}
#[tokio::test]

View File

@@ -0,0 +1,347 @@
//! Port of pnpm's
//! [`tryResolveFromWorkspace`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L806-L844)
//! and its inner
//! [`tryResolveFromWorkspacePackages`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L846-L888)
//! / [`pickMatchingLocalVersionOrNull`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L890-L906)
//! / [`resolveFromLocalPackage`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L908-L951)
//! helpers.
//!
//! The npm-resolver intercepts every `workspace:`-shaped wanted
//! dependency *except* the path-relative forms (`workspace:./foo`,
//! `workspace:../bar`) — those flow through unchanged so the
//! local-resolver in the chain takes them as `link:`-shaped
//! directory specs.
use std::path::{Path, PathBuf};
use derive_more::{Display, Error};
use miette::Diagnostic;
use node_semver::Version;
use pacquet_lockfile::{DirectoryResolution, LockfileResolution};
use pacquet_resolving_resolver_base::{
PkgResolutionId, ResolveResult, WantedDependency, WorkspacePackage, WorkspacePackages,
WorkspacePackagesByVersion,
};
use pacquet_workspace_range_resolver::resolve_workspace_range;
use crate::{
parse_bare_specifier::parse_bare_specifier,
pick_package_from_meta::{RegistryPackageSpec, RegistryPackageSpecType},
workspace_pref_to_npm::{InvalidWorkspaceSpecError, workspace_pref_to_npm},
};
/// Options threaded into [`try_resolve_from_workspace`]. Mirrors
/// upstream's per-call bag at
/// [`tryResolveFromWorkspace`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L808-L819).
#[derive(Debug, Clone)]
pub struct ResolveFromWorkspaceOptions<'a> {
/// Workspace-relative `<root>/pnpm-workspace.yaml` directory the
/// `link:` entry's relative path is rendered against.
pub project_dir: &'a Path,
/// Lockfile root. Mirrors upstream's `lockfileDir` argument —
/// `link:`-shaped resolutions render relative to `project_dir`
/// regardless, but `file:`-shaped (injected) resolutions use this
/// as the relativity anchor.
pub lockfile_dir: &'a Path,
/// Registry URL passed through to [`parse_bare_specifier`] so
/// `npm:<alias>@<version>` outputs flow through the same parsing
/// path as a plain npm spec.
pub registry: &'a str,
/// Default tag (`latest`) the parser falls back on when the
/// translated spec is bare (no version after the alias).
pub default_tag: &'a str,
/// Workspace packages map indexed by name → version → manifest.
/// `None` when the install caller never populated one; this
/// surfaces as a `WORKSPACE_PACKAGES_NOT_LOADED` error.
pub workspace_packages: Option<&'a WorkspacePackages>,
/// `true` materialises the dependency as a `file:` (hard-linked
/// copy) resolution instead of a `link:` symlink. Mirrors upstream's
/// `injectWorkspacePackages` + per-dep `injected` toggle.
pub inject_workspace_packages: bool,
}
/// Error envelope for [`try_resolve_from_workspace`]. The two pnpm
/// codes (`WORKSPACE_PKG_NOT_FOUND`, `NO_MATCHING_VERSION_INSIDE_WORKSPACE`)
/// are reproduced verbatim.
#[derive(Debug, Display, Error, Diagnostic)]
pub enum ResolveFromWorkspaceError {
/// The translated workspace spec failed to parse. Mirrors
/// upstream's `throw new Error('Invalid workspace: spec ...')`
/// branch — practically unreachable since the caller checks the
/// `workspace:` prefix before invoking this entry point.
#[diagnostic(transparent)]
InvalidWorkspaceSpec(#[error(source)] InvalidWorkspaceSpecError),
/// `workspace_packages` was `None`. Mirrors upstream's
/// [`Cannot resolve package from workspace because opts.workspacePackages is not defined`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L829)
/// throw.
#[display(
"Cannot resolve package from workspace because workspace packages were not loaded into the resolver"
)]
#[diagnostic(code(pacquet_resolving_npm_resolver::workspace_packages_not_loaded))]
WorkspacePackagesNotLoaded,
/// The npm parser refused the translated bare specifier. Mirrors
/// upstream's `throw new Error('Invalid workspace: spec (${...})')`.
#[display("Invalid workspace: spec ({bare_specifier})")]
#[diagnostic(code(pacquet_resolving_npm_resolver::invalid_workspace_translated_spec))]
UnparsableSpec {
#[error(not(source))]
bare_specifier: String,
},
/// Workspace map didn't carry the requested package name. Mirrors
/// pnpm's
/// [`WORKSPACE_PKG_NOT_FOUND`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L862-L868).
#[display(
"In {project_dir}: \"{name}@{bare_specifier}\" is in the dependencies but no package named \"{name}\" is present in the workspace"
)]
#[diagnostic(code(ERR_PNPM_WORKSPACE_PKG_NOT_FOUND), help("{hint}"))]
WorkspacePkgNotFound {
name: String,
bare_specifier: String,
project_dir: String,
#[error(not(source))]
hint: String,
},
/// Workspace map carried the name but no version satisfied the
/// range. Mirrors pnpm's
/// [`NO_MATCHING_VERSION_INSIDE_WORKSPACE`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L877-L885).
#[display(
"In {project_dir}: No matching version found for {alias}@{bare_specifier} inside the workspace{available}"
)]
#[diagnostic(code(ERR_PNPM_NO_MATCHING_VERSION_INSIDE_WORKSPACE))]
NoMatchingVersionInsideWorkspace {
alias: String,
bare_specifier: String,
project_dir: String,
#[error(not(source))]
available: String,
},
}
/// Try to resolve a `workspace:` wanted dependency against the project
/// workspace. Returns `Ok(None)` when the wanted dep isn't
/// workspace-prefixed (so the caller can fall through to the npm
/// path); returns `Ok(Some(_))` with a `link:` / `file:` resolution
/// otherwise.
///
/// Mirrors upstream's
/// [`tryResolveFromWorkspace`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L806-L844).
pub fn try_resolve_from_workspace(
wanted_dependency: &WantedDependency,
opts: &ResolveFromWorkspaceOptions<'_>,
) -> Result<Option<ResolveResult>, ResolveFromWorkspaceError> {
let Some(bare) = wanted_dependency.bare_specifier.as_deref() else {
return Ok(None);
};
if !bare.starts_with("workspace:") {
return Ok(None);
}
// `workspace:./foo` / `workspace:../foo` are owned by the local
// resolver; let those fall through so the chain doesn't claim
// them here.
if bare.starts_with("workspace:.") {
return Ok(None);
}
let translated =
workspace_pref_to_npm(bare).map_err(ResolveFromWorkspaceError::InvalidWorkspaceSpec)?;
let spec = parse_bare_specifier(
&translated,
wanted_dependency.alias.as_deref(),
opts.default_tag,
opts.registry,
)
.ok_or_else(|| ResolveFromWorkspaceError::UnparsableSpec {
bare_specifier: bare.to_string(),
})?;
let workspace_packages =
opts.workspace_packages.ok_or(ResolveFromWorkspaceError::WorkspacePackagesNotLoaded)?;
let result =
try_resolve_from_workspace_packages(workspace_packages, &spec, wanted_dependency, opts)?;
Ok(Some(result))
}
fn try_resolve_from_workspace_packages(
workspace_packages: &WorkspacePackages,
spec: &RegistryPackageSpec,
wanted_dependency: &WantedDependency,
opts: &ResolveFromWorkspaceOptions<'_>,
) -> Result<ResolveResult, ResolveFromWorkspaceError> {
let matching_name = workspace_packages.get(spec.name.as_str()).ok_or_else(|| {
let names = workspace_packages.keys().cloned().collect::<Vec<_>>().join(", ");
ResolveFromWorkspaceError::WorkspacePkgNotFound {
name: spec.name.clone(),
bare_specifier: wanted_dependency.bare_specifier.clone().unwrap_or_default(),
project_dir: opts.project_dir.display().to_string(),
hint: format!("Packages found in the workspace: {names}"),
}
})?;
let picked = pick_matching_local_version_or_null(matching_name, spec).ok_or_else(|| {
let mut versions: Vec<String> = matching_name.keys().cloned().collect();
versions.sort_by(|a, b| rcompare_versions(a, b));
let available = if versions.is_empty() {
String::new()
} else {
format!(". Available versions: {}", versions.join(", "))
};
ResolveFromWorkspaceError::NoMatchingVersionInsideWorkspace {
alias: wanted_dependency.alias.clone().unwrap_or_default(),
bare_specifier: wanted_dependency.bare_specifier.clone().unwrap_or_default(),
project_dir: opts.project_dir.display().to_string(),
available,
}
})?;
let local_package =
matching_name.get(&picked).expect("picked version came from the matching set");
Ok(resolve_from_local_package(
local_package,
wanted_dependency,
opts.inject_workspace_packages || wanted_dependency.injected.unwrap_or(false),
opts.project_dir,
opts.lockfile_dir,
))
}
/// Mirror upstream's
/// [`pickMatchingLocalVersionOrNull`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L890-L906).
fn pick_matching_local_version_or_null(
versions: &WorkspacePackagesByVersion,
spec: &RegistryPackageSpec,
) -> Option<String> {
match spec.spec_type {
RegistryPackageSpecType::Tag => {
let raw: Vec<String> = versions.keys().cloned().collect();
resolve_workspace_range("*", &raw)
}
RegistryPackageSpecType::Version => {
if versions.contains_key(&spec.fetch_spec) {
Some(spec.fetch_spec.clone())
} else {
None
}
}
RegistryPackageSpecType::Range => {
let raw: Vec<String> = versions.keys().cloned().collect();
resolve_workspace_range(&spec.fetch_spec, &raw)
}
}
}
/// Mirror upstream's
/// [`resolveFromLocalPackage`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L908-L951).
///
/// The TS branch also derives a `normalizedBareSpecifier` for the
/// add / update paths via `calcSpecifierForWorkspaceDep`; pacquet
/// doesn't carry the pinned-version / save-workspace-protocol config
/// through to the resolver yet, so the field stays `None` until those
/// land.
fn resolve_from_local_package(
local_package: &WorkspacePackage,
wanted_dependency: &WantedDependency,
hard_link_local_packages: bool,
project_dir: &Path,
lockfile_dir: &Path,
) -> ResolveResult {
let local_dir = resolve_local_package_dir(local_package);
let (id_text, directory) = if hard_link_local_packages {
let relative_to_lockfile = forward_slashes(relative_path(lockfile_dir, &local_dir));
let id = format!("file:{relative_to_lockfile}");
(id, relative_to_lockfile)
} else {
let relative_to_project = forward_slashes(relative_path(project_dir, &local_dir));
let id = format!("link:{relative_to_project}");
(id, relative_to_project)
};
ResolveResult {
id: PkgResolutionId::from(id_text),
name_ver: None,
latest: None,
published_at: None,
manifest: Some(local_package.manifest.clone()),
resolution: LockfileResolution::Directory(DirectoryResolution { directory }),
resolved_via: "workspace".to_string(),
normalized_bare_specifier: None,
alias: wanted_dependency.alias.clone(),
policy_violation: None,
}
}
/// Mirror upstream's
/// [`resolveLocalPackageDir`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/index.ts#L992-L998).
/// Honours `publishConfig.directory` when `publishConfig.linkDirectory`
/// is unset or `true`; otherwise the project's own `rootDir`.
fn resolve_local_package_dir(local_package: &WorkspacePackage) -> PathBuf {
let publish_config = local_package.manifest.get("publishConfig");
let publish_dir =
publish_config.and_then(|cfg| cfg.get("directory")).and_then(serde_json::Value::as_str);
let link_directory = publish_config
.and_then(|cfg| cfg.get("linkDirectory"))
.and_then(serde_json::Value::as_bool);
if publish_dir.is_none() || link_directory == Some(false) {
return local_package.root_dir.clone();
}
local_package.root_dir.join(publish_dir.expect("guard above"))
}
fn relative_path(base: &Path, target: &Path) -> String {
pathdiff_string(base, target).unwrap_or_else(|| target.display().to_string())
}
fn forward_slashes(input: String) -> String {
if input.contains('\\') { input.replace('\\', "/") } else { input }
}
/// Tiny pathdiff fallback. The npm-resolver crate doesn't pull in
/// `pathdiff` today; this helper keeps the dependency footprint
/// unchanged.
fn pathdiff_string(base: &Path, target: &Path) -> Option<String> {
use std::path::Component;
let mut base_components: Vec<Component<'_>> = base.components().collect();
let mut target_components: Vec<Component<'_>> = target.components().collect();
// Strip the common prefix.
let mut common = 0;
while common < base_components.len()
&& common < target_components.len()
&& base_components[common] == target_components[common]
{
common += 1;
}
base_components.drain(..common);
target_components.drain(..common);
let mut out = PathBuf::new();
for _ in base_components.iter().filter(|component| !matches!(component, Component::CurDir)) {
out.push("..");
}
for component in target_components {
out.push(component.as_os_str());
}
if out.as_os_str().is_empty() {
out.push(".");
}
Some(out.display().to_string())
}
/// Compare semver versions in *descending* order for the "available
/// versions" hint. Versions that don't parse fall back to lexicographic
/// reverse so the message at least stays stable.
fn rcompare_versions(left: &str, right: &str) -> std::cmp::Ordering {
match (Version::parse(left), Version::parse(right)) {
(Ok(left_parsed), Ok(right_parsed)) => right_parsed.cmp(&left_parsed),
_ => right.cmp(left),
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,235 @@
//! Unit tests for [`try_resolve_from_workspace`]. The upstream tests
//! exercise this via the full `resolveNpm` flow; pacquet's tests
//! invoke the helper directly with a hand-built
//! [`WorkspacePackages`] map.
use std::{collections::BTreeMap, path::Path};
use pacquet_lockfile::LockfileResolution;
use pacquet_resolving_resolver_base::{
WantedDependency, WorkspacePackage, WorkspacePackages, WorkspacePackagesByVersion,
};
use serde_json::json;
use super::{ResolveFromWorkspaceError, ResolveFromWorkspaceOptions, try_resolve_from_workspace};
fn build_packages() -> WorkspacePackages {
let mut foo: WorkspacePackagesByVersion = BTreeMap::new();
foo.insert(
"1.0.0".to_string(),
WorkspacePackage {
root_dir: Path::new("/repo/packages/foo").to_path_buf(),
manifest: json!({ "name": "foo", "version": "1.0.0" }),
},
);
foo.insert(
"2.0.0".to_string(),
WorkspacePackage {
root_dir: Path::new("/repo/packages/foo-2").to_path_buf(),
manifest: json!({ "name": "foo", "version": "2.0.0" }),
},
);
let mut bar: WorkspacePackagesByVersion = BTreeMap::new();
bar.insert(
"0.1.2".to_string(),
WorkspacePackage {
root_dir: Path::new("/repo/packages/bar").to_path_buf(),
manifest: json!({ "name": "bar", "version": "0.1.2" }),
},
);
let mut packages: WorkspacePackages = BTreeMap::new();
packages.insert("foo".to_string(), foo);
packages.insert("bar".to_string(), bar);
packages
}
fn opts<'a>(packages: &'a WorkspacePackages) -> ResolveFromWorkspaceOptions<'a> {
ResolveFromWorkspaceOptions {
project_dir: Path::new("/repo/packages/consumer"),
lockfile_dir: Path::new("/repo"),
registry: "https://registry.npmjs.org/",
default_tag: "latest",
workspace_packages: Some(packages),
inject_workspace_packages: false,
}
}
fn wanted(alias: &str, bare: &str) -> WantedDependency {
WantedDependency {
alias: Some(alias.to_string()),
bare_specifier: Some(bare.to_string()),
..WantedDependency::default()
}
}
#[test]
fn non_workspace_spec_returns_none() {
let packages = build_packages();
let opts = opts(&packages);
let result = try_resolve_from_workspace(&wanted("foo", "^1.0.0"), &opts).expect("ok").is_none();
assert!(result);
}
#[test]
fn workspace_path_form_defers_to_local_resolver() {
let packages = build_packages();
let opts = opts(&packages);
let result = try_resolve_from_workspace(&wanted("foo", "workspace:../foo"), &opts).expect("ok");
assert!(result.is_none());
}
#[test]
fn workspace_star_resolves_to_link_against_highest_version() {
let packages = build_packages();
let opts = opts(&packages);
let result = try_resolve_from_workspace(&wanted("foo", "workspace:*"), &opts)
.expect("ok")
.expect("some");
assert_eq!(result.id.as_str(), "link:../foo-2");
assert_eq!(result.resolved_via, "workspace");
match &result.resolution {
LockfileResolution::Directory(dir) => assert_eq!(dir.directory, "../foo-2"),
other => panic!("expected directory resolution, got {other:?}"),
}
assert_eq!(result.alias.as_deref(), Some("foo"));
}
#[test]
fn workspace_caret_range_picks_lower_when_pinned_range_excludes_higher() {
let packages = build_packages();
let opts = opts(&packages);
let result = try_resolve_from_workspace(&wanted("foo", "workspace:^1.0.0"), &opts)
.expect("ok")
.expect("some");
assert_eq!(result.id.as_str(), "link:../foo");
}
#[test]
fn workspace_exact_version_picks_that_entry() {
let packages = build_packages();
let opts = opts(&packages);
let result = try_resolve_from_workspace(&wanted("bar", "workspace:0.1.2"), &opts)
.expect("ok")
.expect("some");
assert_eq!(result.id.as_str(), "link:../bar");
}
#[test]
fn aliased_workspace_form_routes_through_package_name() {
let packages = build_packages();
let opts = opts(&packages);
let result = try_resolve_from_workspace(&wanted("bar", "workspace:foo@^1.0.0"), &opts)
.expect("ok")
.expect("some");
assert_eq!(result.id.as_str(), "link:../foo");
assert_eq!(result.alias.as_deref(), Some("bar"));
}
#[test]
fn missing_workspace_package_surfaces_pnpm_error_code() {
let packages = build_packages();
let opts = opts(&packages);
let err = try_resolve_from_workspace(&wanted("missing", "workspace:*"), &opts).unwrap_err();
assert!(matches!(
err,
ResolveFromWorkspaceError::WorkspacePkgNotFound { ref name, .. } if name == "missing",
));
}
#[test]
fn no_matching_version_surfaces_pnpm_error_code() {
let packages = build_packages();
let opts = opts(&packages);
let err = try_resolve_from_workspace(&wanted("foo", "workspace:^99.0.0"), &opts).unwrap_err();
assert!(matches!(err, ResolveFromWorkspaceError::NoMatchingVersionInsideWorkspace { .. }));
}
#[test]
fn workspace_packages_unset_surfaces_error() {
let packages = build_packages();
let mut opts = opts(&packages);
opts.workspace_packages = None;
let err = try_resolve_from_workspace(&wanted("foo", "workspace:*"), &opts).unwrap_err();
assert!(matches!(err, ResolveFromWorkspaceError::WorkspacePackagesNotLoaded));
}
#[test]
fn inject_workspace_packages_writes_file_resolution() {
let packages = build_packages();
let mut opts = opts(&packages);
opts.inject_workspace_packages = true;
let result = try_resolve_from_workspace(&wanted("foo", "workspace:*"), &opts)
.expect("ok")
.expect("some");
assert_eq!(result.id.as_str(), "file:packages/foo-2");
match &result.resolution {
LockfileResolution::Directory(dir) => assert_eq!(dir.directory, "packages/foo-2"),
other => panic!("expected directory resolution, got {other:?}"),
}
}
#[test]
fn publish_config_directory_overrides_root_when_link_directory_is_unset() {
let mut packages: WorkspacePackages = BTreeMap::new();
let mut entries: WorkspacePackagesByVersion = BTreeMap::new();
entries.insert(
"1.0.0".to_string(),
WorkspacePackage {
root_dir: Path::new("/repo/packages/foo").to_path_buf(),
manifest: json!({
"name": "foo",
"version": "1.0.0",
"publishConfig": { "directory": "dist" },
}),
},
);
packages.insert("foo".to_string(), entries);
let opts = ResolveFromWorkspaceOptions {
project_dir: Path::new("/repo/packages/consumer"),
lockfile_dir: Path::new("/repo"),
registry: "https://registry.npmjs.org/",
default_tag: "latest",
workspace_packages: Some(&packages),
inject_workspace_packages: false,
};
let result = try_resolve_from_workspace(&wanted("foo", "workspace:*"), &opts)
.expect("ok")
.expect("some");
assert_eq!(result.id.as_str(), "link:../foo/dist");
}
#[test]
fn publish_config_link_directory_false_keeps_root() {
let mut packages: WorkspacePackages = BTreeMap::new();
let mut entries: WorkspacePackagesByVersion = BTreeMap::new();
entries.insert(
"1.0.0".to_string(),
WorkspacePackage {
root_dir: Path::new("/repo/packages/foo").to_path_buf(),
manifest: json!({
"name": "foo",
"version": "1.0.0",
"publishConfig": { "directory": "dist", "linkDirectory": false },
}),
},
);
packages.insert("foo".to_string(), entries);
let opts = ResolveFromWorkspaceOptions {
project_dir: Path::new("/repo/packages/consumer"),
lockfile_dir: Path::new("/repo"),
registry: "https://registry.npmjs.org/",
default_tag: "latest",
workspace_packages: Some(&packages),
inject_workspace_packages: false,
};
let result = try_resolve_from_workspace(&wanted("foo", "workspace:*"), &opts)
.expect("ok")
.expect("some");
assert_eq!(result.id.as_str(), "link:../foo");
}

View File

@@ -0,0 +1,50 @@
//! Port of pnpm's
//! [`workspacePrefToNpm`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/workspacePrefToNpm.ts).
//!
//! Translates a `workspace:` bare specifier into the npm-shaped form
//! the [`crate::parse_bare_specifier()`] flow consumes:
//!
//! - `workspace:` / `workspace:*` / `workspace:^` / `workspace:~` → `*`
//! - `workspace:<version>` → `<version>`
//! - `workspace:<alias>@<version>` → `npm:<alias>@<version-token>`
//! (the `^`/`~`/empty sentinels collapse to `*` for the version
//! token too).
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_workspace_spec::WorkspaceSpec;
/// Error raised when the input does not start with `workspace:` (and
/// therefore does not parse as a [`WorkspaceSpec`]). Callers are
/// expected to ensure the prefix is present before invoking
/// [`workspace_pref_to_npm`], so this surfaces as a programming error
/// rather than a user-facing diagnostic.
#[derive(Debug, Display, Error, Diagnostic, Clone)]
#[display("Invalid workspace spec: {bare_specifier}")]
pub struct InvalidWorkspaceSpecError {
#[error(not(source))]
pub bare_specifier: String,
}
/// Translate a `workspace:` bare specifier into its npm-shape
/// equivalent. Mirrors upstream's
/// [`workspacePrefToNpm`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/src/workspacePrefToNpm.ts#L3-L14).
pub fn workspace_pref_to_npm(
workspace_bare_specifier: &str,
) -> Result<String, InvalidWorkspaceSpecError> {
let Some(parsed) = WorkspaceSpec::parse(workspace_bare_specifier) else {
return Err(InvalidWorkspaceSpecError {
bare_specifier: workspace_bare_specifier.to_string(),
});
};
let WorkspaceSpec { alias, version } = parsed;
let version_part =
if version == "^" || version == "~" || version.is_empty() { "*" } else { version.as_str() };
Ok(match alias {
Some(alias) => format!("npm:{alias}@{version_part}"),
None => version_part.to_string(),
})
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,48 @@
//! Port of pnpm's
//! [`resolving/npm-resolver/test/workspacePrefToNpm.test.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/npm-resolver/test/workspacePrefToNpm.test.ts).
use super::workspace_pref_to_npm;
#[test]
fn resolves_workspace_only_version_aliases() {
assert_eq!(workspace_pref_to_npm("workspace:").unwrap(), "*");
assert_eq!(workspace_pref_to_npm("workspace:*").unwrap(), "*");
assert_eq!(workspace_pref_to_npm("workspace:^").unwrap(), "*");
assert_eq!(workspace_pref_to_npm("workspace:~").unwrap(), "*");
}
#[test]
fn resolves_package_name_aliases() {
assert_eq!(
workspace_pref_to_npm("workspace:is-positive@3.0.0").unwrap(),
"npm:is-positive@3.0.0",
);
assert_eq!(workspace_pref_to_npm("workspace:is-positive@*").unwrap(), "npm:is-positive@*");
assert_eq!(workspace_pref_to_npm("workspace:is-positive@^").unwrap(), "npm:is-positive@*");
}
#[test]
fn resolves_scoped_package_name_aliases() {
assert_eq!(
workspace_pref_to_npm("workspace:@scope/is-positive@1.2.3").unwrap(),
"npm:@scope/is-positive@1.2.3",
);
assert_eq!(
workspace_pref_to_npm("workspace:@scope/is-positive@^1.2.3").unwrap(),
"npm:@scope/is-positive@^1.2.3",
);
assert_eq!(
workspace_pref_to_npm("workspace:@scope/is-positive@*").unwrap(),
"npm:@scope/is-positive@*",
);
assert_eq!(
workspace_pref_to_npm("workspace:@scope/is-positive@~").unwrap(),
"npm:@scope/is-positive@*",
);
}
#[test]
fn raises_invalid_workspace_spec_for_non_workspace_input() {
let err = workspace_pref_to_npm("*").unwrap_err();
assert_eq!(err.bare_specifier, "*");
}

View File

@@ -0,0 +1,17 @@
[package]
name = "pacquet-workspace-range-resolver"
version = "0.0.1"
publish = false
authors.workspace = true
description.workspace = true
edition.workspace = true
homepage.workspace = true
keywords.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
node-semver = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,92 @@
//! Pacquet port of pnpm's
//! [`@pnpm/workspace.range-resolver`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/range-resolver/src/index.ts).
//!
//! Picks the best workspace-sibling version for one of the `workspace:`
//! range tokens — `*`, `^`, `~`, the empty string, or an arbitrary
//! semver range.
use node_semver::{Range, Version};
/// Pick the highest workspace-sibling version matching `range`.
///
/// `range` is the `<version>` portion of a `workspace:` specifier (see
/// `pacquet-workspace-spec`'s `WorkspaceSpec`). The four sentinel tokens
/// (`*`, `^`, `~`, `""`) widen the search to *all* versions, prereleases
/// included — mirroring the
/// [`includePrerelease: true`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/range-resolver/src/index.ts#L4-L8)
/// branch upstream takes. Any other input is treated as a node-semver
/// range and prereleases are excluded the same way `semver.maxSatisfying`
/// excludes them in the non-`includePrerelease` branch.
///
/// Returns the matching raw version string (one of the entries in
/// `versions`) or `None` when nothing satisfies.
pub fn resolve_workspace_range(range: &str, versions: &[String]) -> Option<String> {
if is_wildcard(range) {
return max_version_including_prerelease(versions);
}
max_satisfying(versions, range)
}
fn is_wildcard(range: &str) -> bool {
matches!(range, "*" | "^" | "~" | "")
}
/// Highest version overall, including prereleases. The TS impl reaches
/// `semver.maxSatisfying(versions, '*', { includePrerelease: true })`
/// for this; since `*` matches everything when prereleases are allowed,
/// pacquet collapses that to a direct max.
fn max_version_including_prerelease(versions: &[String]) -> Option<String> {
let mut best: Option<(Version, &str)> = None;
for raw in versions {
let Ok(parsed) = Version::parse(raw) else { continue };
match &best {
Some((current, _)) if current >= &parsed => {}
_ => best = Some((parsed, raw.as_str())),
}
}
best.map(|(_, raw)| raw.to_string())
}
/// Highest version satisfying `range`. Prereleases are excluded unless
/// the range itself contains a prerelease tag — mirroring the
/// non-`includePrerelease` branch of `semver.maxSatisfying`.
fn max_satisfying(versions: &[String], range: &str) -> Option<String> {
let parsed_range = Range::parse(range).ok()?;
let range_allows_prereleases = range_allows_prereleases(range);
let mut best: Option<(Version, &str)> = None;
for raw in versions {
let Ok(parsed) = Version::parse(raw) else { continue };
if !parsed.satisfies(&parsed_range) {
continue;
}
if !parsed.pre_release.is_empty() && !range_allows_prereleases {
continue;
}
match &best {
Some((current, _)) if current >= &parsed => {}
_ => best = Some((parsed, raw.as_str())),
}
}
best.map(|(_, raw)| raw.to_string())
}
/// Heuristic for whether `range` would let `semver.maxSatisfying`
/// surface prereleases without the `includePrerelease` flag.
///
/// In node-semver a range only matches prereleases when one of its
/// comparators carries a `-<pre>` tag (e.g. `>=1.2.3-rc.0` matches
/// `1.2.3-rc.1` but not `1.2.4-rc.0`). The exact rule is involved, but
/// for our purposes "the range string contains a `-` after a digit"
/// is a tight enough approximation — same heuristic upstream's
/// `maxSatisfying` uses when constructing the comparator filter.
fn range_allows_prereleases(range: &str) -> bool {
let bytes = range.as_bytes();
let mut prev_is_digit = false;
for &byte in bytes {
if byte == b'-' && prev_is_digit {
return true;
}
prev_is_digit = byte.is_ascii_digit();
}
false
}

View File

@@ -0,0 +1,40 @@
//! Port of pnpm's
//! [`workspace/range-resolver/test/index.test.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/range-resolver/test/index.test.ts).
use pacquet_workspace_range_resolver::resolve_workspace_range;
fn versions() -> Vec<String> {
vec!["1.0.0".to_string(), "2.0.0".to_string(), "3.0.0-beta.1".to_string()]
}
#[test]
fn resolves_star_to_max_version_including_prereleases() {
assert_eq!(resolve_workspace_range("*", &versions()).as_deref(), Some("3.0.0-beta.1"));
}
#[test]
fn resolves_caret_to_max_version_including_prereleases() {
assert_eq!(resolve_workspace_range("^", &versions()).as_deref(), Some("3.0.0-beta.1"));
}
#[test]
fn resolves_tilde_to_max_version_including_prereleases() {
assert_eq!(resolve_workspace_range("~", &versions()).as_deref(), Some("3.0.0-beta.1"));
}
#[test]
fn resolves_empty_string_to_max_version_including_prereleases() {
assert_eq!(resolve_workspace_range("", &versions()).as_deref(), Some("3.0.0-beta.1"));
}
#[test]
fn resolves_semver_range() {
assert_eq!(resolve_workspace_range("^1.0.0", &versions()).as_deref(), Some("1.0.0"));
assert_eq!(resolve_workspace_range("^2.0.0", &versions()).as_deref(), Some("2.0.0"));
assert_eq!(resolve_workspace_range(">=1.0.0", &versions()).as_deref(), Some("2.0.0"));
}
#[test]
fn returns_none_when_no_version_satisfies_range() {
assert_eq!(resolve_workspace_range("^4.0.0", &versions()), None);
}

View File

@@ -0,0 +1,14 @@
[package]
name = "pacquet-workspace-spec"
version = "0.0.1"
publish = false
authors.workspace = true
description.workspace = true
edition.workspace = true
homepage.workspace = true
keywords.workspace = true
license.workspace = true
repository.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,95 @@
//! Pacquet port of pnpm's
//! [`@pnpm/workspace.spec-parser`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/src/index.ts).
//!
//! Parses the `workspace:` family of bare specifiers:
//!
//! - `workspace:*` / `workspace:^` / `workspace:~` — pick the highest
//! matching version from the workspace, with the version token kept
//! verbatim so the npm resolver can translate it to a range when it
//! rewrites the specifier.
//! - `workspace:1.2.3` / `workspace:^1.2.3` — exact / range against
//! workspace siblings.
//! - `workspace:<alias>@<version>` / `workspace:@scope/<alias>@<version>`
//! — npm-alias form, used when the importer wants to install a
//! workspace package under a different local name.
//!
//! The parser is deliberately permissive — it just splits the
//! `<alias>@<version>` shape. Validity of `<version>` (semver vs.
//! `*`/`^`/`~`/empty) is the caller's responsibility; see
//! `pacquet-workspace-range-resolver`'s `resolve_workspace_range` for
//! the matching range-pick logic.
use std::fmt;
/// Parsed `workspace:` bare specifier. Mirrors upstream's
/// [`WorkspaceSpec`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/src/index.ts#L3-L22)
/// class — a record of `(alias, version)` plus a `Display` impl that
/// round-trips back to the original `workspace:` form.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct WorkspaceSpec {
/// The optional `<alias>` portion of `workspace:<alias>@<version>`.
/// `None` for the un-aliased `workspace:<version>` form.
pub alias: Option<String>,
/// The `<version>` portion — `*`, `^`, `~`, the empty string, an
/// exact version, or a semver range. Kept as a raw string so the
/// caller can decide how to interpret it.
pub version: String,
}
impl WorkspaceSpec {
/// Construct a [`WorkspaceSpec`] directly. The TS class exposes a
/// `new WorkspaceSpec(version, alias?)` constructor; pacquet keeps
/// the same shape so call sites don't have to translate.
pub fn new(version: impl Into<String>, alias: Option<impl Into<String>>) -> Self {
Self { alias: alias.map(Into::into), version: version.into() }
}
/// Parse a bare specifier. Returns `None` when the input does not
/// start with `workspace:` so the caller can fall through to the
/// next protocol in the resolver chain.
///
/// Mirrors upstream's
/// [`WorkspaceSpec.parse`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/src/index.ts#L12-L16)
/// — same regex (`/^workspace:(?:(?<alias>[^._/][^@]*)@)?(?<version>.*)$/`)
/// expressed as a hand-rolled split so the crate carries no regex
/// dependency.
pub fn parse(bare_specifier: &str) -> Option<Self> {
let suffix = bare_specifier.strip_prefix("workspace:")?;
let (alias, version) = split_alias_version(suffix);
Some(Self { alias: alias.map(str::to_string), version: version.to_string() })
}
}
impl fmt::Display for WorkspaceSpec {
/// Round-trip the spec back to its `workspace:` form. Mirrors
/// upstream's
/// [`toString`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/src/index.ts#L18-L21).
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.alias {
Some(alias) => write!(f, "workspace:{alias}@{version}", version = self.version),
None => write!(f, "workspace:{version}", version = self.version),
}
}
}
/// Split the post-`workspace:` portion into `(alias?, version)` using
/// the same shape upstream's `[^._/][^@]*@` regex implements: the
/// alias must start with a character that is **not** `.`, `_`, or `/`,
/// be at least one character long, and be followed by a literal `@`.
fn split_alias_version(suffix: &str) -> (Option<&str>, &str) {
let mut chars = suffix.char_indices();
let Some((_, first)) = chars.next() else {
return (None, suffix);
};
if matches!(first, '.' | '_' | '/') {
return (None, suffix);
}
let Some(at_offset) = suffix[first.len_utf8()..].find('@') else {
return (None, suffix);
};
let split_at = first.len_utf8() + at_offset;
(Some(&suffix[..split_at]), &suffix[split_at + 1..])
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,86 @@
//! Port of pnpm's
//! [`workspace/spec-parser/test/workspace-spec.test.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/test/workspace-spec.test.ts).
use super::WorkspaceSpec;
fn ws(version: &str, alias: Option<&str>) -> WorkspaceSpec {
WorkspaceSpec { alias: alias.map(str::to_string), version: version.to_string() }
}
#[test]
fn parse_valid_workspace_spec() {
assert_eq!(WorkspaceSpec::parse("workspace:*"), Some(ws("*", None)));
assert_eq!(WorkspaceSpec::parse("workspace:^"), Some(ws("^", None)));
assert_eq!(WorkspaceSpec::parse("workspace:~"), Some(ws("~", None)));
assert_eq!(WorkspaceSpec::parse("workspace:0.1.2"), Some(ws("0.1.2", None)));
assert_eq!(WorkspaceSpec::parse("workspace:foo@*"), Some(ws("*", Some("foo"))));
assert_eq!(WorkspaceSpec::parse("workspace:foo@^"), Some(ws("^", Some("foo"))));
assert_eq!(WorkspaceSpec::parse("workspace:foo@~"), Some(ws("~", Some("foo"))));
assert_eq!(WorkspaceSpec::parse("workspace:foo@0.1.2"), Some(ws("0.1.2", Some("foo"))));
assert_eq!(WorkspaceSpec::parse("workspace:@foo/bar@*"), Some(ws("*", Some("@foo/bar"))));
assert_eq!(WorkspaceSpec::parse("workspace:@foo/bar@^"), Some(ws("^", Some("@foo/bar"))));
assert_eq!(WorkspaceSpec::parse("workspace:@foo/bar@~"), Some(ws("~", Some("@foo/bar"))));
assert_eq!(
WorkspaceSpec::parse("workspace:@foo/bar@0.1.2"),
Some(ws("0.1.2", Some("@foo/bar"))),
);
}
#[test]
fn parse_invalid_workspace_spec() {
assert_eq!(WorkspaceSpec::parse("npm:foo@0.1.2"), None);
assert_eq!(WorkspaceSpec::parse("*"), None);
}
#[test]
fn to_string_round_trips() {
assert_eq!(ws("*", None).to_string(), "workspace:*");
assert_eq!(ws("^", None).to_string(), "workspace:^");
assert_eq!(ws("~", None).to_string(), "workspace:~");
assert_eq!(ws("0.1.2", None).to_string(), "workspace:0.1.2");
assert_eq!(ws("*", Some("foo")).to_string(), "workspace:foo@*");
assert_eq!(ws("^", Some("foo")).to_string(), "workspace:foo@^");
assert_eq!(ws("~", Some("foo")).to_string(), "workspace:foo@~");
assert_eq!(ws("0.1.2", Some("foo")).to_string(), "workspace:foo@0.1.2");
assert_eq!(ws("*", Some("@foo/bar")).to_string(), "workspace:@foo/bar@*");
assert_eq!(ws("^", Some("@foo/bar")).to_string(), "workspace:@foo/bar@^");
assert_eq!(ws("~", Some("@foo/bar")).to_string(), "workspace:@foo/bar@~");
assert_eq!(ws("0.1.2", Some("@foo/bar")).to_string(), "workspace:@foo/bar@0.1.2");
}
/// Upstream's
/// [`mutate alias and version`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/spec-parser/test/workspace-spec.test.ts#L40-L49)
/// case exercises `delete spec.alias` and `spec.version = ...`. Pacquet
/// keeps the fields plain (no setters / phantom state), so the port
/// mutates them directly and re-renders.
#[test]
fn mutate_alias_and_version() {
let mut spec = WorkspaceSpec::parse("workspace:*").expect("parses");
assert_eq!(spec.to_string(), "workspace:*");
spec.version = "^".to_string();
assert_eq!(spec.to_string(), "workspace:^");
spec.alias = Some("foo".to_string());
assert_eq!(spec.to_string(), "workspace:foo@^");
spec.alias = None;
assert_eq!(spec.to_string(), "workspace:^");
}
#[test]
fn empty_version_is_preserved() {
assert_eq!(WorkspaceSpec::parse("workspace:"), Some(ws("", None)));
assert_eq!(
WorkspaceSpec::parse("workspace:").map(|spec| spec.to_string()).as_deref(),
Some("workspace:"),
);
}
/// Upstream's regex rejects aliases that start with `.`, `_`, or `/`
/// — those characters get rolled into the version instead. Locks the
/// behavior so future refactors don't drift.
#[test]
fn alias_first_char_class_excludes_dot_underscore_slash() {
assert_eq!(WorkspaceSpec::parse("workspace:./foo"), Some(ws("./foo", None)));
assert_eq!(WorkspaceSpec::parse("workspace:../foo"), Some(ws("../foo", None)));
assert_eq!(WorkspaceSpec::parse("workspace:_foo@1.0.0"), Some(ws("_foo@1.0.0", None)));
assert_eq!(WorkspaceSpec::parse("workspace:/abs/path"), Some(ws("/abs/path", None)));
}