diff --git a/lm-core/src/main/scala/sbt/internal/librarymanagement/cross/CrossVersionUtil.scala b/lm-core/src/main/scala/sbt/internal/librarymanagement/cross/CrossVersionUtil.scala index b5ee4473b..c693dcd28 100644 --- a/lm-core/src/main/scala/sbt/internal/librarymanagement/cross/CrossVersionUtil.scala +++ b/lm-core/src/main/scala/sbt/internal/librarymanagement/cross/CrossVersionUtil.scala @@ -31,6 +31,8 @@ object CrossVersionUtil { raw"""$basicVersion((?:-$tagPattern)*)""".r // 0-n word suffixes, with leading dashes private val NonReleaseV_1 = raw"""$basicVersion(-$tagPattern)""".r // 1 word suffix, after a dash private[sbt] val PartialVersion = raw"""($longPattern)\.($longPattern)(?:\..+)?""".r + // Dynamic Scala version patterns like "3-latest.candidate" + private val DynamicScala3V = raw"""($longPattern)-latest\..*""".r private[sbt] def isSbtApiCompatible(v: String): Boolean = sbtApiVersion(v).isDefined @@ -113,9 +115,15 @@ object CrossVersionUtil { } def binaryScalaVersion(full: String): String = - if full.startsWith("2.") then - binaryVersionWithApi(full, TransitionScalaVersion)(scalaApiVersion) // Scala 2 binary version - else binaryScala3Version(full) + full match { + // Handle dynamic Scala 3 version patterns like "3-latest.candidate" + case DynamicScala3V(maj) => maj + case _ if full.startsWith("2.") => + binaryVersionWithApi(full, TransitionScalaVersion)( + scalaApiVersion + ) // Scala 2 binary version + case _ => binaryScala3Version(full) + } /** * Returns the binary version of the Scala, except for @@ -124,14 +132,18 @@ object CrossVersionUtil { * In Scala 3 onwards, it would be the major version. */ def earlyScalaVersion(full: String): String = - if full.startsWith("2.") then - partialVersion(full) match - case Some((major, minor)) => s"$major.$minor" - case None => full - else - partialVersion(full) match - case Some((major, minor)) => major.toString - case None => full + full match { + // Handle dynamic Scala 3 version patterns like "3-latest.candidate" + case DynamicScala3V(maj) => maj + case _ if full.startsWith("2.") => + partialVersion(full) match + case Some((major, minor)) => s"$major.$minor" + case None => full + case _ => + partialVersion(full) match + case Some((major, minor)) => major.toString + case None => full + } def binarySbtVersion(full: String): String = sbtApiVersion(full) match { diff --git a/lm-core/src/main/scala/sbt/librarymanagement/ScalaArtifacts.scala b/lm-core/src/main/scala/sbt/librarymanagement/ScalaArtifacts.scala index abd7eed5d..590c2fde4 100644 --- a/lm-core/src/main/scala/sbt/librarymanagement/ScalaArtifacts.scala +++ b/lm-core/src/main/scala/sbt/librarymanagement/ScalaArtifacts.scala @@ -41,7 +41,8 @@ object ScalaArtifacts { name.startsWith(Scala3ReplPrefix) } - def isScala3(scalaVersion: String): Boolean = scalaVersion.startsWith("3.") + def isScala3(scalaVersion: String): Boolean = + scalaVersion.startsWith("3.") || scalaVersion.startsWith("3-latest.") /** * Returns true for pre-release nightlies intentionally. diff --git a/lm-core/src/test/scala/sbt/librarymanagement/CrossVersionTest.scala b/lm-core/src/test/scala/sbt/librarymanagement/CrossVersionTest.scala index 57c8e2ddc..a310022ee 100644 --- a/lm-core/src/test/scala/sbt/librarymanagement/CrossVersionTest.scala +++ b/lm-core/src/test/scala/sbt/librarymanagement/CrossVersionTest.scala @@ -264,6 +264,13 @@ class CrossVersionTest extends UnitSpec { it should "for 4.0.0-M2 return 4.0.0-M2" in { binaryScalaVersion("4.0.0-M2") shouldBe "4.0.0-M2" } + // Dynamic Scala version patterns + it should "for 3-latest.candidate return 3" in { + binaryScalaVersion("3-latest.candidate") shouldBe "3" + } + it should "for 4-latest.candidate return 4" in { + binaryScalaVersion("4-latest.candidate") shouldBe "4" + } "earlyScalaVersion" should "for 2.9.2 return 2.9" in { assert(earlyScalaVersion("2.9.2") == "2.9") @@ -277,6 +284,13 @@ class CrossVersionTest extends UnitSpec { it should "for 4.0.0-M1 return 4" in { assert(earlyScalaVersion("4.0.0-M1") == "4") } + // Dynamic Scala version patterns + it should "for 3-latest.candidate return 3" in { + assert(earlyScalaVersion("3-latest.candidate") == "3") + } + it should "for 4-latest.candidate return 4" in { + assert(earlyScalaVersion("4-latest.candidate") == "4") + } private def patchVersion(fullVersion: String) = CrossVersion(CrossVersion.patch, fullVersion, "dummy") map (fn => fn("artefact")) diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index fa4bef6aa..32dc1af54 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -734,7 +734,8 @@ object Defaults extends BuildCommon { val hasSbtBridge = ScalaArtifacts.isScala3(sv) || ZincLmUtil.hasScala2SbtBridge(sv) hasSbtBridge && managed })(Def.cachedTask { - val sv = scalaVersion.value + // Use scalaDynVersion to resolve dynamic versions (e.g., "3-latest.candidate" -> "3.8.1-RC1") + val sv = scalaDynVersion.value val conv = fileConverter.value val s = streams.value val t = target.value @@ -774,6 +775,13 @@ object Defaults extends BuildCommon { javacOptions :== Nil, scalacOptions :== Nil, scalaVersion := appConfiguration.value.provider.scalaProvider.version, + derive( + scalaDynVersion := { + val sv = scalaVersion.value + val log = streams.value.log + LibraryManagement.resolveDynamicScalaVersion(sv, log) + } + ), consoleProject := ConsoleProject.consoleProjectTask.value, consoleProject / scalaInstance := { val topLoader = classOf[org.jline.terminal.Terminal].getClassLoader @@ -3119,9 +3127,12 @@ object Classpaths { allExcludeDependencies := excludeDependencies.value, scalaModuleInfo := (scalaModuleInfo or ( Def.setting { + // Resolve dynamic Scala version for scalaModuleInfo + val resolvedScalaVersion = + LibraryManagement.resolveDynamicScalaVersion((update / scalaVersion).value) Option( ScalaModuleInfo( - (update / scalaVersion).value, + resolvedScalaVersion, (update / scalaBinaryVersion).value, Vector.empty, filterImplicit = false, @@ -3385,7 +3396,8 @@ object Classpaths { autoScalaLibrary.value && scalaHome.value.isEmpty && managedScalaInstance.value, sbtPlugin.value, scalaOrganization.value, - scalaVersion.value + // Resolve dynamic Scala version (e.g., "3-latest.candidate" -> "3.8.1-RC1") + LibraryManagement.resolveDynamicScalaVersion(scalaVersion.value) ), // Override the default to handle mixing in the sbtPlugin + scala dependencies. allDependencies := Def.uncached { @@ -3397,7 +3409,8 @@ object Classpaths { if (isPlugin) sbtdeps +: base else base val scalaOrg = scalaOrganization.value - val version = scalaVersion.value + // Resolve dynamic Scala version (e.g., "3-latest.candidate" -> "3.8.1-RC1") + val version = LibraryManagement.resolveDynamicScalaVersion(scalaVersion.value) val extResolvers = externalResolvers.value val allToolDeps = if scalaHome.value.isDefined || scalaModuleInfo.value.isEmpty || !managedScalaInstance.value diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index e486da4ea..c3e47e82f 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -218,6 +218,7 @@ object Keys { val scalaInstance = taskKey[ScalaInstance]("Defines the Scala instance to use for compilation, running, and testing.").withRank(DTask) val scalaOrganization = settingKey[String]("Organization/group ID of the Scala used in the project. Default value is 'org.scala-lang'. This is an advanced setting used for clones of the Scala Language. It should be disregarded in standard use cases.").withRank(CSetting) val scalaVersion = settingKey[String]("The version of Scala used for building.").withRank(APlusSetting) + val scalaDynVersion = taskKey[String]("Resolves dynamic Scala version strings like '3-latest.candidate' to concrete versions.").withRank(DSetting) val scalaBinaryVersion = settingKey[String]("The Scala version substring describing binary compatibility.").withRank(BPlusSetting) val scalaEarlyVersion = settingKey[String]("The Scala version substring describing the binary compatibility, except for prereleases it returns the binary version of the release.").withRank(DSetting) val crossScalaVersions = settingKey[Seq[String]]("The versions of Scala used when cross-building.").withRank(BPlusSetting) diff --git a/main/src/main/scala/sbt/internal/LibraryManagement.scala b/main/src/main/scala/sbt/internal/LibraryManagement.scala index bd250f771..f8de54826 100644 --- a/main/src/main/scala/sbt/internal/LibraryManagement.scala +++ b/main/src/main/scala/sbt/internal/LibraryManagement.scala @@ -440,4 +440,107 @@ private[sbt] object LibraryManagement { def lock(app: xsbti.AppConfiguration): xsbti.GlobalLock = app.provider.scalaProvider.launcher.globalLock + + // Pattern for Scala 3 release candidates: X.Y.Z-RCN (not nightlies) + private val Scala3RCPattern = """^(\d+)\.(\d+)\.(\d+)-RC(\d+)$""".r + + // Cache for the latest Scala 3 RC version lookup (24-hour TTL) + private var cachedScala3RC: Option[(String, Long)] = None + private val scala3RCCacheLock = new Object + private val cacheTtlMillis = 24L * 60 * 60 * 1000 // 24 hours + + /** + * Fetches the latest Scala 3 release candidate version from Maven Central. + * Results are cached for 24 hours. + * + * @param log Logger for debug output + * @return The latest Scala 3 RC version string (e.g., "3.8.1-RC1") + */ + def fetchLatestScala3RC(log: Logger): String = { + scala3RCCacheLock.synchronized { + val now = System.currentTimeMillis() + cachedScala3RC match { + case Some((version, timestamp)) if (now - timestamp) < cacheTtlMillis => + log.debug(s"Using cached Scala 3 RC version: $version") + version + case _ => + log.info("Fetching latest Scala 3 release candidate from Maven Central...") + val version = fetchScala3RCFromMaven(log) + cachedScala3RC = Some((version, now)) + version + } + } + } + + private def fetchScala3RCFromMaven(log: Logger): String = { + import scala.util.Using + import java.net.{ HttpURLConnection, URI } + import scala.xml.XML + + val metadataUrl = + "https://repo1.maven.org/maven2/org/scala-lang/scala3-library_3/maven-metadata.xml" + val uri = new URI(metadataUrl) + val conn = uri.toURL.openConnection().asInstanceOf[HttpURLConnection] + conn.setConnectTimeout(10000) + conn.setReadTimeout(30000) + + try { + val xml = Using.resource(conn.getInputStream) { is => + XML.load(is) + } + val versions = (xml \\ "version").map(_.text) + + // Filter for RC versions (not nightlies) and sort to get the latest + val rcVersions = versions.flatMap { v => + v match { + case Scala3RCPattern(major, minor, patch, rc) => + Some((major.toInt, minor.toInt, patch.toInt, rc.toInt, v)) + case _ => None + } + } + + if (rcVersions.isEmpty) { + sys.error("No Scala 3 release candidates found in Maven Central") + } + + // Sort by version components (major, minor, patch, rc) descending + val latestRC = + rcVersions.sortBy { case (maj, min, pat, rc, _) => (-maj, -min, -pat, -rc) }.head._5 + log.info(s"Latest Scala 3 release candidate: $latestRC") + latestRC + } finally { + conn.disconnect() + } + } + + /** + * Resolves a dynamic Scala version string to a concrete version. + * Currently supports: + * - "3-latest.candidate" - resolves to the latest Scala 3 RC + * + * @param version The version string (may be dynamic or concrete) + * @param log Logger for debug output + * @return The resolved concrete version string + */ + def resolveDynamicScalaVersion(version: String, log: Logger): String = { + version match { + case "3-latest.candidate" => fetchLatestScala3RC(log) + case other => other + } + } + + /** + * Resolves a dynamic Scala version string to a concrete version. + * Uses a silent logger for background resolution. + */ + def resolveDynamicScalaVersion(version: String): String = { + resolveDynamicScalaVersion(version, Logger.Null) + } + + /** + * Checks if a version string is a dynamic version that needs resolution. + */ + def isDynamicScalaVersion(version: String): Boolean = { + version == "3-latest.candidate" + } } diff --git a/sbt-app/src/sbt-test/project/scala-dyn-version/build.sbt b/sbt-app/src/sbt-test/project/scala-dyn-version/build.sbt new file mode 100644 index 000000000..cade63196 --- /dev/null +++ b/sbt-app/src/sbt-test/project/scala-dyn-version/build.sbt @@ -0,0 +1,34 @@ +ThisBuild / scalaVersion := "3-latest.candidate" + +lazy val checkDynVersion = taskKey[Unit]("Check that scalaDynVersion resolves correctly") + +lazy val p1 = project + .settings( + libraryDependencies += "org.typelevel" %% "cats-effect" % "3.6.3" + ) + +lazy val p2 = project + .dependsOn(p1) + .settings( + checkDynVersion := { + val log = streams.value.log + val sv = scalaVersion.value + val dynSv = scalaDynVersion.value + val binSv = scalaBinaryVersion.value + + log.info(s"scalaVersion: $sv") + log.info(s"scalaDynVersion: $dynSv") + log.info(s"scalaBinaryVersion: $binSv") + + // scalaVersion should be the dynamic pattern + assert(sv == "3-latest.candidate", s"Expected scalaVersion '3-latest.candidate', got '$sv'") + + // scalaDynVersion should resolve to a concrete RC version (e.g., "3.8.1-RC1") + assert(dynSv.matches("""\d+\.\d+\.\d+-RC\d+"""), s"Expected scalaDynVersion to be a concrete RC version, got '$dynSv'") + + // scalaBinaryVersion should be "3" + assert(binSv == "3", s"Expected scalaBinaryVersion '3', got '$binSv'") + + log.success("All checks passed!") + } + ) diff --git a/sbt-app/src/sbt-test/project/scala-dyn-version/p1/src/main/scala/Example.scala b/sbt-app/src/sbt-test/project/scala-dyn-version/p1/src/main/scala/Example.scala new file mode 100644 index 000000000..336ef029e --- /dev/null +++ b/sbt-app/src/sbt-test/project/scala-dyn-version/p1/src/main/scala/Example.scala @@ -0,0 +1,6 @@ +package example + +import cats.effect.IO + +object Example: + def hello: IO[String] = IO.pure("Hello from Scala 3 RC!") diff --git a/sbt-app/src/sbt-test/project/scala-dyn-version/test b/sbt-app/src/sbt-test/project/scala-dyn-version/test new file mode 100644 index 000000000..05f6351af --- /dev/null +++ b/sbt-app/src/sbt-test/project/scala-dyn-version/test @@ -0,0 +1,2 @@ +> p2/checkDynVersion +> p1/compile