一个YAML就能极速检测超大Repo?用低代码定制你的代码检测!

Herrington Darkholme
15 min readMay 3, 2023

你是否曾经为了让你的代码更规范、更易读、更符合最佳实践而苦恼过?如果你经常参与代码开发,那么你一定知道代码的规范性对于整个项目的成功非常重要。代码规范性不仅可以提高代码的可读性,还可以减少出现bug的概率。

市面上有很多可以帮你自动检查和修复你的代码中的错误用法的工具了。但是如果想为你的团队建立贴合业务的定制规则,就没有那么多简单好用的工具了。

如果你想要快速落地定制规则话,那么你一定要了解一下ast-grep,一个基于抽象语法树(AST)的代码搜索、lint和重写工具。让你用YAML,像写jQuery选择器一样写规则! https://github.com/ast-grep/ast-grep

为什么要用检查工具?

代码检测工具是一种可以帮助开发者提高代码质量和效率的工具。它可以在编写或运行代码的过程中,自动检查和修复代码中的语法错误、风格不一致、潜在的逻辑问题等。 在团队中使用检查工具,有这些好处:

  • 可以根据业务需求定制代码的最佳实践,比如命名规范、注释规范、性能优化等,从而保证代码的可读性、可维护性和可扩展性。
  • 可以分享更好的知识,比如使用一些新的语言特性、框架或者库,或者避免一些常见的错误和陷阱,从而提高开发者的技能和水平。
  • 可以减轻代码审查的负担,比如减少人为的失误、遗漏和冲突,或者提供一些自动化的建议和反馈,从而提高代码审查的效率和质量。

代码检查不仅仅是提高了代码质量,提升了个人工作效率。更重要的是,它是一种团队协作工具,帮助新员工融入团队工程,不用再翻找阅读团队长长的代码规范文档。工具能直接在他撰写代码时通过实时报错一点一滴帮成员建立规范和知识!

什么是AST?为什么用它?

一个代码检测工具通常都会使用基于抽象语法树(Abstract Syntax Tree)来进行代码分析,一般我们简称它为AST。

AST是一种用来表示代码结构和语义的树形数据结构,每个节点代表一个代码元素,如变量、函数、表达式等。AST可以帮助我们分析和操作代码,而不需要关心代码的文本形式。比如,下面这段JavaScript代码:

var x = 1 + 2;

可以表达成下面的AST形式。

{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "x"
},
"init": {
"type": "BinaryExpression",
"operator": "+",
"left": {
"type": "Literal",
"value": 1
},
"right": {
"type": "Literal",
"value": 2
}
}
}
],
"kind": "var"
}
]
}

由于代码本身是文本,所以很自然地我们会想要用文本处理工具(比如正则表达式)来处理代码。但是AST的分析是优于文本分析的。因为基于文本的分析只能对代码的表面形式进行处理,而不能获取代码的语法和语义信息,比如节点类型、属性、关系等。这些信息可以帮助检测工具找出代码中的错误和漏洞。另外,基于文本的分析容易受到代码的格式和风格的影响,比如空格、换行、注释等,而不能准确地识别代码的结构和内容。这可能导致一些误报或漏报的情况。

为什么写AST工具那么麻烦?

虽然AST比较强大,但是遍历解析AST的工作却是比较繁琐又无趣的。

  • AST是源代码的抽象语法结构的树状表示,要写AST工具就要了解不同语言的语法规则和AST结构,这需要一定的学习成本。
  • AST的遍历算法虽然只有一种,但是实现起来还是有一些细节和变形,比如递归和循环、visitorKeys的抽离和写死、enter和exit的回调等,需要注意不同场景下的优缺点。
  • AST的操作也需要一定的技巧,比如如何增删改查节点、如何保持语法正确性、如何处理异常情况等,需要对AST有深入的理解和掌握。

我们拿eslint作为例子。 eslint是一个用于检查和修复JavaScript代码的工具,是一个用ast来实现的工具。eslint可以使用自定义的解析器来将源代码转换成ast,然后使用选择器来遍历和操作ast,从而实现对代码的检查和修复。你可以使用一些在线工具来查看和修改ast,比如esprima.org/demo/parse。

我们来写一个匹配所有在for循环中使用await表达式的eslint规则。根据,一个eslint的规则是一个JavaScript模块,它导出一个对象,包含以下几个属性:

  • meta: 一个对象,包含规则的元信息,比如文档,类型,错误信息等。
  • create: 一个函数,返回一个对象,定义了规则如何检查代码。这个对象的键是AST节点的类型,值是一个函数,接受一个节点作为参数,并根据需要报告错误。

