#ast-node #ast #parser-generator #syntax #generator #compiler #parser

parsel_derive

使用 AST 节点类型作为语法的零代码解析器生成

29 个版本 (15 个重大变更)

0.16.0 2024年6月6日
0.14.0 2024年4月24日
0.12.0 2023年4月17日
0.11.0 2022年11月13日
0.9.0 2022年7月19日

#17 in #ast-node

Download history 9/week @ 2024-04-03 286/week @ 2024-04-17 87/week @ 2024-04-24 3/week @ 2024-05-01 1/week @ 2024-05-15 8/week @ 2024-05-22 152/week @ 2024-05-29 148/week @ 2024-06-05 11/week @ 2024-06-12 1/week @ 2024-06-19

1,664 月下载量
parsel 中使用

MIT 许可证

38KB
432

Parsel,零代码解析器生成器

MSRV

Parsel 是一个库,可以从语法树节点类型直接生成解析器。

主要入口点是自定义 derive proc-macro #[derive(Parse)],它为注释的 AST 节点类型生成 syn::parse::Parse 特质的实现。通过添加 #[derive(FromStr)],也实现了类型的标准 FromStr 特质,通过简单地转发到其 Parse 实现。

此外,提供了一个#[derive(ToTokens)]宏,用于通过quote crate轻松获取特定AST节点的源表示。这反过来又帮助获取其Span,因为实现了泛型impl<T: ToTokens> Spanned for T。添加#[derive(Display)]也将实现类型的标准Display特质,只需简单地将其实例转发到其ToTokens实现。

此外,ast模块提供了一些用于常见需求的辅助类型,例如可选生成式、重复、括号和分组。这些主要是围绕syn已提供的解析集合和解析逻辑构建的轻量级包装器。然而,一些非常有用的syn类型,如Option<T: Parse>Punctuated,有多种同样有效的解析,因此它们不实现Parse以避免歧义。Parsel通过在类型级别将有效解析集拆分为多个无歧义的可解析类型来处理这种歧义。

示例和其工作原理

Parsel背后的基本思想是观察结构体struct和枚举enum直接对应于语法中的序列和交替,并且它们是可组合的:不需要知道子表达式的确切实现即可生成当前规则的解析器。

具有结构体类型的AST节点对应于序列:每个字段(无论是有名还是编号)都将按源中指定的顺序依次解析和填充。

具有枚举类型的AST节点对应于交替:它们的变体将按顺序尝试,并且第一个成功的结果将被返回。元组和结构体变体的字段将按照与结构体字段相同的顺序方式处理。

因此,您通过指定AST节点的字段和变体来定义您的语法,Parsel将从中生成解析器。让我们看看在简单JSON-like语言的解析器和打印器上下文中这看起来像什么。

use core::iter::FromIterator;
use core::convert::TryFrom;
use parsel::{Parse, ToTokens};
use parsel::ast::{Bracket, Brace, Punctuated, LitBool, LitInt, LitFloat, LitStr};
use parsel::ast::token::{Comma, Colon};

mod kw {
    parsel::custom_keyword!(null);
}

#[derive(PartialEq, Eq, Debug, Parse, ToTokens)]
enum Value {
    Null(kw::null),
    Bool(LitBool),
    Int(LitInt),
    Float(LitFloat),
    Str(LitStr),
    Array(
        #[parsel(recursive)]
        Bracket<Punctuated<Value, Comma>>
    ),
    Object(
        #[parsel(recursive)]
        Brace<Punctuated<KeyValue, Comma>>
    ),
}

#[derive(PartialEq, Eq, Debug, Parse, ToTokens)]
struct KeyValue {
    key: LitStr,
    colon: Colon,
    value: Value,
}

