
com.diffplug.selfie.junit5.SelfieGC.kt Maven / Gradle / Ivy
/*
* Copyright (C) 2023-2024 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.selfie.junit5
import com.diffplug.selfie.ArrayMap
import com.diffplug.selfie.ListBackedSet
import com.diffplug.selfie.Snapshot
import kotlin.io.path.name
/** Search for any test annotation classes which are present on the classpath. */
private val testAnnotations =
listOf(
"org.junit.jupiter.api.Test", // junit5,
"org.junit.Test" // junit4
)
.mapNotNull {
try {
Class.forName(it).asSubclass(Annotation::class.java)
} catch (e: ClassNotFoundException) {
null
}
}
/**
* Searches the whole snapshot directory, finds all the `.ss` files, and prunes any which don't have
* matching test files anymore.
*/
internal fun findStaleSnapshotFiles(layout: SnapshotFileLayoutJUnit5): List {
return layout.fs.fileWalk(layout.rootFolder) { walk ->
walk
.filter { layout.fs.name(it).endsWith(layout.extension) }
.map {
layout.subpathToClassname(
layout.rootFolder.toPath().relativize(it.toPath()).toString().replace('\\', '/'))
}
.filter { !classExistsAndHasTests(it) }
.toMutableList()
}
}
private fun classExistsAndHasTests(key: String): Boolean {
return try {
Class.forName(key).methods.any { method ->
testAnnotations.any { method.isAnnotationPresent(it) }
}
} catch (e: ClassNotFoundException) {
false
}
}
internal class MethodSnapshotGC {
private var suffixesToKeep: ArraySet? = EMPTY_SET
fun keepSuffix(suffix: String) {
suffixesToKeep = suffixesToKeep?.plusOrThis(suffix)
}
fun keepAll(): MethodSnapshotGC {
suffixesToKeep = null
return this
}
fun succeeded(success: Boolean) {
if (!success) keepAll() // if a method fails we have to keep all its snapshots just in case
}
override fun toString() = java.util.Objects.toString(suffixesToKeep)
private fun succeededAndUsedNoSnapshots() = suffixesToKeep == EMPTY_SET
private fun keeps(s: String): Boolean = suffixesToKeep?.contains(s) ?: true
companion object {
fun findStaleSnapshotsWithin(
className: String,
snapshots: ArrayMap,
methods: ArrayMap,
): List {
val staleIndices = mutableListOf()
// - Every snapshot is named `testMethod` or `testMethod/subpath`
// - It is possible to have `testMethod/subpath` without `testMethod`
// - If a snapshot does not have a corresponding testMethod, it is stale
// - If a method ran successfully, then we should keep exclusively the snapshots in
// MethodSnapshotUsage#suffixesToKeep
// - Unless that method has `keepAll`, in which case the user asked to exclude that method
// from pruning
// combine what we know about methods that did run with what we know about the tests that
// didn't
var totalGc = methods
for (method in findTestMethodsThatDidntRun(className, methods)) {
totalGc = totalGc.plus(method, MethodSnapshotGC().keepAll())
}
val gcRoots = totalGc.entries
val keys = snapshots.keys
// we'll start with the lowest gc, and the lowest key
var gcIdx = 0
var keyIdx = 0
while (keyIdx < keys.size && gcIdx < gcRoots.size) {
val key = keys[keyIdx]
val gc = gcRoots[gcIdx]
if (key.startsWith(gc.key)) {
if (key.length == gc.key.length) {
// startWith + same length = exact match, no suffix
if (!gc.value.keeps("")) {
staleIndices.add(keyIdx)
}
++keyIdx
continue
} else if (key.elementAt(gc.key.length) == '/') {
// startWith + not same length = can safely query the `/`
val suffix = key.substring(gc.key.length)
if (!gc.value.keeps(suffix)) {
staleIndices.add(keyIdx)
}
++keyIdx
continue
} else {
// key is longer than gc.key, but doesn't start with gc.key, so we must increment gc
++gcIdx
continue
}
} else {
// we don't start with the key, so we must increment
if (gc.key < key) {
++gcIdx
} else {
// we never found a gc that started with this key, so it's stale
staleIndices.add(keyIdx)
++keyIdx
}
}
}
while (keyIdx < keys.size) {
staleIndices.add(keyIdx)
++keyIdx
}
return staleIndices
}
/**
* This method is called only when a class has completed without ever touching a snapshot file.
*/
fun isUnusedSnapshotFileStale(
className: String,
methods: ArrayMap,
classLevelSuccess: Boolean
): Boolean {
// we need the entire class to succeed in order to be sure
return classLevelSuccess &&
// we need every method to succeed and not use any snapshots
methods.values.all { it.succeededAndUsedNoSnapshots() } &&
// we need every method to run
findTestMethodsThatDidntRun(className, methods).isEmpty()
}
private fun findTestMethodsThatDidntRun(
className: String,
methodsThatRan: ArrayMap,
): List {
return Class.forName(className).methods.mapNotNull { method ->
if (!methodsThatRan.containsKey(method.name) &&
testAnnotations.any { method.isAnnotationPresent(it) }) {
method.name
} else null
}
}
private val EMPTY_SET = ArraySet(arrayOf())
}
}
/** An immutable, sorted, array-backed set. */
internal class ArraySet>(private val data: Array) : ListBackedSet() {
override val size: Int
get() = data.size
override fun get(index: Int): K = data[index] as K
fun plusOrThis(key: K): ArraySet {
val idxExisting = data.binarySearch(key)
if (idxExisting >= 0) {
return this
}
val idxInsert = -(idxExisting + 1)
return when (data.size) {
0 -> ArraySet(arrayOf(key))
1 -> {
if (idxInsert == 0)
ArraySet(
arrayOf(
key,
data[0],
))
else ArraySet(arrayOf(data[0], key))
}
else -> {
// TODO: use idxInsert and arrayCopy to do this faster, see ArrayMap#insert
val array = Array(size + 1) { if (it < size) data[it] else key }
array.sort()
ArraySet(array)
}
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy