sbt 1.x からのマイグレーション

build.sbt DSL の Scala 3.x への移行

念の為書いておくと、sbt 1.x 系 2.x 系のどちらを使ってもScala 2.x および Scala 3.x 両方のプログラムをビルドすることが可能だ。ただし、build.sbt DSL を裏付ける Scala は sbt のバージョンによって決定される。sbt 2.0 では、Scala 3.7.x 系に移行する。

そのため、sbt 2.x 用のカスタムタスクや sbt プラグインを実装する場合、それは Scala 3.x を用いて行われることとなる。Scala 3.x に関する詳細はScala 3.x incompatibility tableScala 2 with -Xsource:3 なども参照。

// This works on Scala 2.12.20 under -Xsource:3
import sbt.{ given, * }

Import given

Scala 2.x と 3.x の違いの 1つとして、型クラスのインスタンスの言語スコープへの取り込み方の違いが挙げられる。Scala 2.x では import FooCodec._ と書いたが、Scala 3 は、import FooCodec.given と書く。

// 以下は、sbt 1.x と 2.x の両方で動作する
import sbt.librarymanagement.LibraryManagementCodec.{ given, * }

後置記法の回避

特に ModuleID 関連で、 sbt 0.13 や 1.x のコード例で後置記法を見ることは珍しく無かった:

// BAD
libraryDependencies +=
  "com.github.sbt" % "junit-interface" % "0.13.2" withSources() withJavadoc()

これは、sbt 2.x では起動に失敗する:

-- Error: /private/tmp/foo/build.sbt:9:61 --------------------------------------
9 |  "com.github.sbt" % "junit-interface" % "0.13.2" withSources() withJavadoc()
  |                                                             ^^
  |can't supply unit value with infix notation because nullary method withSources
   in class ModuleIDExtra: (): sbt.librarymanagement.ModuleID takes no arguments;
   use dotted invocation instead: (...).withSources()

これを修正するには、普通の (ピリオドを使った) 関数呼び出しの記法で書く:

// GOOD
libraryDependencies +=
  ("com.github.sbt" % "junit-interface" % "0.13.2").withSources().withJavadoc()

ベア・セッティングの変更点

version := "0.1.0"
scalaVersion := "3.7.3"

settings(...) を介さず、上の例のように build.sbt に直書きされたセッティングをベア・セッティング (bare settings) と呼ぶ。

Warning

sbt 1.x において、ベア・セッティングはルートプロジェクトにのみ適用されるセッティングだった。sbt 2.x においては、build.sbt のベア・セッティングは全サブプロジェクトに注入されるコモン・セッティングとして解釈される。

name := "root"         // 全サブプロジェクトの名前が root になる!
publish / skip := true // 全サブプロジェクトがスキップされる!

ルートプロジェクトにのみセッティングを適用させるためには、マルチ・プロジェクトなビルドを定義するか、セッティングを LocalRootProject にスコープ付けさせる:

LocalRootProject / name := "root"
LocalRootProject / publish / skip := true

ThisBuild のマイグレーション

sbt 2.x においては、ベア・セッティングが ThisBuild にスコープ付けされる場面は無くなったはずだ。コモン・セッティングの ThisBuild に対する利点として委譲の振る舞いが、分かりやすくなるということがある。これらのセッティングはプラグインのセッティングと、settings(...) の間に入るため、 Compile / scalacOptions といったセッティングを定義するのに使える。 ThisBuild ではそれは不可能だった。

キャッシュ・タスクへのマイグレーション

キャッシュ化のオプトアウトを含めキャッシュ・タスク参照。

IntegrationTest の廃止

IntegrationTest コンフィギュレーションの廃止に対応するためには、別のサブプロジェクトを定義して、普通のテストとして実装するのが推奨される。

スラッシュ構文へのマイグレーション

sbt 1.x は sbt 0.13 スタイル構文とスラッシュ構文の両方をサポートしたきた。2.x で sbt 0.13 構文のサポートが無くなるので、sbt シェルと build.sbt の両方で統一スラッシュ構文を使う:

<project-id> / Config / intask / key

具体的には、test:compile という表記はシェルからは使えなくなる。代わりに Test/compile と書く。build.sbt ファイルの半自動的なマイグレーションには統一スラッシュ構文のための syntactic Scalafix rule 参照。

scalafix --rules=https://gist.githubusercontent.com/eed3si9n/57e83f5330592d968ce49f0d5030d4d5/raw/7f576f16a90e432baa49911c9a66204c354947bb/Sbt0_13BuildSyntax.scala *.sbt project/*.scala

sbt プラグインのクロスビルド

sbt 2.x では、sbt プラグインを Scala 3.x と 2.12.x に対してクロスビルドすると自動的に sbt 1.x 向けと sbt 2.x 向けにクロスビルドするようになっている。

// sbt 2.x を使った場合
lazy val plugin = (projectMatrix in file("plugin"))
  .enablePlugins(SbtPlugin)
  .settings(
    name := "sbt-vimquit",
  )
  .jvmPlatform(scalaVersions = Seq("3.6.2", "2.12.20"))

