Skip to content

Commit 8171015

Browse files
committed
(no message)
1 parent 0f99545 commit 8171015

7 files changed

Lines changed: 190 additions & 31 deletions

File tree

backend-intellij/build.gradle.kts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,17 @@ dependencies {
3333
}
3434
}
3535

36+
intellijPlatform {
37+
pluginConfiguration {
38+
version = providers.gradleProperty("VERSION").get()
39+
description =
40+
"PSI-backed Kotlin analysis server plugin that starts a project-scoped Kast backend inside IntelliJ IDEA."
41+
}
42+
}
43+
3644
configurations.named("testRuntimeClasspath") {
3745
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
3846
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core-jvm")
3947
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-test")
4048
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-test-jvm")
4149
}
42-
43-
tasks.named("buildSearchableOptions") {
44-
enabled = false
45-
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
package io.github.amichne.kast.intellij
22

3+
import com.intellij.openapi.application.ApplicationManager
34
import com.intellij.openapi.project.Project
45
import com.intellij.openapi.startup.ProjectActivity
56

67
class KastProjectActivity : ProjectActivity {
78
override suspend fun execute(project: Project) {
9+
if (
10+
ApplicationManager.getApplication().isUnitTestMode &&
11+
!java.lang.Boolean.getBoolean("kast.enable.startup.activity.tests")
12+
) {
13+
return
14+
}
815
project.getService(KastProjectService::class.java).start()
916
}
1017
}

backend-intellij/src/main/kotlin/io/github/amichne/kast/intellij/KastProjectService.kt

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,40 +18,63 @@ class KastProjectService(
1818
) : Disposable {
1919
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
2020
private val started = AtomicBoolean(false)
21+
private val disposed = AtomicBoolean(false)
22+
private val lifecycleLock = Any()
2123

2224
@Volatile
2325
private var server: RunningAnalysisServer? = null
2426

2527
fun start() {
28+
val workspaceRoot = project.basePath ?: return
29+
if (disposed.get()) {
30+
return
31+
}
2632
if (!started.compareAndSet(false, true)) {
2733
return
2834
}
2935

30-
val workspaceRoot = project.basePath ?: return
3136
scope.launch {
32-
val backend = IntelliJAnalysisBackend(
33-
project = project,
34-
limits = ServerLimits(
35-
maxResults = 500,
36-
requestTimeoutMillis = 30_000,
37-
maxConcurrentRequests = 4,
37+
val runningServer = AnalysisServer(
38+
backend = IntelliJAnalysisBackend(
39+
project = project,
40+
limits = ServerLimits(
41+
maxResults = 500,
42+
requestTimeoutMillis = 30_000,
43+
maxConcurrentRequests = 4,
44+
),
3845
),
39-
)
40-
server = AnalysisServer(
41-
backend = backend,
4246
config = AnalysisServerConfig(
4347
host = "127.0.0.1",
4448
port = 0,
4549
maxResults = 500,
4650
maxConcurrentRequests = 4,
4751
),
4852
).start()
49-
println("kast IntelliJ backend started for $workspaceRoot on ${server?.descriptor?.port}")
53+
val port = synchronized(lifecycleLock) {
54+
if (disposed.get()) {
55+
null
56+
} else {
57+
server = runningServer
58+
runningServer.descriptor.port
59+
}
60+
}
61+
if (port == null) {
62+
runningServer.close()
63+
return@launch
64+
}
65+
66+
println("kast IntelliJ backend started for $workspaceRoot on $port")
5067
}
5168
}
5269

5370
override fun dispose() {
54-
server?.close()
71+
disposed.set(true)
72+
val runningServer = synchronized(lifecycleLock) {
73+
server.also {
74+
server = null
75+
}
76+
}
77+
runningServer?.close()
5578
scope.cancel()
5679
}
5780
}

backend-intellij/src/main/resources/META-INF/plugin.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
<depends>com.intellij.modules.platform</depends>
77
<depends>org.jetbrains.kotlin</depends>
88

9+
<extensions defaultExtensionNs="org.jetbrains.kotlin">
10+
<supportsKotlinPluginMode supportsK2="true" />
11+
</extensions>
12+
913
<extensions defaultExtensionNs="com.intellij">
1014
<projectService serviceImplementation="io.github.amichne.kast.intellij.KastProjectService" />
1115
<postStartupActivity implementation="io.github.amichne.kast.intellij.KastProjectActivity" />

backend-intellij/src/test/kotlin/io/github/amichne/kast/intellij/IntelliJAnalysisBackendContractTest.kt

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
11
package io.github.amichne.kast.intellij
22

3+
import io.github.amichne.kast.api.HealthResponse
4+
import io.github.amichne.kast.api.ServerInstanceDescriptor
35
import io.github.amichne.kast.testing.AnalysisBackendContractAssertions
46
import io.github.amichne.kast.testing.AnalysisBackendContractFixture
57
import kotlinx.coroutines.runBlocking
8+
import kotlinx.serialization.json.Json
9+
import org.junit.jupiter.api.Assertions.assertEquals
610
import org.junit.jupiter.api.Test
11+
import org.junit.jupiter.api.io.TempDir
12+
import java.net.URI
13+
import java.net.http.HttpClient
14+
import java.net.http.HttpRequest
15+
import java.net.http.HttpResponse
716
import java.nio.file.Path
17+
import kotlin.io.path.listDirectoryEntries
18+
import kotlin.io.path.notExists
19+
import kotlin.io.path.readText
820

921
class IntelliJAnalysisBackendContractTest : IntelliJFixtureTestCase() {
22+
@TempDir
23+
lateinit var tempHome: Path
24+
1025
@Test
1126
fun `intellij backend satisfies the shared contract fixture`() = runBlocking {
1227
val fixtureProject = createContractFixture()
@@ -29,11 +44,99 @@ class IntelliJAnalysisBackendContractTest : IntelliJFixtureTestCase() {
2944
)
3045
}
3146

47+
@Test
48+
fun `project activity starts a project scoped server and registers a descriptor`() = runBlocking {
49+
val startupProperty = "kast.enable.startup.activity.tests"
50+
val originalUserHome = System.getProperty("user.home")
51+
val originalStartupFlag = System.getProperty(startupProperty)
52+
val descriptorDirectory = tempHome.resolve(".kast/instances")
53+
val projectBasePath = checkNotNull(fixture.project.basePath)
54+
val service = fixture.project.getService(KastProjectService::class.java)
55+
var descriptorPath: Path? = null
56+
57+
System.setProperty("user.home", tempHome.toString())
58+
System.setProperty(startupProperty, "true")
59+
try {
60+
KastProjectActivity().execute(fixture.project)
61+
62+
descriptorPath = waitForCondition("server descriptor") {
63+
if (descriptorDirectory.toFile().isDirectory) {
64+
descriptorDirectory.listDirectoryEntries("*.json").singleOrNull()
65+
} else {
66+
null
67+
}
68+
}
69+
val descriptor = Json.decodeFromString<ServerInstanceDescriptor>(descriptorPath.readText())
70+
71+
assertEquals("intellij", descriptor.backendName)
72+
assertEquals("0.1.0", descriptor.backendVersion)
73+
assertEquals(projectBasePath, descriptor.workspaceRoot)
74+
75+
val healthResponse = waitForCondition("health response") {
76+
fetchHealth(descriptor)
77+
}
78+
assertEquals("ok", healthResponse.status)
79+
assertEquals(descriptor.workspaceRoot, healthResponse.workspaceRoot)
80+
} finally {
81+
service.dispose()
82+
descriptorPath?.let { path ->
83+
waitForCondition("descriptor cleanup") {
84+
if (path.notExists()) true else null
85+
}
86+
}
87+
if (originalStartupFlag == null) {
88+
System.clearProperty(startupProperty)
89+
} else {
90+
System.setProperty(startupProperty, originalStartupFlag)
91+
}
92+
System.setProperty("user.home", originalUserHome)
93+
}
94+
}
95+
3296
private fun createContractFixture(): AnalysisBackendContractFixture {
3397
return AnalysisBackendContractFixture.create(
3498
workspaceRoot = workspaceRoot(),
3599
) { relativePath, content ->
36100
writeWorkspaceFile(relativePath, content)
37101
}
38102
}
103+
104+
private fun fetchHealth(descriptor: ServerInstanceDescriptor): HealthResponse? {
105+
val response = runCatching {
106+
HttpClient.newHttpClient().send(
107+
HttpRequest.newBuilder(
108+
URI.create("http://${descriptor.host}:${descriptor.port}/api/v1/health"),
109+
).GET().build(),
110+
HttpResponse.BodyHandlers.ofString(),
111+
)
112+
}.getOrNull() ?: return null
113+
114+
if (response.statusCode() != 200) {
115+
return null
116+
}
117+
118+
return Json.decodeFromString<HealthResponse>(response.body())
119+
}
120+
121+
private fun <T : Any> waitForCondition(
122+
label: String,
123+
timeoutMillis: Long = 15_000,
124+
pollIntervalMillis: Long = 100,
125+
probe: () -> T?,
126+
): T {
127+
val deadline = System.nanoTime() + timeoutMillis * 1_000_000
128+
while (System.nanoTime() < deadline) {
129+
probe()?.let { return it }
130+
Thread.sleep(pollIntervalMillis)
131+
}
132+
133+
val directoryContents = if (tempHome.toFile().exists()) {
134+
tempHome.toFile().walkTopDown().joinToString(separator = "\n") { it.relativeTo(tempHome.toFile()).path }
135+
} else {
136+
"<temp home missing>"
137+
}
138+
throw AssertionError(
139+
"Timed out waiting for $label under $tempHome. Current contents:\n$directoryContents",
140+
)
141+
}
39142
}

docs/operator-guide.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,22 @@ The current IntelliJ limits are fixed at startup:
6666
- `requestTimeoutMillis = 30000`
6767
- `maxConcurrentRequests = 4`
6868

69-
`DIAGNOSTICS` currently reports parser-level PSI errors only. Semantic
70-
diagnostics and call hierarchy support remain future work.
69+
For repository-side verification of the current IntelliJ `2025.3` and bundled
70+
Kotlin path, run these checks from the repo root:
71+
72+
```bash
73+
./gradlew :backend-intellij:test \
74+
:backend-intellij:verifyPluginStructure \
75+
:backend-intellij:buildSearchableOptions
76+
```
77+
78+
That sequence proves the plugin descriptor structure, searchable-options
79+
generation, and project-scoped server startup path that writes a descriptor and
80+
serves `/api/v1/health` during IntelliJ-hosted tests.
81+
82+
`DIAGNOSTICS` now reports Kotlin semantic diagnostics for Kotlin files and
83+
falls back to PSI parse errors for non-Kotlin PSI. Call hierarchy support
84+
remains future work.
7185

7286
## Standalone host
7387

docs/remaining-work.md

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,20 +78,24 @@ read path is not the hardened nonblocking variant.
7878

7979
## IntelliJ K2 compatibility verification
8080

81-
The plugin packages successfully, but one sandbox verification path still uses a
82-
temporary bypass.
83-
84-
- **Status:** Unverified or partially bypassed
85-
- **Current state:** `buildSearchableOptions` is disabled because the sandbox
86-
IDE rejected the plugin in Kotlin K2 mode
81+
The IntelliJ plugin now has a repeatable verification path for the current
82+
target IDE and bundled Kotlin plugin.
83+
84+
- **Status:** Verified for the current `2025.3` target
85+
- **Current state:** `plugin.xml` declares Kotlin plugin-mode K2 support,
86+
`buildSearchableOptions` succeeds again, and IntelliJ backend tests include a
87+
startup smoke test that starts the project-scoped server, reads its
88+
descriptor, checks `/api/v1/health`, and verifies descriptor cleanup on
89+
shutdown
8790
- **Where:** `backend-intellij/build.gradle.kts`,
88-
`backend-intellij/src/main/resources/META-INF/plugin.xml`
89-
- **Missing:** Explicit Kotlin plugin-mode compatibility metadata or equivalent
90-
configuration that satisfies the 2026.1 sandbox path
91-
- **Impact:** Packaging succeeds, but runtime compatibility in all IDE bootstrap
92-
paths is not fully proven yet
93-
- **Next step:** Add the required Kotlin compatibility declaration, then
94-
re-enable sandbox verification
91+
`backend-intellij/src/main/resources/META-INF/plugin.xml`,
92+
`backend-intellij/src/test/kotlin/io/github/amichne/kast/intellij/IntelliJAnalysisBackendContractTest.kt`
93+
- **Verification commands:** `./gradlew :backend-intellij:test
94+
:backend-intellij:verifyPluginStructure
95+
:backend-intellij:buildSearchableOptions`
96+
- **Notes:** Plugin descriptor version and description now come from the Gradle
97+
IntelliJ plugin configuration so structure verification passes without
98+
editing generated files
9599

96100
## Network hardening
97101

0 commit comments

Comments
 (0)