Skip to content

Commit

Permalink
feat: add Yarn PnP support
Browse files Browse the repository at this point in the history
  • Loading branch information
arcanis authored and merceyz committed Apr 9, 2022
1 parent 6e62273 commit cd8d000
Show file tree
Hide file tree
Showing 12 changed files with 415 additions and 59 deletions.
134 changes: 126 additions & 8 deletions src/compiler/moduleNameResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,22 +267,22 @@ namespace ts {

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

let typeRoots: string[] | undefined;
const typeRoots: string[] = [];
forEachAncestorDirectory(normalizePath(currentDirectory), directory => {
const atTypes = combinePaths(directory, nodeModulesAtTypes);
if (host.directoryExists!(atTypes)) {
(typeRoots || (typeRoots = [])).push(atTypes);
typeRoots.push(atTypes);
}
return undefined;
});

return typeRoots;
}
const nodeModulesAtTypes = combinePaths("node_modules", "@types");
Expand All @@ -292,6 +292,50 @@ namespace ts {
return comparePaths(path1, path2, !useCaseSensitiveFileNames) === Comparison.EqualTo;
}

/**
* @internal
*/
export function getPnpTypeRoots(currentDirectory: string) {
const pnpapi = getPnpApi(currentDirectory);
if (!pnpapi) {
return [];
}

// Some TS consumers pass relative paths that aren't normalized
currentDirectory = sys.resolvePath(currentDirectory);

const currentPackage = pnpapi.findPackageLocator(`${currentDirectory}/`);
if (!currentPackage) {
return [];
}

const {packageDependencies} = pnpapi.getPackageInformation(currentPackage);

const typeRoots: string[] = [];
for (const [name, referencish] of Array.from<any>(packageDependencies.entries())) {
// eslint-disable-next-line no-null/no-null
if (name.startsWith(typesPackagePrefix) && referencish !== null) {
const dependencyLocator = pnpapi.getLocator(name, referencish);
const {packageLocation} = pnpapi.getPackageInformation(dependencyLocator);

typeRoots.push(getDirectoryPath(packageLocation));
}
}

return typeRoots;
}

const typesPackagePrefix = "@types/";

function getDefaultTypeRoots(currentDirectory: string, host: { directoryExists?: (directoryName: string) => boolean }): string[] | undefined {
const nmTypes = getNodeModulesTypeRoots(currentDirectory, host);
const pnpTypes = getPnpTypeRoots(currentDirectory);

if (nmTypes.length > 0 || pnpTypes.length > 0) {
return [...nmTypes, ...pnpTypes];
}
}

/**
* @param {string | undefined} containingFile - file that contains type reference directive, can be undefined if containing file is unknown.
* This is possible in case if resolution is performed for directives specified via 'types' parameter. In this case initial path for secondary lookups
Expand Down Expand Up @@ -425,7 +469,10 @@ namespace ts {
}
let result: Resolved | undefined;
if (!isExternalModuleNameRelative(typeReferenceDirectiveName)) {
const searchResult = loadModuleFromNearestNodeModulesDirectory(Extensions.DtsOnly, typeReferenceDirectiveName, initialLocationForSecondaryLookup, moduleResolutionState, /*cache*/ undefined, /*redirectedReference*/ undefined);
const searchResult = getPnpApi(initialLocationForSecondaryLookup)
? tryLoadModuleUsingPnpResolution(Extensions.DtsOnly, typeReferenceDirectiveName, initialLocationForSecondaryLookup, moduleResolutionState, /*cache*/ undefined, /*redirectedReference*/ undefined)
: loadModuleFromNearestNodeModulesDirectory(Extensions.DtsOnly, typeReferenceDirectiveName, initialLocationForSecondaryLookup, moduleResolutionState, /*cache*/ undefined, /*redirectedReference*/ undefined);

