3个版本
0.1.2 | 2019年3月17日 |
---|---|
0.1.1 | 2019年3月11日 |
0.1.0 | 2019年3月9日 |
#1254 in 解析器实现
285KB
4K SLoC
Kul
一种独特的文本表示法,可以用作数据格式和标记语言,并具有强大的词法和语义扩展能力,同时也是一个用于解析它的 Rust 库。它受到了不为人知的 Curl 语言 的启发,但只是一种类似于 Curl 的表示法,而不是像 Curl 那样的编程语言。
语法非常简洁,仅用于界定嵌套文本的格式,您可以通过绑定 操作符 子格式到类似宏的 组合 函数来自定义解析和语义。因此,它本身不是一个适用于大多数应用程序的完整格式,它们将定义自己的不同扩展。然而,它允许有一个共同的基语法,该语法可以在没有扩展的情况下解析到基本结构,这对于不需要处理所有嵌套文本格式的内部语法的应用程序是有用的。此外,未来还可以创建共享或标准化的扩展。
在 动机 部分,您可以了解为什么设计成这种格式。
在 目标 部分,您可以了解库设计实现了什么。
这只是一个实验性探索,也是我的第一个 Rust 项目,但实现功能相当强大。
表示法的示例
As markup of free-form text for embedding structured data types, such as a
{date 2019-03-05 15:15 PST}, which might be rendered as widgets; or for {italic
{bold styling} the text}. Only the \{, \}, and \\ characters require escaping.
{config {# As a data structure. (This form is a comment.)}
{things {list 9, "blah", {map foo: 8.7, bar: asdf}}}
{logging
{err = yes}
{warn = {maybe, if 1 + x = 3}}
}
{greeting: We can have text in structures without ugly quoting!}
{knobs this := {that}; other := {}}
{{compound-operator with arguments} 7/2 > 𝜋}
}
Using non-default delimiters:
⟪⟪source-code Rust⟫
use kul::common::inmem::parse_str;
fn main() {
let input = "Escaped the {bold non-default} delimiters: ␛⟪, ␛⟫, ␛␛";
dbg!(parse_str(input));
}
⟫
动机
这种表示法与 Curl 在这个层面的设计有相同的优势,即拥有一个 非常可扩展 的语言,用于标记和数据的语法最少。与大多数标记语言不同,您可以使用自己的标记格式及其自己的语法,与大多数数据格式不同,您可以使用自己的富数据类型及其自己的语法,而不是仅限于大多数其他提供的类型。
然而,这也意味着您需要自己实现自定义表单的解析和处理,但其他人可能会提供实现表单扩展的现成库。因为大多数其他表示法都是有限和固定的,应用程序有时最终会添加自己的自定义处理来实现它们想要的功能,这可能会感觉像是不一致的修补。相反,我们设计了这种表示法以从根本上支持扩展,这使得它更加干净、更强大,可以更直接地表达。这有助于您创建具有自己词汇语法的不同领域特定语言(DSL),而不是像其他DSL设计那样被锁定在一种特定的语法中。这也使得可以在同一外部形式或文件中嵌入多个DSL的不同语法。(Lisp语言在允许嵌入多个DSL方面类似,但它们都必须使用相同的S表达式词汇语法。)
起初,这可能会让人感觉会因语法和方言的巨大多样性而不知所措。但我们想要模拟的现实本身的固有复杂性是无法避免的,当您将这种复杂性从表示法中推出来时,它通常会在您后来尝试表达复杂组合时重新体现出来。例如,将DSL强行放入字符串类型、数组或其他类型如映射、标识符和函数签名中,因为您的主要表示法太有限,然后您又回到了必须解析和处理扩展,但使用的是并非为这种扩展而设计的语法和方法。
Kul表示法和解析器是尝试Curl方法的实验,Curl方法是为扩展而设计的。
有关动机的更多信息,请参阅关于Curl的链接论文以及关于它的其他已发表论文。
目标
库旨在提供
-
对完全在内存中输入和流输入(如果适当安排缓冲区为块)的零拷贝解析,包括零拷贝排除转义字符。这最大限度地减少了内存使用。所有这些都是通过通用块文本设计实现的。
-
用于所有块文本类型的预制的
char
-和位置迭代器,它只借用其内部状态(以避免复制)。这大大简化了定义自己的Text
特性impl
(如果需要)并帮助提供预制的那些。通过巧妙地设计以正确生命周期借用关联类型,而不使用Rust的未稳定特性generic_associated_types
实现。 -
对AST类型的非常深的巨大树的支持(例如,长列表和深嵌套)。这使得可以使用解析或自己构造生成的深树。通过自定义
Drop
和PartialEq
实现,避免了在丢弃或比较深树时发生栈溢出(否则会发生)。 -
没有
unsafe
代码。 -
没有外部依赖。
-
非恐慌API。(
panic!
永远不会发生,但目前尚未证明这一点。) -
一个可独立使用且仅使用栈分配的
no_std
核心包。解析器的动态分配可以从固定大小的数组中完成。这也可以用来强制限制内存消耗。 -
一个使用
std
的包,具有更方便的Vec
、String
、Box
等等,这些包建立在核心包之上。 -
在大多数方面非常泛型参数化,以允许最大程度地重用于各种应用。
状态
版本 0.1.2
:实验性和不稳定。构建良好且通过所有测试和代码风格检查。
Rust 版本
至少需要 1.33
。此库将始终只要求 Rust 的稳定版本(而不是夜间版本)。
kul
的用法
鉴于库的参数化非常通用,描述可能的全部用法需要较长的篇幅。
有关针对 no_std
应用程序(其中所有分配均仅从堆栈执行)的用法示例,请参阅 README
文件中的 kul_core
包。
对于常见的应用程序,以下示例显示了如何解析输入字符串,包括有和没有自定义扩展的情况。它打印出每个解析的结果抽象语法树(AST)结构,收集到每个解析的向量中,您将与之交互,因此您可以看到与不同记法形式相对应的 Datum
变体。 (作为一个介绍,这比展示大量匹配和变体分解要清晰。)
use std::{time::SystemTime, str::FromStr, iter::FromIterator};
use kul::{
common::inmem::{
parse_str, parse_str_with,
Text, OperatorBindings, DatumAllocator,
},
Combiner, Datum, datum::{BoxDatum, DatumBox}, Error, Text as _,
};
/// Parse without any bound operators and print results. This shows that the
/// common base syntax can always be parsed without knowing about possible
/// extensions.
fn no_extension() {
dbg!(parse_str("λ"));
dbg!(parse_str(r"es\{c\}ap\\es"));
dbg!(parse_str("{}"));
dbg!(parse_str("{▷}"));
dbg!(parse_str("Surrounding {{▷} λ {}} text."));
}
/// Parse with some bound operators and print results. This shows that the
/// syntax and semantics of particular forms can be extended in custom ways.
fn with_extensions()
{
/// Extends the types that may occur in the returned ASTs.
#[derive(Hash, Eq, PartialEq, Debug)]
enum MyDatumVariants {
Time(SystemTime),
Integer(i128),
}
/// Extends the types that may occur in errors returned by our custom form
/// processing.
#[derive(Debug)]
enum MyCombinerError<'input> {
Oops,
Darnit(MyDatum<'input>),
}
// Convenient type aliases.
type MyDatum<'input> = BoxDatum<Text<'input>, MyDatumVariants>;
type MyOperatorBindings<'input> =
OperatorBindings<'input, MyDatumVariants, MyCombinerError<'input>>;
type MyDatumAllocator<'input> = DatumAllocator<'input, MyDatumVariants>;
type AllocArg<'a> = &'a mut MyDatumAllocator<'static>;
// The functions that process our custom forms. Using closures can be nicer
// because at least some type inference of the argument and return types can
// be gained. The operator and allocator arguments are always ignored for
// this example, as they often are in real programs.
let comment = |_operator, _operands, _: AllocArg<'_>| {
Ok(None)
};
let pass_thru = |_operator, operands, _: AllocArg<'_>| {
Ok(Some(operands))
};
let current_time = |_operator, operands, _: AllocArg<'_>| {
if let Datum::EmptyList = operands {
Ok(Some(Datum::Extra(MyDatumVariants::Time(SystemTime::now()))))
} else {
Err(Error::FailedCombiner(MyCombinerError::Darnit(operands)))
}
};
let int = |_operator, operands: Text<'_>, _: AllocArg<'_>| {
// Must convert the operands text into a `&str`, to be able to use other
// parsing functions/libraries that take string slices. (When the other
// parsing functionality can instead take `Iterator`s of `char`s, this
// conversion is unneeded.)
let i = i128::from_str(&String::from_iter(operands.chars()))
.map_err(|_| Error::FailedCombiner(MyCombinerError::Oops))?;
Ok(Some(Datum::Extra(MyDatumVariants::Integer(i))))
};
// Establish bindings of particular operator sub-forms to our processing
// functions. Other more declarative and concise ways of doing this are
// possible, but, for this example, this shows the basic nature that other
// ways could build on.
let mut bindings = MyOperatorBindings::default();
bindings.hashmap.insert(Datum::Text(Text::from_str("#")),
Combiner::Operative(Box::new(comment)));
bindings.hashmap.insert(Datum::Text(Text::from_str("current-time")),
Combiner::Applicative(Box::new(current_time)));
bindings.hashmap.insert(Datum::Text(Text::from_str("int")),
Combiner::Operative(Box::new(int)));
let compound_operator_form =
Datum::Combination {
operator: DatumBox::new(Datum::Text(Text::from_str("compound"))),
operands: DatumBox::new(Datum::EmptyList),
};
bindings.hashmap.insert(compound_operator_form,
Combiner::Applicative(Box::new(pass_thru)));
// Parse a string that uses all of the above and print results.
dbg!(parse_str_with(
"{{compound} {current-time} {# removed} {unbound form} {int -42}}",
bindings)
);
}
fn main() {
no_extension();
with_extensions();
}
可以通过以下方式运行上述示例:
cargo run --example common_basic
文档
源代码中包含许多文档注释,这些注释作为 API 文档呈现。
在线查看:[http://docs.rs/kul](http://docs.rs/kul) 和 [http://docs.rs/kul_core](http://docs.rs/kul_core)
或者,您可以自己生成它们并在本地查看,方法是
cargo doc --open
请参阅 kul_core
包顶部的文档,以了解关于 Kul 语言和设计的更多概述。
(TODO
:由于重构更改了它们的相对目录和名称,一些不太重要的文档注释链接目前是断开的,并且一些需要建立链接。我希望能尽快稳定intra_rustdoc_links
功能(RFC 1946),以便更容易、更易于维护地修复这些问题。)
测试
这些包有许多单元测试和集成测试,可以通过以下方式运行:
cargo test --all
在我的 64 位 4 核计算机上使用 --test-threads=4
运行它们大约需要 2.1G 的内存。如果您想减少内存使用,可以
cargo test --all -- "" tree-size=$((2**N)) # where N < 21
默认情况下,用于测试 Drop
和 PartialEq
实现的树的大小是 221,这些实现防止深度树(例如长列表和深层嵌套)的 kul::Datum
发生堆栈溢出。低于此大小,它将不会正确测试这一点,因为即使没有我们的实现,也不会发生溢出。或者,您可以尽可能多地增加 N > 21
,以测试非常深的树。要看到溢出发生,请取消注释 src/drop.rs
中的 Drop
impl
。
未解决
以下方面尚未解决
-
一些术语选择可能可以改进。
-
Datum
枚举类型,用于返回的 AST 节点和传递给扩展函数的参数,设计为多用途,以便于限制性应用程序(不使用堆分配,仅使用核心no_std
软件包)只需提供这种简单类型的分配器,从而只需在堆栈上使用基本数组。但这导致一些 API 不太理想,因为Datum
被用于只有其变体子集可能的情况,需要使用具有必须执行的unreachable!
分支的解构匹配。我无法想出在不影响支持基本数组的基本类型限制性分配的情况下改进此方法的方法。目前尚不清楚是否可以在不引起无堆限制性应用程序困难的情况下进行改进。 -
Text
特性,用于表示字符序列块的逻辑连接,设计为多用途,可用于 AST 输出,以及流式或内存源输入,以及高效排除转义字符,并且可以与各种底层表示进行泛型使用。但这也导致必须遍历其逻辑字符序列的所有操作,以便知道其内容,这与其他库(例如,使用字符串切片&str
作为其输入以解析的库)不完全匹配。从Text
内容创建临时String
是使用此类库的合理方法,但这并不符合项目的目标,即尽可能支持零拷贝,并且String
(以及任何动态字符串类型)对于无堆限制性应用程序是不可用的。目前尚不清楚是否可以在保持Text
特性的所有期望用途和品质的同时进行改进。(Kul 库自身通过使用其自己的特殊迭代器和相关特性和类型,在保留零拷贝的同时使用字符迭代器。) -
在放弃名称 Kruvi 后,选择了名称 Kul。Kruvi 是 Lojban 中的曲线一词,以纪念 Curl,但它听起来与现有的软件项目 Groovy 太相似,并且太长,不适合用作文件名扩展名。名称 Kul 好像作为一个项目名称尚未被占用,并且
.kul
作为一个文件名扩展名尚未被占用。我设想应用程序可能会使用类似于.whatever.kul
的文件扩展名来指示它们对格式的特定扩展,以及表明它可以始终解析为基本的.kul
结构。我对任何名称都没有很强的偏好,并愿意更改它,但喜欢 Kul 这个名字很酷。