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

538
client/odin/buffer.odin Normal file
View file

@ -0,0 +1,538 @@
package main
Piece_Source :: enum {
Original,
Add,
}
Piece :: struct {
source: Piece_Source,
start: int,
len: int,
}
Line_Index :: struct {
starts: [dynamic]int,
dirty: bool,
}
Buffer_Edit_Kind :: enum {
Insert,
Delete,
Replace,
}
Buffer_Edit :: struct {
kind: Buffer_Edit_Kind,
offset: int,
text: [dynamic]u8,
replacement: [dynamic]u8,
}
Buffer :: struct {
original: string,
add: [dynamic]u8,
pieces: [dynamic]Piece,
undo: [dynamic]Buffer_Edit,
redo: [dynamic]Buffer_Edit,
line_index: Line_Index,
version: int,
}
Cursor :: struct {
offset: int,
wanted_column: int,
}
buffer_make :: proc(original: string) -> Buffer {
buffer: Buffer
buffer.original = original
buffer.line_index.dirty = true
if len(original) > 0 {
append(&buffer.pieces, Piece{.Original, 0, len(original)})
}
return buffer
}
buffer_len :: proc(buffer: ^Buffer) -> int {
total := 0
for piece in buffer.pieces {
total += piece.len
}
return total
}
buffer_insert :: proc(buffer: ^Buffer, offset: int, text: string) {
if len(text) == 0 do return
edit_text := clone_bytes(transmute([]u8)text)
buffer_push_edit(&buffer.undo, Buffer_Edit{kind = .Insert, offset = clamp_int(offset, 0, buffer_len(buffer)), text = edit_text})
buffer_clear_edits(&buffer.redo)
buffer_insert_raw(buffer, offset, text)
}
buffer_insert_raw :: proc(buffer: ^Buffer, offset: int, text: string) {
buffer_insert_raw_internal(buffer, offset, text, true)
}
buffer_insert_raw_internal :: proc(buffer: ^Buffer, offset: int, text: string, bump_version: bool) {
if len(text) == 0 do return
insert_at := clamp_int(offset, 0, buffer_len(buffer))
add_start := len(buffer.add)
text_bytes := transmute([]u8)text
for b in text_bytes {
append(&buffer.add, b)
}
new_piece := Piece{.Add, add_start, len(text_bytes)}
new_pieces: [dynamic]Piece
inserted := false
cursor := 0
for piece in buffer.pieces {
piece_end := cursor + piece.len
if !inserted && insert_at <= piece_end {
inner := insert_at - cursor
if inner > 0 {
append(&new_pieces, Piece{piece.source, piece.start, inner})
}
append(&new_pieces, new_piece)
if inner < piece.len {
append(&new_pieces, Piece{piece.source, piece.start + inner, piece.len - inner})
}
inserted = true
} else {
append(&new_pieces, piece)
}
cursor = piece_end
}
if !inserted {
append(&new_pieces, new_piece)
}
delete(buffer.pieces)
buffer.pieces = new_pieces
if bump_version do buffer.version += 1
buffer.line_index.dirty = true
}
buffer_delete_range :: proc(buffer: ^Buffer, offset: int, count: int) {
if count <= 0 do return
start := clamp_int(offset, 0, buffer_len(buffer))
end := clamp_int(start + count, start, buffer_len(buffer))
if start == end do return
deleted := buffer_range_bytes(buffer, start, end - start)
buffer_push_edit(&buffer.undo, Buffer_Edit{kind = .Delete, offset = start, text = deleted})
buffer_clear_edits(&buffer.redo)
buffer_delete_range_raw(buffer, start, end - start)
}
buffer_replace_range :: proc(buffer: ^Buffer, offset: int, count: int, replacement: string) {
start := clamp_int(offset, 0, buffer_len(buffer))
end := clamp_int(start + max_int(count, 0), start, buffer_len(buffer))
if start == end && len(replacement) == 0 do return
deleted := buffer_range_bytes(buffer, start, end - start)
if bytes_equal(deleted[:], transmute([]u8)replacement) {
delete(deleted)
return
}
replacement_bytes := clone_bytes(transmute([]u8)replacement)
buffer_push_edit(&buffer.undo, Buffer_Edit{kind = .Replace, offset = start, text = deleted, replacement = replacement_bytes})
buffer_clear_edits(&buffer.redo)
buffer_delete_range_raw_internal(buffer, start, end - start, false)
buffer_insert_raw_internal(buffer, start, replacement, false)
buffer.version += 1
buffer.line_index.dirty = true
}
buffer_delete_range_raw :: proc(buffer: ^Buffer, offset: int, count: int) {
buffer_delete_range_raw_internal(buffer, offset, count, true)
}
buffer_delete_range_raw_internal :: proc(buffer: ^Buffer, offset: int, count: int, bump_version: bool) {
if count <= 0 do return
start := clamp_int(offset, 0, buffer_len(buffer))
end := clamp_int(start + count, start, buffer_len(buffer))
if start == end do return
new_pieces: [dynamic]Piece
cursor := 0
for piece in buffer.pieces {
piece_start := cursor
piece_end := cursor + piece.len
if piece_end <= start || piece_start >= end {
append(&new_pieces, piece)
} else {
keep_left := max_int(0, start - piece_start)
keep_right := max_int(0, piece_end - end)
if keep_left > 0 {
append(&new_pieces, Piece{piece.source, piece.start, keep_left})
}
if keep_right > 0 {
right_start := piece.start + piece.len - keep_right
append(&new_pieces, Piece{piece.source, right_start, keep_right})
}
}
cursor = piece_end
}
delete(buffer.pieces)
buffer.pieces = new_pieces
if bump_version do buffer.version += 1
buffer.line_index.dirty = true
}
buffer_undo :: proc(buffer: ^Buffer, cursor: ^Cursor) -> bool {
edit, ok := buffer_pop_edit(&buffer.undo)
if !ok do return false
switch edit.kind {
case .Insert:
buffer_delete_range_raw(buffer, edit.offset, len(edit.text))
cursor.offset = edit.offset
case .Delete:
buffer_insert_raw(buffer, edit.offset, string(edit.text[:]))
cursor.offset = edit.offset + len(edit.text)
case .Replace:
buffer_delete_range_raw_internal(buffer, edit.offset, len(edit.replacement), false)
buffer_insert_raw_internal(buffer, edit.offset, string(edit.text[:]), false)
buffer.version += 1
buffer.line_index.dirty = true
cursor.offset = edit.offset + len(edit.text)
}
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
buffer_push_edit(&buffer.redo, edit)
return true
}
buffer_redo :: proc(buffer: ^Buffer, cursor: ^Cursor) -> bool {
edit, ok := buffer_pop_edit(&buffer.redo)
if !ok do return false
switch edit.kind {
case .Insert:
buffer_insert_raw(buffer, edit.offset, string(edit.text[:]))
cursor.offset = edit.offset + len(edit.text)
case .Delete:
buffer_delete_range_raw(buffer, edit.offset, len(edit.text))
cursor.offset = edit.offset
case .Replace:
buffer_delete_range_raw_internal(buffer, edit.offset, len(edit.text), false)
buffer_insert_raw_internal(buffer, edit.offset, string(edit.replacement[:]), false)
buffer.version += 1
buffer.line_index.dirty = true
cursor.offset = edit.offset + len(edit.replacement)
}
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
buffer_push_edit(&buffer.undo, edit)
return true
}
buffer_bytes :: proc(buffer: ^Buffer) -> [dynamic]u8 {
out: [dynamic]u8
for piece in buffer.pieces {
switch piece.source {
case .Original:
chunk := buffer.original[piece.start:piece.start + piece.len]
chunk_bytes := transmute([]u8)chunk
for b in chunk_bytes {
append(&out, b)
}
case .Add:
for b in buffer.add[piece.start:piece.start + piece.len] {
append(&out, b)
}
}
}
return out
}
buffer_rebuild_line_index :: proc(buffer: ^Buffer) {
delete(buffer.line_index.starts)
buffer.line_index.starts = make([dynamic]int)
append(&buffer.line_index.starts, 0)
text := buffer_bytes(buffer)
defer delete(text)
for b, i in text {
if b == '\n' {
append(&buffer.line_index.starts, i + 1)
}
}
buffer.line_index.dirty = false
}
buffer_line_count :: proc(buffer: ^Buffer) -> int {
if buffer.line_index.dirty {
buffer_rebuild_line_index(buffer)
}
return len(buffer.line_index.starts)
}
buffer_line_start :: proc(buffer: ^Buffer, line: int) -> int {
if buffer.line_index.dirty {
buffer_rebuild_line_index(buffer)
}
if len(buffer.line_index.starts) == 0 do return 0
index := clamp_int(line, 0, len(buffer.line_index.starts) - 1)
return buffer.line_index.starts[index]
}
buffer_line_end :: proc(buffer: ^Buffer, line: int) -> int {
if buffer.line_index.dirty {
buffer_rebuild_line_index(buffer)
}
line_count := len(buffer.line_index.starts)
if line_count == 0 do return 0
index := clamp_int(line, 0, line_count - 1)
if index + 1 < line_count {
return max_int(buffer.line_index.starts[index], buffer.line_index.starts[index + 1] - 1)
}
return buffer_len(buffer)
}
buffer_offset_to_line_col :: proc(buffer: ^Buffer, offset: int) -> (line: int, column: int) {
if buffer.line_index.dirty {
buffer_rebuild_line_index(buffer)
}
target := clamp_int(offset, 0, buffer_len(buffer))
result := 0
for start, i in buffer.line_index.starts {
if start > target do break
result = i
}
return result, target - buffer.line_index.starts[result]
}
buffer_line_col_to_offset :: proc(buffer: ^Buffer, line: int, column: int) -> int {
start := buffer_line_start(buffer, line)
end := buffer_line_end(buffer, line)
return clamp_int(start + max_int(column, 0), start, end)
}
buffer_line_bytes :: proc(buffer: ^Buffer, line: int) -> [dynamic]u8 {
start := buffer_line_start(buffer, line)
end := buffer_line_end(buffer, line)
return buffer_range_bytes(buffer, start, end - start)
}
buffer_range_bytes :: proc(buffer: ^Buffer, offset: int, count: int) -> [dynamic]u8 {
out: [dynamic]u8
if count <= 0 do return out
start := clamp_int(offset, 0, buffer_len(buffer))
end := clamp_int(start + count, start, buffer_len(buffer))
cursor := 0
for piece in buffer.pieces {
piece_start := cursor
piece_end := cursor + piece.len
if piece_end > start && piece_start < end {
local_start := max_int(start - piece_start, 0)
local_end := piece.len - max_int(piece_end - end, 0)
switch piece.source {
case .Original:
chunk := buffer.original[piece.start + local_start:piece.start + local_end]
chunk_bytes := transmute([]u8)chunk
for b in chunk_bytes {
append(&out, b)
}
case .Add:
for b in buffer.add[piece.start + local_start:piece.start + local_end] {
append(&out, b)
}
}
}
cursor = piece_end
}
return out
}
cursor_insert :: proc(buffer: ^Buffer, cursor: ^Cursor, text: string) {
buffer_insert(buffer, cursor.offset, text)
cursor.offset += len(text)
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
}
cursor_backspace :: proc(buffer: ^Buffer, cursor: ^Cursor) {
if cursor.offset == 0 do return
buffer_delete_range(buffer, cursor.offset - 1, 1)
cursor.offset -= 1
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
}
cursor_delete_forward :: proc(buffer: ^Buffer, cursor: ^Cursor) -> bool {
if cursor.offset >= buffer_len(buffer) do return false
buffer_delete_range(buffer, cursor.offset, 1)
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
return true
}
cursor_insert_newline_auto_indent :: proc(buffer: ^Buffer, cursor: ^Cursor) {
line, _ := buffer_offset_to_line_col(buffer, cursor.offset)
line_bytes := buffer_line_bytes(buffer, line)
defer delete(line_bytes)
indent_len := 0
for b in line_bytes {
if b == ' ' || b == '\t' {
indent_len += 1
} else {
break
}
}
text: [dynamic]u8
defer delete(text)
append(&text, '\n')
for b in line_bytes[:indent_len] {
append(&text, b)
}
cursor_insert(buffer, cursor, string(text[:]))
}
cursor_move_word_left :: proc(buffer: ^Buffer, cursor: ^Cursor) {
text := buffer_bytes(buffer)
defer delete(text)
offset := clamp_int(cursor.offset, 0, len(text))
for offset > 0 && is_space_byte(text[offset - 1]) {
offset -= 1
}
if offset > 0 && is_identifier_byte(text[offset - 1]) {
for offset > 0 && is_identifier_byte(text[offset - 1]) {
offset -= 1
}
} else if offset > 0 {
offset -= 1
}
cursor.offset = offset
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
}
cursor_move_word_right :: proc(buffer: ^Buffer, cursor: ^Cursor) {
text := buffer_bytes(buffer)
defer delete(text)
offset := clamp_int(cursor.offset, 0, len(text))
if offset < len(text) && is_identifier_byte(text[offset]) {
for offset < len(text) && is_identifier_byte(text[offset]) {
offset += 1
}
} else if offset < len(text) {
offset += 1
}
for offset < len(text) && is_space_byte(text[offset]) {
offset += 1
}
cursor.offset = offset
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
}
cursor_move_to_line_col :: proc(buffer: ^Buffer, cursor: ^Cursor, line: int, column: int) {
cursor.offset = buffer_line_col_to_offset(buffer, line, column)
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
}
cursor_move_vertical :: proc(buffer: ^Buffer, cursor: ^Cursor, delta: int) {
line, _ := buffer_offset_to_line_col(buffer, cursor.offset)
target_line := clamp_int(line + delta, 0, buffer_line_count(buffer) - 1)
cursor.offset = buffer_line_col_to_offset(buffer, target_line, cursor.wanted_column)
}
is_identifier_byte :: proc(b: u8) -> bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
}
is_space_byte :: proc(b: u8) -> bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
buffer_destroy :: proc(buffer: ^Buffer) {
buffer_clear_edits(&buffer.undo)
buffer_clear_edits(&buffer.redo)
delete(buffer.add)
delete(buffer.pieces)
delete(buffer.line_index.starts)
}
buffer_push_edit :: proc(stack: ^[dynamic]Buffer_Edit, edit: Buffer_Edit) {
append(stack, edit)
}
buffer_pop_edit :: proc(stack: ^[dynamic]Buffer_Edit) -> (edit: Buffer_Edit, ok: bool) {
if len(stack^) == 0 do return {}, false
index := len(stack^) - 1
edit = stack^[index]
resize(stack, index)
return edit, true
}
buffer_clear_edits :: proc(stack: ^[dynamic]Buffer_Edit) {
for edit in stack^ {
delete(edit.text)
delete(edit.replacement)
}
clear(stack)
}
clone_bytes :: proc(bytes: []u8) -> [dynamic]u8 {
out: [dynamic]u8
for b in bytes {
append(&out, b)
}
return out
}
bytes_equal :: proc(a, b: []u8) -> bool {
if len(a) != len(b) do return false
for value, index in a {
if value != b[index] do return false
}
return true
}
clamp_int :: proc(value, low, high: int) -> int {
if value < low do return low
if value > high do return high
return value
}
max_int :: proc(a, b: int) -> int {
if a > b do return a
return b
}