Scalaでグループ数安全な正規表現パターンマッチをする(regex-refined)

# はじまり

Scalaでは正規表現パターンマッチがありますよね。

```scala
val date = raw"(\d{4})-(\d{2})-(\d{2})".r

"2004-01-20" match {
  case date(year, month, day) => s"$year was a good year for PLs."
}
```

[Regexのdoc](https://www.scala-lang.org/api/2.12.5/scala/util/matching/Regex.html)にある例です。でもRegexのパターンマッチはunapplySeqなので`date`には `case date(p,q,r,s,t) => ...` のように実際のグループ数と異なる数の引数を与えても実行時までエラーにはなりません。これはいけません、正規表現の文字列リテラルはさっき与えたばっかりなのに、、、コンパイル時に判定してほしくないですか？というわけでそういうパターンマッチを作りました。

# できたもの

```scala
"2018-11-18" match {
    case r"""(\d{4}$year)-(\d{2}$month)-(\d{2}$day)""" => println(s"year = $year, month = $month, day = $day")
    case _ => println("no!")
}
```

rがstring interpolator + extractorです。rに与えた正規表現文字列のグループ数と束縛する変数の数が異なる場合にコンパイルエラーになります。上ではおしゃれな書き方をしていますが、正規表現文字列の部分は変数部分をすべて飛ばして繋げた結果を正規表現として解釈させているので、コメントフラグをonにして`r"""(?x)   (\d{4})-(\d{2})-(\d{2})    # $year, $month, $day"""`のように書くこともできます。

# 実装

- string interpolator
- whitebox macro
- extractor macro
    - name-based extractor

ここら辺を行ったり来たりすると・・・こうじゃ:vulcan:

```scala
package object macros {

  implicit class RegexContext(sc: StringContext) {
    def r: RegexContextExtractor = new RegexContextExtractor(sc)
  }

  class RegexContextExtractor(sc: StringContext) {
    def unapply(s: String): Any = macro MacroImpls.provideExtractorImitator
  }

  private[macros] object MacroImpls {

    def provideExtractorImitator(c: whitebox.Context)(s: c.Tree): c.Tree = {
      import c.universe.{Try => _, _}

      val regex = c.prefix.tree match {
        case q"$_.RegexContext($_.StringContext.apply(..${rawParts: List[Tree]})).r" =>
          rawParts.map{ case q"${const: String}" => const }.mkString
        case _ => c.abort(c.enclosingPosition, s"Invalid use of regex string extractor")
      }
      val count = Try(Pattern.compile(regex).matcher("").groupCount()).recover {
        case e: PatternSyntaxException => c.abort(c.enclosingPosition, e.getMessage)
      }.get
      val imitationMethods = (1 to count)
        .map(i => q"def ${TermName("_" + i)} = matched.get(${Literal(Constant(i - 1))})")
        .toList

      q"""
       new {
         var matched: Option[Array[String]] = None
         def isEmpty = matched.isEmpty
         def get = this
         ..$imitationMethods
         def unapply(s: String) = {
           matched = ${Literal(Constant(regex))}.r.unapplySeq(s).map(_.toArray)
           this
         }
       }.unapply($s)
     """
    }
  }
}

```

細かく説明します。

## RegexContext

```scala
  implicit class RegexContext(sc: StringContext) {
    def r: RegexContextExtractor = new RegexContextExtractor(sc)
  }
```

まずこれはstring interpolatorのimplicit classです。string interpolatorはまず、docにもある通り

```scala
s"Hello, $name" → StringContext("Hello, ", "").s(name)
```

の書き換えが発生します。なのでこのようにimplicit classを定義してimportすると

```scala
r"Hello, $name" →
  new RegexContext(StringContext("Hello, ", "")).r(name)
  ≈ new RegexContextExtractor(StringContext("Hello, ", ""))(name)
```

となって、extractorに引数が適用される形になります。

## RegexContextExtractor

```scala
  class RegexContextExtractor(sc: StringContext) {
    def unapply(s: String): Any = macro MacroImpls.provideExtractorImitator
  }
```

unapplyが定義されているのでこれはextractor(抽出子)です。ここでまず説明すべきなのが、`unapply`の返り値型が`Any`になっていることです。この型の本当の型（？）は右辺の(whitebox)マクロによって与えられます。[whiteboxマクロではマクロ展開後のより具体的な型を採用させることができます。](https://docs.scala-lang.org/ja/overviews/macros/blackbox-whitebox.html)なんやて:rolling_eyes:
というわけで右辺のマクロで正規表現文字列リテラルを取ってきて、グループ数を数えてそれに応じたunapplyっぽい返り値を返せばよいことになります。

## マクロ関数の実装部分

```scala
    def provideExtractorImitator(c: whitebox.Context)(s: c.Tree): c.Tree = {
      import c.universe.{Try => _, _}

      val regex = c.prefix.tree match {
        case q"$_.RegexContext($_.StringContext.apply(..${rawParts: List[Tree]})).r" =>
          rawParts.map{ case q"${const: String}" => const }.mkString
        case _ => c.abort(c.enclosingPosition, s"Invalid use of regex string extractor")
      }
      val count = Try(Pattern.compile(regex).matcher("").groupCount()).recover {
        case e: PatternSyntaxException => c.abort(c.enclosingPosition, e.getMessage)
      }.get
      val imitationMethods = (1 to count)
        .map(i => q"def ${TermName("_" + i)} = matched.get(${Literal(Constant(i - 1))})")
        .toList

      q"""
       new {
         var matched: Option[Array[String]] = None
         def isEmpty = matched.isEmpty
         def get = this
         ..$imitationMethods
         def unapply(s: String) = {
           matched = ${Literal(Constant(regex))}.r.unapplySeq(s).map(_.toArray)
           this
         }
       }.unapply($s)
     """
    }
```

まずマクロの本体の関数は`Tree`を取って`Tree`を返します。なので引数の`s`は抽出子に与えられる引数`(name)`の部分の構文木です。これはq(quasiquote)の中で `new {...}.unapply($s)` として再びunapplyの引数としておきます。
qの中で`new {...}`としているのがunapplyの結果っぽいインスタンスです。ここで何がしたいのかというと、unapplyは通常`Option[(T1,T2,...)]`を返すので、それと同じメソッドを生やしたインスタンスを作っているんです。例えば正規表現文字列のグループ数が３だとすると

```scala
new {
  def isEmpty = ???
  def get = this
  def _1: String = ???
  def _2: String = ???
  def _3: String = ???
}
```

こういうインスタンスがあればもう実質`Option[(String, String, String)]`です。これがなぜできるのかというと、普遍的にそうというわけではなくて[2.11からunapplyの返り値型はメソッド名しかみてない](https://xuwei-k.hatenablog.com/entry/20131005/1380887673)からでした...なんやて:rolling_eyes:あと`get = this`とかして単一のインスタンスにすることで無駄なインスタンス生成を抑えています。
それともう一つ解説すべき部分は正規表現文字列リテラルの取得のところです。`c.prefix.tree`は`s`のプレフィックスの構文木なので `new RegexContext(StringContext("Hello, ", "").r` の`new`より右の部分です。これを`q"$_.RegexContext($_.StringContext.apply(..${rawParts: List[Tree]})).r"`でマッチさせるとrawPartsに`List("Hello, ","")`が束縛されます。クラスの修飾子部分などは省略できないので`$_`として変数束縛させて捨てています。（つまりこのマクロ関数実装はstring interpolatorからの呼び出ししか想定していないわけです。）マクロでリテラル値を取得する場合、大体`c.prefix.tree`を参照するとうまくいきます。
このqによるパターンマッチは普通にTreeのマッチで書くと

```scala
case Select(Apply(_, List(Apply(_, rawParts))), _) => ...
```
のようになります。
ここら辺の実装は会社のSlackで挙げられていた[Playのsird](https://github.com/playframework/playframework/blob/2.6.20/framework/src/play/src/main/scala/play/api/routing/sird/package.scala)を参考にしています。

# おわり

というわけで無事にグループ数安全な正規表現パターンマッチができました。これは自作ライブラリ[regex-refined](https://github.com/henoc/regex-refined)に定義されているので使ってみてください:sunglasses: