Skip to content

Commit a66af9b

Browse files
perf: only traverse tree once when building new mutated tree (#2084)
1 parent f8cb694 commit a66af9b

3 files changed

Lines changed: 106 additions & 10 deletions

File tree

modules/core/src/main/scala/stryker4s/extension/TreeExtensions.scala

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,26 @@ object TreeExtensions {
8080
parentOfType[T](thisTree).isDefined
8181
}
8282

83+
implicit final class AncestorsExtension(val thisTree: Tree) extends AnyVal {
84+
85+
/** The ancestors of this tree, from the immediate parent up to (and including) `root`. This tree itself is not
86+
* included.
87+
*/
88+
final def ancestorsUpTo(root: Tree): Seq[Tree] = {
89+
val builder = Vector.newBuilder[Tree]
90+
@tailrec
91+
def loop(tree: Tree): Unit = {
92+
builder += tree
93+
tree.parent match {
94+
case Some(parent) if tree ne root => loop(parent)
95+
case _ => ()
96+
}
97+
}
98+
thisTree.parent.foreach(loop)
99+
builder.result()
100+
}
101+
}
102+
83103
implicit final class GetMods(val tree: Tree) extends AnyVal {
84104
final def getMods: List[Mod] =
85105
tree match {

modules/core/src/main/scala/stryker4s/mutants/findmutants/MutantMatcher.scala

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import cats.syntax.all.*
55
import mutationtesting.cats.*
66
import stryker4s.config.{Config, ExcludedMutation}
77
import stryker4s.extension.PartialFunctionOps.*
8-
import stryker4s.extension.TreeExtensions.{treeEq, PositionExtension, TransformOnceExtension}
8+
import stryker4s.extension.TreeExtensions.{treeEq, AncestorsExtension, CollectFirstExtension, PositionExtension}
99
import stryker4s.model.*
1010
import stryker4s.mutants.tree.{IgnoredMutation, IgnoredMutations, Mutations}
1111
import stryker4s.mutation.*
@@ -143,6 +143,18 @@ class MutantMatcherImpl()(implicit config: Config) extends MutantMatcher {
143143
replacements: NonEmptyVector[T],
144144
mutationToTerm: T => Term
145145
): PlaceableTree => Either[IgnoredMutations, Mutations] = placeableTree => {
146+
// Find the node to replace once, so each replacement only rebuilds the path to that node
147+
val target = placeableTree.tree
148+
.collectFirst {
149+
case t if (t eq original) || (t.pos == original.pos && t === original) => t
150+
}
151+
.getOrElse(
152+
throw new RuntimeException(
153+
show"Could not transform '${original.text}' in ${placeableTree.tree.text} (${original.pos.toLocation})"
154+
)
155+
)
156+
val pathToTarget = target.ancestorsUpTo(placeableTree.tree)
157+
146158
val mutations = replacements.map { mutations =>
147159
val tree = mutationToTerm(mutations)
148160

@@ -159,18 +171,18 @@ class MutantMatcherImpl()(implicit config: Config) extends MutantMatcher {
159171
location,
160172
description
161173
)
162-
val mutatedTopStatement = placeableTree.tree
163-
.transformExactlyOnce {
164-
case t if (t eq original) || (t.pos == original.pos && t === original) =>
165-
tree
166-
}
167-
.getOrElse(
174+
val transformer = new Transformer {
175+
override def apply(t: Tree): Tree =
176+
if (t eq target) tree
177+
else if (pathToTarget.exists(_ eq t)) super.apply(t)
178+
else t
179+
}
180+
181+
transformer(placeableTree.tree) match {
182+
case t if t eq placeableTree.tree =>
168183
throw new RuntimeException(
169184
show"Could not transform '${original.text}' in ${placeableTree.tree.text} (${metadata.location})"
170185
)
171-
)
172-
173-
mutatedTopStatement match {
174186
case t: Term => MutatedCode(t, metadata)
175187
case t =>
176188
throw new RuntimeException(

modules/core/src/test/scala/stryker4s/extension/TreeExtensionsTest.scala

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,70 @@ class TreeExtensionsTest extends Stryker4sSuite {
6767
}
6868
}
6969

70+
describe("ancestorsUpTo") {
71+
test("returns the parent chain from the immediate parent up to and including root") {
72+
val tree = "a + b * c".parseTerm
73+
val b = tree.find(Term.Name("b")).value
74+
75+
val result = b.ancestorsUpTo(tree)
76+
77+
assert(result.nonEmpty)
78+
assert(result.head eq b.parent.value, "should start at the immediate parent")
79+
assert(result.last eq tree, "should end at root")
80+
// consecutive elements should form a parent chain
81+
result.sliding(2).foreach {
82+
case Seq(child, parent) => assert(child.parent.value eq parent)
83+
case _ => ()
84+
}
85+
}
86+
87+
test("does not include the tree itself") {
88+
val tree = "a + b".parseTerm
89+
val b = tree.find(Term.Name("b")).value
90+
91+
val result = b.ancestorsUpTo(tree)
92+
93+
assert(!result.exists(_ eq b))
94+
}
95+
96+
test("returns only the root when the tree is a direct child of root") {
97+
val tree = "a + b".parseTerm
98+
val a = tree.find(Term.Name("a")).value
99+
100+
val result = a.ancestorsUpTo(tree)
101+
102+
assert(result.loneElement eq tree)
103+
}
104+
105+
test("does not include ancestors above root") {
106+
val tree = "def foo = a + b * c".parseDef
107+
val mul = tree.find("b * c".parseTerm).value
108+
val b = tree.find(Term.Name("b")).value
109+
110+
val result = b.ancestorsUpTo(mul)
111+
112+
assert(result.last eq mul, "should stop at root")
113+
assert(!result.exists(_ eq tree), "should not walk above root")
114+
}
115+
116+
test("walks up to the top of the tree when root is not an ancestor") {
117+
val tree = "a + b * c".parseTerm
118+
val unrelated = "x".parseTerm
119+
val b = tree.find(Term.Name("b")).value
120+
121+
val result = b.ancestorsUpTo(unrelated)
122+
123+
assert(result.head eq b.parent.value)
124+
assert(result.last eq tree, "should reach the top of the tree")
125+
}
126+
127+
test("returns empty when the tree has no parent") {
128+
val tree = "a + b".parseTerm
129+
130+
assert(tree.ancestorsUpTo(tree).isEmpty)
131+
}
132+
}
133+
70134
describe("treeEq") {
71135
test("equal for same instance") {
72136
val tree = "a + b".parseTerm

0 commit comments

Comments
 (0)