• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/* @internal */
2namespace ts.OrganizeImports {
3
4    /**
5     * Organize imports by:
6     *   1) Removing unused imports
7     *   2) Coalescing imports from the same module
8     *   3) Sorting imports
9     */
10    export function organizeImports(
11        sourceFile: SourceFile,
12        formatContext: formatting.FormatContext,
13        host: LanguageServiceHost,
14        program: Program,
15        preferences: UserPreferences,
16        mode: OrganizeImportsMode,
17    ) {
18        const changeTracker = textChanges.ChangeTracker.fromContext({ host, formatContext, preferences });
19        const shouldSort = mode === OrganizeImportsMode.SortAndCombine || mode === OrganizeImportsMode.All;
20        const shouldCombine = shouldSort; // These are currently inseparable, but I draw a distinction for clarity and in case we add modes in the future.
21        const shouldRemove = mode === OrganizeImportsMode.RemoveUnused || mode === OrganizeImportsMode.All;
22        const maybeRemove = shouldRemove ? removeUnusedImports : identity;
23        const maybeCoalesce = shouldCombine ? coalesceImports : identity;
24        const processImportsOfSameModuleSpecifier = (importGroup: readonly ImportDeclaration[]) => {
25            const processedDeclarations = maybeCoalesce(maybeRemove(importGroup, sourceFile, program));
26            return shouldSort
27                ? stableSort(processedDeclarations, (s1, s2) => compareImportsOrRequireStatements(s1, s2))
28                : processedDeclarations;
29        };
30
31        // All of the old ImportDeclarations in the file, in syntactic order.
32        const topLevelImportGroupDecls = groupImportsByNewlineContiguous(sourceFile, sourceFile.statements.filter(isImportDeclaration));
33        topLevelImportGroupDecls.forEach(importGroupDecl => organizeImportsWorker(importGroupDecl, processImportsOfSameModuleSpecifier));
34
35        // Exports are always used
36        if (mode !== OrganizeImportsMode.RemoveUnused) {
37            // All of the old ExportDeclarations in the file, in syntactic order.
38            const topLevelExportDecls = sourceFile.statements.filter(isExportDeclaration);
39            organizeImportsWorker(topLevelExportDecls, coalesceExports);
40        }
41
42        for (const ambientModule of sourceFile.statements.filter(isAmbientModule)) {
43            if (!ambientModule.body) continue;
44
45            const ambientModuleImportGroupDecls = groupImportsByNewlineContiguous(sourceFile, ambientModule.body.statements.filter(isImportDeclaration));
46            ambientModuleImportGroupDecls.forEach(importGroupDecl => organizeImportsWorker(importGroupDecl, processImportsOfSameModuleSpecifier));
47
48            // Exports are always used
49            if (mode !== OrganizeImportsMode.RemoveUnused) {
50                const ambientModuleExportDecls = ambientModule.body.statements.filter(isExportDeclaration);
51                organizeImportsWorker(ambientModuleExportDecls, coalesceExports);
52            }
53        }
54
55        return changeTracker.getChanges();
56
57        function organizeImportsWorker<T extends ImportDeclaration | ExportDeclaration>(
58            oldImportDecls: readonly T[],
59            coalesce: (group: readonly T[]) => readonly T[],
60        ) {
61            if (length(oldImportDecls) === 0) {
62                return;
63            }
64
65            // Special case: normally, we'd expect leading and trailing trivia to follow each import
66            // around as it's sorted.  However, we do not want this to happen for leading trivia
67            // on the first import because it is probably the header comment for the file.
68            // Consider: we could do a more careful check that this trivia is actually a header,
69            // but the consequences of being wrong are very minor.
70            suppressLeadingTrivia(oldImportDecls[0]);
71
72            const oldImportGroups = shouldCombine
73                ? group(oldImportDecls, importDecl => getExternalModuleName(importDecl.moduleSpecifier!)!)
74                : [oldImportDecls];
75            const sortedImportGroups = shouldSort
76                ? stableSort(oldImportGroups, (group1, group2) => compareModuleSpecifiers(group1[0].moduleSpecifier, group2[0].moduleSpecifier))
77                : oldImportGroups;
78            const newImportDecls = flatMap(sortedImportGroups, importGroup =>
79                getExternalModuleName(importGroup[0].moduleSpecifier!)
80                    ? coalesce(importGroup)
81                    : importGroup);
82
83            // Delete all nodes if there are no imports.
84            if (newImportDecls.length === 0) {
85                // Consider the first node to have trailingTrivia as we want to exclude the
86                // "header" comment.
87                changeTracker.deleteNodes(sourceFile, oldImportDecls, {
88                    trailingTriviaOption: textChanges.TrailingTriviaOption.Include,
89                }, /*hasTrailingComment*/ true);
90            }
91            else {
92                // Note: Delete the surrounding trivia because it will have been retained in newImportDecls.
93                const replaceOptions = {
94                    leadingTriviaOption: textChanges.LeadingTriviaOption.Exclude, // Leave header comment in place
95                    trailingTriviaOption: textChanges.TrailingTriviaOption.Include,
96                    suffix: getNewLineOrDefaultFromHost(host, formatContext.options),
97                };
98                changeTracker.replaceNodeWithNodes(sourceFile, oldImportDecls[0], newImportDecls, replaceOptions);
99                const hasTrailingComment = changeTracker.nodeHasTrailingComment(sourceFile, oldImportDecls[0], replaceOptions);
100                changeTracker.deleteNodes(sourceFile, oldImportDecls.slice(1), {
101                    trailingTriviaOption: textChanges.TrailingTriviaOption.Include,
102                }, hasTrailingComment);
103            }
104        }
105    }
106
107    function groupImportsByNewlineContiguous(sourceFile: SourceFile, importDecls: ImportDeclaration[]): ImportDeclaration[][] {
108        const scanner = createScanner(sourceFile.languageVersion, /*skipTrivia*/ false, sourceFile.languageVariant);
109        const groupImports: ImportDeclaration[][] = [];
110        let groupIndex = 0;
111        for (const topLevelImportDecl of importDecls) {
112            if (isNewGroup(sourceFile, topLevelImportDecl, scanner)) {
113                groupIndex++;
114            }
115
116            if (!groupImports[groupIndex]) {
117                groupImports[groupIndex] = [];
118            }
119
120            groupImports[groupIndex].push(topLevelImportDecl);
121        }
122
123        return groupImports;
124    }
125
126    // a new group is created if an import includes at least two new line
127    // new line from multi-line comment doesn't count
128    function isNewGroup(sourceFile: SourceFile, topLevelImportDecl: ImportDeclaration, scanner: Scanner) {
129        const startPos = topLevelImportDecl.getFullStart();
130        const endPos = topLevelImportDecl.getStart();
131        scanner.setText(sourceFile.text, startPos, endPos - startPos);
132
133        let numberOfNewLines = 0;
134        while (scanner.getTokenPos() < endPos) {
135            const tokenKind = scanner.scan();
136
137            if (tokenKind === SyntaxKind.NewLineTrivia) {
138                numberOfNewLines++;
139
140                if (numberOfNewLines >= 2) {
141                    return true;
142                }
143            }
144        }
145
146        return false;
147    }
148
149    function removeUnusedImports(oldImports: readonly ImportDeclaration[], sourceFile: SourceFile, program: Program) {
150        const typeChecker = program.getTypeChecker();
151        const compilerOptions = program.getCompilerOptions();
152        const jsxNamespace = typeChecker.getJsxNamespace(sourceFile);
153        const jsxFragmentFactory = typeChecker.getJsxFragmentFactory(sourceFile);
154        const jsxElementsPresent = !!(sourceFile.transformFlags & TransformFlags.ContainsJsx);
155
156        const usedImports: ImportDeclaration[] = [];
157
158        for (const importDecl of oldImports) {
159            const { importClause, moduleSpecifier } = importDecl;
160
161            if (!importClause) {
162                // Imports without import clauses are assumed to be included for their side effects and are not removed.
163                usedImports.push(importDecl);
164                continue;
165            }
166
167            let { name, namedBindings } = importClause;
168
169            // Default import
170            if (name && !isDeclarationUsed(name)) {
171                name = undefined;
172            }
173
174            if (namedBindings) {
175                if (isNamespaceImport(namedBindings)) {
176                    // Namespace import
177                    if (!isDeclarationUsed(namedBindings.name)) {
178                        namedBindings = undefined;
179                    }
180                }
181                else {
182                    // List of named imports
183                    const newElements = namedBindings.elements.filter(e => isDeclarationUsed(e.name));
184                    if (newElements.length < namedBindings.elements.length) {
185                        namedBindings = newElements.length
186                            ? factory.updateNamedImports(namedBindings, newElements)
187                            : undefined;
188                    }
189                }
190            }
191
192            if (name || namedBindings) {
193                usedImports.push(updateImportDeclarationAndClause(importDecl, name, namedBindings));
194            }
195            // If a module is imported to be augmented, it’s used
196            else if (hasModuleDeclarationMatchingSpecifier(sourceFile, moduleSpecifier)) {
197                // If we’re in a declaration file, it’s safe to remove the import clause from it
198                if (sourceFile.isDeclarationFile) {
199                    usedImports.push(factory.createImportDeclaration(
200                        importDecl.modifiers,
201                        /*importClause*/ undefined,
202                        moduleSpecifier,
203                        /*assertClause*/ undefined));
204                }
205                // If we’re not in a declaration file, we can’t remove the import clause even though
206                // the imported symbols are unused, because removing them makes it look like the import
207                // declaration has side effects, which will cause it to be preserved in the JS emit.
208                else {
209                    usedImports.push(importDecl);
210                }
211            }
212        }
213
214        return usedImports;
215
216        function isDeclarationUsed(identifier: Identifier) {
217            // The JSX factory symbol is always used if JSX elements are present - even if they are not allowed.
218            return jsxElementsPresent && (identifier.text === jsxNamespace || jsxFragmentFactory && identifier.text === jsxFragmentFactory) && jsxModeNeedsExplicitImport(compilerOptions.jsx) ||
219                FindAllReferences.Core.isSymbolReferencedInFile(identifier, typeChecker, sourceFile);
220        }
221    }
222
223    function hasModuleDeclarationMatchingSpecifier(sourceFile: SourceFile, moduleSpecifier: Expression) {
224        const moduleSpecifierText = isStringLiteral(moduleSpecifier) && moduleSpecifier.text;
225        return isString(moduleSpecifierText) && some(sourceFile.moduleAugmentations, moduleName =>
226            isStringLiteral(moduleName)
227            && moduleName.text === moduleSpecifierText);
228    }
229
230    function getExternalModuleName(specifier: Expression) {
231        return specifier !== undefined && isStringLiteralLike(specifier)
232            ? specifier.text
233            : undefined;
234    }
235
236    // Internal for testing
237    /**
238     * @param importGroup a list of ImportDeclarations, all with the same module name.
239     */
240    export function coalesceImports(importGroup: readonly ImportDeclaration[]) {
241        if (importGroup.length === 0) {
242            return importGroup;
243        }
244
245        const { importWithoutClause, typeOnlyImports, regularImports } = getCategorizedImports(importGroup);
246
247        const coalescedImports: ImportDeclaration[] = [];
248
249        if (importWithoutClause) {
250            coalescedImports.push(importWithoutClause);
251        }
252
253        for (const group of [regularImports, typeOnlyImports]) {
254            const isTypeOnly = group === typeOnlyImports;
255            const { defaultImports, namespaceImports, namedImports } = group;
256            // Normally, we don't combine default and namespace imports, but it would be silly to
257            // produce two import declarations in this special case.
258            if (!isTypeOnly && defaultImports.length === 1 && namespaceImports.length === 1 && namedImports.length === 0) {
259                // Add the namespace import to the existing default ImportDeclaration.
260                const defaultImport = defaultImports[0];
261                coalescedImports.push(
262                    updateImportDeclarationAndClause(defaultImport, defaultImport.importClause!.name, namespaceImports[0].importClause!.namedBindings)); // TODO: GH#18217
263
264                continue;
265            }
266
267            const sortedNamespaceImports = stableSort(namespaceImports, (i1, i2) =>
268                compareIdentifiers((i1.importClause!.namedBindings as NamespaceImport).name, (i2.importClause!.namedBindings as NamespaceImport).name)); // TODO: GH#18217
269
270            for (const namespaceImport of sortedNamespaceImports) {
271                // Drop the name, if any
272                coalescedImports.push(
273                    updateImportDeclarationAndClause(namespaceImport, /*name*/ undefined, namespaceImport.importClause!.namedBindings)); // TODO: GH#18217
274            }
275
276            if (defaultImports.length === 0 && namedImports.length === 0) {
277                continue;
278            }
279
280            let newDefaultImport: Identifier | undefined;
281            const newImportSpecifiers: ImportSpecifier[] = [];
282            if (defaultImports.length === 1) {
283                newDefaultImport = defaultImports[0].importClause!.name;
284            }
285            else {
286                for (const defaultImport of defaultImports) {
287                    newImportSpecifiers.push(
288                        factory.createImportSpecifier(/*isTypeOnly*/ false, factory.createIdentifier("default"), defaultImport.importClause!.name!)); // TODO: GH#18217
289                }
290            }
291
292            newImportSpecifiers.push(...getNewImportSpecifiers(namedImports));
293
294            const sortedImportSpecifiers = sortSpecifiers(newImportSpecifiers);
295            const importDecl = defaultImports.length > 0
296                ? defaultImports[0]
297                : namedImports[0];
298
299            const newNamedImports = sortedImportSpecifiers.length === 0
300                ? newDefaultImport
301                    ? undefined
302                    : factory.createNamedImports(emptyArray)
303                : namedImports.length === 0
304                    ? factory.createNamedImports(sortedImportSpecifiers)
305                    : factory.updateNamedImports(namedImports[0].importClause!.namedBindings as NamedImports, sortedImportSpecifiers); // TODO: GH#18217
306
307            // Type-only imports are not allowed to mix default, namespace, and named imports in any combination.
308            // We could rewrite a default import as a named import (`import { default as name }`), but we currently
309            // choose not to as a stylistic preference.
310            if (isTypeOnly && newDefaultImport && newNamedImports) {
311                coalescedImports.push(
312                    updateImportDeclarationAndClause(importDecl, newDefaultImport, /*namedBindings*/ undefined));
313                coalescedImports.push(
314                    updateImportDeclarationAndClause(namedImports[0] ?? importDecl, /*name*/ undefined, newNamedImports));
315            }
316            else {
317                coalescedImports.push(
318                    updateImportDeclarationAndClause(importDecl, newDefaultImport, newNamedImports));
319            }
320        }
321
322        return coalescedImports;
323
324    }
325
326    interface ImportGroup {
327        defaultImports: ImportDeclaration[];
328        namespaceImports: ImportDeclaration[];
329        namedImports: ImportDeclaration[];
330    }
331
332    /*
333     * Returns entire import declarations because they may already have been rewritten and
334     * may lack parent pointers.  The desired parts can easily be recovered based on the
335     * categorization.
336     *
337     * NB: There may be overlap between `defaultImports` and `namespaceImports`/`namedImports`.
338     */
339    function getCategorizedImports(importGroup: readonly ImportDeclaration[]) {
340        let importWithoutClause: ImportDeclaration | undefined;
341        const typeOnlyImports: ImportGroup = { defaultImports: [], namespaceImports: [], namedImports: [] };
342        const regularImports: ImportGroup = { defaultImports: [], namespaceImports: [], namedImports: [] };
343
344        for (const importDeclaration of importGroup) {
345            if (importDeclaration.importClause === undefined) {
346                // Only the first such import is interesting - the others are redundant.
347                // Note: Unfortunately, we will lose trivia that was on this node.
348                importWithoutClause = importWithoutClause || importDeclaration;
349                continue;
350            }
351
352            const group = importDeclaration.importClause.isTypeOnly ? typeOnlyImports : regularImports;
353            const { name, namedBindings } = importDeclaration.importClause;
354
355            if (name) {
356                group.defaultImports.push(importDeclaration);
357            }
358
359            if (namedBindings) {
360                if (isNamespaceImport(namedBindings)) {
361                    group.namespaceImports.push(importDeclaration);
362                }
363                else {
364                    group.namedImports.push(importDeclaration);
365                }
366            }
367        }
368
369        return {
370            importWithoutClause,
371            typeOnlyImports,
372            regularImports,
373        };
374    }
375
376    // Internal for testing
377    /**
378     * @param exportGroup a list of ExportDeclarations, all with the same module name.
379     */
380    export function coalesceExports(exportGroup: readonly ExportDeclaration[]) {
381        if (exportGroup.length === 0) {
382            return exportGroup;
383        }
384
385        const { exportWithoutClause, namedExports, typeOnlyExports } = getCategorizedExports(exportGroup);
386
387        const coalescedExports: ExportDeclaration[] = [];
388
389        if (exportWithoutClause) {
390            coalescedExports.push(exportWithoutClause);
391        }
392
393        for (const exportGroup of [namedExports, typeOnlyExports]) {
394            if (exportGroup.length === 0) {
395                continue;
396            }
397            const newExportSpecifiers: ExportSpecifier[] = [];
398            newExportSpecifiers.push(...flatMap(exportGroup, i => i.exportClause && isNamedExports(i.exportClause) ? i.exportClause.elements : emptyArray));
399
400            const sortedExportSpecifiers = sortSpecifiers(newExportSpecifiers);
401
402            const exportDecl = exportGroup[0];
403            coalescedExports.push(
404                factory.updateExportDeclaration(
405                    exportDecl,
406                    exportDecl.modifiers,
407                    exportDecl.isTypeOnly,
408                    exportDecl.exportClause && (
409                        isNamedExports(exportDecl.exportClause) ?
410                            factory.updateNamedExports(exportDecl.exportClause, sortedExportSpecifiers) :
411                            factory.updateNamespaceExport(exportDecl.exportClause, exportDecl.exportClause.name)
412                    ),
413                    exportDecl.moduleSpecifier,
414                    exportDecl.assertClause));
415        }
416
417        return coalescedExports;
418
419        /*
420         * Returns entire export declarations because they may already have been rewritten and
421         * may lack parent pointers.  The desired parts can easily be recovered based on the
422         * categorization.
423         */
424        function getCategorizedExports(exportGroup: readonly ExportDeclaration[]) {
425            let exportWithoutClause: ExportDeclaration | undefined;
426            const namedExports: ExportDeclaration[] = [];
427            const typeOnlyExports: ExportDeclaration[] = [];
428
429            for (const exportDeclaration of exportGroup) {
430                if (exportDeclaration.exportClause === undefined) {
431                    // Only the first such export is interesting - the others are redundant.
432                    // Note: Unfortunately, we will lose trivia that was on this node.
433                    exportWithoutClause = exportWithoutClause || exportDeclaration;
434                }
435                else if (exportDeclaration.isTypeOnly) {
436                    typeOnlyExports.push(exportDeclaration);
437                }
438                else {
439                    namedExports.push(exportDeclaration);
440                }
441            }
442
443            return {
444                exportWithoutClause,
445                namedExports,
446                typeOnlyExports,
447            };
448        }
449    }
450
451    function updateImportDeclarationAndClause(
452        importDeclaration: ImportDeclaration,
453        name: Identifier | undefined,
454        namedBindings: NamedImportBindings | undefined) {
455
456        return factory.updateImportDeclaration(
457            importDeclaration,
458            importDeclaration.modifiers,
459            factory.updateImportClause(importDeclaration.importClause!, importDeclaration.importClause!.isTypeOnly, name, namedBindings), // TODO: GH#18217
460            importDeclaration.moduleSpecifier,
461            importDeclaration.assertClause);
462    }
463
464    function sortSpecifiers<T extends ImportOrExportSpecifier>(specifiers: readonly T[]) {
465        return stableSort(specifiers, compareImportOrExportSpecifiers);
466    }
467
468    export function compareImportOrExportSpecifiers<T extends ImportOrExportSpecifier>(s1: T, s2: T) {
469        return compareBooleans(s1.isTypeOnly, s2.isTypeOnly)
470            || compareIdentifiers(s1.propertyName || s1.name, s2.propertyName || s2.name)
471            || compareIdentifiers(s1.name, s2.name);
472    }
473
474    /* internal */ // Exported for testing
475    export function compareModuleSpecifiers(m1: Expression | undefined, m2: Expression | undefined) {
476        const name1 = m1 === undefined ? undefined : getExternalModuleName(m1);
477        const name2 = m2 === undefined ? undefined : getExternalModuleName(m2);
478        return compareBooleans(name1 === undefined, name2 === undefined) ||
479            compareBooleans(isExternalModuleNameRelative(name1!), isExternalModuleNameRelative(name2!)) ||
480            compareStringsCaseInsensitive(name1!, name2!);
481    }
482
483    function compareIdentifiers(s1: Identifier, s2: Identifier) {
484        return compareStringsCaseInsensitive(s1.text, s2.text);
485    }
486
487    function getModuleSpecifierExpression(declaration: AnyImportOrRequireStatement): Expression | undefined {
488        switch (declaration.kind) {
489            case SyntaxKind.ImportEqualsDeclaration:
490                return tryCast(declaration.moduleReference, isExternalModuleReference)?.expression;
491            case SyntaxKind.ImportDeclaration:
492                return declaration.moduleSpecifier;
493            case SyntaxKind.VariableStatement:
494                return declaration.declarationList.declarations[0].initializer.arguments[0];
495        }
496    }
497
498    export function importsAreSorted(imports: readonly AnyImportOrRequireStatement[]): imports is SortedReadonlyArray<AnyImportOrRequireStatement> {
499        return arrayIsSorted(imports, compareImportsOrRequireStatements);
500    }
501
502    export function importSpecifiersAreSorted(imports: readonly ImportSpecifier[]): imports is SortedReadonlyArray<ImportSpecifier> {
503        return arrayIsSorted(imports, compareImportOrExportSpecifiers);
504    }
505
506    export function getImportDeclarationInsertionIndex(sortedImports: SortedReadonlyArray<AnyImportOrRequireStatement>, newImport: AnyImportOrRequireStatement) {
507        const index = binarySearch(sortedImports, newImport, identity, compareImportsOrRequireStatements);
508        return index < 0 ? ~index : index;
509    }
510
511    export function getImportSpecifierInsertionIndex(sortedImports: SortedReadonlyArray<ImportSpecifier>, newImport: ImportSpecifier) {
512        const index = binarySearch(sortedImports, newImport, identity, compareImportOrExportSpecifiers);
513        return index < 0 ? ~index : index;
514    }
515
516    export function compareImportsOrRequireStatements(s1: AnyImportOrRequireStatement, s2: AnyImportOrRequireStatement) {
517        return compareModuleSpecifiers(getModuleSpecifierExpression(s1), getModuleSpecifierExpression(s2)) || compareImportKind(s1, s2);
518    }
519
520    function compareImportKind(s1: AnyImportOrRequireStatement, s2: AnyImportOrRequireStatement) {
521        return compareValues(getImportKindOrder(s1), getImportKindOrder(s2));
522    }
523
524    // 1. Side-effect imports
525    // 2. Type-only imports
526    // 3. Namespace imports
527    // 4. Default imports
528    // 5. Named imports
529    // 6. ImportEqualsDeclarations
530    // 7. Require variable statements
531    function getImportKindOrder(s1: AnyImportOrRequireStatement) {
532        switch (s1.kind) {
533            case SyntaxKind.ImportDeclaration:
534                if (!s1.importClause) return 0;
535                if (s1.importClause.isTypeOnly) return 1;
536                if (s1.importClause.namedBindings?.kind === SyntaxKind.NamespaceImport) return 2;
537                if (s1.importClause.name) return 3;
538                return 4;
539            case SyntaxKind.ImportEqualsDeclaration:
540                return 5;
541            case SyntaxKind.VariableStatement:
542                return 6;
543        }
544    }
545
546    function getNewImportSpecifiers(namedImports: ImportDeclaration[]) {
547        return flatMap(namedImports, namedImport =>
548            map(tryGetNamedBindingElements(namedImport), importSpecifier =>
549                importSpecifier.name && importSpecifier.propertyName && importSpecifier.name.escapedText === importSpecifier.propertyName.escapedText
550                    ? factory.updateImportSpecifier(importSpecifier, importSpecifier.isTypeOnly, /*propertyName*/ undefined, importSpecifier.name)
551                    : importSpecifier
552            )
553        );
554    }
555
556    function tryGetNamedBindingElements(namedImport: ImportDeclaration) {
557        return namedImport.importClause?.namedBindings && isNamedImports(namedImport.importClause.namedBindings)
558            ? namedImport.importClause.namedBindings.elements
559            : undefined;
560    }
561}
562