Skip to content

Commit 530aad1

Browse files
arcanismerceyz
authored andcommitted
feat: add Yarn PnP support
1 parent 28e3e6f commit 530aad1

10 files changed

+356
-36
lines changed

src/compiler/moduleNameResolver.ts

+127-9
Original file line numberDiff line numberDiff line change
@@ -260,26 +260,66 @@ namespace ts {
260260

261261
/**
262262
* Returns the path to every node_modules/@types directory from some ancestor directory.
263-
* Returns undefined if there are none.
264263
*/
265-
function getDefaultTypeRoots(currentDirectory: string, host: { directoryExists?: (directoryName: string) => boolean }): string[] | undefined {
264+
function getNodeModulesTypeRoots(currentDirectory: string, host: { directoryExists?: (directoryName: string) => boolean }) {
266265
if (!host.directoryExists) {
267266
return [combinePaths(currentDirectory, nodeModulesAtTypes)];
268267
// And if it doesn't exist, tough.
269268
}
270269

271-
let typeRoots: string[] | undefined;
270+
const typeRoots: string[] = [];
272271
forEachAncestorDirectory(normalizePath(currentDirectory), directory => {
273272
const atTypes = combinePaths(directory, nodeModulesAtTypes);
274273
if (host.directoryExists!(atTypes)) {
275-
(typeRoots || (typeRoots = [])).push(atTypes);
274+
typeRoots.push(atTypes);
276275
}
277276
return undefined;
278277
});
278+
279279
return typeRoots;
280280
}
281281
const nodeModulesAtTypes = combinePaths("node_modules", "@types");
282282

283+
export function getPnpTypeRoots(currentDirectory: string) {
284+
const pnpapi = getPnpApi(currentDirectory);
285+
if (!pnpapi) {
286+
return [];
287+
}
288+
289+
// Some TS consumers pass relative paths that aren't normalized
290+
currentDirectory = sys.resolvePath(currentDirectory);
291+
292+
const currentPackage = pnpapi.findPackageLocator(`${currentDirectory}/`);
293+
if (!currentPackage) {
294+
return [];
295+
}
296+
297+
const {packageDependencies} = pnpapi.getPackageInformation(currentPackage);
298+
299+
const typeRoots: string[] = [];
300+
for (const [name, referencish] of Array.from<any>(packageDependencies.entries())) {
301+
// eslint-disable-next-line no-null/no-null
302+
if (name.startsWith(typesPackagePrefix) && referencish !== null) {
303+
const dependencyLocator = pnpapi.getLocator(name, referencish);
304+
const {packageLocation} = pnpapi.getPackageInformation(dependencyLocator);
305+
306+
typeRoots.push(getDirectoryPath(packageLocation));
307+
}
308+
}
309+
310+
return typeRoots;
311+
}
312+
const typesPackagePrefix = "@types/";
313+
314+
function getDefaultTypeRoots(currentDirectory: string, host: { directoryExists?: (directoryName: string) => boolean }): string[] | undefined {
315+
const nmTypes = getNodeModulesTypeRoots(currentDirectory, host);
316+
const pnpTypes = getPnpTypeRoots(currentDirectory);
317+
318+
if (nmTypes.length > 0 || pnpTypes.length > 0) {
319+
return [...nmTypes, ...pnpTypes];
320+
}
321+
}
322+
283323
/**
284324
* @param {string | undefined} containingFile - file that contains type reference directive, can be undefined if containing file is unknown.
285325
* This is possible in case if resolution is performed for directives specified via 'types' parameter. In this case initial path for secondary lookups
@@ -400,7 +440,10 @@ namespace ts {
400440
}
401441
let result: Resolved | undefined;
402442
if (!isExternalModuleNameRelative(typeReferenceDirectiveName)) {
403-
const searchResult = loadModuleFromNearestNodeModulesDirectory(Extensions.DtsOnly, typeReferenceDirectiveName, initialLocationForSecondaryLookup, moduleResolutionState, /*cache*/ undefined, /*redirectedReference*/ undefined);
443+
const searchResult = getPnpApi(initialLocationForSecondaryLookup)
444+
? tryLoadModuleUsingPnpResolution(Extensions.DtsOnly, typeReferenceDirectiveName, initialLocationForSecondaryLookup, moduleResolutionState)
445+
: loadModuleFromNearestNodeModulesDirectory(Extensions.DtsOnly, typeReferenceDirectiveName, initialLocationForSecondaryLookup, moduleResolutionState, /*cache*/ undefined, /*redirectedReference*/ undefined);
446+
404447
result = searchResult && searchResult.value;
405448
}
406449
else {
@@ -1111,8 +1154,14 @@ namespace ts {
11111154
if (traceEnabled) {
11121155
trace(host, Diagnostics.Loading_module_0_from_node_modules_folder_target_file_type_1, moduleName, Extensions[extensions]);
11131156
}
1114-
const resolved = loadModuleFromNearestNodeModulesDirectory(extensions, moduleName, containingDirectory, state, cache, redirectedReference);
1115-
if (!resolved) return undefined;
1157+
1158+
const resolved = getPnpApi(containingDirectory)
1159+
? tryLoadModuleUsingPnpResolution(extensions, moduleName, containingDirectory, state)
1160+
: loadModuleFromNearestNodeModulesDirectory(extensions, moduleName, containingDirectory, state, cache, redirectedReference);
1161+
1162+
if (!resolved) {
1163+
return undefined;
1164+
}
11161165

11171166
let resolvedValue = resolved.value;
11181167
if (!compilerOptions.preserveSymlinks && resolvedValue && !resolvedValue.originalPath) {
@@ -1491,7 +1540,15 @@ namespace ts {
14911540

14921541
function loadModuleFromSpecificNodeModulesDirectory(extensions: Extensions, moduleName: string, nodeModulesDirectory: string, nodeModulesDirectoryExists: boolean, state: ModuleResolutionState): Resolved | undefined {
14931542
const candidate = normalizePath(combinePaths(nodeModulesDirectory, moduleName));
1543+
return loadModuleFromSpecificNodeModulesDirectoryImpl(extensions, moduleName, nodeModulesDirectory, nodeModulesDirectoryExists, state, candidate, undefined, undefined);
1544+
}
14941545

1546+
function loadModuleFromPnpResolution(extensions: Extensions, packageDirectory: string, rest: string, state: ModuleResolutionState): Resolved | undefined {
1547+
const candidate = normalizePath(combinePaths(packageDirectory, rest));
1548+
return loadModuleFromSpecificNodeModulesDirectoryImpl(extensions, undefined, undefined, true, state, candidate, rest, packageDirectory);
1549+
}
1550+
1551+
function loadModuleFromSpecificNodeModulesDirectoryImpl(extensions: Extensions, moduleName: string | undefined, nodeModulesDirectory: string | undefined, nodeModulesDirectoryExists: boolean, state: ModuleResolutionState, candidate: string, rest: string | undefined, packageDirectory: string | undefined): Resolved | undefined {
14951552
// First look for a nested package.json, as in `node_modules/foo/bar/package.json`.
14961553
let packageInfo = getPackageJsonInfo(candidate, !nodeModulesDirectoryExists, state);
14971554
if (packageInfo) {
@@ -1525,9 +1582,10 @@ namespace ts {
15251582
return withPackageId(packageInfo, pathAndExtension);
15261583
};
15271584

1528-
const { packageName, rest } = parsePackageName(moduleName);
1585+
let packageName: string;
1586+
if (rest === undefined) ({ packageName, rest } = parsePackageName(moduleName!));
15291587
if (rest !== "") { // If "rest" is empty, we just did this search above.
1530-
const packageDirectory = combinePaths(nodeModulesDirectory, packageName);
1588+
if (packageDirectory === undefined) packageDirectory = combinePaths(nodeModulesDirectory!, packageName!);
15311589

15321590
// Don't use a "types" or "main" from here because we're not loading the root, but a subdirectory -- just here for the packageId and path mappings.
15331591
packageInfo = getPackageJsonInfo(packageDirectory, !nodeModulesDirectoryExists, state);
@@ -1706,4 +1764,64 @@ namespace ts {
17061764
function toSearchResult<T>(value: T | undefined): SearchResult<T> {
17071765
return value !== undefined ? { value } : undefined;
17081766
}
1767+
1768+
/**
1769+
* We only allow PnP to be used as a resolution strategy if TypeScript
1770+
* itself is executed under a PnP runtime (and we only allow it to access
1771+
* the current PnP runtime, not any on the disk). This ensures that we
1772+
* don't execute potentially malicious code that didn't already have a
1773+
* chance to be executed (if we're running within the runtime, it means
1774+
* that the runtime has already been executed).
1775+
* @internal
1776+
*/
1777+
function getPnpApi(path: string) {
1778+
const {findPnpApi} = require("module");
1779+
if (findPnpApi === undefined) {
1780+
return undefined;
1781+
}
1782+
return findPnpApi(`${path}/`);
1783+
}
1784+
1785+
function loadPnpPackageResolution(packageName: string, containingDirectory: string) {
1786+
try {
1787+
const resolution = getPnpApi(containingDirectory).resolveToUnqualified(packageName, `${containingDirectory}/`, { considerBuiltins: false });
1788+
return normalizeSlashes(resolution);
1789+
}
1790+
catch {
1791+
// Nothing to do
1792+
}
1793+
}
1794+
1795+
function loadPnpTypePackageResolution(packageName: string, containingDirectory: string) {
1796+
return loadPnpPackageResolution(getTypesPackageName(packageName), containingDirectory);
1797+
}
1798+
1799+
/* @internal */
1800+
function tryLoadModuleUsingPnpResolution(extensions: Extensions, moduleName: string, containingDirectory: string, state: ModuleResolutionState) {
1801+
const {packageName, rest} = parsePackageName(moduleName);
1802+
1803+
const packageResolution = loadPnpPackageResolution(packageName, containingDirectory);
1804+
const packageFullResolution = packageResolution
1805+
? loadModuleFromPnpResolution(extensions, packageResolution, rest, state)
1806+
: undefined;
1807+
1808+
let resolved;
1809+
if (packageFullResolution) {
1810+
resolved = packageFullResolution;
1811+
}
1812+
else if (extensions === Extensions.TypeScript || extensions === Extensions.DtsOnly) {
1813+
const typePackageResolution = loadPnpTypePackageResolution(packageName, containingDirectory);
1814+
const typePackageFullResolution = typePackageResolution
1815+
? loadModuleFromPnpResolution(Extensions.DtsOnly, typePackageResolution, rest, state)
1816+
: undefined;
1817+
1818+
if (typePackageFullResolution) {
1819+
resolved = typePackageFullResolution;
1820+
}
1821+
}
1822+
1823+
if (resolved) {
1824+
return toSearchResult(resolved);
1825+
}
1826+
}
17091827
}

src/compiler/moduleSpecifiers.ts

+53-12
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,33 @@ namespace ts.moduleSpecifiers {
475475
if (!host.fileExists || !host.readFile) {
476476
return undefined;
477477
}
478-
const parts: NodeModulePathParts = getNodeModulePathParts(path)!;
478+
let parts: NodeModulePathParts | PackagePathParts | undefined
479+
= getNodeModulePathParts(path);
480+
481+
let packageName: string | undefined;
482+
if (!parts && typeof process.versions.pnp !== "undefined") {
483+
const pnpApi = require("pnpapi");
484+
const locator = pnpApi.findPackageLocator(path);
485+
// eslint-disable-next-line no-null/no-null
486+
if (locator !== null) {
487+
const sourceLocator = pnpApi.findPackageLocator(`${sourceDirectory}/`);
488+
// Don't use the package name when the imported file is inside
489+
// the source directory (prefer a relative path instead)
490+
if (locator === sourceLocator) {
491+
return undefined;
492+
}
493+
const information = pnpApi.getPackageInformation(locator);
494+
packageName = locator.name;
495+
parts = {
496+
topLevelNodeModulesIndex: undefined,
497+
topLevelPackageNameIndex: undefined,
498+
// The last character from packageLocation is the trailing "/", we want to point to it
499+
packageRootIndex: information.packageLocation.length - 1,
500+
fileNameIndex: path.lastIndexOf(`/`),
501+
};
502+
}
503+
}
504+
479505
if (!parts) {
480506
return undefined;
481507
}
@@ -510,19 +536,26 @@ namespace ts.moduleSpecifiers {
510536
return undefined;
511537
}
512538

513-
const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation && host.getGlobalTypingsCacheLocation();
514-
// Get a path that's relative to node_modules or the importing file's path
515-
// if node_modules folder is in this folder or any of its parent folders, no need to keep it.
516-
const pathToTopLevelNodeModules = getCanonicalFileName(moduleSpecifier.substring(0, parts.topLevelNodeModulesIndex));
517-
if (!(startsWith(sourceDirectory, pathToTopLevelNodeModules) || globalTypingsCacheLocation && startsWith(getCanonicalFileName(globalTypingsCacheLocation), pathToTopLevelNodeModules))) {
518-
return undefined;
539+
// If PnP is enabled the node_modules entries we'll get will always be relevant even if they
540+
// are located in a weird path apparently outside of the source directory
541+
if (typeof process.versions.pnp === "undefined") {
542+
const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation && host.getGlobalTypingsCacheLocation();
543+
// Get a path that's relative to node_modules or the importing file's path
544+
// if node_modules folder is in this folder or any of its parent folders, no need to keep it.
545+
const pathToTopLevelNodeModules = getCanonicalFileName(moduleSpecifier.substring(0, parts.topLevelNodeModulesIndex));
546+
if (!(startsWith(sourceDirectory, pathToTopLevelNodeModules) || globalTypingsCacheLocation && startsWith(getCanonicalFileName(globalTypingsCacheLocation), pathToTopLevelNodeModules))) {
547+
return undefined;
548+
}
519549
}
520550

521551
// If the module was found in @types, get the actual Node package name
522-
const nodeModulesDirectoryName = moduleSpecifier.substring(parts.topLevelPackageNameIndex + 1);
523-
const packageName = getPackageNameFromTypesPackageName(nodeModulesDirectoryName);
552+
const nodeModulesDirectoryName = typeof packageName !== "undefined"
553+
? packageName + moduleSpecifier.substring(parts.packageRootIndex)
554+
: moduleSpecifier.substring(parts.topLevelPackageNameIndex! + 1);
555+
556+
const packageNameFromPath = getPackageNameFromTypesPackageName(nodeModulesDirectoryName);
524557
// For classic resolution, only allow importing from node_modules/@types, not other node_modules
525-
return getEmitModuleResolutionKind(options) !== ModuleResolutionKind.NodeJs && packageName === nodeModulesDirectoryName ? undefined : packageName;
558+
return getEmitModuleResolutionKind(options) !== ModuleResolutionKind.NodeJs && packageNameFromPath === nodeModulesDirectoryName ? undefined : packageNameFromPath;
526559

527560
function tryDirectoryWithPackageJson(packageRootIndex: number) {
528561
const packageRootPath = path.substring(0, packageRootIndex);
@@ -563,8 +596,8 @@ namespace ts.moduleSpecifiers {
563596

564597
// If the file is /index, it can be imported by its directory name
565598
// IFF there is not _also_ a file by the same name
566-
if (getCanonicalFileName(fullModulePathWithoutExtension.substring(parts.fileNameIndex)) === "/index" && !tryGetAnyFileFromPath(host, fullModulePathWithoutExtension.substring(0, parts.fileNameIndex))) {
567-
return fullModulePathWithoutExtension.substring(0, parts.fileNameIndex);
599+
if (getCanonicalFileName(fullModulePathWithoutExtension.substring(parts!.fileNameIndex)) === "/index" && !tryGetAnyFileFromPath(host, fullModulePathWithoutExtension.substring(0, parts!.fileNameIndex))) {
600+
return fullModulePathWithoutExtension.substring(0, parts!.fileNameIndex);
568601
}
569602

570603
return fullModulePathWithoutExtension;
@@ -589,6 +622,14 @@ namespace ts.moduleSpecifiers {
589622
readonly packageRootIndex: number;
590623
readonly fileNameIndex: number;
591624
}
625+
626+
interface PackagePathParts {
627+
readonly topLevelNodeModulesIndex: undefined;
628+
readonly topLevelPackageNameIndex: undefined;
629+
readonly packageRootIndex: number;
630+
readonly fileNameIndex: number;
631+
}
632+
592633
function getNodeModulePathParts(fullPath: string): NodeModulePathParts | undefined {
593634
// If fullPath can't be valid module file within node_modules, returns undefined.
594635
// Example of expected pattern: /base/path/node_modules/[@scope/otherpackage/@otherscope/node_modules/]package/[subdirectory/]file.js

src/compiler/sys.ts

+5
Original file line numberDiff line numberDiff line change
@@ -1508,6 +1508,11 @@ namespace ts {
15081508
}
15091509

15101510
function isFileSystemCaseSensitive(): boolean {
1511+
// The PnP runtime is always case-sensitive
1512+
// @ts-ignore
1513+
if (process.versions.pnp) {
1514+
return true;
1515+
}
15111516
// win32\win64 are case insensitive platforms
15121517
if (platform === "win32" || platform === "win64") {
15131518
return false;

0 commit comments

Comments
 (0)