Skip to content

Commit

Permalink
Feature: importOrderMergeDuplicateImports (#19)
Browse files Browse the repository at this point in the history
This PR implements import declaration merging for duplicate imports.

I had to adjust getCodeFromAST so that we could still provide the original nodes, even if some of the nodes were being deleted.
It leverages the existing chunking logic, so if there's a side-effect import, or a prettier-ignore import, it won't merge across that boundary.
I did not implement conversion of import type { stuff } into import { type stuff } as it was not obvious when this would be wise. If we want to control that with an additional config option, something like importOrderMergeTypeStyle: "none" | "to-normal-import" | "unmerge" I could be convinced. I forsee people wanting opposite sides of the coin for that one.


* Add TypeScript enforcement to not-forgetting our Options schema!

* Change syntax of getCodeFromAst to allow for node deletion

* Github-linguist doesn't understand `ecmascript 6`

Just use javascript or js

* Feature: `importOrderMergeDuplicateImports` - Fixes #4

* switch from toMatchInlineSnapshot to toEqual

* PR Feedback: rename `regular` imports to `value` imports

* PR Feedback: Readme wording

* PR Feedback: rename getCodeFromAst parameters

Try to improve the naming and docs for the parameters, particularly the nodes vs importNodes distinciton

* PR Feedback: import-flavor @returns documentation

* PR Feedback: hoist hasIgnoreNextNode for reuse

* PR Feedback: Add strong types for ChunkType/FlavorType

* PR Feedback: deleteContext -> nodesToDelete

* PR Feedback: comment-update: once you mutate nodes, lines/locations are stale!

* Add a few tests and comments
  • Loading branch information
fbartho committed Jun 9, 2022
1 parent d7d2894 commit 3233783
Show file tree
Hide file tree
Showing 19 changed files with 682 additions and 83 deletions.
20 changes: 17 additions & 3 deletions README.md
Expand Up @@ -86,21 +86,27 @@ yarn add --dev @ianvs/prettier-plugin-sort-imports

## Usage

Add an order in prettier config file.
Add your preferred settings in your prettier config file.

```ecmascript 6
```javascript
module.exports = {
"printWidth": 80,
"tabWidth": 4,
"trailingComma": "all",
"singleQuote": true,
"semi": true,
"importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"],
"importOrderBuiltinModulesToTop": true,
"importOrderCaseInsensitive": true,
"importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"],
"importOrderMergeDuplicateImports": true,
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
"importOrderSortSpecifiers": true,
}
```

_Note: all flags are off by default, so explore your options [below](#apis)_

### APIs

#### Prevent imports from being sorted
Expand Down Expand Up @@ -204,6 +210,14 @@ import ExamplesList from './ExamplesList';
import ExampleView from './ExampleView';
```

#### `importOrderMergeDuplicateImports`

**type**: `boolean`

**default value:** `false`

When `true`, multiple import statements from the same module will be combined into a single import.

#### `importOrderParserPlugins`

**type**: `Array<string>`
Expand Down
6 changes: 3 additions & 3 deletions docs/TROUBLESHOOTING.md
Expand Up @@ -7,7 +7,7 @@
You can define the RegEx in the `importOrder`. For
example if you want to sort the following imports:

```ecmascript 6
```javascript
import React from 'react';
import classnames from 'classnames';
import z from '@server/z';
Expand All @@ -20,7 +20,7 @@ import q from '@ui/q';
then the `importOrder` would be `["^@ui/(.*)$","^@server/(.*)$", '^[./]']`.
Now, the final output would be as follows:

```ecmascript 6
```javascript
import classnames from 'classnames';
import React from 'react';
import p from '@ui/p';
Expand All @@ -35,7 +35,7 @@ import s from './';
You can define the `<THIRD_PARTY_MODULES>` special word in the `importOrder`. For example above, the `importOrder` would be like `["^@ui/(.*)$", "^@server/(.*)$", "<THIRD_PARTY_MODULES>", '^[./]']`.
Now, the final output would be as follows:

```ecmascript 6
```javascript
import p from '@ui/p';
import q from '@ui/q';
import a from '@server/a';
Expand Down
11 changes: 11 additions & 0 deletions src/constants.ts
Expand Up @@ -11,6 +11,17 @@ export const newLineCharacters = '\n\n';
export const chunkTypeUnsortable = 'unsortable';
export const chunkTypeOther = 'other';

/** Value imports (including top-level default imports) - import {Thing} from ... or import Thing from ... */
export const importFlavorValue = 'value';
/** import type {} from ... */
export const importFlavorType = 'type';
export const importFlavorSideEffect = 'side-effect';
export const importFlavorIgnore = 'prettier-ignore';
export const mergeableImportFlavors = [
importFlavorValue,
importFlavorType,
] as const;