result = searchResult && searchResult.value;
}
else {
Expand Down Expand Up @@ -1331,7 +1378,9 @@ namespace ts {
if (traceEnabled) {
trace(host, Diagnostics.Loading_module_0_from_node_modules_folder_target_file_type_1, moduleName, Extensions[extensions]);
}
resolved = loadModuleFromNearestNodeModulesDirectory(extensions, moduleName, containingDirectory, state, cache, redirectedReference);
resolved = getPnpApi(containingDirectory)
? tryLoadModuleUsingPnpResolution(extensions, moduleName, containingDirectory, state, cache, redirectedReference)
: loadModuleFromNearestNodeModulesDirectory(extensions, moduleName, containingDirectory, state, cache, redirectedReference);
}
if (!resolved) return undefined;

Expand Down Expand Up @@ -2191,7 +2240,15 @@ namespace ts {

function loadModuleFromSpecificNodeModulesDirectory(extensions: Extensions, moduleName: string, nodeModulesDirectory: string, nodeModulesDirectoryExists: boolean, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined): Resolved | undefined {
const candidate = normalizePath(combinePaths(nodeModulesDirectory, moduleName));
return loadModuleFromSpecificNodeModulesDirectoryImpl(extensions, moduleName, nodeModulesDirectory, nodeModulesDirectoryExists, state, cache, redirectedReference, candidate, /* rest */ undefined, /* packageDirectory */ undefined);
}

function loadModuleFromPnpResolution(extensions: Extensions, packageDirectory: string, rest: string, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined): Resolved | undefined {
const candidate = normalizePath(combinePaths(packageDirectory, rest));
return loadModuleFromSpecificNodeModulesDirectoryImpl(extensions, /*moduleName*/ undefined, /*nodeModulesDirectory*/ undefined, /*nodeModulesDirectoryExists*/ true, state, cache, redirectedReference, candidate, rest, packageDirectory);
}

function loadModuleFromSpecificNodeModulesDirectoryImpl(extensions: Extensions, moduleName: string | undefined, nodeModulesDirectory: string | undefined, nodeModulesDirectoryExists: boolean, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined, candidate: string, rest: string | undefined, packageDirectory: string | undefined): Resolved | undefined {
// First look for a nested package.json, as in `node_modules/foo/bar/package.json`.
let packageInfo = getPackageJsonInfo(candidate, !nodeModulesDirectoryExists, state);
// But only if we're not respecting export maps (if we are, we might redirect around this location)
Expand All @@ -2214,7 +2271,8 @@ namespace ts {
}
}

const { packageName, rest } = parsePackageName(moduleName);
let packageName: string;
if (rest === undefined) ({ packageName, rest } = parsePackageName(moduleName!));
const loader: ResolutionKindSpecificLoader = (extensions, candidate, onlyRecordFailures, state) => {
// package exports are higher priority than file/directory lookups (and, if there's exports present, blocks them)
if (packageInfo && packageInfo.packageJsonContent.exports && state.features & NodeResolutionFeatures.Exports) {
Expand Down Expand Up @@ -2244,7 +2302,7 @@ namespace ts {
};

if (rest !== "") { // If "rest" is empty, we just did this search above.
const packageDirectory = combinePaths(nodeModulesDirectory, packageName);
if (packageDirectory === undefined) packageDirectory = combinePaths(nodeModulesDirectory!, packageName!);

// 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.
packageInfo = getPackageJsonInfo(packageDirectory, !nodeModulesDirectoryExists, state);
Expand Down Expand Up @@ -2424,4 +2482,64 @@ namespace ts {
function toSearchResult<T>(value: T | undefined): SearchResult<T> {
return value !== undefined ? { value } : undefined;
}

/**
* We only allow PnP to be used as a resolution strategy if TypeScript
* itself is executed under a PnP runtime (and we only allow it to access
* the current PnP runtime, not any on the disk). This ensures that we
* don't execute potentially malicious code that didn't already have a
* chance to be executed (if we're running within the runtime, it means
* that the runtime has already been executed).
* @internal
*/
function getPnpApi(path: string) {
const {findPnpApi} = require("module");
if (findPnpApi === undefined) {
return undefined;
}
return findPnpApi(`${path}/`);
}

function loadPnpPackageResolution(packageName: string, containingDirectory: string) {
try {
const resolution = getPnpApi(containingDirectory).resolveToUnqualified(packageName, `${containingDirectory}/`, { considerBuiltins: false });
return normalizeSlashes(resolution).replace(/\/$/, "");
}
catch {
// Nothing to do
}
}

function loadPnpTypePackageResolution(packageName: string, containingDirectory: string) {
return loadPnpPackageResolution(getTypesPackageName(packageName), containingDirectory);
}

/* @internal */
function tryLoadModuleUsingPnpResolution(extensions: Extensions, moduleName: string, containingDirectory: string, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined) {
const {packageName, rest} = parsePackageName(moduleName);

const packageResolution = loadPnpPackageResolution(packageName, containingDirectory);
const packageFullResolution = packageResolution
? loadModuleFromPnpResolution(extensions, packageResolution, rest, state, cache, redirectedReference)
: undefined;

let resolved;
if (packageFullResolution) {
resolved = packageFullResolution;
}
else if (extensions === Extensions.TypeScript || extensions === Extensions.DtsOnly) {
const typePackageResolution = loadPnpTypePackageResolution(packageName, containingDirectory);
const typePackageFullResolution = typePackageResolution
? loadModuleFromPnpResolution(Extensions.DtsOnly, typePackageResolution, rest, state, cache, redirectedReference)
: undefined;

if (typePackageFullResolution) {
resolved = typePackageFullResolution;
}
}

if (resolved) {
return toSearchResult(resolved);
}
}
}
58 changes: 46 additions & 12 deletions src/compiler/moduleSpecifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,34 @@ namespace ts.moduleSpecifiers {
if (!host.fileExists || !host.readFile) {
return undefined;
}
const parts: NodeModulePathParts = getNodeModulePathParts(path)!;
let parts: NodeModulePathParts | PackagePathParts | undefined
= getNodeModulePathParts(path);

let packageName: string | undefined;
if (!parts && typeof process.versions.pnp !== "undefined") {
const {findPnpApi} = require("module");
const pnpApi = findPnpApi(path);
const locator = pnpApi?.findPackageLocator(path);
// eslint-disable-next-line no-null/no-null
if (locator !== null && locator !== undefined) {
const sourceLocator = pnpApi.findPackageLocator(`${sourceDirectory}/`);
// Don't use the package name when the imported file is inside
// the source directory (prefer a relative path instead)
if (locator === sourceLocator) {
return undefined;
}
const information = pnpApi.getPackageInformation(locator);
packageName = locator.name;
parts = {
topLevelNodeModulesIndex: undefined,
topLevelPackageNameIndex: undefined,
// The last character from packageLocation is the trailing "/", we want to point to it
packageRootIndex: information.packageLocation.length - 1,
fileNameIndex: path.lastIndexOf(`/`),
};
}
}

if (!parts) {
return undefined;
}
Expand Down Expand Up @@ -713,19 +740,26 @@ namespace ts.moduleSpecifiers {
return undefined;
}

const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation && host.getGlobalTypingsCacheLocation();
// Get a path that's relative to node_modules or the importing file's path
// if node_modules folder is in this folder or any of its parent folders, no need to keep it.
const pathToTopLevelNodeModules = getCanonicalFileName(moduleSpecifier.substring(0, parts.topLevelNodeModulesIndex));
if (!(startsWith(sourceDirectory, pathToTopLevelNodeModules) || globalTypingsCacheLocation && startsWith(getCanonicalFileName(globalTypingsCacheLocation), pathToTopLevelNodeModules))) {
return undefined;
// If PnP is enabled the node_modules entries we'll get will always be relevant even if they
// are located in a weird path apparently outside of the source directory
if (typeof process.versions.pnp === "undefined") {
const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation && host.getGlobalTypingsCacheLocation();
// Get a path that's relative to node_modules or the importing file's path
// if node_modules folder is in this folder or any of its parent folders, no need to keep it.
const pathToTopLevelNodeModules = getCanonicalFileName(moduleSpecifier.substring(0, parts.topLevelNodeModulesIndex));
if (!(startsWith(sourceDirectory, pathToTopLevelNodeModules) || globalTypingsCacheLocation && startsWith(getCanonicalFileName(globalTypingsCacheLocation), pathToTopLevelNodeModules))) {
return undefined;
}
}

// If the module was found in @types, get the actual Node package name
const nodeModulesDirectoryName = moduleSpecifier.substring(parts.topLevelPackageNameIndex + 1);
const packageName = getPackageNameFromTypesPackageName(nodeModulesDirectoryName);
const nodeModulesDirectoryName = typeof packageName !== "undefined"
? packageName + moduleSpecifier.substring(parts.packageRootIndex)
: moduleSpecifier.substring(parts.topLevelPackageNameIndex! + 1);

const packageNameFromPath = getPackageNameFromTypesPackageName(nodeModulesDirectoryName);
// For classic resolution, only allow importing from node_modules/@types, not other node_modules
return getEmitModuleResolutionKind(options) === ModuleResolutionKind.Classic && packageName === nodeModulesDirectoryName ? undefined : packageName;
return getEmitModuleResolutionKind(options) === ModuleResolutionKind.Classic && packageNameFromPath === nodeModulesDirectoryName ? undefined : packageNameFromPath;

function tryDirectoryWithPackageJson(packageRootIndex: number): { moduleFileToTry: string, packageRootPath?: string, blockedByExports?: true, verbatimFromExports?: true } {
const packageRootPath = path.substring(0, packageRootIndex);
Expand Down Expand Up @@ -785,8 +819,8 @@ namespace ts.moduleSpecifiers {

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

return fullModulePathWithoutExtension;
Expand Down
5 changes: 5 additions & 0 deletions src/compiler/sys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1517,6 +1517,11 @@ namespace ts {
}

function isFileSystemCaseSensitive(): boolean {
// The PnP runtime is always case-sensitive
// @ts-ignore
if (process.versions.pnp) {
return true;
}
// win32\win64 are case insensitive platforms
if (platform === "win32" || platform === "win64") {
return false;
Expand Down
8 changes: 8 additions & 0 deletions src/compiler/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7602,6 +7602,14 @@ namespace ts {
readonly packageRootIndex: number;
readonly fileNameIndex: number;
}

export interface PackagePathParts {
readonly topLevelNodeModulesIndex: undefined;
readonly topLevelPackageNameIndex: undefined;
readonly packageRootIndex: number;
readonly fileNameIndex: number;
}

export function getNodeModulePathParts(fullPath: string): NodeModulePathParts | undefined {
// If fullPath can't be valid module file within node_modules, returns undefined.
// Example of expected pattern: /base/path/node_modules/[@scope/otherpackage/@otherscope/node_modules/]package/[subdirectory/]file.js
Expand Down
Loading

0 comments on commit cd8d000

Please sign in to comment.