Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support explicit type sorting #44

Merged
merged 3 commits into from Oct 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Expand Up @@ -200,6 +200,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>", "^[./]"],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love the "virtual matcher" combined with a custom matcher for Types. "<TYPES>^[./]"
It feels like a bad precedent to have to split these keys into tags and a regex, and have to match them by startsWith, etc.

I don't mind the rest of this PR though! We talked about this feature previously.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it does feel a bit wonky. Do you have any idea how else we could support it, though? (We don't actually use startsWith here, fwiw, but we do remove the <TYPES> part when creating the regex.

The tricky part here is giving the flexibility to declare a regex as only applying to type imports vs value imports. I think we'd need to support objects in importOrder, and use something like importOrder: [{regex: '^[./]', isType: true}, ...], and that doesn't feel great either. I'm open to suggestions here, though.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, so thinking about this more deeply, this is supposed to mirror the ability to create arbitrary groups for types. Eg. interleave types and non-type imports in batches.

I'd mayhaps try to argue that as a plugin for an opinionated formatter, maybe we should fight to remove the importOrder option entirely, but that seems like a hard sell.

Fewer customization points = simpler code, and easier onboarding for users, so I guess your original proposal is better than the alternatives that jump to mind.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think that it's okay to keep importOrder and try to minimize the number of other options that we expose. I definitely think we need a default for it that works in most cases, though.

```

#### `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';