Skip to content

Commit

Permalink
Support explicit type sorting (#44)
Browse files Browse the repository at this point in the history
Closes #35

Adapted from trivago/prettier-plugin-sort-imports#153, by @Xenfo.  

This adds a new special string, `<TYPES>` 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 `<TYPES>` 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 `<TYPES>`.
  • Loading branch information
IanVS committed Oct 27, 2022
1 parent 2afee47 commit 35266d5
Show file tree
Hide file tree
Showing 13 changed files with 325 additions and 12 deletions.
6 changes: 6 additions & 0 deletions README.md
Expand Up @@ -208,6 +208,12 @@ To move the third party imports at desired place, you can use `<THIRD_PARTY_MODU
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"],
```

If you would like to order type imports differently from value imports, you can use the special `<TYPES>` 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": ["<TYPES>", "<TYPES>^[./]", "<THIRD_PARTY_MODULES>", "^[./]"],
```

#### `importOrderSeparation`

**type**: `boolean`
Expand Down
3 changes: 2 additions & 1 deletion src/constants.ts
Expand Up @@ -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 = '<THIRD_PARTY_MODULES>';
export const BUILTIN_MODULES = `^(?:node:)?(?:${builtinModules.join('|')})$`;
export const THIRD_PARTY_MODULES_SPECIAL_WORD = '<THIRD_PARTY_MODULES>';
export const TYPES_SPECIAL_WORD = '<TYPES>';

const PRETTIER_PLUGIN_SORT_IMPORTS_NEW_LINE =
'PRETTIER_PLUGIN_SORT_IMPORTS_NEW_LINE';
Expand Down
16 changes: 14 additions & 2 deletions src/preprocessors/preprocessor.ts
Expand Up @@ -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';
Expand All @@ -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[] = [];
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Expand Up @@ -53,3 +53,7 @@ export type MergeNodesWithMatchingImportFlavors = (
nodes: ImportDeclaration[],
options: { importOrderCombineTypeAndValueImports: boolean },
) => ImportDeclaration[];

export type ExplodeTypeAndValueSpecifiers = (
nodes: ImportDeclaration[],
) => ImportDeclaration[];
126 changes: 126 additions & 0 deletions 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';`,
);
});
62 changes: 62 additions & 0 deletions 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;
};
37 changes: 30 additions & 7 deletions 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.
Expand All @@ -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 <TYPES> when creating regexp
regExp: new RegExp(group.replace(TYPES_SPECIAL_WORD, '')),
}))
// Remove explicit bare <TYPES> 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 <TYPES> 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;
};
16 changes: 14 additions & 2 deletions 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';
Expand All @@ -22,6 +27,7 @@ import { mergeNodesWithMatchingImportFlavors } from './merge-nodes-with-matching
*/
export const getSortedNodes: GetSortedNodes = (nodes, options) => {
const {
importOrder,
importOrderSeparation,
importOrderMergeDuplicateImports,
importOrderCombineTypeAndValueImports,
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions 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";
`;
3 changes: 3 additions & 0 deletions 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';

0 comments on commit 35266d5

Please sign in to comment.