Skip to content

Commit b03ea32

Browse files
Atryclaude
andcommitted
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>
1 parent 2342070 commit b03ea32

16 files changed

Lines changed: 256 additions & 110 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

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: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,25 @@ 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")
1836

1937
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.20" % "test"
2038

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.thoughtworks.sbtApiMappings
2+
3+
import java.io.File
4+
import java.net.URL
5+
6+
import sbt._
7+
import sbtcompat.PluginCompat
8+
9+
/** sbt 1.x specific bindings: only the pieces that genuinely differ from sbt
10+
* 2.x. Everything that sbt2-compat can express uniformly is plain shared code.
11+
*/
12+
object Compat {
13+
14+
/** The value type of the `apiMappings` map. */
15+
type DocUrl = URL
16+
17+
/** Convert a real [[java.io.File]] (e.g. a JDK bootstrap classpath jar) to an
18+
* `apiMappings` key. On sbt 1.x the key is already a `File`.
19+
*/
20+
def fileToDocKey: Def.Initialize[Task[File => PluginCompat.FileRef]] =
21+
Def.task { (file: File) => file }
22+
23+
// On Scala 2.12 a function/partial function cannot be used as a pattern
24+
// directly, so `.extract` adapts it into an extractor object.
25+
26+
final class Extractor[A, B](unlifted: A => Option[B]) {
27+
def unapply(a: A): Option[B] = unlifted(a)
28+
}
29+
30+
implicit final class FunctionExtractOps[A, B](private val f: A => Option[B])
31+
extends AnyVal {
32+
def extract: Extractor[A, B] = new Extractor(f)
33+
}
34+
35+
implicit final class PartialFunctionExtractOps[A, B](
36+
private val pf: PartialFunction[A, B]
37+
) extends AnyVal {
38+
def extract: Extractor[A, B] = new Extractor(pf.lift)
39+
}
40+
41+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.thoughtworks.sbtApiMappings
2+
3+
import java.io.File
4+
import java.net.URI
5+
6+
import sbt._
7+
import sbtcompat.PluginCompat
8+
import xsbti.HashedVirtualFileRef
9+
10+
/** sbt 2.x specific bindings: only the pieces that genuinely differ from sbt
11+
* 1.x. Everything that sbt2-compat can express uniformly is plain shared code.
12+
*/
13+
object Compat:
14+
15+
/** The value type of the `apiMappings` map. */
16+
type DocUrl = URI
17+
18+
/** Convert a real [[java.io.File]] (e.g. a JDK bootstrap classpath jar) to an
19+
* `apiMappings` key.
20+
*
21+
* The bootstrap class loader entries (e.g. the synthetic `/modules/java.base`
22+
* on Java 9+) are not real files, so they cannot go through `fileConverter`
23+
* (which validates that the file exists). Build the reference directly.
24+
*/
25+
def fileToDocKey: Def.Initialize[Task[File => PluginCompat.FileRef]] =
26+
Def.task { (file: File) =>
27+
HashedVirtualFileRef.of(file.toPath.toString, ""): PluginCompat.FileRef
28+
}
29+
30+
// On Scala 3 a PartialFunction is already a valid pattern, so `.extract` turns
31+
// a function (or partial function) into one.
32+
33+
extension [A, B](f: A => Option[B])
34+
def extract: PartialFunction[A, B] = Function.unlift(f)
35+
36+
extension [A, B](pf: PartialFunction[A, B])
37+
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 Compat._
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], Compat.DocUrl]
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 = apiMappingRules.value
53+
val existingMappings = (config / doc / apiMappings).value
54+
(config / 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[Compat.DocUrl](
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 = bootstrapJavadocURL.value
55+
val toDocKey = Compat.fileToDocKey.value
56+
val log = 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 Compat._
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], Compat.DocUrl] = {
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 Compat._
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], Compat.DocUrl] = {
2328
case moduleID.extract(
2429
"com.typesafe.play",
2530
libraryName,

0 commit comments

Comments
 (0)