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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@

### Spring MCP

- fix: Name a trace node of a Kotlin companion member in `explyt_trace_spring_call_chain` after its outer class, as the call targets already are (#591)
- feat: Bound the answers of `explyt_find_spring_endpoint`, `explyt_get_spring_http_endpoints` and `explyt_get_spring_endpoint_contract` by `maxChars` and continue them with `offset` and `expectedRevision`, as the bean, entity and trace tools do: every current key stays and `status`, `revision`, `offset` and `nextOffset` are added, a continuation of another query or of an edited project answers `RESULT_CHANGED`, one endpoint larger than the budget answers `RESPONSE_TOO_LARGE` naming the budget that fetches it, the neighbourhood of a miss is kept on the first page within the budget with `nearestByPrefixTruncated` when cut, and the listing comes in a stable order so pages never overlap (#635)
- docs: Explain in the descriptions of `explyt_find_spring_endpoint`, `explyt_get_spring_endpoint_contract` and `explyt_get_spring_http_endpoints` that `httpMethods` is `["ALL"]` for a route that answers every HTTP method and that such a route passes every value of the `httpMethod` filter of the find and contract tools (#653)
- fix: List the test requests of every verb in 'testUrlReferences' of `explyt_trace_spring_call_chain` for a functional route that answers every HTTP method, as for a bare `@RequestMapping` handler (#659)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package com.explyt.spring.ai.mcp

import com.intellij.psi.PsiClass
import com.intellij.psi.PsiMethod
import com.intellij.psi.PsiModifier
import com.intellij.psi.util.PsiTreeUtil
import org.jetbrains.kotlin.asJava.elements.KtLightMethod
import org.jetbrains.kotlin.psi.KtClassOrObject
Expand All @@ -16,8 +17,7 @@ import org.jetbrains.kotlin.psi.KtObjectDeclaration
internal object SourceNames {

fun shortNameOf(method: PsiMethod): String {
val declaration = kotlinOwnerOf(method)
val owner = if (declaration is KtObjectDeclaration && declaration.isCompanion()) {
val owner = if (isCompanionMember(method)) {
sourceOwnerOf(method)?.name ?: method.containingClass?.name ?: "?"
} else {
method.containingClass?.name ?: "?"
Expand All @@ -33,6 +33,18 @@ internal object SourceNames {
return "$owner.${sourceNameOf(method)}"
}

fun qualifiedOwnerNameOf(method: PsiMethod): String? {
val containingClass = method.containingClass ?: return null
if (method.hasModifierProperty(PsiModifier.STATIC)) return containingClass.qualifiedName
if (isCompanionMember(method)) {
return sourceOwnerOf(method)?.fqName?.asString() ?: containingClass.qualifiedName
}
return containingClass.qualifiedName
}

private fun isCompanionMember(method: PsiMethod): Boolean =
(kotlinOwnerOf(method) as? KtObjectDeclaration)?.isCompanion() == true

private fun sourceOwnerOf(method: PsiMethod): KtClassOrObject? {
val declaration = kotlinOwnerOf(method) ?: return null
if (declaration !is KtObjectDeclaration || !declaration.isCompanion()) return declaration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1736,7 +1736,7 @@ class SpringBootApplicationMcpToolset : McpToolset {
id = id,
layer = containingClass?.let { detectSpringLayer(it) },
reachedBy = traced.reachedBy?.name,
className = containingClass?.qualifiedName ?: containingClass?.name,
className = SourceNames.qualifiedOwnerNameOf(method) ?: containingClass?.name,
methodName = SourceNames.sourceNameOf(method),
filePath = position.filePath,
library = position.library,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,27 +344,53 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture

fun testCompanionCallsLinkToDistinctOuterClassNodes() = runBlockingReleasingWriteIntent {
val chain = traceCompanion("twoParsers")
val companion = JavaPsiFacade.getInstance(project)
.findClass("explyt.trace.VmId.Companion", GlobalSearchScope.projectScope(project))!!
val parse = companion.findMethodsByName("parse", false).filterIsInstance<KtLightMethod>().single { it.kotlinOrigin != null }
val sourceName = SourceNames.qualifiedNameOf(parse)!!
assertTrue("The fixture must expose a light-class owner distinct from its source owner", sourceName.substringBeforeLast(".") != companion.qualifiedName)
assertFalse("The source owner must not use a JVM light-class separator", sourceName.any { it == 36.toChar() })
val calls = chain[0]["callsInto"].filter { it["target"].asText().endsWith(".parse") }
assertEquals("Both companion calls must be present in callsInto", 2, calls.size)
assertTrue("Both calls must link to expanded nodes: $calls", calls.all { it["node"].isIntegralNumber })
val nodes = calls.map { call -> chain.single { it["id"].asInt() == call["node"].asInt() } }
assertEquals(2, nodes.map { it["id"].asInt() }.distinct().size)
assertEquals(
setOf("explyt.trace.VmId.Companion", "explyt.trace.OtherVmId.Companion"),
setOf("explyt.trace.VmId", "explyt.trace.OtherVmId"),
nodes.map { it["className"].asText() }.toSet()
)
assertEquals(listOf("parse", "parse"), nodes.map { it["methodName"].asText() })
assertTrue(nodes.all { it["filePath"].asText().endsWith("Ids.kt") })
}

fun testCompanionInsideClassNamedCompanionUsesTheClassSourceName() = runBlockingReleasingWriteIntent {
val chain = traceCompanion("classNamedCompanionParse")
val call = companionCall(chain, "parse")
assertTrue("The companion call must be expanded", call["node"].isIntegralNumber)
val lightClass = JavaPsiFacade.getInstance(project)
.findClass("explyt.trace.Companion.Companion", GlobalSearchScope.projectScope(project))!!
val parse = lightClass.findMethodsByName("parse", false).filterIsInstance<KtLightMethod>().single { it.kotlinOrigin != null }
assertEquals("Companion", lightClass.name)
assertTrue("The callee must be the companion of a class named Companion", lightClass.qualifiedName!!.endsWith(".Companion.Companion"))
assertEquals("explyt.trace.Companion", SourceNames.qualifiedNameOf(parse)!!.substringBeforeLast("."))
val node = chain.single { it["id"].asInt() == call["node"].asInt() }
assertEquals("explyt.trace.Companion", node["className"].asText())
}

fun testNestedCompanionCallNamesItsImmediateOuterClass() = runBlockingReleasingWriteIntent {
val chain = traceCompanion("nestedCompanionParse")
val call = companionCall(chain, "parse")
assertEquals("InnerId.parse", call["target"].asText())
assertEquals("PROJECT", call["kind"].asText())
assertTrue("The nested companion must be expanded", call["node"].isIntegralNumber)
val node = chain.single { it["id"].asInt() == call["node"].asInt() }
assertEquals("explyt.trace.Outer.InnerId.Companion", node["className"].asText())
val companion = JavaPsiFacade.getInstance(project)
.findClass("explyt.trace.Outer.InnerId.Companion", GlobalSearchScope.projectScope(project))!!
val parse = companion.findMethodsByName("parse", false).filterIsInstance<KtLightMethod>().single { it.kotlinOrigin != null }
val expectedClassName = SourceNames.qualifiedNameOf(parse)!!.substringBeforeLast(".")
assertTrue("The source class name must differ from the light-class name", expectedClassName != companion.qualifiedName)
assertFalse("The source class name must not contain a JVM separator", expectedClassName.any { it == 36.toChar() })
assertEquals(expectedClassName, node["className"].asText())
}

fun testCompanionNameFallsBackToClassNavigationWithoutKotlinMethodOrigin() = runBlockingReleasingWriteIntent {
Expand Down Expand Up @@ -402,14 +428,14 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture
assertEquals("explyt.trace.Owner.Companion.Nested", node["className"].asText())
}

fun testJavaJvmStaticAndCompanionCallsKeepDistinctNodes() = runBlockingReleasingWriteIntent {
fun testJavaJvmStaticAndCompanionCallsKeepDistinctNodesWithTheSourceClassName() = runBlockingReleasingWriteIntent {
val chain = traceCompanion("bothStaticParsers", javaCaller = true)
val calls = chain[0]["callsInto"].toList()
assertEquals(listOf("StaticVmId.parse", "StaticVmId.parse"), calls.map { it["target"].asText() })
assertTrue(calls.all { it["node"].isIntegralNumber })
assertEquals(2, calls.map { it["node"].asInt() }.distinct().size)
val nodes = calls.map { call -> chain.single { it["id"].asInt() == call["node"].asInt() } }
assertEquals(setOf("explyt.trace.StaticVmId", "explyt.trace.StaticVmId.Companion"), nodes.map { it["className"].asText() }.toSet())
assertEquals(listOf("explyt.trace.StaticVmId", "explyt.trace.StaticVmId"), nodes.map { it["className"].asText() })
}

private fun companionCall(chain: JsonNode, methodName: String): JsonNode {
Expand Down Expand Up @@ -437,6 +463,11 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture
fun parse(raw: String): String = raw
}
}
class Companion {
companion object {
fun parse(raw: String): String = raw
}
}
class StaticVmId {
companion object {
@JvmStatic
Expand Down Expand Up @@ -475,6 +506,7 @@ class SpringBootApplicationMcpToolsetTraceCallChainTest : JavaCodeInsightFixture
fun objectParse(raw: String): String = IdFormat.parse(raw)
fun nestedParse(raw: String): String = Outer.Inner().parse(raw)
fun nestedCompanionParse(raw: String): String = Outer.InnerId.parse(raw)
fun classNamedCompanionParse(raw: String): String = Companion.parse(raw)
fun twoParsers(raw: String): String = VmId.parse(raw) + OtherVmId.parse(raw)
}
""".trimIndent()
Expand Down
Loading