Skip to content

Add codefix and completions for promoting existing type-only imports to non-type-only #47552

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

Merged
merged 5 commits into from
Jan 26, 2022
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
8 changes: 8 additions & 0 deletions src/compiler/diagnosticMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -6466,6 +6466,14 @@
"category": "Message",
"code": 90054
},
"Remove 'type' from import declaration from \"{0}\"": {
"category": "Message",
"code": 90055
},
"Remove 'type' from import of '{0}' from \"{1}\"": {
"category": "Message",
"code": 90056
},

"Convert function to an ES2015 class": {
"category": "Message",
Expand Down
4 changes: 2 additions & 2 deletions src/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3141,8 +3141,8 @@ namespace ts {
| ImportClause & { readonly isTypeOnly: true, readonly name: Identifier }
| ImportEqualsDeclaration & { readonly isTypeOnly: true }
| NamespaceImport & { readonly parent: ImportClause & { readonly isTypeOnly: true } }
| ImportSpecifier & { readonly parent: NamedImports & { readonly parent: ImportClause & { readonly isTypeOnly: true } } }
| ExportSpecifier & { readonly parent: NamedExports & { readonly parent: ExportDeclaration & { readonly isTypeOnly: true } } }
| ImportSpecifier & ({ readonly isTypeOnly: true } | { readonly parent: NamedImports & { readonly parent: ImportClause & { readonly isTypeOnly: true } } })
| ExportSpecifier & ({ readonly isTypeOnly: true } | { readonly parent: NamedExports & { readonly parent: ExportDeclaration & { readonly isTypeOnly: true } } })
;

/**
Expand Down
157 changes: 133 additions & 24 deletions src/services/codefixes/importFixes.ts

Large diffs are not rendered by default.

66 changes: 56 additions & 10 deletions src/services/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ namespace ts.Completions {
ThisProperty = "ThisProperty/",
/** Auto-import that comes attached to a class member snippet */
ClassMemberSnippet = "ClassMemberSnippet/",
/** A type-only import that needs to be promoted in order to be used at the completion location */
TypeOnlyAlias = "TypeOnlyAlias/",
}

const enum SymbolOriginInfoKind {
Expand All @@ -69,6 +71,7 @@ namespace ts.Completions {
Promise = 1 << 3,
Nullable = 1 << 4,
ResolvedExport = 1 << 5,
TypeOnlyAlias = 1 << 6,

SymbolMemberNoExport = SymbolMember,
SymbolMemberExport = SymbolMember | Export,
Expand Down Expand Up @@ -96,6 +99,10 @@ namespace ts.Completions {
moduleSpecifier: string;
}

interface SymbolOriginInfoTypeOnlyAlias extends SymbolOriginInfo {
declaration: TypeOnlyAliasDeclaration;
}

function originIsThisType(origin: SymbolOriginInfo): boolean {
return !!(origin.kind & SymbolOriginInfoKind.ThisType);
}
Expand Down Expand Up @@ -128,6 +135,10 @@ namespace ts.Completions {
return !!(origin.kind & SymbolOriginInfoKind.Nullable);
}

function originIsTypeOnlyAlias(origin: SymbolOriginInfo | undefined): origin is SymbolOriginInfoTypeOnlyAlias {
return !!(origin && origin.kind & SymbolOriginInfoKind.TypeOnlyAlias);
}

interface UniqueNameSet {
add(name: string): void;
has(name: string): boolean;
Expand Down Expand Up @@ -740,6 +751,10 @@ namespace ts.Completions {
}
}

if (origin?.kind === SymbolOriginInfoKind.TypeOnlyAlias) {
hasAction = true;
}

if (preferences.includeCompletionsWithClassMemberSnippets &&
preferences.includeCompletionsWithInsertText &&
completionKind === CompletionKind.MemberLike &&
Expand Down Expand Up @@ -1168,6 +1183,9 @@ namespace ts.Completions {
if (origin?.kind === SymbolOriginInfoKind.ThisType) {
return CompletionSource.ThisProperty;
}
if (origin?.kind === SymbolOriginInfoKind.TypeOnlyAlias) {
return CompletionSource.TypeOnlyAlias;
}
}

export function getCompletionEntriesFromSymbols(
Expand Down Expand Up @@ -1245,7 +1263,7 @@ namespace ts.Completions {
}

/** True for locals; false for globals, module exports from other files, `this.` completions. */
const shouldShadowLaterSymbols = !origin && !(symbol.parent === undefined && !some(symbol.declarations, d => d.getSourceFile() === location.getSourceFile()));
const shouldShadowLaterSymbols = (!origin || originIsTypeOnlyAlias(origin)) && !(symbol.parent === undefined && !some(symbol.declarations, d => d.getSourceFile() === location.getSourceFile()));
uniques.set(name, shouldShadowLaterSymbols);
insertSorted(entries, entry, compareCompletionEntries, /*allowDuplicates*/ true);
}
Expand All @@ -1261,6 +1279,7 @@ namespace ts.Completions {
};

function shouldIncludeSymbol(symbol: Symbol, symbolToSortTextIdMap: SymbolSortTextIdMap): boolean {
let allFlags = symbol.flags;
if (!isSourceFile(location)) {
// export = /**/ here we want to get all meanings, so any symbol is ok
if (isExportAssignment(location.parent)) {
Expand All @@ -1287,12 +1306,12 @@ namespace ts.Completions {
|| symbolToSortTextIdMap[getSymbolId(symbolOrigin)] === SortTextId.LocationPriority)) {
return false;
}
// Continue with origin symbol
symbol = symbolOrigin;

allFlags |= getCombinedLocalAndExportSymbolFlags(symbolOrigin);

// import m = /**/ <-- It can only access namespace (if typing import = x. this would get member symbols and not namespace)
if (isInRightSideOfInternalImportEqualsDeclaration(location)) {
return !!(symbol.flags & SymbolFlags.Namespace);
return !!(allFlags & SymbolFlags.Namespace);
}

if (isTypeOnlyLocation) {
Expand All @@ -1302,7 +1321,7 @@ namespace ts.Completions {
}

// expressions are value space (which includes the value namespaces)
return !!(getCombinedLocalAndExportSymbolFlags(symbol) & SymbolFlags.Value);
return !!(allFlags & SymbolFlags.Value);
}
}

Expand Down Expand Up @@ -1533,6 +1552,19 @@ namespace ts.Completions {
}
}

if (originIsTypeOnlyAlias(origin)) {
const codeAction = codefix.getPromoteTypeOnlyCompletionAction(
sourceFile,
origin.declaration.name,
program,
host,
formatContext,
preferences);

Debug.assertIsDefined(codeAction, "Expected to have a code action for promoting type-only alias");
return { codeActions: [codeAction], sourceDisplay: undefined };
}

if (!origin || !(originIsExport(origin) || originIsResolvedExport(origin))) {
return { codeActions: undefined, sourceDisplay: undefined };
}
Expand Down Expand Up @@ -2314,14 +2346,23 @@ namespace ts.Completions {
isInSnippetScope = isSnippetScope(scopeNode);

const symbolMeanings = (isTypeOnlyLocation ? SymbolFlags.None : SymbolFlags.Value) | SymbolFlags.Type | SymbolFlags.Namespace | SymbolFlags.Alias;
const typeOnlyAliasNeedsPromotion = previousToken && !isValidTypeOnlyAliasUseSite(previousToken);

symbols = concatenate(symbols, typeChecker.getSymbolsInScope(scopeNode, symbolMeanings));
Debug.assertEachIsDefined(symbols, "getSymbolsInScope() should all be defined");
for (const symbol of symbols) {
for (let i = 0; i < symbols.length; i++) {
const symbol = symbols[i];
if (!typeChecker.isArgumentsSymbol(symbol) &&
!some(symbol.declarations, d => d.getSourceFile() === sourceFile)) {
symbolToSortTextIdMap[getSymbolId(symbol)] = SortTextId.GlobalsOrKeywords;
}
if (typeOnlyAliasNeedsPromotion && !(symbol.flags & SymbolFlags.Value)) {
const typeOnlyAliasDeclaration = symbol.declarations && find(symbol.declarations, isTypeOnlyImportOrExportDeclaration);
if (typeOnlyAliasDeclaration) {
const origin: SymbolOriginInfoTypeOnlyAlias = { kind: SymbolOriginInfoKind.TypeOnlyAlias, declaration: typeOnlyAliasDeclaration };
symbolToOriginInfoMap[i] = origin;
}
}
}

// Need to insert 'this.' before properties of `this` type, so only do that if `includeInsertTextCompletions`
Expand Down Expand Up @@ -3901,10 +3942,15 @@ namespace ts.Completions {

/** True if symbol is a type or a module containing at least one type. */
function symbolCanBeReferencedAtTypeLocation(symbol: Symbol, checker: TypeChecker, seenModules = new Map<SymbolId, true>()): boolean {
const sym = skipAlias(symbol.exportSymbol || symbol, checker);
return !!(sym.flags & SymbolFlags.Type) || checker.isUnknownSymbol(sym) ||
!!(sym.flags & SymbolFlags.Module) && addToSeen(seenModules, getSymbolId(sym)) &&
checker.getExportsOfModule(sym).some(e => symbolCanBeReferencedAtTypeLocation(e, checker, seenModules));
// Since an alias can be merged with a local declaration, we need to test both the alias and its target.
// This code used to just test the result of `skipAlias`, but that would ignore any locally introduced meanings.
return nonAliasCanBeReferencedAtTypeLocation(symbol) || nonAliasCanBeReferencedAtTypeLocation(skipAlias(symbol.exportSymbol || symbol, checker));

function nonAliasCanBeReferencedAtTypeLocation(symbol: Symbol): boolean {
return !!(symbol.flags & SymbolFlags.Type) || checker.isUnknownSymbol(symbol) ||
!!(symbol.flags & SymbolFlags.Module) && addToSeen(seenModules, getSymbolId(symbol)) &&
checker.getExportsOfModule(symbol).some(e => symbolCanBeReferencedAtTypeLocation(e, checker, seenModules));
}
}

function isDeprecated(symbol: Symbol, checker: TypeChecker) {
Expand Down
15 changes: 15 additions & 0 deletions src/services/textChanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ namespace ts.textChanges {
this.deletedNodes.push({ sourceFile, node });
}

/** Stop! Consider using `delete` instead, which has logic for deleting nodes from delimited lists. */
public deleteNode(sourceFile: SourceFile, node: Node, options: ConfigurableStartEnd = { leadingTriviaOption: LeadingTriviaOption.IncludeAll }): void {
this.deleteRange(sourceFile, getAdjustedRange(sourceFile, node, node, options));
}
Expand Down Expand Up @@ -786,6 +787,20 @@ namespace ts.textChanges {
this.insertText(sourceFile, node.getStart(sourceFile), "export ");
}

public insertImportSpecifierAtIndex(sourceFile: SourceFile, importSpecifier: ImportSpecifier, namedImports: NamedImports, index: number) {
const prevSpecifier = namedImports.elements[index - 1];
if (prevSpecifier) {
this.insertNodeInListAfter(sourceFile, prevSpecifier, importSpecifier);
}
else {
this.insertNodeBefore(
sourceFile,
namedImports.elements[0],
importSpecifier,
!positionsAreOnSameLine(namedImports.elements[0].getStart(), namedImports.parent.parent.getStart(), sourceFile));
}
}

/**
* This function should be used to insert nodes in lists when nodes don't carry separators as the part of the node range,
* i.e. arguments in arguments lists, parameters in parameter lists etc.
Expand Down
10 changes: 7 additions & 3 deletions tests/baselines/reference/api/tsserverlibrary.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1720,19 +1720,23 @@ declare namespace ts {
readonly parent: ImportClause & {
readonly isTypeOnly: true;
};
} | ImportSpecifier & {
} | ImportSpecifier & ({
readonly isTypeOnly: true;
} | {
readonly parent: NamedImports & {
readonly parent: ImportClause & {
readonly isTypeOnly: true;
};
};
} | ExportSpecifier & {
}) | ExportSpecifier & ({
readonly isTypeOnly: true;
} | {
readonly parent: NamedExports & {
readonly parent: ExportDeclaration & {
readonly isTypeOnly: true;
};
};
};
});
/**
* This is either an `export =` or an `export default` declaration.
* Unless `isExportEquals` is set, this node was parsed as an `export default`.
Expand Down
10 changes: 7 additions & 3 deletions tests/baselines/reference/api/typescript.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1720,19 +1720,23 @@ declare namespace ts {
readonly parent: ImportClause & {
readonly isTypeOnly: true;
};
} | ImportSpecifier & {
} | ImportSpecifier & ({
readonly isTypeOnly: true;
} | {
readonly parent: NamedImports & {
readonly parent: ImportClause & {
readonly isTypeOnly: true;
};
};
} | ExportSpecifier & {
}) | ExportSpecifier & ({
readonly isTypeOnly: true;
} | {
readonly parent: NamedExports & {
readonly parent: ExportDeclaration & {
readonly isTypeOnly: true;
};
};
};
});
/**
* This is either an `export =` or an `export default` declaration.
* Unless `isExportEquals` is set, this node was parsed as an `export default`.
Expand Down
34 changes: 34 additions & 0 deletions tests/cases/fourslash/completionsImport_promoteTypeOnly1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/// <reference path="fourslash.ts" />
// @module: es2015

// @Filename: /exports.ts
//// export interface SomeInterface {}
//// export class SomePig {}

// @Filename: /a.ts
//// import type { SomeInterface, SomePig } from "./exports.js";
//// new SomePig/**/

verify.completions({
marker: "",
exact: completion.globalsPlus([{
name: "SomePig",
source: completion.CompletionSource.TypeOnlyAlias,
hasAction: true,
}]),
preferences: { includeCompletionsForModuleExports: true },
});

verify.applyCodeActionFromCompletion("", {
name: "SomePig",
source: completion.CompletionSource.TypeOnlyAlias,
description: `Remove 'type' from import declaration from "./exports.js"`,
newFileContent:
`import { SomeInterface, SomePig } from "./exports.js";
new SomePig`,
preferences: {
includeCompletionsForModuleExports: true,
allowIncompleteCompletions: true,
includeInsertTextCompletions: true,
},
});
19 changes: 19 additions & 0 deletions tests/cases/fourslash/completionsImport_promoteTypeOnly2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// <reference path="fourslash.ts" />

// @module: es2015

// @Filename: /exports.ts
//// export interface SomeInterface {}

// @Filename: /a.ts
//// import type { SomeInterface } from "./exports.js";
//// const SomeInterface = {};
//// SomeI/**/

// Should NOT promote this
verify.completions({
marker: "",
includes: [{
name: "SomeInterface"
}]
});
33 changes: 33 additions & 0 deletions tests/cases/fourslash/completionsImport_promoteTypeOnly3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/// <reference path="fourslash.ts" />
// @module: es2015

// @Filename: /exports.ts
//// export interface SomeInterface {}
//// export class SomePig {}

// @Filename: /a.ts
//// import { type SomePig } from "./exports.js";
//// new SomePig/**/

verify.completions({
marker: "",
includes: [{
name: "SomePig",
source: completion.CompletionSource.TypeOnlyAlias,
hasAction: true,
}]
});

verify.applyCodeActionFromCompletion("", {
name: "SomePig",
source: completion.CompletionSource.TypeOnlyAlias,
description: `Remove 'type' from import of 'SomePig' from "./exports.js"`,
newFileContent:
`import { SomePig } from "./exports.js";
new SomePig`,
preferences: {
includeCompletionsForModuleExports: true,
allowIncompleteCompletions: true,
includeInsertTextCompletions: true,
},
});
Loading