From 786e26825dad9dcc0eff79610bffd8bb121e7e8a Mon Sep 17 00:00:00 2001 From: merceyz Date: Mon, 29 Jan 2024 19:38:45 +0100 Subject: [PATCH] feat: add Yarn PnP support --- .github/workflows/ci.yml | 1 + src/compiler/moduleNameResolver.ts | 83 ++++++++++++++++++++- src/compiler/moduleSpecifiers.ts | 93 ++++++++++++++++++++---- src/compiler/pnp.ts | 80 +++++++++++++++++++++ src/compiler/sys.ts | 4 ++ src/compiler/utilities.ts | 9 +++ src/compiler/watchPublic.ts | 28 +++++++- src/compiler/watchUtilities.ts | 2 + src/server/editorServices.ts | 35 +++++++++ src/server/project.ts | 43 +++++++++++ src/services/exportInfoMap.ts | 8 +++ src/services/stringCompletions.ts | 111 ++++++++++++++++++++++------- src/services/utilities.ts | 14 +++- src/tsserver/nodeServer.ts | 12 ++++ 14 files changed, 478 insertions(+), 45 deletions(-) create mode 100644 src/compiler/pnp.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67cbb21f38b0a..f19f6bed06495 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main - release-* + - '*/pnp-*' pull_request: branches: - main diff --git a/src/compiler/moduleNameResolver.ts b/src/compiler/moduleNameResolver.ts index d29142c721428..b4d96a202dd04 100644 --- a/src/compiler/moduleNameResolver.ts +++ b/src/compiler/moduleNameResolver.ts @@ -109,6 +109,10 @@ import { versionMajorMinor, VersionRange, } from "./_namespaces/ts"; +import { + getPnpApi, + getPnpTypeRoots, +} from "./pnp"; /** @internal */ export function trace(host: ModuleResolutionHost, message: DiagnosticMessage, ...args: any[]): void { @@ -490,7 +494,7 @@ export function getEffectiveTypeRoots(options: CompilerOptions, host: GetEffecti * Returns the path to every node_modules/@types directory from some ancestor directory. * Returns undefined if there are none. */ -function getDefaultTypeRoots(currentDirectory: string): string[] | undefined { +function getNodeModulesTypeRoots(currentDirectory: string) { let typeRoots: string[] | undefined; forEachAncestorDirectory(normalizePath(currentDirectory), directory => { const atTypes = combinePaths(directory, nodeModulesAtTypes); @@ -505,6 +509,18 @@ function arePathsEqual(path1: string, path2: string, host: ModuleResolutionHost) return comparePaths(path1, path2, !useCaseSensitiveFileNames) === Comparison.EqualTo; } +function getDefaultTypeRoots(currentDirectory: string): string[] | undefined { + const nmTypes = getNodeModulesTypeRoots(currentDirectory); + const pnpTypes = getPnpTypeRoots(currentDirectory); + + if (nmTypes?.length) { + return [...nmTypes, ...pnpTypes]; + } + else if (pnpTypes.length) { + return pnpTypes; + } +} + function getOriginalAndResolvedFileName(fileName: string, host: ModuleResolutionHost, traceEnabled: boolean) { const resolvedFileName = realPath(fileName, host, traceEnabled); const pathsAreEqual = arePathsEqual(fileName, resolvedFileName, host); @@ -785,6 +801,18 @@ export function resolvePackageNameToPackageJson( ): PackageJsonInfo | undefined { const moduleResolutionState = getTemporaryModuleResolutionState(cache?.getPackageJsonInfoCache(), host, options); + const pnpapi = getPnpApi(containingDirectory); + if (pnpapi) { + try { + const resolution = pnpapi.resolveToUnqualified(packageName, `${containingDirectory}/`, { considerBuiltins: false }); + const candidate = normalizeSlashes(resolution).replace(/\/$/, ""); + return getPackageJsonInfo(candidate, /*onlyRecordFailures*/ false, moduleResolutionState); + } + catch { + return; + } + } + return forEachAncestorDirectory(containingDirectory, ancestorDirectory => { if (getBaseFileName(ancestorDirectory) !== "node_modules") { const nodeModulesFolder = combinePaths(ancestorDirectory, "node_modules"); @@ -2970,7 +2998,16 @@ function loadModuleFromNearestNodeModulesDirectoryWorker(extensions: Extensions, } function lookup(extensions: Extensions) { - return forEachAncestorDirectory(normalizeSlashes(directory), ancestorDirectory => { + const issuer = normalizeSlashes(directory); + if (getPnpApi(issuer)) { + const resolutionFromCache = tryFindNonRelativeModuleNameInCache(cache, moduleName, mode, issuer, redirectedReference, state); + if (resolutionFromCache) { + return resolutionFromCache; + } + return toSearchResult(loadModuleFromImmediateNodeModulesDirectoryPnP(extensions, moduleName, issuer, state, typesScopeOnly, cache, redirectedReference)); + } + + return forEachAncestorDirectory(issuer, ancestorDirectory => { if (getBaseFileName(ancestorDirectory) !== "node_modules") { const resolutionFromCache = tryFindNonRelativeModuleNameInCache(cache, moduleName, mode, ancestorDirectory, redirectedReference, state); if (resolutionFromCache) { @@ -3009,11 +3046,34 @@ function loadModuleFromImmediateNodeModulesDirectory(extensions: Extensions, mod } } +function loadModuleFromImmediateNodeModulesDirectoryPnP(extensions: Extensions, moduleName: string, directory: string, state: ModuleResolutionState, typesScopeOnly: boolean, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined): Resolved | undefined { + const issuer = normalizeSlashes(directory); + + if (!typesScopeOnly) { + const packageResult = tryLoadModuleUsingPnpResolution(extensions, moduleName, issuer, state, cache, redirectedReference); + if (packageResult) { + return packageResult; + } + } + + if (extensions & Extensions.Declaration) { + return tryLoadModuleUsingPnpResolution(Extensions.Declaration, `@types/${mangleScopedPackageNameWithTrace(moduleName, state)}`, issuer, state, cache, redirectedReference); + } +} + 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)); const { packageName, rest } = parsePackageName(moduleName); const packageDirectory = combinePaths(nodeModulesDirectory, packageName); + return loadModuleFromSpecificNodeModulesDirectoryImpl(extensions, nodeModulesDirectoryExists, state, cache, redirectedReference, candidate, rest, packageDirectory); +} +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, /*nodeModulesDirectoryExists*/ true, state, cache, redirectedReference, candidate, rest, packageDirectory); +} + +function loadModuleFromSpecificNodeModulesDirectoryImpl(extensions: Extensions, nodeModulesDirectoryExists: boolean, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined, candidate: string, rest: string, packageDirectory: string): Resolved | undefined { let rootPackageInfo: PackageJsonInfo | undefined; // First look for a nested package.json, as in `node_modules/foo/bar/package.json`. let packageInfo = getPackageJsonInfo(candidate, !nodeModulesDirectoryExists, state); @@ -3345,3 +3405,22 @@ function useCaseSensitiveFileNames(state: ModuleResolutionState) { typeof state.host.useCaseSensitiveFileNames === "boolean" ? state.host.useCaseSensitiveFileNames : state.host.useCaseSensitiveFileNames(); } + +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 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); + return packageResolution + ? loadModuleFromPnpResolution(extensions, packageResolution, rest, state, cache, redirectedReference) + : undefined; +} diff --git a/src/compiler/moduleSpecifiers.ts b/src/compiler/moduleSpecifiers.ts index fb2a6cb1bb7f3..eef81d1ec6cc4 100644 --- a/src/compiler/moduleSpecifiers.ts +++ b/src/compiler/moduleSpecifiers.ts @@ -90,6 +90,7 @@ import { NodeFlags, NodeModulePathParts, normalizePath, + PackagePathParts, pathContainsNodeModules, pathIsBareSpecifier, pathIsRelative, @@ -117,6 +118,9 @@ import { TypeChecker, UserPreferences, } from "./_namespaces/ts"; +import { + getPnpApi, +} from "./pnp"; // Used by importFixes, getEditsForFileRename, and declaration emit to synthesize import module specifiers. @@ -680,7 +684,17 @@ function getAllModulePathsWorker(info: Info, importedFileName: string, host: Mod host, /*preferSymlinks*/ true, (path, isRedirect) => { - const isInNodeModules = pathContainsNodeModules(path); + let isInNodeModules = pathContainsNodeModules(path); + + const pnpapi = getPnpApi(path); + if (!isInNodeModules && pnpapi) { + const fromLocator = pnpapi.findPackageLocator(info.importingSourceFileName); + const toLocator = pnpapi.findPackageLocator(path); + if (fromLocator && toLocator && fromLocator !== toLocator) { + isInNodeModules = true; + } + } + allFileNames.set(path, { path: info.getCanonicalFileName(path), isRedirect, isInNodeModules }); importedFileFromNodeModules = importedFileFromNodeModules || isInNodeModules; // don't return value, so we collect everything @@ -1011,7 +1025,51 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan if (!host.fileExists || !host.readFile) { return undefined; } - const parts: NodeModulePathParts = getNodeModulePathParts(path)!; + let parts: NodeModulePathParts | PackagePathParts | undefined = getNodeModulePathParts(path); + + let pnpPackageName: string | undefined; + + const pnpApi = getPnpApi(path); + if (pnpApi) { + const fromLocator = pnpApi.findPackageLocator(importingSourceFile.fileName); + const toLocator = pnpApi.findPackageLocator(path); + + // Don't use the package name when the imported file is inside + // the source directory (prefer a relative path instead) + if (fromLocator === toLocator) { + return undefined; + } + + if (fromLocator && toLocator) { + const fromInfo = pnpApi.getPackageInformation(fromLocator); + if (toLocator.reference === fromInfo.packageDependencies.get(toLocator.name)) { + pnpPackageName = toLocator.name; + } + else { + // Aliased dependencies + for (const [name, reference] of fromInfo.packageDependencies) { + if (Array.isArray(reference)) { + if (reference[0] === toLocator.name && reference[1] === toLocator.reference) { + pnpPackageName = name; + break; + } + } + } + } + + if (!parts) { + const toInfo = pnpApi.getPackageInformation(toLocator); + parts = { + topLevelNodeModulesIndex: undefined, + topLevelPackageNameIndex: undefined, + // The last character from packageLocation is the trailing "/", we want to point to it + packageRootIndex: toInfo.packageLocation.length - 1, + fileNameIndex: path.lastIndexOf(`/`), + }; + } + } + } + if (!parts) { return undefined; } @@ -1056,19 +1114,26 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan 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(canonicalSourceDirectory, 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(canonicalSourceDirectory, 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 pnpPackageName !== "undefined" + ? pnpPackageName + 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); @@ -1083,8 +1148,8 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan // The package name that we found in node_modules could be different from the package // name in the package.json content via url/filepath dependency specifiers. We need to // use the actual directory name, so don't look at `packageJsonContent.name` here. - const nodeModulesDirectoryName = packageRootPath.substring(parts.topLevelPackageNameIndex + 1); - const packageName = getPackageNameFromTypesPackageName(nodeModulesDirectoryName); + const nodeModulesDirectoryName = packageRootPath.substring(parts!.topLevelPackageNameIndex! + 1); + const packageName = getPackageNameFromTypesPackageName(pnpPackageName ? pnpPackageName : nodeModulesDirectoryName); const conditions = getConditions(options, importMode); const fromExports = packageJsonContent?.exports ? tryGetModuleNameFromExports(options, host, path, packageRootPath, packageName, packageJsonContent.exports, conditions) @@ -1147,7 +1212,7 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan } else { // No package.json exists; an index.js will still resolve as the package name - const fileName = getCanonicalFileName(moduleFileToTry.substring(parts.packageRootIndex + 1)); + const fileName = getCanonicalFileName(moduleFileToTry.substring(parts!.packageRootIndex + 1)); if (fileName === "index.d.ts" || fileName === "index.js" || fileName === "index.ts" || fileName === "index.tsx") { return { moduleFileToTry, packageRootPath }; } diff --git a/src/compiler/pnp.ts b/src/compiler/pnp.ts new file mode 100644 index 0000000000000..5d0bfdb6de2e7 --- /dev/null +++ b/src/compiler/pnp.ts @@ -0,0 +1,80 @@ +import { + getDirectoryPath, + resolvePath, +} from "./path"; + +export function getPnpApi(path: string) { + if (typeof process.versions.pnp === "undefined") { + return; + } + + const { findPnpApi } = require("module"); + if (findPnpApi) { + return findPnpApi(`${path}/`); + } +} + +export function getPnpApiPath(path: string): string | undefined { + // eslint-disable-next-line no-null/no-null + return getPnpApi(path)?.resolveRequest("pnpapi", /*issuer*/ null); +} + +export function getPnpTypeRoots(currentDirectory: string) { + const pnpApi = getPnpApi(currentDirectory); + if (!pnpApi) { + return []; + } + + // Some TS consumers pass relative paths that aren't normalized + currentDirectory = 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(packageDependencies.entries())) { + // eslint-disable-next-line no-null/no-null + if (name.startsWith(`@types/`) && referencish !== null) { + const dependencyLocator = pnpApi.getLocator(name, referencish); + const { packageLocation } = pnpApi.getPackageInformation(dependencyLocator); + + typeRoots.push(getDirectoryPath(packageLocation)); + } + } + + return typeRoots; +} + +export function isImportablePathPnp(fromPath: string, toPath: string): boolean { + const pnpApi = getPnpApi(fromPath); + + const fromLocator = pnpApi.findPackageLocator(fromPath); + const toLocator = pnpApi.findPackageLocator(toPath); + + // eslint-disable-next-line no-null/no-null + if (toLocator === null) { + return false; + } + + const fromInfo = pnpApi.getPackageInformation(fromLocator); + const toReference = fromInfo.packageDependencies.get(toLocator.name); + + if (toReference) { + return toReference === toLocator.reference; + } + + // Aliased dependencies + for (const reference of fromInfo.packageDependencies.values()) { + if (Array.isArray(reference)) { + if (reference[0] === toLocator.name && reference[1] === toLocator.reference) { + return true; + } + } + } + + return false; +} diff --git a/src/compiler/sys.ts b/src/compiler/sys.ts index 32d7af09c03e7..6fdba9aed7b26 100644 --- a/src/compiler/sys.ts +++ b/src/compiler/sys.ts @@ -1746,6 +1746,10 @@ export let sys: System = (() => { } function isFileSystemCaseSensitive(): boolean { + // The PnP runtime is always case-sensitive + if (typeof process.versions.pnp !== `undefined`) { + return true; + } // win32\win64 are case insensitive platforms if (platform === "win32" || platform === "win64") { return false; diff --git a/src/compiler/utilities.ts b/src/compiler/utilities.ts index 6b3b2aed34994..f1c91c1647949 100644 --- a/src/compiler/utilities.ts +++ b/src/compiler/utilities.ts @@ -10424,6 +10424,15 @@ export interface NodeModulePathParts { readonly packageRootIndex: number; readonly fileNameIndex: number; } + +/** @internal */ +export interface PackagePathParts { + readonly topLevelNodeModulesIndex: undefined; + readonly topLevelPackageNameIndex: undefined; + readonly packageRootIndex: number; + readonly fileNameIndex: number; +} + /** @internal */ export function getNodeModulePathParts(fullPath: string): NodeModulePathParts | undefined { // If fullPath can't be valid module file within node_modules, returns undefined. diff --git a/src/compiler/watchPublic.ts b/src/compiler/watchPublic.ts index 0a95aeafdab37..81d6a2866cb44 100644 --- a/src/compiler/watchPublic.ts +++ b/src/compiler/watchPublic.ts @@ -95,6 +95,9 @@ import { WatchTypeRegistry, WildcardDirectoryWatcher, } from "./_namespaces/ts"; +import { + getPnpApiPath, +} from "./pnp"; export interface ReadBuildProgramHost { useCaseSensitiveFileNames(): boolean; @@ -484,6 +487,12 @@ export function createWatchProgram(host: WatchCompiler configFileWatcher = watchFile(configFileName, scheduleProgramReload, PollingInterval.High, watchOptions, WatchType.ConfigFile); } + let pnpFileWatcher: FileWatcher | undefined; + const pnpApiPath = getPnpApiPath(__filename); + if (pnpApiPath) { + pnpFileWatcher = watchFile(pnpApiPath, scheduleResolutionReload, PollingInterval.High, watchOptions, WatchType.ConfigFile); + } + const compilerHost = createCompilerHostFromProgramHost(host, () => compilerOptions!, directoryStructureHost) as CompilerHost & ResolutionCacheHost; setGetSourceFileAsHashVersioned(compilerHost); // Members for CompilerHost @@ -571,6 +580,10 @@ export function createWatchProgram(host: WatchCompiler configFileWatcher.close(); configFileWatcher = undefined; } + if (pnpFileWatcher) { + pnpFileWatcher.close(); + pnpFileWatcher = undefined; + } extendedConfigCache?.clear(); extendedConfigCache = undefined; if (sharedExtendedConfigFileWatchers) { @@ -608,7 +621,7 @@ export function createWatchProgram(host: WatchCompiler return builderProgram && builderProgram.getProgramOrUndefined(); } - function synchronizeProgram() { + function synchronizeProgram(forceAllFilesAsInvalidated = false) { writeLog(`Synchronizing program`); Debug.assert(compilerOptions); @@ -624,7 +637,7 @@ export function createWatchProgram(host: WatchCompiler } } - const { hasInvalidatedResolutions, hasInvalidatedLibResolutions } = resolutionCache.createHasInvalidatedResolutions(customHasInvalidatedResolutions, customHasInvalidLibResolutions); + const { hasInvalidatedResolutions, hasInvalidatedLibResolutions } = resolutionCache.createHasInvalidatedResolutions(forceAllFilesAsInvalidated ? returnTrue : customHasInvalidatedResolutions, customHasInvalidLibResolutions); const { originalReadFile, originalFileExists, @@ -880,6 +893,13 @@ export function createWatchProgram(host: WatchCompiler scheduleProgramUpdate(); } + function scheduleResolutionReload() { + writeLog("Clearing resolutions"); + resolutionCache.clear(); + updateLevel = ProgramUpdateLevel.Resolutions; + scheduleProgramUpdate(); + } + function updateProgramWithWatchStatus() { timerToUpdateProgram = undefined; reportFileChangeDetectedOnCreateProgram = true; @@ -896,6 +916,10 @@ export function createWatchProgram(host: WatchCompiler perfLogger?.logStartUpdateProgram("FullConfigReload"); reloadConfigFile(); break; + case ProgramUpdateLevel.Resolutions: + perfLogger?.logStartUpdateProgram("SynchronizeProgramWithResolutions"); + synchronizeProgram(/*forceAllFilesAsInvalidated*/ true); + break; default: perfLogger?.logStartUpdateProgram("SynchronizeProgram"); synchronizeProgram(); diff --git a/src/compiler/watchUtilities.ts b/src/compiler/watchUtilities.ts index a617a134dafcc..08cf2c9c4fb5f 100644 --- a/src/compiler/watchUtilities.ts +++ b/src/compiler/watchUtilities.ts @@ -379,6 +379,8 @@ export enum ProgramUpdateLevel { */ Full, + /** Reload the resolutions */ + Resolutions, } /** @internal */ diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index a1480723b8b93..0ba440ee8bb15 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -1,3 +1,6 @@ +import { + getPnpApiPath, +} from "../compiler/pnp"; import { addToSeen, arrayFrom, @@ -1136,6 +1139,8 @@ export class ProjectService { readonly session: Session | undefined; private performanceEventHandler?: PerformanceEventHandler; + /** @internal */ + private pnpWatcher?: FileWatcher; private pendingPluginEnablements?: Map[]>; private currentPluginEnablementPromise?: Promise; @@ -1215,6 +1220,8 @@ export class ProjectService { log, getDetailWatchInfo, ); + + this.pnpWatcher = this.watchPnpFile(); opts.incrementalVerifier?.(this); } @@ -3458,6 +3465,9 @@ export class ProjectService { if (args.watchOptions) { this.hostConfiguration.watchOptions = convertWatchOptions(args.watchOptions)?.watchOptions; this.logger.info(`Host watch options changed to ${JSON.stringify(this.hostConfiguration.watchOptions)}, it will be take effect for next watches.`); + + this.pnpWatcher?.close(); + this.watchPnpFile(); } } } @@ -4663,6 +4673,31 @@ export class ProjectService { }); } + /** @internal */ + private watchPnpFile() { + const pnpApiPath = getPnpApiPath(__filename); + if (!pnpApiPath) { + return; + } + + return this.watchFactory.watchFile( + pnpApiPath, + () => { + this.forEachProject(project => { + for (const info of project.getScriptInfos()) { + project.resolutionCache.invalidateResolutionOfFile(info.path); + } + project.markAsDirty(); + updateProjectIfDirty(project); + }); + this.delayEnsureProjectForOpenFiles(); + }, + PollingInterval.Low, + this.hostConfiguration.watchOptions, + WatchType.ConfigFile, + ); + } + /** @internal */ private watchPackageJsonFile(file: string, path: Path, project: Project | WildcardWatcher) { Debug.assert(project !== undefined); diff --git a/src/server/project.ts b/src/server/project.ts index 109541097f7ca..c0976180b8d52 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -1,3 +1,6 @@ +import { + getPnpApi, +} from "../compiler/pnp"; import * as ts from "./_namespaces/ts"; import { addRange, @@ -61,6 +64,7 @@ import { getNormalizedAbsolutePath, getOrUpdate, GetPackageJsonEntrypointsHost, + getRelativePathFromDirectory, getStringComparer, HasInvalidatedLibResolutions, HasInvalidatedResolutions, @@ -2808,6 +2812,45 @@ export class ConfiguredProject extends Project { } updateReferences(refs: readonly ProjectReference[] | undefined) { + if (typeof process.versions.pnp !== `undefined`) { + // With Plug'n'Play, dependencies that list peer dependencies + // are "virtualized": they are resolved to a unique (virtual) + // path that the underlying filesystem layer then resolve back + // to the original location. + // + // When a workspace depends on another workspace with peer + // dependencies, this other workspace will thus be resolved to + // a unique path that won't match what the initial project has + // listed in its `references` field, and TS thus won't leverage + // the reference at all. + // + // To avoid that, we compute here the virtualized paths for the + // user-provided references in our references by directly querying + // the PnP API. This way users don't have to know the virtual paths, + // but we still support them just fine even through references. + + const basePath = this.getCurrentDirectory(); + + const getPnpPath = (path: string) => { + try { + const pnpApi = getPnpApi(`${path}/`); + if (!pnpApi) { + return path; + } + const targetLocator = pnpApi.findPackageLocator(`${path}/`); + const { packageLocation } = pnpApi.getPackageInformation(targetLocator); + const request = combinePaths(targetLocator.name, getRelativePathFromDirectory(packageLocation, path, /*ignoreCase*/ false)); + return pnpApi.resolveToUnqualified(request, `${basePath}/`); + } + catch { + // something went wrong with the resolution, try not to fail + return path; + } + }; + + refs = refs?.map(r => ({ ...r, path: getPnpPath(r.path) })); + } + this.projectReferences = refs; this.potentialProjectReferences = undefined; } diff --git a/src/services/exportInfoMap.ts b/src/services/exportInfoMap.ts index 322631ad92c31..1d3112e2e827f 100644 --- a/src/services/exportInfoMap.ts +++ b/src/services/exportInfoMap.ts @@ -1,3 +1,7 @@ +import { + getPnpApi, + isImportablePathPnp, +} from "../compiler/pnp"; import { __String, addToSeen, @@ -405,6 +409,10 @@ export function isImportableFile( * A relative import to node_modules is usually a bad idea. */ function isImportablePath(fromPath: string, toPath: string, getCanonicalFileName: GetCanonicalFileName, globalCachePath?: string): boolean { + if (getPnpApi(fromPath)) { + return isImportablePathPnp(fromPath, toPath); + } + // If it's in a `node_modules` but is not reachable from here via a global import, don't bother. const toNodeModules = forEachAncestorDirectory(toPath, ancestor => getBaseFileName(ancestor) === "node_modules" ? ancestor : undefined); const toNodeModulesParent = toNodeModules && getDirectoryPath(getCanonicalFileName(toNodeModules)); diff --git a/src/services/stringCompletions.ts b/src/services/stringCompletions.ts index 5cb669be5368c..4bc3664b13a45 100644 --- a/src/services/stringCompletions.ts +++ b/src/services/stringCompletions.ts @@ -1,6 +1,10 @@ import { getModuleSpecifierPreferences, } from "../compiler/moduleSpecifiers"; +import { + getPnpApi, + getPnpTypeRoots, +} from "../compiler/pnp"; import { addToSeen, altDirectorySeparator, @@ -944,7 +948,38 @@ function getCompletionEntriesForNonRelativeModules( getCompletionEntriesForDirectoryFragment(fragment, nodeModules, extensionOptions, host, /*moduleSpecifierIsRelative*/ false, /*exclude*/ undefined, result); } }; - if (fragmentDirectory && getResolvePackageJsonExports(compilerOptions)) { + + const checkExports = ( + packageFile: string, + packageDirectory: string, + fragmentSubpath: string, + ) => { + const packageJson = readJson(packageFile, host); + const exports = (packageJson as any).exports; + if (exports) { + if (typeof exports !== "object" || exports === null) { // eslint-disable-line no-null/no-null + return true; // null exports or entrypoint only, no sub-modules available + } + const keys = getOwnKeys(exports); + const conditions = getConditions(compilerOptions, mode); + addCompletionEntriesFromPathsOrExports( + result, + /*isExports*/ true, + fragmentSubpath, + packageDirectory, + extensionOptions, + host, + keys, + key => singleElementArray(getPatternFromFirstMatchingCondition(exports[key], conditions)), + comparePatternKeys, + ); + return true; + } + return false; + }; + + const shouldCheckExports = fragmentDirectory && getResolvePackageJsonExports(compilerOptions); + if (shouldCheckExports) { const nodeModulesDirectoryLookup = ancestorLookup; ancestorLookup = ancestor => { const components = getPathComponents(fragment); @@ -963,33 +998,50 @@ function getCompletionEntriesForNonRelativeModules( const packageDirectory = combinePaths(ancestor, "node_modules", packagePath); const packageFile = combinePaths(packageDirectory, "package.json"); if (tryFileExists(host, packageFile)) { - const packageJson = readJson(packageFile, host); - const exports = (packageJson as any).exports; - if (exports) { - if (typeof exports !== "object" || exports === null) { // eslint-disable-line no-null/no-null - return; // null exports or entrypoint only, no sub-modules available - } - const keys = getOwnKeys(exports); - const fragmentSubpath = components.join("/") + (components.length && hasTrailingDirectorySeparator(fragment) ? "/" : ""); - const conditions = getConditions(compilerOptions, mode); - addCompletionEntriesFromPathsOrExports( - result, - /*isExports*/ true, - fragmentSubpath, - packageDirectory, - extensionOptions, - host, - keys, - key => singleElementArray(getPatternFromFirstMatchingCondition(exports[key], conditions)), - comparePatternKeys, - ); + const fragmentSubpath = components.join("/") + (components.length && hasTrailingDirectorySeparator(fragment) ? "/" : ""); + if (checkExports(packageFile, packageDirectory, fragmentSubpath)) { return; } } return nodeModulesDirectoryLookup(ancestor); }; } - forEachAncestorDirectory(scriptPath, ancestorLookup); + + const pnpApi = getPnpApi(scriptPath); + + if (pnpApi) { + // Splits a require request into its components, or return null if the request is a file path + const pathRegExp = /^(?![a-zA-Z]:[\\/]|\\\\|\.{0,2}(?:\/|$))((?:@[^/]+\/)?[^/]+)\/*(.*|)$/; + const dependencyNameMatch = fragment.match(pathRegExp); + if (dependencyNameMatch) { + const [, dependencyName, subPath] = dependencyNameMatch; + let unqualified; + try { + unqualified = pnpApi.resolveToUnqualified(dependencyName, scriptPath, { considerBuiltins: false }); + } + catch { + // It's fine if the resolution fails + } + if (unqualified) { + const packageDirectory = normalizePath(unqualified); + let shouldGetCompletions = true; + + if (shouldCheckExports) { + const packageFile = combinePaths(packageDirectory, "package.json"); + if (tryFileExists(host, packageFile) && checkExports(packageFile, packageDirectory, subPath)) { + shouldGetCompletions = false; + } + } + + if (shouldGetCompletions) { + getCompletionEntriesForDirectoryFragment(subPath, packageDirectory, extensionOptions, host, /*moduleSpecifierIsRelative*/ false, /*exclude*/ undefined, result); + } + } + } + } + else { + forEachAncestorDirectory(scriptPath, ancestorLookup); + } } } @@ -1170,10 +1222,17 @@ function getCompletionEntriesFromTypings(host: LanguageServiceHost, options: Com getCompletionEntriesFromDirectories(root); } - // Also get all @types typings installed in visible node_modules directories - for (const packageJson of findPackageJsons(scriptPath, host)) { - const typesDir = combinePaths(getDirectoryPath(packageJson), "node_modules/@types"); - getCompletionEntriesFromDirectories(typesDir); + if (getPnpApi(scriptPath)) { + for (const root of getPnpTypeRoots(scriptPath)) { + getCompletionEntriesFromDirectories(root); + } + } + else { + // Also get all @types typings installed in visible node_modules directories + for (const packageJson of findPackageJsons(scriptPath, host)) { + const typesDir = combinePaths(getDirectoryPath(packageJson), "node_modules/@types"); + getCompletionEntriesFromDirectories(typesDir); + } } return result; diff --git a/src/services/utilities.ts b/src/services/utilities.ts index 0b2072f449008..0f3ffcabbce72 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -1,3 +1,6 @@ +import { + getPnpApi, +} from "../compiler/pnp"; import { __String, addEmitFlags, @@ -3866,9 +3869,18 @@ export function createPackageJsonImportFilter(fromFile: SourceFile, preferences: } function getNodeModulesPackageNameFromFileName(importedFileName: string, moduleSpecifierResolutionHost: ModuleSpecifierResolutionHost): string | undefined { - if (!importedFileName.includes("node_modules")) { + const pnpapi = getPnpApi(importedFileName); + if (pnpapi) { + const fromLocator = pnpapi.findPackageLocator(fromFile.fileName); + const toLocator = pnpapi.findPackageLocator(importedFileName); + if (!(fromLocator && toLocator)) { + return undefined; + } + } + else if (!importedFileName.includes("node_modules")) { return undefined; } + const specifier = moduleSpecifiers.getNodeModulesPackageName( host.getCompilationSettings(), fromFile, diff --git a/src/tsserver/nodeServer.ts b/src/tsserver/nodeServer.ts index fe4db45d94522..7048d32d5eb0a 100644 --- a/src/tsserver/nodeServer.ts +++ b/src/tsserver/nodeServer.ts @@ -1,3 +1,6 @@ +import { + getPnpApiPath, +} from "../compiler/pnp"; import * as protocol from "../server/protocol"; import * as ts from "./_namespaces/ts"; import { @@ -305,6 +308,10 @@ export function initializeNodeSystem(): StartInput { } try { const args = [combinePaths(libDirectory, "watchGuard.js"), path]; + const pnpApiPath = getPnpApiPath(__filename); + if (pnpApiPath) { + args.unshift("-r", pnpApiPath); + } if (logger.hasLevel(LogLevel.verbose)) { logger.info(`Starting ${process.execPath} with args:${stringifyIndented(args)}`); } @@ -562,6 +569,11 @@ function startNodeSession(options: StartSessionOptions, logger: Logger, cancella } } + const pnpApiPath = getPnpApiPath(__filename); + if (pnpApiPath) { + execArgv.unshift("-r", pnpApiPath); + } + const typingsInstaller = combinePaths(getDirectoryPath(sys.getExecutingFilePath()), "typingsInstaller.js"); this.installer = childProcess.fork(typingsInstaller, args, { execArgv }); this.installer.on("message", m => this.handleMessage(m));