diff --git a/README.md b/README.md index 6762e95..9d0e153 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,30 @@ A boolean value to enable or disable sorting of the specifiers in an import decl A boolean value to enable or disable sorting the namespace specifiers to the top of the import group. +#### `importOrderMergeImports` + +**type**: `boolean` + +**default value:** `false` + +Merges multiple import declarations into a single import declaration, combining all their specifiers. + +Initial file: +```js +import * as NamespacedImport from './example'; +import DefaultImport from './example'; +import { NamedImport1 } from './example'; +import { NamedImport2 } from './example'; +import { NamedImport3 } from './example'; +``` + +When sorted +```js +// namespaced imports are ignored as they cannot contain other specifier types +import * as NamespacedImport from './example'; +import DefaultImport, { NamedImport1, NamedImport2, NamedImport3 } from './example'; +``` + #### `importOrderCaseInsensitive` **type**: `boolean` diff --git a/src/index.ts b/src/index.ts index b449bfd..107d741 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,6 +93,13 @@ const options: Options = { default: 'with', description: 'Provide a keyword for import attributes', }, + importOrderMergeImports: { + type: 'boolean', + category: 'Global', + default: false, + description: + 'Should duplicate import sources be combined into a single import declaration?', + }, }; export default { diff --git a/src/preprocessors/preprocessor.ts b/src/preprocessors/preprocessor.ts index 27563a9..15f0e8d 100644 --- a/src/preprocessors/preprocessor.ts +++ b/src/preprocessors/preprocessor.ts @@ -22,6 +22,7 @@ export function preprocessor(code: string, options: PrettierOptions) { importOrderSideEffects, importOrderImportAttributesKeyword, importOrderExclude, + importOrderMergeImports, filepath, } = options; @@ -54,7 +55,7 @@ export function preprocessor(code: string, options: PrettierOptions) { if (importNodes.length === 0) return code; if (isSortImportsIgnored(getAllCommentsFromNodes(importNodes))) return code; - const allImports = getSortedNodes(importNodes, { + const sortedImports = getSortedNodes(importNodes, { importOrder, importOrderCaseInsensitive, importOrderSeparation, @@ -62,9 +63,10 @@ export function preprocessor(code: string, options: PrettierOptions) { importOrderSortSpecifiers, importOrderSortByLength, importOrderSideEffects, + importOrderMergeImports, }); - return getCodeFromAst(allImports, code, injectIdx, { + return getCodeFromAst(importNodes, code, sortedImports, injectIdx, { importOrderImportAttributesKeyword, }); } diff --git a/src/types.ts b/src/types.ts index 799e4e0..7c076c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,8 +4,7 @@ import { RequiredOptions } from 'prettier'; import { PluginConfig } from '../types'; export interface PrettierOptions - extends Required, - RequiredOptions {} + extends Required, RequiredOptions {} export type ImportGroups = Record; export type ImportOrLine = ImportDeclaration | ExpressionStatement; @@ -21,6 +20,7 @@ export type GetSortedNodes = ( | 'importOrderSortSpecifiers' | 'importOrderSortByLength' | 'importOrderSideEffects' + | 'importOrderMergeImports' >, ) => ImportOrLine[]; diff --git a/src/utils/__tests__/assemble-updated-code.spec.ts b/src/utils/__tests__/assemble-updated-code.spec.ts index 7620486..ae0dc6b 100644 --- a/src/utils/__tests__/assemble-updated-code.spec.ts +++ b/src/utils/__tests__/assemble-updated-code.spec.ts @@ -29,6 +29,7 @@ test('it should remove nodes from the original code', async () => { importOrderSortSpecifiers: false, importOrderSideEffects: true, importOrderSortByLength: null, + importOrderMergeImports: false, }); const allCommentsFromImports = getAllCommentsFromNodes(sortedNodes); @@ -57,6 +58,7 @@ test('it should inject the generated code at the correct location', async () => importOrderSortSpecifiers: false, importOrderSideEffects: true, importOrderSortByLength: null, + importOrderMergeImports: false, }); const allCommentsFromImports = getAllCommentsFromNodes(sortedNodes); diff --git a/src/utils/__tests__/get-all-comments-from-nodes.spec.ts b/src/utils/__tests__/get-all-comments-from-nodes.spec.ts index 4455d55..001aefc 100644 --- a/src/utils/__tests__/get-all-comments-from-nodes.spec.ts +++ b/src/utils/__tests__/get-all-comments-from-nodes.spec.ts @@ -17,6 +17,7 @@ const getSortedImportNodes = (code: string, options?: ParserOptions) => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }); }; diff --git a/src/utils/__tests__/get-code-from-ast.spec.ts b/src/utils/__tests__/get-code-from-ast.spec.ts index 7c238b5..3a0365d 100644 --- a/src/utils/__tests__/get-code-from-ast.spec.ts +++ b/src/utils/__tests__/get-code-from-ast.spec.ts @@ -24,8 +24,9 @@ import a from 'a'; importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }); - const formatted = getCodeFromAst(sortedNodes, code); + const formatted = getCodeFromAst(importNodes, code, sortedNodes); expect(await format(formatted, { parser: 'babel' })).toEqual( `// first comment // second comment diff --git a/src/utils/__tests__/get-merged-specifiers.spec.ts b/src/utils/__tests__/get-merged-specifiers.spec.ts new file mode 100644 index 0000000..ec01cb7 --- /dev/null +++ b/src/utils/__tests__/get-merged-specifiers.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from 'vitest'; + +import { getImportNodes } from '../get-import-nodes'; +import { getMergedSpecifiers } from '../get-merged-specifiers.js'; +import { getSortedNodesModulesNames } from '../get-sorted-nodes-modules-names'; + +test('should merge import specifiers', () => { + const code = ` + import { eventHandler } from '@server/z'; + import { filter } from '@server/z'; + import { reduce } from '@server/z'; + `; + const importNodes = getImportNodes(code); + const [importDeclaration] = getMergedSpecifiers(importNodes); + const specifiersList = getSortedNodesModulesNames( + importDeclaration.specifiers, + ); + + expect(specifiersList).toEqual(['eventHandler', 'filter', 'reduce']); +}); + +test('should merge import specifiers with default import', () => { + const code = ` + import Component from '@server/z'; + import { eventHandler } from '@server/z'; + import { filter } from '@server/z'; + import { reduce } from '@server/z'; + `; + const importNodes = getImportNodes(code); + const [importDeclaration] = getMergedSpecifiers(importNodes); + const specifiersList = getSortedNodesModulesNames( + importDeclaration.specifiers, + ); + + expect(specifiersList).toEqual([ + 'Component', + 'eventHandler', + 'filter', + 'reduce', + ]); +}); + +test('should ignore namespace specifiers', () => { + const code = ` + import * as Component from '@server/z'; + import { eventHandler } from '@server/z'; + import { filter } from '@server/z'; + import { reduce } from '@server/z'; + `; + const importNodes = getImportNodes(code); + const importDeclarations = getMergedSpecifiers(importNodes); + const specifiersLists = importDeclarations.map((declaration) => + getSortedNodesModulesNames(declaration.specifiers), + ); + + expect(specifiersLists).toEqual([ + ['Component'], + ['eventHandler', 'filter', 'reduce'], + ]); +}); diff --git a/src/utils/__tests__/get-sorted-nodes-by-import-order.spec.ts b/src/utils/__tests__/get-sorted-nodes-by-import-order.spec.ts index ff9e8dd..763efb1 100644 --- a/src/utils/__tests__/get-sorted-nodes-by-import-order.spec.ts +++ b/src/utils/__tests__/get-sorted-nodes-by-import-order.spec.ts @@ -33,6 +33,7 @@ test('it returns all sorted nodes', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ @@ -79,6 +80,7 @@ test('it returns all sorted nodes case-insensitive', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ @@ -125,6 +127,7 @@ test('it returns all sorted nodes with sort order', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ @@ -171,6 +174,7 @@ test('it returns all sorted nodes with sort order case-insensitive', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ 'c', @@ -216,6 +220,7 @@ test('it returns all sorted import nodes with sorted import specifiers', () => { importOrderSortSpecifiers: true, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ 'XY', @@ -261,6 +266,7 @@ test('it returns all sorted import nodes with sorted import specifiers with case importOrderSortSpecifiers: true, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ 'c', @@ -306,6 +312,7 @@ test('it returns all sorted nodes with custom third party modules', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ 'a', @@ -332,6 +339,7 @@ test('it returns all sorted nodes with namespace specifiers at the top', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ @@ -367,6 +375,7 @@ test('it returns the default separations if `importOrderSeparation` is false', ( importOrderSortSpecifiers: false, importOrderSideEffects: true, importOrderSortByLength: null, + importOrderMergeImports: false, }); expect(getSeparationData(sorted)).toEqual([ { type: 'ImportDeclaration', value: 'XY' }, @@ -394,6 +403,7 @@ test('it returns default import module separations', () => { importOrderSortSpecifiers: false, importOrderSideEffects: true, importOrderSortByLength: null, + importOrderMergeImports: false, }); expect(getSeparationData(sorted)).toEqual([ { type: 'ImportDeclaration', value: 'XY' }, @@ -426,6 +436,7 @@ test('it returns targeted import module separations', () => { importOrderSortSpecifiers: false, importOrderSideEffects: true, importOrderSortByLength: null, + importOrderMergeImports: false, }); expect(getSeparationData(sorted)).toEqual([ { type: 'ImportDeclaration', value: 'XY' }, @@ -459,6 +470,7 @@ test('it never returns a separation at the top of the list (leading separator)', importOrderSortSpecifiers: false, importOrderSideEffects: true, importOrderSortByLength: null, + importOrderMergeImports: false, }); expect(getSeparationData(sorted)).toEqual([ { type: 'ImportDeclaration', value: './test' }, @@ -480,6 +492,7 @@ test('it never returns a separation at the top of the list (zero preceding impor importOrderSortSpecifiers: false, importOrderSideEffects: true, importOrderSortByLength: null, + importOrderMergeImports: false, }); expect(getSeparationData(sorted)).toEqual([ { type: 'ImportDeclaration', value: './test' }, diff --git a/src/utils/__tests__/get-sorted-nodes.spec.ts b/src/utils/__tests__/get-sorted-nodes.spec.ts index fb9ac6a..aeac6ec 100644 --- a/src/utils/__tests__/get-sorted-nodes.spec.ts +++ b/src/utils/__tests__/get-sorted-nodes.spec.ts @@ -49,6 +49,7 @@ test('it returns all sorted nodes', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ @@ -95,6 +96,7 @@ test('it returns all sorted nodes case-insensitive', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ @@ -141,6 +143,7 @@ test('it returns all sorted nodes with sort order', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ @@ -187,6 +190,7 @@ test('it returns all sorted nodes with sort order case-insensitive', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ 'c', @@ -232,6 +236,7 @@ test('it returns all sorted import nodes with sorted import specifiers', () => { importOrderSortSpecifiers: true, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ 'XY', @@ -277,6 +282,7 @@ test('it returns all sorted import nodes with sorted import specifiers with case importOrderSortSpecifiers: true, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ 'c', @@ -322,6 +328,7 @@ test('it returns all sorted nodes with custom third party modules', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ 'a', @@ -348,6 +355,7 @@ test('it returns all sorted nodes with namespace specifiers at the top', () => { importOrderSortSpecifiers: false, importOrderSortByLength: null, importOrderSideEffects: true, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ @@ -375,6 +383,7 @@ test('it returns all sorted nodes, sorted shortest to longest', () => { importOrderSortSpecifiers: false, importOrderSideEffects: true, importOrderSortByLength: 'asc', + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ 'g', @@ -401,6 +410,7 @@ test('it returns all sorted nodes, sorted longest to shortest', () => { importOrderSortSpecifiers: false, importOrderSideEffects: false, importOrderSortByLength: 'desc', + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ 't', @@ -429,6 +439,7 @@ test('it returns all sorted nodes with types', () => { importOrderSortSpecifiers: false, importOrderSideEffects: true, importOrderSortByLength: null, + importOrderMergeImports: false, }) as ImportDeclaration[]; expect(getSortedNodesNames(sorted)).toEqual([ diff --git a/src/utils/get-code-from-ast.ts b/src/utils/get-code-from-ast.ts index 1fd7ce1..17f14a9 100644 --- a/src/utils/get-code-from-ast.ts +++ b/src/utils/get-code-from-ast.ts @@ -10,22 +10,23 @@ const generate = (generateModule as any).default || generateModule; /** * This function generate a code string from the passed nodes. - * @param nodes all imports + * @param originalNodes all imports * @param originalCode */ export const getCodeFromAst = ( - nodes: Statement[], + originalNodes: Statement[], originalCode: string, + nodesToInject: Statement[], injectIdx: number = 0, options?: Pick, ) => { - const allCommentsFromImports = getAllCommentsFromNodes(nodes); + const allCommentsFromImports = getAllCommentsFromNodes(originalNodes); - const nodesToRemoveFromCode = [...nodes, ...allCommentsFromImports]; + const nodesToRemoveFromCode = [...originalNodes, ...allCommentsFromImports]; const newAST = file({ type: 'Program', - body: nodes, + body: nodesToInject, directives: [], sourceType: 'module', leadingComments: [], diff --git a/src/utils/get-merged-specifiers.ts b/src/utils/get-merged-specifiers.ts new file mode 100644 index 0000000..b29b29e --- /dev/null +++ b/src/utils/get-merged-specifiers.ts @@ -0,0 +1,32 @@ +import type { ImportDeclaration } from '@babel/types'; + +/** + * This function merges the specifiers of import nodes that have the same source + * into a single import declaration. + * @param node All imports nodes that should be merged. + */ +export function getMergedSpecifiers(nodes: ImportDeclaration[]) { + const merged = nodes.reduce((acc, node) => { + if (node.specifiers.length === 0) { + acc.push(node); + return acc; + } + + const nodeToMerge = acc.find( + (n) => + n.source.value === node.source.value && + // ignore import if it's a namespace specifier + n.specifiers[0]?.type !== 'ImportNamespaceSpecifier', + ); + + if (nodeToMerge) { + nodeToMerge.specifiers.push(...node.specifiers); + } else { + acc.push(node); + } + + return acc; + }, []); + + return merged; +} diff --git a/src/utils/get-sorted-nodes-by-import-order.ts b/src/utils/get-sorted-nodes-by-import-order.ts index e0f663a..72bb5d3 100644 --- a/src/utils/get-sorted-nodes-by-import-order.ts +++ b/src/utils/get-sorted-nodes-by-import-order.ts @@ -9,6 +9,7 @@ import { import { naturalSort } from '../natural-sort/index.js'; import { GetSortedNodes, ImportGroups, ImportOrLine } from '../types'; import { getImportNodesMatchedGroup } from './get-import-nodes-matched-group.js'; +import { getMergedSpecifiers } from './get-merged-specifiers.js'; import { getSortedImportSpecifiers } from './get-sorted-import-specifiers.js'; import { getSortedNodesGroup } from './get-sorted-nodes-group.js'; @@ -27,6 +28,7 @@ export const getSortedNodesByImportOrder: GetSortedNodes = (nodes, options) => { importOrderSeparation, importOrderSortSpecifiers, importOrderGroupNamespaceSpecifiers, + importOrderMergeImports, } = options; const originalNodes = nodes.map(clone); @@ -71,11 +73,16 @@ export const getSortedNodesByImportOrder: GetSortedNodes = (nodes, options) => { } if (groupNodes.length === 0) continue; - const sortedInsideGroup = getSortedNodesGroup(groupNodes, { + let sortedInsideGroup = getSortedNodesGroup(groupNodes, { importOrderGroupNamespaceSpecifiers, importOrderSortByLength, }); + // Combine import declarations with the same source into a single import declaration + if (importOrderMergeImports) { + sortedInsideGroup = getMergedSpecifiers(sortedInsideGroup); + } + // Sort the import specifiers if (importOrderSortSpecifiers) { sortedInsideGroup.forEach((node) => diff --git a/types/index.d.ts b/types/index.d.ts index 8019b88..87a1e78 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -133,6 +133,29 @@ used to order imports within each match group. * @default [] */ importOrderExclude?: string[]; + + /** + * Merges multiple import declarations into a single import declaration, combining all their specifiers. + * + * For example, provided the following input: + * ```js + * import * as NamespacedImport from './example'; + * import DefaultImport from './example'; + * import { NamedImport1 } from './example'; + * import { NamedImport2 } from './example'; + * import { NamedImport3 } from './example'; + * ``` + * + * will be transformed into: + * + * ```js + * import * as NamespacedImport from './example'; + * import DefaultImport, { NamedImport1, NamedImport2, NamedImport3 } from './example'; + * ``` + * + * @default false + */ + importOrderMergeImports?: boolean; } export type PrettierConfig = PluginConfig & Config;