如何实现一个代码格式化工具


前言

我在尝试折腾 Dart 的 AST 时,参考了 dartfmt 格式化代码命令的一些实现细节,那时候就惦记着要写下实现代码格式化工具的思路,所以就有了这样一篇小文章。

这里主要阐述一个基础的思路,结合一个小例子进行说明,不对某些特定的复杂场景进行展开。

基础流程

假设我要实现一个 tsfmt,用于格式化 TypeScript 代码,这个工具输入就是一串代码,输出也是一串代码。至于文件读写那部分并不重要,如果需要的话,直接扩展多一个 IO 逻辑即可。

下面是简化的代码格式化流程:

代码格式化流程

在这个流程中,关键有四个部分:

  • parser 用于将代码解析成为一个个节点,例如一个变量声明语句 const a = 1 会被解析为 VariableDeclaration 对象
  • statements 即 parser 的输出,是代码对应的一个个节点,我们对不同类型的节点会有不同类型的格式化需求
  • visitor 用于遍历每一个节点,然后调用对应类型的 generator
  • generator 用于生成格式化好的代码

接下来一步步来尝试实现这个基础流程,parser 解析出来的 AST 节点数据结构会比较复杂,实现过程中的调试可以借助 AST Viewer 来查看相应代码的 AST 结构:

TypeScript AST Viewer

简单实现

依然是假设要实现一个 tsfmt 我需要格式化 TypeScript,那么首先需要解析 TypeScript 代码,即 parser,这里直接使用官方的 compiler 提供的 API,详细可以参考:

TypeScript Compiler Internals

当使用 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