Replace hand-rolled JSON protocol parsing with real JSON

Daemon: parse requests with kotlinx.serialization and read fields from
the params object instead of scanning the whole line for key markers,
which picked up decoy keys ("path", "version", "id") occurring inside
buffer text. Build all responses and events with JsonObject builders,
which also fixes unescaped control characters in output and \uXXXX
escapes in input. Malformed lines now get a BAD_REQUEST reply instead
of corrupting field extraction.

Client: quote outgoing JSON strings with a strict json_quote helper
instead of Odin's %q verb, whose \x/\e escapes are not valid JSON.

Verified end to end: adversarial payloads (decoy protocol keys,
unicode, control chars, malformed lines) against the daemon, and the
full client smoke flow (ids 1-10) against a real Gradle workspace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pavel 2026-07-03 09:04:50 +02:00
commit 8b1723ce31
5 changed files with 245 additions and 256 deletions

View file

@ -10,6 +10,37 @@ import "core:thread"
import "core:time" import "core:time"
import json "core:encoding/json" import json "core:encoding/json"
// Odin's %q verb emits \x/\e escapes that are not valid JSON, so outgoing
// strings are quoted here instead. Control characters become \u00XX.
json_quote :: proc(s: string, allocator := context.temp_allocator) -> string {
builder: strings.Builder
strings.builder_init(&builder, allocator)
strings.write_byte(&builder, '"')
for i := 0; i < len(s); i += 1 {
c := s[i]
switch c {
case '"':
strings.write_string(&builder, "\\\"")
case '\\':
strings.write_string(&builder, "\\\\")
case '\n':
strings.write_string(&builder, "\\n")
case '\r':
strings.write_string(&builder, "\\r")
case '\t':
strings.write_string(&builder, "\\t")
case:
if c < 0x20 {
fmt.sbprintf(&builder, "\\u%04x", c)
} else {
strings.write_byte(&builder, c)
}
}
}
strings.write_byte(&builder, '"')
return strings.to_string(builder)
}
Protocol_Message_Kind :: enum { Protocol_Message_Kind :: enum {
Invalid, Invalid,
Response, Response,
@ -264,7 +295,7 @@ daemon_send_workspace_open :: proc(client: ^Daemon_Client, workspace: string) {
if !client.connected do return if !client.connected do return
id := client.next_id id := client.next_id
client.next_id += 1 client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"workspace/open\",\"params\":{{\"root\":%q}}}}\n", id, workspace) request := fmt.tprintf("{{\"id\":%d,\"method\":\"workspace/open\",\"params\":{{\"root\":%s}}}}\n", id, json_quote(workspace))
daemon_send(client, request) daemon_send(client, request)
} }
@ -272,7 +303,7 @@ daemon_send_text_open :: proc(client: ^Daemon_Client, path: string, version: int
if !client.connected do return if !client.connected do return
id := client.next_id id := client.next_id
client.next_id += 1 client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"text/open\",\"params\":{{\"path\":%q,\"version\":%d,\"text\":%q}}}}\n", id, path, version, text) request := fmt.tprintf("{{\"id\":%d,\"method\":\"text/open\",\"params\":{{\"path\":%s,\"version\":%d,\"text\":%s}}}}\n", id, json_quote(path), version, json_quote(text))
daemon_send(client, request) daemon_send(client, request)
} }
@ -280,7 +311,7 @@ daemon_send_text_change :: proc(client: ^Daemon_Client, path: string, version: i
if !client.connected do return if !client.connected do return
id := client.next_id id := client.next_id
client.next_id += 1 client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"text/change\",\"params\":{{\"path\":%q,\"version\":%d,\"text\":%q}}}}\n", id, path, version, text) request := fmt.tprintf("{{\"id\":%d,\"method\":\"text/change\",\"params\":{{\"path\":%s,\"version\":%d,\"text\":%s}}}}\n", id, json_quote(path), version, json_quote(text))
daemon_send(client, request) daemon_send(client, request)
} }
@ -288,7 +319,7 @@ daemon_send_completion :: proc(client: ^Daemon_Client, path: string, line, colum
if !client.connected do return 0 if !client.connected do return 0
id := client.next_id id := client.next_id
client.next_id += 1 client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/completion\",\"params\":{{\"path\":%q,\"line\":%d,\"column\":%d}}}}\n", id, path, line + 1, column + 1) request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/completion\",\"params\":{{\"path\":%s,\"line\":%d,\"column\":%d}}}}\n", id, json_quote(path), line + 1, column + 1)
daemon_track_response(client, id) daemon_track_response(client, id)
daemon_send(client, request) daemon_send(client, request)
return id return id
@ -298,7 +329,7 @@ daemon_send_hover :: proc(client: ^Daemon_Client, path: string, line, column: in
if !client.connected do return 0 if !client.connected do return 0
id := client.next_id id := client.next_id
client.next_id += 1 client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/hover\",\"params\":{{\"path\":%q,\"line\":%d,\"column\":%d}}}}\n", id, path, line + 1, column + 1) request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/hover\",\"params\":{{\"path\":%s,\"line\":%d,\"column\":%d}}}}\n", id, json_quote(path), line + 1, column + 1)
daemon_track_response(client, id) daemon_track_response(client, id)
daemon_send(client, request) daemon_send(client, request)
return id return id
@ -308,7 +339,7 @@ daemon_send_definition :: proc(client: ^Daemon_Client, path: string, line, colum
if !client.connected do return 0 if !client.connected do return 0
id := client.next_id id := client.next_id
client.next_id += 1 client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/definition\",\"params\":{{\"path\":%q,\"line\":%d,\"column\":%d}}}}\n", id, path, line + 1, column + 1) request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/definition\",\"params\":{{\"path\":%s,\"line\":%d,\"column\":%d}}}}\n", id, json_quote(path), line + 1, column + 1)
daemon_track_response(client, id) daemon_track_response(client, id)
daemon_send(client, request) daemon_send(client, request)
return id return id
@ -318,7 +349,7 @@ daemon_send_references :: proc(client: ^Daemon_Client, path: string, line, colum
if !client.connected do return 0 if !client.connected do return 0
id := client.next_id id := client.next_id
client.next_id += 1 client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/references\",\"params\":{{\"path\":%q,\"line\":%d,\"column\":%d}}}}\n", id, path, line + 1, column + 1) request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/references\",\"params\":{{\"path\":%s,\"line\":%d,\"column\":%d}}}}\n", id, json_quote(path), line + 1, column + 1)
daemon_track_response(client, id) daemon_track_response(client, id)
daemon_send(client, request) daemon_send(client, request)
return id return id
@ -328,7 +359,7 @@ daemon_send_rename :: proc(client: ^Daemon_Client, path: string, line, column: i
if !client.connected do return 0 if !client.connected do return 0
id := client.next_id id := client.next_id
client.next_id += 1 client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/rename\",\"params\":{{\"path\":%q,\"line\":%d,\"column\":%d,\"newName\":%q}}}}\n", id, path, line + 1, column + 1, new_name) request := fmt.tprintf("{{\"id\":%d,\"method\":\"kotlin/rename\",\"params\":{{\"path\":%s,\"line\":%d,\"column\":%d,\"newName\":%s}}}}\n", id, json_quote(path), line + 1, column + 1, json_quote(new_name))
daemon_track_response(client, id) daemon_track_response(client, id)
daemon_send(client, request) daemon_send(client, request)
return id return id
@ -348,7 +379,7 @@ daemon_send_gradle_run :: proc(client: ^Daemon_Client, task: string) -> int {
if !client.connected do return 0 if !client.connected do return 0
id := client.next_id id := client.next_id
client.next_id += 1 client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"gradle/run\",\"params\":{{\"task\":%q}}}}\n", id, task) request := fmt.tprintf("{{\"id\":%d,\"method\":\"gradle/run\",\"params\":{{\"task\":%s}}}}\n", id, json_quote(task))
daemon_track_response(client, id) daemon_track_response(client, id)
daemon_send(client, request) daemon_send(client, request)
return id return id

