如何实现一个代码格式化工具
前言
我在尝试折腾 Dart 的 AST 时,参考了 dartfmt
格式化代码命令的一些实现细节,那时候就惦记着要写下实现代码格式化工具的思路,所以就有了这样一篇小文章。
这里主要阐述一个基础的思路,结合一个小例子进行说明,不对某些特定的复杂场景进行展开。
基础流程
假设我要实现一个 tsfmt
,用于格式化 TypeScript 代码,这个工具输入就是一串代码,输出也是一串代码。至于文件读写那部分并不重要,如果需要的话,直接扩展多一个 IO 逻辑即可。
下面是简化的代码格式化流程:
在这个流程中,关键有四个部分:
- parser 用于将代码解析成为一个个节点,例如一个变量声明语句
const a = 1
会被解析为VariableDeclaration
对象 - statements 即 parser 的输出,是代码对应的一个个节点,我们对不同类型的节点会有不同类型的格式化需求
- visitor 用于遍历每一个节点,然后调用对应类型的 generator
- generator 用于生成格式化好的代码
接下来一步步来尝试实现这个基础流程,parser 解析出来的 AST 节点数据结构会比较复杂,实现过程中的调试可以借助 AST Viewer 来查看相应代码的 AST 结构:
简单实现
依然是假设要实现一个 tsfmt
我需要格式化 TypeScript,那么首先需要解析 TypeScript 代码,即 parser,这里直接使用官方的 compiler 提供的 API,详细可以参考:
当使用 compiler 提供的 API 将代码解析为一个个的 AST 节点时,我需要遍历这些节点,找出需要格式化的地方。下边是一个简单的遍历方法:
import * as ts from "typescript";
// ...
const sourceFile = ts.createSourceFile(
"index.ts",
sourceCode,
ts.ScriptTarget.ES2015,
false,
ts.ScriptKind.TS
);
sourceFile.statements.forEach((statement) => {
traverse(statement, visitor);
});
function traverse(program: ts.Node, visitor: (node: ts.Node) => void) {
visitor(program);
ts.forEachChild(program, (node) => {
traverse(node, visitor);
});
}
function visitor(node: ts.Node) {
console.log(node);
}
上边的代码已经创建了一个 visitor
用于遍历所有的 AST 节点,那么接下来就是格式化这些节点对应的代码。在这之前需要创建一个变量用于保存格式化好的代码,例如:
const chunks: string[] = [];
然后就是基础的 generator
的实现,先直接在 visitor
中去实现即可,看一个简单的例子:
function visitor(node: ts.Node) {
switch (node.kind) {
case ts.SyntaxKind.FunctionDeclaration: {
const t = node as ts.FunctionDeclaration;
// 声明
chunks.push("function ");
chunks.push(t.name.escapedText.toString());
chunks.push("(");
chunks.push(
t.parameters
.map((parameter) =>
sourceCode.substring(parameter.pos, parameter.end)
)
.join(", ")
);
chunks.push(")");
// 函数主体
if (t.body.kind == ts.SyntaxKind.Block) {
chunks.push(" {\n");
t.body.statements.forEach((statement) => {
chunks.push(
" " + sourceCode.substring(statement.pos, statement.end) + "\n"
);
});
chunks.push("}");
}
break;
}
default:
break;
}
}
上面这个简单例子可以实现单行 function
的基础格式化。
这里的代码生成比较简陋,在 visitor
中直接完成,并且只通过字符串数组进行拼接,还不涉及复杂的语句类型等,接下来我们再去探索更加优雅的实现方法。
优化实现
暂不对 visitor
进行改动,改造要点是在 generator
,我应该提供一些更为基础的方法,例如创建一个 CodeBuilder
类,用于辅助我们生成对应的代码:
class CodeBuilder {
private source: string;
constructor(source: string) {
this.source = source;
}
private chunks: string[] = [];
public push(code: string) {
this.chunks.push(code);
}
public copy(pos: number, end: number) {
this.chunks.push(this.source.substring(pos, end));
}
public newline() {
this.chunks.push("\n");
}
public intent() {
this.chunks.push(" ");
}
public space() {
this.chunks.push(" ");
}
// ...
public build() {
return this.chunks.join("");
}
}
这个类提供的方法名都很好理解,也是格式化代码常用的一些方法,上边也只是一部分例子,需要的话可以新增更多。基于这个类,我们再来改造 visitor
中的代码生成逻辑。
在我们前边的例子中,对应 FunctionDeclaration
中的代码格式化实现仅对于函数声明这部分有效,并不具备复用的能力,譬如说 Block
那部分的格式化,其实可以在所有的块语句处理中进行复用。
所以我们需要改造一下,将代码生成的方法拆分到更小的节点上,这样也可以覆盖更加全面的代码语句。用伪代码简单表达一下:
visitor ->
if FunctionDeclaration
+ 'function '
调用 visitor 处理函数名节点
+ '('
调用 visitor 处理参数节点
+ ') '
调用 visitor 处理函数主体节点
if ...
if ...
这里可以发现对于节点的子节点,我们需要手动去控制 visitor
的调用,因为我们需要一个先后顺序的控制,那么之前实现的遍历方法便不再适用了,直接在 visitor
内递归调用即可。
下边是实现的一个简单代码例子:
const builder = new CodeBuilder(sourceCode);
const sourceFile = ts.createSourceFile(
"index.ts",
sourceCode,
ts.ScriptTarget.ES2015,
false,
ts.ScriptKind.TS
);
// 遍历入口的代码语句
sourceFile.statements.forEach((statement) => {
visitor(statement);
});
function visitor(node: ts.Node) {
switch (node.kind) {
case ts.SyntaxKind.FunctionDeclaration: {
const t = node as ts.FunctionDeclaration;
builder.push("function");
builder.space();
// 处理函数名
visitor(t.name);
builder.push("(");
// 处理参数
t.parameters.forEach((child) => visitor(child));
builder.push(")");
builder.space();
// 处理函数主题
visitor(t.body);
break;
}
case ts.SyntaxKind.Identifier: {
const t = node as ts.Identifier;
builder.push(t.text);
break;
}
case ts.SyntaxKind.Parameter: {
builder.copy(node.pos, node.end);
break;
}
case ts.SyntaxKind.Block: {
builder.push("{");
builder.newline();
ts.forEachChild(node, (child) => visitor(child));
builder.push("}");
break;
}
case ts.SyntaxKind.ExpressionStatement: {
builder.intent();
builder.copy(node.pos, node.end);
builder.newline();
break;
}
// ...
default:
break;
}
}
上边的代码统一起来可以简单格式化这样的代码 function hello(a: number){alert('hello');console.log(1);}
,如同文章一开始所说,只是一个简单的例子。
实际上要实现一个代码格式化工具,需要覆盖的场景很多,各种各样的语句都需要你针对性地去处理,这里没有再对某个特定的语句去深入展开讲解。
这里阐述的基础思路本质上是不限制于某种语言的,虽然是使用 TypeScript 作为例子,但是应用到其他语言上,思路也是大致一样的,例如 Dart 的格式化工具等。
后话
日常工作中我们还会用到 Lint 工具,Lint 工具的实现在格式化工具之上,要更复杂一点,这两者的区别就是一个用于格式化代码风格,一个用于发现代码中可能出现问题的地方。
有一个很出名的代码格式化工具是 Prettier,它提供了支持不同语言的插件扩展,有兴趣可以看看具体的 API,或者找其中一个比较简单的语言格式化插件看看具体的实现:Plugins · Prettier。