forked from import-js/eslint-plugin-import
-
-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
perf: improve rule
no-cycle
using strongly connected components (#111)
- Loading branch information
Showing
8 changed files
with
312 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"eslint-plugin-import-x": patch | ||
--- | ||
|
||
Drastically improve `no-cycle`'s performance by skipping unnecessary BFSes using [Tarjan's SCC](https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import calculateScc from '@rtsao/scc' | ||
|
||
import type { ChildContext, RuleContext } from '../types' | ||
|
||
import { ExportMap, childContext } from './export-map' | ||
import { resolve } from './resolve' | ||
|
||
const cache = new Map<string, Record<string, number>>() | ||
|
||
export const StronglyConnectedComponents = { | ||
clearCache() { | ||
cache.clear() | ||
}, | ||
|
||
get(source: string, context: RuleContext) { | ||
const path = resolve(source, context) | ||
if (path == null) { | ||
return null | ||
} | ||
return StronglyConnectedComponents.for(childContext(path, context)) | ||
}, | ||
|
||
for(context: ChildContext) { | ||
const cacheKey = context.cacheKey | ||
if (cache.has(cacheKey)) { | ||
return cache.get(cacheKey)! | ||
} | ||
const scc = StronglyConnectedComponents.calculate(context) | ||
cache.set(cacheKey, scc) | ||
return scc | ||
}, | ||
|
||
calculate(context: ChildContext) { | ||
const exportMap = ExportMap.for(context) | ||
const adjacencyList = | ||
StronglyConnectedComponents.exportMapToAdjacencyList(exportMap) | ||
const calculatedScc = calculateScc(adjacencyList) | ||
return StronglyConnectedComponents.calculatedSccToPlainObject(calculatedScc) | ||
}, | ||
|
||
exportMapToAdjacencyList(initialExportMap: ExportMap | null) { | ||
/** for each dep, what are its direct deps */ | ||
const adjacencyList = new Map<string, Set<string>>() | ||
// BFS | ||
function visitNode(exportMap: ExportMap | null) { | ||
if (!exportMap) { | ||
return | ||
} | ||
for (const [importedPath, v] of exportMap.imports.entries()) { | ||
const from = exportMap.path | ||
const to = importedPath | ||
|
||
if (!adjacencyList.has(from)) { | ||
adjacencyList.set(from, new Set()) | ||
} | ||
|
||
const set = adjacencyList.get(from)! | ||
|
||
if (set.has(to)) { | ||
continue // prevent endless loop | ||
} | ||
set.add(to) | ||
visitNode(v.getter()) | ||
} | ||
} | ||
visitNode(initialExportMap) | ||
// Fill gaps | ||
// eslint-disable-next-line unicorn/no-array-for-each -- Map.forEach, and it is way faster | ||
adjacencyList.forEach(values => { | ||
// eslint-disable-next-line unicorn/no-array-for-each -- Set.forEach | ||
values.forEach(value => { | ||
if (!adjacencyList.has(value)) { | ||
adjacencyList.set(value, new Set()) | ||
} | ||
}) | ||
}) | ||
|
||
return adjacencyList | ||
}, | ||
|
||
calculatedSccToPlainObject(sccs: Array<Set<string>>) { | ||
/** for each key, its SCC's index */ | ||
const obj: Record<string, number> = {} | ||
for (const [index, scc] of sccs.entries()) { | ||
for (const node of scc) { | ||
obj[node] = index | ||
} | ||
} | ||
return obj | ||
}, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,193 @@ | ||
// import sinon from 'sinon'; | ||
import { testContext } from '../utils' | ||
|
||
import { | ||
StronglyConnectedComponents, | ||
ExportMap, | ||
childContext as buildChildContext, | ||
} from 'eslint-plugin-import-x/utils' | ||
|
||
function exportMapFixtureBuilder( | ||
path: string, | ||
imports: ExportMap[], | ||
): ExportMap { | ||
return { | ||
path, | ||
imports: new Map( | ||
imports.map(imp => [ | ||
imp.path, | ||
{ getter: () => imp, declarations: new Set() }, | ||
]), | ||
), | ||
} as ExportMap | ||
} | ||
|
||
describe('Strongly Connected Components Builder', () => { | ||
afterEach(() => StronglyConnectedComponents.clearCache()) | ||
|
||
describe('When getting an SCC', () => { | ||
const source = '' | ||
const ruleContext = testContext({}) | ||
const childContext = buildChildContext(source, ruleContext) | ||
|
||
describe('Given two files', () => { | ||
describe("When they don't cycle", () => { | ||
it('Should return foreign SCCs', () => { | ||
jest | ||
.spyOn(ExportMap, 'for') | ||
.mockReturnValue( | ||
exportMapFixtureBuilder('foo.js', [ | ||
exportMapFixtureBuilder('bar.js', []), | ||
]), | ||
) | ||
const actual = StronglyConnectedComponents.for(childContext) | ||
expect(actual).toEqual({ 'foo.js': 1, 'bar.js': 0 }) | ||
}) | ||
}) | ||
|
||
describe.skip('When they do cycle', () => { | ||
it('Should return same SCC', () => { | ||
jest | ||
.spyOn(ExportMap, 'for') | ||
.mockReturnValue( | ||
exportMapFixtureBuilder('foo.js', [ | ||
exportMapFixtureBuilder('bar.js', [ | ||
exportMapFixtureBuilder('foo.js', []), | ||
]), | ||
]), | ||
) | ||
const actual = StronglyConnectedComponents.get(source, ruleContext) | ||
expect(actual).toEqual({ 'foo.js': 0, 'bar.js': 0 }) | ||
}) | ||
}) | ||
}) | ||
|
||
describe('Given three files', () => { | ||
describe('When they form a line', () => { | ||
describe('When A -> B -> C', () => { | ||
it('Should return foreign SCCs', () => { | ||
jest | ||
.spyOn(ExportMap, 'for') | ||
.mockReturnValue( | ||
exportMapFixtureBuilder('foo.js', [ | ||
exportMapFixtureBuilder('bar.js', [ | ||
exportMapFixtureBuilder('buzz.js', []), | ||
]), | ||
]), | ||
) | ||
const actual = StronglyConnectedComponents.for(childContext) | ||
expect(actual).toEqual({ 'foo.js': 2, 'bar.js': 1, 'buzz.js': 0 }) | ||
}) | ||
}) | ||
|
||
describe('When A -> B <-> C', () => { | ||
it('Should return 2 SCCs, A on its own', () => { | ||
jest | ||
.spyOn(ExportMap, 'for') | ||
.mockReturnValue( | ||
exportMapFixtureBuilder('foo.js', [ | ||
exportMapFixtureBuilder('bar.js', [ | ||
exportMapFixtureBuilder('buzz.js', [ | ||
exportMapFixtureBuilder('bar.js', []), | ||
]), | ||
]), | ||
]), | ||
) | ||
const actual = StronglyConnectedComponents.for(childContext) | ||
expect(actual).toEqual({ 'foo.js': 1, 'bar.js': 0, 'buzz.js': 0 }) | ||
}) | ||
}) | ||
|
||
describe('When A <-> B -> C', () => { | ||
it('Should return 2 SCCs, C on its own', () => { | ||
jest | ||
.spyOn(ExportMap, 'for') | ||
.mockReturnValue( | ||
exportMapFixtureBuilder('foo.js', [ | ||
exportMapFixtureBuilder('bar.js', [ | ||
exportMapFixtureBuilder('buzz.js', []), | ||
exportMapFixtureBuilder('foo.js', []), | ||
]), | ||
]), | ||
) | ||
const actual = StronglyConnectedComponents.for(childContext) | ||
expect(actual).toEqual({ 'foo.js': 1, 'bar.js': 1, 'buzz.js': 0 }) | ||
}) | ||
}) | ||
|
||
describe('When A <-> B <-> C', () => { | ||
it('Should return same SCC', () => { | ||
jest | ||
.spyOn(ExportMap, 'for') | ||
.mockReturnValue( | ||
exportMapFixtureBuilder('foo.js', [ | ||
exportMapFixtureBuilder('bar.js', [ | ||
exportMapFixtureBuilder('foo.js', []), | ||
exportMapFixtureBuilder('buzz.js', [ | ||
exportMapFixtureBuilder('bar.js', []), | ||
]), | ||
]), | ||
]), | ||
) | ||
const actual = StronglyConnectedComponents.for(childContext) | ||
expect(actual).toEqual({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 }) | ||
}) | ||
}) | ||
}) | ||
|
||
describe('When they form a loop', () => { | ||
it('Should return same SCC', () => { | ||
jest | ||
.spyOn(ExportMap, 'for') | ||
.mockReturnValue( | ||
exportMapFixtureBuilder('foo.js', [ | ||
exportMapFixtureBuilder('bar.js', [ | ||
exportMapFixtureBuilder('buzz.js', [ | ||
exportMapFixtureBuilder('foo.js', []), | ||
]), | ||
]), | ||
]), | ||
) | ||
const actual = StronglyConnectedComponents.for(childContext) | ||
expect(actual).toEqual({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 }) | ||
}) | ||
}) | ||
|
||
describe('When they form a Y', () => { | ||
it('Should return 3 distinct SCCs', () => { | ||
jest | ||
.spyOn(ExportMap, 'for') | ||
.mockReturnValue( | ||
exportMapFixtureBuilder('foo.js', [ | ||
exportMapFixtureBuilder('bar.js', []), | ||
exportMapFixtureBuilder('buzz.js', []), | ||
]), | ||
) | ||
const actual = StronglyConnectedComponents.for(childContext) | ||
expect(actual).toEqual({ 'foo.js': 2, 'bar.js': 0, 'buzz.js': 1 }) | ||
}) | ||
}) | ||
|
||
describe('When they form a Mercedes', () => { | ||
it('Should return 1 SCC', () => { | ||
jest | ||
.spyOn(ExportMap, 'for') | ||
.mockReturnValue( | ||
exportMapFixtureBuilder('foo.js', [ | ||
exportMapFixtureBuilder('bar.js', [ | ||
exportMapFixtureBuilder('foo.js', []), | ||
exportMapFixtureBuilder('buzz.js', []), | ||
]), | ||
exportMapFixtureBuilder('buzz.js', [ | ||
exportMapFixtureBuilder('foo.js', []), | ||
exportMapFixtureBuilder('bar.js', []), | ||
]), | ||
]), | ||
) | ||
const actual = StronglyConnectedComponents.for(childContext) | ||
expect(actual).toEqual({ 'foo.js': 0, 'bar.js': 0, 'buzz.js': 0 }) | ||
}) | ||
}) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters