diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 28a1db68e..207b20ab1 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -257,6 +257,7 @@ 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 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/PluginDiscovery.scala b/main/src/main/scala/sbt/internal/PluginDiscovery.scala index 934a0a53d..af28f39d0 100644 --- a/main/src/main/scala/sbt/internal/PluginDiscovery.scala +++ b/main/src/main/scala/sbt/internal/PluginDiscovery.scala @@ -51,6 +51,7 @@ object PluginDiscovery: "sbt.ScriptedPlugin" -> sbt.ScriptedPlugin, "sbt.plugins.SbtPlugin" -> sbt.plugins.SbtPlugin, "sbt.plugins.SemanticdbPlugin" -> sbt.plugins.SemanticdbPlugin, + "sbt.plugins.BestEffortPlugin" -> sbt.plugins.BestEffortPlugin, "sbt.plugins.JUnitXmlReportPlugin" -> sbt.plugins.JUnitXmlReportPlugin, "sbt.plugins.Giter8TemplatePlugin" -> sbt.plugins.Giter8TemplatePlugin, "sbt.plugins.DependencyTreePlugin" -> sbt.plugins.DependencyTreePlugin, 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..3809b2771 --- /dev/null +++ b/main/src/main/scala/sbt/plugins/BestEffortPlugin.scala @@ -0,0 +1,58 @@ +/* + * 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 Keys.* +import sbt.internal.SysProp +import sbt.librarymanagement.syntax.* +import ProjectExtra.inConfig +import sbt.internal.inc.ScalaInstance + +/** + * An AutoPlugin that wires Scala 3's Best Effort compilation into sbt. + * + * When `bestEffortEnabled` is true and the project uses Scala 3.5+, + * `-Ybest-effort` and `-Ywith-best-effort-tasty` are appended to scalacOptions + * so that the compiler produces .betasty files (to META-INF/best-effort/ inside classes) + * even when compilation fails. These files are consumed by IDEs such as Metals + * for improved code intelligence. + */ +object BestEffortPlugin extends AutoPlugin: + override def requires = SemanticdbPlugin + override def trigger = allRequirements + + private val bestEffortFlags = Seq("-Ybest-effort", "-Ywith-best-effort-tasty") + + override lazy val globalSettings: Seq[Def.Setting[?]] = Seq( + bestEffortEnabled := SysProp.bestEffort, + ) + + override lazy val projectSettings: Seq[Def.Setting[?]] = + inConfig(Compile)(configurationSettings) ++ + inConfig(Test)(configurationSettings) + + lazy val configurationSettings: Seq[Def.Setting[?]] = List( + scalacOptions := { + val orig = scalacOptions.value + val enabled = bestEffortEnabled.value + val sv = scalaVersion.value + if enabled && isScala35Plus(sv) then + (orig.filterNot(bestEffortFlags.contains) ++ bestEffortFlags).distinct + else orig.filterNot(bestEffortFlags.contains) + }, + ) + + 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 + } +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..41a4e69f8 --- /dev/null +++ b/sbt-app/src/sbt-test/project/best-effort-enabled/test @@ -0,0 +1,18 @@ +# Step 1: Compile succeeds with best effort enabled (Scala 3.5+) +> compile + +# Step 2: Introduce a type error — compile should fail +$ 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