diff --git a/src/server/moduleSpecifierCache.ts b/src/server/moduleSpecifierCache.ts new file mode 100644 index 0000000000000..eed821a99c627 --- /dev/null +++ b/src/server/moduleSpecifierCache.ts @@ -0,0 +1,95 @@ +/*@internal*/ +namespace ts.server { + export interface ModuleSpecifierResolutionCacheHost { + watchNodeModulesForPackageJsonChanges(directoryPath: string): FileWatcher; + } + + export function createModuleSpecifierCache(host: ModuleSpecifierResolutionCacheHost): ModuleSpecifierCache { + let containedNodeModulesWatchers: ESMap | undefined; + let cache: ESMap | undefined; + let currentKey: string | undefined; + const result: ModuleSpecifierCache = { + get(fromFileName, toFileName, preferences) { + if (!cache || currentKey !== key(fromFileName, preferences)) return undefined; + return cache.get(toFileName); + }, + set(fromFileName, toFileName, preferences, modulePaths, moduleSpecifiers) { + ensureCache(fromFileName, preferences).set(toFileName, createInfo(modulePaths, moduleSpecifiers, /*isAutoImportable*/ true)); + + // If any module specifiers were generated based off paths in node_modules, + // a package.json file in that package was read and is an input to the cached. + // Instead of watching each individual package.json file, set up a wildcard + // directory watcher for any node_modules referenced and clear the cache when + // it sees any changes. + if (moduleSpecifiers) { + for (const p of modulePaths) { + if (p.isInNodeModules) { + // No trailing slash + const nodeModulesPath = p.path.substring(0, p.path.indexOf(nodeModulesPathPart) + nodeModulesPathPart.length - 1); + if (!containedNodeModulesWatchers?.has(nodeModulesPath)) { + (containedNodeModulesWatchers ||= new Map()).set( + nodeModulesPath, + host.watchNodeModulesForPackageJsonChanges(nodeModulesPath), + ); + } + } + } + } + }, + setModulePaths(fromFileName, toFileName, preferences, modulePaths) { + const cache = ensureCache(fromFileName, preferences); + const info = cache.get(toFileName); + if (info) { + info.modulePaths = modulePaths; + } + else { + cache.set(toFileName, createInfo(modulePaths, /*moduleSpecifiers*/ undefined, /*isAutoImportable*/ undefined)); + } + }, + setIsAutoImportable(fromFileName, toFileName, preferences, isAutoImportable) { + const cache = ensureCache(fromFileName, preferences); + const info = cache.get(toFileName); + if (info) { + info.isAutoImportable = isAutoImportable; + } + else { + cache.set(toFileName, createInfo(/*modulePaths*/ undefined, /*moduleSpecifiers*/ undefined, isAutoImportable)); + } + }, + clear() { + containedNodeModulesWatchers?.forEach(watcher => watcher.close()); + cache?.clear(); + containedNodeModulesWatchers?.clear(); + currentKey = undefined; + }, + count() { + return cache ? cache.size : 0; + } + }; + if (Debug.isDebugging) { + Object.defineProperty(result, "__cache", { get: () => cache }); + } + return result; + + function ensureCache(fromFileName: Path, preferences: UserPreferences) { + const newKey = key(fromFileName, preferences); + if (cache && (currentKey !== newKey)) { + result.clear(); + } + currentKey = newKey; + return cache ||= new Map(); + } + + function key(fromFileName: Path, preferences: UserPreferences) { + return `${fromFileName},${preferences.importModuleSpecifierEnding},${preferences.importModuleSpecifierPreference}`; + } + + function createInfo( + modulePaths: readonly ModulePath[] | undefined, + moduleSpecifiers: readonly string[] | undefined, + isAutoImportable: boolean | undefined, + ): ResolvedModuleSpecifierInfo { + return { modulePaths, moduleSpecifiers, isAutoImportable }; + } + } +} diff --git a/src/server/project.ts b/src/server/project.ts index 3e138dc12a0b2..f0a310d2c8662 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -251,7 +251,7 @@ namespace ts.server { public readonly getCanonicalFileName: GetCanonicalFileName; /*@internal*/ - private exportMapCache = createExportMapCache(); + private exportMapCache: ExportInfoMap | undefined; /*@internal*/ private changedFilesForExportMapCache: Set | undefined; /*@internal*/ @@ -793,6 +793,7 @@ namespace ts.server { this.cachedUnresolvedImportsPerFile = undefined!; this.moduleSpecifierCache = undefined!; this.directoryStructureHost = undefined!; + this.exportMapCache = undefined; this.projectErrors = undefined; // Clean up file watchers waiting for missing files @@ -990,7 +991,7 @@ namespace ts.server { /*@internal*/ markFileAsDirty(changedFile: Path) { this.markAsDirty(); - if (!this.exportMapCache.isEmpty()) { + if (this.exportMapCache && !this.exportMapCache.isEmpty()) { (this.changedFilesForExportMapCache ||= new Set()).add(changedFile); } } @@ -1192,7 +1193,8 @@ namespace ts.server { } } - if (!this.exportMapCache.isEmpty()) { + if (this.exportMapCache && !this.exportMapCache.isEmpty()) { + this.exportMapCache.releaseSymbols(); if (this.hasAddedorRemovedFiles || oldProgram && !this.program!.structureIsReused) { this.exportMapCache.clear(); } @@ -1201,10 +1203,10 @@ namespace ts.server { const oldSourceFile = oldProgram.getSourceFileByPath(fileName); const sourceFile = this.program!.getSourceFileByPath(fileName); if (!oldSourceFile || !sourceFile) { - this.exportMapCache.clear(); + this.exportMapCache!.clear(); return true; } - return this.exportMapCache.onFileChanged(oldSourceFile, sourceFile, !!this.getTypeAcquisition().enable); + return this.exportMapCache!.onFileChanged(oldSourceFile, sourceFile, !!this.getTypeAcquisition().enable); }); } } @@ -1666,8 +1668,13 @@ namespace ts.server { } /*@internal*/ - getExportMapCache() { - return this.exportMapCache; + getCachedExportInfoMap() { + return this.exportMapCache ||= createCacheableExportInfoMap(this); + } + + /*@internal*/ + clearCachedExportInfoMap() { + this.exportMapCache?.clear(); } /*@internal*/ @@ -2017,7 +2024,7 @@ namespace ts.server { const oldProgram = this.getCurrentProgram(); const hasSameSetOfFiles = super.updateGraph(); if (oldProgram && oldProgram !== this.getCurrentProgram()) { - this.hostProject.getExportMapCache().clear(); + this.hostProject.clearCachedExportInfoMap(); } return hasSameSetOfFiles; } diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index e9d853de9ee5a..b0a2f59ba77d6 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -23,6 +23,7 @@ "typingsCache.ts", "project.ts", "editorServices.ts", + "moduleSpecifierCache.ts", "packageJsonCache.ts", "session.ts", "scriptVersionCache.ts" diff --git a/src/services/codefixes/importFixes.ts b/src/services/codefixes/importFixes.ts index 9bdb6ea430c5a..378a541425417 100644 --- a/src/services/codefixes/importFixes.ts +++ b/src/services/codefixes/importFixes.ts @@ -230,11 +230,11 @@ namespace ts.codefix { function getInfoWithChecker(checker: TypeChecker, isFromPackageJson: boolean): SymbolExportInfo | undefined { const defaultInfo = getDefaultLikeExportInfo(moduleSymbol, checker, compilerOptions); if (defaultInfo && skipAlias(defaultInfo.symbol, checker) === symbol) { - return { symbol: defaultInfo.symbol, moduleSymbol, exportKind: defaultInfo.exportKind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(symbol, checker), isFromPackageJson }; + return { symbol: defaultInfo.symbol, moduleSymbol, moduleFileName: undefined, exportKind: defaultInfo.exportKind, isTypeOnly: isTypeOnlySymbol(symbol, checker), isFromPackageJson }; } const named = checker.tryGetMemberInModuleExportsAndProperties(symbol.name, moduleSymbol); if (named && skipAlias(named, checker) === symbol) { - return { symbol: named, moduleSymbol, exportKind: ExportKind.Named, exportedSymbolIsTypeOnly: isTypeOnlySymbol(symbol, checker), isFromPackageJson }; + return { symbol: named, moduleSymbol, moduleFileName: undefined, exportKind: ExportKind.Named, isTypeOnly: isTypeOnlySymbol(symbol, checker), isFromPackageJson }; } } } @@ -255,12 +255,12 @@ namespace ts.codefix { const defaultInfo = getDefaultLikeExportInfo(moduleSymbol, checker, compilerOptions); if (defaultInfo && (defaultInfo.name === symbolName || moduleSymbolToValidIdentifier(moduleSymbol, compilerOptions.target) === symbolName) && skipAlias(defaultInfo.symbol, checker) === exportedSymbol && isImportable(program, moduleFile, isFromPackageJson)) { - result.push({ symbol: defaultInfo.symbol, moduleSymbol, exportKind: defaultInfo.exportKind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(defaultInfo.symbol, checker), isFromPackageJson }); + result.push({ symbol: defaultInfo.symbol, moduleSymbol, moduleFileName: moduleFile?.fileName, exportKind: defaultInfo.exportKind, isTypeOnly: isTypeOnlySymbol(defaultInfo.symbol, checker), isFromPackageJson }); } for (const exported of checker.getExportsAndPropertiesOfModule(moduleSymbol)) { if (exported.name === symbolName && skipAlias(exported, checker) === exportedSymbol && isImportable(program, moduleFile, isFromPackageJson)) { - result.push({ symbol: exported, moduleSymbol, exportKind: ExportKind.Named, exportedSymbolIsTypeOnly: isTypeOnlySymbol(exported, checker), isFromPackageJson }); + result.push({ symbol: exported, moduleSymbol, moduleFileName: moduleFile?.fileName, exportKind: ExportKind.Named, isTypeOnly: isTypeOnlySymbol(exported, checker), isFromPackageJson }); } } }); @@ -271,7 +271,8 @@ namespace ts.codefix { } } - export function getModuleSpecifierForBestExportInfo(exportInfo: readonly SymbolExportInfo[], + export function getModuleSpecifierForBestExportInfo( + exportInfo: readonly SymbolExportInfo[], importingFile: SourceFile, program: Program, host: LanguageServiceHost, @@ -292,114 +293,6 @@ namespace ts.codefix { return result && { ...result, computedWithoutCacheCount }; } - export interface SymbolToExportInfoMap { - get(importedName: string, symbol: Symbol, moduleSymbol: Symbol, checker: TypeChecker): readonly SymbolExportInfo[] | undefined; - forEach(getChecker: (isFromPackageJson: boolean) => TypeChecker, action: (info: readonly SymbolExportInfo[], name: string, isFromAmbientModule: boolean) => void): void; - } - - export function getSymbolToExportInfoMap(importingFile: SourceFile, host: LanguageServiceHost, program: Program): SymbolToExportInfoMap { - const start = timestamp(); - // Pulling the AutoImportProvider project will trigger its updateGraph if pending, - // which will invalidate the export map cache if things change, so pull it before - // checking the cache. - host.getPackageJsonAutoImportProvider?.(); - const cache = host.getExportMapCache?.(); - if (cache) { - const cached = cache.get(importingFile.path, program.getTypeChecker()); - if (cached) { - host.log?.("getSymbolToExportInfoMap: cache hit"); - const projectVersion = host.getProjectVersion?.(); - return wrapMultiMap(cached, !projectVersion || cache.getProjectVersion() !== projectVersion); - } - else { - host.log?.("getSymbolToExportInfoMap: cache miss or empty; calculating new results"); - } - } - - const result: MultiMap = createMultiMap(); - const compilerOptions = program.getCompilerOptions(); - const target = getEmitScriptTarget(compilerOptions); - forEachExternalModuleToImportFrom(program, host, /*useAutoImportProvider*/ true, (moduleSymbol, _moduleFile, program, isFromPackageJson) => { - const checker = program.getTypeChecker(); - const defaultInfo = getDefaultLikeExportInfo(moduleSymbol, checker, compilerOptions); - if (defaultInfo && !checker.isUndefinedSymbol(defaultInfo.symbol)) { - const name = getNameForExportedSymbol(getLocalSymbolForExportDefault(defaultInfo.symbol) || defaultInfo.symbol, target); - result.add(key(name, defaultInfo.symbol, moduleSymbol, checker), { - symbol: defaultInfo.symbol, - moduleSymbol, - exportKind: defaultInfo.exportKind, - exportedSymbolIsTypeOnly: isTypeOnlySymbol(defaultInfo.symbol, checker), - isFromPackageJson, - }); - } - const seenExports = new Map(); - for (const exported of checker.getExportsAndPropertiesOfModule(moduleSymbol)) { - if (exported !== defaultInfo?.symbol && addToSeen(seenExports, exported)) { - result.add(key(getNameForExportedSymbol(exported, target), exported, moduleSymbol, checker), { - symbol: exported, - moduleSymbol, - exportKind: ExportKind.Named, - exportedSymbolIsTypeOnly: isTypeOnlySymbol(exported, checker), - isFromPackageJson, - }); - } - } - }); - - if (cache) { - host.log?.("getSymbolToExportInfoMap: caching results"); - cache.set(result, host.getProjectVersion?.()); - } - host.log?.(`getSymbolToExportInfoMap: done in ${timestamp() - start} ms`); - return wrapMultiMap(result, /*isFromPreviousProjectVersion*/ false); - - function key(importedName: string, alias: Symbol, moduleSymbol: Symbol, checker: TypeChecker) { - const moduleName = stripQuotes(moduleSymbol.name); - const moduleKey = isExternalModuleNameRelative(moduleName) ? "/" : moduleName; - const original = skipAlias(alias, checker); - return `${importedName}|${getSymbolId(original.declarations?.[0].symbol || original)}|${moduleKey}`; - } - - function parseKey(key: string) { - const symbolName = key.substring(0, key.indexOf("|")); - const moduleKey = key.substring(key.lastIndexOf("|") + 1); - const ambientModuleName = moduleKey === "/" ? undefined : moduleKey; - return { symbolName, ambientModuleName }; - } - - function wrapMultiMap(map: MultiMap, isFromPreviousProjectVersion: boolean): SymbolToExportInfoMap { - const wrapped: SymbolToExportInfoMap = { - get: (importedName, symbol, moduleSymbol, checker) => { - const info = map.get(key(importedName, symbol, moduleSymbol, checker)); - return isFromPreviousProjectVersion ? info?.map(info => replaceTransientSymbols(info, checker)) : info; - }, - forEach: (getChecker, action) => { - map.forEach((info, key) => { - const { symbolName, ambientModuleName } = parseKey(key); - action( - isFromPreviousProjectVersion ? info.map(i => replaceTransientSymbols(i, getChecker(i.isFromPackageJson))) : info, - symbolName, - !!ambientModuleName); - }); - }, - }; - if (Debug.isDebugging) { - Object.defineProperty(wrapped, "__cache", { get: () => map }); - } - return wrapped; - - function replaceTransientSymbols(info: SymbolExportInfo, checker: TypeChecker) { - if (info.symbol.flags & SymbolFlags.Transient) { - info.symbol = checker.getMergedSymbol(info.symbol.declarations?.[0]?.symbol || info.symbol); - } - if (info.moduleSymbol.flags & SymbolFlags.Transient) { - info.moduleSymbol = checker.getMergedSymbol(info.moduleSymbol.declarations?.[0]?.symbol || info.moduleSymbol); - } - return info; - } - } - } - function isTypeOnlySymbol(s: Symbol, checker: TypeChecker): boolean { return !(skipAlias(s, checker).flags & SymbolFlags.Value); } @@ -501,7 +394,7 @@ namespace ts.codefix { }); } - function getExistingImportDeclarations({ moduleSymbol, exportKind, exportedSymbolIsTypeOnly }: SymbolExportInfo, checker: TypeChecker, importingFile: SourceFile, compilerOptions: CompilerOptions): readonly FixAddToExistingImportInfo[] { + function getExistingImportDeclarations({ moduleSymbol, exportKind, isTypeOnly: exportedSymbolIsTypeOnly }: SymbolExportInfo, checker: TypeChecker, importingFile: SourceFile, compilerOptions: CompilerOptions): readonly FixAddToExistingImportInfo[] { // Can't use an es6 import for a type in JS. if (exportedSymbolIsTypeOnly && isSourceFileJS(importingFile)) return emptyArray; const importKind = getImportKind(importingFile, exportKind, compilerOptions); @@ -565,10 +458,10 @@ namespace ts.codefix { let computedWithoutCacheCount = 0; const fixes = flatMap(moduleSymbols, exportInfo => { const { computedWithoutCache, moduleSpecifiers } = getModuleSpecifiers(exportInfo.moduleSymbol); - computedWithoutCacheCount += Number(computedWithoutCache); + computedWithoutCacheCount += computedWithoutCache ? 1 : 0; return moduleSpecifiers?.map((moduleSpecifier): FixAddNewImport | FixUseImportType => // `position` should only be undefined at a missing jsx namespace, in which case we shouldn't be looking for pure types. - exportInfo.exportedSymbolIsTypeOnly && isJs && position !== undefined + exportInfo.isTypeOnly && isJs && position !== undefined ? { kind: ImportFixKind.ImportType, moduleSpecifier, position, exportInfo } : { kind: ImportFixKind.AddNew, @@ -577,8 +470,9 @@ namespace ts.codefix { useRequire, typeOnly: preferTypeOnlyImport, exportInfo, - }); - }); + } + ); + }); return { computedWithoutCacheCount, fixes }; } @@ -645,7 +539,7 @@ namespace ts.codefix { if (!umdSymbol) return undefined; const symbol = checker.getAliasedSymbol(umdSymbol); const symbolName = umdSymbol.name; - const exportInfos: readonly SymbolExportInfo[] = [{ symbol: umdSymbol, moduleSymbol: symbol, exportKind: ExportKind.UMD, exportedSymbolIsTypeOnly: false, isFromPackageJson: false }]; + const exportInfos: readonly SymbolExportInfo[] = [{ symbol: umdSymbol, moduleSymbol: symbol, moduleFileName: undefined, exportKind: ExportKind.UMD, isTypeOnly: false, isFromPackageJson: false }]; const useRequire = shouldUseRequire(sourceFile, program); const fixes = getImportFixes(exportInfos, symbolName, isIdentifier(token) ? token.getStart(sourceFile) : undefined, /*preferTypeOnlyImport*/ false, useRequire, program, sourceFile, host, preferences); return { fixes, symbolName }; @@ -751,7 +645,7 @@ namespace ts.codefix { !toFile && packageJsonFilter.allowsImportingAmbientModule(moduleSymbol, moduleSpecifierResolutionHost) ) { const checker = program.getTypeChecker(); - originalSymbolToExportInfos.add(getUniqueSymbolId(exportedSymbol, checker).toString(), { symbol: exportedSymbol, moduleSymbol, exportKind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(exportedSymbol, checker), isFromPackageJson }); + originalSymbolToExportInfos.add(getUniqueSymbolId(exportedSymbol, checker).toString(), { symbol: exportedSymbol, moduleSymbol, moduleFileName: toFile?.fileName, exportKind, isTypeOnly: isTypeOnlySymbol(exportedSymbol, checker), isFromPackageJson }); } } forEachExternalModuleToImportFrom(program, host, useAutoImportProvider, (moduleSymbol, sourceFile, program, isFromPackageJson) => { @@ -773,21 +667,6 @@ namespace ts.codefix { return originalSymbolToExportInfos; } - function getDefaultLikeExportInfo(moduleSymbol: Symbol, checker: TypeChecker, compilerOptions: CompilerOptions) { - const exported = getDefaultLikeExportWorker(moduleSymbol, checker); - if (!exported) return undefined; - const { symbol, exportKind } = exported; - const info = getDefaultExportInfoWorker(symbol, checker, compilerOptions); - return info && { symbol, exportKind, ...info }; - } - - function getDefaultLikeExportWorker(moduleSymbol: Symbol, checker: TypeChecker): { readonly symbol: Symbol, readonly exportKind: ExportKind } | undefined { - const exportEquals = checker.resolveExternalModuleSymbol(moduleSymbol); - if (exportEquals !== moduleSymbol) return { symbol: exportEquals, exportKind: ExportKind.ExportEquals }; - const defaultExport = checker.tryGetMemberInModuleExports(InternalSymbolName.Default, moduleSymbol); - if (defaultExport) return { symbol: defaultExport, exportKind: ExportKind.Default }; - } - function getExportEqualsImportKind(importingFile: SourceFile, compilerOptions: CompilerOptions): ImportKind { const allowSyntheticDefaults = getAllowSyntheticDefaultImports(compilerOptions); // 1. 'import =' will not work in es2015+, so the decision is between a default @@ -814,43 +693,6 @@ namespace ts.codefix { return allowSyntheticDefaults ? ImportKind.Default : ImportKind.CommonJS; } - function getDefaultExportInfoWorker(defaultExport: Symbol, checker: TypeChecker, compilerOptions: CompilerOptions): { readonly symbolForMeaning: Symbol, readonly name: string } | undefined { - const localSymbol = getLocalSymbolForExportDefault(defaultExport); - if (localSymbol) return { symbolForMeaning: localSymbol, name: localSymbol.name }; - - const name = getNameForExportDefault(defaultExport); - if (name !== undefined) return { symbolForMeaning: defaultExport, name }; - - if (defaultExport.flags & SymbolFlags.Alias) { - const aliased = checker.getImmediateAliasedSymbol(defaultExport); - if (aliased && aliased.parent) { - // - `aliased` will be undefined if the module is exporting an unresolvable name, - // but we can still offer completions for it. - // - `aliased.parent` will be undefined if the module is exporting `globalThis.something`, - // or another expression that resolves to a global. - return getDefaultExportInfoWorker(aliased, checker, compilerOptions); - } - } - - if (defaultExport.escapedName !== InternalSymbolName.Default && - defaultExport.escapedName !== InternalSymbolName.ExportEquals) { - return { symbolForMeaning: defaultExport, name: defaultExport.getName() }; - } - return { symbolForMeaning: defaultExport, name: getNameForExportedSymbol(defaultExport, compilerOptions.target) }; - } - - function getNameForExportDefault(symbol: Symbol): string | undefined { - return symbol.declarations && firstDefined(symbol.declarations, declaration => { - if (isExportAssignment(declaration)) { - return tryCast(skipOuterExpressions(declaration.expression), isIdentifier)?.text; - } - else if (isExportSpecifier(declaration)) { - Debug.assert(declaration.name.text === InternalSymbolName.Default, "Expected the specifier to be a default export"); - return declaration.propertyName && declaration.propertyName.text; - } - }); - } - function codeActionForFix(context: textChanges.TextChangesContext, sourceFile: SourceFile, symbolName: string, fix: ImportFix, quotePreference: QuotePreference): CodeFixAction { let diag!: DiagnosticAndArguments; const changes = textChanges.ChangeTracker.with(context, tracker => { @@ -1046,34 +888,6 @@ namespace ts.codefix { return some(declarations, decl => !!(getMeaningFromDeclaration(decl) & meaning)); } - export function forEachExternalModuleToImportFrom( - program: Program, - host: LanguageServiceHost, - useAutoImportProvider: boolean, - cb: (module: Symbol, moduleFile: SourceFile | undefined, program: Program, isFromPackageJson: boolean) => void, - ) { - forEachExternalModule(program.getTypeChecker(), program.getSourceFiles(), (module, file) => cb(module, file, program, /*isFromPackageJson*/ false)); - const autoImportProvider = useAutoImportProvider && host.getPackageJsonAutoImportProvider?.(); - if (autoImportProvider) { - const start = timestamp(); - forEachExternalModule(autoImportProvider.getTypeChecker(), autoImportProvider.getSourceFiles(), (module, file) => cb(module, file, autoImportProvider, /*isFromPackageJson*/ true)); - host.log?.(`forEachExternalModuleToImportFrom autoImportProvider: ${timestamp() - start}`); - } - } - - function forEachExternalModule(checker: TypeChecker, allSourceFiles: readonly SourceFile[], cb: (module: Symbol, sourceFile: SourceFile | undefined) => void) { - for (const ambient of checker.getAmbientModules()) { - if (!stringContains(ambient.name, "*")) { - cb(ambient, /*sourceFile*/ undefined); - } - } - for (const sourceFile of allSourceFiles) { - if (isExternalOrCommonJsModule(sourceFile)) { - cb(checker.getMergedSymbol(sourceFile.symbol), sourceFile); - } - } - } - export function moduleSymbolToValidIdentifier(moduleSymbol: Symbol, target: ScriptTarget | undefined): string { return moduleSpecifierToValidIdentifier(removeFileExtension(stripQuotes(moduleSymbol.name)), target); } diff --git a/src/services/completions.ts b/src/services/completions.ts index 13d8be8e09e4e..f5f9824b0ad4e 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -297,7 +297,7 @@ namespace ts.Completions { if (!previousResponse) return undefined; const lowerCaseTokenText = location.text.toLowerCase(); - const exportMap = codefix.getSymbolToExportInfoMap(file, host, program); + const exportMap = getExportInfoMap(file, host, program); const checker = program.getTypeChecker(); const autoImportProvider = host.getPackageJsonAutoImportProvider?.(); const autoImportProviderChecker = autoImportProvider?.getTypeChecker(); @@ -321,9 +321,10 @@ namespace ts.Completions { const { symbol, origin } = Debug.checkDefined(getAutoImportSymbolFromCompletionEntryData(entry.name, entry.data, program, host)); const info = exportMap.get( + file.path, entry.name, - origin.isDefaultExport ? symbol.exportSymbol || symbol : symbol, - origin.moduleSymbol, + symbol, + origin.moduleSymbol.name, origin.isFromPackageJson ? autoImportProviderChecker! : checker); const result = info && context.tryResolve(info, !isExternalModuleNameRelative(stripQuotes(origin.moduleSymbol.name))); @@ -1913,10 +1914,9 @@ namespace ts.Completions { const moduleSpecifierCache = host.getModuleSpecifierCache?.(); const lowerCaseTokenText = previousToken && isIdentifier(previousToken) ? previousToken.text.toLowerCase() : ""; - const exportInfo = codefix.getSymbolToExportInfoMap(sourceFile, host, program); + const exportInfo = getExportInfoMap(sourceFile, host, program); const packageJsonAutoImportProvider = host.getPackageJsonAutoImportProvider?.(); const packageJsonFilter = detailsEntryId ? undefined : createPackageJsonImportFilter(sourceFile, preferences, host); - const getChecker = (isFromPackageJson: boolean) => isFromPackageJson ? packageJsonAutoImportProvider!.getTypeChecker() : typeChecker; resolvingModuleSpecifiers( "collectAutoImports", host, @@ -1925,20 +1925,18 @@ namespace ts.Completions { preferences, !!importCompletionNode, context => { - exportInfo.forEach(getChecker, (info, symbolName, isFromAmbientModule) => { + exportInfo.forEach(sourceFile.path, (info, symbolName, isFromAmbientModule) => { if (!detailsEntryId && isStringANonContextualKeyword(symbolName)) return; const isCompletionDetailsMatch = detailsEntryId && some(info, i => detailsEntryId.source === stripQuotes(i.moduleSymbol.name)); if (isCompletionDetailsMatch || charactersFuzzyMatchInString(symbolName, lowerCaseTokenText)) { - if (isFromAmbientModule && !some(info, isImportableExportInfo)) { + const defaultExportInfo = find(info, isImportableExportInfo); + if (!defaultExportInfo) { return; } // If we don't need to resolve module specifiers, we can use any re-export that is importable at all // (We need to ensure that at least one is importable to show a completion.) - const { exportInfo = find(info, isImportableExportInfo), moduleSpecifier } = context.tryResolve(info, isFromAmbientModule) || {}; - - if (!exportInfo) return; - const moduleFile = tryCast(exportInfo.moduleSymbol.valueDeclaration, isSourceFile); + const { exportInfo = defaultExportInfo, moduleSpecifier } = context.tryResolve(info, isFromAmbientModule) || {}; const isDefaultExport = exportInfo.exportKind === ExportKind.Default; const symbol = isDefaultExport && getLocalSymbolForExportDefault(exportInfo.symbol) || exportInfo.symbol; pushAutoImportSymbol(symbol, { @@ -1946,7 +1944,7 @@ namespace ts.Completions { moduleSpecifier, symbolName, exportName: exportInfo.exportKind === ExportKind.ExportEquals ? InternalSymbolName.ExportEquals : exportInfo.symbol.name, - fileName: moduleFile?.fileName, + fileName: exportInfo.moduleFileName, isDefaultExport, moduleSymbol: exportInfo.moduleSymbol, isFromPackageJson: exportInfo.isFromPackageJson, diff --git a/src/services/exportInfoMap.ts b/src/services/exportInfoMap.ts new file mode 100644 index 0000000000000..78c20881cae3d --- /dev/null +++ b/src/services/exportInfoMap.ts @@ -0,0 +1,411 @@ +/*@internal*/ +namespace ts { + export const enum ImportKind { + Named, + Default, + Namespace, + CommonJS, + } + + export const enum ExportKind { + Named, + Default, + ExportEquals, + UMD, + } + + export interface SymbolExportInfo { + readonly symbol: Symbol; + readonly moduleSymbol: Symbol; + /** Set if `moduleSymbol` is an external module, not an ambient module */ + moduleFileName: string | undefined; + exportKind: ExportKind; + /** If true, can't use an es6 import from a js file. */ + isTypeOnly: boolean; + /** True if export was only found via the package.json AutoImportProvider (for telemetry). */ + isFromPackageJson: boolean; + } + + interface CachedSymbolExportInfo { + // Used to rehydrate `symbol` and `moduleSymbol` when transient + id: number; + symbolName: string; + moduleName: string; + moduleFile: SourceFile | undefined; + + // SymbolExportInfo, but optional symbols + readonly symbol: Symbol | undefined; + readonly moduleSymbol: Symbol | undefined; + moduleFileName: string | undefined; + exportKind: ExportKind; + isTypeOnly: boolean; + isFromPackageJson: boolean; + } + + export interface ExportInfoMap { + isUsableByFile(importingFile: Path): boolean; + clear(): void; + add(importingFile: Path, symbol: Symbol, moduleSymbol: Symbol, moduleFile: SourceFile | undefined, exportKind: ExportKind, isFromPackageJson: boolean, scriptTarget: ScriptTarget, checker: TypeChecker): void; + get(importingFile: Path, importedName: string, symbol: Symbol, moduleName: string, checker: TypeChecker): readonly SymbolExportInfo[] | undefined; + forEach(importingFile: Path, action: (info: readonly SymbolExportInfo[], name: string, isFromAmbientModule: boolean) => void): void; + releaseSymbols(): void; + isEmpty(): boolean; + /** @returns Whether the change resulted in the cache being cleared */ + onFileChanged(oldSourceFile: SourceFile, newSourceFile: SourceFile, typeAcquisitionEnabled: boolean): boolean; + } + + export interface CacheableExportInfoMapHost { + getCurrentProgram(): Program | undefined; + getPackageJsonAutoImportProvider(): Program | undefined; + } + + export function createCacheableExportInfoMap(host: CacheableExportInfoMapHost): ExportInfoMap { + let exportInfoId = 1; + const exportInfo = createMultiMap(); + const symbols = new Map(); + let usableByFileName: Path | undefined; + const cache: ExportInfoMap = { + isUsableByFile: importingFile => importingFile === usableByFileName, + isEmpty: () => !exportInfo.size, + clear: () => { + exportInfo.clear(); + symbols.clear(); + usableByFileName = undefined; + }, + add: (importingFile, symbol, moduleSymbol, moduleFile, exportKind, isFromPackageJson, scriptTarget, checker) => { + if (importingFile !== usableByFileName) { + cache.clear(); + usableByFileName = importingFile; + } + const isDefault = exportKind === ExportKind.Default; + const importedName = getNameForExportedSymbol(isDefault && getLocalSymbolForExportDefault(symbol) || symbol, scriptTarget); + const moduleName = stripQuotes(moduleSymbol.name); + const id = exportInfoId++; + const storedSymbol = symbol.flags & SymbolFlags.Transient ? undefined : symbol; + const storedModuleSymbol = moduleSymbol.flags & SymbolFlags.Transient ? undefined : moduleSymbol; + if (!storedSymbol || !storedModuleSymbol) symbols.set(id, [symbol, moduleSymbol]); + + exportInfo.add(key(importedName, symbol, moduleName, checker), { + id, + symbolName: importedName, + moduleName, + moduleFile, + moduleFileName: moduleFile?.fileName, + exportKind, + isTypeOnly: !(skipAlias(symbol, checker).flags & SymbolFlags.Value), + isFromPackageJson, + symbol: storedSymbol, + moduleSymbol: storedModuleSymbol, + }); + }, + get: (importingFile, importedName, symbol, moduleName, checker) => { + if (importingFile !== usableByFileName) return; + const result = exportInfo.get(key(importedName, symbol, moduleName, checker)); + return result?.map(rehydrateCachedInfo); + }, + forEach: (importingFile, action) => { + if (importingFile !== usableByFileName) return; + exportInfo.forEach((info, key) => { + const { symbolName, ambientModuleName } = parseKey(key); + action(info.map(rehydrateCachedInfo), symbolName, !!ambientModuleName); + }); + }, + releaseSymbols: () => { + symbols.clear(); + }, + onFileChanged: (oldSourceFile: SourceFile, newSourceFile: SourceFile, typeAcquisitionEnabled: boolean) => { + if (fileIsGlobalOnly(oldSourceFile) && fileIsGlobalOnly(newSourceFile)) { + // File is purely global; doesn't affect export map + return false; + } + if ( + usableByFileName && usableByFileName !== newSourceFile.path || + // If ATA is enabled, auto-imports uses existing imports to guess whether you want auto-imports from node. + // Adding or removing imports from node could change the outcome of that guess, so could change the suggestions list. + typeAcquisitionEnabled && consumesNodeCoreModules(oldSourceFile) !== consumesNodeCoreModules(newSourceFile) || + // Module agumentation and ambient module changes can add or remove exports available to be auto-imported. + // Changes elsewhere in the file can change the *type* of an export in a module augmentation, + // but type info is gathered in getCompletionEntryDetails, which doesn’t use the cache. + !arrayIsEqualTo(oldSourceFile.moduleAugmentations, newSourceFile.moduleAugmentations) || + !ambientModuleDeclarationsAreEqual(oldSourceFile, newSourceFile) + ) { + cache.clear(); + return true; + } + usableByFileName = newSourceFile.path; + return false; + }, + }; + if (Debug.isDebugging) { + Object.defineProperty(cache, "__cache", { get: () => exportInfo }); + } + return cache; + + function rehydrateCachedInfo(info: CachedSymbolExportInfo): SymbolExportInfo { + if (info.symbol && info.moduleSymbol) return info as SymbolExportInfo; + const { id, exportKind, isTypeOnly, isFromPackageJson, moduleFileName } = info; + const [cachedSymbol, cachedModuleSymbol] = symbols.get(id) || emptyArray; + if (cachedSymbol && cachedModuleSymbol) { + return { + symbol: cachedSymbol, + moduleSymbol: cachedModuleSymbol, + moduleFileName, + exportKind, + isTypeOnly, + isFromPackageJson, + }; + } + const checker = (isFromPackageJson + ? host.getPackageJsonAutoImportProvider()! + : host.getCurrentProgram()!).getTypeChecker(); + const moduleSymbol = info.moduleSymbol || cachedModuleSymbol || Debug.checkDefined(info.moduleFile + ? checker.getMergedSymbol(info.moduleFile.symbol) + : checker.tryFindAmbientModule(info.moduleName)); + const symbolName = exportKind === ExportKind.Default + ? InternalSymbolName.Default + : info.symbolName; + const symbol = info.symbol || cachedSymbol || Debug.checkDefined(exportKind === ExportKind.ExportEquals + ? checker.resolveExternalModuleSymbol(moduleSymbol) + : checker.tryGetMemberInModuleExportsAndProperties(symbolName, moduleSymbol)); + symbols.set(id, [symbol, moduleSymbol]); + return { + symbol, + moduleSymbol, + moduleFileName, + exportKind, + isTypeOnly, + isFromPackageJson, + }; + } + + function key(importedName: string, symbol: Symbol, moduleName: string, checker: TypeChecker) { + const unquoted = stripQuotes(moduleName); + const moduleKey = isExternalModuleNameRelative(unquoted) ? "/" : unquoted; + const target = skipAlias(symbol, checker); + return `${importedName}|${createSymbolKey(target)}|${moduleKey}`; + } + + function parseKey(key: string) { + const symbolName = key.substring(0, key.indexOf("|")); + const moduleKey = key.substring(key.lastIndexOf("|") + 1); + const ambientModuleName = moduleKey === "/" ? undefined : moduleKey; + return { symbolName, ambientModuleName }; + } + + function createSymbolKey(symbol: Symbol) { + let key = symbol.name; + while (symbol.parent) { + key += `,${symbol.parent.name}`; + symbol = symbol.parent; + } + return key; + } + + function fileIsGlobalOnly(file: SourceFile) { + return !file.commonJsModuleIndicator && !file.externalModuleIndicator && !file.moduleAugmentations && !file.ambientModuleNames; + } + + function ambientModuleDeclarationsAreEqual(oldSourceFile: SourceFile, newSourceFile: SourceFile) { + if (!arrayIsEqualTo(oldSourceFile.ambientModuleNames, newSourceFile.ambientModuleNames)) { + return false; + } + let oldFileStatementIndex = -1; + let newFileStatementIndex = -1; + for (const ambientModuleName of newSourceFile.ambientModuleNames) { + const isMatchingModuleDeclaration = (node: Statement) => isNonGlobalAmbientModule(node) && node.name.text === ambientModuleName; + oldFileStatementIndex = findIndex(oldSourceFile.statements, isMatchingModuleDeclaration, oldFileStatementIndex + 1); + newFileStatementIndex = findIndex(newSourceFile.statements, isMatchingModuleDeclaration, newFileStatementIndex + 1); + if (oldSourceFile.statements[oldFileStatementIndex] !== newSourceFile.statements[newFileStatementIndex]) { + return false; + } + } + return true; + } + } + + export function isImportableFile( + program: Program, + from: SourceFile, + to: SourceFile, + preferences: UserPreferences, + packageJsonFilter: PackageJsonImportFilter | undefined, + moduleSpecifierResolutionHost: ModuleSpecifierResolutionHost, + moduleSpecifierCache: ModuleSpecifierCache | undefined, + ): boolean { + if (from === to) return false; + const cachedResult = moduleSpecifierCache?.get(from.path, to.path, preferences); + if (cachedResult?.isAutoImportable !== undefined) { + return cachedResult.isAutoImportable; + } + + const getCanonicalFileName = hostGetCanonicalFileName(moduleSpecifierResolutionHost); + const globalTypingsCache = moduleSpecifierResolutionHost.getGlobalTypingsCacheLocation?.(); + const hasImportablePath = !!moduleSpecifiers.forEachFileNameOfModule( + from.fileName, + to.fileName, + moduleSpecifierResolutionHost, + /*preferSymlinks*/ false, + toPath => { + const toFile = program.getSourceFile(toPath); + // Determine to import using toPath only if toPath is what we were looking at + // or there doesnt exist the file in the program by the symlink + return (toFile === to || !toFile) && + isImportablePath(from.fileName, toPath, getCanonicalFileName, globalTypingsCache); + } + ); + + if (packageJsonFilter) { + const isAutoImportable = hasImportablePath && packageJsonFilter.allowsImportingSourceFile(to, moduleSpecifierResolutionHost); + moduleSpecifierCache?.setIsAutoImportable(from.path, to.path, preferences, isAutoImportable); + return isAutoImportable; + } + + return hasImportablePath; + } + + /** + * 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 { + // 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)); + return toNodeModulesParent === undefined + || startsWith(getCanonicalFileName(fromPath), toNodeModulesParent) + || (!!globalCachePath && startsWith(getCanonicalFileName(globalCachePath), toNodeModulesParent)); + } + + export function forEachExternalModuleToImportFrom( + program: Program, + host: LanguageServiceHost, + useAutoImportProvider: boolean, + cb: (module: Symbol, moduleFile: SourceFile | undefined, program: Program, isFromPackageJson: boolean) => void, + ) { + forEachExternalModule(program.getTypeChecker(), program.getSourceFiles(), (module, file) => cb(module, file, program, /*isFromPackageJson*/ false)); + const autoImportProvider = useAutoImportProvider && host.getPackageJsonAutoImportProvider?.(); + if (autoImportProvider) { + const start = timestamp(); + forEachExternalModule(autoImportProvider.getTypeChecker(), autoImportProvider.getSourceFiles(), (module, file) => cb(module, file, autoImportProvider, /*isFromPackageJson*/ true)); + host.log?.(`forEachExternalModuleToImportFrom autoImportProvider: ${timestamp() - start}`); + } + } + + function forEachExternalModule(checker: TypeChecker, allSourceFiles: readonly SourceFile[], cb: (module: Symbol, sourceFile: SourceFile | undefined) => void) { + for (const ambient of checker.getAmbientModules()) { + if (!stringContains(ambient.name, "*")) { + cb(ambient, /*sourceFile*/ undefined); + } + } + for (const sourceFile of allSourceFiles) { + if (isExternalOrCommonJsModule(sourceFile)) { + cb(checker.getMergedSymbol(sourceFile.symbol), sourceFile); + } + } + } + + export function getExportInfoMap(importingFile: SourceFile, host: LanguageServiceHost, program: Program): ExportInfoMap { + const start = timestamp(); + // Pulling the AutoImportProvider project will trigger its updateGraph if pending, + // which will invalidate the export map cache if things change, so pull it before + // checking the cache. + host.getPackageJsonAutoImportProvider?.(); + const cache = host.getCachedExportInfoMap?.() || createCacheableExportInfoMap({ + getCurrentProgram: () => program, + getPackageJsonAutoImportProvider: () => host.getPackageJsonAutoImportProvider?.(), + }); + + if (cache.isUsableByFile(importingFile.path)) { + host.log?.("getExportInfoMap: cache hit"); + return cache; + } + + host.log?.("getExportInfoMap: cache miss or empty; calculating new results"); + const compilerOptions = program.getCompilerOptions(); + const scriptTarget = getEmitScriptTarget(compilerOptions); + forEachExternalModuleToImportFrom(program, host, /*useAutoImportProvider*/ true, (moduleSymbol, moduleFile, program, isFromPackageJson) => { + const seenExports = new Map(); + const checker = program.getTypeChecker(); + const defaultInfo = getDefaultLikeExportInfo(moduleSymbol, checker, compilerOptions); + if (defaultInfo && !checker.isUndefinedSymbol(defaultInfo.symbol)) { + cache.add( + importingFile.path, + defaultInfo.symbol, + moduleSymbol, + moduleFile, + defaultInfo.exportKind, + isFromPackageJson, + scriptTarget, + checker); + } + for (const exported of checker.getExportsAndPropertiesOfModule(moduleSymbol)) { + if (exported !== defaultInfo?.symbol && addToSeen(seenExports, exported)) { + cache.add( + importingFile.path, + exported, + moduleSymbol, + moduleFile, + ExportKind.Named, + isFromPackageJson, + scriptTarget, + checker); + } + } + }); + + host.log?.(`getExportInfoMap: done in ${timestamp() - start} ms`); + return cache; + } + + export function getDefaultLikeExportInfo(moduleSymbol: Symbol, checker: TypeChecker, compilerOptions: CompilerOptions) { + const exported = getDefaultLikeExportWorker(moduleSymbol, checker); + if (!exported) return undefined; + const { symbol, exportKind } = exported; + const info = getDefaultExportInfoWorker(symbol, checker, compilerOptions); + return info && { symbol, exportKind, ...info }; + } + + function getDefaultLikeExportWorker(moduleSymbol: Symbol, checker: TypeChecker): { readonly symbol: Symbol, readonly exportKind: ExportKind } | undefined { + const exportEquals = checker.resolveExternalModuleSymbol(moduleSymbol); + if (exportEquals !== moduleSymbol) return { symbol: exportEquals, exportKind: ExportKind.ExportEquals }; + const defaultExport = checker.tryGetMemberInModuleExports(InternalSymbolName.Default, moduleSymbol); + if (defaultExport) return { symbol: defaultExport, exportKind: ExportKind.Default }; + } + + function getDefaultExportInfoWorker(defaultExport: Symbol, checker: TypeChecker, compilerOptions: CompilerOptions): { readonly symbolForMeaning: Symbol, readonly name: string } | undefined { + const localSymbol = getLocalSymbolForExportDefault(defaultExport); + if (localSymbol) return { symbolForMeaning: localSymbol, name: localSymbol.name }; + + const name = getNameForExportDefault(defaultExport); + if (name !== undefined) return { symbolForMeaning: defaultExport, name }; + + if (defaultExport.flags & SymbolFlags.Alias) { + const aliased = checker.getImmediateAliasedSymbol(defaultExport); + if (aliased && aliased.parent) { + // - `aliased` will be undefined if the module is exporting an unresolvable name, + // but we can still offer completions for it. + // - `aliased.parent` will be undefined if the module is exporting `globalThis.something`, + // or another expression that resolves to a global. + return getDefaultExportInfoWorker(aliased, checker, compilerOptions); + } + } + + if (defaultExport.escapedName !== InternalSymbolName.Default && + defaultExport.escapedName !== InternalSymbolName.ExportEquals) { + return { symbolForMeaning: defaultExport, name: defaultExport.getName() }; + } + return { symbolForMeaning: defaultExport, name: getNameForExportedSymbol(defaultExport, compilerOptions.target) }; + } + + function getNameForExportDefault(symbol: Symbol): string | undefined { + return symbol.declarations && firstDefined(symbol.declarations, declaration => { + if (isExportAssignment(declaration)) { + return tryCast(skipOuterExpressions(declaration.expression), isIdentifier)?.text; + } + else if (isExportSpecifier(declaration)) { + Debug.assert(declaration.name.text === InternalSymbolName.Default, "Expected the specifier to be a default export"); + return declaration.propertyName && declaration.propertyName.text; + } + }); + } +} diff --git a/src/services/tsconfig.json b/src/services/tsconfig.json index ba72007a40e2d..f1aa438fefb57 100644 --- a/src/services/tsconfig.json +++ b/src/services/tsconfig.json @@ -11,6 +11,7 @@ "files": [ "types.ts", "utilities.ts", + "exportInfoMap.ts", "classifier.ts", "classifier2020.ts", "stringCompletions.ts", diff --git a/src/services/types.ts b/src/services/types.ts index a7c6238d60cce..4ad9ddfa88946 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -303,7 +303,7 @@ namespace ts { /* @internal */ getPackageJsonsVisibleToFile?(fileName: string, rootDir?: string): readonly PackageJsonInfo[]; /* @internal */ getNearestAncestorDirectoryWithPackageJson?(fileName: string): string | undefined; /* @internal */ getPackageJsonsForAutoImport?(rootDir?: string): readonly PackageJsonInfo[]; - /* @internal */ getExportMapCache?(): ExportMapCache; + /* @internal */ getCachedExportInfoMap?(): ExportInfoMap; /* @internal */ getModuleSpecifierCache?(): ModuleSpecifierCache; /* @internal */ setCompilerHost?(host: CompilerHost): void; /* @internal */ useSourceOfProjectReferenceRedirect?(): boolean; diff --git a/src/services/utilities.ts b/src/services/utilities.ts index 9ce4260c115f9..cba0669f43d9f 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -3167,261 +3167,6 @@ namespace ts { return isInJSFile(declaration) || !findAncestor(declaration, isGlobalScopeAugmentation); } - export const enum ImportKind { - Named, - Default, - Namespace, - CommonJS, - } - - export const enum ExportKind { - Named, - Default, - ExportEquals, - UMD, - } - - /** Information about how a symbol is exported from a module. */ - export interface SymbolExportInfo { - symbol: Symbol; - moduleSymbol: Symbol; - exportKind: ExportKind; - /** If true, can't use an es6 import from a js file. */ - exportedSymbolIsTypeOnly: boolean; - /** True if export was only found via the package.json AutoImportProvider (for telemetry). */ - isFromPackageJson: boolean; - } - - export interface ExportMapCache { - clear(): void; - get(file: Path, checker: TypeChecker): MultiMap | undefined; - getProjectVersion(): string | undefined; - set(suggestions: MultiMap, projectVersion?: string): void; - isEmpty(): boolean; - /** @returns Whether the change resulted in the cache being cleared */ - onFileChanged(oldSourceFile: SourceFile, newSourceFile: SourceFile, typeAcquisitionEnabled: boolean): boolean; - } - export function createExportMapCache(): ExportMapCache { - let cache: MultiMap | undefined; - let projectVersion: string | undefined; - let usableByFileName: Path | undefined; - const wrapped: ExportMapCache = { - isEmpty() { - return !cache; - }, - clear() { - cache = undefined; - projectVersion = undefined; - }, - set(suggestions, version) { - cache = suggestions; - if (version) { - projectVersion = version; - } - }, - get: (file) => { - if (usableByFileName && file !== usableByFileName) { - return undefined; - } - return cache; - }, - getProjectVersion: () => projectVersion, - onFileChanged(oldSourceFile: SourceFile, newSourceFile: SourceFile, typeAcquisitionEnabled: boolean) { - if (fileIsGlobalOnly(oldSourceFile) && fileIsGlobalOnly(newSourceFile)) { - // File is purely global; doesn't affect export map - return false; - } - if ( - usableByFileName && usableByFileName !== newSourceFile.path || - // If ATA is enabled, auto-imports uses existing imports to guess whether you want auto-imports from node. - // Adding or removing imports from node could change the outcome of that guess, so could change the suggestions list. - typeAcquisitionEnabled && consumesNodeCoreModules(oldSourceFile) !== consumesNodeCoreModules(newSourceFile) || - // Module agumentation and ambient module changes can add or remove exports available to be auto-imported. - // Changes elsewhere in the file can change the *type* of an export in a module augmentation, - // but type info is gathered in getCompletionEntryDetails, which doesn’t use the cache. - !arrayIsEqualTo(oldSourceFile.moduleAugmentations, newSourceFile.moduleAugmentations) || - !ambientModuleDeclarationsAreEqual(oldSourceFile, newSourceFile) - ) { - this.clear(); - return true; - } - usableByFileName = newSourceFile.path; - return false; - }, - }; - if (Debug.isDebugging) { - Object.defineProperty(wrapped, "__cache", { get: () => cache }); - } - return wrapped; - - function fileIsGlobalOnly(file: SourceFile) { - return !file.commonJsModuleIndicator && !file.externalModuleIndicator && !file.moduleAugmentations && !file.ambientModuleNames; - } - - function ambientModuleDeclarationsAreEqual(oldSourceFile: SourceFile, newSourceFile: SourceFile) { - if (!arrayIsEqualTo(oldSourceFile.ambientModuleNames, newSourceFile.ambientModuleNames)) { - return false; - } - let oldFileStatementIndex = -1; - let newFileStatementIndex = -1; - for (const ambientModuleName of newSourceFile.ambientModuleNames) { - const isMatchingModuleDeclaration = (node: Statement) => isNonGlobalAmbientModule(node) && node.name.text === ambientModuleName; - oldFileStatementIndex = findIndex(oldSourceFile.statements, isMatchingModuleDeclaration, oldFileStatementIndex + 1); - newFileStatementIndex = findIndex(newSourceFile.statements, isMatchingModuleDeclaration, newFileStatementIndex + 1); - if (oldSourceFile.statements[oldFileStatementIndex] !== newSourceFile.statements[newFileStatementIndex]) { - return false; - } - } - return true; - } - } - - export interface ModuleSpecifierResolutionCacheHost { - watchNodeModulesForPackageJsonChanges(directoryPath: string): FileWatcher; - } - - export function createModuleSpecifierCache(host: ModuleSpecifierResolutionCacheHost): ModuleSpecifierCache { - let containedNodeModulesWatchers: ESMap | undefined; - let cache: ESMap | undefined; - let currentKey: string | undefined; - const result: ModuleSpecifierCache = { - get(fromFileName, toFileName, preferences) { - if (!cache || currentKey !== key(fromFileName, preferences)) return undefined; - return cache.get(toFileName); - }, - set(fromFileName, toFileName, preferences, modulePaths, moduleSpecifiers) { - ensureCache(fromFileName, preferences).set(toFileName, createInfo(modulePaths, moduleSpecifiers, /*isAutoImportable*/ true)); - - // If any module specifiers were generated based off paths in node_modules, - // a package.json file in that package was read and is an input to the cached. - // Instead of watching each individual package.json file, set up a wildcard - // directory watcher for any node_modules referenced and clear the cache when - // it sees any changes. - if (moduleSpecifiers) { - for (const p of modulePaths) { - if (p.isInNodeModules) { - // No trailing slash - const nodeModulesPath = p.path.substring(0, p.path.indexOf(nodeModulesPathPart) + nodeModulesPathPart.length - 1); - if (!containedNodeModulesWatchers?.has(nodeModulesPath)) { - (containedNodeModulesWatchers ||= new Map()).set( - nodeModulesPath, - host.watchNodeModulesForPackageJsonChanges(nodeModulesPath), - ); - } - } - } - } - }, - setModulePaths(fromFileName, toFileName, preferences, modulePaths) { - const cache = ensureCache(fromFileName, preferences); - const info = cache.get(toFileName); - if (info) { - info.modulePaths = modulePaths; - } - else { - cache.set(toFileName, createInfo(modulePaths, /*moduleSpecifiers*/ undefined, /*isAutoImportable*/ undefined)); - } - }, - setIsAutoImportable(fromFileName, toFileName, preferences, isAutoImportable) { - const cache = ensureCache(fromFileName, preferences); - const info = cache.get(toFileName); - if (info) { - info.isAutoImportable = isAutoImportable; - } - else { - cache.set(toFileName, createInfo(/*modulePaths*/ undefined, /*moduleSpecifiers*/ undefined, isAutoImportable)); - } - }, - clear() { - containedNodeModulesWatchers?.forEach(watcher => watcher.close()); - cache?.clear(); - containedNodeModulesWatchers?.clear(); - currentKey = undefined; - }, - count() { - return cache ? cache.size : 0; - } - }; - if (Debug.isDebugging) { - Object.defineProperty(result, "__cache", { get: () => cache }); - } - return result; - - function ensureCache(fromFileName: Path, preferences: UserPreferences) { - const newKey = key(fromFileName, preferences); - if (cache && (currentKey !== newKey)) { - result.clear(); - } - currentKey = newKey; - return cache ||= new Map(); - } - - function key(fromFileName: Path, preferences: UserPreferences) { - return `${fromFileName},${preferences.importModuleSpecifierEnding},${preferences.importModuleSpecifierPreference}`; - } - - function createInfo( - modulePaths: readonly ModulePath[] | undefined, - moduleSpecifiers: readonly string[] | undefined, - isAutoImportable: boolean | undefined, - ): ResolvedModuleSpecifierInfo { - return { modulePaths, moduleSpecifiers, isAutoImportable }; - } - } - - export function isImportableFile( - program: Program, - from: SourceFile, - to: SourceFile, - preferences: UserPreferences, - packageJsonFilter: PackageJsonImportFilter | undefined, - moduleSpecifierResolutionHost: ModuleSpecifierResolutionHost, - moduleSpecifierCache: ModuleSpecifierCache | undefined, - ): boolean { - if (from === to) return false; - const cachedResult = moduleSpecifierCache?.get(from.path, to.path, preferences); - if (cachedResult?.isAutoImportable !== undefined) { - return cachedResult.isAutoImportable; - } - - const getCanonicalFileName = hostGetCanonicalFileName(moduleSpecifierResolutionHost); - const globalTypingsCache = moduleSpecifierResolutionHost.getGlobalTypingsCacheLocation?.(); - const hasImportablePath = !!moduleSpecifiers.forEachFileNameOfModule( - from.fileName, - to.fileName, - moduleSpecifierResolutionHost, - /*preferSymlinks*/ false, - toPath => { - const toFile = program.getSourceFile(toPath); - // Determine to import using toPath only if toPath is what we were looking at - // or there doesnt exist the file in the program by the symlink - return (toFile === to || !toFile) && - isImportablePath(from.fileName, toPath, getCanonicalFileName, globalTypingsCache); - } - ); - - if (packageJsonFilter) { - const isAutoImportable = hasImportablePath && packageJsonFilter.allowsImportingSourceFile(to, moduleSpecifierResolutionHost); - moduleSpecifierCache?.setIsAutoImportable(from.path, to.path, preferences, isAutoImportable); - return isAutoImportable; - } - - return hasImportablePath; - } - - /** - * 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 { - // 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)); - return toNodeModulesParent === undefined - || startsWith(getCanonicalFileName(fromPath), toNodeModulesParent) - || (!!globalCachePath && startsWith(getCanonicalFileName(globalCachePath), toNodeModulesParent)); - } - export function isDeprecatedDeclaration(decl: Declaration) { return !!(getCombinedNodeFlagsAlwaysIncludeJSDoc(decl) & ModifierFlags.Deprecated); } diff --git a/src/testRunner/unittests/tsserver/completionsIncomplete.ts b/src/testRunner/unittests/tsserver/completionsIncomplete.ts index 5ac53ccda78f7..ae9c53cbc9289 100644 --- a/src/testRunner/unittests/tsserver/completionsIncomplete.ts +++ b/src/testRunner/unittests/tsserver/completionsIncomplete.ts @@ -119,6 +119,29 @@ namespace ts.projectSystem { completions.entries.find(entry => (entry.data as any)?.moduleSpecifier?.startsWith("dep-a"))!); }); }); + + it("works for transient symbols between requests", () => { + const constantsDts: File = { + path: "/lib/foo/constants.d.ts", + content: ` + type Signals = "SIGINT" | "SIGABRT"; + declare const exp: {} & { [K in Signals]: K }; + export = exp;`, + }; + const exportingFiles = createExportingModuleFiles("/lib/a", Completions.moduleSpecifierResolutionLimit, 1, i => `S${i}`); + const { typeToTriggerCompletions, session } = setup([tsconfigFile, indexFile, ...exportingFiles, constantsDts]); + openFilesForSession([indexFile], session); + + typeToTriggerCompletions(indexFile.path, "s", completions => { + const sigint = completions.entries.find(e => e.name === "SIGINT"); + assert(sigint); + assert(!(sigint!.data as any).moduleSpecifier); + }) + .continueTyping("i", completions => { + const sigint = completions.entries.find(e => e.name === "SIGINT"); + assert((sigint!.data as any).moduleSpecifier); + }); + }); }); function setup(files: File[]) { @@ -140,11 +163,11 @@ namespace ts.projectSystem { return { host, session, projectService, typeToTriggerCompletions, assertCompletionDetailsOk }; - function typeToTriggerCompletions(fileName: string, typedCharacters: string, cb: (completions: protocol.CompletionInfo) => void) { + function typeToTriggerCompletions(fileName: string, typedCharacters: string, cb?: (completions: protocol.CompletionInfo) => void) { const project = projectService.getDefaultProjectForFile(server.toNormalizedPath(fileName), /*ensureProject*/ true)!; return type(typedCharacters, cb, /*isIncompleteContinuation*/ false); - function type(typedCharacters: string, cb: (completions: protocol.CompletionInfo) => void, isIncompleteContinuation: boolean) { + function type(typedCharacters: string, cb: ((completions: protocol.CompletionInfo) => void) | undefined, isIncompleteContinuation: boolean) { const file = Debug.checkDefined(project.getLanguageService(/*ensureSynchronized*/ true).getProgram()?.getSourceFile(fileName)); const { line, character } = getLineAndCharacterOfPosition(file, file.text.length); const oneBasedEditPosition = { line: line + 1, offset: character + 1 }; @@ -174,7 +197,7 @@ namespace ts.projectSystem { } }).response as protocol.CompletionInfo; - cb(Debug.checkDefined(response)); + cb?.(Debug.checkDefined(response)); return { backspace, continueTyping: (typedCharacters: string, cb: (completions: protocol.CompletionInfo) => void) => { diff --git a/src/testRunner/unittests/tsserver/exportMapCache.ts b/src/testRunner/unittests/tsserver/exportMapCache.ts index 803d8b9d62234..5eb69c514cd1b 100644 --- a/src/testRunner/unittests/tsserver/exportMapCache.ts +++ b/src/testRunner/unittests/tsserver/exportMapCache.ts @@ -23,54 +23,109 @@ namespace ts.projectSystem { path: "/node_modules/mobx/index.d.ts", content: "export declare function observable(): unknown;" }; + const exportEqualsMappedType: File = { + path: "/lib/foo/constants.d.ts", + content: ` + type Signals = "SIGINT" | "SIGABRT"; + declare const exp: {} & { [K in Signals]: K }; + export = exp;`, + }; describe("unittests:: tsserver:: exportMapCache", () => { it("caches auto-imports in the same file", () => { - const { exportMapCache, checker } = setup(); - assert.ok(exportMapCache.get(bTs.path as Path, checker)); + const { exportMapCache } = setup(); + assert.ok(exportMapCache.isUsableByFile(bTs.path as Path)); + assert.ok(!exportMapCache.isEmpty()); }); it("invalidates the cache when new files are added", () => { - const { host, exportMapCache, checker } = setup(); + const { host, exportMapCache } = setup(); host.writeFile("/src/a2.ts", aTs.content); host.runQueuedTimeoutCallbacks(); - assert.isUndefined(exportMapCache.get(bTs.path as Path, checker)); + assert.ok(!exportMapCache.isUsableByFile(bTs.path as Path)); + assert.ok(exportMapCache.isEmpty()); }); it("invalidates the cache when files are deleted", () => { - const { host, projectService, exportMapCache, checker } = setup(); + const { host, projectService, exportMapCache } = setup(); projectService.closeClientFile(aTs.path); host.deleteFile(aTs.path); host.runQueuedTimeoutCallbacks(); - assert.isUndefined(exportMapCache.get(bTs.path as Path, checker)); + assert.ok(!exportMapCache.isUsableByFile(bTs.path as Path)); + assert.ok(exportMapCache.isEmpty()); }); it("does not invalidate the cache when package.json is changed inconsequentially", () => { - const { host, exportMapCache, checker, project } = setup(); + const { host, exportMapCache, project } = setup(); host.writeFile("/package.json", `{ "name": "blah", "dependencies": { "mobx": "*" } }`); host.runQueuedTimeoutCallbacks(); project.getPackageJsonAutoImportProvider(); - assert.ok(exportMapCache.get(bTs.path as Path, checker)); + assert.ok(exportMapCache.isUsableByFile(bTs.path as Path)); + assert.ok(!exportMapCache.isEmpty()); }); it("invalidates the cache when package.json change results in AutoImportProvider change", () => { - const { host, exportMapCache, checker, project } = setup(); + const { host, exportMapCache, project } = setup(); host.writeFile("/package.json", `{}`); host.runQueuedTimeoutCallbacks(); project.getPackageJsonAutoImportProvider(); - assert.isUndefined(exportMapCache.get(bTs.path as Path, checker)); + assert.ok(!exportMapCache.isUsableByFile(bTs.path as Path)); + assert.ok(exportMapCache.isEmpty()); + }); + + it("does not store transient symbols through program updates", () => { + const { exportMapCache, project, session } = setup(); + // SIGINT, exported from /lib/foo/constants.d.ts, is a mapped type property, which will be a transient symbol. + // Transient symbols contain types, which retain the checkers they came from, so are not safe to cache. + // We clear symbols from the cache during updateGraph, leaving only the information about how to re-get them + // (see getters on `CachedSymbolExportInfo`). We can roughly test that this is working by ensuring that + // accessing a transient symbol with two different checkers results in different symbol identities, since + // transient symbols are recreated with every new checker. + const programBefore = project.getCurrentProgram()!; + let sigintPropBefore: readonly SymbolExportInfo[] | undefined; + exportMapCache.forEach(bTs.path as Path, (info, name) => { + if (name === "SIGINT") sigintPropBefore = info; + }); + assert.ok(sigintPropBefore); + assert.ok(sigintPropBefore![0].symbol.flags & SymbolFlags.Transient); + const symbolIdBefore = getSymbolId(sigintPropBefore![0].symbol); + + // Update program without clearing cache + session.executeCommandSeq({ + command: protocol.CommandTypes.UpdateOpen, + arguments: { + changedFiles: [{ + fileName: bTs.path, + textChanges: [{ + newText: " ", + start: { line: 1, offset: 1 }, + end: { line: 1, offset: 1 }, + }] + }] + } + }); + project.getLanguageService(/*ensureSynchronized*/ true); + assert.notEqual(programBefore, project.getCurrentProgram()!); + + // Get same info from cache again + let sigintPropAfter: readonly SymbolExportInfo[] | undefined; + exportMapCache.forEach(bTs.path as Path, (info, name) => { + if (name === "SIGINT") sigintPropAfter = info; + }); + assert.ok(sigintPropAfter); + assert.notEqual(symbolIdBefore, getSymbolId(sigintPropAfter![0].symbol)); }); }); function setup() { - const host = createServerHost([aTs, bTs, ambientDeclaration, tsconfig, packageJson, mobxDts]); + const host = createServerHost([aTs, bTs, ambientDeclaration, tsconfig, packageJson, mobxDts, exportEqualsMappedType]); const session = createSession(host); openFilesForSession([aTs, bTs], session); const projectService = session.getProjectService(); const project = configuredProjectAt(projectService, 0); triggerCompletions(); const checker = project.getLanguageService().getProgram()!.getTypeChecker(); - return { host, project, projectService, exportMapCache: project.getExportMapCache(), checker, triggerCompletions }; + return { host, project, projectService, session, exportMapCache: project.getCachedExportInfoMap(), checker, triggerCompletions }; function triggerCompletions() { const requestLocation: protocol.FileLocationRequestArgs = {