View file

@ -60,7 +60,7 @@ main :: proc() {
send_line(socket, "{\"id\":1,\"method\":\"ping\",\"params\":{}}\n") send_line(socket, "{\"id\":1,\"method\":\"ping\",\"params\":{}}\n")
read_messages(socket, 1) read_messages(socket, 1)
open_workspace := fmt.tprintf("{{\"id\":2,\"method\":\"workspace/open\",\"params\":{{\"root\":%q}}}}\n", workspace) open_workspace := fmt.tprintf("{{\"id\":2,\"method\":\"workspace/open\",\"params\":{{\"root\":%s}}}}\n", json_quote(workspace))
send_line(socket, open_workspace) send_line(socket, open_workspace)
read_messages(socket, 3) read_messages(socket, 3)
@ -72,31 +72,31 @@ main :: proc() {
active_text := editor_active_text(&editor) active_text := editor_active_text(&editor)
defer delete(active_text) defer delete(active_text)
text_change_request := fmt.tprintf("{{\"id\":4,\"method\":\"text/change\",\"params\":{{\"path\":%q,\"version\":1,\"text\":%q}}}}\n", diagnostics_path, string(active_text[:])) text_change_request := fmt.tprintf("{{\"id\":4,\"method\":\"text/change\",\"params\":{{\"path\":%s,\"version\":1,\"text\":%s}}}}\n", json_quote(diagnostics_path), json_quote(string(active_text[:])))
send_line(socket, text_change_request) send_line(socket, text_change_request)
read_messages(socket, 2) read_messages(socket, 2)
diagnostics_request := fmt.tprintf("{{\"id\":5,\"method\":\"kotlin/diagnostics\",\"params\":{{\"path\":%q}}}}\n", diagnostics_path) diagnostics_request := fmt.tprintf("{{\"id\":5,\"method\":\"kotlin/diagnostics\",\"params\":{{\"path\":%s}}}}\n", json_quote(diagnostics_path))
send_line(socket, diagnostics_request) send_line(socket, diagnostics_request)
read_messages(socket, 1) read_messages(socket, 1)
completion_request := fmt.tprintf("{{\"id\":6,\"method\":\"kotlin/completion\",\"params\":{{\"path\":%q,\"line\":1,\"column\":1}}}}\n", diagnostics_path) completion_request := fmt.tprintf("{{\"id\":6,\"method\":\"kotlin/completion\",\"params\":{{\"path\":%s,\"line\":1,\"column\":1}}}}\n", json_quote(diagnostics_path))
send_line(socket, completion_request) send_line(socket, completion_request)
read_messages(socket, 1) read_messages(socket, 1)
hover_request := fmt.tprintf("{{\"id\":7,\"method\":\"kotlin/hover\",\"params\":{{\"path\":%q,\"line\":1,\"column\":1}}}}\n", diagnostics_path) hover_request := fmt.tprintf("{{\"id\":7,\"method\":\"kotlin/hover\",\"params\":{{\"path\":%s,\"line\":1,\"column\":1}}}}\n", json_quote(diagnostics_path))
send_line(socket, hover_request) send_line(socket, hover_request)
read_messages(socket, 1) read_messages(socket, 1)
definition_request := fmt.tprintf("{{\"id\":8,\"method\":\"kotlin/definition\",\"params\":{{\"path\":%q,\"line\":1,\"column\":5}}}}\n", diagnostics_path) definition_request := fmt.tprintf("{{\"id\":8,\"method\":\"kotlin/definition\",\"params\":{{\"path\":%s,\"line\":1,\"column\":5}}}}\n", json_quote(diagnostics_path))
send_line(socket, definition_request) send_line(socket, definition_request)
read_messages(socket, 1) read_messages(socket, 1)
references_request := fmt.tprintf("{{\"id\":9,\"method\":\"kotlin/references\",\"params\":{{\"path\":%q,\"line\":1,\"column\":5}}}}\n", diagnostics_path) references_request := fmt.tprintf("{{\"id\":9,\"method\":\"kotlin/references\",\"params\":{{\"path\":%s,\"line\":1,\"column\":5}}}}\n", json_quote(diagnostics_path))
send_line(socket, references_request) send_line(socket, references_request)
read_messages(socket, 1) read_messages(socket, 1)
rename_request := fmt.tprintf("{{\"id\":10,\"method\":\"kotlin/rename\",\"params\":{{\"path\":%q,\"line\":1,\"column\":5,\"newName\":\"renamedBroken\"}}}}\n", diagnostics_path) rename_request := fmt.tprintf("{{\"id\":10,\"method\":\"kotlin/rename\",\"params\":{{\"path\":%s,\"line\":1,\"column\":5,\"newName\":\"renamedBroken\"}}}}\n", json_quote(diagnostics_path))
send_line(socket, rename_request) send_line(socket, rename_request)
read_messages(socket, 1) read_messages(socket, 1)
} }

