部分文字列にマッチできるマクロを作った

2022/09/18に公開

文字列を柔軟にパターンマッチしたい

Rustでパーサーなどを書いていると、文字列の先頭が一致するかどうかでパターンマッチしたいという状況が発生しがちです。

パターンマッチのパターンには文字列リテラルを使うことができるのですが、これは完全一致のみにしか使えません。

仕方がないので starts_with を使って、

if s.starts_with("foo") {
    todo!()
} else if s.starts_with("bar") {
    todo!()
} else {
    todo!()
}

などとする訳ですが、 starts_with を何度も書かないといけないことや、 一致した部分の後ろ側が欲しい場合に毎回 &s["foo".len()..] などと書かなければいけないのも面倒です。

ところでRustにはsliceのパターンマッチがありまして、 &[T][t1, t2, .., tn] みたいなパターンでマッチすることができます。
たとえば [b'a', b'b', b'c', b'd', b'e', b'f'] という配列から作ったスライスを [b'a', b'b', x @ .., b'f'] にマッチさせると x[b'c', b'd', b'e'] になる訳です。 stras_bytes() を使うことでバイト列に変換できるので、

match s.as_bytes() {
    [b'f', b'o', b'o', rest @ ..] => todo!(),
}

などとすることで前方一致しつつ残りを取り出せるのですが、文字列をいちいちバイト列と解釈してパターンマッチを書かないといけない上に、 rest @ .. でマッチした部分は &[u8] になってしまいます。

とはいえ、スライスの場合は部分一致でマッチできるので、文字列リテラルを受け取ってバイト列のパターンに変換する機能と、一致した部分を &[u8] から &str に変換する機能があれば、文字列の部分マッチ的なことができるはずです。

という訳で str-match というクレートを作りました。

format! マクロに渡すようなパターンで文字列を部分一致できます。たとえば "abc{x}ghi" というパターンを渡すと、先頭が "abc" で末尾が "ghi" の文字列に一致し、先頭と末尾を除いた部分が x&str 型でバインドされます。

use str_match::str_match;

str_match! {
    match s {
        "abc{x}ghi" => todo!(),
    }
}

仕組みとしてはproc-macroを使い、文字列リテラルをパースして {} で囲まれた部分を探し、 {} の内部を識別子に、外側をバイト単位に分割してパターン化するという方法をとっています。 format! と同じように {{}} はエスケープされます。
また、識別子でバインドした部分が &[u8] になる問題ですが、そのまま std::str::from_utf8_unchecked を使って変換しています。マッチ文字列はRustの文字列リテラルなので必ずUTF-8であり、バイト化して部分一致してもUTF-8の境界線で区切られているため、ノーチェックで &str に変換しても問題ありません。

内部的にはスライスのパターンマッチを使っている以上、 [.., 0, ..] のようなパターンマッチが書けないのと同じで、 "{x}0{y}" のようにプレースホルダーが2個以上あるパターンを書くことはできず、前方一致か後方一致、もしくはその両方と、中間のマッチしなかった部分を取ることができるだけです。なお、マッチしなかった部分が不要な場合は識別子の代わりに _ を使うことができます。

仕組み上、複雑なパターンにまで対応するのは難しいため、単一の文字列に対するマッチしかできません。つまり、

str_match! {
    match (a, b) {
        ("a{xa}a", "b{xb}b") => (xa, xb)
    }
}

といったパターンマッチはできないということになります。

冒頭の starts_with を使った方法をこれに置き換えると、

str_match!{
    match s {
        "foo{_}" => todo!(),
        "bar{_}" => todo!(),
        _ => todo!(),
    }
}

となり、すっきりさせることができました。

課題

実際にパーサーで使用してみたところ、これができて欲しいというものがいくつかあったので並べてみます。

  • コードポイント1つ分にマッチさせたい
    • コードポイント1つと言ってもUTF-8では1~4バイトになるので難しい
  • 正規表現の (foo|bar).* のようなパターンに対応したい
    • "foo{_}" | "bar{_}" とすることで一応対応できるが冗長
    • (foo|bar) に一致させるプレースホルダーも欲しい

match 式を利用したのはオーバーヘッドがスライスのパターンマッチと同等になるようにするためですが、ここら辺を実装するなら match 式を使うことを諦める必要があるかもしれません。

Discussion