From a8a8cbce6de233bf9d2b4096dac76ed8a3b31dc8 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Wed, 20 May 2026 23:49:30 +0200 Subject: [PATCH] feat(pacquet): port resolving.local-resolver (file:/link:/workspace:) (#11778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `/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). --- Cargo.lock | 22 + Cargo.toml | 1 + pacquet/crates/package-manager/Cargo.toml | 1 + .../src/install_without_lockfile.rs | 19 +- .../resolving-local-resolver/Cargo.toml | 36 ++ .../resolving-local-resolver/src/chain.rs | 109 ++++ .../resolving-local-resolver/src/lib.rs | 38 ++ .../src/local_resolver.rs | 371 +++++++++++++ .../src/parse_bare_specifier.rs | 351 +++++++++++++ .../resolving-local-resolver/tests/chain.rs | 92 ++++ .../resolving-local-resolver/tests/resolve.rs | 496 ++++++++++++++++++ 11 files changed, 1534 insertions(+), 2 deletions(-) create mode 100644 pacquet/crates/resolving-local-resolver/Cargo.toml create mode 100644 pacquet/crates/resolving-local-resolver/src/chain.rs create mode 100644 pacquet/crates/resolving-local-resolver/src/lib.rs create mode 100644 pacquet/crates/resolving-local-resolver/src/local_resolver.rs create mode 100644 pacquet/crates/resolving-local-resolver/src/parse_bare_specifier.rs create mode 100644 pacquet/crates/resolving-local-resolver/tests/chain.rs create mode 100644 pacquet/crates/resolving-local-resolver/tests/resolve.rs diff --git a/Cargo.lock b/Cargo.lock index 0d5d632d52..722d068db6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 89e93ef766..ea0f6a1a20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/pacquet/crates/package-manager/Cargo.toml b/pacquet/crates/package-manager/Cargo.toml index 8ffb8e97ae..b0fec7366e 100644 --- a/pacquet/crates/package-manager/Cargo.toml +++ b/pacquet/crates/package-manager/Cargo.toml @@ -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 } diff --git a/pacquet/crates/package-manager/src/install_without_lockfile.rs b/pacquet/crates/package-manager/src/install_without_lockfile.rs index 5a4b6e1aae..6d8b7166f0 100644 --- a/pacquet/crates/package-manager/src/install_without_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_without_lockfile.rs @@ -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 // : - // 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 = 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) diff --git a/pacquet/crates/resolving-local-resolver/Cargo.toml b/pacquet/crates/resolving-local-resolver/Cargo.toml new file mode 100644 index 0000000000..b74416660d --- /dev/null +++ b/pacquet/crates/resolving-local-resolver/Cargo.toml @@ -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 diff --git a/pacquet/crates/resolving-local-resolver/src/chain.rs b/pacquet/crates/resolving-local-resolver/src/chain.rs new file mode 100644 index 0000000000..a4b24e48c9 --- /dev/null +++ b/pacquet/crates/resolving-local-resolver/src/chain.rs @@ -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, 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 +} diff --git a/pacquet/crates/resolving-local-resolver/src/lib.rs b/pacquet/crates/resolving-local-resolver/src/lib.rs new file mode 100644 index 0000000000..5c567e2152 --- /dev/null +++ b/pacquet/crates/resolving-local-resolver/src/lib.rs @@ -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}; diff --git a/pacquet/crates/resolving-local-resolver/src/local_resolver.rs b/pacquet/crates/resolving-local-resolver/src/local_resolver.rs new file mode 100644 index 0000000000..f942b32808 --- /dev/null +++ b/pacquet/crates/resolving-local-resolver/src/local_resolver.rs @@ -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, + /// 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, + /// `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, + pub normalized_bare_specifier: Option, + pub resolution: LockfileResolution, + /// `local-filesystem` — the same `resolvedVia` tag upstream uses + /// across every shape this resolver produces. + pub resolved_via: &'static str, +} + +impl From 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, + }, + + /// `` 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 `/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, 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, 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 { + 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, + opts: &LocalResolverOptions, +) -> Result, 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 { + 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(/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 { + let bytes = tokio::fs::read(path).await?; + let mut opts = IntegrityOpts::new().algorithm(Algorithm::Sha512); + opts.input(&bytes); + Ok(opts.result()) +} diff --git a/pacquet/crates/resolving-local-resolver/src/parse_bare_specifier.rs b/pacquet/crates/resolving-local-resolver/src/parse_bare_specifier.rs new file mode 100644 index 0000000000..f7465bf56b --- /dev/null +++ b/pacquet/crates/resolving-local-resolver/src/parse_bare_specifier.rs @@ -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 ``. + 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, 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 { + 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('/') +} diff --git a/pacquet/crates/resolving-local-resolver/tests/chain.rs b/pacquet/crates/resolving-local-resolver/tests/chain.rs new file mode 100644 index 0000000000..7cce4df397 --- /dev/null +++ b/pacquet/crates/resolving-local-resolver/tests/chain.rs @@ -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::().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"); +} diff --git a/pacquet/crates/resolving-local-resolver/tests/resolve.rs b/pacquet/crates/resolving-local-resolver/tests/resolve.rs new file mode 100644 index 0000000000..2adcb34599 --- /dev/null +++ b/pacquet/crates/resolving-local-resolver/tests/resolve.rs @@ -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 `/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: +/// ). +#[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 } +}