View file

@ -298,12 +298,12 @@ ui_state_save :: proc(view: ^SDL_View) {
append(&open_files_json, '[') append(&open_files_json, '[')
for file, index in view.saved_open_files { for file, index in view.saved_open_files {
if index > 0 do append(&open_files_json, ',') if index > 0 do append(&open_files_json, ',')
item := fmt.tprintf("{\"path\":%q,\"cursor\":%d}", file.path, file.cursor) item := fmt.tprintf("{\"path\":%s,\"cursor\":%d}", json_quote(file.path), file.cursor)
for b in transmute([]u8)item do append(&open_files_json, b) for b in transmute([]u8)item do append(&open_files_json, b)
} }
append(&open_files_json, ']') append(&open_files_json, ']')
text := fmt.tprintf("{\n \"windowWidth\": %d,\n \"windowHeight\": %d,\n \"sidebarWidth\": %d,\n \"rightSidebarWidth\": %d,\n \"explorerVisible\": %v,\n \"gradleSidebarVisible\": %v,\n \"workspace\": %q,\n \"activeFile\": %d,\n \"openFiles\": %s\n}\n", view.window_width, view.window_height, view.sidebar_width, view.right_sidebar_width, view.explorer_visible, view.gradle_sidebar_visible, view.cached_workspace, view.saved_active_file, string(open_files_json[:])) text := fmt.tprintf("{\n \"windowWidth\": %d,\n \"windowHeight\": %d,\n \"sidebarWidth\": %d,\n \"rightSidebarWidth\": %d,\n \"explorerVisible\": %v,\n \"gradleSidebarVisible\": %v,\n \"workspace\": %s,\n \"activeFile\": %d,\n \"openFiles\": %s\n}\n", view.window_width, view.window_height, view.sidebar_width, view.right_sidebar_width, view.explorer_visible, view.gradle_sidebar_visible, json_quote(view.cached_workspace), view.saved_active_file, string(open_files_json[:]))
_ = os.write_entire_file(path, transmute([]byte)text) _ = os.write_entire_file(path, transmute([]byte)text)
} }

View file

