Skip to content
This repository has been archived by the owner on Mar 25, 2021. It is now read-only.

Update ordered-imports rule: add default grouping #3138

Merged
merged 1 commit into from
Sep 8, 2017
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 137 additions & 6 deletions src/rules/orderedImportsRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class Rule extends Lint.Rules.AbstractRule {
/* tslint:disable:object-literal-sort-keys */
public static metadata: Lint.IRuleMetadata = {
ruleName: "ordered-imports",
description: "Requires that import statements be alphabetized.",
description: "Requires that import statements be alphabetized and grouped.",
descriptionDetails: Lint.Utils.dedent`
Enforce a consistent ordering for ES6 imports:
- Named imports must be alphabetized (i.e. "import {A, B, C} from "foo";")
Expand All @@ -41,7 +41,8 @@ export class Rule extends Lint.Rules.AbstractRule {
import * as foo from "a";
import * as bar from "b";
- Groups of imports are delineated by blank lines. You can use these to group imports
however you like, e.g. by first- vs. third-party or thematically.`,
however you like, e.g. by first- vs. third-party or thematically or can you can
enforce a grouping of third-party, parent directories and the current directory.`,
hasFix: true,
optionsDescription: Lint.Utils.dedent`
You may set the \`"import-sources-order"\` option to control the ordering of source
Expand All @@ -54,6 +55,14 @@ export class Rule extends Lint.Rules.AbstractRule {
* \`"lowercase-last"\`: Correct order is \`"Bar"\`, \`"Foo"\`, \`"baz"\`.
* \`"any"\`: Allow any order.

You may set the \`"grouped-imports"\` option to control the grouping of source
imports (the \`"foo"\` in \`import {A, B, C} from "foo"\`).

Possible values for \`"grouped-imports"\` are:

* \`false\`: Do not enforce grouping. (This is the default.)
* \`true\`: Group source imports by \`"bar"\`, \`"../baz"\`, \`"./foo"\`.

You may set the \`"named-imports-order"\` option to control the ordering of named
imports (the \`{A, B, C}\` in \`import {A, B, C} from "foo"\`).

Expand All @@ -68,6 +77,9 @@ export class Rule extends Lint.Rules.AbstractRule {
options: {
type: "object",
properties: {
"grouped-imports": {
type: "boolean",
},
"import-sources-order": {
type: "string",
enum: ["case-insensitive", "lowercase-first", "lowercase-last", "any"],
Expand All @@ -88,6 +100,8 @@ export class Rule extends Lint.Rules.AbstractRule {
};
/* tslint:enable:object-literal-sort-keys */

public static IMPORT_SOURCES_NOT_GROUPED =
"Import sources of different groups must be sorted by: libraries, parent directories, current directory.";
public static IMPORT_SOURCES_UNORDERED = "Import sources within a group must be alphabetized.";
public static NAMED_IMPORTS_UNORDERED = "Named imports must be alphabetized.";

Expand All @@ -106,38 +120,56 @@ const TRANSFORMS = new Map<string, Transform>([
["lowercase-last", (x) => x],
]);

enum ImportType {
LIBRARY_IMPORT = 1,
PARENT_DIRECTORY_IMPORT = 2, // starts with "../"
CURRENT_DIRECTORY_IMPORT = 3, // starts with "./"
}

interface Options {
groupedImports: boolean;
importSourcesOrderTransform: Transform;
namedImportsOrderTransform: Transform;
}

interface JsonOptions {
"grouped-imports"?: boolean;
"import-sources-order"?: string;
"named-imports-order"?: string;
}

function parseOptions(ruleArguments: any[]): Options {
const optionSet = (ruleArguments as JsonOptions[])[0];
const {
"grouped-imports": isGrouped = false,
"import-sources-order": sources = "case-insensitive",
"named-imports-order": named = "case-insensitive",
} = optionSet === undefined ? {} : optionSet;
return {
groupedImports: isGrouped,
importSourcesOrderTransform: TRANSFORMS.get(sources)!,
namedImportsOrderTransform: TRANSFORMS.get(named)!,
};
}

class Walker extends Lint.AbstractWalker<Options> {
private currentImportsBlock = new ImportsBlock();
private importsBlocks = [new ImportsBlock()];
// keep a reference to the last Fix object so when the entire block is replaced, the replacement can be added
private lastFix: Lint.Replacement[] | undefined;
private nextType = ImportType.LIBRARY_IMPORT;

private get currentImportsBlock(): ImportsBlock {
return this.importsBlocks[this.importsBlocks.length - 1];
}

public walk(sourceFile: ts.SourceFile): void {
for (const statement of sourceFile.statements) {
this.checkStatement(statement);
}
this.endBlock();
if (this.options.groupedImports) {
this.checkBlocksGrouping();
}
}

private checkStatement(statement: ts.Statement): void {
Expand Down Expand Up @@ -213,7 +245,7 @@ class Walker extends Lint.AbstractWalker<Options> {
}
this.lastFix = undefined;
}
this.currentImportsBlock = new ImportsBlock();
this.importsBlocks.push(new ImportsBlock());
}

private checkNamedImports(node: ts.NamedImports): void {
Expand All @@ -237,6 +269,82 @@ class Walker extends Lint.AbstractWalker<Options> {
this.addFailure(a.getStart(), b.getEnd(), Rule.NAMED_IMPORTS_UNORDERED, this.lastFix);
}
}

private checkBlocksGrouping(): void {
this.importsBlocks.some(this.checkBlockGroups, this);
}

private checkBlockGroups(importsBlock: ImportsBlock): boolean {
const oddImportDeclaration = this.getOddImportDeclaration(importsBlock);
if (oddImportDeclaration !== undefined) {
this.addFailureAtNode(oddImportDeclaration.node, Rule.IMPORT_SOURCES_NOT_GROUPED, this.getReplacements());
return true;
}
return false;
}

private getOddImportDeclaration(importsBlock: ImportsBlock): ImportDeclaration|undefined {
const importDeclarations = importsBlock.getImportDeclarations();
if (importDeclarations.length === 0) {
return undefined;
}
const type = importDeclarations[0].type;
if (type < this.nextType) {
return importDeclarations[0];
} else {
this.nextType = type;
return importDeclarations.find((importDeclaration) => importDeclaration.type != type);
}
}

private getReplacements(): Lint.Replacement[] {
const importDeclarationsList = this.importsBlocks
.map((block) => block.getImportDeclarations())
.filter((imports) => imports.length > 0);
const allImportDeclarations = ([] as ImportDeclaration[]).concat(...importDeclarationsList);
const replacements = this.getReplacementsForExistingImports(importDeclarationsList);
const startOffset = allImportDeclarations.length === 0 ? 0 : allImportDeclarations[0].nodeStartOffset;
replacements.push(Lint.Replacement.appendText(startOffset, this.getGroupedImports(allImportDeclarations)));
return replacements;
}

private getReplacementsForExistingImports(importDeclarationsList: ImportDeclaration[][]): Lint.Replacement[] {
return importDeclarationsList.map((items, index) => {
let start = items[0].nodeStartOffset;
if (index > 0) {
const prevItems = importDeclarationsList[index - 1];
const last = prevItems[prevItems.length - 1];
if (/[\r\n]+/.test(this.sourceFile.text.slice(last.nodeEndOffset, start))) {
// remove whitespace between blocks
start = last.nodeEndOffset;
}
}
return Lint.Replacement.deleteFromTo(start, items[items.length - 1].nodeEndOffset);
});
}

private getGroupedImports(importDeclarations: ImportDeclaration[]): string {
return [ImportType.LIBRARY_IMPORT, ImportType.PARENT_DIRECTORY_IMPORT, ImportType.CURRENT_DIRECTORY_IMPORT]
.map((type) => {
const imports = importDeclarations.filter((importDeclaration) => importDeclaration.type === type);
return getSortedImportDeclarationsAsText(imports);
})
.filter((text) => text.length > 0)
.join(this.getEolChar());
}

private getEolChar(): string {
const lineEnd = this.sourceFile.getLineEndOfPosition(0);
let newLine;
if (lineEnd > 0) {
if (lineEnd > 1 && this.sourceFile.text[lineEnd - 1] === "\r") {
newLine = "\r\n";
} else if (this.sourceFile.text[lineEnd] === "\n") {
newLine = "\n";
}
}
return newLine == null ? ts.sys.newLine : newLine;
}
}

interface ImportDeclaration {
Expand All @@ -245,6 +353,7 @@ interface ImportDeclaration {
nodeStartOffset: number; // start position of node within source file
text: string; // initialized with original import text; modified if the named imports are reordered
sourcePath: string;
type: ImportType;
}

class ImportsBlock {
Expand All @@ -254,6 +363,7 @@ class ImportsBlock {
const start = this.getStartOffset(node);
const end = this.getEndOffset(sourceFile, node);
const text = sourceFile.text.substring(start, end);
const type = this.getImportType(sourcePath);

if (start > node.getStart() || end === 0) {
// skip block if any statements don't end with a newline to simplify implementation
Expand All @@ -267,9 +377,14 @@ class ImportsBlock {
nodeStartOffset: start,
sourcePath,
text,
type,
});
}

public getImportDeclarations(): ImportDeclaration[] {
return this.importDeclarations;
}

// replaces the named imports on the most recent import declaration
public replaceNamedImports(fileOffset: number, length: number, replacement: string) {
const importDeclaration = this.getLastImportDeclaration();
Expand Down Expand Up @@ -299,8 +414,7 @@ class ImportsBlock {
if (this.importDeclarations.length === 0) {
return undefined;
}
const sortedDeclarations = sortByKey(this.importDeclarations.slice(), (x) => x.sourcePath);
const fixedText = sortedDeclarations.map((x) => x.text).join("");
const fixedText = getSortedImportDeclarationsAsText(this.importDeclarations);
const start = this.importDeclarations[0].nodeStartOffset;
const end = this.getLastImportDeclaration().nodeEndOffset;
return new Lint.Replacement(start, end - start, fixedText);
Expand All @@ -322,6 +436,18 @@ class ImportsBlock {
private getLastImportDeclaration() {
return this.importDeclarations[this.importDeclarations.length - 1];
}

private getImportType(sourcePath: string): ImportType {
if (sourcePath.charAt(0) === ".") {
if (sourcePath.charAt(1) === ".") {
return ImportType.PARENT_DIRECTORY_IMPORT;
} else {
return ImportType.CURRENT_DIRECTORY_IMPORT;
}
} else {
return ImportType.LIBRARY_IMPORT;
}
}
}

// Convert aBcD --> AbCd
Expand Down Expand Up @@ -372,6 +498,11 @@ function removeQuotes(value: string): string {
return value;
}

function getSortedImportDeclarationsAsText(importDeclarations: ImportDeclaration[]): string {
const sortedDeclarations = sortByKey(importDeclarations.slice(), (x) => x.sourcePath);
Copy link
Contributor

Choose a reason for hiding this comment

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

No need to pass a slice. The function creates a new array anyway

return sortedDeclarations.map((x) => x.text).join("");
}

function sortByKey<T>(xs: ReadonlyArray<T>, getSortKey: (x: T) => string): T[] {
return xs.slice().sort((a, b) => compare(getSortKey(a), getSortKey(b)));
}
Expand Down
11 changes: 11 additions & 0 deletions test/rules/ordered-imports/grouped-imports/test.ts.fix
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env node

import {afoo, foo} from 'foo';
import x = require('y');

import {bar} from '../bar';

import './baa';
import './baz'; // required

export class Test {}
15 changes: 15 additions & 0 deletions test/rules/ordered-imports/grouped-imports/test.ts.lint
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env node

import {bar} from '../bar';

import {foo, afoo} from 'foo';
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [Import sources of different groups must be sorted by: libraries, parent directories, current directory.]
~~~~~~~~~ [Named imports must be alphabetized.]

import './baz'; // required
import './baa';
~~~~~~~~~~~~~~~ [Import sources within a group must be alphabetized.]

import x = require('y');

export class Test {}
5 changes: 5 additions & 0 deletions test/rules/ordered-imports/grouped-imports/tslint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"rules": {
"ordered-imports": [true, {"import-sources-order": "case-insensitive", "named-imports-order": "case-insensitive", "grouped-imports": true}]
}
}