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 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 {
Invalid,
Response,
@ -264,7 +295,7 @@ daemon_send_workspace_open :: proc(client: ^Daemon_Client, workspace: string) {
if !client.connected do return
id := client.next_id
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)
}
@ -272,7 +303,7 @@ daemon_send_text_open :: proc(client: ^Daemon_Client, path: string, version: int
if !client.connected do return
id := client.next_id
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)
}
@ -280,7 +311,7 @@ daemon_send_text_change :: proc(client: ^Daemon_Client, path: string, version: i
if !client.connected do return
id := client.next_id
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)
}
@ -288,7 +319,7 @@ daemon_send_completion :: proc(client: ^Daemon_Client, path: string, line, colum
if !client.connected do return 0
id := client.next_id
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_send(client, request)
return id
@ -298,7 +329,7 @@ daemon_send_hover :: proc(client: ^Daemon_Client, path: string, line, column: in
if !client.connected do return 0
id := client.next_id
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_send(client, request)
return id
@ -308,7 +339,7 @@ daemon_send_definition :: proc(client: ^Daemon_Client, path: string, line, colum
if !client.connected do return 0
id := client.next_id
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_send(client, request)
return id
@ -318,7 +349,7 @@ daemon_send_references :: proc(client: ^Daemon_Client, path: string, line, colum
if !client.connected do return 0
id := client.next_id
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_send(client, request)
return id
@ -328,7 +359,7 @@ daemon_send_rename :: proc(client: ^Daemon_Client, path: string, line, column: i
if !client.connected do return 0
id := client.next_id
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_send(client, request)
return id
@ -348,7 +379,7 @@ daemon_send_gradle_run :: proc(client: ^Daemon_Client, task: string) -> int {
if !client.connected do return 0
id := client.next_id
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_send(client, request)
return id

View file

@ -60,7 +60,7 @@ main :: proc() {
send_line(socket, "{\"id\":1,\"method\":\"ping\",\"params\":{}}\n")
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)
read_messages(socket, 3)
@ -72,31 +72,31 @@ main :: proc() {
active_text := editor_active_text(&editor)
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)
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)
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)
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)
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)
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)
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)
read_messages(socket, 1)
}

View file

@ -298,12 +298,12 @@ ui_state_save :: proc(view: ^SDL_View) {
append(&open_files_json, '[')
for file, index in view.saved_open_files {
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)
}
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)
}

View file

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

View file

