From 5613f8d8e30dfa9fb3da15e2b8432ed7e2347a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Fri, 2 Apr 2021 13:27:43 +0200 Subject: [PATCH 1/2] feat: add Yarn PnP support --- src/compiler/moduleNameResolver.ts | 135 +++++++++++++++++++++++++++-- src/compiler/moduleSpecifiers.ts | 56 +++++++++--- src/compiler/sys.ts | 5 ++ src/compiler/utilities.ts | 8 ++ src/compiler/watchPublic.ts | 24 ++++- src/compiler/watchUtilities.ts | 4 +- src/server/editorServices.ts | 33 +++++++ src/server/project.ts | 38 ++++++++ src/services/exportInfoMap.ts | 36 +++++++- src/services/stringCompletions.ts | 101 ++++++++++++++++----- src/services/tsconfig.json | 5 +- src/tsserver/nodeServer.ts | 11 +++ 12 files changed, 408 insertions(+), 48 deletions(-) diff --git a/src/compiler/moduleNameResolver.ts b/src/compiler/moduleNameResolver.ts index f30c2a63a6ca9..e3d06b1b58e96 100644 --- a/src/compiler/moduleNameResolver.ts +++ b/src/compiler/moduleNameResolver.ts @@ -281,22 +281,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"); @@ -306,6 +306,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(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 @@ -454,7 +498,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 { @@ -1405,7 +1452,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; @@ -2432,7 +2481,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) @@ -2479,8 +2536,10 @@ namespace ts { return withPackageId(packageInfo, pathAndExtension); }; - const { packageName, rest } = parsePackageName(moduleName); - const packageDirectory = combinePaths(nodeModulesDirectory, packageName); + let packageName: string; + if (rest === undefined) ({ packageName, rest } = parsePackageName(moduleName!)); + if (packageDirectory === undefined) packageDirectory = combinePaths(nodeModulesDirectory!, packageName!); + if (rest !== "") { // Previous `packageInfo` may have been from a nested package.json; ensure we have the one from the package root now. packageInfo = getPackageJsonInfo(packageDirectory, !nodeModulesDirectoryExists, state); @@ -2709,4 +2768,64 @@ namespace ts { trace(state.host, diagnostic, ...args); } } + + /** + * 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); + } + } } diff --git a/src/compiler/moduleSpecifiers.ts b/src/compiler/moduleSpecifiers.ts index 2327f8924b8e6..c7e0d3cc76378 100644 --- a/src/compiler/moduleSpecifiers.ts +++ b/src/compiler/moduleSpecifiers.ts @@ -749,7 +749,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; } @@ -793,19 +820,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); @@ -868,7 +902,7 @@ namespace ts.moduleSpecifiers { } 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/sys.ts b/src/compiler/sys.ts index b9e6540291fa7..a399128cc143a 100644 --- a/src/compiler/sys.ts +++ b/src/compiler/sys.ts @@ -1683,6 +1683,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; diff --git a/src/compiler/utilities.ts b/src/compiler/utilities.ts index 0571a9c92df0d..d2ebd4e770375 100644 --- a/src/compiler/utilities.ts +++ b/src/compiler/utilities.ts @@ -7691,6 +7691,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 diff --git a/src/compiler/watchPublic.ts b/src/compiler/watchPublic.ts index 011a24f5b7129..125c88f65e13b 100644 --- a/src/compiler/watchPublic.ts +++ b/src/compiler/watchPublic.ts @@ -329,6 +329,11 @@ namespace ts { configFileWatcher = watchFile(configFileName, scheduleProgramReload, PollingInterval.High, watchOptions, WatchType.ConfigFile); } + let pnpFileWatcher: FileWatcher | undefined; + if (typeof process.versions.pnp !== `undefined`) { + pnpFileWatcher = watchFile(require.resolve(`pnpapi`), scheduleResolutionReload, PollingInterval.High, watchOptions, WatchType.ConfigFile); + } + const compilerHost = createCompilerHostFromProgramHost(host, () => compilerOptions, directoryStructureHost) as CompilerHost & ResolutionCacheHost; setGetSourceFileAsHashVersioned(compilerHost, host); // Members for CompilerHost @@ -404,6 +409,10 @@ namespace ts { configFileWatcher.close(); configFileWatcher = undefined; } + if (pnpFileWatcher) { + pnpFileWatcher.close(); + pnpFileWatcher = undefined; + } extendedConfigCache?.clear(); extendedConfigCache = undefined; if (sharedExtendedConfigFileWatchers) { @@ -437,7 +446,7 @@ namespace ts { return builderProgram && builderProgram.getProgramOrUndefined(); } - function synchronizeProgram() { + function synchronizeProgram(forceAllFilesAsInvalidated = false) { writeLog(`Synchronizing program`); clearInvalidateResolutionsOfFailedLookupLocations(); @@ -449,7 +458,7 @@ namespace ts { } } - const hasInvalidatedResolutions = resolutionCache.createHasInvalidatedResolutions(customHasInvalidatedResolutions); + const hasInvalidatedResolutions = resolutionCache.createHasInvalidatedResolutions(forceAllFilesAsInvalidated ? returnTrue : customHasInvalidatedResolutions); const { originalReadFile, originalFileExists, originalDirectoryExists, originalCreateDirectory, originalWriteFile, @@ -691,6 +700,13 @@ namespace ts { scheduleProgramUpdate(); } + function scheduleResolutionReload() { + writeLog("Clearing resolutions"); + resolutionCache.clear(); + reloadLevel = ConfigFileProgramReloadLevel.Resolutions; + scheduleProgramUpdate(); + } + function updateProgramWithWatchStatus() { timerToUpdateProgram = undefined; reportFileChangeDetectedOnCreateProgram = true; @@ -707,6 +723,10 @@ namespace ts { perfLogger.logStartUpdateProgram("FullConfigReload"); reloadConfigFile(); break; + case ConfigFileProgramReloadLevel.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 e8c21aa8f63c9..f3ecb2d9e61e2 100644 --- a/src/compiler/watchUtilities.ts +++ b/src/compiler/watchUtilities.ts @@ -308,7 +308,9 @@ namespace ts { /** Update the file name list from the disk */ Partial, /** Reload completely by re-reading contents of config file from disk and updating program */ - Full + Full, + /** Reload the resolutions */ + Resolutions, } export interface SharedExtendedConfigFileWatcher extends FileWatcher { diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 98b64707a459d..8ae0749c80adc 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -803,6 +803,8 @@ namespace ts.server { private performanceEventHandler?: PerformanceEventHandler; + /*@internal*/ + private pnpWatcher?: FileWatcher; private pendingPluginEnablements?: ESMap[]>; private currentPluginEnablementPromise?: Promise; @@ -875,6 +877,8 @@ namespace ts.server { watchDirectory: returnNoopFileWatcher, } : getWatchFactory(this.host, watchLogLevel, log, getDetailWatchInfo); + + this.pnpWatcher = this.watchPnpFile(); } toPath(fileName: string) { @@ -3036,6 +3040,9 @@ namespace ts.server { 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(); } } } @@ -4228,6 +4235,32 @@ namespace ts.server { }); } + /*@internal*/ + private watchPnpFile() { + if (typeof process.versions.pnp === "undefined") { + return; + } + const {findPnpApi} = require("module"); + // eslint-disable-next-line no-null/no-null + const pnpFileName = findPnpApi(__filename).resolveRequest("pnpapi", /*issuer*/ null); + return this.watchFactory.watchFile( + pnpFileName, + () => { + 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(path: Path) { const watchers = this.packageJsonFilesMap || (this.packageJsonFilesMap = new Map()); diff --git a/src/server/project.ts b/src/server/project.ts index 14c98583bd6a7..38c0921a28a39 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -2501,6 +2501,44 @@ namespace ts.server { } updateReferences(refs: readonly ProjectReference[] | undefined) { + // @ts-ignore + if (process.versions.pnp) { + // 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 {findPnpApi} = require("module"); + + const getPnpPath = (path: string) => { + try { + const pnpApi = findPnpApi(`${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 5e927b3ff8afb..0f9a08e8453e5 100644 --- a/src/services/exportInfoMap.ts +++ b/src/services/exportInfoMap.ts @@ -325,7 +325,7 @@ namespace ts { * Don't include something from a `node_modules` that isn't actually reachable by a global import. * A relative import to node_modules is usually a bad idea. */ - function isImportablePath(fromPath: string, toPath: string, getCanonicalFileName: GetCanonicalFileName, globalCachePath?: string): boolean { + function isImportablePathNode(fromPath: string, toPath: string, getCanonicalFileName: GetCanonicalFileName, globalCachePath?: string): boolean { // 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)); @@ -334,6 +334,40 @@ namespace ts { || (!!globalCachePath && startsWith(getCanonicalFileName(globalCachePath), toNodeModulesParent)); } + function getPnpApi(path: string) { + const {findPnpApi} = require("module"); + if (findPnpApi === undefined) { + return undefined; + } + return findPnpApi(`${path}/`); + } + + 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); + + return toReference === toLocator.reference; + } + + function isImportablePath(fromPath: string, toPath: string, getCanonicalFileName: GetCanonicalFileName, globalCachePath?: string): boolean { + if (getPnpApi(fromPath)) { + return isImportablePathPnp(fromPath, toPath); + } + else { + return isImportablePathNode(fromPath, toPath, getCanonicalFileName, globalCachePath); + } + } + export function forEachExternalModuleToImportFrom( program: Program, host: LanguageServiceHost, diff --git a/src/services/stringCompletions.ts b/src/services/stringCompletions.ts index 33b508017683a..4c16c9ec1cd89 100644 --- a/src/services/stringCompletions.ts +++ b/src/services/stringCompletions.ts @@ -675,7 +675,36 @@ namespace ts.Completions.StringCompletions { getCompletionEntriesForDirectoryFragment(fragment, nodeModules, extensionOptions, host, /*exclude*/ undefined, result); } }; - if (fragmentDirectory && isEmitModuleResolutionRespectingExportMaps(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 = mode === ModuleKind.ESNext ? ["node", "import", "types"] : ["node", "require", "types"]; + addCompletionEntriesFromPathsOrExports( + result, + fragmentSubpath, + packageDirectory, + extensionOptions, + host, + keys, + key => singleElementArray(getPatternFromFirstMatchingCondition(exports[key], conditions)), + comparePatternKeys); + return true; + } + return false; + } + + const shouldCheckExports = fragmentDirectory && isEmitModuleResolutionRespectingExportMaps(compilerOptions); + if (shouldCheckExports) { const nodeModulesDirectoryLookup = ancestorLookup; ancestorLookup = ancestor => { const components = getPathComponents(fragment); @@ -694,31 +723,49 @@ namespace ts.Completions.StringCompletions { 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 = mode === ModuleKind.ESNext ? ["node", "import", "types"] : ["node", "require", "types"]; - addCompletionEntriesFromPathsOrExports( - result, - 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 = require("module").findPnpApi?.(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, /*exclude*/ undefined, result); + } + } + } + } else { + forEachAncestorDirectory(scriptPath, ancestorLookup); + } } } @@ -892,10 +939,16 @@ namespace ts.Completions.StringCompletions { 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 (require("module").findPnpApi?.(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/tsconfig.json b/src/services/tsconfig.json index 2099fe308c911..ed6c786d8cb58 100644 --- a/src/services/tsconfig.json +++ b/src/services/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "../tsconfig-base", "compilerOptions": { - "outFile": "../../built/local/services.js" + "outFile": "../../built/local/services.js", + "types": [ + "node" + ] }, "references": [ { "path": "../compiler" }, diff --git a/src/tsserver/nodeServer.ts b/src/tsserver/nodeServer.ts index 47e3ff6bfb1b7..09500651011aa 100644 --- a/src/tsserver/nodeServer.ts +++ b/src/tsserver/nodeServer.ts @@ -209,6 +209,11 @@ namespace ts.server { } try { const args = [combinePaths(__dirname, "watchGuard.js"), path]; + if (typeof process.versions.pnp !== "undefined") { + const {findPnpApi} = require("module"); + // eslint-disable-next-line no-null/no-null + args.unshift("-r", findPnpApi(__filename).resolveRequest("pnpapi", /* issuer */ null)); + } if (logger.hasLevel(LogLevel.verbose)) { logger.info(`Starting ${process.execPath} with args:${stringifyIndented(args)}`); } @@ -507,6 +512,12 @@ namespace ts.server { } } + if (typeof process.versions.pnp !== "undefined") { + const {findPnpApi} = require("module"); + // eslint-disable-next-line no-null/no-null + execArgv.unshift("-r", findPnpApi(__filename).resolveRequest("pnpapi", /* issuer */ null)); + } + this.installer = childProcess.fork(combinePaths(__dirname, "typingsInstaller.js"), args, { execArgv }); this.installer.on("message", m => this.handleMessage(m)); From 9aad21518591677d4bd43f9ab1e100e350377af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Kochen?= Date: Tue, 15 Nov 2022 09:45:29 +0100 Subject: [PATCH 2/2] fix: handle missing pnpapi --- src/server/editorServices.ts | 8 +++++--- src/tsserver/nodeServer.ts | 14 ++++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 8ae0749c80adc..05dcb76a5460f 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -4240,9 +4240,11 @@ namespace ts.server { if (typeof process.versions.pnp === "undefined") { return; } - const {findPnpApi} = require("module"); - // eslint-disable-next-line no-null/no-null - const pnpFileName = findPnpApi(__filename).resolveRequest("pnpapi", /*issuer*/ null); + const pnpApi = require("module").findPnpApi(__filename); + if (!pnpApi) { + return; + } + const pnpFileName = pnpApi.resolveRequest("pnpapi", /*issuer*/ null); return this.watchFactory.watchFile( pnpFileName, () => { diff --git a/src/tsserver/nodeServer.ts b/src/tsserver/nodeServer.ts index 09500651011aa..69e3a00da366d 100644 --- a/src/tsserver/nodeServer.ts +++ b/src/tsserver/nodeServer.ts @@ -210,9 +210,10 @@ namespace ts.server { try { const args = [combinePaths(__dirname, "watchGuard.js"), path]; if (typeof process.versions.pnp !== "undefined") { - const {findPnpApi} = require("module"); - // eslint-disable-next-line no-null/no-null - args.unshift("-r", findPnpApi(__filename).resolveRequest("pnpapi", /* issuer */ null)); + const pnpApi = require("module").findPnpApi(__filename); + if (pnpApi) { + args.unshift('-r', pnpApi.resolveRequest("pnpapi", /* issuer */ null)); + } } if (logger.hasLevel(LogLevel.verbose)) { logger.info(`Starting ${process.execPath} with args:${stringifyIndented(args)}`); @@ -513,9 +514,10 @@ namespace ts.server { } if (typeof process.versions.pnp !== "undefined") { - const {findPnpApi} = require("module"); - // eslint-disable-next-line no-null/no-null - execArgv.unshift("-r", findPnpApi(__filename).resolveRequest("pnpapi", /* issuer */ null)); + const pnpApi = require("module").findPnpApi(__filename); + if (pnpApi) { + execArgv.unshift('-r', pnpApi.resolveRequest("pnpapi", /* issuer */ null)); + } } this.installer = childProcess.fork(combinePaths(__dirname, "typingsInstaller.js"), args, { execArgv });