From 35266d5867ff21ff5ebf65e2e030a97428f4142a Mon Sep 17 00:00:00 2001 From: Ian VanSchooten Date: Thu, 27 Oct 2022 13:36:05 -0400 Subject: [PATCH] Support explicit type sorting (#44) Closes https://github.com/IanVS/prettier-plugin-sort-imports/issues/35 Adapted from https://github.com/trivago/prettier-plugin-sort-imports/pull/153, by @Xenfo. This adds a new special string, `` that can be added to the `importOrder` array. When used, it will cause type imports to be sorted as specified by its location in the array. Notes: - If it is used, it will disable `importOrderCombineTypeAndValueImports`, throwing a warning if both are used. This is because: - We will split apart type and value import declarations if `` is used, so that types can be sorted appropriately. - Thinking towards the next breaking change when we remove options, I think the default will be to enable `importOrderCombineTypeAndValueImports`, and this change will give users a good way to opt-out of that behavior if they want, by specifying the location for ``. --- README.md | 6 + src/constants.ts | 3 +- src/preprocessors/preprocessor.ts | 16 ++- src/types.ts | 4 + .../explode-type-and-value-specifiers.spec.ts | 126 ++++++++++++++++++ .../explode-type-and-value-specifiers.ts | 62 +++++++++ src/utils/get-import-nodes-matched-group.ts | 37 ++++- src/utils/get-sorted-nodes.ts | 16 ++- .../__snapshots__/ppsi.spec.js.snap | 37 +++++ .../imports-with-mixed-declarations.ts | 3 + .../imports-with-third-party-types.ts | 7 + tests/TypesSpecialWord/ppsi.spec.js | 11 ++ types/index.d.ts | 9 ++ 13 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 src/utils/__tests__/explode-type-and-value-specifiers.spec.ts create mode 100644 src/utils/explode-type-and-value-specifiers.ts create mode 100644 tests/TypesSpecialWord/__snapshots__/ppsi.spec.js.snap create mode 100644 tests/TypesSpecialWord/imports-with-mixed-declarations.ts create mode 100644 tests/TypesSpecialWord/imports-with-third-party-types.ts create mode 100644 tests/TypesSpecialWord/ppsi.spec.js diff --git a/README.md b/README.md index b9fbcf2..b5460ef 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,12 @@ To move the third party imports at desired place, you can use `", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], ``` +If you would like to order type imports differently from value imports, you can use the special `` string. This example will place third party types at the top, followed by local types, then third party value imports, and lastly local value imports: + +```json +"importOrder": ["", "^[./]", "", "^[./]"], +``` + #### `importOrderSeparation` **type**: `boolean` diff --git a/src/constants.ts b/src/constants.ts index d5ce26b..7c1835d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -26,8 +26,9 @@ export const mergeableImportFlavors = [ * Used to mark the position between RegExps, * where the not matched imports should be placed */ -export const THIRD_PARTY_MODULES_SPECIAL_WORD = ''; export const BUILTIN_MODULES = `^(?:node:)?(?:${builtinModules.join('|')})$`; +export const THIRD_PARTY_MODULES_SPECIAL_WORD = ''; +export const TYPES_SPECIAL_WORD = ''; const PRETTIER_PLUGIN_SORT_IMPORTS_NEW_LINE = 'PRETTIER_PLUGIN_SORT_IMPORTS_NEW_LINE'; diff --git a/src/preprocessors/preprocessor.ts b/src/preprocessors/preprocessor.ts index ef2fda0..53ceb1e 100644 --- a/src/preprocessors/preprocessor.ts +++ b/src/preprocessors/preprocessor.ts @@ -2,6 +2,7 @@ import { ParserOptions, parse as babelParser } from '@babel/parser'; import traverse, { NodePath } from '@babel/traverse'; import { ImportDeclaration, isTSModuleDeclaration } from '@babel/types'; +import { TYPES_SPECIAL_WORD } from '../constants'; import { PrettierOptions } from '../types'; import { getCodeFromAst } from '../utils/get-code-from-ast'; import { getExperimentalParserPlugins } from '../utils/get-experimental-parser-plugins'; @@ -15,18 +16,29 @@ export function preprocessor(code: string, options: PrettierOptions): string { importOrderCaseInsensitive, importOrderGroupNamespaceSpecifiers, importOrderMergeDuplicateImports, - importOrderCombineTypeAndValueImports, importOrderSeparation, importOrderSortSpecifiers, } = options; + let { importOrderCombineTypeAndValueImports } = options; + if ( importOrderCombineTypeAndValueImports && !importOrderMergeDuplicateImports ) { console.warn( - '[@ianvs/prettier-plugin-sort-imports]: Enabling importOrderCombineTypeAndValueImports will have no effect unless importOrderMergeDuplicateImports is also enabled.', + '[@ianvs/prettier-plugin-sort-imports]: The option importOrderCombineTypeAndValueImports will have no effect since importOrderMergeDuplicateImports is not also enabled.', + ); + } + + if ( + importOrderCombineTypeAndValueImports && + importOrder.some((group) => group.includes(TYPES_SPECIAL_WORD)) + ) { + console.warn( + `[@ianvs/prettier-plugin-sort-imports]: The option importOrderCombineTypeAndValueImports will have no effect since ${TYPES_SPECIAL_WORD} is used in importOrder.`, ); + importOrderCombineTypeAndValueImports = false; } const allOriginalImportNodes: ImportDeclaration[] = []; diff --git a/src/types.ts b/src/types.ts index 25b2165..da04e63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,3 +53,7 @@ export type MergeNodesWithMatchingImportFlavors = ( nodes: ImportDeclaration[], options: { importOrderCombineTypeAndValueImports: boolean }, ) => ImportDeclaration[]; + +export type ExplodeTypeAndValueSpecifiers = ( + nodes: ImportDeclaration[], +) => ImportDeclaration[]; diff --git a/src/utils/__tests__/explode-type-and-value-specifiers.spec.ts b/src/utils/__tests__/explode-type-and-value-specifiers.spec.ts new file mode 100644 index 0000000..28a0e86 --- /dev/null +++ b/src/utils/__tests__/explode-type-and-value-specifiers.spec.ts @@ -0,0 +1,126 @@ +import { explodeTypeAndValueSpecifiers } from '../explode-type-and-value-specifiers'; +import { getCodeFromAst } from '../get-code-from-ast'; +import { getImportNodes } from '../get-import-nodes'; + +test('it should return a default value import unchanged', () => { + const code = `import Default from './source';`; + const importNodes = getImportNodes(code); + const explodedNodes = explodeTypeAndValueSpecifiers(importNodes); + const formatted = getCodeFromAst({ + nodesToOutput: explodedNodes, + originalCode: code, + directives: [], + }); + expect(formatted).toEqual(`import Default from './source';`); +}); + +test('it should return a default value and namespace import unchanged', () => { + const code = `import Default, * as Namespace from './source';`; + const importNodes = getImportNodes(code); + const explodedNodes = explodeTypeAndValueSpecifiers(importNodes); + const formatted = getCodeFromAst({ + nodesToOutput: explodedNodes, + originalCode: code, + directives: [], + }); + expect(formatted).toEqual( + `import Default, * as Namespace from './source';`, + ); +}); + +test('it should return default and namespaced value imports unchanged', () => { + const code = `import Default, { named } from './source';`; + const importNodes = getImportNodes(code); + const explodedNodes = explodeTypeAndValueSpecifiers(importNodes); + const formatted = getCodeFromAst({ + nodesToOutput: explodedNodes, + originalCode: code, + directives: [], + }); + expect(formatted).toEqual(`import Default, { named } from './source';`); +}); + +test('it should return default type imports unchanged', () => { + const code = `import type DefaultType from './source';`; + const importNodes = getImportNodes(code, { plugins: ['typescript'] }); + const explodedNodes = explodeTypeAndValueSpecifiers(importNodes); + const formatted = getCodeFromAst({ + nodesToOutput: explodedNodes, + originalCode: code, + directives: [], + }); + expect(formatted).toEqual(`import type DefaultType from './source';`); +}); + +test('it should return namespace type imports unchanged', () => { + const code = `import type * as NamespaceType from './source';`; + const importNodes = getImportNodes(code, { plugins: ['typescript'] }); + const explodedNodes = explodeTypeAndValueSpecifiers(importNodes); + const formatted = getCodeFromAst({ + nodesToOutput: explodedNodes, + originalCode: code, + directives: [], + }); + expect(formatted).toEqual( + `import type * as NamespaceType from './source';`, + ); +}); + +test('it should return named type imports unchanged', () => { + const code = `import type { NamedType1, NamedType2 } from './source';`; + const importNodes = getImportNodes(code, { plugins: ['typescript'] }); + const explodedNodes = explodeTypeAndValueSpecifiers(importNodes); + const formatted = getCodeFromAst({ + nodesToOutput: explodedNodes, + originalCode: code, + directives: [], + }); + expect(formatted).toEqual( + `import type { NamedType1, NamedType2 } from './source';`, + ); +}); + +test('it should separate named type and value imports', () => { + const code = `import { named, type NamedType } from './source';`; + const importNodes = getImportNodes(code, { plugins: ['typescript'] }); + const explodedNodes = explodeTypeAndValueSpecifiers(importNodes); + const formatted = getCodeFromAst({ + nodesToOutput: explodedNodes, + originalCode: code, + directives: [], + }); + expect(formatted).toEqual( + `import { named } from './source'; +import type { NamedType } from './source';`, + ); +}); + +test('it should separate named type and default value imports', () => { + const code = `import Default, { type NamedType } from './source';`; + const importNodes = getImportNodes(code, { plugins: ['typescript'] }); + const explodedNodes = explodeTypeAndValueSpecifiers(importNodes); + const formatted = getCodeFromAst({ + nodesToOutput: explodedNodes, + originalCode: code, + directives: [], + }); + expect(formatted).toEqual( + `import Default from './source'; +import type { NamedType } from './source';`, + ); +}); + +test('it should separate named type and default and named value imports', () => { + const code = `import Default, { named, type NamedType } from './source';`; + const importNodes = getImportNodes(code, { plugins: ['typescript'] }); + const explodedNodes = explodeTypeAndValueSpecifiers(importNodes); + const formatted = getCodeFromAst({ + nodesToOutput: explodedNodes, + originalCode: code, + directives: [], + }); + expect(formatted).toEqual( + `import Default, { named } from './source'; +import type { NamedType } from './source';`, + ); +}); diff --git a/src/utils/explode-type-and-value-specifiers.ts b/src/utils/explode-type-and-value-specifiers.ts new file mode 100644 index 0000000..ea0901d --- /dev/null +++ b/src/utils/explode-type-and-value-specifiers.ts @@ -0,0 +1,62 @@ +import { importDeclaration, type ImportSpecifier } from '@babel/types'; + +import { ExplodeTypeAndValueSpecifiers } from '../types'; + +/** + * Breaks apart import declarations containing mixed type and value imports into separate declarations. + * + * e.g. + * + * ```diff + * - import foo, { bar, type Baz } from './source'; + * + import foo, { bar } from './source'; + * + import type { Baz } from './source'; + * ``` + */ +export const explodeTypeAndValueSpecifiers: ExplodeTypeAndValueSpecifiers = ( + nodes, +) => { + const explodedNodes = []; + + for (const node of nodes) { + // We don't need to explode type imports, they won't mix type and value + if (node.importKind === 'type') { + explodedNodes.push(node); + continue; + } + + // Nothing to do if there's only one specifier + if (node.specifiers.length <= 1) { + explodedNodes.push(node); + continue; + } + + // @ts-expect-error TS is not refining correctly, but we're checking the type + const typeImports: ImportSpecifier[] = node.specifiers.filter( + (i) => i.type === 'ImportSpecifier' && i.importKind === 'type', + ); + + // If we have a mix of type and value imports, we need to 'splode them into two import declarations + if (typeImports.length) { + const valueImports = node.specifiers.filter( + (i) => + !(i.type === 'ImportSpecifier' && i.importKind === 'type'), + ); + const newValueNode = importDeclaration(valueImports, node.source); + explodedNodes.push(newValueNode); + + // Change the importKind of the specifiers, to avoid `import type {type Foo} from 'foo'` + typeImports.forEach( + (specifier) => (specifier.importKind = 'value'), + ); + const newTypeNode = importDeclaration(typeImports, node.source); + newTypeNode.importKind = 'type'; + explodedNodes.push(newTypeNode); + continue; + } + + // Just a boring old values-only node + explodedNodes.push(node); + } + return explodedNodes; +}; diff --git a/src/utils/get-import-nodes-matched-group.ts b/src/utils/get-import-nodes-matched-group.ts index 3f4a9d1..cd87c9b 100644 --- a/src/utils/get-import-nodes-matched-group.ts +++ b/src/utils/get-import-nodes-matched-group.ts @@ -1,6 +1,9 @@ import { ImportDeclaration } from '@babel/types'; -import { THIRD_PARTY_MODULES_SPECIAL_WORD } from '../constants'; +import { + THIRD_PARTY_MODULES_SPECIAL_WORD, + TYPES_SPECIAL_WORD, +} from '../constants'; /** * Get the regexp group to keep the import nodes. @@ -11,15 +14,35 @@ export const getImportNodesMatchedGroup = ( node: ImportDeclaration, importOrder: string[], ) => { - const groupWithRegExp = importOrder.map((group) => ({ - group, - regExp: new RegExp(group), - })); + const includesTypesSpecialWord = importOrder.some((group) => + group.includes(TYPES_SPECIAL_WORD), + ); + const groupWithRegExp = importOrder + .map((group) => ({ + group, + // Strip when creating regexp + regExp: new RegExp(group.replace(TYPES_SPECIAL_WORD, '')), + })) + // Remove explicit bare group, we'll deal with that at the end similar to third party modules + .filter(({ group }) => group !== TYPES_SPECIAL_WORD); for (const { group, regExp } of groupWithRegExp) { - const matched = node.source.value.match(regExp) !== null; + let matched = false; + // Type imports need to be checked separately + // Note: this does not include import specifiers, just declarations. + if (group.includes(TYPES_SPECIAL_WORD)) { + // Since we stripped above, this will have a regexp too, e.g. local types + matched = + node.importKind === 'type' && + node.source.value.match(regExp) !== null; + } else { + matched = node.source.value.match(regExp) !== null; + } + if (matched) return group; } - return THIRD_PARTY_MODULES_SPECIAL_WORD; + return node.importKind === 'type' && includesTypesSpecialWord + ? TYPES_SPECIAL_WORD + : THIRD_PARTY_MODULES_SPECIAL_WORD; }; diff --git a/src/utils/get-sorted-nodes.ts b/src/utils/get-sorted-nodes.ts index 6c2f8d8..4fbf870 100644 --- a/src/utils/get-sorted-nodes.ts +++ b/src/utils/get-sorted-nodes.ts @@ -1,6 +1,11 @@ -import { chunkTypeUnsortable, newLineNode } from '../constants'; +import { + TYPES_SPECIAL_WORD, + chunkTypeUnsortable, + newLineNode, +} from '../constants'; import { GetSortedNodes, ImportChunk, ImportOrLine } from '../types'; import { adjustCommentsOnSortedNodes } from './adjust-comments-on-sorted-nodes'; +import { explodeTypeAndValueSpecifiers } from './explode-type-and-value-specifiers'; import { getChunkTypeOfNode } from './get-chunk-type-of-node'; import { getSortedNodesByImportOrder } from './get-sorted-nodes-by-import-order'; import { mergeNodesWithMatchingImportFlavors } from './merge-nodes-with-matching-flavors'; @@ -22,6 +27,7 @@ import { mergeNodesWithMatchingImportFlavors } from './merge-nodes-with-matching */ export const getSortedNodes: GetSortedNodes = (nodes, options) => { const { + importOrder, importOrderSeparation, importOrderMergeDuplicateImports, importOrderCombineTypeAndValueImports, @@ -52,11 +58,17 @@ export const getSortedNodes: GetSortedNodes = (nodes, options) => { // do not sort side effect nodes finalNodes.push(...chunk.nodes); } else { - const nodes = importOrderMergeDuplicateImports + let nodes = importOrderMergeDuplicateImports ? mergeNodesWithMatchingImportFlavors(chunk.nodes, { importOrderCombineTypeAndValueImports, }) : chunk.nodes; + // If type ordering is specified explicitly, we need to break apart type and value specifiers + if ( + importOrder.some((group) => group.includes(TYPES_SPECIAL_WORD)) + ) { + nodes = explodeTypeAndValueSpecifiers(nodes); + } // sort non-side effect nodes const sorted = getSortedNodesByImportOrder(nodes, options); finalNodes.push(...sorted); diff --git a/tests/TypesSpecialWord/__snapshots__/ppsi.spec.js.snap b/tests/TypesSpecialWord/__snapshots__/ppsi.spec.js.snap new file mode 100644 index 0000000..43e4376 --- /dev/null +++ b/tests/TypesSpecialWord/__snapshots__/ppsi.spec.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`imports-with-mixed-declarations.ts - typescript-verify: imports-with-mixed-declarations.ts 1`] = ` +import a, {type LocalType} from './local-file'; +import value, {tp} from 'third-party'; +import {specifier, type ThirdPartyType} from 'third-party'; +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +import type { ThirdPartyType } from "third-party"; + +import value, { tp, specifier } from "third-party"; + +import type { LocalType } from "./local-file"; + +import a from "./local-file"; + +`; + +exports[`imports-with-third-party-types.ts - typescript-verify: imports-with-third-party-types.ts 1`] = ` +import a from './local-file'; +import type LocalType from './local-file'; +import value from 'third-party'; +import {specifier} from 'third-party'; +import type ThirdPartyType from 'third-party'; +import type {LocalSpecifierType} from './local-file'; +import type {SpecifierType} from 'third-party'; +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +import type ThirdPartyType from "third-party"; +import type { SpecifierType } from "third-party"; + +import value, { specifier } from "third-party"; + +import type LocalType from "./local-file"; +import type { LocalSpecifierType } from "./local-file"; + +import a from "./local-file"; + +`; diff --git a/tests/TypesSpecialWord/imports-with-mixed-declarations.ts b/tests/TypesSpecialWord/imports-with-mixed-declarations.ts new file mode 100644 index 0000000..7522dd4 --- /dev/null +++ b/tests/TypesSpecialWord/imports-with-mixed-declarations.ts @@ -0,0 +1,3 @@ +import a, {type LocalType} from './local-file'; +import value, {tp} from 'third-party'; +import {specifier, type ThirdPartyType} from 'third-party'; diff --git a/tests/TypesSpecialWord/imports-with-third-party-types.ts b/tests/TypesSpecialWord/imports-with-third-party-types.ts new file mode 100644 index 0000000..b28117e --- /dev/null +++ b/tests/TypesSpecialWord/imports-with-third-party-types.ts @@ -0,0 +1,7 @@ +import a from './local-file'; +import type LocalType from './local-file'; +import value from 'third-party'; +import {specifier} from 'third-party'; +import type ThirdPartyType from 'third-party'; +import type {LocalSpecifierType} from './local-file'; +import type {SpecifierType} from 'third-party'; diff --git a/tests/TypesSpecialWord/ppsi.spec.js b/tests/TypesSpecialWord/ppsi.spec.js new file mode 100644 index 0000000..2207056 --- /dev/null +++ b/tests/TypesSpecialWord/ppsi.spec.js @@ -0,0 +1,11 @@ +run_spec(__dirname, ['typescript'], { + importOrder: [ + '', + '', + '^[./]', + '^[./]', + ], + importOrderSeparation: true, + importOrderMergeDuplicateImports: true, + importOrderParserPlugins: ['typescript'], +}); diff --git a/types/index.d.ts b/types/index.d.ts index c360aa2..d1e55b3 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -26,6 +26,15 @@ export interface PluginConfig { * ```json * "importOrder": ["^@core/(.*)$", "", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], * ``` + * + * If you would like to order type imports differently from value imports, + * you can use the special `` string. + * This example will place third party types at the top, followed by local types, + * then third party value imports, and lastly local value imports: + * + * ```json + * "importOrder": ["", "^[./]", "", "^[./]"], + * ``` */ importOrder?: string[];