Skip to content

Commit ff6c502

Browse files
Atryclaude
andauthored
Cross-build for sbt 1.x and sbt 2.x (#209)
* Cross-build for sbt 1.x and sbt 2.x sbt 2.0.0 introduces several breaking API changes for plugins (built with Scala 3): apiMappings is now Map[HashedVirtualFileRef, URI] instead of Map[File, URL], the dependency classpath holds Attributed[HashedVirtualFileRef], the ModuleID is stored as a JSON string under a StringAttributeKey, inTask was removed, and task results are cached by default. This change makes the plugin cross-build for both sbt 1.x (Scala 2.12) and sbt 2.x (Scala 3): - build.sbt: crossScalaVersions = [2.12.20, 3.8.4] with pluginCrossBuild / sbtVersion selecting sbt 1.12.11 / 2.0.0 per Scala binary version. - Most of the sbt 2.x divergence is absorbed by sbt2-compat (PluginCompat: FileRef, moduleIDStr, parseModuleIDStrAttribute, Def.uncached), keeping the rule sources shared. - A small version-specific Compat object (src/main/scala-2.12, src/main/scala-3) holds only what sbt2-compat cannot unify: the api doc URL type (URL vs URI), the synthetic bootstrap classpath key (which cannot go through fileConverter), and the `.extract` adapter (an implicit Extractor on Scala 2.12; a PartialFunction-returning extension on Scala 3, where a PartialFunction is already a valid pattern). - breakOut / inTask removed in favour of cross-version-friendly equivalents. - CI matrix and scripted test assertions updated for both sbt versions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Scope all task-body reads to config / doc after dropping inConfig/inTask Replacing `inConfig(config) { inTask(doc) { ... } }` with explicit `config / doc / key` settings also changed the scope in which the `.value` reads inside the task bodies resolve. Only `apiMappings` and `dependencyClasspath` had been re-scoped; `apiMappingRules`, `bootstrapJavadocURL` and `streams` were left unscoped and resolved in the default/global scope instead of `config / doc`, so a user override such as `Compile / doc / apiMappingRules += ...` would no longer be picked up. Re-scope every read in both task bodies to `config / doc`, matching the original inConfig/inTask behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Reuse the extractor library via a private Compat.Extractor Make `.extract` opt-in and keep it out of `com.thoughtworks`: - `Compat` is now `private[sbtApiMappings]` and exposes a nested `Extractor` member, imported with `import Compat.Extractor._`. This avoids leaking the `.extract` implicit onto every function via `import Compat._`. - On Scala 2.12, `Compat.Extractor` is `val Extractor = com.thoughtworks.Extractor`, i.e. a re-export of the `com.thoughtworks.extractor` library (added back as a Scala-2.12-only dependency). The library is reused as-is; its `sealed` `private[thoughtworks]` traits cannot be wrapped, but a plain `val` alias re-exports the object so its implicits come in through `import Compat.Extractor._`. - On Scala 3 `Compat.Extractor` is an `object` whose `extension` turns a function/partial function into a `PartialFunction` (already a valid pattern on Scala 3). No `com.thoughtworks.Extractor` shim, so the package is not polluted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix scripted test fixtures for sbt 2.x CI on sbt 2.0.0 surfaced two sbt-1-isms in the test fixtures (keep-api-url already passed; only all-libraries and jdk failed): - all-libraries/test used the sbt 0.13 colon command syntax `+compile:doc::apiMappings`, which sbt 2.x rejects ("Expected ';'"). Switch to slash syntax `+Compile / doc / apiMappings`. - jdk/build.sbt read `sLog.value` in its check/fgrep/jgrep tasks. On sbt 2.x a Logger has no `HashWriter`, so reading it as a task input breaks the default task caching ("given evidence sjsonnew.HashWriter[...Logger...] is not found"). Use `println` for the diagnostic output instead — no task input needed. (The `Map[HashedVirtualFileRef, URI]` from apiMappings does have a HashWriter, as the passing keep-api-url test shows, so only the Logger was the problem.) Both changes are also valid on sbt 1.x. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Run only keep-api-url scripted test under sbt 2.x The all-libraries and jdk scripted fixtures use very old Scala versions (2.12.10 / 2.13.1 / 2.13.8) whose sbt 2.x Zinc compiler bridges fail to build, and they assert sbt 1.x-specific behaviour (the scaladoc `target/api` output path and the autoAPIMappings scala-library URL), which differs on sbt 2.x. Until those fixtures are modernised, gate them to sbt 1.x: under sbt 2.x run only keep-api-url (which exercises the full rule pipeline end-to-end), while sbt 1.x keeps running all three. The sbt 2.x code path is also covered by `+compile` / `+Test/compile`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Make all-libraries and jdk scripted tests pass on both sbt versions Modernise the two fixtures so they run under both sbt 1.x and sbt 2.x, and remove the temporary sbt-2.x gating: - Bump their Scala versions (2.12.10 / 2.13.1 / 2.13.8 -> 2.12.20 / 2.13.16) so the sbt 2.x Zinc compiler bridge builds, and scalacheck to 1.18.1. - all-libraries: the scala-library and scalacheck api doc URLs differ by sbt version (autoAPIMappings reads the POM `apiURL` on sbt 1.x — e.g. https://www.scala-lang.org/api/<v>/ and https://www.javadoc.io/doc/<...> — while on sbt 2.x this plugin's own rules provide http://scala-lang.org/files/archive/ api/<v>/ and https://javadoc.io/page/<...>). Assert leniently (host + version) so one test covers both. - jdk: the scaladoc output directory is `target/api` on sbt 1.x but `target/out/jvm/<...>/api` on sbt 2.x, so the hard-coded `$ exists` / `jgrep` path no longer works. Move the file checks into a `checkDoc` task that locates the directory via `Compile / doc / target`. Verified locally (JDK 17): `++2.12.20 test` and `++3.8.4 test` both run all three scripted tests plus the unit test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Rename Compat to Compatibility Use the full word `Compatibility` instead of the abbreviation `Compat` for the plugin's internal version-specific object. (sbt2-compat's `PluginCompat` / `sbtcompat` are unchanged.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Rename Compatibility.DocUrl to Compatibility.URL `URL` reads better than `DocUrl` for the api-doc value type of the `apiMappings` map. The alias resolves to `java.net.URL` on sbt 1.x and `java.net.URI` on sbt 2.x. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2342070 commit ff6c502

19 files changed

Lines changed: 303 additions & 170 deletions

File tree

.github/workflows/scala.yml

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,31 @@ jobs:
1515
strategy:
1616
fail-fast: false
1717
matrix:
18-
scala:
19-
- 2.12.20
20-
distribution:
21-
- temurin
22-
java:
23-
- 8
24-
- 11
25-
- 17
18+
# Scala 2.12 builds the sbt 1.x plugin, Scala 3 builds the sbt 2.x plugin.
19+
# sbt 2.x requires JDK 17 or later, so Scala 3 is not tested on Java 8/11.
20+
include:
21+
- scala: 2.12.20
22+
java: 8
23+
publish: true
24+
- scala: 2.12.20
25+
java: 11
26+
- scala: 2.12.20
27+
java: 17
28+
- scala: 3.8.4
29+
java: 17
30+
publish: true
31+
- scala: 3.8.4
32+
java: 21
2633

2734
steps:
2835
- uses: actions/checkout@v3
2936
with:
3037
fetch-depth: 0 # Need the git history for sbt-dynver to determine the version
31-
- name: Set up JDK 11
38+
- name: Set up JDK
3239
uses: actions/setup-java@v3
3340
with:
3441
java-version: ${{ matrix.java }}
35-
distribution: ${{ matrix.distribution }}
42+
distribution: temurin
3643
- uses: sbt/setup-sbt@v1
3744
- name: Cache SBT
3845
uses: actions/cache@v3
@@ -51,5 +58,5 @@ jobs:
5158
- name: Publish to Maven Central Repository
5259
env:
5360
GITHUB_PERSONAL_ACCESS_TOKEN: ${{secrets.PERSONAL_ACCESS_TOKEN}}
54-
if: ${{ env.GITHUB_PERSONAL_ACCESS_TOKEN != '' && github.event_name != 'pull_request' && matrix.java == 8 }}
61+
if: ${{ env.GITHUB_PERSONAL_ACCESS_TOKEN != '' && github.event_name != 'pull_request' && matrix.publish }}
5562
run: sbt ${{matrix.sbt-args}} ++${{ matrix.scala }} publishSigned sonaRelease

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"files.watcherExclude": {
3-
"**/target": true
3+
"**/target/**": true
44
}
55
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ This plugin resolves the problem.
3535
addSbtPlugin("com.thoughtworks.sbt-api-mappings" % "sbt-api-mappings" % "latest.release")
3636
```
3737

38-
Note that sbt-api-mappings 1.x requires sbt 0.13.x, sbt-api-mappings 2.x requires sbt 1.x.
38+
Note that sbt-api-mappings 1.x requires sbt 0.13.x, sbt-api-mappings 2.x requires sbt 1.x, and sbt-api-mappings 3.x is cross-built for both sbt 1.x and sbt 2.x.
3939

4040
### Step 2: Reload the Sbt configuration:
4141

build.sbt

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,35 @@ startYear := Some(2015)
1414

1515
scalacOptions += "-deprecation"
1616

17-
libraryDependencies += "com.thoughtworks.extractor" %% "extractor" % "2.1.3"
17+
val scala212 = "2.12.20"
18+
19+
val scala3 = "3.8.4"
20+
21+
// Cross-build for both sbt 1.x (Scala 2.12) and sbt 2.x (Scala 3).
22+
crossScalaVersions := Seq(scala212, scala3)
23+
24+
scalaVersion := scala212
25+
26+
pluginCrossBuild / sbtVersion := {
27+
scalaBinaryVersion.value match {
28+
case "2.12" => "1.12.11" // sbt 1.x
29+
case _ => "2.0.0" // sbt 2.x
30+
}
31+
}
32+
33+
// Compatibility layer that exposes a subset of the sbt 2 API using sbt 1 API,
34+
// so most of the plugin sources can be shared across both sbt versions.
35+
addSbtPlugin("com.github.sbt" % "sbt2-compat" % "0.1.0")
36+
37+
// `.extract` pattern support, reused via `Compat.Extractor`. Only published for
38+
// Scala 2.12; on Scala 3 a PartialFunction is already a valid pattern, so the
39+
// Scala 3 `Compat.Extractor` provides an equivalent extension instead.
40+
libraryDependencies ++= {
41+
if (scalaBinaryVersion.value == "2.12")
42+
Seq("com.thoughtworks.extractor" %% "extractor" % "2.1.3")
43+
else
44+
Seq.empty
45+
}
1846

1947
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.20" % "test"
2048

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.thoughtworks.sbtApiMappings
2+
3+
import java.io.File
4+
5+
import sbt._
6+
import sbtcompat.PluginCompat
7+
8+
/** sbt 1.x specific bindings: only the pieces that genuinely differ from sbt
9+
* 2.x. Everything that sbt2-compat can express uniformly is plain shared code.
10+
*/
11+
private[sbtApiMappings] object Compatibility {
12+
13+
/** The value type of the `apiMappings` map. */
14+
type URL = java.net.URL
15+
16+
/** Convert a real [[java.io.File]] (e.g. a JDK bootstrap classpath jar) to an
17+
* `apiMappings` key. On sbt 1.x the key is already a `File`.
18+
*/
19+
def fileToDocKey: Def.Initialize[Task[File => PluginCompat.FileRef]] =
20+
Def.task { (file: File) => file }
21+
22+
/** The `.extract` pattern support, opted into with
23+
* `import Compatibility.Extractor._`. On Scala 2.12 it simply re-exports the
24+
* `com.thoughtworks.extractor` library.
25+
*/
26+
val Extractor = com.thoughtworks.Extractor
27+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.thoughtworks.sbtApiMappings
2+
3+
import java.io.File
4+
5+
import sbt._
6+
import sbtcompat.PluginCompat
7+
import xsbti.HashedVirtualFileRef
8+
9+
/** sbt 2.x specific bindings: only the pieces that genuinely differ from sbt
10+
* 1.x. Everything that sbt2-compat can express uniformly is plain shared code.
11+
*/
12+
private[sbtApiMappings] object Compatibility:
13+
14+
/** The value type of the `apiMappings` map (a `java.net.URI` on sbt 2.x). */
15+
type URL = java.net.URI
16+
17+
/** Convert a real [[java.io.File]] (e.g. a JDK bootstrap classpath jar) to an
18+
* `apiMappings` key.
19+
*
20+
* The bootstrap class loader entries (e.g. the synthetic `/modules/java.base`
21+
* on Java 9+) are not real files, so they cannot go through `fileConverter`
22+
* (which validates that the file exists). Build the reference directly.
23+
*/
24+
def fileToDocKey: Def.Initialize[Task[File => PluginCompat.FileRef]] =
25+
Def.task { (file: File) =>
26+
HashedVirtualFileRef.of(file.toPath.toString, ""): PluginCompat.FileRef
27+
}
28+
29+
/** The `.extract` pattern support, opted into with `import Compatibility.Extractor._`.
30+
* The `com.thoughtworks.extractor` library has no Scala 3 build, but on Scala
31+
* 3 a [[scala.PartialFunction]] is already a valid pattern, so a function (or
32+
* partial function) only needs to be turned into one.
33+
*/
34+
object Extractor:
35+
extension [A, B](f: A => Option[B])
36+
def extract: PartialFunction[A, B] = Function.unlift(f)
37+
extension [A, B](pf: PartialFunction[A, B])
38+
def extract: PartialFunction[A, B] = pf

src/main/scala/com/thoughtworks/sbtApiMappings/ApiMappings.scala

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,19 @@ package com.thoughtworks.sbtApiMappings
1818

1919
import sbt._
2020
import Keys._
21-
import com.thoughtworks.Extractor._
21+
import Compatibility.Extractor._
2222
import sbt.plugins.JvmPlugin
23+
import sbtcompat.PluginCompat
24+
// Brings `Def.uncached`, which sbt2-compat backfills on sbt 1.x (native on 2.x).
25+
import sbtcompat.PluginCompat._
2326

2427
object ApiMappings extends AutoPlugin {
2528

2629
object autoImport {
2730
val apiMappingRules =
28-
SettingKey[PartialFunction[Attributed[File], URL]](
31+
SettingKey[
32+
PartialFunction[Attributed[PluginCompat.FileRef], Compatibility.URL]
33+
](
2934
"api-mapping-rules",
3035
"Rules to create api-mappings"
3136
)
@@ -41,21 +46,18 @@ object ApiMappings extends AutoPlugin {
4146
}
4247

4348
override def projectSettings = Seq(Compile, Test).flatMap { config =>
44-
inConfig(config) {
45-
inTask(doc) {
46-
Seq(
47-
autoAPIMappings := true,
48-
apiMappings ++= {
49-
val rules = apiMappingRules.value
50-
dependencyClasspath.value.collect {
51-
case jar @ rules.extract(url)
52-
if !apiMappings.value.exists(_._1 == jar.data) =>
53-
jar.data -> url
54-
}(collection.breakOut(Map.canBuildFrom))
55-
}
56-
)
49+
Seq(
50+
config / doc / autoAPIMappings := true,
51+
config / doc / apiMappings ++= Def.uncached {
52+
val rules = (config / doc / apiMappingRules).value
53+
val existingMappings = (config / doc / apiMappings).value
54+
(config / doc / dependencyClasspath).value.view.collect {
55+
case jar @ rules.extract(url)
56+
if !existingMappings.exists(_._1 == jar.data) =>
57+
jar.data -> url
58+
}.toMap
5759
}
58-
}
60+
)
5961
}
6062

6163
}

src/main/scala/com/thoughtworks/sbtApiMappings/BootstrapApiMappings.scala

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ package com.thoughtworks.sbtApiMappings
22

33
import java.io.File
44
import java.lang.management.ManagementFactory
5-
import java.net.URL
65

76
import sbt._
87
import sbt.Keys._
98
import sbt.plugins.JvmPlugin
9+
// Brings `Def.uncached`, which sbt2-compat backfills on sbt 1.x (native on 2.x).
10+
import sbtcompat.PluginCompat._
1011

1112
/** API mappings for classpath used by the bootstrap class loader.
1213
* @author
@@ -19,7 +20,7 @@ object BootstrapApiMappings extends AutoPlugin {
1920

2021
object autoImport {
2122
val bootstrapJavadocURL =
22-
SettingKey[URL](
23+
SettingKey[Compatibility.URL](
2324
"bootstrap-javadoc-url",
2425
"Javadoc URL for classpath used by bootstrap class loader"
2526
)
@@ -43,32 +44,31 @@ object BootstrapApiMappings extends AutoPlugin {
4344
}
4445
}
4546

46-
override def globalSettings: Seq[Def.Setting[_]] = Seq(
47+
override def globalSettings = Seq(
4748
bootstrapJavadocURL := defaultBootstrapJavadocUrl
4849
)
4950

5051
override def projectSettings = Seq(Compile, Test).flatMap { config =>
51-
inConfig(config) {
52-
inTask(doc) {
53-
Seq(
54-
apiMappings ++= {
55-
val url = bootstrapJavadocURL.value
56-
val log = streams.value.log
52+
Seq(
53+
config / doc / apiMappings ++= Def.uncached {
54+
val javadocUrl = (config / doc / bootstrapJavadocURL).value
55+
val toDocKey = Compatibility.fileToDocKey.value
56+
val log = (config / doc / streams).value.log
5757

58-
if (!ManagementFactory.getRuntimeMXBean.isBootClassPathSupported) {
59-
// Copied from scala-js/project/Build.scala for Java 9 or later
60-
Map(file("/modules/java.base") -> url)
61-
} else {
62-
ManagementFactory.getRuntimeMXBean.getBootClassPath
63-
.split(File.pathSeparatorChar)
64-
.map { jar =>
65-
new File(jar) -> url
66-
}(collection.breakOut(Map.canBuildFrom))
58+
if (!ManagementFactory.getRuntimeMXBean.isBootClassPathSupported) {
59+
// Copied from scala-js/project/Build.scala for Java 9 or later
60+
Map(toDocKey(file("/modules/java.base")) -> javadocUrl)
61+
} else {
62+
ManagementFactory.getRuntimeMXBean.getBootClassPath
63+
.split(File.pathSeparatorChar)
64+
.view
65+
.map { jar =>
66+
toDocKey(new File(jar)) -> javadocUrl
6767
}
68-
}
69-
)
68+
.toMap
69+
}
7070
}
71-
}
71+
)
7272
}
7373

7474
}

src/main/scala/com/thoughtworks/sbtApiMappings/JavadocIoApiMappingRule.scala

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package com.thoughtworks.sbtApiMappings
22

33
import sbt._
4-
import com.thoughtworks.Extractor._
4+
import Compatibility.Extractor._
5+
import sbtcompat.PluginCompat
56
import sbt.internal.librarymanagement.mavenint.PomExtraDependencyAttributes
67

78
object JavadocIoApiMappingRule extends AutoPlugin {
@@ -13,19 +14,24 @@ object JavadocIoApiMappingRule extends AutoPlugin {
1314
override def trigger = allRequirements
1415

1516
private def nonSbtModuleID
16-
: Attributed[File] => Option[(String, String, String)] = { jar =>
17-
for {
18-
moduleID <- jar.get(Keys.moduleID.key)
19-
if !moduleID.extraAttributes.contains(
20-
PomExtraDependencyAttributes.SbtVersionKey
21-
)
22-
} yield (moduleID.organization, moduleID.name, moduleID.revision)
17+
: Attributed[PluginCompat.FileRef] => Option[(String, String, String)] = {
18+
jar =>
19+
for {
20+
moduleID <- jar
21+
.get(PluginCompat.moduleIDStr)
22+
.map(PluginCompat.parseModuleIDStrAttribute)
23+
if !moduleID.extraAttributes.contains(
24+
PomExtraDependencyAttributes.SbtVersionKey
25+
)
26+
} yield (moduleID.organization, moduleID.name, moduleID.revision)
2327
}
2428

25-
private def javadocIoRule: PartialFunction[Attributed[File], URL] = {
29+
private def javadocIoRule
30+
: PartialFunction[Attributed[PluginCompat.FileRef], Compatibility.URL] = {
2631
case nonSbtModuleID.extract(organization, libraryName, revision) =>
27-
val organizationPath = organization.replace('.', '/')
28-
url(s"https://javadoc.io/page/$organization/$libraryName/$revision/")
32+
url(
33+
s"https://javadoc.io/page/$organization/$libraryName/$revision/"
34+
)
2935
}
3036

3137
override def projectSettings = {

src/main/scala/com/thoughtworks/sbtApiMappings/PlayApiMappingRule.scala

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package com.thoughtworks.sbtApiMappings
22

3-
import com.thoughtworks.Extractor._
43
import sbt._
4+
import Compatibility.Extractor._
5+
import sbtcompat.PluginCompat
56

67
/** @author
78
* 杨博 (Yang Bo) &lt;pop.atry@gmail.com&gt;
@@ -14,12 +15,16 @@ object PlayApiMappingRule extends AutoPlugin {
1415

1516
override def trigger = allRequirements
1617

17-
private def moduleID: Attributed[File] => Option[(String, String, String)] =
18-
_.get(Keys.moduleID.key).map { moduleID =>
19-
(moduleID.organization, moduleID.name, moduleID.revision)
20-
}
18+
private def moduleID
19+
: Attributed[PluginCompat.FileRef] => Option[(String, String, String)] =
20+
_.get(PluginCompat.moduleIDStr)
21+
.map(PluginCompat.parseModuleIDStrAttribute)
22+
.map { moduleID =>
23+
(moduleID.organization, moduleID.name, moduleID.revision)
24+
}
2125

22-
private def playRule: PartialFunction[Attributed[File], URL] = {
26+
private def playRule
27+
: PartialFunction[Attributed[PluginCompat.FileRef], Compatibility.URL] = {
2328
case moduleID.extract(
2429
"com.typesafe.play",
2530
libraryName,

0 commit comments

Comments
 (0)