@ -18,6 +18,18 @@ import javax.tools.JavaFileObject
import javax.tools.StandardLocation
import javax.tools.ToolProvider
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.model.GradleProject
import org.gradle.tooling.model.idea.IdeaProject
@ -107,35 +119,37 @@ private class ClientSession(
val line = reader.readLine() ?: break
if (line.isBlank()) continue
val id = extractJsonInt(line, "id")
val method = extractJsonString(line, "method")
if (id == null || method == null) {
val request = runCatching { Json.parseToJsonElement(line).jsonObject }.getOrNull()
val id = request?.intField("id")
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"))
continue
}
val params = request["params"] as? JsonObject ?: JsonObject(emptyMap())
when (method) {
"ping" -> writeLine(writer, okJson(id, "{\"pong\":true}"))
"workspace/open" -> openWorkspace(writer, id, line)
"ping" -> writeLine(writer, okJson(id, buildJsonObject { put("pong", true) }))
"workspace/open" -> openWorkspace(writer, id, params)
"workspace/close" -> closeWorkspace(writer, id)
"text/open", "text/change" -> updateText(writer, id, line)
"text/close" -> closeText(writer, id, line)
"kotlin/diagnostics" -> kotlinDiagnostics(writer, id, line)
"kotlin/completion" -> kotlinCompletion(writer, id, line)
"kotlin/definition" -> kotlinDefinition(writer, id, line)
"kotlin/references" -> kotlinReferences(writer, id, line)
"kotlin/rename" -> kotlinRename(writer, id, line)
"kotlin/hover" -> kotlinHover(writer, id, line)
"text/open", "text/change" -> updateText(writer, id, params)
"text/close" -> closeText(writer, id, params)
"kotlin/diagnostics" -> kotlinDiagnostics(writer, id, params)
"kotlin/completion" -> kotlinCompletion(writer, id, params)
"kotlin/definition" -> kotlinDefinition(writer, id, params)
"kotlin/references" -> kotlinReferences(writer, id, params)
"kotlin/rename" -> kotlinRename(writer, id, params)
"kotlin/hover" -> kotlinHover(writer, id, params)
"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"))
}
}
}
}
private fun openWorkspace(writer: BufferedWriter, id: Int, line: String) {
val rootText = extractJsonString(line, "root")
private fun openWorkspace(writer: BufferedWriter, id: Int, params: JsonObject) {
val rootText = params.stringField("root")
if (rootText == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "workspace/open needs params.root"))
return
@ -143,24 +157,24 @@ private class ClientSession(
val root = File(rootText).absoluteFile.normalize()
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
}
state.workspaceRoot = root
writeLine(writer, eventJson("workspace/indexing", "{\"state\":\"started\"}"))
writeLine(writer, eventJson("workspace/indexing", indexingStateJson("started")))
val gradleWorkspace = try {
importGradleWorkspace(root)
} 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))
return
}
state.gradleWorkspace = 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) {
@ -169,38 +183,41 @@ private class ClientSession(
state.openTexts.clear()
state.textVersions.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) {
val path = extractJsonString(line, "path")
private fun updateText(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = params.stringField("path")
if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "text update needs params.path"))
return
}
val normalizedPath = normalizedPath(path)
val version = extractJsonInt(line, "version") ?: 0
state.openTexts[normalizedPath] = extractJsonString(line, "text") ?: ""
val version = params.intField("version") ?: 0
state.openTexts[normalizedPath] = params.stringField("text") ?: ""
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)
}
private fun closeText(writer: BufferedWriter, id: Int, line: String) {
val path = extractJsonString(line, "path")?.let(::normalizedPath)
private fun closeText(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = params.stringField("path")?.let(::normalizedPath)
if (path != null) state.openTexts.remove(path)
if (path != null) state.textVersions.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 {
return "{\"tasks\":${tasksJson(state.gradleWorkspace?.tasks.orEmpty())}}"
private fun gradleTasksJson(): JsonObject {
return buildJsonObject { put("tasks", tasksJson(state.gradleWorkspace?.tasks.orEmpty())) }
}
private fun runGradle(writer: BufferedWriter, id: Int, line: String) {
val task = extractJsonString(line, "task") ?: extractJsonString(line, "path")
private fun runGradle(writer: BufferedWriter, id: Int, params: JsonObject) {
val task = params.stringField("task") ?: params.stringField("path")
if (task == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "gradle/run needs params.task"))
return
@ -212,7 +229,7 @@ private class ClientSession(
return
}
writeLine(writer, eventJson("gradle/run", "{\"task\":\"${escapeJson(task)}\",\"state\":\"started\"}"))
writeLine(writer, eventJson("gradle/run", gradleRunStateJson(task, "started")))
try {
GradleConnector.newConnector().forProjectDirectory(root).connect().use { connection ->
connection.newBuild()
@ -222,13 +239,16 @@ private class ClientSession(
.run()
}
} 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))
return
}
writeLine(writer, eventJson("gradle/run", "{\"task\":\"${escapeJson(task)}\",\"state\":\"finished\"}"))
writeLine(writer, okJson(id, "{\"task\":\"${escapeJson(task)}\",\"success\":true}"))
writeLine(writer, eventJson("gradle/run", gradleRunStateJson(task, "finished")))
writeLine(writer, okJson(id, buildJsonObject {
put("task", task)
put("success", true)
}))
}
private fun gradleOutputStream(writer: BufferedWriter, task: String, stream: String): OutputStream {
@ -256,13 +276,18 @@ private class ClientSession(
if (buffer.isEmpty()) return
val text = buffer.toString()
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) {
val path = extractJsonString(line, "path")
private fun kotlinDiagnostics(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = params.stringField("path")
if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/diagnostics needs params.path"))
return
@ -284,49 +309,45 @@ private class ClientSession(
}
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) {
val path = extractJsonString(line, "path")
private fun kotlinCompletion(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = params.stringField("path")
if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/completion needs params.path"))
return
}
val requestLine = extractJsonInt(line, "line") ?: 1
val requestColumn = extractJsonInt(line, "column") ?: 1
val requestLine = params.intField("line") ?: 1
val requestColumn = params.intField("column") ?: 1
val normalizedPath = normalizedPath(path)
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)
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) {
val path = extractJsonString(line, "path")
private fun kotlinHover(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = params.stringField("path")
if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/hover needs params.path"))
return
}
val requestLine = extractJsonInt(line, "line") ?: 1
val requestColumn = extractJsonInt(line, "column") ?: 1
val requestLine = params.intField("line") ?: 1
val requestColumn = params.intField("column") ?: 1
val normalizedPath = normalizedPath(path)
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)
if (contents == null) {
writeLine(writer, okJson(id, "{\"contents\":null}"))
} else {
writeLine(writer, okJson(id, "{\"contents\":\"${escapeJson(contents)}\"}"))
}
writeLine(writer, okJson(id, buildJsonObject { put("contents", contents) }))
}
private fun kotlinDefinition(writer: BufferedWriter, id: Int, line: String) {
val path = extractJsonString(line, "path")
private fun kotlinDefinition(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = params.stringField("path")
if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/definition needs params.path"))
return
@ -338,19 +359,19 @@ private class ClientSession(
return
}
val requestLine = extractJsonInt(line, "line") ?: 1
val requestColumn = extractJsonInt(line, "column") ?: 1
val requestLine = params.intField("line") ?: 1
val requestColumn = params.intField("column") ?: 1
val normalizedPath = normalizedPath(path)
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)
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) {
val path = extractJsonString(line, "path")
private fun kotlinReferences(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = params.stringField("path")
if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/references needs params.path"))
return
@ -362,24 +383,24 @@ private class ClientSession(
return
}
val requestLine = extractJsonInt(line, "line") ?: 1
val requestColumn = extractJsonInt(line, "column") ?: 1
val requestLine = params.intField("line") ?: 1
val requestColumn = params.intField("column") ?: 1
val normalizedPath = normalizedPath(path)
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)
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) {
val path = extractJsonString(line, "path")
private fun kotlinRename(writer: BufferedWriter, id: Int, params: JsonObject) {
val path = params.stringField("path")
if (path == null) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/rename needs params.path"))
return
}
val newName = extractJsonString(line, "newName")
val newName = params.stringField("newName")
if (newName == null || !isValidIdentifier(newName)) {
writeLine(writer, errorJson(id, "BAD_REQUEST", "kotlin/rename needs valid params.newName"))
return
@ -391,19 +412,27 @@ private class ClientSession(
return
}
val requestLine = extractJsonInt(line, "line") ?: 1
val requestColumn = extractJsonInt(line, "column") ?: 1
val requestLine = params.intField("line") ?: 1
val requestColumn = params.intField("column") ?: 1
val normalizedPath = normalizedPath(path)
val text = state.openTexts[normalizedPath] ?: runCatching { File(normalizedPath).readText() }.getOrDefault("")
val offset = offsetForLineColumn(text, requestLine, requestColumn)
val identifier = identifierAt(text, offset)
if (identifier.isBlank() || identifier in allKeywordCompletionItems) {
writeLine(writer, okJson(id, "{\"applied\":false,\"edits\":[]}"))
writeLine(writer, okJson(id, buildJsonObject {
put("applied", false)
putJsonArray("edits") {}
}))
return
}
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) {
@ -427,7 +456,11 @@ private class ClientSession(
writer,
eventJson(
"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 })
}
private fun workspaceJson(workspace: GradleWorkspace): String {
return buildString {
append("{\"root\":\"").append(escapeJson(workspace.root.path)).append("\"")
append(",\"gradle\":true")
append(",\"modules\":[")
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 indexingStateJson(state: String): JsonObject = buildJsonObject { put("state", state) }
private fun gradleRunStateJson(task: String, state: String): JsonObject = buildJsonObject {
put("task", task)
put("state", state)
}
private fun moduleJson(module: GradleModule): String = buildString {
append("{\"name\":\"").append(escapeJson(module.name)).append("\"")
append(",\"gradlePath\":\"").append(escapeJson(module.gradlePath)).append("\"")
append(",\"directory\":\"").append(escapeJson(module.directory.path)).append("\"")
append(",\"sourceRoots\":").append(filesJson(module.sourceRoots))
append(",\"testSourceRoots\":").append(filesJson(module.testSourceRoots))
append(",\"resourceRoots\":").append(filesJson(module.resourceRoots))
append(",\"testResourceRoots\":").append(filesJson(module.testResourceRoots))
append(",\"classpath\":").append(filesJson(module.classpath))
append('}')
private fun workspaceJson(workspace: GradleWorkspace): JsonObject = buildJsonObject {
put("root", workspace.root.path)
put("gradle", true)
putJsonArray("modules") {
workspace.modules.forEach { add(moduleJson(it)) }
}
put("sourceRoots", filesJson(workspace.modules.flatMap { it.sourceRoots + it.testSourceRoots }))
put("tasks", tasksJson(workspace.tasks))
}
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> {
@ -1017,90 +1050,65 @@ private fun sourceLine(text: String, oneBasedLine: Int): String {
return if (line == oneBasedLine) text.substring(start) else ""
}
private fun completionItemsJson(items: List<CompletionCandidate>): String = buildString {
append('[')
items.forEachIndexed { index, item ->
if (index > 0) append(',')
append("{\"label\":\"").append(escapeJson(item.label)).append("\",\"kind\":\"").append(escapeJson(item.kind)).append("\"}")
private fun completionItemsJson(items: List<CompletionCandidate>): JsonElement = buildJsonArray {
items.forEach { item ->
add(buildJsonObject {
put("label", item.label)
put("kind", item.kind)
})
}
append(']')
}
private fun definitionLocationsJson(location: SourceLocation?): String = buildString {
append('[')
if (location != null) {
append("{\"path\":\"").append(escapeJson(location.path)).append("\"")
append(",\"line\":").append(location.line)
append(",\"column\":").append(location.column)
append('}')
}
append(']')
private fun locationJson(location: SourceLocation): JsonObject = buildJsonObject {
put("path", location.path)
put("line", location.line)
put("column", location.column)
}
private fun locationsJson(locations: List<SourceLocation>): String = buildString {
append('[')
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 definitionLocationsJson(location: SourceLocation?): JsonElement = buildJsonArray {
if (location != null) add(locationJson(location))
}
private fun renameEditsJson(locations: List<SourceLocation>, oldName: String, newName: String): String = buildString {
append('[')
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 locationsJson(locations: List<SourceLocation>): JsonElement = buildJsonArray {
locations.forEach { add(locationJson(it)) }
}
private fun diagnosticsJson(diagnostics: List<KotlinDiagnostic>): String = buildString {
append('[')
diagnostics.forEachIndexed { index, diagnostic ->
if (index > 0) append(',')
append("{\"severity\":\"").append(escapeJson(diagnostic.severity)).append("\"")
append(",\"message\":\"").append(escapeJson(diagnostic.message)).append("\"")
append(",\"path\":")
if (diagnostic.path == null) append("null") else append('\"').append(escapeJson(diagnostic.path)).append('\"')
append(",\"line\":").append(diagnostic.line ?: "null")
append(",\"column\":").append(diagnostic.column ?: "null")
append('}')
private fun renameEditsJson(locations: List<SourceLocation>, oldName: String, newName: String): JsonElement = buildJsonArray {
locations.forEach { location ->
add(buildJsonObject {
put("path", location.path)
put("line", location.line)
put("column", location.column)
put("oldText", oldName)
put("newText", newName)
})
}
append(']')
}
private fun filesJson(files: List<File>): String = buildString {
append('[')
files.distinctBy { it.path }.forEachIndexed { index, file ->
if (index > 0) append(',')
append('\"').append(escapeJson(file.path)).append('\"')
private fun diagnosticsJson(diagnostics: List<KotlinDiagnostic>): JsonElement = buildJsonArray {
diagnostics.forEach { diagnostic ->
add(buildJsonObject {
put("severity", diagnostic.severity)
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 {
append('[')
tasks.forEachIndexed { index, task ->
if (index > 0) append(',')
append("{\"path\":\"").append(escapeJson(task.path)).append("\"")
append(",\"name\":\"").append(escapeJson(task.name)).append("\"")
append(",\"description\":")
if (task.description == null) {
append("null")
} else {
append('\"').append(escapeJson(task.description)).append('\"')
private fun filesJson(files: List<File>): JsonElement = buildJsonArray {
files.distinctBy { it.path }.forEach { add(it.path) }
}
append('}')
private fun tasksJson(tasks: List<GradleTaskInfo>): JsonElement = buildJsonArray {
tasks.forEach { task ->
add(buildJsonObject {
put("path", task.path)
put("name", task.name)
put("description", task.description)
})
}
append(']')
}
private fun writeLineLocked(writer: BufferedWriter, text: String) {
@ -1109,80 +1117,29 @@ private fun writeLineLocked(writer: BufferedWriter, text: String) {
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 =
"{\"id\":$id,\"ok\":false,\"error\":{\"code\":\"${escapeJson(code)}\",\"message\":\"${escapeJson(message)}\"}}"
private fun errorJson(id: Int, code: String, message: String): String = buildJsonObject {
put("id", id)
put("ok", false)
putJsonObject("error") {
put("code", code)
put("message", message)
}
}.toString()
private fun eventJson(event: String, params: String): String =
"{\"event\":\"${escapeJson(event)}\",\"params\":$params}"
private fun eventJson(event: String, params: JsonElement): String = buildJsonObject {
put("event", event)
put("params", params)
}.toString()
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 JsonObject.stringField(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
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 JsonObject.intField(key: String): Int? =
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull
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
}
)
} else {
out.append(ch)
}
index++
}
return null
}
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)
}
}
}