25 个版本 (13 个重大更新)
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 日 |
167 在 Rust 模式 中排名
每月 1,646 次下载
150KB
3K SLoC
Parsel,零代码解析器生成器
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)]
也可以通过简单地转发到其ToTokens
实现来为该类型实现标准Display
特质。
此外,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节点类型。更重要的是,它导致Parse
和ToTokens
实现的循环约束。这些循环约束可以轻易满足和解决,但由于问题 #48214,Rust编译器的约束求解器目前正在努力处理它们。
因此,在推导Parse
和ToTokens
的实现时,必须打破此类约束循环。Parsel通过提供属性#[parsel(recursive)]
或等效的拼写#[parsel(recursive = true)]
来支持此用例。将此属性添加到struct
的字段或enum
的变体中,将导致生成的Parse
和ToTokens
impls的where
子句中省略所有FieldType: Parse
和FieldType: ToTokens
约束,打破约束循环,从而使代码能够编译。
只需在单个类型上(实际上是在需要添加最少#[parsel(recursive)]
注释的类型)上打破每个约束循环即可。然而,如果语法包含多个自引用循环,则必须打破其中的每一个。此外,如果打破循环需要省略出现在struct
或变体的多个字段中的类型的约束,那么就必须将#[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支持将项与左侧关联。有两种方法可以实现这一目标
-
通过在AST中不表示结合性来绕过问题。这是通过使用一个辅助类型来完成的,该类型能够表达任意长度的显式重复(例如,
Separated
),而不是创建二进制AST节点。重复的AST节点将是下一个最高优先级级别的子表达式。这种方法将结合性问题推迟到评估/代码生成阶段,即树遍历时间。 -
使用
LeftAssoc
辅助类型。通过迭代解析(就像Separated
一样)解决了无限递归问题。然后,它将结果线性子表达式列表转换为正确左结合的(左倾)AST节点树。注意,还有一个类似
RightAssoc
的类型。严格来说,这并不是必要的,因为右递归可以正常地进行和终止。然而,以迭代方式构建解析树的好处是递归次数较少,并且出于对称/一致性原因,包含右倾对应项是更好的选择。
跨度与错误报告
实现ToTokens
的类型会自动获得impl Spanned for T: ToTokens
的实现。这意味着默认情况下,所有派生自ToTokens
的类型都将正确报告它们的跨度,并且解析错误将包含有用的跨度信息。
然而,在语法中对交替(enum
)的处理有一个重要的限制。在语法中,交替可以以完全自动和看似简单的方式解析,即尝试逐个解析每个替代生产,并选择第一个成功解析的。然而,如果没有一个成功解析,解析器则不清楚应该报告哪个错误。
我们用来解决这个问题的方法是使用Span
信息来选择在失败之前在标记流中走得最远的变体。这之所以有效,是因为大多数“好的”上下文无关文法都是常数前瞻,甚至更好的是LL(1),即单标记前瞻。这意味着如果一个涉及多个标记的生产在中间失败,它将在流中比其他在第一个标记就失败的生成推进得更远。
然而,如果跨度信息不可用或无用(即,当每个生成都跨越到相同的Span::call_site()
源位置),则这种启发式方法将失效,并且它将选择任意生成,导致错误信息较低。这意味着你应该尽可能保留跨度。这反过来又意味着,对于解析过程外部的代码,使用syn::parse_str()
比使用syn::parse2()
更可取,因为前者将产生一个有用跨度的AST,而后者则不会,至少在例如通过quote!()
或parse_quote!()
获得的TokenStream
上使用时不会。
路线图、待办事项
- 记录所有公共API
- 记录所有非公共API
- 允许为每个生成/AST节点类型指定自定义错误消息
- 允许在解析交替时发现最佳生产/错误信息报告的条件/排序标准/其他自定义
-
enum Either
AST 辅助类型,用于基本的二元交替 -
Any
AST 辅助类型,用于解析直到给定生产成功。与Many
不同,它不需要生产扩展到输入结束。 - 为包装类型(例如,
Paren
、Bracket
、Brace
)一致地实现AsRef
、Deref
和Borrow
- 即使在没有有用的跨度信息的情况下,使交替的错误报告启发式方法(基于最远的解析生产)也能正常工作。**请注意**:这绝对**不应该**仅仅通过计算剩余输入中的标记/字节数量来完成,因为这会导致**意外地二次方解析性能**!
依赖关系
~3.5MB
~69K SLoC