Skip to content

Commit 60e2728

Browse files
amichneCopilot
andcommitted
feat: wire Phase 2 symbol-reference indexing and fix createListener crash
- Fix MockComponentManager.createListener crash by adding reflective fallback in LazyListenerKt that instantiates listeners directly when the message bus owner doesn't support createListener - Wire Phase 2 background indexing: scanFileReferences() walks PSI trees, resolves references via K2, and populates symbol_references table after Phase 1 completes - Refactor BackgroundIndexer.startPhase2 to use scan-then-batch-write pattern (chunked by 50) to minimize SQLite write contention - Cancel backgroundIndexer before cache invalidation in refreshWorkspace() to prevent Phase 2 from recreating deleted files - Add 14 new tests: - 5 SQLite CRUD tests for symbol_references operations - 5 BackgroundIndexer Phase 2 lifecycle tests - 3 findReferences cache-hit/fallback integration tests - 1 LazyListenerKt reflective fallback test Co-authored-by: Copilot <[email protected]>
1 parent 26094df commit 60e2728

7 files changed

Lines changed: 778 additions & 15 deletions

File tree

backend-standalone/src/compat/java/com/intellij/util/messages/impl/LazyListenerKt.java

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,16 @@ public static void subscribeLazyListeners(
6868
throw exception;
6969
}
7070
catch (Throwable exception) {
71-
MessageBusImpl.LOG.error("Cannot create listener", exception);
71+
// Standalone mode: MockComponentManager throws UnsupportedOperationException
72+
// from createListener. Fall back to direct reflective instantiation so that
73+
// K2 FIR session invalidation listeners (and any other lazy listeners) are
74+
// still registered.
75+
Object fallback = instantiateListenerDirectly(descriptor);
76+
if (fallback != null) {
77+
handlers.add(fallback);
78+
} else {
79+
MessageBusImpl.LOG.error("Cannot create listener", exception);
80+
}
7281
}
7382
}
7483

@@ -213,4 +222,30 @@ else if (filteredHandlers != null) {
213222
}
214223
return filteredHandlers;
215224
}
225+
226+
/**
227+
* Reflective fallback for standalone mode where {@code MockComponentManager.createListener}
228+
* throws {@link UnsupportedOperationException}. Loads the listener class using the plugin
229+
* descriptor's classloader and instantiates it via the no-arg constructor.
230+
*
231+
* @return the instantiated listener, or {@code null} if instantiation fails
232+
*/
233+
private static Object instantiateListenerDirectly(PluginListenerDescriptor descriptor) {
234+
try {
235+
String listenerClassName = descriptor.getDescriptor().listenerClassName;
236+
ClassLoader classLoader = descriptor.getPluginDescriptor().getPluginClassLoader();
237+
if (classLoader == null) {
238+
classLoader = Thread.currentThread().getContextClassLoader();
239+
}
240+
if (classLoader == null) {
241+
classLoader = LazyListenerKt.class.getClassLoader();
242+
}
243+
Class<?> listenerClass = Class.forName(listenerClassName, true, classLoader);
244+
java.lang.reflect.Constructor<?> constructor = listenerClass.getDeclaredConstructor();
245+
constructor.setAccessible(true);
246+
return constructor.newInstance();
247+
} catch (Throwable fallbackException) {
248+
return null;
249+
}
250+
}
216251
}