你可以参考中的一些例子来写你自己的eslint规则。对于这个需求,你可以写一个这样的规则:

module.exports = {
meta: {
type: "problem",
docs: {
description: "disallow await inside of loops",
category: "Possible Errors",
recommended: false,
url: "https://eslint.org/docs/rules/no-await-in-loop"
},
messages: {
unexpected: "Unexpected `await` inside a loop."
},
schema: []
},
  create(context) {
// A stack to hold the state of each loop
const loopStack = [];
return {
// When entering a loop, push a new state into the stack
ForStatement(node) {
loopStack.push({ upper: context.getScope().upper });
},
ForInStatement(node) {
loopStack.push({ upper: context.getScope().upper });
},
ForOfStatement(node) {
loopStack.push({ upper: context.getScope().upper });
},
WhileStatement(node) {
loopStack.push({ upper: context.getScope().upper });
},
DoWhileStatement(node) {
loopStack.push({ upper: context.getScope().upper });
},
// When leaving a loop, pop the state from the stack
"ForStatement:exit"(node) {
loopStack.pop();
},
"ForInStatement:exit"(node) {
loopStack.pop();
},
"ForOfStatement:exit"(node) {
loopStack.pop();
},
"WhileStatement:exit"(node) {
loopStack.pop();
},
"DoWhileStatement:exit"(node) {
loopStack.pop();
},
// When encountering an await expression, check if it is inside a loop
AwaitExpression(node) {
const state = loopStack[loopStack.length - 1];
if (state && state.upper === context.getScope().upper) {
context.report({
node,
messageId: "unexpected"
});
}
}
};
}
};

这个规则使用了一个栈来保存每个循环的状态,当遇到await表达式时,检查它是否在循环内部。如果是,就报告一个错误信息。 可以看到,eslint的代码行数比较多,写起来比较繁琐。阅读的时候也需要看完代码上下文才能理解规则的用意。那有没有更方便的办法能完成呢?

有没有一个工具能帮我们快速上手AST操作呢?我们日常工作中最最常用而且最最好用的树结构,其实正是前端里的DOM树。在过去的开发中,几乎人人都能用jQuery写两段DOM操作,入门门槛非常低。那我们能不能用DOM树的方式来操作AST呢?让所有人都能上手写两个简易的AST工具呢?甚至于能学习CSS选择器一样,让使用者不写逻辑,直接低代码配置AST操作?

有!那就是ast-grep!

什么是ast-grep?

ast-grep是一个基于AST的工具,可以用简单的代码模式来搜索、lint和重写代码。你可以像写普通的代码一样写模式,它会匹配所有有相同语法结构的代码。你可以用$符号加大写字母作为通配符,比如$MATCH,来匹配任意单个AST节点。想象一下正则表达式中的点.,只不过它不是基于文本的。你可以试试在线演示来感受一下!

ast-grep还提供了类似jQuery的思维模型来遍历AST,用YAML配置文件来编写新的lint规则或者代码修改。ast-grep用Rust编写,基于tree-sitter进行解析,并利用多核并行处理,所以它的性能是杠杠的。此外它还有一个漂亮的命令行界面:)

ast-grep的愿景是让抽象语法树的魔法普及到每个人,并让人们从繁琐的AST编程中解放出来!如果你是一个开源库的作者,ast-grep可以帮助你的库用户更容易地适应破坏性变更。如果你是一个团队的技术负责人,ast-grep可以帮助你制定和执行符合你业务需求的编码最佳实践。如果你是一个安全研究员,ast-grep可以帮助你更快地编写规则。

如何安装和使用ast-grep?

你可以通过npm或者cargo来安装ast-grep。

# install via pnpm
npm install --global @ast-grep/cli
# install via cargo
cargo install ast-grep

模式匹配和搜索

假设你有一个JavaScript项目,你想要找出所有使用了console.log的地方。你可以用ast-grep来搜索这样的模式:

sg --pattern 'console.log($ARGS)' --language js
# or using shortcut
# sg -p 'console.log($ARGS)' -l js

这会匹配所有调用了console.log函数的代码,不管参数是什么。 在这里,console.log($ARGS) 是一个模式代码,可以理解成一个代码的模板,只要目标代码的AST和这个模板匹配,就是一个命中。 $ARGS叫做元变量(metavariable),是一个通配符,可以匹配任意的AST节点。因此console.log("hello world") 会被匹配到,console.log(greetings)也会。

你也可以在ast-grep的playground里试玩模式匹配!看看ast的模式匹配和正则表达式的模式匹配有什么不一样?

用YAML配置规则

