From 2c340888599c81ffa66f2046c9a71e1f65d42027 Mon Sep 17 00:00:00 2001 From: Alexey Tsvetkov Date: Tue, 13 Sep 2016 22:19:18 +0300 Subject: [PATCH] Track changes in java files generated by kapt2 #KT-13500 fixed --- .../tools/kotlin-gradle-plugin-core/pom.xml | 11 ++ .../gradle/tasks/ChangedJavaFilesProcessor.kt | 82 +++++++++++ .../tasks/GradleIncrementalCacheImpl.kt | 19 ++- .../gradle/tasks/IncrementalCachesManager.kt | 49 +++++++ .../jetbrains/kotlin/gradle/tasks/Tasks.kt | 130 ++++++++---------- .../kotlin/gradle/tasks/gradleCacheVersion.kt | 2 +- .../incremental/snapshots/FileSnapshot.kt | 54 ++++++++ .../snapshots/FileSnapshotExternalizer.kt | 40 ++++++ .../incremental/snapshots/FileSnapshotMap.kt | 55 ++++++++ .../snapshots/FileSnapshotProvider.kt | 31 +++++ .../kotlin/incremental/snapshots/hashUtil.kt | 36 +++++ .../jetbrains/kotlin/TestWithWorkingDir.kt | 22 +++ .../snapshots/FileSnapshotMapTest.kt | 67 +++++++++ .../incremental/snapshots/FileSnapshotTest.kt | 76 ++++++++++ .../org/jetbrains/kotlin/gradle/Kapt2IT.kt | 25 ++++ .../kotlin/gradle/util/stringUtil.kt | 4 + 16 files changed, 621 insertions(+), 82 deletions(-) create mode 100644 libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/ChangedJavaFilesProcessor.kt create mode 100644 libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/IncrementalCachesManager.kt create mode 100644 libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshot.kt create mode 100644 libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotExternalizer.kt create mode 100644 libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotMap.kt create mode 100644 libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotProvider.kt create mode 100644 libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/hashUtil.kt create mode 100644 libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/TestWithWorkingDir.kt create mode 100644 libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotMapTest.kt create mode 100644 libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotTest.kt create mode 100644 libraries/tools/kotlin-gradle-plugin/src/test/kotlin/org/jetbrains/kotlin/gradle/util/stringUtil.kt diff --git a/libraries/tools/kotlin-gradle-plugin-core/pom.xml b/libraries/tools/kotlin-gradle-plugin-core/pom.xml index 6c420290b55..a6a3786cf1a 100644 --- a/libraries/tools/kotlin-gradle-plugin-core/pom.xml +++ b/libraries/tools/kotlin-gradle-plugin-core/pom.xml @@ -62,6 +62,7 @@ ${project.basedir}/src/main/kotlin + ${project.basedir}/src/test/kotlin @@ -89,6 +90,16 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 2.19.1 + + + src/test/resources/** + + + diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/ChangedJavaFilesProcessor.kt b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/ChangedJavaFilesProcessor.kt new file mode 100644 index 00000000000..22b502ef050 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/ChangedJavaFilesProcessor.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2010-2016 JetBrains s.r.o. + * + * 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 + * + * http://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 org.jetbrains.kotlin.gradle.tasks + +import org.gradle.api.logging.Logging +import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment +import org.jetbrains.kotlin.com.intellij.lang.java.JavaLanguage +import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer +import org.jetbrains.kotlin.com.intellij.psi.PsiClass +import org.jetbrains.kotlin.com.intellij.psi.PsiFile +import org.jetbrains.kotlin.com.intellij.psi.PsiFileFactory +import org.jetbrains.kotlin.com.intellij.psi.PsiJavaFile +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.incremental.LookupSymbol +import org.jetbrains.kotlin.incremental.snapshots.FileCollectionDiff +import java.io.File +import java.util.* + +internal class ChangedJavaFilesProcessor { + private val log = Logging.getLogger(this.javaClass.simpleName) + private val allSymbols = HashSet() + private val javaLang = JavaLanguage.INSTANCE + private val psiFileFactory: PsiFileFactory by lazy { + val rootDisposable = Disposer.newDisposable() + val configuration = CompilerConfiguration() + val environment = KotlinCoreEnvironment.createForProduction(rootDisposable, configuration, EnvironmentConfigFiles.JVM_CONFIG_FILES) + val project = environment.project + PsiFileFactory.getInstance(project) + } + + val allChangedSymbols: Collection + get() = allSymbols + + fun process(filesDiff: FileCollectionDiff): ChangesEither { + if (filesDiff.removed.any()) { + log.kotlinDebug { "Some java files are removed: [${filesDiff.removed.joinToString()}]" } + return ChangesEither.Unknown() + } + + val symbols = HashSet() + for (javaFile in filesDiff.newOrModified) { + assert(javaFile.extension.equals("java", ignoreCase = true)) + + val psiFile = javaFile.psiFile() + if (psiFile !is PsiJavaFile) { + log.kotlinDebug { "Expected PsiJavaFile, got ${psiFile?.javaClass}" } + return ChangesEither.Unknown() + } + + psiFile.classes.forEach { it.addLookupSymbols(symbols) } + } + allSymbols.addAll(symbols) + return ChangesEither.Known(lookupSymbols = symbols) + } + + private fun PsiClass.addLookupSymbols(symbols: MutableSet) { + val fqn = qualifiedName.orEmpty() + + symbols.add(LookupSymbol(name.orEmpty(), if (fqn == name) "" else fqn.removeSuffix("." + name!!))) + methods.forEach { symbols.add(LookupSymbol(it.name, fqn)) } + fields.forEach { symbols.add(LookupSymbol(it.name.orEmpty(), fqn)) } + innerClasses.forEach { it.addLookupSymbols(symbols) } + } + + private fun File.psiFile(): PsiFile? = + psiFileFactory.createFileFromText(nameWithoutExtension, javaLang, readText()) +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/GradleIncrementalCacheImpl.kt b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/GradleIncrementalCacheImpl.kt index be5dac7f27e..f6b7c9fa520 100644 --- a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/GradleIncrementalCacheImpl.kt +++ b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/GradleIncrementalCacheImpl.kt @@ -21,22 +21,28 @@ import org.jetbrains.kotlin.build.GeneratedJvmClass import org.jetbrains.kotlin.incremental.CompilationResult import org.jetbrains.kotlin.incremental.IncrementalCacheImpl import org.jetbrains.kotlin.incremental.dumpCollection +import org.jetbrains.kotlin.incremental.snapshots.FileCollectionDiff +import org.jetbrains.kotlin.incremental.snapshots.FileSnapshotMap +import org.jetbrains.kotlin.incremental.snapshots.SimpleFileSnapshotProviderImpl import org.jetbrains.kotlin.incremental.storage.BasicStringMap import org.jetbrains.kotlin.incremental.storage.PathStringDescriptor import org.jetbrains.kotlin.incremental.storage.StringCollectionExternalizer import org.jetbrains.kotlin.modules.TargetId import java.io.File -class GradleIncrementalCacheImpl(targetDataRoot: File, targetOutputDir: File?, target: TargetId) : IncrementalCacheImpl(targetDataRoot, targetOutputDir, target) { - +internal class GradleIncrementalCacheImpl(targetDataRoot: File, targetOutputDir: File?, target: TargetId) : IncrementalCacheImpl(targetDataRoot, targetOutputDir, target) { companion object { private val SOURCES_TO_CLASSFILES = "sources-to-classfiles" + private val FILE_SNAPSHOT = "file-snapshot" } - private val loggerInstance = Logging.getLogger(this.javaClass) - fun getLogger() = loggerInstance + private val log = Logging.getLogger(this.javaClass) private val sourceToClassfilesMap = registerMap(SourceToClassfilesMap(SOURCES_TO_CLASSFILES.storageFile)) + private val fileSnapshotMap = registerMap(FileSnapshotMap(FILE_SNAPSHOT.storageFile)) + + fun compareAndUpdateFileSnapshots(files: Iterable): FileCollectionDiff = + fileSnapshotMap.compareAndUpdate(files, SimpleFileSnapshotProviderImpl()) fun removeClassfilesBySources(sources: Iterable): Unit = sources.forEach { sourceToClassfilesMap.remove(it) } @@ -60,11 +66,10 @@ class GradleIncrementalCacheImpl(targetDataRoot: File, targetOutputDir: File?, t // TODO: do it in the code that uses cache, since cache should not generally delete anything outside of it! // but for a moment it is an easiest solution to implement get(file).forEach { - getLogger().debug("[KOTLIN] Deleting $it on clearing cache for $file") + log.kotlinDebug { "Deleting $it on clearing cache for $file" } it.delete() } storage.remove(file.absolutePath) } } -} - +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/IncrementalCachesManager.kt b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/IncrementalCachesManager.kt new file mode 100644 index 00000000000..e2cddce5388 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/IncrementalCachesManager.kt @@ -0,0 +1,49 @@ +package org.jetbrains.kotlin.gradle.tasks + +import org.jetbrains.kotlin.incremental.LookupStorage +import org.jetbrains.kotlin.modules.TargetId +import java.io.File + +internal class IncrementalCachesManager ( + private val targetId: TargetId, + private val cacheDirectory: File, + private val outputDir: File +) { + private val incrementalCacheDir = File(cacheDirectory, "increCache.${targetId.name}") + private val lookupCacheDir = File(cacheDirectory, "lookups") + private var incrementalCacheOpen = false + private var lookupCacheOpen = false + + val incrementalCache: GradleIncrementalCacheImpl by lazy { + incrementalCacheOpen = true + GradleIncrementalCacheImpl(targetDataRoot = incrementalCacheDir.apply { mkdirs() }, targetOutputDir = outputDir, target = targetId) + } + + val lookupCache: LookupStorage by lazy { + lookupCacheOpen = true + LookupStorage(lookupCacheDir.apply { mkdirs() }) + } + + fun clean() { + close(flush = false) + cacheDirectory.deleteRecursively() + } + + fun close(flush: Boolean = false) { + if (incrementalCacheOpen) { + if (flush) { + incrementalCache.flush(false) + } + incrementalCache.close() + incrementalCacheOpen = false + } + + if (lookupCacheOpen) { + if (flush) { + lookupCache.flush(false) + } + lookupCache.close() + lookupCacheOpen = false + } + } +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/Tasks.kt b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/Tasks.kt index 5e52acaa763..fdd3931e02d 100644 --- a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/Tasks.kt +++ b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/Tasks.kt @@ -43,6 +43,7 @@ import org.jetbrains.kotlin.config.Services import org.jetbrains.kotlin.incremental.* import org.jetbrains.kotlin.incremental.components.LookupTracker import org.jetbrains.kotlin.incremental.components.SourceRetentionAnnotationHandler +import org.jetbrains.kotlin.incremental.snapshots.FileCollectionDiff import org.jetbrains.kotlin.modules.TargetId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.progress.CompilationCanceledStatus @@ -155,7 +156,7 @@ open class KotlinCompile() : AbstractKotlinCompile() { private val sourceRoots = HashSet() // lazy because name is probably not available when constructor is called - val taskBuildDirectory: File by lazy { File(File(project.buildDir, KOTLIN_BUILD_DIR_NAME), name) } + val taskBuildDirectory: File by lazy { File(File(project.buildDir, KOTLIN_BUILD_DIR_NAME), name).apply { mkdirs() } } private val cacheDirectory: File by lazy { File(taskBuildDirectory, CACHES_DIR_NAME) } private val dirtySourcesSinceLastTimeFile: File by lazy { File(taskBuildDirectory, DIRTY_SOURCES_FILE_NAME) } private val lastBuildInfoFile: File by lazy { File(taskBuildDirectory, LAST_BUILD_INFO_FILE_NAME) } @@ -177,6 +178,9 @@ open class KotlinCompile() : AbstractKotlinCompile() { private val compileClasspath: Iterable get() = (classpath + additionalClasspath) .filterTo(LinkedHashSet()) { it.exists() } + private val kapt2GeneratedSourcesDir: File + get() = File(project.buildDir, "generated/source/kapt2") + val kaptOptions = KaptOptions() val pluginOptions = CompilerPluginOptions() @@ -252,10 +256,9 @@ open class KotlinCompile() : AbstractKotlinCompile() { val targetType = "java-production" val moduleName = args.moduleName - val targets = listOf(TargetId(moduleName, targetType)) + val targetId = TargetId(moduleName, targetType) val outputDir = destinationDir - val caches = hashMapOf() - val lookupStorage = LookupStorage(File(cacheDirectory, "lookups")) + val caches = IncrementalCachesManager(targetId, cacheDirectory, outputDir) val lookupTracker = LookupTrackerImpl(LookupTracker.DO_NOTHING) var currentRemoved = removed.filter { it.isKotlinFile() } val allGeneratedFiles = hashSetOf>() @@ -263,43 +266,6 @@ open class KotlinCompile() : AbstractKotlinCompile() { val lastBuildInfo = BuildInfo.read(lastBuildInfoFile) logger.kotlinDebug { "Last Kotlin Build info for task $path -- $lastBuildInfo" } - fun getOrCreateIncrementalCache(target: TargetId): GradleIncrementalCacheImpl { - val cacheDir = File(cacheDirectory, "increCache.${target.name}") - cacheDir.mkdirs() - return GradleIncrementalCacheImpl(targetDataRoot = cacheDir, targetOutputDir = outputDir, target = target) - } - - fun getIncrementalCache(it: TargetId) = caches.getOrPut(it, { getOrCreateIncrementalCache(it) }) - - fun PsiClass.addLookupSymbols(symbols: MutableSet) { - val fqn = qualifiedName.orEmpty() - - symbols.add(LookupSymbol(name.orEmpty(), if (fqn == name) "" else fqn.removeSuffix("." + name!!))) - methods.forEach { symbols.add(LookupSymbol(it.name, fqn)) } - fields.forEach { symbols.add(LookupSymbol(it.name.orEmpty(), fqn)) } - innerClasses.forEach { it.addLookupSymbols(symbols) } - } - - val dirtyJavaLookupSymbols: Lazy> = lazy { - val symbols = HashSet() - val modifiedJavaFiles = modified.filter { it.isJavaFile() } - if (modifiedJavaFiles.isEmpty()) return@lazy symbols - - val rootDisposable = Disposer.newDisposable() - val configuration = CompilerConfiguration() - val environment = KotlinCoreEnvironment.createForProduction(rootDisposable, configuration, EnvironmentConfigFiles.JVM_CONFIG_FILES) - val project = environment.project - val psiFileFactory = PsiFileFactory.getInstance(project) as PsiFileFactoryImpl - - modifiedJavaFiles.forEach { - val javaFile = psiFileFactory.createFileFromText(it.nameWithoutExtension, Language.findLanguageByID("JAVA")!!, it.readText()) - if (javaFile is PsiJavaFile) { - javaFile.classes.forEach { it.addLookupSymbols(symbols) } - } - } - symbols - } - fun getClasspathChanges(modifiedClasspath: List): ChangesEither { if (modifiedClasspath.isEmpty()) { logger.kotlinDebug { "No classpath changes" } @@ -340,11 +306,10 @@ open class KotlinCompile() : AbstractKotlinCompile() { return ChangesEither.Known(symbols, fqNames) } - fun calculateSourcesToCompile(): Pair, Boolean> { + fun calculateSourcesToCompile(javaFilesProcessor: ChangedJavaFilesProcessor): Pair, Boolean> { fun rebuild(reason: String): Pair, Boolean> { logger.kotlinInfo("Non-incremental compilation will be performed: $reason") - targets.forEach { getIncrementalCache(it).clean() } - lookupStorage.clean() + caches.clean() dirtySourcesSinceLastTimeFile.delete() return Pair(sources.toSet(), false) } @@ -352,12 +317,11 @@ open class KotlinCompile() : AbstractKotlinCompile() { if (!incremental) return rebuild("incremental compilation is not enabled") if (!isIncrementalRequested) return rebuild("inputs' changes are unknown (first or clean build)") - // TODO: store java files in the cache and extract removed symbols from it here - val illegalRemovedFiles = removed.filter { it.isJavaFile() || it.hasClassFileExtension() } - if (illegalRemovedFiles.any()) return rebuild("unsupported removed files: ${filesToString(illegalRemovedFiles)}") + val removedClassFiles = removed.filter(File::hasClassFileExtension) + if (removedClassFiles.any()) return rebuild("Removed class files: ${filesToString(removedClassFiles)}") - val illegalModifiedFiles = modified.filter(File::hasClassFileExtension) - if (illegalModifiedFiles.any()) return rebuild("unsupported modified files: ${filesToString(illegalModifiedFiles)}") + val modifiedClassFiles = modified.filter(File::hasClassFileExtension) + if (modifiedClassFiles.any()) return rebuild("Modified class files: ${filesToString(modifiedClassFiles)}") val modifiedClasspathEntries = modified.filter { it in classpath } val classpathChanges = getClasspathChanges(modifiedClasspathEntries) @@ -367,21 +331,28 @@ open class KotlinCompile() : AbstractKotlinCompile() { if (classpathChanges !is ChangesEither.Known) { throw AssertionError("Unknown implementation of ChangesEither: ${classpathChanges.javaClass}") } + val javaFilesDiff = FileCollectionDiff( + newOrModified = modified.filter { it.isJavaFile() }, + removed = removed.filter { it.isJavaFile() }) + val javaFilesChanges = javaFilesProcessor.process(javaFilesDiff) + val affectedJavaSymbols = when (javaFilesChanges) { + is ChangesEither.Known -> javaFilesChanges.lookupSymbols + is ChangesEither.Unknown -> return rebuild("Could not get changes for java files") + } val dirtyFiles = modified.filter { it.isKotlinFile() }.toMutableSet() val lookupSymbols = HashSet() - lookupSymbols.addAll(dirtyJavaLookupSymbols.value) + lookupSymbols.addAll(affectedJavaSymbols) lookupSymbols.addAll(classpathChanges.lookupSymbols) if (lookupSymbols.any()) { - val dirtyFilesFromLookups = mapLookupSymbolsToFiles(lookupStorage, lookupSymbols, logAction, ::projectRelativePath) + val dirtyFilesFromLookups = mapLookupSymbolsToFiles(caches.lookupCache, lookupSymbols, logAction, ::projectRelativePath) dirtyFiles.addAll(dirtyFilesFromLookups) } - val allCaches = targets.map(::getIncrementalCache) - val dirtyClassesFqNames = classpathChanges.fqNames.flatMap { withSubtypes(it, allCaches) } + val dirtyClassesFqNames = classpathChanges.fqNames.flatMap { withSubtypes(it, listOf(caches.incrementalCache)) } if (dirtyClassesFqNames.any()) { - val dirtyFilesFromFqNames = mapClassesFqNamesToFiles(allCaches, dirtyClassesFqNames, logAction, ::projectRelativePath) + val dirtyFilesFromFqNames = mapClassesFqNamesToFiles(listOf(caches.incrementalCache), dirtyClassesFqNames, logAction, ::projectRelativePath) dirtyFiles.addAll(dirtyFilesFromFqNames) } @@ -407,9 +378,7 @@ open class KotlinCompile() : AbstractKotlinCompile() { cleanupOnError() } - lookupStorage.flush(false) - lookupStorage.close() - caches.values.forEach { it.flush(false); it.close() } + caches.close(flush = true) logger.debug("flushed incremental caches") when (exitCode) { @@ -432,8 +401,9 @@ open class KotlinCompile() : AbstractKotlinCompile() { logger.warn(USING_EXPERIMENTAL_INCREMENTAL_MESSAGE) anyClassesCompiled = false + val javaFilesProcessor = ChangedJavaFilesProcessor() // TODO: decide what to do if no files are considered dirty - rebuild or skip the module - var (sourcesToCompile, isIncrementalDecided) = calculateSourcesToCompile() + var (sourcesToCompile, isIncrementalDecided) = calculateSourcesToCompile(javaFilesProcessor) if (isIncrementalDecided) { additionalClasspath.add(destinationDir) @@ -452,12 +422,9 @@ open class KotlinCompile() : AbstractKotlinCompile() { var exitCode = ExitCode.OK while (sourcesToCompile.any() || currentRemoved.any()) { val removedAndModified = (sourcesToCompile + currentRemoved).toList() - val outdatedClasses = targets.flatMap { getIncrementalCache(it).classesBySources(removedAndModified) } - - targets.forEach { getIncrementalCache(it).let { - it.markOutputClassesDirty(removedAndModified) - it.removeClassfilesBySources(removedAndModified) - }} + val outdatedClasses = caches.incrementalCache.classesBySources(removedAndModified) + caches.incrementalCache.markOutputClassesDirty(removedAndModified) + caches.incrementalCache.removeClassfilesBySources(removedAndModified) // can be empty if only removed sources are present if (sourcesToCompile.isNotEmpty()) { @@ -470,7 +437,7 @@ open class KotlinCompile() : AbstractKotlinCompile() { val text = existingSource.map { it.canonicalPath }.joinToString(separator = System.getProperty("line.separator")) dirtySourcesSinceLastTimeFile.writeText(text) - val compilerOutput = compileChanged(targets, existingSource.toSet(), outputDir, args, ::getIncrementalCache, lookupTracker) + val compilerOutput = compileChanged(listOf(targetId), existingSource.toSet(), outputDir, args, { caches.incrementalCache }, lookupTracker) exitCode = compilerOutput.exitCode if (exitCode == ExitCode.OK) { @@ -483,20 +450,36 @@ open class KotlinCompile() : AbstractKotlinCompile() { } allGeneratedFiles.addAll(compilerOutput.generatedFiles) - val compilationResult = updateIncrementalCaches(targets, compilerOutput.generatedFiles, + val compilationResult = updateIncrementalCaches(listOf(targetId), compilerOutput.generatedFiles, compiledWithErrors = exitCode != ExitCode.OK, - getIncrementalCache = { caches[it]!! }) + getIncrementalCache = { caches.incrementalCache }) - lookupStorage.update(lookupTracker, sourcesToCompile, currentRemoved) + caches.lookupCache.update(lookupTracker, sourcesToCompile, currentRemoved) + + val generatedJavaFiles = kapt2GeneratedSourcesDir.walk().filter { it.isJavaFile() }.toList() + val generatedJavaFilesDiff = caches.incrementalCache.compareAndUpdateFileSnapshots(generatedJavaFiles) if (!isIncrementalDecided) { artifactFile?.let { artifactDifferenceRegistry?.remove(it) } break } - val (dirtyLookupSymbols, dirtyClassFqNames) = compilationResult.getDirtyData(caches.values, logAction) - sourcesToCompile = mapLookupSymbolsToFiles(lookupStorage, dirtyLookupSymbols, logAction, ::projectRelativePath, excludes = sourcesToCompile) + - mapClassesFqNamesToFiles(caches.values, dirtyClassFqNames, logAction, ::projectRelativePath, excludes = sourcesToCompile) + val (dirtyLookupSymbols, dirtyClassFqNames) = compilationResult.getDirtyData(listOf(caches.incrementalCache), logAction) + val generatedJavaFilesChanges = javaFilesProcessor.process(generatedJavaFilesDiff) + val dirtyKotlinFilesFromJava = when (generatedJavaFilesChanges) { + is ChangesEither.Unknown -> { + logger.kotlinDebug { "Could not get changes for generated java files, recompiling all kotlin" } + isIncrementalDecided = false + sources.toSet() + } + is ChangesEither.Known -> { + mapLookupSymbolsToFiles(caches.lookupCache, generatedJavaFilesChanges.lookupSymbols, logAction, ::projectRelativePath, excludes = sourcesToCompile) + } + else -> throw IllegalStateException("Unknown ChangesEither implementation: $generatedJavaFiles") + } + sourcesToCompile = dirtyKotlinFilesFromJava + + mapLookupSymbolsToFiles(caches.lookupCache, dirtyLookupSymbols, logAction, ::projectRelativePath, excludes = sourcesToCompile) + + mapClassesFqNamesToFiles(listOf(caches.incrementalCache), dirtyClassFqNames, logAction, ::projectRelativePath, excludes = sourcesToCompile) buildDirtyLookupSymbols.addAll(dirtyLookupSymbols) buildDirtyFqNames.addAll(dirtyClassFqNames) @@ -507,8 +490,8 @@ open class KotlinCompile() : AbstractKotlinCompile() { } } - if (exitCode == ExitCode.OK) { - buildDirtyLookupSymbols.addAll(dirtyJavaLookupSymbols.value) + if (exitCode == ExitCode.OK && isIncrementalDecided) { + buildDirtyLookupSymbols.addAll(javaFilesProcessor.allChangedSymbols) } if (artifactFile != null && artifactDifferenceRegistry != null) { val dirtyData = DirtyData(buildDirtyLookupSymbols, buildDirtyFqNames) @@ -634,7 +617,6 @@ open class KotlinCompile() : AbstractKotlinCompile() { extension.equals(JavaFileType.INSTANCE.defaultExtension, ignoreCase = true) private fun File.isKapt2GeneratedDirectory(): Boolean { - val kapt2GeneratedSourcesDir = File(project.buildDir, "generated/source/kapt2") if (!kapt2GeneratedSourcesDir.isDirectory) return false return FileUtil.isAncestor(kapt2GeneratedSourcesDir, this, /* strict = */ false) } diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/gradleCacheVersion.kt b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/gradleCacheVersion.kt index 5f9778ddb92..d30ca099f37 100644 --- a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/gradleCacheVersion.kt +++ b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/gradleCacheVersion.kt @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.config.IncrementalCompilation import org.jetbrains.kotlin.incremental.CacheVersion import java.io.File -const val GRADLE_CACHE_VERSION = 0 +const val GRADLE_CACHE_VERSION = 1 const val GRADLE_CACHE_VERSION_FILE_NAME = "gradle-format-version.txt" fun gradleCacheVersion(dataRoot: File): CacheVersion = diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshot.kt b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshot.kt new file mode 100644 index 00000000000..4a104c9ff74 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshot.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2010-2016 JetBrains s.r.o. + * + * 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 + * + * http://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 org.jetbrains.kotlin.incremental.snapshots + +import java.io.File +import java.util.* + +internal class FileSnapshot( + val file: File, + val length: Long, + val hash: ByteArray +) { + init { + assert(!file.isDirectory) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other?.javaClass != javaClass) return false + + other as FileSnapshot + + if (file != other.file) return false + if (length != other.length) return false + if (!Arrays.equals(hash, other.hash)) return false + + return true + } + + override fun hashCode(): Int { + var result = file.hashCode() + result = 31 * result + length.hashCode() + result = 31 * result + Arrays.hashCode(hash) + return result + } + + override fun toString(): String { + return "FileSnapshot(file=$file, length=$length, hash=${Arrays.toString(hash)})" + } +} diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotExternalizer.kt b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotExternalizer.kt new file mode 100644 index 00000000000..56c32313fe8 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotExternalizer.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2010-2016 JetBrains s.r.o. + * + * 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 + * + * http://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 org.jetbrains.kotlin.incremental.snapshots + +import org.jetbrains.kotlin.com.intellij.util.io.DataExternalizer +import java.io.DataInput +import java.io.DataOutput +import java.io.File + +internal object FileSnapshotExternalizer : DataExternalizer { + override fun save(out: DataOutput, value: FileSnapshot) { + out.writeUTF(value.file.canonicalPath) + out.writeLong(value.length) + out.writeInt(value.hash.size) + out.write(value.hash) + } + + override fun read(input: DataInput): FileSnapshot { + val file = File(input.readUTF()) + val length = input.readLong() + val hashSize = input.readInt() + val hash = ByteArray(hashSize) + input.readFully(hash) + return FileSnapshot(file, length, hash) + } +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotMap.kt b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotMap.kt new file mode 100644 index 00000000000..06448b23c15 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotMap.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2010-2016 JetBrains s.r.o. + * + * 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 + * + * http://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 org.jetbrains.kotlin.incremental.snapshots + +import org.jetbrains.kotlin.incremental.storage.BasicStringMap +import org.jetbrains.kotlin.incremental.storage.PathStringDescriptor +import java.io.File +import java.util.* + +internal class FileSnapshotMap(storageFile: File) : BasicStringMap(storageFile, PathStringDescriptor, FileSnapshotExternalizer) { + override fun dumpValue(value: FileSnapshot): String = + value.toString() + + fun compareAndUpdate(newFiles: Iterable, snapshotProvider: FileSnapshotProvider): FileCollectionDiff { + val newOrModified = ArrayList() + val removed = ArrayList() + + val newPaths = newFiles.mapTo(HashSet()) { it.canonicalPath } + for (oldPath in storage.keys) { + if (oldPath !in newPaths) { + storage.remove(oldPath) + removed.add(File(oldPath)) + } + } + + for (path in newPaths) { + val file = File(path) + val oldSnapshot = storage[path] + val newSnapshot = snapshotProvider[file] + + if (oldSnapshot == null || oldSnapshot != newSnapshot) { + newOrModified.add(file) + storage[path] = newSnapshot + } + } + + return FileCollectionDiff(newOrModified, removed) + } +} + +internal class FileCollectionDiff(val newOrModified: Iterable, val removed: Iterable) \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotProvider.kt b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotProvider.kt new file mode 100644 index 00000000000..20aa79f480f --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotProvider.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2010-2016 JetBrains s.r.o. + * + * 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 + * + * http://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 org.jetbrains.kotlin.incremental.snapshots + +import java.io.File + +internal interface FileSnapshotProvider { + operator fun get(file: File): FileSnapshot +} + +internal class SimpleFileSnapshotProviderImpl : FileSnapshotProvider { + override fun get(file: File): FileSnapshot { + val length = file.length() + val hash = file.md5 + return FileSnapshot(file, length, hash) + } +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/hashUtil.kt b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/hashUtil.kt new file mode 100644 index 00000000000..d428e605c94 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin-core/src/main/kotlin/org/jetbrains/kotlin/incremental/snapshots/hashUtil.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2010-2016 JetBrains s.r.o. + * + * 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 + * + * http://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 org.jetbrains.kotlin.incremental.snapshots + +import java.io.File +import java.security.MessageDigest + +val File.md5: ByteArray + get() { + val messageDigest = MessageDigest.getInstance("MD5") + val buffer = ByteArray(4048) + inputStream().use { input -> + while (true) { + val len = input.read(buffer) + if (len < 0) { + break + } + messageDigest.update(buffer, 0, len) + } + } + return messageDigest.digest() + } diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/TestWithWorkingDir.kt b/libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/TestWithWorkingDir.kt new file mode 100644 index 00000000000..4574e560757 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/TestWithWorkingDir.kt @@ -0,0 +1,22 @@ +package org.jetbrains.kotlin + +import org.jetbrains.kotlin.com.intellij.openapi.util.io.FileUtil +import org.junit.After +import org.junit.Before +import java.io.File +import kotlin.properties.Delegates + +abstract class TestWithWorkingDir { + protected var workingDir: File by Delegates.notNull() + private set + + @Before + open fun setUp() { + workingDir = FileUtil.createTempDirectory(this.javaClass.simpleName, null) + } + + @After + open fun tearDown() { + workingDir.deleteRecursively() + } +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotMapTest.kt b/libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotMapTest.kt new file mode 100644 index 00000000000..b3812a1b317 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotMapTest.kt @@ -0,0 +1,67 @@ +package org.jetbrains.kotlin.incremental.snapshots + +import org.jetbrains.kotlin.TestWithWorkingDir +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.io.File +import kotlin.properties.Delegates + +class FileSnapshotMapTest : TestWithWorkingDir() { + private var snapshotMap: FileSnapshotMap by Delegates.notNull() + + @Before + override fun setUp() { + super.setUp() + val caches = File(workingDir, "caches").apply { mkdirs() } + val snapshotMapFile = File(caches, "snapshots.tab") + snapshotMap = FileSnapshotMap(snapshotMapFile) + } + + @After + override fun tearDown() { + snapshotMap.flush(false) + snapshotMap.close() + super.tearDown() + } + + @Test + fun testSnapshotMap() { + val src = File(workingDir, "src").apply { mkdirs() } + val foo = File(src, "foo").apply { mkdirs() } + + val removedTxt = File(foo, "removed.txt").apply { writeText("removed") } + val unchangedTxt = File(foo, "unchanged.txt").apply { writeText("unchanged") } + val changedTxt = File(foo, "changed.txt").apply { writeText("changed") } + + val snapshotProvider = SimpleFileSnapshotProviderImpl() + val diff1 = snapshotMap.compareAndUpdate(src.filesWithExt("txt"), snapshotProvider) + + assertArrayEquals("diff1.removed", + diff1.removed.toSortedPaths(), + emptyArray()) + assertArrayEquals("diff1.newOrModified", + diff1.newOrModified.toSortedPaths(), + listOf(removedTxt, unchangedTxt, changedTxt).toSortedPaths()) + + removedTxt.delete() + unchangedTxt.writeText("unchanged") + changedTxt.writeText("degnahc") + val newTxt = File(foo, "new.txt").apply { writeText("new") } + + val diff2 = snapshotMap.compareAndUpdate(src.filesWithExt("txt"), snapshotProvider) + assertArrayEquals("diff2.removed", + diff2.removed.toSortedPaths(), + listOf(removedTxt).toSortedPaths()) + assertArrayEquals("diff2.newOrModified", + diff2.newOrModified.toSortedPaths(), + listOf(newTxt, changedTxt).toSortedPaths()) + } + + private fun Iterable.toSortedPaths(): Array = + map { it.canonicalPath }.sorted().toTypedArray() + + private fun File.filesWithExt(ext: String): Iterable = + walk().filter { it.isFile && it.extension.equals(ext, ignoreCase = true) }.toList() +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotTest.kt b/libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotTest.kt new file mode 100644 index 00000000000..4c294372b82 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin-core/src/test/kotlin/org/jetbrains/kotlin/incremental/snapshots/FileSnapshotTest.kt @@ -0,0 +1,76 @@ +package org.jetbrains.kotlin.incremental.snapshots + +import org.jetbrains.kotlin.TestWithWorkingDir +import org.junit.Assert.* +import org.junit.Test +import java.io.* + +class FileSnapshotTest : TestWithWorkingDir() { + private val fileSnapshotProvider: FileSnapshotProvider + get() = SimpleFileSnapshotProviderImpl() + + @Test + fun testExternalizer() { + val file = File(workingDir, "1.txt") + file.writeText("test") + val snapshot = fileSnapshotProvider[file] + val deserializedSnapshot = saveAndReadBack(snapshot) + assertEquals(snapshot, deserializedSnapshot) + } + + @Test + fun testEqualityNoChanges() { + val file = File(workingDir, "1.txt").apply { writeText("file") } + val oldSnapshot = fileSnapshotProvider[file] + val newSnapshot = fileSnapshotProvider[file] + assertEquals(oldSnapshot, newSnapshot) + } + + @Test + fun testEqualityDifferentFile() { + val file1 = File(workingDir, "1.txt").apply { writeText("file1") } + val file2 = File(workingDir, "2.txt").apply { + writeText(file1.readText()) + setLastModified(file1.lastModified()) + } + val oldSnapshot = fileSnapshotProvider[file1] + val newSnapshot = fileSnapshotProvider[file2] + assertNotEquals(oldSnapshot, newSnapshot) + } + + @Test + fun testEqualityDifferentTimestamp() { + val text = "file" + val file = File(workingDir, "1.txt").apply { writeText(text) } + val oldSnapshot = fileSnapshotProvider[file] + Thread.sleep(1000) + file.writeText(text) + val newSnapshot = fileSnapshotProvider[file] + assertEquals(oldSnapshot, newSnapshot) + } + + @Test + fun testEqualityDifferentSize() { + val file = File(workingDir, "1.txt").apply { writeText("file") } + val oldSnapshot = fileSnapshotProvider[file] + file.writeText("file modified") + val newSnapshot = fileSnapshotProvider[file] + assertNotEquals(oldSnapshot, newSnapshot) + } + + @Test + fun testEqualityDifferentHash() { + val file = File(workingDir, "1.txt").apply { writeText("file") } + val oldSnapshot = fileSnapshotProvider[file] + file.writeText("main") + val newSnapshot = fileSnapshotProvider[file] + assertNotEquals(oldSnapshot, newSnapshot) + } + + private fun saveAndReadBack(snapshot: FileSnapshot): FileSnapshot { + val byteOut = ByteArrayOutputStream() + DataOutputStream(byteOut).use { FileSnapshotExternalizer.save(it, snapshot) } + val byteIn = ByteArrayInputStream(byteOut.toByteArray()) + return DataInputStream(byteIn).use { FileSnapshotExternalizer.read(it) } + } +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin/src/test/kotlin/org/jetbrains/kotlin/gradle/Kapt2IT.kt b/libraries/tools/kotlin-gradle-plugin/src/test/kotlin/org/jetbrains/kotlin/gradle/Kapt2IT.kt index ea130a4cb65..7f51f0c1285 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/test/kotlin/org/jetbrains/kotlin/gradle/Kapt2IT.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/test/kotlin/org/jetbrains/kotlin/gradle/Kapt2IT.kt @@ -217,4 +217,29 @@ class Kapt2IT: BaseGradleIT() { assertFileExists("build/classes/main/example/RuntimeAnnotatedTestClassGenerated.class") } } + + @Test + fun testRemoveAnnotationIC() { + val project = Project("simple", GRADLE_2_14_VERSION, directoryPrefix = "kapt2") + val options = defaultBuildOptions().copy(incremental = true) + project.setupWorkingDir() + val internalDummyKt = project.projectDir.getFileByName("InternalDummy.kt") + + // add annotation + val exampleAnn = "@example.ExampleAnnotation " + internalDummyKt.modify { it.addBeforeSubstring(exampleAnn, "internal class InternalDummy")} + + project.build("classes", options = options) { + assertSuccessful() + } + + // remove annotation + internalDummyKt.modify { it.replace(exampleAnn, "")} + + project.build("classes", options = options) { + assertSuccessful() + val allMainKotlinSrc = File(project.projectDir, "src/main").allKotlinFiles() + assertCompiledKotlinSources(project.relativize(allMainKotlinSrc)) + } + } } \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin/src/test/kotlin/org/jetbrains/kotlin/gradle/util/stringUtil.kt b/libraries/tools/kotlin-gradle-plugin/src/test/kotlin/org/jetbrains/kotlin/gradle/util/stringUtil.kt new file mode 100644 index 00000000000..6780e198253 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/test/kotlin/org/jetbrains/kotlin/gradle/util/stringUtil.kt @@ -0,0 +1,4 @@ +package org.jetbrains.kotlin.gradle.util + +fun String.addBeforeSubstring(prefix: String, substring: String): String = + replace(substring, prefix + substring) \ No newline at end of file