search term:

酢鶏、パート6: sbt query

本稿は sbt 2.x 開発に関する記事で、sbt 2.x リモートキャッシュBazel 互換な sbt 2.x リモートキャッシュ酢鶏、パート4パート5 などの続編だ。僕は個人の時間を使って Scala Center や EngFlow の Billy さんなどのボランティアの人と協力して sbt 2.x の作業をしていて、このような記事はプルリクコメントの拡張版で、将来 sbt に実装されるかもしれない機能を共有できたらいいと思っている。

sbt query

sbt 2.0 ideas で出したアイディアとして sbt query というものがある:

sbt query 参照。クエリはサブプロジェクトをふるい分けるのに使うことができる。

sbt-projectmatrix がデフォルトで使われるようになると、サブプロジェクトの数は増加することになる。大規模なコードベースで作業してる人は既に大量のサブプロジェクトを取り扱っているかもしれない。

sbt 2.0 でのタスク集約におけるサブプロジェクトのふるい分け機構をここで提案したい:

sbt .../test
sbt abc.../test
sbt ...@scalaBinaryVersion=3/test

スラッシュ構文にこれを統合させるというアイディアはthe GitHub discussion において Adrien Piquerez さんが提案したものだ。

クエリ構文は何を行うか?

print コマンドと組み合わせた例だ:

sbt:root> print .../name
foo / name
  foo
bar / name
  bar
baz / name
  baz
name
  root

このとき ... は、全サブプロジェクトを意味し、sbt 1.x における print name と同義だ。次に、名前が b から始まるサブプロジェクトだけを表示してみる:

sbt:root> print b.../name
bar / name
  bar
baz / name
  baz

以下は、scalaBinaryVersion3 のサブプロジェクトだ:

sbt:root> print ...@scalaBinaryVersion=3/name
foo / name
  foo
name
  root

scalaBinaryVersion3 のサブプロジェクトのみをテストすることもできる:

sbt:root> ...@scalaBinaryVersion=3/test
[info] Passed: Total 0, Failed 0, Errors 0, Passed 0
[info] No tests to run for foo / Test / testQuick
[info] compiling 1 Scala source to target/out/jvm/scala-3.3.1/root/backend ...
[info] compiling 1 Scala source to target/out/jvm/scala-3.3.1/root/test-backend ...
[info] Passed: Total 0, Failed 0, Errors 0, Passed 0
[info] No tests to run for Test / testQuick
[success] elapsed time: 3 s, cache 58%, 7 disk cache hits, 5 onsite tasks

Scala.JS と Native をサポートできるようになれば、@platfomrm でのフィルターも追加する予定だ:

sbt:root> ...@scalaBinaryVersion=3@platform=sjs1/test

詳細

プルリクは https://github.com/sbt/sbt/pull/7699 だ。

...@ を選んだ理由

例えば *? を使わずに何故あえて ...@ という組み合わせを選んだのか? 理由は、ドット (.) とアットマーク (@) がシェル環境でもクォートしなくても良いからだ。そのため、以下のように書くことができる:

$ sbt --client .../test

一方 * を採用した場合、これをクォートする必要がある。* は一見すると直観的な選択肢にみえるが、実際は落とし穴につながるアフォーダンス的押し扉を作ることになる。以下に具体例で解説しよう:

$ sbt */test

Bash では、*/testtest という名前のファイルやディレクトリを含むディレクトリにマッチするため、ある程度の確率で src/test にマッチしてしまい、以下が実行される:

$ sbt src/test

これは以下のようなエラーメッセージを表示して失敗する:

[error] Expected ID character
[error] Not a valid command: src (similar: set)
[error] Expected <unspecified>
[error] Expected '@scalaBinaryVersion='
[error] Expected project ID
[error] Expected configuration
[error] Expected ':'
[error] Expected key
[error] Not a valid key: src (similar: sources, ps, run)
[error] src/test
[error]    ^

GitHub issue などで、クエリを使おうと思ったが「Not a valid key: src」と表示されると質問する人が、何度も何度も出てくることが今から目に見える。

act コマンド

sbt コア・コンセプトというトークを ScalaMatsuri 2019 で話したが、これを書いている時点ではもう 5年前となる:

トークの初めの方で CommandState => State の遷移関数だと言ったが、これは人間からのインプットや BSP の処理を行う。コマンド内でのタスクは並列処理されるが、コマンドラインの処理は逐次処理であることに注目してほしい。shell コマンドはユーザからのインプットを受け取り、act コマンドはスラッシュ構文で書かれたタスクをタスクエンジンに持ち上げる。ここで集約のロジックも処理されているため、sbt query を行うパーサーを act コマンドに導入すれば集約をフィルターできる。

def scopedKeyAggregatedFilter(
    current: ProjectRef,
    defaultConfigs: Option[ResolvedReference] => Seq[String],
    structure: BuildStructure
): KeysParserFilter =
  for
    optQuery <- queryOption.?
    selected <- scopedKeySelected(
      structure.index.aggregateKeyIndex,
      current,
      defaultConfigs,
      structure.index.keyMap,
      structure.data,
      askProject = optQuery.isEmpty,
    )
  yield Aggregation
    .aggregate(selected.key, selected.mask, structure.extra)
    .map(k => k.asInstanceOf[ScopedKey[Any]] -> optQuery)

private def queryOption: Parser[ProjectQuery] =
  ProjectQuery.parser <~ spacedSlash

ここでは for 内包表記を使って 2つのパーサーを合成している。最初のパーサーは最初のセグメントが sbt query であるかをチェックして、もしそうならば askProject = false を渡し、そうじゃなければ sbt 1.x 同様に foo / Compile / compile といった感じでサブプロジェクトをパースしようとする。

タブ補完

動作した感じだと JLine のタブ補完はパーサーを何度も呼び出すことで実装されているみたいなので、sbt query 自体が何もマッチしなくても Parser は成功させる必要がある。そのため、クエリが空の場合はパーサーレベルで失敗させるのではなく、emptyResult というエラーメッセージを表示するだけの仮のパーサーを立てる。

def emptyResult: Parser[() => State] =
  Parser.success(() => throw MessageOnlyException("query result is empty"))

for
  keys <-
    action match
      case SingleAction => akp
      case ShowAction | PrintAction | MultiAction =>
        for pairs <- rep1sep(akp, token(Space))
        yield pairs.flatten
  keys1 = applyQuery(keys, structure)
  p <-
    if keys.nonEmpty && keys1.isEmpty then emptyResult
    else evaluate(keys1.map(_._1))
yield p

まとめ

https://github.com/sbt/sbt/pull/7699 は、sbt 2.x 系におけるスラッシュ構文を拡張化してサブプロジェクトのふるい分けを行う機構を提案する。最初は、scalaBinaryVersion をパラメータとしてサポートするが、将来的には platform なども取り扱うと思う:

// TBD
sbt:root> ...@scalaBinaryVersion=3@platform=sjs1/test

上記は、Scala.JS 1.x のサブプロジェクト全てを(差分)テストするという意味になる。