From 8b1723ce3121cbb5e2fabcb439001efef5852092 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 3 Jul 2026 09:04:50 +0200 Subject: [PATCH] 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 --- client/odin/daemon_client.odin | 49 +- client/odin/main.odin | 16 +- client/odin/sdl_app.odin | 4 +- daemon/build.gradle.kts | 1 + .../kotlin/dev/nativeeditor/daemon/Main.kt | 435 ++++++++---------- 5 files changed, 247 insertions(+), 258 deletions(-) diff --git a/client/odin/daemon_client.odin b/client/odin/daemon_client.odin index ae5d029..b0f39ec 100644 --- a/client/odin/daemon_client.odin +++ b/client/odin/daemon_client.odin @@ -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 diff --git a/client/odin/main.odin b/client/odin/main.odin index 13c420a..7893597 100644 --- a/client/odin/main.odin +++ b/client/odin/main.odin @@ -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) } diff --git a/client/odin/sdl_app.odin b/client/odin/sdl_app.odin index 3c61529..b49d75f 100644 --- a/client/odin/sdl_app.odin +++ b/client/odin/sdl_app.odin @@ -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) } diff --git a/daemon/build.gradle.kts b/daemon/build.gradle.kts index d585771..93c1d84 100644 --- a/daemon/build.gradle.kts +++ b/daemon/build.gradle.kts @@ -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 { diff --git a/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt b/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt index 3abad30..84ad71b 100644 --- a/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt +++ b/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt @@ -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 { return tasks.sortedWith(compareBy { 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 { @@ -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): 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): 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): 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, 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): JsonElement = buildJsonArray { + locations.forEach { add(locationJson(it)) } } -private fun diagnosticsJson(diagnostics: List): 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, 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): 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): 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): 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('\"') - } - append('}') +private fun filesJson(files: List): JsonElement = buildJsonArray { + files.distinctBy { it.path }.forEach { add(it.path) } +} + +private fun tasksJson(tasks: List): 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 eventJson(event: String, params: String): String = - "{\"event\":\"${escapeJson(event)}\",\"params\":$params}" - -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 - } - ) - } else { - out.append(ch) - } - index++ +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) } - return null -} +}.toString() + +private fun eventJson(event: String, params: JsonElement): String = buildJsonObject { + put("event", event) + put("params", params) +}.toString() + +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) - } - } -}