正規表現の名前つきキャプチャを便利にする

Java 7から正規表現で名前つきキャプチャが使えて, Scalascala.util.matching.Regex.Matchでもそれに相当する機能がある(インタフェースや実装はJava標準のものとは別)けれど, ちょっと不便なところをどうにかしているうちに, インデックスによるキャプチャグループの上に独自に名前つきキャプチャグループを実装するような形になった.

使い方はREADMEを見てもらうとして, なぜこんなものがほしくなったのかという話を書いておく.

標準の名前つきキャプチャのよくないところ

キャプチャグループの実体と名前の乖離

名前つきキャプチャは正規表現中で(?<name>pattern)のように書くことで, patternにマッチした部分をあとからnameで参照できる. たとえば, Webサーバのルーティング処理で, パス中に名前つきでパターンを書いておけば非常に便利そうに思える.

import java.util.regex.Pattern

val User = "(?<User>[a-zA-Z][a-zA-Z0-9_-]+)"
val pathPattern = Pattern.compile(s"/$User/bookmark")

val m = pathPattern.matcher(req.pathInfo)
val user = if (m.matches) Some(m.group("User")) else None

まぁこれでよいと言えなくもないけれど, 間違えやすかったり不便なところもある. ざっと以下のところが気に入らない.

  • val User(?<User>...)で2回Userって書くのだるい
  • (?<User>...)のところのUserを(たとえばUsrとかに)書き間違えていると死ぬ
  • m.group("User")のところのUserを(たとえばUsreとかに)書き間違えていると死ぬ
  • エンドポイントを一覧したときに/(?<User>[a-zA-Z][a-zA-Z0-9_-]+)/bookmarkと出てきて見づらい
    (/${User}/bookmarkとか書いてあってほしい)

ちなみに, Scala版のインタフェースの場合はキャプチャグループのパターンとその名前を別々に指定するので, これはこれでパターンとグループ名が離れてしまってあまりよくないし, 上に挙げた問題もとくに解決しない.

val User = "([a-zA-Z][a-zA-Z0-9_-]+)"
val pathPattern = s"/$User/bookmark".r("User")

val m = pathPattern.findFirstMatchIn(req.pathInfo)
val user = m.map(_.group("User"))
同じ名前のグループを複数回使えない

たとえば, /$User/$Repo/compare/$Sha1...$Sha1みたいなエンドポイントがあったときには以下のようにパターンを定義したくなる.

import java.util.regex.Pattern
val User = "(?<User>[a-zA-Z][a-zA-Z0-9_-]+)"
val Repo = "(?<Repo>[a-zA-Z0-9_-]+)"
val Sha1 =  "(?<Sha1>[0-9a-f]{40})"
val pathPattern = Pattern.compile(s"/$User/$Repo/compare/$Sha1...$Sha1")

しかしこれはできない. パターンをコンパイルした時点で以下のようなエラーが出て怒られる.

java.util.regex.PatternSyntaxException: Named capturing group <Sha1> is already defined near index 96

ちなみにScala版ではエラーは出ないけれど最後のものしかキャプチャされない.

val User = "([a-zA-Z][a-zA-Z0-9_-]+)"
val Repo = "([a-zA-Z0-9_-]+)"
val Sha1 =  "([0-9a-f]{40})"
val pathPattern = s"/$User/$Repo/compare/$Sha1...$Sha1".r("User", "Repo", "Sha1", "Sha1")

val m = pathPattern.findFirstMatchIn("/tarao/namedcap-scala/compare/da22f64f3cfaec0a2394b05810025e78ba183cdc...1a3336b05d595674d84794fea77b6beb8c680318")
m.map(_.group("Sha1")) // Some(1a3336b05d595674d84794fea77b6beb8c680318)

このようなことがしたければ, 以下のように, 同じパターンでも出現ごとにグループ名を変えるしかない.

import java.util.regex.Pattern
val User = "(?<User>[a-zA-Z][a-zA-Z0-9_-]+)"
val Repo = "(?<Repo>[a-zA-Z0-9_-]+)"
val Sha1 =  "[0-9a-f]{40}"
val Sha1A = s"(?<Sha1A>$Sha1)"
val Sha1B = s"(?<Sha1B>$Sha1)"
val pathPattern = Pattern.compile(s"/$User/$Repo/compare/$Sha1A...$Sha1B")

うーん, きびしい. パス中のパラメータも出現位置が異なるだけでクエリパラメータと同じだと思えば*1同じ名前のパラメータが複数あっても不思議ではないのだし, 無意味に制限されているのはつらい.

その他べんりにやりたいこと

マッチした文字列中のキャプチャされた部分を置き換えたい

正規表現にマッチした全体を置き換えるのではなく, キャプチャされた部分だけ置き換えたい. キャプチャグループの外のパターンにマッチした部分は元のままにしておきたい.

どういうときにこれが欲しいかというと, たとえばキャプチャされた部分になんらかのハイライトを施した文字列を作りたい場合. 具体的なケースとしては, Webサーバのルーティング処理でパターンマッチさせるとして, パスのパターンとしてはエンドポイントを区別できる程度の雑なものにしておいて, キャプチャされた部分がパラメータとして有効かどうかは(クエリパラメータと同様に)別途バリデーションするという場合に, バリデーションエラーをハイライトしたいことがある.

たとえば, /entry/ftp://example.com/という文字列を/entry/(?<Url>.+)というパターンにマッチさせて, キャプチャしたUrlを受け入れるかどうか精査する. もしエラーになったらUrlの部分が悪かったということを表現したいので, たとえば/entry/<strong>ftp://example.com/</strong>というHTML文字列を作りたい.

