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>
This commit is contained in:
pavel 2026-07-03 08:37:41 +02:00
commit 4a15350860
21 changed files with 8112 additions and 0 deletions

449
client/odin/editor.odin Normal file
View file

@ -0,0 +1,449 @@
package main
import "core:fmt"
import "core:os"
import "core:strings"
Diagnostic :: struct {
line: int,
column: int,
severity: string,
message: string,
}
Editor_Buffer :: struct {
path: string,
daemon_path: string,
original_storage: []u8,
buffer: Buffer,
cursor: Cursor,
dirty: bool,
saved_version: int,
selection_active: bool,
selection_anchor: int,
diagnostics: [dynamic]Diagnostic,
}
Editor :: struct {
buffers: [dynamic]Editor_Buffer,
active: int,
status: string,
}
editor_open_file :: proc(editor: ^Editor, path: string) -> bool {
data, err := os.read_entire_file(path, context.allocator)
if err != nil {
fmt.println("open file failed:", err)
return false
}
editor_buffer := Editor_Buffer{
path = strings.clone(path),
daemon_path = strings.clone(""),
original_storage = data,
buffer = buffer_make(string(data)),
saved_version = 0,
}
append(&editor.buffers, editor_buffer)
editor.active = len(editor.buffers) - 1
return true
}
editor_open_or_focus_file :: proc(editor: ^Editor, path: string) -> bool {
for &buffer, index in editor.buffers {
if buffer.path == path {
editor.active = index
return true
}
}
return editor_open_file(editor, path)
}
editor_replace_active_file :: proc(editor: ^Editor, path: string) -> bool {
active := editor_active_buffer(editor)
if active == nil do return false
if active.path == path do return true
for &buffer, index in editor.buffers {
if buffer.path == path {
if index != editor.active {
editor.buffers[editor.active], editor.buffers[index] = editor.buffers[index], editor.buffers[editor.active]
}
return true
}
}
if active.dirty {
editor_set_status(editor, "Save current buffer before navigating to another file")
return false
}
data, err := os.read_entire_file(path, context.allocator)
if err != nil {
fmt.println("open file failed:", err)
return false
}
editor_buffer_destroy(active)
active.path = strings.clone(path)
active.daemon_path = strings.clone("")
active.original_storage = data
active.buffer = buffer_make(string(data))
active.cursor = Cursor{}
active.dirty = false
active.saved_version = active.buffer.version
active.selection_active = false
active.selection_anchor = 0
active.diagnostics = make([dynamic]Diagnostic)
return true
}
editor_active_buffer :: proc(editor: ^Editor) -> ^Editor_Buffer {
if len(editor.buffers) == 0 do return nil
return &editor.buffers[editor.active]
}
editor_replace_active_text :: proc(editor: ^Editor, text: string) {
active := editor_active_buffer(editor)
if active == nil do return
buffer_replace_range(&active.buffer, 0, buffer_len(&active.buffer), text)
active.cursor.offset = len(text)
_, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
editor_clear_selection(active)
editor_update_dirty(active)
}
editor_active_text :: proc(editor: ^Editor) -> [dynamic]u8 {
active := editor_active_buffer(editor)
if active == nil {
empty: [dynamic]u8
return empty
}
return buffer_bytes(&active.buffer)
}
editor_clear_selection :: proc(active: ^Editor_Buffer) {
if active == nil do return
active.selection_active = false
active.selection_anchor = active.cursor.offset
}
editor_start_selection :: proc(active: ^Editor_Buffer) {
if active == nil do return
if !active.selection_active {
active.selection_active = true
active.selection_anchor = active.cursor.offset
}
}
editor_selection_range :: proc(active: ^Editor_Buffer) -> (start: int, end: int, ok: bool) {
if active == nil || !active.selection_active do return 0, 0, false
start = min_int(active.selection_anchor, active.cursor.offset)
end = max_int(active.selection_anchor, active.cursor.offset)
return start, end, start < end
}
editor_delete_selection :: proc(active: ^Editor_Buffer) -> bool {
start, end, ok := editor_selection_range(active)
if !ok do return false
buffer_delete_range(&active.buffer, start, end - start)
active.cursor.offset = start
_, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
editor_clear_selection(active)
return true
}
editor_insert_text :: proc(active: ^Editor_Buffer, text: string) {
if active == nil do return
selection_start, selection_end, has_selection := editor_selection_range(active)
if has_selection {
buffer_replace_range(&active.buffer, selection_start, selection_end - selection_start, text)
active.cursor.offset = selection_start + len(text)
_, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
editor_clear_selection(active)
editor_update_dirty(active)
return
}
cursor_insert(&active.buffer, &active.cursor, text)
editor_clear_selection(active)
editor_update_dirty(active)
}
editor_update_dirty :: proc(active: ^Editor_Buffer) {
if active == nil do return
active.dirty = active.buffer.version != active.saved_version
}
editor_outdent :: proc(active: ^Editor_Buffer) -> bool {
if active == nil do return false
selection_start, selection_end, has_selection := editor_selection_range(active)
start_line, _ := buffer_offset_to_line_col(&active.buffer, selection_start if has_selection else active.cursor.offset)
end_line, end_col := buffer_offset_to_line_col(&active.buffer, selection_end if has_selection else active.cursor.offset)
if has_selection && end_col == 0 && end_line > start_line {
end_line -= 1
}
total_removed := 0
cursor_removed_before := 0
anchor_removed_before := 0
cursor_offset := active.cursor.offset
anchor_offset := active.selection_anchor
for line := end_line; line >= start_line; line -= 1 {
line_start := buffer_line_start(&active.buffer, line)
line_end := buffer_line_end(&active.buffer, line)
remove_count := editor_line_outdent_count(&active.buffer, line_start, line_end)
if remove_count == 0 do continue
buffer_delete_range(&active.buffer, line_start, remove_count)
total_removed += remove_count
if line_start < cursor_offset {
cursor_removed_before += min_int(remove_count, cursor_offset - line_start)
}
if line_start < anchor_offset {
anchor_removed_before += min_int(remove_count, anchor_offset - line_start)
}
}
if total_removed == 0 do return false
active.cursor.offset = clamp_int(cursor_offset - cursor_removed_before, 0, buffer_len(&active.buffer))
if has_selection {
active.selection_anchor = clamp_int(anchor_offset - anchor_removed_before, 0, buffer_len(&active.buffer))
} else {
editor_clear_selection(active)
}
_, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
editor_update_dirty(active)
return true
}
editor_line_outdent_count :: proc(buffer: ^Buffer, line_start, line_end: int) -> int {
if line_start >= line_end do return 0
bytes := buffer_range_bytes(buffer, line_start, min_int(4, line_end - line_start))
defer delete(bytes)
remove_count := 0
for b in bytes {
if b == ' ' && remove_count < 4 {
remove_count += 1
} else if b == '\t' && remove_count == 0 {
return 1
} else {
break
}
}
return remove_count
}
editor_insert_newline_auto_indent :: proc(active: ^Editor_Buffer) {
if active == nil do return
selection_start, selection_end, has_selection := editor_selection_range(active)
if has_selection {
buffer_replace_range(&active.buffer, selection_start, selection_end - selection_start, "\n")
active.cursor.offset = selection_start + 1
_, active.cursor.wanted_column = buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
} else {
cursor_insert_newline_auto_indent(&active.buffer, &active.cursor)
}
editor_clear_selection(active)
editor_update_dirty(active)
}
editor_save_active :: proc(editor: ^Editor) -> bool {
active := editor_active_buffer(editor)
if active == nil do return false
text := buffer_bytes(&active.buffer)
defer delete(text)
err := os.write_entire_file(active.path, text[:])
if err != nil {
fmt.println("save failed:", err)
return false
}
active.saved_version = active.buffer.version
active.dirty = false
return true
}
editor_save_all :: proc(editor: ^Editor) -> bool {
ok := true
for &buffer in editor.buffers {
if !buffer.dirty do continue
text := buffer_bytes(&buffer.buffer)
err := os.write_entire_file(buffer.path, text[:])
delete(text)
if err != nil {
fmt.println("save failed:", buffer.path, err)
ok = false
} else {
buffer.saved_version = buffer.buffer.version
buffer.dirty = false
}
}
return ok
}
editor_close_active :: proc(editor: ^Editor) -> bool {
return editor_close_buffer(editor, editor.active)
}
editor_close_buffer :: proc(editor: ^Editor, index: int) -> bool {
if len(editor.buffers) <= 1 {
editor_set_status(editor, "Cannot close the last buffer")
return false
}
if index < 0 || index >= len(editor.buffers) do return false
active := &editor.buffers[index]
if active.dirty {
editor_set_status(editor, "Buffer has unsaved changes; save before closing")
return false
}
editor_buffer_destroy(active)
ordered_remove(&editor.buffers, index)
if editor.active > index {
editor.active -= 1
}
editor.active = clamp_int(editor.active, 0, len(editor.buffers) - 1)
editor_set_status(editor, "Closed buffer")
return true
}
editor_set_status :: proc(editor: ^Editor, message: string) {
delete(editor.status)
editor.status = strings.clone(message)
}
editor_set_active_diagnostics :: proc(editor: ^Editor, diagnostics: []Diagnostic) {
active := editor_active_buffer(editor)
if active == nil do return
editor_buffer_set_diagnostics(active, diagnostics)
}
editor_set_diagnostics_for_path :: proc(editor: ^Editor, path: string, diagnostics: []Diagnostic) -> bool {
for &buffer in editor.buffers {
if buffer.path == path || buffer.daemon_path == path {
editor_buffer_set_diagnostics(&buffer, diagnostics)
if buffer.daemon_path != path {
delete(buffer.daemon_path)
buffer.daemon_path = strings.clone(path)
}
return true
}
}
_, target_name := os.split_path(path)
match_index := -1
match_count := 0
for &buffer, index in editor.buffers {
_, buffer_name := os.split_path(buffer.path)
if buffer_name == target_name {
match_index = index
match_count += 1
}
}
if match_count == 1 {
editor_buffer_set_diagnostics(&editor.buffers[match_index], diagnostics)
delete(editor.buffers[match_index].daemon_path)
editor.buffers[match_index].daemon_path = strings.clone(path)
return true
}
return false
}
editor_clear_all_diagnostics :: proc(editor: ^Editor) {
for &buffer in editor.buffers {
editor_buffer_set_diagnostics(&buffer, []Diagnostic{})
}
}
editor_buffer_set_diagnostics :: proc(buffer: ^Editor_Buffer, diagnostics: []Diagnostic) {
if buffer == nil do return
for diagnostic in buffer.diagnostics {
delete(diagnostic.severity)
delete(diagnostic.message)
}
delete(buffer.diagnostics)
buffer.diagnostics = make([dynamic]Diagnostic)
for diagnostic in diagnostics {
append(&buffer.diagnostics, diagnostic)
}
}
editor_diagnostic_on_line :: proc(editor: ^Editor, line: int) -> (diagnostic: Diagnostic, ok: bool) {
active := editor_active_buffer(editor)
if active == nil do return {}, false
for diagnostic in active.diagnostics {
if diagnostic.line == line {
return diagnostic, true
}
}
return {}, false
}
editor_diagnostic_counts :: proc(buffer: ^Editor_Buffer) -> (errors, warnings, infos: int) {
if buffer == nil do return 0, 0, 0
for diagnostic in buffer.diagnostics {
switch diagnostic.severity {
case "error":
errors += 1
case "warning":
warnings += 1
case:
infos += 1
}
}
return
}
editor_print_visible :: proc(editor: ^Editor, first_line, line_count: int) {
active := editor_active_buffer(editor)
if active == nil do return
last_line := min_int(first_line + line_count, buffer_line_count(&active.buffer))
for line := first_line; line < last_line; line += 1 {
bytes := buffer_line_bytes(&active.buffer, line)
fmt.printf("%4d | %s\n", line + 1, string(bytes[:]))
delete(bytes)
}
}
editor_destroy :: proc(editor: ^Editor) {
for &editor_buffer in editor.buffers {
editor_buffer_destroy(&editor_buffer)
}
delete(editor.buffers)
delete(editor.status)
}
editor_buffer_destroy :: proc(editor_buffer: ^Editor_Buffer) {
delete(editor_buffer.path)
delete(editor_buffer.daemon_path)
buffer_destroy(&editor_buffer.buffer)
delete(editor_buffer.original_storage)
for diagnostic in editor_buffer.diagnostics {
delete(diagnostic.severity)
delete(diagnostic.message)
}
delete(editor_buffer.diagnostics)
}
min_int :: proc(a, b: int) -> int {
if a < b do return a
return b
}