mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
feat(pacquet): port resolving.local-resolver (file:/link:/workspace:) (#11778)
* feat(pacquet/resolving-local-resolver): port file:/link:/workspace: resolver
Ports pnpm's @pnpm/resolving.local-resolver:
- parse_bare_specifier mirrors parseLocalScheme/parseLocalPath/fromLocal
(link:/workspace:/file: prefixes, bare path shapes, tarball-filename
detection, tilde/drive-letter handling, preserveAbsolutePaths,
injected directories get file: not link:).
- local_resolver provides resolve_from_local_scheme / _path /
resolve_latest_from_local matching upstream's three exports;
ssri-based tarball integrity for file: tarballs;
safe_read_package_json_from_dir for directories with the upstream
fallback (warn + name=basename / version='0.0.0' for missing link:
targets, LINKED_PKG_DIR_NOT_FOUND for missing file: targets,
NOT_PACKAGE_DIRECTORY for ENOTDIR + Windows stat-check) and
PATH_IS_UNSUPPORTED_PROTOCOL for path:.
- chain.rs wraps the free functions behind the Resolver trait so the
default-resolver dispatcher can compose this in alongside the npm
resolver. ResolveResult.name_ver is None for local resolutions —
the canonical name lives in the fetched manifest, not the
resolver-time signal.
17 ported tests mirror resolving/local-resolver/test/index.ts plus
3 chain-dispatch tests verifying the trait wiring. The missing-link:
target warn is emitted via tracing::warn! because pacquet's reporter
doesn't yet have a generic pnpm:logger channel.
Install-side wiring is left for a follow-up alongside the Stage-1
directory-fetcher integration: surfacing Directory resolutions to
install_without_lockfile today would only swap the
SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER error for an UnsupportedResolution
one in install_package_from_registry.
Written by an agent (Claude Code, claude-opus-4-7).
* fix(pacquet): satisfy doc + dylint CI
Doc:
- pacquet_directory_fetcher intra-doc link was unresolved
(resolving-local-resolver has no such dep — it's a sibling).
- LocalSpecError doc linked to crate-private parse_local_scheme /
parse_local_path.
Dylint (perfectionist):
- PkgResolutionId / WantedLocalDependency / ParseOptions /
PathProtocolNotSupportedError derive lists reordered to
prefix_then_alphabetical.
- Single-letter closure params (|p|, |s|, |v|) renamed.
- Impure expression passed to tracing::warn! bound to a let first.
- Multi-line format!/assert_eq! macro invocations gained trailing
commas; the single-line assert! shed its stray trailing comma.
Written by an agent (Claude Code, claude-opus-4-7).
* fix(pacquet/resolving-local-resolver): surface NOT_PACKAGE_DIRECTORY on Windows
`safe_read_package_json_from_dir` opens `<spec>/package.json` and lets
the OS error surface. On Unix that's `ENOTDIR` for a file path; on
Windows it's `NotFound`, so the resolver fell through to the
fallback-manifest branch instead of returning `NOT_PACKAGE_DIRECTORY`.
Upstream pnpm has the same gap on Windows and patches around it inside
`readProjectManifestOnly` (workspace/project-manifest-reader/src/index.ts#L100-L114
at ef87f3ccff) by stat-checking the spec and synthesizing ENOTDIR.
Mirror the check here so `link:./foo.tgz` raises NOT_PACKAGE_DIRECTORY
on every platform.
Written by an agent (Claude Code, claude-opus-4-7).
* fix(pacquet/resolving-local-resolver): raise LINKED_PKG_DIR_NOT_FOUND for missing tarballs
The `file:` tarball branch wrapped every read failure in
`ResolveLocalError::Integrity`, so `file:./missing.tgz` lost the
pnpm-compatible error code. The directory branch already maps the
same scenario to `LINKED_PKG_DIR_NOT_FOUND`; thread the tarball case
through the same code by short-circuiting on `NotFound` from
`compute_tarball_integrity`. Mirrors upstream's `resolveSpec` catch,
where `getTarballIntegrity`'s ENOENT funnels into the same
LINKED_PKG_DIR_NOT_FOUND throw the directory branch raises.
Adds a `fail_when_resolving_missing_tarball_with_file_protocol` test
to pin the contract.
Resolves a CodeRabbit review comment on #11778.
Written by an agent (Claude Code, claude-opus-4-7).
* feat(pacquet/package-manager): wire LocalResolver into install_without_lockfile chain
`install_without_lockfile`'s resolver chain was `[npm, git]` — `link:`
/ `file:` / `workspace:` and bare-path specifiers raised
`SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER` even though the
`resolving-local-resolver` crate that handles them has been ported.
Slot `LocalResolver` in after git, mirroring upstream's
`createResolver` order (npm → jsr → git → tarball → local-scheme → …
→ local-path).
Pacquet doesn't expose `preserveAbsolutePaths` through `Config` yet
so the context defaults to `false`; once that setting lands in
`pacquet-config` the install path can thread it through.
The install pass still can't materialise Directory resolutions — the
tarball-shaped install path raises `UnsupportedResolution` — but the
resolver chain now correctly dispatches to the local resolver, so
the failure mode moves one step closer to the install-side gap
that's tracked by the Stage-1 `directory-fetcher` integration.
Written by an agent (Claude Code, claude-opus-4-7).
* fix(pacquet): satisfy cargo fmt --check
Local cargo fmt formatted the `fail_when_resolving_missing_tarball_with_file_protocol`
struct literal on one line; the CI Format job (`cargo fmt --all -- --check`)
disagreed. Re-run fmt locally to flush the diff.
Written by an agent (Claude Code, claude-opus-4-7).
This commit is contained in:
22
Cargo.lock
generated
22
Cargo.lock
generated
@@ -2306,6 +2306,7 @@ dependencies = [
|
||||
"pacquet-resolving-default-resolver",
|
||||
"pacquet-resolving-deps-resolver",
|
||||
"pacquet-resolving-git-resolver",
|
||||
"pacquet-resolving-local-resolver",
|
||||
"pacquet-resolving-npm-resolver",
|
||||
"pacquet-resolving-resolver-base",
|
||||
"pacquet-resolving-tarball-resolver",
|
||||
@@ -2474,6 +2475,27 @@ dependencies = [
|
||||
"miette 7.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-resolving-local-resolver"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"home",
|
||||
"miette 7.6.0",
|
||||
"pacquet-lockfile",
|
||||
"pacquet-package-manifest",
|
||||
"pacquet-resolving-default-resolver",
|
||||
"pacquet-resolving-resolver-base",
|
||||
"pacquet-testing-utils",
|
||||
"pathdiff",
|
||||
"pretty_assertions",
|
||||
"serde_json",
|
||||
"ssri",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-resolving-npm-resolver"
|
||||
version = "0.0.1"
|
||||
|
||||
@@ -42,6 +42,7 @@ pacquet-resolving-default-resolver = { path = "pacquet/crates/resolving-d
|
||||
pacquet-resolving-deps-resolver = { path = "pacquet/crates/resolving-deps-resolver" }
|
||||
pacquet-resolving-git-resolver = { path = "pacquet/crates/resolving-git-resolver" }
|
||||
pacquet-resolving-jsr-specifier-parser = { path = "pacquet/crates/resolving-jsr-specifier-parser" }
|
||||
pacquet-resolving-local-resolver = { path = "pacquet/crates/resolving-local-resolver" }
|
||||
pacquet-resolving-npm-resolver = { path = "pacquet/crates/resolving-npm-resolver" }
|
||||
pacquet-resolving-parse-wanted-dependency = { path = "pacquet/crates/resolving-parse-wanted-dependency" }
|
||||
pacquet-resolving-resolver-base = { path = "pacquet/crates/resolving-resolver-base" }
|
||||
|
||||
@@ -33,6 +33,7 @@ pacquet-reporter = { workspace = true }
|
||||
pacquet-resolving-default-resolver = { workspace = true }
|
||||
pacquet-resolving-deps-resolver = { workspace = true }
|
||||
pacquet-resolving-git-resolver = { workspace = true }
|
||||
pacquet-resolving-local-resolver = { workspace = true }
|
||||
pacquet-resolving-npm-resolver = { workspace = true }
|
||||
pacquet-resolving-resolver-base = { workspace = true }
|
||||
pacquet-resolving-tarball-resolver = { workspace = true }
|
||||
|
||||
@@ -18,6 +18,7 @@ use pacquet_resolving_deps_resolver::{
|
||||
ResolvePeersOptions, resolve_dependency_tree, resolve_peers,
|
||||
};
|
||||
use pacquet_resolving_git_resolver::{GitResolver, RealGitProbe, RealGitRunner};
|
||||
use pacquet_resolving_local_resolver::{LocalResolver, LocalResolverContext};
|
||||
use pacquet_resolving_npm_resolver::{InMemoryPackageMetaCache, NpmResolver};
|
||||
use pacquet_resolving_resolver_base::{ResolveOptions, Resolver};
|
||||
use pacquet_resolving_tarball_resolver::TarballResolver;
|
||||
@@ -179,14 +180,28 @@ impl<'a, DependencyGroupList> InstallWithoutLockfile<'a, DependencyGroupList> {
|
||||
Arc::new(RealGitRunner::new()),
|
||||
);
|
||||
let tarball_resolver = TarballResolver { http_client: Arc::clone(&http_client_arc) };
|
||||
// `preserveAbsolutePaths` is wired through `Config`; thread the
|
||||
// current value into the local-resolver context so absolute
|
||||
// `file:` / `link:` specs round-trip the same shape upstream
|
||||
// produces under the matching `--config.preserve-absolute-paths`
|
||||
// setting. Pacquet doesn't expose `preserveAbsolutePaths` yet,
|
||||
// so the context defaults to `false`.
|
||||
let local_resolver =
|
||||
LocalResolver::new(LocalResolverContext { preserve_absolute_paths: false });
|
||||
// Order mirrors upstream's chain at
|
||||
// <https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/default-resolver/src/index.ts#L97-L173>:
|
||||
// npm, then git, then tarball. Local/workspace/runtimes will
|
||||
// slot in as those crates land.
|
||||
// npm → git → tarball → local. Runtimes / named-registry slot
|
||||
// in as those crates land; `LocalResolver` covers both the
|
||||
// scheme branch (where upstream interleaves it between tarball
|
||||
// and runtimes) and the path branch (last in upstream's chain)
|
||||
// because no intermediate resolvers exist yet that would split
|
||||
// the two — when named-registry lands the two halves split
|
||||
// into separate trait impls.
|
||||
let resolver: Box<dyn Resolver> = Box::new(DefaultResolver::new(vec![
|
||||
Box::new(npm_resolver),
|
||||
Box::new(git_resolver),
|
||||
Box::new(tarball_resolver),
|
||||
Box::new(local_resolver),
|
||||
]));
|
||||
|
||||
// Compile `minimumReleaseAge` (and its exclude pattern set)
|
||||
|
||||
36
pacquet/crates/resolving-local-resolver/Cargo.toml
Normal file
36
pacquet/crates/resolving-local-resolver/Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "pacquet-resolving-local-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]
|
||||
pacquet-lockfile = { workspace = true }
|
||||
pacquet-package-manifest = { workspace = true }
|
||||
pacquet-resolving-resolver-base = { workspace = true }
|
||||
|
||||
derive_more = { workspace = true }
|
||||
home = { workspace = true }
|
||||
miette = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
ssri = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "io-util", "rt"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pacquet-resolving-default-resolver = { workspace = true }
|
||||
pacquet-testing-utils = { workspace = true }
|
||||
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
109
pacquet/crates/resolving-local-resolver/src/chain.rs
Normal file
109
pacquet/crates/resolving-local-resolver/src/chain.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
//! Chain-friendly wrapper that implements
|
||||
//! [`pacquet_resolving_resolver_base::Resolver`] over the free
|
||||
//! functions in [`super::local_resolver`].
|
||||
//!
|
||||
//! Equivalent to upstream's
|
||||
//! [`resolveSchemeOrPath`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/default-resolver/src/index.ts#L97-L173)
|
||||
//! step inside `createResolver`: try the scheme-prefix interpretation
|
||||
//! first; fall through to the path-shape interpretation; defer
|
||||
//! (`Ok(None)`) when neither claims.
|
||||
|
||||
use pacquet_resolving_resolver_base::{
|
||||
LatestQuery, ResolveError, ResolveFuture, ResolveLatestFuture, ResolveOptions, ResolveResult,
|
||||
Resolver, UpdateBehavior, WantedDependency,
|
||||
};
|
||||
|
||||
use crate::local_resolver::{
|
||||
LocalResolverContext, LocalResolverOptions, LocalResolverUpdate, resolve_from_local_path,
|
||||
resolve_from_local_scheme, resolve_latest_from_local,
|
||||
};
|
||||
use crate::parse_bare_specifier::WantedLocalDependency;
|
||||
|
||||
/// `Resolver`-trait wrapper that the default-resolver chain consumes.
|
||||
/// Holds the install-scoped [`LocalResolverContext`] (just
|
||||
/// `preserveAbsolutePaths` today); the per-resolve `project_dir` /
|
||||
/// `lockfile_dir` / `update` come from [`ResolveOptions`].
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LocalResolver {
|
||||
pub ctx: LocalResolverContext,
|
||||
}
|
||||
|
||||
impl LocalResolver {
|
||||
pub fn new(ctx: LocalResolverContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolver for LocalResolver {
|
||||
fn resolve<'a>(
|
||||
&'a self,
|
||||
wanted_dependency: &'a WantedDependency,
|
||||
opts: &'a ResolveOptions,
|
||||
) -> ResolveFuture<'a> {
|
||||
Box::pin(self.resolve_impl(wanted_dependency, opts))
|
||||
}
|
||||
|
||||
fn resolve_latest<'a>(
|
||||
&'a self,
|
||||
query: &'a LatestQuery,
|
||||
_opts: &'a ResolveOptions,
|
||||
) -> ResolveLatestFuture<'a> {
|
||||
let info = resolve_latest_from_local(query);
|
||||
Box::pin(async move { Ok(info) })
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalResolver {
|
||||
async fn resolve_impl(
|
||||
&self,
|
||||
wanted_dependency: &WantedDependency,
|
||||
opts: &ResolveOptions,
|
||||
) -> Result<Option<ResolveResult>, ResolveError> {
|
||||
let Some(bare) = wanted_dependency.bare_specifier.clone() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let wd = WantedLocalDependency {
|
||||
bare_specifier: bare,
|
||||
injected: wanted_dependency.injected.unwrap_or(false),
|
||||
};
|
||||
let local_opts = LocalResolverOptions {
|
||||
project_dir: opts.project_dir.clone(),
|
||||
lockfile_dir: Some(opts.lockfile_dir.clone()),
|
||||
current_pkg: None,
|
||||
update: match opts.update {
|
||||
UpdateBehavior::Off => LocalResolverUpdate::Off,
|
||||
UpdateBehavior::Compatible | UpdateBehavior::Latest => LocalResolverUpdate::On,
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(result) = resolve_from_local_scheme(&self.ctx, &wd, &local_opts)
|
||||
.await
|
||||
.map_err(|err| Box::new(err) as ResolveError)?
|
||||
{
|
||||
return Ok(Some(into_chain_result(result, wanted_dependency)));
|
||||
}
|
||||
|
||||
if let Some(result) = resolve_from_local_path(&self.ctx, &wd, &local_opts)
|
||||
.await
|
||||
.map_err(|err| Box::new(err) as ResolveError)?
|
||||
{
|
||||
return Ok(Some(into_chain_result(result, wanted_dependency)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread the alias from the [`WantedDependency`] onto the chain
|
||||
/// result so the install layer can address the resolved package by
|
||||
/// the manifest key. Mirrors upstream's
|
||||
/// [`alias: wantedDep.alias`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/default-resolver/src/index.ts#L123)
|
||||
/// thread.
|
||||
fn into_chain_result(
|
||||
result: crate::local_resolver::LocalResolveResult,
|
||||
wanted_dependency: &WantedDependency,
|
||||
) -> ResolveResult {
|
||||
let mut chain: ResolveResult = result.into();
|
||||
chain.alias = wanted_dependency.alias.clone();
|
||||
chain
|
||||
}
|
||||
38
pacquet/crates/resolving-local-resolver/src/lib.rs
Normal file
38
pacquet/crates/resolving-local-resolver/src/lib.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
//! Pacquet port of pnpm's
|
||||
//! [`@pnpm/resolving.local-resolver`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts).
|
||||
//!
|
||||
//! Resolves `file:`, `link:`, `workspace:`, and bare filesystem
|
||||
//! specifiers — the four shapes the install layer can satisfy from
|
||||
//! the project tree rather than a registry or git host. The fetch
|
||||
//! side for the directory case lives in `pacquet-directory-fetcher`;
|
||||
//! this crate is resolution-only.
|
||||
//!
|
||||
//! Three public entry points mirror upstream's:
|
||||
//!
|
||||
//! - [`resolve_from_local_scheme`] — claims a wanted dep iff its bare
|
||||
//! specifier starts with `link:`, `workspace:`, or `file:`. The
|
||||
//! `path:` prefix is rejected with [`PathProtocolNotSupportedError`]
|
||||
//! to match upstream's
|
||||
//! [`PATH_IS_UNSUPPORTED_PROTOCOL`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L29-L34)
|
||||
//! error.
|
||||
//! - [`resolve_from_local_path`] — claims a wanted dep purely by path
|
||||
//! shape (relative path, absolute path, drive letter, tarball
|
||||
//! filename). Bare-specifier dispatchers run this *after*
|
||||
//! [`resolve_from_local_scheme`] so scheme prefixes don't slip
|
||||
//! through.
|
||||
//! - [`resolve_latest_from_local`] — declines (returns
|
||||
//! `Ok(LatestInfo::default())`) for `link:` / `file:` /
|
||||
//! `workspace:` specs so they don't accidentally route to a
|
||||
//! named-registry alias named `link` / `file` / `workspace`.
|
||||
|
||||
mod chain;
|
||||
mod local_resolver;
|
||||
mod parse_bare_specifier;
|
||||
|
||||
pub use chain::LocalResolver;
|
||||
pub use local_resolver::{
|
||||
LocalCurrentPkg, LocalResolveResult, LocalResolverContext, LocalResolverOptions,
|
||||
LocalResolverUpdate, LocalSpecError, ResolveLocalError, resolve_from_local_path,
|
||||
resolve_from_local_scheme, resolve_latest_from_local,
|
||||
};
|
||||
pub use parse_bare_specifier::{PathProtocolNotSupportedError, WantedLocalDependency};
|
||||
371
pacquet/crates/resolving-local-resolver/src/local_resolver.rs
Normal file
371
pacquet/crates/resolving-local-resolver/src/local_resolver.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
//! Port of pnpm's
|
||||
//! [`resolving/local-resolver/src/index.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts).
|
||||
//!
|
||||
//! Three free functions match upstream's exported entry points; they
|
||||
//! all funnel into [`resolve_spec`], which handles the tarball
|
||||
//! integrity / directory manifest reading once a [`LocalPackageSpec`]
|
||||
//! has been chosen.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_lockfile::{DirectoryResolution, LockfileResolution, TarballResolution};
|
||||
use pacquet_package_manifest::{PackageManifestError, safe_read_package_json_from_dir};
|
||||
use pacquet_resolving_resolver_base::{LatestInfo, LatestQuery, PkgResolutionId, ResolveResult};
|
||||
use ssri::{Algorithm, Integrity, IntegrityOpts};
|
||||
|
||||
use crate::parse_bare_specifier::{
|
||||
LocalPackageSpec, LocalSpecKind, ParseOptions, PathProtocolNotSupportedError,
|
||||
WantedLocalDependency, parse_local_path, parse_local_scheme,
|
||||
};
|
||||
|
||||
/// Per-install knobs the dispatcher threads into every resolver call.
|
||||
/// Mirrors upstream's
|
||||
/// [`LocalResolverContext`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L22-L24).
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LocalResolverContext {
|
||||
/// When `true`, an absolute path in the wanted specifier is
|
||||
/// preserved in the resolved `id` rather than being relativised
|
||||
/// against the project / lockfile root. Mirrors pnpm's
|
||||
/// `preserveAbsolutePaths` config (off by default).
|
||||
pub preserve_absolute_paths: bool,
|
||||
}
|
||||
|
||||
/// Per-call options. Mirrors upstream's
|
||||
/// [`LocalResolverOptions`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L26-L34).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalResolverOptions {
|
||||
pub project_dir: PathBuf,
|
||||
/// Lockfile root. Defaults to `project_dir` when `None` — mirrors
|
||||
/// upstream's `opts.lockfileDir ?? opts.projectDir` fallback.
|
||||
pub lockfile_dir: Option<PathBuf>,
|
||||
/// Previously resolved entry from the lockfile, threaded so the
|
||||
/// resolver can short-circuit directory resolution when the
|
||||
/// install isn't asking for an update. Mirrors upstream's
|
||||
/// `currentPkg` field.
|
||||
pub current_pkg: Option<LocalCurrentPkg>,
|
||||
/// `false` keeps the lockfile pin. Mirrors upstream's
|
||||
/// `update?: false | 'compatible' | 'latest'` tri-state, but
|
||||
/// pacquet collapses the two truthy values because the local
|
||||
/// resolver only branches on truthy / falsy.
|
||||
pub update: LocalResolverUpdate,
|
||||
}
|
||||
|
||||
/// Lockfile-pinned slice the local resolver short-circuits on for
|
||||
/// directory deps when no update is requested. Mirrors upstream's
|
||||
/// inline `currentPkg?.{ id, resolution }`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalCurrentPkg {
|
||||
pub id: PkgResolutionId,
|
||||
pub resolution: LockfileResolution,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalResolverUpdate {
|
||||
/// Keep the lockfile pin.
|
||||
#[default]
|
||||
Off,
|
||||
/// Re-resolve. Pnpm uses two truthy values (`'compatible'` and
|
||||
/// `'latest'`); the local resolver treats both identically.
|
||||
On,
|
||||
}
|
||||
|
||||
/// Outcome of a successful local resolve. Mirrors upstream's
|
||||
/// [`LocalResolveResult`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L15-L20).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalResolveResult {
|
||||
pub id: PkgResolutionId,
|
||||
pub manifest: Option<serde_json::Value>,
|
||||
pub normalized_bare_specifier: Option<String>,
|
||||
pub resolution: LockfileResolution,
|
||||
/// `local-filesystem` — the same `resolvedVia` tag upstream uses
|
||||
/// across every shape this resolver produces.
|
||||
pub resolved_via: &'static str,
|
||||
}
|
||||
|
||||
impl From<LocalResolveResult> for ResolveResult {
|
||||
fn from(result: LocalResolveResult) -> Self {
|
||||
ResolveResult {
|
||||
id: result.id,
|
||||
// Local resolutions don't have a `name@version` shape —
|
||||
// the canonical name lives in the fetched manifest, not
|
||||
// the resolver-time signal. Leave `name_ver` empty so
|
||||
// downstream consumers fall back to reading
|
||||
// `result.manifest`.
|
||||
name_ver: None,
|
||||
latest: None,
|
||||
published_at: None,
|
||||
manifest: result.manifest,
|
||||
resolution: result.resolution,
|
||||
resolved_via: result.resolved_via.to_string(),
|
||||
normalized_bare_specifier: result.normalized_bare_specifier,
|
||||
alias: None,
|
||||
policy_violation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned when bare-specifier parsing itself fails (today:
|
||||
/// the `path:` protocol case). Surfaces as
|
||||
/// [`PathProtocolNotSupportedError`] downstream.
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
pub enum LocalSpecError {
|
||||
PathProtocolNotSupported(#[error(source)] PathProtocolNotSupportedError),
|
||||
}
|
||||
|
||||
/// Aggregate error type returned by the three public entry points.
|
||||
/// Mirrors the three branches upstream's `resolveSpec` raises.
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
pub enum ResolveLocalError {
|
||||
/// The wanted specifier carries an unsupported scheme. Today only
|
||||
/// `path:`; reserved for future additions.
|
||||
Spec(#[error(source)] LocalSpecError),
|
||||
|
||||
/// `file:` directory or tarball points at a path that doesn't
|
||||
/// exist. Mirrors pnpm's
|
||||
/// [`LINKED_PKG_DIR_NOT_FOUND`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L111-L114).
|
||||
#[display("Could not install from \"{path}\" as it does not exist.")]
|
||||
#[diagnostic(code(LINKED_PKG_DIR_NOT_FOUND))]
|
||||
LinkedPkgDirNotFound {
|
||||
#[error(not(source))]
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// `<spec.fetchSpec>` exists but isn't a directory (ENOTDIR).
|
||||
/// Mirrors pnpm's
|
||||
/// [`NOT_PACKAGE_DIRECTORY`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L127-L129).
|
||||
#[display("Could not install from \"{path}\" as it is not a directory.")]
|
||||
#[diagnostic(code(NOT_PACKAGE_DIRECTORY))]
|
||||
NotPackageDirectory {
|
||||
#[error(not(source))]
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Tarball integrity computation failed for a `file:` spec.
|
||||
Integrity(#[error(source)] std::io::Error),
|
||||
|
||||
/// Reading `<spec.fetchSpec>/package.json` raised something the
|
||||
/// resolver doesn't have a specific code for (malformed JSON,
|
||||
/// permission denied, …).
|
||||
ReadManifest(#[error(source)] PackageManifestError),
|
||||
}
|
||||
|
||||
/// Resolve a wanted dep declared with an explicit local scheme.
|
||||
/// Mirrors pnpm's
|
||||
/// [`resolveFromLocalScheme`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L40-L49).
|
||||
pub async fn resolve_from_local_scheme(
|
||||
ctx: &LocalResolverContext,
|
||||
wanted_dependency: &WantedLocalDependency,
|
||||
opts: &LocalResolverOptions,
|
||||
) -> Result<Option<LocalResolveResult>, ResolveLocalError> {
|
||||
let project_dir = opts.project_dir.as_path();
|
||||
let lockfile_dir = opts.lockfile_dir.as_deref().unwrap_or(project_dir);
|
||||
let parse_opts = ParseOptions { preserve_absolute_paths: ctx.preserve_absolute_paths };
|
||||
let spec = match parse_local_scheme(wanted_dependency, project_dir, lockfile_dir, parse_opts) {
|
||||
Ok(maybe) => maybe,
|
||||
Err(err) => {
|
||||
return Err(ResolveLocalError::Spec(LocalSpecError::PathProtocolNotSupported(err)));
|
||||
}
|
||||
};
|
||||
resolve_spec(spec, opts).await
|
||||
}
|
||||
|
||||
/// Resolve a wanted dep by path shape alone — no scheme prefix.
|
||||
/// Mirrors pnpm's
|
||||
/// [`resolveFromLocalPath`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L51-L65).
|
||||
pub async fn resolve_from_local_path(
|
||||
ctx: &LocalResolverContext,
|
||||
wanted_dependency: &WantedLocalDependency,
|
||||
opts: &LocalResolverOptions,
|
||||
) -> Result<Option<LocalResolveResult>, ResolveLocalError> {
|
||||
let project_dir = opts.project_dir.as_path();
|
||||
let lockfile_dir = opts.lockfile_dir.as_deref().unwrap_or(project_dir);
|
||||
let parse_opts = ParseOptions { preserve_absolute_paths: ctx.preserve_absolute_paths };
|
||||
let spec = parse_local_path(wanted_dependency, project_dir, lockfile_dir, parse_opts);
|
||||
resolve_spec(spec, opts).await
|
||||
}
|
||||
|
||||
/// Latest-version companion. Mirrors pnpm's
|
||||
/// [`resolveLatestFromLocal`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L67-L77):
|
||||
/// claims `link:` / `file:` / `workspace:` specs with an empty
|
||||
/// [`LatestInfo`] so the dispatcher stops there instead of routing
|
||||
/// the dep into a user-configured named-registry alias of the same
|
||||
/// name.
|
||||
pub fn resolve_latest_from_local(query: &LatestQuery) -> Option<LatestInfo> {
|
||||
let bare = query.wanted_dependency.bare_specifier.as_deref()?;
|
||||
if bare.starts_with("link:") || bare.starts_with("file:") || bare.starts_with("workspace:") {
|
||||
return Some(LatestInfo::default());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn resolve_spec(
|
||||
spec: Option<LocalPackageSpec>,
|
||||
opts: &LocalResolverOptions,
|
||||
) -> Result<Option<LocalResolveResult>, ResolveLocalError> {
|
||||
let Some(spec) = spec else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if matches!(spec.kind, LocalSpecKind::File) {
|
||||
// A missing tarball file raises the same `LINKED_PKG_DIR_NOT_FOUND`
|
||||
// code the directory branch uses for a missing `file:` target —
|
||||
// matches upstream's behavior where `getTarballIntegrity` raises
|
||||
// ENOENT and the same catch in
|
||||
// [`resolveSpec`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L108-L141)
|
||||
// routes both kinds of missing `file:` target through one
|
||||
// pnpm-compatible error code.
|
||||
let integrity = match compute_tarball_integrity(&spec.fetch_spec).await {
|
||||
Ok(integrity) => integrity,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(ResolveLocalError::LinkedPkgDirNotFound {
|
||||
path: spec.fetch_spec.display().to_string(),
|
||||
});
|
||||
}
|
||||
Err(err) => return Err(ResolveLocalError::Integrity(err)),
|
||||
};
|
||||
return Ok(Some(LocalResolveResult {
|
||||
id: spec.id.clone(),
|
||||
manifest: None,
|
||||
normalized_bare_specifier: Some(spec.normalized_bare_specifier),
|
||||
resolution: LockfileResolution::Tarball(TarballResolution {
|
||||
tarball: spec.id.as_str().to_string(),
|
||||
integrity: Some(integrity),
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
}),
|
||||
resolved_via: "local-filesystem",
|
||||
}));
|
||||
}
|
||||
|
||||
// Directory branch. Short-circuit when the lockfile already has
|
||||
// a pin and the install isn't asking for an update — mirrors
|
||||
// upstream's
|
||||
// [`opts.currentPkg.resolution && spec.type === 'directory' && !opts.update`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L99-L104).
|
||||
if let Some(current) = &opts.current_pkg
|
||||
&& opts.update == LocalResolverUpdate::Off
|
||||
{
|
||||
return Ok(Some(LocalResolveResult {
|
||||
id: current.id.clone(),
|
||||
manifest: None,
|
||||
normalized_bare_specifier: Some(spec.normalized_bare_specifier),
|
||||
resolution: current.resolution.clone(),
|
||||
resolved_via: "local-filesystem",
|
||||
}));
|
||||
}
|
||||
|
||||
let manifest = match safe_read_package_json_from_dir(&spec.fetch_spec) {
|
||||
Ok(Some(manifest)) => manifest,
|
||||
Ok(None) => synthesize_fallback_manifest(&spec, opts)?,
|
||||
Err(err) => return Err(handle_manifest_read_failure(err, &spec)),
|
||||
};
|
||||
|
||||
Ok(Some(LocalResolveResult {
|
||||
id: spec.id.clone(),
|
||||
manifest: Some(manifest),
|
||||
normalized_bare_specifier: Some(spec.normalized_bare_specifier),
|
||||
resolution: LockfileResolution::Directory(DirectoryResolution {
|
||||
directory: spec.dependency_path,
|
||||
}),
|
||||
resolved_via: "local-filesystem",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Decide the fall-back when `package.json` is missing. For `file:`
|
||||
/// specs (copy-shaped) upstream throws `LINKED_PKG_DIR_NOT_FOUND` when
|
||||
/// the directory itself doesn't exist; for `link:` and missing
|
||||
/// `package.json` it warns and substitutes a manifest with the
|
||||
/// directory basename and `version: '0.0.0'`. Mirrors upstream's
|
||||
/// [`existsSync(spec.fetchSpec)` branch](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L108-L141).
|
||||
fn synthesize_fallback_manifest(
|
||||
spec: &LocalPackageSpec,
|
||||
opts: &LocalResolverOptions,
|
||||
) -> Result<serde_json::Value, ResolveLocalError> {
|
||||
let metadata = std::fs::metadata(&spec.fetch_spec);
|
||||
if matches!(&metadata, Err(err) if err.kind() == std::io::ErrorKind::NotFound) {
|
||||
if spec.id.as_str().starts_with("file:") {
|
||||
return Err(ResolveLocalError::LinkedPkgDirNotFound {
|
||||
path: spec.fetch_spec.display().to_string(),
|
||||
});
|
||||
}
|
||||
// Match upstream's `logger.warn({ message, prefix })` emit
|
||||
// via `tracing::warn!` until pacquet's reporter grows a
|
||||
// generic `pnpm:logger` channel. Same payload shape.
|
||||
let prefix = opts.project_dir.display();
|
||||
let fetch_spec = spec.fetch_spec.display();
|
||||
tracing::warn!(
|
||||
target: "pacquet::resolving-local-resolver",
|
||||
prefix = %prefix,
|
||||
"Installing a dependency from a non-existent directory: {fetch_spec}",
|
||||
);
|
||||
} else if let Ok(metadata) = metadata
|
||||
&& !metadata.is_dir()
|
||||
{
|
||||
// The path exists but isn't a directory (e.g. a `.tgz` that
|
||||
// slipped past tarball-shape detection because the spec used
|
||||
// the `link:` scheme). Upstream raises ENOTDIR explicitly on
|
||||
// Windows — where `read(<file>/package.json)` returns
|
||||
// `NotFound` rather than `NotADirectory` — inside
|
||||
// [`readProjectManifestOnly`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/workspace/project-manifest-reader/src/index.ts#L100-L114).
|
||||
// Pacquet does the equivalent check here so the resolver
|
||||
// surfaces `NOT_PACKAGE_DIRECTORY` on every platform.
|
||||
return Err(ResolveLocalError::NotPackageDirectory {
|
||||
path: spec.fetch_spec.display().to_string(),
|
||||
});
|
||||
}
|
||||
let name = spec
|
||||
.fetch_spec
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
Ok(serde_json::json!({ "name": name, "version": "0.0.0" }))
|
||||
}
|
||||
|
||||
/// Map a [`PackageManifestError`] from
|
||||
/// [`safe_read_package_json_from_dir`] into the resolver's error
|
||||
/// surface. Upstream's catch block dispatches on the inner code:
|
||||
/// `ENOTDIR` → `NOT_PACKAGE_DIRECTORY`, `ENOENT` →
|
||||
/// fall-back manifest, anything else → re-throw.
|
||||
fn handle_manifest_read_failure(
|
||||
err: PackageManifestError,
|
||||
spec: &LocalPackageSpec,
|
||||
) -> ResolveLocalError {
|
||||
if let PackageManifestError::Io(io_err) = &err {
|
||||
match io_err.kind() {
|
||||
std::io::ErrorKind::NotADirectory => {
|
||||
return ResolveLocalError::NotPackageDirectory {
|
||||
path: spec.fetch_spec.display().to_string(),
|
||||
};
|
||||
}
|
||||
std::io::ErrorKind::NotFound => {
|
||||
// Mirrors upstream's ENOENT fall-through: synthesize
|
||||
// the placeholder manifest. The caller is responsible
|
||||
// for wrapping this back into a `ResolveLocalError`
|
||||
// since the public API surface is fallible only.
|
||||
// Handled by `synthesize_fallback_manifest` already
|
||||
// — Ok(None) from `safe_read_package_json_from_dir`
|
||||
// hits that branch first, so reaching this arm means
|
||||
// a directory was renamed mid-read; pacquet treats it
|
||||
// as a hard failure rather than racing back into the
|
||||
// fall-back path.
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ResolveLocalError::ReadManifest(err)
|
||||
}
|
||||
|
||||
/// Port of pnpm's
|
||||
/// [`getTarballIntegrity`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/crypto/hash/src/index.ts#L40-L42)
|
||||
/// — computes the SSRI integrity hash for a local tarball. Upstream
|
||||
/// streams the file through ssri; pacquet reads the whole file and
|
||||
/// feeds it to ssri's incremental hasher in one shot. Tarballs in the
|
||||
/// `file:` install path are typically a few MB so the simpler shape
|
||||
/// has no measurable cost.
|
||||
async fn compute_tarball_integrity(path: &std::path::Path) -> std::io::Result<Integrity> {
|
||||
let bytes = tokio::fs::read(path).await?;
|
||||
let mut opts = IntegrityOpts::new().algorithm(Algorithm::Sha512);
|
||||
opts.input(&bytes);
|
||||
Ok(opts.result())
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Port of pnpm's
|
||||
//! [`parseBareSpecifier.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts).
|
||||
//!
|
||||
//! Decides whether a wanted dep is a local-filesystem shape (and which
|
||||
//! protocol — `link:` vs `file:`) and builds the [`LocalPackageSpec`]
|
||||
//! the resolver consumes.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use pacquet_resolving_resolver_base::PkgResolutionId;
|
||||
|
||||
/// The wanted-dependency slice the local resolver consumes. Mirrors
|
||||
/// pnpm's
|
||||
/// [`WantedLocalDependency`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L21-L24).
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct WantedLocalDependency {
|
||||
pub bare_specifier: String,
|
||||
/// `dependenciesMeta[*].injected` for this entry. When set on a
|
||||
/// directory dep the resolver picks `file:` (copy semantics)
|
||||
/// instead of `link:` (symlink semantics).
|
||||
pub injected: bool,
|
||||
}
|
||||
|
||||
/// Parsed local-spec the resolver chain consumes. Mirrors pnpm's
|
||||
/// [`LocalPackageSpec`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L14-L20).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct LocalPackageSpec {
|
||||
/// Where the directory will be addressed from inside the lockfile.
|
||||
/// For directories: a normalized path string (relative to the
|
||||
/// lockfile dir for injected file:, absolute for link:).
|
||||
pub dependency_path: String,
|
||||
/// Absolute path the resolver actually inspects (the location of
|
||||
/// `package.json` for directories, the tarball file for files).
|
||||
pub fetch_spec: PathBuf,
|
||||
/// `PkgResolutionId` upstream calls this — the branded identifier
|
||||
/// the install layer uses to dedupe and key into the lockfile.
|
||||
/// Formatted as `<protocol><normalized-path>`.
|
||||
pub id: PkgResolutionId,
|
||||
pub kind: LocalSpecKind,
|
||||
/// Normalized echo of the bare specifier (with the chosen
|
||||
/// protocol prefix). The dispatcher writes this back to the
|
||||
/// manifest spec when `add` / `update` runs.
|
||||
pub normalized_bare_specifier: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum LocalSpecKind {
|
||||
Directory,
|
||||
File,
|
||||
}
|
||||
|
||||
/// Options shared by [`parse_local_scheme`] and [`parse_local_path`].
|
||||
/// Mirrors upstream's
|
||||
/// [`{ preserveAbsolutePaths }`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L40-L44)
|
||||
/// option bag.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub(crate) struct ParseOptions {
|
||||
pub preserve_absolute_paths: bool,
|
||||
}
|
||||
|
||||
/// `path:` is rejected so users get the same nudge they'd get from
|
||||
/// pnpm. Mirrors upstream's
|
||||
/// [`PathIsUnsupportedProtocolError`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L27-L36).
|
||||
#[derive(Debug, Display, Error, Diagnostic, Clone)]
|
||||
#[display(
|
||||
"Local dependencies via `path:` protocol are not supported. \
|
||||
Use the `link:` protocol for folder dependencies and `file:` for local tarballs"
|
||||
)]
|
||||
#[diagnostic(code(PATH_IS_UNSUPPORTED_PROTOCOL))]
|
||||
pub struct PathProtocolNotSupportedError {
|
||||
pub bare_specifier: String,
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
/// Parse a wanted dep with an explicit local-scheme prefix
|
||||
/// (`link:` / `workspace:` / `file:`). Returns `Ok(None)` when the
|
||||
/// specifier doesn't carry one of those prefixes; returns
|
||||
/// `Err(PathProtocolNotSupportedError)` for `path:`.
|
||||
///
|
||||
/// Mirrors upstream's
|
||||
/// [`parseLocalScheme`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L38-L55).
|
||||
pub(crate) fn parse_local_scheme(
|
||||
wd: &WantedLocalDependency,
|
||||
project_dir: &Path,
|
||||
lockfile_dir: &Path,
|
||||
opts: ParseOptions,
|
||||
) -> Result<Option<LocalPackageSpec>, PathProtocolNotSupportedError> {
|
||||
let bare = wd.bare_specifier.as_str();
|
||||
if bare.starts_with("link:") || bare.starts_with("workspace:") {
|
||||
return Ok(Some(from_local(wd, project_dir, lockfile_dir, LocalSpecKind::Directory, opts)));
|
||||
}
|
||||
if bare.starts_with("file:") {
|
||||
let kind =
|
||||
if is_tarball_filename(bare) { LocalSpecKind::File } else { LocalSpecKind::Directory };
|
||||
return Ok(Some(from_local(wd, project_dir, lockfile_dir, kind, opts)));
|
||||
}
|
||||
if let Some(rest) = bare.strip_prefix("path:") {
|
||||
let _ = rest;
|
||||
return Err(PathProtocolNotSupportedError {
|
||||
bare_specifier: bare.to_string(),
|
||||
protocol: "path:".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Parse a wanted dep by path shape alone — no scheme prefix. The
|
||||
/// dispatcher calls this *after* [`parse_local_scheme`] so explicit
|
||||
/// `link:`/`file:`/`workspace:`/`path:` prefixes don't slip through.
|
||||
///
|
||||
/// Mirrors upstream's
|
||||
/// [`parseLocalPath`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L57-L73).
|
||||
pub(crate) fn parse_local_path(
|
||||
wd: &WantedLocalDependency,
|
||||
project_dir: &Path,
|
||||
lockfile_dir: &Path,
|
||||
opts: ParseOptions,
|
||||
) -> Option<LocalPackageSpec> {
|
||||
let bare = wd.bare_specifier.as_str();
|
||||
if is_tarball_filename(bare) || contains_path_sep(bare) || is_filespec(bare) {
|
||||
let kind =
|
||||
if is_tarball_filename(bare) { LocalSpecKind::File } else { LocalSpecKind::Directory };
|
||||
return Some(from_local(wd, project_dir, lockfile_dir, kind, opts));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the final [`LocalPackageSpec`] from a wanted dep that has
|
||||
/// already been claimed by either entry point.
|
||||
///
|
||||
/// Mirrors upstream's
|
||||
/// [`fromLocal`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L75-L136).
|
||||
fn from_local(
|
||||
wd: &WantedLocalDependency,
|
||||
project_dir: &Path,
|
||||
lockfile_dir: &Path,
|
||||
kind: LocalSpecKind,
|
||||
opts: ParseOptions,
|
||||
) -> LocalPackageSpec {
|
||||
let bare = wd.bare_specifier.as_str();
|
||||
let spec = normalize_specifier(bare);
|
||||
|
||||
let protocol: &'static str = if bare.starts_with("file:") {
|
||||
"file:"
|
||||
} else if bare.starts_with("link:")
|
||||
|| (matches!(kind, LocalSpecKind::Directory) && !wd.injected)
|
||||
{
|
||||
"link:"
|
||||
} else {
|
||||
"file:"
|
||||
};
|
||||
|
||||
let (fetch_spec, normalized_bare_specifier) = if let Some(rest) = strip_tilde_prefix(&spec) {
|
||||
let home = home::home_dir().unwrap_or_default();
|
||||
let fetched = resolve_path(&home, rest);
|
||||
let normalized = format!("{protocol}{spec}");
|
||||
(fetched, normalized)
|
||||
} else {
|
||||
let fetched = resolve_path(project_dir, &spec);
|
||||
if is_absolute_specifier(&spec) {
|
||||
(fetched, format!("{protocol}{spec}"))
|
||||
} else {
|
||||
let relative = forward_slashes(
|
||||
pathdiff::diff_paths(&fetched, project_dir)
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| fetched.display().to_string()),
|
||||
);
|
||||
let fetch_spec = fetched;
|
||||
(fetch_spec, format!("{protocol}{relative}"))
|
||||
}
|
||||
};
|
||||
|
||||
// After upstream's `protocol = type === 'directory' && !injected ? 'link:' : 'file:'` step,
|
||||
// upstream re-uses the `injected` variable to mean "is the dep
|
||||
// copy-shaped" (`protocol === 'file:'`) for the dependencyPath /
|
||||
// id calculations below. Match the rebind explicitly so the next
|
||||
// few lines read identically.
|
||||
let copy_shaped = protocol == "file:";
|
||||
|
||||
let dependency_path = if copy_shaped {
|
||||
normalize_relative_or_absolute(lockfile_dir, &fetch_spec, &spec, opts)
|
||||
} else {
|
||||
forward_slashes(fetch_spec.display().to_string())
|
||||
};
|
||||
|
||||
let id_value = if !copy_shaped
|
||||
&& (matches!(kind, LocalSpecKind::Directory) || project_dir == lockfile_dir)
|
||||
{
|
||||
format!(
|
||||
"{protocol}{}",
|
||||
normalize_relative_or_absolute(project_dir, &fetch_spec, &spec, opts),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{protocol}{}",
|
||||
normalize_relative_or_absolute(lockfile_dir, &fetch_spec, &spec, opts),
|
||||
)
|
||||
};
|
||||
|
||||
LocalPackageSpec {
|
||||
dependency_path,
|
||||
fetch_spec,
|
||||
id: PkgResolutionId::from(id_value),
|
||||
kind,
|
||||
normalized_bare_specifier,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror upstream's
|
||||
/// [`bareSpecifier.replace(...)`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L82-L84)
|
||||
/// chain:
|
||||
///
|
||||
/// 1. Replace all `\` with `/`.
|
||||
/// 2. Drive-letter prefix: `^(file|link|workspace):/*([A-Z]:)` → `$1`.
|
||||
/// 3. `^(file|link|workspace):(?:/*([~./]))?` → `$1`. The captured
|
||||
/// char class **includes `/`**, so a leading slash after the
|
||||
/// protocol survives (collapsed to a single one).
|
||||
fn normalize_specifier(bare: &str) -> String {
|
||||
let forward = bare.replace('\\', "/");
|
||||
let Some(after_proto) =
|
||||
["file:", "link:", "workspace:"].iter().find_map(|proto| forward.strip_prefix(proto))
|
||||
else {
|
||||
return forward;
|
||||
};
|
||||
let after_slashes = after_proto.trim_start_matches('/');
|
||||
if is_drive_letter_prefix(after_slashes) {
|
||||
return after_slashes.to_string();
|
||||
}
|
||||
match after_proto.chars().next() {
|
||||
Some('/') => {
|
||||
let trimmed = after_slashes;
|
||||
if let Some(c) = trimmed.chars().next()
|
||||
&& matches!(c, '~' | '.')
|
||||
{
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
let mut result = String::with_capacity(trimmed.len() + 1);
|
||||
result.push('/');
|
||||
result.push_str(trimmed);
|
||||
result
|
||||
}
|
||||
}
|
||||
_ => after_proto.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `spec` against `where_dir`, mirroring Node's
|
||||
/// [`path.resolve`](https://nodejs.org/api/path.html#pathresolvepaths)
|
||||
/// behavior: an absolute `spec` is returned unchanged; otherwise the
|
||||
/// host's path resolver joins the two and canonicalises the result.
|
||||
fn resolve_path(where_dir: &Path, spec: &str) -> PathBuf {
|
||||
if is_absolute_specifier(spec) {
|
||||
return PathBuf::from(spec);
|
||||
}
|
||||
let mut joined = where_dir.to_path_buf();
|
||||
joined.push(spec);
|
||||
normalize_components(&joined)
|
||||
}
|
||||
|
||||
/// Collapse `.` and `..` components the way Node's `path.resolve`
|
||||
/// does (purely lexically — no syscalls). Preserves the absolute /
|
||||
/// relative distinction of the input.
|
||||
fn normalize_components(path: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
out.push("..");
|
||||
}
|
||||
}
|
||||
other => out.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `normalizeRelativeOrAbsolute` from upstream
|
||||
/// [`fromLocal`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/parseBareSpecifier.ts#L109-L117).
|
||||
/// When `preserveAbsolutePaths` is on and the input spec is absolute,
|
||||
/// the result keeps the absolute form (slash-normalised); otherwise
|
||||
/// the result is relative to `relative_to`.
|
||||
fn normalize_relative_or_absolute(
|
||||
relative_to: &Path,
|
||||
from_path: &Path,
|
||||
original_spec: &str,
|
||||
opts: ParseOptions,
|
||||
) -> String {
|
||||
if opts.preserve_absolute_paths && is_absolute_specifier(original_spec) {
|
||||
return forward_slashes(from_path.display().to_string());
|
||||
}
|
||||
let relative = pathdiff::diff_paths(from_path, relative_to)
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| from_path.display().to_string());
|
||||
forward_slashes(relative)
|
||||
}
|
||||
|
||||
fn forward_slashes(input: String) -> String {
|
||||
if input.contains('\\') { input.replace('\\', "/") } else { input }
|
||||
}
|
||||
|
||||
/// Match upstream's `isAbsolutePath` regex (`/^\/|^[A-Z]:/i`).
|
||||
fn is_absolute_specifier(spec: &str) -> bool {
|
||||
let mut chars = spec.chars();
|
||||
match chars.next() {
|
||||
Some('/') => true,
|
||||
Some(c) if c.is_ascii_alphabetic() => chars.next() == Some(':'),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Match upstream's `isFilespec` regex:
|
||||
/// - Windows: `/^(?:[./\\]|~\/|[a-z]:)/i`
|
||||
/// - POSIX: `/^(?:[./]|~\/|[a-z]:)/i`
|
||||
///
|
||||
/// Implemented uniformly because pacquet doesn't need the `\\`
|
||||
/// alternative outside the bigger normalize step (`is_filespec` is
|
||||
/// only consulted on already-forward-slashed paths in the upstream
|
||||
/// flow because `parse_local_path` only inspects `bare_specifier`
|
||||
/// which hasn't been normalised yet; we accept the backslash for
|
||||
/// Windows-host inputs to keep parity).
|
||||
fn is_filespec(spec: &str) -> bool {
|
||||
let mut chars = spec.chars();
|
||||
match chars.next() {
|
||||
Some('.') | Some('/') | Some('\\') => true,
|
||||
Some('~') => chars.next() == Some('/'),
|
||||
Some(c) if c.is_ascii_alphabetic() => chars.next() == Some(':'),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_drive_letter_prefix(spec: &str) -> bool {
|
||||
let mut chars = spec.chars();
|
||||
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic()) && matches!(chars.next(), Some(':'))
|
||||
}
|
||||
|
||||
fn strip_tilde_prefix(spec: &str) -> Option<&str> {
|
||||
spec.strip_prefix("~/")
|
||||
}
|
||||
|
||||
fn is_tarball_filename(bare: &str) -> bool {
|
||||
let lower = bare.to_ascii_lowercase();
|
||||
lower.ends_with(".tgz") || lower.ends_with(".tar.gz") || lower.ends_with(".tar")
|
||||
}
|
||||
|
||||
fn contains_path_sep(bare: &str) -> bool {
|
||||
bare.contains(std::path::MAIN_SEPARATOR) || bare.contains('/')
|
||||
}
|
||||
92
pacquet/crates/resolving-local-resolver/tests/chain.rs
Normal file
92
pacquet/crates/resolving-local-resolver/tests/chain.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! Verify that [`LocalResolver`] composes into the
|
||||
//! [`pacquet_resolving_default_resolver::DefaultResolver`] chain the
|
||||
//! same way upstream's local resolver composes into pnpm's
|
||||
//! [`createResolver`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/default-resolver/src/index.ts#L97-L173).
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pacquet_lockfile::LockfileResolution;
|
||||
use pacquet_resolving_default_resolver::{DefaultResolver, SpecNotSupportedByAnyResolverError};
|
||||
use pacquet_resolving_local_resolver::{LocalResolver, LocalResolverContext};
|
||||
use pacquet_resolving_resolver_base::{ResolveOptions, WantedDependency};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_project() -> (TempDir, PathBuf) {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let inner = tmp.path().join("inner");
|
||||
fs::create_dir_all(&inner).expect("create inner");
|
||||
fs::write(
|
||||
tmp.path().join("package.json"),
|
||||
r#"{"name":"@pnpm/resolving.local-resolver","version":"0.0.0"}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
(tmp, inner)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatcher_routes_link_specifier_through_local_resolver() {
|
||||
let (_tmp, project_dir) = setup_project();
|
||||
let resolver =
|
||||
DefaultResolver::new(vec![Box::new(LocalResolver::new(LocalResolverContext::default()))]);
|
||||
|
||||
let opts = ResolveOptions {
|
||||
project_dir: project_dir.clone(),
|
||||
lockfile_dir: project_dir.clone(),
|
||||
..ResolveOptions::default()
|
||||
};
|
||||
let wd = WantedDependency {
|
||||
alias: Some("parent".to_string()),
|
||||
bare_specifier: Some("link:..".to_string()),
|
||||
..WantedDependency::default()
|
||||
};
|
||||
let result = resolver.resolve(&wd, &opts).await.expect("resolve");
|
||||
assert_eq!(result.id.as_str(), "link:..");
|
||||
assert_eq!(result.alias.as_deref(), Some("parent"));
|
||||
assert!(matches!(result.resolution, LockfileResolution::Directory(_)));
|
||||
assert_eq!(result.resolved_via, "local-filesystem");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatcher_falls_through_when_specifier_is_neither_local_nor_npm() {
|
||||
let (_tmp, project_dir) = setup_project();
|
||||
let resolver =
|
||||
DefaultResolver::new(vec![Box::new(LocalResolver::new(LocalResolverContext::default()))]);
|
||||
let opts = ResolveOptions {
|
||||
project_dir: project_dir.clone(),
|
||||
lockfile_dir: project_dir,
|
||||
..ResolveOptions::default()
|
||||
};
|
||||
let wd = WantedDependency {
|
||||
alias: Some("acme".to_string()),
|
||||
bare_specifier: Some("^1.0.0".to_string()),
|
||||
..WantedDependency::default()
|
||||
};
|
||||
let err = resolver
|
||||
.resolve(&wd, &opts)
|
||||
.await
|
||||
.expect_err("chain with only the local resolver shouldn't claim a registry-shaped dep");
|
||||
assert!(err.downcast_ref::<SpecNotSupportedByAnyResolverError>().is_some(), "got {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_latest_claims_local_scheme_specifiers() {
|
||||
let (_tmp, project_dir) = setup_project();
|
||||
let resolver =
|
||||
DefaultResolver::new(vec![Box::new(LocalResolver::new(LocalResolverContext::default()))]);
|
||||
let opts = ResolveOptions {
|
||||
project_dir: project_dir.clone(),
|
||||
lockfile_dir: project_dir,
|
||||
..ResolveOptions::default()
|
||||
};
|
||||
let query = pacquet_resolving_resolver_base::LatestQuery {
|
||||
wanted_dependency: WantedDependency {
|
||||
alias: Some("parent".to_string()),
|
||||
bare_specifier: Some("link:..".to_string()),
|
||||
..WantedDependency::default()
|
||||
},
|
||||
compatible: false,
|
||||
};
|
||||
let info = resolver.resolve_latest(&query, &opts).await.expect("resolve_latest");
|
||||
assert!(info.is_some(), "local resolver should claim link: specs in resolve_latest");
|
||||
}
|
||||
496
pacquet/crates/resolving-local-resolver/tests/resolve.rs
Normal file
496
pacquet/crates/resolving-local-resolver/tests/resolve.rs
Normal file
@@ -0,0 +1,496 @@
|
||||
//! Port of pnpm's
|
||||
//! [`resolving/local-resolver/test/index.ts`](https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/test/index.ts).
|
||||
//!
|
||||
//! Each `#[tokio::test]` mirrors one upstream `test(...)` block; the
|
||||
//! upstream test name is preserved in the Rust function name.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pacquet_lockfile::{LockfileResolution, TarballResolution};
|
||||
use pacquet_resolving_local_resolver::{
|
||||
LocalResolverContext, LocalResolverOptions, LocalResolverUpdate, ResolveLocalError,
|
||||
WantedLocalDependency, resolve_from_local_path, resolve_from_local_scheme,
|
||||
};
|
||||
use pacquet_resolving_resolver_base::PkgResolutionId;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Set up a `<tmp>/inner/` directory with a package.json carrying the
|
||||
/// `name` upstream's tests assert against. Returns `(tmp, inner)` so
|
||||
/// the temp dir lives as long as the test.
|
||||
fn fixture() -> (TempDir, PathBuf) {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let inner = tmp.path().join("inner");
|
||||
fs::create_dir_all(&inner).expect("create inner dir");
|
||||
fs::write(
|
||||
tmp.path().join("package.json"),
|
||||
r#"{"name":"@pnpm/resolving.local-resolver","version":"0.0.0"}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
(tmp, inner)
|
||||
}
|
||||
|
||||
fn opts(project_dir: &Path) -> LocalResolverOptions {
|
||||
LocalResolverOptions {
|
||||
project_dir: project_dir.to_path_buf(),
|
||||
lockfile_dir: None,
|
||||
current_pkg: None,
|
||||
update: LocalResolverUpdate::Off,
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx_default() -> LocalResolverContext {
|
||||
LocalResolverContext::default()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_directory() {
|
||||
let (_tmp, project_dir) = fixture();
|
||||
let wd = WantedLocalDependency { bare_specifier: "..".to_string(), injected: false };
|
||||
|
||||
let result = resolve_from_local_path(&ctx_default(), &wd, &opts(&project_dir))
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
assert_eq!(result.id.as_str(), "link:..");
|
||||
assert_eq!(result.normalized_bare_specifier.as_deref(), Some("link:.."));
|
||||
let manifest = result.manifest.as_ref().expect("manifest");
|
||||
assert_eq!(
|
||||
manifest.get("name").and_then(|value| value.as_str()),
|
||||
Some("@pnpm/resolving.local-resolver"),
|
||||
);
|
||||
let LockfileResolution::Directory(dir) = &result.resolution else {
|
||||
panic!("expected directory resolution, got {:?}", result.resolution);
|
||||
};
|
||||
let expected_dir =
|
||||
forward_slashes(project_dir.join("..").lexical_normalize().display().to_string());
|
||||
assert_eq!(dir.directory, expected_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_directory_specified_using_absolute_path() {
|
||||
let (_tmp, project_dir) = fixture();
|
||||
let linked_dir = project_dir.join("..").lexical_normalize();
|
||||
let normalized_linked_dir = forward_slashes(linked_dir.display().to_string());
|
||||
|
||||
let wd = WantedLocalDependency {
|
||||
bare_specifier: format!("link:{}", linked_dir.display()),
|
||||
injected: false,
|
||||
};
|
||||
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&project_dir))
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
assert_eq!(result.id.as_str(), "link:..");
|
||||
assert_eq!(
|
||||
result.normalized_bare_specifier.as_deref(),
|
||||
Some(format!("link:{normalized_linked_dir}").as_str()),
|
||||
);
|
||||
let LockfileResolution::Directory(dir) = &result.resolution else {
|
||||
panic!("expected directory resolution, got {:?}", result.resolution);
|
||||
};
|
||||
assert_eq!(dir.directory, normalized_linked_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_directory_specified_using_absolute_path_with_preserve_absolute_paths() {
|
||||
let (_tmp, project_dir) = fixture();
|
||||
let linked_dir = project_dir.join("..").lexical_normalize();
|
||||
let normalized_linked_dir = forward_slashes(linked_dir.display().to_string());
|
||||
|
||||
let wd = WantedLocalDependency {
|
||||
bare_specifier: format!("link:{}", linked_dir.display()),
|
||||
injected: false,
|
||||
};
|
||||
let ctx = LocalResolverContext { preserve_absolute_paths: true };
|
||||
let result = resolve_from_local_scheme(&ctx, &wd, &opts(&project_dir))
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
assert_eq!(result.id.as_str(), format!("link:{normalized_linked_dir}"));
|
||||
assert_eq!(
|
||||
result.normalized_bare_specifier.as_deref(),
|
||||
Some(format!("link:{normalized_linked_dir}").as_str()),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_directory_specified_using_absolute_path_with_preserve_absolute_paths_and_file_scheme()
|
||||
{
|
||||
let (_tmp, project_dir) = fixture();
|
||||
let linked_dir = project_dir.join("..").lexical_normalize();
|
||||
let normalized_linked_dir = forward_slashes(linked_dir.display().to_string());
|
||||
|
||||
let wd = WantedLocalDependency {
|
||||
bare_specifier: format!("file:{}", linked_dir.display()),
|
||||
injected: false,
|
||||
};
|
||||
let ctx = LocalResolverContext { preserve_absolute_paths: true };
|
||||
let result = resolve_from_local_scheme(&ctx, &wd, &opts(&project_dir))
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
assert_eq!(result.id.as_str(), format!("file:{normalized_linked_dir}"));
|
||||
assert_eq!(
|
||||
result.normalized_bare_specifier.as_deref(),
|
||||
Some(format!("file:{normalized_linked_dir}").as_str()),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_injected_directory() {
|
||||
let (_tmp, project_dir) = fixture();
|
||||
let wd = WantedLocalDependency { bare_specifier: "..".to_string(), injected: true };
|
||||
|
||||
let result = resolve_from_local_path(&ctx_default(), &wd, &opts(&project_dir))
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
assert_eq!(result.id.as_str(), "file:..");
|
||||
assert_eq!(result.normalized_bare_specifier.as_deref(), Some("file:.."));
|
||||
let LockfileResolution::Directory(dir) = &result.resolution else {
|
||||
panic!("expected directory resolution, got {:?}", result.resolution);
|
||||
};
|
||||
assert_eq!(dir.directory, "..");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_workspace_directory() {
|
||||
let (_tmp, project_dir) = fixture();
|
||||
let wd = WantedLocalDependency { bare_specifier: "workspace:..".to_string(), injected: false };
|
||||
|
||||
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&project_dir))
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
assert_eq!(result.id.as_str(), "link:..");
|
||||
assert_eq!(result.normalized_bare_specifier.as_deref(), Some("link:.."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_directory_specified_using_the_file_protocol() {
|
||||
let (_tmp, project_dir) = fixture();
|
||||
let wd = WantedLocalDependency { bare_specifier: "file:..".to_string(), injected: false };
|
||||
|
||||
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&project_dir))
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
assert_eq!(result.id.as_str(), "file:..");
|
||||
assert_eq!(result.normalized_bare_specifier.as_deref(), Some("file:.."));
|
||||
let LockfileResolution::Directory(dir) = &result.resolution else {
|
||||
panic!("expected directory resolution");
|
||||
};
|
||||
assert_eq!(dir.directory, "..");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_directory_specified_using_the_link_protocol() {
|
||||
let (_tmp, project_dir) = fixture();
|
||||
let wd = WantedLocalDependency { bare_specifier: "link:..".to_string(), injected: false };
|
||||
|
||||
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&project_dir))
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
assert_eq!(result.id.as_str(), "link:..");
|
||||
assert_eq!(result.normalized_bare_specifier.as_deref(), Some("link:.."));
|
||||
}
|
||||
|
||||
/// Build a tiny tarball at `path` and return its sha512 SSRI string.
|
||||
fn write_tarball(path: &Path) -> String {
|
||||
// Any bytes work — the test asserts the integrity round-trips
|
||||
// through the resolver, not a specific upstream-pinned value.
|
||||
let bytes: &[u8] = b"\x1f\x8b\x08\x00fake-tarball-bytes-for-test\n";
|
||||
fs::write(path, bytes).expect("write tarball");
|
||||
let mut opts = ssri::IntegrityOpts::new().algorithm(ssri::Algorithm::Sha512);
|
||||
opts.input(bytes);
|
||||
opts.result().to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_file() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let test_dir = tmp.path().join("tgz");
|
||||
fs::create_dir_all(&test_dir).expect("create tgz dir");
|
||||
let tarball_path = test_dir.join("pnpm-local-resolver-0.1.1.tgz");
|
||||
let integrity = write_tarball(&tarball_path);
|
||||
|
||||
let wd = WantedLocalDependency {
|
||||
bare_specifier: "./pnpm-local-resolver-0.1.1.tgz".to_string(),
|
||||
injected: false,
|
||||
};
|
||||
let result = resolve_from_local_path(&ctx_default(), &wd, &opts(&test_dir))
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
assert_eq!(result.id.as_str(), "file:pnpm-local-resolver-0.1.1.tgz");
|
||||
assert_eq!(
|
||||
result.normalized_bare_specifier.as_deref(),
|
||||
Some("file:pnpm-local-resolver-0.1.1.tgz"),
|
||||
);
|
||||
let LockfileResolution::Tarball(TarballResolution {
|
||||
tarball, integrity: got_integrity, ..
|
||||
}) = &result.resolution
|
||||
else {
|
||||
panic!("expected tarball resolution, got {:?}", result.resolution);
|
||||
};
|
||||
assert_eq!(tarball, "file:pnpm-local-resolver-0.1.1.tgz");
|
||||
assert_eq!(got_integrity.as_ref().expect("integrity").to_string(), integrity);
|
||||
assert_eq!(result.resolved_via, "local-filesystem");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_file_when_lockfile_directory_differs_from_the_packages_dir() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let test_dir = tmp.path().join("tgz");
|
||||
fs::create_dir_all(&test_dir).expect("create tgz dir");
|
||||
let tarball_path = test_dir.join("pnpm-local-resolver-0.1.1.tgz");
|
||||
let _integrity = write_tarball(&tarball_path);
|
||||
|
||||
let mut options = opts(&test_dir);
|
||||
options.lockfile_dir = Some(test_dir.join("..").lexical_normalize());
|
||||
|
||||
let wd = WantedLocalDependency {
|
||||
bare_specifier: "./pnpm-local-resolver-0.1.1.tgz".to_string(),
|
||||
injected: false,
|
||||
};
|
||||
let result = resolve_from_local_path(&ctx_default(), &wd, &options)
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
assert_eq!(result.id.as_str(), "file:tgz/pnpm-local-resolver-0.1.1.tgz");
|
||||
assert_eq!(
|
||||
result.normalized_bare_specifier.as_deref(),
|
||||
Some("file:pnpm-local-resolver-0.1.1.tgz"),
|
||||
);
|
||||
let LockfileResolution::Tarball(TarballResolution { tarball, .. }) = &result.resolution else {
|
||||
panic!("expected tarball resolution");
|
||||
};
|
||||
assert_eq!(tarball, "file:tgz/pnpm-local-resolver-0.1.1.tgz");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_tarball_specified_with_file_protocol() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let test_dir = tmp.path().join("tgz");
|
||||
fs::create_dir_all(&test_dir).expect("create tgz dir");
|
||||
let tarball_path = test_dir.join("pnpm-local-resolver-0.1.1.tgz");
|
||||
let _integrity = write_tarball(&tarball_path);
|
||||
|
||||
let wd = WantedLocalDependency {
|
||||
bare_specifier: "file:./pnpm-local-resolver-0.1.1.tgz".to_string(),
|
||||
injected: false,
|
||||
};
|
||||
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&test_dir))
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
assert_eq!(result.id.as_str(), "file:pnpm-local-resolver-0.1.1.tgz");
|
||||
assert_eq!(
|
||||
result.normalized_bare_specifier.as_deref(),
|
||||
Some("file:pnpm-local-resolver-0.1.1.tgz"),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_file_with_different_integrity_force_fetch() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let test_dir = tmp.path().join("tgz");
|
||||
fs::create_dir_all(&test_dir).expect("create tgz dir");
|
||||
let tarball_path = test_dir.join("pnpm-local-resolver-0.1.1.tgz");
|
||||
let true_integrity = write_tarball(&tarball_path);
|
||||
|
||||
let mut options = opts(&test_dir);
|
||||
options.current_pkg = Some(pacquet_resolving_local_resolver::LocalCurrentPkg {
|
||||
id: PkgResolutionId::from("file:pnpm-local-resolver-0.1.1.tgz"),
|
||||
resolution: LockfileResolution::Tarball(TarballResolution {
|
||||
tarball: "file:pnpm-local-resolver-0.1.1.tgz".to_string(),
|
||||
integrity: Some(
|
||||
"sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
|
||||
.parse()
|
||||
.expect("parse"),
|
||||
),
|
||||
git_hosted: None,
|
||||
path: None,
|
||||
}),
|
||||
});
|
||||
|
||||
let wd = WantedLocalDependency {
|
||||
bare_specifier: "file:./pnpm-local-resolver-0.1.1.tgz".to_string(),
|
||||
injected: false,
|
||||
};
|
||||
let result = resolve_from_local_scheme(&ctx_default(), &wd, &options)
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
|
||||
let LockfileResolution::Tarball(TarballResolution { integrity, .. }) = &result.resolution
|
||||
else {
|
||||
panic!("expected tarball resolution");
|
||||
};
|
||||
assert_eq!(integrity.as_ref().expect("integrity").to_string(), true_integrity);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fail_when_resolving_tarball_specified_with_the_link_protocol() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let test_dir = tmp.path().join("tgz");
|
||||
fs::create_dir_all(&test_dir).expect("create tgz dir");
|
||||
let tarball_path = test_dir.join("pnpm-local-resolver-0.1.1.tgz");
|
||||
let _ = write_tarball(&tarball_path);
|
||||
|
||||
let wd = WantedLocalDependency {
|
||||
bare_specifier: "link:./pnpm-local-resolver-0.1.1.tgz".to_string(),
|
||||
injected: false,
|
||||
};
|
||||
let err = resolve_from_local_scheme(&ctx_default(), &wd, &opts(&test_dir))
|
||||
.await
|
||||
.expect_err("expected NOT_PACKAGE_DIRECTORY");
|
||||
assert!(matches!(err, ResolveLocalError::NotPackageDirectory { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fail_when_resolving_from_not_existing_directory_an_injected_dependency() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let project_dir = tmp.path();
|
||||
|
||||
let wd = WantedLocalDependency {
|
||||
bare_specifier: "file:./dir-does-not-exist".to_string(),
|
||||
injected: false,
|
||||
};
|
||||
let err = resolve_from_local_scheme(&ctx_default(), &wd, &opts(project_dir))
|
||||
.await
|
||||
.expect_err("expected LINKED_PKG_DIR_NOT_FOUND");
|
||||
let expected = project_dir.join("dir-does-not-exist").display().to_string();
|
||||
match err {
|
||||
ResolveLocalError::LinkedPkgDirNotFound { path } => assert_eq!(path, expected),
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `file:./missing.tgz` spec funnels through the tarball branch
|
||||
/// where `compute_tarball_integrity` raises ENOENT. The resolver must
|
||||
/// surface the same `LINKED_PKG_DIR_NOT_FOUND` code the directory
|
||||
/// branch raises for a missing `file:` target — both kinds of
|
||||
/// missing `file:` target share one pnpm-compatible error path
|
||||
/// (`resolveSpec` upstream:
|
||||
/// <https://github.com/pnpm/pnpm/blob/ef87f3ccff/resolving/local-resolver/src/index.ts#L108-L141>).
|
||||
#[tokio::test]
|
||||
async fn fail_when_resolving_missing_tarball_with_file_protocol() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let project_dir = tmp.path();
|
||||
|
||||
let wd =
|
||||
WantedLocalDependency { bare_specifier: "file:./missing.tgz".to_string(), injected: false };
|
||||
let err = resolve_from_local_scheme(&ctx_default(), &wd, &opts(project_dir))
|
||||
.await
|
||||
.expect_err("expected LINKED_PKG_DIR_NOT_FOUND");
|
||||
let expected = project_dir.join("missing.tgz").display().to_string();
|
||||
match err {
|
||||
ResolveLocalError::LinkedPkgDirNotFound { path } => assert_eq!(path, expected),
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn do_not_fail_when_resolving_from_not_existing_directory() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let project_dir = tmp.path();
|
||||
|
||||
let wd = WantedLocalDependency {
|
||||
bare_specifier: "link:./dir-does-not-exist".to_string(),
|
||||
injected: false,
|
||||
};
|
||||
let result = resolve_from_local_scheme(&ctx_default(), &wd, &opts(project_dir))
|
||||
.await
|
||||
.expect("resolve")
|
||||
.expect("claims");
|
||||
let manifest = result.manifest.as_ref().expect("manifest");
|
||||
assert_eq!(manifest.get("name").and_then(|value| value.as_str()), Some("dir-does-not-exist"));
|
||||
assert_eq!(manifest.get("version").and_then(|value| value.as_str()), Some("0.0.0"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throw_error_when_the_path_protocol_is_used() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let project_dir = tmp.path();
|
||||
|
||||
let wd = WantedLocalDependency { bare_specifier: "path:..".to_string(), injected: false };
|
||||
let err = resolve_from_local_scheme(&ctx_default(), &wd, &opts(project_dir))
|
||||
.await
|
||||
.expect_err("expected PATH_IS_UNSUPPORTED_PROTOCOL");
|
||||
match err {
|
||||
ResolveLocalError::Spec(
|
||||
pacquet_resolving_local_resolver::LocalSpecError::PathProtocolNotSupported(inner),
|
||||
) => {
|
||||
assert_eq!(inner.bare_specifier, "path:..");
|
||||
assert_eq!(inner.protocol, "path:");
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_from_local_path_ignores_explicit_local_schemes() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let project_dir = tmp.path();
|
||||
|
||||
for bare in ["foo"] {
|
||||
let wd = WantedLocalDependency { bare_specifier: bare.to_string(), injected: false };
|
||||
let outcome = resolve_from_local_scheme(&ctx_default(), &wd, &opts(project_dir))
|
||||
.await
|
||||
.expect("resolve_from_local_scheme should not fail on bare specifier");
|
||||
assert!(outcome.is_none(), "scheme parser should defer on '{bare}'");
|
||||
}
|
||||
for bare in ["link:..", "workspace:..", "file:..", "path:.."] {
|
||||
let wd = WantedLocalDependency { bare_specifier: bare.to_string(), injected: false };
|
||||
let outcome = resolve_from_local_path(&ctx_default(), &wd, &opts(project_dir))
|
||||
.await
|
||||
.expect("resolve_from_local_path should not fail on scheme prefix");
|
||||
assert!(outcome.is_none(), "path parser should defer on '{bare}'");
|
||||
}
|
||||
}
|
||||
|
||||
/// Lexically normalize `.` and `..` components without resolving
|
||||
/// symlinks — matches Node's `path.resolve` semantics that the
|
||||
/// upstream tests compare against. `canonicalize` would resolve
|
||||
/// macOS's `/var` → `/private/var` symlink and diverge from the
|
||||
/// upstream string-equality assertions.
|
||||
trait LexicalNormalize: Sized {
|
||||
fn lexical_normalize(self) -> PathBuf;
|
||||
}
|
||||
|
||||
impl LexicalNormalize for PathBuf {
|
||||
fn lexical_normalize(self) -> PathBuf {
|
||||
use std::path::Component;
|
||||
let mut out = PathBuf::new();
|
||||
for component in self.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
out.push("..");
|
||||
}
|
||||
}
|
||||
other => out.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_slashes(input: String) -> String {
|
||||
if input.contains('\\') { input.replace('\\', "/") } else { input }
|
||||
}
|
||||
Reference in New Issue
Block a user