This commit is contained in:
pavel 2026-07-09 08:27:13 +02:00
commit 985aa16236
13 changed files with 1381 additions and 241 deletions

View file

@ -14,7 +14,18 @@ kotlin {
dependencies {
implementation(gradleApi())
implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.21")
// Kotlin Analysis API is published for IDE usage as thin POMs whose internal module
// dependencies are not independently resolvable. Use the combined jars directly.
implementation("org.jetbrains.kotlin:analysis-api-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:analysis-api-impl-base-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:analysis-api-platform-interface-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:analysis-api-k2-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:low-level-api-fir-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:symbol-light-classes-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:analysis-api-standalone-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:kotlin-compiler:2.2.21")
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}

View file

@ -2,6 +2,7 @@ pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies")
}
}
@ -9,6 +10,7 @@ dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies")
}
}

View file

@ -12,6 +12,7 @@ import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import javax.tools.DiagnosticCollector
import javax.tools.JavaFileObject
@ -34,9 +35,25 @@ import org.gradle.tooling.GradleConnector
import org.gradle.tooling.model.GradleProject
import org.gradle.tooling.model.idea.IdeaProject
import org.gradle.tooling.model.idea.IdeaSingleEntryLibraryDependency
import com.intellij.openapi.util.Disposer
import com.intellij.psi.PsiElement
import com.intellij.psi.util.PsiTreeUtil
import org.jetbrains.kotlin.analysis.api.KaImplementationDetail
import org.jetbrains.kotlin.analysis.api.analyze
import org.jetbrains.kotlin.analysis.api.resolution.KaSymbolBasedReference
import org.jetbrains.kotlin.analysis.api.standalone.buildStandaloneAnalysisAPISession
import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISession
import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryModule
import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSourceModule
import org.jetbrains.kotlin.cli.common.ExitCode
import org.jetbrains.kotlin.cli.common.messages.MessageRenderer
import org.jetbrains.kotlin.cli.jvm.K2JVMCompiler
import org.jetbrains.kotlin.platform.jvm.JvmPlatforms
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtDeclaration
import org.jetbrains.kotlin.psi.KtClassOrObject
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtReferenceExpression
fun main(args: Array<String>) {
val requestedPort = args.firstOrNull()?.toIntOrNull() ?: 0
@ -57,15 +74,50 @@ fun main(args: Array<String>) {
private class DaemonState {
@Volatile var workspaceRoot: File? = null
@Volatile var gradleWorkspace: GradleWorkspace? = null
@Volatile var workspaceGeneration: Int = 0
val openTexts = ConcurrentHashMap<String, String>()
val textVersions = ConcurrentHashMap<String, Int>()
val diagnosticsVersions = ConcurrentHashMap<String, Int>()
val diagnosticsCache = ConcurrentHashMap<String, List<KotlinDiagnostic>>()
val semanticCacheLock = Any()
val semanticCache = LinkedHashMap<SemanticCacheKey, CachedSemanticContext>()
fun clearSemanticCache() {
synchronized(semanticCacheLock) {
semanticCache.values.forEach { it.dispose() }
semanticCache.clear()
}
}
fun invalidateSemanticCacheForPath(path: String) {
synchronized(semanticCacheLock) {
val iterator = semanticCache.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.key.path == path) {
entry.value.dispose()
iterator.remove()
}
}
}
}
}
private data class GradleWorkspace(
val root: File,
val modules: List<GradleModule>,
val tasks: List<GradleTaskInfo>,
val sourceTexts: Map<String, String>,
val declarations: List<SourceDeclaration>,
)
private data class SourceDeclaration(
val name: String,
val kind: String,
val location: SourceLocation,
val packageName: String?,
val containerName: String?,
val signature: String,
)
private data class GradleModule(
@ -77,6 +129,8 @@ private data class GradleModule(
val resourceRoots: List<File>,
val testResourceRoots: List<File>,
val classpath: List<File>,
val mainSourceFiles: List<File>,
val testSourceFiles: List<File>,
)
private data class KotlinDiagnostic(
@ -93,6 +147,40 @@ private data class SourceLocation(
val column: Int,
)
private data class HoverInfo(
val contents: String,
val definition: SourceLocation?,
)
private data class SemanticContext(
val session: StandaloneAnalysisAPISession,
val targetFile: KtFile,
val tempPath: String,
val originalPath: String,
)
private data class SemanticCacheKey(
val workspaceGeneration: Int,
val modulePath: String,
val path: String,
val version: Int,
val textHash: Int,
)
private class CachedSemanticContext(
val key: SemanticCacheKey,
val disposable: com.intellij.openapi.Disposable,
val tempDir: File,
val context: SemanticContext,
) {
var lastUsedMillis: Long = System.currentTimeMillis()
fun dispose() {
Disposer.dispose(disposable)
tempDir.deleteRecursively()
}
}
private data class CompletionCandidate(
val label: String,
val kind: String,
@ -173,7 +261,9 @@ private class ClientSession(
return
}
state.clearSemanticCache()
state.gradleWorkspace = gradleWorkspace
state.workspaceGeneration += 1
writeLine(writer, okJson(id, workspaceJson(gradleWorkspace)))
writeLine(writer, eventJson("workspace/indexing", indexingStateJson("idle")))
}
@ -184,6 +274,8 @@ private class ClientSession(
state.openTexts.clear()
state.textVersions.clear()
state.diagnosticsVersions.clear()
state.diagnosticsCache.clear()
state.clearSemanticCache()
writeLine(writer, okJson(id, buildJsonObject { put("closed", true) }))
}
@ -198,6 +290,8 @@ private class ClientSession(
val version = params.intField("version") ?: 0
state.openTexts[normalizedPath] = params.stringField("text") ?: ""
state.textVersions[normalizedPath] = version
state.diagnosticsCache.keys.removeIf { it.startsWith("$normalizedPath\u0000") && it != diagnosticsCacheKey(normalizedPath, version) }
state.invalidateSemanticCacheForPath(normalizedPath)
writeLine(writer, okJson(id, buildJsonObject {
put("path", normalizedPath)
put("version", version)
@ -210,6 +304,8 @@ private class ClientSession(
if (path != null) state.openTexts.remove(path)
if (path != null) state.textVersions.remove(path)
if (path != null) state.diagnosticsVersions.remove(path)
if (path != null) state.diagnosticsCache.keys.removeIf { it.startsWith("$path\u0000") }
if (path != null) state.invalidateSemanticCacheForPath(path)
writeLine(writer, okJson(id, buildJsonObject { put("closed", true) }))
}
@ -309,7 +405,7 @@ private class ClientSession(
return
}
val diagnostics = compileForDiagnostics(file, module, state.openTexts[normalizedPath])
val diagnostics = diagnosticsFor(file, module, normalizedPath)
writeLine(writer, okJson(id, buildJsonObject { put("diagnostics", diagnosticsJson(diagnostics)) }))
}
@ -326,7 +422,7 @@ private class ClientSession(
val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("")
val offset = offsetForLineColumn(text, requestLine, requestColumn)
val prefix = identifierPrefixAt(text, offset)
val items = completionCandidates(prefix, text, state.gradleWorkspace)
val items = completionCandidates(prefix, text, normalizedPath, state.gradleWorkspace)
writeLine(writer, okJson(id, buildJsonObject { put("items", completionItemsJson(items)) }))
}
@ -343,8 +439,19 @@ private class ClientSession(
val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("")
val offset = offsetForLineColumn(text, requestLine, requestColumn)
val identifier = identifierAt(text, offset)
val contents = hoverContents(identifier, normalizedPath, text, state.gradleWorkspace)
writeLine(writer, okJson(id, buildJsonObject { put("contents", contents) }))
val workspace = state.gradleWorkspace
val file = File(normalizedPath)
val module = workspace?.let { findModule(it, file) }
val hover = if (module != null && file.extension.lowercase() in setOf("kt", "kts")) {
semanticHover(state, file, module, normalizedPath, text, requestLine, requestColumn)
} else {
null
} ?: HoverInfo(
contents = hoverContents(identifier, normalizedPath, text, workspace).orEmpty(),
definition = findSimpleDeclarationInText(normalizedPath, text, identifier)
?: workspace?.let { findSimpleDeclaration(it, identifier, normalizedPath, text) },
)
writeLine(writer, okJson(id, hoverInfoJson(hover)))
}
private fun kotlinDefinition(writer: BufferedWriter, id: Int, params: JsonObject) {
@ -366,8 +473,14 @@ private class ClientSession(
val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("")
val offset = offsetForLineColumn(text, requestLine, requestColumn)
val identifier = identifierAt(text, offset)
val location = findSimpleDeclarationInText(normalizedPath, text, identifier)
?: findSimpleDeclaration(workspace, identifier)
val file = File(normalizedPath)
val module = findModule(workspace, file)
val location = if (module != null && file.extension.lowercase() in setOf("kt", "kts")) {
semanticDefinition(state, file, module, normalizedPath, text, requestLine, requestColumn)
} else {
null
} ?: findSimpleDeclarationInText(normalizedPath, text, identifier)
?: findSimpleDeclaration(workspace, identifier, normalizedPath, text)
writeLine(writer, okJson(id, buildJsonObject { put("locations", definitionLocationsJson(location)) }))
}
@ -390,7 +503,13 @@ private class ClientSession(
val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("")
val offset = offsetForLineColumn(text, requestLine, requestColumn)
val identifier = identifierAt(text, offset)
val locations = findSimpleReferences(workspace, normalizedPath, text, identifier)
val file = File(normalizedPath)
val module = findModule(workspace, file)
val locations = if (module != null && file.extension.lowercase() in setOf("kt", "kts")) {
semanticReferences(state, file, module, normalizedPath, text, requestLine, requestColumn)
} else {
null
} ?: findSimpleReferences(workspace, normalizedPath, text, identifier)
writeLine(writer, okJson(id, buildJsonObject { put("locations", locationsJson(locations)) }))
}
@ -447,23 +566,25 @@ private class ClientSession(
val file = File(path).absoluteFile.normalize()
val module = findModule(workspace, file) ?: return@thread
val diagnostics = try {
compileForDiagnostics(file, module, state.openTexts[path])
diagnosticsFor(file, module, path)
} catch (t: Throwable) {
listOf(KotlinDiagnostic("error", t.message ?: t.javaClass.name, file.path, null, null))
}
if (state.diagnosticsVersions[path] != version || state.textVersions[path] != version) return@thread
writeLine(
writer,
eventJson(
"diagnostics/publish",
buildJsonObject {
put("path", file.path)
put("version", version)
put("diagnostics", diagnosticsJson(diagnostics))
},
),
)
runCatching {
writeLine(
writer,
eventJson(
"diagnostics/publish",
buildJsonObject {
put("path", file.path)
put("version", version)
put("diagnostics", diagnosticsJson(diagnostics))
},
),
)
}
}
}
@ -472,8 +593,19 @@ private class ClientSession(
writeLineLocked(writer, text)
}
}
private fun diagnosticsFor(file: File, module: GradleModule, normalizedPath: String): List<KotlinDiagnostic> {
val version = state.textVersions[normalizedPath] ?: -1
val key = diagnosticsCacheKey(normalizedPath, version)
state.diagnosticsCache[key]?.let { return it }
val diagnostics = compileForDiagnostics(file, module, state.openTexts[normalizedPath])
state.diagnosticsCache[key] = diagnostics
return diagnostics
}
}
private fun diagnosticsCacheKey(path: String, version: Int): String = "$path\u0000$version"
private fun findModule(workspace: GradleWorkspace, file: File): GradleModule? {
val normalizedFile = file.absoluteFile.normalize()
return workspace.modules
@ -526,19 +658,21 @@ private fun importGradleWorkspace(root: File): GradleWorkspace {
val gradleProject = connection.getModel(GradleProject::class.java)
val ideaProject = connection.getModel(IdeaProject::class.java)
return GradleWorkspace(
root = root,
modules = ideaProject.modules.map { module ->
val sourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.sourceDirectories.map { it.directory } } +
generatedSourceRoots(module.gradleProject.projectDirectory, test = false)
val testSourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.testDirectories.map { it.directory } } +
generatedSourceRoots(module.gradleProject.projectDirectory, test = true)
val modules = ideaProject.modules.map { module ->
val sourceRoots = (module.contentRoots.flatMap { contentRoot -> contentRoot.sourceDirectories.map { it.directory } } +
generatedSourceRoots(module.gradleProject.projectDirectory, test = false))
.filter { it.exists() }
.distinctBy { it.absoluteFile.normalize().path }
val testSourceRoots = (module.contentRoots.flatMap { contentRoot -> contentRoot.testDirectories.map { it.directory } } +
generatedSourceRoots(module.gradleProject.projectDirectory, test = true))
.filter { it.exists() }
.distinctBy { it.absoluteFile.normalize().path }
GradleModule(
name = module.name,
gradlePath = module.gradleProject.path,
directory = module.gradleProject.projectDirectory,
sourceRoots = sourceRoots.filter { it.exists() }.distinctBy { it.absoluteFile.normalize().path },
testSourceRoots = testSourceRoots.filter { it.exists() }.distinctBy { it.absoluteFile.normalize().path },
sourceRoots = sourceRoots,
testSourceRoots = testSourceRoots,
resourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.resourceDirectories.map { it.directory } }.distinctBy { it.path },
testResourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.testResourceDirectories.map { it.directory } }.distinctBy { it.path },
classpath = module.dependencies
@ -546,13 +680,88 @@ private fun importGradleWorkspace(root: File): GradleWorkspace {
.map { it.file }
.filter { it.exists() }
.distinctBy { it.path },
mainSourceFiles = collectKotlinAndJavaSources(sourceRoots),
testSourceFiles = collectKotlinAndJavaSources(testSourceRoots),
)
},
}
val sourceTexts = sourceTexts(modules)
return GradleWorkspace(
root = root,
modules = modules,
tasks = collectTasks(gradleProject),
sourceTexts = sourceTexts,
declarations = sourceDeclarations(sourceTexts),
)
}
}
private fun sourceDeclarations(sourceTexts: Map<String, String>): List<SourceDeclaration> {
val declarations = mutableListOf<SourceDeclaration>()
val patterns = listOf(
Triple(Regex("\\b(fun|class|object|interface|val|var|typealias)\\s+([A-Za-z_][A-Za-z0-9_]*)"), 2, 1),
Triple(Regex("\\b(class|interface|enum|record)\\s+([A-Za-z_][A-Za-z0-9_]*)"), 2, 1),
Triple(Regex("\\b[A-Za-z_][A-Za-z0-9_<>, ?\\[\\]]+\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(\\(|=|;)"), 1, 2),
)
for ((path, text) in sourceTexts) {
val packageName = sourcePackageName(text, semicolon = path.endsWith(".java"))
val containers = mutableListOf<Pair<String, Int>>()
var braceDepth = 0
text.lineSequence().forEachIndexed { lineIndex, rawLine ->
val line = rawLine.trim()
while (containers.isNotEmpty() && braceDepth <= containers.last().second) {
containers.removeLast()
}
val containerName = containers.lastOrNull()?.first
for ((pattern, nameGroup, markerGroup) in patterns) {
for (match in pattern.findAll(rawLine)) {
val name = match.groupValues[nameGroup]
if (name in allKeywordCompletionItems) continue
val marker = match.groupValues[markerGroup]
val kind = when (marker) {
"fun" -> "function"
"class", "object", "interface", "typealias", "enum", "record" -> "type"
"val", "var" -> "variable"
else -> if (marker == "(") "function" else "variable"
}
val column = match.range.first + match.value.lastIndexOf(name) + 1
declarations += SourceDeclaration(
name = name,
kind = kind,
location = SourceLocation(path, lineIndex + 1, column),
packageName = packageName,
containerName = containerName,
signature = line.take(180),
)
}
}
val typeMatch = Regex("\\b(class|object|interface|enum|record)\\s+([A-Za-z_][A-Za-z0-9_]*)").find(rawLine)
val opens = rawLine.count { it == '{' }
val closes = rawLine.count { it == '}' }
if (typeMatch != null && opens > 0) {
containers += typeMatch.groupValues[2] to braceDepth
}
braceDepth += opens - closes
if (braceDepth < 0) braceDepth = 0
}
}
return declarations.distinctBy { "${it.name}\u0000${it.location.path}\u0000${it.location.line}\u0000${it.location.column}" }
}
private fun sourceTexts(modules: List<GradleModule>): Map<String, String> {
return modules
.flatMap { it.mainSourceFiles + it.testSourceFiles }
.distinctBy { it.absoluteFile.normalize().path }
.mapNotNull { file ->
val path = file.absoluteFile.normalize().path
val text = runCatching { file.readText() }.getOrNull() ?: return@mapNotNull null
path to text
}
.toMap()
}
private fun generatedSourceRoots(projectDir: File, test: Boolean): List<File> {
val sourceSet = if (test) "test" else "main"
val kotlinSourceSets = if (test) listOf("test", "commonTest", "jvmTest") else listOf("main", "commonMain", "jvmMain")
@ -665,7 +874,7 @@ private fun compileKotlinForDiagnostics(file: File, module: GradleModule, openTe
val sourceFilePath = sourceFile.absoluteFile.normalize().path
val diagnosticSourceRoots = sourceRootsForFile(module, file)
val sourceFiles = collectKotlinAndJavaSources(diagnosticSourceRoots)
val sourceFiles = sourceFilesForFile(module, file)
.filter { it.absoluteFile.normalize().path != originalPath }
.map { it.absoluteFile.normalize().path }
.toMutableList()
@ -756,7 +965,14 @@ private fun compileJavaForDiagnostics(file: File, module: GradleModule, openText
}
private fun collectKotlinSources(module: GradleModule): List<File> {
return collectKotlinSources(module.sourceRoots + module.testSourceRoots)
return (module.mainSourceFiles + module.testSourceFiles)
.filter { it.extension == "kt" || it.extension == "kts" }
}
private fun sourceFilesForFile(module: GradleModule, file: File): List<File> {
val normalizedFile = file.absoluteFile.normalize()
val isTestFile = module.testSourceRoots.any { root -> rootContains(root, normalizedFile) }
return if (isTestFile) module.mainSourceFiles + module.testSourceFiles else module.mainSourceFiles
}
private fun sourcePackageName(text: String, semicolon: Boolean): String? {
@ -796,20 +1012,294 @@ private fun collectWorkspaceKotlinSources(workspace: GradleWorkspace): List<File
private fun collectWorkspaceSourceFiles(workspace: GradleWorkspace): List<File> {
return workspace.modules
.flatMap { module -> collectKotlinAndJavaSources(module.sourceRoots + module.testSourceRoots) }
.flatMap { module -> module.mainSourceFiles + module.testSourceFiles }
.distinctBy { it.absoluteFile.normalize().path }
}
private fun findSimpleDeclaration(workspace: GradleWorkspace, identifier: String): SourceLocation? {
private fun findSimpleDeclaration(workspace: GradleWorkspace, identifier: String, currentPath: String? = null, currentText: String? = null): SourceLocation? {
if (identifier.isBlank() || identifier in allKeywordCompletionItems) return null
for (file in collectWorkspaceSourceFiles(workspace)) {
val text = runCatching { file.readText() }.getOrNull() ?: continue
findSimpleDeclarationInText(file.absoluteFile.normalize().path, text, identifier)?.let { return it }
workspaceDeclaration(workspace, identifier, currentPath, currentText)?.let { return it.location }
for ((path, text) in workspace.sourceTexts) {
findSimpleDeclarationInText(path, text, identifier)?.let { return it }
}
return null
}
@OptIn(KaImplementationDetail::class)
private fun semanticDefinition(
state: DaemonState,
file: File,
module: GradleModule,
normalizedPath: String,
text: String,
requestLine: Int,
requestColumn: Int,
): SourceLocation? {
return withSemanticContext(state, file, module, normalizedPath, text) { context ->
val reference = referenceAt(context.targetFile, text, requestLine, requestColumn) ?: return@withSemanticContext null
val resolvedPsi = resolvedSymbolPsi(reference) ?: return@withSemanticContext null
sourceLocationForPsi(resolvedPsi, context.tempPath, context.originalPath)
}
}
@OptIn(KaImplementationDetail::class)
private fun semanticHover(
state: DaemonState,
file: File,
module: GradleModule,
normalizedPath: String,
text: String,
requestLine: Int,
requestColumn: Int,
): HoverInfo? {
return withSemanticContext(state, file, module, normalizedPath, text) { context ->
val reference = referenceAt(context.targetFile, text, requestLine, requestColumn) ?: return@withSemanticContext null
val resolvedPsi = resolvedSymbolPsi(reference) ?: return@withSemanticContext null
val declaration = PsiTreeUtil.getParentOfType(resolvedPsi, KtDeclaration::class.java, false) ?: resolvedPsi as? KtDeclaration
val headline = declaration?.text?.lineSequence()?.firstOrNull()?.trim()?.take(240) ?: return@withSemanticContext null
val classLikeDeclaration = declaration as? KtClassOrObject
?: PsiTreeUtil.getParentOfType(declaration, KtClassOrObject::class.java, false)
?.takeIf { declaration.name == null || it.name == declaration.name }
val members = classLikeDeclaration?.let { hoverMembersForClass(it) }.orEmpty()
val contents = if (members.isEmpty()) headline else buildString {
appendLine(headline)
appendLine()
appendLine("Members:")
members.forEach { appendLine(" $it") }
}.trimEnd()
HoverInfo(contents, sourceLocationForPsi(resolvedPsi, context.tempPath, context.originalPath))
}
}
private fun hoverMembersForClass(classOrObject: KtClassOrObject): List<String> {
val members = mutableListOf<String>()
if (classOrObject is KtClass) {
classOrObject.primaryConstructorParameters
.asSequence()
.filter { it.hasValOrVar() }
.map { it.text.lineSequence().firstOrNull()?.trim()?.take(160).orEmpty() }
.filter { it.isNotBlank() }
.forEach { members += it }
}
classOrObject.declarations
.asSequence()
.filterIsInstance<KtDeclaration>()
.mapNotNull { member -> member.text.lineSequence().firstOrNull()?.trim()?.take(160) }
.filter { it.isNotBlank() }
.forEach { members += it }
return members.distinct().take(8)
}
@OptIn(KaImplementationDetail::class)
private fun semanticReferences(
state: DaemonState,
file: File,
module: GradleModule,
normalizedPath: String,
text: String,
requestLine: Int,
requestColumn: Int,
): List<SourceLocation>? {
return withSemanticContext(state, file, module, normalizedPath, text) { context ->
val reference = referenceAt(context.targetFile, text, requestLine, requestColumn) ?: return@withSemanticContext null
val targetPsi = resolvedSymbolPsi(reference) ?: return@withSemanticContext null
val name = reference.text
if (name.isBlank() || name in allKeywordCompletionItems) return@withSemanticContext null
val locations = mutableListOf<SourceLocation>()
for (ktFile in context.session.modulesWithFiles.values.flatten().filterIsInstance<KtFile>()) {
val references = PsiTreeUtil.collectElementsOfType(ktFile, KtReferenceExpression::class.java)
for (candidate in references) {
if (candidate.text != name) continue
val candidatePsi = resolvedSymbolPsi(candidate) ?: continue
if (candidatePsi == targetPsi || sourceLocationForPsi(candidatePsi, context.tempPath, context.originalPath) == sourceLocationForPsi(targetPsi, context.tempPath, context.originalPath)) {
val path = ktFile.virtualFilePath.let { if (it == context.tempPath) context.originalPath else it }
val lineColumn = lineColumnForOffset(ktFile.text, candidate.textOffset)
locations += SourceLocation(path, lineColumn.first, lineColumn.second)
}
}
}
locations.distinctBy { "${it.path}\u0000${it.line}\u0000${it.column}" }.ifEmpty { null }
}
}
@OptIn(KaImplementationDetail::class)
private fun <T> withSemanticContext(
state: DaemonState,
file: File,
module: GradleModule,
normalizedPath: String,
text: String,
action: (SemanticContext) -> T?,
): T? {
val key = SemanticCacheKey(
workspaceGeneration = state.workspaceGeneration,
modulePath = module.gradlePath,
path = normalizedPath,
version = state.textVersions[normalizedPath] ?: -1,
textHash = text.hashCode(),
)
return synchronized(state.semanticCacheLock) {
val cached = state.semanticCache[key] ?: run {
val created = createSemanticContext(key, file, module, normalizedPath, text) ?: return@synchronized null
state.semanticCache[key] = created
trimSemanticCache(state)
created
}
cached.lastUsedMillis = System.currentTimeMillis()
action(cached.context)
}
}
@OptIn(KaImplementationDetail::class)
private fun createSemanticContext(
key: SemanticCacheKey,
file: File,
module: GradleModule,
normalizedPath: String,
text: String,
): CachedSemanticContext? {
var tempDir: File? = null
val disposable = Disposer.newDisposable("native-editor-semantic")
return try {
tempDir = Files.createTempDirectory("native-editor-semantic-").toFile()
val packageDir = sourcePackageName(text, semicolon = false)
?.replace('.', File.separatorChar)
?.let { File(tempDir, it) }
?: tempDir
packageDir.mkdirs()
val tempFile = File(packageDir, file.name)
tempFile.writeText(text)
val tempPath = tempFile.absoluteFile.normalize().path
val sourceRoots = sourceRootsForFile(module, file, tempDir)
val libraryRoots = classpathForFile(module, file)
.filter { it.exists() }
.map { it.toPath() }
val session = buildStandaloneAnalysisAPISession(disposable) {
buildKtModuleProvider {
platform = JvmPlatforms.defaultJvmPlatform
val libraryModule = if (libraryRoots.isNotEmpty()) {
addModule(buildKtLibraryModule {
libraryName = "${module.name.ifBlank { "module" }}-classpath"
platform = JvmPlatforms.defaultJvmPlatform
addBinaryRoots(libraryRoots)
})
} else {
null
}
addModule(buildKtSourceModule {
moduleName = module.name.ifBlank { "native-editor" }
platform = JvmPlatforms.defaultJvmPlatform
addSourceRoots(sourceRoots)
if (libraryModule != null) addRegularDependency(libraryModule)
})
}
}
val targetFile = session.modulesWithFiles.values
.flatten()
.filterIsInstance<KtFile>()
.firstOrNull { it.virtualFile.path == tempPath }
?: return null
CachedSemanticContext(key, disposable, tempDir, SemanticContext(session, targetFile, tempPath, normalizedPath)).also {
tempDir = null
}
} catch (_: Throwable) {
null
} finally {
if (tempDir != null) {
Disposer.dispose(disposable)
tempDir?.deleteRecursively()
}
}
}
private const val maxSemanticCacheEntries = 6
private fun trimSemanticCache(state: DaemonState) {
while (state.semanticCache.size > maxSemanticCacheEntries) {
val oldest = state.semanticCache.minByOrNull { it.value.lastUsedMillis } ?: return
state.semanticCache.remove(oldest.key)
oldest.value.dispose()
}
}
private fun referenceAt(targetFile: KtFile, text: String, requestLine: Int, requestColumn: Int): KtReferenceExpression? {
val offset = offsetForLineColumn(text, requestLine, requestColumn)
return PsiTreeUtil.findElementOfClassAtOffset(targetFile, offset, KtReferenceExpression::class.java, false)
?: PsiTreeUtil.findElementOfClassAtOffset(targetFile, (offset - 1).coerceAtLeast(0), KtReferenceExpression::class.java, false)
}
@OptIn(KaImplementationDetail::class)
private fun resolvedSymbolPsi(reference: KtReferenceExpression): PsiElement? {
return analyze(reference) {
reference.references
.asSequence()
.filterIsInstance<KaSymbolBasedReference>()
.flatMap { it.resolveToSymbols().asSequence() }
.firstNotNullOfOrNull { it.psi }
}
}
private fun sourceRootsForFile(module: GradleModule, file: File, tempDir: File): List<Path> {
val roots = linkedSetOf<Path>()
roots.add(tempDir.toPath())
for (root in module.sourceRoots + module.testSourceRoots) {
if (root.exists()) roots.add(root.toPath())
}
val parent = file.parentFile
if (roots.size == 1 && parent != null) roots.add(parent.toPath())
return roots.toList()
}
private fun sourceLocationForPsi(source: PsiElement, tempPath: String, originalPath: String): SourceLocation? {
val declaration = PsiTreeUtil.getParentOfType(source, KtDeclaration::class.java, false) ?: source as? KtDeclaration ?: return null
val ktFile = declaration.containingKtFile
val path = ktFile.virtualFilePath.let { if (it == tempPath) originalPath else it }
val lineColumn = lineColumnForOffset(ktFile.text, declaration.textOffset)
return SourceLocation(path, lineColumn.first, lineColumn.second)
}
private fun workspaceDeclaration(workspace: GradleWorkspace, identifier: String, currentPath: String?, currentText: String?): SourceDeclaration? {
val candidates = workspace.declarations.filter { it.name == identifier }
if (candidates.isEmpty()) return null
if (currentPath == null || currentText == null) return candidates.first()
val currentPackage = sourcePackageName(currentText, semicolon = currentPath.endsWith(".java"))
val exactImports = importedSymbols(currentText)
val wildcardImports = wildcardImports(currentText)
return candidates.maxByOrNull { declaration ->
val qualifiedName = listOfNotNull(declaration.packageName, declaration.containerName, declaration.name).joinToString(".")
when {
qualifiedName in exactImports -> 60
declaration.packageName != null && declaration.packageName in wildcardImports -> 50
declaration.packageName == currentPackage -> 40
declaration.location.path == currentPath -> 30
declaration.packageName == null -> 20
else -> 10
}
}
}
private fun importedSymbols(text: String): Set<String> {
return Regex("(?m)^\\s*import\\s+([A-Za-z_][A-Za-z0-9_.]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)?)\\s*;?\\s*$")
.findAll(text)
.map { it.groupValues[1] }
.filter { !it.endsWith(".*") }
.toSet()
}
private fun wildcardImports(text: String): Set<String> {
return Regex("(?m)^\\s*import\\s+([A-Za-z_][A-Za-z0-9_.]*)\\.\\*\\s*;?\\s*$")
.findAll(text)
.map { it.groupValues[1] }
.toSet()
}
private fun findSimpleDeclarationInText(path: String, text: String, identifier: String): SourceLocation? {
if (identifier.isBlank() || identifier in allKeywordCompletionItems) return null
val escaped = Regex.escape(identifier)
@ -832,10 +1322,8 @@ private fun findSimpleReferences(workspace: GradleWorkspace, openPath: String, o
val locations = mutableListOf<SourceLocation>()
locations += findSimpleReferencesInText(openPath, openText, identifier)
for (file in collectWorkspaceSourceFiles(workspace)) {
val path = file.absoluteFile.normalize().path
for ((path, text) in workspace.sourceTexts) {
if (path == openPath) continue
val text = runCatching { file.readText() }.getOrNull() ?: continue
locations += findSimpleReferencesInText(path, text, identifier)
if (locations.size >= 100) break
}
@ -898,7 +1386,7 @@ private val javaKeywordCompletionItems = listOf(
private val allKeywordCompletionItems = (kotlinKeywordCompletionItems + javaKeywordCompletionItems).distinct()
private fun completionCandidates(prefix: String, currentText: String, workspace: GradleWorkspace?): List<CompletionCandidate> {
private fun completionCandidates(prefix: String, currentText: String, currentPath: String, workspace: GradleWorkspace?): List<CompletionCandidate> {
val normalized = prefix.lowercase()
val candidates = linkedMapOf<String, CompletionCandidate>()
@ -912,8 +1400,14 @@ private fun completionCandidates(prefix: String, currentText: String, workspace:
collectCompletionIdentifiers(currentText, 80).forEach { add(it.first, it.second) }
if (workspace != null && candidates.size < 80) {
for (file in collectWorkspaceSourceFiles(workspace)) {
val text = runCatching { file.readText() }.getOrNull() ?: continue
for (declaration in rankedDeclarations(workspace, currentPath, currentText)) {
add(declaration.name, declaration.kind)
if (candidates.size >= 80) break
}
}
if (workspace != null && candidates.size < 80) {
for (text in workspace.sourceTexts.values) {
collectCompletionIdentifiers(text, 60).forEach { add(it.first, it.second) }
if (candidates.size >= 80) break
}
@ -922,6 +1416,23 @@ private fun completionCandidates(prefix: String, currentText: String, workspace:
return candidates.values.take(80)
}
private fun rankedDeclarations(workspace: GradleWorkspace, currentPath: String, currentText: String): List<SourceDeclaration> {
val currentPackage = sourcePackageName(currentText, semicolon = currentPath.endsWith(".java"))
val exactImports = importedSymbols(currentText)
val wildcardImports = wildcardImports(currentText)
return workspace.declarations.sortedByDescending { declaration ->
val qualifiedName = listOfNotNull(declaration.packageName, declaration.containerName, declaration.name).joinToString(".")
when {
declaration.location.path == currentPath -> 70
qualifiedName in exactImports -> 60
declaration.packageName != null && declaration.packageName in wildcardImports -> 50
declaration.packageName == currentPackage -> 40
declaration.packageName == null -> 20
else -> 10
}
}
}
private fun collectCompletionIdentifiers(text: String, limit: Int): List<Pair<String, String>> {
val result = linkedMapOf<String, String>()
val kotlinDeclaration = Regex("\\b(fun|class|object|interface|val|var|typealias)\\s+([A-Za-z_][A-Za-z0-9_]*)")
@ -1017,17 +1528,24 @@ private fun hoverContents(identifier: String, currentPath: String, currentText:
return hoverDeclarationContents(identifier, currentDeclaration, currentText)
}
if (workspace != null) {
for (file in collectWorkspaceSourceFiles(workspace)) {
val path = file.absoluteFile.normalize().path
if (path == currentPath) continue
val text = runCatching { file.readText() }.getOrNull() ?: continue
val declaration = findSimpleDeclarationInText(path, text, identifier) ?: continue
return hoverDeclarationContents(identifier, declaration, text)
val declaration = workspaceDeclaration(workspace, identifier, currentPath, currentText)
if (declaration != null) {
return hoverDeclarationContents(declaration)
}
}
return "Identifier `$identifier`"
}
private fun hoverDeclarationContents(declaration: SourceDeclaration): String {
val qualifiedName = listOfNotNull(declaration.packageName, declaration.containerName, declaration.name).joinToString(".")
val title = when {
qualifiedName.isNotEmpty() -> "${declaration.kind} `$qualifiedName`"
else -> "${declaration.kind} `${declaration.name}`"
}
val signature = declaration.signature.ifBlank { declaration.name }
return "$title\n$signature\n${declaration.location.path}:${declaration.location.line}:${declaration.location.column}"
}
private fun hoverDeclarationContents(identifier: String, location: SourceLocation, text: String): String {
val declarationLine = sourceLine(text, location.line).trim().ifEmpty { identifier }
return "${declarationLine}\n${location.path}:${location.line}:${location.column}"
@ -1067,6 +1585,11 @@ private fun locationJson(location: SourceLocation): JsonObject = buildJsonObject
put("column", location.column)
}
private fun hoverInfoJson(hover: HoverInfo): JsonObject = buildJsonObject {
put("contents", hover.contents)
hover.definition?.let { put("definition", locationJson(it)) }
}
private fun definitionLocationsJson(location: SourceLocation?): JsonElement = buildJsonArray {
if (location != null) add(locationJson(location))
}
@ -1145,4 +1668,3 @@ private fun JsonObject.stringField(key: String): String? =
private fun JsonObject.intField(key: String): Int? =
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull