跳转至

用 Rust Procedure Macro 实现 GLL Parser

背景

在编译原理课上,PA 框架采用的是 MashPlant/lalr1 ,是一个比较好用的 Lexer + Parser 的工具,它的大概语法见 一个完整的例子 。然后之前看到了 GLL Parser,想着可不可以照着类似的语法也写一个 GLL 的 Parser Generator,也是用 Rust Procedure Macro 的方法,就开始了研究。

尝试

首先是阅读 GLL 的论文,它并不长,大概的意思就是,LL(1) 文法需要考虑 PS 冲突的情况,而 GLL 的解决方法就是“都试一下”,然后为了效率,用了 GSS 表示解析过程和 SPPF 表示解析结果。然后就开始照着论文手写了不同版本的实现,见 jiegec/gll-test

第一种就是按照论文里第一段实现直接抄过来,每个可能性作为一个 Continuation 存下来,它有自己的栈和执行位置(Label)。这样 Work 以后呢,我又想到了 async/await,用类似的方法又写了一遍,相对要简洁一些,也是很平常的递归下降的写法,而不是 Loop + Label 的形式。但这些都不能做到合并栈的目的,所以遇到十分有歧义的文法的时候会很糟糕。

然后开始按照论文中的 GSS 进行编写,基本还是按照论文进行翻译,然后一步一步做,做好以后把 GSS 画出来,和论文的图可以对的上;然后照着 GLL parse-tree generation 的论文把 SPPF 实现了,这时候就可以从 recongizer 变成一个 parser 了。

得到一份可行的代码以后,就要扩展到通用的情况上。学习了一下 MashPlant/lalr1 的实现,实现了一个 proc macro,它读取了用户的程序,从一个模板文件开始,往里面插入一些生成的代码,丢给编译器去编译。这时候就涉及到编译期和运行时的不同了,我把运行时一些通用的结构放到了 gll-pg-core 中,把编译期的代码放到了 gll-pg-macros

代码生成的时候,基本按照之前自己写的样子抄,只不过这个时候要按照用户编写的产生式进行生成了,各种名字都要规范化,变得可以复用,然后尽量减少命名空间的污染等等这些常见的写宏需要注意的操作。

不过,考虑到现在还没有实现 Lexer,所以先用了 Logos 库作为 Lexer。但我其实不大喜欢它,因为它太简单,也没有行号的信息,不过暂且先这样吧,以后可能会自己实现。

然后 0.1.0 版本就诞生了,它的样例长这样:

//! This example is taken from MashPlant/lalr1

use gll_pg_core::LogosToken;
use gll_pg_macros::gll;
use logos::Logos;

#[derive(Logos, Debug, Eq, PartialEq, Clone)]
pub enum Token {
    #[end]
    End,
    #[error]
    Error,
    #[token = " "]
    _Eps,
    #[token = "+"]
    Add,
    #[token = "-"]
    Sub,
    #[token = "*"]
    Mul,
    #[token = "/"]
    Div,
    #[token = "%"]
    Mod,
    #[token = "("]
    LPar,
    #[token = ")"]
    RPar,
    #[regex = "[0-9]+"]
    IntLit,
}

#[gll(Expr, Token)]
impl Parser {
    #[rule(Expr -> Expr Add Expr)]
    fn expr_add(l: i32, _op: LogosToken<Token>, r: i32) -> i32 {
        l + r
    }
    #[rule(Expr -> Expr Sub Expr)]
    fn expr_sub(l: i32, _op: LogosToken<Token>, r: i32) -> i32 {
        l - r
    }
    #[rule(Expr -> Expr Mul Expr)]
    fn expr_mul(l: i32, _op: LogosToken<Token>, r: i32) -> i32 {
        l * r
    }
    #[rule(Expr -> Expr Div Expr)]
    fn expr_div(l: i32, _op: LogosToken<Token>, r: i32) -> i32 {
        l / r
    }
    #[rule(Expr -> Expr Mod Expr)]
    fn expr_mod(l: i32, _op: LogosToken<Token>, r: i32) -> i32 {
        l % r
    }
    #[rule(Expr -> Sub Expr)]
    fn expr_neg(_op: LogosToken<Token>, r: i32) -> i32 {
        -r
    }
    #[rule(Expr -> LPar Expr RPar)]
    fn expr_paren(_l: LogosToken<Token>, i: i32, _r: LogosToken<Token>) -> i32 {
        i
    }
    #[rule(Expr -> IntLit)]
    fn expr_int(i: LogosToken<Token>) -> i32 {
        i.slice.parse().unwrap()
    }
}

#[test]
fn gll() {
    let mut lexer = Token::lexer("1 + 2 * 3");
    let res = Parser::parse(&mut lexer);
    // two ways to parse
    assert_eq!(res, [7, 9]);
}

可以看到,它解析的结果是一个数组,对应所有可能出现的情况。这样比较简单,但是要求中间各种类型都是 Clone,因为同一个结点可能会被用多次。它的计算方法就是在最终的 SPPF 上递归找到所有可能性,然后调用用户代码,最后放到一个 Vec 中。

