Posted at
ScalaDay 13

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

More than 1 year has passed since last update.


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


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

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

めでたしめでたし


終わり

備考