Skip to content
2 changes: 1 addition & 1 deletion maven/src/main/scala/stryker4s/log/MavenMojoLogger.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ class MavenMojoLogger(mavenLogger: Log) extends Logger {
case Level.Error => if mavenLogger.isErrorEnabled() then onError
}

override val colorEnabled = MessageUtils.isColorEnabled() && !sys.env.contains("NO_COLOR")
override lazy val colorEnabled = MessageUtils.isColorEnabled() && !sys.env.contains("NO_COLOR")
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ class Stryker4sMavenRunner(
val binaryVersion = ScalaVersions.binaryVersion(ScalaVersions.fullVersionUnsafe(project))
resolver
.resolveTransitively(s"io.stryker-mutator:stryker4s-testrunner_$binaryVersion:$version")
.map(_.map(f => Path.fromNioPath(f.toPath)))
}

/** JVM options for the forked test-runner: the parent's system properties, minus platform/internal ones (mirrors the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package stryker4s.maven.runner

import cats.effect.IO
import fs2.io.file.Path
import org.apache.maven.execution.MavenSession
import org.apache.maven.project.MavenProject
import org.eclipse.aether.RepositorySystem
Expand All @@ -12,7 +13,6 @@ import org.eclipse.aether.util.artifact.JavaScopes
import org.eclipse.aether.util.filter.DependencyFilterUtils
import stryker4s.log.Logger

import java.io.File
import scala.jdk.CollectionConverters.*

/** Resolves Maven artifacts (and their transitive runtime dependencies) at plugin runtime via Aether, against the
Expand All @@ -28,15 +28,15 @@ class ArtifactResolver(project: MavenProject, session: MavenSession, repoSystem:
* @param coordinates
* `groupId:artifactId[:extension[:classifier]]:version`
*/
def resolveArtifact(coordinates: String): IO[File] = {
def resolveArtifact(coordinates: String): IO[Path] = {
log.debug(s"Resolving artifact $coordinates")
val request = new ArtifactRequest(new DefaultArtifact(coordinates), repositories, null)
IO.blocking:
repoSystem.resolveArtifact(repoSession, request).getArtifact().getFile()
Path.fromNioPath(repoSystem.resolveArtifact(repoSession, request).getArtifact().getPath())
}

/** Resolve an artifact together with all of its transitive runtime dependencies. */
def resolveTransitively(coordinates: String): IO[Seq[File]] = {
def resolveTransitively(coordinates: String): IO[Seq[Path]] = {
log.debug(s"Resolving $coordinates with transitive dependencies")
val root = new Dependency(new DefaultArtifact(coordinates), JavaScopes.RUNTIME)
val collectRequest = new CollectRequest(root, repositories)
Expand All @@ -49,6 +49,6 @@ class ArtifactResolver(project: MavenProject, session: MavenSession, repoSystem:
.getArtifactResults()
.asScala
.toSeq
.map(_.getArtifact().getFile())
.map(a => Path.fromNioPath(a.getArtifact().getPath()))
}
}
18 changes: 12 additions & 6 deletions maven/src/main/scala/stryker4s/maven/runner/ZincCompiler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,22 @@ object ZincCompiler {
.resolveTransitively(compilerCoords)
.toResource
.flatMap(makeScalaInstance(scalaVersion, _))
scalac <- resolver.resolveArtifact(bridgeCoords).map(ZincUtil.scalaCompiler(scalaInstance, _)).toResource
scalac <- resolver
.resolveArtifact(bridgeCoords)
.map(p => ZincUtil.scalaCompiler(scalaInstance, p.toNioPath))
.toResource
compilers = ZincUtil.compilers(scalaInstance, ClasspathOptionsUtil.boot(), None, scalac)
} yield new ZincCompiler(scalaInstance, compilers)
}

def makeScalaInstance(scalaVersion: String, scalaCompilerClasspath: Seq[File]): Resource[IO, ScalaInstance] = {
val allJars = scalaCompilerClasspath.filterNot { jar =>
val name = jar.getName()
name.startsWith("compiler-interface") || name.startsWith("util-interface")
}.toArray
def makeScalaInstance(scalaVersion: String, scalaCompilerClasspath: Seq[Path]): Resource[IO, ScalaInstance] = {
val allJars = scalaCompilerClasspath
.filterNot { jar =>
val name = jar.fileName.toString
name.startsWith("compiler-interface") || name.startsWith("util-interface")
}
.map(_.toNioPath.toFile())
.toArray
val libraryJars = allJars.filter { jar =>
val name = jar.getName()
name.startsWith("scala-library") || name.startsWith("scala3-library") || name.startsWith("scala-reflect")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
package stryker4s.maven.runner

import cats.effect.IO
import fs2.io.file.Path
import stryker4s.testkit.Stryker4sIOSuite

import java.io.File

class ZincCompilerTest extends Stryker4sIOSuite {

private val jars = Seq(
new File("scala-library-2.13.18.jar"),
new File("scala3-library_3-3.3.4.jar"),
new File("scala-reflect-2.13.18.jar"),
new File("scala-compiler-2.13.18.jar"),
new File("compiler-interface-2.0.0.jar"),
new File("util-interface-2.0.0.jar")
Path("scala-library-2.13.18.jar"),
Path("scala3-library_3-3.3.4.jar"),
Path("scala-reflect-2.13.18.jar"),
Path("scala-compiler-2.13.18.jar"),
Path("compiler-interface-2.0.0.jar"),
Path("util-interface-2.0.0.jar")
)

test("makeScalaInstance excludes the compiler-interface and util-interface jars from allJars") {
Expand Down
2 changes: 1 addition & 1 deletion modules/api/src/main/scala/stryker4s/log/Logger.scala
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ trait Logger {
/** Whether colors are enabled in the log. Loggers can override this, a build tool might provide a flag to
* disable/enable colors
*/
protected def colorEnabled: Boolean = {
protected lazy val colorEnabled: Boolean = {

// Explicitly disable color https://no-color.org/
val notNoColor = !sys.env.contains("NO_COLOR")
Expand Down
5 changes: 5 additions & 0 deletions modules/core/src/main/scala/stryker4s/config/Config.scala
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ object Config {
(cpuCoreCount.toDouble / 4).round.toInt + 1
}

/** Parallelism for local CPU-bound work (parsing, mutating and writing files). Uses all available cores, unlike the
* `concurrency` setting, which is sized for the (much heavier) test-runner processes.
*/
def cpuParallelism: Int = Runtime.getRuntime().availableProcessors()

// Only used in tests
lazy val default: Config = {
import cats.effect.unsafe.implicits.global
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,18 @@ object TreeExtensions {

/** Structural equality for `Tree`s
*/
implicit def treeEq[A <: Tree]: Eq[A] = Eq.instance((x, y) => x == y || x.structure == y.structure)
implicit def treeEq[A <: Tree]: Eq[A] = Eq.instance((x, y) => (x eq y) || structurallyEqual(x, y))

/** Compares two trees by their structure (class, leaf values and children, recursively)
*/
private def structurallyEqual(x: Tree, y: Tree): Boolean =
(x eq y) ||
(x.getClass == y.getClass &&
((x, y) match {
case (x: Name, y: Name) => x.value == y.value
case (x: Lit, y: Lit) => x.value == y.value
case _ => true
}) && x.children.corresponds(y.children)(structurallyEqual(_, _)))

implicit final class CollectFirstExtension(tree: Tree) {
final def collectFirst[T](pf: PartialFunction[Tree, T]): Option[T] = {
Expand Down
9 changes: 3 additions & 6 deletions modules/core/src/main/scala/stryker4s/mutants/Mutator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ class Mutator(
mutantFinder: MutantFinder,
collector: MutantCollector,
instrumenter: MutantInstrumenter
)(implicit
config: Config,
log: Logger
) {
)(implicit log: Logger) {

type Found[A, B] = (SourceContext, (Vector[A], Map[PlaceableTree, B]))
type FoundMutations = Found[IgnoredMutation, Mutations]
Expand All @@ -32,7 +29,7 @@ class Mutator(
def go(files: Stream[IO, Path]): IO[(MutantResultsPerFile, Vector[MutatedFile])] = {
files
// Parse and mutate files
.parEvalMap(config.concurrency)(path =>
.parEvalMap(Config.cpuParallelism)(path =>
mutantFinder.parseFile(path).map { source =>
val foundMutations = collector(source)

Expand All @@ -44,7 +41,7 @@ class Mutator(
// Split mutations into active and ignored mutations
.flatMap { case (ctx, (ignored, found)) => splitIgnoredAndFound(ctx, ignored, found) }
// Instrument files
.parEvalMapUnordered(config.concurrency)(_.traverse { case (context, mutations) =>
.parEvalMapUnordered(Config.cpuParallelism)(_.traverse { case (context, mutations) =>
IO(log.debug(s"Instrumenting mutations in ${mutations.size} places for ${context.path}")) *>
IO(instrumenter.instrumentFile(context, mutations))
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,9 @@ class MutantMatcherImpl()(implicit config: Config) extends MutantMatcher {
val tree = mutationToTerm(mutations)

val (location, description, replacement) = mutations match {
case r: RegularExpression => (r.location, r.description.some, r.replacement)
case _ =>
case r: RegularExpression => (r.location, r.description.some, r.replacement)
case s: SubstitutionMutation[?] => (original.pos.toLocation, none, s.treeSyntax)
case _ =>
(original.pos.toLocation, none, tree.reprint())
}

Expand All @@ -161,7 +162,7 @@ class MutantMatcherImpl()(implicit config: Config) extends MutantMatcher {
)
val mutatedTopStatement = placeableTree.tree
.transformExactlyOnce {
case t if t === original && t.pos == original.pos =>
case t if (t eq original) || (t.pos == original.pos && t === original) =>
tree
}
.getOrElse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ object Mutation {
trait SubstitutionMutation[T <: Tree] extends Mutation[T] with NoInvalidPlacement[T] {
def tree: T

final lazy val treeSyntax: String = tree.reprint()

override def unapply(arg: T): Option[T] =
arg.some
.filter(_ === tree)
Expand Down
26 changes: 15 additions & 11 deletions modules/core/src/main/scala/stryker4s/run/MutantRunner.scala
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ class MutantRunner(

def writeOriginalFile(tmpDir: Path): Pipe[IO, Path, Unit] =
in =>
in.parEvalMapUnordered(config.concurrency) { file =>
in.parEvalMapUnordered(Config.cpuParallelism) { file =>
val newSubPath = file.inSubDir(tmpDir)

IO(log.debug(s"Copying $file to $newSubPath")) *>
Expand All @@ -141,17 +141,16 @@ class MutantRunner(
}

def writeMutatedFile(tmpDir: Path): Pipe[IO, MutatedFile, Unit] =
_.parEvalMap(config.concurrency) { mutatedFile =>
_.parEvalMapUnordered(Config.cpuParallelism) { mutatedFile =>
val targetPath = mutatedFile.fileOrigin.inSubDir(tmpDir)
IO(log.debug(s"Writing ${mutatedFile.fileOrigin} file to $targetPath")) *>
Files[IO]
.createDirectories(targetPath.parent.get)
.as((mutatedFile, targetPath))
}.map { case (mutatedFile, targetPath) =>
Stream(mutatedFile.mutatedSource.text)
.covary[IO]
.through(Files[IO].writeUtf8(targetPath))
}.parJoin(config.concurrency)
Files[IO].createDirectories(targetPath.parent.get) *>
Stream
.eval(IO(mutatedFile.mutatedSource.text))
.through(Files[IO].writeUtf8(targetPath))
.compile
.drain
}

private def runMutants(
mutatedFiles: Seq[MutatedFile],
Expand Down Expand Up @@ -202,7 +201,12 @@ class MutantRunner(
.emits(testableMutants)
.through(testRunnerPool.run { case (testRunner, (path, mutant)) =>
val coverageForMutant = coverageExclusions.coveredMutants.getOrElse(mutant.id, Seq.empty)
IO(log.debug(s"Running mutant $mutant")) *>
IO(
log.debug {
val metadata = mutant.mutatedCode.metadata
s"Running mutant ${mutant.id.value} (${metadata.mutatorName} at ${metadata.location.show})"
}
) *>
testRunner.runMutant(mutant, coverageForMutant).timed.flatMap { case (duration, result) =>
IO(log.debug(s"Mutant ${mutant.id} tested in ${duration.toHumanReadable}")).as(path -> result)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package stryker4s.extension

import cats.syntax.eq.*
import cats.syntax.option.*
import mutationtesting.{Location, Position}
import stryker4s.extension.TreeExtensions.*
Expand Down Expand Up @@ -66,6 +67,69 @@ class TreeExtensionsTest extends Stryker4sSuite {
}
}

describe("treeEq") {
test("equal for same instance") {
val tree = "a + b".parseTerm
assert(tree === tree)
}

test("equal for identical trees") {
assert("a + b".parseTerm === "a + b".parseTerm)
}

test("equal ignoring positions") {
assert("a + b".parseTerm === "a + b".parseTerm)
}

test("equal for nested trees") {
assert("a + (b * c)".parseTerm === "a + (b * c)".parseTerm)
}

test("not equal for different node types") {
assert("5".parseTerm =!= "x".parseTerm)
}

test("not equal for different name types") {
assert((Term.Name("a"): Tree) =!= (Type.Name("a"): Tree))
}

test("not equal for different literal types") {
assert("5".parseTerm =!= "\"5\"".parseTerm)
}

test("not equal for different name values") {
assert(Term.Name("a") =!= Term.Name("b"))
}

test("not equal for different int literals") {
assert(Lit.Int(5) =!= Lit.Int(6))
}

test("not equal for different string literals") {
assert(Lit.String("a") =!= Lit.String("b"))
}

test("not equal for different boolean literals") {
assert(Lit.Boolean(true) =!= Lit.Boolean(false))
}

test("not equal for different operators") {
assert("a + b".parseTerm =!= "a - b".parseTerm)
}

test("not equal for different operands") {
assert("a + b".parseTerm =!= "a + c".parseTerm)
}

test("not equal for different child count") {
assert("f(a)".parseTerm =!= "f(a, b)".parseTerm)
}

test("not equal for nested difference") {
assert("a + (b * c)".parseTerm =!= "a + (b * d)".parseTerm)
}
}

describe("transformOnce") {

test("should transform does not recursively transform new subtree") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class MutantRunResultMapperTest extends Stryker4sSuite {
MutantId(id),
MutatedCode(
foundOrig,
MutantMetadata(foundOrig.syntax, category.tree.syntax, category.mutationName, foundOrig.pos, none)
MutantMetadata(foundOrig.syntax, category.treeSyntax, category.mutationName, foundOrig.pos, none)
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,11 @@ class MutantRunnerTest extends Stryker4sIOSuite with LogMatchers with TestData {
val mutants = NonEmptyVector.one(mutant)
val mutatedFile = MutatedFile(file, "def foo = 4".parseDef, mutants)

val metadata = mutant.mutatedCode.metadata
sut(Vector(mutatedFile))
.assertLoggedDebug(s"Running mutant $mutant")
.assertLoggedDebug(
s"Running mutant ${mutant.id.value} (${metadata.mutatorName} at ${metadata.location.show})"
)
.assertLoggedDebug("Mutant 1 tested in")
}

Expand Down
2 changes: 1 addition & 1 deletion modules/mill/src/main/scala/stryker4s/log/MillLogger.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ class MillLogger(millLogger: MillInternalLogger, env: Map[String, String]) exten
e.printStackTrace(pw)
writer.toString()

override protected def colorEnabled: Boolean =
override protected lazy val colorEnabled: Boolean =
mill.api.daemon.loggerColorEnabled(millLogger) && !env.contains("NO_COLOR")
}
Loading