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.8.1"

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 ではそれは不可能だった。

Changes to exportJars

exportJars defaults to true, was false. This might break getResource("/") and resource.toURI. Set exportJars := false if this logic is broken in your build, producing NullPointerExceptions and FileSystemNotFoundExceptions. Set exportJars := false in your build if you want to keep the old behavior. The change was introduced by sbt/sbt#7464, see also blog.

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

In sbt 2.x, all tasks are cached by default. To participate in caching, the task result type must provide a given for sjsonnew.JsonFormat. Any task whose result type lacks JsonFormat (e.g. complex objects like ParadoxProcessor, ClassLoader, Seq[PathMapping], or function types) will fail at build load time in sbt 2.

If you don't want to define the given, the easiest way to migrate is to wrap the tasks with Def.uncached(...) so sbt 2 skips caching and always re-executes them:

myTask := Def.uncached {
  // task body returning a non-serializable type
}

When considering caching for a task, watch out for side-effecting tasks. When sbt 2 restores a task result from its disk cache, it returns the cached value without re-executing the task body. Any side effect (e.g. writing files, syncing mappings) is silently skipped. If a task is meant to produce a side effect every time it runs, wrap it in Def.uncached(...) so sbt 2 always re-executes it.

The sbt2-compat plugin provides Def.uncached as a compatibility shim on sbt 1.x (where it is a no-op). See Cached task reference for details, including build-wide and per-task opt-out options.

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-RC9"
      }
    },
  )

%% の変更点

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

In sbt 1.x, target.value resolves to the project root target/ directory. In sbt 2.x, it resolves to target/out/jvm/scala-<ver>/<project-name> instead. Plugins should be aware of this change during migration.

PluginCompat 技法

To use the same *.scala source but target both sbt 1.x and 2.x, we can create a shim, for example an object named PluginCompat in both src/main/scala-2.12/ and src/main/scala-3/. APIs commonly encountered during migrations are abstracted into the sbt2-compat plugin that can be used to avoid creating the shims manually. To use it in your sbt plugin, you can add it to your sbt plugin's build.sbt:

addSbtPlugin("com.github.sbt" % "sbt2-compat" % "<version>")

And import and use the conversion methods in your shared source:

import sbtcompat.PluginCompat._

// Use the conversion methods here

You can read more about sbt2-compat, the PluginCompat pattern and how to use them the following blog article: Migrating sbt plugins to sbt 2 with sbt2-compat plugin.

Classpath 型のマイグレーション

sbt 2.x changed the Classpath type to be an alias of Seq[Attributed[xsbti.HashedVirtualFileRef]] instead of Seq[Attributed[File]]. Any plugin that needs File or Path for I/O (classpath URLs, mappings, sync, validation) must convert these references. With sbt2-compat added and imported as above, use toNioPaths and toFiles, for example:

import sbtcompat.PluginCompat._

myTask := {
  implicit val conv: FileConverter = fileConverter.value
  val paths = toNioPaths((Compile / dependencyClasspath).value)
  val files = toFiles((Compile / dependencyClasspath).value)
  // ...
}

sbt2-compat also provides toNioPath, toFile, toFileRefsMapping, Def.uncached and other convenience methods to be used in the shared sources. See the sbt2-compat README for the up-to-date documentation on the API provided by the plugin.

Defining your own PluginCompat shims

For the APIs broken between sbt 1.x and 2.x that are not covered by sbt2-compat, you can define your own PluginCompat shims by creating separate source files under src/main/scala-2.12/PluginCompat.scala and src/main/scala-3/PluginCompat.scala. For example:

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

package sbtfoo

import sbt._
import Keys._

object PluginCompat {
  def someSharedMethod(): Unit = ...
}
// src/main/scala-3/PluginCompat.scala

package sbtfoo

import sbt._
import Keys._

object PluginCompat {
  def someSharedMethod(): Unit = ...
}

Then use your own PluginCompat shims in your sbt plugin:

import sbtfoo.PluginCompat._

myTask := {
  someSharedMethod()
}

This pattern is compatible with sbt2-compat and can be used alongside it to absorb the differences between sbt 1.x and 2.x.