Skip to content

Commit 1fd7147

Browse files
Fix duplicate auto-import completions (#42850)
* Fix duplicate auto-import completions * Update src/services/completions.ts Co-authored-by: Daniel Rosenwasser <[email protected]> Co-authored-by: Daniel Rosenwasser <[email protected]>
1 parent 05ee94f commit 1fd7147

11 files changed

+41
-133
lines changed

src/compiler/utilities.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -5511,10 +5511,9 @@ namespace ts {
55115511
}
55125512

55135513
/** Add a value to a set, and return true if it wasn't already present. */
5514-
export function addToSeen(seen: ESMap<string, true>, key: string | number): boolean;
5515-
export function addToSeen<T>(seen: ESMap<string, T>, key: string | number, value: T): boolean;
5516-
export function addToSeen<T>(seen: ESMap<string, T>, key: string | number, value: T = true as any): boolean {
5517-
key = String(key);
5514+
export function addToSeen<K>(seen: ESMap<K, true>, key: K): boolean;
5515+
export function addToSeen<K, T>(seen: ESMap<K, T>, key: K, value: T): boolean;
5516+
export function addToSeen<K, T>(seen: ESMap<K, T>, key: K, value: T = true as any): boolean {
55185517
if (seen.has(key)) {
55195518
return false;
55205519
}

src/harness/fourslashImpl.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,7 @@ namespace FourSlash {
933933
assert.equal(actual.sortText, expected.sortText || ts.Completions.SortText.LocationPriority, this.messageAtLastKnownMarker(`Actual entry: ${JSON.stringify(actual)}`));
934934

935935
if (expected.text !== undefined) {
936-
const actualDetails = this.getCompletionEntryDetails(actual.name, actual.source)!;
936+
const actualDetails = ts.Debug.checkDefined(this.getCompletionEntryDetails(actual.name, actual.source), `No completion details available for name '${actual.name}' and source '${actual.source}'`);
937937
assert.equal(ts.displayPartsToString(actualDetails.displayParts), expected.text, "Expected 'text' property to match 'displayParts' string");
938938
assert.equal(ts.displayPartsToString(actualDetails.documentation), expected.documentation || "", "Expected 'documentation' property to match 'documentation' display parts string");
939939
// TODO: GH#23587

src/services/codefixes/convertToTypeOnlyExport.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace ts.codefix {
1212
},
1313
fixIds: [fixId],
1414
getAllCodeActions: context => {
15-
const fixedExportDeclarations = new Map<string, true>();
15+
const fixedExportDeclarations = new Map<number, true>();
1616
return codeFixAll(context, errorCodes, (changes, diag) => {
1717
const exportSpecifier = getExportSpecifierForDiagnosticSpan(diag, context.sourceFile);
1818
if (exportSpecifier && addToSeen(fixedExportDeclarations, getNodeId(exportSpecifier.parent.parent))) {

src/services/codefixes/fixAwaitInSyncFunction.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ namespace ts.codefix {
1616
},
1717
fixIds: [fixId],
1818
getAllCodeActions: context => {
19-
const seen = new Map<string, true>();
19+
const seen = new Map<number, true>();
2020
return codeFixAll(context, errorCodes, (changes, diag) => {
2121
const nodes = getNodes(diag.file, diag.start);
2222
if (!nodes || !addToSeen(seen, getNodeId(nodes.insertBefore))) return;

src/services/codefixes/fixClassDoesntImplementInheritedAbstractMember.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ namespace ts.codefix {
1515
},
1616
fixIds: [fixId],
1717
getAllCodeActions: context => {
18-
const seenClassDeclarations = new Map<string, true>();
18+
const seenClassDeclarations = new Map<number, true>();
1919
return codeFixAll(context, errorCodes, (changes, diag) => {
2020
const classDeclaration = getClass(diag.file, diag.start);
2121
if (addToSeen(seenClassDeclarations, getNodeId(classDeclaration))) {

src/services/codefixes/fixClassIncorrectlyImplementsInterface.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ namespace ts.codefix {
1717
},
1818
fixIds: [fixId],
1919
getAllCodeActions(context) {
20-
const seenClassDeclarations = new Map<string, true>();
20+
const seenClassDeclarations = new Map<number, true>();
2121
return codeFixAll(context, errorCodes, (changes, diag) => {
2222
const classDeclaration = getClass(diag.file, diag.start);
2323
if (addToSeen(seenClassDeclarations, getNodeId(classDeclaration))) {

src/services/codefixes/fixClassSuperMustPrecedeThisAccess.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ namespace ts.codefix {
1515
fixIds: [fixId],
1616
getAllCodeActions(context) {
1717
const { sourceFile } = context;
18-
const seenClasses = new Map<string, true>(); // Ensure we only do this once per class.
18+
const seenClasses = new Map<number, true>(); // Ensure we only do this once per class.
1919
return codeFixAll(context, errorCodes, (changes, diag) => {
2020
const nodes = getNodes(diag.file, diag.start);
2121
if (!nodes) return;

src/services/completions.ts

+29-110
Original file line numberDiff line numberDiff line change
@@ -1645,7 +1645,7 @@ namespace ts.Completions {
16451645
}
16461646

16471647
/** True if symbol is a type or a module containing at least one type. */
1648-
function symbolCanBeReferencedAtTypeLocation(symbol: Symbol, seenModules = new Map<string, true>()): boolean {
1648+
function symbolCanBeReferencedAtTypeLocation(symbol: Symbol, seenModules = new Map<SymbolId, true>()): boolean {
16491649
const sym = skipAlias(symbol.exportSymbol || symbol, typeChecker);
16501650
return !!(sym.flags & SymbolFlags.Type) ||
16511651
!!(sym.flags & SymbolFlags.Module) &&
@@ -1655,50 +1655,7 @@ namespace ts.Completions {
16551655

16561656
/**
16571657
* Gathers symbols that can be imported from other files, de-duplicating along the way. Symbols can be "duplicates"
1658-
* if re-exported from another module, e.g. `export { foo } from "./a"`. That syntax creates a fresh symbol, but
1659-
* it’s just an alias to the first, and both have the same name, so we generally want to filter those aliases out,
1660-
* if and only if the the first can be imported (it may be excluded due to package.json filtering in
1661-
* `codefix.forEachExternalModuleToImportFrom`).
1662-
*
1663-
* Example. Imagine a chain of node_modules re-exporting one original symbol:
1664-
*
1665-
* ```js
1666-
* node_modules/x/index.js node_modules/y/index.js node_modules/z/index.js
1667-
* +-----------------------+ +--------------------------+ +--------------------------+
1668-
* | | | | | |
1669-
* | export const foo = 0; | <--- | export { foo } from 'x'; | <--- | export { foo } from 'y'; |
1670-
* | | | | | |
1671-
* +-----------------------+ +--------------------------+ +--------------------------+
1672-
* ```
1673-
*
1674-
* Also imagine three buckets, which we’ll reference soon:
1675-
*
1676-
* ```md
1677-
* | | | | | |
1678-
* | **Bucket A** | | **Bucket B** | | **Bucket C** |
1679-
* | Symbols to | | Aliases to symbols | | Symbols to return |
1680-
* | definitely | | in Buckets A or C | | if nothing better |
1681-
* | return | | (don’t return these) | | comes along |
1682-
* |__________________| |______________________| |___________________|
1683-
* ```
1684-
*
1685-
* We _probably_ want to show `foo` from 'x', but not from 'y' or 'z'. However, if 'x' is not in a package.json, it
1686-
* will not appear in a `forEachExternalModuleToImportFrom` iteration. Furthermore, the order of iterations is not
1687-
* guaranteed, as it is host-dependent. Therefore, when presented with the symbol `foo` from module 'y' alone, we
1688-
* may not be sure whether or not it should go in the list. So, we’ll take the following steps:
1689-
*
1690-
* 1. Resolve alias `foo` from 'y' to the export declaration in 'x', get the symbol there, and see if that symbol is
1691-
* already in Bucket A (symbols we already know will be returned). If it is, put `foo` from 'y' in Bucket B
1692-
* (symbols that are aliases to symbols in Bucket A). If it’s not, put it in Bucket C.
1693-
* 2. Next, imagine we see `foo` from module 'z'. Again, we resolve the alias to the nearest export, which is in 'y'.
1694-
* At this point, if that nearest export from 'y' is in _any_ of the three buckets, we know the symbol in 'z'
1695-
* should never be returned in the final list, so put it in Bucket B.
1696-
* 3. Next, imagine we see `foo` from module 'x', the original. Syntactically, it doesn’t look like a re-export, so
1697-
* we can just check Bucket C to see if we put any aliases to the original in there. If they exist, throw them out.
1698-
* Put this symbol in Bucket A.
1699-
* 4. After we’ve iterated through every symbol of every module, any symbol left in Bucket C means that step 3 didn’t
1700-
* occur for that symbol---that is, the original symbol is not in Bucket A, so we should include the alias. Move
1701-
* everything from Bucket C to Bucket A.
1658+
* if re-exported from another module by the same name, e.g. `export { foo } from "./a"`.
17021659
*/
17031660
function getSymbolsFromOtherSourceFileExports(target: ScriptTarget, host: LanguageServiceHost): readonly AutoImportSuggestion[] {
17041661
const cached = importSuggestionsCache && importSuggestionsCache.get(
@@ -1713,16 +1670,8 @@ namespace ts.Completions {
17131670

17141671
const startTime = timestamp();
17151672
log(`getSymbolsFromOtherSourceFileExports: Recomputing list${detailsEntryId ? " for details entry" : ""}`);
1716-
const seenResolvedModules = new Map<string, true>();
1717-
const seenExports = new Map<string, true>();
1718-
/** Bucket B */
1719-
const aliasesToAlreadyIncludedSymbols = new Map<string, true>();
1720-
/** Bucket C */
1721-
const aliasesToReturnIfOriginalsAreMissing = new Map<string, { alias: Symbol, moduleSymbol: Symbol, isFromPackageJson: boolean }>();
1722-
/** Bucket A */
1723-
const results: AutoImportSuggestion[] = [];
1724-
/** Ids present in `results` for faster lookup */
1725-
const resultSymbolIds = new Map<string, true>();
1673+
const seenResolvedModules = new Map<SymbolId, true>();
1674+
const results = createMultiMap<SymbolId, AutoImportSuggestion>();
17261675

17271676
codefix.forEachExternalModuleToImportFrom(program, host, sourceFile, !detailsEntryId, /*useAutoImportProvider*/ true, (moduleSymbol, _, program, isFromPackageJson) => {
17281677
// Perf -- ignore other modules if this is a request for details
@@ -1744,73 +1693,52 @@ namespace ts.Completions {
17441693
}
17451694

17461695
for (const symbol of typeChecker.getExportsAndPropertiesOfModule(moduleSymbol)) {
1747-
const symbolId = getSymbolId(symbol).toString();
1748-
// `getExportsAndPropertiesOfModule` can include duplicates
1749-
if (!addToSeen(seenExports, symbolId)) {
1750-
continue;
1751-
}
17521696
// If this is `export { _break as break };` (a keyword) -- skip this and prefer the keyword completion.
17531697
if (some(symbol.declarations, d => isExportSpecifier(d) && !!d.propertyName && isIdentifierANonContextualKeyword(d.name))) {
17541698
continue;
17551699
}
17561700

1757-
// If `symbol.parent !== moduleSymbol`, this is an `export * from "foo"` re-export. Those don't create new symbols.
1758-
const isExportStarFromReExport = typeChecker.getMergedSymbol(symbol.parent!) !== resolvedModuleSymbol;
1759-
// If `!!d.parent.parent.moduleSpecifier`, this is `export { foo } from "foo"` re-export, which creates a new symbol (thus isn't caught by the first check).
1760-
if (isExportStarFromReExport || some(symbol.declarations, d => isExportSpecifier(d) && !d.propertyName && !!d.parent.parent.moduleSpecifier)) {
1761-
// Walk the export chain back one module (step 1 or 2 in diagrammed example).
1762-
// Or, in the case of `export * from "foo"`, `symbol` already points to the original export, so just use that.
1763-
const nearestExportSymbol = isExportStarFromReExport ? symbol : getNearestExportSymbol(symbol);
1764-
if (!nearestExportSymbol) continue;
1765-
const nearestExportSymbolId = getSymbolId(nearestExportSymbol).toString();
1766-
const symbolHasBeenSeen = resultSymbolIds.has(nearestExportSymbolId) || aliasesToAlreadyIncludedSymbols.has(nearestExportSymbolId);
1767-
if (!symbolHasBeenSeen) {
1768-
aliasesToReturnIfOriginalsAreMissing.set(nearestExportSymbolId, { alias: symbol, moduleSymbol, isFromPackageJson });
1769-
aliasesToAlreadyIncludedSymbols.set(symbolId, true);
1770-
}
1771-
else {
1772-
// Perf - we know this symbol is an alias to one that’s already covered in `symbols`, so store it here
1773-
// in case another symbol re-exports this one; that way we can short-circuit as soon as we see this symbol id.
1774-
addToSeen(aliasesToAlreadyIncludedSymbols, symbolId);
1775-
}
1776-
}
1777-
else {
1778-
// This is not a re-export, so see if we have any aliases pending and remove them (step 3 in diagrammed example)
1779-
aliasesToReturnIfOriginalsAreMissing.delete(symbolId);
1780-
pushSymbol(symbol, moduleSymbol, isFromPackageJson, /*skipFilter*/ false);
1781-
}
1701+
pushSymbol(symbol, moduleSymbol, isFromPackageJson, /*skipFilter*/ false);
17821702
}
17831703
});
17841704

1785-
// By this point, any potential duplicates that were actually duplicates have been
1786-
// removed, so the rest need to be added. (Step 4 in diagrammed example)
1787-
aliasesToReturnIfOriginalsAreMissing.forEach(({ alias, moduleSymbol, isFromPackageJson }) => pushSymbol(alias, moduleSymbol, isFromPackageJson, /*skipFilter*/ false));
17881705
log(`getSymbolsFromOtherSourceFileExports: ${timestamp() - startTime}`);
1789-
return results;
1706+
return flatten(arrayFrom(results.values()));
17901707

17911708
function pushSymbol(symbol: Symbol, moduleSymbol: Symbol, isFromPackageJson: boolean, skipFilter: boolean) {
17921709
const isDefaultExport = symbol.escapedName === InternalSymbolName.Default;
1710+
const nonLocalSymbol = symbol;
17931711
if (isDefaultExport) {
17941712
symbol = getLocalSymbolForExportDefault(symbol) || symbol;
17951713
}
17961714
if (typeChecker.isUndefinedSymbol(symbol)) {
17971715
return;
17981716
}
1799-
addToSeen(resultSymbolIds, getSymbolId(symbol));
1800-
const origin: SymbolOriginInfoExport = { kind: SymbolOriginInfoKind.Export, moduleSymbol, isDefaultExport, isFromPackageJson };
1801-
results.push({
1802-
symbol,
1803-
symbolName: getNameForExportedSymbol(symbol, target),
1804-
origin,
1805-
skipFilter,
1806-
});
1717+
const original = skipAlias(nonLocalSymbol, typeChecker);
1718+
const symbolName = getNameForExportedSymbol(symbol, target);
1719+
const existingSuggestions = results.get(getSymbolId(original));
1720+
if (!some(existingSuggestions, s => s.symbolName === symbolName && moduleSymbolsAreDuplicateOrigins(moduleSymbol, s.origin.moduleSymbol))) {
1721+
const origin: SymbolOriginInfoExport = { kind: SymbolOriginInfoKind.Export, moduleSymbol, isDefaultExport, isFromPackageJson };
1722+
results.add(getSymbolId(original), {
1723+
symbol,
1724+
symbolName,
1725+
origin,
1726+
skipFilter,
1727+
});
1728+
}
18071729
}
18081730
}
18091731

1810-
function getNearestExportSymbol(fromSymbol: Symbol) {
1811-
return findAlias(typeChecker, fromSymbol, alias => {
1812-
return some(alias.declarations, d => isExportSpecifier(d) || !!d.localSymbol);
1813-
});
1732+
/**
1733+
* Determines whether a module symbol is redundant with another for purposes of offering
1734+
* auto-import completions for exports of the same symbol. Exports of the same symbol
1735+
* will not be offered from different external modules, but they will be offered from
1736+
* different ambient modules.
1737+
*/
1738+
function moduleSymbolsAreDuplicateOrigins(a: Symbol, b: Symbol) {
1739+
const ambientNameA = pathIsBareSpecifier(stripQuotes(a.name)) ? a.name : undefined;
1740+
const ambientNameB = pathIsBareSpecifier(stripQuotes(b.name)) ? b.name : undefined;
1741+
return ambientNameA === ambientNameB;
18141742
}
18151743

18161744
/**
@@ -2897,15 +2825,6 @@ namespace ts.Completions {
28972825
return nodeIsMissing(left);
28982826
}
28992827

2900-
function findAlias(typeChecker: TypeChecker, symbol: Symbol, predicate: (symbol: Symbol) => boolean): Symbol | undefined {
2901-
let currentAlias: Symbol | undefined = symbol;
2902-
while (currentAlias.flags & SymbolFlags.Alias && (currentAlias = typeChecker.getImmediateAliasedSymbol(currentAlias))) {
2903-
if (predicate(currentAlias)) {
2904-
return currentAlias;
2905-
}
2906-
}
2907-
}
2908-
29092828
/** Determines if a type is exactly the same type resolved by the global 'self', 'global', or 'globalThis'. */
29102829
function isProbablyGlobalType(type: Type, sourceFile: SourceFile, checker: TypeChecker) {
29112830
// The type of `self` and `window` is the same in lib.dom.d.ts, but `window` does not exist in

src/services/findAllReferences.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ namespace ts.FindAllReferences {
231231
}
232232
else {
233233
const queue = entries && [...entries];
234-
const seenNodes = new Map<string, true>();
234+
const seenNodes = new Map<number, true>();
235235
while (queue && queue.length) {
236236
const entry = queue.shift() as NodeEntry;
237237
if (!addToSeen(seenNodes, getNodeId(entry.node))) {
@@ -2154,7 +2154,7 @@ namespace ts.FindAllReferences {
21542154
* The value of previousIterationSymbol is undefined when the function is first called.
21552155
*/
21562156
function getPropertySymbolsFromBaseTypes<T>(symbol: Symbol, propertyName: string, checker: TypeChecker, cb: (symbol: Symbol) => T | undefined): T | undefined {
2157-
const seen = new Map<string, true>();
2157+
const seen = new Map<SymbolId, true>();
21582158
return recur(symbol);
21592159

21602160
function recur(symbol: Symbol): T | undefined {

src/services/textChanges.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ namespace ts.textChanges {
261261
export class ChangeTracker {
262262
private readonly changes: Change[] = [];
263263
private readonly newFiles: { readonly oldFile: SourceFile | undefined, readonly fileName: string, readonly statements: readonly (Statement | SyntaxKind.NewLineTrivia)[] }[] = [];
264-
private readonly classesWithNodesInsertedAtStart = new Map<string, { readonly node: ClassDeclaration | InterfaceDeclaration | ObjectLiteralExpression, readonly sourceFile: SourceFile }>(); // Set<ClassDeclaration> implemented as Map<node id, ClassDeclaration>
264+
private readonly classesWithNodesInsertedAtStart = new Map<number, { readonly node: ClassDeclaration | InterfaceDeclaration | ObjectLiteralExpression, readonly sourceFile: SourceFile }>(); // Set<ClassDeclaration> implemented as Map<node id, ClassDeclaration>
265265
private readonly deletedNodes: { readonly sourceFile: SourceFile, readonly node: Node | NodeArray<TypeParameterDeclaration> }[] = [];
266266

267267
public static fromContext(context: TextChangesContext): ChangeTracker {

0 commit comments

Comments
 (0)