Skip to content

Commit 6abfa5c

Browse files
authored
Merge pull request #10 from amichne/amichne/complete-issue-7-tasks
2 parents 3ea0ecc + f097919 commit 6abfa5c

13 files changed

Lines changed: 803 additions & 108 deletions

File tree

.claude/settings.local.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"WebSearch",
5+
"Bash(npx:*)"
6+
]
7+
},
8+
"remote": {
9+
"defaultEnvironmentId": "env_01E2wnJX2hrFiDys2iPfiqUy"
10+
}
11+
}

.github/workflows/ci.yml

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
standalone-smoke:
14+
name: Standalone smoke (${{ matrix.os }})
15+
runs-on: ${{ matrix.os }}
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
os:
20+
- ubuntu-latest
21+
- macos-latest
22+
env:
23+
GRADLE_USER_HOME: ${{ runner.temp }}/gradle-home
24+
steps:
25+
- uses: actions/checkout@v5
26+
27+
- uses: actions/setup-java@v5
28+
with:
29+
distribution: temurin
30+
java-version: "21"
31+
32+
- name: Build standalone backend
33+
run: >
34+
./gradlew
35+
:shared-testing:test
36+
:backend-standalone:test
37+
:backend-standalone:fatJar
38+
:backend-standalone:writeWrapperScript
39+
40+
- name: Check standalone artifacts
41+
run: |
42+
set -euo pipefail
43+
python3 - <<'PY'
44+
from pathlib import Path
45+
import zipfile
46+
47+
script = Path("backend-standalone/build/scripts/backend-standalone")
48+
if not script.exists():
49+
raise SystemExit("wrapper script was not created")
50+
if script.stat().st_mode & 0o111 == 0:
51+
raise SystemExit("wrapper script is not executable")
52+
53+
jars = list(Path("backend-standalone/build/libs").glob("*-all.jar"))
54+
if len(jars) != 1:
55+
raise SystemExit(f"expected one standalone fat jar, found {len(jars)}")
56+
57+
with zipfile.ZipFile(jars[0]) as archive:
58+
names = archive.namelist()
59+
if not any(name.endswith("StandaloneMainKt.class") for name in names):
60+
raise SystemExit("standalone fat jar does not include StandaloneMainKt")
61+
PY
62+
63+
- name: Run standalone smoke test
64+
shell: bash
65+
run: |
66+
set -euo pipefail
67+
68+
workspace_dir=$(mktemp -d)
69+
instance_dir=$(mktemp -d)
70+
log_file="$RUNNER_TEMP/kast-standalone.log"
71+
72+
python3 - "$workspace_dir" <<'PY'
73+
from pathlib import Path
74+
import sys
75+
76+
root = Path(sys.argv[1]) / "src/main/kotlin/sample"
77+
root.mkdir(parents=True, exist_ok=True)
78+
(root / "Greeter.kt").write_text(
79+
'package sample\n\nfun greet(name: String): String = "hi $name"\n',
80+
encoding="utf-8",
81+
)
82+
(root / "Use.kt").write_text(
83+
'package sample\n\nfun use(): String = greet("kast")\n',
84+
encoding="utf-8",
85+
)
86+
(root / "SecondaryUse.kt").write_text(
87+
'package sample\n\nfun useAgain(): String = greet("again")\n',
88+
encoding="utf-8",
89+
)
90+
(root / "Broken.kt").write_text(
91+
"package sample\n\nfun broken(): String = missingValue()\n",
92+
encoding="utf-8",
93+
)
94+
PY
95+
96+
KAST_INSTANCE_DIR="$instance_dir" \
97+
./backend-standalone/build/scripts/backend-standalone \
98+
--workspace-root="$workspace_dir" \
99+
>"$log_file" 2>&1 &
100+
pid=$!
101+
102+
cleanup() {
103+
kill "$pid" 2>/dev/null || true
104+
wait "$pid" 2>/dev/null || true
105+
}
106+
trap cleanup EXIT
107+
108+
python3 - "$instance_dir" "$workspace_dir" <<'PY'
109+
import json
110+
import sys
111+
import time
112+
import urllib.request
113+
from pathlib import Path
114+
115+
instance_dir = Path(sys.argv[1])
116+
workspace_dir = Path(sys.argv[2])
117+
118+
descriptor_path = None
119+
for _ in range(120):
120+
matches = sorted(instance_dir.glob("*.json"))
121+
if matches:
122+
descriptor_path = matches[0]
123+
break
124+
time.sleep(1)
125+
if descriptor_path is None:
126+
raise SystemExit("standalone server never wrote a descriptor file")
127+
128+
descriptor = json.loads(descriptor_path.read_text(encoding="utf-8"))
129+
base_url = f"http://{descriptor['host']}:{descriptor['port']}/api/v1"
130+
131+
def get(route: str):
132+
with urllib.request.urlopen(f"{base_url}{route}") as response:
133+
return json.loads(response.read().decode("utf-8"))
134+
135+
def post(route: str, payload: dict):
136+
data = json.dumps(payload).encode("utf-8")
137+
request = urllib.request.Request(
138+
f"{base_url}{route}",
139+
data=data,
140+
headers={"Content-Type": "application/json"},
141+
)
142+
with urllib.request.urlopen(request) as response:
143+
return json.loads(response.read().decode("utf-8"))
144+
145+
use_file = workspace_dir / "src/main/kotlin/sample/Use.kt"
146+
broken_file = workspace_dir / "src/main/kotlin/sample/Broken.kt"
147+
offset = use_file.read_text(encoding="utf-8").index("greet")
148+
149+
health = get("/health")
150+
capabilities = get("/capabilities")
151+
symbol = post(
152+
"/symbol/resolve",
153+
{
154+
"position": {
155+
"filePath": str(use_file),
156+
"offset": offset,
157+
},
158+
},
159+
)
160+
references = post(
161+
"/references",
162+
{
163+
"position": {
164+
"filePath": str(use_file),
165+
"offset": offset,
166+
},
167+
"includeDeclaration": True,
168+
},
169+
)
170+
diagnostics = post(
171+
"/diagnostics",
172+
{
173+
"filePaths": [str(broken_file)],
174+
},
175+
)
176+
rename = post(
177+
"/rename",
178+
{
179+
"position": {
180+
"filePath": str(use_file),
181+
"offset": offset,
182+
},
183+
"newName": "welcome",
184+
},
185+
)
186+
187+
assert health["status"] == "ok"
188+
assert health["backendName"] == "standalone"
189+
assert capabilities["backendName"] == "standalone"
190+
assert "RESOLVE_SYMBOL" in capabilities["readCapabilities"]
191+
assert "FIND_REFERENCES" in capabilities["readCapabilities"]
192+
assert "DIAGNOSTICS" in capabilities["readCapabilities"]
193+
assert "RENAME" in capabilities["mutationCapabilities"]
194+
assert symbol["symbol"]["fqName"] == "sample.greet"
195+
assert symbol["symbol"]["location"]["filePath"].endswith("Greeter.kt")
196+
assert len(references["references"]) == 2
197+
assert references["references"][0]["filePath"].endswith("SecondaryUse.kt")
198+
assert references["references"][1]["filePath"].endswith("Use.kt")
199+
assert diagnostics["diagnostics"], "expected at least one standalone diagnostic"
200+
assert diagnostics["diagnostics"][0]["code"] == "UNRESOLVED_REFERENCE"
201+
assert len(rename["edits"]) == 3
202+
assert sorted(rename["affectedFiles"]) == sorted(
203+
edit["filePath"] for edit in rename["edits"]
204+
)
205+
PY
206+
207+
kill "$pid"
208+
wait "$pid" || true
209+
trap - EXIT
210+
211+
for _ in 1 2 3 4 5; do
212+
if ! find "$instance_dir" -name '*.json' -print -quit | grep -q .; then
213+
break
214+
fi
215+
sleep 1
216+
done
217+
218+
if find "$instance_dir" -name '*.json' -print -quit | grep -q .; then
219+
echo "descriptor file was not removed on shutdown" >&2
220+
cat "$log_file" >&2
221+
exit 1
222+
fi
223+
224+
intellij-tests:
225+
name: IntelliJ tests
226+
runs-on: ubuntu-latest
227+
env:
228+
GRADLE_USER_HOME: ${{ runner.temp }}/gradle-home-test
229+
steps:
230+
- uses: actions/checkout@v5
231+
232+
- uses: actions/setup-java@v5
233+
with:
234+
distribution: temurin
235+
java-version: "21"
236+
237+
- name: Run IntelliJ contract tests
238+
run: ./gradlew :backend-intellij:test --tests '*IntelliJAnalysisBackendContractTest'
239+
240+
intellij-package:
241+
name: IntelliJ package verification
242+
runs-on: ubuntu-latest
243+
env:
244+
GRADLE_USER_HOME: ${{ runner.temp }}/gradle-home-package
245+
steps:
246+
- uses: actions/checkout@v5
247+
248+
- uses: actions/setup-java@v5
249+
with:
250+
distribution: temurin
251+
java-version: "21"
252+
253+
- name: Build and verify IntelliJ plugin
254+
run: >
255+
./gradlew
256+
:backend-intellij:verifyPluginProjectConfiguration
257+
:backend-intellij:buildPlugin
258+
:backend-intellij:verifyPluginStructure
259+
:backend-intellij:verifyPlugin
260+
261+
- name: Check plugin artifact contents
262+
run: |
263+
set -euo pipefail
264+
python3 - <<'PY'
265+
from pathlib import Path
266+
from zipfile import ZipFile
267+
268+
archives = list(Path("backend-intellij/build/distributions").glob("*.zip"))
269+
if len(archives) != 1:
270+
raise SystemExit(f"expected one plugin archive, found {len(archives)}")
271+
272+
with ZipFile(archives[0]) as archive:
273+
names = archive.namelist()
274+
plugin_xml_names = [name for name in names if name.endswith("/META-INF/plugin.xml")]
275+
if len(plugin_xml_names) != 1:
276+
raise SystemExit("plugin archive does not contain exactly one plugin.xml")
277+
plugin_xml = archive.read(plugin_xml_names[0]).decode("utf-8")
278+
if "<id>io.github.amichne.kast</id>" not in plugin_xml:
279+
raise SystemExit("plugin.xml does not contain the expected plugin id")
280+
if not any("/lib/" in name and name.endswith(".jar") for name in names):
281+
raise SystemExit("plugin archive does not contain any library jars")
282+
PY
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package io.github.amichne.kast.intellij
2+
3+
import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase5
4+
import io.github.amichne.kast.api.ServerLimits
5+
import io.github.amichne.kast.testing.AnalysisBackendContractAssertions
6+
import io.github.amichne.kast.testing.AnalysisBackendContractFixture
7+
import kotlinx.coroutines.test.runTest
8+
import org.junit.jupiter.api.Test
9+
import java.nio.file.Path
10+
11+
class IntelliJAnalysisBackendContractTest : LightJavaCodeInsightFixtureTestCase5() {
12+
@Test
13+
fun `intellij backend satisfies the shared contract fixture`() = runTest {
14+
val fixtureProject = createContractFixture()
15+
val backend = createBackend()
16+
17+
AnalysisBackendContractAssertions.assertCommonContract(
18+
backend = backend,
19+
fixture = fixtureProject,
20+
)
21+
}
22+
23+
@Test
24+
fun `intellij diagnostics report fixture syntax errors`() = runTest {
25+
val fixtureProject = createContractFixture()
26+
val backend = createBackend()
27+
28+
AnalysisBackendContractAssertions.assertDiagnostics(
29+
backend = backend,
30+
fixture = fixtureProject,
31+
)
32+
}
33+
34+
private fun createContractFixture(): AnalysisBackendContractFixture {
35+
return AnalysisBackendContractFixture.create(
36+
workspaceRoot = Path.of(fixture.tempDirPath),
37+
) { relativePath, content ->
38+
Path.of(fixture.addFileToProject(relativePath, content).virtualFile.path)
39+
}
40+
}
41+
42+
private fun createBackend(): IntelliJAnalysisBackend = IntelliJAnalysisBackend(
43+
project = project,
44+
limits = ServerLimits(
45+
maxResults = 100,
46+
requestTimeoutMillis = 30_000,
47+
maxConcurrentRequests = 4,
48+
),
49+
)
50+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.github.amichne.kast.standalone
2+
3+
import io.github.amichne.kast.api.ServerLimits
4+
import io.github.amichne.kast.testing.AnalysisBackendContractAssertions
5+
import io.github.amichne.kast.testing.AnalysisBackendContractFixture
6+
import kotlinx.coroutines.test.runTest
7+
import org.junit.jupiter.api.Test
8+
import org.junit.jupiter.api.io.TempDir
9+
import java.nio.file.Path
10+
11+
class StandaloneAnalysisBackendContractTest {
12+
@TempDir
13+
lateinit var workspaceRoot: Path
14+
15+
@Test
16+
fun `standalone backend satisfies the shared contract fixture`() = runTest {
17+
val fixture = AnalysisBackendContractFixture.create(workspaceRoot)
18+
val session = StandaloneAnalysisSession(
19+
workspaceRoot = workspaceRoot,
20+
sourceRoots = emptyList(),
21+
classpathRoots = emptyList(),
22+
moduleName = "sources",
23+
)
24+
try {
25+
val backend = StandaloneAnalysisBackend(
26+
workspaceRoot = workspaceRoot,
27+
limits = ServerLimits(
28+
maxResults = 100,
29+
requestTimeoutMillis = 30_000,
30+
maxConcurrentRequests = 4,
31+
),
32+
session = session,
33+
)
34+
35+
AnalysisBackendContractAssertions.assertCommonContract(
36+
backend = backend,
37+
fixture = fixture,
38+
)
39+
} finally {
40+
session.close()
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)