|
| 1 | +package io.github.amichne.kast.standalone |
| 2 | + |
| 3 | +import io.github.amichne.kast.api.ModuleName |
| 4 | +import io.github.amichne.kast.api.NormalizedPath |
| 5 | +import io.github.amichne.kast.standalone.cache.SourceIndexCache |
| 6 | +import io.github.amichne.kast.standalone.cache.SqliteSourceIndexStore |
| 7 | +import io.github.amichne.kast.standalone.cache.SymbolReferenceRow |
| 8 | +import java.nio.file.Path |
| 9 | +import java.util.concurrent.CompletableFuture |
| 10 | +import java.util.concurrent.atomic.AtomicInteger |
| 11 | +import java.util.concurrent.atomic.AtomicReference |
| 12 | +import kotlin.concurrent.thread |
| 13 | + |
| 14 | +/** |
| 15 | + * Manages eager background indexing in two phases: |
| 16 | + * |
| 17 | + * - **Phase 1 (identifier index)**: A fast text-only scan that builds |
| 18 | + * [MutableSourceIdentifierIndex] from source files. This runs immediately on |
| 19 | + * [startPhase1] and completes [identifierIndexReady]. |
| 20 | + * |
| 21 | + * - **Phase 2 (symbol references)**: A deeper scan that resolves K2 symbol |
| 22 | + * references and populates the `symbol_references` table in SQLite. Triggered |
| 23 | + * via [startPhase2] after Phase 1 and the K2 session are ready. Completes |
| 24 | + * [referenceIndexReady]. |
| 25 | + * |
| 26 | + * The indexer is designed to be cancelled cleanly: [close] interrupts in-flight |
| 27 | + * work and completes both futures so callers never hang. |
| 28 | + */ |
| 29 | +internal class BackgroundIndexer( |
| 30 | + private val sourceRoots: List<Path>, |
| 31 | + private val sourceIndexFileReader: (Path) -> String, |
| 32 | + private val sourceModuleNameResolver: (NormalizedPath) -> ModuleName?, |
| 33 | + private val sourceIndexCache: SourceIndexCache, |
| 34 | + private val store: SqliteSourceIndexStore, |
| 35 | + private val initialSourceIndexBuilder: (() -> Map<String, List<String>>)? = null, |
| 36 | +) : AutoCloseable { |
| 37 | + |
| 38 | + val identifierIndexReady = CompletableFuture<Unit>() |
| 39 | + val referenceIndexReady = CompletableFuture<Unit>() |
| 40 | + |
| 41 | + private val generation = AtomicInteger(0) |
| 42 | + private val indexRef = AtomicReference<MutableSourceIdentifierIndex?>(null) |
| 43 | + |
| 44 | + @Volatile |
| 45 | + private var cancelled = false |
| 46 | + private var phase1Thread: Thread? = null |
| 47 | + private var phase2Thread: Thread? = null |
| 48 | + |
| 49 | + /** |
| 50 | + * Starts Phase 1 (identifier index) on a daemon thread. Returns the |
| 51 | + * generation counter so the caller can detect stale results. |
| 52 | + */ |
| 53 | + fun startPhase1(): Int { |
| 54 | + val gen = generation.incrementAndGet() |
| 55 | + phase1Thread = thread( |
| 56 | + start = true, |
| 57 | + isDaemon = true, |
| 58 | + name = "kast-background-indexer-phase1", |
| 59 | + ) { |
| 60 | + runCatching { |
| 61 | + if (cancelled) return@thread |
| 62 | + initialSourceIndexBuilder |
| 63 | + ?.invoke() |
| 64 | + ?.let(MutableSourceIdentifierIndex::fromCandidatePathsByIdentifier) |
| 65 | + ?: loadOrBuildIndex() |
| 66 | + }.onSuccess { index -> |
| 67 | + if (cancelled || generation.get() != gen) return@onSuccess |
| 68 | + indexRef.set(index) |
| 69 | + runCatching { sourceIndexCache.save(index = index, sourceRoots = sourceRoots) } |
| 70 | + identifierIndexReady.complete(Unit) |
| 71 | + }.onFailure { error -> |
| 72 | + if (cancelled || generation.get() != gen) return@onFailure |
| 73 | + identifierIndexReady.completeExceptionally(error) |
| 74 | + } |
| 75 | + } |
| 76 | + return gen |
| 77 | + } |
| 78 | + |
| 79 | + /** |
| 80 | + * Starts Phase 2 (symbol reference index) on a daemon thread. The |
| 81 | + * [referenceScanner] callback resolves references for a single file path |
| 82 | + * and returns a list of [SymbolReferenceRow]s. It is called inside the |
| 83 | + * caller-provided read-access context (e.g., K2 analysis session). |
| 84 | + */ |
| 85 | + fun startPhase2(referenceScanner: (String) -> List<SymbolReferenceRow>) { |
| 86 | + phase2Thread = thread( |
| 87 | + start = true, |
| 88 | + isDaemon = true, |
| 89 | + name = "kast-background-indexer-phase2", |
| 90 | + ) { |
| 91 | + runCatching { |
| 92 | + if (cancelled) return@thread |
| 93 | + val allPaths = store.loadManifest()?.keys ?: return@thread |
| 94 | + generation.incrementAndGet() |
| 95 | + for (filePath in allPaths) { |
| 96 | + 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 | + ) |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | + if (!cancelled) { |
| 112 | + referenceIndexReady.complete(Unit) |
| 113 | + } |
| 114 | + }.onFailure { error -> |
| 115 | + if (cancelled) return@onFailure |
| 116 | + if (!referenceIndexReady.isDone) { |
| 117 | + referenceIndexReady.completeExceptionally(error) |
| 118 | + } |
| 119 | + } |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + /** Returns the current identifier index, or null if Phase 1 hasn't completed. */ |
| 124 | + fun getIndex(): MutableSourceIdentifierIndex? = indexRef.get() |
| 125 | + |
| 126 | + /** Returns the current generation counter. */ |
| 127 | + fun currentGeneration(): Int = generation.get() |
| 128 | + |
| 129 | + /** |
| 130 | + * Re-indexes a set of changed file paths incrementally. Skips files that |
| 131 | + * no longer exist on disk (deleted between discovery and read). |
| 132 | + */ |
| 133 | + fun reindexFiles( |
| 134 | + index: MutableSourceIdentifierIndex, |
| 135 | + paths: Set<NormalizedPath>, |
| 136 | + ) { |
| 137 | + paths.forEach { normalizedPath -> |
| 138 | + val filePath = normalizedPath.toJavaPath() |
| 139 | + if (!java.nio.file.Files.isRegularFile(filePath)) { |
| 140 | + index.removeFile(normalizedPath.value) |
| 141 | + sourceIndexCache.saveRemovedFile(normalizedPath.value) |
| 142 | + return@forEach |
| 143 | + } |
| 144 | + runCatching { |
| 145 | + index.updateFile( |
| 146 | + normalizedPath = normalizedPath.value, |
| 147 | + newContent = sourceIndexFileReader(filePath), |
| 148 | + moduleName = sourceModuleNameResolver(normalizedPath), |
| 149 | + ) |
| 150 | + sourceIndexCache.saveFileIndex(index, normalizedPath) |
| 151 | + } |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + override fun close() { |
| 156 | + cancelled = true |
| 157 | + phase1Thread?.interrupt() |
| 158 | + phase2Thread?.interrupt() |
| 159 | + if (!identifierIndexReady.isDone) { |
| 160 | + identifierIndexReady.complete(Unit) |
| 161 | + } |
| 162 | + if (!referenceIndexReady.isDone) { |
| 163 | + referenceIndexReady.complete(Unit) |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + // ------------------------------------------------------------------------- |
| 168 | + // Phase 1 internals |
| 169 | + // ------------------------------------------------------------------------- |
| 170 | + |
| 171 | + private fun loadOrBuildIndex(): MutableSourceIdentifierIndex { |
| 172 | + val incrementalResult = runCatching { |
| 173 | + sourceIndexCache.load(sourceRoots) |
| 174 | + }.getOrNull() |
| 175 | + val index = incrementalResult?.index ?: return buildFullIndex() |
| 176 | + incrementalResult.deletedPaths.forEach(index::removeFile) |
| 177 | + (incrementalResult.newPaths + incrementalResult.modifiedPaths).forEach { pathString -> |
| 178 | + if (cancelled || Thread.currentThread().isInterrupted) return index |
| 179 | + refreshFileIndex(index, NormalizedPath.ofNormalized(pathString)) |
| 180 | + } |
| 181 | + return index |
| 182 | + } |
| 183 | + |
| 184 | + private fun buildFullIndex(): MutableSourceIdentifierIndex { |
| 185 | + val index = MutableSourceIdentifierIndex( |
| 186 | + pathsByIdentifier = java.util.concurrent.ConcurrentHashMap(), |
| 187 | + identifiersByPath = java.util.concurrent.ConcurrentHashMap(), |
| 188 | + ) |
| 189 | + allTrackedKotlinSourcePaths().forEach { normalizedFilePath -> |
| 190 | + if (cancelled || Thread.currentThread().isInterrupted) return index |
| 191 | + val normalizedPath = NormalizedPath.ofNormalized(normalizedFilePath) |
| 192 | + runCatching { |
| 193 | + index.updateFile( |
| 194 | + normalizedPath = normalizedFilePath, |
| 195 | + newContent = sourceIndexFileReader(normalizedPath.toJavaPath()), |
| 196 | + moduleName = sourceModuleNameResolver(normalizedPath), |
| 197 | + ) |
| 198 | + } |
| 199 | + // Skip files that fail to read (e.g., deleted between discovery and read) |
| 200 | + } |
| 201 | + return index |
| 202 | + } |
| 203 | + |
| 204 | + private fun refreshFileIndex( |
| 205 | + index: MutableSourceIdentifierIndex, |
| 206 | + normalizedPath: NormalizedPath, |
| 207 | + ) { |
| 208 | + val filePath = normalizedPath.toJavaPath() |
| 209 | + if (!java.nio.file.Files.isRegularFile(filePath)) { |
| 210 | + index.removeFile(normalizedPath.value) |
| 211 | + return |
| 212 | + } |
| 213 | + runCatching { |
| 214 | + index.updateFile( |
| 215 | + normalizedPath = normalizedPath.value, |
| 216 | + newContent = sourceIndexFileReader(filePath), |
| 217 | + moduleName = sourceModuleNameResolver(normalizedPath), |
| 218 | + ) |
| 219 | + } |
| 220 | + } |
| 221 | + |
| 222 | + private fun allTrackedKotlinSourcePaths(): Set<String> = |
| 223 | + io.github.amichne.kast.standalone.cache.scanTrackedKotlinFileTimestamps(sourceRoots).keys |
| 224 | +} |
0 commit comments