diff --git a/Jakefile.js b/Jakefile.js index 9e8c51a306eea..d676926abac8d 100644 --- a/Jakefile.js +++ b/Jakefile.js @@ -141,6 +141,7 @@ var harnessSources = harnessCoreSources.concat([ "typingsInstaller.ts", "projectErrors.ts", "matchFiles.ts", + "organizeImports.ts", "initializeTSConfig.ts", "extractConstants.ts", "extractFunctions.ts", diff --git a/src/compiler/core.ts b/src/compiler/core.ts index 212fa86b3667d..698824fa9b0fb 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -1905,6 +1905,11 @@ namespace ts { Comparison.EqualTo; } + /** True is greater than false. */ + export function compareBooleans(a: boolean, b: boolean): Comparison { + return compareValues(a ? 1 : 0, b ? 1 : 0); + } + function compareMessageText(text1: string | DiagnosticMessageChain, text2: string | DiagnosticMessageChain): Comparison { while (text1 && text2) { // We still have both chains. diff --git a/src/harness/harnessLanguageService.ts b/src/harness/harnessLanguageService.ts index 1c85acfb80e3f..d24f572d1d51d 100644 --- a/src/harness/harnessLanguageService.ts +++ b/src/harness/harnessLanguageService.ts @@ -522,6 +522,9 @@ namespace Harness.LanguageService { getApplicableRefactors(): ts.ApplicableRefactorInfo[] { throw new Error("Not supported on the shim."); } + organizeImports(_scope: ts.OrganizeImportsScope, _formatOptions: ts.FormatCodeSettings): ReadonlyArray { + throw new Error("Not supported on the shim."); + } getEmitOutput(fileName: string): ts.EmitOutput { return unwrapJSONCallResult(this.shim.getEmitOutput(fileName)); } diff --git a/src/harness/tsconfig.json b/src/harness/tsconfig.json index 25642ab517923..cd6dbc5e0bb85 100644 --- a/src/harness/tsconfig.json +++ b/src/harness/tsconfig.json @@ -117,6 +117,7 @@ "./unittests/tsserverProjectSystem.ts", "./unittests/tscWatchMode.ts", "./unittests/matchFiles.ts", + "./unittests/organizeImports.ts", "./unittests/initializeTSConfig.ts", "./unittests/compileOnSave.ts", "./unittests/typingsInstaller.ts", diff --git a/src/harness/unittests/organizeImports.ts b/src/harness/unittests/organizeImports.ts new file mode 100644 index 0000000000000..2accc84d43f9c --- /dev/null +++ b/src/harness/unittests/organizeImports.ts @@ -0,0 +1,426 @@ +/// +/// + + +namespace ts { + describe("Organize imports", () => { + describe("Sort imports", () => { + it("No imports", () => { + assert.isEmpty(OrganizeImports.sortImports([])); + }); + + it("One import", () => { + const unsortedImports = parseImports(`import "lib";`); + const actualSortedImports = OrganizeImports.sortImports(unsortedImports); + const expectedSortedImports = unsortedImports; + assertListEqual(expectedSortedImports, actualSortedImports); + }); + + it("Stable - import kind", () => { + assertUnaffectedBySort( + `import "lib";`, + `import * as x from "lib";`, + `import x from "lib";`, + `import {x} from "lib";`); + }); + + it("Stable - default property alias", () => { + assertUnaffectedBySort( + `import x from "lib";`, + `import y from "lib";`); + }); + + it("Stable - module alias", () => { + assertUnaffectedBySort( + `import * as x from "lib";`, + `import * as y from "lib";`); + }); + + it("Stable - symbol", () => { + assertUnaffectedBySort( + `import {x} from "lib";`, + `import {y} from "lib";`); + }); + + it("Sort - non-relative vs non-relative", () => { + assertSortsBefore( + `import y from "lib1";`, + `import x from "lib2";`); + }); + + it("Sort - relative vs relative", () => { + assertSortsBefore( + `import y from "./lib1";`, + `import x from "./lib2";`); + }); + + it("Sort - relative vs non-relative", () => { + assertSortsBefore( + `import y from "lib";`, + `import x from "./lib";`); + }); + + function assertUnaffectedBySort(...importStrings: string[]) { + const unsortedImports1 = parseImports(...importStrings); + assertListEqual(unsortedImports1, OrganizeImports.sortImports(unsortedImports1)); + + const unsortedImports2 = reverse(unsortedImports1); + assertListEqual(unsortedImports2, OrganizeImports.sortImports(unsortedImports2)); + } + + function assertSortsBefore(importString1: string, importString2: string) { + const imports = parseImports(importString1, importString2); + assertListEqual(imports, OrganizeImports.sortImports(imports)); + assertListEqual(imports, OrganizeImports.sortImports(reverse(imports))); + } + }); + + describe("Coalesce imports", () => { + it("No imports", () => { + assert.isEmpty(OrganizeImports.coalesceImports([])); + }); + + it("Sort specifiers", () => { + const sortedImports = parseImports(`import { default as m, a as n, b, y, z as o } from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = parseImports(`import { a as n, b, default as m, y, z as o } from "lib";`); + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine side-effect-only imports", () => { + const sortedImports = parseImports( + `import "lib";`, + `import "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = parseImports(`import "lib";`); + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine namespace imports", () => { + const sortedImports = parseImports( + `import * as x from "lib";`, + `import * as y from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = sortedImports; + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine default imports", () => { + const sortedImports = parseImports( + `import x from "lib";`, + `import y from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = parseImports(`import { default as x, default as y } from "lib";`); + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine property imports", () => { + const sortedImports = parseImports( + `import { x } from "lib";`, + `import { y as z } from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = parseImports(`import { x, y as z } from "lib";`); + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine side-effect-only import with namespace import", () => { + const sortedImports = parseImports( + `import "lib";`, + `import * as x from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = sortedImports; + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine side-effect-only import with default import", () => { + const sortedImports = parseImports( + `import "lib";`, + `import x from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = sortedImports; + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine side-effect-only import with property import", () => { + const sortedImports = parseImports( + `import "lib";`, + `import { x } from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = sortedImports; + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine namespace import with default import", () => { + const sortedImports = parseImports( + `import * as x from "lib";`, + `import y from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = parseImports( + `import y, * as x from "lib";`); + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine namespace import with property import", () => { + const sortedImports = parseImports( + `import * as x from "lib";`, + `import { y } from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = sortedImports; + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine default import with property import", () => { + const sortedImports = parseImports( + `import x from "lib";`, + `import { y } from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = parseImports( + `import x, { y } from "lib";`); + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine many imports", () => { + const sortedImports = parseImports( + `import "lib";`, + `import * as y from "lib";`, + `import w from "lib";`, + `import { b } from "lib";`, + `import "lib";`, + `import * as x from "lib";`, + `import z from "lib";`, + `import { a } from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = parseImports( + `import "lib";`, + `import * as x from "lib";`, + `import * as y from "lib";`, + `import { a, b, default as w, default as z } from "lib";`); + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + it("Combine imports from different modules", () => { + const sortedImports = parseImports( + `import { d } from "lib1";`, + `import { b } from "lib1";`, + `import { c } from "lib2";`, + `import { a } from "lib2";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = parseImports( + `import { b, d } from "lib1";`, + `import { a, c } from "lib2";`); + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + + // This is descriptive, rather than normative + it("Combine two namespace imports with one default import", () => { + const sortedImports = parseImports( + `import * as x from "lib";`, + `import * as y from "lib";`, + `import z from "lib";`); + const actualCoalescedImports = OrganizeImports.coalesceImports(sortedImports); + const expectedCoalescedImports = sortedImports; + assertListEqual(expectedCoalescedImports, actualCoalescedImports); + }); + }); + + describe("Baselines", () => { + + const libFile = { + path: "/lib.ts", + content: ` +export function F1(); +export default function F2(); +`, + }; + + testOrganizeImports("Simple", + { + path: "/test.ts", + content: ` +import { F1, F2 } from "lib"; +import * as NS from "lib"; +import D from "lib"; + +NS.F1(); +D(); +F1(); +F2(); +`, + }, + libFile); + + testOrganizeImports("MoveToTop", + { + path: "/test.ts", + content: ` +import { F1, F2 } from "lib"; +F1(); +F2(); +import * as NS from "lib"; +NS.F1(); +import D from "lib"; +D(); +`, + }, + libFile); + + // tslint:disable no-invalid-template-strings + testOrganizeImports("MoveToTop_Invalid", + { + path: "/test.ts", + content: ` +import { F1, F2 } from "lib"; +F1(); +F2(); +import * as NS from "lib"; +NS.F1(); +import b from ${"`${'lib'}`"}; +import a from ${"`${'lib'}`"}; +import D from "lib"; +D(); +`, + }, + libFile); + // tslint:enable no-invalid-template-strings + + testOrganizeImports("CoalesceTrivia", + { + path: "/test.ts", + content: ` +/*A*/import /*B*/ { /*C*/ F2 /*D*/ } /*E*/ from /*F*/ "lib" /*G*/;/*H*/ //I +/*J*/import /*K*/ { /*L*/ F1 /*M*/ } /*N*/ from /*O*/ "lib" /*P*/;/*Q*/ //R + +F1(); +F2(); +`, + }, + libFile); + + testOrganizeImports("SortTrivia", + { + path: "/test.ts", + content: ` +/*A*/import /*B*/ "lib2" /*C*/;/*D*/ //E +/*F*/import /*G*/ "lib1" /*H*/;/*I*/ //J +`, + }, + { path: "/lib1.ts", content: "" }, + { path: "/lib2.ts", content: "" }); + + function testOrganizeImports(testName: string, testFile: TestFSWithWatch.FileOrFolder, ...otherFiles: TestFSWithWatch.FileOrFolder[]) { + it(testName, () => runBaseline(`organizeImports/${testName}.ts`, testFile, ...otherFiles)); + } + + function runBaseline(baselinePath: string, testFile: TestFSWithWatch.FileOrFolder, ...otherFiles: TestFSWithWatch.FileOrFolder[]) { + const { path: testPath, content: testContent } = testFile; + const languageService = makeLanguageService(testFile, ...otherFiles); + const changes = languageService.organizeImports({ type: "file", fileName: testPath }, testFormatOptions); + assert.equal(1, changes.length); + assert.equal(testPath, changes[0].fileName); + + Harness.Baseline.runBaseline(baselinePath, () => { + const newText = textChanges.applyChanges(testContent, changes[0].textChanges); + return [ + "// ==ORIGINAL==", + testContent, + "// ==ORGANIZED==", + newText, + ].join(newLineCharacter); + }); + } + + function makeLanguageService(...files: TestFSWithWatch.FileOrFolder[]) { + const host = projectSystem.createServerHost(files); + const projectService = projectSystem.createProjectService(host, { useSingleInferredProject: true }); + files.forEach(f => projectService.openClientFile(f.path)); + return projectService.inferredProjects[0].getLanguageService(); + } + }); + + function parseImports(...importStrings: string[]): ReadonlyArray { + const sourceFile = createSourceFile("a.ts", importStrings.join("\n"), ScriptTarget.ES2015, /*setParentNodes*/ true, ScriptKind.TS); + const imports = filter(sourceFile.statements, isImportDeclaration); + assert.equal(importStrings.length, imports.length); + return imports; + } + + function assertEqual(node1?: Node, node2?: Node) { + if (node1 === undefined) { + assert.isUndefined(node2); + return; + } + else if (node2 === undefined) { + assert.isUndefined(node1); // Guaranteed to fail + return; + } + + assert.equal(node1.kind, node2.kind); + + switch (node1.kind) { + case SyntaxKind.ImportDeclaration: + const decl1 = node1 as ImportDeclaration; + const decl2 = node2 as ImportDeclaration; + assertEqual(decl1.importClause, decl2.importClause); + assertEqual(decl1.moduleSpecifier, decl2.moduleSpecifier); + break; + case SyntaxKind.ImportClause: + const clause1 = node1 as ImportClause; + const clause2 = node2 as ImportClause; + assertEqual(clause1.name, clause2.name); + assertEqual(clause1.namedBindings, clause2.namedBindings); + break; + case SyntaxKind.NamespaceImport: + const nsi1 = node1 as NamespaceImport; + const nsi2 = node2 as NamespaceImport; + assertEqual(nsi1.name, nsi2.name); + break; + case SyntaxKind.NamedImports: + const ni1 = node1 as NamedImports; + const ni2 = node2 as NamedImports; + assertListEqual(ni1.elements, ni2.elements); + break; + case SyntaxKind.ImportSpecifier: + const is1 = node1 as ImportSpecifier; + const is2 = node2 as ImportSpecifier; + assertEqual(is1.name, is2.name); + assertEqual(is1.propertyName, is2.propertyName); + break; + case SyntaxKind.Identifier: + const id1 = node1 as Identifier; + const id2 = node2 as Identifier; + assert.equal(id1.text, id2.text); + break; + case SyntaxKind.StringLiteral: + case SyntaxKind.NoSubstitutionTemplateLiteral: + const sl1 = node1 as LiteralLikeNode; + const sl2 = node2 as LiteralLikeNode; + assert.equal(sl1.text, sl2.text); + break; + default: + assert.equal(node1.getText(), node2.getText()); + break; + } + } + + function assertListEqual(list1: ReadonlyArray, list2: ReadonlyArray) { + if (list1 === undefined || list2 === undefined) { + assert.isUndefined(list1); + assert.isUndefined(list2); + return; + } + + assert.equal(list1.length, list2.length); + for (let i = 0; i < list1.length; i++) { + assertEqual(list1[i], list2[i]); + } + } + + function reverse(list: ReadonlyArray) { + const result = []; + for (let i = list.length - 1; i >= 0; i--) { + result.push(list[i]); + } + return result; + } + }); +} \ No newline at end of file diff --git a/src/harness/unittests/session.ts b/src/harness/unittests/session.ts index 7d1e5b0816c87..765fc29ee49b7 100644 --- a/src/harness/unittests/session.ts +++ b/src/harness/unittests/session.ts @@ -262,6 +262,8 @@ namespace ts.server { CommandNames.GetApplicableRefactors, CommandNames.GetEditsForRefactor, CommandNames.GetEditsForRefactorFull, + CommandNames.OrganizeImports, + CommandNames.OrganizeImportsFull, ]; it("should not throw when commands are executed with invalid arguments", () => { diff --git a/src/server/client.ts b/src/server/client.ts index 8203475b06dbb..cee65c0e5a485 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -629,6 +629,10 @@ namespace ts.server { }; } + organizeImports(_scope: OrganizeImportsScope, _formatOptions: FormatCodeSettings): ReadonlyArray { + return notImplemented(); + } + private convertCodeEditsToTextChanges(edits: protocol.FileCodeEdits[]): FileTextChanges[] { return edits.map(edit => { const fileName = edit.fileName; diff --git a/src/server/protocol.ts b/src/server/protocol.ts index 45c08034e6e75..fbff4501133fe 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -113,6 +113,10 @@ namespace ts.server.protocol { /* @internal */ GetEditsForRefactorFull = "getEditsForRefactor-full", + OrganizeImports = "organizeImports", + /* @internal */ + OrganizeImportsFull = "organizeImports-full", + // NOTE: If updating this, be sure to also update `allCommandNames` in `harness/unittests/session.ts`. } @@ -547,6 +551,27 @@ namespace ts.server.protocol { renameFilename?: string; } + /** + * Organize imports by: + * 1) Removing unused imports + * 2) Coalescing imports from the same module + * 3) Sorting imports + */ + export interface OrganizeImportsRequest extends Request { + command: CommandTypes.OrganizeImports; + arguments: OrganizeImportsRequestArgs; + } + + export type OrganizeImportsScope = GetCombinedCodeFixScope; + + export interface OrganizeImportsRequestArgs { + scope: OrganizeImportsScope; + } + + export interface OrganizeImportsResponse extends Response { + edits: ReadonlyArray; + } + /** * Request for the available codefixes at a specific position. */ diff --git a/src/server/session.ts b/src/server/session.ts index 356ea0d254ea8..bff19d7bc232f 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1597,6 +1597,19 @@ namespace ts.server { } } + private organizeImports({ scope }: protocol.OrganizeImportsRequestArgs, simplifiedResult: boolean): ReadonlyArray | ReadonlyArray { + Debug.assert(scope.type === "file"); + const { file, project } = this.getFileAndProject(scope.args); + const formatOptions = this.projectService.getFormatCodeOptions(file); + const changes = project.getLanguageService().organizeImports({ type: "file", fileName: file }, formatOptions); + if (simplifiedResult) { + return this.mapTextChangesToCodeEdits(project, changes); + } + else { + return changes; + } + } + private getCodeFixes(args: protocol.CodeFixRequestArgs, simplifiedResult: boolean): ReadonlyArray | ReadonlyArray { if (args.errorCodes.length === 0) { return undefined; @@ -2041,6 +2054,12 @@ namespace ts.server { }, [CommandNames.GetEditsForRefactorFull]: (request: protocol.GetEditsForRefactorRequest) => { return this.requiredResponse(this.getEditsForRefactor(request.arguments, /*simplifiedResult*/ false)); + }, + [CommandNames.OrganizeImports]: (request: protocol.OrganizeImportsRequest) => { + return this.requiredResponse(this.organizeImports(request.arguments, /*simplifiedResult*/ true)); + }, + [CommandNames.OrganizeImportsFull]: (request: protocol.OrganizeImportsRequest) => { + return this.requiredResponse(this.organizeImports(request.arguments, /*simplifiedResult*/ false)); } }); diff --git a/src/services/organizeImports.ts b/src/services/organizeImports.ts new file mode 100644 index 0000000000000..d6b782a19aa6d --- /dev/null +++ b/src/services/organizeImports.ts @@ -0,0 +1,234 @@ +/* @internal */ +namespace ts.OrganizeImports { + export function organizeImports( + sourceFile: SourceFile, + formatContext: formatting.FormatContext, + host: LanguageServiceHost) { + + // TODO (https://github.com/Microsoft/TypeScript/issues/10020): sort *within* ambient modules (find using isAmbientModule) + + // All of the old ImportDeclarations in the file, in syntactic order. + const oldImportDecls = sourceFile.statements.filter(isImportDeclaration); + + if (oldImportDecls.length === 0) { + return []; + } + + const oldValidImportDecls = oldImportDecls.filter(importDecl => getExternalModuleName(importDecl.moduleSpecifier)); + const oldInvalidImportDecls = oldImportDecls.filter(importDecl => !getExternalModuleName(importDecl.moduleSpecifier)); + + // All of the new ImportDeclarations in the file, in sorted order. + const newImportDecls = coalesceImports(sortImports(removeUnusedImports(oldValidImportDecls))).concat(oldInvalidImportDecls); + + const changeTracker = textChanges.ChangeTracker.fromContext({ host, formatContext }); + + // Delete or replace the first import. + if (newImportDecls.length === 0) { + changeTracker.deleteNode(sourceFile, oldImportDecls[0]); + } + else { + // Note: Delete the surrounding trivia because it will have been retained in newImportDecls. + changeTracker.replaceNodeWithNodes(sourceFile, oldImportDecls[0], newImportDecls, { + useNonAdjustedStartPosition: false, + useNonAdjustedEndPosition: false, + suffix: getNewLineOrDefaultFromHost(host, formatContext.options), + }); + } + + // Delete any subsequent imports. + for (let i = 1; i < oldImportDecls.length; i++) { + changeTracker.deleteNode(sourceFile, oldImportDecls[i]); + } + + return changeTracker.getChanges(); + } + + function removeUnusedImports(oldImports: ReadonlyArray) { + return oldImports; // TODO (https://github.com/Microsoft/TypeScript/issues/10020) + } + + /* @internal */ // Internal for testing + export function sortImports(oldImports: ReadonlyArray) { + return stableSort(oldImports, (import1, import2) => { + const name1 = getExternalModuleName(import1.moduleSpecifier); + const name2 = getExternalModuleName(import2.moduleSpecifier); + Debug.assert(name1 !== undefined); + Debug.assert(name2 !== undefined); + return compareBooleans(isExternalModuleNameRelative(name1), isExternalModuleNameRelative(name2)) || + compareStringsCaseSensitive(name1, name2); + }); + } + + function getExternalModuleName(specifier: Expression) { + return isStringLiteral(specifier) || isNoSubstitutionTemplateLiteral(specifier) + ? specifier.text + : undefined; + } + + /** + * @param sortedImports a non-empty list of ImportDeclarations, sorted by module name. + */ + function groupSortedImports(sortedImports: ReadonlyArray): ReadonlyArray> { + Debug.assert(length(sortedImports) > 0); + + const groups: ImportDeclaration[][] = []; + + let groupName: string | undefined = getExternalModuleName(sortedImports[0].moduleSpecifier); + Debug.assert(groupName !== undefined); + let group: ImportDeclaration[] = []; + + for (const importDeclaration of sortedImports) { + const moduleName = getExternalModuleName(importDeclaration.moduleSpecifier); + Debug.assert(moduleName !== undefined); + if (moduleName === groupName) { + group.push(importDeclaration); + } + else if (group.length) { + groups.push(group); + + groupName = moduleName; + group = [importDeclaration]; + } + } + + if (group.length) { + groups.push(group); + } + + return groups; + } + + /* @internal */ // Internal for testing + /** + * @param sortedImports a list of ImportDeclarations, sorted by module name. + */ + export function coalesceImports(sortedImports: ReadonlyArray) { + if (sortedImports.length === 0) { + return sortedImports; + } + + const coalescedImports: ImportDeclaration[] = []; + + const groupedImports = groupSortedImports(sortedImports); + for (const importGroup of groupedImports) { + + const { importWithoutClause, defaultImports, namespaceImports, namedImports } = getImportParts(importGroup); + + if (importWithoutClause) { + coalescedImports.push(importWithoutClause); + } + + // Normally, we don't combine default and namespace imports, but it would be silly to + // produce two import declarations in this special case. + if (defaultImports.length === 1 && namespaceImports.length === 1 && namedImports.length === 0) { + // Add the namespace import to the existing default ImportDeclaration. + const defaultImportClause = defaultImports[0].parent as ImportClause; + coalescedImports.push( + updateImportDeclarationAndClause(defaultImportClause, defaultImportClause.name, namespaceImports[0])); + + continue; + } + + const sortedNamespaceImports = stableSort(namespaceImports, (n1, n2) => compareIdentifiers(n1.name, n2.name)); + + for (const namespaceImport of sortedNamespaceImports) { + // Drop the name, if any + coalescedImports.push( + updateImportDeclarationAndClause(namespaceImport.parent, /*name*/ undefined, namespaceImport)); + } + + if (defaultImports.length === 0 && namedImports.length === 0) { + continue; + } + + let newDefaultImport: Identifier | undefined; + const newImportSpecifiers: ImportSpecifier[] = []; + if (defaultImports.length === 1) { + newDefaultImport = defaultImports[0]; + } + else { + for (const defaultImport of defaultImports) { + newImportSpecifiers.push( + createImportSpecifier(createIdentifier("default"), defaultImport)); + } + } + + newImportSpecifiers.push(...flatMap(namedImports, n => n.elements)); + + const sortedImportSpecifiers = stableSort(newImportSpecifiers, (s1, s2) => + compareIdentifiers(s1.propertyName || s1.name, s2.propertyName || s2.name) || + compareIdentifiers(s1.name, s2.name)); + + const importClause = defaultImports.length > 0 + ? defaultImports[0].parent as ImportClause + : namedImports[0].parent; + + const newNamedImports = sortedImportSpecifiers.length === 0 + ? undefined + : namedImports.length === 0 + ? createNamedImports(sortedImportSpecifiers) + : updateNamedImports(namedImports[0], sortedImportSpecifiers); + + coalescedImports.push( + updateImportDeclarationAndClause(importClause, newDefaultImport, newNamedImports)); + } + + return coalescedImports; + + function getImportParts(importGroup: ReadonlyArray) { + let importWithoutClause: ImportDeclaration | undefined; + const defaultImports: Identifier[] = []; + const namespaceImports: NamespaceImport[] = []; + const namedImports: NamedImports[] = []; + + for (const importDeclaration of importGroup) { + if (importDeclaration.importClause === undefined) { + // Only the first such import is interesting - the others are redundant. + // Note: Unfortunately, we will lose trivia that was on this node. + importWithoutClause = importWithoutClause || importDeclaration; + continue; + } + + const { name, namedBindings } = importDeclaration.importClause; + + if (name) { + defaultImports.push(name); + } + + if (namedBindings) { + if (isNamespaceImport(namedBindings)) { + namespaceImports.push(namedBindings); + } + else { + namedImports.push(namedBindings); + } + } + } + + return { + importWithoutClause, + defaultImports, + namespaceImports, + namedImports, + }; + } + + function compareIdentifiers(s1: Identifier, s2: Identifier) { + return compareStringsCaseSensitive(s1.text, s2.text); + } + + function updateImportDeclarationAndClause( + importClause: ImportClause, + name: Identifier | undefined, + namedBindings: NamedImportBindings | undefined) { + + const importDeclaration = importClause.parent; + return updateImportDeclaration( + importDeclaration, + importDeclaration.decorators, + importDeclaration.modifiers, + updateImportClause(importClause, name, namedBindings), + importDeclaration.moduleSpecifier); + } + } +} \ No newline at end of file diff --git a/src/services/services.ts b/src/services/services.ts index 6df52dd804e84..b5d6f0ec2a655 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -14,6 +14,7 @@ /// /// /// +/// /// /// /// @@ -1848,6 +1849,15 @@ namespace ts { return codefix.getAllFixes({ fixId, sourceFile, program, host, cancellationToken, formatContext }); } + function organizeImports(scope: OrganizeImportsScope, formatOptions: FormatCodeSettings): ReadonlyArray { + synchronizeHostData(); + Debug.assert(scope.type === "file"); + const sourceFile = getValidSourceFile(scope.fileName); + const formatContext = formatting.getFormatContext(formatOptions); + + return OrganizeImports.organizeImports(sourceFile, formatContext, host); + } + function applyCodeActionCommand(action: CodeActionCommand): Promise; function applyCodeActionCommand(action: CodeActionCommand[]): Promise; function applyCodeActionCommand(action: CodeActionCommand | CodeActionCommand[]): Promise; @@ -2143,6 +2153,7 @@ namespace ts { getCodeFixesAtPosition, getCombinedCodeFix, applyCodeActionCommand, + organizeImports, getEmitOutput, getNonBoundSourceFile, getSourceFile, diff --git a/src/services/tsconfig.json b/src/services/tsconfig.json index ef0d68b20412e..bbd88a1a00401 100644 --- a/src/services/tsconfig.json +++ b/src/services/tsconfig.json @@ -58,6 +58,7 @@ "jsTyping.ts", "navigateTo.ts", "navigationBar.ts", + "organizeImports.ts", "outliningElementsCollector.ts", "pathCompletions.ts", "patternMatcher.ts", diff --git a/src/services/types.ts b/src/services/types.ts index 594484e1ea733..51710c88f4f73 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -308,6 +308,7 @@ namespace ts { applyCodeActionCommand(fileName: string, action: CodeActionCommand | CodeActionCommand[]): Promise; getApplicableRefactors(fileName: string, positionOrRaneg: number | TextRange): ApplicableRefactorInfo[]; getEditsForRefactor(fileName: string, formatOptions: FormatCodeSettings, positionOrRange: number | TextRange, refactorName: string, actionName: string): RefactorEditInfo | undefined; + organizeImports(scope: OrganizeImportsScope, formatOptions: FormatCodeSettings): ReadonlyArray; getEmitOutput(fileName: string, emitOnlyDtsFiles?: boolean): EmitOutput; @@ -326,6 +327,8 @@ namespace ts { export interface CombinedCodeFixScope { type: "file"; fileName: string; } + export type OrganizeImportsScope = CombinedCodeFixScope; + export interface GetCompletionsAtPositionOptions { includeExternalModuleExports: boolean; includeInsertTextCompletions: boolean; diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 370d841a7f19f..1a7edaa44c8ef 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -4135,6 +4135,7 @@ declare namespace ts { applyCodeActionCommand(fileName: string, action: CodeActionCommand | CodeActionCommand[]): Promise; getApplicableRefactors(fileName: string, positionOrRaneg: number | TextRange): ApplicableRefactorInfo[]; getEditsForRefactor(fileName: string, formatOptions: FormatCodeSettings, positionOrRange: number | TextRange, refactorName: string, actionName: string): RefactorEditInfo | undefined; + organizeImports(scope: OrganizeImportsScope, formatOptions: FormatCodeSettings): ReadonlyArray; getEmitOutput(fileName: string, emitOnlyDtsFiles?: boolean): EmitOutput; getProgram(): Program; dispose(): void; @@ -4143,6 +4144,7 @@ declare namespace ts { type: "file"; fileName: string; } + type OrganizeImportsScope = CombinedCodeFixScope; interface GetCompletionsAtPositionOptions { includeExternalModuleExports: boolean; includeInsertTextCompletions: boolean; @@ -5078,6 +5080,7 @@ declare namespace ts.server.protocol { GetSupportedCodeFixes = "getSupportedCodeFixes", GetApplicableRefactors = "getApplicableRefactors", GetEditsForRefactor = "getEditsForRefactor", + OrganizeImports = "organizeImports", } /** * A TypeScript Server message @@ -5429,6 +5432,23 @@ declare namespace ts.server.protocol { renameLocation?: Location; renameFilename?: string; } + /** + * Organize imports by: + * 1) Removing unused imports + * 2) Coalescing imports from the same module + * 3) Sorting imports + */ + interface OrganizeImportsRequest extends Request { + command: CommandTypes.OrganizeImports; + arguments: OrganizeImportsRequestArgs; + } + type OrganizeImportsScope = GetCombinedCodeFixScope; + interface OrganizeImportsRequestArgs { + scope: OrganizeImportsScope; + } + interface OrganizeImportsResponse extends Response { + edits: ReadonlyArray; + } /** * Request for the available codefixes at a specific position. */ @@ -7282,6 +7302,7 @@ declare namespace ts.server { private extractPositionAndRange(args, scriptInfo); private getApplicableRefactors(args); private getEditsForRefactor(args, simplifiedResult); + private organizeImports({scope}, simplifiedResult); private getCodeFixes(args, simplifiedResult); private getCombinedCodeFix({scope, fixId}, simplifiedResult); private applyCodeActionCommand(args); diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index baed457b3b055..8f9c095090dcf 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -4387,6 +4387,7 @@ declare namespace ts { applyCodeActionCommand(fileName: string, action: CodeActionCommand | CodeActionCommand[]): Promise; getApplicableRefactors(fileName: string, positionOrRaneg: number | TextRange): ApplicableRefactorInfo[]; getEditsForRefactor(fileName: string, formatOptions: FormatCodeSettings, positionOrRange: number | TextRange, refactorName: string, actionName: string): RefactorEditInfo | undefined; + organizeImports(scope: OrganizeImportsScope, formatOptions: FormatCodeSettings): ReadonlyArray; getEmitOutput(fileName: string, emitOnlyDtsFiles?: boolean): EmitOutput; getProgram(): Program; dispose(): void; @@ -4395,6 +4396,7 @@ declare namespace ts { type: "file"; fileName: string; } + type OrganizeImportsScope = CombinedCodeFixScope; interface GetCompletionsAtPositionOptions { includeExternalModuleExports: boolean; includeInsertTextCompletions: boolean; diff --git a/tests/baselines/reference/organizeImports/CoalesceTrivia.ts b/tests/baselines/reference/organizeImports/CoalesceTrivia.ts new file mode 100644 index 0000000000000..972df9e18ebb6 --- /dev/null +++ b/tests/baselines/reference/organizeImports/CoalesceTrivia.ts @@ -0,0 +1,15 @@ +// ==ORIGINAL== + +/*A*/import /*B*/ { /*C*/ F2 /*D*/ } /*E*/ from /*F*/ "lib" /*G*/;/*H*/ //I +/*J*/import /*K*/ { /*L*/ F1 /*M*/ } /*N*/ from /*O*/ "lib" /*P*/;/*Q*/ //R + +F1(); +F2(); + +// ==ORGANIZED== + +/*A*/ import { /*L*/ F1 /*M*/, /*C*/ F2 /*D*/ } /*E*/ from "lib" /*G*/; /*H*/ //I + + +F1(); +F2(); diff --git a/tests/baselines/reference/organizeImports/MoveToTop.ts b/tests/baselines/reference/organizeImports/MoveToTop.ts new file mode 100644 index 0000000000000..c0e57b930ab9f --- /dev/null +++ b/tests/baselines/reference/organizeImports/MoveToTop.ts @@ -0,0 +1,18 @@ +// ==ORIGINAL== + +import { F1, F2 } from "lib"; +F1(); +F2(); +import * as NS from "lib"; +NS.F1(); +import D from "lib"; +D(); + +// ==ORGANIZED== + +import * as NS from "lib"; +import D, { F1, F2 } from "lib"; +F1(); +F2(); +NS.F1(); +D(); diff --git a/tests/baselines/reference/organizeImports/MoveToTop_Invalid.ts b/tests/baselines/reference/organizeImports/MoveToTop_Invalid.ts new file mode 100644 index 0000000000000..e2372f680cfbe --- /dev/null +++ b/tests/baselines/reference/organizeImports/MoveToTop_Invalid.ts @@ -0,0 +1,22 @@ +// ==ORIGINAL== + +import { F1, F2 } from "lib"; +F1(); +F2(); +import * as NS from "lib"; +NS.F1(); +import b from `${'lib'}`; +import a from `${'lib'}`; +import D from "lib"; +D(); + +// ==ORGANIZED== + +import * as NS from "lib"; +import D, { F1, F2 } from "lib"; +import b from `${'lib'}`; +import a from `${'lib'}`; +F1(); +F2(); +NS.F1(); +D(); diff --git a/tests/baselines/reference/organizeImports/Simple.ts b/tests/baselines/reference/organizeImports/Simple.ts new file mode 100644 index 0000000000000..3f36ae633a6cf --- /dev/null +++ b/tests/baselines/reference/organizeImports/Simple.ts @@ -0,0 +1,20 @@ +// ==ORIGINAL== + +import { F1, F2 } from "lib"; +import * as NS from "lib"; +import D from "lib"; + +NS.F1(); +D(); +F1(); +F2(); + +// ==ORGANIZED== + +import * as NS from "lib"; +import D, { F1, F2 } from "lib"; + +NS.F1(); +D(); +F1(); +F2(); diff --git a/tests/baselines/reference/organizeImports/SortTrivia.ts b/tests/baselines/reference/organizeImports/SortTrivia.ts new file mode 100644 index 0000000000000..e46c836b96656 --- /dev/null +++ b/tests/baselines/reference/organizeImports/SortTrivia.ts @@ -0,0 +1,10 @@ +// ==ORIGINAL== + +/*A*/import /*B*/ "lib2" /*C*/;/*D*/ //E +/*F*/import /*G*/ "lib1" /*H*/;/*I*/ //J + +// ==ORGANIZED== + +/*F*/ import "lib1" /*H*/; /*I*/ //J +/*A*/ import "lib2" /*C*/; /*D*/ //E +