From 165b23f002281bcd9ab157125a4ffb62013e9340 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 2 Nov 2026 15:21:32 +0000 Subject: [PATCH 01/15] ADFA-6858: style: spotless reformat, no functional change --- .../androidide/utils/DocumentUtils.java | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/shared/src/main/java/com/itsaky/androidide/utils/DocumentUtils.java b/shared/src/main/java/com/itsaky/androidide/utils/DocumentUtils.java index 04162b096f..ff0b13a343 100644 --- a/shared/src/main/java/com/itsaky/androidide/utils/DocumentUtils.java +++ b/shared/src/main/java/com/itsaky/androidide/utils/DocumentUtils.java @@ -27,36 +27,36 @@ */ public class DocumentUtils { - public static boolean isJavaFile(Path file) { - final var name = PathsKt.getName(file); - final var extension = PathsKt.getExtension(file); - - return extension.equals("java") - && Files.exists(file) - && !Files.isDirectory(file) - && !name.equals("module-info.java") - && !name.equals("package-info.java"); - } - - public static boolean isXmlFile(Path file) { - return PathsKt.getExtension(file).equals("xml") && Files.exists(file) && !Files.isDirectory(file); - } - - public static boolean isKotlinFile(Path file) { - if (file == null) { - return false; - } - final var extension = PathsKt.getExtension(file); - return (extension.equals("kt") || extension.equals("kts")) - && Files.exists(file) - && !Files.isDirectory(file); - } - - public static boolean isSameFile(Path first, Path second) { - try { - return Files.isSameFile(first, second); - } catch (IOException e) { - return false; - } - } + public static boolean isJavaFile(Path file) { + final var name = PathsKt.getName(file); + final var extension = PathsKt.getExtension(file); + + return extension.equals("java") + && Files.exists(file) + && !Files.isDirectory(file) + && !name.equals("module-info.java") + && !name.equals("package-info.java"); + } + + public static boolean isKotlinFile(Path file) { + if (file == null) { + return false; + } + final var extension = PathsKt.getExtension(file); + return (extension.equals("kt") || extension.equals("kts")) + && Files.exists(file) + && !Files.isDirectory(file); + } + + public static boolean isSameFile(Path first, Path second) { + try { + return Files.isSameFile(first, second); + } catch (IOException e) { + return false; + } + } + + public static boolean isXmlFile(Path file) { + return PathsKt.getExtension(file).equals("xml") && Files.exists(file) && !Files.isDirectory(file); + } } From 8c81a268951cc16b5c81b67dcc53f8c567dbaddb Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 2 Nov 2026 15:24:02 +0000 Subject: [PATCH 02/15] ADFA-6858: Move file add/remove handling into AbstractCompilationEnvironment --- .../AbstractCompilationEnvironment.kt | 82 +++++++++++++++++++ .../kotlin/compiler/CompilationEnvironment.kt | 78 ------------------ 2 files changed, 82 insertions(+), 78 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt index 7d53968061..fe826dfdc0 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.lsp.kotlin.compiler import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex +import com.itsaky.androidide.lsp.kotlin.compiler.modules.AbstractKtModule import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisScheduler import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule @@ -9,18 +10,28 @@ import com.itsaky.androidide.lsp.kotlin.compiler.modules.isSourceModule import com.itsaky.androidide.lsp.kotlin.compiler.services.JavaModuleAccessibilityChecker import com.itsaky.androidide.lsp.kotlin.compiler.services.JavaModuleAnnotationsProvider import com.itsaky.androidide.lsp.kotlin.compiler.services.KtLspService +import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider +import com.itsaky.androidide.lsp.kotlin.compiler.services.ResolutionScopeProvider import com.itsaky.androidide.lsp.kotlin.compiler.services.WriteAccessGuard import com.itsaky.androidide.lsp.kotlin.compiler.services.latestLanguageVersionSettings import com.itsaky.androidide.lsp.kotlin.compiler.util.SLF4JLogger +import com.itsaky.androidide.lsp.kotlin.utils.toVirtualFileOrNull import com.itsaky.androidide.progress.ICancelChecker import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.kotlin.K1Deprecation +import org.jetbrains.kotlin.analysis.api.KaImplementationDetail +import org.jetbrains.kotlin.analysis.api.platform.analysisMessageBus import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinAnnotationsResolverFactory import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinDeclarationProviderFactory import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinDirectInheritorsProvider import org.jetbrains.kotlin.analysis.api.platform.java.KotlinJavaModuleAccessibilityChecker import org.jetbrains.kotlin.analysis.api.platform.java.KotlinJavaModuleAnnotationsProvider +import org.jetbrains.kotlin.analysis.api.platform.modification.KaElementModificationType +import org.jetbrains.kotlin.analysis.api.platform.modification.KaSourceModificationService +import org.jetbrains.kotlin.analysis.api.platform.modification.KotlinModuleStateModificationEvent +import org.jetbrains.kotlin.analysis.api.platform.modification.KotlinModuleStateModificationKind +import org.jetbrains.kotlin.analysis.api.platform.modification.publishModificationEvent import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackagePartProviderFactory import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackageProviderFactory import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KotlinModuleDependentsProvider @@ -31,6 +42,7 @@ import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.Standa import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.registerProjectExtensionPoints import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.registerProjectModelServices import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.registerProjectServices +import org.jetbrains.kotlin.analysis.low.level.api.fir.sessions.LLFirSessionInvalidationTopics import org.jetbrains.kotlin.cli.common.intellijPluginRoot import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation @@ -80,6 +92,7 @@ import org.jetbrains.kotlin.config.useFir import org.jetbrains.kotlin.load.kotlin.MetadataFinderFactory import org.jetbrains.kotlin.load.kotlin.VirtualFileFinderFactory import org.jetbrains.kotlin.metadata.jvm.deserialization.JvmProtoBufUtil +import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtPsiFactory import org.slf4j.LoggerFactory import java.nio.file.Path @@ -339,6 +352,75 @@ internal abstract class AbstractCompilationEnvironment( postInit(libraryRoots) } + suspend fun onFileCreated(path: Path) { + if (project.isDisposed) return + notifyElementModifiedForPath(path) { KaElementModificationType.ElementAdded } + ktSymbolIndex.submitForIndexing(path) + } + + suspend fun onFileRemoved(path: Path) { + if (project.isDisposed) return + notifyElementModifiedForPath(path) { ktFile -> + KaElementModificationType.ElementRemoved(ktFile) + } + ProjectStructureProvider.getInstance(project).unregisterInMemoryFile(path.pathString) + ktSymbolIndex.removeFromIndex(path) + } + + @OptIn(KaImplementationDetail::class) + protected inline fun notifyElementModifiedForPath( + path: Path, + crossinline typeProvider: (KtFile) -> KaElementModificationType, + ) { + // Resolve PSI/module structure under the read lock; driving psiManager.findFile / + // structureProvider concurrently with an `analyze` read section otherwise races. + val (ktFile, module) = + project.readUnlessDisposed { + val structureProvider = ProjectStructureProvider.getInstance(project) + val ktFile = + path.toVirtualFileOrNull()?.let { + psiManager.findFile(it) as? KtFile + } + + val module = + ( + ktFile?.let { structureProvider.getModule(it, null) } + ?: structureProvider.findModuleForSourceId(path.pathString) + ) as? AbstractKtModule + + ktFile to module + } ?: return + + project.writeUnlessDisposed { + // Must run under the write lock so the session mutation can't race a concurrent + // `analyze` (which only holds the read lock); see KtSymbolIndex.refreshToCurrent. + if (ktFile != null) { + KaSourceModificationService + .getInstance(project) + .handleElementModification(ktFile, typeProvider(ktFile)) + } + + if (module != null) { + module.invalidateSearchScope() + project.publishModificationEvent( + KotlinModuleStateModificationEvent( + module, + KotlinModuleStateModificationKind.UPDATE, + ), + ) + project.analysisMessageBus + .syncPublisher(LLFirSessionInvalidationTopics.SESSION_INVALIDATION) + .afterInvalidation(setOf(module)) + ResolutionScopeProvider.getInstance(project).invalidate(module) + } else { + project.analysisMessageBus + .syncPublisher(LLFirSessionInvalidationTopics.SESSION_INVALIDATION) + .afterGlobalInvalidation() + ResolutionScopeProvider.getInstance(project).invalidateAll() + } + } + } + private fun createCompilerConfiguration(): CompilerConfiguration = CompilerConfiguration().apply { this.moduleName = JvmProtoBufUtil.DEFAULT_MODULE_NAME diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt index 5c9ea19594..62dbce1ea2 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt @@ -13,7 +13,6 @@ import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvid import com.itsaky.androidide.lsp.kotlin.compiler.services.ResolutionScopeProvider import com.itsaky.androidide.lsp.kotlin.diagnostic.collectDiagnosticsFor import com.itsaky.androidide.lsp.kotlin.utils.SymbolVisibilityChecker -import com.itsaky.androidide.lsp.kotlin.utils.toVirtualFileOrNull import com.itsaky.androidide.projects.FileManager import com.itsaky.androidide.utils.KeyedDebouncingAction import io.sentry.Sentry @@ -34,13 +33,7 @@ import org.appdevforall.codeonthego.indexing.jvm.KT_SOURCE_FILE_INDEX_KEY import org.appdevforall.codeonthego.indexing.jvm.KT_SOURCE_FILE_META_INDEX_KEY import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex import org.jetbrains.kotlin.analysis.api.KaImplementationDetail -import org.jetbrains.kotlin.analysis.api.platform.analysisMessageBus import org.jetbrains.kotlin.analysis.api.platform.modification.KaElementModificationType -import org.jetbrains.kotlin.analysis.api.platform.modification.KaSourceModificationService -import org.jetbrains.kotlin.analysis.api.platform.modification.KotlinModuleStateModificationEvent -import org.jetbrains.kotlin.analysis.api.platform.modification.KotlinModuleStateModificationKind -import org.jetbrains.kotlin.analysis.api.platform.modification.publishModificationEvent -import org.jetbrains.kotlin.analysis.low.level.api.fir.sessions.LLFirSessionInvalidationTopics import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation import org.jetbrains.kotlin.cli.common.messages.MessageCollector @@ -50,10 +43,8 @@ import org.jetbrains.kotlin.cli.jvm.index.JavaRoot import org.jetbrains.kotlin.com.intellij.mock.MockProject import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager import org.jetbrains.kotlin.config.LanguageVersion -import org.jetbrains.kotlin.psi.KtFile import org.slf4j.LoggerFactory import java.nio.file.Path -import kotlin.io.path.pathString import kotlin.time.Duration.Companion.milliseconds /** @@ -260,60 +251,6 @@ internal class CompilationEnvironment( ktSymbolIndex.invalidateCurrent(path) } - @OptIn(KaImplementationDetail::class) - private inline fun notifyElementModifiedForPath( - path: Path, - crossinline typeProvider: (KtFile) -> KaElementModificationType, - ) { - // Resolve PSI/module structure under the read lock; driving psiManager.findFile / - // structureProvider concurrently with an `analyze` read section otherwise races. - val (ktFile, module) = - project.readUnlessDisposed { - val structureProvider = ProjectStructureProvider.getInstance(project) - val ktFile = - path.toVirtualFileOrNull()?.let { - psiManager.findFile(it) as? KtFile - } - - val module = - ( - ktFile?.let { structureProvider.getModule(it, null) } - ?: structureProvider.findModuleForSourceId(path.pathString) - ) as? AbstractKtModule - - ktFile to module - } ?: return - - project.writeUnlessDisposed { - // Must run under the write lock so the session mutation can't race a concurrent - // `analyze` (which only holds the read lock); see KtSymbolIndex.refreshToCurrent. - if (ktFile != null) { - KaSourceModificationService - .getInstance(project) - .handleElementModification(ktFile, typeProvider(ktFile)) - } - - if (module != null) { - module.invalidateSearchScope() - project.publishModificationEvent( - KotlinModuleStateModificationEvent( - module, - KotlinModuleStateModificationKind.UPDATE, - ), - ) - project.analysisMessageBus - .syncPublisher(LLFirSessionInvalidationTopics.SESSION_INVALIDATION) - .afterInvalidation(setOf(module)) - ResolutionScopeProvider.getInstance(project).invalidate(module) - } else { - project.analysisMessageBus - .syncPublisher(LLFirSessionInvalidationTopics.SESSION_INVALIDATION) - .afterGlobalInvalidation() - ResolutionScopeProvider.getInstance(project).invalidateAll() - } - } - } - suspend fun onFileChangedOnDisk(path: Path) { if (project.isDisposed || FileManager.isActive(path)) return ktSymbolIndex.evictDiskCopy(path) @@ -321,21 +258,6 @@ internal class CompilationEnvironment( ktSymbolIndex.submitForIndexing(path) } - suspend fun onFileCreated(path: Path) { - if (project.isDisposed) return - notifyElementModifiedForPath(path) { KaElementModificationType.ElementAdded } - ktSymbolIndex.submitForIndexing(path) - } - - suspend fun onFileRemoved(path: Path) { - if (project.isDisposed) return - notifyElementModifiedForPath(path) { ktFile -> - KaElementModificationType.ElementRemoved(ktFile) - } - ProjectStructureProvider.getInstance(project).unregisterInMemoryFile(path.pathString) - ktSymbolIndex.removeFromIndex(path) - } - suspend fun onFileMoved( fromPath: Path, toPath: Path, From 85decbf3b8ca3c33e67503960b3e9f90513e0d05 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 2 Nov 2026 15:24:17 +0000 Subject: [PATCH 03/15] ADFA-6858: Notify listeners after KtFileMetadataIndex writes Deliberate breaks, each reverted: - notify before backing.insert in upsert: listenerSeesTheWriteAlreadyApplied FAILED - close handle made a no-op: closedRegistrationIsNotCalled FAILED --- .../indexing/jvm/KtFileMetadataIndex.kt | 64 ++++++++++++++- .../KtFileMetadataIndexWriteListenerTest.kt | 78 +++++++++++++++++++ 2 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndexWriteListenerTest.kt diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndex.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndex.kt index df7a82b158..4ce5fea8b5 100644 --- a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndex.kt +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndex.kt @@ -8,6 +8,34 @@ import org.appdevforall.codeonthego.indexing.api.indexQuery import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataDescriptor.KEY_IS_INDEXED import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataDescriptor.KEY_PACKAGE import java.io.Closeable +import java.util.concurrent.CopyOnWriteArrayList + +/** + * A completed write to a [KtFileMetadataIndex]. + */ +sealed interface KtFileMetadataWrite { + /** The record for [filePath] was inserted or replaced; its package is [packageFqName]. */ + data class Upserted( + val filePath: String, + val packageFqName: String, + ) : KtFileMetadataWrite + + /** The records for [filePaths] were removed. */ + data class Removed( + val filePaths: Collection, + ) : KtFileMetadataWrite + + /** Every record was removed. */ + data object Cleared : KtFileMetadataWrite +} + +/** + * Receives each [KtFileMetadataWrite] after it has been applied to the index. + */ +fun interface KtFileMetadataWriteListener { + /** Called synchronously on the writing thread once [write] is visible to readers. */ + fun onWrite(write: KtFileMetadataWrite) +} /** * An index of [KtFileMetadata] entries, one per Kotlin source file. @@ -40,20 +68,42 @@ class KtFileMetadataIndex( ) } + private val listeners = CopyOnWriteArrayList() + + /** + * Registers [listener] to be told about every later write. + * + * Closing the returned handle unregisters it. + */ + fun addWriteListener(listener: KtFileMetadataWriteListener): AutoCloseable { + listeners.add(listener) + return AutoCloseable { listeners.remove(listener) } + } + + private fun notifyListeners(write: KtFileMetadataWrite) { + listeners.forEach { it.onWrite(write) } + } + /** * Insert or replace the metadata record for a single file. * * Because [KtFileMetadata.key] and [KtFileMetadata.sourceId] are both the * file path, a file has at most one row and an insert replaces it. */ - suspend fun upsert(metadata: KtFileMetadata) = backing.insert(metadata) + suspend fun upsert(metadata: KtFileMetadata) { + backing.insert(metadata) + notifyListeners(KtFileMetadataWrite.Upserted(metadata.filePath, metadata.packageFqName)) + } /** * Remove the metadata record for [filePath]. * * No-op if the file is not in the index. */ - suspend fun remove(filePath: String) = backing.removeBySource(filePath) + suspend fun remove(filePath: String) { + backing.removeBySource(filePath) + notifyListeners(KtFileMetadataWrite.Removed(listOf(filePath))) + } /** * Remove the metadata records for every path in [filePaths] as a single @@ -63,7 +113,10 @@ class KtFileMetadataIndex( * transaction instead of N - see [Index.removeBySources]. Paths not present in * the index are ignored. */ - suspend fun removeAll(filePaths: Collection) = backing.removeBySources(filePaths) + suspend fun removeAll(filePaths: Collection) { + backing.removeBySources(filePaths) + notifyListeners(KtFileMetadataWrite.Removed(filePaths)) + } /** * Return the [KtFileMetadata] for [filePath], or `null` if the file is @@ -154,7 +207,10 @@ class KtFileMetadataIndex( ).map { it.filePath } /** Remove all records from the index. */ - suspend fun clear() = backing.clear() + suspend fun clear() { + backing.clear() + notifyListeners(KtFileMetadataWrite.Cleared) + } /** Whether the backing index still serves reads and writes. */ val isOpen: Boolean diff --git a/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndexWriteListenerTest.kt b/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndexWriteListenerTest.kt new file mode 100644 index 0000000000..9b7a71bdd3 --- /dev/null +++ b/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndexWriteListenerTest.kt @@ -0,0 +1,78 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.appdevforall.codeonthego.indexing.InMemoryIndex +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.time.Instant + +@RunWith(JUnit4::class) +class KtFileMetadataIndexWriteListenerTest { + private val index = KtFileMetadataIndex(InMemoryIndex(KtFileMetadataDescriptor)) + private val writes = mutableListOf() + + private fun metadata( + path: String, + pkg: String, + ) = KtFileMetadata(path, pkg, Instant.EPOCH, 1L) + + @Test + fun upsertReportsPathAndPackage() = + runTest { + index.addWriteListener { writes += it } + + index.upsert(metadata("/a/A.kt", "com.a")) + + assertThat(writes).containsExactly(KtFileMetadataWrite.Upserted("/a/A.kt", "com.a")) + } + + @Test + fun removeAndRemoveAllReportPaths() = + runTest { + index.addWriteListener { writes += it } + + index.remove("/a/A.kt") + index.removeAll(listOf("/a/B.kt", "/a/C.kt")) + + assertThat(writes) + .containsExactly( + KtFileMetadataWrite.Removed(listOf("/a/A.kt")), + KtFileMetadataWrite.Removed(listOf("/a/B.kt", "/a/C.kt")), + ).inOrder() + } + + @Test + fun clearReportsCleared() = + runTest { + index.addWriteListener { writes += it } + + index.clear() + + assertThat(writes).containsExactly(KtFileMetadataWrite.Cleared) + } + + @Test + fun listenerSeesTheWriteAlreadyApplied() = + runTest { + var pathsSeenInListener: List? = null + index.addWriteListener { + pathsSeenInListener = index.getFilePathsForPackage("com.a").toList() + } + + index.upsert(metadata("/a/A.kt", "com.a")) + + assertThat(pathsSeenInListener).containsExactly("/a/A.kt") + } + + @Test + fun closedRegistrationIsNotCalled() = + runTest { + index.addWriteListener { writes += it }.close() + + index.upsert(metadata("/a/A.kt", "com.a")) + + assertThat(writes).isEmpty() + } +} From cffd946565c8489aaffe9b2b64dd2024547bea83 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 2 Nov 2026 15:29:47 +0000 Subject: [PATCH 04/15] ADFA-6858: Read only top-level declarations in the declaration provider Deliberate breaks, each reverted: - classesWithId skips class bodies: TopLevelDeclarationsEquivalenceTest fails with "missing (1): OBJECT_DECLARATION" (a nested ClassId). - topLevelFunctions drops private: PrivateTopLevelResolutionTest fails ("expected to be empty but was: [UNRESOLVED_REFERENCE]" and "iterable.size() expected 1 but was 0"); the equivalence test also fails with "missing (1): FUN". The equivalence test covers 18 packages (all of lsp/kotlin/src/main). --- .../compiler/services/DeclarationsProvider.kt | 65 ++-------- .../compiler/services/TopLevelDeclarations.kt | 65 ++++++++++ .../services/PrivateTopLevelResolutionTest.kt | 44 +++++++ .../TopLevelDeclarationsEquivalenceTest.kt | 114 ++++++++++++++++++ 4 files changed, 233 insertions(+), 55 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PrivateTopLevelResolutionTest.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarationsEquivalenceTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt index fddf89d779..c9ea6f24ab 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt @@ -17,13 +17,11 @@ import org.jetbrains.kotlin.com.intellij.mock.MockProject import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager import org.jetbrains.kotlin.com.intellij.psi.search.GlobalSearchScope -import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil import org.jetbrains.kotlin.fileClasses.javaFileFacadeFqName import org.jetbrains.kotlin.name.CallableId import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name -import org.jetbrains.kotlin.psi.KtCallableDeclaration import org.jetbrains.kotlin.psi.KtClassLikeDeclaration import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtFile @@ -31,7 +29,6 @@ import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtScript import org.jetbrains.kotlin.psi.KtTypeAlias -import org.jetbrains.kotlin.psi.psiUtil.isTopLevelKtOrJavaMember import java.nio.file.Paths internal class DeclarationProviderFactory : @@ -94,22 +91,10 @@ internal abstract class AbstractDeclarationProvider( ktFilesForPackage(scriptFqName).mapNotNull { it.script }.toList() override fun getAllClassesByClassId(classId: ClassId): Collection = - ktFilesForPackage(classId.packageFqName) - .flatMap { - project.read { - PsiTreeUtil.collectElementsOfType(it, KtClassOrObject::class.java).asSequence() - } - }.filter { it.getClassId() == classId } - .toList() + ktFilesForPackage(classId.packageFqName).let { files -> project.read { files.classesWithId(classId) } } override fun getAllTypeAliasesByClassId(classId: ClassId): Collection = - ktFilesForPackage(classId.packageFqName) - .flatMap { - project.read { - PsiTreeUtil.collectElementsOfType(it, KtTypeAlias::class.java).asSequence() - } - }.filter { it.getClassId() == classId } - .toList() + ktFilesForPackage(classId.packageFqName).let { files -> project.read { files.typeAliasesWithId(classId) } } override fun getClassLikeDeclarationByClassId(classId: ClassId): KtClassLikeDeclaration? = getAllClassesByClassId(classId).firstOrNull() @@ -122,50 +107,20 @@ internal abstract class AbstractDeclarationProvider( } override fun getTopLevelFunctions(callableId: CallableId): Collection = - ktFilesForPackage(callableId.packageName) - .flatMap { - project.read { - PsiTreeUtil - .collectElementsOfType(it, KtNamedFunction::class.java) - .asSequence() - } - }.filter { it.isTopLevel } - .filter { it.nameAsName == callableId.callableName } - .toList() + ktFilesForPackage(callableId.packageName).let { files -> + project.read { files.topLevelFunctions(callableId.callableName) } + } override fun getTopLevelKotlinClassLikeDeclarationNamesInPackage(packageFqName: FqName): Set = - ktFilesForPackage(packageFqName) - .flatMap { - project.read { - PsiTreeUtil - .collectElementsOfType(it, KtClassLikeDeclaration::class.java) - .asSequence() - } - }.filter { it.isTopLevelKtOrJavaMember() } - .mapNotNull { it.nameAsName } - .toSet() + ktFilesForPackage(packageFqName).let { files -> project.read { files.topLevelClassLikeNames() } } override fun getTopLevelCallableNamesInPackage(packageFqName: FqName): Set = - ktFilesForPackage(packageFqName) - .flatMap { - project.read { - PsiTreeUtil - .collectElementsOfType(it, KtCallableDeclaration::class.java) - .asSequence() - } - }.filter { it.isTopLevelKtOrJavaMember() } - .mapNotNull { it.nameAsName } - .toSet() + ktFilesForPackage(packageFqName).let { files -> project.read { files.topLevelCallableNames() } } override fun getTopLevelProperties(callableId: CallableId): Collection = - ktFilesForPackage(callableId.packageName) - .flatMap { - project.read { - PsiTreeUtil.collectElementsOfType(it, KtProperty::class.java).asSequence() - } - }.filter { it.isTopLevel } - .filter { it.nameAsName == callableId.callableName } - .toList() + ktFilesForPackage(callableId.packageName).let { files -> + project.read { files.topLevelProperties(callableId.callableName) } + } } internal class DeclarationProvider( diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt new file mode 100644 index 0000000000..a6340e208d --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt @@ -0,0 +1,65 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtCallableDeclaration +import org.jetbrains.kotlin.psi.KtClassLikeDeclaration +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtTypeAlias + +/* + * Declaration lookups that read only KtFile.declarations instead of walking the whole PSI tree. + * Local declarations have no ClassId and are never top-level, so they never match. Callers must + * hold the project read lock. + */ + +internal fun Sequence.classesWithId(classId: ClassId): List = + declarationsAndNested() + .filterIsInstance() + .filter { it.getClassId() == classId } + .toList() + +internal fun Sequence.typeAliasesWithId(classId: ClassId): List = + declarationsAndNested() + .filterIsInstance() + .filter { it.getClassId() == classId } + .toList() + +internal fun Sequence.topLevelFunctions(name: Name): List = + flatMap { it.declarations } + .filterIsInstance() + .filter { it.nameAsName == name } + .toList() + +internal fun Sequence.topLevelProperties(name: Name): List = + flatMap { it.declarations } + .filterIsInstance() + .filter { it.nameAsName == name } + .toList() + +internal fun Sequence.topLevelCallableNames(): Set = + flatMap { it.declarations } + .filterIsInstance() + .mapNotNull { it.nameAsName } + .toSet() + +internal fun Sequence.topLevelClassLikeNames(): Set = + flatMap { it.declarations } + .filterIsInstance() + .mapNotNull { it.nameAsName } + .toSet() + +private fun Sequence.declarationsAndNested(): Sequence = + flatMap { it.declarations }.flatMap { it.withNestedDeclarations() } + +private fun KtDeclaration.withNestedDeclarations(): Sequence = + sequence { + yield(this@withNestedDeclarations) + if (this@withNestedDeclarations is KtClassOrObject) { + for (member in declarations) yieldAll(member.withNestedDeclarations()) + } + } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PrivateTopLevelResolutionTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PrivateTopLevelResolutionTest.kt new file mode 100644 index 0000000000..a97d7e5636 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PrivateTopLevelResolutionTest.kt @@ -0,0 +1,44 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter +import org.jetbrains.kotlin.name.Name +import org.junit.Test + +class PrivateTopLevelResolutionTest : KtLspTest() { + private val file by lazy { + createSourceFile( + "p/Private.kt", + """ + package p + + private fun helper(): Int = 1 + + fun use(): Int = helper() + + private val secret = 2 + + fun read(): Int = secret + """.trimIndent(), + ) + } + + @Test + fun privateTopLevelDeclarationsResolveWithinTheirFile() { + val diagnostics = + analyzeMaybeDanglingForTest(file) { + file.collectDiagnostics(KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS).map { it.factoryName } + } + + assertThat(diagnostics).isEmpty() + } + + @Test + fun privateTopLevelFunctionsAreListed() { + val found = env.project.read { sequenceOf(file).topLevelFunctions(Name.identifier("helper")) } + + assertThat(found).hasSize(1) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarationsEquivalenceTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarationsEquivalenceTest.kt new file mode 100644 index 0000000000..c01c65e552 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarationsEquivalenceTest.kt @@ -0,0 +1,114 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtCallableDeclaration +import org.jetbrains.kotlin.psi.KtClassLikeDeclaration +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtTypeAlias +import org.jetbrains.kotlin.psi.psiUtil.isTopLevelKtOrJavaMember +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Paths + +class TopLevelDeclarationsEquivalenceTest : KtLspTest() { + @Test + fun topLevelLookupsMatchTheFullTreeWalksOnRealSources() { + val byPackage = parseMainSources().groupBy { it.packageFqName } + assertThat(byPackage.size).isGreaterThan(15) + + env.project.read { + for ((_, list) in byPackage) { + val files = list.asSequence() + assertThat(files.topLevelCallableNames()).isEqualTo(legacyCallableNames(files)) + assertThat(files.topLevelClassLikeNames()).isEqualTo(legacyClassLikeNames(files)) + for (id in legacyAllClassIds(files)) { + assertThat(files.classesWithId(id)).containsExactlyElementsIn(legacyClasses(files, id)) + assertThat(files.typeAliasesWithId(id)).containsExactlyElementsIn(legacyTypeAliases(files, id)) + } + for (name in legacyCallableNames(files)) { + assertThat(files.topLevelFunctions(name)).containsExactlyElementsIn(legacyFunctions(files, name)) + assertThat(files.topLevelProperties(name)).containsExactlyElementsIn(legacyProperties(files, name)) + } + } + } + } + + private fun parseMainSources(): List { + val root = + generateSequence(Paths.get("").toAbsolutePath()) { it.parent } + .first { Files.exists(it.resolve(".androidide_root")) } + val paths = + Files.walk(root.resolve("lsp/kotlin/src/main")).use { stream -> + stream.filter { it.toString().endsWith(".kt") }.toList() + } + return env.project.read { + paths.map { env.parser.createFile(it.fileName.toString(), it.toFile().readText()) } + } + } + + private fun legacyAllClassIds(files: Sequence): Set { + val classes = files.flatMap { PsiTreeUtil.collectElementsOfType(it, KtClassOrObject::class.java) }.mapNotNull { it.getClassId() } + val aliases = files.flatMap { PsiTreeUtil.collectElementsOfType(it, KtTypeAlias::class.java) }.mapNotNull { it.getClassId() } + return (classes + aliases).toSet() + } + + private fun legacyClasses( + files: Sequence, + classId: ClassId, + ): List = + files + .flatMap { PsiTreeUtil.collectElementsOfType(it, KtClassOrObject::class.java).asSequence() } + .filter { it.getClassId() == classId } + .toList() + + private fun legacyTypeAliases( + files: Sequence, + classId: ClassId, + ): List = + files + .flatMap { PsiTreeUtil.collectElementsOfType(it, KtTypeAlias::class.java).asSequence() } + .filter { it.getClassId() == classId } + .toList() + + private fun legacyFunctions( + files: Sequence, + name: Name, + ): List = + files + .flatMap { PsiTreeUtil.collectElementsOfType(it, KtNamedFunction::class.java).asSequence() } + .filter { it.isTopLevel } + .filter { it.nameAsName == name } + .toList() + + private fun legacyProperties( + files: Sequence, + name: Name, + ): List = + files + .flatMap { PsiTreeUtil.collectElementsOfType(it, KtProperty::class.java).asSequence() } + .filter { it.isTopLevel } + .filter { it.nameAsName == name } + .toList() + + private fun legacyClassLikeNames(files: Sequence): Set = + files + .flatMap { PsiTreeUtil.collectElementsOfType(it, KtClassLikeDeclaration::class.java).asSequence() } + .filter { it.isTopLevelKtOrJavaMember() } + .mapNotNull { it.nameAsName } + .toSet() + + private fun legacyCallableNames(files: Sequence): Set = + files + .flatMap { PsiTreeUtil.collectElementsOfType(it, KtCallableDeclaration::class.java).asSequence() } + .filter { it.isTopLevelKtOrJavaMember() } + .mapNotNull { it.nameAsName } + .toSet() +} From 1a0267127d530844bee8569cfe4e9effa56191f9 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 2 Nov 2026 15:30:55 +0000 Subject: [PATCH 05/15] ADFA-6858: Add a bounded package-to-files cache invalidated by index writes Deliberate breaks, each reverted: - Store without the generation check: lookupRacingAWriteDoesNotCacheTheOldList (PackageFileCacheTest.kt:114) and evictionRacingInvalidationLeavesNoStaleList (:149) fail. - No-op onWrite: 7 of 9 fail (upsertDropsItsPackage :55, upsertDropsThePackageThatListedThePath :66, removedPathDropsItsPackage :76, clearDropsEveryPackage :88, rootPackageIsCachedAndInvalidated :101, both race tests). - The eviction race test uses bound 2, not 1: at bound 1 every entry is evicted at once, so a stale put is harmless and the test passes without the guard. --- .../kotlin/compiler/index/PackageFileCache.kt | 75 ++++++++ .../compiler/index/PackageFileCacheTest.kt | 169 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCacheTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt new file mode 100644 index 0000000000..33a87d7c8a --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt @@ -0,0 +1,75 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.index + +import com.github.benmanes.caffeine.cache.Caffeine +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataWrite +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataWriteListener +import java.util.concurrent.Executor +import java.util.concurrent.ForkJoinPool + +/** + * Caches the files of each package, dropping entries as index writes make them stale. + * + * The cache is bounded by the total number of files it lists, so retained memory stays small. + */ +internal class PackageFileCache( + maxCachedFiles: Long, + private val pathOf: (F) -> String, + private val query: (packageFqName: String) -> List, + executor: Executor = ForkJoinPool.commonPool(), +) : KtFileMetadataWriteListener { + private val lock = Any() + + // Bumped by every write, under [lock]. A lookup only caches its result if no write ran meanwhile. + private var generation = 0L + + private val cache = + Caffeine + .newBuilder() + .executor(executor) + .maximumWeight(maxCachedFiles) + .weigher> { _, files -> files.size + 1 } + .build>() + + /** Returns the files declared in [packageFqName], querying only when the answer is not cached. */ + fun filesFor(packageFqName: String): List { + cache.getIfPresent(packageFqName)?.let { return it } + val observed = synchronized(lock) { generation } + val files = query(packageFqName) + synchronized(lock) { + if (generation == observed) cache.put(packageFqName, files) + } + return files + } + + override fun onWrite(write: KtFileMetadataWrite) = + synchronized(lock) { + generation++ + when (write) { + is KtFileMetadataWrite.Upserted -> { + cache.invalidate(write.packageFqName) + invalidateContaining(setOf(write.filePath)) + } + + is KtFileMetadataWrite.Removed -> { + invalidateContaining(write.filePaths.toSet()) + } + + KtFileMetadataWrite.Cleared -> { + cache.invalidateAll() + } + } + } + + private fun invalidateContaining(paths: Set) { + val stale = + cache + .asMap() + .filterValues { files -> files.any { pathOf(it) in paths } } + .keys + cache.invalidateAll(stale) + } + + companion object { + const val DEFAULT_MAX_CACHED_FILES = 2_000L + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCacheTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCacheTest.kt new file mode 100644 index 0000000000..cd6a613d82 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCacheTest.kt @@ -0,0 +1,169 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.index + +import com.google.common.truth.Truth.assertThat +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataWrite +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread + +internal class PackageFileCacheTest { + private val backing = mutableMapOf>() + private val queries = AtomicInteger() + + private val entered = AtomicReference() + private val release = AtomicReference() + private var held: CountDownLatch? = null + + private fun cache(max: Long = 100) = + PackageFileCache( + maxCachedFiles = max, + pathOf = { it }, + query = { pkg -> + queries.incrementAndGet() + val snapshot = backing[pkg].orEmpty() + entered.getAndSet(null)?.countDown() + release.getAndSet(null)?.await(5, TimeUnit.SECONDS) + snapshot + }, + executor = Runnable::run, + ) + + private fun upserted( + path: String, + pkg: String, + ) = KtFileMetadataWrite.Upserted(path, pkg) + + @Test + fun secondLookupDoesNotQueryAgain() { + backing["p"] = listOf("/a.kt") + val cache = cache() + cache.filesFor("p") + assertThat(cache.filesFor("p")).containsExactly("/a.kt") + assertThat(queries.get()).isEqualTo(1) + } + + @Test + fun upsertDropsItsPackage() { + backing["p"] = listOf("/a.kt") + val cache = cache() + cache.filesFor("p") + backing["p"] = listOf("/a.kt", "/b.kt") + cache.onWrite(upserted("/b.kt", "p")) + assertThat(cache.filesFor("p")).containsExactly("/a.kt", "/b.kt") + } + + @Test + fun upsertDropsThePackageThatListedThePath() { + backing["old"] = listOf("/a.kt") + val cache = cache() + cache.filesFor("old") + backing["old"] = emptyList() + backing["new"] = listOf("/a.kt") + cache.onWrite(upserted("/a.kt", "new")) + assertThat(cache.filesFor("old")).isEmpty() + } + + @Test + fun removedPathDropsItsPackage() { + backing["p"] = listOf("/a.kt", "/b.kt") + val cache = cache() + cache.filesFor("p") + backing["p"] = listOf("/b.kt") + cache.onWrite(KtFileMetadataWrite.Removed(listOf("/a.kt"))) + assertThat(cache.filesFor("p")).containsExactly("/b.kt") + } + + @Test + fun clearDropsEveryPackage() { + backing["p"] = listOf("/a.kt") + backing["q"] = listOf("/b.kt") + val cache = cache() + cache.filesFor("p") + cache.filesFor("q") + backing.clear() + cache.onWrite(KtFileMetadataWrite.Cleared) + assertThat(cache.filesFor("p")).isEmpty() + assertThat(cache.filesFor("q")).isEmpty() + } + + @Test + fun rootPackageIsCachedAndInvalidated() { + backing[""] = listOf("/a.kt") + val cache = cache() + cache.filesFor("") + cache.filesFor("") + assertThat(queries.get()).isEqualTo(1) + backing[""] = listOf("/a.kt", "/b.kt") + cache.onWrite(upserted("/b.kt", "")) + assertThat(cache.filesFor("")).containsExactly("/a.kt", "/b.kt") + } + + @Test + fun lookupRacingAWriteDoesNotCacheTheOldList() { + backing["p"] = listOf("/a.kt") + val cache = cache() + val racing = startLookupBlockedInQuery(cache, "p") + + backing["p"] = listOf("/a.kt", "/b.kt") + cache.onWrite(upserted("/b.kt", "p")) + finish(racing) + + assertThat(cache.filesFor("p")).containsExactly("/a.kt", "/b.kt") + assertThat(queries.get()).isEqualTo(2) + } + + @Test + fun evictedPackagesAreRebuilt() { + val cache = cache(max = 3) + val names = listOf("a", "b", "c", "d") + names.forEach { + backing[it] = listOf("/$it.kt") + cache.filesFor(it) + } + val evicted = + names.first { name -> + val before = queries.get() + cache.filesFor(name) + queries.get() != before + } + backing[evicted] = listOf("/changed.kt") + cache.onWrite(upserted("/changed.kt", evicted)) + assertThat(cache.filesFor(evicted)).containsExactly("/changed.kt") + } + + @Test + fun evictionRacingInvalidationLeavesNoStaleList() { + backing["a"] = listOf("/a.kt") + backing["b"] = listOf("/b.kt") + val cache = cache(max = 2) + val racing = startLookupBlockedInQuery(cache, "a") + + cache.filesFor("b") + backing["a"] = listOf("/a2.kt") + cache.onWrite(upserted("/a2.kt", "a")) + finish(racing) + + assertThat(cache.filesFor("a")).containsExactly("/a2.kt") + } + + private fun startLookupBlockedInQuery( + cache: PackageFileCache, + pkg: String, + ): Thread { + val enteredLatch = CountDownLatch(1) + entered.set(enteredLatch) + held = CountDownLatch(1).also { release.set(it) } + val thread = thread { cache.filesFor(pkg) } + assertThat(enteredLatch.await(5, TimeUnit.SECONDS)).isTrue() + return thread + } + + private fun finish(racing: Thread) { + held?.countDown() + racing.join(5000) + assertThat(racing.isAlive).isFalse() + } +} From 791d4084400122d901797d67d28cd87cfbeaba29 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 2 Nov 2026 15:45:43 +0000 Subject: [PATCH 06/15] ADFA-6858: Cache package file lists in KtSymbolIndex The cache matches written paths with toNioPath().pathString, the same derivation KtFile.toMetadata uses for filePath, so invalidation by path cannot miss on a differently spelled path. Deliberate breaks, each run against DeclarationStalenessTest and restored: - Listener never registered (AutoCloseable {}): newFileInPackageIsFound "expected not to contain: UNRESOLVED_REFERENCE but was: [UNRESOLVED_REFERENCE]"; packageChangeMovesTheFile "expected not to contain: .../src/C.kt but was: [.../src/C.kt]". - getKtFile skips the live getCurrentKtFileIfPresent branch: all ten spec 1-3 tests fail ("added*": "expected not to contain: UNRESOLVED_REFERENCE"; "removed*": "expected to contain: UNRESOLVED_REFERENCE but was: []"). - pathOf appends "/": packageChangeMovesTheFile "expected not to contain: .../src/C.kt". --- .../kotlin/compiler/index/KtSymbolIndex.kt | 27 ++- .../compiler/services/DeclarationsProvider.kt | 7 +- .../services/DeclarationStalenessTest.kt | 161 ++++++++++++++++++ 3 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationStalenessTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt index 92d2ac79f9..e53ebe5562 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt @@ -35,14 +35,17 @@ import org.jetbrains.kotlin.analysis.low.level.api.fir.util.originalKtFile import org.jetbrains.kotlin.com.intellij.openapi.application.ApplicationManager import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager import org.jetbrains.kotlin.com.intellij.psi.PsiManager import org.jetbrains.kotlin.com.intellij.psi.impl.PsiManagerEx import org.jetbrains.kotlin.com.intellij.psi.impl.file.impl.FileManagerImpl +import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtPsiFactory import org.slf4j.LoggerFactory import java.nio.file.Path +import java.nio.file.Paths import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService @@ -108,6 +111,15 @@ internal class KtSymbolIndex( .maximumSize(cacheSize) .build() + private val packageFiles = + PackageFileCache( + maxCachedFiles = PackageFileCache.DEFAULT_MAX_CACHED_FILES, + pathOf = { it.toNioPath().pathString }, + query = ::findSourceFiles, + ) + + private val packageFilesRegistration: AutoCloseable = fileIndex.addWriteListener(packageFiles) + /** Set by AbstractCompilationEnvironment.initialize once the env's KtPsiFactory exists. */ lateinit var parser: KtPsiFactory @@ -526,6 +538,17 @@ internal class KtSymbolIndex( } } + /** Returns the source files the metadata index lists for [packageFqName]. */ + internal fun sourceFilesForPackage(packageFqName: FqName): List = packageFiles.filesFor(packageFqName.asString()) + + private fun findSourceFiles(packageFqName: String): List { + val vfm = VirtualFileManager.getInstance() + return fileIndex + .getFilePathsForPackage(packageFqName) + .mapNotNull { vfm.findFileByNioPath(Paths.get(it)) } + .toList() + } + /** [getKtFile] for [vf], keyed by the path it maps to. */ @ResolutionSideKtFileAccess internal fun getKtFile(vf: VirtualFile): KtFile? = getKtFile(vf.toNioPath(), vf) @@ -583,6 +606,8 @@ internal class KtSymbolIndex( } suspend fun close() { + packageFilesRegistration.close() + /* * Cancel the worker rather than queueing a stop: a stop waits behind every queued file, and * a large pass outlasts the caller's bounded wait, so disposal would proceed under a live worker. @@ -608,8 +633,6 @@ internal class KtSymbolIndex( internal fun KtSymbolIndex.packageExistsInSource(packageFqn: String) = fileIndex.packageExists(packageFqn) -internal fun KtSymbolIndex.filesForPackage(packageFqn: String) = fileIndex.getFilesForPackage(packageFqn) - internal fun KtSymbolIndex.subpackageNames(packageFqn: String) = fileIndex.getSubpackageNames(packageFqn) /** diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt index c9ea6f24ab..47443b600b 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt @@ -2,7 +2,6 @@ package com.itsaky.androidide.lsp.kotlin.compiler.services import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex import com.itsaky.androidide.lsp.kotlin.compiler.index.ResolutionSideKtFileAccess -import com.itsaky.androidide.lsp.kotlin.compiler.index.filesForPackage import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule import com.itsaky.androidide.lsp.kotlin.compiler.read import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinCompositeDeclarationProvider @@ -15,7 +14,6 @@ import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule import org.jetbrains.kotlin.cli.jvm.index.JavaRoot import org.jetbrains.kotlin.com.intellij.mock.MockProject import org.jetbrains.kotlin.com.intellij.openapi.project.Project -import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager import org.jetbrains.kotlin.com.intellij.psi.search.GlobalSearchScope import org.jetbrains.kotlin.fileClasses.javaFileFacadeFqName import org.jetbrains.kotlin.name.CallableId @@ -29,7 +27,6 @@ import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtScript import org.jetbrains.kotlin.psi.KtTypeAlias -import java.nio.file.Paths internal class DeclarationProviderFactory : KtLspService, @@ -134,8 +131,8 @@ internal class DeclarationProvider( @OptIn(ResolutionSideKtFileAccess::class) override fun ktFilesForPackage(fqName: FqName): Sequence = index - .filesForPackage(fqName.asString()) - .mapNotNull { VirtualFileManager.getInstance().findFileByNioPath(Paths.get(it.filePath)) } + .sourceFilesForPackage(fqName) + .asSequence() .filter { it in scope } .mapNotNull { index.getKtFile(it) } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationStalenessTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationStalenessTest.kt new file mode 100644 index 0000000000..32d8352685 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationStalenessTest.kt @@ -0,0 +1,161 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.eventbus.events.editor.ChangeType +import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent +import com.itsaky.androidide.eventbus.events.editor.DocumentCloseEvent +import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.projects.FileManager +import kotlinx.coroutines.runBlocking +import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtFile +import org.junit.After +import org.junit.Test +import java.nio.file.Path + +/** + * The declaration provider must never answer from a stale package file list. + * + * Each test edits one file the way the editor does, then analyses another file in the same package. + */ +internal class DeclarationStalenessTest : KtLspTest() { + override val enableParserEventSystem = true + + private val openedPaths = mutableListOf() + + @After + fun closeDocs() { + openedPaths.forEach { FileManager.onDocumentClose(DocumentCloseEvent(it)) } + openedPaths.clear() + } + + @Test + fun addedFunctionResolvesInAnotherFile() = assertAddedDeclarationResolves("fun foo() = 1", "fun b() = foo()") + + @Test + fun removedFunctionIsUnresolvedInAnotherFile() = assertRemovedDeclarationIsUnresolved("fun foo() = 1", "fun b() = foo()") + + @Test + fun addedPropertyResolvesInAnotherFile() = assertAddedDeclarationResolves("val foo = 1", "fun b() = foo") + + @Test + fun removedPropertyIsUnresolvedInAnotherFile() = assertRemovedDeclarationIsUnresolved("val foo = 1", "fun b() = foo") + + @Test + fun addedClassResolvesInAnotherFile() = assertAddedDeclarationResolves("class Foo", "fun b() = Foo()") + + @Test + fun removedClassIsUnresolvedInAnotherFile() = assertRemovedDeclarationIsUnresolved("class Foo", "fun b() = Foo()") + + @Test + fun addedTypealiasResolvesInAnotherFile() = assertAddedDeclarationResolves("typealias Foo = String", "fun b(x: Foo) = x") + + @Test + fun removedTypealiasIsUnresolvedInAnotherFile() = assertRemovedDeclarationIsUnresolved("typealias Foo = String", "fun b(x: Foo) = x") + + @Test + fun addedNestedClassResolvesInAnotherFile() = + assertAddedDeclarationResolves("class Outer { class Inner }", "fun b() = Outer.Inner()", without = "class Outer") + + @Test + fun removedNestedClassIsUnresolvedInAnotherFile() = + assertRemovedDeclarationIsUnresolved("class Outer { class Inner }", "fun b() = Outer.Inner()", without = "class Outer") + + @Test + fun newFileInPackageIsFound() { + val b = createSourceFile("B.kt", "package p\nfun b() = bar()") + env.ktSymbolIndex.sourceFilesForPackage(FqName("p")) + + createSourceFile("C.kt", "package p\nfun bar() = 1") + + assertThat(diagnosticsOf(b)).doesNotContain(UNRESOLVED_REFERENCE) + } + + @Test + fun packageChangeMovesTheFile() { + createSourceFile("C.kt", "package p\nfun bar() = 1") + val path = sourcePath("C.kt") + env.ktSymbolIndex.sourceFilesForPackage(FqName("p")) + env.ktSymbolIndex.sourceFilesForPackage(FqName("q")) + + env.ktSymbolIndex.evictDiskCopy(path) + val moved = createSourceFile("C.kt", "package q\nfun bar() = 1") + assertThat(moved.packageFqName.asString()).isEqualTo("q") + + assertThat(pathsIn("p")).doesNotContain(path) + assertThat(pathsIn("q")).contains(path) + } + + /** A's disk copy lacks [declaration]; only the live document has it. */ + private fun assertAddedDeclarationResolves( + declaration: String, + usage: String, + without: String = "", + ) { + val withoutText = "package p\n$without" + val a = openDocument("A.kt", withoutText) + val b = createSourceFile("B.kt", "package p\n$usage") + + edit(a, withoutText, "package p\n$declaration", 2) + + assertThat(diagnosticsOf(b)).doesNotContain(UNRESOLVED_REFERENCE) + } + + /** A's disk copy has [declaration]; only the live document has dropped it. */ + private fun assertRemovedDeclarationIsUnresolved( + declaration: String, + usage: String, + without: String = "", + ) { + val withText = "package p\n$declaration" + val a = openDocument("A.kt", withText) + val b = createSourceFile("B.kt", "package p\n$usage") + + edit(a, withText, "package p\n$without", 2) + + assertThat(diagnosticsOf(b)).contains(UNRESOLVED_REFERENCE) + } + + private fun openDocument( + relativePath: String, + text: String, + ): Path { + createSourceFile(relativePath, text) + val path = sourcePath(relativePath) + FileManager.onDocumentOpen(DocumentOpenEvent(path, text, 1)) + openedPaths.add(path) + return path + } + + /** Changes the open document at [path], then waits for the live refresh, as the editor's next request would. */ + private fun edit( + path: Path, + oldText: String, + newText: String, + version: Int, + ) { + FileManager.onDocumentContentChange( + DocumentChangeEvent(path, oldText, newText, version, ChangeType.NEW_TEXT, 0, Range.NONE), + ) + runBlocking { env.ktSymbolIndex.refreshCurrentKtFile(path) } + } + + private fun diagnosticsOf(file: KtFile): List = + analyzeMaybeDanglingForTest(file) { + file + .collectDiagnostics(KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS) + .map { it.factoryName } + } + + private fun pathsIn(packageFqName: String): List = + env.ktSymbolIndex.sourceFilesForPackage(FqName(packageFqName)).map { it.toNioPath() } + + private fun sourcePath(relativePath: String): Path = env.sourceRoots.first().resolve(relativePath) + + private companion object { + const val UNRESOLVED_REFERENCE = "UNRESOLVED_REFERENCE" + } +} From 6dd66133237d47a777415580e1171e260ffc91a7 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 2 Nov 2026 15:46:42 +0000 Subject: [PATCH 07/15] ADFA-6858: Return lazy sequences from top-level declaration helpers --- .../compiler/services/DeclarationsProvider.kt | 22 ++++++++--------- .../compiler/services/TopLevelDeclarations.kt | 24 ++++++++----------- .../services/PrivateTopLevelResolutionTest.kt | 2 +- .../TopLevelDeclarationsEquivalenceTest.kt | 12 +++++----- 4 files changed, 27 insertions(+), 33 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt index c9ea6f24ab..df76679692 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt @@ -91,14 +91,16 @@ internal abstract class AbstractDeclarationProvider( ktFilesForPackage(scriptFqName).mapNotNull { it.script }.toList() override fun getAllClassesByClassId(classId: ClassId): Collection = - ktFilesForPackage(classId.packageFqName).let { files -> project.read { files.classesWithId(classId) } } + project.read { ktFilesForPackage(classId.packageFqName).classesWithId(classId).toList() } override fun getAllTypeAliasesByClassId(classId: ClassId): Collection = - ktFilesForPackage(classId.packageFqName).let { files -> project.read { files.typeAliasesWithId(classId) } } + project.read { ktFilesForPackage(classId.packageFqName).typeAliasesWithId(classId).toList() } override fun getClassLikeDeclarationByClassId(classId: ClassId): KtClassLikeDeclaration? = - getAllClassesByClassId(classId).firstOrNull() - ?: getAllTypeAliasesByClassId(classId).firstOrNull() + project.read { + val files = ktFilesForPackage(classId.packageFqName) + files.classesWithId(classId).firstOrNull() ?: files.typeAliasesWithId(classId).firstOrNull() + } override fun getTopLevelCallableFiles(callableId: CallableId): Collection = buildSet { @@ -107,20 +109,16 @@ internal abstract class AbstractDeclarationProvider( } override fun getTopLevelFunctions(callableId: CallableId): Collection = - ktFilesForPackage(callableId.packageName).let { files -> - project.read { files.topLevelFunctions(callableId.callableName) } - } + project.read { ktFilesForPackage(callableId.packageName).topLevelFunctions(callableId.callableName).toList() } override fun getTopLevelKotlinClassLikeDeclarationNamesInPackage(packageFqName: FqName): Set = - ktFilesForPackage(packageFqName).let { files -> project.read { files.topLevelClassLikeNames() } } + project.read { ktFilesForPackage(packageFqName).topLevelClassLikeNames().toSet() } override fun getTopLevelCallableNamesInPackage(packageFqName: FqName): Set = - ktFilesForPackage(packageFqName).let { files -> project.read { files.topLevelCallableNames() } } + project.read { ktFilesForPackage(packageFqName).topLevelCallableNames().toSet() } override fun getTopLevelProperties(callableId: CallableId): Collection = - ktFilesForPackage(callableId.packageName).let { files -> - project.read { files.topLevelProperties(callableId.callableName) } - } + project.read { ktFilesForPackage(callableId.packageName).topLevelProperties(callableId.callableName).toList() } } internal class DeclarationProvider( diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt index a6340e208d..2f7e47de06 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt @@ -13,45 +13,41 @@ import org.jetbrains.kotlin.psi.KtTypeAlias /* * Declaration lookups that read only KtFile.declarations instead of walking the whole PSI tree. - * Local declarations have no ClassId and are never top-level, so they never match. Callers must - * hold the project read lock. + * Local declarations have no ClassId and are never top-level, so they never match. + * + * The returned sequences are lazy and touch PSI while iterated, so callers must fully consume + * them under the project read lock. The name sequences may contain duplicates. */ -internal fun Sequence.classesWithId(classId: ClassId): List = +internal fun Sequence.classesWithId(classId: ClassId): Sequence = declarationsAndNested() .filterIsInstance() .filter { it.getClassId() == classId } - .toList() -internal fun Sequence.typeAliasesWithId(classId: ClassId): List = +internal fun Sequence.typeAliasesWithId(classId: ClassId): Sequence = declarationsAndNested() .filterIsInstance() .filter { it.getClassId() == classId } - .toList() -internal fun Sequence.topLevelFunctions(name: Name): List = +internal fun Sequence.topLevelFunctions(name: Name): Sequence = flatMap { it.declarations } .filterIsInstance() .filter { it.nameAsName == name } - .toList() -internal fun Sequence.topLevelProperties(name: Name): List = +internal fun Sequence.topLevelProperties(name: Name): Sequence = flatMap { it.declarations } .filterIsInstance() .filter { it.nameAsName == name } - .toList() -internal fun Sequence.topLevelCallableNames(): Set = +internal fun Sequence.topLevelCallableNames(): Sequence = flatMap { it.declarations } .filterIsInstance() .mapNotNull { it.nameAsName } - .toSet() -internal fun Sequence.topLevelClassLikeNames(): Set = +internal fun Sequence.topLevelClassLikeNames(): Sequence = flatMap { it.declarations } .filterIsInstance() .mapNotNull { it.nameAsName } - .toSet() private fun Sequence.declarationsAndNested(): Sequence = flatMap { it.declarations }.flatMap { it.withNestedDeclarations() } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PrivateTopLevelResolutionTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PrivateTopLevelResolutionTest.kt index a97d7e5636..28bfd3fd96 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PrivateTopLevelResolutionTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PrivateTopLevelResolutionTest.kt @@ -37,7 +37,7 @@ class PrivateTopLevelResolutionTest : KtLspTest() { @Test fun privateTopLevelFunctionsAreListed() { - val found = env.project.read { sequenceOf(file).topLevelFunctions(Name.identifier("helper")) } + val found = env.project.read { sequenceOf(file).topLevelFunctions(Name.identifier("helper")).toList() } assertThat(found).hasSize(1) } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarationsEquivalenceTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarationsEquivalenceTest.kt index c01c65e552..3ea605fe80 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarationsEquivalenceTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarationsEquivalenceTest.kt @@ -27,15 +27,15 @@ class TopLevelDeclarationsEquivalenceTest : KtLspTest() { env.project.read { for ((_, list) in byPackage) { val files = list.asSequence() - assertThat(files.topLevelCallableNames()).isEqualTo(legacyCallableNames(files)) - assertThat(files.topLevelClassLikeNames()).isEqualTo(legacyClassLikeNames(files)) + assertThat(files.topLevelCallableNames().toSet()).isEqualTo(legacyCallableNames(files)) + assertThat(files.topLevelClassLikeNames().toSet()).isEqualTo(legacyClassLikeNames(files)) for (id in legacyAllClassIds(files)) { - assertThat(files.classesWithId(id)).containsExactlyElementsIn(legacyClasses(files, id)) - assertThat(files.typeAliasesWithId(id)).containsExactlyElementsIn(legacyTypeAliases(files, id)) + assertThat(files.classesWithId(id).toList()).containsExactlyElementsIn(legacyClasses(files, id)) + assertThat(files.typeAliasesWithId(id).toList()).containsExactlyElementsIn(legacyTypeAliases(files, id)) } for (name in legacyCallableNames(files)) { - assertThat(files.topLevelFunctions(name)).containsExactlyElementsIn(legacyFunctions(files, name)) - assertThat(files.topLevelProperties(name)).containsExactlyElementsIn(legacyProperties(files, name)) + assertThat(files.topLevelFunctions(name).toList()).containsExactlyElementsIn(legacyFunctions(files, name)) + assertThat(files.topLevelProperties(name).toList()).containsExactlyElementsIn(legacyProperties(files, name)) } } } From 84a3d974c83c08f553df22948ad9d1770dee2ea0 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 2 Nov 2026 15:40:11 +0000 Subject: [PATCH 08/15] ADFA-6858: Let deletes and renames remove Kotlin files from the index The delete and rename handlers gated on isKotlinFile, which checks the file exists, so a deleted or renamed-away .kt never reached onFileRemoved. They now gate on the extension alone. removeFromIndex also evicts the cached disk copy, which otherwise outlived the file. With the old path now counted as Kotlin, a rename reaches the both-Kotlin branch for the first time. That branch used the kind-based compilationEnvironmentFor, which throws for a .kts side; it now uses the path-based lookup, which yields no environment for a script. The tests call onFileRemoved inside runWriteAction: the unit-test application grants platform write access only there, and the modification events assert it. Production's MockApplication always grants it. Deliberate breaks, each run then restored: - hasKotlinExtension also requires Files.exists: deletedKotlinPathStillCountsAsKotlin fails at DocumentUtilsKotlinPathTest.kt:16. - removeFromIndex without ktFileCache.invalidate: deletedFileLosesItsRowAndDiskCopy fails at KotlinFileRemovalTest.kt:84 (isDiskCopyCached expected to be false). - onFileRemoved without notifyElementModifiedForPath: removalOfAGoneFileInvalidatesTheModule fails at KotlinFileRemovalTest.kt:102 (no UNRESOLVED_REFERENCE). - onFileRemoved without removeFromIndex: removalOfAnOpenFileClearsItsRow fails at KotlinFileRemovalTest.kt:116 (row still present after 10 s). Reverting the handler guard changes, or the rename branch's .kts fix, fails no unit test: KotlinLanguageServer cannot be built in one, and the tests call onFileRemoved past the handlers. --- .../lsp/kotlin/KotlinLanguageServer.kt | 19 +-- .../lsp/kotlin/compiler/Compiler.kt | 7 ++ .../kotlin/compiler/index/KtSymbolIndex.kt | 6 + .../compiler/index/KotlinFileRemovalTest.kt | 118 ++++++++++++++++++ .../androidide/utils/DocumentUtils.java | 22 ++-- .../utils/DocumentUtilsKotlinPathTest.kt | 32 +++++ 6 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KotlinFileRemovalTest.kt create mode 100644 shared/src/test/java/com/itsaky/androidide/utils/DocumentUtilsKotlinPathTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index 98b6f32dc7..180c5760dd 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -475,12 +475,13 @@ class KotlinLanguageServer : ILanguageServer { @Suppress("unused") fun onFileDeleted(event: FileDeletionEvent) { val path = event.file.toPath() - if (!DocumentUtils.isKotlinFile(path)) { + // The file is already gone, so only its extension can say it was Kotlin. + if (!DocumentUtils.hasKotlinExtension(path)) { return } scope.launch { - runCatching { compiler?.compilationEnvironmentFor(path) } + runCatching { compiler?.compilationEnvironmentForKotlinPath(path) } .getOrNull() ?.onFileRemoved(path) } @@ -495,7 +496,8 @@ class KotlinLanguageServer : ILanguageServer { scope.launch { // One read: a rebuild can swap the compiler between two reads of the property. val compiler = compiler ?: return@launch - val oldIsKotlinFile = DocumentUtils.isKotlinFile(fromPath) + // The old path no longer exists, so only its extension can say it was Kotlin. + val oldIsKotlinFile = DocumentUtils.hasKotlinExtension(fromPath) val newIsKotlinFile = DocumentUtils.isKotlinFile(toPath) if (!oldIsKotlinFile && newIsKotlinFile) { @@ -511,17 +513,16 @@ class KotlinLanguageServer : ILanguageServer { // only the old file was a Kotlin file // so just remove it from the index compiler - .compilationEnvironmentFor(fromPath) + .compilationEnvironmentForKotlinPath(fromPath) ?.onFileRemoved(fromPath) return@launch } - val fromKind = runCatching { compiler.compilationKindFor(fromPath) }.getOrNull() - val toKind = runCatching { compiler.compilationKindFor(toPath) }.getOrNull() - val fromEnv = fromKind?.let { compiler.compilationEnvironmentFor(it) } - val toEnv = toKind?.let { compiler.compilationEnvironmentFor(it) } + // Path-based, so a .kts side yields no environment instead of the kind-based overload's throw. + val fromEnv = runCatching { compiler.compilationEnvironmentForKotlinPath(fromPath) }.getOrNull() + val toEnv = runCatching { compiler.compilationEnvironmentForKotlinPath(toPath) }.getOrNull() - if (fromKind != null && fromEnv == toEnv && toEnv != null) { + if (fromEnv != null && fromEnv == toEnv) { // file was renamed within the same compilation environment toEnv.onFileMoved(fromPath, toPath) return@launch diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt index 0023b4c2d7..714ff14a3d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt @@ -70,7 +70,14 @@ internal class Compiler( */ fun compilationEnvironmentFor(file: Path): CompilationEnvironment? { if (!DocumentUtils.isKotlinFile(file)) return null + return compilationEnvironmentForKotlinPath(file) + } + /** + * As [compilationEnvironmentFor], but without checking that [file] exists: for a Kotlin path that + * was deleted or renamed away, whose environment still holds its index entries. + */ + fun compilationEnvironmentForKotlinPath(file: Path): CompilationEnvironment? { val kind = compilationKindFor(file) if (kind == CompilationKind.Script) return null diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt index 92d2ac79f9..871b3454ff 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt @@ -1,5 +1,6 @@ package com.itsaky.androidide.lsp.kotlin.compiler.index +import androidx.annotation.VisibleForTesting import com.github.benmanes.caffeine.cache.Caffeine import com.itsaky.androidide.lsp.kotlin.compiler.CompilationKind import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority @@ -210,6 +211,7 @@ internal class KtSymbolIndex( } suspend fun removeFromIndex(path: Path) { + ktFileCache.invalidate(path) indexWorker.submitCommand(IndexCommand.RemoveFromIndex(path)) } @@ -317,6 +319,10 @@ internal class KtSymbolIndex( return VersionedKtFile(version, newKtFile) } + /** Whether the disk copy of [path] is held in the parsed-file cache. */ + @VisibleForTesting + internal fun isDiskCopyCached(path: Path): Boolean = ktFileCache.getIfPresent(path) != null + fun evictDiskCopy(path: Path) { ktFileCache.invalidate(path) if (project.isDisposed) return diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KotlinFileRemovalTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KotlinFileRemovalTest.kt new file mode 100644 index 0000000000..19cbfaf78a --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KotlinFileRemovalTest.kt @@ -0,0 +1,118 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.index + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.eventbus.events.editor.DocumentCloseEvent +import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.projects.FileManager +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter +import org.jetbrains.kotlin.com.intellij.openapi.application.ApplicationManager +import org.jetbrains.kotlin.psi.KtFile +import org.junit.After +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * A Kotlin file deleted from disk must leave the index: its metadata row, its cached disk copy, and + * every resolution that went through it. + */ +internal class KotlinFileRemovalTest : KtLspTest() { + private val openedPaths = mutableListOf() + + @After + fun closeDocs() { + openedPaths.forEach { FileManager.onDocumentClose(DocumentCloseEvent(it)) } + openedPaths.clear() + } + + private val declaringContent = "package p\n\nfun foo() = 1\n" + + private class Sources( + val declaring: Path, + val calling: KtFile, + ) + + /** A file declaring `foo`, and a caller whose analysis loads the declaring file's disk copy. */ + private fun createSources(): Sources { + createSourceFile("p/A.kt", declaringContent) + val calling = createSourceFile("p/B.kt", "package p\n\nfun bar() = foo()\n") + return Sources(env.sourceRoots.first().resolve("p/A.kt"), calling) + } + + private fun diagnosticNames(ktFile: KtFile): List = + analyze(ktFile) { + ktFile.collectDiagnostics(KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS).map { it.factoryName } + } + + /** Whether the index worker drops the metadata row for [path] within the timeout. */ + private fun rowRemoved(path: Path): Boolean = + runBlocking { + withTimeoutOrNull(10.seconds) { + while (env.ktSymbolIndex.fileIndex.contains(path.pathString)) { + delay(20.milliseconds) + } + } != null + } + + /* + * The unit-test application grants platform write access only inside runWriteAction, and the + * modification events onFileRemoved publishes assert it. Production's MockApplication always grants it. + */ + private fun removeInWriteAction(path: Path) { + ApplicationManager.getApplication().runWriteAction { runBlocking { env.onFileRemoved(path) } } + } + + @Test + fun deletedFileLosesItsRowAndDiskCopy() { + val sources = createSources() + assertThat(diagnosticNames(sources.calling)).doesNotContain("UNRESOLVED_REFERENCE") + assertThat(env.ktSymbolIndex.isDiskCopyCached(sources.declaring)).isTrue() + + Files.delete(sources.declaring) + env.ktSymbolIndex.syncIndexInBackground() + removeInWriteAction(sources.declaring) + val rowRemoved = rowRemoved(sources.declaring) + + assertThat(rowRemoved).isTrue() + assertThat(env.ktSymbolIndex.isDiskCopyCached(sources.declaring)).isFalse() + assertThat(diagnosticNames(sources.calling)).contains("UNRESOLVED_REFERENCE") + } + + /* + * The removal handler used to run only for paths that still existed, so a gone path reaching it is + * new: the VirtualFile may be stale or missing. The index worker is not started, so the deleted + * file's row stays and nothing but the invalidation can drop the caller's earlier resolution. + */ + @Test + fun removalOfAGoneFileInvalidatesTheModule() { + val sources = createSources() + assertThat(diagnosticNames(sources.calling)).doesNotContain("UNRESOLVED_REFERENCE") + + Files.delete(sources.declaring) + val failure = runCatching { removeInWriteAction(sources.declaring) }.exceptionOrNull() + + assertThat(failure).isNull() + assertThat(diagnosticNames(sources.calling)).contains("UNRESOLVED_REFERENCE") + } + + @Test + fun removalOfAnOpenFileClearsItsRow() { + val sources = createSources() + FileManager.onDocumentOpen(DocumentOpenEvent(sources.declaring, declaringContent, 1)) + openedPaths.add(sources.declaring) + assertThat(diagnosticNames(sources.calling)).doesNotContain("UNRESOLVED_REFERENCE") + + Files.delete(sources.declaring) + env.ktSymbolIndex.syncIndexInBackground() + removeInWriteAction(sources.declaring) + + assertThat(rowRemoved(sources.declaring)).isTrue() + } +} diff --git a/shared/src/main/java/com/itsaky/androidide/utils/DocumentUtils.java b/shared/src/main/java/com/itsaky/androidide/utils/DocumentUtils.java index ff0b13a343..551b00a5c6 100644 --- a/shared/src/main/java/com/itsaky/androidide/utils/DocumentUtils.java +++ b/shared/src/main/java/com/itsaky/androidide/utils/DocumentUtils.java @@ -27,6 +27,20 @@ */ public class DocumentUtils { + /** + * Whether {@code file} has a Kotlin source extension ({@code .kt} or {@code .kts}), without touching the filesystem. + * + *

+ * Unlike {@link #isKotlinFile(Path)}, this holds for a path that no longer exists. + */ + public static boolean hasKotlinExtension(Path file) { + if (file == null) { + return false; + } + final var extension = PathsKt.getExtension(file); + return extension.equals("kt") || extension.equals("kts"); + } + public static boolean isJavaFile(Path file) { final var name = PathsKt.getName(file); final var extension = PathsKt.getExtension(file); @@ -39,13 +53,7 @@ public static boolean isJavaFile(Path file) { } public static boolean isKotlinFile(Path file) { - if (file == null) { - return false; - } - final var extension = PathsKt.getExtension(file); - return (extension.equals("kt") || extension.equals("kts")) - && Files.exists(file) - && !Files.isDirectory(file); + return hasKotlinExtension(file) && Files.exists(file) && !Files.isDirectory(file); } public static boolean isSameFile(Path first, Path second) { diff --git a/shared/src/test/java/com/itsaky/androidide/utils/DocumentUtilsKotlinPathTest.kt b/shared/src/test/java/com/itsaky/androidide/utils/DocumentUtilsKotlinPathTest.kt new file mode 100644 index 0000000000..4fc9ed7b50 --- /dev/null +++ b/shared/src/test/java/com/itsaky/androidide/utils/DocumentUtilsKotlinPathTest.kt @@ -0,0 +1,32 @@ +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class DocumentUtilsKotlinPathTest { + @get:Rule + val tempDir = TemporaryFolder() + + @Test + fun deletedKotlinPathStillCountsAsKotlin() { + val deleted = tempDir.root.toPath().resolve("Gone.kt") + + assertThat(DocumentUtils.hasKotlinExtension(deleted)).isTrue() + } + + @Test + fun ktsCounts() { + val script = tempDir.newFile("build.gradle.kts").toPath() + + assertThat(DocumentUtils.hasKotlinExtension(script)).isTrue() + } + + @Test + fun javaDoesNot() { + val java = tempDir.newFile("Main.java").toPath() + + assertThat(DocumentUtils.hasKotlinExtension(java)).isFalse() + } +} From 2cdfa794034f2c098e6c990a91a54e4cc85adc1a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 2 Nov 2026 16:06:35 +0000 Subject: [PATCH 09/15] ADFA-6858: Use a block comment for the cache generation field --- .../androidide/lsp/kotlin/compiler/index/PackageFileCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt index 33a87d7c8a..87127cb23d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt @@ -19,7 +19,7 @@ internal class PackageFileCache( ) : KtFileMetadataWriteListener { private val lock = Any() - // Bumped by every write, under [lock]. A lookup only caches its result if no write ran meanwhile. + /** Bumped by every write, under [lock]. A lookup only caches its result if no write ran meanwhile. */ private var generation = 0L private val cache = From 872939eeed999d097389a3b19347c278b6b653df Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 2 Nov 2026 16:02:34 +0000 Subject: [PATCH 10/15] ADFA-6858: Harden package cache paths and read-lock the remaining provider lookups --- .../androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt | 3 ++- .../lsp/kotlin/compiler/services/DeclarationsProvider.kt | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt index 94f55a0779..8d0ff0bf41 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt @@ -13,6 +13,7 @@ import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider import com.itsaky.androidide.lsp.kotlin.compiler.write import com.itsaky.androidide.lsp.kotlin.compiler.writeUnlessDisposed +import com.itsaky.androidide.lsp.kotlin.utils.toNioPathOrNull import com.itsaky.androidide.lsp.kotlin.utils.toVirtualFileOrNull import com.itsaky.androidide.projects.FileManager import com.itsaky.androidide.utils.DocumentUtils @@ -115,7 +116,7 @@ internal class KtSymbolIndex( private val packageFiles = PackageFileCache( maxCachedFiles = PackageFileCache.DEFAULT_MAX_CACHED_FILES, - pathOf = { it.toNioPath().pathString }, + pathOf = { it.toNioPathOrNull()?.pathString.orEmpty() }, query = ::findSourceFiles, ) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt index 8c5fe9fad8..b9117790e5 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt @@ -82,10 +82,11 @@ internal abstract class AbstractDeclarationProvider( // that means we expect the kotlin stdlib to be included on the project emptyList() - override fun findFilesForFacadeByPackage(packageFqName: FqName): Collection = ktFilesForPackage(packageFqName).toList() + override fun findFilesForFacadeByPackage(packageFqName: FqName): Collection = + project.read { ktFilesForPackage(packageFqName).toList() } override fun findFilesForScript(scriptFqName: FqName): Collection = - ktFilesForPackage(scriptFqName).mapNotNull { it.script }.toList() + project.read { ktFilesForPackage(scriptFqName).mapNotNull { it.script }.toList() } override fun getAllClassesByClassId(classId: ClassId): Collection = project.read { ktFilesForPackage(classId.packageFqName).classesWithId(classId).toList() } From 18651d4a91813dd2ee312d59ac544c85a40a5ddd Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 3 Nov 2026 16:43:00 +0000 Subject: [PATCH 11/15] ADFA-6858: Keep the edited package cached and match writes against stored path sets --- .../kotlin/compiler/index/PackageFileCache.kt | 35 ++++++++++++++----- .../compiler/index/PackageFileCacheTest.kt | 27 +++++++++++++- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt index 87127cb23d..eb2c2d4dc1 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt @@ -22,21 +22,28 @@ internal class PackageFileCache( /** Bumped by every write, under [lock]. A lookup only caches its result if no write ran meanwhile. */ private var generation = 0L + /** A package's files, with their paths converted once so writes only do set lookups. */ + private class Entry( + val files: List, + val paths: Set, + ) + private val cache = Caffeine .newBuilder() .executor(executor) .maximumWeight(maxCachedFiles) - .weigher> { _, files -> files.size + 1 } - .build>() + .weigher> { _, entry -> entry.files.size + 1 } + .build>() /** Returns the files declared in [packageFqName], querying only when the answer is not cached. */ fun filesFor(packageFqName: String): List { - cache.getIfPresent(packageFqName)?.let { return it } + cache.getIfPresent(packageFqName)?.let { return it.files } val observed = synchronized(lock) { generation } val files = query(packageFqName) + val entry = Entry(files, files.mapTo(HashSet(), pathOf)) synchronized(lock) { - if (generation == observed) cache.put(packageFqName, files) + if (generation == observed) cache.put(packageFqName, entry) } return files } @@ -46,8 +53,9 @@ internal class PackageFileCache( generation++ when (write) { is KtFileMetadataWrite.Upserted -> { - cache.invalidate(write.packageFqName) - invalidateContaining(setOf(write.filePath)) + // A live edit re-upserts a file its package already lists; that list is still right. + invalidateUnlessListed(write.packageFqName, write.filePath) + invalidateContaining(setOf(write.filePath), except = write.packageFqName) } is KtFileMetadataWrite.Removed -> { @@ -60,11 +68,22 @@ internal class PackageFileCache( } } - private fun invalidateContaining(paths: Set) { + private fun invalidateUnlessListed( + packageFqName: String, + path: String, + ) { + val entry = cache.getIfPresent(packageFqName) ?: return + if (path !in entry.paths) cache.invalidate(packageFqName) + } + + private fun invalidateContaining( + paths: Set, + except: String? = null, + ) { val stale = cache .asMap() - .filterValues { files -> files.any { pathOf(it) in paths } } + .filter { (packageFqName, entry) -> packageFqName != except && paths.any { it in entry.paths } } .keys cache.invalidateAll(stale) } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCacheTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCacheTest.kt index cd6a613d82..3b968cce1c 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCacheTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCacheTest.kt @@ -12,6 +12,7 @@ import kotlin.concurrent.thread internal class PackageFileCacheTest { private val backing = mutableMapOf>() private val queries = AtomicInteger() + private val pathConversions = AtomicInteger() private val entered = AtomicReference() private val release = AtomicReference() @@ -20,7 +21,10 @@ internal class PackageFileCacheTest { private fun cache(max: Long = 100) = PackageFileCache( maxCachedFiles = max, - pathOf = { it }, + pathOf = { + pathConversions.incrementAndGet() + it + }, query = { pkg -> queries.incrementAndGet() val snapshot = backing[pkg].orEmpty() @@ -66,6 +70,27 @@ internal class PackageFileCacheTest { assertThat(cache.filesFor("old")).isEmpty() } + @Test + fun upsertOfAFileAlreadyListedKeepsItsPackage() { + backing["p"] = listOf("/a.kt", "/b.kt") + val cache = cache() + cache.filesFor("p") + cache.onWrite(upserted("/a.kt", "p")) + assertThat(cache.filesFor("p")).containsExactly("/a.kt", "/b.kt") + assertThat(queries.get()).isEqualTo(1) + } + + @Test + fun writesDoNotConvertCachedPaths() { + backing["p"] = listOf("/a.kt", "/b.kt", "/c.kt") + val cache = cache() + cache.filesFor("p") + pathConversions.set(0) + cache.onWrite(KtFileMetadataWrite.Removed(listOf("/other.kt"))) + cache.onWrite(upserted("/other.kt", "q")) + assertThat(pathConversions.get()).isEqualTo(0) + } + @Test fun removedPathDropsItsPackage() { backing["p"] = listOf("/a.kt", "/b.kt") From 75fcfef60fbc6e3e3d1ed83d4ea623071a1b2e84 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 3 Nov 2026 16:43:00 +0000 Subject: [PATCH 12/15] ADFA-6858: Skip session invalidation for paths in no module --- .../lsp/kotlin/compiler/AbstractCompilationEnvironment.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt index fe826dfdc0..50ac8e22d3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt @@ -400,6 +400,8 @@ internal abstract class AbstractCompilationEnvironment( .handleElementModification(ktFile, typeProvider(ktFile)) } + // A path in no module is in no session's scope; invalidating globally would only force + // the platform and library sessions to rebuild. if (module != null) { module.invalidateSearchScope() project.publishModificationEvent( @@ -412,11 +414,6 @@ internal abstract class AbstractCompilationEnvironment( .syncPublisher(LLFirSessionInvalidationTopics.SESSION_INVALIDATION) .afterInvalidation(setOf(module)) ResolutionScopeProvider.getInstance(project).invalidate(module) - } else { - project.analysisMessageBus - .syncPublisher(LLFirSessionInvalidationTopics.SESSION_INVALIDATION) - .afterGlobalInvalidation() - ResolutionScopeProvider.getInstance(project).invalidateAll() } } } From 858dd7a17e7ebc55675d041e632f1d4b5a951858 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 3 Nov 2026 16:43:00 +0000 Subject: [PATCH 13/15] ADFA-6858: Drop the old path's in-memory copy on every IDE rename --- .../lsp/kotlin/compiler/CompilationEnvironment.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt index 62dbce1ea2..b48032b191 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt @@ -262,11 +262,12 @@ internal class CompilationEnvironment( fromPath: Path, toPath: Path, ) { - val isFileOpen = FileManager.isActive(fromPath) onFileRemoved(fromPath) onFileCreated(toPath) - if (isFileOpen) { - ktSymbolIndex.invalidateCurrent(fromPath) + // The IDE moves an open document to its new path before posting the rename, so fromPath is + // never active here; drop its in-memory copy unconditionally and ask about toPath instead. + ktSymbolIndex.invalidateCurrent(fromPath) + if (FileManager.isActive(toPath)) { onFileOpen(toPath) } } From e1eabd304522e12290d6705eb30275a50a71689b Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 3 Nov 2026 16:43:00 +0000 Subject: [PATCH 14/15] ADFA-6858: Reuse toVirtualFileOrNull in findSourceFiles --- .../lsp/kotlin/compiler/index/KtSymbolIndex.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt index 8d0ff0bf41..f2a738517a 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt @@ -37,7 +37,6 @@ import org.jetbrains.kotlin.analysis.low.level.api.fir.util.originalKtFile import org.jetbrains.kotlin.com.intellij.openapi.application.ApplicationManager import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile -import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager import org.jetbrains.kotlin.com.intellij.psi.PsiManager import org.jetbrains.kotlin.com.intellij.psi.impl.PsiManagerEx import org.jetbrains.kotlin.com.intellij.psi.impl.file.impl.FileManagerImpl @@ -548,13 +547,11 @@ internal class KtSymbolIndex( /** Returns the source files the metadata index lists for [packageFqName]. */ internal fun sourceFilesForPackage(packageFqName: FqName): List = packageFiles.filesFor(packageFqName.asString()) - private fun findSourceFiles(packageFqName: String): List { - val vfm = VirtualFileManager.getInstance() - return fileIndex + private fun findSourceFiles(packageFqName: String): List = + fileIndex .getFilePathsForPackage(packageFqName) - .mapNotNull { vfm.findFileByNioPath(Paths.get(it)) } + .mapNotNull { Paths.get(it).toVirtualFileOrNull() } .toList() - } /** [getKtFile] for [vf], keyed by the path it maps to. */ @ResolutionSideKtFileAccess From 8440a558e8a9101da0a0c9d74501f93cdd1fc2db Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 3 Nov 2026 16:43:00 +0000 Subject: [PATCH 15/15] ADFA-6858: Find a class-like declaration in one walk of the package --- .../compiler/services/DeclarationsProvider.kt | 5 +- .../compiler/services/TopLevelDeclarations.kt | 11 ++++ .../compiler/services/ClassLikeWithIdTest.kt | 55 +++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ClassLikeWithIdTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt index b9117790e5..9c509048c2 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt @@ -95,10 +95,7 @@ internal abstract class AbstractDeclarationProvider( project.read { ktFilesForPackage(classId.packageFqName).typeAliasesWithId(classId).toList() } override fun getClassLikeDeclarationByClassId(classId: ClassId): KtClassLikeDeclaration? = - project.read { - val files = ktFilesForPackage(classId.packageFqName) - files.classesWithId(classId).firstOrNull() ?: files.typeAliasesWithId(classId).firstOrNull() - } + project.read { ktFilesForPackage(classId.packageFqName).classLikeWithId(classId) } override fun getTopLevelCallableFiles(callableId: CallableId): Collection = buildSet { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt index 2f7e47de06..4c6604869d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt @@ -29,6 +29,17 @@ internal fun Sequence.typeAliasesWithId(classId: ClassId): Sequence() .filter { it.getClassId() == classId } +/** The class, or failing that the type alias, with [classId], found in one walk of the files. */ +internal fun Sequence.classLikeWithId(classId: ClassId): KtClassLikeDeclaration? { + var typeAlias: KtTypeAlias? = null + for (declaration in declarationsAndNested()) { + if (declaration !is KtClassLikeDeclaration || declaration.getClassId() != classId) continue + if (declaration is KtClassOrObject) return declaration + if (typeAlias == null) typeAlias = declaration as? KtTypeAlias + } + return typeAlias +} + internal fun Sequence.topLevelFunctions(name: Name): Sequence = flatMap { it.declarations } .filterIsInstance() diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ClassLikeWithIdTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ClassLikeWithIdTest.kt new file mode 100644 index 0000000000..1d24159e8b --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ClassLikeWithIdTest.kt @@ -0,0 +1,55 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtClassLikeDeclaration +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtTypeAlias +import org.junit.Test + +class ClassLikeWithIdTest : KtLspTest() { + private var passes = 0 + + private fun parse(vararg sources: String): List = + env.project.read { sources.mapIndexed { i, text -> env.parser.createFile("F$i.kt", text) } } + + private fun lookup( + files: List, + name: String, + ): KtClassLikeDeclaration? = + env.project.read { + val counted = + sequence { + passes++ + yieldAll(files) + } + counted.classLikeWithId(ClassId(FqName("p"), Name.identifier(name))) + } + + @Test + fun aMissWalksThePackageOnce() { + val files = parse("package p\n\nclass A\n", "package p\n\nfun b() = 1\n") + + assertThat(lookup(files, "Missing")).isNull() + assertThat(passes).isEqualTo(1) + } + + @Test + fun aClassWinsOverATypeAliasListedBeforeIt() { + val files = parse("package p\n\ntypealias X = Int\n", "package p\n\nclass X\n") + + assertThat(lookup(files, "X")).isInstanceOf(KtClassOrObject::class.java) + } + + @Test + fun aTypeAliasIsFoundWhenNoClassMatches() { + val files = parse("package p\n\nclass A\n", "package p\n\ntypealias X = Int\n") + + assertThat(lookup(files, "X")).isInstanceOf(KtTypeAlias::class.java) + } +}