Gecko:一款支持自动语法错误恢复的快速 GLR 解析器
摘要
Gecko 是一个全新的可嵌入 C 库,可为任意上下文无关文法提供 GLR 解析、自动语法错误恢复,并保持 YACC 级速度。
<p><a href="https://lobste.rs/s/chsbgc/gecko_fast_glr_parser_with_automatic">评论</a></p>
查看缓存全文
缓存时间: 2026/04/23 08:48
# Gecko:支持自动语法错误恢复的快速 GLR 解析器
来源:https://vnmakarov.github.io/parsing/compilers/c/open-source/2026/04/22/gecko-glr.html
## Gecko:C 语言实现的快速独立 GLR 解析库
解析器是每一种编程语言实现的骨架,解析技术的选择对语言设计者影响深远。
LALR(1)(https://en.wikipedia.org/wiki/LALR_parser)解析器如
YACC(https://invisible-island.net/byacc)
和
Bison(https://www.gnu.org/software/bison/)
统治了数十年,却强迫语法作者为避免冲突而扭曲文法。
广义解析器——能够处理全部
上下文无关文法(https://en.wikipedia.org/wiki/Context-free_grammar),包括
歧义文法(https://en.wikipedia.org/wiki/Ambiguous_grammar)——历来被认为太慢,不适合生产环境。
Gecko 是一个新的
GLR(https://en.wikipedia.org/wiki/GLR_parser)
解析库,挑战了这一假设:它支持**任何**上下文无关文法,提供**零修改的自动语法错误恢复**,速度却与 YACC 在无歧义文法上处于同一水平。
本文将介绍 Gecko 的设计目标、核心特性、如何在项目中使用,以及验证其性能的基准测试。
## 为何再造解析器?
解析器生成器的世界早已拥挤:YACC 与 Bison 自 1970 年代就已存在;近年又出现了
PEG(https://en.wikipedia.org/wiki/Parsing_expression_grammar)解析器、
解析器组合子(https://en.wikipedia.org/wiki/Parser_combinator),以及各种
Earley(https://en.wikipedia.org/wiki/Earley_parser)
系工具。为什么还要写一个?
职业生涯中我写过若干编译器生成器。第一个是 1985 年用 Pascal 写的 LALR(1) 解析器(可查阅 1985 年俄文期刊《Программирование》)。九十年代又写了
MSTA(https://github.com/cocom-org/msta),
一个带多项优化的 LALR(k)/LR(k) 解析器,速度超越 YACC/Bison。
使用它时我发现,解析器速度并非最重要,现代 CPU 已足够快;LR(k) 也无法覆盖真实语言的全部语法。
于是约十年前我写了
YAEP(https://github.com/vnmakarov/yaep):
一个支持任意上下文无关文法的 Earley 解析器,并把它优化到我所知最快。
然而我对它在无歧义或轻度歧义文法上的速度与内存占用仍不满意——Earley 算法的内在特性导致难以再突破。
因此最近我转向 GLR 解析,Gecko 便是这一路线的新实现。
**我尚未见任何工具能同时提供 Gecko 的以下组合**:
1. **完整 CFG 支持**——对文法结构零限制,包括歧义文法
2. **库形态**——可嵌入任何 C 程序,无需外部代码生成
3. **运算符优先级/结合性**(https://en.wikipedia.org/wiki/Order_of_operations)(https://en.wikipedia.org/wiki/Operator_associativity)——沿用 YACC 用户熟悉数十年的机制
4. **自动错误恢复**——无需 `error` 令牌,无需改动文法,只需最小跳字即可得到正确语法树
5. **可竞争的速度**——无歧义文法下与 YACC 差距 5–10%,歧义文法下远快于其它 GLR 实现
下面逐项详解。
## 特性概览
### 小巧简洁
Gecko 核心约 2800 行 C 代码(SLOC),另含 6 个头文件级工具库(自定义分配器、位图、哈希、哈希表、变长对象、对象栈)共约 900 行。
**总计约 3,700 行 C**(SLOC),x86-64 下编译后仅 **90 KB**。
除 C 标准库外零依赖,**直接丢源文件即可编译嵌入**。
### 完整上下文无关文法支持
YACC/Bison 限 LALR(1);Bison 虽声称支持 GLR,我却无法用歧义文法使其正常工作:
对 Gecko 与
ElkHound(https://github.com/WeiDUorg/elkhound)
共用的歧义 C 文法,Bison 在第三行就报语法错误;
对最简单的歧义文法 `E : E '+' E | 'a'`,它在第 23 个令牌处报错。
Gecko 则能解析**任意上下文无关文法**,包括歧义文法。
这对 C 语言尤其有用:著名的“typedef 问题”(`T * x;` 是指针声明还是乘法?)若在词法阶段不区分类型名与标识符,就会产生真实歧义。
用 Gecko,可统一只发一种标识符令牌,让解析器并行探索两种解释,歧义在生成的语法树中结构体现,无需词法-语法反馈 hack。
### 简洁的语法制导翻译
Gecko 不像 YACC 那样用语义动作,而是采用**抽象语法树(AST)**(https://en.wikipedia.org/wiki/Abstract_syntax_tree)
输出,规则右侧用紧凑记法指明哪些子节点参与构成 AST:
```
E : T # 0
| E '+' T # plus (0 2)
;
T : F # 0
| T '*' F # mult (0 2)
;
F : 'a' # 0
| '(' E ')' # 1
;
```
每条候选后的 `#` 指明翻译方式:
- `# 0` 表示“本规则翻译等于右侧第 0 号符号的翻译”(透传)
- `# plus (0 2)` 表示“创建名为 `plus` 的抽象节点,子节点取右侧第 0、2 号符号的翻译”
- `# 1` 表示“翻译等于右侧第 1 号符号的翻译”(跳过左括号)
这种写法比 YACC 动作简单:无需在文法里写 C 代码即可得到干净 AST。
生成的树由 `GP_TERM`(终结符)、`GP_ANODE`(带命名子节点的抽象节点)、`GP_NIL`(空翻译)、`GP_ALT`(歧义候选)、`GP_OPT`(上下文相关候选)构成。
对上述文法与输入 `a+a*(a*a+a)`,Gecko 会生成如下树:
AST for a+a*(a*a+a)
其中 `ANODE`、`TERM` 为节点类型(`GP_ANODE`、`GP_TERM`),`plus`、`mult` 为抽象节点的 `name` 字段,`a` 为输入令牌属性。
### 运算符文法
真实文法常含几十级优先级的表达式语法。按优先级逐级写非终结符既繁琐又易错。
Gecko 支持**运算符优先级与结合性声明**,语法与 YACC/Bison 相同:
```
LEFT '+';
LEFT '*';
E : 'a' # 0
| '(' E ')' # 1
| E '+' E # plus (0 2)
| E '*' E # mult (0 2)
;
```
`LEFT '+'` 与 `LEFT '*'` 声明左结合运算符,且 `*` 优先级高于 `+`(后声明者更高)。
于是可放心写看似歧义的 `E '+' E`,Gecko 会像 YACC 一样解决冲突;若文法其他部分真有歧义,GLR 机制仍保留完整能力。
支持 `LEFT`、`RIGHT`、`NONASSOC`,同行列出的记号共享优先级。
与 YACC 一样,使用运算符声明并配合左递归规则,可获得更快解析速度与更低内存占用。
### 库架构
Gecko 是**库**,而非独立工具。这是根本设计差异。
YACC/Bison 生成 C 源文件再编译链接;Gecko 则直接链接进程序。
文法可通过函数调用(`gp_read_grammar`)或类 YACC 描述字符串(`gp_parse_grammar`)提供。
这意味着:
- **构建过程无需代码生成步骤**
- **同一程序可并存多个文法**
- **文法可在运行时更换**——从配置读取、动态构造、或在多遍解析中切换
- **读入文法后启动极快**
核心 API 故意极简:
```c
struct grammar *g = gp_create_grammar();
gp_parse_grammar(g, true, description);
gp_parse(g, read_token, &root, &ambiguity, NULL);
gp_fin(g);
```
四步即可从文法到语法树。
### 自动语法错误恢复
用过 YACC/Bison 的人都知道,实现高质量语法错误恢复极其困难。
据我经验,这项任务花费的时间——以及后续在语义检查中处理语法错误的时间——远超最初写文法。
因此 Gecko 项目把“简化错误恢复”作为重点。
Gecko 的语法错误恢复**完全自动**,无需任何 `error` 令牌,**零文法改动**。
这得益于 GLR 架构天然同时维护多栈状态。
#### 工作机制
当检测到语法错误(当前没有任何栈能移进或归约该令牌)时,恢复算法启动:
1. **拒绝并弹出**。对每个失败栈,Gecko 丢弃当前令牌,并持续弹出栈顶直到新栈能对原令牌执行动作。
2. **寻找最小代价成功**。当某候选栈以最小代价连续成功消费可配置数量的令牌(默认 5,可通过 `gp_set_recovery_match` 修改)未再遇错误,或到达 EOF,即认为恢复成功。
3. **恢复正常解析**。所有至少匹配到一个令牌的栈成为新的继续解析起点。
#### 核心保证
Gecko 保证**解析器始终生成对应“语法正确输入”的语法树**。
实现思路很直观:在出错点前后忽略最少数量的令牌,使剩余令牌流对文法而言合法。
解析器通过用户可配的错误回调报告哪些令牌被涉及。
以简单表达式文法(见前)和输入 `a+a*(a*+a)` 为例——注意 `*` 后多出一个 `+`。
Gecko 在 `+` 处检测到错误,发现跳过第二个 `*` 即可得到合法续解析,于是生成如同输入为 `a+a*(a+a)` 的语法树。
错误回调报告问题令牌,解析继续。
该方案在实践中表现良好:
- **文法无关**——任何文法白送错误恢复
- **局部最小代价**——非任意修复
- **多错误支持**——各错误独立恢复
- **输出树始终结构合法**
注意“最小代价”仅指局部搜索空间内的最小;据我所知尚无解析器做全局最小代价错误恢复。
例如 YAEP 仅在有限意义上全局搜索:它最小化被特殊文法终结符 `error` 覆盖的令牌数。
## 文法描述语言
Gecko 通过 `gp_parse_grammar` 接受类 YACC 的文本格式,支持:
### 终结符声明
命名终结符可显式指定编码:
```
TERM IDENTIFIER = 300 CONSTANT = 338 STRING_LITERAL = 340;
```
同一 `TERM` 语句可声明多个终结符,均可选 `= code`。
单字符字面量(`'a'`、`'+'` 等)自动以 ASCII 值为编码,无需在 TERM 中声明。
### 运算符优先级
```
LEFT '+' '-';
LEFT '*' '/' '%';
RIGHT ELSE;
NONASSOC '<' '>';
```
同行记号共享优先级,后行优先级更高,语义与 YACC/Bison 完全一致。
### 规则
```
lhs : rhs_symbol_1 rhs_symbol_2 ... # translation
| alternative_rhs ... # translation
;
```
终结符可以是单字符字面量(`'a'`、`'+'`)或命名令牌(`IDENTIFIER`)。
非终结符为未声明成终结符的任意名字。
Gecko 要求文法公理(开始符号)只有一条规则。
因此使用 `gp_read_grammar` 时通常需显式加开始规则如 `$S : E`;
使用 `gp_parse_grammar` 时 Geckco 会自动添加。
### 翻译说明
`#` 之后:
- 单个数字 `N` 表示翻译取右侧第 N 号符号的翻译(0 基)
- `name (i j k)` 创建指定名字的抽象节点,子节点取右侧 i、j、k 位置符号的翻译
- 省略 `#` 表示空翻译
可选**守卫**可跟在翻译后:`? N` 给该规则候选分配守卫号 N(见“配置中的规则守卫”)。
文法描述中支持 C 风格 `/* ... */` 注释。
## 程序化构造文法
为最大灵活性,Gecko 亦支持通过 `gp_read_grammar` 函数以编程方式逐条构建文法。
需提供两个回调:
**`read_terminal`**——反复返回终结符定义:
```c
const char *read_terminal(int *code, int *priority, enum gp_assoc *assoc) {
// 返回终结符名字,并设置编码、优先级、结合性。
// 返回 NULL 表示结束。
}
```
**`read_rule`**——反复返回语法规则:
```c
const char *read_rule(const char ***rhs, const char **abs_node,
int **transl, int *guard_num) {
// 返回左侧名字,并设置右侧数组、抽象节点名、翻译数组、守卫号。
// 返回 NULL 表示结束。
}
```
假设有简单表达式文法:
```
LEFT '+';
E : 'a' # 1
| E '+' E # plus (0 2)
| '(' E ')' # 1
;
```
以下给出完整的程序化构造示例:
(注:原文代码被截断,此处保留原文片段,未作补全)
相似文章
Gossamer:一种具有真实goroutines和无暂停内存的Rust风格语言
Gossamer是一种受Rust启发的新编程语言,具有真实goroutines、基于引用计数和区域的无暂停确定性内存管理,以及配备LLVM编译的字节码虚拟机。它旨在提供富有表现力的语法,无需借用检查器或垃圾回收暂停。
用于C语言的单头文件解析器组合子
CParseC 是一个基于 C99 的单头文件解析器组合子库,灵感来自 Haskell 的 Parsec,提供零拷贝解析、无隐藏内存分配以及 SIMD 优化的组合子。其目标是提供一种灵活、高性能的手写解析器和 lex/yacc 工具的替代方案。
句法即罗塞塔石碑:通用依存助力科普特语上下文翻译
乔治城大学研究团队通过将通用依存句法解析与双语注释一起加入上下文提示,显著提升了低资源科普特语到英语的翻译效果,刷新最佳纪录。
@badlogicgames: 一个很棒的项目:parakeet.cpp https://github.com/mudler/parakeet.cpp… 基于GGML的parakeet推理管道…
parakeet.cpp 是一个快速、轻依赖的C++17推理管道,用于NVIDIA的NeMo Parakeet语音识别模型,基于ggml构建。它能实现与NeMo字节相同的转录结果,并在CPU和GPU上显著提升速度。
用于生成紧凑型LR(1)解析器的APLR(1)算法比IELR(1)更简单且功能更强
一份技术报告,介绍了用于生成紧凑型LR(1)解析器的APLR(1)算法。该算法比现有的IELR(1)算法更简单、功能更强,并且支持用于GLR解析的非确定性/歧义文法。