いい感じの sbt パーサを作る

## この記事を読むと分かること

* 複数個の入力を受け付ける sbt タスクの作り方
* 選択済みの候補が補完から除かれていく sbt パーサの作り方
* 補完の一覧から正規表現で候補を選択できる sbt パーサの作り方

## 導入

まずは最小構成のプロジェクトを用意

```build.sbt
lazy val root = project.settings(HelloSettings.task)
```

タブ補完を試すためのモジュールも定義

```project/HelloSettings.scala
import sbt.Def
import sbt.Def.inputKey
import sbt.Keys.streams

object HelloSettings {
  lazy val hello = inputKey[Unit]("hello, parser")

  def task = hello := {
    println("selected items..")

    val items = Def.setting(sampleParser).parsed
    items foreach println
  }
  private def sampleParser: Parser[Seq[String]] = ???
}
``` 

この `Parser[Seq[String]] = ???` をいい感じに作り上げるのが今回の主旨です

## 公式ドキュメントの読解

若干不親切ながらも、目的のパーサに近いコード ([Dependent parsers](http://www.scala-sbt.org/0.13/docs/Parsing-Input.html#Dependent+parsers)) が申しわけ程度に記載されています

ほぼそのまま抜粋 :

```project/SampleParser.scala
import sbt.complete.DefaultParsers._
import sbt.complete.{FixedSetExamples, Parser}

object SampleParser {
  def select1(items: Iterable[String]) =
    token(Space ~> StringBasic.examples(FixedSetExamples(items)))

  def selectSome(items: Seq[String]): Parser[Seq[String]] = {
    select1(items).flatMap { v ⇒
      val remaining = items filter {
        _ != v
      }
      if (remaining.size == 0)
        success(v :: Nil)
      else
        selectSome(remaining).?.map(v +: _.getOrElse(Seq()))
    }
  }
}
``` 

先の実装を埋めるとこんな感じです

```project/HelloSettings.scala
private def sampleParser =
  SampleParser selectSome Seq(
    "foo1.xml",
    "foo2.xml",
    "foo3.txt",
    "bar1.yml"
  )
```

試してみましょう

```
> hello
bar1.yml   foo1.xml   foo2.xml   foo3.txt   
> hello foo
foo1.xml   foo2.xml   foo3.txt   
> hello foo1.xml foo
foo2.xml   foo3.txt   
> hello foo1.xml foo2.xml
selected items..
foo1.xml
foo2.xml
```

このままでも一応

* 指定した文字列のみが候補に表示されて
* 最初に foo を入力すると foo で始まる候補に絞られて
* foo1.xml を選択すると次からは foo1.xml が候補から除かれる

ので、動作は概ね問題ないようにも見えますが…

```
> hello bazbazbaz
selected items..
bazbazbaz
> hello .*xml
selected items..
.*xml
```

* 一覧にない不正な文字列も許可してしまう
* 複数個の .xml を一度に選択するようなことはできない

という点で残念ながら使い勝手はイマイチです

## 指定した候補のみを許可したい

ここまでで判明していること :

* `flatMap` によって「選択した文字列に応じて次にマッチする文字列が変わる」パーサを実現できる
* `token` を ` | ` でつなげれば複数の候補にマッチする `Parser[String]` を作れる
  * rf. [Controlling tab completion](http://www.scala-sbt.org/0.13/docs/Parsing-Input.html#Controlling+tab+completion)

を押さえてあればあとはシンプルです

```StrictParser.scala
import sbt.complete.DefaultParsers.{Space, failure, token}
import sbt.complete.Parser

object StrictParser {
  def from(items: Seq[String]): Parser[Seq[String]] = {
    new StrictParser(items).parser
  }
}
class StrictParser private (items: Seq[String]) {
  private type Filter = String => Boolean

  def parser: Parser[Seq[String]] = {
    val fixed: Parser[Filter] = {
      val base = items map (token(_)) reduceOption (_ | _)
      base getOrElse failure("none") map (item => _ == item)
    }
    (Space ~> fixed flatMap next) ?? Nil
  }
  private def next(filter: Filter): Parser[Seq[String]] = {
    val (consumed, remains) = items partition filter
    if (consumed.nonEmpty){
      StrictParser from remains map (consumed ++ _)
    } else {
      failure("input not matched")
    }
  }
}
```

使い方はさっきとほとんど同じです

```project/HelloSettings.scala
private def strictParser: Parser[Seq[String]] =
  StrictParser from Seq(
    "foo1.xml",
    "foo2.xml",
    "foo3.txt",
    "bar1.yml"
  )
```

試してみましょう

```
> hello
bar1.yml   foo1.xml   foo2.xml   foo3.txt   
> hello foo
foo1.xml   foo2.xml   foo3.txt   
> hello foo1.xml
bar1.yml   foo2.xml   foo3.txt   
> hello foo1.xml foo
foo2.xml   foo3.txt   
> hello foo1.xml foo2.xml
selected items..
foo1.xml
foo2.xml

> hello foo1.xml foo2.xml bazbazbaz
[error] Expected 'bar1.yml'
[error] hello foo1.xml foo2.xml bazbazbaz
[error]                           ^
```

不正な文字列を無事に弾くことができるようになりました

## 正規表現で候補を選択したい

上記の `StrictParser` に

* 正規表現を受け付けるために `String#matches` による判定を追加
* ただし不正な入力に対しては例外が吐かれるので `Try` で捕捉

という変更を加えるだけで済みます

```ReductiveParser.scala
import sbt.complete.DefaultParsers.{NotSpace, Space, failure, token}
import sbt.complete.Parser
import scala.util.{Failure, Success, Try}

object ReductiveParser {
  def from(items: Seq[String]): Parser[Seq[String]] = {
    new ReductiveParser(items).parser
  }
}
class ReductiveParser private (items: Seq[String]) {
  private type Filter = String => Boolean

  def parser: Parser[Seq[String]] = {
    val fixed: Parser[Filter] = {
      val base = items map (token(_)) reduceOption (_ | _)
      base getOrElse failure("none") map (item => _ == item)
    }
    val manually: Parser[Filter] = {
      val base = NotSpace
      base map (input => _ matches input)
    }
    (Space ~> (fixed | manually) flatMap next) ?? Nil
  }
  private def next(filter: Filter): Parser[Seq[String]] =
    Try(items partition filter) match {
      case Success((consumed, remains)) if consumed.nonEmpty =>
        ReductiveParser from remains map (consumed ++ _)
      case Success(_) =>
        failure("input not matched")
      case Failure(e) =>
        failure(s"invalid input: ${e.getMessage}")
    }
}
```

試してみましょう

```
> hello .*xml
bar1.yml   foo3.txt   
> hello .*xml bar1.yml
selected items..
foo1.xml
foo2.xml
bar1.yml

> hello .*1.* foo
foo2.xml   foo3.txt   
> hello .*1.* foo2.xml
selected items..
foo1.xml
bar1.yml
foo2.xml
```

正規表現によって選択できるようになりました

```
> hello .*conf
[error] input not matched
[error] hello .*conf
[error]             ^

> hello (*)
[error] invalid input: Dangling meta character '*' near index 1
[error] (*)
[error]  ^
```

不正な入力も問題なく検出できています
めでたしめでたし

## 終わり

備考

* 動作確認コードの置き場所 : [github.com - Cliche/scala/sbt-teal](https://github.com/x7c1/Cliche/tree/master/scala/sbt-teal)