Skip to content

Commit b82d320

Browse files
authored
Fix organizeImports with type-only imports (#36807)
1 parent 9b518c8 commit b82d320

File tree

3 files changed

+197
-104
lines changed

3 files changed

+197
-104
lines changed

src/services/organizeImports.ts

+129-104
Original file line numberDiff line numberDiff line change
@@ -174,115 +174,134 @@ namespace ts.OrganizeImports {
174174
return importGroup;
175175
}
176176

177-
const { importWithoutClause, defaultImports, namespaceImports, namedImports } = getCategorizedImports(importGroup);
177+
const { importWithoutClause, typeOnlyImports, regularImports } = getCategorizedImports(importGroup);
178178

179179
const coalescedImports: ImportDeclaration[] = [];
180180

181181
if (importWithoutClause) {
182182
coalescedImports.push(importWithoutClause);
183183
}
184184

185-
// Normally, we don't combine default and namespace imports, but it would be silly to
186-
// produce two import declarations in this special case.
187-
if (defaultImports.length === 1 && namespaceImports.length === 1 && namedImports.length === 0) {
188-
// Add the namespace import to the existing default ImportDeclaration.
189-
const defaultImport = defaultImports[0];
190-
coalescedImports.push(
191-
updateImportDeclarationAndClause(defaultImport, defaultImport.importClause!.name, namespaceImports[0].importClause!.namedBindings)); // TODO: GH#18217
185+
for (const group of [regularImports, typeOnlyImports]) {
186+
const isTypeOnly = group === typeOnlyImports;
187+
const { defaultImports, namespaceImports, namedImports } = group;
188+
// Normally, we don't combine default and namespace imports, but it would be silly to
189+
// produce two import declarations in this special case.
190+
if (!isTypeOnly && defaultImports.length === 1 && namespaceImports.length === 1 && namedImports.length === 0) {
191+
// Add the namespace import to the existing default ImportDeclaration.
192+
const defaultImport = defaultImports[0];
193+
coalescedImports.push(
194+
updateImportDeclarationAndClause(defaultImport, defaultImport.importClause!.name, namespaceImports[0].importClause!.namedBindings)); // TODO: GH#18217
192195

193-
return coalescedImports;
194-
}
196+
continue;
197+
}
195198

196-
const sortedNamespaceImports = stableSort(namespaceImports, (i1, i2) =>
197-
compareIdentifiers((i1.importClause!.namedBindings as NamespaceImport).name, (i2.importClause!.namedBindings as NamespaceImport).name)); // TODO: GH#18217
199+
const sortedNamespaceImports = stableSort(namespaceImports, (i1, i2) =>
200+
compareIdentifiers((i1.importClause!.namedBindings as NamespaceImport).name, (i2.importClause!.namedBindings as NamespaceImport).name)); // TODO: GH#18217
198201

199-
for (const namespaceImport of sortedNamespaceImports) {
200-
// Drop the name, if any
201-
coalescedImports.push(
202-
updateImportDeclarationAndClause(namespaceImport, /*name*/ undefined, namespaceImport.importClause!.namedBindings)); // TODO: GH#18217
203-
}
202+
for (const namespaceImport of sortedNamespaceImports) {
203+
// Drop the name, if any
204+
coalescedImports.push(
205+
updateImportDeclarationAndClause(namespaceImport, /*name*/ undefined, namespaceImport.importClause!.namedBindings)); // TODO: GH#18217
206+
}
204207

205-
if (defaultImports.length === 0 && namedImports.length === 0) {
206-
return coalescedImports;
207-
}
208+
if (defaultImports.length === 0 && namedImports.length === 0) {
209+
continue;
210+
}
208211

209-
let newDefaultImport: Identifier | undefined;
210-
const newImportSpecifiers: ImportSpecifier[] = [];
211-
if (defaultImports.length === 1) {
212-
newDefaultImport = defaultImports[0].importClause!.name;
213-
}
214-
else {
215-
for (const defaultImport of defaultImports) {
216-
newImportSpecifiers.push(
217-
createImportSpecifier(createIdentifier("default"), defaultImport.importClause!.name!)); // TODO: GH#18217
212+
let newDefaultImport: Identifier | undefined;
213+
const newImportSpecifiers: ImportSpecifier[] = [];
214+
if (defaultImports.length === 1) {
215+
newDefaultImport = defaultImports[0].importClause!.name;
216+
}
217+
else {
218+
for (const defaultImport of defaultImports) {
219+
newImportSpecifiers.push(
220+
createImportSpecifier(createIdentifier("default"), defaultImport.importClause!.name!)); // TODO: GH#18217
221+
}
218222
}
219-
}
220223

221-
newImportSpecifiers.push(...flatMap(namedImports, i => (i.importClause!.namedBindings as NamedImports).elements)); // TODO: GH#18217
224+
newImportSpecifiers.push(...flatMap(namedImports, i => (i.importClause!.namedBindings as NamedImports).elements)); // TODO: GH#18217
222225

223-
const sortedImportSpecifiers = sortSpecifiers(newImportSpecifiers);
226+
const sortedImportSpecifiers = sortSpecifiers(newImportSpecifiers);
224227

225-
const importDecl = defaultImports.length > 0
226-
? defaultImports[0]
227-
: namedImports[0];
228+
const importDecl = defaultImports.length > 0
229+
? defaultImports[0]
230+
: namedImports[0];
228231

229-
const newNamedImports = sortedImportSpecifiers.length === 0
230-
? newDefaultImport
231-
? undefined
232-
: createNamedImports(emptyArray)
233-
: namedImports.length === 0
234-
? createNamedImports(sortedImportSpecifiers)
235-
: updateNamedImports(namedImports[0].importClause!.namedBindings as NamedImports, sortedImportSpecifiers); // TODO: GH#18217
232+
const newNamedImports = sortedImportSpecifiers.length === 0
233+
? newDefaultImport
234+
? undefined
235+
: createNamedImports(emptyArray)
236+
: namedImports.length === 0
237+
? createNamedImports(sortedImportSpecifiers)
238+
: updateNamedImports(namedImports[0].importClause!.namedBindings as NamedImports, sortedImportSpecifiers); // TODO: GH#18217
236239

237-
coalescedImports.push(
238-
updateImportDeclarationAndClause(importDecl, newDefaultImport, newNamedImports));
240+
// Type-only imports are not allowed to combine
241+
if (isTypeOnly && newDefaultImport && newNamedImports) {
242+
coalescedImports.push(
243+
updateImportDeclarationAndClause(importDecl, newDefaultImport, /*namedBindings*/ undefined));
244+
coalescedImports.push(
245+
updateImportDeclarationAndClause(namedImports[0] ?? importDecl, /*name*/ undefined, newNamedImports));
246+
}
247+
else {
248+
coalescedImports.push(
249+
updateImportDeclarationAndClause(importDecl, newDefaultImport, newNamedImports));
250+
}
251+
}
239252

240253
return coalescedImports;
241254

242-
/*
243-
* Returns entire import declarations because they may already have been rewritten and
244-
* may lack parent pointers. The desired parts can easily be recovered based on the
245-
* categorization.
246-
*
247-
* NB: There may be overlap between `defaultImports` and `namespaceImports`/`namedImports`.
248-
*/
249-
function getCategorizedImports(importGroup: readonly ImportDeclaration[]) {
250-
let importWithoutClause: ImportDeclaration | undefined;
251-
const defaultImports: ImportDeclaration[] = [];
252-
const namespaceImports: ImportDeclaration[] = [];
253-
const namedImports: ImportDeclaration[] = [];
254-
255-
for (const importDeclaration of importGroup) {
256-
if (importDeclaration.importClause === undefined) {
257-
// Only the first such import is interesting - the others are redundant.
258-
// Note: Unfortunately, we will lose trivia that was on this node.
259-
importWithoutClause = importWithoutClause || importDeclaration;
260-
continue;
261-
}
255+
}
262256

263-
const { name, namedBindings } = importDeclaration.importClause;
257+
interface ImportGroup {
258+
defaultImports: ImportDeclaration[];
259+
namespaceImports: ImportDeclaration[];
260+
namedImports: ImportDeclaration[];
261+
}
264262

265-
if (name) {
266-
defaultImports.push(importDeclaration);
267-
}
263+
/*
264+
* Returns entire import declarations because they may already have been rewritten and
265+
* may lack parent pointers. The desired parts can easily be recovered based on the
266+
* categorization.
267+
*
268+
* NB: There may be overlap between `defaultImports` and `namespaceImports`/`namedImports`.
269+
*/
270+
function getCategorizedImports(importGroup: readonly ImportDeclaration[]) {
271+
let importWithoutClause: ImportDeclaration | undefined;
272+
const typeOnlyImports: ImportGroup = { defaultImports: [], namespaceImports: [], namedImports: [] };
273+
const regularImports: ImportGroup = { defaultImports: [], namespaceImports: [], namedImports: [] };
274+
275+
for (const importDeclaration of importGroup) {
276+
if (importDeclaration.importClause === undefined) {
277+
// Only the first such import is interesting - the others are redundant.
278+
// Note: Unfortunately, we will lose trivia that was on this node.
279+
importWithoutClause = importWithoutClause || importDeclaration;
280+
continue;
281+
}
268282

269-
if (namedBindings) {
270-
if (isNamespaceImport(namedBindings)) {
271-
namespaceImports.push(importDeclaration);
272-
}
273-
else {
274-
namedImports.push(importDeclaration);
275-
}
276-
}
283+
const group = importDeclaration.importClause.isTypeOnly ? typeOnlyImports : regularImports;
284+
const { name, namedBindings } = importDeclaration.importClause;
285+
286+
if (name) {
287+
group.defaultImports.push(importDeclaration);
277288
}
278289

279-
return {
280-
importWithoutClause,
281-
defaultImports,
282-
namespaceImports,
283-
namedImports,
284-
};
290+
if (namedBindings) {
291+
if (isNamespaceImport(namedBindings)) {
292+
group.namespaceImports.push(importDeclaration);
293+
}
294+
else {
295+
group.namedImports.push(importDeclaration);
296+
}
297+
}
285298
}
299+
300+
return {
301+
importWithoutClause,
302+
typeOnlyImports,
303+
regularImports,
304+
};
286305
}
287306

288307
// Internal for testing
@@ -294,37 +313,38 @@ namespace ts.OrganizeImports {
294313
return exportGroup;
295314
}
296315

297-
const { exportWithoutClause, namedExports } = getCategorizedExports(exportGroup);
316+
const { exportWithoutClause, namedExports, typeOnlyExports } = getCategorizedExports(exportGroup);
298317

299318
const coalescedExports: ExportDeclaration[] = [];
300319

301320
if (exportWithoutClause) {
302321
coalescedExports.push(exportWithoutClause);
303322
}
304323

305-
if (namedExports.length === 0) {
306-
return coalescedExports;
324+
for (const exportGroup of [namedExports, typeOnlyExports]) {
325+
if (exportGroup.length === 0) {
326+
continue;
327+
}
328+
const newExportSpecifiers: ExportSpecifier[] = [];
329+
newExportSpecifiers.push(...flatMap(exportGroup, i => i.exportClause && isNamedExports(i.exportClause) ? i.exportClause.elements : emptyArray));
330+
331+
const sortedExportSpecifiers = sortSpecifiers(newExportSpecifiers);
332+
333+
const exportDecl = exportGroup[0];
334+
coalescedExports.push(
335+
updateExportDeclaration(
336+
exportDecl,
337+
exportDecl.decorators,
338+
exportDecl.modifiers,
339+
exportDecl.exportClause && (
340+
isNamedExports(exportDecl.exportClause) ?
341+
updateNamedExports(exportDecl.exportClause, sortedExportSpecifiers) :
342+
updateNamespaceExport(exportDecl.exportClause, exportDecl.exportClause.name)
343+
),
344+
exportDecl.moduleSpecifier,
345+
exportDecl.isTypeOnly));
307346
}
308347

309-
const newExportSpecifiers: ExportSpecifier[] = [];
310-
newExportSpecifiers.push(...flatMap(namedExports, i => i.exportClause && isNamedExports(i.exportClause) ? i.exportClause.elements : emptyArray));
311-
312-
const sortedExportSpecifiers = sortSpecifiers(newExportSpecifiers);
313-
314-
const exportDecl = namedExports[0];
315-
coalescedExports.push(
316-
updateExportDeclaration(
317-
exportDecl,
318-
exportDecl.decorators,
319-
exportDecl.modifiers,
320-
exportDecl.exportClause && (
321-
isNamedExports(exportDecl.exportClause) ?
322-
updateNamedExports(exportDecl.exportClause, sortedExportSpecifiers) :
323-
updateNamespaceExport(exportDecl.exportClause, exportDecl.exportClause.name)
324-
),
325-
exportDecl.moduleSpecifier,
326-
exportDecl.isTypeOnly));
327-
328348
return coalescedExports;
329349

330350
/*
@@ -335,13 +355,17 @@ namespace ts.OrganizeImports {
335355
function getCategorizedExports(exportGroup: readonly ExportDeclaration[]) {
336356
let exportWithoutClause: ExportDeclaration | undefined;
337357
const namedExports: ExportDeclaration[] = [];
358+
const typeOnlyExports: ExportDeclaration[] = [];
338359

339360
for (const exportDeclaration of exportGroup) {
340361
if (exportDeclaration.exportClause === undefined) {
341362
// Only the first such export is interesting - the others are redundant.
342363
// Note: Unfortunately, we will lose trivia that was on this node.
343364
exportWithoutClause = exportWithoutClause || exportDeclaration;
344365
}
366+
else if (exportDeclaration.isTypeOnly) {
367+
typeOnlyExports.push(exportDeclaration);
368+
}
345369
else {
346370
namedExports.push(exportDeclaration);
347371
}
@@ -350,6 +374,7 @@ namespace ts.OrganizeImports {
350374
return {
351375
exportWithoutClause,
352376
namedExports,
377+
typeOnlyExports,
353378
};
354379
}
355380
}

src/testRunner/unittests/services/organizeImports.ts

+53
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,28 @@ namespace ts {
168168
const expectedCoalescedImports = sortedImports;
169169
assertListEqual(actualCoalescedImports, expectedCoalescedImports);
170170
});
171+
172+
it("Combine type-only imports separately from other imports", () => {
173+
const sortedImports = parseImports(
174+
`import type { x } from "lib";`,
175+
`import type { y } from "lib";`,
176+
`import { z } from "lib";`);
177+
const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports);
178+
const expectedCoalescedImports = parseImports(
179+
`import { z } from "lib";`,
180+
`import type { x, y } from "lib";`);
181+
assertListEqual(actualCoalescedImports, expectedCoalescedImports);
182+
});
183+
184+
it("Do not combine type-only default, namespace, or named imports with each other", () => {
185+
const sortedImports = parseImports(
186+
`import type { x } from "lib";`,
187+
`import type * as y from "lib";`,
188+
`import type z from "lib";`);
189+
const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports);
190+
const expectedCoalescedImports = actualCoalescedImports;
191+
assertListEqual(actualCoalescedImports, expectedCoalescedImports);
192+
});
171193
});
172194

173195
describe("Coalesce exports", () => {
@@ -240,6 +262,25 @@ namespace ts {
240262
`export { x as a, y, z as b } from "lib";`);
241263
assertListEqual(actualCoalescedExports, expectedCoalescedExports);
242264
});
265+
266+
it("Keep type-only exports separate", () => {
267+
const sortedExports = parseExports(
268+
`export { x };`,
269+
`export type { y };`);
270+
const actualCoalescedExports = OrganizeImports.coalesceExports(sortedExports);
271+
const expectedCoalescedExports = sortedExports;
272+
assertListEqual(actualCoalescedExports, expectedCoalescedExports);
273+
});
274+
275+
it("Combine type-only exports", () => {
276+
const sortedExports = parseExports(
277+
`export type { x };`,
278+
`export type { y };`);
279+
const actualCoalescedExports = OrganizeImports.coalesceExports(sortedExports);
280+
const expectedCoalescedExports = parseExports(
281+
`export type { x, y };`);
282+
assertListEqual(actualCoalescedExports, expectedCoalescedExports);
283+
});
243284
});
244285

245286
describe("Baselines", () => {
@@ -425,6 +466,18 @@ D();
425466
libFile);
426467
/* eslint-enable no-template-curly-in-string */
427468

469+
testOrganizeImports("TypeOnly",
470+
{
471+
path: "/test.ts",
472+
content: `
473+
import { X } from "lib";
474+
import type Y from "lib";
475+
import { Z } from "lib";
476+
import type { A, B } from "lib";
477+
478+
export { A, B, X, Y, Z };`
479+
});
480+
428481
testOrganizeImports("CoalesceMultipleModules",
429482
{
430483
path: "/test.ts",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// ==ORIGINAL==
2+
3+
import { X } from "lib";
4+
import type Y from "lib";
5+
import { Z } from "lib";
6+
import type { A, B } from "lib";
7+
8+
export { A, B, X, Y, Z };
9+
// ==ORGANIZED==
10+
11+
import { X, Z } from "lib";
12+
import type Y from "lib";
13+
import type { A, B } from "lib";
14+
15+
export { A, B, X, Y, Z };

0 commit comments

Comments
 (0)