Part1
2020-03-21

【使用 Rust 写 Parser】1. 初识 nom

关于 nom 计划写一个系列, 大概有3篇或更多专栏文章(如果我不是太忙或不鸽的话), 从基本概念开始, 到中等难度的 json 解析器, 最后可能会用 nom 5.0 实现简单语言的解析器.


简介

最近在读书练习用 Rust 写算术表达式解析器被正则表达式弄烦了, 不由得想起那句金句

Some people, when confronted with a problem, think “I know, I’ll use regular expressions.” Now they have two problems. By Jamie Zawinski

虽然最后用正则表达式实现原有需求, 甚至想写篇专栏记录下, 但一个星期后再看代码, 好像比当初写的时候还要晦涩, 算了, Let it Go 吧 : )

经过每天25小时的高强度网上冲浪, 我找到一个在写解析器时比正则表达式要更方便的 crate: nom

nom, 发音类似大口咀嚼时发出的声音, 比喻这个 crate 会一口一口吞掉你的数据.

quick start

以 nom README 上16进制颜色值解析器为例, 简要说明下 nom 的一些概念和常见函数

use nom::{
  IResult,
  bytes::complete::{tag, take_while_m_n},
  combinator::map_res,
  sequence::tuple
};

#[derive(Debug,PartialEq)]
pub struct Color {
  pub red:   u8,
  pub green: u8,
  pub blue:  u8,
}

fn from_hex(input: &str) -> Result<u8, std::num::ParseIntError> {
  u8::from_str_radix(input, 16)
}

fn is_hex_digit(c: char) -> bool {
  c.is_digit(16)
}

fn hex_primary(input: &str) -> IResult<&str, u8> {
  map_res(
    take_while_m_n(2, 2, is_hex_digit),
    from_hex
  )(input)
}

fn hex_color(input: &str) -> IResult<&str, Color> {
  let (input, _) = tag("#")(input)?;
  let (input, (red, green, blue)) = tuple((hex_primary, hex_primary, hex_primary))(input)?;

  Ok((input, Color { red, green, blue }))
}

fn main() {}

#[test]
fn parse_color() {
  assert_eq!(hex_color("#2F14DF"), Ok(("", Color {
    red: 47,
    green: 20,
    blue: 223,
  })));
}

对于一个16进制颜色值, 其以 “#” 开头, 接着6个16进制数(0-9和a-f,大小写不敏感), 每2个值构成一组, 从左到右为 RGB 通道值, 可以简写为 #R(hex, hex)G(hex, hex)B(hex, hex).

因此, 解析器逻辑可以概括为, 去掉开头的 “#”, 如果随后的6个字符为16进制数, 则分为三组, 并将每组的值从16进制转换为10进制.

匹配某个模式这个需求在解析过程中普遍存在, nom 提供了 tag, tag 会匹配(只匹配开头)你给出的字符模式, 并返回匹配的模式和余下字符, 如果不匹配, 则返回错误.

let (input, _) = tag("#")(input)?;

tag("#") 返回的是一个函数, 所以我们可以用待解析的字符作为参数, 调用 tag("#") 的返回值, 函数返回值为 IResult<Input, Input, Error>, 其中 Input 为函数输入参数类型, 返回值第一个值为去掉匹配模式后的输入值(这里为字符串切片), 第二个值为 pattern, 第三为错误值.

IResult 实现了 Error trait, 因此可以用 ? 快速失败, 其相当于 std::result::Result<Ok(remaining, pattern), Err>, 在 nom 中绝大多数解析函数返回值都是这种形式.

use nom::{IResult, bytes::complete::tag};

fn parse(input: &str) -> IResult<&str, &str> {
    tag("#")(input)
}
fn main() {
    let (remain, pattern) = parse("#ffffff").unwrap();
    println!("{}, {}",remain, pattern);
}

输出 ffffff, #.

接着要对剩下字符做解析, 拿出两个字符, 判断这字符是否是16进制数, 如果是, 则将其转换为10进制, Rust 有 take_while, nom 提供了扩展性更好的 take_while_m_n, m, n 分为 最少和最多匹配数

因此函数可以这样调用 take_while_m_n(2, 2, |c: &char| c.is_digit(16)), 在 Rust 中可以对 Result 使用 map, nom 也有类似函数 map_res, 按下面的方式调用

map_res(
    take_while_m_n(2, 2, |c: &char| c.is_digit(16)),
    to_decimal
)(input)

会先对 input 应用 take_while_m_n(2, 2, |c: &char| c.is_digit(16)), 如果 Ok 则对结果应用 to_decimal 转换为10进制

fn from_hex(input: &str) -> Result<u8, std::num::ParseIntError> {
  u8::from_str_radix(input, 16)
}

fn is_hex_digit(c: char) -> bool {
  c.is_digit(16)
}

fn hex_primary(input: &str) -> IResult<&str, u8> {
  map_res(
    take_while_m_n(2, 2, is_hex_digit),
    from_hex
  )(input)
}

现在要对输入应用三次 hex_primary, 用 for 循环? 不 nom 有更趁手的工具 tuple, tuple 接受一组组合子, 将组合子按顺序应用到输入上, 然后按顺序返回以元组返回解析结果

tuple((hex_primary, hex_primary, hex_primary))(input)?

把上面的函数组合组合起来, 一个16进制颜色值解析器就完成了

fn hex_color(input: &str) -> IResult<&str, Color> {
  let (input, _) = tag("#")(input)?;
  let (input, (red, green, blue)) = tuple((hex_primary, hex_primary, hex_primary))(input)?;

  Ok((input, Color { red, green, blue }))
}

如果你熟悉 nom, 那么这函数功能非常清晰. 它接受一个类型为 &str 的输入值, 如果输入值以 “#” 开头, 取余下的字符, 尝试连续应用三次 hex_primary 返回元组. 清晰明了.

总结

通过上面的小例子展示 nom 的用法和风格, 特别是 tag, map_res, tupleIResult 这几个函数或 数据结构的使用, 它们在 nom 被广泛使用, 熟悉它们可以帮我们更快更高效地使用 nom 构建功能丰富更复杂的解析器. 根据作者在 5.0 版本以后的倡议, 以后的例子都尽量采用函数而不是宏, 因为我个人在阅读某些依赖 nom 的项目时, 大量使用宏确实会导致代码易读性下降, 而且 Rust 编辑器或 IDE 对宏的支持都不太好, 这导致这些代码既不好读, 也不容易写或 debug.

下一篇会尝试用 nom 写一个 S 表达式解析器, 这个例子同样来自 nom 文档, 心急的可以直接移步项目文档. 这个例子将展示如何从基本的元素开始, 一步步把 nom 赋予的简单有效的组合子通过递归等方式组合起来, 最后实现 S 表达式解析器.

最后, 在时常抽风的 Github 上冲浪翻文档不易, 如果喜欢, 求赞支持.