Skip to content

Commit 6130546

Browse files
authored
feat(resolver): rework yarn manifest file look up (#586)
The current manifest file (`.pnp.cjs`) lookup technique does not work when `enableGlobalCache` is set to `true`, which is the default behavior for yarn. Instead, we try and find the closest pnp maniest from cwd. This will break the multi-project case mentioned in https://yarnpkg.com/advanced/pnp-spec#find_pnp_manifest, but I think this is an acceptable trade off. For napi, I set `process.env.OXC_RESOLVER_YARN_PNP` in `napi/index.js` so the Rust side can enable yarn pnp accordingly.
1 parent 5271786 commit 6130546

File tree

12 files changed

+143
-120
lines changed

12 files changed

+143
-120
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ See https://stackblitz.com/edit/oxc-resolver for usage example.
6060

6161
See [docs.rs/oxc_resolver](https://docs.rs/oxc_resolver/latest/oxc_resolver).
6262

63+
### [Yarn Plug'n'Play](https://yarnpkg.com/features/pnp)
64+
65+
- For node.js, yarn pnp should work without any configuration, given the following conditions:
66+
- the program is called with the `yarn` command, where the value [`process.versions.pnp`](https://yarnpkg.com/advanced/pnpapi#processversionspnp) is set.
67+
- `.pnp.cjs` manifest file exists in the closest directory, searched from the current working directory,
68+
- no multi-project setup, per second bullet point in [FIND_PNP_MANIFEST](https://yarnpkg.com/advanced/pnp-spec#find_pnp_manifest)
69+
6370
## Terminology
6471

6572
### `directory`

napi/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ mimalloc-safe = { version = "0.1.52", optional = true, features = ["skip_collect
3939
napi-build = "2.2.1"
4040

4141
[features]
42-
default = ["tracing-subscriber"]
42+
default = ["tracing-subscriber", "yarn_pnp"]
4343
allocator = ["dep:mimalloc-safe"]
4444
tracing-subscriber = ["dep:tracing-subscriber"]
4545
yarn_pnp = ["oxc_resolver/yarn_pnp"]

napi/index.d.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,6 @@ export interface NapiResolveOptions {
5757
* Default `None`
5858
*/
5959
tsconfig?: TsconfigOptions
60-
/** Enable Yarn Plug'n'Play */
61-
yarnPnp?: boolean
6260
/**
6361
* Alias for [ResolveOptions::alias] and [ResolveOptions::fallback].
6462
*

napi/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,3 +387,7 @@ module.exports.ResolverFactory = nativeBinding.ResolverFactory
387387
module.exports.EnforceExtension = nativeBinding.EnforceExtension
388388
module.exports.ModuleType = nativeBinding.ModuleType
389389
module.exports.sync = nativeBinding.sync
390+
391+
if (process.versions.pnp) {
392+
process.env.OXC_RESOLVER_YARN_PNP = '1'
393+
}

napi/patch.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,9 @@ if (!nativeBinding && globalThis.process?.versions?.["webcontainer"]) {
1515
}
1616
` + s,
1717
);
18+
data = data + `
19+
if (process.versions.pnp) {
20+
process.env.OXC_RESOLVER_YARN_PNP = '1'
21+
}
22+
`
1823
fs.writeFileSync(filename, data);

napi/src/lib.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,7 @@ impl ResolverFactory {
151151
#[napi]
152152
#[allow(clippy::should_implement_trait)]
153153
pub fn default() -> Self {
154-
let default_options = ResolveOptions::default();
155-
Self { resolver: Arc::new(Resolver::new(default_options)) }
154+
Self { resolver: Arc::new(Resolver::new(ResolveOptions::default())) }
156155
}
157156

158157
/// Clone the resolver using the same underlying cache.
@@ -190,6 +189,7 @@ impl ResolverFactory {
190189
let default = ResolveOptions::default();
191190
// merging options
192191
ResolveOptions {
192+
cwd: None,
193193
tsconfig: op.tsconfig.map(|tsconfig| tsconfig.into()),
194194
alias: op
195195
.alias
@@ -279,7 +279,7 @@ impl ResolverFactory {
279279
.allow_package_exports_in_directory_resolve
280280
.unwrap_or(default.allow_package_exports_in_directory_resolve),
281281
#[cfg(feature = "yarn_pnp")]
282-
yarn_pnp: op.yarn_pnp.unwrap_or(default.yarn_pnp),
282+
yarn_pnp: default.yarn_pnp,
283283
}
284284
}
285285
}

napi/src/options.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,6 @@ pub struct NapiResolveOptions {
1616
/// Default `None`
1717
pub tsconfig: Option<TsconfigOptions>,
1818

19-
/// Enable Yarn Plug'n'Play
20-
pub yarn_pnp: Option<bool>,
21-
2219
/// Alias for [ResolveOptions::alias] and [ResolveOptions::fallback].
2320
///
2421
/// For the second value of the tuple, `None -> AliasValue::Ignore`, Some(String) ->

src/cache.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ pub trait Cache: Sized {
5252
path: &Path,
5353
callback: F,
5454
) -> Result<Arc<Self::Tc>, ResolveError>;
55+
56+
#[cfg(feature = "yarn_pnp")]
57+
fn get_yarn_pnp_manifest(&self, cwd: Option<&Path>) -> Result<&pnp::Manifest, ResolveError>;
5558
}
5659

5760
#[allow(clippy::missing_errors_doc)] // trait impls should be free to return any typesafe error

src/fs_cache.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ pub struct FsCache<Fs> {
4141
pub(crate) fs: Fs,
4242
paths: HashSet<FsCachedPath, BuildHasherDefault<IdentityHasher>>,
4343
tsconfigs: HashMap<PathBuf, Arc<TsConfigSerde>, BuildHasherDefault<FxHasher>>,
44+
#[cfg(feature = "yarn_pnp")]
45+
yarn_pnp_manifest: OnceLock<pnp::Manifest>,
4446
}
4547

4648
impl<Fs: FileSystem> Cache for FsCache<Fs> {
@@ -190,6 +192,18 @@ impl<Fs: FileSystem> Cache for FsCache<Fs> {
190192
tsconfigs.insert(path.to_path_buf(), Arc::clone(&tsconfig));
191193
Ok(tsconfig)
192194
}
195+
196+
#[cfg(feature = "yarn_pnp")]
197+
fn get_yarn_pnp_manifest(&self, cwd: Option<&Path>) -> Result<&pnp::Manifest, ResolveError> {
198+
self.yarn_pnp_manifest.get_or_try_init(|| {
199+
// TODO: map to proper error
200+
let cwd =
201+
cwd.map_or_else(|| Cow::Owned(std::env::current_dir().unwrap()), Cow::Borrowed);
202+
// TODO: map to proper error
203+
let manifest = pnp::find_pnp_manifest(&cwd).unwrap().unwrap();
204+
Ok(manifest)
205+
})
206+
}
193207
}
194208

195209
impl<Fs: FileSystem> FsCache<Fs> {
@@ -204,6 +218,8 @@ impl<Fs: FileSystem> FsCache<Fs> {
204218
.hasher(BuildHasherDefault::default())
205219
.resize_mode(papaya::ResizeMode::Blocking)
206220
.build(),
221+
#[cfg(feature = "yarn_pnp")]
222+
yarn_pnp_manifest: OnceLock::new(),
207223
}
208224
}
209225

src/lib.rs

Lines changed: 64 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,6 @@ pub type Resolver = ResolverGeneric<FsCache<FileSystemOs>>;
135135
pub struct ResolverGeneric<C: Cache> {
136136
options: ResolveOptions,
137137
cache: Arc<C>,
138-
#[cfg(feature = "yarn_pnp")]
139-
pnp_cache: Arc<papaya::HashMap<C::Cp, Option<pnp::Manifest>>>,
140138
}
141139

142140
impl<C: Cache> fmt::Debug for ResolverGeneric<C> {
@@ -154,34 +152,19 @@ impl<C: Cache + Default> Default for ResolverGeneric<C> {
154152
impl<C: Cache + Default> ResolverGeneric<C> {
155153
#[must_use]
156154
pub fn new(options: ResolveOptions) -> Self {
157-
Self {
158-
options: options.sanitize(),
159-
cache: Arc::new(C::default()),
160-
#[cfg(feature = "yarn_pnp")]
161-
pnp_cache: Arc::new(papaya::HashMap::default()),
162-
}
155+
Self { options: options.sanitize(), cache: Arc::new(C::default()) }
163156
}
164157
}
165158

166159
impl<C: Cache> ResolverGeneric<C> {
167160
pub fn new_with_cache(cache: Arc<C>, options: ResolveOptions) -> Self {
168-
Self {
169-
cache,
170-
options: options.sanitize(),
171-
#[cfg(feature = "yarn_pnp")]
172-
pnp_cache: Arc::new(papaya::HashMap::default()),
173-
}
161+
Self { cache, options: options.sanitize() }
174162
}
175163

176164
/// Clone the resolver using the same underlying cache.
177165
#[must_use]
178166
pub fn clone_with_options(&self, options: ResolveOptions) -> Self {
179-
Self {
180-
options: options.sanitize(),
181-
cache: Arc::clone(&self.cache),
182-
#[cfg(feature = "yarn_pnp")]
183-
pnp_cache: Arc::clone(&self.pnp_cache),
184-
}
167+
Self { options: options.sanitize(), cache: Arc::clone(&self.cache) }
185168
}
186169

187170
/// Returns the options.
@@ -883,92 +866,81 @@ impl<C: Cache> ResolverGeneric<C> {
883866
specifier: &str,
884867
ctx: &mut Ctx,
885868
) -> Result<Option<C::Cp>, ResolveError> {
886-
let pnp_cache = self.pnp_cache.pin();
887-
let pnp_manifest = pnp_cache.get_or_insert_with(cached_path.clone(), || {
888-
if let Some(path) = pnp::find_pnp_manifest(cached_path.path()).unwrap() {
889-
return Some(path);
890-
}
891-
self.options.roots.iter().find_map(|root| pnp::find_pnp_manifest(root).unwrap())
892-
});
869+
let pnp_manifest = self.cache.get_yarn_pnp_manifest(self.options.cwd.as_deref())?;
893870

894-
if let Some(pnp_manifest) = pnp_manifest.as_ref() {
895-
// "pnpapi" in a P'n'P builtin module
896-
if specifier == "pnpapi" {
897-
return Ok(Some(self.cache.value(pnp_manifest.manifest_path.as_path())));
898-
}
871+
// "pnpapi" in a P'n'P builtin module
872+
if specifier == "pnpapi" {
873+
return Ok(Some(self.cache.value(pnp_manifest.manifest_path.as_path())));
874+
}
899875

900-
// `resolve_to_unqualified` requires a trailing slash
901-
let mut path = cached_path.to_path_buf();
902-
path.push("");
876+
// `resolve_to_unqualified` requires a trailing slash
877+
let mut path = cached_path.to_path_buf();
878+
path.push("");
903879

904-
let resolution =
905-
pnp::resolve_to_unqualified_via_manifest(pnp_manifest, specifier, path);
880+
let resolution = pnp::resolve_to_unqualified_via_manifest(pnp_manifest, specifier, path);
906881

907-
match resolution {
908-
Ok(pnp::Resolution::Resolved(path, subpath)) => {
909-
let cached_path = self.cache.value(&path);
910-
let cached_path_string = cached_path.path().to_string_lossy();
882+
match resolution {
883+
Ok(pnp::Resolution::Resolved(path, subpath)) => {
884+
let cached_path = self.cache.value(&path);
885+
let cached_path_string = cached_path.path().to_string_lossy();
911886

912-
let export_resolution = self.load_package_self(&cached_path, specifier, ctx)?;
913-
// can be found in pnp cached folder
914-
if export_resolution.is_some() {
915-
return Ok(export_resolution);
916-
}
887+
let export_resolution = self.load_package_self(&cached_path, specifier, ctx)?;
888+
// can be found in pnp cached folder
889+
if export_resolution.is_some() {
890+
return Ok(export_resolution);
891+
}
917892

918-
// symbol linked package doesn't have node_modules structure
919-
let pkg_name = cached_path_string.rsplit_once("node_modules/").map_or(
920-
"",
921-
// remove trailing slash
922-
|(_, last)| last.strip_suffix('/').unwrap_or(last),
923-
);
893+
// symbol linked package doesn't have node_modules structure
894+
let pkg_name = cached_path_string.rsplit_once("node_modules/").map_or(
895+
"",
896+
// remove trailing slash
897+
|(_, last)| last.strip_suffix('/').unwrap_or(last),
898+
);
924899

925-
let inner_request = if pkg_name.is_empty() {
926-
subpath.map_or_else(
927-
|| ".".to_string(),
928-
|mut p| {
929-
p.insert_str(0, "./");
930-
p
931-
},
932-
)
900+
let inner_request = if pkg_name.is_empty() {
901+
subpath.map_or_else(
902+
|| ".".to_string(),
903+
|mut p| {
904+
p.insert_str(0, "./");
905+
p
906+
},
907+
)
908+
} else {
909+
let (first, rest) = specifier.split_once('/').unwrap_or((specifier, ""));
910+
// the original `pkg_name` in cached path could be different with specifier
911+
// due to alias like `"custom-minimist": "npm:minimist@^1.2.8"`
912+
// in this case, `specifier` is `pkg_name`'s source of truth
913+
let pkg_name = if first.starts_with('@') {
914+
&format!("{first}/{}", rest.split_once('/').unwrap_or((rest, "")).0)
933915
} else {
934-
let (first, rest) = specifier.split_once('/').unwrap_or((specifier, ""));
935-
// the original `pkg_name` in cached path could be different with specifier
936-
// due to alias like `"custom-minimist": "npm:minimist@^1.2.8"`
937-
// in this case, `specifier` is `pkg_name`'s source of truth
938-
let pkg_name = if first.starts_with('@') {
939-
&format!("{first}/{}", rest.split_once('/').unwrap_or((rest, "")).0)
940-
} else {
941-
first
942-
};
943-
let inner_specifier = specifier.strip_prefix(pkg_name).unwrap();
944-
String::from("./")
945-
+ inner_specifier.strip_prefix("/").unwrap_or(inner_specifier)
916+
first
946917
};
918+
let inner_specifier = specifier.strip_prefix(pkg_name).unwrap();
919+
String::from("./")
920+
+ inner_specifier.strip_prefix("/").unwrap_or(inner_specifier)
921+
};
947922

948-
// it could be a directory with `package.json` that redirects to another file,
949-
// take `@atlaskit/pragmatic-drag-and-drop` for example, as described at import-js/eslint-import-resolver-typescript#409
950-
if let Ok(Some(result)) = self.load_as_directory(
951-
&self.cache.value(&path.join(inner_request.clone()).normalize()),
952-
ctx,
953-
) {
954-
return Ok(Some(result));
955-
}
923+
// it could be a directory with `package.json` that redirects to another file,
924+
// take `@atlaskit/pragmatic-drag-and-drop` for example, as described at import-js/eslint-import-resolver-typescript#409
925+
if let Ok(Some(result)) = self.load_as_directory(
926+
&self.cache.value(&path.join(inner_request.clone()).normalize()),
927+
ctx,
928+
) {
929+
return Ok(Some(result));
930+
}
956931

957-
let inner_resolver = self.clone_with_options(self.options().clone());
932+
let inner_resolver = self.clone_with_options(self.options().clone());
958933

959-
// try as file or directory `path` in the pnp folder
960-
let Ok(inner_resolution) = inner_resolver.resolve(&path, &inner_request) else {
961-
return Err(ResolveError::NotFound(specifier.to_string()));
962-
};
963-
964-
Ok(Some(self.cache.value(inner_resolution.path())))
965-
}
934+
// try as file or directory `path` in the pnp folder
935+
let Ok(inner_resolution) = inner_resolver.resolve(&path, &inner_request) else {
936+
return Err(ResolveError::NotFound(specifier.to_string()));
937+
};
966938

967-
Ok(pnp::Resolution::Skipped) => Ok(None),
968-
Err(_) => Err(ResolveError::NotFound(specifier.to_string())),
939+
Ok(Some(self.cache.value(inner_resolution.path())))
969940
}
970-
} else {
971-
Ok(None)
941+
942+
Ok(pnp::Resolution::Skipped) => Ok(None),
943+
Err(_) => Err(ResolveError::NotFound(specifier.to_string())),
972944
}
973945
}
974946

0 commit comments

Comments
 (0)