Skip to content

Commit

Permalink
feat(eslint-plugin): [no-circular-imports] add new rule
Browse files Browse the repository at this point in the history
  • Loading branch information
yeonjuan committed Apr 26, 2024
1 parent d07eb9e commit 27d3002
Show file tree
Hide file tree
Showing 19 changed files with 390 additions and 1 deletion.
47 changes: 47 additions & 0 deletions packages/eslint-plugin/docs/rules/no-circular-import.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
description: 'Disallow the use of import module that result in circular imports.'
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

> 🛑 This file is source code, not the primary documentation location! 🛑
>
> See **https://typescript-eslint.io/rules/no-circular-import** for documentation.
This rule disallows the use of import module that result in circular imports except for the type-only imports.

## Examples

<Tabs>
<TabItem value="❌ Incorrect">

```ts skipValidation
// foo.ts
import { bar } from './bar';
export const foo = 1;

// bar.ts
import { foo } from './foo';
export const bar = 1;
```

</TabItem>
<TabItem value="✅ Correct">

```ts
// foo.ts
import type { bar } from './bar';
export type baz = number;

// bar.ts
import { type baz } from './baz';
export type bar = number;
```

</TabItem>
</Tabs>

## When Not To Use It

## Related To
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export = {
'@typescript-eslint/no-base-to-string': 'error',
'@typescript-eslint/no-confusing-non-null-assertion': 'error',
'@typescript-eslint/no-confusing-void-expression': 'error',
'@typescript-eslint/no-circular-import': 'error',
'no-dupe-class-members': 'off',
'@typescript-eslint/no-dupe-class-members': 'error',
'@typescript-eslint/no-duplicate-enum-values': 'error',
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/disable-type-checked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export = {
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-array-delete': 'off',
'@typescript-eslint/no-base-to-string': 'off',
'@typescript-eslint/no-circular-import': 'off',
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/no-duplicate-type-constituents': 'off',
'@typescript-eslint/no-floating-promises': 'off',
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import namingConvention from './naming-convention';
import noArrayConstructor from './no-array-constructor';
import noArrayDelete from './no-array-delete';
import noBaseToString from './no-base-to-string';
import noCircularImport from './no-circular-import';
import confusingNonNullAssertionLikeNotEqual from './no-confusing-non-null-assertion';
import noConfusingVoidExpression from './no-confusing-void-expression';
import noDupeClassMembers from './no-dupe-class-members';
Expand Down Expand Up @@ -184,6 +185,7 @@ export default {
'no-array-constructor': noArrayConstructor,
'no-array-delete': noArrayDelete,
'no-base-to-string': noBaseToString,
'no-circular-import': noCircularImport,
'no-confusing-non-null-assertion': confusingNonNullAssertionLikeNotEqual,
'no-confusing-void-expression': noConfusingVoidExpression,
'no-dupe-class-members': noDupeClassMembers,
Expand Down
181 changes: 181 additions & 0 deletions packages/eslint-plugin/src/rules/no-circular-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import * as ts from 'typescript';

import { createRule, getParserServices } from '../util';

type Options = [];
type MessageIds = 'noCircularImport';

interface Edge {
filename: string;
specifier: string;
}

class Graph {
private graph = new Map<
string,
{
filename: string;
specifier: string;
}[]
>();

addEdge(start: string, edge: Edge): void {
if (this.graph.has(start)) {
this.graph.get(start)?.push(edge);
} else {
this.graph.set(start, [edge]);
}
}

hasEdge(name: string): boolean {
return this.graph.has(name);
}

getEdges(name: string): Edge[] {
return this.graph.get(name) ?? [];
}
}
// imports “a.ts” and is imported from “b.ts”, resulting in a circular reference.
export default createRule<Options, MessageIds>({
name: 'no-circular-import',
meta: {
docs: {
description:
'Disallow the use of import module that result in circular imports',
requiresTypeChecking: true,
},
messages: {
noCircularImport: 'Circular import dcetected via {{paths}}.',
},
schema: [],
type: 'suggestion',
},
defaultOptions: [],
create(context) {
const services = getParserServices(context);
const graph = new Graph();

function resolveSpecifier(
containingFile: string,
specifier: string,
): ts.ResolvedModuleWithFailedLookupLocations {
return ts.resolveModuleName(
specifier,
containingFile,
services.program.getCompilerOptions(),
ts.sys,
);
}

function isTypeOnlyImport(node: ts.ImportDeclaration): boolean {
return (
node.importClause?.isTypeOnly ||
(!!node.importClause?.namedBindings &&
ts.isNamedImports(node.importClause.namedBindings) &&
node.importClause.namedBindings.elements.every(
elem => elem.isTypeOnly,
))
);
}

function addEdgesRecursively(
graph: Graph,
containingFile: string,
importDeclaration: ts.ImportDeclaration,
): void {
if (graph.hasEdge(containingFile)) {
return;
}

if (isTypeOnlyImport(importDeclaration)) {
return;
}

if (!ts.isStringLiteral(importDeclaration.moduleSpecifier)) {
return;
}

const specifier = importDeclaration.moduleSpecifier.text;

const resolved = resolveSpecifier(containingFile, specifier);

if (
!resolved.resolvedModule ||
resolved.resolvedModule.isExternalLibraryImport
) {
return;
}

const resolvedFile = resolved.resolvedModule.resolvedFileName;

graph.addEdge(containingFile, {
filename: resolvedFile,
specifier,
});

const sourceCode = services.program.getSourceFile(resolvedFile);
sourceCode?.statements.forEach(statement => {
if (ts.isImportDeclaration(statement)) {
addEdgesRecursively(graph, resolvedFile, statement);
}
});
}

function detectCycleWorker(
start: string,
graph: Graph,
filename: string,
visited: Set<string>,
paths: string[],
): string[] {
visited.add(filename);

for (const edge of graph.getEdges(filename)) {
if (visited.has(edge.filename)) {
if (edge.filename === start) {
return paths.concat(edge.specifier);
}
return [];
}
const detected = detectCycleWorker(
start,
graph,
edge.filename,
visited,
paths.concat(edge.specifier),
);
if (detected.length) {
return detected;
}
}
return [];
}

function detectCycle(graph: Graph, filename: string): string[] {
const visited = new Set<string>();
return detectCycleWorker(filename, graph, filename, visited, []);
}

return {
ImportDeclaration(node): void {
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
const containingFile = tsNode.parent.getSourceFile().fileName;
addEdgesRecursively(graph, containingFile, tsNode);

const cycle = detectCycle(graph, containingFile);
if (cycle.length > 1) {
context.report({
messageId: 'noCircularImport',
node,
data: {
paths:
cycle.length === 2
? cycle[0]
: `${cycle[0]} ... ${cycle[cycle.length - 2]}`,
},
});
}
},
};
},
});

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { entry } from './entry';
export const one = entry;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { two } from './depth-two';
export const three = two;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { one } from './depth-one';
export const two = one;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const entry = 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { b } from './isolated-circular-b';
export const a = 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { a } from './isolated-circular-a';
export const b = 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { entry } from './entry';
import { type entry as EntryType } from './entry';
export type TypeOnly = typeof entry | typeof EntryType;
3 changes: 2 additions & 1 deletion packages/eslint-plugin/tests/fixtures/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"file.ts",
"consistent-type-exports.ts",
"mixed-enums-decl.ts",
"react.tsx"
"react.tsx",
"no-circular-import"
]
}

0 comments on commit 27d3002

Please sign in to comment.