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) - } - } -}