IDE中的魔法 - 语法分析器生成工具 ANTLR

1,589 阅读10分钟

本文收录于专栏文章「IDE中的魔法」,希望读者能够通过这系列文章,对 IDE 相关技术的实现有一定的认知,同时,通过对语言进行静态分析,能够从编译器的视角,审视语言特性,帮助大家在了解 IDE 的同时,也能更深入的了解语言本身。

IDE 中的分析涉及的技术很多,也很庞杂,不能期待短时间就能揭开魔法的面纱,不过,作为开始,我们可以从一些简单的场景入手,伴随着实践,体会其中的奥妙。首先我们要做的,是实现一个公式语法,并且为公式实现自动补全、错误推断。具体的效果和 Excel 中的公式相同,如下图所示:

公式语法是非常简单的,它只是表达式再加上函数调用的语法,并且没有变量,没有作用域,这可以让我们在做分析时集中于自动补全和错误处理上。但是它又没那么简单,要实现图中的补全和错误提示的功能,需要做大量的工作,而且有不少相关的知识需要逐步介绍,这不是一篇文章能够全部囊括的,我们会分几个章节,循序渐进,最终实现图中的效果。

工欲善其事,必先利其器,当我们想要设计一门的语言,并不需要我们从词法分析,语法分析一步一步写过来,社区提供各种各样的语法分析的生成工具,能够熟练灵活地使用它们,会让我们在开发语言时事半功倍。ANTLR 就是这样的一个具有良好生态的工具,它可以将你的语法生成为很多不同语言的语法分析器。我们后续就会使用 ANTLR 生成 excel 中公式语法的 parser,并且我们针对公式语法,完成自动补全,错误监测和推断等静态分析。

初始化一个 ANTLR 项目

ANTLR 是通过 java 实现的,因此,安装 ANTLR 首先需要安装 java 1.7 或者以上版本,再安装完 java 后,执行下述命令安装 ANTLR:

cd /usr/local/lib
curl -O https://www.antlr.org/download/antlr-4.9-complete.jar
#建议将下述命令写在 .bash_profile 中
export CLASSPATH=".:/usr/local/lib/antlr-4.9-complete.jar:$CLASSPATH"
alias antlr4='java -Xmx500M -cp "/usr/local/lib/antlr-4.9-complete.jar:$CLASSPATH" org.antlr.v4.Tool'
alias grun='java -Xmx500M -cp "/usr/local/lib/antlr-4.9-complete.jar:$CLASSPATH" org.antlr.v4.gui.TestRig'

得益于 ANTLR 的生态,ANTLR 支持非常多的生成目标,而我们期望的能够生成 Typescript 的 parser,这样我们便可以在前端或者 vscode 的插件中使用我们的新语法。现在我们初始化我们的项目,新建一个 hello (或其它任意名字) 目录,然后进到这个目录中,新建名叫 Hello.g4 的文件并写入如下内容

// Hello.g4 - ANTLR 官方提供「hello world」示例
// Define a grammar called Hello
grammar Hello;
r  : 'hello' ID ;         // match keyword hello followed by an identifier
ID : [a-z]+ ;             // match lower-case identifiers
WS : [ \t\r\n]+ -> skip ; // skip spaces, tabs, newlines

然后再初始化 npm 并安装依赖,执行:

# 初始化 npm
npm init
# 安装依赖
npm i antlr4ts typescript
npm i -save-dev ts-node @types/node antlr4ts-cli

在安装完 typescript 后,配置一下 tsconfig.json,然后在 package.json 中添加如下 script:

 "scripts" : {
    "build" : "antlr4ts -visitor Hello.g4",
    "start" : "ts-node index.ts"
}

然后,执行 npm run build,再创建我们自己的程序的入口文件 index.ts,其内容如下:

import { ANTLRInputStream, CommonTokenStream } from 'antlr4ts';
import { HelloLexer } from './HelloLexer'
import { HelloParser } from './HelloParser'