@ -15,6 +15,7 @@ kotlin {
dependencies { dependencies {
implementation(gradleApi()) implementation(gradleApi())
implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.21") implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.21")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
} }
application { application {

View file

@ -18,6 +18,18 @@ import javax.tools.JavaFileObject
import javax.tools.StandardLocation import javax.tools.StandardLocation
import javax.tools.ToolProvider import javax.tools.ToolProvider
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import org.gradle.tooling.GradleConnector import org.gradle.tooling.GradleConnector
import org.gradle.tooling.model.GradleProject import org.gradle.tooling.model.GradleProject
import org.gradle.tooling.model.idea.IdeaProject import org.gradle.tooling.model.idea.IdeaProject
@ -107,35 +119,37 @@ private class ClientSession(
val line = reader.readLine() ?: break val line = reader.readLine() ?: break
if (line.isBlank()) continue if (line.isBlank()) continue
val id = extractJsonInt(line, "id") val request = runCatching { Json.parseToJsonElement(line).jsonObject }.getOrNull()
val method = extractJsonString(line, "method") val id = request?.intField("id")
if (id == null || method == null) { val method = request?.stringField("method")
if (request == null || id == null || method == null) {
writeLine(writer, errorJson(id ?: 0, "BAD_REQUEST", "Request needs numeric id and string method")) writeLine(writer, errorJson(id ?: 0, "BAD_REQUEST", "Request needs numeric id and string method"))
continue continue
} }
val params = request["params"] as? JsonObject ?: JsonObject(emptyMap())
when (method) { when (method) {
"ping" -> writeLine(writer, okJson(id, "{\"pong\":true}")) "ping" -> writeLine(writer, okJson(id, buildJsonObject { put("pong", true) }))
"workspace/open" -> openWorkspace(writer, id, line) "workspace/open" -> openWorkspace(writer, id, params)
"workspace/close" -> closeWorkspace(writer, id) "workspace/close" -> closeWorkspace(writer, id)
"text/open", "text/change" -> updateText(writer, id, line) "text/open", "text/change" -> updateText(writer, id, params)
"text/close" -> closeText(writer, id, line) "text/close" -> closeText(writer, id, params)
"kotlin/diagnostics" -> kotlinDiagnostics(writer, id, line) "kotlin/diagnostics" -> kotlinDiagnostics(writer, id, params)
"kotlin/completion" -> kotlinCompletion(writer, id, line) "kotlin/completion" -> kotlinCompletion(writer, id, params)
"kotlin/definition" -> kotlinDefinition(writer, id, line) "kotlin/definition" -> kotlinDefinition(writer, id, params)
"kotlin/references" -> kotlinReferences(writer, id, line) "kotlin/references" -> kotlinReferences(writer, id, params)
"kotlin/rename" -> kotlinRename(writer, id, line) "kotlin/rename" -> kotlinRename(writer, id, params)
"kotlin/hover" -> kotlinHover(writer, id, line) "kotlin/hover" -> kotlinHover(writer, id, params)
"gradle/tasks" -> writeLine(writer, okJson(id, gradleTasksJson())) "gradle/tasks" -> writeLine(writer, okJson(id, gradleTasksJson()))
"gradle/run" -> runGradle(writer, id, line) "gradle/run" -> runGradle(writer, id, params)
else -> writeLine(writer, errorJson(id, "UNKNOWN_METHOD", "No handler for $method")) else -> writeLine(writer, errorJson(id, "UNKNOWN_METHOD", "No handler for $method"))
} }
} }
} }
} }
private fun openWorkspace(writer: BufferedWriter, id: Int, line: String) { private fun openWorkspace(writer: BufferedWriter, id: Int, params: JsonObject) {
val rootText = extractJsonString(line, "root") val rootText = params.stringField("root")
if (rootText == null) { if (rootText == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "workspace/open needs params.root")) writeLine(writer, errorJson(id, "BAD_REQUEST", "workspace/open needs params.root"))
return return
@ -143,24 +157,24 @@ private class ClientSession(
val root = File(rootText).absoluteFile.normalize() val root = File(rootText).absoluteFile.normalize()
if (!root.isDirectory) { if (!root.isDirectory) {
writeLine(writer, errorJson(id, "NO_SUCH_WORKSPACE", "Workspace root does not exist: ${escapeJson(root.path)}")) writeLine(writer, errorJson(id, "NO_SUCH_WORKSPACE", "Workspace root does not exist: ${root.path}"))
return return
} }
state.workspaceRoot = root state.workspaceRoot = root
writeLine(writer, eventJson("workspace/indexing", "{\"state\":\"started\"}")) writeLine(writer, eventJson("workspace/indexing", indexingStateJson("started")))
val gradleWorkspace = try { val gradleWorkspace = try {
importGradleWorkspace(root) importGradleWorkspace(root)
} catch (t: Throwable) { } catch (t: Throwable) {
writeLine(writer, eventJson("workspace/indexing", "{\"state\":\"failed\"}")) writeLine(writer, eventJson("workspace/indexing", indexingStateJson("failed")))
writeLine(writer, errorJson(id, "GRADLE_IMPORT_FAILED", t.message ?: t.javaClass.name)) writeLine(writer, errorJson(id, "GRADLE_IMPORT_FAILED", t.message ?: t.javaClass.name))
return return
} }
state.gradleWorkspace = gradleWorkspace state.gradleWorkspace = gradleWorkspace
writeLine(writer, okJson(id, workspaceJson(gradleWorkspace))) writeLine(writer, okJson(id, workspaceJson(gradleWorkspace)))
writeLine(writer, eventJson("workspace/indexing", "{\"state\":\"idle\"}")) writeLine(writer, eventJson("workspace/indexing", indexingStateJson("idle")))
} }
private fun closeWorkspace(writer: BufferedWriter, id: Int) { private fun closeWorkspace(writer: BufferedWriter, id: Int) {
@ -169,38 +183,41 @@ private class ClientSession(
state.openTexts.clear() state.openTexts.clear()
state.textVersions.clear() state.textVersions.clear()
state.diagnosticsVersions.clear() state.diagnosticsVersions.clear()
writeLine(writer, okJson(id, "{\"closed\":true}")) writeLine(writer, okJson(id, buildJsonObject { put("closed", true) }))
} }
private fun updateText(writer: BufferedWriter, id: Int, line: String) { private fun updateText(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = extractJsonString(line, "path") val path = params.stringField("path")
if (path == null) { if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "text update needs params.path")) writeLine(writer, errorJson(id, "BAD_REQUEST", "text update needs params.path"))
return return
} }
val normalizedPath = normalizedPath(path) val normalizedPath = normalizedPath(path)
val version = extractJsonInt(line, "version") ?: 0 val version = params.intField("version") ?: 0
state.openTexts[normalizedPath] = extractJsonString(line, "text") ?: "" state.openTexts[normalizedPath] = params.stringField("text") ?: ""
state.textVersions[normalizedPath] = version state.textVersions[normalizedPath] = version
writeLine(writer, okJson(id, "{\"path\":\"${escapeJson(normalizedPath)}\",\"version\":$version}")) writeLine(writer, okJson(id, buildJsonObject {
put("path", normalizedPath)
put("version", version)
}))
scheduleDiagnostics(writer, normalizedPath, version) scheduleDiagnostics(writer, normalizedPath, version)
} }
private fun closeText(writer: BufferedWriter, id: Int, line: String) { private fun closeText(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = extractJsonString(line, "path")?.let(::normalizedPath) val path = params.stringField("path")?.let(::normalizedPath)
if (path != null) state.openTexts.remove(path) if (path != null) state.openTexts.remove(path)
if (path != null) state.textVersions.remove(path) if (path != null) state.textVersions.remove(path)
if (path != null) state.diagnosticsVersions.remove(path) if (path != null) state.diagnosticsVersions.remove(path)
writeLine(writer, okJson(id, "{\"closed\":true}")) writeLine(writer, okJson(id, buildJsonObject { put("closed", true) }))
} }
private fun gradleTasksJson(): String { private fun gradleTasksJson(): JsonObject {
return "{\"tasks\":${tasksJson(state.gradleWorkspace?.tasks.orEmpty())}}" return buildJsonObject { put("tasks", tasksJson(state.gradleWorkspace?.tasks.orEmpty())) }
} }
private fun runGradle(writer: BufferedWriter, id: Int, line: String) { private fun runGradle(writer: BufferedWriter, id: Int, params: JsonObject) {
val task = extractJsonString(line, "task") ?: extractJsonString(line, "path") val task = params.stringField("task") ?: params.stringField("path")
if (task == null) { if (task == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "gradle/run needs params.task")) writeLine(writer, errorJson(id, "BAD_REQUEST", "gradle/run needs params.task"))
return return
@ -212,7 +229,7 @@ private class ClientSession(
return return
} }
writeLine(writer, eventJson("gradle/run", "{\"task\":\"${escapeJson(task)}\",\"state\":\"started\"}")) writeLine(writer, eventJson("gradle/run", gradleRunStateJson(task, "started")))
try { try {
GradleConnector.newConnector().forProjectDirectory(root).connect().use { connection -> GradleConnector.newConnector().forProjectDirectory(root).connect().use { connection ->
connection.newBuild() connection.newBuild()
@ -222,13 +239,16 @@ private class ClientSession(
.run() .run()
} }
} catch (t: Throwable) { } catch (t: Throwable) {
writeLine(writer, eventJson("gradle/run", "{\"task\":\"${escapeJson(task)}\",\"state\":\"failed\"}")) writeLine(writer, eventJson("gradle/run", gradleRunStateJson(task, "failed")))
writeLine(writer, errorJson(id, "GRADLE_RUN_FAILED", t.message ?: t.javaClass.name)) writeLine(writer, errorJson(id, "GRADLE_RUN_FAILED", t.message ?: t.javaClass.name))
return return
} }
writeLine(writer, eventJson("gradle/run", "{\"task\":\"${escapeJson(task)}\",\"state\":\"finished\"}")) writeLine(writer, eventJson("gradle/run", gradleRunStateJson(task, "finished")))
writeLine(writer, okJson(id, "{\"task\":\"${escapeJson(task)}\",\"success\":true}")) writeLine(writer, okJson(id, buildJsonObject {
put("task", task)
put("success", true)
}))
} }
private fun gradleOutputStream(writer: BufferedWriter, task: String, stream: String): OutputStream { private fun gradleOutputStream(writer: BufferedWriter, task: String, stream: String): OutputStream {
@ -256,13 +276,18 @@ private class ClientSession(
if (buffer.isEmpty()) return if (buffer.isEmpty()) return
val text = buffer.toString() val text = buffer.toString()
buffer.clear() buffer.clear()
writeLine(writer, eventJson("gradle/run", "{\"task\":\"${escapeJson(task)}\",\"state\":\"output\",\"stream\":\"$stream\",\"text\":\"${escapeJson(text)}\"}")) writeLine(writer, eventJson("gradle/run", buildJsonObject {
put("task", task)
put("state", "output")
put("stream", stream)
put("text", text)
}))
} }
} }
} }
private fun kotlinDiagnostics(writer: BufferedWriter, id: Int, line: String) { private fun kotlinDiagnostics(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = extractJsonString(line, "path") val path = params.stringField("path")
if (path == null) { if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/diagnostics needs params.path")) writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/diagnostics needs params.path"))
return return
@ -284,49 +309,45 @@ private class ClientSession(
} }
val diagnostics = compileForDiagnostics(file, module, state.openTexts[normalizedPath]) val diagnostics = compileForDiagnostics(file, module, state.openTexts[normalizedPath])
writeLine(writer, okJson(id, "{\"diagnostics\":${diagnosticsJson(diagnostics)}}")) writeLine(writer, okJson(id, buildJsonObject { put("diagnostics", diagnosticsJson(diagnostics)) }))
} }
private fun kotlinCompletion(writer: BufferedWriter, id: Int, line: String) { private fun kotlinCompletion(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = extractJsonString(line, "path") val path = params.stringField("path")
if (path == null) { if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/completion needs params.path")) writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/completion needs params.path"))
return return
} }
val requestLine = extractJsonInt(line, "line") ?: 1 val requestLine = params.intField("line") ?: 1
val requestColumn = extractJsonInt(line, "column") ?: 1 val requestColumn = params.intField("column") ?: 1
val normalizedPath = normalizedPath(path) val normalizedPath = normalizedPath(path)
val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("") val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("")
val offset = offsetForLineColumn(text, requestLine, requestColumn) val offset = offsetForLineColumn(text, requestLine, requestColumn)
val prefix = identifierPrefixAt(text, offset) val prefix = identifierPrefixAt(text, offset)
val items = completionCandidates(prefix, text, state.gradleWorkspace) val items = completionCandidates(prefix, text, state.gradleWorkspace)
writeLine(writer, okJson(id, "{\"items\":${completionItemsJson(items)}}")) writeLine(writer, okJson(id, buildJsonObject { put("items", completionItemsJson(items)) }))
} }
private fun kotlinHover(writer: BufferedWriter, id: Int, line: String) { private fun kotlinHover(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = extractJsonString(line, "path") val path = params.stringField("path")
if (path == null) { if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/hover needs params.path")) writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/hover needs params.path"))
return return
} }
val requestLine = extractJsonInt(line, "line") ?: 1 val requestLine = params.intField("line") ?: 1
val requestColumn = extractJsonInt(line, "column") ?: 1 val requestColumn = params.intField("column") ?: 1
val normalizedPath = normalizedPath(path) val normalizedPath = normalizedPath(path)
val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("") val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("")
val offset = offsetForLineColumn(text, requestLine, requestColumn) val offset = offsetForLineColumn(text, requestLine, requestColumn)
val identifier = identifierAt(text, offset) val identifier = identifierAt(text, offset)
val contents = hoverContents(identifier, normalizedPath, text, state.gradleWorkspace) val contents = hoverContents(identifier, normalizedPath, text, state.gradleWorkspace)
if (contents == null) { writeLine(writer, okJson(id, buildJsonObject { put("contents", contents) }))
writeLine(writer, okJson(id, "{\"contents\":null}"))
} else {
writeLine(writer, okJson(id, "{\"contents\":\"${escapeJson(contents)}\"}"))
}
} }
private fun kotlinDefinition(writer: BufferedWriter, id: Int, line: String) { private fun kotlinDefinition(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = extractJsonString(line, "path") val path = params.stringField("path")
if (path == null) { if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/definition needs params.path")) writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/definition needs params.path"))
return return
@ -338,19 +359,19 @@ private class ClientSession(
return return
} }
val requestLine = extractJsonInt(line, "line") ?: 1 val requestLine = params.intField("line") ?: 1
val requestColumn = extractJsonInt(line, "column") ?: 1 val requestColumn = params.intField("column") ?: 1
val normalizedPath = normalizedPath(path) val normalizedPath = normalizedPath(path)
val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("") val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("")
val offset = offsetForLineColumn(text, requestLine, requestColumn) val offset = offsetForLineColumn(text, requestLine, requestColumn)
val identifier = identifierAt(text, offset) val identifier = identifierAt(text, offset)
val location = findSimpleDeclarationInText(normalizedPath, text, identifier) val location = findSimpleDeclarationInText(normalizedPath, text, identifier)
?: findSimpleDeclaration(workspace, identifier) ?: findSimpleDeclaration(workspace, identifier)
writeLine(writer, okJson(id, "{\"locations\":${definitionLocationsJson(location)}}")) writeLine(writer, okJson(id, buildJsonObject { put("locations", definitionLocationsJson(location)) }))
} }
private fun kotlinReferences(writer: BufferedWriter, id: Int, line: String) { private fun kotlinReferences(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = extractJsonString(line, "path") val path = params.stringField("path")
if (path == null) { if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/references needs params.path")) writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/references needs params.path"))
return return
@ -362,24 +383,24 @@ private class ClientSession(
return return
} }
val requestLine = extractJsonInt(line, "line") ?: 1 val requestLine = params.intField("line") ?: 1
val requestColumn = extractJsonInt(line, "column") ?: 1 val requestColumn = params.intField("column") ?: 1
val normalizedPath = normalizedPath(path) val normalizedPath = normalizedPath(path)
val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("") val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("")
val offset = offsetForLineColumn(text, requestLine, requestColumn) val offset = offsetForLineColumn(text, requestLine, requestColumn)
val identifier = identifierAt(text, offset) val identifier = identifierAt(text, offset)
val locations = findSimpleReferences(workspace, normalizedPath, text, identifier) val locations = findSimpleReferences(workspace, normalizedPath, text, identifier)
writeLine(writer, okJson(id, "{\"locations\":${locationsJson(locations)}}")) writeLine(writer, okJson(id, buildJsonObject { put("locations", locationsJson(locations)) }))
} }
private fun kotlinRename(writer: BufferedWriter, id: Int, line: String) { private fun kotlinRename(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = extractJsonString(line, "path") val path = params.stringField("path")
if (path == null) { if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/rename needs params.path")) writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/rename needs params.path"))
return return
} }
val newName = extractJsonString(line, "newName") val newName = params.stringField("newName")
if (newName == null || !isValidIdentifier(newName)) { if (newName == null || !isValidIdentifier(newName)) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/rename needs valid params.newName")) writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/rename needs valid params.newName"))
return return
@ -391,19 +412,27 @@ private class ClientSession(
return return
} }
val requestLine = extractJsonInt(line, "line") ?: 1 val requestLine = params.intField("line") ?: 1
val requestColumn = extractJsonInt(line, "column") ?: 1 val requestColumn = params.intField("column") ?: 1
val normalizedPath = normalizedPath(path) val normalizedPath = normalizedPath(path)
val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("") val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("")
val offset = offsetForLineColumn(text, requestLine, requestColumn) val offset = offsetForLineColumn(text, requestLine, requestColumn)
val identifier = identifierAt(text, offset) val identifier = identifierAt(text, offset)
if (identifier.isBlank() || identifier in allKeywordCompletionItems) { if (identifier.isBlank() || identifier in allKeywordCompletionItems) {
writeLine(writer, okJson(id, "{\"applied\":false,\"edits\":[]}")) writeLine(writer, okJson(id, buildJsonObject {
put("applied", false)
putJsonArray("edits") {}
}))
return return
} }
val locations = findSimpleReferences(workspace, normalizedPath, text, identifier) val locations = findSimpleReferences(workspace, normalizedPath, text, identifier)
writeLine(writer, okJson(id, "{\"applied\":false,\"oldName\":\"${escapeJson(identifier)}\",\"newName\":\"${escapeJson(newName)}\",\"edits\":${renameEditsJson(locations, identifier, newName)}}")) writeLine(writer, okJson(id, buildJsonObject {
put("applied", false)
put("oldName", identifier)
put("newName", newName)
put("edits", renameEditsJson(locations, identifier, newName))
}))
} }
private fun scheduleDiagnostics(writer: BufferedWriter, path: String, version: Int) { private fun scheduleDiagnostics(writer: BufferedWriter, path: String, version: Int) {
@ -427,7 +456,11 @@ private class ClientSession(
writer, writer,
eventJson( eventJson(
"diagnostics/publish", "diagnostics/publish",
"{\"path\":\"${escapeJson(file.path)}\",\"version\":$version,\"diagnostics\":${diagnosticsJson(diagnostics)}}", buildJsonObject {
put("path", file.path)
put("version", version)
put("diagnostics", diagnosticsJson(diagnostics))
},
), ),
) )
} }
@ -577,32 +610,32 @@ private fun collectTasks(project: GradleProject): List<GradleTaskInfo> {
return tasks.sortedWith(compareBy<GradleTaskInfo> { it.path }.thenBy { it.name }) return tasks.sortedWith(compareBy<GradleTaskInfo> { it.path }.thenBy { it.name })
} }
private fun workspaceJson(workspace: GradleWorkspace): String { private fun indexingStateJson(state: String): JsonObject = buildJsonObject { put("state", state) }
return buildString {
append("{\"root\":\"").append(escapeJson(workspace.root.path)).append("\"") private fun gradleRunStateJson(task: String, state: String): JsonObject = buildJsonObject {
append(",\"gradle\":true") put("task", task)
append(",\"modules\":[") put("state", state)
workspace.modules.forEachIndexed { index, module ->
if (index > 0) append(',')
append(moduleJson(module))
}
append(']')
append(",\"sourceRoots\":").append(filesJson(workspace.modules.flatMap { it.sourceRoots + it.testSourceRoots }))
append(",\"tasks\":").append(tasksJson(workspace.tasks))
append('}')
}
} }
private fun moduleJson(module: GradleModule): String = buildString { private fun workspaceJson(workspace: GradleWorkspace): JsonObject = buildJsonObject {
append("{\"name\":\"").append(escapeJson(module.name)).append("\"") put("root", workspace.root.path)
append(",\"gradlePath\":\"").append(escapeJson(module.gradlePath)).append("\"") put("gradle", true)
append(",\"directory\":\"").append(escapeJson(module.directory.path)).append("\"") putJsonArray("modules") {
append(",\"sourceRoots\":").append(filesJson(module.sourceRoots)) workspace.modules.forEach { add(moduleJson(it)) }
append(",\"testSourceRoots\":").append(filesJson(module.testSourceRoots)) }
append(",\"resourceRoots\":").append(filesJson(module.resourceRoots)) put("sourceRoots", filesJson(workspace.modules.flatMap { it.sourceRoots + it.testSourceRoots }))
append(",\"testResourceRoots\":").append(filesJson(module.testResourceRoots)) put("tasks", tasksJson(workspace.tasks))
append(",\"classpath\":").append(filesJson(module.classpath)) }
append('}')
private fun moduleJson(module: GradleModule): JsonObject = buildJsonObject {
put("name", module.name)
put("gradlePath", module.gradlePath)
put("directory", module.directory.path)
put("sourceRoots", filesJson(module.sourceRoots))
put("testSourceRoots", filesJson(module.testSourceRoots))
put("resourceRoots", filesJson(module.resourceRoots))
put("testResourceRoots", filesJson(module.testResourceRoots))
put("classpath", filesJson(module.classpath))
} }
private fun compileForDiagnostics(file: File, module: GradleModule, openText: String?): List<KotlinDiagnostic> { private fun compileForDiagnostics(file: File, module: GradleModule, openText: String?): List<KotlinDiagnostic> {
@ -1017,90 +1050,65 @@ private fun sourceLine(text: String, oneBasedLine: Int): String {
return if (line == oneBasedLine) text.substring(start) else "" return if (line == oneBasedLine) text.substring(start) else ""
} }
private fun completionItemsJson(items: List<CompletionCandidate>): String = buildString { private fun completionItemsJson(items: List<CompletionCandidate>): JsonElement = buildJsonArray {
append('[') items.forEach { item ->
items.forEachIndexed { index, item -> add(buildJsonObject {
if (index > 0) append(',') put("label", item.label)
append("{\"label\":\"").append(escapeJson(item.label)).append("\",\"kind\":\"").append(escapeJson(item.kind)).append("\"}") put("kind", item.kind)
})
} }
append(']')
} }
private fun definitionLocationsJson(location: SourceLocation?): String = buildString { private fun locationJson(location: SourceLocation): JsonObject = buildJsonObject {
append('[') put("path", location.path)
if (location != null) { put("line", location.line)
append("{\"path\":\"").append(escapeJson(location.path)).append("\"") put("column", location.column)
append(",\"line\":").append(location.line)
append(",\"column\":").append(location.column)
append('}')
}
append(']')
} }
private fun locationsJson(locations: List<SourceLocation>): String = buildString { private fun definitionLocationsJson(location: SourceLocation?): JsonElement = buildJsonArray {
append('[') if (location != null) add(locationJson(location))
locations.forEachIndexed { index, location ->
if (index > 0) append(',')
append("{\"path\":\"").append(escapeJson(location.path)).append("\"")
append(",\"line\":").append(location.line)
append(",\"column\":").append(location.column)
append('}')
}
append(']')
} }
private fun renameEditsJson(locations: List<SourceLocation>, oldName: String, newName: String): String = buildString { private fun locationsJson(locations: List<SourceLocation>): JsonElement = buildJsonArray {
append('[') locations.forEach { add(locationJson(it)) }
locations.forEachIndexed { index, location ->
if (index > 0) append(',')
append("{\"path\":\"").append(escapeJson(location.path)).append("\"")
append(",\"line\":").append(location.line)
append(",\"column\":").append(location.column)
append(",\"oldText\":\"").append(escapeJson(oldName)).append("\"")
append(",\"newText\":\"").append(escapeJson(newName)).append("\"}")
}
append(']')
} }
private fun diagnosticsJson(diagnostics: List<KotlinDiagnostic>): String = buildString { private fun renameEditsJson(locations: List<SourceLocation>, oldName: String, newName: String): JsonElement = buildJsonArray {
append('[') locations.forEach { location ->
diagnostics.forEachIndexed { index, diagnostic -> add(buildJsonObject {
if (index > 0) append(',') put("path", location.path)
append("{\"severity\":\"").append(escapeJson(diagnostic.severity)).append("\"") put("line", location.line)
append(",\"message\":\"").append(escapeJson(diagnostic.message)).append("\"") put("column", location.column)
append(",\"path\":") put("oldText", oldName)
if (diagnostic.path == null) append("null") else append('\"').append(escapeJson(diagnostic.path)).append('\"') put("newText", newName)
append(",\"line\":").append(diagnostic.line ?: "null") })
append(",\"column\":").append(diagnostic.column ?: "null")
append('}')
} }
append(']')
} }
private fun filesJson(files: List<File>): String = buildString { private fun diagnosticsJson(diagnostics: List<KotlinDiagnostic>): JsonElement = buildJsonArray {
append('[') diagnostics.forEach { diagnostic ->
files.distinctBy { it.path }.forEachIndexed { index, file -> add(buildJsonObject {
if (index > 0) append(',') put("severity", diagnostic.severity)
append('\"').append(escapeJson(file.path)).append('\"') put("message", diagnostic.message)
put("path", diagnostic.path)
put("line", diagnostic.line)
put("column", diagnostic.column)
})
} }
append(']')
} }
private fun tasksJson(tasks: List<GradleTaskInfo>): String = buildString { private fun filesJson(files: List<File>): JsonElement = buildJsonArray {
append('[') files.distinctBy { it.path }.forEach { add(it.path) }
tasks.forEachIndexed { index, task -> }
if (index > 0) append(',')
append("{\"path\":\"").append(escapeJson(task.path)).append("\"") private fun tasksJson(tasks: List<GradleTaskInfo>): JsonElement = buildJsonArray {
append(",\"name\":\"").append(escapeJson(task.name)).append("\"") tasks.forEach { task ->
append(",\"description\":") add(buildJsonObject {
if (task.description == null) { put("path", task.path)
append("null") put("name", task.name)
} else { put("description", task.description)
append('\"').append(escapeJson(task.description)).append('\"') })
} }
append('}')
}
append(']')
} }
private fun writeLineLocked(writer: BufferedWriter, text: String) { private fun writeLineLocked(writer: BufferedWriter, text: String) {
@ -1109,80 +1117,29 @@ private fun writeLineLocked(writer: BufferedWriter, text: String) {
writer.flush() writer.flush()
} }
private fun okJson(id: Int, result: String): String = "{\"id\":$id,\"ok\":true,\"result\":$result}" private fun okJson(id: Int, result: JsonElement): String = buildJsonObject {
put("id", id)
put("ok", true)
put("result", result)
}.toString()
private fun errorJson(id: Int, code: String, message: String): String = private fun errorJson(id: Int, code: String, message: String): String = buildJsonObject {
"{\"id\":$id,\"ok\":false,\"error\":{\"code\":\"${escapeJson(code)}\",\"message\":\"${escapeJson(message)}\"}}" put("id", id)
put("ok", false)
private fun eventJson(event: String, params: String): String = putJsonObject("error") {
"{\"event\":\"${escapeJson(event)}\",\"params\":$params}" put("code", code)
put("message", message)
private fun extractJsonString(json: String, key: String): String? {
val marker = "\"$key\""
val keyIndex = json.indexOf(marker)
if (keyIndex < 0) return null
val colon = json.indexOf(':', keyIndex + marker.length)
if (colon < 0) return null
var index = colon + 1
while (index < json.length && json[index].isWhitespace()) index++
if (index >= json.length || json[index] != '\"') return null
return readJsonString(json, index)
}
private fun extractJsonInt(json: String, key: String): Int? {
val marker = "\"$key\""
val keyIndex = json.indexOf(marker)
if (keyIndex < 0) return null
val colon = json.indexOf(':', keyIndex + marker.length)
if (colon < 0) return null
var index = colon + 1
while (index < json.length && json[index].isWhitespace()) index++
val start = index
if (index < json.length && json[index] == '-') index++
while (index < json.length && json[index].isDigit()) index++
if (index == start) return null
return json.substring(start, index).toIntOrNull()
}
private fun readJsonString(json: String, quoteIndex: Int): String? {
val out = StringBuilder()
var index = quoteIndex + 1
while (index < json.length) {
val ch = json[index]
if (ch == '\"') return out.toString()
if (ch == '\\') {
index++
if (index >= json.length) return null
out.append(
when (val escaped = json[index]) {
'\"' -> '\"'
'\\' -> '\\'
'/' -> '/'
'b' -> '\b'
'f' -> '\u000C'
'n' -> '\n'
'r' -> '\r'
't' -> '\t'
else -> escaped
} }
) }.toString()
} else {
out.append(ch) private fun eventJson(event: String, params: JsonElement): String = buildJsonObject {
} put("event", event)
index++ put("params", params)
} }.toString()
return null
} private fun JsonObject.stringField(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.intField(key: String): Int? =
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull
private fun escapeJson(value: String): String = buildString {
value.forEach { ch ->
when (ch) {
'\\' -> append("\\\\")
'\"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(ch)
}
}
}