Skip to content

Commit 99f3e13

Browse files
committed
feat: add Yarn PnP support
1 parent c1216de commit 99f3e13

16 files changed

+447
-23
lines changed

.github/workflows/ci.yml

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
branches:
66
- main
77
- release-*
8+
- '*/pnp-*'
89
pull_request:
910
branches:
1011
- main

src/compiler/moduleNameResolver.ts

+78-1
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ import {
110110
versionMajorMinor,
111111
VersionRange,
112112
} from "./_namespaces/ts.js";
113+
import { getPnpTypeRoots } from "./pnp.js";
114+
import { getPnpApi } from "./pnpapi.js";
113115

114116
/** @internal */
115117
export function trace(host: ModuleResolutionHost, message: DiagnosticMessage, ...args: any[]): void {
@@ -495,7 +497,7 @@ export function getEffectiveTypeRoots(options: CompilerOptions, host: GetEffecti
495497
* Returns the path to every node_modules/@types directory from some ancestor directory.
496498
* Returns undefined if there are none.
497499
*/
498-
function getDefaultTypeRoots(currentDirectory: string): string[] | undefined {
500+
function getNodeModulesTypeRoots(currentDirectory: string) {
499501
let typeRoots: string[] | undefined;
500502
forEachAncestorDirectory(normalizePath(currentDirectory), directory => {
501503
const atTypes = combinePaths(directory, nodeModulesAtTypes);
@@ -510,6 +512,18 @@ function arePathsEqual(path1: string, path2: string, host: ModuleResolutionHost)
510512
return comparePaths(path1, path2, !useCaseSensitiveFileNames) === Comparison.EqualTo;
511513
}
512514

515+
function getDefaultTypeRoots(currentDirectory: string): string[] | undefined {
516+
const nmTypes = getNodeModulesTypeRoots(currentDirectory);
517+
const pnpTypes = getPnpTypeRoots(currentDirectory);
518+
519+
if (nmTypes?.length) {
520+
return [...nmTypes, ...pnpTypes];
521+
}
522+
else if (pnpTypes.length) {
523+
return pnpTypes;
524+
}
525+
}
526+
513527
function getOriginalAndResolvedFileName(fileName: string, host: ModuleResolutionHost, traceEnabled: boolean) {
514528
const resolvedFileName = realPath(fileName, host, traceEnabled);
515529
const pathsAreEqual = arePathsEqual(fileName, resolvedFileName, host);
@@ -790,6 +804,18 @@ export function resolvePackageNameToPackageJson(
790804
): PackageJsonInfo | undefined {
791805
const moduleResolutionState = getTemporaryModuleResolutionState(cache?.getPackageJsonInfoCache(), host, options);
792806

807+
const pnpapi = getPnpApi(containingDirectory);
808+
if (pnpapi) {
809+
try {
810+
const resolution = pnpapi.resolveToUnqualified(packageName, `${containingDirectory}/`, { considerBuiltins: false });
811+
const candidate = normalizeSlashes(resolution).replace(/\/$/, "");
812+
return getPackageJsonInfo(candidate, /*onlyRecordFailures*/ false, moduleResolutionState);
813+
}
814+
catch {
815+
return;
816+
}
817+
}
818+
793819
return forEachAncestorDirectoryStoppingAtGlobalCache(host, containingDirectory, ancestorDirectory => {
794820
if (getBaseFileName(ancestorDirectory) !== "node_modules") {
795821
const nodeModulesFolder = combinePaths(ancestorDirectory, "node_modules");
@@ -3013,6 +3039,15 @@ function loadModuleFromNearestNodeModulesDirectoryWorker(extensions: Extensions,
30133039
}
30143040

30153041
function lookup(extensions: Extensions) {
3042+
const issuer = normalizeSlashes(directory);
3043+
if (getPnpApi(issuer)) {
3044+
const resolutionFromCache = tryFindNonRelativeModuleNameInCache(cache, moduleName, mode, issuer, redirectedReference, state);
3045+
if (resolutionFromCache) {
3046+
return resolutionFromCache;
3047+
}
3048+
return toSearchResult(loadModuleFromImmediateNodeModulesDirectoryPnP(extensions, moduleName, issuer, state, typesScopeOnly, cache, redirectedReference));
3049+
}
3050+
30163051
return forEachAncestorDirectoryStoppingAtGlobalCache(
30173052
state.host,
30183053
normalizeSlashes(directory),
@@ -3075,11 +3110,34 @@ function loadModuleFromImmediateNodeModulesDirectory(extensions: Extensions, mod
30753110
}
30763111
}
30773112

3113+
function loadModuleFromImmediateNodeModulesDirectoryPnP(extensions: Extensions, moduleName: string, directory: string, state: ModuleResolutionState, typesScopeOnly: boolean, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined): Resolved | undefined {
3114+
const issuer = normalizeSlashes(directory);
3115+
3116+
if (!typesScopeOnly) {
3117+
const packageResult = tryLoadModuleUsingPnpResolution(extensions, moduleName, issuer, state, cache, redirectedReference);
3118+
if (packageResult) {
3119+
return packageResult;
3120+
}
3121+
}
3122+
3123+
if (extensions & Extensions.Declaration) {
3124+
return tryLoadModuleUsingPnpResolution(Extensions.Declaration, `@types/${mangleScopedPackageNameWithTrace(moduleName, state)}`, issuer, state, cache, redirectedReference);
3125+
}
3126+
}
3127+
30783128
function loadModuleFromSpecificNodeModulesDirectory(extensions: Extensions, moduleName: string, nodeModulesDirectory: string, nodeModulesDirectoryExists: boolean, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined): Resolved | undefined {
30793129
const candidate = normalizePath(combinePaths(nodeModulesDirectory, moduleName));
30803130
const { packageName, rest } = parsePackageName(moduleName);
30813131
const packageDirectory = combinePaths(nodeModulesDirectory, packageName);
3132+
return loadModuleFromSpecificNodeModulesDirectoryImpl(extensions, nodeModulesDirectoryExists, state, cache, redirectedReference, candidate, rest, packageDirectory);
3133+
}
3134+
3135+
function loadModuleFromPnpResolution(extensions: Extensions, packageDirectory: string, rest: string, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined): Resolved | undefined {
3136+
const candidate = normalizePath(combinePaths(packageDirectory, rest));
3137+
return loadModuleFromSpecificNodeModulesDirectoryImpl(extensions, /*nodeModulesDirectoryExists*/ true, state, cache, redirectedReference, candidate, rest, packageDirectory);
3138+
}
30823139

3140+
function loadModuleFromSpecificNodeModulesDirectoryImpl(extensions: Extensions, nodeModulesDirectoryExists: boolean, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined, candidate: string, rest: string, packageDirectory: string): Resolved | undefined {
30833141
let rootPackageInfo: PackageJsonInfo | undefined;
30843142
// First look for a nested package.json, as in `node_modules/foo/bar/package.json`.
30853143
let packageInfo = getPackageJsonInfo(candidate, !nodeModulesDirectoryExists, state);
@@ -3415,3 +3473,22 @@ function useCaseSensitiveFileNames(state: ModuleResolutionState) {
34153473
typeof state.host.useCaseSensitiveFileNames === "boolean" ? state.host.useCaseSensitiveFileNames :
34163474
state.host.useCaseSensitiveFileNames();
34173475
}
3476+
3477+
function loadPnpPackageResolution(packageName: string, containingDirectory: string) {
3478+
try {
3479+
const resolution = getPnpApi(containingDirectory).resolveToUnqualified(packageName, `${containingDirectory}/`, { considerBuiltins: false });
3480+
return normalizeSlashes(resolution).replace(/\/$/, "");
3481+
}
3482+
catch {
3483+
// Nothing to do
3484+
}
3485+
}
3486+
3487+
function tryLoadModuleUsingPnpResolution(extensions: Extensions, moduleName: string, containingDirectory: string, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined) {
3488+
const { packageName, rest } = parsePackageName(moduleName);
3489+
3490+
const packageResolution = loadPnpPackageResolution(packageName, containingDirectory);
3491+
return packageResolution
3492+
? loadModuleFromPnpResolution(extensions, packageResolution, rest, state, cache, redirectedReference)
3493+
: undefined;
3494+
}

src/compiler/moduleSpecifiers.ts

+77-14
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import {
100100
NodeModulePathParts,
101101
normalizePath,
102102
PackageJsonPathFields,
103+
PackagePathParts,
103104
pathContainsNodeModules,
104105
pathIsBareSpecifier,
105106
pathIsRelative,
@@ -129,6 +130,7 @@ import {
129130
TypeChecker,
130131
UserPreferences,
131132
} from "./_namespaces/ts.js";
133+
import { getPnpApi } from "./pnpapi.js";
132134

133135
const stringToRegex = memoizeOne((pattern: string) => {
134136
try {
@@ -833,7 +835,17 @@ function getAllModulePathsWorker(info: Info, importedFileName: string, host: Mod
833835
host,
834836
/*preferSymlinks*/ true,
835837
(path, isRedirect) => {
836-
const isInNodeModules = pathContainsNodeModules(path);
838+
let isInNodeModules = pathContainsNodeModules(path);
839+
840+
const pnpapi = getPnpApi(path);
841+
if (!isInNodeModules && pnpapi) {
842+
const fromLocator = pnpapi.findPackageLocator(info.importingSourceFileName);
843+
const toLocator = pnpapi.findPackageLocator(path);
844+
if (fromLocator && toLocator && fromLocator !== toLocator) {
845+
isInNodeModules = true;
846+
}
847+
}
848+
837849
allFileNames.set(path, { path: info.getCanonicalFileName(path), isRedirect, isInNodeModules });
838850
importedFileFromNodeModules = importedFileFromNodeModules || isInNodeModules;
839851
// don't return value, so we collect everything
@@ -1187,7 +1199,51 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan
11871199
if (!host.fileExists || !host.readFile) {
11881200
return undefined;
11891201
}
1190-
const parts: NodeModulePathParts = getNodeModulePathParts(path)!;
1202+
let parts: NodeModulePathParts | PackagePathParts | undefined = getNodeModulePathParts(path);
1203+
1204+
let pnpPackageName: string | undefined;
1205+
1206+
const pnpApi = getPnpApi(path);
1207+
if (pnpApi) {
1208+
const fromLocator = pnpApi.findPackageLocator(importingSourceFile.fileName);
1209+
const toLocator = pnpApi.findPackageLocator(path);
1210+
1211+
// Don't use the package name when the imported file is inside
1212+
// the source directory (prefer a relative path instead)
1213+
if (fromLocator === toLocator) {
1214+
return undefined;
1215+
}
1216+
1217+
if (fromLocator && toLocator) {
1218+
const fromInfo = pnpApi.getPackageInformation(fromLocator);
1219+
if (toLocator.reference === fromInfo.packageDependencies.get(toLocator.name)) {
1220+
pnpPackageName = toLocator.name;
1221+
}
1222+
else {
1223+
// Aliased dependencies
1224+
for (const [name, reference] of fromInfo.packageDependencies) {
1225+
if (Array.isArray(reference)) {
1226+
if (reference[0] === toLocator.name && reference[1] === toLocator.reference) {
1227+
pnpPackageName = name;
1228+
break;
1229+
}
1230+
}
1231+
}
1232+
}
1233+
1234+
if (!parts) {
1235+
const toInfo = pnpApi.getPackageInformation(toLocator);
1236+
parts = {
1237+
topLevelNodeModulesIndex: undefined,
1238+
topLevelPackageNameIndex: undefined,
1239+
// The last character from packageLocation is the trailing "/", we want to point to it
1240+
packageRootIndex: toInfo.packageLocation.length - 1,
1241+
fileNameIndex: path.lastIndexOf(`/`),
1242+
};
1243+
}
1244+
}
1245+
}
1246+
11911247
if (!parts) {
11921248
return undefined;
11931249
}
@@ -1232,19 +1288,26 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan
12321288
return undefined;
12331289
}
12341290

1235-
const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation && host.getGlobalTypingsCacheLocation();
1236-
// Get a path that's relative to node_modules or the importing file's path
1237-
// if node_modules folder is in this folder or any of its parent folders, no need to keep it.
1238-
const pathToTopLevelNodeModules = getCanonicalFileName(moduleSpecifier.substring(0, parts.topLevelNodeModulesIndex));
1239-
if (!(startsWith(canonicalSourceDirectory, pathToTopLevelNodeModules) || globalTypingsCacheLocation && startsWith(getCanonicalFileName(globalTypingsCacheLocation), pathToTopLevelNodeModules))) {
1240-
return undefined;
1291+
// If PnP is enabled the node_modules entries we'll get will always be relevant even if they
1292+
// are located in a weird path apparently outside of the source directory
1293+
if (typeof process.versions.pnp === "undefined") {
1294+
const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation && host.getGlobalTypingsCacheLocation();
1295+
// Get a path that's relative to node_modules or the importing file's path
1296+
// if node_modules folder is in this folder or any of its parent folders, no need to keep it.
1297+
const pathToTopLevelNodeModules = getCanonicalFileName(moduleSpecifier.substring(0, parts.topLevelNodeModulesIndex));
1298+
if (!(startsWith(canonicalSourceDirectory, pathToTopLevelNodeModules) || globalTypingsCacheLocation && startsWith(getCanonicalFileName(globalTypingsCacheLocation), pathToTopLevelNodeModules))) {
1299+
return undefined;
1300+
}
12411301
}
12421302

12431303
// If the module was found in @types, get the actual Node package name
1244-
const nodeModulesDirectoryName = moduleSpecifier.substring(parts.topLevelPackageNameIndex + 1);
1245-
const packageName = getPackageNameFromTypesPackageName(nodeModulesDirectoryName);
1304+
const nodeModulesDirectoryName = typeof pnpPackageName !== "undefined"
1305+
? pnpPackageName + moduleSpecifier.substring(parts.packageRootIndex)
1306+
: moduleSpecifier.substring(parts.topLevelPackageNameIndex! + 1);
1307+
1308+
const packageNameFromPath = getPackageNameFromTypesPackageName(nodeModulesDirectoryName);
12461309
// For classic resolution, only allow importing from node_modules/@types, not other node_modules
1247-
return getEmitModuleResolutionKind(options) === ModuleResolutionKind.Classic && packageName === nodeModulesDirectoryName ? undefined : packageName;
1310+
return getEmitModuleResolutionKind(options) === ModuleResolutionKind.Classic && packageNameFromPath === nodeModulesDirectoryName ? undefined : packageNameFromPath;
12481311

12491312
function tryDirectoryWithPackageJson(packageRootIndex: number): { moduleFileToTry: string; packageRootPath?: string; blockedByExports?: true; verbatimFromExports?: true; } {
12501313
const packageRootPath = path.substring(0, packageRootIndex);
@@ -1259,8 +1322,8 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan
12591322
// The package name that we found in node_modules could be different from the package
12601323
// name in the package.json content via url/filepath dependency specifiers. We need to
12611324
// use the actual directory name, so don't look at `packageJsonContent.name` here.
1262-
const nodeModulesDirectoryName = packageRootPath.substring(parts.topLevelPackageNameIndex + 1);
1263-
const packageName = getPackageNameFromTypesPackageName(nodeModulesDirectoryName);
1325+
const nodeModulesDirectoryName = packageRootPath.substring(parts!.topLevelPackageNameIndex! + 1);
1326+
const packageName = getPackageNameFromTypesPackageName(pnpPackageName ? pnpPackageName : nodeModulesDirectoryName);
12641327
const conditions = getConditions(options, importMode);
12651328
const fromExports = packageJsonContent?.exports
12661329
? tryGetModuleNameFromExports(
@@ -1333,7 +1396,7 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan
13331396
}
13341397
else {
13351398
// No package.json exists; an index.js will still resolve as the package name
1336-
const fileName = getCanonicalFileName(moduleFileToTry.substring(parts.packageRootIndex + 1));
1399+
const fileName = getCanonicalFileName(moduleFileToTry.substring(parts!.packageRootIndex + 1));
13371400
if (fileName === "index.d.ts" || fileName === "index.js" || fileName === "index.ts" || fileName === "index.tsx") {
13381401
return { moduleFileToTry, packageRootPath };
13391402
}

src/compiler/pnp.ts

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
getDirectoryPath,
3+
resolvePath,
4+
} from "./path.js";
5+
import { getPnpApi } from "./pnpapi.js";
6+
7+
export function getPnpTypeRoots(currentDirectory: string): string[] {
8+
const pnpApi = getPnpApi(currentDirectory);
9+
if (!pnpApi) {
10+
return [];
11+
}
12+
13+
// Some TS consumers pass relative paths that aren't normalized
14+
currentDirectory = resolvePath(currentDirectory);
15+
16+
const currentPackage = pnpApi.findPackageLocator(`${currentDirectory}/`);
17+
if (!currentPackage) {
18+
return [];
19+
}
20+
21+
const { packageDependencies } = pnpApi.getPackageInformation(currentPackage);
22+
23+
const typeRoots: string[] = [];
24+
for (const [name, referencish] of Array.from<any>(packageDependencies.entries())) {
25+
// eslint-disable-next-line no-restricted-syntax
26+
if (name.startsWith(`@types/`) && referencish !== null) {
27+
const dependencyLocator = pnpApi.getLocator(name, referencish);
28+
const { packageLocation } = pnpApi.getPackageInformation(dependencyLocator);
29+
30+
typeRoots.push(getDirectoryPath(packageLocation));
31+
}
32+
}
33+
34+
return typeRoots;
35+
}
36+
37+
export function isImportablePathPnp(fromPath: string, toPath: string): boolean {
38+
const pnpApi = getPnpApi(fromPath);
39+
40+
const fromLocator = pnpApi.findPackageLocator(fromPath);
41+
const toLocator = pnpApi.findPackageLocator(toPath);
42+
43+
// eslint-disable-next-line no-restricted-syntax
44+
if (toLocator === null) {
45+
return false;
46+
}
47+
48+
const fromInfo = pnpApi.getPackageInformation(fromLocator);
49+
const toReference = fromInfo.packageDependencies.get(toLocator.name);
50+
51+
if (toReference) {
52+
return toReference === toLocator.reference;
53+
}
54+
55+
// Aliased dependencies
56+
for (const reference of fromInfo.packageDependencies.values()) {
57+
if (Array.isArray(reference)) {
58+
if (reference[0] === toLocator.name && reference[1] === toLocator.reference) {
59+
return true;
60+
}
61+
}
62+
}
63+
64+
return false;
65+
}

src/compiler/pnpapi.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// To preserve the effects of https://github.com/microsoft/TypeScript/pull/55326
2+
// this file needs to avoid importing large graphs.
3+
4+
export function getPnpApi(path: string): any {
5+
if (typeof process.versions.pnp === "undefined") {
6+
return;
7+
}
8+
9+
const { findPnpApi } = require("module");
10+
if (findPnpApi) {
11+
return findPnpApi(`${path}/`);
12+
}
13+
}
14+
15+
export function getPnpApiPath(path: string): string | undefined {
16+
// eslint-disable-next-line no-restricted-syntax
17+
return getPnpApi(path)?.resolveRequest("pnpapi", /*issuer*/ null);
18+
}

src/compiler/sys.ts

+4
Original file line numberDiff line numberDiff line change
@@ -1720,6 +1720,10 @@ export let sys: System = (() => {
17201720
}
17211721

17221722
function isFileSystemCaseSensitive(): boolean {
1723+
// The PnP runtime is always case-sensitive
1724+
if (typeof process.versions.pnp !== `undefined`) {
1725+
return true;
1726+
}
17231727
// win32\win64 are case insensitive platforms
17241728
if (platform === "win32" || platform === "win64") {
17251729
return false;

src/compiler/utilities.ts

+9
Original file line numberDiff line numberDiff line change
@@ -10837,6 +10837,15 @@ export interface NodeModulePathParts {
1083710837
readonly packageRootIndex: number;
1083810838
readonly fileNameIndex: number;
1083910839
}
10840+
10841+
/** @internal */
10842+
export interface PackagePathParts {
10843+
readonly topLevelNodeModulesIndex: undefined;
10844+
readonly topLevelPackageNameIndex: undefined;
10845+
readonly packageRootIndex: number;
10846+
readonly fileNameIndex: number;
10847+
}
10848+
1084010849
/** @internal */
1084110850
export function getNodeModulePathParts(fullPath: string): NodeModulePathParts | undefined {
1084210851
// If fullPath can't be valid module file within node_modules, returns undefined.

0 commit comments

Comments
 (0)