From fce1975fa842a785e98daddeddba7b609e552bd0 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 29 Oct 2021 13:37:10 -0700 Subject: [PATCH] Enable auto imports in member snippet completions --- src/compiler/diagnosticMessages.json | 5 ++ src/harness/fourslashImpl.ts | 2 +- src/services/codeFixProvider.ts | 7 --- src/services/codefixes/importFixes.ts | 7 ++- src/services/completions.ts | 63 +++++++++++++++---- src/services/utilities.ts | 7 +++ .../fourslash/completionsOverridingMethod8.ts | 56 +++++++++++++++++ tests/cases/fourslash/fourslash.ts | 3 +- 8 files changed, 128 insertions(+), 22 deletions(-) create mode 100644 tests/cases/fourslash/completionsOverridingMethod8.ts diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index 8b7568ffb4d85..26d8e39f07084 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -6433,6 +6433,11 @@ "category": "Message", "code": 90053 }, + "Includes imports of types referenced by '{0}'": { + "category": "Message", + "code": 90054 + }, + "Convert function to an ES2015 class": { "category": "Message", "code": 95001 diff --git a/src/harness/fourslashImpl.ts b/src/harness/fourslashImpl.ts index 01f8113efe122..0e8b1825d9e58 100644 --- a/src/harness/fourslashImpl.ts +++ b/src/harness/fourslashImpl.ts @@ -942,7 +942,7 @@ namespace FourSlash { expected = typeof expected === "string" ? { name: expected } : expected; if (actual.insertText !== expected.insertText) { - this.raiseError(`Expected completion insert text to be ${expected.insertText}, got ${actual.insertText}`); + this.raiseError(`Completion insert text did not match: ${showTextDiff(expected.insertText || "", actual.insertText || "")}`); } const convertedReplacementSpan = expected.replacementSpan && ts.createTextSpanFromRange(expected.replacementSpan); if (convertedReplacementSpan?.length) { diff --git a/src/services/codeFixProvider.ts b/src/services/codeFixProvider.ts index 5baf4734002fc..83ddaffa458a0 100644 --- a/src/services/codeFixProvider.ts +++ b/src/services/codeFixProvider.ts @@ -3,13 +3,6 @@ namespace ts.codefix { const errorCodeToFixes = createMultiMap(); const fixIdToRegistration = new Map(); - export type DiagnosticAndArguments = DiagnosticMessage | [DiagnosticMessage, string] | [DiagnosticMessage, string, string]; - function diagnosticToString(diag: DiagnosticAndArguments): string { - return isArray(diag) - ? formatStringFromArgs(getLocaleSpecificMessage(diag[0]), diag.slice(1) as readonly string[]) - : getLocaleSpecificMessage(diag); - } - export function createCodeFixActionWithoutFixAll(fixName: string, changes: FileTextChanges[], description: DiagnosticAndArguments) { return createCodeFixActionWorker(fixName, diagnosticToString(description), changes, /*fixId*/ undefined, /*fixAllDescription*/ undefined); } diff --git a/src/services/codefixes/importFixes.ts b/src/services/codefixes/importFixes.ts index 0b95a88b9307e..6284cb6775569 100644 --- a/src/services/codefixes/importFixes.ts +++ b/src/services/codefixes/importFixes.ts @@ -33,6 +33,7 @@ namespace ts.codefix { }); export interface ImportAdder { + hasFixes(): boolean; addImportFromDiagnostic: (diagnostic: DiagnosticWithLocation, context: CodeFixContextBase) => void; addImportFromExportedSymbol: (exportedSymbol: Symbol, isValidTypeOnlyUseSite?: boolean) => void; writeFixes: (changeTracker: textChanges.ChangeTracker) => void; @@ -59,7 +60,7 @@ namespace ts.codefix { type NewImportsKey = `${0 | 1}|${string}`; /** Use `getNewImportEntry` for access */ const newImports = new Map>(); - return { addImportFromDiagnostic, addImportFromExportedSymbol, writeFixes }; + return { addImportFromDiagnostic, addImportFromExportedSymbol, writeFixes, hasFixes }; function addImportFromDiagnostic(diagnostic: DiagnosticWithLocation, context: CodeFixContextBase) { const info = getFixesInfo(context, diagnostic.code, diagnostic.start, useAutoImportProvider); @@ -217,6 +218,10 @@ namespace ts.codefix { insertImports(changeTracker, sourceFile, newDeclarations, /*blankLineBetween*/ true); } } + + function hasFixes() { + return addToNamespace.length > 0 || importType.length > 0 || addToExisting.size > 0 || newImports.size > 0; + } } // Sorted with the preferred fix coming first. diff --git a/src/services/completions.ts b/src/services/completions.ts index c70efda3f34e7..1d7d88f496d29 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -57,7 +57,9 @@ namespace ts.Completions { */ export enum CompletionSource { /** Completions that require `this.` insertion text */ - ThisProperty = "ThisProperty/" + ThisProperty = "ThisProperty/", + /** Auto-import that comes attached to a class member snippet */ + ClassMemberSnippet = "ClassMemberSnippet/", } const enum SymbolOriginInfoKind { @@ -641,6 +643,7 @@ namespace ts.Completions { let replacementSpan = getReplacementSpanForContextToken(replacementToken); let data: CompletionEntryData | undefined; let isSnippet: true | undefined; + let source = getSourceFromOrigin(origin); let sourceDisplay; let hasAction; @@ -702,7 +705,12 @@ namespace ts.Completions { preferences.includeCompletionsWithInsertText && completionKind === CompletionKind.MemberLike && isClassLikeMemberCompletion(symbol, location)) { - ({ insertText, isSnippet } = getEntryForMemberCompletion(host, program, options, preferences, name, symbol, location, contextToken)); + let importAdder; + ({ insertText, isSnippet, importAdder } = getEntryForMemberCompletion(host, program, options, preferences, name, symbol, location, contextToken)); + if (importAdder?.hasFixes()) { + hasAction = true; + source = CompletionSource.ClassMemberSnippet; + } } const kind = SymbolDisplay.getSymbolKind(typeChecker, symbol, location); @@ -758,7 +766,7 @@ namespace ts.Completions { kind, kindModifiers: SymbolDisplay.getSymbolModifiers(typeChecker, symbol), sortText, - source: getSourceFromOrigin(origin), + source, hasAction: hasAction ? true : undefined, isRecommended: isRecommendedCompletionMatch(symbol, recommendedCompletion, typeChecker) || undefined, insertText, @@ -828,7 +836,7 @@ namespace ts.Completions { symbol: Symbol, location: Node, contextToken: Node | undefined, - ): { insertText: string, isSnippet?: true } { + ): { insertText: string, isSnippet?: true, importAdder?: codefix.ImportAdder } { const classLikeDeclaration = findAncestor(location, isClassLike); if (!classLikeDeclaration) { return { insertText: name }; @@ -921,7 +929,7 @@ namespace ts.Completions { insertText = printer.printSnippetList(ListFormat.MultiLine, factory.createNodeArray(completionNodes), sourceFile); } - return { insertText, isSnippet }; + return { insertText, isSnippet, importAdder }; } function getPresentModifiers(contextToken: Node): ModifierFlags { @@ -1297,6 +1305,7 @@ namespace ts.Completions { location: Node; origin: SymbolOriginInfo | SymbolOriginInfoExport | SymbolOriginInfoResolvedExport | undefined; previousToken: Node | undefined; + contextToken: Node | undefined; readonly isJsxInitializer: IsJsxInitializer; readonly isTypeOnlyLocation: boolean; } @@ -1312,11 +1321,13 @@ namespace ts.Completions { if (entryId.data) { const autoImport = getAutoImportSymbolFromCompletionEntryData(entryId.name, entryId.data, program, host); if (autoImport) { + const { contextToken, previousToken } = getRelevantTokens(position, sourceFile); return { type: "symbol", symbol: autoImport.symbol, location: getTouchingPropertyName(sourceFile, position), - previousToken: findPrecedingToken(position, sourceFile, /*startNode*/ undefined)!, + previousToken, + contextToken, isJsxInitializer: false, isTypeOnlyLocation: false, origin: autoImport.origin, @@ -1333,7 +1344,7 @@ namespace ts.Completions { return { type: "request", request: completionData }; } - const { symbols, literals, location, completionKind, symbolToOriginInfoMap, previousToken, isJsxInitializer, isTypeOnlyLocation } = completionData; + const { symbols, literals, location, completionKind, symbolToOriginInfoMap, contextToken, previousToken, isJsxInitializer, isTypeOnlyLocation } = completionData; const literal = find(literals, l => completionNameForLiteral(sourceFile, preferences, l) === entryId.name); if (literal !== undefined) return { type: "literal", literal }; @@ -1345,8 +1356,8 @@ namespace ts.Completions { return firstDefined(symbols, (symbol, index): SymbolCompletion | undefined => { const origin = symbolToOriginInfoMap[index]; const info = getCompletionEntryDisplayNameForSymbol(symbol, getEmitScriptTarget(compilerOptions), origin, completionKind, completionData.isJsxIdentifierExpected); - return info && info.name === entryId.name && getSourceFromOrigin(origin) === entryId.source - ? { type: "symbol" as const, symbol, location, origin, previousToken, isJsxInitializer, isTypeOnlyLocation } + return info && info.name === entryId.name && (entryId.source === CompletionSource.ClassMemberSnippet && symbol.flags & SymbolFlags.ClassMember || getSourceFromOrigin(origin) === entryId.source) + ? { type: "symbol" as const, symbol, location, origin, contextToken, previousToken, isJsxInitializer, isTypeOnlyLocation } : undefined; }) || { type: "none" }; } @@ -1370,7 +1381,7 @@ namespace ts.Completions { ): CompletionEntryDetails | undefined { const typeChecker = program.getTypeChecker(); const compilerOptions = program.getCompilerOptions(); - const { name } = entryId; + const { name, source, data } = entryId; const contextToken = findPrecedingToken(position, sourceFile); if (isInString(sourceFile, position, contextToken)) { @@ -1396,8 +1407,8 @@ namespace ts.Completions { } } case "symbol": { - const { symbol, location, origin, previousToken } = symbolCompletion; - const { codeActions, sourceDisplay } = getCompletionEntryCodeActionsAndSourceDisplay(origin, symbol, program, host, compilerOptions, sourceFile, position, previousToken, formatContext, preferences, entryId.data); + const { symbol, location, contextToken, origin, previousToken } = symbolCompletion; + const { codeActions, sourceDisplay } = getCompletionEntryCodeActionsAndSourceDisplay(name, location, contextToken, origin, symbol, program, host, compilerOptions, sourceFile, position, previousToken, formatContext, preferences, data, source); return createCompletionDetailsForSymbol(symbol, typeChecker, sourceFile, location, cancellationToken, codeActions, sourceDisplay); // TODO: GH#18217 } case "literal": { @@ -1433,6 +1444,9 @@ namespace ts.Completions { readonly sourceDisplay: SymbolDisplayPart[] | undefined; } function getCompletionEntryCodeActionsAndSourceDisplay( + name: string, + location: Node, + contextToken: Node | undefined, origin: SymbolOriginInfo | SymbolOriginInfoExport | SymbolOriginInfoResolvedExport | undefined, symbol: Symbol, program: Program, @@ -1444,6 +1458,7 @@ namespace ts.Completions { formatContext: formatting.FormatContext, preferences: UserPreferences, data: CompletionEntryData | undefined, + source: string | undefined, ): CodeActionsAndSourceDisplay { if (data?.moduleSpecifier) { const { contextToken, previousToken } = getRelevantTokens(position, sourceFile); @@ -1453,6 +1468,30 @@ namespace ts.Completions { } } + if (source === CompletionSource.ClassMemberSnippet) { + const { importAdder } = getEntryForMemberCompletion( + host, + program, + compilerOptions, + preferences, + name, + symbol, + location, + contextToken); + if (importAdder) { + const changes = textChanges.ChangeTracker.with( + { host, formatContext, preferences }, + importAdder.writeFixes); + return { + sourceDisplay: undefined, + codeActions: [{ + changes, + description: diagnosticToString([Diagnostics.Includes_imports_of_types_referenced_by_0, name]), + }], + }; + } + } + if (!origin || !(originIsExport(origin) || originIsResolvedExport(origin))) { return { codeActions: undefined, sourceDisplay: undefined }; } diff --git a/src/services/utilities.ts b/src/services/utilities.ts index 70ae2dba7437b..30552aa9b9d3b 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -3283,5 +3283,12 @@ namespace ts { return newLineCharacter === "\n" ? NewLineKind.LineFeed : NewLineKind.CarriageReturnLineFeed; } + export type DiagnosticAndArguments = DiagnosticMessage | [DiagnosticMessage, string] | [DiagnosticMessage, string, string]; + export function diagnosticToString(diag: DiagnosticAndArguments): string { + return isArray(diag) + ? formatStringFromArgs(getLocaleSpecificMessage(diag[0]), diag.slice(1) as readonly string[]) + : getLocaleSpecificMessage(diag); + } + // #endregion } diff --git a/tests/cases/fourslash/completionsOverridingMethod8.ts b/tests/cases/fourslash/completionsOverridingMethod8.ts new file mode 100644 index 0000000000000..9724dfee22abe --- /dev/null +++ b/tests/cases/fourslash/completionsOverridingMethod8.ts @@ -0,0 +1,56 @@ +/// + +// @newline: LF + +// @Filename: /types1.ts +//// export interface I { foo: string } + +// @Filename: /types2.ts +//// import { I } from "./types1"; +//// export interface Base { method(p: I): void } + +// @Filename: /index.ts +//// import { Base } from "./types2"; +//// export class C implements Base { +//// /**/ +//// } + +goTo.marker(""); +verify.completions({ + marker: "", + isNewIdentifierLocation: true, + preferences: { + includeCompletionsWithInsertText: true, + includeCompletionsWithSnippetText: false, + includeCompletionsWithClassMemberSnippets: true, + }, + includes: [{ + name: "method", + sortText: completion.SortText.LocationPriority, + replacementSpan: { + fileName: "", + pos: 0, + end: 0, + }, + insertText: "method(p: I): void {\n}\n", + hasAction: true, + source: completion.CompletionSource.ClassMemberSnippet, + }], +}); + +verify.applyCodeActionFromCompletion("", { + preferences: { + includeCompletionsWithInsertText: true, + includeCompletionsWithSnippetText: false, + includeCompletionsWithClassMemberSnippets: true, + }, + name: "method", + source: completion.CompletionSource.ClassMemberSnippet, + description: "Includes imports of types referenced by 'method'", + newFileContent: +`import { I } from "./types1"; +import { Base } from "./types2"; +export class C implements Base { + +}` +}); diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index 25b98d2118a5d..5d5f4bcdaf388 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -850,7 +850,8 @@ declare namespace completion { DeprecatedAutoImportSuggestions = "24" } export const enum CompletionSource { - ThisProperty = "ThisProperty/" + ThisProperty = "ThisProperty/", + ClassMemberSnippet = "ClassMemberSnippet/", } export const globalThisEntry: Entry; export const undefinedVarEntry: Entry;