KT-45777: Compute classpath changes for incremental Kotlin compile

Test: Updated unit tests + incremental compilation integration tests
This commit is contained in:
Hung Nguyen
2021-08-25 16:23:20 +01:00
committed by nataliya.valtman
parent e3d7b7a30e
commit a48bf63630
12 changed files with 473 additions and 420 deletions
@@ -148,7 +148,24 @@ class IncrementalCompilationFirJvmMultiProjectIT : IncrementalCompilationJvmMult
}
class IncrementalCompilationClasspathSnapshotJvmMultiProjectIT : IncrementalCompilationJvmMultiProjectIT() {
override fun defaultBuildOptions() = super.defaultBuildOptions().copy(useClasspathSnapshot = true)
override fun testAddDependencyInLib_expectedFiles(project: Project): Iterable<File> {
// With classpath snapshot, no files are recompiled
return emptyList()
}
override fun testAbiChangeInLib_afterLibClean_expectedFiles(project: Project): Iterable<File> {
// With classpath snapshot, app compilation is incremental
return File(project.projectDir, "app").getFilesByNames("AA.kt", "AAA.kt", "BB.kt", "fooUseA.kt") +
File(project.projectDir, "lib").allKotlinFiles()
}
override fun testCompileLibWithGroovy_expectedFiles(project: Project): Iterable<File> {
// With classpath snapshot, no files in app are recompiled
return listOf(File(project.projectDir, "lib").getFileByName("A.kt"))
}
}
abstract class BaseIncrementalCompilationMultiProjectIT : IncrementalCompilationBaseIT() {
@@ -20,7 +20,26 @@ open class IncrementalJavaChangeDefaultIT : IncrementalCompilationJavaChangesBas
}
class IncrementalJavaChangeClasspathSnapshotIT : IncrementalJavaChangeDefaultIT() {
override fun defaultBuildOptions() = super.defaultBuildOptions().copy(useClasspathSnapshot = true)
@Test
override fun testAbiChangeInLib_changeMethodSignature() {
// With classpath snapshot, fewer Kotlin files are recompiled
doTest(
javaClass, changeSignature,
expectedCompiledFileNames = listOf("JavaClassChild.kt", "useJavaClass.kt")
)
}
@Test
override fun testNonAbiChangeInLib_changeMethodBody() {
// With classpath snapshot, no Kotlin files are recompiled
doTest(
javaClass, changeBody,
expectedCompiledFileNames = emptyList()
)
}
}
class IncrementalJavaChangePreciseIT : IncrementalCompilationJavaChangesBase(usePreciseJavaTracking = true) {
@@ -70,12 +89,12 @@ abstract class IncrementalCompilationJavaChangesBase(val usePreciseJavaTracking:
override fun defaultBuildOptions() = super.defaultBuildOptions().copy(usePreciseJavaTracking = usePreciseJavaTracking)
protected val trackedJavaClass = "TrackedJavaClass.java"
private val javaClass = "JavaClass.java"
protected val javaClass = "JavaClass.java"
protected val changeBody: (String) -> String = { it.replace("Hello, World!", "Hello, World!!!!") }
protected val changeSignature: (String) -> String = { it.replace("String getString", "Object getString") }
@Test
fun testAbiChangeInLib_changeMethodSignature() {
open fun testAbiChangeInLib_changeMethodSignature() {
doTest(
javaClass, changeSignature,
expectedCompiledFileNames = listOf("JavaClassChild.kt", "useJavaClass.kt", "useJavaClassFooMethodUsage.kt")
@@ -83,7 +102,7 @@ abstract class IncrementalCompilationJavaChangesBase(val usePreciseJavaTracking:
}
@Test
fun testNonAbiChangeInLib_changeMethodBody() {
open fun testNonAbiChangeInLib_changeMethodBody() {
doTest(
javaClass, changeBody,
expectedCompiledFileNames = listOf("JavaClassChild.kt", "useJavaClass.kt", "useJavaClassFooMethodUsage.kt")
@@ -5,184 +5,94 @@
package org.jetbrains.kotlin.gradle.incremental
import com.google.common.annotations.VisibleForTesting
import com.intellij.openapi.util.io.FileUtil
import org.jetbrains.kotlin.build.report.BuildReporter
import org.jetbrains.kotlin.build.report.ICReporter
import org.jetbrains.kotlin.build.report.metrics.*
import org.jetbrains.kotlin.cli.common.ExitCode
import org.jetbrains.kotlin.incremental.*
import java.io.File
import org.jetbrains.kotlin.gradle.incremental.ChangesCollectorResult.Success
import org.jetbrains.kotlin.gradle.incremental.ChangesCollectorResult.Failure
import org.jetbrains.kotlin.incremental.storage.FileToCanonicalPathConverter
import java.util.*
import kotlin.collections.LinkedHashMap
/** Computes [ClasspathChanges] between two [ClasspathSnapshot]s .*/
object ClasspathChangesComputer {
fun getChanges(current: ClasspathSnapshot, previous: ClasspathSnapshot): ClasspathChanges {
val changesCollector = ChangesCollector()
return when (collectClasspathChanges(current, previous, changesCollector)) {
Success -> {
val (lookupSymbols, fqNames, _) = changesCollector.getDirtyData(emptyList(), NoOpBuildReporter)
ClasspathChanges.Available(lookupSymbols.toList(), fqNames.toList())
}
is Failure -> ClasspathChanges.NotAvailable.UnableToCompute
}
}
fun compute(currentClasspathSnapshot: ClasspathSnapshot, previousClasspathSnapshot: ClasspathSnapshot): ClasspathChanges {
val currentClassSnapshots = currentClasspathSnapshot.getClassSnapshots()
val previousClassSnapshots = previousClasspathSnapshot.getClassSnapshots()
private fun collectClasspathChanges(
current: ClasspathSnapshot,
previous: ClasspathSnapshot,
changesCollector: ChangesCollector
): ChangesCollectorResult {
if (current.classpathEntrySnapshots.size != previous.classpathEntrySnapshots.size) {
return Failure.AddedRemovedClasspathEntries
}
for (index in current.classpathEntrySnapshots.indices) {
val result = collectClasspathEntryChanges(
current.classpathEntrySnapshots[index],
previous.classpathEntrySnapshots[index],
changesCollector
)
if (result is Failure) {
return result
}
}
return Success
}
private fun collectClasspathEntryChanges(
current: ClasspathEntrySnapshot,
previous: ClasspathEntrySnapshot,
changesCollector: ChangesCollector
): ChangesCollectorResult {
if (current.classSnapshots.size != previous.classSnapshots.size) {
return Failure.AddedRemovedClasses
}
for (key in current.classSnapshots.keys) {
val currentSnapshot = current.classSnapshots[key]!!
val previousSnapshot = previous.classSnapshots[key] ?: return Failure.AddedRemovedClasses
val result = collectClassChanges(currentSnapshot, previousSnapshot, changesCollector)
if (result !is Success) {
return result
}
}
return Success
}
private fun collectClassChanges(
current: ClassSnapshot,
previous: ClassSnapshot,
@Suppress("UNUSED_PARAMETER") changesCollector: ChangesCollector
): ChangesCollectorResult {
if (current is JavaClassSnapshot && previous is JavaClassSnapshot &&
(current !is RegularJavaClassSnapshot || previous !is RegularJavaClassSnapshot)
) {
return Failure.NotYetImplemented
}
// TODO: Store results in changesCollector and return SUCCESS here
computeClassChanges(current, previous)
return Failure.NotYetImplemented
}
@VisibleForTesting
internal fun computeClassChanges(current: ClassSnapshot, previous: ClassSnapshot): DirtyData {
// TODO Create IncrementalJvmCache early once and reuse it here
val workingDir =
FileUtil.createTempDirectory(this::class.java.simpleName, "_WorkingDir_${UUID.randomUUID()}", /* deleteOnExit */ true)
val incrementalJvmCache = IncrementalJvmCache(workingDir, /* targetOutputDir */ null, FileToCanonicalPathConverter)
val changesCollector = ChangesCollector()
when {
current is KotlinClassSnapshot && previous is KotlinClassSnapshot ->
collectKotlinClassChanges(current, previous, incrementalJvmCache, changesCollector)
current is JavaClassSnapshot && previous is JavaClassSnapshot ->
collectJavaClassChanges(current, previous, incrementalJvmCache, changesCollector)
else -> {
// TODO: Handle current is KotlinClassSnapshot && previous is JavaClassSnapshot, and vice versa
error("Incompatible types: ${current.javaClass.name} vs. ${previous.javaClass.name}")
// Store previous class snapshots in incrementalJvmCache, the returned ChangesCollector result is not used.
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 EmptyJavaClassSnapshot -> {
// Nothing to process
}
}
}
// 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.
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 EmptyJavaClassSnapshot -> {
// Nothing to process
}
}
}
incrementalJvmCache.clearCacheForRemovedClasses(changesCollector)
val dirtyData = changesCollector.getDirtyData(listOf(incrementalJvmCache), EmptyICReporter)
workingDir.deleteRecursively()
return changesCollector.getDirtyData(listOf(incrementalJvmCache), NoOpBuildReporter.NoOpICReporter)
}
private fun collectKotlinClassChanges(
current: KotlinClassSnapshot,
previous: KotlinClassSnapshot,
incrementalJvmCache: IncrementalJvmCache,
changesCollector: ChangesCollector
) {
// Store previous snapshot in incrementalJvmCache, the returned ChangesCollector result is not used.
incrementalJvmCache.saveClassToCache(
kotlinClassInfo = previous.classInfo,
sourceFiles = null,
changesCollector = ChangesCollector()
return ClasspathChanges.Available(
lookupSymbols = LinkedHashSet(dirtyData.dirtyLookupSymbols),
fqNames = LinkedHashSet(dirtyData.dirtyClassesFqNames)
)
incrementalJvmCache.clearCacheForRemovedClasses(changesCollector)
// Compute changes between the current snapshot and the previously stored snapshot, and store the result in changesCollector.
incrementalJvmCache.saveClassToCache(
kotlinClassInfo = current.classInfo,
sourceFiles = null,
changesCollector = changesCollector
)
incrementalJvmCache.clearCacheForRemovedClasses(changesCollector)
}
private fun collectJavaClassChanges(
current: JavaClassSnapshot,
previous: JavaClassSnapshot,
incrementalJvmCache: IncrementalJvmCache,
changesCollector: ChangesCollector
) {
// Store previous snapshot in incrementalJvmCache, the returned ChangesCollector result is not used.
val previousSnapshot = (previous as RegularJavaClassSnapshot).serializedJavaClass // TODO Handle unsafe cast
incrementalJvmCache.saveJavaClassProto(/* source */ null, previousSnapshot, ChangesCollector())
incrementalJvmCache.clearCacheForRemovedClasses(changesCollector)
// Compute changes between the current snapshot and the previously stored snapshot, and store the result in changesCollector.
val currentSnapshot = (current as RegularJavaClassSnapshot).serializedJavaClass
incrementalJvmCache.saveJavaClassProto(/* source */ null, currentSnapshot, changesCollector)
incrementalJvmCache.clearCacheForRemovedClasses(changesCollector)
}
}
private sealed class ChangesCollectorResult {
object Success : ChangesCollectorResult()
sealed class Failure : ChangesCollectorResult() {
// TODO: Handle these cases
object AddedRemovedClasspathEntries : Failure()
object AddedRemovedClasses : Failure()
object NotYetImplemented : Failure()
}
}
private object NoOpBuildReporter : BuildReporter(NoOpICReporter, NoOpBuildMetricsReporter) {
object NoOpICReporter : ICReporter {
override fun report(message: () -> String) {}
override fun reportVerbose(message: () -> String) {}
override fun reportCompileIteration(incremental: Boolean, sourceFiles: Collection<File>, exitCode: ExitCode) {}
override fun reportMarkDirtyClass(affectedFiles: Iterable<File>, classFqName: String) {}
override fun reportMarkDirtyMember(affectedFiles: Iterable<File>, scope: String, name: String) {}
override fun reportMarkDirty(affectedFiles: Iterable<File>, reason: String) {}
}
object NoOpBuildMetricsReporter : BuildMetricsReporter {
override fun startMeasure(time: BuildTime, startNs: Long) {}
override fun endMeasure(time: BuildTime, endNs: Long) {}
override fun addTimeMetric(metric: BuildTime, durationMs: Long) {}
override fun addMetric(metric: BuildPerformanceMetric, value: Long) {}
override fun addAttribute(attribute: BuildAttribute) {}
override fun getMetrics(): BuildMetrics = BuildMetrics()
override fun addMetrics(metrics: BuildMetrics?) {}
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`.
val classSnapshots = LinkedHashMap<String, ClassSnapshot>(classpathEntrySnapshots.sumOf { it.classSnapshots.size })
for (classpathEntrySnapshot in classpathEntrySnapshots) {
for ((unixStyleRelativePath, classSnapshot) in classpathEntrySnapshot.classSnapshots) {
classSnapshots.putIfAbsent(unixStyleRelativePath, classSnapshot)
}
}
return classSnapshots.values.toList()
}
}
@@ -43,9 +43,7 @@ class KotlinClassSnapshot(val classInfo: KotlinClassInfo) : ClassSnapshot()
sealed class JavaClassSnapshot : ClassSnapshot()
/** [JavaClassSnapshot] of a typical Java class. */
class RegularJavaClassSnapshot(
val serializedJavaClass: SerializedJavaClass
) : JavaClassSnapshot()
class RegularJavaClassSnapshot(val serializedJavaClass: SerializedJavaClass) : JavaClassSnapshot()
/**
* [JavaClassSnapshot] of a Java class where there is nothing to capture.
@@ -10,8 +10,6 @@ import org.jetbrains.kotlin.incremental.JavaClassProtoMapValueExternalizer
import org.jetbrains.kotlin.incremental.KotlinClassInfo
import org.jetbrains.kotlin.incremental.storage.*
import org.jetbrains.kotlin.load.kotlin.header.KotlinClassHeader
import org.jetbrains.kotlin.name.ClassId
import org.jetbrains.kotlin.name.FqName
import java.io.*
/** Utility to serialize a [ClasspathSnapshot]. */
@@ -93,34 +91,6 @@ object KotlinClassInfoExternalizer : DataExternalizer<KotlinClassInfo> {
}
}
object ClassIdExternalizer : DataExternalizer<ClassId> {
override fun save(output: DataOutput, classId: ClassId) {
FqNameExternalizer.save(output, classId.packageFqName)
FqNameExternalizer.save(output, classId.relativeClassName)
output.writeBoolean(classId.isLocal)
}
override fun read(input: DataInput): ClassId {
return ClassId(
/* packageFqName */ FqNameExternalizer.read(input),
/* relativeClassName */ FqNameExternalizer.read(input),
/* isLocal */ input.readBoolean()
)
}
}
object FqNameExternalizer : DataExternalizer<FqName> {
override fun save(output: DataOutput, fqName: FqName) {
output.writeString(fqName.asString())
}
override fun read(input: DataInput): FqName {
return FqName(input.readString())
}
}
object JavaClassSnapshotExternalizer : DataExternalizer<JavaClassSnapshot> {
override fun save(output: DataOutput, snapshot: JavaClassSnapshot) {
@@ -166,39 +136,27 @@ object EmptyJavaClassSnapshotExternalizer : DataExternalizer<EmptyJavaClassSnaps
interface DataSerializer<T> : DataExternalizer<T> {
fun save(file: File, value: T) {
return FileOutputStream(file).buffered().use {
it.writeValue(value)
return DataOutputStream(FileOutputStream(file).buffered()).use {
save(it, value)
}
}
fun load(file: File): T {
return FileInputStream(file).buffered().use {
it.readValue()
return DataInputStream(FileInputStream(file).buffered()).use {
read(it)
}
}
fun toByteArray(value: T): ByteArray {
val byteArrayOutputStream = ByteArrayOutputStream()
byteArrayOutputStream.buffered().use {
it.writeValue(value)
DataOutputStream(byteArrayOutputStream.buffered()).use {
save(it, value)
}
return byteArrayOutputStream.toByteArray()
}
fun fromByteArray(byteArray: ByteArray): T {
return ByteArrayInputStream(byteArray).buffered().use {
it.readValue()
}
}
private fun OutputStream.writeValue(value: T) {
DataOutputStream(this).use {
save(it, value)
}
}
private fun InputStream.readValue(): T {
return DataInputStream(this).use {
return DataInputStream(ByteArrayInputStream(byteArray).buffered()).use {
read(it)
}
}
@@ -30,8 +30,7 @@ object ClasspathEntrySnapshotter {
val snapshots = ClassSnapshotter.snapshot(classes)
val relativePathsToSnapshotsMap =
classes.map { it.classFile.unixStyleRelativePath }.zip(snapshots).toMap(LinkedHashMap())
val relativePathsToSnapshotsMap = classes.map { it.classFile.unixStyleRelativePath }.zipToMap(snapshots)
return ClasspathEntrySnapshot(relativePathsToSnapshotsMap)
}
}
@@ -48,17 +47,16 @@ object ClassSnapshotter {
*/
fun snapshot(classes: List<ClassFileWithContents>): List<ClassSnapshot> {
// Snapshot Kotlin classes first
val kotlinClassSnapshots: Map<ClassFile, KotlinClassSnapshot?> = classes.associate {
it.classFile to trySnapshotKotlinClass(it)
val kotlinClassSnapshots: Map<ClassFileWithContents, KotlinClassSnapshot?> = classes.associateWith {
trySnapshotKotlinClass(it)
}
// Snapshot Java classes in one invocation
val javaClasses: List<ClassFileWithContents> = classes.filter { kotlinClassSnapshots[it.classFile] == null }
// Snapshot the remaining Java classes in one invocation
val javaClasses: List<ClassFileWithContents> = classes.filter { kotlinClassSnapshots[it] == null }
val snapshots: List<JavaClassSnapshot> = snapshotJavaClasses(javaClasses)
val javaClassSnapshots: Map<ClassFile, JavaClassSnapshot> = javaClasses.map { it.classFile }.zip(snapshots).toMap()
val javaClassSnapshots: Map<ClassFileWithContents, JavaClassSnapshot> = javaClasses.zipToMap(snapshots)
// Return a snapshot for each class
return classes.map { kotlinClassSnapshots[it.classFile] ?: javaClassSnapshots[it.classFile]!! }
return classes.map { kotlinClassSnapshots[it] ?: javaClassSnapshots[it]!! }
}
/** Creates [KotlinClassSnapshot] of the given class, or returns `null` if the class is not a Kotlin class. */
@@ -75,44 +73,56 @@ object ClassSnapshotter {
* Therefore, outer classes and nested classes must be passed together in one invocation of this method.
*/
private fun snapshotJavaClasses(classes: List<ClassFileWithContents>): List<JavaClassSnapshot> {
val classFiles = classes.map { it.classFile }
val classesContents = classes.map { it.contents }
val classNames = classesContents.map { JavaClassName.compute(it) }
val classIds = computeJavaClassIds(classNames)
val classNames: List<JavaClassName> = classes.map { JavaClassName.compute(it.contents) }
val classNameToClassFile: LinkedHashMap<JavaClassName, ClassFileWithContents> = classNames.zipToMap(classes)
// Snapshot special cases first
// Map a class index to its snapshot, or `null` if it will be created later
val specialCaseSnapshots: Map<Int, JavaClassSnapshot?> = classFiles.indices.associateWith { index ->
val className = classNames[index]
val classId = classIds[index]
if (classId.isLocal) {
// A local class can't be referenced from other source files, so any changes in a local class will not cause recompilation
// of other source files. Therefore, the snapshot of a local class is empty.
// In that regard, a nested class of a local class is also considered local (which matches the definition of
// ClassId.isLocal, see ClassId's kdoc). Therefore, we checked `classId.isLocal`, which is a super set of `className is
// LocalClass`.
// We divide classes into 2 categories:
// - Special classes, which includes local, anonymous, or synthetic classes, and their nested classes. These classes can't be
// referenced from other source files, so any changes in these classes will not cause recompilation of other source files.
// Therefore, the snapshots of these classes are empty.
// - Regular classes: Any classes that do not belong to the above category.
val specialClasses = getSpecialClasses(classNames).toSet()
// Snapshot special classes first
val specialClassSnapshots: Map<JavaClassName, JavaClassSnapshot?> = classNames.associateWith {
if (it in specialClasses) {
EmptyJavaClassSnapshot
} else if (className is NestedNonLocalClass && (className.isAnonymous || className.isSynthetic)) {
// An anonymous or synthetic class also can't be referenced from other source files, so its snapshot is also empty.
EmptyJavaClassSnapshot
} else {
null
} else null
}
// Snapshot the remaining regular classes in one invocation
val regularClasses: List<JavaClassName> = classNames.filter { specialClassSnapshots[it] == null }
val regularClassIds: List<ClassId> = computeJavaClassIds(regularClasses)
val regularClassesContents: List<ByteArray> = regularClasses.map { classNameToClassFile[it]!!.contents }
val snapshots: List<RegularJavaClassSnapshot> = JavaClassDescriptorCreator.create(regularClassIds, regularClassesContents).map {
RegularJavaClassSnapshot(it.toSerializedJavaClass())
}
val regularClassSnapshots: LinkedHashMap<JavaClassName, JavaClassSnapshot> = regularClasses.zipToMap(snapshots)
return classNames.map { specialClassSnapshots[it] ?: regularClassSnapshots[it]!! }
}
/** Returns local, anonymous, or synthetic classes, and their nested classes. */
private fun getSpecialClasses(classNames: List<JavaClassName>): List<JavaClassName> {
val specialClasses: MutableMap<JavaClassName, Boolean> = HashMap(classNames.size)
val nameToClassName: Map<String, JavaClassName> = classNames.associateBy { it.name }
fun JavaClassName.isSpecial(): Boolean {
specialClasses[this]?.let { return it }
return if (isAnonymous || isSynthetic) {
true
} else when (this) {
is TopLevelClass -> false
is NestedNonLocalClass -> nameToClassName[outerName]?.isSpecial() ?: error("Class name not found: $outerName")
is LocalClass -> true
}.also {
specialClasses[this] = it
}
}
// Snapshot the remaining classes in one invocation
val remainingClassesIndices: List<Int> = classFiles.indices.filter { specialCaseSnapshots[it] == null }
val remainingClassIds: List<ClassId> = remainingClassesIndices.map { classIds[it] }
val remainingClassesContents: List<ByteArray> = remainingClassesIndices.map { classes[it].contents }
val snapshots: List<JavaClassSnapshot> = JavaClassDescriptorCreator.create(remainingClassIds, remainingClassesContents).map {
RegularJavaClassSnapshot(it.toSerializedJavaClass())
}
val remainingSnapshots: Map<Int, JavaClassSnapshot> /* maps a class index to its snapshot */ =
remainingClassesIndices.zip(snapshots).toMap()
// Return a snapshot for each class
return classFiles.indices.map { specialCaseSnapshots[it] ?: remainingSnapshots[it]!! }
return classNames.filter { it.isSpecial() }
}
}
@@ -120,14 +130,15 @@ object ClassSnapshotter {
private object DirectoryOrJarContentsReader {
/**
* Returns a map from Unix-style relative paths of entries to their contents. The paths are relative to the container (directory or
* jar).
* Returns a map from Unix-style relative paths of entries to their contents. The paths are relative to the given container (directory
* or jar).
*
* The map entries need to satisfy the given filter.
*
* 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).
* 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).
*/
fun read(
directoryOrJar: File,
@@ -172,3 +183,18 @@ private object DirectoryOrJarContentsReader {
return relativePathsToContents.sortedBy { it.first }.toMap(LinkedHashMap())
}
}
/**
* Combines two lists of the same size into a map.
*
* This method is more efficient than calling `[Iterable.zip].toMap()` as it doesn't create short-lived intermediate [Pair]s as done by
* [Iterable.zip].
*/
private fun <K, V> List<K>.zipToMap(other: List<V>): LinkedHashMap<K, V> {
check(this.size == other.size)
val map = LinkedHashMap<K, V>(size)
indices.forEach { index ->
map[this[index]] = other[index]
}
return map
}
@@ -556,7 +556,9 @@ abstract class KotlinCompile @Inject constructor(
it.attributes.attribute(ARTIFACT_TYPE_ATTRIBUTE, CLASSPATH_ENTRY_SNAPSHOT_ARTIFACT_TYPE)
}.files
)
task.classpathSnapshotProperties.classpathSnapshotDir.value(getClasspathSnapshotDir(task)).disallowChanges()
val classpathSnapshotDir = getClasspathSnapshotDir(task)
task.classpathSnapshotProperties.classpathSnapshotDir.value(classpathSnapshotDir).disallowChanges()
task.classpathSnapshotProperties.classpathSnapshotDirFileCollection.from(classpathSnapshotDir)
}
}
}
@@ -604,6 +606,14 @@ abstract class KotlinCompile @Inject constructor(
@get:OutputDirectory
@get:Optional // Set if useClasspathSnapshot == true
abstract val classpathSnapshotDir: DirectoryProperty
/**
* [FileCollection] containing a single file which is [classpathSnapshotDir], used when a [FileCollection] is required instead of a
* [DirectoryProperty].
*/
// Set if useClasspathSnapshot == true
@get:Internal
abstract val classpathSnapshotDirFileCollection: ConfigurableFileCollection
}
@get:Internal
@@ -682,7 +692,7 @@ abstract class KotlinCompile @Inject constructor(
val classpathChanges = when {
!classpathSnapshotProperties.useClasspathSnapshot.get() -> ClasspathChanges.NotAvailable.ClasspathSnapshotIsDisabled
else -> when (changedFiles) {
is ChangedFiles.Known -> getClasspathChanges()
is ChangedFiles.Known -> getClasspathChanges(changedFiles)
is ChangedFiles.Unknown -> ClasspathChanges.NotAvailable.ForNonIncrementalRun
is ChangedFiles.Dependencies -> error("Unexpected type: ${changedFiles.javaClass.name}")
}
@@ -698,9 +708,16 @@ abstract class KotlinCompile @Inject constructor(
)
} else null
with(classpathSnapshotProperties) {
if (isIncrementalCompilationEnabled() && useClasspathSnapshot.get()) {
copyClasspathSnapshotFilesToDir(classpathSnapshot.files.toList(), classpathSnapshotDir.get().asFile)
}
}
val environment = GradleCompilerEnvironment(
defaultCompilerClasspath, messageCollector, outputItemCollector,
outputFiles = allOutputFiles(),
// The compiler runner should not manage (read, modify, or delete) classpathSnapshotDir
outputFiles = allOutputFiles().minus(classpathSnapshotProperties.classpathSnapshotDirFileCollection),
reportingSettings = reportingSettings,
incrementalCompilationEnvironment = icEnv,
kotlinScriptExtensions = sourceFilesExtensions.get().toTypedArray()
@@ -714,12 +731,6 @@ abstract class KotlinCompile @Inject constructor(
environment,
defaultKotlinJavaToolchain.get().providedJvm.get().javaHome
)
with(classpathSnapshotProperties) {
if (isIncrementalCompilationEnabled() && useClasspathSnapshot.get()) {
copyClasspathSnapshotFilesToDir(classpathSnapshot.files.toList(), classpathSnapshotDir.get().asFile)
}
}
}
private fun validateKotlinAndJavaHasSameTargetCompatibility(args: K2JVMCompilerArguments) {
@@ -786,14 +797,29 @@ abstract class KotlinCompile @Inject constructor(
return super.source(*sources)
}
private fun getClasspathChanges(): ClasspathChanges {
val currentSnapshotFiles = classpathSnapshotProperties.classpathSnapshot.files.toList()
private fun getClasspathChanges(knownChangedFiles: ChangedFiles.Known): ClasspathChanges {
// Find current snapshot files that have been changed (added or modified)
val currentSnapshotFiles = classpathSnapshotProperties.classpathSnapshot.files
val addedOrModifiedFiles = knownChangedFiles.modified.toSet()
val (changedCurrentSnapshotFiles, unchangedCurrentSnapshotFiles) = currentSnapshotFiles.partition { it in addedOrModifiedFiles }
// Find previous snapshot files that have been changed (modified or removed)
val previousSnapshotFiles = getClasspathSnapshotFilesInDir(classpathSnapshotProperties.classpathSnapshotDir.get().asFile)
var unchangedSnapshotIndex = 0
var unchangedSnapshot: ByteArray? = unchangedCurrentSnapshotFiles.getOrNull(unchangedSnapshotIndex)?.readBytes()
val changedPreviousSnapshotFiles = previousSnapshotFiles.filter {
if (unchangedSnapshot != null && it.readBytes().contentEquals(unchangedSnapshot)) {
unchangedSnapshotIndex++
unchangedSnapshot = unchangedCurrentSnapshotFiles.getOrNull(unchangedSnapshotIndex)?.readBytes()
false
} else true
}
val currentSnapshot = ClasspathSnapshotSerializer.load(currentSnapshotFiles)
val previousSnapshot = ClasspathSnapshotSerializer.load(previousSnapshotFiles)
// Compute changes for the changed snapshot files only, ignoring unchanged ones
val changedCurrentSnapshot = ClasspathSnapshotSerializer.load(changedCurrentSnapshotFiles)
val changedPreviousSnapshot = ClasspathSnapshotSerializer.load(changedPreviousSnapshotFiles)
return ClasspathChangesComputer.getChanges(currentSnapshot, previousSnapshot)
return ClasspathChangesComputer.compute(changedCurrentSnapshot, changedPreviousSnapshot)
}
/**
@@ -5,8 +5,9 @@
package org.jetbrains.kotlin.gradle.incremental
import org.jetbrains.kotlin.incremental.DirtyData
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
@@ -24,6 +25,45 @@ abstract class ClasspathChangesComputerTest : ClasspathSnapshotTestCommon() {
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 -> 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
@@ -31,31 +71,30 @@ abstract class ClasspathChangesComputerTest : ClasspathSnapshotTestCommon() {
// - adding an annotation
@Test
fun testCollectClassChanges_changedPublicMethodSignature() {
fun testComputeClassChanges_changedPublicMethodSignature() {
val updatedSnapshot = testSourceFile.changePublicMethodSignature().compileAndSnapshot()
val dirtyData = ClasspathChangesComputer.computeClassChanges(updatedSnapshot, originalSnapshot)
val classChanges = computeClassChanges(updatedSnapshot, originalSnapshot)
val testClass = testSourceFile.sourceFile.unixStyleRelativePath.substringBeforeLast('.').replace('/', '.')
val testClassFqName = testSourceFile.sourceFile.getClassFqName()
assertEquals(
DirtyData(
dirtyLookupSymbols = setOf(
LookupSymbol(name = SAM_LOOKUP_NAME.asString(), scope = testClass),
LookupSymbol(name = "publicMethod", scope = testClass),
LookupSymbol(name = "changedPublicMethod", scope = testClass)
Changes(
lookupSymbols = setOf(
LookupSymbol(name = SAM_LOOKUP_NAME.asString(), scope = testClassFqName.asString()),
LookupSymbol(name = "changedPublicMethod", scope = testClassFqName.asString()),
LookupSymbol(name = "publicMethod", scope = testClassFqName.asString())
),
dirtyClassesFqNames = setOf(FqName(testClass)),
dirtyClassesFqNamesForceRecompile = emptySet()
fqNames = setOf(testClassFqName),
),
dirtyData
classChanges
)
}
@Test
fun testCollectClassChanges_changedMethodImplementation() {
fun testComputeClassChanges_changedMethodImplementation() {
val updatedSnapshot = testSourceFile.changeMethodImplementation().compileAndSnapshot()
val dirtyData = ClasspathChangesComputer.computeClassChanges(updatedSnapshot, originalSnapshot)
val classChanges = computeClassChanges(updatedSnapshot, originalSnapshot)
assertEquals(DirtyData(emptySet(), emptySet(), emptySet()), dirtyData)
assertEquals(Changes(emptySet(), emptySet()), classChanges)
}
}