2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ScalaAdvent Calendar 2016

Day 13

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

Last updated at Posted at 2016-12-12

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

  • 複数個の入力を受け付ける 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]  ^

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

終わり

備考

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?