Skip to content

decouple sbt-scalafix from scalafix lifecycle #482

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .scala-steward.conf
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ updates.pin = [
# JGit 6.x requires Java 11, see https://www.eclipse.org/lists/cross-project-issues-dev/msg18654.html
{ groupId = "org.eclipse.jgit", artifactId = "org.eclipse.jgit", version = "5." },
]

dependencyOverrides = [
{
dependency = { groupId = "ch.epfl.scala", artifactId = "scalafix-interfaces" },
pullRequests = { frequency = "@asap" },
Copy link
Collaborator Author

@bjaglin bjaglin Apr 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes sure the project/plugins.sbt version is up to date, for dogfooding

},
]
1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,6 @@ scriptedParallelInstances := 2
scriptedLaunchOpts ++= Seq(
"-Xmx2048M",
s"-Dplugin.version=${version.value}",
s"-Dscalafix-interfaces.version=${Dependencies.scalafixInterfacesVersion}",
"-Dsbt-scalafix.uselastmodified=true" // the caching scripted relies on sbt-scalafix only checking file attributes, not content
)
9 changes: 7 additions & 2 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ import sbt._

object Dependencies {
val x = List(1) // scalafix:ok
def scalafixVersion: String = "0.14.2"

// keep this as low as possible, to allow bumping sbt-scalafix without scalafix-interfaces
def scalafixInterfacesVersion: String =
"0.14.2+48-9b6e03ac-SNAPSHOT" // scala-steward:off
Copy link
Collaborator Author

@bjaglin bjaglin Apr 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 this must be updated to the next stable version before merging, or at least tagging a release


val all = List(
"ch.epfl.scala" % "scalafix-interfaces" % scalafixInterfacesVersion
exclude ("ch.epfl.scala", "scalafix-properties"),
Copy link
Collaborator Author

@bjaglin bjaglin Apr 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the key change in this PR, leveraging scalacenter/scalafix#2226.

exclude is honored in the POM, and thus will allow a sbt-scalafix-only install to load ScalafixPlugin without linking errors (since classes from interfaces are present) but fail at invocation time (because properties file cannot be found).

"ch.epfl.scala" % "scalafix-interfaces" % scalafixInterfacesVersion % Test,
"org.eclipse.jgit" % "org.eclipse.jgit" % "5.13.3.202401111512-r",
"ch.epfl.scala" % "scalafix-interfaces" % scalafixVersion,
"io.get-coursier" % "interface" % "1.0.28",
"org.scala-lang.modules" %% "scala-collection-compat" % "2.13.0"
)
Expand Down
3 changes: 3 additions & 0 deletions project/plugins.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ Compile / unmanagedSourceDirectories ++= {
)
}
libraryDependencies ++= Dependencies.all

// latest version of interfaces to test compatibility & get most recent behavior for scalafix invocations
libraryDependencies += "ch.epfl.scala" % "scalafix-interfaces" % "0.14.2"
14 changes: 9 additions & 5 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ https://github.com/scalacenter/scalafix/issues

## Nightlies