用模式匹配来搜索代码虽然非常便利,但是它在匹配复杂代码的情况下容易显得捉襟见肘。另外,单个模式既不满足复杂的逻辑需求,也不方便记录成文供团队分享合作。 因此,ast-grep提供了一种更加灵活的方式来编写规则:用YAML配置文件。

一个ast-grep的yaml规则文件有以下几个关键字段:

  • id: 一个唯一的,描述性的标识符,比如no-unused-variable。
  • message: 主要的信息,说明为什么这个规则被触发。它应该是单行的,简洁的,但是足够具体,不需要额外的上下文。
  • severity: 指定匹配结果的级别。可选的有hint, info, warning, 或 error。
  • language: 指定要解析和匹配的语言。
  • rule: 一个对象,指定如何找到匹配的AST节点。详见rule object reference。

它还有一些其他字段,大家可以参考官网的文档

我们这里写一个非常简单的规则,匹配所有用eval函数的代码,并提示不安全。

id: no-eval
message: Avoid using eval function as it may cause security issues.
severity: warning
language: JavaScript
rule:
pattern: eval($CODE)

以上的规则中除了rule的其他字段是不言自明的,我们讲解下rule这个字段。它接收一个对象,在这里我们传入的对象有pattern字段。pattern字段的值就是我们之前看到的模板代码,它会匹配所有对eval的直接调用。

我们把它保存在一个名叫no-eval.yml的文件里,就可以用ast-grep的命令行来运行了!

sg scan -r no-eval.yml

这里用到了ast-grep的scan 命令,它会按照提供的规则来扫描当下目录的文件。-r的命令行参数是--rule的缩写,它指定了要使用的规则文件。

当运行之后,我们就能看到检查结果。

当然,你也可以用web playground在线预览结果。

规则进阶

以上只是最简单的写法!ast-grep的yaml配置还有更强大的配置功能!

在规则中的rule接受一个对象,它可以有以下几种类型的键:

  • 原子规则键:pattern, kind, regex。这些键用来匹配单个的AST节点。
  • 关系规则键:inside, has等。这些键用来根据节点相对于其他节点的位置来过滤匹配。
  • 复合规则键:all, any, not等。这些键用来用逻辑运算符组合其他规则

我们再写一个复杂点的例子。 假设你想要匹配所有在for循环中使用await表达式的代码,并给出一个提示信息。你可以写一个这样的yaml规则:

id: no-await-in-for-loop
message: Avoid using await expression in for loop as it may cause performance issues.
severity: hint
language: JavaScript
rule:
all:
- pattern: await $PROMISE
- inside:
any:
- kind: for_in_statement
- kind: for_statement
- kind: while_statement
- kind: do_statement
stopBy: end

这个规则使用了all和inside两个复合规则键,以及pattern和kind两个原子规则键。它的含义是:匹配所有既是await表达式,又在for循环中的节点。

  • kind 接受一个AST节点的类型,你可以用Web Playground来找代码的节点类型。这里提到的kind都是JS循环的语法类型。
  • all 接受一个规则列表,如果所有的子规则都匹配,那么all就匹配。
  • any 接受一个规则列表,如果任意一个的子规则都匹配,那么any就匹配。
  • inside 接受一个关系规则对象,如果节点在另一个满足关系规则对象的节点内部,那么inside就会匹配。

例如,在上面的例子中,all的作用是要求节点既满足pattern规则,又满足inside规则。pattern规则代表了需要匹配所有的await语句,而inside的作用是要求节点在任何一种for循环中。

你也可以用在线Playground来体验这个规则。

总结

ast-grep是一个基于AST的代码搜索和重写工具,它可以让你用YAML来编写代码检查的规则,而不需要学习eslint的JavaScript写法。

YAML的规则更加简洁和直观,可以用声明式的方式表达你想要匹配和替换的代码模式。

相比eslint,ast-grep的规则更容易阅读和理解,而且性能更好,因为它使用了tree-sitter来解析代码,并利用了多核处理。

如果你需要自定义代码检查的规则,ast-grep能让你用一个YAML就解决问题。你可以把YAML放在你的项目里,也可以放在npm包里,在代码提交时用sg命令运行一下就能做到检测。甚至可以把YAML存在数据库里,做成代码检测的低代码平台。

YAML在手,天下我有。想了解更多低代码检测的高级用法,可以猛戳ast-grep官网

--

--

Herrington Darkholme

🌐 Frontend Vimmer, ⚒️OSS with @typescript @vuejs and @rustlang 💻 Previously worked at @BytedanceTalk. Hobby project: https://ast-grep.github.io/