mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 18:05:29 -04:00
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:
24
Cargo.lock
generated
24
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
24
pacquet/crates/exportable-manifest/Cargo.toml
Normal file
24
pacquet/crates/exportable-manifest/Cargo.toml
Normal 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
|
||||
25
pacquet/crates/exportable-manifest/src/lib.rs
Normal file
25
pacquet/crates/exportable-manifest/src/lib.rs
Normal 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,
|
||||
};
|
||||
331
pacquet/crates/exportable-manifest/src/replace.rs
Normal file
331
pacquet/crates/exportable-manifest/src/replace.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
169
pacquet/crates/exportable-manifest/src/tests.rs
Normal file
169
pacquet/crates/exportable-manifest/src/tests.rs
Normal 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");
|
||||
}
|
||||
@@ -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>.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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: ®istry,
|
||||
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
|
||||
|
||||
@@ -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(®istry);
|
||||
|
||||
// `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(®istry);
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, "*");
|
||||
}
|
||||
17
pacquet/crates/workspace-range-resolver/Cargo.toml
Normal file
17
pacquet/crates/workspace-range-resolver/Cargo.toml
Normal 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
|
||||
92
pacquet/crates/workspace-range-resolver/src/lib.rs
Normal file
92
pacquet/crates/workspace-range-resolver/src/lib.rs
Normal 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
|
||||
}
|
||||
40
pacquet/crates/workspace-range-resolver/tests/resolve.rs
Normal file
40
pacquet/crates/workspace-range-resolver/tests/resolve.rs
Normal 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);
|
||||
}
|
||||
14
pacquet/crates/workspace-spec/Cargo.toml
Normal file
14
pacquet/crates/workspace-spec/Cargo.toml
Normal 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
|
||||
95
pacquet/crates/workspace-spec/src/lib.rs
Normal file
95
pacquet/crates/workspace-spec/src/lib.rs
Normal 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;
|
||||
86
pacquet/crates/workspace-spec/src/tests.rs
Normal file
86
pacquet/crates/workspace-spec/src/tests.rs
Normal 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)));
|
||||
}
|
||||
Reference in New Issue
Block a user