Skip to content

Commit 252730d

Browse files
authored
Cache removal + shared call hierarchy engine extraction (#46)
1 parent 2cfaac5 commit 252730d

18 files changed

Lines changed: 615 additions & 624 deletions

File tree

.agents/skills/kast/references/command-reference.md

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,7 @@ kast call hierarchy \
307307
[--depth=3] \
308308
[--max-total-calls=256] \
309309
[--max-children-per-node=64] \
310-
[--timeout-millis=5000] \
311-
[--persist-to-git-sha-cache=true]
310+
[--timeout-millis=5000]
312311
```
313312

314313
**Request-file form:**
@@ -329,8 +328,7 @@ kast call hierarchy \
329328
"depth": 3,
330329
"maxTotalCalls": 256,
331330
"maxChildrenPerNode": 64,
332-
"timeoutMillis": null,
333-
"persistToGitShaCache": false
331+
"timeoutMillis": null
334332
}
335333
```
336334

@@ -366,7 +364,6 @@ kast call hierarchy \
366364
"maxChildrenPerNodeReached": false,
367365
"filesVisited": 1
368366
},
369-
"persistence": null,
370367
"schemaVersion": 1
371368
}
372369
```
@@ -379,9 +376,6 @@ populated for child edges.
379376
`truncation.reason` values: `CYCLE` | `MAX_TOTAL_CALLS` |
380377
`MAX_CHILDREN_PER_NODE` | `TIMEOUT`
381378

382-
`persistence` is `CallHierarchyPersistence | null` and is populated when
383-
cache persistence is requested and a git-SHA-scoped cache is available.
384-
385379
**Errors:** `NOT_FOUND`, `CAPABILITY_NOT_SUPPORTED` (`CALL_HIERARCHY`).
386380

387381
---

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Kast agent guide
2+
ALways TDD with tracer bullets, use the skill
23

34
Kast is a Kotlin analysis tool with one line-delimited JSON-RPC contract and
45
two supported operator paths: the repo-local `kast` CLI manages a standalone

analysis-api/src/main/kotlin/io/github/amichne/kast/api/CallHierarchyQuery.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,4 @@ data class CallHierarchyQuery(
1010
val maxTotalCalls: Int = 256,
1111
val maxChildrenPerNode: Int = 64,
1212
val timeoutMillis: Long? = null,
13-
val persistToGitShaCache: Boolean = false,
1413
)

analysis-api/src/main/kotlin/io/github/amichne/kast/api/CallHierarchyResult.kt

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import kotlinx.serialization.Serializable
66
data class CallHierarchyResult(
77
val root: CallNode,
88
val stats: CallHierarchyStats,
9-
val persistence: CallHierarchyPersistence? = null,
109
val schemaVersion: Int = SCHEMA_VERSION,
1110
)
1211

@@ -21,10 +20,3 @@ data class CallHierarchyStats(
2120
val maxChildrenPerNodeReached: Boolean,
2221
val filesVisited: Int,
2322
)
24-
25-
@Serializable
26-
data class CallHierarchyPersistence(
27-
val gitSha: String,
28-
val cacheFilePath: String,
29-
val cacheHit: Boolean,
30-
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package io.github.amichne.kast.intellij
2+
3+
import com.intellij.openapi.application.ApplicationManager
4+
import com.intellij.openapi.project.Project
5+
import com.intellij.psi.PsiElement
6+
import com.intellij.psi.PsiRecursiveElementWalkingVisitor
7+
import com.intellij.psi.search.GlobalSearchScope
8+
import com.intellij.psi.search.searches.ReferencesSearch
9+
import io.github.amichne.kast.shared.analysis.callHierarchyDeclaration
10+
import io.github.amichne.kast.shared.analysis.resolvedFilePath
11+
import io.github.amichne.kast.shared.analysis.toSymbolModel
12+
import io.github.amichne.kast.shared.hierarchy.CallEdge
13+
import io.github.amichne.kast.shared.hierarchy.CallEdgeResolver
14+
import io.github.amichne.kast.shared.hierarchy.callSiteLocation
15+
16+
/**
17+
* IntelliJ-backend implementation of [CallEdgeResolver].
18+
*
19+
* Uses [ReferencesSearch] and [GlobalSearchScope.projectScope] for incoming
20+
* edges, and a [PsiRecursiveElementWalkingVisitor] walk for outgoing edges.
21+
*
22+
* Each method acquires its own short-lived read lock so that the caller
23+
* (recursive [io.github.amichne.kast.shared.hierarchy.CallHierarchyEngine])
24+
* does **not** need to hold the IDE read lock for the entire traversal.
25+
*/
26+
internal class IntelliJCallEdgeResolver(
27+
private val project: Project,
28+
private val workspacePrefix: String,
29+
) : CallEdgeResolver {
30+
31+
override fun incomingEdges(
32+
target: PsiElement,
33+
timeoutCheck: () -> Boolean,
34+
onFileVisited: (filePath: String) -> Unit,
35+
): List<CallEdge> = ApplicationManager.getApplication().runReadAction<List<CallEdge>> {
36+
val edges = mutableListOf<CallEdge>()
37+
val visitedFiles = mutableSetOf<String>()
38+
val searchScope = GlobalSearchScope.projectScope(project)
39+
40+
ReferencesSearch.search(target, searchScope).forEach { ref ->
41+
if (timeoutCheck()) return@runReadAction edges
42+
val element = ref.element
43+
val filePath = element.resolvedFilePath().value
44+
if (visitedFiles.add(filePath)) {
45+
onFileVisited(filePath)
46+
}
47+
if (!filePath.startsWith(workspacePrefix)) return@forEach
48+
49+
val caller = element.callHierarchyDeclaration() ?: return@forEach
50+
edges += CallEdge(
51+
target = caller,
52+
symbol = caller.toSymbolModel(containingDeclaration = null),
53+
callSite = ref.callSiteLocation(),
54+
)
55+
}
56+
57+
edges
58+
}
59+
60+
override fun outgoingEdges(
61+
target: PsiElement,
62+
timeoutCheck: () -> Boolean,
63+
onFileVisited: (filePath: String) -> Unit,
64+
): List<CallEdge> = ApplicationManager.getApplication().runReadAction<List<CallEdge>> {
65+
val declaration = target.callHierarchyDeclaration() ?: return@runReadAction emptyList()
66+
val filePath = declaration.resolvedFilePath().value
67+
onFileVisited(filePath)
68+
val edges = mutableListOf<CallEdge>()
69+
70+
declaration.accept(
71+
object : PsiRecursiveElementWalkingVisitor() {
72+
override fun visitElement(element: PsiElement) {
73+
if (timeoutCheck()) {
74+
stopWalking()
75+
return
76+
}
77+
// Skip nested declarations to avoid expanding inner hierarchy targets.
78+
if (element !== declaration && element.callHierarchyDeclaration() === element) {
79+
return
80+
}
81+
element.references.forEach { reference ->
82+
val resolved = reference.resolve() ?: return@forEach
83+
if (resolved.containingFile == null) return@forEach
84+
val resolvedPath = resolved.resolvedFilePath().value
85+
if (!resolvedPath.startsWith(workspacePrefix)) return@forEach
86+
edges += CallEdge(
87+
target = resolved,
88+
symbol = resolved.toSymbolModel(containingDeclaration = null),
89+
callSite = reference.callSiteLocation(),
90+
)
91+
}
92+
super.visitElement(element)
93+
}
94+
},
95+
)
96+
97+
edges
98+
}
99+
}

backend-intellij/src/main/kotlin/io/github/amichne/kast/intellij/KastPluginBackend.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import io.github.amichne.kast.api.AnalysisBackend
1212
import io.github.amichne.kast.api.ApplyEditsQuery
1313
import io.github.amichne.kast.api.ApplyEditsResult
1414
import io.github.amichne.kast.api.BackendCapabilities
15+
import io.github.amichne.kast.api.CallHierarchyQuery
16+
import io.github.amichne.kast.api.CallHierarchyResult
1517
import io.github.amichne.kast.api.DiagnosticsQuery
1618
import io.github.amichne.kast.api.DiagnosticsResult
1719
import io.github.amichne.kast.api.ImportOptimizeQuery
@@ -47,6 +49,9 @@ import io.github.amichne.kast.shared.analysis.toApiDiagnostics
4749
import io.github.amichne.kast.shared.analysis.toKastLocation
4850
import io.github.amichne.kast.shared.analysis.toSymbolModel
4951
import io.github.amichne.kast.shared.analysis.visibility
52+
import io.github.amichne.kast.shared.hierarchy.CallHierarchyEngine
53+
import io.github.amichne.kast.shared.hierarchy.ReadAccessScope
54+
import io.github.amichne.kast.shared.hierarchy.TraversalBudget
5055
import kotlinx.coroutines.Dispatchers
5156
import kotlinx.coroutines.withContext
5257
import org.jetbrains.kotlin.analysis.api.KaExperimentalApi
@@ -72,6 +77,7 @@ internal class KastPluginBackend(
7277
readCapabilities = setOf(
7378
ReadCapability.RESOLVE_SYMBOL,
7479
ReadCapability.FIND_REFERENCES,
80+
ReadCapability.CALL_HIERARCHY,
7581
ReadCapability.SEMANTIC_INSERTION_POINT,
7682
ReadCapability.DIAGNOSTICS,
7783
),
@@ -158,6 +164,46 @@ internal class KastPluginBackend(
158164
}
159165
}
160166

167+
override suspend fun callHierarchy(query: CallHierarchyQuery): CallHierarchyResult = withContext(readDispatcher) {
168+
// Resolve the root target under a short read lock; the recursive
169+
// traversal acquires per-level read locks inside the edge resolver
170+
// so the IDE write lock is not starved for the full duration.
171+
val rootTarget = readAction {
172+
val file = findKtFile(query.position.filePath)
173+
resolveTarget(file, query.position.offset)
174+
}
175+
176+
val budget = TraversalBudget(
177+
maxTotalCalls = query.maxTotalCalls,
178+
maxChildrenPerNode = query.maxChildrenPerNode,
179+
timeoutMillis = query.timeoutMillis ?: limits.requestTimeoutMillis,
180+
)
181+
val resolver = IntelliJCallEdgeResolver(
182+
project = project,
183+
workspacePrefix = workspacePrefix,
184+
)
185+
val intellijReadAccess = object : ReadAccessScope {
186+
override fun <T> run(action: () -> T): T =
187+
com.intellij.openapi.application.ApplicationManager.getApplication()
188+
.runReadAction<T> { action() }
189+
}
190+
val engine = CallHierarchyEngine(edgeResolver = resolver, readAccess = intellijReadAccess)
191+
val root = engine.buildNode(
192+
target = rootTarget,
193+
parentCallSite = null,
194+
direction = query.direction,
195+
depthRemaining = query.depth,
196+
pathKeys = emptySet(),
197+
budget = budget,
198+
currentDepth = 0,
199+
)
200+
201+
CallHierarchyResult(
202+
root = root,
203+
stats = budget.toStats(),
204+
)
205+
}
206+
161207
override suspend fun semanticInsertionPoint(
162208
query: SemanticInsertionQuery,
163209
): SemanticInsertionResult = withContext(readDispatcher) {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package io.github.amichne.kast.shared.hierarchy
2+
3+
import com.intellij.psi.PsiElement
4+
import io.github.amichne.kast.api.Location
5+
import io.github.amichne.kast.api.Symbol
6+
7+
/**
8+
* Represents a single edge in the call hierarchy: a resolved target declaration
9+
* plus the call-site location where the reference occurs.
10+
*/
11+
data class CallEdge(
12+
val target: PsiElement,
13+
val symbol: Symbol,
14+
val callSite: Location,
15+
)
16+
17+
/**
18+
* Backend-agnostic strategy for discovering incoming and outgoing call edges.
19+
*
20+
* Each backend (standalone, IntelliJ plugin) provides its own implementation
21+
* using its native reference-search infrastructure.
22+
*/
23+
interface CallEdgeResolver {
24+
25+
/**
26+
* Returns all declarations that call [target].
27+
*
28+
* @param onFileVisited called once per unique file examined during the search,
29+
* regardless of whether it yields edges. Implementations must deduplicate.
30+
*/
31+
fun incomingEdges(
32+
target: PsiElement,
33+
timeoutCheck: () -> Boolean,
34+
onFileVisited: (filePath: String) -> Unit,
35+
): List<CallEdge>
36+
37+
/**
38+
* Returns all declarations called by [target].
39+
*
40+
* @param onFileVisited called once per unique file examined during the search,
41+
* regardless of whether it yields edges. Implementations must deduplicate.
42+
*/
43+
fun outgoingEdges(
44+
target: PsiElement,
45+
timeoutCheck: () -> Boolean,
46+
onFileVisited: (filePath: String) -> Unit,
47+
): List<CallEdge>
48+
}

0 commit comments

Comments
 (0)