diff --git a/main/src/main/scala/sbt/ScopeFilter.scala b/main/src/main/scala/sbt/ScopeFilter.scala index f93d83f48..94ba0532a 100644 --- a/main/src/main/scala/sbt/ScopeFilter.scala +++ b/main/src/main/scala/sbt/ScopeFilter.scala @@ -9,11 +9,11 @@ package sbt import sbt.internal.{ Load, LoadedBuildUnit } -import sbt.internal.util.{ AttributeKey, Dag } +import sbt.internal.util.{ AttributeKey, AttributeMap, Dag } import sbt.librarymanagement.{ ConfigRef, Configuration } import sbt.internal.util.Types.const import Def.Initialize -import sbt.ScopeAxis.{ Select, Zero } +import sbt.ScopeAxis.{ Select, This, Zero } import java.net.URI sealed abstract class ScopeFilter { self => @@ -21,6 +21,12 @@ sealed abstract class ScopeFilter { self => /** Implements this filter. */ private[ScopeFilter] def apply(data: ScopeFilter.Data): Set[Scope] + /** + * Optional ordering hint used by `.all(...)` when the filter can provide a stable + * project ordering. + */ + private[ScopeFilter] def scopeOrdering(data: ScopeFilter.Data): Scope => Option[Int] = _ => None + /** Constructs a filter that selects values that match this filter but not `other`. */ def --(other: ScopeFilter): ScopeFilter = this && -other @@ -84,6 +90,10 @@ object ScopeFilter { } yield scope res.toSet + override private[ScopeFilter] def scopeOrdering(data: Data): Scope => Option[Int] = + val projectOrdering = projects.ordering(data) + scope => projectOrdering(scope.project) + def debug(delegate: ScopeFilter): ScopeFilter = new ScopeFilter: def apply(data: Data): Set[Scope] = @@ -97,7 +107,8 @@ object ScopeFilter { * static inspections will not show them. */ def all(sfilter: => ScopeFilter): Initialize[Seq[A]] = Def.flatMap(getData) { data => - sfilter(data).toSeq.map(s => Project.inScope(s, i)).join + val filter = sfilter + orderedScopes(filter(data), filter.scopeOrdering(data)).map(s => Project.inScope(s, i)).join } final class TaskKeyAll[A] private[sbt] (i: Initialize[Task[A]]): @@ -107,9 +118,103 @@ object ScopeFilter { */ def all(sfilter: => ScopeFilter): Initialize[Task[Seq[A]]] = Def.flatMap(getData) { data => import std.TaskExtra.* - sfilter(data).toSeq.map(s => Project.inScope(s, i)).join(_.join) + val filter = sfilter + orderedScopes(filter(data), filter.scopeOrdering(data)) + .map(s => Project.inScope(s, i)) + .join( + _.join + ) } + private type ScopeAxisOrderingKey = (Int, String) + private type ScopeOrderingKey = ( + ScopeAxisOrderingKey, + ScopeAxisOrderingKey, + ScopeAxisOrderingKey, + ScopeAxisOrderingKey + ) + + private def orderedScopes( + scopes: Iterable[Scope], + scopeOrdering: Scope => Option[Int] + ): Seq[Scope] = + scopes.toSeq.sortBy { scope => + ( + scopeOrdering(scope) match + case Some(order) => (0, order) + case None => (1, Int.MaxValue), + scopeOrderingKey(scope) + ) + } + + private[sbt] def orderedScopesForTests( + scopes: Iterable[Scope], + scopeOrdering: Scope => Option[Int] + ): Seq[Scope] = orderedScopes(scopes, scopeOrdering) + + private def scopeOrderingKey(scope: Scope): ScopeOrderingKey = + ( + scopeAxisOrderingKey(scope.project)(referenceOrderingKey), + scopeAxisOrderingKey(scope.config)(_.name), + scopeAxisOrderingKey(scope.task)(attributeKeyOrderingKey), + scopeAxisOrderingKey(scope.extra)(attributeMapOrderingKey) + ) + + private def scopeAxisOrderingKey[A]( + axis: ScopeAxis[A] + )(selectedKey: A => String): ScopeAxisOrderingKey = + axis match + case Select(selected) => (0, selectedKey(selected)) + case This => (1, "") + case Zero => (2, "") + + private def referenceOrderingKey(reference: Reference): String = + reference match + case ThisBuild => "0" + case ThisProject => "1" + case LocalAggregate => "2" + case LocalRootProject => "3" + case LocalProject(id) => s"4:$id" + case BuildRef(build) => s"5:${uriOrderingKey(build)}" + case RootProject(build) => s"6:${uriOrderingKey(build)}" + case ProjectRef(build, project) => + s"7:${uriOrderingKey(build)}:$project" + + private def uriOrderingKey(uri: URI): String = uri.normalize.toASCIIString + + private def attributeKeyOrderingKey(key: AttributeKey[?]): String = + s"${key.label}:${key.tag}:rank=${key.rank}" + + private def attributeMapOrderingKey(map: AttributeMap): String = + map.entries.toSeq + .map(entry => + val key = attributeKeyOrderingKey(entry.key) + val value = attributeValueOrderingKey(entry.value) + s"$key=$value" + ) + .sorted + .mkString("[", ",", "]") + + private def attributeValueOrderingKey(value: Any): String = + value match + case null => "null" + case m: collection.Map[?, ?] => + m.iterator + .map((k, v) => s"${attributeValueOrderingKey(k)}->${attributeValueOrderingKey(v)}") + .toSeq + .sorted + .mkString("Map(", ",", ")") + case i: Iterable[?] => + i.iterator + .map(attributeValueOrderingKey) + .mkString("Iterable(", ",", ")") + case p: Product => + p.productIterator + .map(attributeValueOrderingKey) + .mkString(s"Product(${p.productPrefix}|", ",", ")") + case other => + s"${other.getClass.getName}(${other.toString})" + private[sbt] val Make = new Make {} trait Make { @@ -283,7 +388,20 @@ object ScopeFilter { inResolvedProjects(data => projects.map(data.resolve)) private def inResolvedProjects(projects: Data => Seq[ProjectRef]): ProjectFilter = - selectAxis(data => projects(data).toSet) + new AxisFilter[Reference]: + private[sbt] def apply(data: Data): ScopeAxis[Reference] => Boolean = + val selected = projects(data).toSet + _ match + case Select(ref: ProjectRef) => selected(ref) + case _ => false + + override private[ScopeFilter] def ordering( + data: Data + ): ScopeAxis[Reference] => Option[Int] = + val index = projects(data).zipWithIndex.toMap + _ match + case Select(ref: ProjectRef) => index.get(ref) + case _ => None private def zeroAxis[T]: AxisFilter[T] = new AxisFilter[T] { private[sbt] def apply(data: Data): ScopeAxis[T] => Boolean = _ == Zero @@ -305,6 +423,11 @@ object ScopeFilter { /** Implements this filter. */ private[ScopeFilter] def apply(data: Data): ScopeAxis[In] => Boolean + /** + * Optional ordering hint for axes selected by this filter. + */ + private[ScopeFilter] def ordering(data: Data): ScopeAxis[In] => Option[Int] = _ => None + /** Constructs a filter that selects values that match this filter but not `other`. */ def --(other: AxisFilter[In]): AxisFilter[In] = this && -other diff --git a/main/src/test/scala/ScopeFilterOrderingSpec.scala b/main/src/test/scala/ScopeFilterOrderingSpec.scala new file mode 100644 index 000000000..8ecaec941 --- /dev/null +++ b/main/src/test/scala/ScopeFilterOrderingSpec.scala @@ -0,0 +1,67 @@ +/* + * 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 + +import sbt.ScopeAxis.{ Select, Zero } +import hedgehog.* +import hedgehog.runner.* + +import java.net.URI + +object ScopeFilterOrderingSpec extends Properties { + private val buildUri = new URI("file:///scope-filter-ordering/") + + override def tests: List[Test] = + List( + property( + "all ordering is deterministic without ordering hints", + projectIdsGen.forAll.map { projectIds => + val scopes = projectIds.map(toProjectRef).flatMap(projectScopes) + val noHint: Scope => Option[Int] = _ => None + val fromSet = ScopeFilter.orderedScopesForTests(scopes.toSet, noHint) + val fromReverse = ScopeFilter.orderedScopesForTests(scopes.reverse, noHint) + fromSet ==== fromReverse + } + ), + property( + "all ordering preserves explicit project ordering hints", + projectIdsGen.forAll.map { projectIds => + val orderedProjects = projectIds.map(toProjectRef) + val expected = orderedProjects.flatMap(projectScopes) + val ordering = orderedProjects.zipWithIndex.toMap + val byProjectOrder: Scope => Option[Int] = _.project match + case Select(ref: ProjectRef) => ordering.get(ref) + case _ => None + val actual = ScopeFilter.orderedScopesForTests(expected.reverse, byProjectOrder) + actual ==== expected + } + ) + ) + + private val projectIdGen: Gen[String] = + for + head <- Gen.char('a', 'z') + tail <- Gen.string(Gen.alphaNum, Range.linear(0, 6)) + yield s"$head$tail" + + private val projectIdsGen: Gen[List[String]] = + projectIdGen.list(Range.linear(1, 6)).filter { ids => + ids.distinct.size == ids.size + } + + private def toProjectRef(id: String): ProjectRef = + ProjectRef(buildUri, id) + + private def projectScopes(project: ProjectRef): List[Scope] = + List( + Scope(Select(project), Select(ConfigKey("compile")), Zero, Zero), + Scope(Select(project), Select(ConfigKey("test")), Zero, Zero), + Scope(Select(project), Zero, Zero, Zero) + ) +} diff --git a/sbt-app/src/sbt-test/actions/all-ordering/build.sbt b/sbt-app/src/sbt-test/actions/all-ordering/build.sbt new file mode 100644 index 000000000..4d64746c3 --- /dev/null +++ b/sbt-app/src/sbt-test/actions/all-ordering/build.sbt @@ -0,0 +1,48 @@ +lazy val marker = taskKey[String]("Identifies the selected scope.") + +lazy val checkProjectOrdering = taskKey[Unit]("Checks deterministic project ordering.") +lazy val checkConfigOrdering = taskKey[Unit]("Checks deterministic configuration ordering.") +lazy val checkAllOrdering = taskKey[Unit]("Runs all ScopeFilter ordering checks.") + +def assertEquals[A](actual: Seq[A], expected: Seq[A], context: String): Unit = + assert(actual == expected, s"$context expected=$expected actual=$actual") + +lazy val a = project.settings( + marker := "a-global", + Compile / marker := "a-compile", + Test / marker := "a-test", +) + +lazy val b = project.settings( + marker := "b-global", + Compile / marker := "b-compile", + Test / marker := "b-test", +) + +lazy val c = project.settings( + marker := "c-global", + Compile / marker := "c-compile", + Test / marker := "c-test", +) + +lazy val root = project + .aggregate(a, b, c) + .settings( + checkProjectOrdering := { + val values = marker.all(ScopeFilter(inProjects(c, a, b))).value + assertEquals(values, Seq("c-global", "a-global", "b-global"), "project ordering") + }, + checkConfigOrdering := { + val values = marker.all( + ScopeFilter( + projects = inProjects(b), + configurations = inConfigurations(Test, Compile) || inZeroConfiguration + ) + ).value + assertEquals(values, Seq("b-compile", "b-test", "b-global"), "configuration ordering") + }, + checkAllOrdering := { + checkProjectOrdering.value + checkConfigOrdering.value + }, + ) diff --git a/sbt-app/src/sbt-test/actions/all-ordering/test b/sbt-app/src/sbt-test/actions/all-ordering/test new file mode 100644 index 000000000..fecd29bcd --- /dev/null +++ b/sbt-app/src/sbt-test/actions/all-ordering/test @@ -0,0 +1 @@ +> checkAllOrdering