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() + } +} 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/AbstractCompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt index 7d53968061..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 @@ -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,72 @@ 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)) + } + + // 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( + KotlinModuleStateModificationEvent( + module, + KotlinModuleStateModificationKind.UPDATE, + ), + ) + project.analysisMessageBus + .syncPublisher(LLFirSessionInvalidationTopics.SESSION_INVALIDATION) + .afterInvalidation(setOf(module)) + ResolutionScopeProvider.getInstance(project).invalidate(module) + } + } + } + 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..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 @@ -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,30 +258,16 @@ 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, ) { - 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) } } 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..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 @@ -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 @@ -12,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 @@ -38,11 +40,13 @@ import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile 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 +112,15 @@ internal class KtSymbolIndex( .maximumSize(cacheSize) .build() + private val packageFiles = + PackageFileCache( + maxCachedFiles = PackageFileCache.DEFAULT_MAX_CACHED_FILES, + pathOf = { it.toNioPathOrNull()?.pathString.orEmpty() }, + query = ::findSourceFiles, + ) + + private val packageFilesRegistration: AutoCloseable = fileIndex.addWriteListener(packageFiles) + /** Set by AbstractCompilationEnvironment.initialize once the env's KtPsiFactory exists. */ lateinit var parser: KtPsiFactory @@ -210,6 +223,7 @@ internal class KtSymbolIndex( } suspend fun removeFromIndex(path: Path) { + ktFileCache.invalidate(path) indexWorker.submitCommand(IndexCommand.RemoveFromIndex(path)) } @@ -317,6 +331,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 @@ -526,6 +544,15 @@ 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 = + fileIndex + .getFilePathsForPackage(packageFqName) + .mapNotNull { Paths.get(it).toVirtualFileOrNull() } + .toList() + /** [getKtFile] for [vf], keyed by the path it maps to. */ @ResolutionSideKtFileAccess internal fun getKtFile(vf: VirtualFile): KtFile? = getKtFile(vf.toNioPath(), vf) @@ -583,6 +610,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 +637,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/index/PackageFileCache.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt new file mode 100644 index 0000000000..eb2c2d4dc1 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCache.kt @@ -0,0 +1,94 @@ +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 + + /** 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> { _, 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.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, entry) + } + return files + } + + override fun onWrite(write: KtFileMetadataWrite) = + synchronized(lock) { + generation++ + when (write) { + is KtFileMetadataWrite.Upserted -> { + // 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 -> { + invalidateContaining(write.filePaths.toSet()) + } + + KtFileMetadataWrite.Cleared -> { + cache.invalidateAll() + } + } + } + + 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() + .filter { (packageFqName, entry) -> packageFqName != except && paths.any { it in entry.paths } } + .keys + cache.invalidateAll(stale) + } + + companion object { + const val DEFAULT_MAX_CACHED_FILES = 2_000L + } +} 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..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 @@ -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,15 +14,12 @@ 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.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,8 +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 org.jetbrains.kotlin.psi.psiUtil.isTopLevelKtOrJavaMember -import java.nio.file.Paths internal class DeclarationProviderFactory : KtLspService, @@ -88,32 +82,20 @@ 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 = - ktFilesForPackage(classId.packageFqName) - .flatMap { - project.read { - PsiTreeUtil.collectElementsOfType(it, KtClassOrObject::class.java).asSequence() - } - }.filter { it.getClassId() == classId } - .toList() + project.read { ktFilesForPackage(classId.packageFqName).classesWithId(classId).toList() } override fun getAllTypeAliasesByClassId(classId: ClassId): Collection = - ktFilesForPackage(classId.packageFqName) - .flatMap { - project.read { - PsiTreeUtil.collectElementsOfType(it, KtTypeAlias::class.java).asSequence() - } - }.filter { it.getClassId() == classId } - .toList() + project.read { ktFilesForPackage(classId.packageFqName).typeAliasesWithId(classId).toList() } override fun getClassLikeDeclarationByClassId(classId: ClassId): KtClassLikeDeclaration? = - getAllClassesByClassId(classId).firstOrNull() - ?: getAllTypeAliasesByClassId(classId).firstOrNull() + project.read { ktFilesForPackage(classId.packageFqName).classLikeWithId(classId) } override fun getTopLevelCallableFiles(callableId: CallableId): Collection = buildSet { @@ -122,50 +104,16 @@ 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() + project.read { ktFilesForPackage(callableId.packageName).topLevelFunctions(callableId.callableName).toList() } 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() + project.read { ktFilesForPackage(packageFqName).topLevelClassLikeNames().toSet() } 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() + project.read { ktFilesForPackage(packageFqName).topLevelCallableNames().toSet() } 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() + project.read { ktFilesForPackage(callableId.packageName).topLevelProperties(callableId.callableName).toList() } } internal class DeclarationProvider( @@ -179,8 +127,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/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..4c6604869d --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/TopLevelDeclarations.kt @@ -0,0 +1,72 @@ +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. + * + * 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): Sequence = + declarationsAndNested() + .filterIsInstance() + .filter { it.getClassId() == classId } + +internal fun Sequence.typeAliasesWithId(classId: ClassId): Sequence = + declarationsAndNested() + .filterIsInstance() + .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() + .filter { it.nameAsName == name } + +internal fun Sequence.topLevelProperties(name: Name): Sequence = + flatMap { it.declarations } + .filterIsInstance() + .filter { it.nameAsName == name } + +internal fun Sequence.topLevelCallableNames(): Sequence = + flatMap { it.declarations } + .filterIsInstance() + .mapNotNull { it.nameAsName } + +internal fun Sequence.topLevelClassLikeNames(): Sequence = + flatMap { it.declarations } + .filterIsInstance() + .mapNotNull { it.nameAsName } + +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/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/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..3b968cce1c --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/PackageFileCacheTest.kt @@ -0,0 +1,194 @@ +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 pathConversions = AtomicInteger() + + private val entered = AtomicReference() + private val release = AtomicReference() + private var held: CountDownLatch? = null + + private fun cache(max: Long = 100) = + PackageFileCache( + maxCachedFiles = max, + pathOf = { + pathConversions.incrementAndGet() + 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 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") + 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() + } +} 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) + } +} 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" + } +} 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..28bfd3fd96 --- /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")).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 new file mode 100644 index 0000000000..3ea605fe80 --- /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().toSet()).isEqualTo(legacyCallableNames(files)) + assertThat(files.topLevelClassLikeNames().toSet()).isEqualTo(legacyClassLikeNames(files)) + for (id in legacyAllClassIds(files)) { + 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).toList()).containsExactlyElementsIn(legacyFunctions(files, name)) + assertThat(files.topLevelProperties(name).toList()).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() +} 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..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,36 +27,44 @@ */ 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; - } - } + /** + * 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); + + 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) { + return hasKotlinExtension(file) && 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); + } } 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() + } +}