これはべつにキャプチャグループが名前つきかどうかは関係なくて, 正規表現一般の話と言えるけれど, こういうことをやっているのはそれほど見かけない気がするし, 簡単にやれるようなインタフェースは用意されてなさそうだった.

どう解決されたか

コード中の識別子とキャプチャグループ名の一致

今回作った名前つきキャプチャのライブラリでは, 最初の例は以下のようになる.

import com.github.tarao.namedcap.Group
import com.github.tarao.namedcap.Implicits._

case object User extends Group("[a-zA-Z][a-zA-Z0-9_-]+")
val pathPattern = pattern"/$User/bookmark"

val m = pathPattern(req.pathInfo)
val user = m.get(User.name)

まず, 名前つきキャプチャグループを表すオブジェクトをcase objectで定義する*2. こうすると, Scala上の識別子Userとキャプチャグループ名が自動的に一致する(case objectではtoStringの結果がそのまま識別子名と同じものになることを利用).

パターンオブジェクトはpattern補間子で書くとキャプチャグループを埋め込むことができる. このパターンオブジェクト内ではキャプチャグループの名前が認識されているので, pathPattern.toStringの結果は/${User}/bookmarkとなり, 煩雑な詳細パターンの代わりにグループ名で表現したものが得られる.

パターンマッチはパターンオブジェクトのapply()で実行できて, 返ってきたMap[]に問い合わせるとキャプチャされた値が得られる. このときm.get("User")としても構わないけれど, m.get(User.name)と書いておいた方がUserの部分の書き間違いをコンパイルエラーとして検出できてより安心できる.

インデックスによるキャプチャグループの上に実装

同名グループを複数回利用する場合は以下のようにできる.

import com.github.tarao.namedcap.Group
import com.github.tarao.namedcap.Implicits._

case object User extends Group("[a-zA-Z][a-zA-Z0-9_-]+")
case object Repo extends Group("[a-zA-Z0-9_-]+")
case object Sha1 extends Group("[0-9a-f]{40}")
val pathPattern = pattern"/$User/$Repo/compare/$Sha1...$Sha1"

val m = pathPattern(req.pathInfo)
val Seq(from, to) = m.getAll(Sha1.name)

パターンマッチの結果は実際には独自のMultiMapになっていて, 通常のMap[]として使うとキャプチャした値のうち最初のものを返し, getAll()したときは同じグループ名でキャプチャされた複数の値をすべて(出現順に)返すようになっている.

Scala版のインタフェースでも, 内部的にはインデックスによるキャプチャグループを使っていて, そこに名前を対応させて返しているだけらしい. 同じ発想で内部的にはインデックスで扱っていて, 名前と対応づけた結果をMultiMapにしているにすぎない.

マッチした文字列中のキャプチャされた部分を置き換え

パターンオブジェクトにmapGroupsIn()というメソッドを用意して, それで可能にした.

import com.github.tarao.namedcap.Group
import com.github.tarao.namedcap.Implicits._

case object Url extends Group(".+")
val pathPattern = pattern"/entry/$Url"
pathPattern.mapGroupsIn("/entry/ftp://example.com/") { (g, s) =>
  s"""<strong data-pattern-id="${g.name}">$s</strong>"""
}
// /entry/<strong data-pattern-id="Url">ftp://example.com/</strong>

これはべつに名前つきキャプチャグループによらず, インデックスによるキャプチャグループでもできることで, 実際インデックスによるキャプチャグループのためのメソッドを定義して利用している.

キャプチャグループには文字列のどこからどこまでにマッチしたかという情報があるので, それを使ってマッチし文字列全体をうまく切り出してくればよいだけなので実はそんなに難しくない(のでなんかこういうの既にありそうなんだけどなぁ). キャプチャグループが入れ子になっている場合も考慮しなければならない点は少しトリッキーではあるものの, やればできる.

感想

実はこれらすべての問題を考慮して独自実装していたわけではなく, 主に1つ目の問題があるために独自のやり方を進めていた. このユースケース特有の問題というか, 設計方針の問題でもあるように感じていたので, 作ったライブラリはしばらくプロジェクトのソースコードに同梱してクローズドな運用をしていた.

そもそも名前つきキャプチャグループを標準的なやり方ではどう扱うのか調べてみたらJavaの正規表現まわりにいろいろイケてないところがあるという話を見つけて, 2つ目の問題が解決されるのは有益と感じたので公開することにした.

Java文化を遠巻きに見ていた頃は, 標準ライブラリや事実上の標準となっているライブラリはちゃんとしててまともそう, という印象だったけれど, 意外とそうでもないということが最近実感できるようになってきた. 他の言語では当たり前にあるようなべんりに使えるやつも案外なかったりしてつらい. Javaはともかく, ScalaまわりはLLっぽい人たちも多そうだし, こういうところは積極的によくしていきたい.

とはいえ, 今回のライブラリもそうだけど, 小さい部品としてのライブラリを公開するのもあまりJava/Scala文化っぽくないように感じられる. もちろん, 依存関係地獄になりたくないからあまり有象無象の小さなライブラリに依存したくないというのもわかる. でもそれはビルドツールの問題だとおもうのでなんとかなってほしい. あとは個人で作った小さなライブラリだと, 名前空間がいかにも個人用っぽくて, 人を集めてドメインをとって大きく作っているものと比べてデファクトスタンダード感を出しづらい, というのもある気がする.

とにかく文化のせいにするのももったいないので, あえて空気は読まずに, べんりそうなものはどんどん公開していこう, とおもっている.

*1:実際, 多くのWebフレームワークではパラメータを参照するときのインタフェースは共通だったりする

*2:いちおうcase objectを使わない定義方法もあるけれど, 今回の趣旨には合わないので割愛