diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 1b567de2c..509598a4b 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -694,6 +694,7 @@ object Defaults extends BuildCommon with DefExtra { case vf: VirtualFile => vf }, semanticdbTargetRoot := target.value / (prefix(configuration.value.name) + "meta"), + bestEffortTargetRoot := target.value / (prefix(configuration.value.name) + "betasty"), compileAnalysisTargetRoot := target.value / (prefix(configuration.value.name) + "zinc"), earlyCompileAnalysisTargetRoot := target.value / (prefix( configuration.value.name diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 28a1db68e..9fe3d98f2 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -257,6 +257,9 @@ object Keys { val semanticdbIncludeInJar = settingKey[Boolean]("Include *.semanticdb files in published artifacts").withRank(CSetting) val semanticdbTargetRoot = settingKey[File]("The output directory to produce META-INF/semanticdb/**/*.semanticdb files").withRank(CSetting) val semanticdbOptions = settingKey[Seq[String]]("The Scalac options introduced for SemanticDB").withRank(CSetting) + val bestEffortEnabled = settingKey[Boolean]("Enables Scala 3 Best Effort compilation to produce .betasty files").withRank(CSetting) + val bestEffortTargetRoot = settingKey[File]("The output directory for Best Effort TASTy (.betasty) files").withRank(CSetting) + val bestEffortOptions = settingKey[Seq[String]]("The Scalac options introduced for Best Effort compilation").withRank(CSetting) val clean = taskKey[Unit]("Deletes files produced by the build, such as generated sources, compiled classes, and task caches.").withRank(APlusTask) val console = taskKey[Unit]("Starts the Scala interpreter with the project classes on the classpath.").withRank(APlusTask) diff --git a/main/src/main/scala/sbt/internal/SysProp.scala b/main/src/main/scala/sbt/internal/SysProp.scala index f8246fde9..8033457f6 100644 --- a/main/src/main/scala/sbt/internal/SysProp.scala +++ b/main/src/main/scala/sbt/internal/SysProp.scala @@ -99,6 +99,7 @@ object SysProp: def allowRootDir: Boolean = getOrFalse("sbt.rootdir") def legacyTestReport: Boolean = getOrFalse("sbt.testing.legacyreport") def semanticdb: Boolean = getOrFalse("sbt.semanticdb") + def bestEffort: Boolean = getOrFalse("sbt.bestEffort") def forceServerStart: Boolean = getOrFalse("sbt.server.forcestart") def serverAutoStart: Boolean = getOrTrue("sbt.server.autostart") def remoteCache: Option[URI] = sys.props diff --git a/main/src/main/scala/sbt/plugins/BestEffortPlugin.scala b/main/src/main/scala/sbt/plugins/BestEffortPlugin.scala new file mode 100644 index 000000000..ebd93e948 --- /dev/null +++ b/main/src/main/scala/sbt/plugins/BestEffortPlugin.scala @@ -0,0 +1,107 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package plugins + +import java.io.File + +import Keys.* +import sbt.internal.SysProp +import sbt.librarymanagement.syntax.* +import sbt.librarymanagement.Configuration +import ProjectExtra.inConfig +import sbt.internal.inc.ScalaInstance +import sbt.ScopeFilter.Make.* +import sbt.util.CacheImplicits.given + +/** + * An AutoPlugin that wires Scala 3's Best Effort compilation into sbt. + * + * When `bestEffortEnabled` is true and the project uses Scala 3.5+, + * the plugin appends `-Ybest-effort` and `-Ybest-effort-dir` to scalacOptions + * so that the compiler produces .betasty files even when compilation fails. + * These files are consumed by IDEs such as Metals for improved code intelligence. + */ +object BestEffortPlugin extends AutoPlugin: + override def requires = JvmPlugin + override def trigger = allRequirements + + override lazy val globalSettings: Seq[Def.Setting[?]] = Seq( + bestEffortEnabled := SysProp.bestEffort, + bestEffortOptions := List(), + ) + + override lazy val projectSettings: Seq[Def.Setting[?]] = Seq( + bestEffortOptions ++= { + val enabled = bestEffortEnabled.value + val sv = scalaVersion.value + if enabled && isScala35Plus(sv) then Seq("-Ybest-effort") + else Nil + }, + ) ++ + inConfig(Compile)(configurationSettings) ++ + inConfig(Test)(configurationSettings) + + lazy val configurationSettings: Seq[Def.Setting[?]] = List( + compileIncremental := Def.taskIf { + if !bestEffortEnabled.value then compileIncremental.value + else compileIncAndCacheBestEffortTargetRootTask.value + }.value, + bestEffortOptions --= Def.settingDyn { + val scalaV = scalaVersion.value + val config = configuration.value + Def.setting { + bestEffortTargetRoot.?.all(ancestorConfigs(config)).value.flatten + .flatMap(targetRootOptions(scalaV, _)) + } + }.value, + bestEffortOptions ++= + targetRootOptions(scalaVersion.value, bestEffortTargetRoot.value), + scalacOptions := (Def.taskDyn { + val orig = scalacOptions.value + val config = configuration.value + if bestEffortEnabled.value then + Def.task { + (orig diff bestEffortOptions.?.all(ancestorConfigs(config)).value.flatten.flatten) ++ + bestEffortOptions.value + } + else + Def.task { + orig + } + }).value, + ) + + private[sbt] def isScala35Plus(scalaVersion: String): Boolean = + ScalaInstance.isDotty(scalaVersion) && { + val versionPart = scalaVersion.stripPrefix("3.") + val minor = versionPart.takeWhile(_.isDigit) + minor.nonEmpty && minor.toInt >= 5 + } + + def targetRootOptions(scalaVersion: String, targetRoot: File): Seq[String] = + if isScala35Plus(scalaVersion) then + Seq("-Ybest-effort-dir", targetRoot.toString) + else Nil + + private val compileIncAndCacheBestEffortTargetRootTask = Def.cachedTask { + val prev = compileIncremental.value + val converter = fileConverter.value + val targetRoot = bestEffortTargetRoot.value + + val vfTargetRoot = converter.toVirtualFile(targetRoot.toPath) + Def.declareOutputDirectory(vfTargetRoot) + prev + } + + private def ancestorConfigs(config: Configuration) = + def ancestors(configs: Vector[Configuration]): Vector[Configuration] = + configs ++ configs.flatMap(conf => ancestors(conf.extendsConfigs)) + ScopeFilter(configurations = inConfigurations(ancestors(config.extendsConfigs)*)) +end BestEffortPlugin diff --git a/sbt-app/src/sbt-test/project/best-effort-enabled/build.sbt b/sbt-app/src/sbt-test/project/best-effort-enabled/build.sbt new file mode 100644 index 000000000..24ca0d166 --- /dev/null +++ b/sbt-app/src/sbt-test/project/best-effort-enabled/build.sbt @@ -0,0 +1,2 @@ +scalaVersion := "3.6.4" +bestEffortEnabled := true diff --git a/sbt-app/src/sbt-test/project/best-effort-enabled/changes/ErrorFile.scala b/sbt-app/src/sbt-test/project/best-effort-enabled/changes/ErrorFile.scala new file mode 100644 index 000000000..3de93ee6e --- /dev/null +++ b/sbt-app/src/sbt-test/project/best-effort-enabled/changes/ErrorFile.scala @@ -0,0 +1,8 @@ +object A { + val x: Int = 1 +} +//object B +val error: Int = "not an int" +object C { + val z: Boolean = true +} diff --git a/sbt-app/src/sbt-test/project/best-effort-enabled/changes/FixedFile.scala b/sbt-app/src/sbt-test/project/best-effort-enabled/changes/FixedFile.scala new file mode 100644 index 000000000..b522c0b97 --- /dev/null +++ b/sbt-app/src/sbt-test/project/best-effort-enabled/changes/FixedFile.scala @@ -0,0 +1,8 @@ +//object A +object B { + val y: String = "hello" +} +//error +object C { + val z: Boolean = true +} diff --git a/sbt-app/src/sbt-test/project/best-effort-enabled/src/main/scala/A.scala b/sbt-app/src/sbt-test/project/best-effort-enabled/src/main/scala/A.scala new file mode 100644 index 000000000..f25e0ceca --- /dev/null +++ b/sbt-app/src/sbt-test/project/best-effort-enabled/src/main/scala/A.scala @@ -0,0 +1,6 @@ +object A { + val x: Int = 1 +} +object B { + val y: String = "hello" +} diff --git a/sbt-app/src/sbt-test/project/best-effort-enabled/test b/sbt-app/src/sbt-test/project/best-effort-enabled/test new file mode 100644 index 000000000..b5cb1bf1d --- /dev/null +++ b/sbt-app/src/sbt-test/project/best-effort-enabled/test @@ -0,0 +1,19 @@ +# Step 1: Verify best effort flags appear in scalacOptions for Scala 3.5+ +> show Compile / scalacOptions +> compile + +# Step 2: Introduce a type error — compile should fail but produce .betasty +$ copy-file changes/ErrorFile.scala src/main/scala/A.scala +-> compile + +# Step 3: Fix the error — compile should succeed again +$ copy-file changes/FixedFile.scala src/main/scala/A.scala +> compile + +# Step 4: Verify best effort flags are absent for Scala 2 +> set scalaVersion := "2.13.16" +> show Compile / scalacOptions + +# Step 5: Switch back to Scala 3 and verify flags are restored +> set scalaVersion := "3.6.4" +> show Compile / scalacOptions