j
This commit is contained in:
parent
5b65c4c0e4
commit
985aa16236
13 changed files with 1381 additions and 241 deletions
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue