editor/client/odin/daemon_client.odin
pavel 4a15350860 Initial commit: Odin editor client + Kotlin daemon prototype
Native SDL3 editor written in Odin with a piece-table buffer core,
backed by a Kotlin/JVM daemon speaking newline-delimited JSON over
localhost TCP for Gradle import, Kotlin/Java diagnostics, and
heuristic completion/hover/definition/references/rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:37:41 +02:00

615 lines
18 KiB
Odin

package main
import "core:fmt"
import "core:net"
import "core:os"
import "core:strconv"
import "core:strings"
import "core:sync"
import "core:thread"
import "core:time"
import json "core:encoding/json"
Protocol_Message_Kind :: enum {
Invalid,
Response,
Event,
}
Protocol_Message :: struct {
kind: Protocol_Message_Kind,
id: int,
ok: bool,
event: string,
}
Daemon_Response :: struct {
id: int,
line: [dynamic]u8,
}
Daemon_Event :: struct {
line: [dynamic]u8,
}
Daemon_Process :: struct {
process: os.Process,
stdout: ^os.File,
running: bool,
}
Daemon_Client :: struct {
socket: net.TCP_Socket,
connected: bool,
next_id: int,
reader: ^thread.Thread,
event_mutex: sync.Mutex,
response_mutex: sync.Mutex,
diagnostic_events: [dynamic]Daemon_Event,
gradle_events: [dynamic]Daemon_Event,
pending_response_ids: [dynamic]int,
responses: [dynamic]Daemon_Response,
}
daemon_connect :: proc(port: int) -> Daemon_Client {
client := Daemon_Client{next_id = 1}
if port <= 0 do return client
socket, err := net.dial_tcp("127.0.0.1", port)
if err != nil {
fmt.println("daemon connect failed:", err)
return client
}
client.socket = socket
client.connected = true
return client
}
daemon_start_process :: proc(workspace: string) -> (Daemon_Process, int, bool) {
child, ok := daemon_start_process_begin(workspace)
if !ok {
return child, 0, false
}
port, port_ok := daemon_read_port(&child)
if !port_ok {
daemon_stop_process(&child)
return child, 0, false
}
return child, port, true
}
daemon_start_process_begin :: proc(workspace: string) -> (Daemon_Process, bool) {
child := Daemon_Process{}
stdout_r, stdout_w, pipe_err := os.pipe()
if pipe_err != nil {
fmt.println("daemon pipe failed:", pipe_err)
return child, false
}
command := []string{"gradle", "-p", workspace, "run", "--quiet"}
process, start_err := os.process_start(os.Process_Desc{
command = command,
stdout = stdout_w,
})
_ = os.close(stdout_w)
if start_err != nil {
fmt.println("daemon start failed:", start_err)
_ = os.close(stdout_r)
return child, false
}
child = Daemon_Process{process = process, stdout = stdout_r, running = true}
return child, true
}
daemon_stop_process :: proc(child: ^Daemon_Process) {
if child.stdout != nil {
_ = os.close(child.stdout)
child.stdout = nil
}
if child.running {
_ = os.process_terminate(child.process)
state, wait_err := os.process_wait(child.process, 2 * time.Second)
if wait_err != nil || !state.exited {
_ = os.process_kill(child.process)
_, _ = os.process_wait(child.process)
}
child.running = false
}
}
daemon_read_port :: proc(child: ^Daemon_Process) -> (int, bool) {
line: [dynamic]u8
defer delete(line)
start := time.now()
buf: [1024]u8
for time.diff(start, time.now()) < 30 * time.Second {
state, wait_err := os.process_wait(child.process, 0)
if wait_err == nil && state.exited {
fmt.println("daemon exited before announcing port")
return 0, false
}
has_data, data_err := os.pipe_has_data(child.stdout)
if data_err != nil {
fmt.println("daemon stdout failed:", data_err)
return 0, false
}
if !has_data {
time.sleep(100 * time.Millisecond)
continue
}
n, read_err := os.read(child.stdout, buf[:])
if read_err != nil {
fmt.println("daemon stdout read failed:", read_err)
return 0, false
}
for b in buf[:n] {
if b == '\n' {
port, ok := daemon_parse_port_line(string(line[:]))
if ok do return port, true
clear(&line)
} else {
append(&line, b)
}
}
}
fmt.println("daemon start timed out")
return 0, false
}
daemon_poll_port :: proc(child: ^Daemon_Process, line: ^[dynamic]u8) -> (int, bool, bool) {
if child == nil || !child.running || child.stdout == nil do return 0, false, true
state, wait_err := os.process_wait(child.process, 0)
if wait_err == nil && state.exited {
fmt.println("daemon exited before announcing port")
return 0, false, true
}
has_data, data_err := os.pipe_has_data(child.stdout)
if data_err != nil {
fmt.println("daemon stdout failed:", data_err)
return 0, false, true
}
if !has_data do return 0, false, false
buf: [1024]u8
n, read_err := os.read(child.stdout, buf[:])
if read_err != nil {
fmt.println("daemon stdout read failed:", read_err)
return 0, false, true
}
for b in buf[:n] {
if b == '\n' {
port, ok := daemon_parse_port_line(string(line[:]))
clear(line)
if ok do return port, true, false
} else {
append(line, b)
}
}
return 0, false, false
}
daemon_parse_port_line :: proc(line: string) -> (int, bool) {
if !strings.has_prefix(line, "PORT ") do return 0, false
port, ok := strconv.parse_int(line[len("PORT "):])
if !ok do return 0, false
return int(port), true
}
daemon_start_reader :: proc(client: ^Daemon_Client) {
if client.connected && client.reader == nil {
client.reader = thread.create_and_start_with_data(rawptr(client), daemon_reader_thread)
}
}
daemon_close :: proc(client: ^Daemon_Client) {
if client.connected {
net.close(client.socket)
client.connected = false
}
if client.reader != nil && thread.is_done(client.reader) {
thread.destroy(client.reader)
client.reader = nil
}
for &event in client.diagnostic_events {
delete(event.line)
}
delete(client.diagnostic_events)
client.diagnostic_events = nil
for &event in client.gradle_events {
delete(event.line)
}
delete(client.gradle_events)
client.gradle_events = nil
delete(client.pending_response_ids)
client.pending_response_ids = nil
for &response in client.responses {
delete(response.line)
}
delete(client.responses)
client.responses = nil
}
daemon_send :: proc(client: ^Daemon_Client, message: string) {
if !client.connected do return
bytes := transmute([]byte)message
_, err := net.send_tcp(client.socket, bytes)
if err != nil {
fmt.println("daemon send failed:", err)
daemon_close(client)
}
}
daemon_track_response :: proc(client: ^Daemon_Client, id: int) {
if id == 0 do return
if sync.mutex_guard(&client.response_mutex) {
append(&client.pending_response_ids, id)
}
}
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)
daemon_send(client, request)
}
daemon_send_text_open :: proc(client: ^Daemon_Client, path: string, version: int, text: string) {
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)
daemon_send(client, request)
}
daemon_send_text_change :: proc(client: ^Daemon_Client, path: string, version: int, text: string) {
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)
daemon_send(client, request)
}
daemon_send_completion :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
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)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_hover :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
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)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_definition :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
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)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_references :: proc(client: ^Daemon_Client, path: string, line, column: int) -> int {
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)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_rename :: proc(client: ^Daemon_Client, path: string, line, column: int, new_name: string) -> int {
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)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_send_gradle_tasks :: proc(client: ^Daemon_Client) -> int {
if !client.connected do return 0
id := client.next_id
client.next_id += 1
request := fmt.tprintf("{{\"id\":%d,\"method\":\"gradle/tasks\",\"params\":{{}}}}\n", id)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
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)
daemon_track_response(client, id)
daemon_send(client, request)
return id
}
daemon_sync_active_buffer :: proc(client: ^Daemon_Client, editor: ^Editor, open: bool) {
active := editor_active_buffer(editor)
if active == nil do return
text := editor_active_text(editor)
defer delete(text)
if open {
daemon_send_text_open(client, active.path, active.buffer.version, string(text[:]))
} else {
daemon_send_text_change(client, active.path, active.buffer.version, string(text[:]))
}
}
daemon_reader_thread :: proc(data: rawptr) {
client := (^Daemon_Client)(data)
buf: [4096]u8
line: [dynamic]u8
defer delete(line)
for client.connected {
n, err := net.recv_tcp(client.socket, buf[:])
if err != nil || n == 0 {
client.connected = false
break
}
for b in buf[:n] {
if b == '\n' {
daemon_store_line(client, string(line[:]))
clear(&line)
} else {
append(&line, b)
}
}
}
}
daemon_store_line :: proc(client: ^Daemon_Client, line: string) {
message, value, ok := parse_protocol_message(line)
if !ok do return
defer json.destroy_value(value)
if message.kind == .Response {
if sync.mutex_guard(&client.response_mutex) {
tracked := false
for pending_id, index in client.pending_response_ids {
if pending_id == message.id {
ordered_remove(&client.pending_response_ids, index)
tracked = true
break
}
}
if !tracked do return
response := Daemon_Response{id = message.id}
for b in transmute([]u8)line {
append(&response.line, b)
}
append(&client.responses, response)
}
return
}
if message.kind != .Event do return
if message.event == "gradle/run" {
if sync.mutex_guard(&client.event_mutex) {
event := Daemon_Event{}
for b in transmute([]u8)line {
append(&event.line, b)
}
append(&client.gradle_events, event)
}
return
}
if message.event != "diagnostics/publish" do return
if sync.mutex_guard(&client.event_mutex) {
event := Daemon_Event{}
for b in transmute([]u8)line {
append(&event.line, b)
}
append(&client.diagnostic_events, event)
}
}
daemon_take_response :: proc(client: ^Daemon_Client, expected_id: int) -> [dynamic]u8 {
out: [dynamic]u8
if expected_id == 0 do return out
if sync.mutex_guard(&client.response_mutex) {
for &response, index in client.responses {
if response.id != expected_id do continue
for b in response.line {
append(&out, b)
}
delete(response.line)
ordered_remove(&client.responses, index)
break
}
}
return out
}
daemon_take_diagnostic_event :: proc(client: ^Daemon_Client) -> [dynamic]u8 {
out: [dynamic]u8
if !client.connected && len(client.diagnostic_events) == 0 do return out
if sync.mutex_guard(&client.event_mutex) {
if len(client.diagnostic_events) == 0 do return out
event := client.diagnostic_events[0]
for b in event.line {
append(&out, b)
}
delete(event.line)
ordered_remove(&client.diagnostic_events, 0)
}
return out
}
daemon_take_gradle_event :: proc(client: ^Daemon_Client) -> [dynamic]u8 {
out: [dynamic]u8
if !client.connected && len(client.gradle_events) == 0 do return out
if sync.mutex_guard(&client.event_mutex) {
if len(client.gradle_events) == 0 do return out
event := client.gradle_events[0]
for b in event.line {
append(&out, b)
}
delete(event.line)
ordered_remove(&client.gradle_events, 0)
}
return out
}
daemon_apply_latest_diagnostics :: proc(client: ^Daemon_Client, editor: ^Editor) {
for {
event := daemon_take_diagnostic_event(client)
if len(event) == 0 {
delete(event)
return
}
path, diagnostics := parse_diagnostics_event(string(event[:]))
if len(path) == 0 || !editor_set_diagnostics_for_path(editor, path, diagnostics[:]) {
diagnostics_destroy(diagnostics[:])
}
delete(path)
delete(diagnostics)
delete(event)
}
}
diagnostics_destroy :: proc(diagnostics: []Diagnostic) {
for diagnostic in diagnostics {
delete(diagnostic.severity)
delete(diagnostic.message)
}
}
parse_diagnostics_event :: proc(event: string) -> (string, [dynamic]Diagnostic) {
diagnostics: [dynamic]Diagnostic
message, value, parsed := parse_protocol_message(event)
if !parsed do return "", diagnostics
defer json.destroy_value(value)
if message.kind != .Event || message.event != "diagnostics/publish" do return "", diagnostics
params, has_params := json_object_get(value, "params")
if !has_params do return "", diagnostics
path, _ := json_get_string(params, "path")
diagnostics_value, has_diagnostics := json_object_get(params, "diagnostics")
if !has_diagnostics do return strings.clone(path), diagnostics
#partial switch items in diagnostics_value {
case json.Array:
for item in items {
severity, _ := json_get_string(item, "severity")
diagnostic_message, _ := json_get_string(item, "message")
line, _ := json_get_int(item, "line")
column, _ := json_get_int(item, "column")
append(&diagnostics, Diagnostic{
line = max_int(line - 1, 0),
column = max_int(column - 1, 0),
severity = strings.clone(severity),
message = strings.clone(diagnostic_message),
})
}
}
return strings.clone(path), diagnostics
}
parse_protocol_message :: proc(line: string) -> (Protocol_Message, json.Value, bool) {
value, err := json.parse_string(line, .JSON, true)
if err != nil {
return Protocol_Message{}, value, false
}
message := Protocol_Message{}
if event, ok := json_get_string(value, "event"); ok {
message.kind = .Event
message.event = event
return message, value, true
}
if id, ok := json_get_int(value, "id"); ok {
message.kind = .Response
message.id = id
message.ok, _ = json_get_bool(value, "ok")
return message, value, true
}
return message, value, false
}
json_object_get :: proc(value: json.Value, key: string) -> (json.Value, bool) {
#partial switch object in value {
case json.Object:
if item, ok := object[key]; ok {
return item, true
}
}
return json.Value{}, false
}
json_get_string :: proc(value: json.Value, key: string) -> (string, bool) {
item, ok := json_object_get(value, key)
if !ok do return "", false
#partial switch s in item {
case json.String:
return string(s), true
}
return "", false
}
json_get_int :: proc(value: json.Value, key: string) -> (int, bool) {
item, ok := json_object_get(value, key)
if !ok do return 0, false
#partial switch n in item {
case json.Integer:
return int(n), true
case json.Float:
return int(n), true
}
return 0, false
}
json_get_bool :: proc(value: json.Value, key: string) -> (bool, bool) {
item, ok := json_object_get(value, key)
if !ok do return false, false
#partial switch b in item {
case json.Boolean:
return bool(b), true
}
return false, false
}