记忆化

但是,上面的做法有一个很大的问题,就是,虽然 SPPF 的空间复杂度是有限的,但所有可能的解析树可以有很多,如果把每一个情况都完整的存在一个 Vec 中,空间要求是很高的,中间也有很多重复计算的情况。所以需要做记忆化,然后每次给出一个。因为依赖自己内部的状态,所以不能是 Iterator 只能是 StreamingIterator。

记忆化也花了我一番功夫,现在用了一个比较土的办法,在每个结点上记录了当前遇到过的所有可能,这个是逐渐构造的,意味着如果只需要第一种解析树,不需要额外的空间。然后逐渐扩张,如果有可以重用的结构就重用,把涉及的所有的结构都放在一个 Vec 中,用完之后一起 drop 掉。

当然了,这个时候,各种东西都变成了引用:

//! This example is taken from MashPlant/lalr1

use gll_pg_core::*;
use gll_pg_macros::gll;
use logos::Logos;

#[derive(Logos, Debug, Eq, PartialEq, Clone)]
enum Token {
    #[end]
    End,
    #[error]
    Error,
    #[token = " "]
    _Eps,
    #[token = "+"]
    Add,
    #[token = "-"]
    Sub,
    #[token = "*"]
    Mul,
    #[token = "/"]
    Div,
    #[token = "%"]
    Mod,
    #[token = "("]
    LPar,
    #[token = ")"]
    RPar,
    #[regex = "[0-9]+"]
    IntLit,
}

#[derive(Default)]
struct Parser {
    literals: Vec<i32>,
}

#[gll(Expr, Token)]
impl Parser {
    // you can omit self
    #[rule(Expr -> Expr Add Expr)]
    fn expr_add(l: &i32, _op: &LogosToken<Token>, r: &i32) -> i32 {
        *l + *r
    }
    // you can use &self
    #[rule(Expr -> Expr Sub Expr)]
    fn expr_sub(&self, l: &i32, _op: &LogosToken<Token>, r: &i32) -> i32 {
        *l - *r
    }
    // you can use &mut self as well
    // but all of these have &mut self in fact
    #[rule(Expr -> Expr Mul Expr)]
    fn expr_mul(&mut self, l: &i32, _op: &LogosToken<Token>, r: &i32) -> i32 {
        *l * *r
    }
    #[rule(Expr -> Expr Div Expr)]
    fn expr_div(l: &i32, _op: &LogosToken<Token>, r: &i32) -> i32 {
        *l / *r
    }
    #[rule(Expr -> Expr Mod Expr)]
    fn expr_mod(l: &i32, _op: &LogosToken<Token>, r: &i32) -> i32 {
        *l % *r
    }
    #[rule(Expr -> Sub Expr)]
    fn expr_neg(_op: &LogosToken<Token>, r: &i32) -> i32 {
        -*r
    }
    #[rule(Expr -> LPar Expr RPar)]
    fn expr_paren(_l: &LogosToken<Token>, i: &i32, _r: &LogosToken<Token>) -> i32 {
        *i
    }
    // so you can make your IDE happy with &mut self here
    #[rule(Expr -> IntLit)]
    fn expr_int(&mut self, i: &LogosToken<Token>) -> i32 {
        let lit = i.slice.parse().unwrap();
        self.literals.push(lit);
        lit
    }
}

#[test]
fn ambiguous() {
    let mut lexer = Token::lexer("1 + 2 + 3");
    let mut parser = Parser { literals: vec![] };
    let res = parser.parse(&mut lexer).unwrap();
    // two ways to parse
    let res: Vec<_> = res.cloned().collect();
    assert_eq!(res, vec![6, 6]);
}

这时候就是 0.3.0 版本,基本达到了我一开始想要的程度。

错误处理

在之前写编译原理 PA1 的时候,遇到的一个问题就是,如果自己的代码有错,因为宏展开以后丢失了位置信息,所以报错都会在错误的位置。一番查找以后,找到了解决方案:原样记录下原来的代码(syn::Block),然后通过 quote 宏直接拼接到最终的 TokenStream 中,这样在结果里,虽然代码还是那些代码,但部分的 Token 就有了正确的位置,这样就很方便用户代码的修改了。不过还是不方便找模板部分的代码错误,毕竟那部分确实在原来的代码中没有出现过。

对于模板中的代码错误,我最终的解决方案是 cargo-expand ,把我的测试代码和展开后的代码拼接起来,然后在茫茫的无关报错下去找我的错误的地方。虽然不是很好用,但毕竟还是 work 的。另外,宏还需要对用户代码的一些类型进行检查,比如上面的 Expr 对应 i32,这个就需要在各处都保持一致,但这个就需要自己进行检查了。使用了一下 proc_macro_diagnostic 的 API,还不是很好用,等它 stable 吧。

总结

终于自己手写了一个 Procedure Macro,感觉现有的工具已经比较成熟了,有 syn quote 以后很多操作都很方便。但代码还有很多地方可以优化,慢慢搞吧。

评论