KT-45777: Compute classpath changes based on changed snapshots only

to avoid unnecessarily loading unchanged ones.

Duplicate classes will make this a bit tricky. This commit outlines the
algorithm to handle them, the full implementation will follow later.

Also handle removed classes when computing classpath changes.

Test: New tests in ClasspathChangesComputerTest
This commit is contained in:
Hung Nguyen
2021-09-21 09:49:04 +01:00
committed by nataliya.valtman
parent c2af8b2a29
commit e26dc4d574
28 changed files with 482 additions and 175 deletions
@@ -109,15 +109,17 @@ abstract class AbstractIncrementalCache<ClassName>(
override fun markDirty(removedAndCompiledSources: Collection<File>) {
for (sourceFile in removedAndCompiledSources) {
val classes = sourceToClassesMap[sourceFile]
classes.forEach {
dirtyOutputClassesMap.markDirty(it)
sourceToClassesMap[sourceFile].forEach { className ->
markDirty(className)
}
sourceToClassesMap.clearOutputsForSource(sourceFile)
}
}
fun markDirty(className: ClassName) {
dirtyOutputClassesMap.markDirty(className)
}
/**
* Updates class storage based on the given class proto.
*
@@ -8,16 +8,114 @@ package org.jetbrains.kotlin.gradle.incremental
import com.intellij.openapi.util.io.FileUtil
import org.jetbrains.kotlin.incremental.*
import org.jetbrains.kotlin.incremental.storage.FileToCanonicalPathConverter
import org.jetbrains.kotlin.resolve.jvm.JvmClassName
import java.io.File
import java.util.*
import kotlin.collections.LinkedHashMap
/** Computes [ClasspathChanges] between two [ClasspathSnapshot]s .*/
object ClasspathChangesComputer {
fun compute(currentClasspathSnapshot: ClasspathSnapshot, previousClasspathSnapshot: ClasspathSnapshot): ClasspathChanges {
val currentClassSnapshots = currentClasspathSnapshot.getClassSnapshots()
val previousClassSnapshots = previousClasspathSnapshot.getClassSnapshots()
fun compute(
currentClasspathEntrySnapshotFiles: List<File>,
previousClasspathEntrySnapshotFiles: List<File>,
unchangedCurrentClasspathEntrySnapshotFiles: List<File>
): ClasspathChanges {
// To improve performance, we will compute changes for the changed snapshot files only, ignoring unchanged ones. (Duplicate classes
// will make this a bit tricky, but it will be dealt with below.)
// First, align unchanged snapshot files in the current classpath with unchanged snapshot files in the previous classpath. Gradle
// has this information, but doesn't expose it, so we have to reconstruct it here.
val unchangedCurrentToPreviousAlignment: Map<File, File> =
alignUnchangedSnapshotFiles(unchangedCurrentClasspathEntrySnapshotFiles, previousClasspathEntrySnapshotFiles)
// Use sets to make presence checks faster
val unchangedCurrentFiles: Set<File> = unchangedCurrentToPreviousAlignment.keys
val unchangedPreviousFiles: Set<File> = unchangedCurrentToPreviousAlignment.values.toSet()
// We will split the current files into 2 groups:
// 1a) Unchanged current files
// 1b) Added files
// We will split the previous files into 2 groups:
// 2a) Unchanged previous files
// 2b) Removed files
// If the classpath doesn't contain duplicate classes, comparing (1b) with (2b) would be enough.
// However, if the classpath contains duplicate classes, comparing (1b) with (2b) would not be enough.
// Therefore, to deal with duplicate classes while still being able to compare (1b) with (2b), we will find snapshot files in groups
// (1a) and (2a) that have duplicate classes with groups (1b) or (2b) and add them to groups (1b) and (2b). Duplicate classes in
// groups (1b) and (2b) will then be handled in a separate step (see ClasspathChangesComputer.getNonDuplicateClassSnapshots).
val addedFiles: List<File> = currentClasspathEntrySnapshotFiles.filter { it !in unchangedCurrentFiles }
val removedFiles: List<File> = previousClasspathEntrySnapshotFiles.filter { it !in unchangedPreviousFiles }
val adjustedAddedFiles = addedFiles.toMutableSet()
val adjustedRemovedFiles = removedFiles.toMutableSet()
unchangedCurrentToPreviousAlignment.forEach { (unchangedCurrentFile, unchangedPreviousFile) ->
if (unchangedCurrentFile.containsDuplicatesWith(addedFiles) || unchangedPreviousFile.containsDuplicatesWith(removedFiles)) {
adjustedAddedFiles.add(unchangedCurrentFile)
adjustedRemovedFiles.add(unchangedPreviousFile)
}
}
// Keep the original order of added/removed files as it is important for the handling of duplicate classes.
val finalAddedFiles: List<File> = currentClasspathEntrySnapshotFiles.filter { it in adjustedAddedFiles }
val finalRemovedFiles: List<File> = previousClasspathEntrySnapshotFiles.filter { it in adjustedRemovedFiles }
val changedCurrentSnapshot = ClasspathSnapshotSerializer.load(finalAddedFiles)
val changedPreviousSnapshot = ClasspathSnapshotSerializer.load(finalRemovedFiles)
return compute(changedCurrentSnapshot, changedPreviousSnapshot)
}
/**
* Maps the unchanged snapshot files of the current build to the unchanged snapshot files of the previous build (selected from all the
* snapshot files of the previous build).
*/
private fun alignUnchangedSnapshotFiles(unchangedCurrentSnapshotFiles: List<File>, previousSnapshotFiles: List<File>): Map<File, File> {
var index = 0
return unchangedCurrentSnapshotFiles.associateWith { unchangedCurrentFile ->
var candidates = previousSnapshotFiles.subList(index, previousSnapshotFiles.size).filter { previousFile ->
unchangedCurrentFile.lastModified() == previousFile.lastModified() && unchangedCurrentFile.length() == previousFile.length()
}
if (candidates.size > 1) {
candidates = candidates.filter { candidate ->
unchangedCurrentFile.readBytes().contentEquals(candidate.readBytes())
}
}
check(candidates.isNotEmpty()) {
"Can't find previous snapshot file of unchanged current snapshot file '${unchangedCurrentFile.path}'"
}
// If there are multiple candidates, select the first one. (It doesn't have to match Gradle's alignment as long as it's still a
// correct alignment.)
val unchangedPreviousFile = candidates.first()
while (previousSnapshotFiles[index] != unchangedPreviousFile) {
index++
}
index++
unchangedPreviousFile
}
}
/** Returns `true` if this snapshot file contains a duplicate class with another snapshot file in the given set. */
@Suppress("unused", "UNUSED_PARAMETER")
private fun File.containsDuplicatesWith(otherSnapshotFiles: List<File>): Boolean {
// TODO: Implement and optimize this method
// Existing approach (with `kotlin.incremental.useClasspathSnapshot=false`) doesn't seem to handle duplicate classes, so it is
// probably not a regression that we are not handling duplicate classes here yet.
return false
}
fun compute(currentClasspathSnapshot: ClasspathSnapshot, previousClasspathSnapshot: ClasspathSnapshot): ClasspathChanges {
val currentClassSnapshots = currentClasspathSnapshot.getNonDuplicateClassSnapshots()
val previousClassSnapshots = previousClasspathSnapshot.getNonDuplicateClassSnapshots()
return compute(currentClassSnapshots, previousClassSnapshots)
}
/**
* Computes changes between two lists of [ClassSnapshot]s.
*
* Each list must not contain duplicate classes.
*/
fun compute(currentClassSnapshots: List<ClassSnapshot>, previousClassSnapshots: List<ClassSnapshot>): ClasspathChanges {
if (currentClassSnapshots.any { it is ContentHashJavaClassSnapshot }
|| previousClassSnapshots.any { it is ContentHashJavaClassSnapshot }) {
return ClasspathChanges.NotAvailable.UnableToCompute
@@ -27,56 +125,83 @@ object ClasspathChangesComputer {
FileUtil.createTempDirectory(this::class.java.simpleName, "_WorkingDir_${UUID.randomUUID()}", /* deleteOnExit */ true)
val incrementalJvmCache = IncrementalJvmCache(workingDir, /* targetOutputDir */ null, FileToCanonicalPathConverter)
// Store previous class snapshots in incrementalJvmCache, the returned ChangesCollector result is not used.
// Step 1:
// - Add previous class snapshots to incrementalJvmCache.
// - Internally, incrementalJvmCache maintains a set of dirty classes to detect removed classes. Add previous classes to this set
// to detect removed classes later (see step 2).
// - The final ChangesCollector result will contain all symbols in the previous classes (we actually don't need them, but it's
// part of the API's effects).
val unusedChangesCollector = ChangesCollector()
for (previousSnapshot in previousClassSnapshots) {
when (previousSnapshot) {
is KotlinClassSnapshot -> incrementalJvmCache.saveClassToCache(
kotlinClassInfo = previousSnapshot.classInfo,
sourceFiles = null,
changesCollector = unusedChangesCollector
)
is RegularJavaClassSnapshot -> incrementalJvmCache.saveJavaClassProto(
source = null,
serializedJavaClass = previousSnapshot.serializedJavaClass,
collector = unusedChangesCollector
)
is KotlinClassSnapshot -> {
incrementalJvmCache.saveClassToCache(
kotlinClassInfo = previousSnapshot.classInfo,
sourceFiles = null,
changesCollector = unusedChangesCollector
)
incrementalJvmCache.markDirty(previousSnapshot.classInfo.className)
}
is RegularJavaClassSnapshot -> {
incrementalJvmCache.saveJavaClassProto(
source = null,
serializedJavaClass = previousSnapshot.serializedJavaClass,
collector = unusedChangesCollector
)
incrementalJvmCache.markDirty(JvmClassName.byClassId(previousSnapshot.serializedJavaClass.classId))
}
is EmptyJavaClassSnapshot -> {
// Nothing to process
// Nothing to process as these classes don't impact the result.
}
is ContentHashJavaClassSnapshot -> {
error("Unexpected type (it should have been handled earlier): ${previousSnapshot.javaClass.name}")
}
}
}
// Call the following method even though there are no removed classes, just in case the method updates the state of
// incrementalJvmCache.
incrementalJvmCache.clearCacheForRemovedClasses(unusedChangesCollector)
// Compute changes between the current class snapshots and the previously stored snapshots, and save the result in changesCollector.
// Step 2:
// - Add current class snapshots to incrementalJvmCache. This will overwrite any previous class snapshots that have the same
// `JvmClassName`. The previous class snapshots that are not overwritten will be detected as removed class snapshots and will be
// removed from incrementalJvmCache in step 3.
// - Internally, incrementalJvmCache will mark these classes as non-dirty. That means unchanged/modified classes that were marked
// as dirty in step 1 will now be non-dirty (added classes will also be marked as non-dirty even though they were not marked as
// dirty before). After this, the remaining dirty classes will be removed classes.
// - The intermediate ChangesCollector result will contain all symbols in added classes and changed (added/modified/removed)
// symbols in modified classes. We will collect all symbols in removed classes in step 3.
val changesCollector = ChangesCollector()
for (currentSnapshot in currentClassSnapshots) {
when (currentSnapshot) {
is KotlinClassSnapshot -> incrementalJvmCache.saveClassToCache(
kotlinClassInfo = currentSnapshot.classInfo,
sourceFiles = null,
changesCollector = changesCollector
)
is RegularJavaClassSnapshot -> incrementalJvmCache.saveJavaClassProto(
source = null,
serializedJavaClass = currentSnapshot.serializedJavaClass,
collector = changesCollector
)
is KotlinClassSnapshot -> {
incrementalJvmCache.saveClassToCache(
kotlinClassInfo = currentSnapshot.classInfo,
sourceFiles = null,
changesCollector = changesCollector
)
}
is RegularJavaClassSnapshot -> {
incrementalJvmCache.saveJavaClassProto(
source = null,
serializedJavaClass = currentSnapshot.serializedJavaClass,
collector = changesCollector
)
}
is EmptyJavaClassSnapshot -> {
// Nothing to process
// Nothing to process as these classes don't impact the result.
}
is ContentHashJavaClassSnapshot -> {
error("Unexpected type (it should have been handled earlier): ${currentSnapshot.javaClass.name}")
}
}
}
// Step 3:
// - Detect removed classes: they are the remaining dirty classes.
// - Remove previous class snapshots of removed classes from incrementalJvmCache.
// - The final ChangesCollector result will contain all symbols in added classes, changed (added/modified/removed) symbols in
// modified classes, and all symbols in removed classes.
incrementalJvmCache.clearCacheForRemovedClasses(changesCollector)
// Normalize the changes and clean up
val dirtyData = changesCollector.getDirtyData(listOf(incrementalJvmCache), EmptyICReporter)
workingDir.deleteRecursively()
@@ -86,18 +211,12 @@ object ClasspathChangesComputer {
)
}
private fun ClasspathSnapshot.getClassSnapshots(): List<ClassSnapshot> {
// If there are duplicate classes on the classpath, retain only the first one to match the compiler's behavior.
// We still need to consider whether to remove duplicate classes based on the class file name or the `ClassId`, as two different
// `ClassId`s can have the same class file name (e.g., nested class `B$C` of top-level class `A` in `first.jar` and nested class `C`
// of top-level class `A$B` in `second.jar` both have the same class file name `A$B$C`).
// - If we use class file name, only `A$B$C` in `first.jar` will be retained. This matches the compiler's behavior because when
// resolving either of those classes, the compiler will only look for `A$B$C` in the first jar, even when the actual class is
// located in the second jar.
// - If we use `ClassId`, both `A$B$C` in `first.jar` and `A$B$C` in `second.jar` will be retained. That means that the snapshot
// of the class in `second.jar` will be considered when computing classpath changes, which is not expected as it doesn't match
// the compiler's behavior.
// Therefore, we will remove duplicate classes based on the class file name, not the `ClassId`.
/**
* Returns all [ClassSnapshot]s in this [ClasspathSnapshot].
*
* If there are duplicate classes on the classpath, retain only the first one to match the compiler's behavior.
*/
private fun ClasspathSnapshot.getNonDuplicateClassSnapshots(): List<ClassSnapshot> {
val classSnapshots = LinkedHashMap<String, ClassSnapshot>(classpathEntrySnapshots.sumOf { it.classSnapshots.size })
for (classpathEntrySnapshot in classpathEntrySnapshots) {
for ((unixStyleRelativePath, classSnapshot) in classpathEntrySnapshot.classSnapshots) {
@@ -239,8 +239,7 @@ private object DirectoryOrJarContentsReader {
*
* The map entries are sorted based on their Unix-style relative paths (to ensure deterministic results across filesystems).
*
* Note: If a jar has duplicate entries, only one of them will be used (there is no guarantee which one will be used, but the selection
* will be deterministic).
* Note: If a jar has duplicate entries after filtering, only the first one is retained.
*/
fun read(
directoryOrJar: File,
@@ -258,31 +257,31 @@ private object DirectoryOrJarContentsReader {
directory: File,
entryFilter: ((unixStyleRelativePath: String, isDirectory: Boolean) -> Boolean)? = null
): LinkedHashMap<String, ByteArray> {
val relativePathsToContents: MutableList<Pair<String, ByteArray>> = mutableListOf()
val relativePathsToContents = mutableMapOf<String, ByteArray>()
directory.walk().forEach { file ->
val unixStyleRelativePath = file.relativeTo(directory).invariantSeparatorsPath
if (entryFilter == null || entryFilter(unixStyleRelativePath, file.isDirectory)) {
relativePathsToContents.add(unixStyleRelativePath to file.readBytes())
relativePathsToContents[unixStyleRelativePath] = file.readBytes()
}
}
return relativePathsToContents.sortedBy { it.first }.toMap(LinkedHashMap())
return relativePathsToContents.toSortedMap().toMap(LinkedHashMap())
}
private fun readJar(
jarFile: File,
entryFilter: ((unixStyleRelativePath: String, isDirectory: Boolean) -> Boolean)? = null
): LinkedHashMap<String, ByteArray> {
val relativePathsToContents: MutableList<Pair<String, ByteArray>> = mutableListOf()
val relativePathsToContents = mutableMapOf<String, ByteArray>()
ZipInputStream(jarFile.inputStream().buffered()).use { zipInputStream ->
while (true) {
val entry = zipInputStream.nextEntry ?: break
val unixStyleRelativePath = entry.name
if (entryFilter == null || entryFilter(unixStyleRelativePath, entry.isDirectory)) {
relativePathsToContents.add(unixStyleRelativePath to zipInputStream.readBytes())
relativePathsToContents.computeIfAbsent(unixStyleRelativePath) { zipInputStream.readBytes() }
}
}
}
return relativePathsToContents.sortedBy { it.first }.toMap(LinkedHashMap())
return relativePathsToContents.toSortedMap().toMap(LinkedHashMap())
}
}
@@ -22,11 +22,9 @@ abstract class ClasspathEntrySnapshotTransform : TransformAction<TransformParame
override fun transform(outputs: TransformOutputs) {
val classpathEntry = inputArtifact.get().asFile
val snapshotFile = outputs.file(CLASSPATH_ENTRY_SNAPSHOT_FILE_NAME)
val snapshotFile = outputs.file(classpathEntry.name.replace('.', '_') + "-snapshot.bin")
val snapshot = ClasspathEntrySnapshotter.snapshot(classpathEntry)
ClasspathEntrySnapshotSerializer.save(snapshotFile, snapshot)
}
}
const val CLASSPATH_ENTRY_SNAPSHOT_FILE_NAME = "classpath-entry-snapshot.bin"
@@ -42,7 +42,6 @@ import org.jetbrains.kotlin.gradle.internal.*
import org.jetbrains.kotlin.gradle.internal.tasks.TaskConfigurator
import org.jetbrains.kotlin.gradle.internal.tasks.TaskWithLocalState
import org.jetbrains.kotlin.gradle.internal.tasks.allOutputFiles
import org.jetbrains.kotlin.gradle.internal.transforms.CLASSPATH_ENTRY_SNAPSHOT_FILE_NAME
import org.jetbrains.kotlin.gradle.internal.transforms.ClasspathEntrySnapshotTransform
import org.jetbrains.kotlin.gradle.logging.GradleKotlinLogger
import org.jetbrains.kotlin.gradle.logging.GradlePrintingMessageCollector
@@ -62,6 +61,7 @@ import org.jetbrains.kotlin.statistics.metrics.BooleanMetrics
import org.jetbrains.kotlin.utils.JsLibraryUtils
import org.jetbrains.kotlin.utils.addToStdlib.cast
import java.io.File
import java.util.LinkedHashSet
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@@ -457,7 +457,8 @@ open class KotlinCompileArgumentsProvider<T : AbstractKotlinCompile<out CommonCo
val isMultiplatform: Boolean = taskProvider.multiPlatformEnabled.get()
private val pluginData = taskProvider.kotlinPluginData?.orNull
val pluginClasspath: FileCollection = listOfNotNull(taskProvider.pluginClasspath, pluginData?.classpath).reduce(FileCollection::plus)
val pluginOptions: CompilerPluginOptions = listOfNotNull(taskProvider.pluginOptions, pluginData?.options).reduce(CompilerPluginOptions::plus)
val pluginOptions: CompilerPluginOptions =
listOfNotNull(taskProvider.pluginOptions, pluginData?.options).reduce(CompilerPluginOptions::plus)
}
class KotlinJvmCompilerArgumentsProvider
@@ -735,7 +736,7 @@ abstract class KotlinCompile @Inject constructor(
with(classpathSnapshotProperties) {
if (isIncrementalCompilationEnabled() && useClasspathSnapshot.get()) {
copyClasspathSnapshotFilesToDir(classpathSnapshot.files.toList(), classpathSnapshotDir.get().asFile)
copyCurrentClasspathEntrySnapshotFiles()
}
}
@@ -825,44 +826,54 @@ abstract class KotlinCompile @Inject constructor(
return super.source(*sources)
}
private fun getClasspathChanges(@Suppress("UNUSED_PARAMETER") inputChanges: InputChanges): ClasspathChanges {
val currentSnapshotFiles = classpathSnapshotProperties.classpathSnapshot.files.toList()
val previousSnapshotFiles = getClasspathSnapshotFilesInDir(classpathSnapshotProperties.classpathSnapshotDir.get().asFile)
// TODO: Compute changes for the changed snapshot files only, ignoring unchanged ones.
// We'll need to be careful when the classpath contains duplicate classes, as we'll need to look at the entire classpath in order to
// detect them.
val currentSnapshot = ClasspathSnapshotSerializer.load(currentSnapshotFiles)
val previousSnapshot = ClasspathSnapshotSerializer.load(previousSnapshotFiles)
return ClasspathChangesComputer.compute(currentSnapshot, previousSnapshot)
}
/**
* Copies classpath snapshot files to the given directory.
*
* To preserve their order, we put them in subdirectories with names being their indices in the original list, as shown below:
* classpathSnapshotDir/0/snapshotFileName
* classpathSnapshotDir/1/snapshotFileName
* ...
* classpathSnapshotDir/N-1/snapshotFileName
*/
private fun copyClasspathSnapshotFilesToDir(classpathSnapshotFiles: List<File>, classpathSnapshotDir: File) {
classpathSnapshotDir.deleteRecursively()
classpathSnapshotDir.mkdirs()
for ((index, file) in classpathSnapshotFiles.withIndex()) {
file.copyTo(File("$classpathSnapshotDir/$index/$CLASSPATH_ENTRY_SNAPSHOT_FILE_NAME"), overwrite = true)
private fun getClasspathChanges(inputChanges: InputChanges): ClasspathChanges {
val fileChanges = inputChanges.getFileChanges(classpathSnapshotProperties.classpathSnapshot).toList()
return if (fileChanges.isEmpty()) {
ClasspathChanges.Available(LinkedHashSet(), LinkedHashSet())
} else {
val currentClasspathEntrySnapshotFiles = classpathSnapshotProperties.classpathSnapshot.files.toList()
val changedCurrentFiles = fileChanges
.filter { it.changeType == ChangeType.ADDED || it.changeType == ChangeType.MODIFIED }
.map { it.file }.toSet()
ClasspathChangesComputer.compute(
currentClasspathEntrySnapshotFiles = currentClasspathEntrySnapshotFiles,
previousClasspathEntrySnapshotFiles = getPreviousClasspathEntrySnapshotFiles(),
unchangedCurrentClasspathEntrySnapshotFiles = currentClasspathEntrySnapshotFiles.filter { it !in changedCurrentFiles }
)
}
}
/**
* Returns all classpath snapshot files in the given directory, sorted by their original indices (the subdirectories' names).
* Copies classpath entry snapshot files of the current build to `classpathSnapshotDir`. They will be used in the next build (see
* [getPreviousClasspathEntrySnapshotFiles]).
*
* See [copyClasspathSnapshotFilesToDir] for the structure of the classpath snapshot directory.
* To preserve their order, we put them in subdirectories with names being their indices, as shown below:
* classpathSnapshotDir/0/a-snapshot.bin
* classpathSnapshotDir/1/b-snapshot.bin
* ...
* classpathSnapshotDir/N-1/z-snapshot.bin
*/
private fun getClasspathSnapshotFilesInDir(classpathSnapshotDir: File): List<File> {
val subDirs = classpathSnapshotDir.listFiles() ?: return emptyList()
return subDirs.toList().sortedBy { it.name.toInt() }.map { File(it, CLASSPATH_ENTRY_SNAPSHOT_FILE_NAME) }
private fun copyCurrentClasspathEntrySnapshotFiles() {
val snapshotFiles = classpathSnapshotProperties.classpathSnapshot.files.toList()
val classpathSnapshotDir = classpathSnapshotProperties.classpathSnapshotDir.get().asFile
classpathSnapshotDir.deleteRecursively()
classpathSnapshotDir.mkdirs()
snapshotFiles.forEachIndexed { index, snapshotFile ->
val targetFile = File("$classpathSnapshotDir/$index/${snapshotFile.name}")
snapshotFile.copyTo(targetFile, overwrite = true)
// Preserve timestamp to find out unchanged files later (see `ClasspathChangesComputer.alignUnchangedSnapshotFiles`)
targetFile.setLastModified(snapshotFile.lastModified())
}
}
/**
* Returns classpath entry snapshot files of the previous build, stored in `classpathSnapshotDir` (see
* [copyCurrentClasspathEntrySnapshotFiles]).
*/
private fun getPreviousClasspathEntrySnapshotFiles(): List<File> {
val classpathSnapshotDir = classpathSnapshotProperties.classpathSnapshotDir.get().asFile
val subDirs = classpathSnapshotDir.listFiles()!!.sortedBy { it.name.toInt() }
return subDirs.map { it.listFiles()!!.single() }
}
}
@@ -5,65 +5,19 @@
package org.jetbrains.kotlin.gradle.incremental
import org.jetbrains.kotlin.gradle.incremental.ClasspathSnapshotTestCommon.*
import org.jetbrains.kotlin.gradle.incremental.ClasspathSnapshotTestCommon.Util.snapshot
import org.jetbrains.kotlin.incremental.ClasspathChanges
import org.jetbrains.kotlin.incremental.LookupSymbol
import org.jetbrains.kotlin.name.ClassId
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.resolve.sam.SAM_LOOKUP_NAME
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
abstract class ClasspathChangesComputerTest : ClasspathSnapshotTestCommon() {
protected abstract val testSourceFile: ChangeableTestSourceFile
private lateinit var originalSnapshot: ClassSnapshot
@Before
fun setUp() {
originalSnapshot = testSourceFile.compileAndSnapshot()
}
/** Adapted version of [ClasspathChanges.Available] for readability in this test. */
private data class Changes(private val lookupSymbols: Set<LookupSymbol>, private val fqNames: Set<FqName>)
private fun computeClassChanges(current: ClassSnapshot, previous: ClassSnapshot): Changes {
val classChanges =
ClasspathChangesComputer.compute(current.toClasspathSnapshot(), previous.toClasspathSnapshot()) as ClasspathChanges.Available
return Changes(HashSet(classChanges.lookupSymbols), HashSet(classChanges.fqNames))
}
private fun ClassSnapshot.toClasspathSnapshot(): ClasspathSnapshot {
return ClasspathSnapshot(
classpathEntrySnapshots = listOf(
ClasspathEntrySnapshot(
LinkedHashMap<String, ClassSnapshot>(1).also {
it[getClassId()!!.getUnixStyleRelativePath()] = this
})
)
)
}
private fun ClassSnapshot.getClassId(): ClassId? {
return when (this) {
is KotlinClassSnapshot -> classInfo.classId
is RegularJavaClassSnapshot -> serializedJavaClass.classId
is EmptyJavaClassSnapshot, is ContentHashJavaClassSnapshot -> null
}
}
private fun ClassId.getUnixStyleRelativePath() = asString().replace('.', '$') + ".class"
/**
* Returns the [FqName] of the class in this source file (e.g., "com/example/Foo$Bar.kt" or
* "com/example/Foo$Bar.java" has [FqName] "com.example.Foo.Bar").
*
* This source file must contain only 1 class.
*/
private fun SourceFile.getClassFqName() =
FqName(unixStyleRelativePath.substringBeforeLast('.').replace('/', '.').replace('$', '.'))
// TODO Add more test cases:
// - private/non-private fields
// - inline functions
@@ -71,37 +25,173 @@ abstract class ClasspathChangesComputerTest : ClasspathSnapshotTestCommon() {
// - adding an annotation
@Test
fun testComputeClassChanges_changedPublicMethodSignature() {
val updatedSnapshot = testSourceFile.changePublicMethodSignature().compileAndSnapshot()
val classChanges = computeClassChanges(updatedSnapshot, originalSnapshot)
abstract fun testSingleClass_changePublicMethodSignature()
@Test
abstract fun testSingleClass_changeMethodImplementation()
@Test
abstract fun testMultipleClasses()
}
class KotlinClassesClasspathChangesComputerTest : ClasspathChangesComputerTest() {
@Test
override fun testSingleClass_changePublicMethodSignature() {
val sourceFile = SimpleKotlinClass(tmpDir)
val previousSnapshot = sourceFile.compileAndSnapshot()
val currentSnapshot = sourceFile.changePublicMethodSignature().compileAndSnapshot()
val changes = ClasspathChangesComputer.compute(listOf(currentSnapshot), listOf(previousSnapshot)).normalize()
val testClassFqName = testSourceFile.sourceFile.getClassFqName()
assertEquals(
Changes(
lookupSymbols = setOf(
LookupSymbol(name = SAM_LOOKUP_NAME.asString(), scope = testClassFqName.asString()),
LookupSymbol(name = "changedPublicMethod", scope = testClassFqName.asString()),
LookupSymbol(name = "publicMethod", scope = testClassFqName.asString())
LookupSymbol(name = SAM_LOOKUP_NAME.asString(), scope = "com.example.SimpleKotlinClass"),
LookupSymbol(name = "changedPublicMethod", scope = "com.example.SimpleKotlinClass"),
LookupSymbol(name = "publicMethod", scope = "com.example.SimpleKotlinClass")
),
fqNames = setOf(
FqName("com.example.SimpleKotlinClass")
),
fqNames = setOf(testClassFqName),
),
classChanges
changes
)
}
@Test
fun testComputeClassChanges_changedMethodImplementation() {
val updatedSnapshot = testSourceFile.changeMethodImplementation().compileAndSnapshot()
val classChanges = computeClassChanges(updatedSnapshot, originalSnapshot)
override fun testSingleClass_changeMethodImplementation() {
val sourceFile = SimpleKotlinClass(tmpDir)
val previousSnapshot = sourceFile.compileAndSnapshot()
val currentSnapshot = sourceFile.changeMethodImplementation().compileAndSnapshot()
val changes = ClasspathChangesComputer.compute(listOf(currentSnapshot), listOf(previousSnapshot)).normalize()
assertEquals(Changes(emptySet(), emptySet()), classChanges)
assertEquals(Changes(emptySet(), emptySet()), changes)
}
@Test
override fun testMultipleClasses() {
val classpathSourceDir = File(testDataDir, "../ClasspathChangesComputerTest/testMultipleClasses/src/kotlin").canonicalFile
val currentSnapshot = snapshotClasspath(File(classpathSourceDir, "current-classpath"), tmpDir)
val previousSnapshot = snapshotClasspath(File(classpathSourceDir, "previous-classpath"), tmpDir)
val changes = ClasspathChangesComputer.compute(currentSnapshot, previousSnapshot).normalize()
assertEquals(
Changes(
lookupSymbols = setOf(
LookupSymbol(name = SAM_LOOKUP_NAME.asString(), scope = "com.example.B"),
LookupSymbol(name = "b2", scope = "com.example.B"),
LookupSymbol(name = "b3", scope = "com.example.B"),
LookupSymbol(name = "b4", scope = "com.example.B"),
LookupSymbol(name = "C", scope = "com.example"),
LookupSymbol(name = "D", scope = "com.example")
),
fqNames = setOf(
FqName("com.example.B"),
FqName("com.example.C"),
FqName("com.example.D")
)
),
changes
)
}
}
class KotlinClassesClasspathChangesComputerTest : ClasspathChangesComputerTest() {
override val testSourceFile = SimpleKotlinClass(tmpDir)
class JavaClassesClasspathChangesComputerTest : ClasspathChangesComputerTest() {
@Test
override fun testSingleClass_changePublicMethodSignature() {
val sourceFile = SimpleJavaClass(tmpDir)
val previousSnapshot = sourceFile.compileAndSnapshot()
val currentSnapshot = sourceFile.changePublicMethodSignature().compileAndSnapshot()
val changes = ClasspathChangesComputer.compute(listOf(currentSnapshot), listOf(previousSnapshot)).normalize()
assertEquals(
Changes(
lookupSymbols = setOf(
LookupSymbol(name = SAM_LOOKUP_NAME.asString(), scope = "com.example.SimpleJavaClass"),
LookupSymbol(name = "changedPublicMethod", scope = "com.example.SimpleJavaClass"),
LookupSymbol(name = "publicMethod", scope = "com.example.SimpleJavaClass")
),
fqNames = setOf(
FqName("com.example.SimpleJavaClass")
),
),
changes
)
}
@Test
override fun testSingleClass_changeMethodImplementation() {
val sourceFile = SimpleJavaClass(tmpDir)
val previousSnapshot = sourceFile.compileAndSnapshot()
val currentSnapshot = sourceFile.changeMethodImplementation().compileAndSnapshot()
val changes = ClasspathChangesComputer.compute(listOf(currentSnapshot), listOf(previousSnapshot)).normalize()
assertEquals(Changes(emptySet(), emptySet()), changes)
}
@Test
override fun testMultipleClasses() {
val classpathSourceDir = File(testDataDir, "../ClasspathChangesComputerTest/testMultipleClasses/src/java").canonicalFile
val currentSnapshot = snapshotClasspath(File(classpathSourceDir, "current-classpath"), tmpDir)
val previousSnapshot = snapshotClasspath(File(classpathSourceDir, "previous-classpath"), tmpDir)
val changes = ClasspathChangesComputer.compute(currentSnapshot, previousSnapshot).normalize()
assertEquals(
Changes(
lookupSymbols = setOf(
LookupSymbol(name = SAM_LOOKUP_NAME.asString(), scope = "com.example.B"),
LookupSymbol(name = "b2", scope = "com.example.B"),
LookupSymbol(name = "b3", scope = "com.example.B"),
LookupSymbol(name = "b4", scope = "com.example.B"),
LookupSymbol(name = "C", scope = "com.example"),
LookupSymbol(name = "D", scope = "com.example"),
LookupSymbol(name = SAM_LOOKUP_NAME.asString(), scope = "com.example.D"),
LookupSymbol(name = "<init>", scope = "com.example.D"),
LookupSymbol(name = "d", scope = "com.example.D")
),
fqNames = setOf(
FqName("com.example.B"),
FqName("com.example.C"),
FqName("com.example.D")
)
),
changes
)
}
}
class JavaClassesClasspathChangesComputerTest : ClasspathChangesComputerTest() {
override val testSourceFile = SimpleJavaClass(tmpDir)
private fun snapshotClasspath(classpathSourceDir: File, tmpDir: TemporaryFolder): ClasspathSnapshot {
val classpathEntrySnapshots = classpathSourceDir.listFiles()!!.map { classpathEntrySourceDir ->
val relativePathsInDir = classpathEntrySourceDir.walk()
.filter { it.extension == "kt" || it.extension == "java" }
.map { file -> file.toRelativeString(classpathEntrySourceDir) }
.sortedBy { it }
val sourceFiles = relativePathsInDir.map { relativePath ->
if (relativePath.endsWith(".kt")) {
KotlinSourceFile(
classpathEntrySourceDir, relativePath,
preCompiledClassFile = ClassFile(
File(classpathEntrySourceDir.path.replace("src/kotlin", "classes/kotlin")),
relativePath.replace(".kt", ".class")
)
)
} else {
SourceFile(classpathEntrySourceDir, relativePath)
}
}
val classFiles = sourceFiles.map { TestSourceFile(it, tmpDir).compile() }
ClasspathEntrySnapshot(
classSnapshots = classFiles.map { it.unixStyleRelativePath to it.snapshot() }.toMap(LinkedHashMap())
)
}
return ClasspathSnapshot(classpathEntrySnapshots)
}
/** Adapted version of [ClasspathChanges.Available] for readability in this test. */
private data class Changes(private val lookupSymbols: Set<LookupSymbol>, private val fqNames: Set<FqName>)
private fun ClasspathChanges.normalize(): Changes {
this as ClasspathChanges.Available
return Changes(lookupSymbols, fqNames)
}
@@ -26,7 +26,7 @@ abstract class ClasspathSnapshotTestCommon {
private val gson by lazy { GsonBuilder().setPrettyPrinting().create() }
protected fun Any.toGson(): String = gson.toJson(this)
class SourceFile(val baseDir: File, relativePath: String) {
open class SourceFile(val baseDir: File, relativePath: String) {
val unixStyleRelativePath: String
init {
@@ -36,14 +36,18 @@ abstract class ClasspathSnapshotTestCommon {
fun asFile() = File(baseDir, unixStyleRelativePath)
}
/** Same as [SourceFile] but with a [TemporaryFolder] to store the results of operations on the [SourceFile]. */
open class TestSourceFile(val sourceFile: SourceFile, protected val tmpDir: TemporaryFolder) {
class KotlinSourceFile(baseDir: File, relativePath: String, val preCompiledClassFile: ClassFile) : SourceFile(baseDir, relativePath)
fun replace(oldValue: String, newValue: String, newBaseDir: File? = null): TestSourceFile {
/** Same as [SourceFile] but with a [TemporaryFolder] to store the results of operations on the [SourceFile]. */
open class TestSourceFile(val sourceFile: SourceFile, private val tmpDir: TemporaryFolder) {
fun replace(oldValue: String, newValue: String, preCompiledKotlinClassFile: ClassFile? = null): TestSourceFile {
val fileContents = sourceFile.asFile().readText()
check(fileContents.contains(oldValue)) { "String '$oldValue' not found in file '${sourceFile.asFile().path}'" }
val newSourceFile = SourceFile(newBaseDir ?: tmpDir.newFolder(), sourceFile.unixStyleRelativePath)
val newSourceFile =
preCompiledKotlinClassFile?.let { KotlinSourceFile(tmpDir.newFolder(), sourceFile.unixStyleRelativePath, it) }
?: SourceFile(tmpDir.newFolder(), sourceFile.unixStyleRelativePath)
newSourceFile.asFile().parentFile.mkdirs()
newSourceFile.asFile().writeText(fileContents.replace(oldValue, newValue))
return TestSourceFile(newSourceFile, tmpDir)
@@ -69,14 +73,12 @@ abstract class ClasspathSnapshotTestCommon {
}
private fun compileKotlin(): List<ClassFile> {
// TODO: Call Kotlin compiler to generate classes (see https://github.com/JetBrains/kotlin/pull/4512#discussion_r679432232)
// Currently, we use the precompiled classes in the test data.
return listOf(
ClassFile(
File(testDataDir.path + "/classes/" + sourceFile.baseDir.name),
sourceFile.unixStyleRelativePath.replace(".kt", ".class")
)
)
// TODO: Call Kotlin compiler to generate classes, for example:
// org.jetbrains.kotlin.test.MockLibraryUtil.compileKotlin(sourceFile.asFile().path, sourceFile.preCompiledClassFile.classRoot)
// However, currently it is not possible (see https://github.com/JetBrains/kotlin/pull/4512#discussion_r679432232), so we use
// the precompiled classes in the test data instead.
sourceFile as KotlinSourceFile
return listOf(sourceFile.preCompiledClassFile)
}
private fun compileJava(): List<ClassFile> {
@@ -125,17 +127,25 @@ abstract class ClasspathSnapshotTestCommon {
}
class SimpleKotlinClass(tmpDir: TemporaryFolder) : ChangeableTestSourceFile(
SourceFile(File(testDataDir, "src/original"), "com/example/SimpleKotlinClass.kt"), tmpDir
KotlinSourceFile(
baseDir = File(testDataDir, "src/original"),
relativePath = "com/example/SimpleKotlinClass.kt",
preCompiledClassFile = ClassFile(File(testDataDir, "classes/original"), "com/example/SimpleKotlinClass.class")
), tmpDir
) {
override fun changePublicMethodSignature() = replace(
"publicMethod()", "changedPublicMethod()",
newBaseDir = File(tmpDir.newFolder(), "changedPublicMethodSignature")
preCompiledKotlinClassFile = ClassFile(
File(testDataDir, "classes/changedPublicMethodSignature"), "com/example/SimpleKotlinClass.class"
)
)
override fun changeMethodImplementation() = replace(
"I'm in a public method", "This method implementation has changed!",
newBaseDir = File(tmpDir.newFolder(), "changedMethodImplementation")
preCompiledKotlinClassFile = ClassFile(
File(testDataDir, "classes/changedMethodImplementation"), "com/example/SimpleKotlinClass.class"
)
)
}
@@ -0,0 +1,6 @@
package com.example;
// Unchanged class
public class A {
public int a = 0;
}
@@ -0,0 +1,9 @@
package com.example;
// Modified class
public class B {
public int b1 = 0; // Unchanged field
public String b2 = ""; // Modified field
//public int b3 = 0; // Removed field
public int b4 = 0; // Added field
}
@@ -0,0 +1,6 @@
package com.example;
// Added class
public class D {
public int d = 0;
}
@@ -0,0 +1,5 @@
package com.example;
public class A {
public int a = 0;
}
@@ -0,0 +1,7 @@
package com.example;
public class B {
public int b1 = 0;
public int b2 = 0;
public int b3 = 0;
}
@@ -0,0 +1,6 @@
package com.example;
// Will be removed
public class C {
public int c = 0;
}
@@ -0,0 +1,6 @@
package com.example
// Unchanged class
class A {
val a: Int = 0
}
@@ -0,0 +1,9 @@
package com.example
// Modified class
public class B {
val b1: Int = 0 // Unchanged field
val b2: String = "" // Modified field
//val b3: Int = 0 // Removed field
val b4: Int = 0 // Added field
}
@@ -0,0 +1,6 @@
package com.example
// Added class
public class D {
val d: Int = 0
}
@@ -0,0 +1,5 @@
package com.example
class A {
val a: Int = 0
}
@@ -0,0 +1,7 @@
package com.example
public class B {
val b1: Int = 0
val b2: Int = 0
val b3: Int = 0
}
@@ -0,0 +1,6 @@
package com.example
// Will be removed
public class C {
val c: Int = 0
}