一直认为编写解析器是非常有挑战性的任务. TOML 本身已经很简洁. 为 TOML 写个解析器很有吸引力.
我们知道已经有了 YACC 这样工具可以完成此类工作. 事实上 Go 提供了这样的工具, TOML 上也有关于 EBNF 的讨论, 已经出现出几个版本. 但是要让这些 EBNF 定义转换成特定语言的代码, 还有很多辅助工作. 作为学习目的, 我采取先手工写一个解析器, 可以对完整使用 EBNF 有更深刻的理解.
鉴于 TOML 的简洁. 手工写出所有的 First 集和 Follow 集是可行的. parser.go 中 stateEmpty/tokensEmpty 就是 First 集, 按照编译原理所阐述的, 解析完整结束也会回到 First 集. 解析开始的时候至少要匹配到 First 集合中的一项(TOML 没有二义性, 只匹配一个). 解析结束的时候会回到 First 集合, 由于我写的 First 集合中没有 EOF 匹配, 所以当匹配不了 First 集合时, 解析结束, 相反如果在 First 集合中写下 stateEof/tokensEof 那最终会以匹配 EOF 而结束. 其他的 Follow 集合也是必须要有匹配, 如果没有被匹配, 那表示输入无效, 实现中我在每个 stateXX 中增加了一个没有匹配到要执行的动作, 用来给出一点提示信息.
解析器是有明确的阶段, 其中词法分析(也可以称为扫描器)是第一阶段. 对于手工写的解析器, 这些阶段的代码可以混合在一起. tom-toml 的扫描器 Scanner 是一个纯粹的 UTF-8 字符扫描器, 每次只扫描一个 UTF-8 字符, 别致的地方在于 Scanner 消除了 token 匹配中常见的 peek 操作. First 集和 Follow 集具体的匹配代码写法和这种 Scanner 是配合的, 所以在 itsString 这样的 token 匹配代码中可以看到 flag 这个状态标志, peek 被消除了. 当然这种方法只是一种尝试, 我并不确定是否可以普遍适用. 采用这种写法有个原因维护 peek 总让我晕头转向.
TOML 的实现有很多, 在 tom-toml 之前, 很多实现都是不支持注释操作的, 我认为注释是必要被支持的. 曾经 fork 了 pelletier/go-toml 并增加了注释支持, 好像 pelletier 不理解支持注释是必要. 鉴于改造的比较大, 不如重新写一个解析器.
先写下解释用的 TOML 文本
[nameOftable] # Kind() 为 TableName, String() 同此行
key1 = "v1" # Kind() 为 String, String() 是 "v1"
key2 = "v2" # Kind() 为 String, String() 是 "v2"
[[arrayOftables]] # Kind() 为 ArrayOfTables, String() 是此行及以下行
key3 = "v3" # Kind() 为 String, String() 是 "v3"
因为采用 map
和支持注释的原因, 使用上有些特别. Toml 对象中存储的
[nameOftable]
的字面值.[[arrayOftables]]
的一个 Table.因此用 tm
表示上述 Toml 对象的话
tm["nameOftable"] 仅仅是 `[nameOftable]`, 不包含 Key/Value 部分
tm["arrayOftables"] 是全部的 `arrayOftables`, 因为它是数组
tm.Fetch("nameOftable") 是`[nameOftable]`的 Key/Value 部分, 类型是 Toml
tm["arrayOftables"].Table(0) 是第一个 Table, 类型也是 Table
tm["nameOftable.key1"] 直接访问到了值为 "v1" 的数据
可以看出
Fetch()
方法才能得到一个 TOML 规范中定义的 Table 的主体.Table()
方法才能得到 Table
类型.arrayOftables.key3
这种写法是错误的, 不满足 TOML 规范的定义看上去很古怪, 但是如果要用 map 进行存储的话只能是这样, 就算不支持注释, 也逃不过 ArrayOfTables 的古怪.
map 带来 “nameOftable.key1” 这种点字符串方便的同时也产生了一些副作用.
map 更多的是表现平板式的数据结构, 没有太深的嵌套. 你可以用
<!-- lang: cpp -->
tm["a.b.c.d.foo"] // 一下就访问到最终的目标
// 而不用像这样
tm.Get("a").Get("b").Get("c").Get("d").Get("foo")
TOML v0.2.0 定义中是可以深层嵌套的. 用 map 完全实现 TOML 的标准, 访问的时候必然产生一些语义上的差异.
由于上述的特别原因, tom-toml 在实现中, 把 TOML 定义中的段(Table/ArrayOfTables)和值(String, Integer …)分开进行定义. 事实上 Table 的存储也被 Value 负责, 在 tom-toml 中 TableName 实际上就是个空的 Value. 因此会有这样的判断代码
<!-- lang: cpp -->
func (p *Value) IsValid() bool {
return p.kind != InvalidKind && (p.v != nil || p.kind == TableName )
}
保留这个空的 Table 对 Toml 对象格式化输出TOML文本是有意义的.
Value 的方法 Int/String/Float/Boolean/Datetime 是仿照 reflect.Value 的方法设计的. 也就是说使用者要自己确定 Value 的 Kind 并调用相应的方法获取数据的值, 如果错误的调用(String方法特殊, 其他类型可以转换到 string), 方法不会产生错误, 会返回一个缺省值.
Item 扩展自 Value, 目前是为了支持 ArrayOfTables 的, 可以看出 Value 主要负责存储值的维护, Item 维护了复杂的类型定义.
经过解析得到 Toml 对象后, 可以进行增删改所有 TOML 所支持的元素, 包括注释. 操作完后可以用 TomlString/String 方法得到带缩进的格式化输出. Toml 使用 map 保存数据, go 语言中 map 是无序的, tom-toml 内部使用一个计数器保证输出次序.
这个名字很不好, 因为事实上经过分析, 这个定义就是允许以数组的形式进行 TOML 嵌套.
下面转贴官方在 讨论 中给出的例子, 这明明就是嵌套的 TOML.
[[fruit]]
name = "apple"
[fruit.physical]
color = "red"
shape = "round"
[[fruit.variety]]
name = "red delicious"
[[fruit.variety]]
name = "granny smith"
[[fruit]]
name = "banana"
[[fruit.variety]]
name = "plantain"
如果您有任何问题, 建议请 issues 反馈.