let actual: Value = parsel::parse_quote!({
    "key1": "string value",
    "other key": 318,
    "recursive": [
        1.6180,
        2.7182,
        3.1416,
        null
    ],
    "inner": {
        "nested key": true,
        "hard to write a parser": false
    }
});
let expected = Value::Object(Brace::from(Punctuated::from_iter([
    KeyValue {
        key: LitStr::from("key1"),
        colon: Colon::default(),
        value: Value::Str(LitStr::from("string value")),
    },
    KeyValue {
        key: LitStr::from("other key"),
        colon: Colon::default(),
        value: Value::Int(LitInt::from(318)),
    },
    KeyValue {
        key: LitStr::from("recursive"),
        colon: Colon::default(),
        value: Value::Array(Bracket::from(Punctuated::from_iter([
            Value::Float(LitFloat::try_from(1.6180).unwrap()),
            Value::Float(LitFloat::try_from(2.7182).unwrap()),
            Value::Float(LitFloat::try_from(3.1416).unwrap()),
            Value::Null(kw::null::default()),
        ]))),
    },
    KeyValue {
        key: LitStr::from("inner"),
        colon: Colon::default(),
        value: Value::Object(Brace::from(Punctuated::from_iter([
            KeyValue {
                key: LitStr::from("nested key"),
                colon: Colon::default(),
                value: Value::Bool(LitBool::from(true)),
            },
            KeyValue {
                key: LitStr::from("hard to write a parser"),
                colon: Colon::default(),
                value: Value::Bool(LitBool::from(false)),
            },
        ]))),
    },
])));

assert_eq!(actual, expected);

递归AST节点和循环约束

大多数实用的实际语法都是递归的,即它们包含直接(直接递归)或间接(相互递归)地引用自身的生成式。这导致AST节点类型包含指向同一类型的指针。更重要的是,它导致了ParseToTokens实现中的循环约束。这些循环约束可以轻易地得到满足和解决,但由于问题 #48214,Rust编译器的约束求解器目前正努力解决这些问题。

因此,在推导ParseToTokens的实现时,必须打破这样的约束循环。Parsel通过提供属性#[parsel(recursive)]或等效的写法,#[parsel(recursive = true)]来支持这种用法。将此属性添加到结构体或枚举变体的字段中,将导致从生成的ParseToTokens impls的where子句中省略所有FieldType: ParseFieldType: ToTokens约束,打破约束循环,从而允许代码编译。

