feat: queriable slash syntax (sbt query)

**Problem**
We want a more flexible way of aggregating subprojects.

**Solution**
This implements a subproject filtering as a replacement of
the subproject axis in the act command.
This commit is contained in:
Eugene Yokota 2024-09-25 03:28:56 -04:00
parent b5d3a6d258
commit e61ae80088
7 changed files with 231 additions and 78 deletions

View File

@ -18,7 +18,15 @@ import sbt.internal.util.Types.idFun
import sbt.ProjectExtra.{ failure => _, * }
import java.net.URI
import sbt.internal.CommandStrings.{ MultiTaskCommand, ShowCommand, PrintCommand }
import sbt.internal.util.{ AttributeEntry, AttributeKey, AttributeMap, IMap, Settings, Util }
import sbt.internal.util.{
AttributeEntry,
AttributeKey,
AttributeMap,
IMap,
MessageOnlyException,
Settings,
Util,
}
import sbt.util.Show
import scala.collection.mutable
@ -49,6 +57,10 @@ object Act {
private[sbt] val colonSeq: Seq[String] = Seq(":")
private[sbt] val colonColonSeq: Seq[String] = Seq("::")
type KeysParser = Parser[Seq[ScopedKey[Any]]]
type KeysParserSep = Parser[Seq[(ScopedKey[Any], Seq[String])]]
type KeysParserFilter = Parser[Seq[(ScopedKey[Any], Option[ProjectQuery])]]
// this does not take aggregation into account
def scopedKey(
index: KeyIndex,
@ -57,7 +69,7 @@ object Act {
keyMap: Map[String, AttributeKey[_]],
data: Settings[Scope]
): Parser[ScopedKey[Any]] =
scopedKeySelected(index, current, defaultConfigs, keyMap, data)
scopedKeySelected(index, current, defaultConfigs, keyMap, data, askProject = true)
.map(_.key.asInstanceOf[ScopedKey[Any]])
// the index should be an aggregated index for proper tab completion
@ -72,7 +84,8 @@ object Act {
current,
defaultConfigs,
structure.index.keyMap,
structure.data
structure.data,
askProject = true,
)
)
yield Aggregation.aggregate(
@ -81,6 +94,28 @@ object Act {
structure.extra
)
def scopedKeyAggregatedFilter(
current: ProjectRef,
defaultConfigs: Option[ResolvedReference] => Seq[String],
structure: BuildStructure
): KeysParserFilter =
for
optQuery <- queryOption.?
selected <- scopedKeySelected(
structure.index.aggregateKeyIndex,
current,
defaultConfigs,
structure.index.keyMap,
structure.data,
askProject = optQuery.isEmpty,
)
yield Aggregation
.aggregate(selected.key, selected.mask, structure.extra)
.map(k => k.asInstanceOf[ScopedKey[Any]] -> optQuery)
private def queryOption: Parser[ProjectQuery] =
ProjectQuery.parser <~ spacedSlash
def scopedKeyAggregatedSep(
current: ProjectRef,
defaultConfigs: Option[ResolvedReference] => Seq[String],
@ -91,7 +126,8 @@ object Act {
current,
defaultConfigs,
structure.index.keyMap,
structure.data
structure.data,
askProject = true,
)
yield Aggregation
.aggregate(selected.key, selected.mask, structure.extra)
@ -102,24 +138,29 @@ object Act {
current: ProjectRef,
defaultConfigs: Option[ResolvedReference] => Seq[String],
keyMap: Map[String, AttributeKey[_]],
data: Settings[Scope]
data: Settings[Scope],
askProject: Boolean,
): Parser[ParsedKey] =
scopedKeyFull(index, current, defaultConfigs, keyMap).flatMap { choices =>
select(choices, data)(showRelativeKey2(current))
scopedKeyFull(index, current, defaultConfigs, keyMap, askProject = askProject).flatMap {
choices =>
select(choices, data)(showRelativeKey2(current))
}
def scopedKeyFull(
index: KeyIndex,
current: ProjectRef,
defaultConfigs: Option[ResolvedReference] => Seq[String],
keyMap: Map[String, AttributeKey[_]]
keyMap: Map[String, AttributeKey[_]],
askProject: Boolean,
): Parser[Seq[Parser[ParsedKey]]] = {
val confParserCache
: mutable.Map[Option[sbt.ResolvedReference], Parser[(ParsedAxis[String], Seq[String])]] =
mutable.Map.empty
def fullKey =
for {
rawProject <- optProjectRef(index, current)
for
rawProject <-
if askProject then optProjectRef(index, current)
else success(Omitted)
proj = resolveProject(rawProject, current)
confPair <- confParserCache.getOrElseUpdate(
proj,
@ -131,7 +172,7 @@ object Act {
)
(confAmb, seps) = confPair
partialMask = ScopeMask(rawProject.isExplicit, confAmb.isExplicit, false, false)
} yield taskKeyExtra(index, defaultConfigs, keyMap, proj, confAmb, partialMask, seps)
yield taskKeyExtra(index, defaultConfigs, keyMap, proj, confAmb, partialMask, seps)
val globalIdent = token(GlobalIdent ~ spacedSlash) ^^^ ParsedGlobal
def globalKey =
@ -485,49 +526,59 @@ object Act {
def actParser(s: State): Parser[() => State] = requireSession(s, actParser0(s))
private[this] def actParser0(state: State): Parser[() => State] = {
val extracted = Project extract state
private[this] def actParser0(state: State): Parser[() => State] =
val extracted = Project.extract(state)
import extracted.{ showKey, structure }
import Aggregation.evaluatingParser
actionParser.flatMap { action =>
val akp = aggregatedKeyParserSep(extracted)
def warnOldShellSyntax(seps: Seq[String], keyStrings: String): Unit =
if (seps.contains(":") || seps.contains("::")) {
state.log.warn(
s"sbt 0.13 shell syntax is deprecated; use slash syntax instead: $keyStrings"
)
} else ()
def evaluate(pairs: Seq[(ScopedKey[_], Seq[String])]): Parser[() => State] = {
val kvs = pairs.map(_._1)
val seps = pairs.headOption.map(_._2).getOrElse(Nil)
actionParser.flatMap: action =>
val akp = aggregatedKeyParserFilter(extracted)
// If the task name matches, but the query is empty, we should succeed the parser,
// but fail the task. Otherwise, the composed parser would think we made a typo.
def emptyResult: Parser[() => State] =
Parser.success(() => throw MessageOnlyException("query result is empty"))
def evaluate(kvs: Seq[ScopedKey[_]]): Parser[() => State] =
val preparedPairs = anyKeyValues(structure, kvs)
val showConfig = if (action == PrintAction) {
Aggregation.ShowConfig(true, true, println, false)
} else {
Aggregation.defaultShow(state, showTasks = action == ShowAction)
}
evaluatingParser(state, showConfig)(preparedPairs) map { evaluate => () =>
{
val keyStrings = preparedPairs.map(pp => showKey.show(pp.key)).mkString(", ")
state.log.debug("Evaluating tasks: " + keyStrings)
warnOldShellSyntax(seps, keyStrings)
evaluate()
}
}
}
action match {
case SingleAction => akp.flatMap(evaluate)
case ShowAction | PrintAction | MultiAction =>
rep1sep(akp, token(Space)) flatMap { pairs =>
val flat: mutable.ListBuffer[(ScopedKey[_], Seq[String])] = mutable.ListBuffer.empty
pairs foreach { xs =>
flat ++= xs
}
evaluate(flat.toList)
}
}
val showConfig = action match
case PrintAction =>
Aggregation.ShowConfig(
settingValues = true,
taskValues = true,
print = println,
success = false
)
case _ => Aggregation.defaultShow(state, showTasks = action == ShowAction)
Aggregation
.evaluatingParser(state, showConfig)(preparedPairs)
.map: evaluate =>
() =>
val keyStrings = preparedPairs.map(pp => showKey.show(pp.key)).mkString(", ")
state.log.debug("Evaluating tasks: " + keyStrings)
evaluate()
for
keys <-
action match
case SingleAction => akp
case ShowAction | PrintAction | MultiAction =>
for pairs <- rep1sep(akp, token(Space))
yield pairs.flatten
keys1 = applyQuery(keys, structure)
p <-
if keys.nonEmpty && keys1.isEmpty then emptyResult
else evaluate(keys1.map(_._1))
yield p
end actParser0
private def applyQuery(
pairs: Seq[(ScopedKey[_], Option[ProjectQuery])],
structure: BuildStructure,
): Seq[(ScopedKey[_], Option[ProjectQuery])] =
pairs.filter {
case (_, None) => true
case (keys, Some(query)) =>
val f = query.buildQuery(structure)
keys.scope.project.toOption match
case Some(ref: ProjectRef) => f(ref)
case _ => true
}
}
private[this] final class ActAction
private[this] final val ShowAction, MultiAction, SingleAction, PrintAction = new ActAction
@ -551,9 +602,6 @@ object Act {
structure.data
)
type KeysParser = Parser[Seq[ScopedKey[Any]]]
type KeysParserSep = Parser[Seq[(ScopedKey[Any], Seq[String])]]
def aggregatedKeyParser(state: State): KeysParser = aggregatedKeyParser(Project extract state)
def aggregatedKeyParser(extracted: Extracted): KeysParser =
aggregatedKeyParser(extracted.structure, extracted.currentRef)
@ -567,6 +615,13 @@ object Act {
currentRef: ProjectRef
): KeysParserSep =
scopedKeyAggregatedSep(currentRef, structure.extra.configurationsForAxis, structure)
private[sbt] def aggregatedKeyParserFilter(extracted: Extracted): KeysParserFilter =
aggregatedKeyParserFilter(extracted.structure, extracted.currentRef)
private[sbt] def aggregatedKeyParserFilter(
structure: BuildStructure,
currentRef: ProjectRef
): KeysParserFilter =
scopedKeyAggregatedFilter(currentRef, structure.extra.configurationsForAxis, structure)
def keyValues[T](state: State)(keys: Seq[ScopedKey[T]]): Values[T] =
keyValues(Project extract state)(keys)

View File

@ -254,22 +254,18 @@ object Aggregation {
else extra.aggregates.forward(ref)
}
def aggregate[T, Proj](
key: ScopedKey[T],
def aggregate[A1, Proj](
key: ScopedKey[A1],
rawMask: ScopeMask,
extra: BuildUtil[Proj],
reverse: Boolean = false
): Seq[ScopedKey[T]] = {
): Seq[ScopedKey[A1]] =
val mask = rawMask.copy(project = true)
Dag.topologicalSort(key) { k =>
if (reverse)
reverseAggregatedKeys(k, extra, mask)
else if (aggregationEnabled(k, extra.data))
aggregatedKeys(k, extra, mask)
else
Nil
}
}
Dag.topologicalSort(key): (k) =>
if reverse then reverseAggregatedKeys(k, extra, mask)
else if aggregationEnabled(k, extra.data) then aggregatedKeys(k, extra, mask)
else Nil
def reverseAggregatedKeys[T](
key: ScopedKey[T],
extra: BuildUtil[_],

View File

@ -0,0 +1,55 @@
package sbt
package internal
import sbt.internal.util.complete.{ DefaultParsers, Parser }
import sbt.Keys.scalaBinaryVersion
import DefaultParsers.*
import scala.annotation.nowarn
import scala.util.matching.Regex
import sbt.internal.util.AttributeKey
private[sbt] case class ProjectQuery(
projectName: String,
params: Map[AttributeKey[?], String],
):
import ProjectQuery.*
private lazy val pattern: Regex = Regex("^" + projectName.replace(wildcard, ".*") + "$")
@nowarn
def buildQuery(structure: BuildStructure): ProjectRef => Boolean =
(p: ProjectRef) =>
val projectMatches =
if projectName == wildcard then true
else pattern.matches(p.project)
val scalaMatches =
params.get(Keys.scalaBinaryVersion.key) match
case Some(expected) =>
val actualSbv = structure.data.get(Scope.ThisScope.in(p), scalaBinaryVersion.key)
actualSbv match
case Some(sbv) => sbv == expected
case None => true
case None => true
projectMatches && scalaMatches
end ProjectQuery
object ProjectQuery:
private val wildcard = "..."
// make sure @ doesn't match on this one
def projectName: Parser[String] =
charClass(c => c.isLetter || c.isDigit || c == '_' || c == '.').+.string
.examples(wildcard)
def parser: Parser[ProjectQuery] =
(projectName ~
token("@scalaBinaryVersion=" ~> StringBasic.map((scalaBinaryVersion.key, _)))
.examples("@scalaBinaryVersion=3")
.?)
.map { case (proj, params) =>
ProjectQuery(proj, Map(params.toSeq: _*))
}
.filter(
(q) => q.projectName.contains("...") || q.params.nonEmpty,
(msg) => s"$msg isn't a query"
)
end ProjectQuery

View File

@ -6,8 +6,8 @@ object Marker extends AutoPlugin:
override def trigger = allRequirements
override def requires = sbt.plugins.JvmPlugin
object autoImport {
final lazy val Mark = TaskKey[Unit]("mark")
final def mark: Initialize[Task[Unit]] = mark(baseDirectory)
final lazy val mark = taskKey[Unit]("mark")
final def markTask: Initialize[Task[Unit]] = mark(baseDirectory)
final def mark(project: Reference): Initialize[Task[Unit]] = mark(project / baseDirectory)
final def mark(baseKey: SettingKey[File]): Initialize[Task[Unit]] = baseKey.toTaskable mapN {
base =>

View File

@ -5,19 +5,19 @@
$ absent ran
# single project, 'mark' defined
> set Mark := mark.value
> set mark := markTask.value
> mark
$ exists ran
$ delete ran
# single project, aggregate = true on Mark
> set Mark / aggregate := true
> set mark / aggregate := true
> mark
$ exists ran
$ delete ran
# single project, aggregate = false on Mark
> set Mark / aggregate := false
> set mark / aggregate := false
> mark
$ exists ran
$ delete ran
@ -28,14 +28,14 @@ $ copy-file changes/build.sbt build.sbt
> reload
# define in root project only
> set Mark := mark.value
> set mark := markTask.value
> mark
$ exists ran
$ absent sub/ran
$ delete ran
# define in sub project, but shouldn't run without aggregation
> set sub / Mark := mark(sub).value
> set sub / mark := mark(sub).value
> mark
$ exists ran
$ absent sub/ran
@ -57,8 +57,8 @@ $ touch aggregate
> reload
# add tasks to each subproject
> set sub / Mark := mark(sub).value
> set sub2 / Mark := mark(sub2).value
> set sub / mark := mark(sub).value
> set sub2 / mark := mark(sub2).value
# check that aggregation works when root project has no task
> mark
@ -73,17 +73,17 @@ $ absent ran
$ delete sub/ran sub/sub/ran
# add task to root project
> set Mark := mark.value
> set mark := markTask.value
# disable aggregation for sub/mark so that sub2/mark doesn't run
> set sub / Mark / aggregate := false
> set sub / mark / aggregate := false
> mark
$ exists ran sub/ran
$ absent sub/sub/ran
$ delete ran sub/ran
# the aggregation setting in a leaf shouldn't affect whether it can be run directly
> set sub2 / Mark / aggregate := false
> set sub2 / mark / aggregate := false
> sub2/mark
$ exists sub/sub/ran
$ absent ran sub/ran

View File

@ -0,0 +1,23 @@
scalaVersion := "3.3.3"
lazy val someTask = taskKey[Unit]("")
lazy val root = (project in file("."))
.aggregate(foo, bar, baz)
.settings(
name := "root",
)
lazy val foo = project
lazy val bar = project
lazy val baz = project
.settings(
scalaVersion := "2.12.19",
)
someTask := {
val x = target.value / (name.value + ".txt")
val s = streams.value
s.log.info(s"writing $x")
IO.touch(x)
}

View File

@ -0,0 +1,24 @@
> ... / someTask
$ exists target/out/jvm/scala-3.3.3/root/root.txt
$ exists target/out/jvm/scala-3.3.3/foo/foo.txt
$ exists target/out/jvm/scala-3.3.3/bar/bar.txt
$ exists target/out/jvm/scala-2.12.19/baz/baz.txt
> clean
> b... / someTask
$ absent target/out/jvm/scala-3.3.3/root/root.txt
$ absent target/out/jvm/scala-3.3.3/foo/foo.txt
$ exists target/out/jvm/scala-3.3.3/bar/bar.txt
$ exists target/out/jvm/scala-2.12.19/baz/baz.txt
> clean
> ...@scalaBinaryVersion=3 / someTask
$ exists target/out/jvm/scala-3.3.3/root/root.txt
$ exists target/out/jvm/scala-3.3.3/foo/foo.txt
$ exists target/out/jvm/scala-3.3.3/bar/bar.txt
$ absent target/out/jvm/scala-2.12.19/baz/baz.txt