diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d1107d9..40fad6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.0.1] — 2026-09-22 + +### Fixed +- **`find_symbols` no longer fails outright in Rider.** A contributor that refuses the platform's + `processNames` call — which Rider's do, because its symbols live in the ReSharper backend and are + served only over its own protocol — is skipped instead of taking the whole tool down with + `UnsupportedOperationException: Use RdChooseByNameContributor.processNamesLifetimed`. An answer + reached by asking fewer contributors than the IDE has carries `refused` and `contributors`, and when + it is also empty it carries a `note`, so an empty list is not read as an absent symbol or a stale + index. Only when every contributor refuses does the tool fail, naming what does work there + (`search_text`, `find_files`, `file_outline`, `definition`, `references`, `implementations`). + ## [6.0.0] — 2026-09-12 **Claude becomes one with your IDE.** The plugin now runs four MCP servers of its own inside the IDE diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 063c4357..dea38d0c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,15 @@ +## v6.0.1 — 2026-09-22 + +**`find_symbols` works in Rider again.** Asking Claude for a symbol there failed outright with +`UnsupportedOperationException: Use RdChooseByNameContributor.processNamesLifetimed`: Rider keeps its +symbols in the ReSharper backend and its contributors refuse the platform call the tool was making. A +contributor that refuses is now skipped rather than taking the tool down with it, and the answer says how +many refused out of how many the IDE has — with a note when the result is also empty — so Claude does not +read an empty list as "this symbol does not exist" or "the index is still warming up". The tool only fails +when every contributor refuses, and then it names the tools that do work there. + +Nothing else changed. + ## v6.0.0 — 2026-09-12 **Claude becomes one with your IDE.** This release gives Claude the IDE itself as its instrument. diff --git a/build.gradle.kts b/build.gradle.kts index 916c56c9..24573fd0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,7 +28,7 @@ plugins { } group = "dev.lain" -version = "6.0.0" +version = "6.0.1" repositories { mavenCentral() diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/NavigateTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/NavigateTools.kt index 9bfdad28..ca3d4ec7 100644 --- a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/NavigateTools.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/NavigateTools.kt @@ -42,37 +42,21 @@ internal class NavigateTools(private val project: Project) { val query = args.string("query") val max = args.int("max", DEFAULT_MAX) val libraries = args.boolean("libraries", false) - val rows = indexed { + val (outcome, rows) = indexed { val scope = if (libraries) GlobalSearchScope.allScope(project) else GlobalSearchScope.projectScope(project) val parameters = FindSymbolParameters.simple(project, libraries) - val items = LinkedHashSet() - for (contributor in contributors()) { - val names = LinkedHashSet() - contributor.processNames( - { name -> - if (name.contains(query, ignoreCase = true)) names += name - names.size < max - }, - scope, - null, - ) - for (name in names) { - if (items.size >= max) break - contributor.processElementsWithName( - name, - { item -> - items += item - items.size < max - }, - parameters, - ) - } - if (items.size >= max) break - } - items.filter { it is PsiElement && Locations.located(it) }.map(::symbolRow) + val sources = contributors().map { ContributorSymbolSource(it, scope, parameters) } + val outcome = SymbolSearch.collect(sources, query, max) + if (outcome.noneAnswered) throw ToolException(noSymbolIndex(outcome)) + outcome to outcome.items.filter { it is PsiElement && Locations.located(it) }.map(::symbolRow) } return buildJsonObject { put("query", query) + if (outcome.incomplete) { + put("refused", outcome.refused) + put("contributors", outcome.sources) + if (rows.isEmpty()) put("note", refusedNote(outcome)) + } table("symbols", rows, rows.size >= max) } } @@ -114,6 +98,18 @@ internal class NavigateTools(private val project: Project) { return buildJsonObject { table("implementations", rows, rows.size >= max) } } + private fun noSymbolIndex(outcome: SymbolSearch.Outcome): String = + "every one of the ${outcome.sources} symbol contributors refused the call: this IDE serves its symbols " + + "from a backend the platform's name index does not reach, as Rider does through ReSharper. Use " + + "search_text, find_files or file_outline instead; definition, references and implementations work " + + "as usual." + + private fun refusedNote(outcome: SymbolSearch.Outcome): String = + "${outcome.refused} of the ${outcome.sources} symbol contributors refused the call, so what they hold was " + + "never searched: those symbols live in a backend the platform's name index does not reach, as Rider's " + + "do through ReSharper. An empty result here does not mean the symbol is absent, and does not mean the " + + "index is stale; search_text and file_outline see what they hold." + private fun resolved(args: ToolArgs): PsiElement = Locations.declarationAt(project, args) private fun symbolRow(item: NavigationItem): JsonObject = buildJsonObject { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SymbolSearch.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SymbolSearch.kt new file mode 100644 index 00000000..fe84267d --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SymbolSearch.kt @@ -0,0 +1,69 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.navigation.ChooseByNameContributorEx +import com.intellij.navigation.NavigationItem +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.util.indexing.FindSymbolParameters + +internal fun interface SymbolSource { + + fun symbolsInto(query: String, max: Int, into: MutableSet) +} + +internal class ContributorSymbolSource( + private val contributor: ChooseByNameContributorEx, + private val scope: GlobalSearchScope, + private val parameters: FindSymbolParameters, +) : SymbolSource { + + override fun symbolsInto(query: String, max: Int, into: MutableSet) { + for (name in namesMatching(query, max)) { + if (into.size >= max) break + contributor.processElementsWithName(name, { item -> gather(item, into, max) }, parameters) + } + } + + private fun namesMatching(query: String, max: Int): Set { + val names = LinkedHashSet() + contributor.processNames({ name -> keep(name, query, names, max) }, scope, null) + return names + } + + private fun keep(name: String, query: String, names: MutableSet, max: Int): Boolean { + if (name.contains(query, ignoreCase = true)) names += name + return names.size < max + } + + private fun gather(item: NavigationItem, into: MutableSet, max: Int): Boolean { + into += item + return into.size < max + } +} + +internal object SymbolSearch { + + data class Outcome(val items: List, val sources: Int, val refused: Int) { + + val noneAnswered: Boolean = sources > 0 && refused == sources + + val incomplete: Boolean = refused > 0 + } + + fun collect(sources: List, query: String, max: Int): Outcome { + val items = LinkedHashSet() + var refused = 0 + for (source in sources) { + if (items.size >= max) break + if (!answered(source, query, max, items)) refused++ + } + return Outcome(items.toList(), sources.size, refused) + } + + private fun answered(source: SymbolSource, query: String, max: Int, into: MutableSet): Boolean = + try { + source.symbolsInto(query, max, into) + true + } catch (_: UnsupportedOperationException) { + false + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt b/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt index d0dc7b1f..db63029d 100644 --- a/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt +++ b/src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt @@ -2,7 +2,7 @@ package dev.lain.claudejb.util object PluginIdentity { - const val PLUGIN_VERSION = "6.0.0" + const val PLUGIN_VERSION = "6.0.1" const val NOTIFICATION_GROUP = "Claude Code" diff --git a/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SymbolSearchTest.kt b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SymbolSearchTest.kt new file mode 100644 index 00000000..99c1cd4a --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SymbolSearchTest.kt @@ -0,0 +1,84 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.navigation.ItemPresentation +import com.intellij.navigation.NavigationItem +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SymbolSearchTest { + + @Test + fun `a source that refuses the platform call does not take the ones that answer down with it`() { + val outcome = SymbolSearch.collect(listOf(refusing(), answering("Alpha")), "a", 10) + + assertEquals(listOf("Alpha"), outcome.items.map { it.name }) + assertEquals(1, outcome.refused) + assertFalse(outcome.noneAnswered) + assertTrue(outcome.incomplete) { "a list missing a whole contributor must not read as the complete answer" } + } + + @Test + fun `when every source refuses, the outcome says so instead of reading as a project without symbols`() { + val outcome = SymbolSearch.collect(listOf(refusing(), refusing()), "a", 10) + + assertTrue(outcome.items.isEmpty()) + assertTrue(outcome.noneAnswered) { "with nothing left to ask, an empty list would be a lie" } + } + + @Test + fun `sources that answer with nothing are an empty result, not a failure`() { + val outcome = SymbolSearch.collect(listOf(answering(), answering(), refusing()), "a", 10) + + assertTrue(outcome.items.isEmpty()) + assertFalse(outcome.noneAnswered) { + "most contributors answered and simply held no match; failing here would turn every fruitless " + + "search in that IDE into an error" + } + assertTrue(outcome.incomplete) { "the refusal still has to be reported alongside the empty list" } + } + + @Test + fun `a refusal is not swallowed as an empty contributor, so the count stays honest`() { + val outcome = SymbolSearch.collect(listOf(answering(), refusing()), "a", 10) + + assertEquals(1, outcome.refused) + assertEquals(2, outcome.sources) + } + + @Test + fun `no further source is asked once max is reached`() { + val asked = mutableListOf() + val counting = SymbolSource { query, _, into -> + asked += query + into += item("symbol${asked.size}") + } + + val outcome = SymbolSearch.collect(listOf(counting, counting), "symbol", 1) + + assertEquals(listOf("symbol"), asked) + assertEquals(1, outcome.items.size) + } + + private fun refusing(): SymbolSource = SymbolSource { _, _, _ -> + throw UnsupportedOperationException("Use RdChooseByNameContributor.processNamesLifetimed") + } + + private fun answering(vararg names: String): SymbolSource = SymbolSource { _, _, into -> + names.forEach { into += item(it) } + } + + private fun item(named: String): NavigationItem = object : NavigationItem { + + override fun getName(): String = named + + override fun getPresentation(): ItemPresentation? = null + + override fun navigate(requestFocus: Boolean) = Unit + + override fun canNavigate(): Boolean = false + + override fun canNavigateToSource(): Boolean = false + } +}