Skip to content

Commit f8cb694

Browse files
perf: splice mutated file instead of re-printing entire source tree (#2083)
1 parent 54977a7 commit f8cb694

4 files changed

Lines changed: 71 additions & 15 deletions

File tree

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,49 @@
11
package stryker4s.model
22

3+
import cats.Order
4+
import cats.data.NonEmptySet
5+
import cats.effect.IO
6+
import fs2.Stream
37
import fs2.io.file.Path
48
import stryker4s.mutants.tree.MutantsWithId
59

6-
import scala.meta.Tree
10+
import scala.meta.prettyprinters.XtensionReprint
11+
import scala.meta.{Term, Tree}
712

8-
final case class MutatedFile(fileOrigin: Path, mutatedSource: Tree, mutants: MutantsWithId)
13+
final case class MutatedFile(
14+
fileOrigin: Path,
15+
mutatedSource: Tree,
16+
mutants: MutantsWithId,
17+
splice: Option[SourceSplice] = None
18+
) {
19+
20+
def mutatedSourceText: Stream[IO, String] =
21+
splice.fold(Stream.eval(IO(mutatedSource.text)))(_.render)
22+
}
23+
24+
/** Segments needed to serialize a mutated file
25+
*/
26+
final case class SourceSplice(originalText: String, replacements: NonEmptySet[SourceReplacement]) {
27+
28+
/** Emits the file in parts:
29+
*
30+
* 1. each original slice preceding a mutation switch
31+
* 2. then the rendered switch
32+
* 3. finally the trailing original slice.
33+
*/
34+
def render: Stream[IO, String] = {
35+
val switches = Stream.emits(replacements.toNonEmptyList.toList).zipWithPrevious.flatMap { case (prev, r) =>
36+
val sliceStart = prev.fold(0)(_.endOffset)
37+
Stream.emit(originalText.substring(sliceStart, r.begOffset)) ++ Stream.eval(IO(r.tree.reprint()))
38+
}
39+
switches ++ Stream.emit(originalText.substring(replacements.last.endOffset))
40+
}
41+
}
42+
43+
/** A mutation switch that replaces the original source in the half-open range `[begOffset, endOffset)`. */
44+
final case class SourceReplacement(begOffset: Int, endOffset: Int, tree: Term)
45+
46+
object SourceReplacement {
47+
implicit val order: Order[SourceReplacement] = Order.by(_.begOffset)
48+
implicit val ordering: Ordering[SourceReplacement] = order.toOrdering
49+
}

modules/core/src/main/scala/stryker4s/mutants/tree/MutantInstrumenter.scala

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import stryker4s.exception.{Stryker4sException, UnableToBuildPatternMatchExcepti
77
import stryker4s.extension.TreeExtensions.{treeEq, TransformOnceExtension}
88
import stryker4s.log.Logger
99
import stryker4s.model.*
10+
import stryker4s.model.SourceReplacement.*
1011

12+
import scala.collection.immutable.SortedSet
1113
import scala.meta.*
12-
import scala.util.control.NonFatal
1314
import scala.util.{Failure, Success, Try}
1415

1516
/** Instrument (place) mutants in a tree
@@ -22,23 +23,29 @@ class MutantInstrumenter(options: InstrumenterOptions)(implicit log: Logger) {
2223

2324
def instrumentFile(context: SourceContext, mutantMap: Map[PlaceableTree, MutantsWithId]): MutatedFile = {
2425

25-
def instrumentWithMutants(mutantMap: Map[PlaceableTree, MutantsWithId]): PartialFunction[Tree, Tree] = {
26+
// Rendered mutation switches for each outermost mutated statement, used to splice the file together
27+
val spliceReplacements = SortedSet.newBuilder[SourceReplacement]
28+
29+
def instrumentWithMutants(
30+
mutantMap: Map[PlaceableTree, MutantsWithId],
31+
record: Boolean
32+
): PartialFunction[Tree, Tree] = {
2633

2734
Function.unlift { originalTree =>
2835
val p = PlaceableTree(originalTree)
2936
mutantMap.get(p).map { case mutations =>
3037
val mutableCases = mutations.map(mutantToCase)
3138

3239
// Continue deeper into the tree (without the currently placed mutants)
33-
val withDefaultsTransformed = PlaceableTree(p.tree.transformOnce(instrumentWithMutants(mutantMap - p)))
40+
val withDefaultsTransformed =
41+
PlaceableTree(p.tree.transformOnce(instrumentWithMutants(mutantMap - p, record = false)))
3442
val default = defaultCase(withDefaultsTransformed, mutations.map(_.id).toNonEmptyList)
3543

3644
val cases = mutableCases :+ default
3745

38-
try
39-
buildMatch(cases)
40-
catch {
41-
case NonFatal(e) =>
46+
val mutationSwitch = Either
47+
.catchNonFatal(buildMatch(cases))
48+
.valueOr { e =>
4249
log.error(
4350
s"Failed to instrument mutants in `${context.path}`. Original statement: [${originalTree.text}]"
4451
)
@@ -50,12 +57,17 @@ class MutantInstrumenter(options: InstrumenterOptions)(implicit log: Logger) {
5057
e
5158
)
5259
throw UnableToBuildPatternMatchException(context.path)
53-
}
60+
}
61+
62+
if (record)
63+
spliceReplacements += SourceReplacement(p.tree.begOffset, p.tree.endOffset, mutationSwitch)
64+
65+
mutationSwitch
5466
}
5567
}
5668
}
5769

58-
val newTree = Try(context.source.transformOnce(instrumentWithMutants(mutantMap))) match {
70+
val newTree = Try(context.source.transformOnce(instrumentWithMutants(mutantMap, record = true))) match {
5971
case Success(tree) => tree
6072
case Failure(e: Stryker4sException) => throw e
6173
case Failure(e) =>
@@ -64,8 +76,9 @@ class MutantInstrumenter(options: InstrumenterOptions)(implicit log: Logger) {
6476
}
6577

6678
val mutations: MutantsWithId = mutantMap.map(_._2).toVector.toNev.get.flatten
79+
val splice = spliceReplacements.result().toNes.map(SourceSplice(context.source.pos.input.text, _))
6780

68-
MutatedFile(context.path, newTree, mutations)
81+
MutatedFile(context.path, newTree, mutations, splice)
6982
}
7083

7184
def mutantToCase(mutant: MutantWithId): Case = {

modules/core/src/main/scala/stryker4s/run/MutantRunner.scala

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,7 @@ class MutantRunner(
145145
val targetPath = mutatedFile.fileOrigin.inSubDir(tmpDir)
146146
IO(log.debug(s"Writing ${mutatedFile.fileOrigin} file to $targetPath")) *>
147147
Files[IO].createDirectories(targetPath.parent.get) *>
148-
Stream
149-
.eval(IO(mutatedFile.mutatedSource.text))
148+
mutatedFile.mutatedSourceText
150149
.through(Files[IO].writeUtf8(targetPath))
151150
.compile
152151
.drain

modules/core/src/main/scala/stryker4s/run/RollbackHandler.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ object RollbackHandler {
7070
case Both(mutants, results) =>
7171
// If not all errors were removed, we can't continue (return a Left)
7272
errorsWithoutIds.toLeft(
73-
mutatedFile.copy(mutatedSource = treeWithoutErrors, mutants = mutants.toNev).asRight -> results
73+
// Drop the splice info: it describes the pre-rollback tree, so the rewritten tree must be rendered whole
74+
mutatedFile
75+
.copy(mutatedSource = treeWithoutErrors, mutants = mutants.toNev, splice = none)
76+
.asRight -> results
7477
)
7578
// All mutants were removed
7679
case Ior.Right(results) => (mutatedFile.fileOrigin.asLeft, results).asRight

0 commit comments

Comments
 (0)