Skip to content

Commit 0d3ff0c

Browse files
authored
Add codefix and completions for promoting existing type-only imports to non-type-only (microsoft#47552)
* Import fix * Wire up completions, add sorting to fix * Fix overlapping changes when there’s only one import specifier * Update API baseline * Add sorting and filtering back to UMD fix
1 parent 3718182 commit 0d3ff0c

17 files changed

+460
-42
lines changed

src/compiler/diagnosticMessages.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6474,6 +6474,14 @@
64746474
"category": "Message",
64756475
"code": 90054
64766476
},
6477+
"Remove 'type' from import declaration from \"{0}\"": {
6478+
"category": "Message",
6479+
"code": 90055
6480+
},
6481+
"Remove 'type' from import of '{0}' from \"{1}\"": {
6482+
"category": "Message",
6483+
"code": 90056
6484+
},
64776485

64786486
"Convert function to an ES2015 class": {
64796487
"category": "Message",

src/compiler/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3142,8 +3142,8 @@ namespace ts {
31423142
| ImportClause & { readonly isTypeOnly: true, readonly name: Identifier }
31433143
| ImportEqualsDeclaration & { readonly isTypeOnly: true }
31443144
| NamespaceImport & { readonly parent: ImportClause & { readonly isTypeOnly: true } }
3145-
| ImportSpecifier & { readonly parent: NamedImports & { readonly parent: ImportClause & { readonly isTypeOnly: true } } }
3146-
| ExportSpecifier & { readonly parent: NamedExports & { readonly parent: ExportDeclaration & { readonly isTypeOnly: true } } }
3145+
| ImportSpecifier & ({ readonly isTypeOnly: true } | { readonly parent: NamedImports & { readonly parent: ImportClause & { readonly isTypeOnly: true } } })
3146+
| ExportSpecifier & ({ readonly isTypeOnly: true } | { readonly parent: NamedExports & { readonly parent: ExportDeclaration & { readonly isTypeOnly: true } } })
31473147
;
31483148

31493149
/**

src/services/codefixes/importFixes.ts

Lines changed: 133 additions & 24 deletions
Large diffs are not rendered by default.

src/services/completions.ts

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ namespace ts.Completions {
6060
ThisProperty = "ThisProperty/",
6161
/** Auto-import that comes attached to a class member snippet */
6262
ClassMemberSnippet = "ClassMemberSnippet/",
63+
/** A type-only import that needs to be promoted in order to be used at the completion location */
64+
TypeOnlyAlias = "TypeOnlyAlias/",
6365
}
6466

6567
const enum SymbolOriginInfoKind {
@@ -69,6 +71,7 @@ namespace ts.Completions {
6971
Promise = 1 << 3,
7072
Nullable = 1 << 4,
7173
ResolvedExport = 1 << 5,
74+
TypeOnlyAlias = 1 << 6,
7275

7376
SymbolMemberNoExport = SymbolMember,
7477
SymbolMemberExport = SymbolMember | Export,
@@ -96,6 +99,10 @@ namespace ts.Completions {
9699
moduleSpecifier: string;
97100
}
98101

102+
interface SymbolOriginInfoTypeOnlyAlias extends SymbolOriginInfo {
103+
declaration: TypeOnlyAliasDeclaration;
104+
}
105+
99106
function originIsThisType(origin: SymbolOriginInfo): boolean {
100107
return !!(origin.kind & SymbolOriginInfoKind.ThisType);
101108
}
@@ -128,6 +135,10 @@ namespace ts.Completions {
128135
return !!(origin.kind & SymbolOriginInfoKind.Nullable);
129136
}
130137

138+
function originIsTypeOnlyAlias(origin: SymbolOriginInfo | undefined): origin is SymbolOriginInfoTypeOnlyAlias {
139+
return !!(origin && origin.kind & SymbolOriginInfoKind.TypeOnlyAlias);
140+
}
141+
131142
interface UniqueNameSet {
132143
add(name: string): void;
133144
has(name: string): boolean;
@@ -740,6 +751,10 @@ namespace ts.Completions {
740751
}
741752
}
742753

754+
if (origin?.kind === SymbolOriginInfoKind.TypeOnlyAlias) {
755+
hasAction = true;
756+
}
757+
743758
if (preferences.includeCompletionsWithClassMemberSnippets &&
744759
preferences.includeCompletionsWithInsertText &&
745760
completionKind === CompletionKind.MemberLike &&
@@ -1168,6 +1183,9 @@ namespace ts.Completions {
11681183
if (origin?.kind === SymbolOriginInfoKind.ThisType) {
11691184
return CompletionSource.ThisProperty;
11701185
}
1186+
if (origin?.kind === SymbolOriginInfoKind.TypeOnlyAlias) {
1187+
return CompletionSource.TypeOnlyAlias;
1188+
}
11711189
}
11721190

11731191
export function getCompletionEntriesFromSymbols(
@@ -1245,7 +1263,7 @@ namespace ts.Completions {
12451263
}
12461264

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

12631281
function shouldIncludeSymbol(symbol: Symbol, symbolToSortTextIdMap: SymbolSortTextIdMap): boolean {
1282+
let allFlags = symbol.flags;
12641283
if (!isSourceFile(location)) {
12651284
// export = /**/ here we want to get all meanings, so any symbol is ok
12661285
if (isExportAssignment(location.parent)) {
@@ -1287,12 +1306,12 @@ namespace ts.Completions {
12871306
|| symbolToSortTextIdMap[getSymbolId(symbolOrigin)] === SortTextId.LocationPriority)) {
12881307
return false;
12891308
}
1290-
// Continue with origin symbol
1291-
symbol = symbolOrigin;
1309+
1310+
allFlags |= getCombinedLocalAndExportSymbolFlags(symbolOrigin);
12921311

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

12981317
if (isTypeOnlyLocation) {
@@ -1302,7 +1321,7 @@ namespace ts.Completions {
13021321
}
13031322

13041323
// expressions are value space (which includes the value namespaces)
1305-
return !!(getCombinedLocalAndExportSymbolFlags(symbol) & SymbolFlags.Value);
1324+
return !!(allFlags & SymbolFlags.Value);
13061325
}
13071326
}
13081327

@@ -1533,6 +1552,19 @@ namespace ts.Completions {
15331552
}
15341553
}
15351554

1555+
if (originIsTypeOnlyAlias(origin)) {
1556+
const codeAction = codefix.getPromoteTypeOnlyCompletionAction(
1557+
sourceFile,
1558+
origin.declaration.name,
1559+
program,
1560+
host,
1561+
formatContext,
1562+
preferences);
1563+
1564+
Debug.assertIsDefined(codeAction, "Expected to have a code action for promoting type-only alias");
1565+
return { codeActions: [codeAction], sourceDisplay: undefined };
1566+
}
1567+
15361568
if (!origin || !(originIsExport(origin) || originIsResolvedExport(origin))) {
15371569
return { codeActions: undefined, sourceDisplay: undefined };
15381570
}
@@ -2314,14 +2346,23 @@ namespace ts.Completions {
23142346
isInSnippetScope = isSnippetScope(scopeNode);
23152347

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

23182351
symbols = concatenate(symbols, typeChecker.getSymbolsInScope(scopeNode, symbolMeanings));
23192352
Debug.assertEachIsDefined(symbols, "getSymbolsInScope() should all be defined");
2320-
for (const symbol of symbols) {
2353+
for (let i = 0; i < symbols.length; i++) {
2354+
const symbol = symbols[i];
23212355
if (!typeChecker.isArgumentsSymbol(symbol) &&
23222356
!some(symbol.declarations, d => d.getSourceFile() === sourceFile)) {
23232357
symbolToSortTextIdMap[getSymbolId(symbol)] = SortTextId.GlobalsOrKeywords;
23242358
}
2359+
if (typeOnlyAliasNeedsPromotion && !(symbol.flags & SymbolFlags.Value)) {
2360+
const typeOnlyAliasDeclaration = symbol.declarations && find(symbol.declarations, isTypeOnlyImportOrExportDeclaration);
2361+
if (typeOnlyAliasDeclaration) {
2362+
const origin: SymbolOriginInfoTypeOnlyAlias = { kind: SymbolOriginInfoKind.TypeOnlyAlias, declaration: typeOnlyAliasDeclaration };
2363+
symbolToOriginInfoMap[i] = origin;
2364+
}
2365+
}
23252366
}
23262367

23272368
// Need to insert 'this.' before properties of `this` type, so only do that if `includeInsertTextCompletions`
@@ -3905,10 +3946,15 @@ namespace ts.Completions {
39053946

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

39143960
function isDeprecated(symbol: Symbol, checker: TypeChecker) {

src/services/textChanges.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ namespace ts.textChanges {
339339
this.deletedNodes.push({ sourceFile, node });
340340
}
341341

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

790+
public insertImportSpecifierAtIndex(sourceFile: SourceFile, importSpecifier: ImportSpecifier, namedImports: NamedImports, index: number) {
791+
const prevSpecifier = namedImports.elements[index - 1];
792+
if (prevSpecifier) {
793+
this.insertNodeInListAfter(sourceFile, prevSpecifier, importSpecifier);
794+
}
795+
else {
796+
this.insertNodeBefore(
797+
sourceFile,
798+
namedImports.elements[0],
799+
importSpecifier,
800+
!positionsAreOnSameLine(namedImports.elements[0].getStart(), namedImports.parent.parent.getStart(), sourceFile));
801+
}
802+
}
803+
789804
/**
790805
* This function should be used to insert nodes in lists when nodes don't carry separators as the part of the node range,
791806
* i.e. arguments in arguments lists, parameters in parameter lists etc.

tests/baselines/reference/api/tsserverlibrary.d.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1720,19 +1720,23 @@ declare namespace ts {
17201720
readonly parent: ImportClause & {
17211721
readonly isTypeOnly: true;
17221722
};
1723-
} | ImportSpecifier & {
1723+
} | ImportSpecifier & ({
1724+
readonly isTypeOnly: true;
1725+
} | {
17241726
readonly parent: NamedImports & {
17251727
readonly parent: ImportClause & {
17261728
readonly isTypeOnly: true;
17271729
};
17281730
};
1729-
} | ExportSpecifier & {
1731+
}) | ExportSpecifier & ({
1732+
readonly isTypeOnly: true;
1733+
} | {
17301734
readonly parent: NamedExports & {
17311735
readonly parent: ExportDeclaration & {
17321736
readonly isTypeOnly: true;
17331737
};
17341738
};
1735-
};
1739+
});
17361740
/**
17371741
* This is either an `export =` or an `export default` declaration.
17381742
* Unless `isExportEquals` is set, this node was parsed as an `export default`.

tests/baselines/reference/api/typescript.d.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1720,19 +1720,23 @@ declare namespace ts {
17201720
readonly parent: ImportClause & {
17211721
readonly isTypeOnly: true;
17221722
};
1723-
} | ImportSpecifier & {
1723+
} | ImportSpecifier & ({
1724+
readonly isTypeOnly: true;
1725+
} | {
17241726
readonly parent: NamedImports & {
17251727
readonly parent: ImportClause & {
17261728
readonly isTypeOnly: true;
17271729
};
17281730
};
1729-
} | ExportSpecifier & {
1731+
}) | ExportSpecifier & ({
1732+
readonly isTypeOnly: true;
1733+
} | {
17301734
readonly parent: NamedExports & {
17311735
readonly parent: ExportDeclaration & {
17321736
readonly isTypeOnly: true;
17331737
};
17341738
};
1735-
};
1739+
});
17361740
/**
17371741
* This is either an `export =` or an `export default` declaration.
17381742
* Unless `isExportEquals` is set, this node was parsed as an `export default`.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/// <reference path="fourslash.ts" />
2+
// @module: es2015
3+
4+
// @Filename: /exports.ts
5+
//// export interface SomeInterface {}
6+
//// export class SomePig {}
7+
8+
// @Filename: /a.ts
9+
//// import type { SomeInterface, SomePig } from "./exports.js";
10+
//// new SomePig/**/
11+
12+
verify.completions({
13+
marker: "",
14+
exact: completion.globalsPlus([{
15+
name: "SomePig",
16+
source: completion.CompletionSource.TypeOnlyAlias,
17+
hasAction: true,
18+
}]),
19+
preferences: { includeCompletionsForModuleExports: true },
20+
});
21+
22+
verify.applyCodeActionFromCompletion("", {
23+
name: "SomePig",
24+
source: completion.CompletionSource.TypeOnlyAlias,
25+
description: `Remove 'type' from import declaration from "./exports.js"`,
26+
newFileContent:
27+
`import { SomeInterface, SomePig } from "./exports.js";
28+
new SomePig`,
29+
preferences: {
30+
includeCompletionsForModuleExports: true,
31+
allowIncompleteCompletions: true,
32+
includeInsertTextCompletions: true,
33+
},
34+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/// <reference path="fourslash.ts" />
2+
3+
// @module: es2015
4+
5+
// @Filename: /exports.ts
6+
//// export interface SomeInterface {}
7+
8+
// @Filename: /a.ts
9+
//// import type { SomeInterface } from "./exports.js";
10+
//// const SomeInterface = {};
11+
//// SomeI/**/
12+
13+
// Should NOT promote this
14+
verify.completions({
15+
marker: "",
16+
includes: [{
17+
name: "SomeInterface"
18+
}]
19+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/// <reference path="fourslash.ts" />
2+
// @module: es2015
3+
4+
// @Filename: /exports.ts
5+
//// export interface SomeInterface {}
6+
//// export class SomePig {}
7+
8+
// @Filename: /a.ts
9+
//// import { type SomePig } from "./exports.js";
10+
//// new SomePig/**/
11+
12+
verify.completions({
13+
marker: "",
14+
includes: [{
15+
name: "SomePig",
16+
source: completion.CompletionSource.TypeOnlyAlias,
17+
hasAction: true,
18+
}]
19+
});
20+
21+
verify.applyCodeActionFromCompletion("", {
22+
name: "SomePig",
23+
source: completion.CompletionSource.TypeOnlyAlias,
24+
description: `Remove 'type' from import of 'SomePig' from "./exports.js"`,
25+
newFileContent:
26+
`import { SomePig } from "./exports.js";
27+
new SomePig`,
28+
preferences: {
29+
includeCompletionsForModuleExports: true,
30+
allowIncompleteCompletions: true,
31+
includeInsertTextCompletions: true,
32+
},
33+
});

0 commit comments

Comments
 (0)