/*
* Used to mark the position between RegExps,
* where the not matched imports should be placed
Expand Down
22 changes: 21 additions & 1 deletion src/index.ts
@@ -1,10 +1,24 @@
import type { RequiredOptions as PrettierRequiredOptions } from 'prettier';
import { parsers as babelParsers } from 'prettier/parser-babel';
import { parsers as flowParsers } from 'prettier/parser-flow';
import { parsers as typescriptParsers } from 'prettier/parser-typescript';

import { preprocessor } from './preprocessor';
import type { PrettierOptions } from './types';

const options = {
// Not sure what the type from Prettier should be, but this is a good enough start.
interface PrettierOptionSchema {
type: string;
category: 'Global';
array?: boolean;
default: unknown;
description: string;
}

const options: Record<
Exclude<keyof PrettierOptions, keyof PrettierRequiredOptions>,
PrettierOptionSchema
> = {
importOrder: {
type: 'path',
category: 'Global',
Expand Down Expand Up @@ -52,6 +66,12 @@ const options = {
default: false,
description: 'Should node-builtins be hoisted to the top?',
},
importOrderMergeDuplicateImports: {
type: 'boolean',
category: 'Global',
default: false,
description: 'Should duplicate imports be merged?',
},
};

module.exports = {
Expand Down
28 changes: 18 additions & 10 deletions src/preprocessor.ts
Expand Up @@ -11,14 +11,15 @@ export function preprocessor(code: string, options: PrettierOptions): string {
const {
importOrderParserPlugins,
importOrder,
importOrderBuiltinModulesToTop,
importOrderCaseInsensitive,
importOrderSeparation,
importOrderGroupNamespaceSpecifiers,
importOrderMergeDuplicateImports,
importOrderSeparation,
importOrderSortSpecifiers,
importOrderBuiltinModulesToTop,
} = options;

const importNodes: ImportDeclaration[] = [];
const allOriginalImportNodes: ImportDeclaration[] = [];
const parserOptions: ParserOptions = {
sourceType: 'module',
plugins: getExperimentalParserPlugins(importOrderParserPlugins),
Expand All @@ -35,24 +36,31 @@ export function preprocessor(code: string, options: PrettierOptions): string {
isTSModuleDeclaration(p),
);
if (!tsModuleParent) {
importNodes.push(path.node);
allOriginalImportNodes.push(path.node);
}
},
});

// short-circuit if there are no import declaration
if (importNodes.length === 0) {
// short-circuit if there are no import declarations
if (allOriginalImportNodes.length === 0) {
return code;
}

const allImports = getSortedNodes(importNodes, {
const nodesToOutput = getSortedNodes(allOriginalImportNodes, {
importOrder,
importOrderBuiltinModulesToTop,
importOrderCaseInsensitive,
importOrderSeparation,
importOrderGroupNamespaceSpecifiers,
importOrderMergeDuplicateImports,
importOrderSeparation,
importOrderSortSpecifiers,
importOrderBuiltinModulesToTop,
});

return getCodeFromAst(allImports, code, directives, interpreter);
return getCodeFromAst({
nodesToOutput,
allOriginalImportNodes,
originalCode: code,
directives,
interpreter,
});
}
36 changes: 30 additions & 6 deletions src/types.ts
@@ -1,20 +1,37 @@
import { ExpressionStatement, ImportDeclaration } from '@babel/types';
import { RequiredOptions } from 'prettier';

import {
chunkTypeOther,
chunkTypeUnsortable,
importFlavorIgnore,
importFlavorSideEffect,
importFlavorType,
importFlavorValue,
} from './constants';

export interface PrettierOptions extends RequiredOptions {
importOrder: string[];
importOrderCaseInsensitive: boolean;
importOrderBuiltinModulesToTop: boolean;
// should be of type ParserPlugin from '@babel/parser' but prettier does not support nested arrays in options
importOrderParserPlugins: string[];
importOrderSeparation: boolean;
importOrderGroupNamespaceSpecifiers: boolean;
importOrderMergeDuplicateImports: boolean;
importOrderSeparation: boolean;
importOrderSortSpecifiers: boolean;
// should be of type ParserPlugin from '@babel/parser' but prettier does not support nested arrays in options
importOrderParserPlugins: string[];
}

export type ChunkType = typeof chunkTypeOther | typeof chunkTypeUnsortable;
export type FlavorType =
| typeof importFlavorIgnore
| typeof importFlavorSideEffect
| typeof importFlavorType
| typeof importFlavorValue;

export interface ImportChunk {
nodes: ImportDeclaration[];
type: string;
type: ChunkType;
}

export type ImportGroups = Record<string, ImportDeclaration[]>;
Expand All @@ -27,10 +44,17 @@ export type GetSortedNodes = (
| 'importOrder'
| 'importOrderBuiltinModulesToTop'
| 'importOrderCaseInsensitive'
| 'importOrderSeparation'
| 'importOrderGroupNamespaceSpecifiers'
| 'importOrderMergeDuplicateImports'
| 'importOrderSeparation'
| 'importOrderSortSpecifiers'
>,
) => ImportOrLine[];

export type GetChunkTypeOfNode = (node: ImportDeclaration) => string;
export type GetChunkTypeOfNode = (node: ImportDeclaration) => ChunkType;

export type GetImportFlavorOfNode = (node: ImportDeclaration) => FlavorType;

export type MergeNodesWithMatchingImportFlavors = (
nodes: ImportDeclaration[],
) => ImportDeclaration[];
5 changes: 3 additions & 2 deletions src/utils/__tests__/get-all-comments-from-nodes.spec.ts
Expand Up @@ -10,11 +10,12 @@ const getSortedImportNodes = (code: string, options?: ParserOptions) => {

return getSortedNodes(importNodes, {
importOrder: [],
importOrderBuiltinModulesToTop: false,
importOrderCaseInsensitive: false,
importOrderSeparation: false,
importOrderGroupNamespaceSpecifiers: false,
importOrderMergeDuplicateImports: false,
importOrderSeparation: false,
importOrderSortSpecifiers: false,
importOrderBuiltinModulesToTop: false,
});
};

Expand Down
57 changes: 53 additions & 4 deletions src/utils/__tests__/get-code-from-ast.spec.ts
Expand Up @@ -4,7 +4,7 @@ import { getCodeFromAst } from '../get-code-from-ast';
import { getImportNodes } from '../get-import-nodes';
import { getSortedNodes } from '../get-sorted-nodes';

test('it sorts imports correctly', () => {
it('sorts imports correctly', () => {
const code = `// first comment
// second comment
import z from 'z';
Expand All @@ -17,13 +17,18 @@ import a from 'a';
const importNodes = getImportNodes(code);
const sortedNodes = getSortedNodes(importNodes, {
importOrder: [],
importOrderBuiltinModulesToTop: false,
importOrderCaseInsensitive: false,
importOrderSeparation: false,
importOrderGroupNamespaceSpecifiers: false,
importOrderMergeDuplicateImports: false,
importOrderSeparation: false,
importOrderSortSpecifiers: false,
importOrderBuiltinModulesToTop: false,
});
const formatted = getCodeFromAst(sortedNodes, code, [], undefined);
const formatted = getCodeFromAst({
nodesToOutput: sortedNodes,
originalCode: code,
directives: [],
});
expect(format(formatted, { parser: 'babel' })).toEqual(
`// first comment
// second comment
Expand All @@ -36,3 +41,47 @@ import z from "z";
`,
);
});

it('merges duplicate imports correctly', () => {
const code = `// first comment
// second comment
import z from 'z';
import c from 'c';
import type {C} from 'c';
import type {See} from 'c';
import g from 'g';
import t from 't';
import k from 'k';
import a from 'a';
import {b} from 'a';
import {type Bee} from 'a';
`;
const importNodes = getImportNodes(code, { plugins: ['typescript'] });
const sortedNodes = getSortedNodes(importNodes, {
importOrder: [],
importOrderBuiltinModulesToTop: false,
importOrderCaseInsensitive: false,
importOrderGroupNamespaceSpecifiers: false,
importOrderMergeDuplicateImports: true,
importOrderSeparation: false,
importOrderSortSpecifiers: false,
});
const formatted = getCodeFromAst({
nodesToOutput: sortedNodes,
allOriginalImportNodes: importNodes,
originalCode: code,
directives: [],
});
expect(format(formatted, { parser: 'babel' })).toEqual(
`// first comment
// second comment
import a, { b, type Bee } from "a";
import c from "c";
import type { C, See } from "c";
import g from "g";
import k from "k";
import t from "t";
import z from "z";
`,
);
});
32 changes: 32 additions & 0 deletions src/utils/__tests__/get-import-flavor-of-node.spec.ts
@@ -0,0 +1,32 @@
import { getImportFlavorOfNode } from '../get-import-flavor-of-node';
import { getImportNodes } from '../get-import-nodes';

it('should correctly classify a bunch of import expressions', () => {
expect(
getImportNodes(
`
import "./side-effects";
import { a } from "a";
import type { b } from "b";
import { type C } from "c";
import D from "d";
import e = require("e"); // Doesn't count as import
const f = require("f"); // Doesn't count as import
// prettier-ignore
import { g } from "g";
`,
{ plugins: ['typescript'] },
).map((node) => getImportFlavorOfNode(node)),
).toMatchInlineSnapshot(`
Array [
"side-effect",
"value",
"type",
"value",
"value",
"prettier-ignore",
]
`);
});

0 comments on commit 3233783

Please sign in to comment.