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.
This commit is contained in:
Martin Duhem 2025-08-11 08:59:20 +02:00
parent c565b5a82b
commit da0fa5388e
No known key found for this signature in database
GPG Key ID: 7AED2383601007B6
5 changed files with 68 additions and 14 deletions

View File

@ -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] =

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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)
}
)

View File

@ -0,0 +1,6 @@
$ absent ran
> myTask
$ exists ran
# verify the task is not cached (i.e. annotation is not lost)
-> myTask