// Create the lexer and parser
let inputStream = new ANTLRInputStream("hello world");
let lexer = new HelloLexer(inputStream);
let tokenStream = new CommonTokenStream(lexer);
let parser = new HelloParser(tokenStream);
let tree = parser.r();
console.log(tree.toStringTree());

最后,执行 npm run start,如果我们能看到命令行输出 (helloworld hello world) ,那么说明项目已经初始化成功了,toStringTree 是以 LISP 格式显示的树,其格式为 (root child1 ... childN),上述程序为我们输出了以 helloworld 为根节点,包含 hello 和 world 的两个子节点的语法树。

How it works

我们已经在之前的文章里介绍了编译器工作流程的各个阶段,首先明确的是,ANTLR 是一个语法分析生成工具,我们通过写语法规则(ANTLR meta-language),通过 ANTLR 生成能够分析我们的语法的 LL(*) 解释器,这个解释器完成从词法分析到语法分析的过程,它将输入的字符流,转化成一颗 parse tree。而在这个过程中,我们不需要把词法分析和语法分析再写一遍,在我们修改语法或者词法规则后,只用重新用 ANTLR 生成一次 parser,不用我们再次手工修改 parser 的代码,我们可以把精力更集中在语法本身上。

虽然 ANTLR 帮我们自动做了很多,我们还是有必要简单了解一下 ANTLR 是怎么做的。完完全全把 ANTLR 当一个黑盒也不利于使用 ANTLR 生成的 parser。首先我们看一条简单的语法规则:

// 小写字母开头的是语法规则(非终结符)
assign: ID '=' expr ';';
// 大写字母开头的是词法规则(终结符)
ID: [a-zA-Z_]+;

ANTLR 生成的是一个递归下降的语法分析器,递归下降的语法分析器实际上是若干递归方法的集合,每个方法对应一条规则。解析的过程就是从入口开始,向叶子节点(终结符)进行解析。例如上述的语法规则可能会生成下述的方法:

function assign() {
    match(ID); // 匹配 INT
    match('='); // 匹配 +
    expr();     // 调用 expr, 匹配一个表达式
    match(';');
}

assign 依次执行,验证输入是否可以和语法规则匹配,并且通过 assign,expr 以及 match 方法的每一次调用,都对应语法分析树上的一个节点,match 方法相当于匹配到了叶子节点。如果我们从入口处递归向下成功匹配完所有输入字符,我们也就得到了输入字符对应着的 parse tree。不过上述的语法规则还是很简单,现实情况下,很多语法规则都有多个分支,试想下述语法规则:

stat: assign
    | ifstat
    | whilestat
    ...

它对应着的方法可能是一个 switch 语句:

function stat() {
    switch (nextToken) {
        case ID: assign(); break;
        case IF: ifstat(); break;
        case WHILE: whilestate(); break;
        ...
        default:
            // 抛出无可选分支异常
            throw Error()
    }
}

stat() 需要根据下一个 token 在作出判断哪一个备选分支是正确的,通常,这个 token 被称作 lookahead token,lookahead token 严格讲就是指的任何一个在匹配和消费之前就由语法分析器知晓并用于处理的词法符号。在 stat 中,每个备选分支都通过不同的 token 开头,于是它只需要一个 lookahead token 就能做出抉择。但是在更加复杂的情况下,我们可能需要向前嗅探很多个 token 才能抉择,有些时候,甚至会扫描到输入字符流的末尾。

可以形象地理解递归下降语法分析的过程就像走迷宫一样,我们从起点出发,每到一个岔路口,我们就试着进入其中的某一个分支,发现这个分支不匹配(lookahead token 匹配不上),则重新退回到岔路口查看别的分支,如此反复,直到我们走到了迷宫的终点或者我们发现所有的路都是死路,我们就完成了语法分析的过程。

如果我们发现有两条路都可以走到终点,那说明我们的语法存在二义性,这种情况下,ANTLR 会选择所有符合的备选分支中的第一条。不过通常而言,我们都应该避免语法上存在二义性,这一般都被看作语法存在 bug,因为同样的输入流可以生成多个不同的语法树,多种不同的语义,这对于程序语言来讲,是不可接受的。

