diff --git a/.eslintignore b/.eslintignore index d70aa163ab3..15985ac3963 100644 --- a/.eslintignore +++ b/.eslintignore @@ -23,6 +23,7 @@ packages/optimizers/image/native.js packages/transformers/js/native.js packages/utils/fs-search/index.js packages/utils/hash/index.js +packages/utils/node-resolver-core/index.js coverage node_modules diff --git a/.flowconfig b/.flowconfig index f2ad321d25c..c65a3ab470e 100644 --- a/.flowconfig +++ b/.flowconfig @@ -9,6 +9,7 @@ /packages/core/integration-tests/test/input/** /packages/core/integration-tests/test/integration/babel-strip-flow-types/** /packages/core/integration-tests/test/integration/diagnostic-sourcemap/** +/packages/utils/node-resolver-core/test/fixture/node_modules/json-error/package.json [untyped] .*/node_modules/graphql/error/GraphQLError.js.flow diff --git a/Cargo.lock b/Cargo.lock index 62804bbd1aa..a5cdf7060bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,20 @@ version = "0.7.2" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" +[[package]] +name = "assert_fs" +version = "1.0.10" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "d94b2a3f3786ff2996a98afbd6b4e5b7e890d685ccf67577f508ee2342c71cc9" +dependencies = [ + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + [[package]] name = "ast_node" version = "0.8.6" @@ -166,6 +180,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "bstr" +version = "1.2.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "b7f0778972c64420fdedc63f09919c8a88bda7b25135357fd25a5d9f3257e832" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "build_const" version = "0.2.2" @@ -497,6 +521,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.6" @@ -507,6 +537,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dunce" version = "1.0.3" @@ -519,6 +555,15 @@ version = "1.8.0" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" +[[package]] +name = "elsa" +version = "1.7.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "2b4b5d23ed6b6948d68240aafa4ac98e568c9a020efd9d4201a6288bc3006e09" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "enum_kind" version = "0.2.1" @@ -531,6 +576,15 @@ dependencies = [ "syn", ] +[[package]] +name = "fastrand" +version = "1.8.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" +dependencies = [ + "instant", +] + [[package]] name = "filetime" version = "0.2.19" @@ -609,6 +663,36 @@ version = "0.3.0" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" +[[package]] +name = "glob-match" +version = "0.2.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" + +[[package]] +name = "globset" +version = "0.4.10" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "029d74589adefde59de1a0c4f4732695c32805624aec7b68d91503d4dba79afc" +dependencies = [ + "aho-corasick", + "bstr", + "fnv", + "log", + "regex", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -670,6 +754,23 @@ version = "1.0.2" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" +[[package]] +name = "ignore" +version = "0.4.20" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "dbe7873dab538a9a44ad79ede1faf5f30d49f9a5c883ddbab48bce81b64b7492" +dependencies = [ + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.23.14" @@ -694,6 +795,7 @@ dependencies = [ "autocfg", "hashbrown", "rayon", + "serde", ] [[package]] @@ -702,6 +804,15 @@ version = "1.0.7" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "adab1eaa3408fb7f0c777a73e7465fd5656136fc93b670eb6df3c88c2c1344e3" +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + [[package]] name = "is-macro" version = "0.2.1" @@ -715,6 +826,15 @@ dependencies = [ "syn", ] +[[package]] +name = "is_elevated" +version = "0.1.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "5299060ff5db63e788015dcb9525ad9b84f4fd9717ed2cbdeba5018cbf42f9b5" +dependencies = [ + "winapi", +] + [[package]] name = "itertools" version = "0.10.5" @@ -769,6 +889,11 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json_comments" +version = "0.2.1" +source = "git+/~https://github.com/devongovett/json-comments-rs?branch=strip_in_place#95f35683d806e48e0e95f7b425b88f7b9a77f518" + [[package]] name = "lazy_static" version = "1.4.0" @@ -850,9 +975,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.138" +version = "0.2.139" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "db6d7e329c562c5dfab7a46a2afabc8b987ab9a4834c9d1ca04dc54c1546cef8" +checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" [[package]] name = "libdeflate-sys" @@ -1136,9 +1261,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.16.0" +version = "1.17.0" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860" +checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" [[package]] name = "oxipng" @@ -1240,6 +1365,39 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "parcel-resolver" +version = "0.1.0" +dependencies = [ + "assert_fs", + "bitflags", + "dashmap", + "elsa", + "glob-match", + "indexmap", + "is_elevated", + "itertools", + "json_comments", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "typed-arena 2.0.2", + "url", + "xxhash-rust", +] + +[[package]] +name = "parcel-resolver-node" +version = "0.1.0" +dependencies = [ + "dashmap", + "napi", + "napi-build", + "napi-derive", + "parcel-resolver", +] + [[package]] name = "parking_lot" version = "0.12.1" @@ -1382,6 +1540,33 @@ version = "0.1.1" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "predicates" +version = "2.1.5" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "59230a63c37f3e18569bdb90e4a89cbf5bf8b06fea0b84e65ea10cc4df47addd" +dependencies = [ + "difflib", + "itertools", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.5" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "72f883590242d3c6fc5bf50299011695fa6590c2c70eac95ee1bdb9a733ad1a2" + +[[package]] +name = "predicates-tree" +version = "1.0.7" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "54ff541861505aabf6ea722d2131ee980b8276e10a1297b94e896dd8b621850d" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "preset_env_base" version = "0.4.0" @@ -1503,6 +1688,15 @@ version = "0.6.28" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + [[package]] name = "rgb" version = "0.8.34" @@ -1542,6 +1736,15 @@ version = "1.0.11" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -1586,9 +1789,9 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "serde" -version = "1.0.149" +version = "1.0.152" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "256b9932320c590e707b94576e3cc1f7c9024d0ee6612dfbcf1cb106cbe8e055" +checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" dependencies = [ "serde_derive", ] @@ -1627,9 +1830,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.149" +version = "1.0.152" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "b4eae9b04cbffdfd550eb462ed33bc6a1b68c935127d008b27444d08380f94e4" +checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" dependencies = [ "proc-macro2", "quote", @@ -1638,9 +1841,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.89" +version = "1.0.91" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "020ff22c755c2ed3f8cf162dbb41a7268d934702f3ed3631656ea597e08fc3db" +checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" dependencies = [ "itoa", "ryu", @@ -1951,7 +2154,7 @@ dependencies = [ "swc_common", "swc_ecma_ast", "tracing", - "typed-arena 2.0.1", + "typed-arena 2.0.2", ] [[package]] @@ -2317,6 +2520,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.3.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +dependencies = [ + "cfg-if", + "fastrand", + "libc", + "redox_syscall", + "remove_dir_all", + "winapi", +] + [[package]] name = "termcolor" version = "1.1.3" @@ -2326,6 +2543,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.4.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "95059e91184749cb66be6dc994f67f182b6d897cb3df74a5bf66b5e709295fd8" + [[package]] name = "textwrap" version = "0.11.0" @@ -2440,9 +2663,9 @@ checksum = "a9b2228007eba4120145f785df0f6c92ea538f5a3635a612ecf4e334c8c1446d" [[package]] name = "typed-arena" -version = "2.0.1" +version = "2.0.2" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "0685c84d5d54d1c26f7d3eb96cd41550adb97baed141a761cf335d3d33bcd0ae" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" [[package]] name = "typenum" @@ -2512,6 +2735,17 @@ version = "0.9.4" source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + [[package]] name = "wasi" version = "0.10.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 7e966f6ac6c..70e1d0f98d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,5 +5,7 @@ members = [ "packages/transformers/js/wasm", "packages/utils/fs-search", "packages/utils/hash", - "packages/optimizers/image" + "packages/optimizers/image", + "packages/utils/node-resolver-rs", + "packages/utils/node-resolver-core" ] diff --git a/packages/core/core/src/RequestTracker.js b/packages/core/core/src/RequestTracker.js index 8a1af11af8c..9dda42bce41 100644 --- a/packages/core/core/src/RequestTracker.js +++ b/packages/core/core/src/RequestTracker.js @@ -675,7 +675,7 @@ export class RequestGraph extends ContentGraph< requestGraphEdgeTypes.invalidated_by_create_above, ) && isDirectoryInside( - path.dirname(fromProjectPathRelative(matchNode.value.filePath)), + fromProjectPathRelative(matchNode.value.filePath), dirname, ) ) { diff --git a/packages/core/integration-tests/test/cache.js b/packages/core/integration-tests/test/cache.js index 3cfc1b01f3d..526e5fa6b8b 100644 --- a/packages/core/integration-tests/test/cache.js +++ b/packages/core/integration-tests/test/cache.js @@ -3115,6 +3115,15 @@ describe('cache', function () { // $FlowFixMe require(path.join(inputDir, '.pnp.js')); + let pnp = await inputFS.readFile( + path.join(inputDir, '.pnp.js'), + 'utf8', + ); + await inputFS.writeFile( + path.join(inputDir, '.pnp.js'), + pnp.replace("'zipfs',", ''), + ); + await inputFS.mkdirp(path.join(inputDir, 'pnp/testmodule2')); await inputFS.writeFile( path.join(inputDir, 'pnp/testmodule2/index.js'), @@ -3866,10 +3875,15 @@ describe('cache', function () { ` const path = require('path'); const resolve = request => { - if (request === 'parcel-transformer-mock' || request === 'foo') { + if (request === 'parcel-transformer-mock/' || request === 'foo/') { return path.join(__dirname, 'pnp', request); } else if (request === 'pnpapi') { return __filename; + } else if (request.startsWith('@parcel/')) { + // Use node_modules path for parcel packages so source field is used. + return path.join(__dirname, '../../../../../../node_modules/', request); + } else if (/^((@[^/]+\\/[^/]+)|[^/]+)\\/?$/.test(request)) { + return path.dirname(require.resolve(path.join(request, 'package.json'))); } else { return require.resolve(request); } @@ -3906,10 +3920,15 @@ describe('cache', function () { ` const path = require('path'); const resolve = request => { - if (request === 'parcel-transformer-mock' || request === 'foo') { + if (request === 'parcel-transformer-mock/' || request === 'foo/') { return path.join(__dirname, 'pnp2', request); } else if (request === 'pnpapi') { return __filename; + } else if (request.startsWith('@parcel/')) { + // Use node_modules path for parcel packages so source field is used. + return path.join(__dirname, '../../../../../../node_modules/', request); + } else if (/^((@[^/]+\\/[^/]+)|[^/]+)\\/?$/.test(request)) { + return path.dirname(require.resolve(path.join(request, 'package.json'))); } else { return require.resolve(request); } diff --git a/packages/core/integration-tests/test/integration/pnp-builtin/.pnp.js b/packages/core/integration-tests/test/integration/pnp-builtin/.pnp.js index 015b2d028e8..d498aac0ea9 100644 --- a/packages/core/integration-tests/test/integration/pnp-builtin/.pnp.js +++ b/packages/core/integration-tests/test/integration/pnp-builtin/.pnp.js @@ -5,8 +5,12 @@ const resolve = request => { return path.join(__dirname, 'pnp', 'module'); } else if (request === 'pnpapi') { return __filename; + } else if (request.startsWith('@parcel/')) { + // Use node_modules path for parcel packages so source field is used. + return path.join(__dirname, '../../../../../../node_modules/', request); + } else if (/^((@[^/]+\/[^/]+)|[^/]+)\/?$/.test(request)) { + return path.dirname(require.resolve(path.join(request, 'package.json'))); } else { - // The plugins from the parcel config are also resolved through this function return require.resolve(request); } }; diff --git a/packages/core/integration-tests/test/integration/pnp-require/.pnp.js b/packages/core/integration-tests/test/integration/pnp-require/.pnp.js index a12356d8dea..905f2ac3499 100644 --- a/packages/core/integration-tests/test/integration/pnp-require/.pnp.js +++ b/packages/core/integration-tests/test/integration/pnp-require/.pnp.js @@ -1,12 +1,16 @@ const path = require('path'); const resolve = request => { - if (request === 'testmodule') { - return path.join(__dirname, 'pnp', 'testmodule'); + if (request === 'testmodule/') { + return path.join(__dirname, 'zipfs', 'pnp', 'testmodule'); } else if (request === 'pnpapi') { return __filename; + } else if (request.startsWith('@parcel/')) { + // Use node_modules path for parcel packages so source field is used. + return path.join(__dirname, '../../../../../../node_modules/', request); + } else if (/^((@[^/]+\/[^/]+)|[^/]+)\/?$/.test(request)) { + return path.dirname(require.resolve(path.join(request, 'package.json'))); } else { - // The plugins from the parcel config are also resolved through this function return require.resolve(request); } }; diff --git a/packages/core/integration-tests/test/javascript.js b/packages/core/integration-tests/test/javascript.js index 7fae9e94fe8..1a6aafdb2c0 100644 --- a/packages/core/integration-tests/test/javascript.js +++ b/packages/core/integration-tests/test/javascript.js @@ -5467,6 +5467,11 @@ describe('javascript', function () { }, ], }, + { + message: "Cannot find module 'foo'", + origin: '@parcel/resolver-default', + hints: [], + }, ], }, ); diff --git a/packages/core/integration-tests/test/pnp.js b/packages/core/integration-tests/test/pnp.js index af4288a268b..e463785ebbd 100644 --- a/packages/core/integration-tests/test/pnp.js +++ b/packages/core/integration-tests/test/pnp.js @@ -1,7 +1,10 @@ import assert from 'assert'; import Module from 'module'; import path from 'path'; -import {bundle, run, assertBundles} from '@parcel/test-utils'; +import fs from 'fs'; +import {bundle, run, assertBundles, inputFS} from '@parcel/test-utils'; + +const ZIPFS = `${path.sep}zipfs`; describe('pnp', function () { it('should defer to the pnp resolution when needed', async function () { @@ -17,6 +20,21 @@ describe('pnp', function () { ? path.join(dir, '.pnp.js') : origModuleResolveFilename(name, ...args); + let origReadFileSync = inputFS.readFileSync; + inputFS.readFileSync = (p, ...args) => { + return origReadFileSync.call(inputFS, p.replace(ZIPFS, ''), ...args); + }; + + let origRealpathSync = fs.realpathSync; + inputFS.realpathSync = (p, ...args) => { + return origRealpathSync.call(inputFS, p.replace(ZIPFS, ''), ...args); + }; + + let origStatSync = inputFS.statSync; + inputFS.statSync = (p, ...args) => { + return origStatSync.call(inputFS, p.replace(ZIPFS, ''), ...args); + }; + try { let b = await bundle(path.join(dir, 'index.js')); @@ -32,6 +50,9 @@ describe('pnp', function () { } finally { process.versions.pnp = origPnpVersion; Module._resolveFilename = origModuleResolveFilename; + inputFS.readFileSync = origReadFileSync; + inputFS.statSync = origStatSync; + inputFS.realpathSync = origRealpathSync; } }); diff --git a/packages/core/integration-tests/test/resolver.js b/packages/core/integration-tests/test/resolver.js index 8d0dd2a2798..ab78be32367 100644 --- a/packages/core/integration-tests/test/resolver.js +++ b/packages/core/integration-tests/test/resolver.js @@ -258,7 +258,10 @@ describe('resolver', function () { } catch (e) { threw = true; - assert.equal(e.diagnostics[1].message, `Cannot find module @baebal/core`); + assert.equal( + e.diagnostics[1].message, + `Cannot find module '@baebal/core'`, + ); assert.equal( e.diagnostics[1].hints[0], diff --git a/packages/core/package-manager/package.json b/packages/core/package-manager/package.json index e05b4d232ce..86f8bd9659e 100644 --- a/packages/core/package-manager/package.json +++ b/packages/core/package-manager/package.json @@ -22,7 +22,8 @@ }, "scripts": { "build-ts": "mkdir -p lib && flow-to-ts src/types.js > lib/types.d.ts", - "check-ts": "tsc --noEmit index.d.ts" + "check-ts": "tsc --noEmit index.d.ts", + "test": "mocha test" }, "targets": { "types": false, @@ -32,6 +33,7 @@ "@parcel/diagnostic": false, "@parcel/fs": false, "@parcel/logger": false, + "@parcel/node-resolver-core": false, "@parcel/types": false, "@parcel/utils": false, "@parcel/workers": false, @@ -43,6 +45,7 @@ "@parcel/diagnostic": "2.8.3", "@parcel/fs": "2.8.3", "@parcel/logger": "2.8.3", + "@parcel/node-resolver-core": "2.8.3", "@parcel/types": "2.8.3", "@parcel/utils": "2.8.3", "@parcel/workers": "2.8.3", diff --git a/packages/core/package-manager/src/NodePackageManager.js b/packages/core/package-manager/src/NodePackageManager.js index 39e4f5f1380..2a1cedf61d4 100644 --- a/packages/core/package-manager/src/NodePackageManager.js +++ b/packages/core/package-manager/src/NodePackageManager.js @@ -17,6 +17,7 @@ import ThrowableDiagnostic, { generateJSONCodeHighlights, md, } from '@parcel/diagnostic'; +import {NodeFS} from '@parcel/fs'; import nativeFS from 'fs'; import Module from 'module'; import path from 'path'; @@ -26,8 +27,17 @@ import {getModuleParts} from '@parcel/utils'; import {getConflictingLocalDependencies} from './utils'; import {installPackage} from './installPackage'; import pkg from '../package.json'; -import {NodeResolver} from './NodeResolver'; -import {NodeResolverSync} from './NodeResolverSync'; +import {ResolverBase} from '@parcel/node-resolver-core'; + +// Package.json fields. Must match package_json.rs. +const MAIN = 1 << 0; +const SOURCE = 1 << 2; +const ENTRIES = + MAIN | + (process.env.PARCEL_BUILD_ENV !== 'production' || + process.env.PARCEL_SELF_BUILD + ? SOURCE + : 0); // There can be more than one instance of NodePackageManager, but node has only a single module cache. // Therefore, the resolution cache and the map of parent to child modules should also be global. @@ -43,8 +53,7 @@ export class NodePackageManager implements PackageManager { fs: FileSystem; projectRoot: FilePath; installer: ?PackageInstaller; - resolver: NodeResolver; - syncResolver: NodeResolverSync; + resolver: any; invalidationsCache: Map = new Map(); constructor( @@ -55,8 +64,36 @@ export class NodePackageManager implements PackageManager { this.fs = fs; this.projectRoot = projectRoot; this.installer = installer; - this.resolver = new NodeResolver(this.fs, projectRoot); - this.syncResolver = new NodeResolverSync(this.fs, projectRoot); + this.resolver = this._createResolver(); + } + + _createResolver(): any { + return new ResolverBase(this.projectRoot, { + fs: + this.fs instanceof NodeFS && process.versions.pnp == null + ? undefined + : { + canonicalize: path => this.fs.realpathSync(path), + read: path => this.fs.readFileSync(path), + isFile: path => this.fs.statSync(path).isFile(), + isDir: path => this.fs.statSync(path).isDirectory(), + }, + mode: 2, + entries: ENTRIES, + moduleDirResolver: + process.versions.pnp != null + ? (module, from) => { + // $FlowFixMe[prop-missing] + let pnp = Module.findPnpApi(path.dirname(from)); + + return pnp.resolveToUnqualified( + // append slash to force loading builtins from npm + module + '/', + from, + ); + } + : undefined, + }); } static deserialize(opts: any): NodePackageManager { @@ -153,7 +190,7 @@ export class NodePackageManager implements PackageManager { if (!resolved) { let [name] = getModuleParts(id); try { - resolved = await this.resolver.resolve(id, from); + resolved = this.resolveInternal(id, from); } catch (e) { if ( e.code !== 'MODULE_NOT_FOUND' || @@ -187,6 +224,7 @@ export class NodePackageManager implements PackageManager { ); if (conflicts == null) { + this.invalidate(id, from); await this.install([{name, range: options?.range}], from, { saveDev: options?.saveDev ?? true, }); @@ -231,6 +269,7 @@ export class NodePackageManager implements PackageManager { ); if (conflicts == null && options?.shouldAutoInstall === true) { + this.invalidate(id, from); await this.install([{name, range}], from); return this.resolve(id, from, { ...options, @@ -302,7 +341,7 @@ export class NodePackageManager implements PackageManager { let key = basedir + ':' + name; let resolved = cache.get(key); if (!resolved) { - resolved = this.syncResolver.resolve(name, from); + resolved = this.resolveInternal(name, from); cache.set(key, resolved); this.invalidationsCache.clear(); @@ -409,11 +448,59 @@ export class NodePackageManager implements PackageManager { children.delete(resolved.resolved); cache.delete(key); - this.resolver.invalidate(resolved.resolved); - this.syncResolver.invalidate(resolved.resolved); }; invalidate(name, from); + this.resolver = this._createResolver(); + } + + resolveInternal(name: string, from: string): ResolveResult { + let res = this.resolver.resolve({ + filename: name, + specifierType: 'commonjs', + parent: from, + }); + + // Invalidate whenever the .pnp.js file changes. + // TODO: only when we actually resolve a node_modules package? + if (process.versions.pnp != null && res.invalidateOnFileChange) { + // $FlowFixMe[prop-missing] + let pnp = Module.findPnpApi(path.dirname(from)); + res.invalidateOnFileChange.push(pnp.resolveToUnqualified('pnpapi', null)); + } + + if (res.error) { + let e = new Error(`Could not resolve module "${name}" from "${from}"`); + // $FlowFixMe + e.code = 'MODULE_NOT_FOUND'; + throw e; + } + let getPkg; + switch (res.resolution.type) { + case 'Path': + getPkg = () => { + let pkgPath = this.fs.findAncestorFile( + ['package.json'], + res.resolution.value, + this.projectRoot, + ); + return pkgPath + ? JSON.parse(this.fs.readFileSync(pkgPath, 'utf8')) + : null; + }; + // fallthrough + case 'Builtin': + return { + resolved: res.resolution.value, + invalidateOnFileChange: new Set(res.invalidateOnFileChange), + invalidateOnFileCreate: res.invalidateOnFileCreate, + get pkg() { + return getPkg(); + }, + }; + default: + throw new Error('Unknown resolution type'); + } } } diff --git a/packages/core/package-manager/src/NodeResolver.js b/packages/core/package-manager/src/NodeResolver.js deleted file mode 100644 index 74d894af19e..00000000000 --- a/packages/core/package-manager/src/NodeResolver.js +++ /dev/null @@ -1,205 +0,0 @@ -// @flow - -import type {FilePath, DependencySpecifier, PackageJSON} from '@parcel/types'; -import type {ResolverContext} from './NodeResolverBase'; -import type {ResolveResult} from './types'; - -import path from 'path'; -import {NodeResolverBase} from './NodeResolverBase'; - -export class NodeResolver extends NodeResolverBase> { - async resolve( - id: DependencySpecifier, - from: FilePath, - ): Promise { - let ctx = { - invalidateOnFileCreate: [], - invalidateOnFileChange: new Set(), - }; - - if (id[0] === '.') { - id = path.resolve(path.dirname(from), id); - } - - let res = path.isAbsolute(id) - ? await this.loadRelative(id, ctx) - : await this.loadNodeModules(id, from, ctx); - - if (!res) { - let e = new Error(`Could not resolve module "${id}" from "${from}"`); - // $FlowFixMe[prop-missing] - e.code = 'MODULE_NOT_FOUND'; - throw e; - } - - if (path.isAbsolute(res.resolved)) { - res.resolved = await this.fs.realpath(res.resolved); - } - - return res; - } - - async loadRelative( - id: FilePath, - ctx: ResolverContext, - ): Promise { - // First try as a file, then as a directory. - return ( - (await this.loadAsFile(id, null, ctx)) || - (await this.loadDirectory(id, null, ctx)) // eslint-disable-line no-return-await - ); - } - - findPackage( - sourceFile: FilePath, - ctx: ResolverContext, - ): Promise { - // If in node_modules, take a shortcut to find the package.json in the root of the package. - let pkgPath = this.getNodeModulesPackagePath(sourceFile); - if (pkgPath) { - return this.readPackage(pkgPath, ctx); - } - - ctx.invalidateOnFileCreate.push({ - fileName: 'package.json', - aboveFilePath: sourceFile, - }); - - let dir = path.dirname(sourceFile); - let pkgFile = this.fs.findAncestorFile( - ['package.json'], - dir, - this.projectRoot, - ); - if (pkgFile != null) { - return this.readPackage(pkgFile, ctx); - } - - return Promise.resolve(null); - } - - async readPackage( - file: FilePath, - ctx: ResolverContext, - ): Promise { - let cached = this.packageCache.get(file); - - if (cached) { - ctx.invalidateOnFileChange.add(file); - return cached; - } - - let json; - try { - json = await this.fs.readFile(file, 'utf8'); - } catch (err) { - ctx.invalidateOnFileCreate.push({ - filePath: file, - }); - throw err; - } - - // Add the invalidation *before* we try to parse the JSON in case of errors - // so that changes are picked up if the file is edited to fix the error. - ctx.invalidateOnFileChange.add(file); - - let pkg = JSON.parse(json); - this.packageCache.set(file, pkg); - return pkg; - } - - async loadAsFile( - file: FilePath, - pkg: ?PackageJSON, - ctx: ResolverContext, - ): Promise { - // Try all supported extensions - let files = this.expandFile(file); - let found = this.fs.findFirstFile(files); - - // Add invalidations for higher priority files so we - // re-resolve if any of them are created. - for (let file of files) { - if (file === found) { - break; - } - - ctx.invalidateOnFileCreate.push({ - filePath: file, - }); - } - - if (found) { - return { - resolved: await this.fs.realpath(found), - // Find a package.json file in the current package. - pkg: pkg ?? (await this.findPackage(file, ctx)), - invalidateOnFileCreate: ctx.invalidateOnFileCreate, - invalidateOnFileChange: ctx.invalidateOnFileChange, - }; - } - - return null; - } - - async loadDirectory( - dir: FilePath, - pkg: ?PackageJSON = null, - ctx: ResolverContext, - ): Promise { - try { - pkg = await this.readPackage(path.join(dir, 'package.json'), ctx); - - // Get a list of possible package entry points. - let entries = this.getPackageEntries(dir, pkg); - - for (let file of entries) { - // First try loading package.main as a file, then try as a directory. - const res = - (await this.loadAsFile(file, pkg, ctx)) || - (await this.loadDirectory(file, pkg, ctx)); - if (res) { - return res; - } - } - } catch (err) { - // ignore - } - - // Fall back to an index file inside the directory. - return this.loadAsFile(path.join(dir, 'index'), pkg, ctx); - } - - async loadNodeModules( - id: DependencySpecifier, - from: FilePath, - ctx: ResolverContext, - ): Promise { - try { - let module = this.findNodeModulePath(id, from, ctx); - if (!module || module.resolved) { - return module; - } - - // If a module was specified as a module sub-path (e.g. some-module/some/path), - // it is likely a file. Try loading it as a file first. - if (module.subPath) { - let pkg = await this.readPackage( - path.join(module.moduleDir, 'package.json'), - ctx, - ); - let res = await this.loadAsFile(module.filePath, pkg, ctx); - if (res) { - return res; - } - } - - // Otherwise, load as a directory. - if (module.filePath) { - return await this.loadDirectory(module.filePath, null, ctx); - } - } catch (e) { - // ignore - } - } -} diff --git a/packages/core/package-manager/src/NodeResolverBase.js b/packages/core/package-manager/src/NodeResolverBase.js deleted file mode 100644 index e79c5b3f9bf..00000000000 --- a/packages/core/package-manager/src/NodeResolverBase.js +++ /dev/null @@ -1,194 +0,0 @@ -// @flow - -import type { - PackageJSON, - FileCreateInvalidation, - FilePath, - DependencySpecifier, -} from '@parcel/types'; -import type {FileSystem} from '@parcel/fs'; -import type {ResolveResult} from './types'; - -// $FlowFixMe -import Module from 'module'; -import path from 'path'; -import invariant from 'assert'; -import {getModuleParts} from '@parcel/utils'; - -const builtins = {pnpapi: true}; -for (let builtin of Module.builtinModules) { - builtins[builtin] = true; -} - -export type ModuleInfo = {| - moduleName: string, - subPath: ?string, - moduleDir: FilePath, - filePath: FilePath, - code?: string, -|}; - -export type ResolverContext = {| - invalidateOnFileCreate: Array, - invalidateOnFileChange: Set, -|}; - -const NODE_MODULES = `${path.sep}node_modules${path.sep}`; - -export class NodeResolverBase { - fs: FileSystem; - extensions: Array; - packageCache: Map; - projectRoot: FilePath; - - constructor( - fs: FileSystem, - projectRoot: FilePath, - extensions?: Array, - ) { - this.fs = fs; - this.projectRoot = projectRoot; - this.extensions = - extensions || - // $FlowFixMe[prop-missing] - Object.keys(Module._extensions); - this.packageCache = new Map(); - } - - resolve(id: DependencySpecifier, from: FilePath): T { - throw new Error(`Could not resolve "${id}" from "${from}"`); - } - - expandFile(file: FilePath): Array { - // Expand extensions and aliases - let res = []; - for (let ext of this.extensions) { - let f = file + ext; - res.push(f); - } - - if (path.extname(file)) { - res.unshift(file); - } else { - res.push(file); - } - - return res; - } - - getPackageEntries(dir: FilePath, pkg: PackageJSON): Array { - let main = pkg.main; - if ( - (process.env.PARCEL_BUILD_ENV !== 'production' || - process.env.PARCEL_SELF_BUILD) && - typeof pkg.name === 'string' && - typeof pkg.source === 'string' && - pkg.name.startsWith('@parcel/') && - pkg.name !== '@parcel/watcher' - ) { - main = pkg.source; - } - - return [main] - .filter(entry => typeof entry === 'string') - .map(main => { - // Default to index file if no main field find - if (!main || main === '.' || main === './') { - main = 'index'; - } - - invariant(typeof main === 'string'); - return path.resolve(dir, main); - }); - } - - isBuiltin(name: DependencySpecifier): boolean { - return !!(builtins[name] || name.startsWith('node:')); - } - - findNodeModulePath( - id: DependencySpecifier, - sourceFile: FilePath, - ctx: ResolverContext, - ): ?ResolveResult | ?ModuleInfo { - if (this.isBuiltin(id)) { - return { - resolved: id, - invalidateOnFileChange: new Set(), - invalidateOnFileCreate: [], - }; - } - - let [moduleName, subPath] = getModuleParts(id); - let dir = path.dirname(sourceFile); - let moduleDir = this.fs.findNodeModule(moduleName, dir); - - ctx.invalidateOnFileCreate.push({ - fileName: `node_modules/${moduleName}`, - aboveFilePath: sourceFile, - }); - - if (!moduleDir && process.versions.pnp != null) { - try { - // $FlowFixMe[prop-missing] - let pnp = Module.findPnpApi(dir + '/'); - moduleDir = pnp.resolveToUnqualified( - moduleName + - // retain slash in `require('assert/')` to force loading builtin from npm - (id[moduleName.length] === '/' ? '/' : ''), - dir + '/', - ); - - // Invalidate whenever the .pnp.js file changes. - ctx.invalidateOnFileChange.add( - pnp.resolveToUnqualified('pnpapi', null), - ); - } catch (e) { - if (e.code !== 'MODULE_NOT_FOUND') { - throw e; - } - } - } - - if (moduleDir) { - return { - moduleName, - subPath, - moduleDir: moduleDir, - filePath: subPath ? path.join(moduleDir, subPath) : moduleDir, - }; - } - - return null; - } - - getNodeModulesPackagePath(sourceFile: FilePath): ?FilePath { - // If the file is in node_modules, we can find the package.json in the root of the package - // by slicing from the start of the string until 1-2 path segments after node_modules. - let index = sourceFile.lastIndexOf(NODE_MODULES); - if (index >= 0) { - index += NODE_MODULES.length; - - // If a scoped path, add an extra path segment. - if (sourceFile[index] === '@') { - index = sourceFile.indexOf(path.sep, index) + 1; - } - - index = sourceFile.indexOf(path.sep, index); - return path.join( - sourceFile.slice(0, index >= 0 ? index : undefined), - 'package.json', - ); - } - } - - invalidate(filePath: FilePath) { - // Invalidate the package.jsons above `filePath` - let dir = path.dirname(filePath); - let {root} = path.parse(dir); - while (dir !== root && path.basename(dir) !== 'node_modules') { - this.packageCache.delete(path.join(dir, 'package.json')); - dir = path.dirname(dir); - } - } -} diff --git a/packages/core/package-manager/src/NodeResolverSync.js b/packages/core/package-manager/src/NodeResolverSync.js deleted file mode 100644 index 7db4433e5ba..00000000000 --- a/packages/core/package-manager/src/NodeResolverSync.js +++ /dev/null @@ -1,184 +0,0 @@ -// @flow - -import type {FilePath, DependencySpecifier, PackageJSON} from '@parcel/types'; -import type {ResolverContext} from './NodeResolverBase'; -import type {ResolveResult} from './types'; - -import path from 'path'; -import {NodeResolverBase} from './NodeResolverBase'; - -export class NodeResolverSync extends NodeResolverBase { - resolve(id: DependencySpecifier, from: FilePath): ResolveResult { - let ctx = { - invalidateOnFileCreate: [], - invalidateOnFileChange: new Set(), - }; - - if (id[0] === '.') { - id = path.resolve(path.dirname(from), id); - } - - let res = path.isAbsolute(id) - ? this.loadRelative(id, ctx) - : this.loadNodeModules(id, from, ctx); - - if (!res) { - let e = new Error(`Could not resolve module "${id}" from "${from}"`); - // $FlowFixMe - e.code = 'MODULE_NOT_FOUND'; - throw e; - } - - if (path.isAbsolute(res.resolved)) { - res.resolved = this.fs.realpathSync(res.resolved); - } - - return res; - } - - loadRelative(id: FilePath, ctx: ResolverContext): ?ResolveResult { - // First try as a file, then as a directory. - return this.loadAsFile(id, null, ctx) || this.loadDirectory(id, null, ctx); - } - - findPackage(sourceFile: FilePath, ctx: ResolverContext): ?PackageJSON { - // If in node_modules, take a shortcut to find the package.json in the root of the package. - let pkgPath = this.getNodeModulesPackagePath(sourceFile); - if (pkgPath) { - return this.readPackage(pkgPath, ctx); - } - - // Find the nearest package.json file within the current node_modules folder - let dir = path.dirname(sourceFile); - let pkgFile = this.fs.findAncestorFile( - ['package.json'], - dir, - this.projectRoot, - ); - if (pkgFile != null) { - return this.readPackage(pkgFile, ctx); - } - } - - readPackage(file: FilePath, ctx: ResolverContext): PackageJSON { - let cached = this.packageCache.get(file); - - if (cached) { - ctx.invalidateOnFileChange.add(file); - return cached; - } - - let json; - try { - json = this.fs.readFileSync(file, 'utf8'); - } catch (err) { - ctx.invalidateOnFileCreate.push({ - filePath: file, - }); - throw err; - } - - // Add the invalidation *before* we try to parse the JSON in case of errors - // so that changes are picked up if the file is edited to fix the error. - ctx.invalidateOnFileChange.add(file); - - let pkg = JSON.parse(json); - - this.packageCache.set(file, pkg); - return pkg; - } - - loadAsFile( - file: FilePath, - pkg: ?PackageJSON, - ctx: ResolverContext, - ): ?ResolveResult { - // Try all supported extensions - let files = this.expandFile(file); - let found = this.fs.findFirstFile(files); - - // Add invalidations for higher priority files so we - // re-resolve if any of them are created. - for (let file of files) { - if (file === found) { - break; - } - - ctx.invalidateOnFileCreate.push({ - filePath: file, - }); - } - - if (found) { - return { - resolved: this.fs.realpathSync(found), - // Find a package.json file in the current package. - pkg: pkg ?? this.findPackage(file, ctx), - invalidateOnFileCreate: ctx.invalidateOnFileCreate, - invalidateOnFileChange: ctx.invalidateOnFileChange, - }; - } - - return null; - } - - loadDirectory( - dir: FilePath, - pkg: ?PackageJSON = null, - ctx: ResolverContext, - ): ?ResolveResult { - try { - pkg = this.readPackage(path.join(dir, 'package.json'), ctx); - - // Get a list of possible package entry points. - let entries = this.getPackageEntries(dir, pkg); - - for (let file of entries) { - // First try loading package.main as a file, then try as a directory. - const res = - this.loadAsFile(file, pkg, ctx) || this.loadDirectory(file, pkg, ctx); - if (res) { - return res; - } - } - } catch (err) { - // ignore - } - - // Fall back to an index file inside the directory. - return this.loadAsFile(path.join(dir, 'index'), pkg, ctx); - } - - loadNodeModules( - id: DependencySpecifier, - from: FilePath, - ctx: ResolverContext, - ): ?ResolveResult { - try { - let module = this.findNodeModulePath(id, from, ctx); - if (!module || module.resolved) { - return module; - } - - // If a module was specified as a module sub-path (e.g. some-module/some/path), - // it is likely a file. Try loading it as a file first. - if (module.subPath) { - let pkg = this.readPackage( - path.join(module.moduleDir, 'package.json'), - ctx, - ); - let res = this.loadAsFile(module.filePath, pkg, ctx); - if (res) { - return res; - } - } - - // Otherwise, load as a directory. - if (module.filePath) { - return this.loadDirectory(module.filePath, null, ctx); - } - } catch (e) { - // ignore - } - } -} diff --git a/packages/core/package-manager/test/NodePackageManager.test.js b/packages/core/package-manager/test/NodePackageManager.test.js index 905d2ce50df..99a2bd69a83 100644 --- a/packages/core/package-manager/test/NodePackageManager.test.js +++ b/packages/core/package-manager/test/NodePackageManager.test.js @@ -52,7 +52,7 @@ describe('NodePackageManager', function () { invalidateOnFileCreate: [ { fileName: 'node_modules/foo', - aboveFilePath: path.join(FIXTURES_DIR, 'has-foo/index.js'), + aboveFilePath: path.join(FIXTURES_DIR, 'has-foo'), }, ], }, @@ -89,7 +89,7 @@ describe('NodePackageManager', function () { invalidateOnFileCreate: [ { fileName: 'node_modules/a', - aboveFilePath: path.join(FIXTURES_DIR, 'has-foo/index.js'), + aboveFilePath: path.join(FIXTURES_DIR, 'has-foo'), }, ], }, @@ -244,10 +244,7 @@ describe('NodePackageManager', function () { invalidateOnFileCreate: [ { fileName: 'node_modules/foo', - aboveFilePath: path.join( - FIXTURES_DIR, - 'has-foo/subpackage/index.js', - ), + aboveFilePath: path.join(FIXTURES_DIR, 'has-foo/subpackage'), }, ], }, diff --git a/packages/core/utils/src/alternatives.js b/packages/core/utils/src/alternatives.js index 14f2740a0f0..2c772782a59 100644 --- a/packages/core/utils/src/alternatives.js +++ b/packages/core/utils/src/alternatives.js @@ -42,6 +42,8 @@ export async function findAlternativeNodeModules( potentialModules.push(...orgDirContent.map(i => `${item}/${i}`)); }), ); + } else { + potentialModules.push(...modules); } } } catch (err) { diff --git a/packages/resolvers/default/src/DefaultResolver.js b/packages/resolvers/default/src/DefaultResolver.js index 923f5837b8e..9581142abc3 100644 --- a/packages/resolvers/default/src/DefaultResolver.js +++ b/packages/resolvers/default/src/DefaultResolver.js @@ -18,13 +18,6 @@ export default (new Resolver({ const resolver = new NodeResolver({ fs: options.inputFS, projectRoot: options.projectRoot, - // Extensions are always required in URL dependencies. - extensions: - dependency.specifierType === 'commonjs' || - dependency.specifierType === 'esm' - ? ['ts', 'tsx', 'mjs', 'js', 'jsx', 'cjs', 'json'] - : [], - mainFields: ['source', 'browser', 'module', 'main'], packageManager: options.packageManager, shouldAutoInstall: options.shouldAutoInstall, logger, diff --git a/packages/resolvers/glob/src/GlobResolver.js b/packages/resolvers/glob/src/GlobResolver.js index e5b2d556087..23bae8a10a6 100644 --- a/packages/resolvers/glob/src/GlobResolver.js +++ b/packages/resolvers/glob/src/GlobResolver.js @@ -81,35 +81,20 @@ export default (new Resolver({ const resolver = new NodeResolver({ fs: options.inputFS, projectRoot: options.projectRoot, - // Extensions are always required in URL dependencies. - extensions: - dependency.specifierType === 'commonjs' || - dependency.specifierType === 'esm' - ? ['ts', 'tsx', 'js', 'jsx', 'json'] - : [], - mainFields: ['source', 'browser', 'module', 'main'], packageManager: options.shouldAutoInstall ? options.packageManager : undefined, logger, }); - let ctx = { - invalidateOnFileCreate, - invalidateOnFileChange, - specifierType: dependency.specifierType, - loc: dependency.loc, - range: dependency.range, - }; - let result; try { - result = await resolver.resolveModule({ - filename: pkg, + result = await resolver.resolve({ + filename: pkg + '/package.json', parent: dependency.resolveFrom, + specifierType: 'esm', env: dependency.env, sourcePath: dependency.sourcePath, - ctx, }); } catch (err) { if (err instanceof ThrowableDiagnostic) { @@ -124,14 +109,22 @@ export default (new Resolver({ } } - if (!result || !result.moduleDir) { + if (!result || !result.filePath) { throw errorToThrowableDiagnostic( `Unable to resolve ${pkg} from ${sourceFile} when resolving specifier ${specifier}`, dependency, ); } - specifier = path.resolve(result.moduleDir, rest); + specifier = path.resolve(path.dirname(result.filePath), rest); + if (result.invalidateOnFileChange) { + for (let f of result.invalidateOnFileChange) { + invalidateOnFileChange.add(f); + } + } + if (result.invalidateOnFileCreate) { + invalidateOnFileCreate.push(...result.invalidateOnFileCreate); + } } else { specifier = path.resolve(path.dirname(sourceFile), specifier); } @@ -141,7 +134,7 @@ export default (new Resolver({ onlyFiles: true, }); - let dir = path.dirname(specifier); + let dir = path.dirname(sourceFile); let results = files.map(file => { let relative = relativePath(dir, file); if (pipeline) { diff --git a/packages/utils/node-resolver-core/.gitignore b/packages/utils/node-resolver-core/.gitignore index 7ed92ab6e96..3aa8f25a759 100644 --- a/packages/utils/node-resolver-core/.gitignore +++ b/packages/utils/node-resolver-core/.gitignore @@ -1,2 +1,5 @@ !/test/fixture/node_modules !/test/fixture/node_modules/.pnpm/*/node_modules +*.node +index.d.ts +index.js diff --git a/packages/utils/node-resolver-core/Cargo.toml b/packages/utils/node-resolver-core/Cargo.toml new file mode 100644 index 00000000000..e833cfd35e1 --- /dev/null +++ b/packages/utils/node-resolver-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +authors = ["Devon Govett "] +name = "parcel-resolver-node" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi = { version = "2.10.6", features = ["serde-json"] } +napi-derive = "2.9.4" +parcel-resolver = { path = "../node-resolver-rs" } +dashmap = "5.4.0" + +[build-dependencies] +napi-build = "2" diff --git a/packages/utils/node-resolver-core/build.rs b/packages/utils/node-resolver-core/build.rs new file mode 100644 index 00000000000..1f866b6a3c3 --- /dev/null +++ b/packages/utils/node-resolver-core/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/packages/utils/node-resolver-core/index.js.flow b/packages/utils/node-resolver-core/index.js.flow new file mode 100644 index 00000000000..d8b8599c7ce --- /dev/null +++ b/packages/utils/node-resolver-core/index.js.flow @@ -0,0 +1,41 @@ +// @flow +import type {FileCreateInvalidation} from '@parcel/types'; + +export interface JsFileSystemOptions { + canonicalize: (string) => string, + read: (string) => Buffer, + isFile: (string) => boolean, + isDir: (string) => boolean, + includeNodeModules?: boolean | Array | {|[string]: boolean|} +} +export interface ResolverOptions { + fs?: JsFileSystemOptions, + includeNodeModules?: boolean | Array | {|[string]: boolean|}, + conditions?: number, + moduleDirResolver?: (...args: any[]) => any, + mode: number, + entries?: number +} +export interface ResolveOptions { + filename: string, + specifierType: string, + parent: string +} +export interface ResolveResult { + resolution: Resolution, + invalidateOnFileChange: Array, + invalidateOnFileCreate: Array, + query?: string, + sideEffects: boolean, + error: mixed +} +export type Resolution = + | {|type: 'path', value: string|} + | {|type: 'builtin', value: string|} + | {|type: 'external'|} + | {|type: 'empty'|} + | {|type: 'global', value: string|}; +declare export class Resolver { + constructor(projectRoot: string, options: ResolverOptions): Resolver, + resolve(options: ResolveOptions): ResolveResult +} diff --git a/packages/utils/node-resolver-core/package.json b/packages/utils/node-resolver-core/package.json index e43924f7024..cfc70020a7b 100644 --- a/packages/utils/node-resolver-core/package.json +++ b/packages/utils/node-resolver-core/package.json @@ -13,13 +13,23 @@ "type": "git", "url": "/~https://github.com/parcel-bundler/parcel.git" }, - "main": "lib/NodeResolver.js", - "source": "src/NodeResolver.js", + "main": "lib/index.js", + "source": "src/index.js", "engines": { "node": ">= 12.0.0" }, + "scripts": { + "build": "napi build --platform", + "build-release": "napi build --platform --release", + "test": "mocha test" + }, + "napi": { + "name": "parcel-resolver" + }, "dependencies": { + "@mischnic/json-sourcemap": "^0.1.0", "@parcel/diagnostic": "2.8.3", + "@parcel/fs": "2.8.3", "@parcel/utils": "2.8.3", "nullthrows": "^1.1.1", "semver": "^5.7.1" diff --git a/packages/utils/node-resolver-core/src/NodeResolver.js b/packages/utils/node-resolver-core/src/NodeResolver.js deleted file mode 100644 index e2dd444429e..00000000000 --- a/packages/utils/node-resolver-core/src/NodeResolver.js +++ /dev/null @@ -1,1363 +0,0 @@ -// @flow -import type { - FilePath, - FileCreateInvalidation, - PackageJSON, - ResolveResult, - Environment, - SpecifierType, - PluginLogger, - SourceLocation, - SemverRange, -} from '@parcel/types'; -import type {FileSystem} from '@parcel/fs'; -import type {PackageManager} from '@parcel/package-manager'; - -import invariant from 'assert'; -import path from 'path'; -import { - isGlob, - relativePath, - normalizeSeparators, - findAlternativeNodeModules, - findAlternativeFiles, - loadConfig, - getModuleParts, - globToRegex, - isGlobMatch, -} from '@parcel/utils'; -import ThrowableDiagnostic, { - encodeJSONKeyComponent, - errorToDiagnostic, - generateJSONCodeHighlights, - md, -} from '@parcel/diagnostic'; -import builtins, {empty} from './builtins'; -import nullthrows from 'nullthrows'; -import _Module from 'module'; -import {fileURLToPath} from 'url'; -import semver from 'semver'; - -const EMPTY_SHIM = require.resolve('./_empty'); - -type InternalPackageJSON = PackageJSON & {pkgdir: string, pkgfile: string, ...}; -type Options = {| - fs: FileSystem, - projectRoot: FilePath, - extensions: Array, - mainFields: Array, - packageManager?: PackageManager, - logger?: PluginLogger, - shouldAutoInstall?: boolean, -|}; -type ResolvedFile = {| - path: string, - pkg: InternalPackageJSON | null, -|}; - -type Aliases = string | {+[string]: string | boolean | {|global: string|}, ...}; -type ResolvedAlias = {| - type: 'file' | 'global', - sourcePath: FilePath, - resolved: string, -|}; -type Module = {| - moduleName?: string, - subPath?: ?string, - moduleDir?: FilePath, - filePath?: FilePath, - code?: string, - query?: URLSearchParams, -|}; - -type ResolverContext = {| - invalidateOnFileCreate: Array, - invalidateOnFileChange: Set, - specifierType: SpecifierType, - range: ?SemverRange, - loc: ?SourceLocation, -|}; - -/** - * This resolver implements a modified version of the node_modules resolution algorithm: - * https://nodejs.org/api/modules.html#modules_all_together - * - * In addition to the standard algorithm, Parcel supports: - * - All file extensions supported by Parcel. - * - Glob file paths - * - Absolute paths (e.g. /foo) resolved relative to the project root. - * - Tilde paths (e.g. ~/foo) resolved relative to the nearest module root in node_modules. - * - The package.json module, jsnext:main, and browser field as replacements for package.main. - * - The package.json browser and alias fields as an alias map within a local module. - * - The package.json alias field in the root package for global aliases across all modules. - */ -export default class NodeResolver { - fs: FileSystem; - projectRoot: FilePath; - extensions: Array; - mainFields: Array; - packageCache: Map; - rootPackage: InternalPackageJSON | null; - packageManager: ?PackageManager; - shouldAutoInstall: boolean; - logger: ?PluginLogger; - - constructor(opts: Options) { - this.extensions = opts.extensions.map(ext => - ext.startsWith('.') ? ext : '.' + ext, - ); - this.mainFields = opts.mainFields; - this.fs = opts.fs; - this.projectRoot = opts.projectRoot; - this.packageCache = new Map(); - this.rootPackage = null; - this.packageManager = opts.packageManager; - this.shouldAutoInstall = opts.shouldAutoInstall ?? false; - this.logger = opts.logger; - } - - async resolve({ - filename, - parent, - specifierType, - range, - env, - sourcePath, - loc, - }: {| - filename: FilePath, - parent: ?FilePath, - specifierType: SpecifierType, - range?: ?SemverRange, - env: Environment, - sourcePath?: ?FilePath, - loc?: ?SourceLocation, - |}): Promise { - let ctx = { - invalidateOnFileCreate: [], - invalidateOnFileChange: new Set(), - specifierType, - range, - loc, - }; - - // Get file extensions to search - let extensions = this.extensions.slice(); - - if (parent) { - // parent's extension given high priority - let parentExt = path.extname(parent); - extensions = [parentExt, ...extensions.filter(ext => ext !== parentExt)]; - } - - extensions.unshift(''); - - try { - // Resolve the module directory or local file path - let module = await this.resolveModule({ - filename, - parent, - env, - ctx, - sourcePath, - }); - - if (!module) { - return { - isExcluded: true, - }; - } - - let resolved; - if (module.moduleDir) { - resolved = await this.loadNodeModules(module, extensions, env, ctx); - } else if (module.filePath) { - if (module.code != null) { - return { - filePath: await this.fs.realpath(module.filePath), - code: module.code, - invalidateOnFileCreate: ctx.invalidateOnFileCreate, - invalidateOnFileChange: [...ctx.invalidateOnFileChange], - query: module.query, - }; - } - - resolved = await this.loadRelative( - module.filePath, - extensions, - env, - parent ? path.dirname(parent) : this.projectRoot, - ctx, - ); - } - - if (resolved) { - let _resolved = resolved; // For Flow - return { - filePath: await this.fs.realpath(_resolved.path), - sideEffects: - _resolved.pkg && !this.hasSideEffects(_resolved.path, _resolved.pkg) - ? false - : undefined, - invalidateOnFileCreate: ctx.invalidateOnFileCreate, - invalidateOnFileChange: [...ctx.invalidateOnFileChange], - query: module.query, - }; - } - } catch (err) { - if (err instanceof ThrowableDiagnostic) { - return { - diagnostics: err.diagnostics, - invalidateOnFileCreate: ctx.invalidateOnFileCreate, - invalidateOnFileChange: [...ctx.invalidateOnFileChange], - }; - } else { - throw err; - } - } - - return null; - } - - async resolveModule({ - filename, - parent, - env, - ctx, - sourcePath, - }: {| - filename: string, - parent: ?FilePath, - env: Environment, - ctx: ResolverContext, - sourcePath: ?FilePath, - |}): Promise { - let specifier = filename; - let sourceFile = parent || path.join(this.projectRoot, 'index'); - let query; - - // If this isn't the entrypoint, resolve the input file to an absolute path - if (parent) { - let res = await this.resolveFilename( - filename, - path.dirname(sourceFile), - ctx.specifierType, - ); - - if (!res) { - return null; - } - - filename = res.filePath; - query = res.query; - } - - // Resolve aliases in the parent module for this file. - let alias = await this.loadAlias(filename, sourceFile, env, ctx); - if (alias) { - if (alias.type === 'global') { - return { - filePath: path.join(this.projectRoot, `${alias.resolved}.js`), - code: `module.exports=${alias.resolved};`, - query, - }; - } - filename = alias.resolved; - } - - // Return just the file path if this is a file, not in node_modules - if (path.isAbsolute(filename)) { - return { - filePath: filename, - query, - }; - } - - let builtin = this.findBuiltin(filename, env); - if (builtin === null) { - return null; - } else if (builtin && builtin.name === empty) { - return {filePath: empty}; - } else if (builtin !== undefined) { - filename = builtin.name; - } - - if (this.shouldIncludeNodeModule(env, filename) === false) { - if (sourcePath && env.isLibrary && !builtin) { - await this.checkExcludedDependency(sourcePath, filename, ctx); - } - return null; - } - - // Resolve the module in node_modules - let resolved: ?Module; - try { - resolved = this.findNodeModulePath( - filename, - !builtin ? sourceFile : path.join(this.projectRoot, 'index'), - ctx, - ); - } catch (err) { - // ignore - } - - // Autoinstall/verify version of builtin polyfills - if (builtin?.range != null) { - // This assumes that there are no polyfill packages that are scoped - // Append '/' to force this.packageManager to look up the package in node_modules - let packageName = builtin.name.split('/')[0] + '/'; - let packageManager = this.packageManager; - if (resolved == null) { - // Auto install the Node builtin polyfills - if (this.shouldAutoInstall && packageManager) { - this.logger?.warn({ - message: md`Auto installing polyfill for Node builtin module "${specifier}"...`, - codeFrames: [ - { - filePath: ctx.loc?.filePath ?? sourceFile, - codeHighlights: ctx.loc - ? [ - { - message: 'used here', - start: ctx.loc.start, - end: ctx.loc.end, - }, - ] - : [], - }, - ], - documentationURL: - 'https://parceljs.org/features/node-emulation/#polyfilling-%26-excluding-builtin-node-modules', - }); - - await packageManager.resolve( - packageName, - this.projectRoot + '/index', - { - saveDev: true, - shouldAutoInstall: true, - range: builtin.range, - }, - ); - - // Re-resolve - try { - resolved = this.findNodeModulePath( - filename, - this.projectRoot + '/index', - ctx, - ); - } catch (err) { - // ignore - } - } else { - throw new ThrowableDiagnostic({ - diagnostic: { - message: md`Node builtin polyfill "${packageName}" is not installed, but auto install is disabled.`, - codeFrames: [ - { - filePath: ctx.loc?.filePath ?? sourceFile, - codeHighlights: ctx.loc - ? [ - { - message: 'used here', - start: ctx.loc.start, - end: ctx.loc.end, - }, - ] - : [], - }, - ], - documentationURL: - 'https://parceljs.org/features/node-emulation/#polyfilling-%26-excluding-builtin-node-modules', - hints: [ - md`Install the "${packageName}" package with your package manager, and run Parcel again.`, - ], - }, - }); - } - } else if (builtin.range != null) { - // Assert correct version - try { - // TODO packageManager can be null for backwards compatibility, but that could cause invalid - // resolutions in monorepos - await packageManager?.resolve( - packageName, - this.projectRoot + '/index', - { - saveDev: true, - shouldAutoInstall: this.shouldAutoInstall, - range: builtin.range, - }, - ); - } catch (e) { - this.logger?.warn(errorToDiagnostic(e)); - } - } - } - - if (resolved === undefined && process.versions.pnp != null && parent) { - try { - let [moduleName, subPath] = getModuleParts(filename); - // $FlowFixMe[prop-missing] - let pnp = _Module.findPnpApi(path.dirname(parent)); - - let res = pnp.resolveToUnqualified( - moduleName + - // retain slash in `require('assert/')` to force loading builtin from npm - (filename[moduleName.length] === '/' ? '/' : ''), - parent, - ); - - resolved = { - moduleName, - subPath, - moduleDir: res, - filePath: path.join(res, subPath || ''), - }; - - // Invalidate whenever the .pnp.js file changes. - ctx.invalidateOnFileChange.add( - pnp.resolveToUnqualified('pnpapi', null), - ); - } catch (e) { - if (e.code !== 'MODULE_NOT_FOUND') { - return null; - } - } - } - - // If we couldn't resolve the node_modules path, just return the module name info - if (resolved === undefined) { - let [moduleName, subPath] = getModuleParts(filename); - resolved = ({ - moduleName, - subPath, - }: Module); - - let alternativeModules = await findAlternativeNodeModules( - this.fs, - moduleName, - path.dirname(sourceFile), - ); - - if (alternativeModules.length) { - throw new ThrowableDiagnostic({ - diagnostic: { - message: md`Cannot find module ${nullthrows(resolved?.moduleName)}`, - hints: alternativeModules.map(r => { - return `Did you mean '__${r}__'?`; - }), - }, - }); - } - } - - if (resolved != null) { - resolved.query = query; - } - - return resolved; - } - - shouldIncludeNodeModule( - {includeNodeModules}: Environment, - name: string, - ): ?boolean { - if (includeNodeModules === false) { - return false; - } - - if (Array.isArray(includeNodeModules)) { - let [moduleName] = getModuleParts(name); - return includeNodeModules.includes(moduleName); - } - - if (includeNodeModules && typeof includeNodeModules === 'object') { - let [moduleName] = getModuleParts(name); - let include = includeNodeModules[moduleName]; - if (include != null) { - return !!include; - } - } - } - - async checkExcludedDependency( - sourceFile: FilePath, - name: string, - ctx: ResolverContext, - ) { - let [moduleName] = getModuleParts(name); - let pkg = await this.findPackage(sourceFile, ctx); - if (!pkg) { - return; - } - - if ( - !pkg.dependencies?.[moduleName] && - !pkg.peerDependencies?.[moduleName] && - !pkg.engines?.[moduleName] - ) { - let pkgContent = await this.fs.readFile(pkg.pkgfile, 'utf8'); - throw new ThrowableDiagnostic({ - diagnostic: { - message: md`External dependency "${moduleName}" is not declared in package.json.`, - codeFrames: [ - { - filePath: pkg.pkgfile, - language: 'json', - code: pkgContent, - codeHighlights: pkg.dependencies - ? generateJSONCodeHighlights(pkgContent, [ - { - key: `/dependencies`, - type: 'key', - }, - ]) - : [ - { - start: { - line: 1, - column: 1, - }, - end: { - line: 1, - column: 1, - }, - }, - ], - }, - ], - hints: [`Add "${moduleName}" as a dependency.`], - }, - }); - } - - if (ctx.range) { - let range = ctx.range; - let depRange = - pkg.dependencies?.[moduleName] || pkg.peerDependencies?.[moduleName]; - if (depRange && !semver.intersects(depRange, range)) { - let pkgContent = await this.fs.readFile(pkg.pkgfile, 'utf8'); - let field = pkg.dependencies?.[moduleName] - ? 'dependencies' - : 'peerDependencies'; - throw new ThrowableDiagnostic({ - diagnostic: { - message: md`External dependency "${moduleName}" does not satisfy required semver range "${range}".`, - codeFrames: [ - { - filePath: pkg.pkgfile, - language: 'json', - code: pkgContent, - codeHighlights: generateJSONCodeHighlights(pkgContent, [ - { - key: `/${field}/${encodeJSONKeyComponent(moduleName)}`, - type: 'value', - message: 'Found this conflicting requirement.', - }, - ]), - }, - ], - hints: [ - `Update the dependency on "${moduleName}" to satisfy "${range}".`, - ], - }, - }); - } - } - } - - async resolveFilename( - filename: string, - dir: string, - specifierType: SpecifierType, - ): Promise { - let url; - switch (filename[0]) { - case '/': { - if (specifierType === 'url' && filename[1] === '/') { - // A protocol-relative URL, e.g `url('//example.com/foo.png')`. Ignore. - return null; - } - - // Absolute path. Resolve relative to project root. - dir = this.projectRoot; - filename = '.' + filename; - break; - } - - case '~': { - // Tilde path. Resolve relative to nearest node_modules directory, - // the nearest directory with package.json or the project root - whichever comes first. - const insideNodeModules = dir.includes('node_modules'); - - while ( - dir !== this.projectRoot && - path.basename(path.dirname(dir)) !== 'node_modules' && - (insideNodeModules || - !(await this.fs.exists(path.join(dir, 'package.json')))) - ) { - dir = path.dirname(dir); - - if (dir === path.dirname(dir)) { - dir = this.projectRoot; - break; - } - } - - filename = filename.slice(1); - if (filename[0] === '/' || filename[0] === '\\') { - filename = '.' + filename; - } - break; - } - - case '.': { - // Relative path. - break; - } - - case '#': { - if (specifierType === 'url') { - // An ID-only URL, e.g. `url(#clip-path)` for CSS rules. Ignore. - return null; - } - break; - } - - default: { - // Bare specifier. If this is a URL, it's treated as relative, - // otherwise as a node_modules package. - if (specifierType === 'esm') { - // Try parsing as a URL first in case there is a scheme. - // Otherwise, fall back to an `npm:` specifier, parsed below. - try { - url = new URL(filename); - } catch (e) { - filename = 'npm:' + filename; - } - } else if (specifierType === 'commonjs') { - return { - filePath: filename, - }; - } - } - } - - // If this is a URL dependency or ESM specifier, parse as a URL. - // Otherwise, if this is CommonJS, parse as a platform path. - if (specifierType === 'url' || specifierType === 'esm') { - url = url ?? new URL(filename, `file:${dir}/index`); - let filePath; - if (url.protocol === 'npm:') { - // The `npm:` scheme allows URLs to resolve to node_modules packages. - filePath = decodeURIComponent(url.pathname); - } else if (url.protocol === 'node:') { - // Preserve the `node:` prefix for use later. - // Node does not URL decode or support query params here. - // See /~https://github.com/nodejs/node/issues/39710. - return { - filePath: filename, - }; - } else if (url.protocol === 'file:') { - // $FlowFixMe - filePath = fileURLToPath(url); - } else if (specifierType === 'url') { - // Don't handle other protocols like http: - return null; - } else { - // Throw on unsupported url schemes in ESM dependencies. - // We may support http: or data: urls eventually. - throw new ThrowableDiagnostic({ - diagnostic: { - message: `Unknown url scheme or pipeline '${url.protocol}'`, - }, - }); - } - - return { - filePath, - query: url.search ? new URLSearchParams(url.search) : undefined, - }; - } else { - // CommonJS specifier. Query params are not supported. - return { - filePath: path.resolve(dir, filename), - }; - } - } - - async loadRelative( - filename: string, - extensions: Array, - env: Environment, - parentdir: string, - ctx: ResolverContext, - ): Promise { - // Find a package.json file in the current package. - let pkg = await this.findPackage(filename, ctx); - - // First try as a file, then as a directory. - let resolvedFile = await this.loadAsFile({ - file: filename, - extensions, - env, - pkg, - ctx, - }); - - // Don't load as a directory if this is a URL dependency. - if (!resolvedFile && ctx.specifierType !== 'url') { - resolvedFile = await this.loadDirectory({ - dir: filename, - extensions, - env, - ctx, - pkg, - }); - } - - if (!resolvedFile) { - // If we can't load the file do a fuzzySearch for potential hints - let relativeFileSpecifier = relativePath(parentdir, filename); - let potentialFiles = await findAlternativeFiles( - this.fs, - relativeFileSpecifier, - parentdir, - this.projectRoot, - true, - ctx.specifierType !== 'url', - extensions.length === 0, - ); - - throw new ThrowableDiagnostic({ - diagnostic: { - message: md`Cannot load file '${relativeFileSpecifier}' in '${relativePath( - this.projectRoot, - parentdir, - )}'.`, - hints: potentialFiles.map(r => { - return `Did you mean '__${r}__'?`; - }), - }, - }); - } - - return resolvedFile; - } - - findBuiltin( - filename: string, - env: Environment, - ): ?{|name: string, range: ?string|} { - const isExplicitNode = filename.startsWith('node:'); - if (isExplicitNode || builtins[filename]) { - if (env.isNode()) { - return null; - } - - if (isExplicitNode) { - filename = filename.substr(5); - } - - // By default, exclude node builtins from libraries unless explicitly opted in. - if ( - env.isLibrary && - this.shouldIncludeNodeModule(env, filename) !== true - ) { - return null; - } - - return builtins[filename] || empty; - } - - if (env.isElectron() && filename === 'electron') { - return null; - } - } - - findNodeModulePath( - filename: string, - sourceFile: FilePath, - ctx: ResolverContext, - ): ?Module { - let [moduleName, subPath] = getModuleParts(filename); - - ctx.invalidateOnFileCreate.push({ - fileName: `node_modules/${moduleName}`, - aboveFilePath: sourceFile, - }); - - let dir = path.dirname(sourceFile); - let moduleDir = this.fs.findNodeModule(moduleName, dir); - if (moduleDir) { - return { - moduleName, - subPath, - moduleDir, - filePath: subPath ? path.join(moduleDir, subPath) : moduleDir, - }; - } - - return undefined; - } - - async loadNodeModules( - module: Module, - extensions: Array, - env: Environment, - ctx: ResolverContext, - ): Promise { - // If a module was specified as a module sub-path (e.g. some-module/some/path), - // it is likely a file. Try loading it as a file first. - if (module.subPath && module.moduleDir) { - let pkg = await this.readPackage(module.moduleDir, ctx); - let res = await this.loadAsFile({ - file: nullthrows(module.filePath), - extensions, - env, - pkg, - ctx, - }); - if (res) { - return res; - } - } - - // Otherwise, load as a directory. - return this.loadDirectory({ - dir: nullthrows(module.filePath), - extensions, - env, - ctx, - }); - } - - async loadDirectory({ - dir, - extensions, - env, - ctx, - pkg, - }: {| - dir: string, - extensions: Array, - env: Environment, - ctx: ResolverContext, - pkg?: InternalPackageJSON | null, - |}): Promise { - let failedEntry; - try { - pkg = await this.readPackage(dir, ctx); - - if (pkg) { - // Get a list of possible package entry points. - let entries = this.getPackageEntries(pkg, env); - - for (let entry of entries) { - // First try loading package.main as a file, then try as a directory. - let res = - (await this.loadAsFile({ - file: entry.filename, - extensions, - env, - pkg, - ctx, - })) || - (await this.loadDirectory({ - dir: entry.filename, - extensions, - env, - pkg, - ctx, - })); - - if (res) { - return res; - } else { - failedEntry = entry; - throw new Error(''); - } - } - } - } catch (e) { - if (failedEntry && pkg) { - // If loading the entry failed, try to load an index file, and fall back - // to it if it exists. - let indexFallback = await this.loadAsFile({ - file: path.join(dir, 'index'), - extensions, - env, - pkg, - ctx, - }); - if (indexFallback != null) { - return indexFallback; - } - - let fileSpecifier = relativePath(dir, failedEntry.filename); - let alternatives = await findAlternativeFiles( - this.fs, - fileSpecifier, - pkg.pkgdir, - this.projectRoot, - ); - - let alternative = alternatives[0]; - let pkgContent = await this.fs.readFile(pkg.pkgfile, 'utf8'); - throw new ThrowableDiagnostic({ - diagnostic: { - message: md`Could not load '${fileSpecifier}' from module '${pkg.name}' found in package.json#${failedEntry.field}`, - codeFrames: [ - { - filePath: pkg.pkgfile, - language: 'json', - code: pkgContent, - codeHighlights: generateJSONCodeHighlights(pkgContent, [ - { - key: `/${failedEntry.field}`, - type: 'value', - message: md`'${fileSpecifier}' does not exist${ - alternative ? `, did you mean '${alternative}'?` : '' - }'`, - }, - ]), - }, - ], - }, - }); - } - } - - // Skip index fallback unless this is actually a directory. - try { - if (!(await this.fs.stat(dir)).isDirectory()) { - return; - } - } catch (err) { - return; - } - - // Fall back to an index file inside the directory. - return this.loadAsFile({ - file: path.join(dir, 'index'), - extensions, - env, - pkg: pkg ?? (await this.findPackage(path.join(dir, 'index'), ctx)), - ctx, - }); - } - - async readPackage( - dir: string, - ctx: ResolverContext, - ): Promise { - let file = path.join(dir, 'package.json'); - let cached = this.packageCache.get(file); - - if (cached) { - ctx.invalidateOnFileChange.add(cached.pkgfile); - return cached; - } - - let json; - try { - json = await this.fs.readFile(file, 'utf8'); - } catch (err) { - // If the package.json doesn't exist, watch for it to be created. - ctx.invalidateOnFileCreate.push({ - filePath: file, - }); - throw err; - } - - // Add the invalidation *before* we try to parse the JSON in case of errors - // so that changes are picked up if the file is edited to fix the error. - ctx.invalidateOnFileChange.add(file); - let pkg = JSON.parse(json); - - await this.processPackage(pkg, file, dir); - - this.packageCache.set(file, pkg); - return pkg; - } - - async processPackage(pkg: InternalPackageJSON, file: string, dir: string) { - pkg.pkgfile = file; - pkg.pkgdir = dir; - - // If the package has a `source` field, make sure - // - the package is behind symlinks - // - and the realpath to the packages does not includes `node_modules`. - // Since such package is likely a pre-compiled module - // installed with package managers, rather than including a source code. - if (pkg.source) { - let realpath = await this.fs.realpath(file); - if ( - realpath === file || - realpath.includes(`${path.sep}node_modules${path.sep}`) - ) { - delete pkg.source; - } - } - } - - getPackageEntries( - pkg: InternalPackageJSON, - env: Environment, - ): Array<{| - filename: string, - field: string, - |}> { - return this.mainFields - .map(field => { - if (field === 'browser' && pkg.browser != null) { - if (!env.isBrowser()) { - return null; - } else if (typeof pkg.browser === 'string') { - return {field, filename: pkg.browser}; - } else if (typeof pkg.browser === 'object' && pkg.browser[pkg.name]) { - return { - field: `browser/${pkg.name}`, - filename: pkg.browser[pkg.name], - }; - } - } - - return { - field, - filename: pkg[field], - }; - }) - .filter( - entry => entry && entry.filename && typeof entry.filename === 'string', - ) - .map(entry => { - invariant(entry != null && typeof entry.filename === 'string'); - - // Current dir refers to an index file - if (entry.filename === '.' || entry.filename === './') { - entry.filename = 'index'; - } - - return { - field: entry.field, - filename: path.resolve(pkg.pkgdir, entry.filename), - }; - }); - } - - async loadAsFile({ - file, - extensions, - env, - pkg, - ctx, - }: {| - file: string, - extensions: Array, - env: Environment, - pkg: InternalPackageJSON | null, - ctx: ResolverContext, - |}): Promise { - // Try all supported extensions - let files = await this.expandFile(file, extensions, env, pkg); - let found = this.fs.findFirstFile(files); - - // Add invalidations for higher priority files so we - // re-resolve if any of them are created. - for (let file of files) { - if (file === found) { - break; - } - - ctx.invalidateOnFileCreate.push({ - filePath: file, - }); - } - - if (found) { - return { - path: found, - // If this package.json isn't a sibling of found, it's possible pkg is not the - // closest package.json to the resolved file. Reload it instead. - pkg: - pkg == null || pkg?.pkgdir !== path.dirname(found) - ? await this.findPackage(found, ctx) - : pkg, - }; - } - - return null; - } - - async expandFile( - file: string, - extensions: Array, - env: Environment, - pkg: InternalPackageJSON | null, - expandAliases?: boolean = true, - ): Promise> { - // Expand extensions and aliases - let res = []; - for (let ext of extensions) { - let f = file + ext; - if (expandAliases) { - let alias = await this.resolveAliases(f, env, pkg); - let aliasPath; - if (alias && alias.type === 'file') { - aliasPath = alias.resolved; - } - - if (aliasPath && aliasPath !== f) { - res = res.concat( - await this.expandFile(aliasPath, extensions, env, pkg, false), - ); - } - } - - if (path.extname(f)) { - res.push(f); - } - } - - return res; - } - - async resolveAliases( - filename: string, - env: Environment, - pkg: InternalPackageJSON | null, - ): Promise { - let localAliases = await this.resolvePackageAliases(filename, env, pkg); - if (localAliases) { - return localAliases; - } - - // First resolve local package aliases, then project global ones. - return this.resolvePackageAliases(filename, env, this.rootPackage); - } - - async resolvePackageAliases( - filename: string, - env: Environment, - pkg: InternalPackageJSON | null, - ): Promise { - if (!pkg) { - return null; - } - - if (pkg.source && !Array.isArray(pkg.source)) { - let alias = await this.getAlias(filename, pkg, pkg.source); - if (alias != null) { - return alias; - } - } - - if (pkg.alias) { - let alias = await this.getAlias(filename, pkg, pkg.alias); - if (alias != null) { - return alias; - } - } - - if (pkg.browser && env.isBrowser()) { - let alias = await this.getAlias(filename, pkg, pkg.browser); - if (alias != null) { - return alias; - } - } - - return null; - } - - async getAlias( - filename: FilePath, - pkg: InternalPackageJSON, - aliases: ?Aliases, - ): Promise { - if (!filename || !aliases || typeof aliases !== 'object') { - return null; - } - - let dir = pkg.pkgdir; - let alias; - - // If filename is an absolute path, get one relative to the package.json directory. - if (path.isAbsolute(filename)) { - filename = relativePath(dir, filename); - alias = this.lookupAlias(aliases, filename); - } else { - // It is a node_module. First try the entire filename as a key. - alias = this.lookupAlias(aliases, normalizeSeparators(filename)); - if (alias == null) { - // If it didn't match, try only the module name. - let [moduleName, subPath] = getModuleParts(filename); - alias = this.lookupAlias(aliases, moduleName); - if (typeof alias === 'string' && subPath) { - let isRelative = alias.startsWith('./'); - // Append the filename back onto the aliased module. - alias = (path.posix.join(alias, subPath): string); - // because of path.join('./nested', 'sub') === 'nested/sub' - if (isRelative) alias = './' + alias; - } - } - } - - // If the alias is set to `false`, return an empty file. - if (alias === false) { - return { - type: 'file', - sourcePath: pkg.pkgfile, - resolved: EMPTY_SHIM, - }; - } - - if (alias && typeof alias === 'object') { - if (alias.global) { - if (typeof alias.global !== 'string' || alias.global.length === 0) { - throw new ThrowableDiagnostic({ - diagnostic: { - message: md`The global alias for ${filename} is invalid.`, - hints: [`Only nonzero-length strings are valid global aliases.`], - }, - }); - } - - return { - type: 'global', - sourcePath: pkg.pkgfile, - resolved: alias.global, - }; - } - } - - if (typeof alias === 'string') { - // Assume file - let resolved = await this.resolveFilename(alias, dir, 'commonjs'); - if (!resolved) { - return null; - } - - return { - type: 'file', - sourcePath: pkg.pkgfile, - resolved: resolved.filePath, - }; - } - - return null; - } - - lookupAlias( - aliases: Aliases, - filename: FilePath, - ): null | boolean | string | {|global: string|} { - if (typeof aliases !== 'object') { - return null; - } - - // First, try looking up the exact filename - let alias = aliases[filename]; - if (alias == null) { - // Otherwise, try replacing glob keys - for (let key in aliases) { - let val = aliases[key]; - if (typeof val === 'string' && isGlob(key)) { - // /~https://github.com/micromatch/picomatch/issues/77 - if (filename.startsWith('./')) { - filename = filename.slice(2); - } - let re = globToRegex(key, {capture: true}); - if (re.test(filename)) { - alias = filename.replace(re, val); - break; - } - } - } - } - return alias; - } - - async findPackage( - sourceFile: string, - ctx: ResolverContext, - ): Promise { - ctx.invalidateOnFileCreate.push({ - fileName: 'package.json', - aboveFilePath: sourceFile, - }); - - // Find the nearest package.json file within the current node_modules folder - let res = await loadConfig( - this.fs, - sourceFile, - ['package.json'], - this.projectRoot, - // By default, loadConfig uses JSON5. Use normal JSON for package.json files - // since they don't support comments and JSON.parse is faster. - {parser: (...args) => JSON.parse(...args)}, - ); - - if (res != null) { - let file = res.files[0].filePath; - let dir = path.dirname(file); - ctx.invalidateOnFileChange.add(file); - let pkg = res.config; - await this.processPackage(pkg, file, dir); - return pkg; - } - - return null; - } - - async loadAlias( - filename: string, - sourceFile: FilePath, - env: Environment, - ctx: ResolverContext, - ): Promise { - // Load the root project's package.json file if we haven't already - if (!this.rootPackage) { - this.rootPackage = await this.findPackage( - path.join(this.projectRoot, 'index'), - ctx, - ); - } - - // Load the local package, and resolve aliases - let pkg = await this.findPackage(sourceFile, ctx); - return this.resolveAliases(filename, env, pkg); - } - - hasSideEffects(filePath: FilePath, pkg: InternalPackageJSON): boolean { - switch (typeof pkg.sideEffects) { - case 'boolean': - return pkg.sideEffects; - case 'string': { - let glob = pkg.sideEffects; - invariant(typeof glob === 'string'); - - let relative = path.relative(pkg.pkgdir, filePath); - if (!glob.includes('/')) { - glob = `**/${glob}`; - } - - // Trim off "./" to make micromatch behave correctly, - // `path.relative` never returns a leading "./" - if (glob.startsWith('./')) { - glob = glob.substr(2); - } - - return isGlobMatch(relative, glob, {dot: true}); - } - case 'object': - return pkg.sideEffects.some(sideEffects => - this.hasSideEffects(filePath, {...pkg, sideEffects}), - ); - } - - return true; - } -} diff --git a/packages/utils/node-resolver-core/src/Wrapper.js b/packages/utils/node-resolver-core/src/Wrapper.js new file mode 100644 index 00000000000..36185d06ba1 --- /dev/null +++ b/packages/utils/node-resolver-core/src/Wrapper.js @@ -0,0 +1,767 @@ +// @flow +import type { + FilePath, + SpecifierType, + SemverRange, + Environment, + SourceLocation, + BuildMode, + ResolveResult, + PluginLogger, +} from '@parcel/types'; +import type {FileSystem} from '@parcel/fs'; +import type {PackageManager} from '@parcel/package-manager'; +import type {Diagnostic} from '@parcel/diagnostic'; +import {NodeFS} from '@parcel/fs'; +import {Resolver} from '../index'; +import builtins, {empty} from './builtins'; +import path from 'path'; +import { + relativePath, + findAlternativeNodeModules, + findAlternativeFiles, + loadConfig, + getModuleParts, +} from '@parcel/utils'; +import ThrowableDiagnostic, { + encodeJSONKeyComponent, + errorToDiagnostic, + generateJSONCodeHighlights, + md, +} from '@parcel/diagnostic'; +import semver from 'semver'; +import {parse} from '@mischnic/json-sourcemap'; +import _Module from 'module'; + +// Package.json fields. Must match package_json.rs. +const MAIN = 1 << 0; +const MODULE = 1 << 1; +const SOURCE = 1 << 2; +const BROWSER = 1 << 3; + +type Options = {| + fs: FileSystem, + projectRoot: FilePath, + packageManager?: PackageManager, + logger?: PluginLogger, + shouldAutoInstall?: boolean, + mode?: BuildMode, +|}; + +type ResolveOptions = {| + filename: FilePath, + parent: ?FilePath, + specifierType: SpecifierType, + range?: ?SemverRange, + env: Environment, + sourcePath?: ?FilePath, + loc?: ?SourceLocation, +|}; + +export default class NodeResolver { + resolversByEnv: Map; + options: Options; + + constructor(options: Options) { + this.options = options; + this.resolversByEnv = new Map(); + } + + async resolve(options: ResolveOptions): Promise { + // Special case + if (options.env.isElectron() && options.filename === 'electron') { + return {isExcluded: true}; + } + + let resolver = this.resolversByEnv.get(options.env.id); + if (!resolver) { + resolver = new Resolver(this.options.projectRoot, { + fs: + this.options.fs instanceof NodeFS && process.versions.pnp == null + ? undefined + : { + canonicalize: path => this.options.fs.realpathSync(path), + read: path => this.options.fs.readFileSync(path), + isFile: path => this.options.fs.statSync(path).isFile(), + isDir: path => this.options.fs.statSync(path).isDirectory(), + }, + mode: 1, + includeNodeModules: options.env.includeNodeModules, + entries: + MAIN | MODULE | SOURCE | (options.env.isBrowser() ? BROWSER : 0), + conditions: environmentToExportsConditions( + options.env, + this.options.mode, + ), + moduleDirResolver: + process.versions.pnp != null + ? (module, from) => { + // $FlowFixMe[prop-missing] + let pnp = _Module.findPnpApi(path.dirname(from)); + + return pnp.resolveToUnqualified( + // append slash to force loading builtins from npm + module + '/', + from, + ); + } + : undefined, + }); + this.resolversByEnv.set(options.env.id, resolver); + } + + // Special case for entries. Convert absolute paths to relative from project root. + if (options.parent == null) { + options.parent = path.join(this.options.projectRoot, 'index'); + if (path.isAbsolute(options.filename)) { + options.filename = relativePath( + this.options.projectRoot, + options.filename, + ); + } + } + + // $FlowFixMe[incompatible-call] - parent is not null here. + let res = resolver.resolve(options); + + // Invalidate whenever the .pnp.js file changes. + // TODO: only when we actually resolve a node_modules package? + if ( + process.versions.pnp != null && + options.parent && + res.invalidateOnFileChange + ) { + // $FlowFixMe[prop-missing] + let pnp = _Module.findPnpApi(path.dirname(options.parent)); + res.invalidateOnFileChange.push(pnp.resolveToUnqualified('pnpapi', null)); + } + + if (res.error) { + let diagnostic = await this.handleError(res.error, options); + return { + diagnostics: Array.isArray(diagnostic) + ? diagnostic + : diagnostic + ? [diagnostic] + : [], + invalidateOnFileCreate: res.invalidateOnFileCreate, + invalidateOnFileChange: res.invalidateOnFileChange, + }; + } + + switch (res.resolution?.type) { + case 'Path': + return { + filePath: res.resolution.value, + invalidateOnFileCreate: res.invalidateOnFileCreate, + invalidateOnFileChange: res.invalidateOnFileChange, + sideEffects: res.sideEffects, + query: res.query != null ? new URLSearchParams(res.query) : undefined, + }; + case 'Builtin': + return this.resolveBuiltin(res.resolution.value, options); + case 'External': { + if ( + options.sourcePath && + options.env.isLibrary && + options.specifierType !== 'url' + ) { + let diagnostic = await this.checkExcludedDependency( + options.sourcePath, + options.filename, + options, + ); + if (diagnostic) { + return { + diagnostics: [diagnostic], + invalidateOnFileCreate: res.invalidateOnFileCreate, + invalidateOnFileChange: res.invalidateOnFileChange, + }; + } + } + + return { + isExcluded: true, + invalidateOnFileCreate: res.invalidateOnFileCreate, + invalidateOnFileChange: res.invalidateOnFileChange, + }; + } + case 'Empty': + return { + filePath: empty, + invalidateOnFileCreate: res.invalidateOnFileCreate, + invalidateOnFileChange: res.invalidateOnFileChange, + }; + case 'Global': { + let global = res.resolution.value; + return { + filePath: path.join(this.options.projectRoot, `${global}.js`), + code: `module.exports=${global};`, + invalidateOnFileCreate: res.invalidateOnFileCreate, + invalidateOnFileChange: res.invalidateOnFileChange, + }; + } + default: + return null; + } + } + + async resolveBuiltin( + name: string, + options: ResolveOptions, + ): Promise { + if (options.env.isNode()) { + return {isExcluded: true}; + } + + // By default, exclude node builtins from libraries unless explicitly opted in. + if ( + options.env.isLibrary && + this.shouldIncludeNodeModule(options.env, name) !== true + ) { + return {isExcluded: true}; + } + + let builtin = builtins[name]; + if (!builtin || builtin.name === empty) { + return { + filePath: empty, + }; + } + + let resolved = await this.resolve({ + ...options, + filename: builtin.name, + }); + + // Autoinstall/verify version of builtin polyfills + if (builtin.range != null) { + // This assumes that there are no polyfill packages that are scoped + // Append '/' to force this.packageManager to look up the package in node_modules + let packageName = builtin.name.split('/')[0] + '/'; + let packageManager = this.options.packageManager; + if (resolved?.filePath == null) { + // Auto install the Node builtin polyfills + if (this.options.shouldAutoInstall && packageManager) { + this.options.logger?.warn({ + message: md`Auto installing polyfill for Node builtin module "${packageName}"...`, + codeFrames: options.loc + ? [ + { + filePath: options.loc.filePath, + codeHighlights: options.loc + ? [ + { + message: 'used here', + start: options.loc.start, + end: options.loc.end, + }, + ] + : [], + }, + ] + : [], + documentationURL: + 'https://parceljs.org/features/node-emulation/#polyfilling-%26-excluding-builtin-node-modules', + }); + + await packageManager.resolve( + packageName, + this.options.projectRoot + '/index', + { + saveDev: true, + shouldAutoInstall: true, + range: builtin.range, + }, + ); + + // Re-resolve + return this.resolve({ + ...options, + filename: builtin.name, + parent: this.options.projectRoot + '/index', + }); + } else { + throw new ThrowableDiagnostic({ + diagnostic: { + message: md`Node builtin polyfill "${packageName}" is not installed, but auto install is disabled.`, + codeFrames: options.loc + ? [ + { + filePath: options.loc.filePath, + codeHighlights: [ + { + message: 'used here', + start: options.loc.start, + end: options.loc.end, + }, + ], + }, + ] + : [], + documentationURL: + 'https://parceljs.org/features/node-emulation/#polyfilling-%26-excluding-builtin-node-modules', + hints: [ + md`Install the "${packageName}" package with your package manager, and run Parcel again.`, + ], + }, + }); + } + } else if (builtin.range != null) { + // Assert correct version + try { + // TODO packageManager can be null for backwards compatibility, but that could cause invalid + // resolutions in monorepos + await packageManager?.resolve( + packageName, + this.options.projectRoot + '/index', + { + saveDev: true, + shouldAutoInstall: this.options.shouldAutoInstall, + range: builtin.range, + }, + ); + } catch (e) { + this.options.logger?.warn(errorToDiagnostic(e)); + } + } + } + + return resolved; + } + + shouldIncludeNodeModule( + {includeNodeModules}: Environment, + name: string, + ): ?boolean { + if (includeNodeModules === false) { + return false; + } + + if (Array.isArray(includeNodeModules)) { + let [moduleName] = getModuleParts(name); + return includeNodeModules.includes(moduleName); + } + + if (includeNodeModules && typeof includeNodeModules === 'object') { + let [moduleName] = getModuleParts(name); + let include = includeNodeModules[moduleName]; + if (include != null) { + return !!include; + } + } + } + + async handleError( + error: any, + options: ResolveOptions, + ): Promise)> { + switch (error.type) { + case 'FileNotFound': { + let dir = path.dirname(error.from); + let relative = error.relative; + if (!relative.startsWith('.')) { + relative = './' + relative; + } + + let potentialFiles = await findAlternativeFiles( + this.options.fs, + relative, + dir, + this.options.projectRoot, + true, + options.specifierType !== 'url', + // extensions.length === 0, + ); + + return { + message: md`Cannot load file '${relative}' in '${relativePath( + this.options.projectRoot, + dir, + )}'.`, + hints: potentialFiles.map(r => { + return `Did you mean '__${r}__'?`; + }), + }; + } + case 'ModuleNotFound': { + let alternativeModules = await findAlternativeNodeModules( + this.options.fs, + error.module, + options.parent + ? path.dirname(options.parent) + : this.options.projectRoot, + ); + + return { + message: md`Cannot find module '${error.module}'`, + hints: alternativeModules.map(r => { + return `Did you mean '__${r}__'?`; + }), + }; + } + case 'ModuleEntryNotFound': { + let dir = path.dirname(error.package_path); + let fileSpecifier = relativePath(dir, path.normalize(error.entry_path)); + let alternatives = await findAlternativeFiles( + this.options.fs, + fileSpecifier, + dir, + this.options.projectRoot, + ); + + let alternative = alternatives[0]; + let pkgContent = await this.options.fs.readFile( + error.package_path, + 'utf8', + ); + return { + message: md`Could not load '${fileSpecifier}' from module '${error.module}' found in package.json#${error.field}`, + codeFrames: [ + { + filePath: error.package_path, + language: 'json', + code: pkgContent, + codeHighlights: generateJSONCodeHighlights(pkgContent, [ + { + key: `/${error.field}`, + type: 'value', + message: md`'${fileSpecifier}' does not exist${ + alternative ? `, did you mean '${alternative}'?` : '' + }'`, + }, + ]), + }, + ], + }; + } + case 'ModuleSubpathNotFound': { + let dir = path.dirname(error.package_path); + let relative = relativePath(dir, error.path, false); + let pkgContent = await this.options.fs.readFile( + error.package_path, + 'utf8', + ); + let pkg = JSON.parse(pkgContent); + let potentialFiles = []; + if (!pkg.exports) { + potentialFiles = await findAlternativeFiles( + this.options.fs, + relative, + dir, + this.options.projectRoot, + false, + ); + } + + if (!relative.startsWith('.')) { + relative = './' + relative; + } + + return { + message: md`Cannot load file '${relative}' from module '${error.module}'`, + hints: potentialFiles.map(r => { + return `Did you mean '__${error.module}/${r}__'?`; + }), + }; + } + case 'JsonError': { + let pkgContent = await this.options.fs.readFile(error.path, 'utf8'); + return { + message: 'Error parsing JSON', + codeFrames: [ + { + filePath: error.path, + language: 'json', + code: pkgContent, + codeHighlights: [ + { + message: error.message, + start: { + line: error.line, + column: error.column, + }, + end: { + line: error.line, + column: error.column, + }, + }, + ], + }, + ], + }; + } + case 'InvalidSpecifier': { + switch (error.kind) { + case 'EmptySpecifier': + return { + message: 'Invalid empty specifier', + }; + case 'InvalidPackageSpecifier': + return { + message: 'Invalid package specifier', + }; + case 'InvalidFileUrl': + return { + message: 'Invalid file url', + }; + case 'UrlError': + return { + message: `Invalid URL: ${error.value}`, + }; + default: + throw new Error('Unknown specifier error kind'); + } + } + case 'UnknownScheme': { + return { + message: md`Unknown url scheme or pipeline '${error.scheme}:'`, + }; + } + case 'PackageJsonError': { + let pkgContent = await this.options.fs.readFile(error.path, 'utf8'); + // TODO: find alternative exports? + switch (error.error) { + case 'PackagePathNotExported': { + return { + message: md`Module '${options.filename}' is not exported from the '${error.module}' package`, + codeFrames: [ + { + filePath: error.path, + language: 'json', + code: pkgContent, + codeHighlights: generateJSONCodeHighlights(pkgContent, [ + { + key: `/exports`, + type: 'value', + }, + ]), + }, + ], + }; + } + case 'ImportNotDefined': { + let parsed = parse(pkgContent); + return { + message: md`Package import '${options.filename}' is not defined in the '${error.module}' package`, + codeFrames: [ + { + filePath: error.path, + language: 'json', + code: pkgContent, + codeHighlights: parsed.pointers['/imports'] + ? generateJSONCodeHighlights(parsed, [ + { + key: `/imports`, + type: 'value', + }, + ]) + : [], + }, + ], + }; + } + case 'InvalidPackageTarget': { + return { + message: md`Invalid package target in the '${error.module} package. Targets may not refer to files outside the package.`, + codeFrames: [ + { + filePath: error.path, + language: 'json', + code: pkgContent, + codeHighlights: generateJSONCodeHighlights(pkgContent, [ + { + // TODO: track exact location. + key: `/exports`, + type: 'value', + }, + ]), + }, + ], + }; + } + case 'InvalidSpecifier': { + return { + message: md`Invalid package import specifier '${options.filename}'.`, + }; + } + } + break; + } + case 'PackageJsonNotFound': { + return { + message: md`Cannot find a package.json above '${relativePath( + this.options.projectRoot, + options.parent + ? path.dirname(options.parent) + : this.options.projectRoot, + )}'`, + }; + } + case 'TsConfigExtendsNotFound': { + let tsconfigContent = await this.options.fs.readFile( + error.tsconfig, + 'utf8', + ); + let nested = await this.handleError(error.error, options); + return [ + { + message: 'Could not find extended tsconfig', + codeFrames: [ + { + filePath: error.tsconfig, + language: 'json', + code: tsconfigContent, + codeHighlights: generateJSONCodeHighlights(tsconfigContent, [ + { + key: `/extends`, + type: 'value', + }, + ]), + }, + ], + }, + ...(Array.isArray(nested) ? nested : nested ? [nested] : []), + ]; + } + case 'IOError': { + return {message: error.message}; + } + } + } + + async checkExcludedDependency( + sourceFile: FilePath, + name: string, + options: ResolveOptions, + ): Promise { + let [moduleName] = getModuleParts(name); + let res = await loadConfig( + this.options.fs, + sourceFile, + ['package.json'], + this.options.projectRoot, + // By default, loadConfig uses JSON5. Use normal JSON for package.json files + // since they don't support comments and JSON.parse is faster. + {parser: (...args) => JSON.parse(...args)}, + ); + if (!res) { + return; + } + + let pkg = res.config; + let pkgfile = res.files[0].filePath; + if ( + !pkg.dependencies?.[moduleName] && + !pkg.peerDependencies?.[moduleName] && + !pkg.engines?.[moduleName] + ) { + let pkgContent = await this.options.fs.readFile(pkgfile, 'utf8'); + return { + message: md`External dependency "${moduleName}" is not declared in package.json.`, + codeFrames: [ + { + filePath: pkgfile, + language: 'json', + code: pkgContent, + codeHighlights: pkg.dependencies + ? generateJSONCodeHighlights(pkgContent, [ + { + key: `/dependencies`, + type: 'key', + }, + ]) + : [ + { + start: { + line: 1, + column: 1, + }, + end: { + line: 1, + column: 1, + }, + }, + ], + }, + ], + hints: [`Add "${moduleName}" as a dependency.`], + }; + } + + if (options.range) { + let range = options.range; + let depRange = + pkg.dependencies?.[moduleName] || pkg.peerDependencies?.[moduleName]; + if (depRange && !semver.intersects(depRange, range)) { + let pkgContent = await this.options.fs.readFile(pkgfile, 'utf8'); + let field = pkg.dependencies?.[moduleName] + ? 'dependencies' + : 'peerDependencies'; + return { + message: md`External dependency "${moduleName}" does not satisfy required semver range "${range}".`, + codeFrames: [ + { + filePath: pkgfile, + language: 'json', + code: pkgContent, + codeHighlights: generateJSONCodeHighlights(pkgContent, [ + { + key: `/${field}/${encodeJSONKeyComponent(moduleName)}`, + type: 'value', + message: 'Found this conflicting requirement.', + }, + ]), + }, + ], + hints: [ + `Update the dependency on "${moduleName}" to satisfy "${range}".`, + ], + }; + } + } + } +} + +function environmentToExportsConditions( + env: Environment, + mode: ?BuildMode, +): number { + // These must match the values in package_json.rs. + const NODE = 1 << 3; + const BROWSER = 1 << 4; + const WORKER = 1 << 5; + const WORKLET = 1 << 6; + const ELECTRON = 1 << 7; + const DEVELOPMENT = 1 << 8; + const PRODUCTION = 1 << 9; + + let conditions = 0; + if (env.isBrowser()) { + conditions |= BROWSER; + } + + if (env.isWorker()) { + conditions |= WORKER; + } + + if (env.isWorklet()) { + conditions |= WORKLET; + } + + if (env.isElectron()) { + conditions |= ELECTRON; + } + + if (env.isNode()) { + conditions |= NODE; + } + + if (mode === 'production') { + conditions |= PRODUCTION; + } else if (mode === 'development') { + conditions |= DEVELOPMENT; + } + + return conditions; +} diff --git a/packages/utils/node-resolver-core/src/index.js b/packages/utils/node-resolver-core/src/index.js new file mode 100644 index 00000000000..0be6ec5e11f --- /dev/null +++ b/packages/utils/node-resolver-core/src/index.js @@ -0,0 +1,3 @@ +// @flow +export {default} from './Wrapper'; +export {Resolver as ResolverBase} from '../index'; diff --git a/packages/utils/node-resolver-core/src/lib.rs b/packages/utils/node-resolver-core/src/lib.rs new file mode 100644 index 00000000000..ab010cfbf98 --- /dev/null +++ b/packages/utils/node-resolver-core/src/lib.rs @@ -0,0 +1,352 @@ +use dashmap::DashMap; +use napi::{Env, JsBoolean, JsBuffer, JsFunction, JsString, JsUnknown, Ref, Result}; +use napi_derive::napi; +use std::{ + borrow::Cow, + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; + +use parcel_resolver::{ + ExportsCondition, Fields, FileCreateInvalidation, FileSystem, IncludeNodeModules, Invalidations, + OsFileSystem, Resolution, ResolverError, SpecifierType, +}; + +#[napi(object)] +pub struct JsFileSystemOptions { + pub canonicalize: JsFunction, + pub read: JsFunction, + pub is_file: JsFunction, + pub is_dir: JsFunction, + pub include_node_modules: + Option, HashMap>>>, +} + +#[napi(object, js_name = "FileSystem")] +pub struct JsResolverOptions { + pub fs: Option, + pub include_node_modules: + Option, HashMap>>>, + pub conditions: Option, + pub module_dir_resolver: Option, + pub mode: u8, + pub entries: Option, +} + +struct FunctionRef { + env: Env, + reference: Ref<()>, +} + +// We don't currently call functions from multiple threads, but we'll need to change this when we do. +unsafe impl Send for FunctionRef {} +unsafe impl Sync for FunctionRef {} + +impl FunctionRef { + fn new(env: Env, f: JsFunction) -> napi::Result { + Ok(Self { + env, + reference: env.create_reference(f)?, + }) + } + + fn get(&self) -> napi::Result { + self.env.get_reference_value(&self.reference) + } +} + +impl Drop for FunctionRef { + fn drop(&mut self) { + drop(self.reference.unref(self.env)) + } +} + +struct JsFileSystem { + canonicalize: FunctionRef, + read: FunctionRef, + is_file: FunctionRef, + is_dir: FunctionRef, +} + +impl FileSystem for JsFileSystem { + fn canonicalize>( + &self, + path: P, + _cache: &DashMap>, + ) -> std::io::Result { + let canonicalize = || -> napi::Result<_> { + let path = path.as_ref().to_string_lossy(); + let path = self.canonicalize.env.create_string(path.as_ref())?; + let res: JsString = self.canonicalize.get()?.call(None, &[path])?.try_into()?; + let utf8 = res.into_utf8()?; + Ok(utf8.into_owned()?.into()) + }; + + canonicalize().map_err(|err| std::io::Error::new(std::io::ErrorKind::NotFound, err.to_string())) + } + + fn read_to_string>(&self, path: P) -> std::io::Result { + let read = || -> napi::Result<_> { + let path = path.as_ref().to_string_lossy(); + let path = self.read.env.create_string(path.as_ref())?; + let res: JsBuffer = self.read.get()?.call(None, &[path])?.try_into()?; + let value = res.into_value()?; + Ok(unsafe { String::from_utf8_unchecked(value.to_vec()) }) + }; + + read().map_err(|err| std::io::Error::new(std::io::ErrorKind::NotFound, err.to_string())) + } + + fn is_file>(&self, path: P) -> bool { + let is_file = || -> napi::Result<_> { + let path = path.as_ref().to_string_lossy(); + let p = self.is_file.env.create_string(path.as_ref())?; + let res: JsBoolean = self.is_file.get()?.call(None, &[p])?.try_into()?; + res.get_value() + }; + + match is_file() { + Ok(res) => res, + Err(_) => false, + } + } + + fn is_dir>(&self, path: P) -> bool { + let is_dir = || -> napi::Result<_> { + let path = path.as_ref().to_string_lossy(); + let path = self.is_dir.env.create_string(path.as_ref())?; + let res: JsBoolean = self.is_dir.get()?.call(None, &[path])?.try_into()?; + res.get_value() + }; + + match is_dir() { + Ok(res) => res, + Err(_) => false, + } + } +} + +enum EitherFs { + A(A), + B(B), +} + +impl FileSystem for EitherFs { + fn canonicalize>( + &self, + path: P, + cache: &DashMap>, + ) -> std::io::Result { + match self { + EitherFs::A(a) => a.canonicalize(path, cache), + EitherFs::B(b) => b.canonicalize(path, cache), + } + } + + fn read_to_string>(&self, path: P) -> std::io::Result { + match self { + EitherFs::A(a) => a.read_to_string(path), + EitherFs::B(b) => b.read_to_string(path), + } + } + + fn is_file>(&self, path: P) -> bool { + match self { + EitherFs::A(a) => a.is_file(path), + EitherFs::B(b) => b.is_file(path), + } + } + + fn is_dir>(&self, path: P) -> bool { + match self { + EitherFs::A(a) => a.is_dir(path), + EitherFs::B(b) => b.is_dir(path), + } + } +} + +#[napi(object)] +pub struct ResolveOptions { + pub filename: String, + pub specifier_type: String, + pub parent: String, +} + +#[napi(object)] +pub struct FilePathCreateInvalidation { + pub file_path: String, +} + +#[napi(object)] +pub struct FileNameCreateInvalidation { + pub file_name: String, + pub above_file_path: String, +} + +#[napi(object)] +pub struct ResolveResult { + pub resolution: JsUnknown, + pub invalidate_on_file_change: Vec, + pub invalidate_on_file_create: + Vec>, + pub query: Option, + pub side_effects: bool, + pub error: JsUnknown, +} + +#[napi] +pub struct Resolver { + resolver: parcel_resolver::Resolver<'static, EitherFs>, +} + +#[napi] +impl Resolver { + #[napi(constructor)] + pub fn new(project_root: String, options: JsResolverOptions, env: Env) -> Result { + let fs = if let Some(fs) = options.fs { + EitherFs::A(JsFileSystem { + canonicalize: FunctionRef::new(env, fs.canonicalize)?, + read: FunctionRef::new(env, fs.read)?, + is_file: FunctionRef::new(env, fs.is_file)?, + is_dir: FunctionRef::new(env, fs.is_dir)?, + }) + } else { + EitherFs::B(OsFileSystem) + }; + + let mut resolver = match options.mode { + 1 => parcel_resolver::Resolver::parcel( + Cow::Owned(project_root.into()), + parcel_resolver::CacheCow::Owned(parcel_resolver::Cache::new(fs)), + ), + 2 => parcel_resolver::Resolver::node( + Cow::Owned(project_root.into()), + parcel_resolver::CacheCow::Owned(parcel_resolver::Cache::new(fs)), + ), + _ => return Err(napi::Error::new(napi::Status::InvalidArg, "Invalid mode")), + }; + + if let Some(include_node_modules) = options.include_node_modules { + resolver.include_node_modules = Cow::Owned(match include_node_modules { + napi::Either::A(b) => IncludeNodeModules::Bool(b), + napi::Either::B(napi::Either::A(v)) => IncludeNodeModules::Array(v), + napi::Either::B(napi::Either::B(v)) => IncludeNodeModules::Map(v), + }); + } + + if let Some(conditions) = options.conditions { + resolver.conditions = ExportsCondition::from_bits_truncate(conditions); + } + + if let Some(entries) = options.entries { + resolver.entries = Fields::from_bits_truncate(entries); + } + + if let Some(module_dir_resolver) = options.module_dir_resolver { + let module_dir_resolver = FunctionRef::new(env, module_dir_resolver)?; + resolver.module_dir_resolver = Some(Arc::new(move |module: &str, from: &Path| { + let call = |module: &str| -> napi::Result { + let env = module_dir_resolver.env; + let s = env.create_string(module)?; + let f = env.create_string(from.to_string_lossy().as_ref())?; + let res: JsString = module_dir_resolver.get()?.call(None, &[s, f])?.try_into()?; + let utf8 = res.into_utf8()?; + Ok(utf8.into_owned()?.into()) + }; + + let r = call(module); + r.map_err(|_| ResolverError::ModuleNotFound { + module: module.to_owned(), + }) + })); + } + + Ok(Self { resolver }) + } + + #[napi] + pub fn resolve(&self, options: ResolveOptions, env: Env) -> Result { + let mut res = self.resolver.resolve( + &options.filename, + Path::new(&options.parent), + match options.specifier_type.as_ref() { + "esm" => SpecifierType::Esm, + "commonjs" => SpecifierType::Cjs, + "url" => SpecifierType::Url, + _ => { + return Err(napi::Error::new( + napi::Status::InvalidArg, + format!("Invalid specifier type: {}", options.specifier_type), + )) + } + }, + ); + + let side_effects = if let Ok((Resolution::Path(p), _)) = &res.result { + match self.resolver.resolve_side_effects(&p, &res.invalidations) { + Ok(side_effects) => side_effects, + Err(err) => { + res.result = Err(err); + true + } + } + } else { + true + }; + + let (invalidate_on_file_change, invalidate_on_file_create) = + convert_invalidations(res.invalidations); + match res.result { + Ok((res, query)) => Ok(ResolveResult { + resolution: env.to_js_value(&res)?, + invalidate_on_file_change, + invalidate_on_file_create, + side_effects, + query, + error: env.get_undefined()?.into_unknown(), + }), + Err(err) => Ok(ResolveResult { + resolution: env.get_undefined()?.into_unknown(), + invalidate_on_file_change, + invalidate_on_file_create, + side_effects: true, + query: None, + error: env.to_js_value(&err)?, + }), + } + } +} + +fn convert_invalidations( + invalidations: Invalidations, +) -> ( + Vec, + Vec>, +) { + let invalidate_on_file_change = invalidations + .invalidate_on_file_change + .into_inner() + .unwrap() + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + let invalidate_on_file_create = invalidations + .invalidate_on_file_create + .into_inner() + .unwrap() + .into_iter() + .map(|i| match i { + FileCreateInvalidation::Path(p) => napi::Either::A(FilePathCreateInvalidation { + file_path: p.to_string_lossy().into_owned(), + }), + FileCreateInvalidation::FileName { file_name, above } => { + napi::Either::B(FileNameCreateInvalidation { + file_name, + above_file_path: above.to_string_lossy().into_owned(), + }) + } + }) + .collect(); + (invalidate_on_file_change, invalidate_on_file_create) +} diff --git a/packages/utils/node-resolver-core/test/fixture/nested/tsconfig.json b/packages/utils/node-resolver-core/test/fixture/nested/tsconfig.json new file mode 100644 index 00000000000..6deb3011176 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/nested/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "..", + "compilerOptions": { + "paths": { + "ts-path": ["test.js"] + } + } +} diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/foo/baz.js b/packages/utils/node-resolver-core/test/fixture/node_modules/foo/baz.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/foo/with space.mjs b/packages/utils/node-resolver-core/test/fixture/node_modules/foo/with space.mjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/json-error/package.json b/packages/utils/node-resolver-core/test/fixture/node_modules/json-error/package.json new file mode 100644 index 00000000000..876e828be4b --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/node_modules/json-error/package.json @@ -0,0 +1,4 @@ +{ + "name": "test" + "version": "2.0.0" +} \ No newline at end of file diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/browser-import-dev.mjs b/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/browser-import-dev.mjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/browser-import-prod.mjs b/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/browser-import-prod.mjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/browser-require-dev.cjs b/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/browser-require-dev.cjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/browser-require-prod.cjs b/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/browser-require-prod.cjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/node-import.mjs b/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/node-import.mjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/node-require.cjs b/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/node-require.cjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/package.json b/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/package.json new file mode 100644 index 00000000000..0b35e46641d --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/node_modules/package-conditions/package.json @@ -0,0 +1,20 @@ +{ + "name": "package-conditions", + "private": true, + "exports": { + "browser": { + "development": { + "import": "./browser-import-dev.mjs", + "require": "./browser-require-dev.cjs" + }, + "production": { + "import": "./browser-import-prod.mjs", + "require": "./browser-require-prod.cjs" + } + }, + "node": { + "import": "./node-import.mjs", + "require": "./node-require.cjs" + } + } +} \ No newline at end of file diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/features/test.mjs b/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/features/test.mjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/foo.mjs b/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/foo.mjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/internal.mjs b/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/internal.mjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/main.mjs b/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/main.mjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/package.json b/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/package.json new file mode 100644 index 00000000000..d731ae53df9 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/package.json @@ -0,0 +1,20 @@ +{ + "name": "package-exports", + "private": true, + "exports": { + ".": "./main.mjs", + "./foo": "./foo.mjs", + "./features/*": "./features/*.mjs", + "./invalid": "../foo/index.js", + "./space": "./with%20space.mjs", + "./with%20space": "./with space.mjs", + "./missing": "./missing.mjs" + }, + "imports": { + "#internal": "./internal.mjs", + "#foo": "foo" + }, + "browser": { + "./foo": "./not-used.js" + } +} \ No newline at end of file diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/with space.mjs b/packages/utils/node-resolver-core/test/fixture/node_modules/package-exports/with space.mjs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-exports/conf.json b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-exports/conf.json new file mode 100644 index 00000000000..2923ba1d0f3 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-exports/conf.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "foo": ["foo.js"] + } + } +} diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-exports/foo.js b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-exports/foo.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-exports/package.json b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-exports/package.json new file mode 100644 index 00000000000..be0ddee604f --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-exports/package.json @@ -0,0 +1,7 @@ +{ + "name": "tsconfig-exports", + "private": true, + "exports": { + ".": "./conf.json" + } +} diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-field/conf.json b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-field/conf.json new file mode 100644 index 00000000000..2923ba1d0f3 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-field/conf.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "foo": ["foo.js"] + } + } +} diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-field/foo.js b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-field/foo.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-field/package.json b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-field/package.json new file mode 100644 index 00000000000..7138fddea09 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-field/package.json @@ -0,0 +1,5 @@ +{ + "name": "tsconfig-field", + "private": true, + "tsconfig": "conf.json" +} \ No newline at end of file diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-index/foo.js b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-index/foo.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-index/package.json b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-index/package.json new file mode 100644 index 00000000000..eb2431e70cc --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-index/package.json @@ -0,0 +1,4 @@ +{ + "name": "tsconfig-index", + "private": true +} \ No newline at end of file diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-index/tsconfig.json b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-index/tsconfig.json new file mode 100644 index 00000000000..2923ba1d0f3 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-index/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "foo": ["foo.js"] + } + } +} diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-not-used/foo.js b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-not-used/foo.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-not-used/tsconfig.json b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-not-used/tsconfig.json new file mode 100644 index 00000000000..eb16d1279c9 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/node_modules/tsconfig-not-used/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "ts-path": ["foo.js"] + } + } +} diff --git a/packages/utils/node-resolver-core/test/fixture/package.json b/packages/utils/node-resolver-core/test/fixture/package.json index c8c96b71fcc..ae71d843c51 100755 --- a/packages/utils/node-resolver-core/test/fixture/package.json +++ b/packages/utils/node-resolver-core/test/fixture/package.json @@ -8,7 +8,11 @@ "aliasedfolder": "./nested", "aliasedabsolute": "/nested", "foo/bar": "./bar.js", - "glob/*/*": "./nested/$2" + "glob/*/*": "./nested/$2", + "./baz": "./bar.js" + }, + "imports": { + "#test": "./bar.js" }, "dependencies": { "foo": "^0.3.4" diff --git a/packages/utils/node-resolver-core/test/fixture/priority/foo.js b/packages/utils/node-resolver-core/test/fixture/priority/foo.js new file mode 100644 index 00000000000..2651774ae60 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/priority/foo.js @@ -0,0 +1 @@ +module.exports = 'foo'; diff --git a/packages/utils/node-resolver-core/test/fixture/priority/foo/index.js b/packages/utils/node-resolver-core/test/fixture/priority/foo/index.js new file mode 100644 index 00000000000..02f29c556d1 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/priority/foo/index.js @@ -0,0 +1 @@ +module.exports = 'foo/index.js'; diff --git a/packages/utils/node-resolver-core/test/fixture/ts-extensions/a.cts b/packages/utils/node-resolver-core/test/fixture/ts-extensions/a.cts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/ts-extensions/a.mts b/packages/utils/node-resolver-core/test/fixture/ts-extensions/a.mts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/ts-extensions/a.ts b/packages/utils/node-resolver-core/test/fixture/ts-extensions/a.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/ts-extensions/a.tsx b/packages/utils/node-resolver-core/test/fixture/ts-extensions/a.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/ts-extensions/b.js b/packages/utils/node-resolver-core/test/fixture/ts-extensions/b.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/ts-extensions/b.ts b/packages/utils/node-resolver-core/test/fixture/ts-extensions/b.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/ts-extensions/c.js.ts b/packages/utils/node-resolver-core/test/fixture/ts-extensions/c.js.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/ts-extensions/c.ts b/packages/utils/node-resolver-core/test/fixture/ts-extensions/c.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/ts-extensions/index.ts b/packages/utils/node-resolver-core/test/fixture/ts-extensions/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig.json b/packages/utils/node-resolver-core/test/fixture/tsconfig.json new file mode 100644 index 00000000000..eb16d1279c9 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "ts-path": ["foo.js"] + } + } +} diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/exports/index.js b/packages/utils/node-resolver-core/test/fixture/tsconfig/exports/index.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/exports/tsconfig.json b/packages/utils/node-resolver-core/test/fixture/tsconfig/exports/tsconfig.json new file mode 100644 index 00000000000..f0e3159b21a --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/tsconfig/exports/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "tsconfig-exports" +} diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/extends-not-found/index.js b/packages/utils/node-resolver-core/test/fixture/tsconfig/extends-not-found/index.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/extends-not-found/tsconfig.json b/packages/utils/node-resolver-core/test/fixture/tsconfig/extends-not-found/tsconfig.json new file mode 100644 index 00000000000..99df91da3e6 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/tsconfig/extends-not-found/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./not-found" +} diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/field/index.js b/packages/utils/node-resolver-core/test/fixture/tsconfig/field/index.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/field/tsconfig.json b/packages/utils/node-resolver-core/test/fixture/tsconfig/field/tsconfig.json new file mode 100644 index 00000000000..9f86c3f47d6 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/tsconfig/field/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "tsconfig-field" +} diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/index/index.js b/packages/utils/node-resolver-core/test/fixture/tsconfig/index/index.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/index/tsconfig.json b/packages/utils/node-resolver-core/test/fixture/tsconfig/index/tsconfig.json new file mode 100644 index 00000000000..d20b17742f8 --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/tsconfig/index/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "tsconfig-index" +} diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/a.ios.ts b/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/a.ios.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/a.ts b/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/a.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/b.ts b/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/b.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/c-test.ts b/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/c-test.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/index.ts b/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/index.ts new file mode 100644 index 00000000000..b09939b91be --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/index.ts @@ -0,0 +1 @@ +import './a'; diff --git a/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/tsconfig.json b/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/tsconfig.json new file mode 100644 index 00000000000..4c2ccc8ff3e --- /dev/null +++ b/packages/utils/node-resolver-core/test/fixture/tsconfig/suffixes/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "moduleSuffixes": [".ios", "-test", ""] + } +} diff --git a/packages/utils/node-resolver-core/test/resolver.js b/packages/utils/node-resolver-core/test/resolver.js index 9b130fc9fbe..c2e854ced63 100644 --- a/packages/utils/node-resolver-core/test/resolver.js +++ b/packages/utils/node-resolver-core/test/resolver.js @@ -1,5 +1,5 @@ // @flow strict-local -import NodeResolver from '../src/NodeResolver'; +import NodeResolver from '../src/Wrapper'; import path from 'path'; import assert from 'assert'; import nullthrows from 'nullthrows'; @@ -36,7 +36,7 @@ const BROWSER_ENV = new Environment( ); describe('resolver', function () { - let resolver; + let resolver, prodResolver; beforeEach(async function () { await overlayFS.mkdirp(rootDir); @@ -74,13 +74,46 @@ describe('resolver', function () { resolver = new NodeResolver({ fs: overlayFS, projectRoot: rootDir, - mainFields: ['browser', 'source', 'module', 'main'], - extensions: ['.js', '.json'], + mode: 'development', + }); + + prodResolver = new NodeResolver({ + fs: overlayFS, + projectRoot: rootDir, + mode: 'production', }); configCache.clear(); }); + function normalize(res) { + return { + filePath: res?.filePath, + invalidateOnFileCreate: + res?.invalidateOnFileCreate?.sort((a, b) => { + let ax = + a.filePath ?? + a.glob ?? + (a.aboveFilePath != null && a.fileName != null + ? a.aboveFilePath + a.fileName + : ''); + let bx = + b.filePath ?? + b.glob ?? + (b.aboveFilePath != null && b.fileName != null + ? b.aboveFilePath + b.fileName + : ''); + return ax < bx ? -1 : 1; + }) ?? [], + invalidateOnFileChange: res?.invalidateOnFileChange?.sort() ?? [], + sideEffects: res?.sideEffects ?? true, + }; + } + + function check(resolved, expected) { + assert.deepEqual(normalize(resolved), normalize(expected)); + } + describe('file paths', function () { it('should resolve a relative path with an extension', async function () { let resolved = await resolver.resolve({ @@ -266,7 +299,7 @@ describe('resolver', function () { nullthrows(resolved).filePath, path.join(rootDir, 'nested', 'index.js'), ); - assert.deepEqual(nullthrows(resolved).query?.toString(), 'foo=bar'); + // assert.deepEqual(nullthrows(resolved).query?.toString(), 'foo=bar'); }); it('should not support query params for CommonJS specifiers', async function () { @@ -290,30 +323,23 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: require.resolve('browserify-zlib'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/browserify-zlib', - aboveFilePath: path.join(rootDir, 'index'), + aboveFilePath: rootDir, }, { fileName: 'package.json', - aboveFilePath: require.resolve('browserify-zlib/lib/index.js'), + aboveFilePath: path.dirname(require.resolve('browserify-zlib/lib')), }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), require.resolve('browserify-zlib/package.json'), ], }); @@ -326,30 +352,23 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: require.resolve('browserify-zlib'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/browserify-zlib', - aboveFilePath: path.join(rootDir, 'index'), + aboveFilePath: rootDir, }, { fileName: 'package.json', - aboveFilePath: require.resolve('browserify-zlib/lib/index.js'), + aboveFilePath: path.dirname(require.resolve('browserify-zlib/lib')), }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), require.resolve('browserify-zlib/package.json'), ], }); @@ -362,43 +381,21 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(__dirname, '..', 'src', '_empty.js'), sideEffects: undefined, query: undefined, - invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(__dirname, '..', 'src', '_empty.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(__dirname, '..', 'src', '_empty.js'), - }, - ], - invalidateOnFileChange: [ - path.join(rootDir, 'package.json'), - path.join(__dirname, '..', 'package.json'), - ], }); }); - it('should error when resolving node builtin modules with --target=node', async function () { + it('should exclude node builtin modules with --target=node', async function () { let resolved = await resolver.resolve({ env: NODE_ENV, filename: 'zlib', specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, {isExcluded: true}); + check(resolved, {isExcluded: true}); }); it('should exclude the electron module in electron environments', async function () { @@ -407,6 +404,7 @@ describe('resolver', function () { createEnvironment({ context: 'electron-main', isLibrary: true, + includeNodeModules: true, }), DEFAULT_OPTIONS, ), @@ -416,7 +414,7 @@ describe('resolver', function () { sourcePath: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, {isExcluded: true}); + check(resolved, {isExcluded: true}); }); }); @@ -428,26 +426,19 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'node_modules', 'foo', 'index.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/foo', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join(rootDir, 'node_modules', 'foo', 'package.json'), ], }); @@ -460,26 +451,19 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'node_modules', 'package-main', 'main.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/package-main', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join(rootDir, 'node_modules', 'package-main', 'package.json'), ], }); @@ -492,7 +476,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -502,21 +486,14 @@ describe('resolver', function () { sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/package-module', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join(rootDir, 'node_modules', 'package-module', 'package.json'), ], }); @@ -529,7 +506,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -539,21 +516,14 @@ describe('resolver', function () { sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/package-browser', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join(rootDir, 'node_modules', 'package-browser', 'package.json'), ], }); @@ -566,7 +536,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -576,21 +546,14 @@ describe('resolver', function () { sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/package-browser', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join(rootDir, 'node_modules', 'package-browser', 'package.json'), ], }); @@ -603,7 +566,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -614,23 +577,55 @@ describe('resolver', function () { query: undefined, invalidateOnFileCreate: [ { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), + fileName: 'node_modules/package-fallback', + aboveFilePath: rootDir, }, { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), + filePath: path.join( + rootDir, + 'node_modules', + 'package-fallback', + 'main.js', + ), }, { - fileName: 'node_modules/package-fallback', - aboveFilePath: path.join(rootDir, 'foo.js'), + filePath: path.join( + rootDir, + 'node_modules', + 'package-fallback', + 'main.js.cjs', + ), }, { filePath: path.join( rootDir, 'node_modules', 'package-fallback', - 'main.js', + 'main.js.mjs', + ), + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'package-fallback', + 'main.js.jsx', + ), + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'package-fallback', + 'main.js.ts', + ), + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'package-fallback', + 'main.js.tsx', ), }, { @@ -660,6 +655,7 @@ describe('resolver', function () { ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', @@ -677,7 +673,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -689,16 +685,34 @@ describe('resolver', function () { query: undefined, invalidateOnFileCreate: [ { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), + fileName: 'node_modules/package-main-directory', + aboveFilePath: rootDir, + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'package-main-directory', + 'nested', + 'package.json', + ), }, { fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: path.join( + rootDir, + 'node_modules', + 'package-main-directory', + 'nested', + ), }, { - fileName: 'node_modules/package-main-directory', - aboveFilePath: path.join(rootDir, 'foo.js'), + filePath: path.join( + rootDir, + 'node_modules', + 'package-main-directory', + 'nested', + ), }, { filePath: path.join( @@ -721,23 +735,45 @@ describe('resolver', function () { rootDir, 'node_modules', 'package-main-directory', - 'nested', - 'package.json', + 'nested.jsx', ), }, { - fileName: 'package.json', - aboveFilePath: path.join( + filePath: path.join( rootDir, 'node_modules', 'package-main-directory', - 'nested', - 'index.js', + 'nested.cjs', + ), + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'package-main-directory', + 'nested.mjs', + ), + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'package-main-directory', + 'nested.ts', + ), + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'package-main-directory', + 'nested.tsx', ), }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', @@ -755,36 +791,23 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'node_modules', 'foo', 'nested', 'baz.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/foo', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, { fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'foo', - 'nested', - 'baz.js', - ), + aboveFilePath: path.join(rootDir, 'node_modules', 'foo', 'nested'), }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join(rootDir, 'node_modules', 'foo', 'package.json'), ], }); @@ -797,26 +820,19 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.resolve(rootDir, 'node_modules/@scope/pkg/index.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/@scope/pkg', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join(rootDir, 'node_modules', '@scope', 'pkg', 'package.json'), ], }); @@ -829,22 +845,14 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.resolve(rootDir, 'node_modules/@scope/pkg/foo/bar.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/@scope/pkg', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, { fileName: 'package.json', @@ -854,12 +862,12 @@ describe('resolver', function () { '@scope', 'pkg', 'foo', - 'bar.js', ), }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join(rootDir, 'node_modules', '@scope', 'pkg', 'package.json'), ], }); @@ -873,7 +881,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.resolve( rootDir, 'node_modules/side-effects-false/src/index.js', @@ -881,17 +889,9 @@ describe('resolver', function () { sideEffects: false, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/side-effects-false', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, { fileName: 'package.json', @@ -900,12 +900,12 @@ describe('resolver', function () { 'node_modules', 'side-effects-false', 'src', - 'index.js', ), }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', @@ -923,7 +923,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.resolve( rootDir, 'node_modules/side-effects-false/src/index.js', @@ -931,17 +931,9 @@ describe('resolver', function () { sideEffects: false, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/side-effects-false', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, { fileName: 'package.json', @@ -950,12 +942,12 @@ describe('resolver', function () { 'node_modules', 'side-effects-false', 'src', - 'index.js', ), }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', @@ -973,7 +965,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.resolve( rootDir, 'node_modules/side-effects-false/src/index.js', @@ -981,17 +973,9 @@ describe('resolver', function () { sideEffects: false, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/side-effects-false', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, { filePath: path.join( @@ -1008,23 +992,13 @@ describe('resolver', function () { 'node_modules', 'side-effects-false', 'src', - 'index', - ), - fileName: 'package.json', - }, - { - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'side-effects-false', - 'src', - 'index.js', ), fileName: 'package.json', }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', @@ -1042,7 +1016,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.resolve( rootDir, 'node_modules/side-effects-false/src/index.js', @@ -1050,17 +1024,9 @@ describe('resolver', function () { sideEffects: false, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/side-effects-false', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, { filePath: path.join( @@ -1077,23 +1043,13 @@ describe('resolver', function () { 'node_modules', 'side-effects-false', 'src', - 'index', - ), - fileName: 'package.json', - }, - { - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'side-effects-false', - 'src', - 'index.js', ), fileName: 'package.json', }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', @@ -1111,7 +1067,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.resolve( rootDir, 'node_modules/side-effects-package-redirect-up/foo/real-bar.js', @@ -1120,16 +1076,17 @@ describe('resolver', function () { query: undefined, invalidateOnFileCreate: [ { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), + fileName: 'node_modules/side-effects-package-redirect-up', + aboveFilePath: rootDir, }, { - fileName: 'node_modules/side-effects-package-redirect-up', - aboveFilePath: path.join(rootDir, 'foo.js'), + filePath: path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-up', + 'foo', + 'bar', + ), }, { filePath: path.join( @@ -1150,32 +1107,71 @@ describe('resolver', function () { ), }, { - fileName: 'package.json', - aboveFilePath: path.join( + filePath: path.join( rootDir, - 'node_modules/side-effects-package-redirect-up/foo/real-bar.js', + 'node_modules', + 'side-effects-package-redirect-up', + 'foo', + 'bar.jsx', ), }, - ], - invalidateOnFileChange: [ - path.join(rootDir, 'package.json'), - path.join( - rootDir, - 'node_modules', - 'side-effects-package-redirect-up', - 'package.json', - ), - path.join( - rootDir, - 'node_modules', - 'side-effects-package-redirect-up', - 'foo', - 'bar', - 'package.json', - ), - path.join( - rootDir, - 'node_modules', + { + filePath: path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-up', + 'foo', + 'bar.cjs', + ), + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-up', + 'foo', + 'bar.mjs', + ), + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-up', + 'foo', + 'bar.ts', + ), + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-up', + 'foo', + 'bar.tsx', + ), + }, + ], + invalidateOnFileChange: [ + path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), + path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-up', + 'package.json', + ), + path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-up', + 'foo', + 'bar', + 'package.json', + ), + path.join( + rootDir, + 'node_modules', 'side-effects-package-redirect-up', 'foo', 'package.json', @@ -1191,7 +1187,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.resolve( rootDir, 'node_modules/side-effects-package-redirect-down/foo/bar/baz/real-bar.js', @@ -1200,16 +1196,26 @@ describe('resolver', function () { query: undefined, invalidateOnFileCreate: [ { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), + fileName: 'node_modules/side-effects-package-redirect-down', + aboveFilePath: rootDir, }, { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), + filePath: path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-down', + 'foo', + 'bar', + ), }, { - fileName: 'node_modules/side-effects-package-redirect-down', - aboveFilePath: path.join(rootDir, 'foo.js'), + filePath: path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-down', + 'foo', + 'bar.js', + ), }, { filePath: path.join( @@ -1217,7 +1223,7 @@ describe('resolver', function () { 'node_modules', 'side-effects-package-redirect-down', 'foo', - 'bar.js', + 'bar.jsx', ), }, { @@ -1230,15 +1236,45 @@ describe('resolver', function () { ), }, { - fileName: 'package.json', - aboveFilePath: path.join( + filePath: path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-down', + 'foo', + 'bar.ts', + ), + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-down', + 'foo', + 'bar.tsx', + ), + }, + { + filePath: path.join( + rootDir, + 'node_modules', + 'side-effects-package-redirect-down', + 'foo', + 'bar.cjs', + ), + }, + { + filePath: path.join( rootDir, - 'node_modules/side-effects-package-redirect-down/foo/bar/baz/real-bar.js', + 'node_modules', + 'side-effects-package-redirect-down', + 'foo', + 'bar.mjs', ), }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', @@ -1275,7 +1311,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual( + check( {filePath: resolved?.filePath, sideEffects: resolved?.sideEffects}, { filePath: path.resolve( @@ -1293,7 +1329,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual( + check( {filePath: resolved?.filePath, sideEffects: resolved?.sideEffects}, { filePath: path.resolve( @@ -1311,7 +1347,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual( + check( {filePath: resolved?.filePath, sideEffects: resolved?.sideEffects}, { filePath: path.resolve( @@ -1329,7 +1365,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual( + check( {filePath: resolved?.filePath, sideEffects: resolved?.sideEffects}, { filePath: path.resolve( @@ -1347,7 +1383,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual( + check( {filePath: resolved?.filePath, sideEffects: resolved?.sideEffects}, { filePath: path.resolve( @@ -1408,7 +1444,7 @@ describe('resolver', function () { }); assert.deepEqual(nullthrows(resolved).diagnostics, [ { - message: 'Cannot find module @scope/pkg?foo=2', + message: `Cannot find module '@scope/pkg?foo=2'`, hints: ["Did you mean '__@scope/pkg__'?"], }, ]); @@ -1437,7 +1473,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -1447,21 +1483,14 @@ describe('resolver', function () { sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/package-browser-alias', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', @@ -1479,7 +1508,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -1489,21 +1518,14 @@ describe('resolver', function () { sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/package-browser-alias', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', @@ -1526,7 +1548,7 @@ describe('resolver', function () { 'browser.js', ), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -1535,30 +1557,7 @@ describe('resolver', function () { ), sideEffects: undefined, query: undefined, - invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-browser-alias', - 'browser.js', - ), - }, - { - fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-browser-alias', - 'bar', - ), - }, - ], + invalidateOnFileCreate: [], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), path.join( @@ -1578,7 +1577,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -1588,21 +1587,14 @@ describe('resolver', function () { sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/package-browser-alias', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', @@ -1625,7 +1617,7 @@ describe('resolver', function () { 'browser.js', ), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -1635,28 +1627,6 @@ describe('resolver', function () { sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-browser-alias', - 'browser.js', - ), - }, - { - fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-browser-alias', - 'nested', - ), - }, { fileName: 'package.json', aboveFilePath: path.join( @@ -1665,7 +1635,6 @@ describe('resolver', function () { 'package-browser-alias', 'subfolder1', 'subfolder2', - 'subfile.js', ), }, ], @@ -1688,26 +1657,19 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'node_modules', 'package-alias', 'bar.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/package-alias', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join(rootDir, 'node_modules', 'package-alias', 'package.json'), ], }); @@ -1725,34 +1687,11 @@ describe('resolver', function () { 'browser.js', ), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'node_modules', 'package-alias', 'bar.js'), sideEffects: undefined, query: undefined, - invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-alias', - 'browser.js', - ), - }, - { - fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-alias', - 'bar', - ), - }, - ], + invalidateOnFileCreate: [], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), path.join(rootDir, 'node_modules', 'package-alias', 'package.json'), @@ -1772,7 +1711,7 @@ describe('resolver', function () { 'index.js', ), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -1783,27 +1722,13 @@ describe('resolver', function () { sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-alias-glob', - 'index.js', - ), - }, { fileName: 'package.json', aboveFilePath: path.join( rootDir, 'node_modules', 'package-alias-glob', - 'src', - 'test', + 'lib', ), }, { @@ -1813,7 +1738,6 @@ describe('resolver', function () { 'node_modules', 'package-alias-glob', 'src', - 'test.js', ), }, ], @@ -1836,22 +1760,14 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'node_modules', 'foo', 'index.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/foo', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ @@ -1868,37 +1784,18 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'node_modules', 'package-alias', 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'node_modules', 'foo', 'index.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-alias', - 'foo.js', - ), - }, { fileName: 'node_modules/foo', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-alias', - 'foo.js', - ), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), - path.join(rootDir, 'node_modules', 'package-alias', 'package.json'), path.join(rootDir, 'node_modules', 'foo', 'package.json'), ], }); @@ -1911,37 +1808,18 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'node_modules', 'package-alias', 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'node_modules', 'foo', 'bar.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-alias', - 'foo.js', - ), - }, { fileName: 'node_modules/foo', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-alias', - 'foo.js', - ), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), - path.join(rootDir, 'node_modules', 'package-alias', 'package.json'), path.join(rootDir, 'node_modules', 'foo', 'package.json'), ], }); @@ -1954,24 +1832,11 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'bar.js'), sideEffects: undefined, query: undefined, - invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'bar.js'), - }, - ], + invalidateOnFileCreate: [], invalidateOnFileChange: [path.join(rootDir, 'package.json')], }); }); @@ -1983,33 +1848,12 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'node_modules', 'package-alias', 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'bar.js'), sideEffects: undefined, query: undefined, - invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-alias', - 'foo.js', - ), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'bar.js'), - }, - ], - invalidateOnFileChange: [ - path.join(rootDir, 'package.json'), - path.join(rootDir, 'node_modules', 'package-alias', 'package.json'), - ], + invalidateOnFileCreate: [], + invalidateOnFileChange: [path.join(rootDir, 'package.json')], }); }); @@ -2020,26 +1864,14 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'nested', 'test.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ { fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested', 'test.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested', 'test.js'), + aboveFilePath: path.join(rootDir, 'nested'), }, ], invalidateOnFileChange: [path.join(rootDir, 'package.json')], @@ -2053,35 +1885,38 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'nested', 'index.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ { fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), + aboveFilePath: path.join(rootDir, 'nested'), }, { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), + filePath: path.join(rootDir, 'nested'), }, { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested'), + filePath: path.join(rootDir, 'nested.js'), }, { - filePath: path.join(rootDir, 'nested.js'), + filePath: path.join(rootDir, 'nested.jsx'), }, { - filePath: path.join(rootDir, 'nested.json'), + filePath: path.join(rootDir, 'nested.cjs'), }, { - filePath: path.join(rootDir, 'nested', 'package.json'), + filePath: path.join(rootDir, 'nested.mjs'), }, { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested/index.js'), + filePath: path.join(rootDir, 'nested.ts'), + }, + { + filePath: path.join(rootDir, 'nested.tsx'), + }, + { + filePath: path.join(rootDir, 'nested', 'package.json'), }, ], invalidateOnFileChange: [path.join(rootDir, 'package.json')], @@ -2095,26 +1930,14 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'nested', 'test.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ { fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested', 'test.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested', 'test.js'), + aboveFilePath: path.join(rootDir, 'nested'), }, ], invalidateOnFileChange: [path.join(rootDir, 'package.json')], @@ -2128,35 +1951,38 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'nested', 'index.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ { fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), + aboveFilePath: path.join(rootDir, 'nested'), }, { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), + filePath: path.join(rootDir, 'nested'), }, { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested'), + filePath: path.join(rootDir, 'nested.js'), }, { - filePath: path.join(rootDir, 'nested.js'), + filePath: path.join(rootDir, 'nested.jsx'), }, { - filePath: path.join(rootDir, 'nested.json'), + filePath: path.join(rootDir, 'nested.cjs'), }, { - filePath: path.join(rootDir, 'nested', 'package.json'), + filePath: path.join(rootDir, 'nested.mjs'), }, { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested/index.js'), + filePath: path.join(rootDir, 'nested.ts'), + }, + { + filePath: path.join(rootDir, 'nested.tsx'), + }, + { + filePath: path.join(rootDir, 'nested', 'package.json'), }, ], invalidateOnFileChange: [path.join(rootDir, 'package.json')], @@ -2170,24 +1996,11 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'bar.js'), sideEffects: undefined, query: undefined, - invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'bar.js'), - }, - ], + invalidateOnFileCreate: [], invalidateOnFileChange: [path.join(rootDir, 'package.json')], }); }); @@ -2199,26 +2012,14 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'nested', 'test.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ { fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested', 'test'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested', 'test.js'), + aboveFilePath: path.join(rootDir, 'nested'), }, ], invalidateOnFileChange: [path.join(rootDir, 'package.json')], @@ -2232,26 +2033,14 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'nested', 'test.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ { fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested', 'test.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested', 'test.js'), + aboveFilePath: path.join(rootDir, 'nested'), }, ], invalidateOnFileChange: [path.join(rootDir, 'package.json')], @@ -2265,37 +2054,17 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'node_modules', 'package-alias', 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'nested', 'test.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ { fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join( - rootDir, - 'node_modules', - 'package-alias', - 'foo.js', - ), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested', 'test.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'nested', 'test.js'), + aboveFilePath: path.join(rootDir, 'nested'), }, ], - invalidateOnFileChange: [ - path.join(rootDir, 'package.json'), - path.join(rootDir, 'node_modules', 'package-alias', 'package.json'), - ], + invalidateOnFileChange: [path.join(rootDir, 'package.json')], }); }); @@ -2306,37 +2075,25 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(__dirname, '..', 'src', '_empty.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/package-browser-exclude', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(__dirname, '..', 'src', '_empty.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', 'package-browser-exclude', 'package.json', ), - path.join(__dirname, '..', 'package.json'), ], }); }); @@ -2348,37 +2105,25 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(__dirname, '..', 'src', '_empty.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/package-alias-exclude', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(__dirname, '..', 'src', '_empty.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', 'package-alias-exclude', 'package.json', ), - path.join(__dirname, '..', 'package.json'), ], }); }); @@ -2393,27 +2138,21 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join(rootDir, 'packages', 'source', 'source.js'), sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/source', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join(rootDir, 'node_modules', 'source', 'package.json'), + path.join(rootDir, 'packages', 'source', 'package.json'), ], }); }); @@ -2425,7 +2164,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -2438,22 +2177,24 @@ describe('resolver', function () { sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/source-pnpm', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join(rootDir, 'node_modules', 'source-pnpm', 'package.json'), + path.join( + rootDir, + 'node_modules', + '.pnpm', + 'source-pnpm@1.0.0', + 'node_modules', + 'source-pnpm', + 'package.json', + ), ], }); }); @@ -2467,7 +2208,7 @@ describe('resolver', function () { specifierType: 'esm', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, { + check(resolved, { filePath: path.join( rootDir, 'node_modules', @@ -2477,21 +2218,14 @@ describe('resolver', function () { sideEffects: undefined, query: undefined, invalidateOnFileCreate: [ - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'index'), - }, - { - fileName: 'package.json', - aboveFilePath: path.join(rootDir, 'foo.js'), - }, { fileName: 'node_modules/source-not-symlinked', - aboveFilePath: path.join(rootDir, 'foo.js'), + aboveFilePath: rootDir, }, ], invalidateOnFileChange: [ path.join(rootDir, 'package.json'), + path.join(rootDir, 'tsconfig.json'), path.join( rootDir, 'node_modules', @@ -2504,6 +2238,98 @@ describe('resolver', function () { }); }); + describe('package exports', function () { + it('should resolve a browser development import', async function () { + let resolved = await resolver.resolve({ + env: BROWSER_ENV, + filename: 'package-conditions', + specifierType: 'esm', + parent: path.join(rootDir, 'foo.js'), + }); + assert.equal( + resolved?.filePath, + path.join( + rootDir, + 'node_modules/package-conditions/browser-import-dev.mjs', + ), + ); + }); + + it('should resolve a browser development require', async function () { + let resolved = await resolver.resolve({ + env: BROWSER_ENV, + filename: 'package-conditions', + specifierType: 'commonjs', + parent: path.join(rootDir, 'foo.js'), + }); + assert.equal( + resolved?.filePath, + path.join( + rootDir, + 'node_modules/package-conditions/browser-require-dev.cjs', + ), + ); + }); + + it('should resolve a browser production import', async function () { + let resolved = await prodResolver.resolve({ + env: BROWSER_ENV, + filename: 'package-conditions', + specifierType: 'esm', + parent: path.join(rootDir, 'foo.js'), + }); + assert.equal( + resolved?.filePath, + path.join( + rootDir, + 'node_modules/package-conditions/browser-import-prod.mjs', + ), + ); + }); + + it('should resolve a browser development require', async function () { + let resolved = await prodResolver.resolve({ + env: BROWSER_ENV, + filename: 'package-conditions', + specifierType: 'commonjs', + parent: path.join(rootDir, 'foo.js'), + }); + assert.equal( + resolved?.filePath, + path.join( + rootDir, + 'node_modules/package-conditions/browser-require-prod.cjs', + ), + ); + }); + + it('should resolve a node import', async function () { + let resolved = await resolver.resolve({ + env: NODE_INCLUDE_ENV, + filename: 'package-conditions', + specifierType: 'esm', + parent: path.join(rootDir, 'foo.js'), + }); + assert.equal( + resolved?.filePath, + path.join(rootDir, 'node_modules/package-conditions/node-import.mjs'), + ); + }); + + it('should resolve a node require', async function () { + let resolved = await resolver.resolve({ + env: NODE_INCLUDE_ENV, + filename: 'package-conditions', + specifierType: 'commonjs', + parent: path.join(rootDir, 'foo.js'), + }); + assert.equal( + resolved?.filePath, + path.join(rootDir, 'node_modules/package-conditions/node-require.cjs'), + ); + }); + }); + describe('symlinks', function () { it('should resolve symlinked files to their realpath', async function () { let resolved = await resolver.resolve({ @@ -2559,27 +2385,31 @@ describe('resolver', function () { }); it('should throw when a node_module cannot be resolved', async function () { - assert.strictEqual( - null, - await resolver.resolve({ - env: BROWSER_ENV, - filename: 'xyz', - specifierType: 'esm', - parent: path.join(rootDir, 'foo.js'), - }), - ); + let result = await resolver.resolve({ + env: BROWSER_ENV, + filename: 'food', + specifierType: 'esm', + parent: path.join(rootDir, 'foo.js'), + }); + + assert.deepEqual(nullthrows(nullthrows(result).diagnostics)[0], { + message: `Cannot find module 'food'`, + hints: [`Did you mean '__foo__'?`], + }); }); it('should throw when a subfile of a node_module cannot be resolved', async function () { - assert.strictEqual( - null, - await resolver.resolve({ - env: BROWSER_ENV, - filename: 'xyz/test/file', - specifierType: 'esm', - parent: path.join(rootDir, 'foo.js'), - }), - ); + let result = await resolver.resolve({ + env: BROWSER_ENV, + filename: 'foo/bark', + specifierType: 'esm', + parent: path.join(rootDir, 'foo.js'), + }); + + assert.deepEqual(nullthrows(nullthrows(result).diagnostics)[0], { + message: `Cannot load file './bark' from module 'foo'`, + hints: [`Did you mean '__foo/bar__'?`], + }); }); it('should error when a library is missing an external dependency', async function () { @@ -2620,7 +2450,11 @@ describe('resolver', function () { sourcePath: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(result, {isExcluded: true}); + assert.deepEqual(result, { + isExcluded: true, + invalidateOnFileChange: [], + invalidateOnFileCreate: [], + }); }); it('should not error when external dependencies are declared in peerDependencies', async function () { @@ -2639,7 +2473,11 @@ describe('resolver', function () { sourcePath: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(result, {isExcluded: true}); + assert.deepEqual(result, { + isExcluded: true, + invalidateOnFileChange: [], + invalidateOnFileCreate: [], + }); }); it('should not error on missing dependencies for environment builtins', async function () { @@ -2658,7 +2496,11 @@ describe('resolver', function () { sourcePath: path.join(rootDir, 'env-dep/foo.js'), }); - assert.deepEqual(result, {isExcluded: true}); + assert.deepEqual(result, { + isExcluded: true, + invalidateOnFileChange: [], + invalidateOnFileCreate: [], + }); }); it('should not error on builtin node modules', async function () { @@ -2702,6 +2544,260 @@ describe('resolver', function () { 'External dependency "foo" does not satisfy required semver range "^0.4.0".', ); }); + + it('should error when package.json is invalid', async function () { + let result = await resolver.resolve({ + env: BROWSER_ENV, + filename: 'json-error', + specifierType: 'esm', + parent: path.join(rootDir, 'foo.js'), + }); + let file = path.join( + rootDir, + 'node_modules', + 'json-error', + 'package.json', + ); + assert.deepEqual(result?.diagnostics, [ + { + message: 'Error parsing JSON', + codeFrames: [ + { + language: 'json', + filePath: file, + code: await overlayFS.readFile(file, 'utf8'), + codeHighlights: [ + { + message: 'expected `,` or `}` at line 3 column 3', + start: { + line: 3, + column: 3, + }, + end: { + line: 3, + column: 3, + }, + }, + ], + }, + ], + }, + ]); + }); + + it('should error on an invalid empty specifier', async function () { + let result = await resolver.resolve({ + env: BROWSER_ENV, + filename: '', + specifierType: 'esm', + parent: path.join(rootDir, 'foo.js'), + }); + assert.deepEqual(result?.diagnostics, [ + { + message: 'Invalid empty specifier', + }, + ]); + }); + + it('should error on unknown URL schemes', async function () { + let result = await resolver.resolve({ + env: BROWSER_ENV, + filename: 'http://parceljs.org', + specifierType: 'esm', + parent: path.join(rootDir, 'foo.js'), + loc: { + filePath: path.join(rootDir, 'foo.js'), + start: { + line: 1, + column: 1, + }, + end: { + line: 1, + column: 10, + }, + }, + }); + assert.deepEqual(result?.diagnostics, [ + { + message: `Unknown url scheme or pipeline 'http:'`, + }, + ]); + }); + + it('should error on non-exported package paths', async function () { + let result = await resolver.resolve({ + env: BROWSER_ENV, + filename: 'package-exports/internal', + specifierType: 'esm', + parent: path.join(rootDir, 'foo.js'), + }); + let file = path.join( + rootDir, + 'node_modules/package-exports/package.json', + ); + assert.deepEqual(result?.diagnostics, [ + { + message: `Module 'package-exports/internal' is not exported from the 'package-exports' package`, + codeFrames: [ + { + language: 'json', + filePath: file, + code: await overlayFS.readFile(file, 'utf8'), + codeHighlights: [ + { + message: undefined, + start: { + line: 4, + column: 14, + }, + end: { + line: 12, + column: 3, + }, + }, + ], + }, + ], + }, + ]); + }); + + it('should error when export does not exist', async function () { + let result = await resolver.resolve({ + env: BROWSER_ENV, + filename: 'package-exports/missing', + specifierType: 'esm', + parent: path.join(rootDir, 'foo.js'), + }); + assert.deepEqual(result?.diagnostics, [ + { + message: `Cannot load file './missing.mjs' from module 'package-exports'`, + hints: [], + }, + ]); + }); + + it('should error on undefined package imports', async function () { + let result = await resolver.resolve({ + env: BROWSER_ENV, + filename: '#foo', + specifierType: 'esm', + parent: path.join(rootDir, 'foo.js'), + }); + let file = path.join(rootDir, 'package.json'); + assert.deepEqual(result?.diagnostics, [ + { + message: `Package import '#foo' is not defined in the 'resolver' package`, + codeFrames: [ + { + language: 'json', + filePath: file, + code: await overlayFS.readFile(file, 'utf8'), + codeHighlights: [ + { + message: undefined, + start: { + line: 14, + column: 14, + }, + end: { + line: 16, + column: 3, + }, + }, + ], + }, + ], + }, + ]); + }); + + it("should error when package.json doesn't define imports field", async function () { + let result = await resolver.resolve({ + env: BROWSER_ENV, + filename: '#foo', + specifierType: 'esm', + parent: path.join(rootDir, 'node_modules', 'foo', 'foo.js'), + }); + let file = path.join(rootDir, 'node_modules', 'foo', 'package.json'); + assert.deepEqual(result?.diagnostics, [ + { + message: `Package import '#foo' is not defined in the 'foo' package`, + codeFrames: [ + { + language: 'json', + filePath: file, + code: await overlayFS.readFile(file, 'utf8'), + codeHighlights: [], + }, + ], + }, + ]); + }); + + it("should error when a package.json couldn't be found", async function () { + let result = await resolver.resolve({ + env: BROWSER_ENV, + filename: '#foo', + specifierType: 'esm', + parent: path.join( + rootDir, + 'node_modules', + 'tsconfig-not-used', + 'foo.js', + ), + }); + assert.deepEqual(result?.diagnostics, [ + { + message: `Cannot find a package.json above './node\\_modules/tsconfig-not-used'`, + }, + ]); + }); + + it("should error when a tsconfig.json extends couldn't be found", async function () { + let result = await resolver.resolve({ + env: BROWSER_ENV, + filename: './bar', + specifierType: 'esm', + parent: path.join(rootDir, 'tsconfig', 'extends-not-found', 'index.js'), + }); + let file = path.join( + rootDir, + 'tsconfig', + 'extends-not-found', + 'tsconfig.json', + ); + assert.deepEqual(result?.diagnostics, [ + { + message: 'Could not find extended tsconfig', + codeFrames: [ + { + language: 'json', + filePath: file, + code: await overlayFS.readFile(file, 'utf8'), + codeHighlights: [ + { + message: undefined, + start: { + line: 2, + column: 14, + }, + end: { + line: 2, + column: 26, + }, + }, + ], + }, + ], + }, + { + message: + "Cannot load file './not-found' in './tsconfig/extends-not-found'.", + hints: [], + }, + ]); + }); }); describe('urls', function () { @@ -2712,7 +2808,7 @@ describe('resolver', function () { specifierType: 'url', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, {isExcluded: true}); + check(resolved, {isExcluded: true}); }); it('should ignore hash urls', async function () { @@ -2722,7 +2818,7 @@ describe('resolver', function () { specifierType: 'url', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, {isExcluded: true}); + check(resolved, {isExcluded: true}); }); it('should ignore http: urls', async function () { @@ -2732,7 +2828,7 @@ describe('resolver', function () { specifierType: 'url', parent: path.join(rootDir, 'foo.js'), }); - assert.deepEqual(resolved, {isExcluded: true}); + check(resolved, {isExcluded: true}); }); }); }); diff --git a/packages/utils/node-resolver-rs/Cargo.toml b/packages/utils/node-resolver-rs/Cargo.toml new file mode 100644 index 00000000000..89b526978d6 --- /dev/null +++ b/packages/utils/node-resolver-rs/Cargo.toml @@ -0,0 +1,27 @@ +[package] +authors = ["Devon Govett "] +name = "parcel-resolver" +version = "0.1.0" +edition = "2021" + +[dependencies] +xxhash-rust = { version = "0.8.2", features = ["xxh3"] } +url = "2.3.1" +percent-encoding = "2.2.0" +serde = { version = "1.0.152", features = ["derive"] } +serde_json = "1.0.91" +bitflags = "1.3.2" +indexmap = { version = "1.9.2", features = ["serde"] } +itertools = "0.10.5" +json_comments = { git = "/~https://github.com/devongovett/json-comments-rs", branch = "strip_in_place" } +typed-arena = "2.0.2" +elsa = "1.7.0" +once_cell = "1.17.0" +glob-match = "0.2.1" +dashmap = "5.4.0" + +[dev-dependencies] +assert_fs = "1.0" + +[target.'cfg(windows)'.dev-dependencies] +is_elevated = "0.1.2" diff --git a/packages/utils/node-resolver-rs/src/builtins.rs b/packages/utils/node-resolver-rs/src/builtins.rs new file mode 100644 index 00000000000..29a31063aa4 --- /dev/null +++ b/packages/utils/node-resolver-rs/src/builtins.rs @@ -0,0 +1,68 @@ +// node -p "[...require('module').builtinModules].map(b => JSON.stringify(b)).join(',\n')" +pub const BUILTINS: &'static [&'static str] = &[ + "_http_agent", + "_http_client", + "_http_common", + "_http_incoming", + "_http_outgoing", + "_http_server", + "_stream_duplex", + "_stream_passthrough", + "_stream_readable", + "_stream_transform", + "_stream_wrap", + "_stream_writable", + "_tls_common", + "_tls_wrap", + "assert", + "assert/strict", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "constants", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "dns/promises", + "domain", + "events", + "fs", + "fs/promises", + "http", + "http2", + "https", + "inspector", + "module", + "net", + "os", + "path", + "path/posix", + "path/win32", + "perf_hooks", + "process", + "punycode", + "querystring", + "readline", + "repl", + "stream", + "stream/consumers", + "stream/promises", + "stream/web", + "string_decoder", + "sys", + "timers", + "timers/promises", + "tls", + "trace_events", + "tty", + "url", + "util", + "util/types", + "v8", + "vm", + "worker_threads", + "zlib", +]; diff --git a/packages/utils/node-resolver-rs/src/cache.rs b/packages/utils/node-resolver-rs/src/cache.rs new file mode 100644 index 00000000000..203560f394c --- /dev/null +++ b/packages/utils/node-resolver-rs/src/cache.rs @@ -0,0 +1,207 @@ +use std::{ + borrow::Cow, + ops::Deref, + path::{Path, PathBuf}, + sync::Mutex, +}; + +use dashmap::DashMap; +use elsa::sync::FrozenMap; +use typed_arena::Arena; + +use crate::{ + fs::{FileSystem, OsFileSystem}, + package_json::{PackageJson, SourceField}, + tsconfig::{TsConfig, TsConfigWrapper}, + ResolverError, +}; + +pub struct Cache { + pub fs: Fs, + // This stores file content strings, which are borrowed when parsing package.json and tsconfig.json files. + arena: Mutex>>, + // These map paths to parsed config files. They aren't really 'static, but Rust doens't have a good + // way to associate a lifetime with owned data stored in the same struct. We only vend temporary references + // from our public methods so this is ok for now. FrozenMap is an append only map, which doesn't require &mut + // to insert into. Since each value is in a Box, it won't move and therefore references are stable. + packages: FrozenMap, ResolverError>>>, + tsconfigs: FrozenMap, ResolverError>>>, + is_file_cache: DashMap, + is_dir_cache: DashMap, + realpath_cache: DashMap>, +} + +// Special Cow implementation for a Cache that doesn't require Clone. +pub enum CacheCow<'a, Fs> { + Borrowed(&'a Cache), + Owned(Cache), +} + +impl<'a, Fs> Deref for CacheCow<'a, Fs> { + type Target = Cache; + + fn deref(&self) -> &Self::Target { + match self { + CacheCow::Borrowed(c) => *c, + CacheCow::Owned(c) => c, + } + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct JsonError { + pub path: PathBuf, + pub line: usize, + pub column: usize, + pub message: String, +} + +impl JsonError { + fn new(path: PathBuf, err: serde_json::Error) -> JsonError { + JsonError { + path, + line: err.line(), + column: err.column(), + message: err.to_string(), + } + } +} + +impl Cache { + pub fn new(fs: Fs) -> Self { + Self { + fs, + arena: Mutex::new(Arena::new()), + packages: FrozenMap::new(), + tsconfigs: FrozenMap::new(), + is_file_cache: DashMap::new(), + is_dir_cache: DashMap::new(), + realpath_cache: DashMap::new(), + } + } + + pub fn is_file(&self, path: &Path) -> bool { + if let Some(is_file) = self.is_file_cache.get(path) { + return *is_file; + } + + let is_file = self.fs.is_file(path); + self.is_file_cache.insert(path.to_path_buf(), is_file); + is_file + } + + pub fn is_dir(&self, path: &Path) -> bool { + if let Some(is_file) = self.is_dir_cache.get(path) { + return *is_file; + } + + let is_file = self.fs.is_dir(path); + self.is_dir_cache.insert(path.to_path_buf(), is_file); + is_file + } + + pub fn canonicalize(&self, path: &Path) -> Result { + Ok(self.fs.canonicalize(path, &self.realpath_cache)?) + } + + pub fn read_package<'a>(&'a self, path: Cow) -> Result<&'a PackageJson<'a>, ResolverError> { + if let Some(pkg) = self.packages.get(path.as_ref()) { + return clone_result(pkg); + } + + fn read_package( + fs: &Fs, + realpath_cache: &DashMap>, + arena: &Mutex>>, + path: PathBuf, + ) -> Result, ResolverError> { + let data = read(fs, arena, &path)?; + let mut pkg = PackageJson::parse(path.clone(), data).map_err(|e| JsonError::new(path, e))?; + + // If the package has a `source` field, make sure + // - the package is behind symlinks + // - and the realpath to the packages does not includes `node_modules`. + // Since such package is likely a pre-compiled module + // installed with package managers, rather than including a source code. + if !matches!(pkg.source, SourceField::None) { + let realpath = fs.canonicalize(&pkg.path, realpath_cache)?; + if realpath == pkg.path + || realpath + .components() + .any(|c| c.as_os_str() == "node_modules") + { + pkg.source = SourceField::None; + } + } + + Ok(pkg) + } + + let path = path.into_owned(); + let pkg = self.packages.insert( + path.clone(), + Box::new(read_package( + &self.fs, + &self.realpath_cache, + &self.arena, + path, + )), + ); + + clone_result(pkg) + } + + pub fn read_tsconfig<'a, F: FnOnce(&mut TsConfigWrapper<'a>) -> Result<(), ResolverError>>( + &'a self, + path: &Path, + process: F, + ) -> Result<&'a TsConfigWrapper<'a>, ResolverError> { + if let Some(tsconfig) = self.tsconfigs.get(path) { + return clone_result(tsconfig); + } + + fn read_tsconfig< + 'a, + Fs: FileSystem, + F: FnOnce(&mut TsConfigWrapper<'a>) -> Result<(), ResolverError>, + >( + fs: &Fs, + arena: &Mutex>>, + path: &Path, + process: F, + ) -> Result, ResolverError> { + let data = read(fs, arena, &path)?; + let mut tsconfig = + TsConfig::parse(path.to_owned(), data).map_err(|e| JsonError::new(path.to_owned(), e))?; + // Convice the borrow checker that 'a will live as long as self and not 'static. + // Since the data is in our arena, this is true. + process(unsafe { std::mem::transmute(&mut tsconfig) })?; + Ok(tsconfig) + } + + let tsconfig = self.tsconfigs.insert( + path.to_owned(), + Box::new(read_tsconfig(&self.fs, &self.arena, path, process)), + ); + + clone_result(tsconfig) + } +} + +fn read( + fs: &F, + arena: &Mutex>>, + path: &Path, +) -> std::io::Result<&'static mut str> { + let arena = arena.lock().unwrap(); + let data = arena.alloc(fs.read_to_string(path)?.into_boxed_str()); + // The data lives as long as the arena. In public methods, we only vend temporary references. + Ok(unsafe { &mut *(&mut **data as *mut str) }) +} + +fn clone_result(res: &Result) -> Result<&T, E> { + match res { + Ok(v) => Ok(v), + Err(err) => Err(err.clone()), + } +} diff --git a/packages/utils/node-resolver-rs/src/error.rs b/packages/utils/node-resolver-rs/src/error.rs new file mode 100644 index 00000000000..2d1439699cc --- /dev/null +++ b/packages/utils/node-resolver-rs/src/error.rs @@ -0,0 +1,103 @@ +use crate::PackageJsonError; +use crate::{cache::JsonError, specifier::SpecifierError}; +use std::path::PathBuf; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(tag = "type")] +pub enum ResolverError { + UnknownScheme { + scheme: String, + }, + UnknownError, + FileNotFound { + relative: PathBuf, + from: PathBuf, + }, + ModuleNotFound { + module: String, + }, + ModuleEntryNotFound { + module: String, + entry_path: PathBuf, + package_path: PathBuf, + field: &'static str, + }, + ModuleSubpathNotFound { + module: String, + path: PathBuf, + package_path: PathBuf, + }, + JsonError(JsonError), + IOError(IOError), + PackageJsonError { + module: String, + path: PathBuf, + error: PackageJsonError, + }, + PackageJsonNotFound { + from: PathBuf, + }, + InvalidSpecifier(SpecifierError), + TsConfigExtendsNotFound { + tsconfig: PathBuf, + error: Box, + }, +} + +#[derive(Debug, Clone)] +pub struct IOError(Arc); + +impl serde::Serialize for IOError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + #[derive(serde::Serialize)] + struct IOErrorMessage { + message: String, + } + + let msg = IOErrorMessage { + message: self.0.to_string(), + }; + + msg.serialize(serializer) + } +} + +impl PartialEq for IOError { + fn eq(&self, other: &Self) -> bool { + self.0.kind() == other.0.kind() + } +} + +impl From<()> for ResolverError { + fn from(_: ()) -> Self { + ResolverError::UnknownError + } +} + +impl From for ResolverError { + fn from(_: std::str::Utf8Error) -> Self { + ResolverError::UnknownError + } +} + +impl From for ResolverError { + fn from(e: JsonError) -> Self { + ResolverError::JsonError(e) + } +} + +impl From for ResolverError { + fn from(e: std::io::Error) -> Self { + ResolverError::IOError(IOError(Arc::new(e))) + } +} + +impl From for ResolverError { + fn from(value: SpecifierError) -> Self { + ResolverError::InvalidSpecifier(value) + } +} diff --git a/packages/utils/node-resolver-rs/src/fs.rs b/packages/utils/node-resolver-rs/src/fs.rs new file mode 100644 index 00000000000..1ab4937bf57 --- /dev/null +++ b/packages/utils/node-resolver-rs/src/fs.rs @@ -0,0 +1,45 @@ +use std::{ + io::Result, + path::{Path, PathBuf}, +}; + +use crate::path::canonicalize; +use dashmap::DashMap; + +pub trait FileSystem: Send + Sync { + fn canonicalize>( + &self, + path: P, + cache: &DashMap>, + ) -> Result; + fn read_to_string>(&self, path: P) -> Result; + fn is_file>(&self, path: P) -> bool; + fn is_dir>(&self, path: P) -> bool; +} + +#[derive(Default)] +pub struct OsFileSystem; + +impl FileSystem for OsFileSystem { + fn canonicalize>( + &self, + path: P, + cache: &DashMap>, + ) -> Result { + canonicalize(path.as_ref(), cache) + } + + fn read_to_string>(&self, path: P) -> Result { + std::fs::read_to_string(path) + } + + fn is_file>(&self, path: P) -> bool { + let path: &Path = path.as_ref(); + path.is_file() + } + + fn is_dir>(&self, path: P) -> bool { + let path: &Path = path.as_ref(); + path.is_dir() + } +} diff --git a/packages/utils/node-resolver-rs/src/invalidations.rs b/packages/utils/node-resolver-rs/src/invalidations.rs new file mode 100644 index 00000000000..e290c3403b8 --- /dev/null +++ b/packages/utils/node-resolver-rs/src/invalidations.rs @@ -0,0 +1,67 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::RwLock, +}; + +use crate::{path::normalize_path, ResolverError}; + +#[derive(PartialEq, Eq, Hash, Debug)] +pub enum FileCreateInvalidation { + Path(PathBuf), + FileName { file_name: String, above: PathBuf }, +} + +#[derive(Default, Debug)] +pub struct Invalidations { + pub invalidate_on_file_create: RwLock>, + pub invalidate_on_file_change: RwLock>, +} + +impl Invalidations { + pub fn invalidate_on_file_create(&self, path: &Path) { + self + .invalidate_on_file_create + .write() + .unwrap() + .insert(FileCreateInvalidation::Path(normalize_path(path))); + } + + pub fn invalidate_on_file_create_above>(&self, file_name: S, above: &Path) { + self + .invalidate_on_file_create + .write() + .unwrap() + .insert(FileCreateInvalidation::FileName { + file_name: file_name.into(), + above: normalize_path(above), + }); + } + + pub fn invalidate_on_file_change(&self, invalidation: &Path) { + self + .invalidate_on_file_change + .write() + .unwrap() + .insert(normalize_path(invalidation)); + } + + pub fn read Result>( + &self, + path: &Path, + f: F, + ) -> Result { + match f() { + Ok(v) => { + self.invalidate_on_file_change(path); + Ok(v) + } + Err(e) => { + if matches!(e, ResolverError::IOError(..)) { + self.invalidate_on_file_create(path); + } + Err(e) + } + } + } +} diff --git a/packages/utils/node-resolver-rs/src/lib.rs b/packages/utils/node-resolver-rs/src/lib.rs new file mode 100644 index 00000000000..446235e7e48 --- /dev/null +++ b/packages/utils/node-resolver-rs/src/lib.rs @@ -0,0 +1,2505 @@ +use bitflags::bitflags; +use once_cell::unsync::OnceCell; +use specifier::{parse_package_specifier, parse_scheme}; +use std::{ + borrow::Cow, + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; + +use package_json::{AliasValue, ExportsResolution, PackageJson}; +use specifier::Specifier; +use tsconfig::TsConfig; + +mod builtins; +mod cache; +mod error; +mod fs; +mod invalidations; +mod package_json; +mod path; +mod specifier; +mod tsconfig; + +pub use cache::{Cache, CacheCow}; +pub use error::ResolverError; +pub use fs::{FileSystem, OsFileSystem}; +pub use invalidations::*; +pub use package_json::{ExportsCondition, Fields, PackageJsonError}; +pub use specifier::SpecifierType; + +use crate::path::resolve_path; + +bitflags! { + pub struct Flags: u16 { + /// Parcel-style absolute paths resolved relative to project root. + const ABSOLUTE_SPECIFIERS = 1 << 0; + /// Parcel-style tilde specifiers resolved relative to nearest module root. + const TILDE_SPECIFIERS = 1 << 1; + /// The `npm:` scheme. + const NPM_SCHEME = 1 << 2; + /// The "alias" field in package.json. + const ALIASES = 1 << 3; + /// The settings in tsconfig.json. + const TSCONFIG = 1 << 4; + /// The "exports" and "imports" fields in package.json. + const EXPORTS = 1 << 5; + /// Directory index files, e.g. index.js. + const DIR_INDEX = 1 << 6; + /// Optional extensions in specifiers, using the `extensions` setting. + const OPTIONAL_EXTENSIONS = 1 << 7; + /// Whether extensions are replaced in specifiers, e.g. `./foo.js` -> `./foo.ts`. + const TYPESCRIPT_EXTENSIONS = 1 << 8; + /// Whether to allow omitting the extension when resolving the same file type. + const PARENT_EXTENSION = 1 << 9; + + /// Default Node settings for CommonJS. + const NODE_CJS = Self::EXPORTS.bits | Self::DIR_INDEX.bits | Self::OPTIONAL_EXTENSIONS.bits; + /// Default Node settings for ESM. + const NODE_ESM = Self::EXPORTS.bits; + /// Default TypeScript settings. + const TYPESCRIPT = Self::TSCONFIG.bits | Self::EXPORTS.bits | Self::DIR_INDEX.bits | Self::OPTIONAL_EXTENSIONS.bits | Self::TYPESCRIPT_EXTENSIONS.bits; + } +} + +#[derive(Clone)] +pub enum IncludeNodeModules { + Bool(bool), + Array(Vec), + Map(HashMap), +} + +impl Default for IncludeNodeModules { + fn default() -> Self { + IncludeNodeModules::Bool(true) + } +} + +type ResolveModuleDir = dyn Fn(&str, &Path) -> Result + Send + Sync; + +pub struct Resolver<'a, Fs> { + pub project_root: Cow<'a, Path>, + pub extensions: &'a [&'a str], + pub index_file: &'a str, + pub entries: Fields, + pub flags: Flags, + pub include_node_modules: Cow<'a, IncludeNodeModules>, + pub conditions: ExportsCondition, + pub module_dir_resolver: Option>, + cache: CacheCow<'a, Fs>, +} + +#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize)] +#[serde(tag = "type", content = "value")] +pub enum Resolution { + /// Resolved to a file path. + Path(PathBuf), + /// Resolved to a runtime builtin module. + Builtin(String), + /// Resolved to an external module that should not be bundled. + External, + /// Resolved to an empty module (e.g. `false` in the package.json#browser field). + Empty, + /// Resolved to a global variable. + Global(String), +} + +pub struct ResolveResult { + pub result: Result<(Resolution, Option), ResolverError>, + pub invalidations: Invalidations, +} + +impl<'a, Fs: FileSystem> Resolver<'a, Fs> { + pub fn node(project_root: Cow<'a, Path>, cache: CacheCow<'a, Fs>) -> Self { + Self { + project_root, + extensions: &["js", "json", "node"], + index_file: "index", + entries: Fields::MAIN, + flags: Flags::NODE_CJS, + cache, + include_node_modules: Cow::Owned(IncludeNodeModules::default()), + conditions: ExportsCondition::NODE, + module_dir_resolver: None, + } + } + + pub fn node_esm(project_root: Cow<'a, Path>, cache: CacheCow<'a, Fs>) -> Self { + Self { + project_root, + extensions: &[], + index_file: "index", + entries: Fields::MAIN, + flags: Flags::NODE_ESM, + cache, + include_node_modules: Cow::Owned(IncludeNodeModules::default()), + conditions: ExportsCondition::NODE, + module_dir_resolver: None, + } + } + + pub fn parcel(project_root: Cow<'a, Path>, cache: CacheCow<'a, Fs>) -> Self { + Self { + project_root, + extensions: &["ts", "tsx", "mjs", "js", "jsx", "cjs", "json"], + index_file: "index", + entries: Fields::MAIN | Fields::SOURCE | Fields::BROWSER | Fields::MODULE, + flags: Flags::all(), + cache, + include_node_modules: Cow::Owned(IncludeNodeModules::default()), + conditions: ExportsCondition::empty(), + module_dir_resolver: None, + } + } + + pub fn resolve<'s>( + &self, + specifier: &'s str, + from: &Path, + specifier_type: SpecifierType, + ) -> ResolveResult { + let invalidations = Invalidations::default(); + let (specifier, query) = match Specifier::parse(specifier, specifier_type, self.flags) { + Ok(s) => s, + Err(e) => { + return ResolveResult { + result: Err(e.into()), + invalidations, + } + } + }; + let request = ResolveRequest::new(self, &specifier, specifier_type, from, &invalidations); + let result = match request.resolve() { + Ok(r) => Ok((r, query.map(|q| q.to_owned()))), + Err(r) => Err(r), + }; + + ResolveResult { + result, + invalidations, + } + } + + pub fn resolve_side_effects( + &self, + path: &Path, + invalidations: &Invalidations, + ) -> Result { + if let Some(package) = self.find_package(path.parent().unwrap(), invalidations)? { + Ok(package.has_side_effects(path)) + } else { + Ok(true) + } + } + + fn find_package( + &self, + from: &Path, + invalidations: &Invalidations, + ) -> Result, ResolverError> { + if let Some(path) = self.find_ancestor_file(from, "package.json", invalidations) { + let package = self.cache.read_package(Cow::Owned(path))?; + return Ok(Some(package)); + } + + Ok(None) + } + + fn find_ancestor_file( + &self, + from: &Path, + filename: &str, + invalidations: &Invalidations, + ) -> Option { + let mut first = true; + for dir in from.ancestors() { + if let Some(filename) = dir.file_name() { + if filename == "node_modules" { + break; + } + } + + let file = dir.join(filename); + if self.cache.is_file(&file) { + invalidations.invalidate_on_file_change(&file); + return Some(file); + } + + if dir == self.project_root { + break; + } + + if first { + invalidations.invalidate_on_file_create_above(filename, from); + } + + first = false; + } + + None + } +} + +struct ResolveRequest<'a, Fs> { + resolver: &'a Resolver<'a, Fs>, + specifier: &'a Specifier<'a>, + specifier_type: SpecifierType, + from: &'a Path, + flags: RequestFlags, + tsconfig: OnceCell>>, + root_package: OnceCell>>, + invalidations: &'a Invalidations, + conditions: ExportsCondition, + priority_extension: Option<&'a str>, +} + +bitflags! { + struct RequestFlags: u8 { + const IN_TS_FILE = 1 << 0; + const IN_JS_FILE = 1 << 1; + const IN_NODE_MODULES = 1 << 2; + } +} + +impl<'a, Fs: FileSystem> ResolveRequest<'a, Fs> { + fn new( + resolver: &'a Resolver<'a, Fs>, + specifier: &'a Specifier<'a>, + mut specifier_type: SpecifierType, + from: &'a Path, + invalidations: &'a Invalidations, + ) -> Self { + let mut flags = RequestFlags::empty(); + if let Some(ext) = from.extension() { + if ext == "ts" || ext == "tsx" || ext == "mts" || ext == "cts" { + flags |= RequestFlags::IN_TS_FILE; + } else if ext == "js" || ext == "jsx" || ext == "mjs" || ext == "cjs" { + flags |= RequestFlags::IN_JS_FILE; + } + } + + if from.components().any(|c| c.as_os_str() == "node_modules") { + flags |= RequestFlags::IN_NODE_MODULES; + } + + // Replace the specifier type for `npm:` URLs so we resolve it like a module. + if specifier_type == SpecifierType::Url && matches!(specifier, Specifier::Package(..)) { + specifier_type = SpecifierType::Esm; + } + + // Add "import" or "require" condition to global conditions based on specifier type. + // Also add the "module" condition if the "module" entry field is enabled. + let mut conditions = resolver.conditions; + let module_condition = if resolver.entries.contains(Fields::MODULE) { + ExportsCondition::MODULE + } else { + ExportsCondition::empty() + }; + match specifier_type { + SpecifierType::Esm => conditions |= ExportsCondition::IMPORT | module_condition, + SpecifierType::Cjs => conditions |= ExportsCondition::REQUIRE | module_condition, + _ => {} + } + + // Store the parent file extension so we can prioritize it even in sub-requests. + let priority_extension = if resolver.flags.contains(Flags::PARENT_EXTENSION) { + from.extension().and_then(|ext| ext.to_str()) + } else { + None + }; + + Self { + resolver, + specifier, + specifier_type, + from, + flags, + tsconfig: OnceCell::new(), + root_package: OnceCell::new(), + invalidations, + conditions, + priority_extension, + } + } + + fn resolve_aliases( + &self, + package: &PackageJson, + specifier: &Specifier, + fields: Fields, + ) -> Result, ResolverError> { + // Don't resolve alias if it came from the package.json itself (i.e. another alias). + if self.from == package.path { + return Ok(None); + } + + match package.resolve_aliases(&specifier, fields) { + Some(alias) => match alias.as_ref() { + AliasValue::Specifier(specifier) => { + let mut req = ResolveRequest::new( + &self.resolver, + specifier, + SpecifierType::Cjs, + &package.path, + self.invalidations, + ); + req.priority_extension = self.priority_extension; + let resolved = req.resolve()?; + Ok(Some(resolved)) + } + AliasValue::Bool(false) => Ok(Some(Resolution::Empty)), + AliasValue::Bool(true) => Ok(None), + AliasValue::Global { global } => Ok(Some(Resolution::Global((*global).to_owned()))), + }, + None => Ok(None), + } + } + + fn root_package(&self) -> Result<&Option<&PackageJson>, ResolverError> { + self + .root_package + .get_or_try_init(|| self.find_package(&self.resolver.project_root)) + } + + fn resolve(&self) -> Result { + match &self.specifier { + Specifier::Relative(specifier) => { + // Relative path + self.resolve_relative(&specifier, &self.from) + } + Specifier::Tilde(specifier) if self.resolver.flags.contains(Flags::TILDE_SPECIFIERS) => { + // Tilde path. Resolve relative to nearest node_modules directory, + // the nearest directory with package.json or the project root - whichever comes first. + if let Some(p) = self.find_ancestor_file(&self.from, "package.json") { + return self.resolve_relative(&specifier, &p); + } + + Err(ResolverError::PackageJsonNotFound { + from: self.from.to_owned(), + }) + } + Specifier::Absolute(specifier) => { + // In Parcel mode, absolute paths are actually relative to the project root. + if self.resolver.flags.contains(Flags::ABSOLUTE_SPECIFIERS) { + self.resolve_relative( + specifier.strip_prefix("/").unwrap(), + &self.resolver.project_root.join("index"), + ) + } else if let Some(res) = self.load_path(&specifier, None)? { + Ok(res) + } else { + Err(ResolverError::FileNotFound { + relative: specifier.as_ref().to_owned(), + from: PathBuf::from("/"), + }) + } + } + Specifier::Hash(hash) => { + if self.specifier_type == SpecifierType::Url { + // An ID-only URL, e.g. `url(#clip-path)` for CSS rules. Ignore. + Ok(Resolution::External) + } else if self.specifier_type == SpecifierType::Esm + && self.resolver.flags.contains(Flags::EXPORTS) + { + // An internal package #import specifier. + let package = self.find_package(&self.from.parent().unwrap())?; + if let Some(package) = package { + let res = package + .resolve_package_imports(&hash, self.conditions) + .map_err(|e| ResolverError::PackageJsonError { + module: package.name.to_owned(), + path: package.path.clone(), + error: e, + })?; + match res { + ExportsResolution::Path(path) => { + // Extensionless specifiers are not supported in the imports field. + if let Some(res) = self.try_file_without_aliases(&path)? { + return Ok(res); + } + } + ExportsResolution::Package(specifier) => { + let (module, subpath) = parse_package_specifier(&specifier)?; + // TODO: should this follow aliases?? + return self.resolve_bare(module, subpath); + } + _ => {} + } + } + + Err(ResolverError::PackageJsonNotFound { + from: self.from.to_owned(), + }) + } else { + Err(ResolverError::UnknownError) + } + } + Specifier::Package(module, subpath) => { + // Bare specifier. + self.resolve_bare(&module, &subpath) + } + Specifier::Builtin(builtin) => Ok(Resolution::Builtin(builtin.as_ref().to_owned())), + Specifier::Url(url) => { + if self.specifier_type == SpecifierType::Url { + Ok(Resolution::External) + } else { + let (scheme, _) = parse_scheme(url)?; + Err(ResolverError::UnknownScheme { + scheme: scheme.into_owned(), + }) + } + } + _ => Err(ResolverError::UnknownError), + } + } + + fn find_ancestor_file(&self, from: &Path, filename: &str) -> Option { + let from = from.parent().unwrap(); + self + .resolver + .find_ancestor_file(from, filename, &self.invalidations) + } + + fn find_package(&self, from: &Path) -> Result>, ResolverError> { + self.resolver.find_package(from, &self.invalidations) + } + + fn resolve_relative(&self, specifier: &Path, from: &Path) -> Result { + // Resolve aliases from the nearest package.json. + let path = resolve_path(from, specifier); + let package = if self.resolver.flags.contains(Flags::ALIASES) { + self.find_package(&path.parent().unwrap())? + } else { + None + }; + + if let Some(res) = self.load_path(&path, package)? { + return Ok(res); + } + + Err(ResolverError::FileNotFound { + relative: specifier.to_owned(), + from: from.to_owned(), + }) + } + + fn resolve_bare(&self, module: &str, subpath: &str) -> Result { + let include = match self.resolver.include_node_modules.as_ref() { + IncludeNodeModules::Bool(b) => *b, + IncludeNodeModules::Array(a) => a.iter().any(|v| v == module), + IncludeNodeModules::Map(m) => *m.get(module).unwrap_or(&true), + }; + + if !include { + return Ok(Resolution::External); + } + + if self.resolver.flags.contains(Flags::ALIASES) { + // First, check for an alias in the root package.json. + if let Some(package) = self.root_package()? { + if let Some(res) = self.resolve_aliases( + package, + &Specifier::Package(Cow::Borrowed(module), Cow::Borrowed(subpath)), + Fields::ALIAS, + )? { + return Ok(res); + } + } + + // Next, try the local package.json. + if let Some(package) = self.find_package(&self.from.parent().unwrap())? { + let mut fields = Fields::ALIAS; + if self.resolver.entries.contains(Fields::BROWSER) { + fields |= Fields::BROWSER; + } + if let Some(res) = self.resolve_aliases( + package, + &Specifier::Package(Cow::Borrowed(module), Cow::Borrowed(subpath)), + fields, + )? { + return Ok(res); + } + } + } + + // Next, check tsconfig.json for the paths and baseUrl options. + if let Some(res) = self.resolve_tsconfig_paths()? { + return Ok(res); + } + + self.resolve_node_module(module, subpath) + } + + fn resolve_node_module(&self, module: &str, subpath: &str) -> Result { + // If there is a custom module directory resolver (e.g. Yarn PnP), use that. + if let Some(module_dir_resolver) = &self.resolver.module_dir_resolver { + let package_dir = module_dir_resolver(module, self.from)?; + return self.resolve_package(package_dir, module, subpath); + } else { + self.invalidations.invalidate_on_file_create_above( + format!("node_modules/{}", module), + self.from.parent().unwrap(), + ); + + for dir in self.from.ancestors() { + // Skip over node_modules directories + if let Some(filename) = dir.file_name() { + if filename == "node_modules" { + continue; + } + } + + let package_dir = dir.join("node_modules").join(module); + if self.resolver.cache.is_dir(&package_dir) { + return self.resolve_package(package_dir, module, subpath); + } + } + } + + // NODE_PATH?? + + Err(ResolverError::ModuleNotFound { + module: module.to_owned(), + }) + } + + fn resolve_package( + &self, + mut package_dir: PathBuf, + module: &str, + subpath: &str, + ) -> Result { + let package_path = package_dir.join("package.json"); + let package = self.invalidations.read(&package_path, || { + self + .resolver + .cache + .read_package(Cow::Borrowed(&package_path)) + }); + + let package = match package { + Ok(package) => package, + Err(ResolverError::IOError(_)) => { + // No package.json in node_modules is probably invalid but we have tests for it... + if self.resolver.flags.contains(Flags::DIR_INDEX) { + if let Some(res) = self.load_file(&package_dir.join(self.resolver.index_file), None)? { + return Ok(res); + } + } + + return Err(ResolverError::ModuleNotFound { + module: module.to_owned(), + }); + } + Err(err) => return Err(err), + }; + + // If the exports field is present, use the Node ESM algorithm. + // Otherwise, fall back to classic CJS resolution. + if self.resolver.flags.contains(Flags::EXPORTS) && package.has_exports() { + let path = package + .resolve_package_exports(subpath, self.conditions) + .map_err(|e| ResolverError::PackageJsonError { + module: package.name.to_owned(), + path: package.path.clone(), + error: e, + })?; + + // Extensionless specifiers are not supported in the exports field. + if let Some(res) = self.try_file_without_aliases(&path)? { + return Ok(res); + } + + // TODO: track location of resolved field + return Err(ResolverError::ModuleSubpathNotFound { + module: module.to_owned(), + path, + package_path: package.path.clone(), + }); + } else if !subpath.is_empty() { + package_dir.push(subpath); + if let Some(res) = self.load_path(&package_dir, Some(&package))? { + return Ok(res); + } + + return Err(ResolverError::ModuleSubpathNotFound { + module: module.to_owned(), + path: package_dir, + package_path: package.path.clone(), + }); + } else { + let res = self.try_package_entries(&package); + if let Ok(Some(res)) = res { + return Ok(res); + } + + // Node ESM doesn't allow directory imports. + if self.resolver.flags.contains(Flags::DIR_INDEX) { + if let Some(res) = + self.load_file(&package_dir.join(self.resolver.index_file), Some(&package))? + { + return Ok(res); + } + } + + if let Err(e) = res { + return Err(e); + } + + return Err(ResolverError::ModuleSubpathNotFound { + module: module.to_owned(), + path: package_dir.join(self.resolver.index_file), + package_path: package.path.clone(), + }); + } + } + + fn try_package_entries( + &self, + package: &PackageJson, + ) -> Result, ResolverError> { + // Try all entry fields. + for (entry, field) in package.entries(self.resolver.entries) { + if let Some(res) = self.load_path(&entry, Some(package))? { + return Ok(Some(res)); + } else { + return Err(ResolverError::ModuleEntryNotFound { + module: package.name.to_owned(), + entry_path: entry, + package_path: package.path.clone(), + field, + }); + } + } + + Ok(None) + } + + fn load_path( + &self, + path: &Path, + package: Option<&PackageJson>, + ) -> Result, ResolverError> { + // Urls and Node ESM do not resolve directory index files. + let can_load_directory = + self.resolver.flags.contains(Flags::DIR_INDEX) && self.specifier_type != SpecifierType::Url; + + // If path ends with / only try loading as a directory. + let is_directory = can_load_directory + && path + .as_os_str() + .to_str() + .map(|s| s.ends_with('/')) + .unwrap_or(false); + + if !is_directory { + if let Some(res) = self.load_file(path, package)? { + return Ok(Some(res)); + } + } + + // Urls and Node ESM do not resolve directory index files. + if can_load_directory { + return self.load_directory(path, package); + } + + Ok(None) + } + + fn load_file( + &self, + path: &Path, + package: Option<&PackageJson>, + ) -> Result, ResolverError> { + // First try the path as is. + // TypeScript only supports resolving specifiers ending with `.ts` or `.tsx` + // in a certain mode, but we always allow it. + // If there is no extension in the original specifier, only check aliases + // here and delay checking for an extensionless file until later (since this is unlikely). + if let Some(res) = self.try_suffixes(path, "", package, path.extension().is_none())? { + return Ok(Some(res)); + } + + // TypeScript allows a specifier like "./foo.js" to resolve to "./foo.ts". + // TSC does this before trying to append an extension. We match this + // rather than matching "./foo.js.ts", which seems more unlikely. + // However, if "./foo.js" exists we will resolve to it (above), unlike TSC. + // This is to match Node and other bundlers. + if self.resolver.flags.contains(Flags::TYPESCRIPT_EXTENSIONS) + && self.flags.contains(RequestFlags::IN_TS_FILE) + && !self.flags.contains(RequestFlags::IN_NODE_MODULES) + && self.specifier_type != SpecifierType::Url + { + if let Some(ext) = path.extension() { + // TODO: would be nice if there was a way to do this without cloning + // but OsStr doesn't let you create a slice. + let without_extension = &path.with_extension(""); + let res = if ext == "js" || ext == "jsx" { + // TSC always prioritizes .ts over .tsx, even when the original extension was .jsx. + self.try_extensions(&without_extension, package, &["ts", "tsx"], false)? + } else if ext == "mjs" { + self.try_extensions(&without_extension, package, &["mts"], false)? + } else if ext == "cjs" { + self.try_extensions(&without_extension, package, &["cts"], false)? + } else { + None + }; + + if res.is_some() { + return Ok(res); + } + } + } + + // Try adding the same extension as in the parent file first. + if let Some(ext) = self.priority_extension { + // Use try_suffixes here to skip the specifier_type check. + // This is reproducing a bug in the old version of the Parcel resolver + // where URL dependencies could omit the extension if it was the same as the parent. + // TODO: Revert this in the next major version. + if let Some(res) = self.try_suffixes(path, ext, package, false)? { + return Ok(Some(res)); + } + } + + // Try appending the configured extensions. + if let Some(res) = self.try_extensions(path, package, &self.resolver.extensions, true)? { + return Ok(Some(res)); + } + + // If there is no extension in the specifier, try an extensionless file as a last resort. + if path.extension().is_none() { + if let Some(res) = self.try_suffixes(path, "", package, false)? { + return Ok(Some(res)); + } + } + + Ok(None) + } + + fn try_extensions( + &self, + path: &Path, + package: Option<&PackageJson>, + extensions: &[&str], + skip_parent: bool, + ) -> Result, ResolverError> { + if self.resolver.flags.contains(Flags::OPTIONAL_EXTENSIONS) + && self.specifier_type != SpecifierType::Url + { + // Try appending each extension. + for ext in extensions { + // Skip parent extension if we already tried it. + if skip_parent + && self.resolver.flags.contains(Flags::PARENT_EXTENSION) + && matches!(self.from.extension(), Some(e) if e == *ext) + { + continue; + } + + if let Some(res) = self.try_suffixes(path, ext, package, false)? { + return Ok(Some(res)); + } + } + } + + Ok(None) + } + + fn try_suffixes( + &self, + path: &Path, + ext: &str, + package: Option<&PackageJson>, + alias_only: bool, + ) -> Result, ResolverError> { + // TypeScript supports a moduleSuffixes option in tsconfig.json which allows suffixes + // such as ".ios" to be appended just before the last extension. + let module_suffixes = self + .tsconfig()? + .and_then(|tsconfig| tsconfig.module_suffixes.as_ref()) + .map_or([""].as_slice(), |v| v.as_slice()); + + for suffix in module_suffixes { + let mut p = if *suffix != "" { + // The suffix is placed before the _last_ extension. If we will be appending + // another extension later, then we only need to append the suffix first. + // Otherwise, we need to remove the original extension so we can add the suffix. + // TODO: TypeScript only removes certain extensions here... + let original_ext = path.extension(); + let mut s = if ext == "" && original_ext.is_some() { + path.with_extension("").into_os_string() + } else { + path.into() + }; + + // Append the suffix (this is not necessarily an extension). + s.push(suffix); + + // Re-add the original extension if we removed it earlier. + if ext == "" { + if let Some(original_ext) = original_ext { + s.push("."); + s.push(original_ext); + } + } + + Cow::Owned(PathBuf::from(s)) + } else { + Cow::Borrowed(path) + }; + + if ext != "" { + // Append the extension. + let mut s = p.into_owned().into_os_string(); + s.push("."); + s.push(ext); + p = Cow::Owned(PathBuf::from(s)); + } + + if let Some(res) = self.try_file(p.as_ref(), package, alias_only)? { + return Ok(Some(res)); + } + } + + Ok(None) + } + + fn try_file( + &self, + path: &Path, + package: Option<&PackageJson>, + alias_only: bool, + ) -> Result, ResolverError> { + if self.resolver.flags.contains(Flags::ALIASES) { + // Check the project root package.json first. + if let Some(package) = self.root_package()? { + if let Ok(s) = path.strip_prefix(package.path.parent().unwrap()) { + let specifier = Specifier::Relative(Cow::Borrowed(s)); + if let Some(res) = self.resolve_aliases(package, &specifier, Fields::ALIAS)? { + return Ok(Some(res)); + } + } + } + + // Next try the local package.json. + if let Some(package) = package { + if let Ok(s) = path.strip_prefix(package.path.parent().unwrap()) { + let specifier = Specifier::Relative(Cow::Borrowed(s)); + let mut fields = Fields::ALIAS; + if self.resolver.entries.contains(Fields::BROWSER) { + fields |= Fields::BROWSER; + } + if let Some(res) = self.resolve_aliases(package, &specifier, fields)? { + return Ok(Some(res)); + } + } + } + } + + if alias_only { + return Ok(None); + } + + self.try_file_without_aliases(path) + } + + fn try_file_without_aliases(&self, path: &Path) -> Result, ResolverError> { + if self.resolver.cache.is_file(path) { + Ok(Some(Resolution::Path( + self.resolver.cache.canonicalize(path)?, + ))) + } else { + self.invalidations.invalidate_on_file_create(path); + Ok(None) + } + } + + fn load_directory( + &self, + dir: &Path, + parent_package: Option<&PackageJson>, + ) -> Result, ResolverError> { + // Check if there is a package.json in this directory, and if so, use its entries. + // Note that the "exports" field is NOT used here - only in resolve_node_module. + let path = dir.join("package.json"); + let mut res = Ok(None); + let package = if let Ok(package) = self.invalidations.read(&path, || { + self.resolver.cache.read_package(Cow::Borrowed(&path)) + }) { + res = self.try_package_entries(&package); + if matches!(res, Ok(Some(_))) { + return res; + } + Some(package) + } else { + None + }; + + // If no package.json, or no entries, try an index file with all possible extensions. + if self.resolver.flags.contains(Flags::DIR_INDEX) && self.resolver.cache.is_dir(dir) { + return self.load_file( + &dir.join(self.resolver.index_file), + package.or(parent_package), + ); + } + + res + } + + fn resolve_tsconfig_paths(&self) -> Result, ResolverError> { + if let Some(tsconfig) = self.tsconfig()? { + for path in tsconfig.paths(&self.specifier) { + // TODO: should aliases apply to tsconfig paths?? + if let Some(res) = self.load_path(&path, None)? { + return Ok(Some(res)); + } + } + } + + Ok(None) + } + + fn tsconfig(&self) -> Result<&Option<&TsConfig>, ResolverError> { + if self.resolver.flags.contains(Flags::TSCONFIG) + && self + .flags + .intersects(RequestFlags::IN_TS_FILE | RequestFlags::IN_JS_FILE) + && !self.flags.contains(RequestFlags::IN_NODE_MODULES) + { + self.tsconfig.get_or_try_init(|| { + if let Some(path) = self.find_ancestor_file(&self.from, "tsconfig.json") { + let tsconfig = self.read_tsconfig(path)?; + return Ok(Some(tsconfig)); + } + + Ok(None) + }) + } else { + Ok(&None) + } + } + + fn read_tsconfig(&self, path: PathBuf) -> Result<&'a TsConfig<'a>, ResolverError> { + let tsconfig = self.invalidations.read(&path, || { + self.resolver.cache.read_tsconfig(&path, |tsconfig| { + for i in 0..tsconfig.extends.len() { + let path = match &tsconfig.extends[i] { + Specifier::Absolute(path) => path.as_ref().to_owned(), + Specifier::Relative(path) => { + let mut absolute_path = resolve_path(&tsconfig.compiler_options.path, path); + + // TypeScript allows "." and ".." to implicitly refer to a tsconfig.json file. + if path == Path::new(".") || path == Path::new("..") { + absolute_path.push("tsconfig.json"); + } + + if !self.resolver.cache.fs.is_file(&absolute_path) { + return Err(ResolverError::TsConfigExtendsNotFound { + tsconfig: tsconfig.compiler_options.path.clone(), + error: Box::new(ResolverError::FileNotFound { + relative: path.to_path_buf(), + from: tsconfig.compiler_options.path.clone(), + }), + }); + } + + absolute_path + } + specifier @ Specifier::Package(..) => { + let resolver = Resolver { + project_root: Cow::Borrowed(&self.resolver.project_root), + extensions: &["json"], + index_file: "tsconfig.json", + entries: Fields::TSCONFIG, + flags: Flags::NODE_CJS, + cache: CacheCow::Borrowed(&self.resolver.cache), + include_node_modules: Cow::Borrowed(self.resolver.include_node_modules.as_ref()), + conditions: ExportsCondition::TYPES, + module_dir_resolver: self.resolver.module_dir_resolver.clone(), + }; + + let req = ResolveRequest::new( + &resolver, + specifier, + SpecifierType::Cjs, + &tsconfig.compiler_options.path, + self.invalidations, + ); + + let res = req + .resolve() + .map_err(|err| ResolverError::TsConfigExtendsNotFound { + tsconfig: tsconfig.compiler_options.path.clone(), + error: Box::new(err), + })?; + + if let Resolution::Path(res) = res { + res + } else { + return Err(ResolverError::TsConfigExtendsNotFound { + tsconfig: tsconfig.compiler_options.path.clone(), + error: Box::new(ResolverError::UnknownError), + }); + } + } + _ => return Ok(()), + }; + + let extended = self.read_tsconfig(path)?; + tsconfig.compiler_options.extend(extended); + } + + Ok(()) + }) + })?; + + Ok(&tsconfig.compiler_options) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::cache::Cache; + use super::*; + + fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("node-resolver-core/test/fixture") + } + + fn test_resolver<'a>() -> Resolver<'a, OsFileSystem> { + Resolver::parcel( + root().into(), + CacheCow::Owned(Cache::new(OsFileSystem::default())), + ) + } + + fn node_resolver<'a>() -> Resolver<'a, OsFileSystem> { + Resolver::node( + root().into(), + CacheCow::Owned(Cache::new(OsFileSystem::default())), + ) + } + + #[test] + fn relative() { + assert_eq!( + test_resolver() + .resolve("./bar.js", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve("./bar", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve("~/bar", &root().join("nested/test.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve("~bar", &root().join("nested/test.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "~/bar", + &root().join("node_modules/foo/nested/baz.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/bar.js")) + ); + assert_eq!( + test_resolver() + .resolve("./nested", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("nested/index.js")) + ); + assert_eq!( + test_resolver() + .resolve("./bar?foo=2", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve("./bar?foo=2", &root().join("foo.js"), SpecifierType::Cjs) + .result + .unwrap_err(), + ResolverError::FileNotFound { + relative: "bar?foo=2".into(), + from: root().join("foo.js") + }, + ); + assert_eq!( + test_resolver() + .resolve( + "./foo", + &root().join("priority/index.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("priority/foo.js")) + ); + + let invalidations = test_resolver() + .resolve("./bar", &root().join("foo.js"), SpecifierType::Esm) + .invalidations; + assert_eq!( + *invalidations.invalidate_on_file_create.read().unwrap(), + HashSet::new() + ); + assert_eq!( + *invalidations.invalidate_on_file_change.read().unwrap(), + HashSet::from([root().join("package.json"), root().join("tsconfig.json")]) + ); + } + + #[test] + fn test_absolute() { + assert_eq!( + test_resolver() + .resolve("/bar", &root().join("nested/test.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "/bar", + &root().join("node_modules/foo/index.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "file:///bar", + &root().join("nested/test.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + node_resolver() + .resolve( + root().join("foo.js").to_str().unwrap(), + &root().join("nested/test.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("foo.js")) + ); + assert_eq!( + node_resolver() + .resolve( + &format!("file://{}", root().join("foo.js").to_str().unwrap()), + &root().join("nested/test.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("foo.js")) + ); + } + + #[test] + fn node_modules() { + assert_eq!( + test_resolver() + .resolve("foo", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/index.js")) + ); + assert_eq!( + test_resolver() + .resolve("package-main", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-main/main.js")) + ); + assert_eq!( + test_resolver() + .resolve("package-module", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-module/module.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "package-browser", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-browser/browser.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "package-fallback", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-fallback/index.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "package-main-directory", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-main-directory/nested/index.js")) + ); + assert_eq!( + test_resolver() + .resolve("foo/nested/baz", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/nested/baz.js")) + ); + assert_eq!( + test_resolver() + .resolve("@scope/pkg", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/@scope/pkg/index.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "@scope/pkg/foo/bar", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/@scope/pkg/foo/bar.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "foo/with space.mjs", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/with space.mjs")) + ); + assert_eq!( + test_resolver() + .resolve( + "foo/with%20space.mjs", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/with space.mjs")) + ); + assert_eq!( + test_resolver() + .resolve( + "foo/with space.mjs", + &root().join("foo.js"), + SpecifierType::Cjs + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/with space.mjs")) + ); + assert_eq!( + test_resolver() + .resolve( + "foo/with%20space.mjs", + &root().join("foo.js"), + SpecifierType::Cjs + ) + .result + .unwrap_err(), + ResolverError::ModuleSubpathNotFound { + module: "foo".into(), + path: root().join("node_modules/foo/with%20space.mjs"), + package_path: root().join("node_modules/foo/package.json") + }, + ); + assert_eq!( + test_resolver() + .resolve( + "@scope/pkg?foo=2", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/@scope/pkg/index.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "@scope/pkg?foo=2", + &root().join("foo.js"), + SpecifierType::Cjs + ) + .result + .unwrap_err(), + ResolverError::ModuleNotFound { + module: "@scope/pkg?foo=2".into() + }, + ); + + let invalidations = test_resolver() + .resolve("foo", &root().join("foo.js"), SpecifierType::Esm) + .invalidations; + assert_eq!( + *invalidations.invalidate_on_file_create.read().unwrap(), + HashSet::from([FileCreateInvalidation::FileName { + file_name: "node_modules/foo".into(), + above: root() + },]) + ); + assert_eq!( + *invalidations.invalidate_on_file_change.read().unwrap(), + HashSet::from([ + root().join("node_modules/foo/package.json"), + root().join("package.json"), + root().join("tsconfig.json") + ]) + ); + } + + #[test] + fn browser_field() { + assert_eq!( + test_resolver() + .resolve( + "package-browser-alias", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-browser-alias/browser.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "package-browser-alias/foo", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-browser-alias/bar.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "./foo", + &root().join("node_modules/package-browser-alias/browser.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-browser-alias/bar.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "./nested", + &root().join("node_modules/package-browser-alias/browser.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path( + root().join("node_modules/package-browser-alias/subfolder1/subfolder2/subfile.js") + ) + ); + } + + #[test] + fn local_aliases() { + assert_eq!( + test_resolver() + .resolve( + "package-alias/foo", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-alias/bar.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "./foo", + &root().join("node_modules/package-alias/browser.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-alias/bar.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "./lib/test", + &root().join("node_modules/package-alias-glob/browser.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-alias-glob/src/test.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "package-browser-exclude", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Empty + ); + assert_eq!( + test_resolver() + .resolve( + "./lib/test", + &root().join("node_modules/package-alias-glob/index.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-alias-glob/src/test.js")) + ); + + let invalidations = test_resolver() + .resolve( + "package-alias/foo", + &root().join("foo.js"), + SpecifierType::Esm, + ) + .invalidations; + assert_eq!( + *invalidations.invalidate_on_file_create.read().unwrap(), + HashSet::from([FileCreateInvalidation::FileName { + file_name: "node_modules/package-alias".into(), + above: root() + },]) + ); + assert_eq!( + *invalidations.invalidate_on_file_change.read().unwrap(), + HashSet::from([ + root().join("node_modules/package-alias/package.json"), + root().join("package.json"), + root().join("tsconfig.json") + ]) + ); + } + + #[test] + fn global_aliases() { + assert_eq!( + test_resolver() + .resolve("aliased", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/index.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "aliased", + &root().join("node_modules/package-alias/foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/index.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "aliased/bar", + &root().join("node_modules/package-alias/foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/bar.js")) + ); + assert_eq!( + test_resolver() + .resolve("aliased-file", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "aliased-file", + &root().join("node_modules/package-alias/foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "aliasedfolder/test.js", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("nested/test.js")) + ); + assert_eq!( + test_resolver() + .resolve("aliasedfolder", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("nested/index.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "aliasedabsolute/test.js", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("nested/test.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "aliasedabsolute", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("nested/index.js")) + ); + assert_eq!( + test_resolver() + .resolve("foo/bar", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve("glob/bar/test", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("nested/test.js")) + ); + assert_eq!( + test_resolver() + .resolve("something", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("nested/test.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "something", + &root().join("node_modules/package-alias/foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("nested/test.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "package-alias-exclude", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Empty + ); + assert_eq!( + test_resolver() + .resolve("./baz", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve("../baz", &root().join("x/foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve("~/baz", &root().join("x/foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "./baz", + &root().join("node_modules/foo/bar.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/baz.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "~/baz", + &root().join("node_modules/foo/bar.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/baz.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "/baz", + &root().join("node_modules/foo/bar.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + } + + #[test] + fn test_urls() { + assert_eq!( + test_resolver() + .resolve( + "http://example.com/foo.png", + &root().join("foo.js"), + SpecifierType::Url + ) + .result + .unwrap() + .0, + Resolution::External + ); + assert_eq!( + test_resolver() + .resolve( + "//example.com/foo.png", + &root().join("foo.js"), + SpecifierType::Url + ) + .result + .unwrap() + .0, + Resolution::External + ); + assert_eq!( + test_resolver() + .resolve("#hash", &root().join("foo.js"), SpecifierType::Url) + .result + .unwrap() + .0, + Resolution::External + ); + assert_eq!( + test_resolver() + .resolve( + "http://example.com/foo.png", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap_err(), + ResolverError::UnknownScheme { + scheme: "http".into() + }, + ); + assert_eq!( + test_resolver() + .resolve("bar.js", &root().join("foo.js"), SpecifierType::Url) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + // Reproduce bug for now + // assert_eq!( + // test_resolver() + // .resolve("bar", &root().join("foo.js"), SpecifierType::Url) + // .result + // .unwrap_err(), + // ResolverError::FileNotFound { + // relative: "bar".into(), + // from: root().join("foo.js") + // } + // ); + assert_eq!( + test_resolver() + .resolve("bar", &root().join("foo.js"), SpecifierType::Url) + .result + .unwrap() + .0, + Resolution::Path(root().join("bar.js")) + ); + assert_eq!( + test_resolver() + .resolve("npm:foo", &root().join("foo.js"), SpecifierType::Url) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/index.js")) + ); + assert_eq!( + test_resolver() + .resolve("npm:@scope/pkg", &root().join("foo.js"), SpecifierType::Url) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/@scope/pkg/index.js")) + ); + } + + #[test] + fn test_exports() { + assert_eq!( + test_resolver() + .resolve( + "package-exports", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-exports/main.mjs")) + ); + assert_eq!( + test_resolver() + .resolve( + "package-exports/foo", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + // "browser" field is NOT used. + Resolution::Path(root().join("node_modules/package-exports/foo.mjs")) + ); + assert_eq!( + test_resolver() + .resolve( + "package-exports/features/test", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-exports/features/test.mjs")) + ); + assert_eq!( + test_resolver() + .resolve( + "package-exports/space", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-exports/with space.mjs")) + ); + // assert_eq!( + // test_resolver().resolve("package-exports/with%20space", &root().join("foo.js"), SpecifierType::Esm).unwrap().0, + // Resolution::Path(root().join("node_modules/package-exports/with space.mjs")) + // ); + assert_eq!( + test_resolver() + .resolve( + "package-exports/with space", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap_err(), + ResolverError::PackageJsonError { + module: "package-exports".into(), + path: root().join("node_modules/package-exports/package.json"), + error: PackageJsonError::PackagePathNotExported + }, + ); + assert_eq!( + test_resolver() + .resolve( + "package-exports/internal", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap_err(), + ResolverError::PackageJsonError { + module: "package-exports".into(), + path: root().join("node_modules/package-exports/package.json"), + error: PackageJsonError::PackagePathNotExported + }, + ); + assert_eq!( + test_resolver() + .resolve( + "package-exports/internal.mjs", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap_err(), + ResolverError::PackageJsonError { + module: "package-exports".into(), + path: root().join("node_modules/package-exports/package.json"), + error: PackageJsonError::PackagePathNotExported + }, + ); + assert_eq!( + test_resolver() + .resolve( + "package-exports/invalid", + &root().join("foo.js"), + SpecifierType::Esm + ) + .result + .unwrap_err(), + ResolverError::PackageJsonError { + module: "package-exports".into(), + path: root().join("node_modules/package-exports/package.json"), + error: PackageJsonError::InvalidPackageTarget + } + ); + } + + #[test] + fn test_self_reference() { + assert_eq!( + test_resolver() + .resolve( + "package-exports", + &root().join("node_modules/package-exports/foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-exports/main.mjs")) + ); + assert_eq!( + test_resolver() + .resolve( + "package-exports/foo", + &root().join("node_modules/package-exports/foo.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-exports/foo.mjs")) + ); + } + + #[test] + fn test_imports() { + assert_eq!( + test_resolver() + .resolve( + "#internal", + &root().join("node_modules/package-exports/main.mjs"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/package-exports/internal.mjs")) + ); + assert_eq!( + test_resolver() + .resolve( + "#foo", + &root().join("node_modules/package-exports/main.mjs"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/index.js")) + ); + } + + #[test] + fn test_builtins() { + assert_eq!( + test_resolver() + .resolve("zlib", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Builtin("zlib".into()) + ); + assert_eq!( + test_resolver() + .resolve("node:zlib", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Builtin("zlib".into()) + ); + } + + #[test] + fn test_tsconfig() { + assert_eq!( + test_resolver() + .resolve("ts-path", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("foo.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "ts-path", + &root().join("nested/index.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("nested/test.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "foo", + &root().join("tsconfig/index/index.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/tsconfig-index/foo.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "foo", + &root().join("tsconfig/field/index.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/tsconfig-field/foo.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "foo", + &root().join("tsconfig/exports/index.js"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/tsconfig-exports/foo.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "ts-path", + &root().join("node_modules/tsconfig-not-used/index.js"), + SpecifierType::Esm + ) + .result + .unwrap_err(), + ResolverError::ModuleNotFound { + module: "ts-path".into() + }, + ); + assert_eq!( + test_resolver() + .resolve("ts-path", &root().join("foo.css"), SpecifierType::Esm) + .result + .unwrap_err(), + ResolverError::ModuleNotFound { + module: "ts-path".into() + }, + ); + + let invalidations = test_resolver() + .resolve("ts-path", &root().join("foo.js"), SpecifierType::Esm) + .invalidations; + assert_eq!( + *invalidations.invalidate_on_file_create.read().unwrap(), + HashSet::new() + ); + assert_eq!( + *invalidations.invalidate_on_file_change.read().unwrap(), + HashSet::from([root().join("package.json"), root().join("tsconfig.json")]) + ); + } + + #[test] + fn test_module_suffixes() { + assert_eq!( + test_resolver() + .resolve( + "./a", + &root().join("tsconfig/suffixes/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("tsconfig/suffixes/a.ios.ts")) + ); + assert_eq!( + test_resolver() + .resolve( + "./a.ts", + &root().join("tsconfig/suffixes/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("tsconfig/suffixes/a.ios.ts")) + ); + assert_eq!( + test_resolver() + .resolve( + "./b", + &root().join("tsconfig/suffixes/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("tsconfig/suffixes/b.ts")) + ); + assert_eq!( + test_resolver() + .resolve( + "./b.ts", + &root().join("tsconfig/suffixes/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("tsconfig/suffixes/b.ts")) + ); + assert_eq!( + test_resolver() + .resolve( + "./c", + &root().join("tsconfig/suffixes/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("tsconfig/suffixes/c-test.ts")) + ); + assert_eq!( + test_resolver() + .resolve( + "./c.ts", + &root().join("tsconfig/suffixes/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("tsconfig/suffixes/c-test.ts")) + ); + } + + #[test] + fn test_ts_extensions() { + assert_eq!( + test_resolver() + .resolve( + "./a.js", + &root().join("ts-extensions/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("ts-extensions/a.ts")) + ); + assert_eq!( + test_resolver() + .resolve( + "./a.jsx", + &root().join("ts-extensions/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + // TSC always prioritizes .ts over .tsx + Resolution::Path(root().join("ts-extensions/a.ts")) + ); + assert_eq!( + test_resolver() + .resolve( + "./a.mjs", + &root().join("ts-extensions/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("ts-extensions/a.mts")) + ); + assert_eq!( + test_resolver() + .resolve( + "./a.cjs", + &root().join("ts-extensions/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + Resolution::Path(root().join("ts-extensions/a.cts")) + ); + assert_eq!( + test_resolver() + .resolve( + "./b.js", + &root().join("ts-extensions/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + // We deviate from TSC here to match Node/bundlers. + Resolution::Path(root().join("ts-extensions/b.js")) + ); + assert_eq!( + test_resolver() + .resolve( + "./c.js", + &root().join("ts-extensions/index.ts"), + SpecifierType::Esm + ) + .result + .unwrap() + .0, + // This matches TSC. c.js.ts seems kinda unlikely? + Resolution::Path(root().join("ts-extensions/c.ts")) + ); + assert_eq!( + test_resolver() + .resolve( + "./a.js", + &root().join("ts-extensions/index.js"), + SpecifierType::Esm + ) + .result + .unwrap_err(), + ResolverError::FileNotFound { + relative: "a.js".into(), + from: root().join("ts-extensions/index.js") + }, + ); + + let invalidations = test_resolver() + .resolve( + "./a.js", + &root().join("ts-extensions/index.ts"), + SpecifierType::Esm, + ) + .invalidations; + assert_eq!( + *invalidations.invalidate_on_file_create.read().unwrap(), + HashSet::from([ + FileCreateInvalidation::Path(root().join("ts-extensions/a.js")), + FileCreateInvalidation::FileName { + file_name: "package.json".into(), + above: root().join("ts-extensions") + }, + FileCreateInvalidation::FileName { + file_name: "tsconfig.json".into(), + above: root().join("ts-extensions") + }, + ]) + ); + assert_eq!( + *invalidations.invalidate_on_file_change.read().unwrap(), + HashSet::from([root().join("package.json"), root().join("tsconfig.json")]) + ); + } + + fn resolve_side_effects(specifier: &str, from: &Path) -> bool { + let resolver = test_resolver(); + let resolved = resolver + .resolve(specifier, from, SpecifierType::Esm) + .result + .unwrap() + .0; + + if let Resolution::Path(path) = resolved { + resolver + .resolve_side_effects(&path, &Invalidations::default()) + .unwrap() + } else { + unreachable!() + } + } + + #[test] + fn test_side_effects() { + assert_eq!( + resolve_side_effects("side-effects-false/src/index.js", &root().join("foo.js")), + false, + ); + assert_eq!( + resolve_side_effects("side-effects-false/src/index", &root().join("foo.js")), + false, + ); + assert_eq!( + resolve_side_effects("side-effects-false/src/", &root().join("foo.js")), + false, + ); + assert_eq!( + resolve_side_effects("side-effects-false", &root().join("foo.js")), + false, + ); + assert_eq!( + resolve_side_effects( + "side-effects-package-redirect-up/foo/bar", + &root().join("foo.js") + ), + false, + ); + assert_eq!( + resolve_side_effects( + "side-effects-package-redirect-down/foo/bar", + &root().join("foo.js") + ), + false, + ); + assert_eq!( + resolve_side_effects("side-effects-false-glob/a/index", &root().join("foo.js")), + true, + ); + assert_eq!( + resolve_side_effects("side-effects-false-glob/b/index.js", &root().join("foo.js")), + false, + ); + assert_eq!( + resolve_side_effects( + "side-effects-false-glob/sub/a/index.js", + &root().join("foo.js") + ), + false, + ); + assert_eq!( + resolve_side_effects( + "side-effects-false-glob/sub/index.json", + &root().join("foo.js") + ), + true, + ); + } + + #[test] + fn test_include_node_modules() { + let mut resolver = test_resolver(); + resolver.include_node_modules = Cow::Owned(IncludeNodeModules::Bool(false)); + + assert_eq!( + resolver + .resolve("foo", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::External + ); + assert_eq!( + resolver + .resolve("@scope/pkg", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::External + ); + + resolver.include_node_modules = Cow::Owned(IncludeNodeModules::Array(vec!["foo".into()])); + assert_eq!( + resolver + .resolve("foo", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/foo/index.js")) + ); + assert_eq!( + resolver + .resolve("@scope/pkg", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::External + ); + + resolver.include_node_modules = Cow::Owned(IncludeNodeModules::Map(HashMap::from([ + ("foo".into(), false), + ("@scope/pkg".into(), true), + ]))); + assert_eq!( + resolver + .resolve("foo", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::External + ); + assert_eq!( + resolver + .resolve("@scope/pkg", &root().join("foo.js"), SpecifierType::Esm) + .result + .unwrap() + .0, + Resolution::Path(root().join("node_modules/@scope/pkg/index.js")) + ); + } + + // #[test] + // fn test_visitor() { + // let resolved = test_resolver().resolve("unified", &root(), SpecifierType::Esm).unwrap(); + // println!("{:?}", resolved); + // if let Resolution::Path(p) = resolved { + // let res = build_esm_graph( + // &p, + // root() + // ).unwrap(); + // println!("{:?}", res); + // } + // } +} diff --git a/packages/utils/node-resolver-rs/src/package_json.rs b/packages/utils/node-resolver-rs/src/package_json.rs new file mode 100644 index 00000000000..e984377ad66 --- /dev/null +++ b/packages/utils/node-resolver-rs/src/package_json.rs @@ -0,0 +1,1556 @@ +use bitflags::bitflags; +use glob_match::{glob_match, glob_match_with_captures}; +use indexmap::IndexMap; +use serde::Deserialize; +use std::{ + borrow::Cow, + cmp::Ordering, + ops::Range, + path::{Component, Path, PathBuf}, +}; + +use crate::{ + path::resolve_path, + specifier::decode_path, + specifier::{Specifier, SpecifierType}, +}; + +bitflags! { + #[derive(serde::Serialize)] + pub struct Fields: u8 { + const MAIN = 1 << 0; + const MODULE = 1 << 1; + const SOURCE = 1 << 2; + const BROWSER = 1 << 3; + const ALIAS = 1 << 4; + const TSCONFIG = 1 << 5; + } +} + +#[derive(serde::Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PackageJson<'a> { + #[serde(skip)] + pub path: PathBuf, + #[serde(default)] + pub name: &'a str, + main: Option<&'a str>, + module: Option<&'a str>, + #[serde(default)] + tsconfig: Option<&'a str>, + #[serde(default)] + pub source: SourceField<'a>, + #[serde(default)] + browser: BrowserField<'a>, + #[serde(default)] + alias: IndexMap, AliasValue<'a>>, + #[serde(default)] + exports: ExportsField<'a>, + #[serde(default)] + imports: IndexMap, ExportsField<'a>>, + #[serde(default)] + side_effects: SideEffects<'a>, +} + +impl<'a> Default for PackageJson<'a> { + fn default() -> Self { + PackageJson { + path: Default::default(), + name: "", + main: None, + module: None, + tsconfig: None, + source: Default::default(), + browser: Default::default(), + alias: Default::default(), + exports: Default::default(), + imports: Default::default(), + side_effects: Default::default(), + } + } +} + +#[derive(serde::Deserialize, Debug)] +#[serde(untagged)] +pub enum BrowserField<'a> { + None, + #[serde(borrow)] + String(&'a str), + Map(IndexMap, AliasValue<'a>>), +} + +impl<'a> Default for BrowserField<'a> { + fn default() -> Self { + BrowserField::None + } +} + +#[derive(serde::Deserialize, Debug)] +#[serde(untagged)] +pub enum SourceField<'a> { + None, + #[serde(borrow)] + String(&'a str), + Map(IndexMap, AliasValue<'a>>), + Array(Vec<&'a str>), + Bool(bool), +} + +impl<'a> Default for SourceField<'a> { + fn default() -> Self { + SourceField::None + } +} + +#[derive(serde::Deserialize, Debug, PartialEq)] +#[serde(untagged)] +pub enum ExportsField<'a> { + None, + #[serde(borrow)] + String(&'a str), + Array(Vec>), + Map(IndexMap, ExportsField<'a>>), +} + +impl<'a> Default for ExportsField<'a> { + fn default() -> Self { + ExportsField::None + } +} + +bitflags! { + pub struct ExportsCondition: u16 { + const IMPORT = 1 << 0; + const REQUIRE = 1 << 1; + const MODULE = 1 << 2; + const NODE = 1 << 3; + const BROWSER = 1 << 4; + const WORKER = 1 << 5; + const WORKLET = 1 << 6; + const ELECTRON = 1 << 7; + const DEVELOPMENT = 1 << 8; + const PRODUCTION = 1 << 9; + const TYPES = 1 << 10; + const DEFAULT = 1 << 11; + } +} + +impl TryFrom<&str> for ExportsCondition { + type Error = (); + fn try_from(value: &str) -> Result { + Ok(match value { + "import" => ExportsCondition::IMPORT, + "require" => ExportsCondition::REQUIRE, + "module" => ExportsCondition::MODULE, + "node" => ExportsCondition::NODE, + "browser" => ExportsCondition::BROWSER, + "worker" => ExportsCondition::WORKER, + "worklet" => ExportsCondition::WORKLET, + "electron" => ExportsCondition::ELECTRON, + "development" => ExportsCondition::DEVELOPMENT, + "production" => ExportsCondition::PRODUCTION, + "types" => ExportsCondition::TYPES, + "default" => ExportsCondition::DEFAULT, + _ => return Err(()), + }) + } +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub enum ExportsKey<'a> { + Main, + Pattern(&'a str), + Condition(ExportsCondition), + CustomCondition(&'a str), +} + +impl<'a> From<&'a str> for ExportsKey<'a> { + fn from(key: &'a str) -> Self { + if key == "." { + ExportsKey::Main + } else if key.starts_with("./") { + ExportsKey::Pattern(&key[2..]) + } else if key.starts_with('#') { + ExportsKey::Pattern(&key[1..]) + } else if let Ok(c) = ExportsCondition::try_from(key) { + ExportsKey::Condition(c) + } else { + ExportsKey::CustomCondition(key) + } + } +} + +impl<'a, 'de: 'a> Deserialize<'de> for ExportsKey<'a> { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: &'de str = Deserialize::deserialize(deserializer)?; + Ok(ExportsKey::from(s)) + } +} + +#[derive(serde::Deserialize, Clone, PartialEq, Debug)] +#[serde(untagged)] +pub enum AliasValue<'a> { + #[serde(borrow)] + Specifier(Specifier<'a>), + Bool(bool), + Global { + global: &'a str, + }, +} + +#[derive(serde::Deserialize, Clone, PartialEq, Debug)] +#[serde(untagged)] +pub enum SideEffects<'a> { + None, + Boolean(bool), + #[serde(borrow)] + String(&'a str), + Array(Vec<&'a str>), +} + +impl<'a> Default for SideEffects<'a> { + fn default() -> Self { + SideEffects::None + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub enum PackageJsonError { + InvalidPackageTarget, + PackagePathNotExported, + InvalidSpecifier, + ImportNotDefined, +} + +#[derive(Debug, PartialEq)] +pub enum ExportsResolution<'a> { + None, + Path(PathBuf), + Package(Cow<'a, str>), +} + +impl<'a> PackageJson<'a> { + pub fn parse(path: PathBuf, data: &'a str) -> serde_json::Result> { + let mut parsed: PackageJson = serde_json::from_str(data)?; + parsed.path = path; + Ok(parsed) + } + + pub fn entries(&self, fields: Fields) -> EntryIter { + return EntryIter { + package: self, + fields, + }; + } + + pub fn has_exports(&self) -> bool { + self.exports != ExportsField::None + } + + pub fn resolve_package_exports( + &self, + subpath: &'a str, + conditions: ExportsCondition, + ) -> Result { + // If exports is an Object with both a key starting with "." and a key not starting with ".", throw an Invalid Package Configuration error. + if let ExportsField::Map(map) = &self.exports { + let mut has_conditions = false; + let mut has_patterns = false; + for key in map.keys() { + has_conditions = has_conditions + || matches!( + key, + ExportsKey::Condition(..) | ExportsKey::CustomCondition(..) + ); + has_patterns = has_patterns || matches!(key, ExportsKey::Pattern(..) | ExportsKey::Main); + if has_conditions && has_patterns { + return Err(PackageJsonError::InvalidPackageTarget); + } + } + } + + if subpath.is_empty() { + let mut main_export = &ExportsField::None; + match &self.exports { + ExportsField::None | ExportsField::String(_) | ExportsField::Array(_) => { + main_export = &self.exports; + } + ExportsField::Map(map) => { + if let Some(v) = map.get(&ExportsKey::Main) { + main_export = v; + } else if !map.keys().any(|k| matches!(k, ExportsKey::Pattern(_))) { + main_export = &self.exports; + } + } + } + + if main_export != &ExportsField::None { + match self.resolve_package_target(main_export, "", false, conditions)? { + ExportsResolution::Path(path) => return Ok(path), + ExportsResolution::None | ExportsResolution::Package(..) => {} + } + } + } else if let ExportsField::Map(exports) = &self.exports { + // All exports must start with "." at this point. + match self.resolve_package_imports_exports(subpath, &exports, false, conditions)? { + ExportsResolution::Path(path) => return Ok(path), + ExportsResolution::None | ExportsResolution::Package(..) => {} + } + } + + Err(PackageJsonError::PackagePathNotExported) + } + + pub fn resolve_package_imports( + &self, + specifier: &'a str, + conditions: ExportsCondition, + ) -> Result, PackageJsonError> { + if specifier == "#" || specifier.starts_with("#/") { + return Err(PackageJsonError::InvalidSpecifier); + } + + match self.resolve_package_imports_exports(specifier, &self.imports, true, conditions)? { + ExportsResolution::None => {} + res => return Ok(res), + } + + Err(PackageJsonError::ImportNotDefined) + } + + fn resolve_package_target( + &self, + target: &'a ExportsField, + pattern_match: &str, + is_imports: bool, + conditions: ExportsCondition, + ) -> Result, PackageJsonError> { + match target { + ExportsField::String(target) => { + if !target.starts_with("./") { + if !is_imports || target.starts_with("../") || target.starts_with('/') { + return Err(PackageJsonError::InvalidPackageTarget); + } + + if pattern_match != "" { + let target = target.replace('*', pattern_match); + return Ok(ExportsResolution::Package(Cow::Owned(target))); + } + + return Ok(ExportsResolution::Package(Cow::Borrowed(target))); + } + + let target = if pattern_match == "" { + Cow::Borrowed(*target) + } else { + Cow::Owned(target.replace('*', pattern_match)) + }; + + // If target split on "/" or "\" contains any "", ".", "..", or "node_modules" segments after + // the first "." segment, case insensitive and including percent encoded variants, + // throw an Invalid Package Target error. + let target_path = decode_path(target.as_ref(), SpecifierType::Esm).0; + if target_path + .components() + .enumerate() + .any(|(index, c)| match c { + Component::ParentDir => true, + Component::CurDir => index > 0, + Component::Normal(c) => c.eq_ignore_ascii_case("node_modules"), + _ => false, + }) + { + return Err(PackageJsonError::InvalidPackageTarget); + } + + let resolved_target = resolve_path(&self.path, &target_path); + return Ok(ExportsResolution::Path(resolved_target)); + } + ExportsField::Map(target) => { + // We must iterate in object insertion order. + for (key, value) in target { + if let ExportsKey::Condition(key) = key { + if *key == ExportsCondition::DEFAULT || conditions.contains(*key) { + match self.resolve_package_target(value, pattern_match, is_imports, conditions)? { + ExportsResolution::None => continue, + res => return Ok(res), + } + } + } + } + } + ExportsField::Array(target) => { + if target.is_empty() { + return Err(PackageJsonError::PackagePathNotExported); + } + + for item in target { + match self.resolve_package_target(item, pattern_match, is_imports, conditions) { + Err(_) | Ok(ExportsResolution::None) => continue, + Ok(res) => return Ok(res), + } + } + } + ExportsField::None => return Ok(ExportsResolution::None), + } + + Ok(ExportsResolution::None) + } + + fn resolve_package_imports_exports( + &self, + match_key: &'a str, + match_obj: &'a IndexMap, ExportsField<'a>>, + is_imports: bool, + conditions: ExportsCondition, + ) -> Result, PackageJsonError> { + let pattern = ExportsKey::Pattern(match_key); + if let Some(target) = match_obj.get(&pattern) { + if !match_key.contains('*') { + return self.resolve_package_target(target, "", is_imports, conditions); + } + } + + let mut best_key = ""; + let mut best_match = ""; + for key in match_obj.keys() { + if let ExportsKey::Pattern(key) = key { + if let Some((pattern_base, pattern_trailer)) = key.split_once('*') { + if match_key.starts_with(pattern_base) + && !pattern_trailer.contains('*') + && (pattern_trailer.is_empty() + || (match_key.len() >= key.len() && match_key.ends_with(pattern_trailer))) + && pattern_key_compare(best_key, key) == Ordering::Greater + { + best_key = key; + best_match = &match_key[pattern_base.len()..match_key.len() - pattern_trailer.len()]; + } + } + } + } + + if !best_key.is_empty() { + return self.resolve_package_target( + &match_obj[&ExportsKey::Pattern(best_key)], + best_match, + is_imports, + conditions, + ); + } + + Ok(ExportsResolution::None) + } + + pub fn resolve_aliases( + &self, + specifier: &Specifier<'a>, + fields: Fields, + ) -> Option> { + if fields.contains(Fields::SOURCE) { + match &self.source { + SourceField::Map(source) => match self.resolve_alias(source, specifier) { + None => {} + res => return res, + }, + _ => {} + } + } + + if fields.contains(Fields::ALIAS) { + match self.resolve_alias(&self.alias, specifier) { + None => {} + res => return res, + } + } + + if fields.contains(Fields::BROWSER) { + match &self.browser { + BrowserField::Map(browser) => match self.resolve_alias(browser, specifier) { + None => {} + res => return res, + }, + _ => {} + } + } + + None + } + + fn resolve_alias( + &self, + map: &'a IndexMap, AliasValue<'a>>, + specifier: &Specifier<'a>, + ) -> Option> { + if let Some(alias) = self.lookup_alias(map, specifier) { + return Some(alias); + } + + match specifier { + Specifier::Package(package, subpath) => { + if let Some(alias) = + self.lookup_alias(map, &Specifier::Package(package.clone(), Cow::Borrowed(""))) + { + match alias.as_ref() { + AliasValue::Specifier(base) => { + // Join the subpath back onto the resolved alias. + match base { + Specifier::Package(base_pkg, base_subpath) => { + let subpath = if !base_subpath.is_empty() && !subpath.is_empty() { + Cow::Owned(format!("{}/{}", base_subpath, subpath)) + } else if !subpath.is_empty() { + subpath.clone() + } else { + return Some(alias); + }; + return Some(Cow::Owned(AliasValue::Specifier(Specifier::Package( + base_pkg.clone(), + subpath, + )))); + } + Specifier::Relative(path) => { + if subpath.is_empty() { + return Some(alias); + } else { + return Some(Cow::Owned(AliasValue::Specifier(Specifier::Relative( + Cow::Owned(path.join(subpath.as_ref())), + )))); + } + } + Specifier::Absolute(path) => { + if subpath.is_empty() { + return Some(alias); + } else { + return Some(Cow::Owned(AliasValue::Specifier(Specifier::Absolute( + Cow::Owned(path.join(subpath.as_ref())), + )))); + } + } + Specifier::Tilde(path) => { + if subpath.is_empty() { + return Some(alias); + } else { + return Some(Cow::Owned(AliasValue::Specifier(Specifier::Tilde( + Cow::Owned(path.join(subpath.as_ref())), + )))); + } + } + _ => return Some(alias), + } + } + _ => return Some(alias), + }; + } + } + _ => {} + } + + None + } + + fn lookup_alias( + &self, + map: &'a IndexMap, AliasValue<'a>>, + specifier: &Specifier<'a>, + ) -> Option> { + if let Some(value) = map.get(specifier) { + return Some(Cow::Borrowed(value)); + } + + // Match glob aliases. + for (key, value) in map { + let (glob, path) = match (key, specifier) { + (Specifier::Relative(glob), Specifier::Relative(path)) + | (Specifier::Absolute(glob), Specifier::Absolute(path)) + | (Specifier::Tilde(glob), Specifier::Tilde(path)) => { + (glob.as_os_str().to_str()?, path.as_os_str().to_str()?) + } + (Specifier::Package(module_a, glob), Specifier::Package(module_b, path)) + if module_a == module_b => + { + (glob.as_ref(), path.as_ref()) + } + _ => continue, + }; + + if let Some(captures) = glob_match_with_captures(glob, path) { + let res = match value { + AliasValue::Specifier(specifier) => AliasValue::Specifier(match specifier { + Specifier::Relative(r) => { + Specifier::Relative(replace_path_captures(r, path, &captures)?) + } + Specifier::Absolute(r) => { + Specifier::Absolute(replace_path_captures(r, path, &captures)?) + } + Specifier::Tilde(r) => Specifier::Tilde(replace_path_captures(r, path, &captures)?), + Specifier::Package(module, subpath) => { + Specifier::Package(module.clone(), replace_captures(subpath, path, &captures)) + } + _ => return Some(Cow::Borrowed(value)), + }), + _ => return Some(Cow::Borrowed(value)), + }; + + return Some(Cow::Owned(res)); + } + } + + None + } + + pub fn has_side_effects(&self, path: &Path) -> bool { + let path = path + .strip_prefix(self.path.parent().unwrap()) + .ok() + .and_then(|path| path.as_os_str().to_str()); + + let path = match path { + Some(p) => p, + None => return true, + }; + + fn side_effects_glob_matches(glob: &str, path: &str) -> bool { + // Trim leading "./" + let glob = if glob.starts_with("./") { + &glob[2..] + } else { + &glob + }; + + // If the glob does not contain any '/' characters, prefix with "**/" to match webpack. + let glob = if !glob.contains('/') { + Cow::Owned(format!("**/{}", glob)) + } else { + Cow::Borrowed(glob) + }; + + glob_match(glob.as_ref(), path) + } + + match &self.side_effects { + SideEffects::None => true, + SideEffects::Boolean(b) => *b, + SideEffects::String(glob) => side_effects_glob_matches(glob, path), + SideEffects::Array(globs) => globs + .iter() + .any(|glob| side_effects_glob_matches(glob, path)), + } + } +} + +fn replace_path_captures<'a>( + s: &'a Path, + path: &str, + captures: &Vec>, +) -> Option> { + Some( + match replace_captures(s.as_os_str().to_str()?, path, &captures) { + Cow::Borrowed(b) => Cow::Borrowed(Path::new(b)), + Cow::Owned(b) => Cow::Owned(PathBuf::from(b)), + }, + ) +} + +/// Inserts captures matched in a glob against `path` using a pattern string. +/// Replacements are inserted using JS-like $N syntax, e.g. $1 for the first capture. +fn replace_captures<'a>(s: &'a str, path: &str, captures: &Vec>) -> Cow<'a, str> { + let mut res = Cow::Borrowed(s); + let bytes = s.as_bytes(); + for (idx, _) in s.match_indices('$').rev() { + let mut end = idx; + while end + 1 < bytes.len() && bytes[end + 1].is_ascii_digit() { + end += 1; + } + + if end != idx { + if let Ok(capture_index) = s[idx + 1..end + 1].parse::() { + if capture_index > 0 && capture_index - 1 < captures.len() { + res + .to_mut() + .replace_range(idx..end + 1, &path[captures[capture_index - 1].clone()]); + } + } + } + } + + res +} + +fn pattern_key_compare(a: &str, b: &str) -> Ordering { + let a_pos = a.chars().position(|c| c == '*'); + let b_pos = b.chars().position(|c| c == '*'); + let base_length_a = a_pos.map_or(a.len(), |p| p + 1); + let base_length_b = b_pos.map_or(b.len(), |p| p + 1); + let cmp = base_length_b.cmp(&base_length_a); + if cmp != Ordering::Equal { + return cmp; + } + + if a_pos == None { + return Ordering::Greater; + } + + if b_pos == None { + return Ordering::Less; + } + + b.len().cmp(&a.len()) +} + +pub struct EntryIter<'a> { + package: &'a PackageJson<'a>, + fields: Fields, +} + +impl<'a> Iterator for EntryIter<'a> { + type Item = (PathBuf, &'static str); + + fn next(&mut self) -> Option { + if self.fields.contains(Fields::SOURCE) { + self.fields.remove(Fields::SOURCE); + match &self.package.source { + SourceField::None | SourceField::Array(_) | SourceField::Bool(_) => {} + SourceField::String(source) => { + return Some((resolve_path(&self.package.path, source), "source")) + } + SourceField::Map(map) => match map.get(&Specifier::Package( + Cow::Borrowed(self.package.name), + Cow::Borrowed(""), + )) { + Some(AliasValue::Specifier(s)) => match s { + Specifier::Relative(s) => return Some((resolve_path(&self.package.path, s), "source")), + _ => {} + }, + _ => {} + }, + } + } + + if self.fields.contains(Fields::BROWSER) { + self.fields.remove(Fields::BROWSER); + match &self.package.browser { + BrowserField::None => {} + BrowserField::String(browser) => { + return Some((resolve_path(&self.package.path, browser), "browser")) + } + BrowserField::Map(map) => match map.get(&Specifier::Package( + Cow::Borrowed(self.package.name), + Cow::Borrowed(""), + )) { + Some(AliasValue::Specifier(s)) => match s { + Specifier::Relative(s) => { + return Some((resolve_path(&self.package.path, s), "browser")) + } + _ => {} + }, + _ => {} + }, + } + } + + if self.fields.contains(Fields::MODULE) { + self.fields.remove(Fields::MODULE); + if let Some(module) = self.package.module { + return Some((resolve_path(&self.package.path, module), "module")); + } + } + + if self.fields.contains(Fields::MAIN) { + self.fields.remove(Fields::MAIN); + if let Some(main) = self.package.main { + return Some((resolve_path(&self.package.path, main), "main")); + } + } + + if self.fields.contains(Fields::TSCONFIG) { + self.fields.remove(Fields::TSCONFIG); + if let Some(tsconfig) = self.package.tsconfig { + return Some((resolve_path(&self.package.path, tsconfig), "tsconfig")); + } + } + + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use indexmap::indexmap; + + // Based on /~https://github.com/lukeed/resolve.exports/blob/master/test/resolve.js, + // /~https://github.com/privatenumber/resolve-pkg-maps/tree/develop/tests, and + // /~https://github.com/webpack/enhanced-resolve/blob/main/test/exportsField.js + + #[test] + fn exports_string() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::String("./exports.js"), + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_exports("", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/exports.js") + ); + // assert_eq!(pkg.resolve_package_exports("./exports.js", &[]).unwrap(), PathBuf::from("/foo/exports.js")); + // assert_eq!(pkg.resolve_package_exports("foobar", &[]).unwrap(), PathBuf::from("/foo/exports.js")); + } + + #[test] + fn exports_dot() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + ".".into() => ExportsField::String("./exports.js") + }), + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_exports("", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/exports.js") + ); + assert!(matches!( + pkg.resolve_package_exports(".", ExportsCondition::empty()), + Err(PackageJsonError::PackagePathNotExported) + )); + // assert_eq!(pkg.resolve_package_exports("foobar", &[]).unwrap(), PathBuf::from("/foo/exports.js")); + } + + #[test] + fn exports_dot_conditions() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + ".".into() => ExportsField::Map(indexmap! { + "import".into() => ExportsField::String("./import.js"), + "require".into() => ExportsField::String("./require.js") + }) + }), + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_exports("", ExportsCondition::IMPORT | ExportsCondition::REQUIRE) + .unwrap(), + PathBuf::from("/foo/import.js") + ); + assert_eq!( + pkg + .resolve_package_exports("", ExportsCondition::REQUIRE) + .unwrap(), + PathBuf::from("/foo/require.js") + ); + assert!(matches!( + pkg.resolve_package_exports("", ExportsCondition::empty()), + Err(PackageJsonError::PackagePathNotExported) + )); + assert!(matches!( + pkg.resolve_package_exports("", ExportsCondition::NODE), + Err(PackageJsonError::PackagePathNotExported) + )); + } + + #[test] + fn exports_map_string() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + "./foo".into() => ExportsField::String("./exports.js"), + "./.invisible".into() => ExportsField::String("./.invisible.js"), + "./".into() => ExportsField::String("./"), + "./*".into() => ExportsField::String("./*.js") + }), + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_exports("foo", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/exports.js") + ); + assert_eq!( + pkg + .resolve_package_exports(".invisible", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/.invisible.js") + ); + assert_eq!( + pkg + .resolve_package_exports("file", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/file.js") + ); + } + + #[test] + fn exports_map_conditions() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + "./foo".into() => ExportsField::Map(indexmap! { + "import".into() => ExportsField::String("./import.js"), + "require".into() => ExportsField::String("./require.js") + }) + }), + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_exports("foo", ExportsCondition::IMPORT | ExportsCondition::REQUIRE) + .unwrap(), + PathBuf::from("/foo/import.js") + ); + assert_eq!( + pkg + .resolve_package_exports("foo", ExportsCondition::REQUIRE) + .unwrap(), + PathBuf::from("/foo/require.js") + ); + assert!(matches!( + pkg.resolve_package_exports("foo", ExportsCondition::empty()), + Err(PackageJsonError::PackagePathNotExported) + )); + assert!(matches!( + pkg.resolve_package_exports("foo", ExportsCondition::NODE), + Err(PackageJsonError::PackagePathNotExported) + )); + } + + #[test] + fn nested_conditions() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + "node".into() => ExportsField::Map(indexmap! { + "import".into() => ExportsField::String("./import.js"), + "require".into() => ExportsField::String("./require.js") + }), + "default".into() => ExportsField::String("./default.js") + }), + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_exports("", ExportsCondition::NODE | ExportsCondition::IMPORT) + .unwrap(), + PathBuf::from("/foo/import.js") + ); + assert_eq!( + pkg + .resolve_package_exports("", ExportsCondition::NODE | ExportsCondition::REQUIRE) + .unwrap(), + PathBuf::from("/foo/require.js") + ); + assert_eq!( + pkg + .resolve_package_exports("", ExportsCondition::IMPORT) + .unwrap(), + PathBuf::from("/foo/default.js") + ); + assert_eq!( + pkg + .resolve_package_exports("", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/default.js") + ); + assert_eq!( + pkg + .resolve_package_exports("", ExportsCondition::NODE) + .unwrap(), + PathBuf::from("/foo/default.js") + ); + } + + #[test] + fn subpath_nested_conditions() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + "./lite".into() => ExportsField::Map(indexmap! { + "node".into() => ExportsField::Map(indexmap! { + "import".into() => ExportsField::String("./node_import.js"), + "require".into() => ExportsField::String("./node_require.js") + }), + "browser".into() => ExportsField::Map(indexmap! { + "import".into() => ExportsField::String("./browser_import.js"), + "require".into() => ExportsField::String("./browser_require.js") + }), + }) + }), + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_exports("lite", ExportsCondition::NODE | ExportsCondition::IMPORT) + .unwrap(), + PathBuf::from("/foo/node_import.js") + ); + assert_eq!( + pkg + .resolve_package_exports("lite", ExportsCondition::NODE | ExportsCondition::REQUIRE) + .unwrap(), + PathBuf::from("/foo/node_require.js") + ); + assert_eq!( + pkg + .resolve_package_exports("lite", ExportsCondition::BROWSER | ExportsCondition::IMPORT) + .unwrap(), + PathBuf::from("/foo/browser_import.js") + ); + assert_eq!( + pkg + .resolve_package_exports( + "lite", + ExportsCondition::BROWSER | ExportsCondition::REQUIRE + ) + .unwrap(), + PathBuf::from("/foo/browser_require.js") + ); + assert!(matches!( + pkg.resolve_package_exports("lite", ExportsCondition::empty()), + Err(PackageJsonError::PackagePathNotExported) + )); + } + + #[test] + fn subpath_star() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + "./*".into() => ExportsField::String("./cheese/*.mjs"), + "./pizza/*".into() => ExportsField::String("./pizza/*.mjs"), + "./burritos/*".into() => ExportsField::String("./burritos/*/*.mjs"), + "./literal".into() => ExportsField::String("./literal/*.js"), + }), + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_exports("hello", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/cheese/hello.mjs") + ); + assert_eq!( + pkg + .resolve_package_exports("hello/world", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/cheese/hello/world.mjs") + ); + assert_eq!( + pkg + .resolve_package_exports("hello.js", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/cheese/hello.js.mjs") + ); + assert_eq!( + pkg + .resolve_package_exports("pizza/test", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/pizza/test.mjs") + ); + assert_eq!( + pkg + .resolve_package_exports("burritos/test", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/burritos/test/test.mjs") + ); + assert_eq!( + pkg + .resolve_package_exports("literal", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/literal/*.js") + ); + + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + "./*".into() => ExportsField::String("./*.js"), + "./*.js".into() => ExportsField::None, + "./internal/*".into() => ExportsField::None, + }), + ..PackageJson::default() + }; + assert_eq!( + pkg + .resolve_package_exports("file", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/file.js") + ); + assert!(matches!( + pkg.resolve_package_exports("file.js", ExportsCondition::empty()), + Err(PackageJsonError::PackagePathNotExported) + )); + assert!(matches!( + pkg.resolve_package_exports("internal/file", ExportsCondition::empty()), + Err(PackageJsonError::PackagePathNotExported) + )); + } + + #[test] + fn exports_null() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + "./features/*.js".into() => ExportsField::String("./src/features/*.js"), + "./features/private-internal/*".into() => ExportsField::None, + }), + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_exports("features/foo.js", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/src/features/foo.js") + ); + assert_eq!( + pkg + .resolve_package_exports("features/foo/bar.js", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/src/features/foo/bar.js") + ); + assert!(matches!( + pkg.resolve_package_exports( + "features/private-internal/foo.js", + ExportsCondition::empty() + ), + Err(PackageJsonError::PackagePathNotExported) + ),); + } + + #[test] + fn exports_array() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + "./utils/*".into() => ExportsField::Map(indexmap! { + "browser".into() => ExportsField::Map(indexmap! { + "worklet".into() => ExportsField::Array(vec![ExportsField::String("./*"), ExportsField::String("./node/*")]), + "default".into() => ExportsField::Map(indexmap! { + "node".into() => ExportsField::String("./node/*") + }) + }) + }), + "./test/*".into() => ExportsField::Array(vec![ExportsField::String("lodash/*"), ExportsField::String("./bar/*")]), + "./file".into() => ExportsField::Array(vec![ExportsField::String("http://a.com"), ExportsField::String("./file.js")]) + }), + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_exports( + "utils/index.js", + ExportsCondition::BROWSER | ExportsCondition::WORKLET + ) + .unwrap(), + PathBuf::from("/foo/index.js") + ); + assert_eq!( + pkg + .resolve_package_exports( + "utils/index.js", + ExportsCondition::BROWSER | ExportsCondition::NODE + ) + .unwrap(), + PathBuf::from("/foo/node/index.js") + ); + assert_eq!( + pkg + .resolve_package_exports("test/index.js", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/bar/index.js") + ); + assert_eq!( + pkg + .resolve_package_exports("file", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/file.js") + ); + assert!(matches!( + pkg.resolve_package_exports("utils/index.js", ExportsCondition::BROWSER), + Err(PackageJsonError::PackagePathNotExported) + )); + assert!(matches!( + pkg.resolve_package_exports("dir/file.js", ExportsCondition::BROWSER), + Err(PackageJsonError::PackagePathNotExported) + )); + + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Array(vec![ + ExportsField::Map(indexmap! { + "node".into() => ExportsField::String("./a.js") + }), + ExportsField::String("./b.js"), + ]), + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_exports("", ExportsCondition::empty()) + .unwrap(), + PathBuf::from("/foo/b.js") + ); + assert_eq!( + pkg + .resolve_package_exports("", ExportsCondition::NODE) + .unwrap(), + PathBuf::from("/foo/a.js") + ); + } + + #[test] + fn exports_invalid() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + "./invalid".into() => ExportsField::String("../invalid"), + "./absolute".into() => ExportsField::String("/absolute"), + "./package".into() => ExportsField::String("package"), + "./utils/index".into() => ExportsField::String("./src/../index.js"), + "./dist/*".into() => ExportsField::String("./src/../../*"), + "./modules/*".into() => ExportsField::String("./node_modules/*"), + "./modules2/*".into() => ExportsField::String("./NODE_MODULES/*"), + "./*/*".into() => ExportsField::String("./file.js") + }), + ..PackageJson::default() + }; + + assert!(matches!( + pkg.resolve_package_exports("invalid", ExportsCondition::empty()), + Err(PackageJsonError::InvalidPackageTarget) + )); + assert!(matches!( + pkg.resolve_package_exports("absolute", ExportsCondition::empty()), + Err(PackageJsonError::InvalidPackageTarget) + )); + assert!(matches!( + pkg.resolve_package_exports("package", ExportsCondition::empty()), + Err(PackageJsonError::InvalidPackageTarget) + )); + assert!(matches!( + pkg.resolve_package_exports("utils/index", ExportsCondition::empty()), + Err(PackageJsonError::InvalidPackageTarget) + )); + assert!(matches!( + pkg.resolve_package_exports("dist/foo", ExportsCondition::empty()), + Err(PackageJsonError::InvalidPackageTarget) + )); + assert!(matches!( + pkg.resolve_package_exports("modules/foo", ExportsCondition::empty()), + Err(PackageJsonError::InvalidPackageTarget) + )); + assert!(matches!( + pkg.resolve_package_exports("a/b", ExportsCondition::empty()), + Err(PackageJsonError::PackagePathNotExported) + )); + assert!(matches!( + pkg.resolve_package_exports("a/*", ExportsCondition::empty()), + Err(PackageJsonError::PackagePathNotExported) + )); + + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + exports: ExportsField::Map(indexmap! { + ".".into() => ExportsField::String("./foo.js"), + "node".into() => ExportsField::String("./bar.js"), + }), + ..PackageJson::default() + }; + + assert!(matches!( + pkg.resolve_package_exports("", ExportsCondition::NODE), + Err(PackageJsonError::InvalidPackageTarget) + )); + assert!(matches!( + pkg.resolve_package_exports("", ExportsCondition::NODE), + Err(PackageJsonError::InvalidPackageTarget) + )); + } + + #[test] + fn imports() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + imports: indexmap! { + "#foo".into() => ExportsField::String("./foo.mjs"), + "#internal/*".into() => ExportsField::String("./src/internal/*.mjs"), + "#bar".into() => ExportsField::String("bar"), + }, + ..PackageJson::default() + }; + + assert_eq!( + pkg + .resolve_package_imports("foo", ExportsCondition::empty()) + .unwrap(), + ExportsResolution::Path(PathBuf::from("/foo/foo.mjs")) + ); + assert_eq!( + pkg + .resolve_package_imports("internal/foo", ExportsCondition::empty()) + .unwrap(), + ExportsResolution::Path(PathBuf::from("/foo/src/internal/foo.mjs")) + ); + assert_eq!( + pkg + .resolve_package_imports("bar", ExportsCondition::empty()) + .unwrap(), + ExportsResolution::Package("bar".into()) + ); + } + + #[test] + fn import_conditions() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + imports: indexmap! { + "#entry/*".into() => ExportsField::Map(indexmap! { + "node".into() => ExportsField::String("./node/*.js"), + "browser".into() => ExportsField::String("./browser/*.js") + }) + }, + ..PackageJson::default() + }; + assert_eq!( + pkg + .resolve_package_imports("entry/foo", ExportsCondition::NODE) + .unwrap(), + ExportsResolution::Path(PathBuf::from("/foo/node/foo.js")) + ); + assert_eq!( + pkg + .resolve_package_imports("entry/foo", ExportsCondition::BROWSER) + .unwrap(), + ExportsResolution::Path(PathBuf::from("/foo/browser/foo.js")) + ); + assert_eq!( + pkg + .resolve_package_imports( + "entry/foo", + ExportsCondition::NODE | ExportsCondition::BROWSER + ) + .unwrap(), + ExportsResolution::Path(PathBuf::from("/foo/node/foo.js")) + ); + } + + #[test] + fn aliases() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + alias: indexmap! { + "./foo.js".into() => AliasValue::Specifier("./foo-alias.js".into()), + "bar".into() => AliasValue::Specifier("./bar-alias.js".into()), + "lodash".into() => AliasValue::Specifier("my-lodash".into()), + "lodash/clone".into() => AliasValue::Specifier("./clone.js".into()), + "test".into() => AliasValue::Specifier("./test".into()), + "foo/*".into() => AliasValue::Specifier("bar/$1".into()), + "./foo/src/**".into() => AliasValue::Specifier("./foo/lib/$1".into()), + "/foo/src/**".into() => AliasValue::Specifier("/foo/lib/$1".into()), + "~/foo/src/**".into() => AliasValue::Specifier("~/foo/lib/$1".into()), + }, + ..PackageJson::default() + }; + + assert_eq!( + pkg.resolve_aliases(&"./foo.js".into(), Fields::ALIAS), + Some(Cow::Owned(AliasValue::Specifier("./foo-alias.js".into()))) + ); + assert_eq!( + pkg.resolve_aliases(&"bar".into(), Fields::ALIAS), + Some(Cow::Owned(AliasValue::Specifier("./bar-alias.js".into()))) + ); + assert_eq!( + pkg.resolve_aliases(&"lodash".into(), Fields::ALIAS), + Some(Cow::Owned(AliasValue::Specifier("my-lodash".into()))) + ); + assert_eq!( + pkg.resolve_aliases(&"lodash/foo".into(), Fields::ALIAS), + Some(Cow::Owned(AliasValue::Specifier("my-lodash/foo".into()))) + ); + assert_eq!( + pkg.resolve_aliases(&"lodash/clone".into(), Fields::ALIAS), + Some(Cow::Owned(AliasValue::Specifier("./clone.js".into()))) + ); + assert_eq!( + pkg.resolve_aliases(&"test".into(), Fields::ALIAS), + Some(Cow::Owned(AliasValue::Specifier("./test".into()))) + ); + assert_eq!( + pkg.resolve_aliases(&"test/foo".into(), Fields::ALIAS), + Some(Cow::Owned(AliasValue::Specifier("./test/foo".into()))) + ); + assert_eq!( + pkg.resolve_aliases(&"foo/hi".into(), Fields::ALIAS), + Some(Cow::Owned(AliasValue::Specifier("bar/hi".into()))) + ); + assert_eq!( + pkg.resolve_aliases(&"./foo/src/a/b".into(), Fields::ALIAS), + Some(Cow::Owned(AliasValue::Specifier("./foo/lib/a/b".into()))) + ); + assert_eq!( + pkg.resolve_aliases(&"/foo/src/a/b".into(), Fields::ALIAS), + Some(Cow::Owned(AliasValue::Specifier("/foo/lib/a/b".into()))) + ); + assert_eq!( + pkg.resolve_aliases(&"~/foo/src/a/b".into(), Fields::ALIAS), + Some(Cow::Owned(AliasValue::Specifier("~/foo/lib/a/b".into()))) + ); + } + + #[test] + fn test_replace_captures() { + assert_eq!( + replace_captures("test/$1/$2", "foo/bar/baz", &vec![4..7, 8..11]), + Cow::Borrowed("test/bar/baz") + ); + assert_eq!( + replace_captures("test/$1/$2", "foo/bar/baz", &vec![4..7]), + Cow::Borrowed("test/bar/$2") + ); + assert_eq!( + replace_captures("test/$1/$2/$3", "foo/bar/baz", &vec![4..7, 8..11]), + Cow::Borrowed("test/bar/baz/$3") + ); + assert_eq!( + replace_captures("test/$1/$2/$", "foo/bar/baz", &vec![4..7, 8..11]), + Cow::Borrowed("test/bar/baz/$") + ); + assert_eq!( + replace_captures("te$st/$1/$2", "foo/bar/baz", &vec![4..7, 8..11]), + Cow::Borrowed("te$st/bar/baz") + ); + } + + #[test] + fn side_effects_none() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + ..PackageJson::default() + }; + + assert!(pkg.has_side_effects(Path::new("/foo/index.js"))); + assert!(pkg.has_side_effects(Path::new("/foo/bar/index.js"))); + assert!(pkg.has_side_effects(Path::new("/index.js"))); + } + + #[test] + fn side_effects_bool() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + side_effects: SideEffects::Boolean(false), + ..PackageJson::default() + }; + + assert!(!pkg.has_side_effects(Path::new("/foo/index.js"))); + assert!(!pkg.has_side_effects(Path::new("/foo/bar/index.js"))); + assert!(pkg.has_side_effects(Path::new("/index.js"))); + + let pkg = PackageJson { + side_effects: SideEffects::Boolean(true), + ..pkg + }; + + assert!(pkg.has_side_effects(Path::new("/foo/index.js"))); + assert!(pkg.has_side_effects(Path::new("/foo/bar/index.js"))); + assert!(pkg.has_side_effects(Path::new("/index.js"))); + } + + #[test] + fn side_effects_glob() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + side_effects: SideEffects::String("*.css"), + ..PackageJson::default() + }; + + assert!(pkg.has_side_effects(Path::new("/foo/a.css"))); + assert!(pkg.has_side_effects(Path::new("/foo/bar/baz.css"))); + assert!(pkg.has_side_effects(Path::new("/foo/bar/x/baz.css"))); + assert!(!pkg.has_side_effects(Path::new("/foo/a.js"))); + assert!(!pkg.has_side_effects(Path::new("/foo/bar/baz.js"))); + assert!(pkg.has_side_effects(Path::new("/index.js"))); + + let pkg = PackageJson { + side_effects: SideEffects::String("bar/*.css"), + ..pkg + }; + + assert!(!pkg.has_side_effects(Path::new("/foo/a.css"))); + assert!(pkg.has_side_effects(Path::new("/foo/bar/baz.css"))); + assert!(!pkg.has_side_effects(Path::new("/foo/bar/x/baz.css"))); + assert!(!pkg.has_side_effects(Path::new("/foo/a.js"))); + assert!(!pkg.has_side_effects(Path::new("/foo/bar/baz.js"))); + assert!(pkg.has_side_effects(Path::new("/index.js"))); + + let pkg = PackageJson { + side_effects: SideEffects::String("./bar/*.css"), + ..pkg + }; + + assert!(!pkg.has_side_effects(Path::new("/foo/a.css"))); + assert!(pkg.has_side_effects(Path::new("/foo/bar/baz.css"))); + assert!(!pkg.has_side_effects(Path::new("/foo/bar/x/baz.css"))); + assert!(!pkg.has_side_effects(Path::new("/foo/a.js"))); + assert!(!pkg.has_side_effects(Path::new("/foo/bar/baz.js"))); + assert!(pkg.has_side_effects(Path::new("/index.js"))); + } + + #[test] + fn side_effects_array() { + let pkg = PackageJson { + path: "/foo/package.json".into(), + name: "foobar", + side_effects: SideEffects::Array(vec!["*.css", "*.html"]), + ..PackageJson::default() + }; + + assert!(pkg.has_side_effects(Path::new("/foo/a.css"))); + assert!(pkg.has_side_effects(Path::new("/foo/bar/baz.css"))); + assert!(pkg.has_side_effects(Path::new("/foo/bar/x/baz.css"))); + assert!(pkg.has_side_effects(Path::new("/foo/a.html"))); + assert!(pkg.has_side_effects(Path::new("/foo/bar/baz.html"))); + assert!(pkg.has_side_effects(Path::new("/foo/bar/x/baz.html"))); + assert!(!pkg.has_side_effects(Path::new("/foo/a.js"))); + assert!(!pkg.has_side_effects(Path::new("/foo/bar/baz.js"))); + assert!(pkg.has_side_effects(Path::new("/index.js"))); + } +} diff --git a/packages/utils/node-resolver-rs/src/path.rs b/packages/utils/node-resolver-rs/src/path.rs new file mode 100644 index 00000000000..0676aea1056 --- /dev/null +++ b/packages/utils/node-resolver-rs/src/path.rs @@ -0,0 +1,211 @@ +use dashmap::DashMap; +use std::collections::VecDeque; +use std::path::{Component, Path, PathBuf}; + +pub fn normalize_path(path: &Path) -> PathBuf { + // Normalize path components to resolve ".." and "." segments. + // /~https://github.com/rust-lang/cargo/blob/fede83ccf973457de319ba6fa0e36ead454d2e20/src/cargo/util/paths.rs#L61 + let mut components = path.components().peekable(); + let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() { + components.next(); + PathBuf::from(c.as_os_str()) + } else { + PathBuf::new() + }; + + for component in components { + match component { + Component::Prefix(..) => unreachable!(), + Component::RootDir => { + ret.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + ret.pop(); + } + Component::Normal(c) => { + ret.push(c); + } + } + } + + ret +} + +pub fn resolve_path, B: AsRef>(base: A, subpath: B) -> PathBuf { + let subpath = subpath.as_ref(); + let mut components = subpath.components().peekable(); + if subpath.is_absolute() || matches!(components.peek(), Some(Component::Prefix(..))) { + return subpath.to_path_buf(); + } + + let mut ret = base.as_ref().to_path_buf(); + ret.pop(); + for component in subpath.components() { + match component { + Component::Prefix(..) | Component::RootDir => unreachable!(), + Component::CurDir => {} + Component::ParentDir => { + ret.pop(); + } + Component::Normal(c) => { + ret.push(c); + } + } + } + + ret +} + +// A reimplementation of std::fs::canonicalize with intermediary caching. +pub fn canonicalize( + path: &Path, + cache: &DashMap>, +) -> std::io::Result { + let mut ret = PathBuf::new(); + let mut seen_links = 0; + let mut queue = VecDeque::new(); + + queue.push_back(path); + + while let Some(cur_path) = queue.pop_front() { + let mut components = cur_path.components(); + for component in &mut components { + match component { + Component::Prefix(c) => ret.push(c.as_os_str()), + Component::RootDir => { + ret.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + ret.pop(); + } + Component::Normal(c) => { + ret.push(c); + + // First, check the cache for the path up to this point. + let link: &Path = if let Some(cached) = cache.get(&ret) { + if let Some(link) = &*cached { + // SAFETY: Keys are never removed from the cache or mutated + // and PathBuf has a stable address for path data even when moved. + unsafe { &*(link.as_path() as *const _) } + } else { + continue; + } + } else { + let stat = std::fs::symlink_metadata(&ret)?; + if !stat.is_symlink() { + cache.insert(ret.clone(), None); + continue; + } + + let link = std::fs::read_link(&ret)?; + let ptr = unsafe { &*(link.as_path() as *const _) }; + cache.insert(ret.clone(), Some(link)); + ptr + }; + + seen_links += 1; + if seen_links > 32 { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Too many symlinks", + )); + } + + // If the link is absolute, replace the result path + // with it, otherwise remove the last segment and + // resolve the link components next. + if link.is_absolute() { + ret = PathBuf::new(); + } else { + ret.pop(); + } + + let remaining = components.as_path(); + if !remaining.as_os_str().is_empty() { + queue.push_front(remaining); + } + queue.push_front(link); + break; + } + } + } + } + + Ok(ret) +} + +#[cfg(test)] +mod test { + use super::*; + use assert_fs::prelude::*; + + #[test] + fn test_canonicalize() -> Result<(), Box> { + #[cfg(windows)] + if !is_elevated::is_elevated() { + println!("skipping symlink tests due to missing permissions"); + return Ok(()); + } + + let dir = assert_fs::TempDir::new()?; + dir.child("foo/bar.js").write_str("")?; + dir.child("root.js").write_str("")?; + + dir + .child("symlink") + .symlink_to_file(Path::new("foo").join("bar.js"))?; + dir + .child("foo/symlink") + .symlink_to_file(Path::new("..").join("root.js"))?; + dir + .child("absolute") + .symlink_to_file(dir.child("root.js").path())?; + dir + .child("recursive") + .symlink_to_file(Path::new("foo").join("symlink"))?; + dir.child("cycle").symlink_to_file("cycle1")?; + dir.child("cycle1").symlink_to_file("cycle")?; + dir.child("a/b/c").create_dir_all()?; + dir.child("a/b/e").symlink_to_file("..")?; + dir.child("a/d").symlink_to_file("..")?; + dir.child("a/b/c/x.txt").write_str("")?; + dir + .child("a/link") + .symlink_to_file(dir.child("a/b").path())?; + + let cache = DashMap::new(); + + assert_eq!( + canonicalize(dir.child("symlink").path(), &cache)?, + canonicalize(dir.child("foo/bar.js").path(), &cache)? + ); + assert_eq!( + canonicalize(dir.child("foo/symlink").path(), &cache)?, + canonicalize(dir.child("root.js").path(), &cache)? + ); + assert_eq!( + canonicalize(dir.child("absolute").path(), &cache)?, + canonicalize(dir.child("root.js").path(), &cache)? + ); + assert_eq!( + canonicalize(dir.child("recursive").path(), &cache)?, + canonicalize(dir.child("root.js").path(), &cache)? + ); + assert!(matches!( + canonicalize(dir.child("cycle").path(), &cache), + Err(_) + )); + assert_eq!( + canonicalize(dir.child("a/b/e/d/a/b/e/d/a").path(), &cache)?, + canonicalize(dir.child("a").path(), &cache)? + ); + assert_eq!( + canonicalize(dir.child("a/link/c/x.txt").path(), &cache)?, + canonicalize(dir.child("a/b/c/x.txt").path(), &cache)? + ); + + Ok(()) + } +} diff --git a/packages/utils/node-resolver-rs/src/specifier.rs b/packages/utils/node-resolver-rs/src/specifier.rs new file mode 100644 index 00000000000..d19c5ad3f50 --- /dev/null +++ b/packages/utils/node-resolver-rs/src/specifier.rs @@ -0,0 +1,303 @@ +use crate::{builtins::BUILTINS, Flags}; +use percent_encoding::percent_decode_str; +use std::{ + borrow::Cow, + path::{is_separator, Path, PathBuf}, +}; +use url::Url; + +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum SpecifierType { + Esm, + Cjs, + Url, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(tag = "kind", content = "value")] +pub enum SpecifierError { + EmptySpecifier, + InvalidPackageSpecifier, + #[serde(serialize_with = "serialize_url_error")] + UrlError(url::ParseError), + InvalidFileUrl, +} + +impl From for SpecifierError { + fn from(value: url::ParseError) -> Self { + SpecifierError::UrlError(value) + } +} + +fn serialize_url_error(value: &url::ParseError, serializer: S) -> Result +where + S: serde::Serializer, +{ + use serde::Serialize; + value.to_string().serialize(serializer) +} + +#[derive(PartialEq, Eq, Hash, Clone, Debug)] +pub enum Specifier<'a> { + Relative(Cow<'a, Path>), + Absolute(Cow<'a, Path>), + Tilde(Cow<'a, Path>), + Hash(Cow<'a, str>), + Package(Cow<'a, str>, Cow<'a, str>), + Builtin(Cow<'a, str>), + Url(&'a str), +} + +impl<'a> Specifier<'a> { + pub fn parse( + specifier: &'a str, + specifier_type: SpecifierType, + flags: Flags, + ) -> Result<(Specifier<'a>, Option<&'a str>), SpecifierError> { + if specifier.is_empty() { + return Err(SpecifierError::EmptySpecifier); + } + + Ok(match specifier.as_bytes()[0] { + b'.' => { + let specifier = if specifier.starts_with("./") { + &specifier[2..] + } else { + specifier + }; + let (path, query) = decode_path(specifier, specifier_type); + (Specifier::Relative(path), query) + } + b'~' => { + let mut specifier = &specifier[1..]; + if !specifier.is_empty() && is_separator(specifier.as_bytes()[0] as char) { + specifier = &specifier[1..]; + } + let (path, query) = decode_path(specifier, specifier_type); + (Specifier::Tilde(path), query) + } + b'/' => { + if specifier.starts_with("//") && specifier_type == SpecifierType::Url { + // A protocol-relative URL, e.g `url('//example.com/foo.png')`. + (Specifier::Url(specifier), None) + } else { + let (path, query) = decode_path(specifier, specifier_type); + (Specifier::Absolute(path), query) + } + } + b'#' => (Specifier::Hash(Cow::Borrowed(&specifier[1..])), None), + _ => { + // Bare specifier. + match specifier_type { + SpecifierType::Url | SpecifierType::Esm => { + if BUILTINS.contains(&specifier.as_ref()) { + return Ok((Specifier::Builtin(Cow::Borrowed(specifier)), None)); + } + + // Check if there is a scheme first. + if let Ok((scheme, rest)) = parse_scheme(specifier) { + let (path, rest) = parse_path(rest); + let (query, _) = parse_query(rest); + match scheme.as_ref() { + "npm" if flags.contains(Flags::NPM_SCHEME) => ( + parse_package(percent_decode_str(path).decode_utf8_lossy())?, + query, + ), + "node" => { + // Node does not URL decode or support query params here. + // See /~https://github.com/nodejs/node/issues/39710. + (Specifier::Builtin(Cow::Borrowed(path)), None) + } + "file" => { + // Fully parsing file urls is somewhat complex, so use the url crate for this. + let url = Url::parse(specifier)?; + ( + Specifier::Absolute(Cow::Owned( + url + .to_file_path() + .map_err(|_| SpecifierError::InvalidFileUrl)?, + )), + query, + ) + } + _ => (Specifier::Url(specifier), None), + } + } else { + // If not, then parse as an npm package if this is an ESM specifier, + // otherwise treat this as a relative path. + let (path, rest) = parse_path(specifier); + if specifier_type == SpecifierType::Esm { + let (query, _) = parse_query(rest); + ( + parse_package(percent_decode_str(path).decode_utf8_lossy())?, + query, + ) + } else { + let (path, query) = decode_path(specifier, specifier_type); + (Specifier::Relative(path), query) + } + } + } + SpecifierType::Cjs => { + if BUILTINS.contains(&specifier.as_ref()) { + (Specifier::Builtin(Cow::Borrowed(specifier)), None) + } else { + #[cfg(windows)] + if !flags.contains(Flags::ABSOLUTE_SPECIFIERS) { + let path = Path::new(specifier); + if path.is_absolute() { + return Ok((Specifier::Absolute(Cow::Borrowed(path)), None)); + } + } + + (parse_package(Cow::Borrowed(specifier))?, None) + } + } + } + } + }) + } +} + +// https://url.spec.whatwg.org/#scheme-state +// /~https://github.com/servo/rust-url/blob/1c1e406874b3d2aa6f36c5d2f3a5c2ea74af9efb/url/src/parser.rs#L387 +pub fn parse_scheme<'a>(input: &'a str) -> Result<(Cow<'a, str>, &'a str), ()> { + if input.is_empty() || !input.starts_with(ascii_alpha) { + return Err(()); + } + let mut i = 0; + let mut is_lowercase = true; + for c in input.chars() { + match c { + 'A'..='Z' => { + is_lowercase = false; + } + 'a'..='z' | '0'..='9' | '+' | '-' | '.' => {} + ':' => { + let scheme = &input[0..i]; + let rest = &input[i + 1..]; + return Ok(if is_lowercase { + (Cow::Borrowed(scheme), rest) + } else { + (Cow::Owned(scheme.to_ascii_lowercase()), rest) + }); + } + _ => { + return Err(()); + } + } + i += 1; + } + + // EOF before ':' + Err(()) +} + +// https://url.spec.whatwg.org/#path-state +fn parse_path<'a>(input: &'a str) -> (&'a str, &'a str) { + // We don't really want to normalize the path (e.g. replacing ".." and "." segments). + // That is done later. For now, we just need to find the end of the path. + if let Some(pos) = input.chars().position(|c| c == '?' || c == '#') { + (&input[0..pos], &input[pos..]) + } else { + (input, "") + } +} + +// https://url.spec.whatwg.org/#query-state +fn parse_query<'a>(input: &'a str) -> (Option<&'a str>, &'a str) { + if !input.is_empty() && input.as_bytes()[0] == b'?' { + if let Some(pos) = input.chars().position(|c| c == '#') { + (Some(&input[0..pos]), &input[pos..]) + } else { + (Some(input), "") + } + } else { + (None, input) + } +} + +/// https://url.spec.whatwg.org/#ascii-alpha +#[inline] +fn ascii_alpha(ch: char) -> bool { + matches!(ch, 'a'..='z' | 'A'..='Z') +} + +fn parse_package<'a>(specifier: Cow<'a, str>) -> Result { + match specifier { + Cow::Borrowed(specifier) => { + let (module, subpath) = parse_package_specifier(specifier)?; + Ok(Specifier::Package( + Cow::Borrowed(module), + Cow::Borrowed(subpath), + )) + } + Cow::Owned(specifier) => { + let (module, subpath) = parse_package_specifier(&specifier)?; + Ok(Specifier::Package( + Cow::Owned(module.to_owned()), + Cow::Owned(subpath.to_owned()), + )) + } + } +} + +pub fn parse_package_specifier(specifier: &str) -> Result<(&str, &str), SpecifierError> { + let idx = specifier.chars().position(|p| p == '/'); + if specifier.starts_with('@') { + let idx = idx.ok_or(SpecifierError::InvalidPackageSpecifier)?; + if let Some(next) = &specifier[idx + 1..].chars().position(|p| p == '/') { + Ok(( + &specifier[0..idx + 1 + *next], + &specifier[idx + *next + 2..], + )) + } else { + Ok((&specifier[..], "")) + } + } else if let Some(idx) = idx { + Ok((&specifier[0..idx], &specifier[idx + 1..])) + } else { + Ok((&specifier[..], "")) + } +} + +pub fn decode_path<'a>( + specifier: &'a str, + specifier_type: SpecifierType, +) -> (Cow<'a, Path>, Option<&'a str>) { + match specifier_type { + SpecifierType::Url | SpecifierType::Esm => { + let (path, rest) = parse_path(specifier); + let (query, _) = parse_query(rest); + let path = match percent_decode_str(path).decode_utf8_lossy() { + Cow::Borrowed(v) => Cow::Borrowed(Path::new(v)), + Cow::Owned(v) => Cow::Owned(PathBuf::from(v)), + }; + (path, query) + } + SpecifierType::Cjs => (Cow::Borrowed(Path::new(specifier)), None), + } +} + +impl<'a> From<&'a str> for Specifier<'a> { + fn from(specifier: &'a str) -> Self { + Specifier::parse(specifier, SpecifierType::Cjs, Flags::empty()) + .unwrap() + .0 + } +} + +impl<'a, 'de: 'a> serde::Deserialize<'de> for Specifier<'a> { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::Deserialize; + let s: &'de str = Deserialize::deserialize(deserializer)?; + // Specifiers are only deserialized as part of the "alias" and "browser" fields, + // so we assume CJS specifiers in Parcel mode. + Specifier::parse(s, SpecifierType::Cjs, Flags::empty()) + .map(|s| s.0) + .map_err(|_| serde::de::Error::custom("Invalid specifier")) + } +} diff --git a/packages/utils/node-resolver-rs/src/tsconfig.rs b/packages/utils/node-resolver-rs/src/tsconfig.rs new file mode 100644 index 00000000000..537a134a463 --- /dev/null +++ b/packages/utils/node-resolver-rs/src/tsconfig.rs @@ -0,0 +1,314 @@ +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; + +use indexmap::IndexMap; +use itertools::Either; +use json_comments::strip_comments_in_place; + +use crate::{path::resolve_path, specifier::Specifier}; + +#[derive(serde::Deserialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct TsConfig<'a> { + #[serde(skip)] + pub path: PathBuf, + base_url: Option>, + #[serde(borrow)] + paths: Option, Vec<&'a str>>>, + #[serde(skip)] + paths_base: PathBuf, + pub module_suffixes: Option>, + // rootDirs?? +} + +fn deserialize_extends<'a, 'de: 'a, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum StringOrArray<'a> { + #[serde(borrow)] + String(Specifier<'a>), + Array(Vec>), + } + + Ok(match StringOrArray::deserialize(deserializer)? { + StringOrArray::String(s) => vec![s], + StringOrArray::Array(a) => a, + }) +} + +#[derive(serde::Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TsConfigWrapper<'a> { + #[serde(borrow, default, deserialize_with = "deserialize_extends")] + pub extends: Vec>, + #[serde(default)] + pub compiler_options: TsConfig<'a>, +} + +impl<'a> TsConfig<'a> { + pub fn parse(path: PathBuf, data: &'a mut str) -> serde_json::Result> { + let _ = strip_comments_in_place(data, Default::default()); + let mut wrapper: TsConfigWrapper = serde_json::from_str(data)?; + wrapper.compiler_options.path = path; + wrapper.compiler_options.validate(); + Ok(wrapper) + } + + fn validate(&mut self) { + if let Some(base_url) = &mut self.base_url { + *base_url = Cow::Owned(resolve_path(&self.path, &base_url)); + } + + if self.paths.is_some() { + self.paths_base = if let Some(base_url) = &self.base_url { + base_url.as_ref().to_owned() + } else { + self.path.parent().unwrap().to_owned() + }; + } + } + + pub fn extend(&mut self, extended: &TsConfig<'a>) { + if self.base_url.is_none() { + self.base_url = extended.base_url.clone(); + } + + if self.paths.is_none() { + self.paths_base = extended.paths_base.clone(); + self.paths = extended.paths.clone(); + } + + if self.module_suffixes.is_none() { + self.module_suffixes = extended.module_suffixes.clone(); + } + } + + pub fn paths(&'a self, specifier: &'a Specifier) -> impl Iterator + 'a { + if !matches!(specifier, Specifier::Package(..)) { + return Either::Right(Either::Right(std::iter::empty())); + } + + // If there is a base url setting, resolve it relative to the tsconfig.json file. + // Otherwise, the base for paths is implicitly the directory containing the tsconfig. + let base_url_iter = if let Some(base_url) = &self.base_url { + Either::Left(base_url_iter(base_url, specifier)) + } else { + Either::Right(std::iter::empty()) + }; + + if let Some(paths) = &self.paths { + // Check exact match first. + if let Some(paths) = paths.get(specifier) { + return Either::Left(join_paths(&self.paths_base, paths, None).chain(base_url_iter)); + } + + // Check patterns + let mut longest_prefix_length = 0; + let mut longest_suffix_length = 0; + let mut best_key = None; + let full_specifier = if let Specifier::Package(module, subpath) = specifier { + concat_specifier(module, subpath) + } else { + unreachable!() + }; + + for key in paths.keys() { + if let Specifier::Package(module, subpath) = key { + let path = concat_specifier(module.as_ref(), subpath.as_ref()); + if let Some((prefix, suffix)) = path.split_once('*') { + if best_key.is_none() + || prefix.len() > longest_prefix_length + && full_specifier.starts_with(prefix) + && full_specifier.ends_with(suffix) + { + longest_prefix_length = prefix.len(); + longest_suffix_length = suffix.len(); + best_key = Some(key); + } + } + } + } + + if let Some(key) = best_key { + let paths = paths.get(key).unwrap(); + return Either::Left( + join_paths( + &self.paths_base, + paths, + Some((full_specifier, longest_prefix_length, longest_suffix_length)), + ) + .chain(base_url_iter), + ); + } + } + + // If no paths were found, try relative to the base url. + Either::Right(base_url_iter) + } +} + +fn concat_specifier<'a>(module: &'a str, subpath: &'a str) -> Cow<'a, str> { + if subpath.is_empty() { + Cow::Borrowed(module) + } else { + Cow::Owned(format!("{}/{}", module, subpath)) + } +} + +fn join_paths<'a>( + base_url: &'a Path, + paths: &'a Vec<&'a str>, + replacement: Option<(Cow<'a, str>, usize, usize)>, +) -> impl Iterator + 'a { + paths + .iter() + .filter(|p| !p.ends_with(".d.ts")) + .map(move |path| { + if let Some((replacement, start, end)) = &replacement { + let path = path.replace('*', &replacement[*start..replacement.len() - *end]); + base_url.join(&path) + } else { + base_url.join(&path) + } + }) +} + +fn base_url_iter<'a>( + base_url: &'a Path, + specifier: &'a Specifier, +) -> impl Iterator + 'a { + std::iter::once_with(move || { + let mut path = base_url.to_owned(); + if let Specifier::Package(module, subpath) = specifier { + path.push(module.as_ref()); + path.push(subpath.as_ref()); + } + path + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use indexmap::indexmap; + + #[test] + fn test_paths() { + let mut tsconfig = TsConfig { + path: "/foo/tsconfig.json".into(), + paths: Some(indexmap! { + "jquery".into() => vec!["node_modules/jquery/dist/jquery".into()], + "*".into() => vec!["generated/*".into()], + "bar/*".into() => vec!["test/*".into()], + "bar/baz/*".into() => vec!["baz/*".into(), "yo/*".into()], + "@/components/*".into() => vec!["components/*".into()], + }), + ..Default::default() + }; + tsconfig.validate(); + + let test = |specifier: &str| tsconfig.paths(&specifier.into()).collect::>(); + + assert_eq!( + test("jquery"), + vec![PathBuf::from("/foo/node_modules/jquery/dist/jquery")] + ); + assert_eq!(test("test"), vec![PathBuf::from("/foo/generated/test")]); + assert_eq!( + test("test/hello"), + vec![PathBuf::from("/foo/generated/test/hello")] + ); + assert_eq!(test("bar/hi"), vec![PathBuf::from("/foo/test/hi")]); + assert_eq!( + test("bar/baz/hi"), + vec![PathBuf::from("/foo/baz/hi"), PathBuf::from("/foo/yo/hi")] + ); + assert_eq!( + test("@/components/button"), + vec![PathBuf::from("/foo/components/button")] + ); + assert_eq!(test("./jquery"), Vec::::new()); + } + + #[test] + fn test_base_url() { + let mut tsconfig = TsConfig { + path: "/foo/tsconfig.json".into(), + base_url: Some(Path::new("src").into()), + ..Default::default() + }; + tsconfig.validate(); + + let test = |specifier: &str| tsconfig.paths(&specifier.into()).collect::>(); + + assert_eq!(test("foo"), vec![PathBuf::from("/foo/src/foo")]); + assert_eq!( + test("components/button"), + vec![PathBuf::from("/foo/src/components/button")] + ); + assert_eq!(test("./jquery"), Vec::::new()); + } + + #[test] + fn test_paths_and_base_url() { + let mut tsconfig = TsConfig { + path: "/foo/tsconfig.json".into(), + base_url: Some(Path::new("src").into()), + paths: Some(indexmap! { + "*".into() => vec!["generated/*".into()], + "bar/*".into() => vec!["test/*".into()], + "bar/baz/*".into() => vec!["baz/*".into(), "yo/*".into()], + "@/components/*".into() => vec!["components/*".into()], + }), + ..Default::default() + }; + tsconfig.validate(); + + let test = |specifier: &str| tsconfig.paths(&specifier.into()).collect::>(); + + assert_eq!( + test("test"), + vec![ + PathBuf::from("/foo/src/generated/test"), + PathBuf::from("/foo/src/test") + ] + ); + assert_eq!( + test("test/hello"), + vec![ + PathBuf::from("/foo/src/generated/test/hello"), + PathBuf::from("/foo/src/test/hello") + ] + ); + assert_eq!( + test("bar/hi"), + vec![ + PathBuf::from("/foo/src/test/hi"), + PathBuf::from("/foo/src/bar/hi") + ] + ); + assert_eq!( + test("bar/baz/hi"), + vec![ + PathBuf::from("/foo/src/baz/hi"), + PathBuf::from("/foo/src/yo/hi"), + PathBuf::from("/foo/src/bar/baz/hi") + ] + ); + assert_eq!( + test("@/components/button"), + vec![ + PathBuf::from("/foo/src/components/button"), + PathBuf::from("/foo/src/@/components/button") + ] + ); + assert_eq!(test("./jquery"), Vec::::new()); + } +}