Our CI publishes a [snapshot release to Sonatype](https://oss.sonatype.org/content/repositories/snapshots/ch/epfl/scala/sbt-scalafix_2.12_1.0/)
on every merge into main. The latest snapshot at the time of the writing can be used via:
Our CI publishes a snapshot release to Sonatype on every merge into main. The latest snapshot(s) of
[the plugin](https://oss.sonatype.org/content/repositories/snapshots/ch/epfl/scala/sbt-scalafix_2.12_1.0/)
and/or [scalafix-interfaces](https://oss.sonatype.org/content/repositories/snapshots/ch/epfl/scala/scalafix-interfaces/)
can be used via:

```diff
// project/plugins.sbt
-addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.34")
+addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.34+5-5dfe5fb6-SNAPSHOT")
+resolvers += Resolver.sonatypeRepo("snapshots")
+resolvers ++= Resolver.sonatypeOssRepos("snapshots")
-addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.2")
+addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.2+17-4ba873d2-SNAPSHOT")
-libraryDependencies += "ch.epfl.scala" % "scalafix-interfaces" % "0.14.2"
+libraryDependencies += "ch.epfl.scala" % "scalafix-interfaces" % "0.14.2+48-9b6e03ac-SNAPSHOT"
```

## Team
Expand Down
7 changes: 5 additions & 2 deletions src/main/scala/scalafix/sbt/BuildInfo.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ object BuildInfo {
props.load(stream)
case None =>
sys.error(
s"failed to load the resource file '$propertiesPath'. To fix this problem, make sure to enable the sbt-scalafix in 'project/plugins.sbt' and validate that the scalafix-interfaces jar is available on the classpath of the sbt build."
s"failed to load the resource file '$propertiesPath'; " +
"to fix this problem, add \"ch.epfl.scala\" % \"scalafix-interfaces\" " +
"to libraryDependencies in 'project/plugins.sbt'"
)
}
props
Expand All @@ -39,7 +41,8 @@ object BuildInfo {
Option(props.getProperty(key)).getOrElse {
sys.error(
s"sbt-scalafix property '$key' missing in $propertiesPath; " +
"to fix this problem, upgrade to the latest version of Scalafix."
"to fix this problem, upgrade to a more recent version of " +
"\"ch.epfl.scala\" % \"scalafix-interfaces\""
)
}
}
217 changes: 60 additions & 157 deletions src/main/scala/scalafix/sbt/ScalafixEnable.scala
Original file line number Diff line number Diff line change
@@ -1,188 +1,91 @@
package scalafix.sbt

import scala.jdk.CollectionConverters.*
import scala.util.*

import sbt.*
import sbt.Keys.*
import sbt.VersionNumber.SemVer

import coursierapi.Repository

import ScalafixPlugin.autoImport.scalafixResolvers
import ScalafixPlugin.autoImport.*

/** Command to automatically enable semanticdb compiler output for shell session
*/
/** Command to automatically prepare the build for scalafix invocations */
object ScalafixEnable {

/** If the provided Scala binary version is supported, return the latest scala
* full version for which the recommended semanticdb-scalac is available
*/
private lazy val recommendedSemanticdbScalacScalaVersion
: PartialFunction[(Long, Long), VersionNumber] = (for {
v <- BuildInfo.supportedScalaVersions
p <- CrossVersion.partialVersion(v).toList
} yield p -> VersionNumber(v)).toMap

/** If the provided Scala binary version is supported, return the latest scala
* full version for which the recommended semanticdb-scalac is available, or
* None if semanticdb support is built-in in the compiler
*/
private lazy val maybeRecommendedSemanticdbScalacScalaVersion
: PartialFunction[(Long, Long), Option[VersionNumber]] =
recommendedSemanticdbScalacScalaVersion.andThen(Some.apply).orElse {
// semanticdb is built-in in the Scala 3 compiler
case (major, _) if major == 3 => None
}
private def latestSemanticdbScalac(
scalaVersion: String,
repositories: Seq[Repository]
): Option[String] = {
val semanticdbScalacModule = coursierapi.Module.parse(
"org.scalameta:::semanticdb-scalac",
coursierapi.ScalaVersion.of(scalaVersion)
)
Option(
coursierapi.Versions.create
.withModule(semanticdbScalacModule)
.withRepositories(repositories*)
.versions()
.getMergedListings
.getRelease
).filter(_.nonEmpty)
}

/** Collect compatible projects across the entire build */
private def collectProjects(extracted: Extracted): Seq[CompatibleProject] =
private def collectProjects(extracted: Extracted): Seq[CompatibleProject] = {
val repositories = (ThisBuild / scalafixResolvers)
.get(extracted.structure.data)
.getOrElse(Seq.empty)
for {
p <- extracted.structure.allProjectRefs
scalaV <- (p / scalaVersion).get(extracted.structure.data).toList
partialVersion <- CrossVersion.partialVersion(scalaV).toList
maybeRecommendedSemanticdbScalacV <-
maybeRecommendedSemanticdbScalacScalaVersion.lift(partialVersion).toList
scalafixResolvers0 <- (p / scalafixResolvers)
.get(extracted.structure.data)
.toList
semanticdbCompilerPlugin0 <- (p / semanticdbCompilerPlugin)
.get(extracted.structure.data)
.toList
} yield CompatibleProject(
p,
VersionNumber(scalaV),
semanticdbCompilerPlugin0,
scalafixResolvers0,
maybeRecommendedSemanticdbScalacV
)
(scalaVersion, projectRefs) <-
extracted.structure.allProjectRefs
.flatMap { projectRef =>
(projectRef / scalaVersion)
.get(extracted.structure.data)
.map(sv => (sv, projectRef))
}
.foldLeft(Map.empty[String, Seq[ProjectRef]]) {
case (acc, (sv, projectRef)) =>
acc.updated(sv, acc.getOrElse(sv, Seq.empty) :+ projectRef)
}
.toSeq
maybeSemanticdbVersion <-
if (scalaVersion.startsWith("2."))
latestSemanticdbScalac(scalaVersion, repositories) match {
case None =>
Seq.empty // don't activate semanticdb
case Some(version) =>
Seq(Some(version)) // activate semanticdb with plugin
}
else Seq(None) // activate semanticdb without plugin
projectRef <- projectRefs
} yield CompatibleProject(projectRef, maybeSemanticdbVersion)
}

private case class CompatibleProject(
ref: ProjectRef,
scalaVersion0: VersionNumber,
semanticdbCompilerPlugin0: ModuleID,
scalafixResolvers0: Seq[Repository],
maybeRecommendedSemanticdbScalacScalaV: Option[VersionNumber]
semanticdbVersion: Option[String]
)

lazy val command: Command = Command.command(
"scalafixEnable",
briefHelp =
"Configure SemanticdbPlugin for scalafix on supported projects.",
detail = """1. set semanticdbEnabled := true
|2. for scala 2.x,
| - set semanticdbCompilerPlugin to the scalameta version tracked by scalafix if available for scalaVersion,
| - otherwise set semanticdbCompilerPlugin to a compatible version available for scalaVersion,
| - otherwise force scalaVersion to the latest version supported by the scalameta version tracked by scalafix.""".stripMargin
detail = """1. set scalafixAllowDynamicFallback := true
|2. set semanticdbEnabled := true
|3. for scala 2.x, set semanticdbVersion to the latest scalameta version""".stripMargin
) { s =>
val extracted = Project.extract(s)
val scalacOptionsSettings = Seq(Compile, Test).flatMap(
inConfig(_)(ScalafixPlugin.relaxScalacOptionsConfigSettings)
)
val dynamicFallbackSetting =
scalafixAllowDynamicFallback := true
val settings = for {
project <- collectProjects(extracted)
enableSemanticdbPlugin <-
project.maybeRecommendedSemanticdbScalacScalaV.toList
.flatMap { recommendedSemanticdbScalacScalaV =>

import scalafix.internal.sbt.Implicits._
val semanticdbScalacModule =
coursierapi.Dependency
.parse(
project.semanticdbCompilerPlugin0.asCoursierCoordinates,
coursierapi.ScalaVersion.of(project.scalaVersion0.toString)
)
.getModule
val recommendedSemanticdbV =
VersionNumber(BuildInfo.scalametaVersion)
val compatibleSemanticdbVs = Try(
coursierapi.Versions.create
.withRepositories(project.scalafixResolvers0*)
.withModule(semanticdbScalacModule)
.versions()
.getMergedListings
.getAvailable
.asScala
.map(VersionNumber.apply)
// don't use snapshots
.filter(_.extras == Nil)
// https://github.com/scalameta/scalameta/blob/main/COMPATIBILITY.md
.filter(SemVer.isCompatible(_, recommendedSemanticdbV))
.toList
)

compatibleSemanticdbVs match {
case Success(Nil) | Failure(_) =>
Seq(
scalaVersion := {
val v = recommendedSemanticdbScalacScalaV.toString
sLog.value.warn(
s"Forcing scalaVersion to $v in project " +
s"${project.ref.project} since no semanticdb-scalac " +
s"version binary-compatible with $recommendedSemanticdbV " +
s"and cross-published for scala " +
s"${project.scalaVersion0.toString} was found - " +
s"consider bumping scala"
)
v
},
semanticdbVersion := recommendedSemanticdbV.toString
)
case Success(available)
if available.contains(recommendedSemanticdbV) =>
Seq(
semanticdbVersion := recommendedSemanticdbV.toString
)
case Success(earliestAvailable :: tail) =>
val safeRecommendedSemanticdbV =
if (recommendedSemanticdbV.toString == "4.13.1.1")
VersionNumber("4.13.1")
else recommendedSemanticdbV

val futureVersion =
SemanticSelector.apply(s">${safeRecommendedSemanticdbV}")

if (earliestAvailable.matchesSemVer(futureVersion)) {
Seq(
semanticdbVersion := {
val v = earliestAvailable.toString
sLog.value.info(
s"Setting semanticdbVersion to $v in project " +
s"${project.ref.project} since the version " +
s"${recommendedSemanticdbV} tracked by scalafix " +
s"${BuildInfo.scalafixVersion} will not be " +
s"published for scala " +
s"${project.scalaVersion0.toString} - " +
s"consider upgrading sbt-scalafix"
)
v
}
)
} else {
val latestAvailable =
tail.lastOption.getOrElse(earliestAvailable)
Seq(
semanticdbVersion := {
val v = latestAvailable.toString
sLog.value.info(
s"Setting semanticdbVersion to $v in project " +
s"${project.ref.project} since the version " +
s"${recommendedSemanticdbV} tracked by scalafix " +
s"${BuildInfo.scalafixVersion} is no longer " +
s"published for scala " +
s"${project.scalaVersion0.toString} - " +
s"consider bumping scala"
)
v
}
)
}
}
} :+ (semanticdbEnabled := true)
settings <-
inScope(ThisScope.copy(project = Select(project.ref)))(
scalacOptionsSettings ++ enableSemanticdbPlugin
)
semanticdbPluginSettings <- (semanticdbEnabled := true) +:
project.semanticdbVersion.map { version =>
semanticdbVersion := version
}.toSeq
settings <- inScope(ThisScope.copy(project = Select(project.ref)))(
scalacOptionsSettings ++ semanticdbPluginSettings :+ dynamicFallbackSetting
)
} yield settings
extracted.appendWithSession(settings, s)
}
Expand Down
18 changes: 16 additions & 2 deletions src/main/scala/scalafix/sbt/ScalafixPlugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,13 @@ object ScalafixPlugin extends AutoPlugin {
"Defaults to a wrapper around `sbt.Logger`."
)

val scalafixSemanticdb: ModuleID =
val scalafixAllowDynamicFallback: SettingKey[Boolean] =
settingKey[Boolean](
"Control whether the latest version of scalafix may be downloaded instead of failing when " +
"scalafix-interfaces is not explicitly provided. Off by default because non-deterministic."
)
Comment on lines +92 to +96
Copy link
Collaborator Author

@bjaglin bjaglin Apr 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was initially a private key used internally by scalafixEnable, but I thought we could make it public for power users, without necessarily advertizing it in the docs


lazy val scalafixSemanticdb: ModuleID =
scalafixSemanticdb(BuildInfo.scalametaVersion)
def scalafixSemanticdb(scalametaVersion: String): ModuleID =
("org.scalameta" % "semanticdb-scalac" % scalametaVersion)
Expand Down Expand Up @@ -263,7 +269,8 @@ object ScalafixPlugin extends AutoPlugin {
"via `semanticdbVersion` does follow the version recommended " +
"for Scalafix, but is not supported for the given Scala " +
s"version ${scalaV}. Please consider upgrading to a more recent version " +
"of sbt-scalafix and/or Scala, or uninstalling sbt-scalafix plugin."
"of \"ch.epfl.scala\" % \"scalafix-interfaces\" and/or Scala, or " +
"uninstalling sbt-scalafix."
throw inc.copy(message = Some(msg))
case _ =>
}
Expand All @@ -287,6 +294,7 @@ object ScalafixPlugin extends AutoPlugin {
scalafixCaching := true,
scalafixResolvers := defaultScalafixResolvers,
scalafixDependencies := Nil,
scalafixAllowDynamicFallback := false,
commands += ScalafixEnable.command,
scalafixInterfaceCache := new BlockingCache,
concurrentRestrictions += Tags.exclusiveGroup(Scalafix),
Expand Down Expand Up @@ -443,6 +451,12 @@ object ScalafixPlugin extends AutoPlugin {
config: ConfigKey
): Def.Initialize[Task[Unit]] = {
val task = Def.taskDyn {
if (!scalafixAllowDynamicFallback.value) {
// force scalafix properties to be loaded, to trigger an actionnable error instead
// of letting the built-in scalafix-interfaces fallback to the latest scalafix version
BuildInfo.scalafixVersion
}

implicit val conv: FileConverter = fileConverter.value

val errorLogger =
Expand Down
2 changes: 2 additions & 0 deletions src/sbt-test/sbt-scalafix/basic/project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
resolvers += Resolver.sonatypeRepo("public")
libraryDependencies += "com.googlecode.java-diff-utils" % "diffutils" % "1.3.0"
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % sys.props("plugin.version"))
libraryDependencies += "ch.epfl.scala" % "scalafix-interfaces" %
sys.props("scalafix-interfaces.version")
2 changes: 2 additions & 0 deletions src/sbt-test/sbt-scalafix/build-lint/project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
resolvers += Resolver.sonatypeRepo("public")
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % sys.props("plugin.version"))
libraryDependencies += "ch.epfl.scala" % "scalafix-interfaces" %
sys.props("scalafix-interfaces.version")
Loading