Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix completion details auto-imports crashes #52686

Merged
merged 8 commits into from
Feb 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions src/harness/fourslashImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -882,18 +882,28 @@ export class TestState {

public verifyCompletions(options: FourSlashInterface.VerifyCompletionsOptions) {
if (options.marker === undefined) {
this.verifyCompletionsWorker(options);
return this.verifyCompletionsWorker(options);
}
else {
for (const marker of toArray(options.marker)) {
this.goToMarker(marker);
this.verifyCompletionsWorker({ ...options, marker });
if (ts.isArray(options.marker)) {
for (const marker of options.marker) {
this.goToMarker(marker);
this.verifyCompletionsWorker({ ...options, marker });
}
return {
andApplyCodeAction: () => {
this.raiseError(`Cannot apply code action when multiple markers are specified.`);
}
};
}
this.goToMarker(options.marker);
return this.verifyCompletionsWorker({ ...options, marker: options.marker });
}
}

private verifyCompletionsWorker(options: FourSlashInterface.VerifyCompletionsOptions): void {
const actualCompletions = this.getCompletionListAtCaret({ ...options.preferences, triggerCharacter: options.triggerCharacter })!;
private verifyCompletionsWorker(options: FourSlashInterface.VerifyCompletionsOptions) {
const preferences = options.preferences;
const actualCompletions = this.getCompletionListAtCaret({ ...preferences, triggerCharacter: options.triggerCharacter })!;
if (!actualCompletions) {
if (ts.hasProperty(options, "exact") && (options.exact === undefined || ts.isArray(options.exact) && !options.exact.length)) {
return;
Expand All @@ -917,6 +927,7 @@ export class TestState {
}

const nameToEntries = new Map<string, ts.CompletionEntry[]>();
const nameAndSourceToData = new Map<string, ts.CompletionEntryData | false>();
for (const entry of actualCompletions.entries) {
const entries = nameToEntries.get(entry.name);
if (!entries) {
Expand All @@ -934,6 +945,15 @@ export class TestState {
}
entries.push(entry);
}
if (entry.data && entry.source) {
const key = `${entry.name}|${entry.source}`;
if (nameAndSourceToData.has(key)) {
nameAndSourceToData.set(key, false);
}
else {
nameAndSourceToData.set(key, entry.data);
}
}
}

if (ts.hasProperty(options, "exact")) {
Expand Down Expand Up @@ -977,6 +997,26 @@ export class TestState {
}
}
}

return {
andApplyCodeAction: (options: {
name: string,
source: string,
description: string,
newFileContent?: string,
newRangeContent?: string,
}) => {
const { name, source, description, newFileContent, newRangeContent } = options;
const data = nameAndSourceToData.get(`${options.name}|${options.source}`);
if (data === false) {
this.raiseError(`Multiple completion entries found for '${options.name}' from '${options.source}'. This API cannot be used. Use 'verify.applyCodeActionFromCompletion' instead.`);
}
if (data === undefined) {
this.raiseError(`No completion entry found for '${options.name}' from '${options.source}'`);
}
this.applyCodeActionFromCompletion(/*markerName*/ undefined, { name, source, data, description, newFileContent, newRangeContent, preferences });
}
};
}

private verifyCompletionEntry(actual: ts.CompletionEntry, expected: FourSlashInterface.ExpectedCompletionEntry) {
Expand Down Expand Up @@ -2062,7 +2102,11 @@ export class TestState {
}
}
}
Harness.Baseline.runBaseline(baselineFile, annotations + "\n\n" + stringify(result));
Harness.Baseline.runBaseline(baselineFile, annotations + "\n\n" + stringify(result, (key, value) => {
return key === "exportMapKey"
? value.replace(/\|[0-9]+/g, "|*")
: value;
}));
}

private annotateContentWithTooltips<T extends ts.QuickInfo | ts.SignatureHelpItems | {
Expand Down
8 changes: 8 additions & 0 deletions src/harness/fourslashInterfaceImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,17 @@ export class Verify extends VerifyNegatable {
}

public completions(...optionsArray: VerifyCompletionsOptions[]) {
if (optionsArray.length === 1) {
return this.state.verifyCompletions(optionsArray[0]);
}
for (const options of optionsArray) {
this.state.verifyCompletions(options);
}
return {
andApplyCodeAction: () => {
throw new Error("Cannot call andApplyCodeAction on multiple completions requests");
},
};
}

public getInlayHints(expected: readonly VerifyInlayHintsOptions[], span: ts.TextSpan, preference?: ts.UserPreferences) {
Expand Down
36 changes: 23 additions & 13 deletions src/services/codefixes/importFixes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,9 @@ function createImportAdderWorker(sourceFile: SourceFile, program: Program, useAu
const symbolName = getNameForExportedSymbol(exportedSymbol, getEmitScriptTarget(compilerOptions));
const checker = program.getTypeChecker();
const symbol = checker.getMergedSymbol(skipAlias(exportedSymbol, checker));
const exportInfo = getAllExportInfoForSymbol(sourceFile, symbol, symbolName, /*isJsxTagName*/ false, program, host, preferences, cancellationToken);
const exportInfo = getAllExportInfoForSymbol(sourceFile, symbol, symbolName, moduleSymbol, /*isJsxTagName*/ false, program, host, preferences, cancellationToken);
const useRequire = shouldUseRequire(sourceFile, program);
const fix = getImportFixForSymbol(sourceFile, Debug.checkDefined(exportInfo), moduleSymbol, program, /*position*/ undefined, !!isValidTypeOnlyUseSite, useRequire, host, preferences);
const fix = getImportFixForSymbol(sourceFile, Debug.checkDefined(exportInfo), program, /*position*/ undefined, !!isValidTypeOnlyUseSite, useRequire, host, preferences);
if (fix) {
addImport({ fix, symbolName, errorIdentifierText: undefined });
}
Expand Down Expand Up @@ -490,6 +490,7 @@ interface FixAddToExistingImportInfo {
export function getImportCompletionAction(
targetSymbol: Symbol,
moduleSymbol: Symbol,
exportMapKey: string | undefined,
sourceFile: SourceFile,
symbolName: string,
isJsxTagName: boolean,
Expand All @@ -501,15 +502,25 @@ export function getImportCompletionAction(
cancellationToken: CancellationToken,
): { readonly moduleSpecifier: string, readonly codeAction: CodeAction } {
const compilerOptions = program.getCompilerOptions();
let exportInfos;

const exportInfos = pathIsBareSpecifier(stripQuotes(moduleSymbol.name))
? [getSingleExportInfoForSymbol(targetSymbol, moduleSymbol, program, host)]
: getAllExportInfoForSymbol(sourceFile, targetSymbol, symbolName, isJsxTagName, program, host, preferences, cancellationToken);
if (exportMapKey) {
// The new way: `exportMapKey` should be in the `data` of each auto-import completion entry and
// sent back when asking for details.
exportInfos = getExportInfoMap(sourceFile, host, program, preferences, cancellationToken).get(sourceFile.path, exportMapKey);
Debug.assertIsDefined(exportInfos, "Some exportInfo should match the specified exportMapKey");
}
else {
// The old way, kept alive for super old editors that don't give us `data` back.
exportInfos = pathIsBareSpecifier(stripQuotes(moduleSymbol.name))
? [getSingleExportInfoForSymbol(targetSymbol, symbolName, moduleSymbol, program, host)]
: getAllExportInfoForSymbol(sourceFile, targetSymbol, symbolName, moduleSymbol, isJsxTagName, program, host, preferences, cancellationToken);
Debug.assertIsDefined(exportInfos, "Some exportInfo should match the specified symbol / moduleSymbol");
}

Debug.assertIsDefined(exportInfos);
const useRequire = shouldUseRequire(sourceFile, program);
const isValidTypeOnlyUseSite = isValidTypeOnlyAliasUseSite(getTokenAtPosition(sourceFile, position));
const fix = Debug.checkDefined(getImportFixForSymbol(sourceFile, exportInfos, moduleSymbol, program, position, isValidTypeOnlyUseSite, useRequire, host, preferences));
const fix = Debug.checkDefined(getImportFixForSymbol(sourceFile, exportInfos, program, position, isValidTypeOnlyUseSite, useRequire, host, preferences));
return {
moduleSpecifier: fix.moduleSpecifier,
codeAction: codeFixActionToCodeAction(codeActionForFix(
Expand All @@ -532,8 +543,7 @@ export function getPromoteTypeOnlyCompletionAction(sourceFile: SourceFile, symbo
return fix && codeFixActionToCodeAction(codeActionForFix({ host, formatContext, preferences }, sourceFile, symbolName, fix, includeSymbolNameInDescription, compilerOptions, preferences));
}

function getImportFixForSymbol(sourceFile: SourceFile, exportInfos: readonly SymbolExportInfo[], moduleSymbol: Symbol, program: Program, position: number | undefined, isValidTypeOnlyUseSite: boolean, useRequire: boolean, host: LanguageServiceHost, preferences: UserPreferences) {
Debug.assert(exportInfos.some(info => info.moduleSymbol === moduleSymbol || info.symbol.parent === moduleSymbol), "Some exportInfo should match the specified moduleSymbol");
function getImportFixForSymbol(sourceFile: SourceFile, exportInfos: readonly SymbolExportInfo[], program: Program, position: number | undefined, isValidTypeOnlyUseSite: boolean, useRequire: boolean, host: LanguageServiceHost, preferences: UserPreferences) {
const packageJsonImportFilter = createPackageJsonImportFilter(sourceFile, preferences, host);
return getBestFix(getImportFixes(exportInfos, position, isValidTypeOnlyUseSite, useRequire, program, sourceFile, host, preferences).fixes, sourceFile, program, packageJsonImportFilter, host);
}
Expand All @@ -542,17 +552,17 @@ function codeFixActionToCodeAction({ description, changes, commands }: CodeFixAc
return { description, changes, commands };
}

function getAllExportInfoForSymbol(importingFile: SourceFile, symbol: Symbol, symbolName: string, preferCapitalized: boolean, program: Program, host: LanguageServiceHost, preferences: UserPreferences, cancellationToken: CancellationToken | undefined): readonly SymbolExportInfo[] | undefined {
function getAllExportInfoForSymbol(importingFile: SourceFile, symbol: Symbol, symbolName: string, moduleSymbol: Symbol, preferCapitalized: boolean, program: Program, host: LanguageServiceHost, preferences: UserPreferences, cancellationToken: CancellationToken | undefined): readonly SymbolExportInfo[] | undefined {
const getChecker = createGetChecker(program, host);
return getExportInfoMap(importingFile, host, program, preferences, cancellationToken)
.search(importingFile.path, preferCapitalized, name => name === symbolName, info => {
if (skipAlias(info[0].symbol, getChecker(info[0].isFromPackageJson)) === symbol) {
if (skipAlias(info[0].symbol, getChecker(info[0].isFromPackageJson)) === symbol && info.some(i => i.moduleSymbol === moduleSymbol || i.symbol.parent === moduleSymbol)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't recall but I think our own utility for checking if something is in an array is faster.

return info;
}
});
}

function getSingleExportInfoForSymbol(symbol: Symbol, moduleSymbol: Symbol, program: Program, host: LanguageServiceHost): SymbolExportInfo {
function getSingleExportInfoForSymbol(symbol: Symbol, symbolName: string, moduleSymbol: Symbol, program: Program, host: LanguageServiceHost): SymbolExportInfo {
const compilerOptions = program.getCompilerOptions();
const mainProgramInfo = getInfoWithChecker(program.getTypeChecker(), /*isFromPackageJson*/ false);
if (mainProgramInfo) {
Expand All @@ -566,7 +576,7 @@ function getSingleExportInfoForSymbol(symbol: Symbol, moduleSymbol: Symbol, prog
if (defaultInfo && skipAlias(defaultInfo.symbol, checker) === symbol) {
return { symbol: defaultInfo.symbol, moduleSymbol, moduleFileName: undefined, exportKind: defaultInfo.exportKind, targetFlags: skipAlias(symbol, checker).flags, isFromPackageJson };
}
const named = checker.tryGetMemberInModuleExportsAndProperties(symbol.name, moduleSymbol);
const named = checker.tryGetMemberInModuleExportsAndProperties(symbolName, moduleSymbol);
if (named && skipAlias(named, checker) === symbol) {
return { symbol: named, moduleSymbol, moduleFileName: undefined, exportKind: ExportKind.Named, targetFlags: skipAlias(symbol, checker).flags, isFromPackageJson };
}
Expand Down
12 changes: 8 additions & 4 deletions src/services/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ interface SymbolOriginInfoResolvedExport extends SymbolOriginInfo {
symbolName: string;
moduleSymbol: Symbol;
exportName: string;
exportMapKey?: string;
moduleSpecifier: string;
}

Expand Down Expand Up @@ -1924,6 +1925,7 @@ function originToCompletionEntryData(origin: SymbolOriginInfoExport | SymbolOrig
if (originIsResolvedExport(origin)) {
const resolvedData: CompletionEntryDataResolved = {
exportName: origin.exportName,
exportMapKey: origin.exportMapKey,
moduleSpecifier: origin.moduleSpecifier,
ambientModuleName,
fileName: origin.fileName,
Expand All @@ -1948,6 +1950,7 @@ function completionEntryDataToSymbolOriginInfo(data: CompletionEntryData, comple
const resolvedOrigin: SymbolOriginInfoResolvedExport = {
kind: SymbolOriginInfoKind.ResolvedExport,
exportName: data.exportName,
exportMapKey: data.exportMapKey,
moduleSpecifier: data.moduleSpecifier,
symbolName: completionName,
fileName: data.fileName,
Expand Down Expand Up @@ -2454,6 +2457,7 @@ function getCompletionEntryCodeActionsAndSourceDisplay(
const { moduleSpecifier, codeAction } = codefix.getImportCompletionAction(
targetSymbol,
moduleSymbol,
data?.exportMapKey,
sourceFile,
name,
isJsxOpeningTagName,
Expand Down Expand Up @@ -3439,8 +3443,8 @@ function getCompletionData(
// module resolution modes, getting past this point guarantees that we'll be
// able to generate a suitable module specifier, so we can safely show a completion,
// even if we defer computing the module specifier.
const firstImportableExportInfo = find(info, isImportableExportInfo);
if (!firstImportableExportInfo) {
info = filter(info, isImportableExportInfo);
if (!info.length) {
return;
}

Expand All @@ -3457,9 +3461,9 @@ function getCompletionData(
// it should be identical regardless of which one is used. During the subsequent
// `CompletionEntryDetails` request, we'll get all the ExportInfos again and pick
// the best one based on the module specifier it produces.
let exportInfo = firstImportableExportInfo, moduleSpecifier;
let exportInfo = info[0], moduleSpecifier;
if (result !== "skipped") {
({ exportInfo = firstImportableExportInfo, moduleSpecifier } = result);
({ exportInfo = info[0], moduleSpecifier } = result);
}

const isDefaultExport = exportInfo.exportKind === ExportKind.Default;
Expand Down
2 changes: 1 addition & 1 deletion src/services/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1337,6 +1337,7 @@ export interface CompletionEntryDataAutoImport {
* in the case of InternalSymbolName.ExportEquals and InternalSymbolName.Default.
*/
exportName: string;
exportMapKey?: string;
moduleSpecifier?: string;
/** The file name declaring the export's module symbol, if it was an external module */
fileName?: string;
Expand All @@ -1347,7 +1348,6 @@ export interface CompletionEntryDataAutoImport {
}

export interface CompletionEntryDataUnresolved extends CompletionEntryDataAutoImport {
/** The key in the `ExportMapCache` where the completion entry's `SymbolExportInfo[]` is found */
exportMapKey: string;
}

Expand Down
2 changes: 1 addition & 1 deletion tests/baselines/reference/api/tsserverlibrary.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10590,6 +10590,7 @@ declare namespace ts {
* in the case of InternalSymbolName.ExportEquals and InternalSymbolName.Default.
*/
exportName: string;
exportMapKey?: string;
moduleSpecifier?: string;
/** The file name declaring the export's module symbol, if it was an external module */
fileName?: string;
Expand All @@ -10599,7 +10600,6 @@ declare namespace ts {
isPackageJsonImport?: true;
}
interface CompletionEntryDataUnresolved extends CompletionEntryDataAutoImport {
/** The key in the `ExportMapCache` where the completion entry's `SymbolExportInfo[]` is found */
exportMapKey: string;
}
interface CompletionEntryDataResolved extends CompletionEntryDataAutoImport {
Expand Down
2 changes: 1 addition & 1 deletion tests/baselines/reference/api/typescript.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6688,6 +6688,7 @@ declare namespace ts {
* in the case of InternalSymbolName.ExportEquals and InternalSymbolName.Default.
*/
exportName: string;
exportMapKey?: string;
moduleSpecifier?: string;
/** The file name declaring the export's module symbol, if it was an external module */
fileName?: string;
Expand All @@ -6697,7 +6698,6 @@ declare namespace ts {
isPackageJsonImport?: true;
}
interface CompletionEntryDataUnresolved extends CompletionEntryDataAutoImport {
/** The key in the `ExportMapCache` where the completion entry's `SymbolExportInfo[]` is found */
exportMapKey: string;
}
interface CompletionEntryDataResolved extends CompletionEntryDataAutoImport {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"isImportStatementCompletion": true,
"data": {
"exportName": "foo",
"exportMapKey": "foo|*|",
"moduleSpecifier": "./$foo",
"fileName": "/tests/cases/fourslash/$foo.ts"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,7 @@ Info 67 [00:02:07.000] response:
"isImportStatementCompletion": true,
"data": {
"exportName": "foo",
"exportMapKey": "foo|*|",
"moduleSpecifier": "./a",
"fileName": "/src/a.ts"
}
Expand Down Expand Up @@ -993,6 +994,7 @@ Info 67 [00:02:07.000] response:
"isImportStatementCompletion": true,
"data": {
"exportName": "observable",
"exportMapKey": "observable|*|",
"moduleSpecifier": "mobx",
"fileName": "/node_modules/mobx/index.d.ts",
"isPackageJsonImport": true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,7 @@ Info 67 [00:02:07.000] response:
"isImportStatementCompletion": true,
"data": {
"exportName": "foo",
"exportMapKey": "foo|*|",
"moduleSpecifier": "./a",
"fileName": "/src/a.ts"
}
Expand Down Expand Up @@ -993,6 +994,7 @@ Info 67 [00:02:07.000] response:
"isImportStatementCompletion": true,
"data": {
"exportName": "observable",
"exportMapKey": "observable|*|",
"moduleSpecifier": "mobx",
"fileName": "/node_modules/mobx/index.d.ts",
"isPackageJsonImport": true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ verify.completions({
},
], { noLib: true }),
preferences: { includeCompletionsForModuleExports: true },
});

verify.applyCodeActionFromCompletion("", {
}).andApplyCodeAction({
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new fourslash API for calling completionEntryDetails with the exact data that was returned in the completionInfo response, as an editor would do. I was considering making sending data for auto-import completion details requests mandatory, breaking super old editors and about 40 old tests like this one, but ultimately decided against it.

name: "Test1",
source: "/a",
description: `Update import from "./a"`,
Expand Down
Loading