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

  • 3
    いいね
  • 0
    コメント

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

  • 複数個の入力を受け付ける 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) が申しわけ程度に記載されています

ほぼそのまま抜粋 :

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] を作れる

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

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]  ^

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

終わり

備考

この投稿は Scala Advent Calendar 201613日目の記事です。