diff --git a/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt b/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt index 654d55aebe..8ec068ae84 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/SnippetHandler.kt @@ -15,7 +15,7 @@ object SnippetHandler { private val log = LoggerFactory.getLogger(SnippetHandler::class.java) fun loadUserSnippets() { - loadUserSnippetsForLanguage("java", JavaSnippetScope.entries.toTypedArray()) + loadUserSnippetsForLanguage("java", JavaSnippetScope.entries) loadUserSnippetsForLanguage("xml", XML_SNIPPET_SCOPES) } @@ -50,7 +50,7 @@ object SnippetHandler { private fun loadUserSnippetsForLanguage( language: String, - scopes: Array, + scopes: Iterable, ) { SnippetRegistry.clearUserSnippets(language) val userSnippets = UserSnippetLoader.loadUserSnippets(language, scopes) diff --git a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt index 3a5add1edb..787c3e9a43 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -46,6 +46,7 @@ import com.itsaky.androidide.lsp.IDELanguageClientImpl import com.itsaky.androidide.lsp.api.ILanguageServer import com.itsaky.androidide.lsp.api.ILanguageServerRegistry import com.itsaky.androidide.lsp.java.JavaLanguageServer +import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer import com.itsaky.androidide.lsp.xml.XMLLanguageServer import com.itsaky.androidide.models.Range import com.itsaky.androidide.preferences.internal.EditorPreferences @@ -545,6 +546,7 @@ class CodeEditorView( val serverID: String = when (file.extension) { "java" -> JavaLanguageServer.SERVER_ID + "kt", "kts" -> KotlinLanguageServer.SERVER_ID "xml" -> XMLLanguageServer.SERVER_ID else -> return null } diff --git a/lsp/api/build.gradle.kts b/lsp/api/build.gradle.kts index 2aab922a93..e728203e1b 100644 --- a/lsp/api/build.gradle.kts +++ b/lsp/api/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { api(projects.lookup) api(projects.lsp.models) api(projects.preferences) + api(projects.idetooltips) compileOnly(projects.actions) compileOnly(projects.common) diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/CommentLineAction.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/CommentLineAction.kt new file mode 100644 index 0000000000..d95d804fd3 --- /dev/null +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/CommentLineAction.kt @@ -0,0 +1,86 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.lsp.actions + +import android.content.Context +import android.graphics.drawable.Drawable +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.EditorActionItem +import com.itsaky.androidide.actions.hasRequiredData +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.requireContext +import com.itsaky.androidide.actions.requireEditor +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.resources.R +import io.github.rosemoe.sora.text.batchEdit +import java.io.File + +/** @author Akash Yadav */ +class CommentLineAction( + lang: String, + private val targetFileExtensions: List, + private val lineCommentToken: String, +) : EditorActionItem { + + constructor(lang: String, extension: String, lineCommentToken: String) : + this(lang, listOf(extension), lineCommentToken) + override val id: String = "ide.editor.lsp.$lang.commentLine" + override var label: String = "" + + override var visible = true + override var enabled = true + override var icon: Drawable? = null + override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS + override var requiresUIThread: Boolean = true + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_COMMENT + + override fun prepare(data: ActionData) { + super.prepare(data) + + if (!data.hasRequiredData(Context::class.java, File::class.java)) { + markInvisible() + return + } + + val context = data.requireContext() + label = context.getString(R.string.action_comment_line) + + val file = data.requireFile() + if (file.extension !in targetFileExtensions) { + markInvisible() + return + } + } + + override suspend fun execAction(data: ActionData): Boolean { + val editor = data.requireEditor() + val text = editor.text + val cursor = editor.cursor + + text.batchEdit { + for (line in cursor.leftLine..cursor.rightLine) { + text.insert(line, 0, lineCommentToken) + } + } + + return true + } + + override fun dismissOnAction() = true +} diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/UncommentLineAction.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/UncommentLineAction.kt new file mode 100644 index 0000000000..66cf2b9ffd --- /dev/null +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/actions/UncommentLineAction.kt @@ -0,0 +1,92 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.lsp.actions + +import android.content.Context +import android.graphics.drawable.Drawable +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.EditorActionItem +import com.itsaky.androidide.actions.hasRequiredData +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.requireContext +import com.itsaky.androidide.actions.requireEditor +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.resources.R +import io.github.rosemoe.sora.text.batchEdit +import java.io.File + +/** @author Akash Yadav */ +class UncommentLineAction( + lang: String, + private val targetFileExtensions: List, + private val lineCommentToken: String, +) : EditorActionItem { + + constructor(lang: String, extension: String, lineCommentToken: String) : + this(lang, listOf(extension), lineCommentToken) + + override val id: String = "ide.editor.lsp.$lang.uncommentLine" + override var label: String = "" + + override var visible = true + override var enabled = true + override var icon: Drawable? = null + override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS + override var requiresUIThread: Boolean = true + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_UNCOMMENT + + + override fun prepare(data: ActionData) { + super.prepare(data) + + if (!data.hasRequiredData(Context::class.java, File::class.java)) { + markInvisible() + return + } + + val context = data.requireContext() + label = context.getString(R.string.action_uncomment_line) + + val file = data.requireFile() + if (file.extension !in targetFileExtensions) { + markInvisible() + return + } + } + + override suspend fun execAction(data: ActionData): Boolean { + val editor = data.requireEditor() + val text = editor.text + val cursor = editor.cursor + + text.batchEdit { + for (line in cursor.leftLine..cursor.rightLine) { + val l = text.getLineString(line) + if (l.trim().startsWith(lineCommentToken)) { + val i = l.indexOf(lineCommentToken) + text.delete(line, i, line, i + 2) + } + } + } + + return true + } + + override fun dismissOnAction() = true +} diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetParser.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetParser.kt index 4e846c5468..d8d825c672 100644 --- a/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetParser.kt +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetParser.kt @@ -37,7 +37,7 @@ object SnippetParser { fun parse( lang: String, - scopes: Array, + scopes: Iterable, snippetFactory: (String, String, List) -> ISnippet = { prefix, desc, body -> DefaultSnippet(prefix, desc, body.toTypedArray()) }, diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetRegistry.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetRegistry.kt index f8e3721c39..993e84e289 100644 --- a/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetRegistry.kt +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/SnippetRegistry.kt @@ -67,7 +67,7 @@ object SnippetRegistry { return scopes.flatMap { getSnippets(language, it) } } - fun initBuiltIn(language: String, scopes: Array) { + fun initBuiltIn(language: String, scopes: Iterable) { val parsed = SnippetParser.parse(language, scopes) lock.write { parsed.forEach { (scope, snippets) -> diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/UserSnippetLoader.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/UserSnippetLoader.kt index e94e54c640..38e9af7f77 100644 --- a/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/UserSnippetLoader.kt +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/snippets/UserSnippetLoader.kt @@ -12,7 +12,7 @@ object UserSnippetLoader { fun loadUserSnippets( language: String, - scopes: Array, + scopes: Iterable, ): Map> { val langDir = getUserSnippetsDir(language) if (!langDir.isDirectory) return emptyMap() diff --git a/lsp/indexing/build.gradle.kts b/lsp/indexing/build.gradle.kts index b81c2d47b3..4ccc7230dd 100644 --- a/lsp/indexing/build.gradle.kts +++ b/lsp/indexing/build.gradle.kts @@ -16,4 +16,7 @@ dependencies { api(libs.kotlinx.coroutines.core) api(projects.logger) + + testImplementation(projects.testing.unit) + testImplementation(libs.tests.kotlinx.coroutines) } diff --git a/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/FilteredIndexTest.kt b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/FilteredIndexTest.kt new file mode 100644 index 0000000000..81168d8d74 --- /dev/null +++ b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/FilteredIndexTest.kt @@ -0,0 +1,165 @@ +package org.appdevforall.codeonthego.indexing + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.appdevforall.codeonthego.indexing.api.IndexDescriptor +import org.appdevforall.codeonthego.indexing.api.IndexField +import org.appdevforall.codeonthego.indexing.api.IndexQuery +import org.appdevforall.codeonthego.indexing.api.Indexable +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class FilteredIndexTest { + + data class Entry( + override val key: String, + override val sourceId: String, + val value: String, + ) : Indexable + + private val descriptor = object : IndexDescriptor { + override val name = "test_filtered" + override val fields = listOf(IndexField("value")) + + override fun fieldValues(entry: Entry) = mapOf("value" to entry.value) + + override fun serialize(entry: Entry) = + "${entry.key}|${entry.sourceId}|${entry.value}".toByteArray() + + override fun deserialize(bytes: ByteArray): Entry { + val parts = String(bytes).split("|") + return Entry(parts[0], parts[1], parts[2]) + } + } + + private suspend fun setupBackingAndFiltered(): Pair, FilteredIndex> { + val backing = InMemoryIndex(descriptor) + backing.insert(Entry("k1", "src1", "val1")) + backing.insert(Entry("k2", "src1", "val2")) + backing.insert(Entry("k3", "src2", "val3")) + return backing to FilteredIndex(backing) + } + + @Test + fun `query returns nothing when no sources active`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + assertThat(filtered.query(IndexQuery.ALL).toList()).isEmpty() + } + + @Test + fun `query returns entries for active source`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + filtered.activateSource("src1") + val keys = filtered.query(IndexQuery.ALL).map { it.key }.toList() + assertThat(keys).containsExactly("k1", "k2") + } + + @Test + fun `deactivateSource hides entries from that source`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + filtered.activateSource("src1") + filtered.activateSource("src2") + filtered.deactivateSource("src1") + + val keys = filtered.query(IndexQuery.ALL).map { it.key }.toList() + assertThat(keys).containsExactly("k3") + } + + @Test + fun `setActiveSources replaces entire active set`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + filtered.activateSource("src1") + filtered.setActiveSources(setOf("src2")) + + val keys = filtered.query(IndexQuery.ALL).map { it.key }.toList() + assertThat(keys).containsExactly("k3") + } + + @Test + fun `get returns null when source is inactive`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + assertThat(filtered.get("k1")).isNull() + } + + @Test + fun `get returns entry when source is active`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + filtered.activateSource("src1") + assertThat(filtered.get("k1")).isNotNull() + assertThat(filtered.get("k1")!!.key).isEqualTo("k1") + } + + @Test + fun `get returns null for nonexistent key even with active source`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + filtered.activateSource("src1") + assertThat(filtered.get("missing")).isNull() + } + + @Test + fun `containsSource returns false when source inactive`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + assertThat(filtered.containsSource("src1")).isFalse() + } + + @Test + fun `containsSource returns true only when source is active AND in backing`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + filtered.activateSource("src1") + assertThat(filtered.containsSource("src1")).isTrue() + assertThat(filtered.containsSource("src999")).isFalse() + } + + @Test + fun `isCached returns true if source exists in backing regardless of active state`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + assertThat(filtered.isCached("src1")).isTrue() + assertThat(filtered.isCached("src999")).isFalse() + } + + @Test + fun `activeSources returns current active set`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + filtered.activateSource("src1") + filtered.activateSource("src2") + assertThat(filtered.activeSources()).containsExactly("src1", "src2") + } + + @Test + fun `isActive reflects current state`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + assertThat(filtered.isActive("src1")).isFalse() + filtered.activateSource("src1") + assertThat(filtered.isActive("src1")).isTrue() + filtered.deactivateSource("src1") + assertThat(filtered.isActive("src1")).isFalse() + } + + @Test + fun `explicit sourceId query returns empty for inactive source`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + // src1 has entries in backing but is not active + val results = filtered.query(IndexQuery.bySource("src1")).toList() + assertThat(results).isEmpty() + } + + @Test + fun `all sources active returns all entries from backing`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + filtered.activateSource("src1") + filtered.activateSource("src2") + + val results = filtered.query(IndexQuery.ALL).toList() + assertThat(results).hasSize(3) + } + + @Test + fun `close clears active sources`() = runTest { + val (_, filtered) = setupBackingAndFiltered() + filtered.activateSource("src1") + filtered.close() + assertThat(filtered.activeSources()).isEmpty() + } +} diff --git a/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndexTest.kt b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndexTest.kt new file mode 100644 index 0000000000..45cff41032 --- /dev/null +++ b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndexTest.kt @@ -0,0 +1,249 @@ +package org.appdevforall.codeonthego.indexing + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.appdevforall.codeonthego.indexing.api.IndexDescriptor +import org.appdevforall.codeonthego.indexing.api.IndexField +import org.appdevforall.codeonthego.indexing.api.IndexQuery +import org.appdevforall.codeonthego.indexing.api.Indexable +import org.appdevforall.codeonthego.indexing.api.indexQuery +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class InMemoryIndexTest { + + data class TestEntry( + override val key: String, + override val sourceId: String, + val name: String, + val category: String? = null, + ) : Indexable + + private val descriptor = object : IndexDescriptor { + override val name = "test" + override val fields = listOf( + IndexField("name", prefixSearchable = true), + IndexField("category"), + ) + + override fun fieldValues(entry: TestEntry) = mapOf( + "name" to entry.name, + "category" to entry.category, + ) + + override fun serialize(entry: TestEntry) = + "${entry.key}|${entry.sourceId}|${entry.name}|${entry.category}".toByteArray() + + override fun deserialize(bytes: ByteArray): TestEntry { + val parts = String(bytes).split("|") + return TestEntry(parts[0], parts[1], parts[2], parts[3].takeIf { it != "null" }) + } + } + + private fun makeIndex() = InMemoryIndex(descriptor) + + private fun entry(key: String, sourceId: String, name: String, category: String? = null) = + TestEntry(key, sourceId, name, category) + + @Test + fun `insert and get by key`() = runTest { + val index = makeIndex() + val e = entry("k1", "src1", "Foo") + index.insert(e) + assertThat(index.get("k1")).isEqualTo(e) + assertThat(index.get("missing")).isNull() + } + + @Test + fun `query returns all entries when no predicates`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Alpha")) + index.insert(entry("k2", "src1", "Beta")) + index.insert(entry("k3", "src2", "Gamma")) + + val results = index.query(IndexQuery.ALL).toList() + assertThat(results).hasSize(3) + } + + @Test + fun `exact match on field`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Alpha", "lib")) + index.insert(entry("k2", "src1", "Beta", "app")) + index.insert(entry("k3", "src2", "Gamma", "lib")) + + val results = index.query(indexQuery { eq("category", "lib") }).toList() + assertThat(results.map { it.key }).containsExactly("k1", "k3") + } + + @Test + fun `prefix match on field`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "ArrayList")) + index.insert(entry("k2", "src1", "ArrayDeque")) + index.insert(entry("k3", "src1", "String")) + + val results = index.query(indexQuery { prefix("name", "Array") }).toList() + assertThat(results.map { it.key }).containsExactlyElementsIn(listOf("k1", "k2")) + } + + @Test + fun `prefix match is case-insensitive`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "ArrayList")) + index.insert(entry("k2", "src1", "ARRAYDEQUE")) + + val results = index.query(indexQuery { prefix("name", "array") }).toList() + assertThat(results).hasSize(2) + } + + @Test + fun `containsSource returns true for indexed source`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Foo")) + assertThat(index.containsSource("src1")).isTrue() + assertThat(index.containsSource("src999")).isFalse() + } + + @Test + fun `removeBySource removes all entries from that source`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Alpha")) + index.insert(entry("k2", "src1", "Beta")) + index.insert(entry("k3", "src2", "Gamma")) + + index.removeBySource("src1") + + assertThat(index.get("k1")).isNull() + assertThat(index.get("k2")).isNull() + assertThat(index.get("k3")).isNotNull() + assertThat(index.containsSource("src1")).isFalse() + assertThat(index.size).isEqualTo(1) + } + + @Test + fun `clear removes all entries`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Alpha")) + index.insert(entry("k2", "src2", "Beta")) + + index.clear() + + assertThat(index.size).isEqualTo(0) + assertThat(index.sourceCount).isEqualTo(0) + } + + @Test + fun `insert replaces existing entry with same key`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Original")) + index.insert(entry("k1", "src1", "Updated")) + + assertThat(index.get("k1")!!.name).isEqualTo("Updated") + assertThat(index.size).isEqualTo(1) + } + + @Test + fun `query by key returns single match`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Alpha")) + index.insert(entry("k2", "src1", "Beta")) + + val results = index.query(IndexQuery.byKey("k1")).toList() + assertThat(results).hasSize(1) + assertThat(results[0].key).isEqualTo("k1") + } + + @Test + fun `query by source returns entries for that source`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Alpha")) + index.insert(entry("k2", "src1", "Beta")) + index.insert(entry("k3", "src2", "Gamma")) + + val results = index.query(IndexQuery.bySource("src1")).toList() + assertThat(results.map { it.key }).containsExactly("k1", "k2") + } + + @Test + fun `query respects limit`() = runTest { + val index = makeIndex() + repeat(10) { i -> index.insert(entry("k$i", "src1", "Name$i")) } + + val results = index.query(IndexQuery(limit = 3)).toList() + assertThat(results).hasSize(3) + } + + @Test + fun `presence filter - field exists`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Alpha", "lib")) + index.insert(entry("k2", "src1", "Beta")) // no category + + val results = index.query(indexQuery { exists("category") }).toList() + assertThat(results.map { it.key }).containsExactly("k1") + } + + @Test + fun `presence filter - field absent`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Alpha", "lib")) + index.insert(entry("k2", "src1", "Beta")) + + val results = index.query(indexQuery { notExists("category") }).toList() + assertThat(results.map { it.key }).containsExactly("k2") + } + + @Test + fun `distinctValues returns unique field values`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Alpha", "lib")) + index.insert(entry("k2", "src1", "Beta", "lib")) + index.insert(entry("k3", "src2", "Gamma", "app")) + + val values = index.distinctValues("category").toSet() + assertThat(values).containsExactly("lib", "app") + } + + @Test + fun `multi-field combined query`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "ArrayList", "java")) + index.insert(entry("k2", "src1", "ArrayDeque", "java")) + index.insert(entry("k3", "src2", "ArrayBlockingQueue", "concurrent")) + + val results = index.query(indexQuery { + eq("category", "java") + prefix("name", "Array") + }).toList() + assertThat(results.map { it.key }).containsExactlyElementsIn(listOf("k1", "k2")) + } + + @Test + fun `insertAll inserts multiple entries`() = runTest { + val index = makeIndex() + val entries = (1..5).map { i -> entry("k$i", "src1", "Name$i") } + index.insertAll(entries.asSequence()) + + assertThat(index.size).isEqualTo(5) + } + + @Test + fun `query with unknown field returns empty sequence`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Foo")) + + val results = index.query(indexQuery { eq("nonexistentField", "value") }).toList() + assertThat(results).isEmpty() + } + + @Test + fun `removeBySource is no-op for unknown source`() = runTest { + val index = makeIndex() + index.insert(entry("k1", "src1", "Foo")) + index.removeBySource("unknownSource") + assertThat(index.size).isEqualTo(1) + } +} diff --git a/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/MergedIndexTest.kt b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/MergedIndexTest.kt new file mode 100644 index 0000000000..d41c8ed03c --- /dev/null +++ b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/MergedIndexTest.kt @@ -0,0 +1,156 @@ +package org.appdevforall.codeonthego.indexing + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.appdevforall.codeonthego.indexing.api.IndexDescriptor +import org.appdevforall.codeonthego.indexing.api.IndexField +import org.appdevforall.codeonthego.indexing.api.IndexQuery +import org.appdevforall.codeonthego.indexing.api.Indexable +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class MergedIndexTest { + + data class Entry( + override val key: String, + override val sourceId: String, + val value: String, + ) : Indexable + + private val descriptor = object : IndexDescriptor { + override val name = "test_merged" + override val fields = listOf(IndexField("value")) + + override fun fieldValues(entry: Entry) = mapOf("value" to entry.value) + + override fun serialize(entry: Entry) = + "${entry.key}|${entry.sourceId}|${entry.value}".toByteArray() + + override fun deserialize(bytes: ByteArray): Entry { + val parts = String(bytes).split("|") + return Entry(parts[0], parts[1], parts[2]) + } + } + + private fun makeIndex() = InMemoryIndex(descriptor) + + @Test + fun `merges results from multiple indexes`() = runTest { + val idx1 = makeIndex().also { it.insert(Entry("k1", "s1", "v1")) } + val idx2 = makeIndex().also { it.insert(Entry("k2", "s2", "v2")) } + val merged = MergedIndex(idx1, idx2) + + val keys = merged.query(IndexQuery.ALL).map { it.key }.toList() + assertThat(keys).containsExactly("k1", "k2") + } + + @Test + fun `deduplicates by key - first index wins`() = runTest { + val idx1 = makeIndex().also { it.insert(Entry("k1", "s1", "from-index1")) } + val idx2 = makeIndex().also { it.insert(Entry("k1", "s2", "from-index2")) } + val merged = MergedIndex(idx1, idx2) + + val results = merged.query(IndexQuery.ALL).toList() + assertThat(results).hasSize(1) + assertThat(results[0].value).isEqualTo("from-index1") + } + + @Test + fun `respects limit across indexes`() = runTest { + val idx1 = makeIndex().also { e -> + (1..5).forEach { i -> e.insert(Entry("k$i", "s1", "v$i")) } + } + val idx2 = makeIndex().also { e -> + (6..10).forEach { i -> e.insert(Entry("k$i", "s2", "v$i")) } + } + val merged = MergedIndex(idx1, idx2) + + val results = merged.query(IndexQuery(limit = 3)).toList() + assertThat(results).hasSize(3) + } + + @Test + fun `get returns first match in priority order`() = runTest { + val idx1 = makeIndex().also { it.insert(Entry("k1", "s1", "primary")) } + val idx2 = makeIndex().also { it.insert(Entry("k1", "s2", "secondary")) } + val merged = MergedIndex(idx1, idx2) + + assertThat(merged.get("k1")!!.value).isEqualTo("primary") + } + + @Test + fun `get returns from second index when not in first`() = runTest { + val idx1 = makeIndex() + val idx2 = makeIndex().also { it.insert(Entry("k2", "s2", "second")) } + val merged = MergedIndex(idx1, idx2) + + assertThat(merged.get("k2")!!.value).isEqualTo("second") + } + + @Test + fun `get returns null if key not in any index`() = runTest { + val merged = MergedIndex(makeIndex()) + assertThat(merged.get("missing")).isNull() + } + + @Test + fun `containsSource checks all indexes`() = runTest { + val idx1 = makeIndex().also { it.insert(Entry("k1", "src1", "v1")) } + val idx2 = makeIndex().also { it.insert(Entry("k2", "src2", "v2")) } + val merged = MergedIndex(idx1, idx2) + + assertThat(merged.containsSource("src1")).isTrue() + assertThat(merged.containsSource("src2")).isTrue() + assertThat(merged.containsSource("src999")).isFalse() + } + + @Test + fun `distinctValues deduplicates across indexes`() = runTest { + val idx1 = makeIndex().also { e -> + e.insert(Entry("k1", "s1", "alpha")) + e.insert(Entry("k2", "s1", "beta")) + } + val idx2 = makeIndex().also { e -> + e.insert(Entry("k3", "s2", "beta")) // duplicate + e.insert(Entry("k4", "s2", "gamma")) + } + val merged = MergedIndex(idx1, idx2) + + val values = merged.distinctValues("value").toSet() + assertThat(values).containsExactly("alpha", "beta", "gamma") + } + + @Test + fun `empty merged index returns empty results`() = runTest { + val merged = MergedIndex(emptyList()) + + assertThat(merged.query(IndexQuery.ALL).toList()).isEmpty() + assertThat(merged.get("k1")).isNull() + assertThat(merged.containsSource("s1")).isFalse() + assertThat(merged.distinctValues("value").toList()).isEmpty() + } + + @Test + fun `single index merged works identically to unmerged`() = runTest { + val idx = makeIndex().also { + it.insert(Entry("k1", "s1", "val1")) + it.insert(Entry("k2", "s1", "val2")) + } + val merged = MergedIndex(idx) + + assertThat(merged.query(IndexQuery.ALL).toList()).hasSize(2) + assertThat(merged.get("k1")).isNotNull() + assertThat(merged.containsSource("s1")).isTrue() + } + + @Test + fun `vararg constructor builds merged index`() = runTest { + val idx1 = makeIndex().also { it.insert(Entry("k1", "s1", "a")) } + val idx2 = makeIndex().also { it.insert(Entry("k2", "s2", "b")) } + val merged = MergedIndex(idx1, idx2) + + assertThat(merged.query(IndexQuery.ALL).toList()).hasSize(2) + } +} diff --git a/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/service/IndexRegistryTest.kt b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/service/IndexRegistryTest.kt new file mode 100644 index 0000000000..6181fcddbc --- /dev/null +++ b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/service/IndexRegistryTest.kt @@ -0,0 +1,174 @@ +package org.appdevforall.codeonthego.indexing.service + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.io.Closeable + +@RunWith(JUnit4::class) +class IndexRegistryTest { + + private val keyA = IndexKey("index-a") + private val keyB = IndexKey("index-b") + + @Test + fun `get returns null before registration`() { + val registry = IndexRegistry() + assertThat(registry.get(keyA)).isNull() + } + + @Test + fun `register and get return same instance`() { + val registry = IndexRegistry() + registry.register(keyA, "hello") + assertThat(registry.get(keyA)).isEqualTo("hello") + } + + @Test + fun `require throws if not registered`() { + val registry = IndexRegistry() + try { + registry.require(keyA) + error("expected exception not thrown") + } catch (e: IllegalStateException) { + assertThat(e.message).contains("index-a") + } + } + + @Test + fun `require returns value when registered`() { + val registry = IndexRegistry() + registry.register(keyA, "world") + assertThat(registry.require(keyA)).isEqualTo("world") + } + + @Test + fun `isRegistered reflects current state`() { + val registry = IndexRegistry() + assertThat(registry.isRegistered(keyA)).isFalse() + registry.register(keyA, "x") + assertThat(registry.isRegistered(keyA)).isTrue() + } + + @Test + fun `registeredKeys returns all registered key names`() { + val registry = IndexRegistry() + registry.register(keyA, "x") + registry.register(keyB, 42) + assertThat(registry.registeredKeys()).containsExactly("index-a", "index-b") + } + + @Test + fun `ifAvailable returns null when not registered`() { + val registry = IndexRegistry() + val result = registry.ifAvailable(keyA) { it.length } + assertThat(result).isNull() + } + + @Test + fun `ifAvailable invokes block when registered`() { + val registry = IndexRegistry() + registry.register(keyA, "hello") + val result = registry.ifAvailable(keyA) { it.length } + assertThat(result).isEqualTo(5) + } + + @Test + fun `re-registering replaces old value`() { + val registry = IndexRegistry() + registry.register(keyA, "first") + registry.register(keyA, "second") + assertThat(registry.get(keyA)).isEqualTo("second") + } + + @Test + fun `onAvailable notifies immediately if already registered`() { + val registry = IndexRegistry() + registry.register(keyA, "present") + + var received: String? = null + registry.onAvailable(keyA) { received = it } + + assertThat(received).isEqualTo("present") + } + + @Test + fun `onAvailable notifies after subsequent registration`() { + val registry = IndexRegistry() + + var received: String? = null + registry.onAvailable(keyA) { received = it } + assertThat(received).isNull() + + registry.register(keyA, "later") + assertThat(received).isEqualTo("later") + } + + @Test + fun `onAvailable listener is called on re-registration`() { + val registry = IndexRegistry() + registry.register(keyA, "first") + + val received = mutableListOf() + registry.onAvailable(keyA) { received.add(it) } + + registry.register(keyA, "second") + + assertThat(received).containsExactly("first", "second") + } + + @Test + fun `unregister returns old value`() { + val registry = IndexRegistry() + registry.register(keyA, "value") + val old = registry.unregister(keyA) + assertThat(old).isEqualTo("value") + assertThat(registry.get(keyA)).isNull() + } + + @Test + fun `unregister returns null if not registered`() { + val registry = IndexRegistry() + assertThat(registry.unregister(keyA)).isNull() + } + + @Test + fun `close clears all indexes and listeners`() { + val registry = IndexRegistry() + registry.register(keyA, "x") + registry.register(keyB, 42) + registry.close() + assertThat(registry.registeredKeys()).isEmpty() + } + + @Test + fun `close calls close on Closeable indexes`() { + val registry = IndexRegistry() + val keyC = IndexKey("index-c") + val closeable = CloseableValue() + registry.register(keyC, closeable) + registry.close() + assertThat(closeable.closed).isTrue() + } + + @Test + fun `multiple keys coexist independently`() { + val registry = IndexRegistry() + registry.register(keyA, "text") + registry.register(keyB, 99) + assertThat(registry.get(keyA)).isEqualTo("text") + assertThat(registry.get(keyB)).isEqualTo(99) + } + + @Test + fun `registeredKeys returns empty set when nothing registered`() { + val registry = IndexRegistry() + assertThat(registry.registeredKeys()).isEmpty() + } + + class CloseableValue : Closeable { + var closed = false + override fun close() { closed = true } + } +} diff --git a/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/service/IndexingServiceManagerTest.kt b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/service/IndexingServiceManagerTest.kt new file mode 100644 index 0000000000..02d1cf6ef4 --- /dev/null +++ b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/service/IndexingServiceManagerTest.kt @@ -0,0 +1,119 @@ +package org.appdevforall.codeonthego.indexing.service + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class IndexingServiceManagerTest { + + class TestService(override val id: String) : IndexingService { + override val providedKeys = emptyList>() + var initialized = false + var buildCompleted = false + var closed = false + + override suspend fun initialize(registry: IndexRegistry) { + initialized = true + } + + override suspend fun onBuildCompleted() { + buildCompleted = true + } + + override fun close() { + closed = true + } + } + + @Test + fun `register adds service retrievable by id`() { + val manager = IndexingServiceManager() + val svc = TestService("svc-a") + manager.register(svc) + assertThat(manager.getService("svc-a")).isSameInstanceAs(svc) + manager.close() + } + + @Test + fun `duplicate registration is silently ignored`() { + val manager = IndexingServiceManager() + val svc1 = TestService("svc-a") + val svc2 = TestService("svc-a") + manager.register(svc1) + manager.register(svc2) + assertThat(manager.getService("svc-a")).isSameInstanceAs(svc1) + manager.close() + } + + @Test + fun `allServices returns all registered services`() { + val manager = IndexingServiceManager() + manager.register(TestService("a")) + manager.register(TestService("b")) + manager.register(TestService("c")) + assertThat(manager.allServices()).hasSize(3) + manager.close() + } + + @Test + fun `getService returns null for unregistered id`() { + val manager = IndexingServiceManager() + assertThat(manager.getService("unknown")).isNull() + manager.close() + } + + @Test + fun `close calls close on each registered service`() { + val manager = IndexingServiceManager() + val svc1 = TestService("a") + val svc2 = TestService("b") + manager.register(svc1) + manager.register(svc2) + manager.close() + assertThat(svc1.closed).isTrue() + assertThat(svc2.closed).isTrue() + } + + @Test + fun `close clears services list`() { + val manager = IndexingServiceManager() + manager.register(TestService("a")) + manager.close() + assertThat(manager.allServices()).isEmpty() + } + + @Test + fun `registry is accessible and starts empty`() { + val manager = IndexingServiceManager() + assertThat(manager.registry).isNotNull() + assertThat(manager.registry.registeredKeys()).isEmpty() + manager.close() + } + + @Test + fun `onBuildCompleted before initialization does not throw`() { + val manager = IndexingServiceManager() + val svc = TestService("a") + manager.register(svc) + // Should be a no-op and not throw + manager.onBuildCompleted() + manager.close() + } + + @Test + fun `onSourceChanged before initialization is a no-op`() { + val manager = IndexingServiceManager() + manager.register(TestService("a")) + manager.onSourceChanged() + manager.close() + } + + @Test + fun `close after close does not throw`() { + val manager = IndexingServiceManager() + manager.close() + manager.close() // second close should be safe + } +} diff --git a/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexerTest.kt b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexerTest.kt new file mode 100644 index 0000000000..0e3d96380f --- /dev/null +++ b/lsp/indexing/src/test/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexerTest.kt @@ -0,0 +1,188 @@ +package org.appdevforall.codeonthego.indexing.util + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.appdevforall.codeonthego.indexing.InMemoryIndex +import org.appdevforall.codeonthego.indexing.api.IndexDescriptor +import org.appdevforall.codeonthego.indexing.api.IndexField +import org.appdevforall.codeonthego.indexing.api.Indexable +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class BackgroundIndexerTest { + + data class Entry( + override val key: String, + override val sourceId: String, + val value: String, + ) : Indexable + + private val descriptor = object : IndexDescriptor { + override val name = "bg_test" + override val fields = listOf(IndexField("value")) + + override fun fieldValues(entry: Entry) = mapOf("value" to entry.value) + + override fun serialize(entry: Entry) = + "${entry.key}|${entry.sourceId}|${entry.value}".toByteArray() + + override fun deserialize(bytes: ByteArray): Entry { + val parts = String(bytes).split("|") + return Entry(parts[0], parts[1], parts[2]) + } + } + + private fun makeIndexAndIndexer(): Pair, BackgroundIndexer> { + val index = InMemoryIndex(descriptor) + return index to BackgroundIndexer(index) + } + + @Test + fun `indexSource inserts all provided entries`() = runTest { + val (index, indexer) = makeIndexAndIndexer() + + indexer.indexSource("src1", skipIfExists = false) { sourceId -> + sequenceOf( + Entry("k1", sourceId, "v1"), + Entry("k2", sourceId, "v2"), + ) + }.join() + + assertThat(index.size).isEqualTo(2) + assertThat(index.get("k1")).isNotNull() + assertThat(index.get("k2")).isNotNull() + } + + @Test + fun `skipIfExists=true skips re-indexing of existing source`() = runTest { + val (index, indexer) = makeIndexAndIndexer() + + indexer.indexSource("src1", skipIfExists = false) { sourceId -> + sequenceOf(Entry("k1", sourceId, "original")) + }.join() + + indexer.indexSource("src1", skipIfExists = true) { sourceId -> + sequenceOf(Entry("k1", sourceId, "updated")) + }.join() + + assertThat(index.get("k1")!!.value).isEqualTo("original") + } + + @Test + fun `skipIfExists=false forces re-indexing`() = runTest { + val (index, indexer) = makeIndexAndIndexer() + + indexer.indexSource("src1", skipIfExists = false) { sourceId -> + sequenceOf(Entry("k1", sourceId, "original")) + }.join() + + indexer.indexSource("src1", skipIfExists = false) { sourceId -> + sequenceOf(Entry("k1", sourceId, "updated")) + }.join() + + assertThat(index.get("k1")!!.value).isEqualTo("updated") + } + + @Test + fun `progressListener receives Started and Completed events`() = runTest { + val (_, indexer) = makeIndexAndIndexer() + val events = mutableListOf() + indexer.progressListener = IndexingProgressListener { _, event -> events.add(event) } + + indexer.indexSource("src1", skipIfExists = false) { sourceId -> + sequenceOf(Entry("k1", sourceId, "v1")) + }.join() + + assertThat(events).contains(IndexingEvent.Started) + val completed = events.filterIsInstance() + assertThat(completed).hasSize(1) + assertThat(completed.first().totalIndexed).isEqualTo(1) + } + + @Test + fun `progressListener receives Skipped when source already indexed`() = runTest { + val (_, indexer) = makeIndexAndIndexer() + + indexer.indexSource("src1", skipIfExists = false) { sourceId -> + sequenceOf(Entry("k1", sourceId, "v1")) + }.join() + + val events = mutableListOf() + indexer.progressListener = IndexingProgressListener { _, event -> events.add(event) } + + indexer.indexSource("src1", skipIfExists = true) { _ -> + emptySequence() + }.join() + + assertThat(events).contains(IndexingEvent.Skipped) + } + + @Test + fun `awaitAll waits for all in-flight jobs`() = runTest { + val (index, indexer) = makeIndexAndIndexer() + + indexer.indexSource("src1", skipIfExists = false) { sourceId -> + sequenceOf(Entry("k1", sourceId, "v1")) + } + indexer.indexSource("src2", skipIfExists = false) { sourceId -> + sequenceOf(Entry("k2", sourceId, "v2")) + } + + indexer.awaitAll() + + assertThat(index.size).isEqualTo(2) + } + + @Test + fun `indexSources indexes all provided sources`() = runTest { + val (index, indexer) = makeIndexAndIndexer() + val sources = listOf("jar1", "jar2", "jar3") + + val jobs = indexer.indexSources(sources, skipIfExists = false) { sourceId -> + sourceId to sequenceOf(Entry("key-$sourceId", sourceId, "val")) + } + jobs.forEach { it.join() } + + assertThat(index.size).isEqualTo(3) + assertThat(index.containsSource("jar1")).isTrue() + assertThat(index.containsSource("jar2")).isTrue() + assertThat(index.containsSource("jar3")).isTrue() + } + + @Test + fun `activeJobCount reflects in-flight jobs`() = runTest { + val (_, indexer) = makeIndexAndIndexer() + assertThat(indexer.activeJobCount).isEqualTo(0) + } + + @Test + fun `close cancels all active jobs`() = runTest { + val (_, indexer) = makeIndexAndIndexer() + indexer.close() + assertThat(indexer.activeJobCount).isEqualTo(0) + } + + @Test + fun `indexSource removes stale entries before re-indexing`() = runTest { + val (index, indexer) = makeIndexAndIndexer() + + // Index with two entries + indexer.indexSource("src1", skipIfExists = false) { sourceId -> + sequenceOf( + Entry("k1", sourceId, "old1"), + Entry("k2", sourceId, "old2"), + ) + }.join() + + // Re-index with only one entry + indexer.indexSource("src1", skipIfExists = false) { sourceId -> + sequenceOf(Entry("k1", sourceId, "new1")) + }.join() + + assertThat(index.size).isEqualTo(1) + assertThat(index.get("k1")!!.value).isEqualTo("new1") + assertThat(index.get("k2")).isNull() + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt index 8d106784c7..1f1b3ecdfc 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionsMenu.kt @@ -19,12 +19,12 @@ package com.itsaky.androidide.lsp.java.actions import com.itsaky.androidide.actions.ActionItem import com.itsaky.androidide.lsp.actions.IActionsMenuProvider -import com.itsaky.androidide.lsp.java.actions.common.CommentAction +import com.itsaky.androidide.lsp.actions.CommentLineAction import com.itsaky.androidide.lsp.java.actions.common.FindReferencesAction import com.itsaky.androidide.lsp.java.actions.common.GoToDefinitionAction import com.itsaky.androidide.lsp.java.actions.common.OrganizeImportsAction import com.itsaky.androidide.lsp.java.actions.common.RemoveUnusedImportsAction -import com.itsaky.androidide.lsp.java.actions.common.UncommentAction +import com.itsaky.androidide.lsp.actions.UncommentLineAction import com.itsaky.androidide.lsp.java.actions.diagnostics.AddImportAction import com.itsaky.androidide.lsp.java.actions.diagnostics.AddThrowsAction import com.itsaky.androidide.lsp.java.actions.diagnostics.AutoFixImportsAction @@ -48,29 +48,33 @@ import com.itsaky.androidide.lsp.java.actions.generators.OverrideSuperclassMetho */ object JavaCodeActionsMenu : IActionsMenuProvider { - override val actions: List = - listOf( - CommentAction(), - UncommentAction(), - GoToDefinitionAction(), - FindReferencesAction(), - AddImportAction(), - AutoFixImportsAction(), - ImplementAbstractMethodsAction(), - VariableToStatementAction(), - FieldToBlockAction(), - RemoveClassAction(), - RemoveMethodAction(), - RemoveUnusedThrowsAction(), - CreateMissingMethodAction(), - SuppressUncheckedWarningAction(), - AddThrowsAction(), - GenerateSettersAndGettersAction(), - OverrideSuperclassMethodsAction(), - GenerateMissingConstructorAction(), - GenerateConstructorAction(), - GenerateToStringMethodAction(), - RemoveUnusedImportsAction(), - OrganizeImportsAction() - ) + private const val LANG = "java" + private const val EXT = "java" + private const val LINE_COMMENT_TOKEN = "//" + + override val actions: List = + listOf( + CommentLineAction(LANG, EXT, LINE_COMMENT_TOKEN), + UncommentLineAction(LANG, EXT, LINE_COMMENT_TOKEN), + GoToDefinitionAction(), + FindReferencesAction(), + AddImportAction(), + AutoFixImportsAction(), + ImplementAbstractMethodsAction(), + VariableToStatementAction(), + FieldToBlockAction(), + RemoveClassAction(), + RemoveMethodAction(), + RemoveUnusedThrowsAction(), + CreateMissingMethodAction(), + SuppressUncheckedWarningAction(), + AddThrowsAction(), + GenerateSettersAndGettersAction(), + OverrideSuperclassMethodsAction(), + GenerateMissingConstructorAction(), + GenerateConstructorAction(), + GenerateToStringMethodAction(), + RemoveUnusedImportsAction(), + OrganizeImportsAction() + ) } diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/common/CommentAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/common/CommentAction.kt deleted file mode 100644 index d15f9e2bac..0000000000 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/common/CommentAction.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.lsp.java.actions.common - -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.requireEditor -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction -import com.itsaky.androidide.resources.R - -/** @author Akash Yadav */ -class CommentAction : BaseJavaCodeAction() { - override val id: String = "ide.editor.lsp.java.commentLine" - override var label: String = "" - - override val titleTextRes: Int = R.string.action_comment_line - - override var requiresUIThread: Boolean = true - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_COMMENT - - override suspend fun execAction(data: ActionData): Boolean { - val editor = data.requireEditor() - val text = editor.text - val cursor = editor.cursor - - text.beginBatchEdit() - for (line in cursor.leftLine..cursor.rightLine) { - text.insert(line, 0, "//") - } - text.endBatchEdit() - - return true - } - - override fun dismissOnAction() = true -} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/common/UncommentAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/common/UncommentAction.kt deleted file mode 100644 index 9c9978fe7f..0000000000 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/common/UncommentAction.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.lsp.java.actions.common - -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.requireEditor -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction -import com.itsaky.androidide.resources.R - -/** @author Akash Yadav */ -class UncommentAction : BaseJavaCodeAction() { - override val id: String = "ide.editor.lsp.java.uncommentLine" - override var label: String = "" - - override val titleTextRes: Int = R.string.action_uncomment_line - - override var requiresUIThread: Boolean = true - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_UNCOMMENT - - override suspend fun execAction(data: ActionData): Boolean { - val editor = data.requireEditor() - val text = editor.text - val cursor = editor.cursor - - text.beginBatchEdit() - for (line in cursor.leftLine..cursor.rightLine) { - val l = text.getLineString(line) - if (l.trim().startsWith("//")) { - val i = l.indexOf("//") - text.delete(line, i, line, i + 2) - } - } - text.endBatchEdit() - - return true - } - - override fun dismissOnAction() = true -} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/snippet/JavaSnippetRepository.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/snippet/JavaSnippetRepository.kt index 8a39b1b8b2..07a229c419 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/snippet/JavaSnippetRepository.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/snippet/JavaSnippetRepository.kt @@ -20,14 +20,19 @@ package com.itsaky.androidide.lsp.java.providers.snippet import com.itsaky.androidide.lsp.snippets.ISnippet import com.itsaky.androidide.lsp.snippets.SnippetRegistry +/** + * Repository to store various snippets for Java. + * + * @author Akash Yadav + */ object JavaSnippetRepository { - val snippets: Map> - get() = JavaSnippetScope.entries.associateWith { scope -> - SnippetRegistry.getSnippets("java", scope.filename) - } + val snippets: Map> + get() = JavaSnippetScope.entries.associateWith { scope -> + SnippetRegistry.getSnippets("java", scope.filename) + } - fun init() { - SnippetRegistry.initBuiltIn("java", JavaSnippetScope.entries.toTypedArray()) - } + fun init() { + SnippetRegistry.initBuiltIn("java", JavaSnippetScope.entries) + } } diff --git a/lsp/jvm-symbol-index/build.gradle.kts b/lsp/jvm-symbol-index/build.gradle.kts index 959f2264be..a26497c9c2 100644 --- a/lsp/jvm-symbol-index/build.gradle.kts +++ b/lsp/jvm-symbol-index/build.gradle.kts @@ -21,4 +21,7 @@ dependencies { api(projects.lsp.jvmSymbolModels) api(projects.subprojects.kotlinAnalysisApi) api(projects.subprojects.projects) + + testImplementation(projects.testing.unit) + testImplementation(libs.tests.kotlinx.coroutines) } diff --git a/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptorTest.kt b/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptorTest.kt new file mode 100644 index 0000000000..de10f07d10 --- /dev/null +++ b/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptorTest.kt @@ -0,0 +1,368 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class JvmSymbolDescriptorTest { + + private fun classSymbol( + name: String = "com/example/Foo", + shortName: String = "Foo", + pkg: String = "com.example", + sourceId: String = "foo.jar", + kind: JvmSymbolKind = JvmSymbolKind.CLASS, + language: JvmSourceLanguage = JvmSourceLanguage.KOTLIN, + visibility: JvmVisibility = JvmVisibility.PUBLIC, + kotlin: KotlinClassInfo? = null, + containingClass: String = "", + ) = JvmSymbol( + key = name, + sourceId = sourceId, + name = name, + shortName = shortName, + packageName = pkg, + kind = kind, + language = language, + visibility = visibility, + data = JvmClassInfo(containingClassName = containingClass, kotlin = kotlin), + ) + + private fun roundtrip(symbol: JvmSymbol): JvmSymbol = + JvmSymbolDescriptor.deserialize(JvmSymbolDescriptor.serialize(symbol)) + + @Test + fun `roundtrip preserves all core fields for class`() { + val symbol = classSymbol( + name = "com/example/MyClass", + shortName = "MyClass", + pkg = "com.example", + sourceId = "mylib.jar", + visibility = JvmVisibility.INTERNAL, + language = JvmSourceLanguage.JAVA, + ) + val restored = roundtrip(symbol) + assertThat(restored.name).isEqualTo(symbol.name) + assertThat(restored.shortName).isEqualTo(symbol.shortName) + assertThat(restored.packageName).isEqualTo(symbol.packageName) + assertThat(restored.sourceId).isEqualTo(symbol.sourceId) + assertThat(restored.kind).isEqualTo(symbol.kind) + assertThat(restored.language).isEqualTo(symbol.language) + assertThat(restored.visibility).isEqualTo(symbol.visibility) + } + + @Test + fun `roundtrip preserves kotlin class metadata`() { + val symbol = classSymbol( + kind = JvmSymbolKind.DATA_CLASS, + kotlin = KotlinClassInfo( + isData = true, + companionObjectName = "Companion", + sealedSubclasses = listOf("com/example/Foo\$Sub1", "com/example/Foo\$Sub2"), + isSealed = false, + ), + ) + val restored = roundtrip(symbol) + val kt = (restored.data as JvmClassInfo).kotlin!! + assertThat(kt.isData).isTrue() + assertThat(kt.companionObjectName).isEqualTo("Companion") + assertThat(kt.sealedSubclasses).containsExactly("com/example/Foo\$Sub1", "com/example/Foo\$Sub2") + } + + @Test + fun `roundtrip for sealed class with subclasses`() { + val symbol = classSymbol( + kind = JvmSymbolKind.SEALED_CLASS, + kotlin = KotlinClassInfo( + isSealed = true, + sealedSubclasses = listOf("A", "B", "C"), + ), + ) + val kt = (roundtrip(symbol).data as JvmClassInfo).kotlin!! + assertThat(kt.isSealed).isTrue() + assertThat(kt.sealedSubclasses).containsExactly("A", "B", "C") + } + + @Test + fun `roundtrip for class without kotlin metadata`() { + val symbol = classSymbol(language = JvmSourceLanguage.JAVA) + val restored = roundtrip(symbol) + assertThat((restored.data as JvmClassInfo).kotlin).isNull() + } + + @Test + fun `roundtrip preserves function with parameters`() { + val params = listOf( + JvmParameterInfo("x", "java/lang/String", "String"), + JvmParameterInfo("y", "I", "Int", hasDefaultValue = true), + ) + val symbol = JvmSymbol( + key = "com/example/Foo.bar(java/lang/String,I)", + sourceId = "foo.jar", + name = "com/example/Foo.bar", + shortName = "bar", + packageName = "com.example", + kind = JvmSymbolKind.FUNCTION, + language = JvmSourceLanguage.KOTLIN, + data = JvmFunctionInfo( + containingClassName = "com/example/Foo", + returnTypeName = "kotlin/String", + returnTypeDisplayName = "String", + parameterCount = 2, + parameters = params, + signatureDisplay = "fun bar(x: String, y: Int = 0): String", + typeParameters = listOf("T"), + isStatic = false, + ), + ) + val restored = roundtrip(symbol) + val data = restored.data as JvmFunctionInfo + assertThat(data.parameters).hasSize(2) + assertThat(data.parameters[0].name).isEqualTo("x") + assertThat(data.parameters[0].typeName).isEqualTo("java/lang/String") + assertThat(data.parameters[1].hasDefaultValue).isTrue() + assertThat(data.returnTypeDisplayName).isEqualTo("String") + assertThat(data.signatureDisplay).isEqualTo("fun bar(x: String, y: Int = 0): String") + assertThat(data.typeParameters).containsExactly("T") + } + + @Test + fun `roundtrip preserves kotlin function metadata`() { + val symbol = JvmSymbol( + key = "com/example/exFun()", + sourceId = "foo.jar", + name = "com/example/exFun", + shortName = "exFun", + packageName = "com.example", + kind = JvmSymbolKind.EXTENSION_FUNCTION, + language = JvmSourceLanguage.KOTLIN, + data = JvmFunctionInfo( + kotlin = KotlinFunctionInfo( + receiverTypeName = "kotlin/collections/List", + receiverTypeDisplayName = "List<*>", + isSuspend = true, + isInline = true, + isInfix = false, + isOperator = false, + ), + ), + ) + val kt = (roundtrip(symbol).data as JvmFunctionInfo).kotlin!! + assertThat(kt.receiverTypeName).isEqualTo("kotlin/collections/List") + assertThat(kt.receiverTypeDisplayName).isEqualTo("List<*>") + assertThat(kt.isSuspend).isTrue() + assertThat(kt.isInline).isTrue() + } + + @Test + fun `roundtrip preserves vararg parameter flag`() { + val symbol = JvmSymbol( + key = "f(I)", + sourceId = "s", + name = "f", + shortName = "f", + packageName = "p", + kind = JvmSymbolKind.FUNCTION, + language = JvmSourceLanguage.KOTLIN, + data = JvmFunctionInfo( + parameters = listOf( + JvmParameterInfo("args", "[I", "IntArray", isVararg = true), + ), + ), + ) + val restored = roundtrip(symbol) + val param = (restored.data as JvmFunctionInfo).parameters.first() + assertThat(param.isVararg).isTrue() + } + + @Test + fun `roundtrip preserves field with kotlin property metadata`() { + val symbol = JvmSymbol( + key = "com/example/Foo.count", + sourceId = "foo.jar", + name = "com/example/Foo.count", + shortName = "count", + packageName = "com.example", + kind = JvmSymbolKind.PROPERTY, + language = JvmSourceLanguage.KOTLIN, + data = JvmFieldInfo( + containingClassName = "com/example/Foo", + typeName = "I", + typeDisplayName = "Int", + isFinal = true, + kotlin = KotlinPropertyInfo( + isConst = true, + hasGetter = true, + hasSetter = false, + isTypeNullable = false, + ), + ), + ) + val data = roundtrip(symbol).data as JvmFieldInfo + assertThat(data.typeDisplayName).isEqualTo("Int") + assertThat(data.isFinal).isTrue() + val kotlin = data.kotlin!! + assertThat(kotlin.isConst).isTrue() + assertThat(kotlin.hasGetter).isTrue() + assertThat(kotlin.hasSetter).isFalse() + } + + @Test + fun `roundtrip preserves enum entry`() { + val symbol = JvmSymbol( + key = "com/example/Status.ACTIVE", + sourceId = "foo.jar", + name = "com/example/Status.ACTIVE", + shortName = "ACTIVE", + packageName = "com.example", + kind = JvmSymbolKind.ENUM_ENTRY, + language = JvmSourceLanguage.KOTLIN, + data = JvmEnumEntryInfo(containingClassName = "com/example/Status", ordinal = 2), + ) + val data = roundtrip(symbol).data as JvmEnumEntryInfo + assertThat(data.ordinal).isEqualTo(2) + assertThat(data.containingClassName).isEqualTo("com/example/Status") + } + + @Test + fun `roundtrip preserves type alias`() { + val symbol = JvmSymbol( + key = "com/example/MyList", + sourceId = "foo.jar", + name = "com/example/MyList", + shortName = "MyList", + packageName = "com.example", + kind = JvmSymbolKind.TYPE_ALIAS, + language = JvmSourceLanguage.KOTLIN, + data = JvmTypeAliasInfo( + expandedTypeName = "java/util/ArrayList", + expandedTypeDisplayName = "ArrayList", + typeParameters = listOf("T"), + ), + ) + val data = roundtrip(symbol).data as JvmTypeAliasInfo + assertThat(data.expandedTypeName).isEqualTo("java/util/ArrayList") + assertThat(data.expandedTypeDisplayName).isEqualTo("ArrayList") + assertThat(data.typeParameters).containsExactly("T") + } + + @Test + fun `all JvmSymbolKind values roundtrip through serialization`() { + for (kind in JvmSymbolKind.entries) { + val data: JvmSymbolInfo = when { + kind == JvmSymbolKind.ENUM_ENTRY -> JvmEnumEntryInfo() + kind == JvmSymbolKind.TYPE_ALIAS -> JvmTypeAliasInfo() + kind == JvmSymbolKind.FIELD + || kind == JvmSymbolKind.PROPERTY + || kind == JvmSymbolKind.EXTENSION_PROPERTY -> JvmFieldInfo() + kind.isCallable -> JvmFunctionInfo() + else -> JvmClassInfo() + } + val symbol = JvmSymbol( + key = "k", sourceId = "s", name = "n", shortName = "n", + packageName = "p", kind = kind, + language = JvmSourceLanguage.KOTLIN, data = data, + ) + assertThat(roundtrip(symbol).kind).isEqualTo(kind) + } + } + + @Test + fun `all JvmVisibility values roundtrip`() { + for (vis in JvmVisibility.entries) { + val restored = roundtrip(classSymbol(visibility = vis)) + assertThat(restored.visibility).isEqualTo(vis) + } + } + + @Test + fun `all JvmSourceLanguage values roundtrip`() { + for (lang in JvmSourceLanguage.entries) { + val restored = roundtrip(classSymbol(language = lang)) + assertThat(restored.language).isEqualTo(lang) + } + } + + @Test + fun `isDeprecated flag roundtrips`() { + val s = classSymbol().copy(isDeprecated = true) + assertThat(roundtrip(s).isDeprecated).isTrue() + } + + @Test + fun `fieldValues extracts name shortName package kind language`() { + val symbol = classSymbol( + name = "com/example/Foo", + shortName = "Foo", + pkg = "com.example", + kind = JvmSymbolKind.CLASS, + language = JvmSourceLanguage.KOTLIN, + ) + val fields = JvmSymbolDescriptor.fieldValues(symbol) + assertThat(fields[JvmSymbolDescriptor.KEY_NAME]).isEqualTo("Foo") + assertThat(fields[JvmSymbolDescriptor.KEY_PACKAGE]).isEqualTo("com.example") + assertThat(fields[JvmSymbolDescriptor.KEY_KIND]).isEqualTo("CLASS") + assertThat(fields[JvmSymbolDescriptor.KEY_LANGUAGE]).isEqualTo("KOTLIN") + } + + @Test + fun `fieldValues containingClass is null for top-level class`() { + val fields = JvmSymbolDescriptor.fieldValues(classSymbol()) + assertThat(fields[JvmSymbolDescriptor.KEY_CONTAINING_CLASS]).isNull() + } + + @Test + fun `fieldValues containingClass is set for nested class`() { + val symbol = classSymbol(containingClass = "com/example/Outer") + val fields = JvmSymbolDescriptor.fieldValues(symbol) + assertThat(fields[JvmSymbolDescriptor.KEY_CONTAINING_CLASS]).isEqualTo("com/example/Outer") + } + + @Test + fun `fieldValues receiverType is null for regular function`() { + val symbol = JvmSymbol( + key = "f()", sourceId = "s", name = "f", shortName = "f", packageName = "p", + kind = JvmSymbolKind.FUNCTION, language = JvmSourceLanguage.KOTLIN, + data = JvmFunctionInfo(), + ) + assertThat(JvmSymbolDescriptor.fieldValues(symbol)[JvmSymbolDescriptor.KEY_RECEIVER_TYPE]) + .isNull() + } + + @Test + fun `fieldValues receiverType is set for extension function`() { + val symbol = JvmSymbol( + key = "f()", sourceId = "s", name = "f", shortName = "f", packageName = "p", + kind = JvmSymbolKind.EXTENSION_FUNCTION, language = JvmSourceLanguage.KOTLIN, + data = JvmFunctionInfo( + kotlin = KotlinFunctionInfo(receiverTypeName = "kotlin/collections/List"), + ), + ) + assertThat(JvmSymbolDescriptor.fieldValues(symbol)[JvmSymbolDescriptor.KEY_RECEIVER_TYPE]) + .isEqualTo("kotlin/collections/List") + } + + @Test + fun `descriptor name is jvm_symbols`() { + assertThat(JvmSymbolDescriptor.name).isEqualTo("jvm_symbols") + } + + @Test + fun `descriptor has 6 fields`() { + assertThat(JvmSymbolDescriptor.fields).hasSize(6) + } + + @Test + fun `name field is prefix-searchable`() { + val nameField = JvmSymbolDescriptor.fields.first { it.name == JvmSymbolDescriptor.KEY_NAME } + assertThat(nameField.prefixSearchable).isTrue() + } + + @Test + fun `other fields are not prefix-searchable`() { + val otherFields = JvmSymbolDescriptor.fields.filter { it.name != JvmSymbolDescriptor.KEY_NAME } + assertThat(otherFields.none { it.prefixSearchable }).isTrue() + } +} diff --git a/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndexTest.kt b/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndexTest.kt new file mode 100644 index 0000000000..c22b7da504 --- /dev/null +++ b/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndexTest.kt @@ -0,0 +1,315 @@ +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.appdevforall.codeonthego.indexing.util.BackgroundIndexer +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Unit tests for [JvmSymbolIndex] using an in-memory backing index. + * + * Each test activates the source before inserting so that [FilteredIndex] + * makes the entries visible. + */ +@RunWith(JUnit4::class) +class JvmSymbolIndexTest { + + private val defaultSource = "test.jar" + + private fun makeIndex(): JvmSymbolIndex { + val backing = InMemoryIndex(JvmSymbolDescriptor) + val indexer = BackgroundIndexer(backing) + return JvmSymbolIndex(backing, indexer) + } + + private fun classSymbol( + internalName: String, + pkg: String = internalName.substringBeforeLast('/').replace('/', '.'), + shortName: String = internalName.substringAfterLast('/'), + sourceId: String = defaultSource, + ) = JvmSymbol( + key = internalName, + sourceId = sourceId, + name = internalName, + shortName = shortName, + packageName = pkg, + kind = JvmSymbolKind.CLASS, + language = JvmSourceLanguage.KOTLIN, + data = JvmClassInfo(), + ) + + private fun funSymbol( + internalName: String, + shortName: String, + pkg: String, + containingClass: String = "", + receiverType: String = "", + sourceId: String = defaultSource, + params: List = emptyList(), + ): JvmSymbol { + val kind = if (receiverType.isNotEmpty()) JvmSymbolKind.EXTENSION_FUNCTION else JvmSymbolKind.FUNCTION + return JvmSymbol( + key = "$internalName(${params.joinToString(",") { it.typeName }})", + sourceId = sourceId, + name = internalName, + shortName = shortName, + packageName = pkg, + kind = kind, + language = JvmSourceLanguage.KOTLIN, + data = JvmFunctionInfo( + containingClassName = containingClass, + parameters = params, + parameterCount = params.size, + kotlin = if (receiverType.isNotEmpty()) KotlinFunctionInfo(receiverTypeName = receiverType) else null, + ), + ) + } + + @Test + fun `findByPrefix returns symbols with matching name prefix`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + index.insertAll( + sequenceOf( + classSymbol("com/example/ArrayList"), + classSymbol("com/example/ArrayDeque"), + classSymbol("com/example/String"), + ) + ) + + val results = index.findByPrefix("Array").map { it.shortName }.toList() + assertThat(results).containsExactly("ArrayList", "ArrayDeque") + } + + @Test + fun `findByPrefix is case-insensitive`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + index.insert(classSymbol("com/example/ArrayList")) + + assertThat(index.findByPrefix("array").toList()).hasSize(1) + assertThat(index.findByPrefix("ARRAY").toList()).hasSize(1) + } + + @Test + fun `findByPrefix respects limit`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + (1..20).forEach { i -> index.insert(classSymbol("com/example/ArrayType$i")) } + + assertThat(index.findByPrefix("Array", limit = 5).toList()).hasSize(5) + } + + @Test + fun `findByPrefix returns empty when no source activated`() = runTest { + val index = makeIndex() + index.insert(classSymbol("com/example/ArrayList")) + // no activateSource call + + assertThat(index.findByPrefix("Array").toList()).isEmpty() + } + + @Test + fun `findByPrefix returns empty when prefix does not match`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + index.insert(classSymbol("com/example/String")) + + assertThat(index.findByPrefix("Array").toList()).isEmpty() + } + + @Test + fun `findExtensionsFor returns extensions with given receiver type`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + index.insertAll( + sequenceOf( + funSymbol("com/example/filter", "filter", "com.example", + receiverType = "kotlin/collections/List"), + funSymbol("com/example/map", "map", "com.example", + receiverType = "kotlin/collections/List"), + funSymbol("com/example/size", "size", "com.example", + receiverType = "kotlin/collections/Map"), + ) + ) + + val results = index.findExtensionsFor("kotlin/collections/List").map { it.shortName }.toList() + assertThat(results).containsExactly("filter", "map") + } + + @Test + fun `findExtensionsFor with namePrefix filters further`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + index.insertAll( + sequenceOf( + funSymbol("com/example/filter", "filter", "com.example", + receiverType = "kotlin/collections/List"), + funSymbol("com/example/filterNot", "filterNot", "com.example", + receiverType = "kotlin/collections/List"), + funSymbol("com/example/map", "map", "com.example", + receiverType = "kotlin/collections/List"), + ) + ) + + val results = index.findExtensionsFor("kotlin/collections/List", namePrefix = "filter") + .map { it.shortName }.toList() + assertThat(results).containsExactly("filter", "filterNot") + } + + @Test + fun `findTopLevelCallablesInPackage returns only top-level callables`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + // Top-level fun + index.insert(funSymbol("com/example/topFun", "topFun", "com.example")) + // Member fun (has containing class → not top-level) + index.insert( + funSymbol( + "com/example/Foo.memberFun", "memberFun", "com.example", + containingClass = "com/example/Foo", + ) + ) + // Class (not callable) + index.insert(classSymbol("com/example/Foo")) + + val results = index.findTopLevelCallablesInPackage("com.example").map { it.shortName }.toList() + assertThat(results).containsExactly("topFun") + } + + @Test + fun `findTopLevelCallablesInPackage with namePrefix`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + index.insert(funSymbol("com/example/filter", "filter", "com.example")) + index.insert(funSymbol("com/example/filterNot", "filterNot", "com.example")) + index.insert(funSymbol("com/example/map", "map", "com.example")) + + val results = index.findTopLevelCallablesInPackage("com.example", namePrefix = "filter") + .map { it.shortName }.toList() + assertThat(results).containsExactly("filter", "filterNot") + } + + @Test + fun `findClassifiersInPackage returns only classifiers`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + index.insert(classSymbol("com/example/Foo")) + index.insert(classSymbol("com/example/Bar")) + index.insert(funSymbol("com/example/bazFun", "bazFun", "com.example")) + + val results = index.findClassifiersInPackage("com.example").map { it.shortName }.toList() + assertThat(results).containsExactly("Foo", "Bar") + } + + @Test + fun `findClassifiersInPackage with namePrefix`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + index.insert(classSymbol("com/example/Array")) + index.insert(classSymbol("com/example/ArrayList")) + index.insert(classSymbol("com/example/String")) + + val results = index.findClassifiersInPackage("com.example", namePrefix = "Array") + .map { it.shortName }.toList() + assertThat(results).containsExactly("Array", "ArrayList") + } + + @Test + fun `findMembersOf returns symbols with given containing class`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + index.insert(funSymbol("com/example/Foo.m1", "m1", "com.example", + containingClass = "com/example/Foo")) + index.insert(funSymbol("com/example/Foo.m2", "m2", "com.example", + containingClass = "com/example/Foo")) + index.insert(funSymbol("com/example/Bar.other", "other", "com.example", + containingClass = "com/example/Bar")) + + val results = index.findMembersOf("com/example/Foo").map { it.shortName }.toList() + assertThat(results).containsExactly("m1", "m2") + } + + @Test + fun `findMembersOf with namePrefix`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + index.insert(funSymbol("com/example/Foo.get", "get", "com.example", + containingClass = "com/example/Foo")) + index.insert(funSymbol("com/example/Foo.getOrNull", "getOrNull", "com.example", + containingClass = "com/example/Foo")) + index.insert(funSymbol("com/example/Foo.set", "set", "com.example", + containingClass = "com/example/Foo")) + + val results = index.findMembersOf("com/example/Foo", namePrefix = "get") + .map { it.shortName }.toList() + assertThat(results).containsExactly("get", "getOrNull") + } + + @Test + fun `allPackages returns distinct packages from active sources`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + index.insert(classSymbol("com/example/Foo")) + index.insert(classSymbol("com/example/Bar")) + index.insert(classSymbol("org/other/Baz", pkg = "org.other")) + + val packages = index.allPackages().toSet() + assertThat(packages).containsAtLeast("com.example", "org.other") + } + + @Test + fun `findByKey retrieves symbol by exact key`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + val symbol = classSymbol("com/example/Foo") + index.insert(symbol) + + assertThat(index.findByKey("com/example/Foo")).isEqualTo(symbol) + } + + @Test + fun `findByKey returns null for unknown key`() = runTest { + val index = makeIndex() + index.activateSource(defaultSource) + assertThat(index.findByKey("missing")).isNull() + } + + @Test + fun `findByKey returns null when source not activated`() = runTest { + val index = makeIndex() + val symbol = classSymbol("com/example/Foo") + index.insert(symbol) + // no activateSource + assertThat(index.findByKey("com/example/Foo")).isNull() + } + + @Test + fun `symbols from inactive sources are not visible`() = runTest { + val index = makeIndex() + index.insert(classSymbol("com/a/A", sourceId = "a.jar")) + index.insert(classSymbol("com/b/B", sourceId = "b.jar")) + + index.activateSource("a.jar") + // b.jar not activated + + val results = index.findByPrefix("").toList() + assertThat(results.all { it.sourceId == "a.jar" }).isTrue() + } + + @Test + fun `activating multiple sources shows all their symbols`() = runTest { + val index = makeIndex() + index.insert(classSymbol("com/a/A", sourceId = "a.jar")) + index.insert(classSymbol("com/b/B", sourceId = "b.jar")) + + index.activateSource("a.jar") + index.activateSource("b.jar") + + val results = index.findByPrefix("").toList() + assertThat(results).hasSize(2) + } +} diff --git a/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolTest.kt b/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolTest.kt new file mode 100644 index 0000000000..6651cd2297 --- /dev/null +++ b/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolTest.kt @@ -0,0 +1,262 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class JvmSymbolTest { + + private fun classSymbol( + name: String = "com/example/Foo", + containingClass: String = "", + visibility: JvmVisibility = JvmVisibility.PUBLIC, + language: JvmSourceLanguage = JvmSourceLanguage.KOTLIN, + ) = JvmSymbol( + key = name, + sourceId = "test.jar", + name = name, + shortName = name.substringAfterLast('/').substringAfterLast('$'), + packageName = name.substringBeforeLast('/').replace('/', '.'), + kind = JvmSymbolKind.CLASS, + language = language, + visibility = visibility, + data = JvmClassInfo(containingClassName = containingClass), + ) + + private fun funSymbol( + name: String = "com/example/Foo.bar", + shortName: String = "bar", + kind: JvmSymbolKind = JvmSymbolKind.FUNCTION, + receiverType: String = "", + containingClass: String = "", + ) = JvmSymbol( + key = "$name()", + sourceId = "test.jar", + name = name, + shortName = shortName, + packageName = "com.example", + kind = kind, + language = JvmSourceLanguage.KOTLIN, + data = JvmFunctionInfo( + containingClassName = containingClass, + kotlin = if (receiverType.isNotEmpty()) KotlinFunctionInfo(receiverTypeName = receiverType) else null, + ), + ) + + @Test + fun `isTopLevel is true when containingClassName is empty`() { + assertThat(classSymbol(containingClass = "").isTopLevel).isTrue() + } + + @Test + fun `isTopLevel is false when containingClassName is non-empty`() { + assertThat(classSymbol(containingClass = "com/example/Outer").isTopLevel).isFalse() + } + + + @Test + fun `isExtension is true for EXTENSION_FUNCTION`() { + assertThat(funSymbol(kind = JvmSymbolKind.EXTENSION_FUNCTION).isExtension).isTrue() + } + + @Test + fun `isExtension is true for EXTENSION_PROPERTY`() { + val s = JvmSymbol( + key = "ext", sourceId = "s", name = "ext", shortName = "ext", + packageName = "p", kind = JvmSymbolKind.EXTENSION_PROPERTY, + language = JvmSourceLanguage.KOTLIN, + data = JvmFieldInfo(), + ) + assertThat(s.isExtension).isTrue() + } + + @Test + fun `isExtension is false for regular FUNCTION`() { + assertThat(funSymbol(kind = JvmSymbolKind.FUNCTION).isExtension).isFalse() + } + + @Test + fun `isExtension is false for CLASS`() { + assertThat(classSymbol().isExtension).isFalse() + } + + @Test + fun `fqName converts slashes to dots`() { + assertThat(classSymbol(name = "com/example/Foo").fqName).isEqualTo("com.example.Foo") + } + + @Test + fun `fqName converts dollars to dots for nested classes`() { + assertThat(classSymbol(name = "com/example/Outer\$Inner").fqName) + .isEqualTo("com.example.Outer.Inner") + } + + @Test + fun `containingClassFqName on JvmClassInfo converts correctly`() { + val info = JvmClassInfo(containingClassName = "com/example/Outer\$Inner") + assertThat(info.containingClassFqName).isEqualTo("com.example.Outer.Inner") + } + + @Test + fun `receiverTypeName extracted from EXTENSION_FUNCTION kotlin metadata`() { + val s = funSymbol(kind = JvmSymbolKind.EXTENSION_FUNCTION, receiverType = "kotlin/collections/List") + assertThat(s.receiverTypeName).isEqualTo("kotlin/collections/List") + } + + @Test + fun `receiverTypeName is null for non-extension function`() { + assertThat(funSymbol(kind = JvmSymbolKind.FUNCTION).receiverTypeName).isNull() + } + + @Test + fun `receiverTypeName is null when kotlin metadata receiver is empty`() { + val s = funSymbol(kind = JvmSymbolKind.EXTENSION_FUNCTION, receiverType = "") + assertThat(s.receiverTypeName).isNull() + } + + @Test + fun `receiverTypeName extracted from EXTENSION_PROPERTY field info`() { + val s = JvmSymbol( + key = "k", sourceId = "s", name = "n", shortName = "n", packageName = "p", + kind = JvmSymbolKind.EXTENSION_PROPERTY, + language = JvmSourceLanguage.KOTLIN, + data = JvmFieldInfo(kotlin = KotlinPropertyInfo(receiverTypeName = "kotlin/String")), + ) + assertThat(s.receiverTypeName).isEqualTo("kotlin/String") + } + + @Test + fun `returnTypeDisplay for function`() { + val s = JvmSymbol( + key = "f()", sourceId = "s", name = "f", shortName = "f", packageName = "p", + kind = JvmSymbolKind.FUNCTION, + language = JvmSourceLanguage.KOTLIN, + data = JvmFunctionInfo(returnTypeDisplayName = "String"), + ) + assertThat(s.returnTypeDisplay).isEqualTo("String") + } + + @Test + fun `returnTypeDisplay for field`() { + val s = JvmSymbol( + key = "f", sourceId = "s", name = "f", shortName = "f", packageName = "p", + kind = JvmSymbolKind.FIELD, + language = JvmSourceLanguage.JAVA, + data = JvmFieldInfo(typeDisplayName = "int"), + ) + assertThat(s.returnTypeDisplay).isEqualTo("int") + } + + @Test + fun `returnTypeDisplay is empty for class`() { + assertThat(classSymbol().returnTypeDisplay).isEmpty() + } + + @Test + fun `signatureDisplay for function`() { + val s = JvmSymbol( + key = "f()", sourceId = "s", name = "f", shortName = "f", packageName = "p", + kind = JvmSymbolKind.FUNCTION, + language = JvmSourceLanguage.KOTLIN, + data = JvmFunctionInfo(signatureDisplay = "fun f(x: Int): String"), + ) + assertThat(s.signatureDisplay).isEqualTo("fun f(x: Int): String") + } + + @Test + fun `signatureDisplay is empty for class`() { + assertThat(classSymbol().signatureDisplay).isEmpty() + } + + @Test + fun `isCallable is true for callable kinds`() { + assertThat(JvmSymbolKind.FUNCTION.isCallable).isTrue() + assertThat(JvmSymbolKind.EXTENSION_FUNCTION.isCallable).isTrue() + assertThat(JvmSymbolKind.CONSTRUCTOR.isCallable).isTrue() + assertThat(JvmSymbolKind.PROPERTY.isCallable).isTrue() + assertThat(JvmSymbolKind.EXTENSION_PROPERTY.isCallable).isTrue() + assertThat(JvmSymbolKind.FIELD.isCallable).isTrue() + } + + @Test + fun `isCallable is false for classifier kinds`() { + assertThat(JvmSymbolKind.CLASS.isCallable).isFalse() + assertThat(JvmSymbolKind.INTERFACE.isCallable).isFalse() + assertThat(JvmSymbolKind.ENUM.isCallable).isFalse() + assertThat(JvmSymbolKind.OBJECT.isCallable).isFalse() + assertThat(JvmSymbolKind.TYPE_ALIAS.isCallable).isFalse() + } + + @Test + fun `isClassifier is true for classifier kinds`() { + assertThat(JvmSymbolKind.CLASS.isClassifier).isTrue() + assertThat(JvmSymbolKind.INTERFACE.isClassifier).isTrue() + assertThat(JvmSymbolKind.ENUM.isClassifier).isTrue() + assertThat(JvmSymbolKind.DATA_CLASS.isClassifier).isTrue() + assertThat(JvmSymbolKind.VALUE_CLASS.isClassifier).isTrue() + assertThat(JvmSymbolKind.OBJECT.isClassifier).isTrue() + assertThat(JvmSymbolKind.COMPANION_OBJECT.isClassifier).isTrue() + assertThat(JvmSymbolKind.SEALED_CLASS.isClassifier).isTrue() + assertThat(JvmSymbolKind.SEALED_INTERFACE.isClassifier).isTrue() + assertThat(JvmSymbolKind.ANNOTATION_CLASS.isClassifier).isTrue() + assertThat(JvmSymbolKind.TYPE_ALIAS.isClassifier).isTrue() + } + + @Test + fun `isClassifier is false for callable kinds`() { + assertThat(JvmSymbolKind.FUNCTION.isClassifier).isFalse() + assertThat(JvmSymbolKind.PROPERTY.isClassifier).isFalse() + assertThat(JvmSymbolKind.FIELD.isClassifier).isFalse() + } + + @Test + fun `isAccessibleOutsideClass for PUBLIC, PROTECTED, INTERNAL`() { + assertThat(JvmVisibility.PUBLIC.isAccessibleOutsideClass).isTrue() + assertThat(JvmVisibility.PROTECTED.isAccessibleOutsideClass).isTrue() + assertThat(JvmVisibility.INTERNAL.isAccessibleOutsideClass).isTrue() + } + + @Test + fun `isAccessibleOutsideClass is false for PRIVATE and PACKAGE_PRIVATE`() { + assertThat(JvmVisibility.PRIVATE.isAccessibleOutsideClass).isFalse() + assertThat(JvmVisibility.PACKAGE_PRIVATE.isAccessibleOutsideClass).isFalse() + } + + @Test + fun `JvmFunctionInfo returnTypeFqName converts slashes and dollars`() { + val info = JvmFunctionInfo(returnTypeName = "com/example/Foo\$Bar") + assertThat(info.returnTypeFqName).isEqualTo("com.example.Foo.Bar") + } + + @Test + fun `JvmFieldInfo typeFqName converts slashes and dollars`() { + val info = JvmFieldInfo(typeName = "java/util/List") + assertThat(info.typeFqName).isEqualTo("java.util.List") + } + + @Test + fun `JvmParameterInfo typeFqName converts slashes and dollars`() { + val param = JvmParameterInfo(name = "p", typeName = "kotlin/String", typeDisplayName = "String") + assertThat(param.typeFqName).isEqualTo("kotlin.String") + } + + @Test + fun `JvmTypeAliasInfo expandedTypeFqName converts correctly`() { + val info = JvmTypeAliasInfo(expandedTypeName = "java/util/ArrayList") + assertThat(info.expandedTypeFqName).isEqualTo("java.util.ArrayList") + } + + @Test + fun `KotlinFunctionInfo receiverTypeFqName converts correctly`() { + val info = KotlinFunctionInfo(receiverTypeName = "kotlin/collections/List") + assertThat(info.receiverTypeFqName).isEqualTo("kotlin.collections.List") + } + + @Test + fun `KotlinPropertyInfo receiverTypeFqName converts correctly`() { + val info = KotlinPropertyInfo(receiverTypeName = "kotlin/String") + assertThat(info.receiverTypeFqName).isEqualTo("kotlin.String") + } +} diff --git a/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataTest.kt b/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataTest.kt new file mode 100644 index 0000000000..ae53cb72b3 --- /dev/null +++ b/lsp/jvm-symbol-index/src/test/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataTest.kt @@ -0,0 +1,175 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.time.Instant + +@RunWith(JUnit4::class) +class KtFileMetadataTest { + + @Test + fun `key equals filePath`() { + val meta = KtFileMetadata("/project/src/Main.kt", "com.example", Instant.now(), 1L) + assertThat(meta.key).isEqualTo("/project/src/Main.kt") + } + + @Test + fun `sourceId equals filePath`() { + val meta = KtFileMetadata("/project/src/Main.kt", "com.example", Instant.now(), 1L) + assertThat(meta.sourceId).isEqualTo("/project/src/Main.kt") + } + + @Test + fun `shouldBeSkipped is false when existing is null`() { + val meta = KtFileMetadata("/f.kt", "p", Instant.now(), 1L) + assertThat(KtFileMetadata.shouldBeSkipped(null, meta)).isFalse() + } + + @Test + fun `shouldBeSkipped is true when existing is same timestamp and stamp`() { + val ts = Instant.ofEpochMilli(500L) + val a = KtFileMetadata("/f.kt", "p", ts, 5L) + val b = KtFileMetadata("/f.kt", "p", ts, 5L) + assertThat(KtFileMetadata.shouldBeSkipped(a, b)).isTrue() + } + + @Test + fun `shouldBeSkipped is true when existing is newer by wall clock`() { + val older = KtFileMetadata("/f.kt", "p", Instant.ofEpochMilli(100L), 1L) + val newer = KtFileMetadata("/f.kt", "p", Instant.ofEpochMilli(200L), 2L) + // Existing is newer → skip + assertThat(KtFileMetadata.shouldBeSkipped(newer, older)).isTrue() + } + + @Test + fun `shouldBeSkipped is false when new file has newer wall clock`() { + val old = KtFileMetadata("/f.kt", "p", Instant.ofEpochMilli(100L), 1L) + val new = KtFileMetadata("/f.kt", "p", Instant.ofEpochMilli(200L), 2L) + assertThat(KtFileMetadata.shouldBeSkipped(old, new)).isFalse() + } + + @Test + fun `shouldBeSkipped is false when new has higher modificationStamp (same wall clock)`() { + val ts = Instant.ofEpochMilli(500L) + val existing = KtFileMetadata("/f.kt", "p", ts, 1L) + val new = KtFileMetadata("/f.kt", "p", ts, 2L) + // same wallclock but new has higher stamp + assertThat(KtFileMetadata.shouldBeSkipped(existing, new)).isFalse() + } + + @Test + fun `shouldBeSkipped is true when existing has higher modificationStamp (same wall clock)`() { + val ts = Instant.ofEpochMilli(500L) + val existing = KtFileMetadata("/f.kt", "p", ts, 3L) + val new = KtFileMetadata("/f.kt", "p", ts, 2L) + assertThat(KtFileMetadata.shouldBeSkipped(existing, new)).isTrue() + } + + @Test + fun `shouldBeSkipped handles zero modificationStamp symmetrically`() { + val ts = Instant.ofEpochMilli(100L) + val a = KtFileMetadata("/f.kt", "p", ts, 0L) + val b = KtFileMetadata("/f.kt", "p", ts, 0L) + // Both have stamp 0 — treated as "no stamp info available" + assertThat(KtFileMetadata.shouldBeSkipped(a, b)).isTrue() + } +} + +@RunWith(JUnit4::class) +class KtFileMetadataDescriptorTest { + + private fun roundtrip(meta: KtFileMetadata): KtFileMetadata = + KtFileMetadataDescriptor.deserialize(KtFileMetadataDescriptor.serialize(meta)) + + @Test + fun `serialize and deserialize preserves all fields`() { + val meta = KtFileMetadata( + filePath = "/project/src/Main.kt", + packageFqName = "com.example.main", + lastModified = Instant.ofEpochMilli(1_234_567_890L), + modificationStamp = 42L, + isIndexed = true, + symbolKeys = listOf("com.example.main.Foo", "com.example.main.Bar"), + ) + val restored = roundtrip(meta) + assertThat(restored.filePath).isEqualTo(meta.filePath) + assertThat(restored.packageFqName).isEqualTo(meta.packageFqName) + assertThat(restored.lastModified).isEqualTo(meta.lastModified) + assertThat(restored.modificationStamp).isEqualTo(meta.modificationStamp) + assertThat(restored.isIndexed).isTrue() + assertThat(restored.symbolKeys).containsExactlyElementsIn(meta.symbolKeys) + } + + @Test + fun `serialize with empty symbolKeys and isIndexed=false`() { + val meta = KtFileMetadata( + filePath = "/f.kt", + packageFqName = "", + lastModified = Instant.EPOCH, + modificationStamp = 0L, + isIndexed = false, + symbolKeys = emptyList(), + ) + val restored = roundtrip(meta) + assertThat(restored.symbolKeys).isEmpty() + assertThat(restored.isIndexed).isFalse() + assertThat(restored.packageFqName).isEmpty() + assertThat(restored.lastModified).isEqualTo(Instant.EPOCH) + } + + @Test + fun `serialize preserves large symbolKeys list`() { + val keys = (1..100).map { "com.example.Symbol$it" } + val meta = KtFileMetadata("/big.kt", "com.example", Instant.now(), 1L, + isIndexed = true, symbolKeys = keys) + assertThat(roundtrip(meta).symbolKeys).hasSize(100) + } + + @Test + fun `fieldValues returns package and isIndexed`() { + val meta = KtFileMetadata("/f.kt", "org.example", Instant.now(), 1L, isIndexed = true) + val fields = KtFileMetadataDescriptor.fieldValues(meta) + assertThat(fields[KtFileMetadataDescriptor.KEY_PACKAGE]).isEqualTo("org.example") + assertThat(fields[KtFileMetadataDescriptor.KEY_IS_INDEXED]).isEqualTo("true") + } + + @Test + fun `fieldValues isIndexed false`() { + val meta = KtFileMetadata("/f.kt", "p", Instant.now(), 1L, isIndexed = false) + assertThat(KtFileMetadataDescriptor.fieldValues(meta)[KtFileMetadataDescriptor.KEY_IS_INDEXED]) + .isEqualTo("false") + } + + @Test + fun `descriptor name is kt_file_metadata`() { + assertThat(KtFileMetadataDescriptor.name).isEqualTo("kt_file_metadata") + } + + @Test + fun `descriptor has exactly two fields`() { + assertThat(KtFileMetadataDescriptor.fields).hasSize(2) + assertThat(KtFileMetadataDescriptor.fields.map { it.name }) + .containsExactly( + KtFileMetadataDescriptor.KEY_PACKAGE, + KtFileMetadataDescriptor.KEY_IS_INDEXED, + ) + } + + @Test + fun `neither field is prefix-searchable`() { + assertThat(KtFileMetadataDescriptor.fields.none { it.prefixSearchable }).isTrue() + } + + @Test + fun `roundtrip with special characters in path`() { + val meta = KtFileMetadata( + filePath = "/Users/test user/my project/src/Main.kt", + packageFqName = "com.example", + lastModified = Instant.now(), + modificationStamp = 7L, + ) + assertThat(roundtrip(meta).filePath).isEqualTo(meta.filePath) + } +} diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index a4fcabb56b..2fb68ceecd 100644 --- a/lsp/kotlin/build.gradle.kts +++ b/lsp/kotlin/build.gradle.kts @@ -40,6 +40,7 @@ kapt { dependencies { kapt(projects.annotationProcessors) + implementation(projects.actions) implementation(projects.lsp.api) implementation(projects.lsp.jvmSymbolIndex) implementation(projects.lsp.models) @@ -58,4 +59,5 @@ dependencies { compileOnly(projects.common) testImplementation(projects.testing.lsp) + testImplementation(libs.tests.kotlinx.coroutines) } diff --git a/lsp/kotlin/src/main/assets/data/editor/kt/snippets.global.json b/lsp/kotlin/src/main/assets/data/editor/kt/snippets.global.json new file mode 100644 index 0000000000..087c37c124 --- /dev/null +++ b/lsp/kotlin/src/main/assets/data/editor/kt/snippets.global.json @@ -0,0 +1,36 @@ +{ + "lcomm": { + "desc": "Create a line comment", + "body": [ + "// $0" + ] + }, + "bcomm": { + "desc": "Create a block comment", + "body": [ + "/*", + " * $0", + " */" + ] + }, + "todo": { + "desc": "Create a TODO comment", + "body": [ + "// TODO: $0" + ] + }, + "fixme": { + "desc": "Create a FIXME comment", + "body": [ + "// FIXME: $0" + ] + }, + "kdoc": { + "desc": "Create a KDoc comment", + "body": [ + "/**", + " * $0", + " */" + ] + } +} diff --git a/lsp/kotlin/src/main/assets/data/editor/kt/snippets.local.json b/lsp/kotlin/src/main/assets/data/editor/kt/snippets.local.json new file mode 100644 index 0000000000..4714818d44 --- /dev/null +++ b/lsp/kotlin/src/main/assets/data/editor/kt/snippets.local.json @@ -0,0 +1,156 @@ +{ + "for": { + "desc": "For-in loop", + "body": [ + "for (${1:item} in ${2:collection}) {", + "\t$0", + "}" + ] + }, + "fori": { + "desc": "Indexed for loop", + "body": [ + "for (${1:i} in 0 until ${2:count}) {", + "\t$0", + "}" + ] + }, + "forr": { + "desc": "Reverse-indexed for loop", + "body": [ + "for (${1:i} in ${2:count} - 1 downTo 0) {", + "\t$0", + "}" + ] + }, + "while": { + "desc": "While loop", + "body": [ + "while (${1:condition}) {", + "\t$0", + "}" + ] + }, + "dowhile": { + "desc": "Do-while loop", + "body": [ + "do {", + "\t$0", + "} while (${1:condition})" + ] + }, + "if": { + "desc": "If statement", + "body": [ + "if (${1:condition}) {", + "\t$0", + "}" + ] + }, + "ifelse": { + "desc": "If-else statement", + "body": [ + "if (${1:condition}) {", + "\t$0", + "} else {", + "\t", + "}" + ] + }, + "ifnull": { + "desc": "If-null check", + "body": [ + "if (${1:obj} == null) {", + "\t$0", + "}" + ] + }, + "ifnotnull": { + "desc": "If-not-null check", + "body": [ + "if (${1:obj} != null) {", + "\t$0", + "}" + ] + }, + "when": { + "desc": "When expression", + "body": [ + "when (${1:expr}) {", + "\t$0", + "}" + ] + }, + "trycatch": { + "desc": "Try-catch statement", + "body": [ + "try {", + "\t$0", + "} catch (${1:e}: ${2:Exception}) {", + "\t", + "}" + ] + }, + "tryfinally": { + "desc": "Try-finally statement", + "body": [ + "try {", + "\t$0", + "} finally {", + "\t", + "}" + ] + }, + "let": { + "desc": "Safe-call let scope function", + "body": [ + "${1:obj}?.let { ${2:it} ->", + "\t$0", + "}" + ] + }, + "apply": { + "desc": "Apply scope function", + "body": [ + "${1:obj}.apply {", + "\t$0", + "}" + ] + }, + "also": { + "desc": "Also scope function", + "body": [ + "${1:obj}.also { ${2:it} ->", + "\t$0", + "}" + ] + }, + "run": { + "desc": "Run scope function", + "body": [ + "${1:obj}.run {", + "\t$0", + "}" + ] + }, + "with": { + "desc": "With scope function", + "body": [ + "with(${1:obj}) {", + "\t$0", + "}" + ] + }, + "require": { + "desc": "Require precondition check", + "body": [ + "require(${1:condition}) { \"${2:message}\" }" + ] + }, + "check": { + "desc": "Check state assertion", + "body": [ + "check(${1:condition}) { \"${2:message}\" }" + ] + } +} diff --git a/lsp/kotlin/src/main/assets/data/editor/kt/snippets.member.json b/lsp/kotlin/src/main/assets/data/editor/kt/snippets.member.json new file mode 100644 index 0000000000..ff07d33b7b --- /dev/null +++ b/lsp/kotlin/src/main/assets/data/editor/kt/snippets.member.json @@ -0,0 +1,62 @@ +{ + "fun": { + "desc": "Create a member function", + "body": [ + "fun ${1:name}($2)${3:: Unit} {", + "\t$0", + "}" + ] + }, + "pfun": { + "desc": "Create a private function", + "body": [ + "private fun ${1:name}($2)${3:: Unit} {", + "\t$0", + "}" + ] + }, + "ofun": { + "desc": "Create an override function", + "body": [ + "override fun ${1:name}($2)${3:: Unit} {", + "\t$0", + "}" + ] + }, + "val": { + "desc": "Create a val property", + "body": [ + "val ${1:name}: ${2:Type} = ${3:value}" + ] + }, + "var": { + "desc": "Create a var property", + "body": [ + "var ${1:name}: ${2:Type} = ${3:value}" + ] + }, + "lazy": { + "desc": "Lazy-initialized property", + "body": [ + "val ${1:name} by lazy {", + "\t$0", + "}" + ] + }, + "comp": { + "desc": "Create a companion object", + "body": [ + "companion object {", + "\t$0", + "}" + ] + }, + "init": { + "desc": "Create an initializer block", + "body": [ + "init {", + "\t$0", + "}" + ] + } +} diff --git a/lsp/kotlin/src/main/assets/data/editor/kt/snippets.top-level.json b/lsp/kotlin/src/main/assets/data/editor/kt/snippets.top-level.json new file mode 100644 index 0000000000..b6fd6b9969 --- /dev/null +++ b/lsp/kotlin/src/main/assets/data/editor/kt/snippets.top-level.json @@ -0,0 +1,72 @@ +{ + "class": { + "desc": "Create a new class", + "body": [ + "class ${1:$TM_FILENAME_BASE} {", + "\t$0", + "}" + ] + }, + "dclass": { + "desc": "Create a new data class", + "body": [ + "data class ${1:$TM_FILENAME_BASE}(", + "\t${2:val prop: Type}", + ")" + ] + }, + "sclass": { + "desc": "Create a new sealed class", + "body": [ + "sealed class ${1:$TM_FILENAME_BASE} {", + "\t$0", + "}" + ] + }, + "interface": { + "desc": "Create a new interface", + "body": [ + "interface ${1:$TM_FILENAME_BASE} {", + "\t$0", + "}" + ] + }, + "sinterface": { + "desc": "Create a new sealed interface", + "body": [ + "sealed interface ${1:$TM_FILENAME_BASE} {", + "\t$0", + "}" + ] + }, + "enum": { + "desc": "Create a new enum class", + "body": [ + "enum class ${1:$TM_FILENAME_BASE} {", + "\t$0", + "}" + ] + }, + "object": { + "desc": "Create a new object declaration", + "body": [ + "object ${1:$TM_FILENAME_BASE} {", + "\t$0", + "}" + ] + }, + "const": { + "desc": "Create a compile-time constant", + "body": [ + "const val ${1:NAME} = ${2:value}" + ] + }, + "fun": { + "desc": "Create a top-level function", + "body": [ + "fun ${1:name}($2)${3:: Unit} {", + "\t$0", + "}" + ] + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt new file mode 100644 index 0000000000..4e451059a8 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt @@ -0,0 +1,19 @@ +package com.itsaky.androidide.lsp.kotlin + +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.lsp.actions.CommentLineAction +import com.itsaky.androidide.lsp.actions.IActionsMenuProvider +import com.itsaky.androidide.lsp.actions.UncommentLineAction + +object KotlinCodeActionsMenu : IActionsMenuProvider { + + private const val KT_LANG = "kt" + private val KT_EXTS = listOf("kt", "kts") + private const val KT_LINE_COMMENT_TOKEN = "//" + + override val actions: List = + listOf( + CommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN), + UncommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN) + ) +} \ No newline at end of file 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 46d1062f19..1cd9653187 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 @@ -30,6 +30,7 @@ import com.itsaky.androidide.lsp.kotlin.compiler.Compiler import com.itsaky.androidide.lsp.kotlin.compiler.KotlinProjectModel import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_INDEX_KEY import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_META_INDEX_KEY +import com.itsaky.androidide.lsp.kotlin.completion.KotlinSnippetRepository import com.itsaky.androidide.lsp.kotlin.completion.complete import com.itsaky.androidide.lsp.kotlin.diagnostic.collectDiagnosticsFor import com.itsaky.androidide.lsp.models.CompletionParams @@ -42,6 +43,7 @@ import com.itsaky.androidide.lsp.models.ReferenceParams import com.itsaky.androidide.lsp.models.ReferenceResult import com.itsaky.androidide.lsp.models.SignatureHelp import com.itsaky.androidide.lsp.models.SignatureHelpParams +import com.itsaky.androidide.lsp.util.LSPEditorActions import com.itsaky.androidide.models.Range import com.itsaky.androidide.projects.FileManager import com.itsaky.androidide.projects.ProjectManagerImpl @@ -106,6 +108,8 @@ class KotlinLanguageServer : ILanguageServer { if (!EventBus.getDefault().isRegistered(this)) { EventBus.getDefault().register(this) } + + KotlinSnippetRepository.init() } override fun shutdown() { @@ -126,6 +130,8 @@ class KotlinLanguageServer : ILanguageServer { override fun setupWithProject(workspace: Workspace) { logger.info("setupWithProject called, initialized={}", initialized) + LSPEditorActions.ensureActionsMenuRegistered(KotlinCodeActionsMenu) + val context = BaseApplication.baseInstance val indexingServiceManager = ProjectManagerImpl.getInstance() .indexingServiceManager diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt index be58063f7b..dd92d771b3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt @@ -15,9 +15,9 @@ import com.itsaky.androidide.lsp.models.CompletionItemKind import com.itsaky.androidide.lsp.models.CompletionParams import com.itsaky.androidide.lsp.models.CompletionResult import com.itsaky.androidide.lsp.models.InsertTextFormat +import com.itsaky.androidide.preferences.utils.indentationString import com.itsaky.androidide.projects.FileManager import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.runBlocking import org.appdevforall.codeonthego.indexing.jvm.JvmClassInfo import org.appdevforall.codeonthego.indexing.jvm.JvmFunctionInfo import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol @@ -55,9 +55,14 @@ import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtClassBody import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtFunction import org.jetbrains.kotlin.psi.KtQualifiedExpression import org.jetbrains.kotlin.psi.KtSafeQualifiedExpression +import org.jetbrains.kotlin.psi.KtWhenExpression import org.jetbrains.kotlin.psi.psiUtil.getParentOfType import org.jetbrains.kotlin.psi.psiUtil.startOffset import org.jetbrains.kotlin.types.Variance @@ -143,6 +148,7 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi } collectKeywordCompletions(to = items) + collectSnippetCompletions(to = items) CompletionResult(items) } } @@ -420,6 +426,67 @@ private fun KaSession.collectKeywordCompletions( } } +context(ctx: AnalysisContext) +private fun KaSession.collectSnippetCompletions(to: MutableList) { + val snippets = buildList { + // add global snippets, if any + KotlinSnippetRepository.snippets[KotlinSnippetScope.GLOBAL]?.also { addAll(it) } + + val snippetScope = when (ctx.declarationKind) { + DeclarationKind.CLASS , + DeclarationKind.INTERFACE , + DeclarationKind.OBJECT , + DeclarationKind.ENUM_CLASS , + DeclarationKind.ANNOTATION_CLASS -> KotlinSnippetScope.MEMBER + + DeclarationKind.CONSTRUCTOR, + DeclarationKind.FUN -> KotlinSnippetScope.LOCAL + + DeclarationKind.UNKNOWN -> KotlinSnippetScope.TOP_LEVEL.takeIf { ctx.declarationContext == DeclarationContext.TOP_LEVEL } + + DeclarationKind.PROPERTY_VAL -> null + DeclarationKind.PROPERTY_VAR -> null + DeclarationKind.TYPEALIAS -> null + } + + logger.info("Adding completions for snippet scope: {} (context: {}, kind: {})", snippetScope, ctx.declarationContext, ctx.declarationKind) + KotlinSnippetRepository.snippets[snippetScope]?.also { addAll(it) } + } + + val indent = computeIndentLevelAt(ctx.ktElement) + for (snippet in snippets) { + to += ktCompletionItem(snippet.prefix, CompletionItemKind.SNIPPET).apply { + detail = snippet.description + ideSortText = "00000${snippet.prefix}" + snippetDescription = describeSnippet(ctx.partial) + + val indentation = indentationString(indent) + insertTextFormat = InsertTextFormat.SNIPPET + insertText = snippet.body.joinToString(separator = System.lineSeparator()) { + it.replace("\t", indentation) + .replace("\n", "\n${indentation}") + } + } + } +} + +private fun computeIndentLevelAt(ktElement: KtElement): Int { + var indentLevel = 0 + var current = ktElement.parent + + while (current != null) { + if (current is KtBlockExpression || + current is KtClassBody || + current is KtWhenExpression || + current is KtFunction) { + indentLevel++ + } + current = current.parent + } + + return indentLevel +} + context(ctx: AnalysisContext) @JvmName("callablesToCompletionItems") private fun KaSession.toCompletionItems( diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinSnippetRepository.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinSnippetRepository.kt new file mode 100644 index 0000000000..e62a82e941 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinSnippetRepository.kt @@ -0,0 +1,13 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +import com.itsaky.androidide.lsp.snippets.ISnippet +import com.itsaky.androidide.lsp.snippets.SnippetParser + +object KotlinSnippetRepository { + lateinit var snippets: Map> + private set + + fun init() { + snippets = SnippetParser.parse("kt", KotlinSnippetScope.entries) + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinSnippetScope.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinSnippetScope.kt new file mode 100644 index 0000000000..a3d5a9d47b --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinSnippetScope.kt @@ -0,0 +1,26 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +import com.itsaky.androidide.lsp.snippets.ISnippetScope + +/** + * Snippet scopes for Kotlin source files. + * + * @author Akash Yadav + */ +enum class KotlinSnippetScope(override val filename: String) : ISnippetScope { + + /** + * Snippets that can be used at the top level. This includes snippets such as class, interface, + * enum templates. + */ + TOP_LEVEL("top-level"), + + /** Snippets that can be used at the member level i.e. inside a class tree. */ + MEMBER("member"), + + /** Snippets that can be used at a local level. E.g. inside a method or constructor. */ + LOCAL("local"), + + /** Snippets that can be used anywhere in the code, irrespective of the current scope. */ + GLOBAL("global"), +} \ No newline at end of file diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ContextKeywordsTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ContextKeywordsTest.kt new file mode 100644 index 0000000000..a7a7e40911 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ContextKeywordsTest.kt @@ -0,0 +1,175 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.completion.DeclarationContext +import org.jetbrains.kotlin.lexer.KtTokens +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Unit tests for [ContextKeywords]. + * + * Verifies that [ContextKeywords.keywordsFor] returns the expected set of keyword + * tokens for each [DeclarationContext], and that the constant sets contain the + * expected members. + */ +@RunWith(JUnit4::class) +class ContextKeywordsTest { + + @Test + fun `STATEMENT_KEYWORDS contains core control-flow tokens`() { + assertThat(ContextKeywords.STATEMENT_KEYWORDS).containsAtLeast( + KtTokens.IF_KEYWORD, + KtTokens.ELSE_KEYWORD, + KtTokens.WHEN_KEYWORD, + KtTokens.WHILE_KEYWORD, + KtTokens.DO_KEYWORD, + KtTokens.FOR_KEYWORD, + KtTokens.TRY_KEYWORD, + KtTokens.RETURN_KEYWORD, + KtTokens.THROW_KEYWORD, + KtTokens.BREAK_KEYWORD, + ) + } + + @Test + fun `STATEMENT_KEYWORDS contains local declaration starters`() { + assertThat(ContextKeywords.STATEMENT_KEYWORDS).containsAtLeast( + KtTokens.VAL_KEYWORD, + KtTokens.VAR_KEYWORD, + KtTokens.FUN_KEYWORD, + ) + } + + @Test + fun `DECLARATION_KEYWORDS contains all top-level declaration starters`() { + assertThat(ContextKeywords.DECLARATION_KEYWORDS).containsAtLeast( + KtTokens.VAL_KEYWORD, + KtTokens.VAR_KEYWORD, + KtTokens.FUN_KEYWORD, + KtTokens.CLASS_KEYWORD, + KtTokens.INTERFACE_KEYWORD, + KtTokens.OBJECT_KEYWORD, + KtTokens.TYPE_ALIAS_KEYWORD, + ) + } + + @Test + fun `TOP_LEVEL_ONLY contains exactly PACKAGE and IMPORT`() { + assertThat(ContextKeywords.TOP_LEVEL_ONLY).containsExactly( + KtTokens.PACKAGE_KEYWORD, + KtTokens.IMPORT_KEYWORD, + ) + } + + @Test + fun `keywordsFor TOP_LEVEL includes TOP_LEVEL_ONLY tokens`() { + val keywords = ContextKeywords.keywordsFor(DeclarationContext.TOP_LEVEL) + assertThat(keywords).containsAtLeast(KtTokens.PACKAGE_KEYWORD, KtTokens.IMPORT_KEYWORD) + } + + @Test + fun `keywordsFor TOP_LEVEL includes all DECLARATION_KEYWORDS`() { + val keywords = ContextKeywords.keywordsFor(DeclarationContext.TOP_LEVEL) + assertThat(keywords).containsAtLeastElementsIn(ContextKeywords.DECLARATION_KEYWORDS) + } + + @Test + fun `keywordsFor SCRIPT_TOP_LEVEL equals keywordsFor TOP_LEVEL`() { + assertThat(ContextKeywords.keywordsFor(DeclarationContext.SCRIPT_TOP_LEVEL)) + .containsExactlyElementsIn(ContextKeywords.keywordsFor(DeclarationContext.TOP_LEVEL)) + } + + @Test + fun `keywordsFor FUNCTION_BODY equals STATEMENT_KEYWORDS`() { + assertThat(ContextKeywords.keywordsFor(DeclarationContext.FUNCTION_BODY)) + .containsExactlyElementsIn(ContextKeywords.STATEMENT_KEYWORDS) + } + + @Test + fun `keywordsFor FUNCTION_BODY does not include PACKAGE or IMPORT`() { + val keywords = ContextKeywords.keywordsFor(DeclarationContext.FUNCTION_BODY) + assertThat(keywords).doesNotContain(KtTokens.PACKAGE_KEYWORD) + assertThat(keywords).doesNotContain(KtTokens.IMPORT_KEYWORD) + } + + @Test + fun `keywordsFor FUNCTION_BODY does not include CLASS or INTERFACE`() { + val keywords = ContextKeywords.keywordsFor(DeclarationContext.FUNCTION_BODY) + // local class is included (rare but legal), interface is not + assertThat(keywords).doesNotContain(KtTokens.INTERFACE_KEYWORD) + } + + @Test + fun `keywordsFor CLASS_BODY includes INIT and CONSTRUCTOR`() { + val keywords = ContextKeywords.keywordsFor(DeclarationContext.CLASS_BODY) + assertThat(keywords).contains(KtTokens.INIT_KEYWORD) + assertThat(keywords).contains(KtTokens.CONSTRUCTOR_KEYWORD) + } + + @Test + fun `keywordsFor CLASS_BODY includes standard declaration starters`() { + val keywords = ContextKeywords.keywordsFor(DeclarationContext.CLASS_BODY) + assertThat(keywords).containsAtLeast( + KtTokens.VAL_KEYWORD, KtTokens.VAR_KEYWORD, KtTokens.FUN_KEYWORD, + ) + } + + @Test + fun `keywordsFor INTERFACE_BODY does not include CONSTRUCTOR`() { + assertThat(ContextKeywords.keywordsFor(DeclarationContext.INTERFACE_BODY)) + .doesNotContain(KtTokens.CONSTRUCTOR_KEYWORD) + } + + @Test + fun `keywordsFor INTERFACE_BODY does not include INIT`() { + assertThat(ContextKeywords.keywordsFor(DeclarationContext.INTERFACE_BODY)) + .doesNotContain(KtTokens.INIT_KEYWORD) + } + + @Test + fun `keywordsFor INTERFACE_BODY includes declaration starters for interface members`() { + val keywords = ContextKeywords.keywordsFor(DeclarationContext.INTERFACE_BODY) + assertThat(keywords).containsAtLeast( + KtTokens.VAL_KEYWORD, KtTokens.VAR_KEYWORD, KtTokens.FUN_KEYWORD, + ) + } + + @Test + fun `keywordsFor OBJECT_BODY does not include CONSTRUCTOR`() { + assertThat(ContextKeywords.keywordsFor(DeclarationContext.OBJECT_BODY)) + .doesNotContain(KtTokens.CONSTRUCTOR_KEYWORD) + } + + @Test + fun `keywordsFor ENUM_BODY does not include CONSTRUCTOR`() { + assertThat(ContextKeywords.keywordsFor(DeclarationContext.ENUM_BODY)) + .doesNotContain(KtTokens.CONSTRUCTOR_KEYWORD) + } + + @Test + fun `keywordsFor OBJECT_BODY and ENUM_BODY return same set`() { + assertThat(ContextKeywords.keywordsFor(DeclarationContext.OBJECT_BODY)) + .containsExactlyElementsIn(ContextKeywords.keywordsFor(DeclarationContext.ENUM_BODY)) + } + + @Test + fun `keywordsFor ANNOTATION_BODY returns only VAL`() { + assertThat(ContextKeywords.keywordsFor(DeclarationContext.ANNOTATION_BODY)) + .containsExactly(KtTokens.VAL_KEYWORD) + } + + @Test + fun `FUNCTION_BODY keywords are never in TOP_LEVEL_ONLY`() { + val functionKeywords = ContextKeywords.keywordsFor(DeclarationContext.FUNCTION_BODY) + assertThat(functionKeywords.none { it in ContextKeywords.TOP_LEVEL_ONLY }).isTrue() + } + + @Test + fun `all contexts return non-empty keyword sets`() { + for (ctx in DeclarationContext.entries) { + assertThat(ContextKeywords.keywordsFor(ctx)).isNotEmpty() + } + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityCheckerTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityCheckerTest.kt new file mode 100644 index 0000000000..c6fe6721a6 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityCheckerTest.kt @@ -0,0 +1,190 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider +import io.mockk.mockk +import org.appdevforall.codeonthego.indexing.jvm.JvmClassInfo +import org.appdevforall.codeonthego.indexing.jvm.JvmSourceLanguage +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolKind +import org.appdevforall.codeonthego.indexing.jvm.JvmVisibility +import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Unit tests for [SymbolVisibilityChecker.isDeclarationVisible]. + * + * The method is pure (depends only on [JvmSymbol.visibility], [JvmSymbol.packageName], + * and module identity comparisons), so it can be tested without a running K2 compiler. + */ +@RunWith(JUnit4::class) +class SymbolVisibilityCheckerTest { + + // Use relaxed mocks so that no stubs are required for unused methods + private val structureProvider = mockk(relaxed = true) + private val checker = SymbolVisibilityChecker(structureProvider) + + // Two distinct modules + private val moduleA: KaModule = mockk(relaxed = true) + private val moduleB: KaModule = mockk(relaxed = true) + + private fun symbol( + visibility: JvmVisibility, + pkg: String = "com.example", + sourceId: String = "jar-a", + ) = JvmSymbol( + key = "com/example/Foo", + sourceId = sourceId, + name = "com/example/Foo", + shortName = "Foo", + packageName = pkg, + kind = JvmSymbolKind.CLASS, + language = JvmSourceLanguage.KOTLIN, + visibility = visibility, + data = JvmClassInfo(), + ) + + @Test + fun `PUBLIC symbol is always visible regardless of module`() { + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.PUBLIC), + useSiteModule = moduleA, + declaringModule = moduleB, + useSitePackage = "other.pkg", + ) + ).isTrue() + } + + @Test + fun `PUBLIC symbol is visible even with null useSitePackage`() { + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.PUBLIC), + useSiteModule = moduleA, + declaringModule = moduleB, + useSitePackage = null, + ) + ).isTrue() + } + + @Test + fun `PRIVATE symbol is never visible`() { + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.PRIVATE), + useSiteModule = moduleA, + declaringModule = moduleB, + ) + ).isFalse() + } + + @Test + fun `PRIVATE symbol is not visible even from same module`() { + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.PRIVATE), + useSiteModule = moduleA, + declaringModule = moduleA, // same module + ) + ).isFalse() + } + + @Test + fun `INTERNAL symbol is visible from same module`() { + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.INTERNAL), + useSiteModule = moduleA, + declaringModule = moduleA, + ) + ).isTrue() + } + + @Test + fun `INTERNAL symbol is not visible from different module`() { + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.INTERNAL), + useSiteModule = moduleA, + declaringModule = moduleB, + ) + ).isFalse() + } + + @Test + fun `PACKAGE_PRIVATE symbol is visible within same package`() { + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.PACKAGE_PRIVATE, pkg = "com.example"), + useSiteModule = moduleA, + declaringModule = moduleB, + useSitePackage = "com.example", + ) + ).isTrue() + } + + @Test + fun `PACKAGE_PRIVATE symbol is not visible from different package`() { + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.PACKAGE_PRIVATE, pkg = "com.example"), + useSiteModule = moduleA, + declaringModule = moduleB, + useSitePackage = "org.other", + ) + ).isFalse() + } + + @Test + fun `PACKAGE_PRIVATE symbol is not visible when useSitePackage is null`() { + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.PACKAGE_PRIVATE, pkg = "com.example"), + useSiteModule = moduleA, + declaringModule = moduleB, + useSitePackage = null, + ) + ).isFalse() + } + + @Test + fun `PROTECTED symbol is visible within same package`() { + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.PROTECTED, pkg = "com.example"), + useSiteModule = moduleA, + declaringModule = moduleB, + useSitePackage = "com.example", + ) + ).isTrue() + } + + @Test + fun `PROTECTED symbol is visible as assumed descendant across packages`() { + // isDescendant is hardcoded to true in the current implementation + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.PROTECTED, pkg = "com.example"), + useSiteModule = moduleA, + declaringModule = moduleB, + useSitePackage = "different.pkg", + ) + ).isTrue() + } + + @Test + fun `PROTECTED symbol is visible with null useSitePackage due to descendant assumption`() { + // isSamePackage=false but isDescendant=true → visible + assertThat( + checker.isDeclarationVisible( + symbol(JvmVisibility.PROTECTED, pkg = "com.example"), + useSiteModule = moduleA, + declaringModule = moduleB, + useSitePackage = null, + ) + ).isTrue() + } +} diff --git a/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetRepository.kt b/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetRepository.kt index 418601b3bd..341f1b9152 100644 --- a/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetRepository.kt +++ b/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetRepository.kt @@ -22,12 +22,12 @@ import com.itsaky.androidide.lsp.snippets.SnippetRegistry object XmlSnippetRepository { - val snippets: Map> - get() = XML_SNIPPET_SCOPES.associateWith { scope -> - SnippetRegistry.getSnippets("xml", scope.filename) - } + val snippets: Map> + get() = XML_SNIPPET_SCOPES.associateWith { scope -> + SnippetRegistry.getSnippets("xml", scope.filename) + } - fun init() { - SnippetRegistry.initBuiltIn("xml", XML_SNIPPET_SCOPES) - } + fun init() { + SnippetRegistry.initBuiltIn("xml", XML_SNIPPET_SCOPES) + } } diff --git a/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetScope.kt b/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetScope.kt index af9d262b06..0928747fb9 100644 --- a/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetScope.kt +++ b/lsp/xml/src/main/java/com/itsaky/androidide/lsp/xml/providers/snippet/XmlSnippetScope.kt @@ -19,30 +19,30 @@ package com.itsaky.androidide.lsp.xml.providers.snippet import com.itsaky.androidide.lsp.snippets.ISnippetScope -val XML_SNIPPET_SCOPES : Array = - arrayOf( - DefaultXmlSnippetScope(XmlResourceType.LAYOUT, XmlScope.TAG), - DefaultXmlSnippetScope(XmlResourceType.LAYOUT, XmlScope.INSIDE), - DefaultXmlSnippetScope(XmlResourceType.MANIFEST, XmlScope.INSIDE) - ) +val XML_SNIPPET_SCOPES = + listOf( + DefaultXmlSnippetScope(XmlResourceType.LAYOUT, XmlScope.TAG), + DefaultXmlSnippetScope(XmlResourceType.LAYOUT, XmlScope.INSIDE), + DefaultXmlSnippetScope(XmlResourceType.MANIFEST, XmlScope.INSIDE) + ) abstract class IXmlSnippetScope : ISnippetScope { - abstract val type: XmlResourceType - abstract val scope: XmlScope + abstract val type: XmlResourceType + abstract val scope: XmlScope - override val filename: String - get() = "${type.name.lowercase()}-${scope.name.lowercase()}" + override val filename: String + get() = "${type.name.lowercase()}-${scope.name.lowercase()}" } class DefaultXmlSnippetScope(override val type: XmlResourceType, override val scope: XmlScope) : - IXmlSnippetScope() + IXmlSnippetScope() enum class XmlResourceType { - LAYOUT, - MANIFEST + LAYOUT, + MANIFEST } enum class XmlScope { - TAG, - INSIDE + TAG, + INSIDE } diff --git a/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/models/ToolingApiTestLauncherParams.kt b/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/models/ToolingApiTestLauncherParams.kt index 82a1e261c1..03b1b56faf 100644 --- a/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/models/ToolingApiTestLauncherParams.kt +++ b/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/models/ToolingApiTestLauncherParams.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.testing.tooling.models import com.itsaky.androidide.testing.tooling.ToolingApiTestLauncher +import com.itsaky.androidide.tooling.api.messages.BuildId import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams import com.itsaky.androidide.utils.FileProvider import org.slf4j.Logger @@ -35,7 +36,8 @@ data class ToolingApiTestLauncherParams( val client: ToolingApiTestLauncher.MultiVersionTestClient = ToolingApiTestLauncher.MultiVersionTestClient(), val initParams: InitializeProjectParams = InitializeProjectParams( projectDir.pathString, - client.gradleDistParams + client.gradleDistParams, + buildId = BuildId.Unknown, ), val log: Logger = LoggerFactory.getLogger("BuildOutputLogger"), val sysProps: Map = emptyMap(),