Skip to content

Commit 155e849

Browse files
authored
Merge pull request #13 from amichne/amichne/issue-4
2 parents 79ffa86 + 34938ed commit 155e849

32 files changed

Lines changed: 799 additions & 511 deletions

File tree

.idea/misc.xml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/modules.xml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,9 @@ The bootstrap and first vertical slice are in place:
2121
- the Gradle build, convention plugins, and module structure exist
2222
- the HTTP server, descriptor file workflow, and edit-application path work
2323
- the IntelliJ backend provides PSI-backed symbol resolution, references,
24-
rename planning, and parser-level diagnostics
25-
- the standalone backend is scaffolded and currently advertises `APPLY_EDITS`
26-
only
24+
rename planning, and Kotlin semantic diagnostics for Kotlin files
25+
- the standalone backend provides symbol resolution, references, diagnostics,
26+
rename planning, and edit application through the shared HTTP contract
2727

28-
The next work is to replace the standalone scaffolding with a full Kotlin
29-
Analysis API implementation and to bring `callHierarchy` online behind
30-
capability gating.
28+
The main remaining work is to bring `callHierarchy` online and harden the
29+
standalone dependency path so it does not depend on the IntelliJ build cache.

analysis-common/build.gradle.kts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// analysis-common provides shared PSI and K2 Analysis API utilities used by both
2+
// backend-intellij and backend-standalone. PSI and K2 Analysis API types are
3+
// provided at runtime by each backend; this module compiles against them as compileOnly.
4+
plugins {
5+
id("kas.kotlin-library")
6+
}
7+
8+
// IJ distribution is populated by building backend-intellij first.
9+
// If absent, run: ./gradlew :backend-intellij:build
10+
private val ideaHomeOrNull: File? = fileTree(gradle.gradleUserHomeDir.resolve("caches/9.0.0/transforms")) {
11+
include("**/transformed/idea-2025.3-*/plugins/Kotlin/lib/kotlin-plugin.jar")
12+
}.files.firstOrNull()?.parentFile?.parentFile?.parentFile?.parentFile
13+
14+
dependencies {
15+
api(project(":analysis-api"))
16+
17+
if (ideaHomeOrNull != null) {
18+
val ideaHome = ideaHomeOrNull
19+
// Full IJ lib directory: provides PsiElement, PsiFile, TextRange, etc.
20+
compileOnly(fileTree(ideaHome.resolve("lib")) {
21+
include("**/*.jar")
22+
exclude("testFramework.jar")
23+
exclude("testFramework-k1.jar")
24+
})
25+
// Kotlin plugin: provides KtFile, KtNamedDeclaration, K2 Analysis API types, etc.
26+
compileOnly(fileTree(ideaHome.resolve("plugins/Kotlin/lib")) {
27+
include("**/*.jar")
28+
exclude("jps/**")
29+
})
30+
// Java plugin: provides PsiMethod, PsiField, PsiClass (for Java-aware symbol mapping).
31+
compileOnly(fileTree(ideaHome.resolve("plugins/java/lib")) { include("**/*.jar") })
32+
}
33+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package io.github.amichne.kast.common
2+
3+
import com.intellij.openapi.util.TextRange
4+
import io.github.amichne.kast.api.Diagnostic
5+
import io.github.amichne.kast.api.DiagnosticSeverity
6+
import org.jetbrains.kotlin.analysis.api.diagnostics.KaDiagnosticWithPsi
7+
import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity
8+
9+
/**
10+
* Converts a K2 Analysis API diagnostic to one or more [Diagnostic] API models.
11+
* Uses the IntelliJ-derived robust range clamping that handles edge cases where
12+
* diagnostic text ranges exceed the PSI element's bounds.
13+
*/
14+
@Suppress("UnstableApiUsage")
15+
fun KaDiagnosticWithPsi<*>.toApiDiagnostics(): List<Diagnostic> {
16+
val ranges = textRanges.ifEmpty { listOf(TextRange(0, psi.textLength)) }
17+
return ranges.map { range ->
18+
Diagnostic(
19+
location = psi.toKastLocation(absoluteRange(range)),
20+
severity = severity.toApiSeverity(),
21+
message = defaultMessage,
22+
code = factoryName,
23+
)
24+
}
25+
}
26+
27+
private fun KaDiagnosticWithPsi<*>.absoluteRange(relativeRange: TextRange): TextRange {
28+
val fileLength = psi.containingFile.textLength
29+
return when {
30+
relativeRange.endOffset <= psi.textLength -> {
31+
val elementStartOffset = psi.textRange.startOffset
32+
TextRange(
33+
elementStartOffset + relativeRange.startOffset,
34+
elementStartOffset + relativeRange.endOffset,
35+
)
36+
}
37+
relativeRange.endOffset <= fileLength -> relativeRange
38+
else -> TextRange(
39+
relativeRange.startOffset.coerceIn(0, fileLength),
40+
relativeRange.endOffset.coerceIn(relativeRange.startOffset.coerceIn(0, fileLength), fileLength),
41+
)
42+
}
43+
}
44+
45+
fun KaSeverity.toApiSeverity(): DiagnosticSeverity = when (this) {
46+
KaSeverity.ERROR -> DiagnosticSeverity.ERROR
47+
KaSeverity.WARNING -> DiagnosticSeverity.WARNING
48+
KaSeverity.INFO -> DiagnosticSeverity.INFO
49+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package io.github.amichne.kast.common
2+
3+
import com.intellij.openapi.util.TextRange
4+
import com.intellij.psi.PsiClass
5+
import com.intellij.psi.PsiElement
6+
import com.intellij.psi.PsiField
7+
import com.intellij.psi.PsiMethod
8+
import com.intellij.psi.PsiNameIdentifierOwner
9+
import com.intellij.psi.PsiNamedElement
10+
import io.github.amichne.kast.api.Location
11+
import io.github.amichne.kast.api.NotFoundException
12+
import io.github.amichne.kast.api.Symbol
13+
import io.github.amichne.kast.api.SymbolKind
14+
import io.github.amichne.kast.api.TextEdit
15+
import org.jetbrains.kotlin.psi.KtClass
16+
import org.jetbrains.kotlin.psi.KtNamedDeclaration
17+
import org.jetbrains.kotlin.psi.KtNamedFunction
18+
import org.jetbrains.kotlin.psi.KtObjectDeclaration
19+
import org.jetbrains.kotlin.psi.KtParameter
20+
import org.jetbrains.kotlin.psi.KtProperty
21+
22+
/**
23+
* Walks the PSI element hierarchy up from [offset] until it finds a resolvable reference
24+
* or a named element, then returns it. Works in both IntelliJ-hosted and standalone modes.
25+
*/
26+
fun resolveTarget(file: com.intellij.psi.PsiFile, offset: Int): PsiElement {
27+
val leaf = file.findElementAt(offset)
28+
?: throw NotFoundException(
29+
message = "No PSI element was found at the requested offset",
30+
details = mapOf("offset" to offset.toString()),
31+
)
32+
33+
generateSequence(leaf as PsiElement?) { it.parent }.forEach { element ->
34+
element.references.firstNotNullOfOrNull { it.resolve() }?.let { return it }
35+
36+
if (element is PsiNamedElement && !element.name.isNullOrBlank()) {
37+
return element
38+
}
39+
}
40+
41+
throw NotFoundException("No resolvable symbol was found at the requested offset")
42+
}
43+
44+
fun PsiElement.toSymbolModel(containingDeclaration: String?): Symbol = Symbol(
45+
fqName = fqName(),
46+
kind = kind(),
47+
location = toKastLocation(nameRange()),
48+
type = typeDescription(),
49+
containingDeclaration = containingDeclaration,
50+
)
51+
52+
fun PsiElement.nameRange(): TextRange = when (this) {
53+
is KtNamedDeclaration -> nameIdentifier?.textRange ?: textRange
54+
is PsiNameIdentifierOwner -> nameIdentifier?.textRange ?: textRange
55+
else -> textRange
56+
}
57+
58+
fun PsiElement.declarationEdit(newName: String): TextEdit {
59+
val range = nameRange()
60+
return TextEdit(
61+
filePath = containingFile.virtualFile.path,
62+
startOffset = range.startOffset,
63+
endOffset = range.endOffset,
64+
newText = newName,
65+
)
66+
}
67+
68+
fun PsiElement.fqName(): String = when (this) {
69+
is KtNamedDeclaration -> fqName?.asString() ?: name ?: "<anonymous>"
70+
is PsiClass -> qualifiedName ?: name ?: "<anonymous>"
71+
is PsiMethod -> "${containingClass?.qualifiedName ?: "<local>"}#$name"
72+
is PsiField -> "${containingClass?.qualifiedName ?: "<local>"}.$name"
73+
is PsiNamedElement -> name ?: "<anonymous>"
74+
else -> text
75+
}
76+
77+
fun PsiElement.kind(): SymbolKind = when (this) {
78+
is KtClass -> if (isInterface()) SymbolKind.INTERFACE else SymbolKind.CLASS
79+
is KtObjectDeclaration -> SymbolKind.OBJECT
80+
is KtNamedFunction -> SymbolKind.FUNCTION
81+
is KtProperty -> SymbolKind.PROPERTY
82+
is KtParameter -> SymbolKind.PARAMETER
83+
is PsiClass -> if (isInterface) SymbolKind.INTERFACE else SymbolKind.CLASS
84+
is PsiMethod -> SymbolKind.FUNCTION
85+
is PsiField -> SymbolKind.PROPERTY
86+
else -> SymbolKind.UNKNOWN
87+
}
88+
89+
fun PsiElement.typeDescription(): String? = when (this) {
90+
is KtNamedFunction -> typeReference?.text
91+
is KtProperty -> typeReference?.text
92+
is KtParameter -> typeReference?.text
93+
is PsiMethod -> returnType?.presentableText
94+
is PsiField -> type.presentableText
95+
else -> null
96+
}
97+
98+
/**
99+
* Converts a PSI element and text range to a [Location] using raw file text.
100+
* Works in both IntelliJ-hosted and standalone modes without requiring a Document.
101+
*/
102+
fun PsiElement.toKastLocation(range: TextRange = nameRange()): Location {
103+
val content = containingFile.text
104+
val startOffset = range.startOffset.coerceIn(0, content.length)
105+
val endOffset = range.endOffset.coerceIn(startOffset, content.length)
106+
val lineStart = content.lastIndexOf('\n', startOffset - 1).let { if (it == -1) 0 else it + 1 }
107+
val lineEnd = content.indexOf('\n', startOffset).let { if (it == -1) content.length else it }
108+
109+
return Location(
110+
filePath = containingFile.virtualFile?.path
111+
?: containingFile.viewProvider.virtualFile.path,
112+
startOffset = startOffset,
113+
endOffset = endOffset,
114+
startLine = content.take(startOffset).count { it == '\n' } + 1,
115+
startColumn = startOffset - lineStart + 1,
116+
preview = content.substring(lineStart, lineEnd).trimEnd(),
117+
)
118+
}

analysis-server/build.gradle.kts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
plugins {
2-
id("kas.ktor-service")
2+
id("kas.kotlin-library")
33
}
44

55
dependencies {
66
api(project(":analysis-api"))
7+
implementation(libs.bundles.ktor.server)
78
implementation(libs.coroutines.core)
89
implementation(libs.serialization.json)
10+
implementation(libs.slf4j.api)
11+
testImplementation(libs.ktor.server.test.host)
912
testImplementation(project(":shared-testing"))
1013
}

analysis-server/src/main/kotlin/io/github/amichne/kast/server/AnalysisServerConfig.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,19 @@ data class AnalysisServerConfig(
1111
val maxResults: Int = 500,
1212
val maxConcurrentRequests: Int = 4,
1313
val descriptorDirectory: Path = defaultDescriptorDirectory(),
14-
)
14+
) {
15+
init {
16+
validate()
17+
}
18+
19+
private fun validate() {
20+
val isLoopback = host == "127.0.0.1" || host == "::1" || host.equals("localhost", ignoreCase = true)
21+
require(isLoopback || !token.isNullOrBlank()) {
22+
"Binding to non-loopback address '$host' requires a non-empty token for security. " +
23+
"Set the 'token' field or bind to 127.0.0.1 / ::1 / localhost instead."
24+
}
25+
}
26+
}
1527

1628
fun defaultDescriptorDirectory(): Path = System.getenv("KAST_INSTANCE_DIR")
1729
?.let(::Path)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.github.amichne.kast.server
2+
3+
import org.junit.jupiter.api.Test
4+
import org.junit.jupiter.api.assertDoesNotThrow
5+
import org.junit.jupiter.api.assertThrows
6+
7+
class AnalysisServerConfigTest {
8+
9+
@Test
10+
fun `loopback host without token is accepted`() {
11+
assertDoesNotThrow { AnalysisServerConfig(host = "127.0.0.1", token = null) }
12+
assertDoesNotThrow { AnalysisServerConfig(host = "::1", token = null) }
13+
assertDoesNotThrow { AnalysisServerConfig(host = "localhost", token = null) }
14+
assertDoesNotThrow { AnalysisServerConfig(host = "LOCALHOST", token = null) }
15+
}
16+
17+
@Test
18+
fun `loopback host with token is accepted`() {
19+
assertDoesNotThrow { AnalysisServerConfig(host = "127.0.0.1", token = "secret") }
20+
}
21+
22+
@Test
23+
fun `non-loopback host with token is accepted`() {
24+
assertDoesNotThrow { AnalysisServerConfig(host = "0.0.0.0", token = "secret") }
25+
assertDoesNotThrow { AnalysisServerConfig(host = "192.168.1.10", token = "t") }
26+
}
27+
28+
@Test
29+
fun `non-loopback host without token is rejected`() {
30+
assertThrows<IllegalArgumentException> { AnalysisServerConfig(host = "0.0.0.0", token = null) }
31+
}
32+
33+
@Test
34+
fun `non-loopback host with blank token is rejected`() {
35+
assertThrows<IllegalArgumentException> { AnalysisServerConfig(host = "0.0.0.0", token = "") }
36+
assertThrows<IllegalArgumentException> { AnalysisServerConfig(host = "0.0.0.0", token = " ") }
37+
}
38+
39+
@Test
40+
fun `default config binds to loopback`() {
41+
assertDoesNotThrow { AnalysisServerConfig() }
42+
}
43+
}

backend-intellij/build.gradle.kts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,16 @@ repositories {
1515

1616
dependencies {
1717
implementation(project(":analysis-api"))
18+
implementation(project(":analysis-common"))
1819
implementation(project(":analysis-server"))
1920
testImplementation(project(":shared-testing"))
20-
testImplementation("junit:junit:4.13.2")
21-
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.3")
22-
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.3")
23-
testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.3")
21+
testImplementation(libs.junit4)
22+
testImplementation(libs.junit.jupiter.api)
23+
testRuntimeOnly(libs.junit.jupiter.engine)
24+
testRuntimeOnly(libs.junit.platform.launcher)
2425

2526
intellijPlatform {
26-
intellijIdea("2025.3")
27+
intellijIdea(libs.versions.intellij.idea.get())
2728
bundledPlugin("com.intellij.java")
2829
bundledPlugin("org.jetbrains.kotlin")
2930
testFramework(TestFrameworkType.Platform)
@@ -33,13 +34,17 @@ dependencies {
3334
}
3435
}
3536

37+
intellijPlatform {
38+
pluginConfiguration {
39+
version = providers.gradleProperty("VERSION").get()
40+
description =
41+
"PSI-backed Kotlin analysis server plugin that starts a project-scoped Kast backend inside IntelliJ IDEA."
42+
}
43+
}
44+
3645
configurations.named("testRuntimeClasspath") {
3746
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
3847
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core-jvm")
3948
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-test")
4049
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-test-jvm")
4150
}
42-
43-
tasks.named("buildSearchableOptions") {
44-
enabled = false
45-
}

0 commit comments

Comments
 (0)