Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ plugins {
}

group = "dev.lain"
version = "6.0.0"
version = "6.0.1"

repositories {
mavenCentral()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NavigationItem>()
for (contributor in contributors()) {
val names = LinkedHashSet<String>()
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)
}
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NavigationItem>)
}

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<NavigationItem>) {
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<String> {
val names = LinkedHashSet<String>()
contributor.processNames({ name -> keep(name, query, names, max) }, scope, null)
return names
}

private fun keep(name: String, query: String, names: MutableSet<String>, max: Int): Boolean {
if (name.contains(query, ignoreCase = true)) names += name
return names.size < max
}

private fun gather(item: NavigationItem, into: MutableSet<NavigationItem>, max: Int): Boolean {
into += item
return into.size < max
}
}

internal object SymbolSearch {

data class Outcome(val items: List<NavigationItem>, val sources: Int, val refused: Int) {

val noneAnswered: Boolean = sources > 0 && refused == sources

val incomplete: Boolean = refused > 0
}

fun collect(sources: List<SymbolSource>, query: String, max: Int): Outcome {
val items = LinkedHashSet<NavigationItem>()
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<NavigationItem>): Boolean =
try {
source.symbolsInto(query, max, into)
true
} catch (_: UnsupportedOperationException) {
false
}
}
2 changes: 1 addition & 1 deletion src/main/kotlin/dev/lain/claudejb/util/PluginIdentity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>()
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
}
}
Loading