backend-standalone/src/main/kotlin/io/github/amichne/kast/standalone/BackgroundIndexer.kt

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,20 +92,37 @@ internal class BackgroundIndexer(
9292
if (cancelled) return@thread
9393
val allPaths = store.loadManifest()?.keys ?: return@thread
9494
generation.incrementAndGet()
95-
for (filePath in allPaths) {
95+
val pathList = allPaths.toList()
96+
// Process files in batches: scan without holding a transaction, then
97+
// batch-write results in a short transaction to minimize SQLite contention.
98+
for (batch in pathList.chunked(PHASE2_BATCH_SIZE)) {
9699
if (cancelled || Thread.currentThread().isInterrupted) break
97-
runCatching {
98-
store.clearReferencesFromFile(filePath)
99-
val refs = referenceScanner(filePath)
100-
refs.forEach { ref ->
101-
store.upsertSymbolReference(
102-
sourcePath = ref.sourcePath,
103-
sourceOffset = ref.sourceOffset,
104-
targetFqName = ref.targetFqName,
105-
targetPath = ref.targetPath,
106-
targetOffset = ref.targetOffset,
107-
)
100+
// Phase A: scan files (no SQLite writes, may be slow due to K2 resolution)
101+
val batchResults = batch.mapNotNull { filePath ->
102+
if (cancelled || Thread.currentThread().isInterrupted) return@mapNotNull null
103+
runCatching {
104+
filePath to referenceScanner(filePath)
105+
}.getOrNull()
106+
}
107+
if (cancelled || Thread.currentThread().isInterrupted) break
108+
// Phase B: batch-write in a short transaction
109+
store.beginTransaction()
110+
try {
111+
for ((filePath, refs) in batchResults) {
112+
store.clearReferencesFromFile(filePath)
113+
refs.forEach { ref ->
114+
store.upsertSymbolReference(
115+
sourcePath = ref.sourcePath,
116+
sourceOffset = ref.sourceOffset,
117+
targetFqName = ref.targetFqName,
118+
targetPath = ref.targetPath,
119+
targetOffset = ref.targetOffset,
120+
)
121+
}
108122
}
123+
store.commitTransaction()
124+
} catch (e: Exception) {
125+
store.rollbackTransaction()
109126
}
110127
}
111128
if (!cancelled) {
@@ -168,6 +185,11 @@ internal class BackgroundIndexer(
168185
// Phase 1 internals
169186
// -------------------------------------------------------------------------
170187

188+
companion object {
189+
/** Number of files to batch per Phase 2 transaction to reduce SQLite write contention. */
190+
private const val PHASE2_BATCH_SIZE = 50
191+
}
192+
171193
private fun loadOrBuildIndex(): MutableSourceIdentifierIndex {
172194
val incrementalResult = runCatching {
173195
sourceIndexCache.load(sourceRoots)

backend-standalone/src/main/kotlin/io/github/amichne/kast/standalone/StandaloneAnalysisSession.kt

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import com.intellij.openapi.vfs.VirtualFileManager
1212
import com.intellij.platform.syntax.psi.CommonElementTypeConverterFactory
1313
import com.intellij.platform.syntax.psi.ElementTypeConverters
1414
import com.intellij.psi.PsiManager
15+
import com.intellij.psi.PsiRecursiveElementWalkingVisitor
1516
import com.intellij.psi.compiled.ClassFileDecompilers
1617
import com.intellij.psi.impl.PsiManagerEx
1718
import io.github.amichne.kast.api.FqName
@@ -21,9 +22,12 @@ import io.github.amichne.kast.api.NormalizedPath
2122
import io.github.amichne.kast.api.NotFoundException
2223
import io.github.amichne.kast.api.PackageName
2324
import io.github.amichne.kast.api.RefreshResult
25+
import io.github.amichne.kast.standalone.analysis.resolvedFilePath
26+
import io.github.amichne.kast.standalone.analysis.targetFqNameAndPackage
2427
import io.github.amichne.kast.standalone.cache.CacheManager
2528
import io.github.amichne.kast.standalone.cache.SourceIndexCache
2629
import io.github.amichne.kast.standalone.cache.SqliteSourceIndexStore
30+
import io.github.amichne.kast.standalone.cache.SymbolReferenceRow
2731
import io.github.amichne.kast.standalone.cache.scanTrackedKotlinFileTimestamps
2832
import io.github.amichne.kast.standalone.workspace.PhasedDiscoveryResult
2933
import org.jetbrains.kotlin.analysis.api.projectStructure.KaSourceModule
@@ -205,7 +209,6 @@ internal class StandaloneAnalysisSession(
205209
fileManager.setViewProvider(virtualFile, null)
206210
}
207211
}
208-
PsiManager.getInstance(session.project).dropResolveCaches()
209212
}
210213

211214
normalizedPaths.forEach { normalizedPath ->
@@ -236,6 +239,7 @@ internal class StandaloneAnalysisSession(
236239
}
237240
}
238241

242+
PsiManager.getInstance(session.project).dropResolveCaches()
239243
refreshSourceIdentifierIndex(normalizedPaths)
240244
targetedCandidatePathsByLookupKey.clear()
241245
return buildRefreshResult(normalizedPaths, fullRefresh = false)
@@ -275,8 +279,32 @@ internal class StandaloneAnalysisSession(
275279
return buildRefreshResult(normalizedPaths, fullRefresh = false)
276280
}
277281

282+
/**
283+
* Rebuilds the K2 analysis session without a full workspace refresh.
284+
*
285+
* In K2 standalone mode, `KotlinStandaloneDeclarationProviderFactory` builds a
286+
* static declaration index at session construction time. Content changes that alter
287+
* declaration signatures (e.g., renames) require a session rebuild for cross-file
288+
* resolution to pick up the new names. The lightweight `refreshFileContents` path
289+
* updates PSI/VFS and our source identifier index, but cannot incrementally update
290+
* K2's declaration index because standalone mode's `MockComponentManager` does not
291+
* support the message-bus-based invalidation that IDE mode relies on.
292+
*
293+
* Call this after `refreshFileContents` when the edits include declaration-level
294+
* changes and cross-file resolution correctness is required.
295+
*/
296+
fun rebuildAnalysisSession() {
297+
analysisSessionLock.write {
298+
val previousDisposable = sessionStateDisposable
299+
buildAnalysisStateAndCache()
300+
fullKtFileMapLoaded = false
301+
Disposer.dispose(previousDisposable)
302+
}
303+
}
304+
278305
fun refreshWorkspace(invalidateCaches: Boolean = false): RefreshResult {
279306
if (invalidateCaches) {
307+
backgroundIndexer.close()
280308
cacheManager.invalidateAll()
281309
}
282310
val currentPaths = allTrackedKotlinSourcePaths()
@@ -686,7 +714,51 @@ internal class StandaloneAnalysisSession(
686714
applyPendingSourceIndexRefreshes(builtIndex)
687715
sourceIdentifierIndex.set(builtIndex)
688716
}
717+
backgroundIndexer.startPhase2(::scanFileReferences)
718+
}
719+
}
720+
721+
/**
722+
* Phase 2 reference scanner: walks a single file's PSI tree, resolves references
723+
* via the K2 analysis session, and extracts [SymbolReferenceRow]s mapping each
724+
* reference site to its target's fully-qualified name, path, and offset.
725+
*
726+
* Called per-file on the Phase 2 background thread. Errors for individual elements
727+
* are silently skipped so one bad reference doesn't abort the entire file scan.
728+
*/
729+
private fun scanFileReferences(filePath: String): List<SymbolReferenceRow> {
730+
val rows = mutableListOf<SymbolReferenceRow>()
731+
withReadAccess {
732+
val ktFile = runCatching { findKtFile(filePath) }.getOrNull() ?: return@withReadAccess
733+
val sourceFilePath = runCatching { ktFile.resolvedFilePath().value }.getOrElse { filePath }
734+
735+
ktFile.accept(
736+
object : PsiRecursiveElementWalkingVisitor() {
737+
override fun visitElement(element: com.intellij.psi.PsiElement) {
738+
element.references.forEach { reference ->
739+
runCatching {
740+
val resolved = reference.resolve() ?: return@forEach
741+
val fqNameAndPkg = resolved.targetFqNameAndPackage() ?: return@forEach
742+
val (fqName, _) = fqNameAndPkg
743+
val targetPath = runCatching { resolved.resolvedFilePath().value }.getOrNull()
744+
val targetOffset = resolved.textRange?.startOffset
745+
val sourceOffset = reference.element.textRange.startOffset +
746+
reference.rangeInElement.startOffset
747+
rows += SymbolReferenceRow(
748+
sourcePath = sourceFilePath,
749+
sourceOffset = sourceOffset,
750+
targetFqName = fqName.value,
751+
targetPath = targetPath,
752+
targetOffset = targetOffset,
753+
)
754+
}
755+
}
756+
super.visitElement(element)
757+
}
758+
},
759+
)
689760
}
761+
return rows
690762
}
691763

692764
private fun applyPendingSourceIndexRefreshes(index: MutableSourceIdentifierIndex) {

0 commit comments

Comments
 (0)