铲屎官ast-grep
ast-grep 简称sg,是一个基于抽象语法树(AST)的代码搜索、代码check和代码重构工具。让你可以在YAML文件里面像写jQuery选择器一样写规则,进行代码的check和重构。
sg提供了cli和vscode插件两种方式使用
sg在官网上提供playground,你可以在playground上很方便地演示、测试和分享你的demo
AST相较于正则表达式
正则表达式只能针对字符串进行模式匹配,而AST提供了对代码的结构化展示,可以更准确地理解、处理代码的各个部分,AST提供了一种更符合代码语法的方式对代码进行操作,在操作代码上AST也比正则表达式更加安全可靠。
看下面的例子,当我们想把console.error
改成console.log
时,正则表达式和AST都可以实现,但是正则表达式会把注释里的console.error
也替换掉,我们并不需要去修改注释的内容,而AST则只对代码处理,不处理注释的内容。从这个例子可以看出,AST和正则表达式的一大区别。
js
// console.error(123)
console.error(456)
第二个例子,当我们想重写const { b, c } = a
时,正则表达式还要处理换行空白符的场景,而AST是具有代码的语义信息的,const { b, c } = a
是否换行,对AST的解析结果是没有影响的。
js
// 一般写法
const { b, c } = a
// 有的人喜欢这么写,换行了
const {
b,
c,
} = a
在简单场景下,正则表达式是一个快速方便的工具,而AST需要一定的学习成本,编写、运用AST来重构代码需要适配各种场景case,写起来挺繁琐的,代码可读性会比较差。比方说,你想匹配commonjs的require
语句,就会遇到各种场景case:
node.type === 'CallExpression' && node.callee.name === 'require'
node.arguments[0].type === 'TemplateLiteral'
node.arguments[0].quasis[0].value.raw.indexOf('@/')
node.arguments[0].quasis[0].value.raw.indexOf('.')
node.arguments[0].type === 'BinaryExpression'
node.arguments[0]..type === 'StringLiteral'
node.arguments[0].quasis[0].value.raw.indexOf('@/')
node.arguments[0].quasis[0].value.raw.indexOf('.')
- ...
上面的场景,那么多if
else
,代码写起来又多又乱的。ast-grep
通过配置化的方式,让你可以低代码操作AST,而且是类似jQuery写css选择器那样子去写你的配置
一个重写console.error
的例子,很简单,几行配置就搞定了,而且配置的意思也很容易理解
yaml
id: fix-console
language: JavaScript
rule:
pattern: console.error($A)
fix:
console.log($A)
项目场景实际应用
背景
我们有一个nodejs项目,项目里引用模块是通过类似require(ROOT+'/controllers/hci.js')
这种方式引用的,ROOT
是在启动文件里通过global.ROOT = process.cwd()
注入全局的。这种方案有几个缺点:
第一个缺点是,ROOT
是代码运行时注入的,无法识别出来引用的模块路径,模块导出的代码缺失了代码提示功能,开发体验不好,影响开发效率。
第二个缺点是,由于ROOT
是一个全局变量,代码写起来就五花八门:
js
require(ROOT+'/controllers/vt.js')
require(ROOT+"/controllers/vt.js") // 用的双引号
require(ROOT+ '/controllers/vt.js') // 多了空格
require(ROOT +
'/controllers/vt.js') // 换行了
require(`${ROOT}/controllers/vt.js`)
require(ROOT + `/controllers/vt.js`) // 用的模板字符串相加
第三个缺点是,当我们需要在项目配置其它的路径别名时,都需要像ROOT
一样写入global.ROOT = process.cwd()
,这种方案的可扩展性是不好的。
后来,我们就通过tsconfig-paths
这个npm包做了方案改进,tsconfig-paths
的能力就是通过配置tsconfig.json
或者jsconfig.json
文件中的路径映射,来实现自定义的路径解析。在项目里的jsconfig.json
编写配置:
js
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
然后在项目启动入口添加require('tsconfig-paths/register')
即可,这样就会在运行时动态重定向路径的导入。从jsconfig.json
读取,可以获得代码提示能力。
但是新的难题又来了,项目里有那么多的require(ROOT+'/xx')
代码,如何重构掉呢?不用担心,ast-grep
一扫解君愁
ast-grep一扫解君愁
初始化
先安装好ast-grep
,安装方式参考官方文档,命令行通过sg --version
可以验证是否安装成功
然后可以编写sg
的配置了,在编写配置的时候,我们先梳理出存在哪些场景case,配置是要覆盖到所有场景case的。
场景case梳理
梳理出来的场景case如下:
javascript
require(ROOT+'/controllers/vt.js')
require(ROOT+"/controllers/vt.js") // 用的双引号
require(ROOT+ '/controllers/vt.js') // 多了空格
require(ROOT +
'/controllers/vt.js') // 换行了
require(`${ROOT}/controllers/vt.js`)
require(ROOT + `/controllers/vt.js`) // 用的模板字符串相加
playground简单验证
然后就可以在sg
官网提供的playground上进行简单编写和验证你的配置
项目里扫描check
要注意的是,如果是模板字符串需要保留为模板字符串,不能简单替换为单引号,因为它们的语义不是对等的,比如把require(${ROOT}/${lang}/tool.js
)替换为require('@/${lang}/tool.js')
就获取不到lang
,是个bug了。
接下来就可以在你的项目里创建sgconfig.yml
、rules/a.yml
、rules/b.yml
、rules/c.yml
yaml
# sgconfig.yml
ruleDirs:
- rules/a.yml
- rules/b.yml
- rules/c.yml
yaml
id: a
language: JavaScript
rule:
pattern: require($A)
constraints:
A:
regex: '^`\$\{ROOT\}.+`$' # 对A做了限制,必须是模板字符串,且前面是ROOT
transform:
# B是通过对A进行字符串裁切获得的
B:
substring:
source: $A
startChar: 8
endChar: -1
fix:
require(`@$B`)
yaml
id: b
language: JavaScript
rule:
pattern: require($A + $B)
constraints:
A:
regex: '^ROOT$' # A必须是ROOT
B:
regex: ^['"].+['"]$ # B必须是单引号或双引号包裹的字符串
transform:
C:
substring:
source: $B
startChar: 1
endChar: -1
fix:
require('@$C') # 双引号也会被重构成单引号
yaml
id: c
language: JavaScript
rule:
pattern: require($A + $B)
constraints:
A:
regex: '^ROOT$'
B:
regex: '^`.+`$' # B是模板字符串
transform:
C:
substring:
source: $B
startChar: 1
endChar: -1
fix:
require(`@$C`) # 模板字符串得保留
配置写好之后,就执行sg scan
看下效果
sg scan
并不会更改你的代码,你可以从cli的输出中查看更改后的效果,检查一下是否有问题,没有问题的话,就可以执行sg scan -U
进行代码更改了。
执行更改
从git上看,是重构了1353个文件
我们来看一下耗时,
- real time:这是指从命令开始执行到结束实际花费的时间。这包括了在命令执行期间所经历的所有延迟和等待时间。
- user time:这是指在 CPU 上运行用户态程序所花费的时间。换句话说,它是您的程序真正执行所花费的时间。如果一个程序启动了另一个程序,那么被启动的程序所花费的 CPU 时间也会被包括在内。
- sys time:这是指在 CPU 上运行内核程序(内核态)所花费的时间。通常,这代表了程序调用操作系统内核所花费的时间
可以看到,扫描几十万行代码的项目,更改1300+文件只需要不到15秒的时间,又快又准!
提交合并请求
1300+文件的改动太大了,我们需要分目录进行提交,相信有一些人或者身边的人遇到过本地git、vscode崩溃挂掉导致辛辛苦苦写的代码丢失。分目录提交的好处有:
- 我们分多次提交,避免在单次提交里出现大量文件,本地git、vscode处理不过来挂掉
- 多次提交可以减少在单次提交里出现大量文件的代码冲突
- 虽然代码重构好了,但保险起见还是要人工review,分目录多次提交,可以按目录来分工,让团队成员进行
Code Review