最后额外再提一点,如果我们细心一点,应该能发现,如果某种语法可以通过自身匹配,这种语法分析会进入到死循环中,比如:

expression: expression + expression
          | INT;

这种左递归的语法在 LL parser 中会生成 expression 递归调用 expression 的方法,从而会导致无线递归,这也是 LL 语法的限制,你的语法不能出现左递归。不过在 ANTLR4 中,你已经可以书写上例中的语法,ANTLR 在处理时会将左递归的语法转化为等价的非左递归的语法。不过,你仍然不能使用间接的左递归,也就是 expression 左递归调 number,number 左递归调 expression 这种形式,这样的语法在 ANTLR4 中也会导致死循环。

Listener and Visitor

在我们成功完成语法分析并得到 parse tree 后,剩下的便是我们需要怎么去访问这棵 parse tree,ANTLR 提供了两种模式可供选择,Listener 和 Visitor。

Listener 模式会提供所有节点的进入和退出事件,ANTLR 为每个语法文件生成一个 ParseTreeListener 类,这个类中,每条语法规则都有对应的 enter 方法和 exit 方法。当遍历器访问到 assign 节点时,它就会调用 enterAssign 方法,并传入对应的树节点 AssignContext 给它,在访问了 assign 的全部子节点后,它会调用 exitAssign 方法。这是一种相对简单,但功能比较有限的模式,如果处理不复杂,比如我们想做 lint,那就只需要关心那些不太合规的写法,而不用关心整棵树,这些情况下,Listener 是非常合适的模式。下图用虚线标识了对 parse tree 进行深度优先搜索的过程,也标识了 enter 和 exit 调用的时机。

Visitor 模式会更加复杂,但它提供了可以让我们完全控制 parse tree 遍历过程的能力,要使用 Visitor,在生成代码时,为 cli 传入额外的参数 -visitor 选项,ANTLR 会为语法生成 Visitor 接口,语法中的每条规则对应接口中的一个 visit 方法,例如 visitAssign、visitExpr、visitID 以及针对终结符的 visitTerminalNode 等。开始遍历时,ANTLR 内部为 Visitor 生成的支持代码会先在根节点处调用 visitAssign 方法,然后由我们实现的 visiAssign 去调用 visit 并把所有的子节点当做参数传给它,从而继续遍历的过程。当然,我们也可以在遍历 assign 时,不用遍历它所有子节点,而是只调用 visitExpr 来遍历 expr 节点。

const visitor = new MyVisitor()
// tree 是语法分析得到的 parse tree
visitor.visit(tree)

两种模式都是对 parse tree 做深度优先搜索,深度优先搜索有一个特性就:在回溯阶段,这个节点的所有子节点都是已经遍历过了的。例如上面 Listener 的图中,当 exitID 触发时候,这个 ID 节点下面的 str 节点一定是处理完了的。形象一些理解,无论你是要对 AST 做什么处理,在回溯阶段,这个节点的「下文」都是已经完成了处理的,这对于处理一部分上下文有关的语义是很有帮助的。

写在最后

本文简要介绍了一下 ANTLR 的初始化和相关原理,了解它的机制,能够帮助我们更好地使用它,同时也能理解它存在的限制。初始化项目部分可以直接参考的 ANTLR 的官方文档 getting-started,你你可以在他的项目主页上看到更多详尽的文档。另外,ANTLR 官方也提供了非常多详尽的例子,可以参考 github.com/antlr/gramm… parser,从官方的示例开始是一个很好的选择。即使你想实现全新的语言,这些示例也具有很强的参考意义。毕竟,计算机发展至今,出现了许多编程语言,但是基本的语言模式并不多,因为在设计语言时,人们还是倾向于将它设计的和我们心中的自然语言相似,大部分时候,沿用这些模式都是有利而无害的。

参考链接