blob: c3ebff58e1caa34a658c64e621a9fc3d9abd885c [file] [log] [blame]
/*
* Copyright 2020 Google LLC
* Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors.
*
* 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 com.google.devtools.ksp
import com.google.devtools.ksp.symbol.*
import com.google.devtools.ksp.symbol.impl.findPsi
import com.google.devtools.ksp.symbol.impl.java.KSFunctionDeclarationJavaImpl
import com.google.devtools.ksp.symbol.impl.java.KSPropertyDeclarationJavaImpl
import com.google.devtools.ksp.visitor.KSDefaultVisitor
import com.intellij.psi.*
import com.intellij.psi.impl.source.PsiClassReferenceType
import com.intellij.util.containers.MultiMap
import com.intellij.util.io.DataExternalizer
import com.intellij.util.io.IOUtil
import com.intellij.util.io.KeyDescriptor
import org.jetbrains.kotlin.container.ComponentProvider
import org.jetbrains.kotlin.container.get
import org.jetbrains.kotlin.descriptors.CallableMemberDescriptor
import org.jetbrains.kotlin.descriptors.ClassDescriptor
import org.jetbrains.kotlin.incremental.*
import org.jetbrains.kotlin.incremental.components.LookupTracker
import org.jetbrains.kotlin.incremental.components.Position
import org.jetbrains.kotlin.incremental.components.ScopeKind
import org.jetbrains.kotlin.incremental.storage.BasicMap
import org.jetbrains.kotlin.incremental.storage.CollectionExternalizer
import org.jetbrains.kotlin.incremental.storage.FileToPathConverter
import org.jetbrains.kotlin.resolve.descriptorUtil.getAllSuperclassesWithoutAny
import org.jetbrains.kotlin.types.KotlinType
import org.jetbrains.kotlin.types.typeUtil.supertypes
import java.io.DataInput
import java.io.DataOutput
import java.io.File
import java.util.*
class FileToSymbolsMap(storageFile: File) : BasicMap<File, Collection<LookupSymbol>>(storageFile, FileKeyDescriptor, CollectionExternalizer(LookupSymbolExternalizer, { HashSet() })) {
override fun dumpKey(key: File): String = key.toString()
override fun dumpValue(value: Collection<LookupSymbol>): String = value.toString()
fun add(file: File, symbol: LookupSymbol) {
storage.append(file, listOf(symbol))
}
operator fun get(key: File): Collection<LookupSymbol>? = storage[key]
operator fun set(key: File, symbols: Set<LookupSymbol>) {
storage[key] = symbols
}
fun remove(key: File) {
storage.remove(key)
}
val keys: Collection<File>
get() = storage.keys
}
object FileKeyDescriptor : KeyDescriptor<File> {
override fun read(input: DataInput): File {
return File(IOUtil.readString(input))
}
override fun save(output: DataOutput, value: File) {
IOUtil.writeString(value.path, output)
}
override fun getHashCode(value: File): Int = value.hashCode()
override fun isEqual(val1: File, val2: File): Boolean = val1 == val2
}
object LookupSymbolExternalizer : DataExternalizer<LookupSymbol> {
override fun read(input: DataInput): LookupSymbol = LookupSymbol(IOUtil.readString(input), IOUtil.readString(input))
override fun save(output: DataOutput, value: LookupSymbol) {
IOUtil.writeString(value.name, output)
IOUtil.writeString(value.scope, output)
}
}
object FileExternalizer : DataExternalizer<File> {
override fun read(input: DataInput): File = File(IOUtil.readString(input))
override fun save(output: DataOutput, value: File) {
IOUtil.writeString(value.path, output)
}
}
class FileToFilesMap(storageFile: File) : BasicMap<File, Collection<File>>(storageFile, FileKeyDescriptor, CollectionExternalizer(FileExternalizer, { HashSet() })) {
operator fun get(key: File): Collection<File>? = storage[key]
operator fun set(key: File, value: Collection<File>) {
storage[key] = value
}
override fun dumpKey(key: File): String = key.path
override fun dumpValue(value: Collection<File>) =
value.dumpCollection()
// TODO: remove values.
fun remove(key: File) {
storage.remove(key)
}
val keys: Collection<File>
get() = storage.keys
}
object symbolCollector : KSDefaultVisitor<(LookupSymbol) -> Unit, Unit>() {
override fun defaultHandler(node: KSNode, collect: (LookupSymbol) -> Unit) = Unit
override fun visitDeclaration(declaration: KSDeclaration, collect: (LookupSymbol) -> Unit) {
if (declaration.isPrivate())
return
val name = declaration.simpleName.asString()
val scope = declaration.qualifiedName?.asString()?.let { it.substring(0, Math.max(it.length - name.length - 1, 0))} ?: return
collect(LookupSymbol(name, scope))
}
override fun visitDeclarationContainer(declarationContainer: KSDeclarationContainer, collect: (LookupSymbol) -> Unit) {
// Local declarations aren't visible to other files / classes.
if (declarationContainer is KSFunctionDeclaration)
return
declarationContainer.declarations.forEach {
it.accept(this, collect)
}
}
}
internal class RelativeFileToPathConverter(val baseDir: File) : FileToPathConverter {
override fun toPath(file: File): String = file.path
override fun toFile(path: String): File = File(path).relativeTo(baseDir)
}
class IncrementalContext(
private val options: KspOptions,
private val ksFiles: List<KSFile>,
private val componentProvider: ComponentProvider,
private val anyChangesWildcard: File,
private val isIncremental: Boolean
) {
// Symbols defined in changed files. This is used to update symbolsMap in the end.
private val updatedSymbols = MultiMap.createSet<File, LookupSymbol>()
// Symbols defined in each file. This is
private val symbolsMap = FileToSymbolsMap(File(options.cachesDir, "symbols"))
private val baseDir = options.projectBaseDir
private val modified = options.knownModified.map{ it.relativeTo(baseDir) }.toSet()
private val removed = options.knownRemoved.map { it.relativeTo(baseDir) }.toSet()
private val lookupTracker: LookupTracker = componentProvider.get()
private val lookupCacheDir = options.cachesDir
private val PATH_CONVERTER = RelativeFileToPathConverter(baseDir)
private val lookupCache = LookupStorage(lookupCacheDir, PATH_CONVERTER)
private val sourceToOutputsMap = FileToFilesMap(File(options.cachesDir, "sourceToOutputs"))
private fun String.toRelativeFile() = File(this).relativeTo(baseDir)
private val KSFile.relativeFile
get() = filePath.toRelativeFile()
private fun cleanIncrementalCache() {
options.cachesDir.deleteRecursively()
}
private fun collectDefinedSymbols() {
ksFiles.forEach { file ->
file.accept(symbolCollector) {
updatedSymbols.putValue(file.relativeFile, it)
}
}
}
fun updateLookupCache(dirtyFiles: Collection<File>) {
if (lookupTracker is LookupTrackerImpl) {
lookupCache.update(lookupTracker, dirtyFiles, options.knownRemoved)
lookupCache.flush(false)
lookupCache.close()
}
}
private fun calcDirtySetByDeps(): Set<File> {
val changedSyms = mutableSetOf<LookupSymbol>()
// Parse and add newly defined symbols in modified files.
ksFiles.filter { it.relativeFile in modified }.forEach { file ->
file.accept(symbolCollector) {
updatedSymbols.putValue(file.relativeFile, it)
changedSyms.add(it)
}
}
// Add previously defined symbols in removed and modified files
ksFiles.filter { it.relativeFile in removed || it.relativeFile in modified }.forEach { file ->
symbolsMap[file.relativeFile]?.let {
changedSyms.addAll(it)
}
}
// For each changed symbol, either changed, modified or removed, invalidate files that looked them up, recursively.
val invalidator = DepInvalidator(lookupCache, symbolsMap, ksFiles.filter { it.relativeFile in removed || it.relativeFile in modified }.map { it.relativeFile })
changedSyms.forEach {
invalidator.invalidate(it)
}
return invalidator.visitedFiles
}
// Propagate dirtiness by source-output maps.
private fun calcDirtySetByOutputs(sourceToOutputs: FileToFilesMap,
initialSet: Set<File>): Set<File> {
val outputToSources = mutableMapOf<File, MutableSet<File>>()
sourceToOutputs.keys.forEach { source ->
if (source != anyChangesWildcard) {
sourceToOutputs[source]!!.forEach { output ->
outputToSources.getOrPut(output) { mutableSetOf() }.add(source)
}
}
}
val visited = mutableSetOf<File>()
fun visit(dirty: File) {
if (dirty in visited)
return
visited.add(dirty)
sourceToOutputs[dirty]?.forEach {
outputToSources[it]?.forEach {
visit(it)
}
}
}
initialSet.forEach {
visit(it)
}
return visited
}
fun logDirtyFilesByDeps(dirtyFiles: Collection<File>) {
if (!options.incrementalLog)
return
val logFile = File(options.projectBaseDir, "build/kspDirtySetByDeps.log")
logFile.appendText("All Files\n")
ksFiles.forEach { logFile.appendText(" ${it.relativeFile}\n") }
logFile.appendText("Modified\n")
options.knownModified.forEach { logFile.appendText(" $it\n") }
logFile.appendText("Removed\n")
options.knownRemoved.forEach { logFile.appendText(" $it\n") }
logFile.appendText("Dirty\n")
dirtyFiles.forEach { logFile.appendText(" ${it}\n") }
val percentage = "%.2f".format(dirtyFiles.size.toDouble() / ksFiles.size.toDouble() * 100)
logFile.appendText("\nDirty / All: $percentage%\n\n")
}
fun logDirtyFilesByOutputs(dirtyFiles: Collection<File>) {
if (!options.incrementalLog)
return
val allOutputs = mutableSetOf<File>()
val validOutputs = mutableSetOf<File>()
sourceToOutputsMap.keys.forEach { source ->
val outputs = sourceToOutputsMap[source]!!
if (source !in removed)
validOutputs.addAll(outputs)
allOutputs.addAll(outputs)
}
val outputsToRemove = allOutputs - validOutputs
val logFile = File(options.projectBaseDir, "build/kspDirtySetByOutputs.log")
logFile.appendText("Dirty sources\n")
dirtyFiles.forEach { logFile.appendText(" $it\n") }
logFile.appendText("Outputs to remove\n")
outputsToRemove.forEach { logFile.appendText(" $it\n")}
logFile.appendText("\n")
}
fun logSourceToOutputs() {
if (!options.incrementalLog)
return
val logFile = File(options.projectBaseDir, "build/kspSourceToOutputs.log")
logFile.appendText("All outputs\n")
sourceToOutputsMap.keys.forEach { source ->
logFile.appendText(" $source:\n")
sourceToOutputsMap[source]!!.forEach { output ->
logFile.appendText(" $output\n")
}
}
logFile.appendText("\n")
}
fun logDirtyFiles(files: List<KSFile>) {
if (!options.incrementalLog)
return
val logFile = File(options.projectBaseDir, "build/kspDirtySet.log")
logFile.appendText("Dirty:\n")
files.forEach {
logFile.appendText(" ${it.relativeFile}\n")
}
logFile.appendText("\n")
}
// Beware: no side-effects here; Caches should only be touched in updateCaches.
fun calcDirtyFiles(): Collection<KSFile> {
if (isIncremental) {
val dirtyFilesByDeps = calcDirtySetByDeps()
logDirtyFilesByDeps(dirtyFilesByDeps)
// modified can be seen as removed + new. Therefore the following check doesn't work:
// if (modified.any { it !in sourceToOutputsMap.keys }) ...
val dirtyFilesByOutputs = if (modified.isNotEmpty()) {
calcDirtySetByOutputs(sourceToOutputsMap, dirtyFilesByDeps + removed + anyChangesWildcard)
} else {
calcDirtySetByOutputs(sourceToOutputsMap, dirtyFilesByDeps + removed)
}
logDirtyFilesByOutputs(dirtyFilesByOutputs)
logDirtyFiles(ksFiles.filter { it.relativeFile in dirtyFilesByOutputs })
return ksFiles.filter { it.relativeFile in dirtyFilesByOutputs }
} else {
cleanIncrementalCache()
collectDefinedSymbols()
logDirtyFiles(ksFiles)
return ksFiles
}
}
fun updateSourceToOutputs(dirtyFiles: Collection<File>, outputs: Set<File>, sourceToOutputs: Map<File, Set<File>>) {
// Prune deleted sources in source-to-outputs map.
removed.forEach {
sourceToOutputsMap.remove(it)
}
// TODO: Should unspecified outputs be associated to all files by default?
// If so, maybe simply disable incremental processing once detected that.
val mutableSourceToOutputs = mutableMapOf<File, MutableSet<File>>().apply {
sourceToOutputs.forEach {
set(it.key, it.value.toMutableSet())
}
}
// Associate unassociated outputs to ALL FILES.
val associated = sourceToOutputs.values.flatten().toSet()
val unassociated = outputs.filterNot { it in associated }
mutableSourceToOutputs.getOrPut(anyChangesWildcard) { mutableSetOf() }.addAll(unassociated)
ksFiles.forEach { ksFile ->
mutableSourceToOutputs.getOrPut( ksFile.relativeFile ) { mutableSetOf() }.addAll(unassociated)
}
// Merge source-to-outputs map from those reprocessed.
dirtyFiles.forEach { source ->
mutableSourceToOutputs[source]?.let { sourceToOutputsMap[source] = it} ?: sourceToOutputsMap.remove(source)
}
logSourceToOutputs()
sourceToOutputsMap.flush(false)
sourceToOutputsMap.close()
}
// TODO: Recover if processing failed.
fun updateOutputs(outputs: Set<File>, cleanOutputs: Collection<File>) {
val outRoot = options.kspOutputDir
val bakRoot = File(options.cachesDir, "backups")
fun File.abs() = File(baseDir, path)
fun File.bak() = File(bakRoot, abs().toRelativeString(outRoot))
// Backing up outputs is necessary for two reasons:
//
// 1. Currently, outputs are always cleaned up in gradle plugin before compiler is called.
// Untouched outputs need to be restore.
//
// TODO: need a change in upstream to not clean files in gradle plugin.
// Not cleaning files in gradle plugin has potentially fewer copies when processing succeeds.
//
// 2. Even if outputs are left from last compilation / processing, processors can still
// fail and the outputs will need to be restored.
// Backup
outputs.forEach { generated ->
generated.abs().copyTo(generated.bak(), overwrite = true)
}
// Restore non-dirty outputs
cleanOutputs.forEach { dst ->
if (dst !in outputs)
dst.bak().copyTo(dst.abs(), overwrite = false)
}
}
// TODO: Don't do anything if processing failed.
fun updateCaches(dirtyFiles: Collection<File>, outputs: Set<File>, sourceToOutputs: Map<File, Set<File>>) {
updateSourceToOutputs(dirtyFiles, outputs, sourceToOutputs)
updateLookupCache(dirtyFiles)
// Update symbolsMap
if (isIncremental) {
// Update symbol caches from modified files.
options.knownModified.forEach {
symbolsMap.set(it, updatedSymbols[it].toSet())
}
// Remove symbol caches from removed files.
options.knownRemoved.forEach {
symbolsMap.remove(it)
}
} else {
symbolsMap.clean()
updatedSymbols.keySet().forEach {
symbolsMap.set(it, updatedSymbols[it].toSet())
}
}
symbolsMap.flush(false)
symbolsMap.close()
}
fun updateCachesAndOutputs(dirtyFiles: Collection<KSFile>, outputs: Set<File>, sourceToOutputs: Map<File, Set<File>>) {
val cleanOutputs = mutableSetOf<File>()
val dirtyFilePaths = dirtyFiles.map { it.relativeFile }
sourceToOutputsMap.keys.forEach { source ->
if (source !in dirtyFilePaths && source != anyChangesWildcard)
cleanOutputs.addAll(sourceToOutputsMap[source]!!)
}
updateCaches(dirtyFilePaths, outputs, sourceToOutputs)
updateOutputs(outputs, cleanOutputs)
}
// Insert Java file -> names lookup records.
fun recordLookup(psiFile: PsiJavaFile, fqn: String) {
val path = psiFile.virtualFile.path
val name = fqn.substringAfterLast('.')
val scope = fqn.substringBeforeLast('.', "<anonymous>")
fun record(scope: String, name: String) =
lookupTracker.record(path, Position.NO_POSITION, scope, ScopeKind.CLASSIFIER, name)
record(scope, name)
// If a resolved name is from some * import, it is overridable by some out-of-file changes.
// Therefore, the potential providers all need to be inserted. They are
// 1. definition of the name in the same package
// 2. other * imports
val onDemandImports =
psiFile.getOnDemandImports(false, false).mapNotNull { (it as? PsiPackage)?.qualifiedName }
if (scope in onDemandImports) {
record(psiFile.packageName, name)
onDemandImports.forEach {
record(it, name)
}
}
}
// Record a *leaf* type reference. This doesn't address type arguments.
private fun recordLookup(ref: PsiClassReferenceType, def: PsiClass) {
val psiFile = ref.reference.containingFile as? PsiJavaFile ?: return
// A type parameter doesn't have qualified name.
//
// Note that bounds of type parameters, or other references in classes,
// are not addressed recursively here. They are recorded in other places
// with more contexts, when necessary.
def.qualifiedName?.let { recordLookup(psiFile, it) }
}
// Record a type reference, including its type arguments.
fun recordLookup(ref: PsiType) {
when (ref) {
is PsiArrayType -> recordLookup(ref.componentType)
is PsiClassReferenceType -> {
val def = ref.resolve() ?: return
recordLookup(ref, def)
// in case the corresponding KotlinType is passed through ways other than KSTypeReferenceJavaImpl
ref.typeArguments().forEach {
if (it is PsiType) {
recordLookup(it)
}
}
}
is PsiWildcardType -> ref.bound?.let { recordLookup(it) }
}
}
// Record all references to super types (if they are written in Java) of a given type,
// in its type hierarchy.
fun recordLookupWithSupertypes(kotlinType: KotlinType) {
(listOf(kotlinType) + kotlinType.supertypes()).mapNotNull {
it.constructor.declarationDescriptor?.findPsi() as? PsiClass
}.forEach {
it.superTypes.forEach {
recordLookup(it)
}
}
}
// Record all type references in a Java field.
private fun recordLookupForJavaField(psi: PsiField) {
recordLookup(psi.type)
}
// Record all type references in a Java method.
private fun recordLookupForJavaMethod(psi: PsiMethod) {
psi.parameterList.parameters.forEach {
recordLookup(it.type)
}
psi.returnType?.let { recordLookup(it) }
psi.typeParameters.forEach {
it.bounds.mapNotNull { it as? PsiType }.forEach {
recordLookup(it)
}
}
}
// Record all type references in a KSDeclaration
fun recordLookupForDeclaration(declaration: KSDeclaration) {
when (declaration) {
is KSPropertyDeclarationJavaImpl -> recordLookupForJavaField(declaration.psi)
is KSFunctionDeclarationJavaImpl -> recordLookupForJavaMethod(declaration.psi)
}
}
// Record all type references in a CallableMemberDescriptor
fun recordLookupForCallableMemberDescriptor(descriptor: CallableMemberDescriptor) {
val psi = descriptor.findPsi()
when (psi) {
is PsiMethod -> recordLookupForJavaMethod(psi)
is PsiField -> recordLookupForJavaField(psi)
}
}
// Record references from all declared functions in the type hierarchy of the given class.
// TODO: optimization: filter out inaccessible members
fun recordLookupForGetAllFunctions(descriptor: ClassDescriptor) {
recordLookupForGetAll(descriptor) {
it.methods.forEach {
recordLookupForJavaMethod(it)
}
}
}
// Record references from all declared fields in the type hierarchy of the given class.
// TODO: optimization: filter out inaccessible members
fun recordLookupForGetAllProperties(descriptor: ClassDescriptor) {
recordLookupForGetAll(descriptor) {
it.fields.forEach {
recordLookupForJavaField(it)
}
}
}
fun recordLookupForGetAll(descriptor: ClassDescriptor, doChild: (PsiClass) -> Unit) {
(descriptor.getAllSuperclassesWithoutAny() + descriptor).mapNotNull {
it.findPsi() as? PsiClass
}.forEach { psiClass ->
psiClass.superTypes.forEach {
recordLookup(it)
}
doChild(psiClass)
}
}
// Debugging and testing only.
internal fun dumpLookupRecords(): Map<String, List<String>> {
val map = mutableMapOf<String, List<String>>()
if (lookupTracker is LookupTrackerImpl) {
lookupTracker.lookups.entrySet().forEach { e ->
val key = "${e.key.scope}.${e.key.name}"
map[key] = e.value.map { PATH_CONVERTER.toFile(it).path }
}
}
return map
}
}
internal class DepInvalidator(
private val lookupCache: LookupStorage,
private val symbolsMap: FileToSymbolsMap,
changedFiles: List<File>
) {
private val visitedSyms = mutableSetOf<LookupSymbol>()
val visitedFiles = mutableSetOf<File>().apply { addAll(changedFiles) }
fun invalidate(sym: LookupSymbol) {
if (sym in visitedSyms)
return
visitedSyms.add(sym)
lookupCache.get(sym).forEach {
invalidate(File(it))
}
}
private fun invalidate(file: File) {
if (file in visitedFiles)
return
visitedFiles.add(file)
symbolsMap[file]!!.forEach {
invalidate(it)
}
}
}