在单个类型上打破每个约束循环(实际上是在需要添加最少#[parsel(recursive)]注解的类型上)就足够了。然而,如果语法包含多个自引用循环,则必须打破其中的每一个。此外,如果打破循环需要省略出现在结构体或变体多个字段中的类型的约束,那么必须将这些字段的所有字段都添加#[parsel(recursive)]

以下是一个示例,考虑以下简单布尔运算的语法及其相关注释

use parsel::{Parse, ToTokens};
use parsel::ast::{Paren, LitBool};
use parsel::ast::token::{Or, And, Not};

#[derive(PartialEq, Eq, Debug, Parse, ToTokens)]
enum Expr {
    Or {
        lhs: Conjunction,
        op: Or,
        #[parsel(recursive)] // break direct recursion
        rhs: Box<Expr>,
    },
    Conjunction(Conjunction),
}

#[derive(PartialEq, Eq, Debug, Parse, ToTokens)]
enum Conjunction {
    And {
        lhs: Term,
        op: And,
        #[parsel(recursive)] // break direct recursion
        rhs: Box<Conjunction>,
    },
    Term(Term),
}

#[derive(PartialEq, Eq, Debug, Parse, ToTokens)]
enum Term {
    Literal(LitBool),
    Not(
        Not,
        #[parsel(recursive)] // break direct recursion
        Box<Term>,
    ),
    Group(
        #[parsel(recursive)] // break mutual recursion
        Paren<Box<Expr>>
    ),
}

let expr: Expr = parsel::parse_str("true & (false | true & true) & !false").unwrap();

assert_eq!(
    expr,
    Expr::Conjunction(Conjunction::And {
        lhs: Term::Literal(LitBool::from(true)),
        op: And::default(),
        rhs: Box::new(Conjunction::And {
            lhs: Term::Group(Paren::from(Box::new(Expr::Or {
                lhs: Conjunction::Term(Term::Literal(LitBool::from(false))),
                op: Or::default(),
                rhs: Box::new(Expr::Conjunction(Conjunction::And {
                    lhs: Term::Literal(LitBool::from(true)),
                    op: And::default(),
                    rhs: Box::new(Conjunction::Term(Term::Literal(LitBool::from(true)))),
                }))
            }))),
            op: And::default(),
            rhs: Box::new(Conjunction::Term(Term::Not(
                Not::default(),
                Box::new(Term::Literal(LitBool::from(false))),
            ))),
        })
    })
);

处理左递归

如果您仔细检查语法,可以注意到它是右递归的,即具有相同优先级的子表达式出现在右侧,而左侧下降一级到下一个最紧密绑定的子表达式。这反过来又意味着具有相等优先级的连续操作将向右关联。这是因为递归下降解析器,如Parsel生成的解析器,如果尝试解析左递归语法,则会陷入无限递归。例如,如果我们的顶层表达式定义为

expr = expr '|' conjunction
     | conjunction

那么为expr生成的代码将立即无条件地再次调用自身。

在简单布尔表达式的情况下,将语法重写为右递归是完全可以的(因为它们是结合的),但通常不可能完全从语法中省略左递归。非结合运算符非常关心它们的分组方式,即使是基本代数运算,如减法和除法,也按照广泛的传统定义为左结合。因此,Parsel需要支持左结合术语。有两种方法可以达到这个目标

  1. 通过简单地不在AST中表示结合性来绕过这个问题。这是通过使用一个辅助类型来实现的,该类型能够表达任意长度的显式重复(例如,Separated),而不是创建二叉AST节点。重复的AST节点将是下一个最高优先级级别的子表达式。这种方法将结合性问题推迟到评估/代码生成时,即树遍历时间。

  2. 使用LeftAssoc辅助类型。通过迭代解析(就像Separated一样)解决了无限递归的问题。然后,它将生成的线性子表达式列表转换为正确左结合(左倾斜)的AST节点树。

    请注意,还有一个类似的RightAssoc类型。严格来说,这并不是必要的,因为右递归可以继续进行并正常终止。然而,以迭代方式推导解析树的好处是递归较少,并且为了对称/一致性的原因,包括右倾斜对应物是更可取的。

范围和错误报告

实现ToTokens的类型会自动获得impl Spanned for T: ToTokens。这意味着默认情况下,所有推导自ToTokens的类型都将正确报告其范围,并且解析错误将具有有用的范围信息。

然而,关于语法中的选择(enum)有一个重要的注意事项。以完全自动和诱人的简单方式解析选择的方法是尝试逐个解析每个选择生成式,并选择第一个成功解析的。然而,如果它们都没有解析成功,那么对于解析器来说,显然不清楚它应该报告哪个错误。

我们用来解决这个问题的方法是,我们使用Span信息来选择在失败之前在标记流中前进得最远的变体。这之所以有效,是因为大多数“好”的上下文无关语法都是常量前瞻,甚至更好,是LL(1),即单标记前瞻。这意味着如果一个涉及多个标记的生产在中间失败,它将在流中比其他在第一个标记就失败的生产前进得更远。

然而,如果span信息不可用或不实用(即,当每个生产都跨越到相同的源位置Span::call_site()),则这种启发式方法会失效,并将选择任意生产,导致错误信息质量低下。这意味着您应该尽可能保留span。这反过来意味着,使用syn::parse_str()来解析过程宏之外代码比使用syn::parse2()更可取,因为前者将产生有用的span AST,而后者则不会,至少在例如通过quote!()parse_quote!()获取的TokenStream上不会。

路线图,待办事项

  • 记录所有公共API
  • 记录所有非公共API
  • 允许为每个生产/AST节点类型指定自定义错误消息
    • 允许为解析选择时发现最佳生产/错误消息的条件/排序标准/其他自定义
  • enum Either AST辅助类型用于基本二进制选择
  • Any AST辅助类型用于解析直到给定生产成功。与Many不同,它不需要生产扩展到输入结束。
  • 为包装类型(例如,ParenBracketBrace)一致地实现AsRefDerefBorrow
  • 即使span信息不实用,使交替的错误报告启发式方法(基于最远的解析生产)也能工作。请注意:绝对不应对剩余输入中的令牌/字节数进行计数,因为这会导致解析性能意外地呈二次方增长!

依赖关系

~3MB
~64K SLoC