From da0fa5388eb204222c617c282fab3879cff5e077 Mon Sep 17 00:00:00 2001 From: Martin Duhem Date: Mon, 11 Aug 2025 08:59:20 +0200 Subject: [PATCH] Support annotated definitions in build.sbt Previously, sbt would fail to load build.sbt files when they included annotated definitions because the parser would not correctly recognize those definitions as such. In sbt 1.x, this used to be fine, because there was little use for annotations in build.sbt. Starting with sbt 2, whether caching should be enabled for a task key can be controlled via annotations on the task key definition. Because these can appear in build.sbt, support for annotations in build.sbt becomes more important. This patch enhances parsing of build.sbt by keeping the parsed trees around so that the AST can be used to determine whether a given line represents a setting or a definition, rather than relying on string matching. --- .../sbt/internal/EvaluateConfigurations.scala | 37 +++++++++++++------ .../internal/parser/SplitExpressions.scala | 3 +- .../parser/SplitExpressionsBehavior.scala | 13 ++++++- .../build-sbt-annotation/build.sbt | 23 ++++++++++++ .../project-load/build-sbt-annotation/test | 6 +++ 5 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 sbt-app/src/sbt-test/project-load/build-sbt-annotation/build.sbt create mode 100644 sbt-app/src/sbt-test/project-load/build-sbt-annotation/test diff --git a/buildfile/src/main/scala/sbt/internal/EvaluateConfigurations.scala b/buildfile/src/main/scala/sbt/internal/EvaluateConfigurations.scala index a8e03feb2..84da1235e 100644 --- a/buildfile/src/main/scala/sbt/internal/EvaluateConfigurations.scala +++ b/buildfile/src/main/scala/sbt/internal/EvaluateConfigurations.scala @@ -22,6 +22,7 @@ import scala.jdk.CollectionConverters.* import xsbti.PathBasedFile import xsbti.VirtualFile import xsbti.VirtualFileRef +import dotty.tools.dotc.ast.untpd.{ Annotated, ValOrDefDef, Tree } /** * This file is responsible for compiling the .sbt files used to configure sbt builds. @@ -97,12 +98,13 @@ private[sbt] object EvaluateConfigurations { builtinImports: Seq[String], offset: Int ): ParsedFile = { + def loseTree(l: (String, Tree, LineRange)): (String, LineRange) = (l._1, l._3) val (importStatements, settingsAndDefinitions) = splitExpressions(file, lines) val allImports = builtinImports.map(s => (s, -1)) ++ addOffset(offset, importStatements) val (definitions, settings) = splitSettingsDefinitions( addOffsetToRange(offset, settingsAndDefinitions) ) - new ParsedFile(allImports, definitions, settings) + new ParsedFile(allImports, definitions.map(loseTree), settings.map(loseTree)) } /** @@ -194,11 +196,14 @@ private[sbt] object EvaluateConfigurations { private def resolveBase(f: File, p: Project) = p.copy(base = IO.resolve(f, p.base)) - def addOffset(offset: Int, lines: Seq[(String, Int)]): Seq[(String, Int)] = + private def addOffset(offset: Int, lines: Seq[(String, Int)]): Seq[(String, Int)] = lines.map { (s, i) => (s, i + offset) } - def addOffsetToRange(offset: Int, ranges: Seq[(String, LineRange)]): Seq[(String, LineRange)] = - ranges.map { (s, r) => (s, r.shift(offset)) } + private def addOffsetToRange( + offset: Int, + ranges: Seq[(String, Tree, LineRange)] + ): Seq[(String, Tree, LineRange)] = + ranges.map { (s, t, r) => (s, t, r.shift(offset)) } /** * The name of the class we cast DSL "setting" (vs. definition) lines to. @@ -286,20 +291,28 @@ private[sbt] object EvaluateConfigurations { private[sbt] def splitExpressions( file: VirtualFileRef, lines: Seq[String] - ): (Seq[(String, Int)], Seq[(String, LineRange)]) = + ): (Seq[(String, Int)], Seq[(String, Tree, LineRange)]) = val split = SbtParser(file, lines) // TODO - Look at pulling the parsed expression trees from the SbtParser and stitch them back into a different // scala compiler rather than re-parsing. - (split.imports, split.settings) + ( + split.imports, + split.settings.zip(split.settingsTrees).map { case ((s, r), (_, t)) => + (s, t, r) + } + ) private def splitSettingsDefinitions( - lines: Seq[(String, LineRange)] - ): (Seq[(String, LineRange)], Seq[(String, LineRange)]) = - lines partition { case (line, _) => isDefinition(line) } + lines: Seq[(String, Tree, LineRange)] + ): (Seq[(String, Tree, LineRange)], Seq[(String, Tree, LineRange)]) = + lines partition { case (_, tree, _) => isDefinition(tree) } - private def isDefinition(line: String): Boolean = { - val trimmed = line.trim - DefinitionKeywords.exists(trimmed.startsWith(_)) + private def isDefinition(tree: Tree): Boolean = { + tree match { + case Annotated(arg, annot) => isDefinition(arg) + case _: ValOrDefDef => true + case _ => false + } } private def extractedValTypes: Seq[String] = diff --git a/buildfile/src/test/scala/sbt/internal/parser/SplitExpressions.scala b/buildfile/src/test/scala/sbt/internal/parser/SplitExpressions.scala index 2399de076..a26268d7a 100644 --- a/buildfile/src/test/scala/sbt/internal/parser/SplitExpressions.scala +++ b/buildfile/src/test/scala/sbt/internal/parser/SplitExpressions.scala @@ -12,8 +12,9 @@ package parser import sbt.internal.util.LineRange import xsbti.VirtualFileRef +import dotty.tools.dotc.ast.untpd.Tree object SplitExpressions: type SplitExpression = - (VirtualFileRef, Seq[String]) => (Seq[(String, Int)], Seq[(String, LineRange)]) + (VirtualFileRef, Seq[String]) => (Seq[(String, Int)], Seq[(String, Tree, LineRange)]) end SplitExpressions diff --git a/buildfile/src/test/scala/sbt/internal/parser/SplitExpressionsBehavior.scala b/buildfile/src/test/scala/sbt/internal/parser/SplitExpressionsBehavior.scala index 3ba3e664d..d3260f998 100644 --- a/buildfile/src/test/scala/sbt/internal/parser/SplitExpressionsBehavior.scala +++ b/buildfile/src/test/scala/sbt/internal/parser/SplitExpressionsBehavior.scala @@ -12,10 +12,11 @@ package parser import sbt.internal.util.LineRange import xsbti.VirtualFileRef +import dotty.tools.dotc.ast.untpd.Tree trait SplitExpression { extension (splitter: SplitExpressions.SplitExpression) - def apply(s: String): (Seq[(String, Int)], Seq[(String, LineRange)]) = + def apply(s: String): (Seq[(String, Int)], Seq[(String, Tree, LineRange)]) = splitter(VirtualFileRef.of("noFile"), s.split('\n').toSeq) } @@ -53,6 +54,16 @@ trait SplitExpressionsBehavior extends SplitExpression { this: verify.BasicTestS assert(imports.size == 2) assert(settingsAndDefs.size == 1) } + + test("parse a config containing an annotated definition") { + val (imports, settingsAndDefs) = splitter( + """|import foo.Bar + |@foo + |lazy val root = (project in file(".")).enablePlugins(PlayScala)""".stripMargin + ) + assert(imports.size == 1) + assert(settingsAndDefs.size == 1) + } } } diff --git a/sbt-app/src/sbt-test/project-load/build-sbt-annotation/build.sbt b/sbt-app/src/sbt-test/project-load/build-sbt-annotation/build.sbt new file mode 100644 index 000000000..afd9f73ea --- /dev/null +++ b/sbt-app/src/sbt-test/project-load/build-sbt-annotation/build.sbt @@ -0,0 +1,23 @@ +import scala.annotation.{tailrec, nowarn} +import sbt.util.cacheLevel + +@tailrec +def even(x: Int): Boolean = Math.abs(x) match + case 0 => true + case 1 => false + case n => even(n - 2) + +@transient val foo = 4 + +@cacheLevel(include = Array.empty) +lazy val myTask = taskKey[Boolean]("...") + +@nowarn +lazy val myProject = project.settings( + myTask := { + assert(!file("ran").exists) + println("running") + IO.touch(file("ran")) + even(2) + } +) diff --git a/sbt-app/src/sbt-test/project-load/build-sbt-annotation/test b/sbt-app/src/sbt-test/project-load/build-sbt-annotation/test new file mode 100644 index 000000000..f9084ebbb --- /dev/null +++ b/sbt-app/src/sbt-test/project-load/build-sbt-annotation/test @@ -0,0 +1,6 @@ +$ absent ran +> myTask +$ exists ran + +# verify the task is not cached (i.e. annotation is not lost) +-> myTask