projectMatrix を使った場合、プラグインを plugin/ のようなサブディレクトリに移動させる必要がある。そうしないと、デフォルトのルートプロジェクトも src/ を拾ってしまうからだ。

sbt 1.x を使った sbt プラグインのクロスビルド

sbt 1.x を使ってクロスビルドする場合 sbt 1.10.2 以降を使う必要がある。

// sbt 1.x を使った場合
lazy val scala212 = "2.12.20"
lazy val scala3 = "3.6.2"
ThisBuild / crossScalaVersions := Seq(scala212, scala3)

lazy val plugin = (project in file("plugin"))
  .enablePlugins(SbtPlugin)
  .settings(
    name := "sbt-vimquit",
    (pluginCrossBuild / sbtVersion) := {
      scalaBinaryVersion.value match {
        case "2.12" => "1.5.8"
        case _      => "2.0.0-RC6"
      }
    },
  )

%% の変更点

sbt 2.x において ModuleID%% 演算子は多プラットフォームに対応するようになった。JVM系のサブプロジェクトは、%% は以前同様に Maven リポジトリにおける (_3 のような) Scala バージョンの接尾辞をエンコードする。

%%% 演算子のマイグレーション

Scala.JS や Scala Native の sbt 2.x の対応ができるようになった場合、%% は (_3 のような) Scala バージョンと (_sjs1 その他の) プラットフォーム接尾辞をエンコードできるようになっている。そのため、%%%%% に置き換えれるようになるはずだ:

libraryDependencies += "org.scala-js" %% "scalajs-dom" % "2.8.0"

JVM ライブラリが必要な場合は .platform(Platform.jvm) を使う。

target に関する変更

sbt 2.x では、target ディレクトリが単一の target/ ディレクトリに統一され、それぞれのサブプロジェクトがプラットフォーム、Scala バージョン、id をエンコードしたサブディレクトリを作る。scripted test でこの変更点を吸収するために、existsabsentdelete のコマンドは glob式 ** および || をサポートする。

# before
$ absent target/out/jvm/scala-3.3.1/clean-managed/src_managed/foo.txt
$ exists target/out/jvm/scala-3.3.1/clean-managed/src_managed/bar.txt

# after
$ absent target/**/src_managed/foo.txt
$ exists target/**/src_managed/bar.txt

# どちらでも ok
$ exists target/**/proj/src_managed/bar.txt || proj/target/**/src_managed/bar.txt

PluginCompat 技法

同じ *.scala ソースから sbt 1.x と 2.x 向けのプラグインを作るには「詰め木」的なコードとして PluginCompat という名前のオブジェクトをsrc/main/scala-2.12/src/main/scala-3/ の両方に入れて違いを吸収することができる。

Classpath 型のマイグレーション

sbt 2.x からは Classpath 型がSeq[Attributed[xsbti.HashedVirtualFileRef]] 型のエイリアスとなった。以下は、sbt 1.x と 2.x の両方のクラスパスに対応するための詰め木だ。

// src/main/scala-3/PluginCompat.scala

package sbtfoo

import java.nio.file.{ Path => NioPath }
import sbt.*
import xsbti.{ FileConverter, HashedVirtualFileRef, VirtualFile }

private[sbtfoo] object PluginCompat:
  type FileRef = HashedVirtualFileRef
  type Out = VirtualFile

  def toNioPath(a: Attributed[HashedVirtualFileRef])(using conv: FileConverter): NioPath =
    conv.toPath(a.data)
  inline def toFile(a: Attributed[HashedVirtualFileRef])(using conv: FileConverter): File =
    toNioPath(a).toFile()
  def toNioPaths(cp: Seq[Attributed[HashedVirtualFileRef]])(using conv: FileConverter): Vector[NioPath] =
    cp.map(toNioPath).toVector
  inline def toFiles(cp: Seq[Attributed[HashedVirtualFileRef]])(using conv: FileConverter): Vector[File] =
    toNioPaths(cp).map(_.toFile())
end PluginCompat

これが sbt 1.x 用だ:

// src/main/scala-2.12/PluginCompat.scala

package sbtfoo

import sbt.*

private[sbtfoo] object PluginCompat {
  type FileRef = java.io.File
  type Out = java.io.File

  def toNioPath(a: Attributed[File])(implicit conv: FileConverter): NioPath =
    a.data.toPath()
  def toFile(a: Attributed[File])(implicit conv: FileConverter): File =
    a.data
  def toNioPaths(cp: Seq[Attributed[File]])(implicit conv: FileConverter): Vector[NioPath] =
    cp.map(_.data.toPath()).toVector
  def toFiles(cp: Seq[Attributed[File]])(implicit conv: FileConverter): Vector[File] =
    cp.map(_.data).toVector

  // This adds `Def.uncached(...)`
  implicit class DefOp(singleton: Def.type) {
    def uncached[A1](a: A1): A1 = a
  }
}

これで PluginCompat.* を import して toNioPaths(...) などを使って sbt 1.x と 2.x の違いを吸収できる。特にこれは、クラスパス型の違いを吸収して NIO Path ベクトルに変換できることを示している。