Skip to content

Commit

Permalink
feat: use @ast-grep/napi to simplify imports extraction logic
Browse files Browse the repository at this point in the history
  • Loading branch information
emosheeep committed Feb 4, 2024
1 parent be6273c commit 7426bc6
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 86 deletions.
5 changes: 5 additions & 0 deletions .changeset/lazy-buttons-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"circular-dependency-scanner": minor
---

feat: use @ast-grep/napi to simplify imports extraction logic
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ const results = circularDepsDetect({

# QA

## How does this tool handle alias paths?

We use `get-tsconfig` to transform ts alias imports, which means you should manually configure `compilerOptions.paths` in the nearest `tsconfig/jsconfig` so that the tool can recognize it correctly, unknown aliases will be dropped.

## Which reference will be pull out from the files

In a short, it find references like:
Expand All @@ -126,6 +130,7 @@ The analysis of file reference depend on the `alias` configurations you supplied

- The Command Line Tool is based on [commander](https://github.com/tj/commander.js).
- The circular dependencies analysis algorithm is based on [graph-cycles](https://github.com/grantila/graph-cycles).
- The typescript paths are transformed by [get-tsconfig](https://github.com/privatenumber/get-tsconfig).

# Issues

Expand Down
7 changes: 6 additions & 1 deletion README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ const results = circularDepsDetect({

# QA

## 那些引用会被提取出来
## How does this tool handle alias paths?

该工具使用 `get-tsconfig` 来转换代码中的别名路径,你需要在最近的 `tsconfig/jsconfig.json` 中配置 `compilerOptions.paths` 以便工具能正确识别别名,未识别的别名将被丢弃。

## 哪些引用会被提取出来

简单来说,满足以下条件的引用路径会被摘取出来:

Expand All @@ -125,6 +129,7 @@ export { test }; // got no export source

- 命令行工具基于 [commander](https://github.com/tj/commander.js).
- 循环依赖分析算法基于 [graph-cycles](https://github.com/grantila/graph-cycles).
- TS 别名转换基于 [get-tsconfig](https://github.com/privatenumber/get-tsconfig).

# Issues

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"versions": "changeset version"
},
"dependencies": {
"@ast-grep/napi": "^0.18.1",
"@vue/compiler-sfc": "^3.4.15",
"commander": "^11.1.0",
"get-tsconfig": "^4.7.2",
Expand Down
79 changes: 79 additions & 0 deletions pnpm-lock.yaml

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

124 changes: 45 additions & 79 deletions src/ast.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { fs } from 'zx';
import ts from 'typescript';
import { tsx } from '@ast-grep/napi';
import sfc from '@vue/compiler-sfc';

interface Visitor {
onImportFrom?(value: string): void;
onExportFrom?(value: string): void;
}

function getScriptContentFromVue(filename: string) {
const { descriptor: result } = sfc.parse(fs.readFileSync(filename, 'utf-8'));
const { script, scriptSetup } = result;
Expand All @@ -19,89 +14,60 @@ function getScriptContentFromVue(filename: string) {
* @example
* ```
* import test from './test'; // got './test'
* export * from './test'; // got './test'
* import './test'; // got './test'
* import('./test'); // got './test'
* require('./test'); // got './test'
* ```
*/
function handleImportFrom(node: ts.Node, callback) {
if (!node || !callback) return;
if (ts.isCallExpression(node)) {
const { expression } = node;
if (
/* require call */ ts.isIdentifier(expression) &&
expression.escapedText === 'require'
) {
const arg0 = node.arguments[0];
if (arg0 && ts.isStringLiteral(arg0)) {
callback(arg0.text);
}
} else if (
/* import expression */ node.expression.kind ===
ts.SyntaxKind.ImportKeyword
) {
const arg0 = node.arguments[0];
if (arg0 && ts.isStringLiteral(arg0)) {
callback(arg0.text);
}
}
} else if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier)
) {
callback(node.moduleSpecifier.text);
}
}

/**
* Get export source from ts ast
* @example
* ```
* export * from './test'; // then got './test'
* export { a }; // no export source
* ```
*/
function handleExportFrom(node: ts.Node, callback) {
if (!node || !callback) return;
if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
callback(node.moduleSpecifier.text);
}
}

export function walkTsNode(node: ts.Node, cb: (node: ts.Node) => void) {
cb(node);
node.forEachChild((child) => walkTsNode(child, cb));
export function getImportNodes(content: string) {
const sgNode = tsx.parse(content).root();
return sgNode.findAll({
rule: {
kind: 'string_fragment',
any: [
{
inside: {
stopBy: 'end',
kind: 'import_statement',
field: 'source',
},
},
{
inside: {
stopBy: 'end',
kind: 'export_statement',
field: 'source',
},
},
{
inside: {
kind: 'string',
inside: {
kind: 'arguments',
inside: {
kind: 'call_expression',
has: {
field: 'function',
regex: '^(import|require)$',
},
},
},
},
},
],
},
});
}

/**
* @param file - absolute file path
* @param visitor - ast visitor
*/
export function walkFile(filename: string, visitor: Visitor = {}) {
filename.endsWith('.vue')
? walkScript(getScriptContentFromVue(filename) || '', visitor)
: walkScript(fs.readFileSync(filename, 'utf-8'), visitor);
}
export function getImportSpecifiers(filePath: string): string[] {
const fileContent = filePath.endsWith('.vue')
? getScriptContentFromVue(filePath) ?? ''
: fs.readFileSync(filePath, 'utf8');

/**
* Script AST traverse
* @param source - script file content
* @param visitor
*/
export function walkScript(source: string, visitor: Visitor) {
walkTsNode(
ts.createSourceFile(
'__virtual-filename.tsx',
source,
ts.ScriptTarget.ESNext,
),
(node) => {
visitor.onImportFrom && handleImportFrom(node, visitor.onImportFrom);
visitor.onExportFrom && handleExportFrom(node, visitor.onExportFrom);
},
);
return getImportNodes(fileContent).map((node) => node.text());
}
6 changes: 4 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export const extensions = [
'vue',
'mjs',
'cjs',
'mts',
'cts',
] as const;

export type Ext = (typeof extensions)[number];
Expand Down Expand Up @@ -38,9 +40,9 @@ export function revertExtension(origin: string) {

const colorize = (filename: string) =>
chalk[
/\.(jsx?)|([mc]js)$/.test(filename)
/\.[mc]?jsx?$/.test(filename)
? 'yellow'
: /\.tsx?$/.test(filename)
: /\.[mc]?tsx?$/.test(filename)
? 'blue'
: /\.vue$/.test(filename)
? 'green'
Expand Down
10 changes: 6 additions & 4 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type Edge, analyzeGraph, FullAnalysisResult } from 'graph-cycles';
import { path, globby } from 'zx';
import { fileURLToPath } from 'url';
import { type TsConfigResult, createPathsMatcher } from 'get-tsconfig';
import { walkFile } from './ast';
import { getImportSpecifiers } from './ast';
import { revertExtension } from './utils';

interface GlobFiles {
Expand Down Expand Up @@ -93,10 +93,12 @@ if (!isMainThread) {
});

const deps: string[] = [];
const visitor = (value) =>
(value = getRealPathOfSpecifier(filename, value)) && deps.push(value);

walkFile(filename, { onExportFrom: visitor, onImportFrom: visitor });
for (const value of getImportSpecifiers(filename)) {
const resolvedPath = getRealPathOfSpecifier(filename, value);
resolvedPath && deps.push(resolvedPath);
}

entries.push(
absolute
? [filename, deps]
Expand Down

0 comments on commit 7426bc6

Please sign in to comment.