This commit is contained in:
pavel 2026-07-09 08:27:13 +02:00
commit 985aa16236
13 changed files with 1381 additions and 241 deletions

View file

@ -66,7 +66,7 @@ Use `Ctrl+W` to close the active buffer, or click a tab's `x`. Dirty buffers are
SDL mode creates an SDL3 `GPUDevice` and uses a direct SDL3 GPU command-buffer renderer when a GPU backend is available. It batches editor rectangles, selections, cursor lines, panels, and `stb_truetype` font-atlas text into GPU vertices. It falls back to the regular SDL renderer in unsupported environments such as `SDL_VIDEODRIVER=dummy` smoke tests.
The bundled Noto Sans Mono font is embedded into the client binary at build time, so the editor always has a working monospace font. Place a `client/odin/assets/fonts/EditorMono.ttf` file to override it.
The bundled Noto Sans Mono font is embedded into the client binary at build time, so the editor always has a working monospace font. Set `NATIVE_EDITOR_FONT=/path/to/font.ttf`, set `fontPath` in `~/.config/native-kotlin-editor/settings.json`, or place a `client/odin/assets/fonts/EditorMono.ttf` file to override it. `NATIVE_EDITOR_FONT` has priority over the settings file.
The GPU editor path includes lightweight Kotlin/Java syntax highlighting for comments, block comments, strings, character literals, triple-quoted Kotlin strings, keywords, numbers, and type-like identifiers.
@ -76,7 +76,7 @@ Use `F8` and `Shift+F8` to move to the next/previous diagnostic in the active fi
Use `Ctrl+Shift+M` to toggle the diagnostics panel. Use Up/Down, PageUp/PageDown, Home/End, mouse wheel, Enter, or row clicks to navigate issues.
Use `Ctrl+Shift+P` to open the command palette. Type to filter commands, use Up/Down, PageUp/PageDown, Home/End, mouse wheel, Enter, or row clicks to run actions. The palette includes Reload Workspace, which refreshes the file tree, reopens the Gradle workspace, clears stale diagnostics, and resyncs the active file.
Use `Ctrl+Shift+P` to open the command palette. Type to filter commands, use Up/Down, PageUp/PageDown, Home/End, mouse wheel, Enter, or row clicks to run actions. The palette includes New File and Reload Workspace. Reload Workspace refreshes the file tree, reopens the Gradle workspace, clears stale diagnostics, and resyncs the active file.
Gradle workspace import records module source roots, test source roots, resource roots, classpath entries, tasks, and common generated source directories when they already exist under `build/generated/`. Diagnostics also include existing module output directories from `build/classes/` and `build/resources/` on their compiler classpath.
@ -118,10 +118,10 @@ Press `Ctrl+Space` to open the completion popup. It sends `kotlin/completion` to
Press `Ctrl+H` to request hover information at the cursor. Hover describes language keywords and shows heuristic Kotlin/Java declaration lines/locations for identifiers when found in the current buffer or workspace.
Press `Ctrl+B` to go to definition. Definition currently uses a heuristic source scan for simple Kotlin/Java declarations and includes the current unsaved buffer.
Press `Ctrl+B` to go to definition. Kotlin definition, hover, and references first try standalone Kotlin Analysis API symbol resolution, then fall back to the heuristic source index; Java navigation currently uses the heuristic source scan.
Use `Alt+Left` and `Alt+Right` to navigate backward and forward through definition jumps.
Press `Ctrl+R` to find references for the identifier at the cursor. References currently use a heuristic source scan and are shown in a popup; use Up/Down, PageUp/PageDown, Home/End, mouse wheel, Enter, or row clicks to jump to a result.
Press `Ctrl+Shift+R` to open the rename preview panel. Enter a new name and press Enter to request preview edits. Rename remains preview-only and does not modify files yet.
Press `Ctrl+Shift+R` to open the rename panel. Enter a new name and press Enter to request preview edits; press Enter again to apply the preview. Open dirty buffers must be saved first; unopened files are verified against the preview before being written.

View file

@ -61,12 +61,12 @@
- Implement `kotlin/completion` in the daemon. Started: prefix-filtered Kotlin keywords plus heuristic current-buffer/workspace identifiers and declarations.
- Add completion popup in SDL UI. Done: popup renders returned items and applies the selected completion with Enter or row click, using a single undoable replace edit.
- Add keyboard navigation in completion popup. Done.
- Implement `kotlin/hover`. Started: keyword hover plus heuristic declaration line/location lookup from the current buffer and workspace.
- Implement `kotlin/hover`. Started: Kotlin Analysis API symbol resolution with heuristic declaration fallback.
- Render hover tooltip. Done.
- Implement `kotlin/definition`. Started: heuristic simple declaration scan.
- Implement `kotlin/definition`. Started: Kotlin Analysis API symbol resolution with heuristic declaration fallback.
- Open target file and jump to line/column. Done.
- Add back/forward navigation stack. Done.
- Add find references. Started: heuristic source scan with SDL references popup.
- Add find references. Started: Kotlin Analysis API symbol matching with heuristic source-scan fallback and SDL references popup.
- Add rename/refactor support. Started: daemon preview-only rename edits with SDL preview panel.
- Add Java diagnostics/completion/navigation. Started: `.java` diagnostics use the JDK compiler with Gradle module classpath/sourcepath; completion/hover/definition/references scan Java source declarations heuristically.
@ -74,7 +74,7 @@
- Replace compiler-per-request diagnostics with long-lived analysis state.
- Cache Gradle module classpaths and source roots.
- Move toward Kotlin Analysis API where feasible.
- Move toward Kotlin Analysis API where feasible. Started: definition, hover, and references use standalone Kotlin Analysis API before heuristic fallback.
- Correctly model multi-module projects. Started: diagnostics choose the module with the most specific matching source root.
- Separate main/test source-set analysis. Started: diagnostics use main roots for main files and main+test roots for test files.
- Include generated sources. Started: common existing `build/generated/` Kotlin/Java roots, including KSP/Kotlin Multiplatform source-set layouts, are added to Gradle module source roots.
@ -110,10 +110,10 @@
7. Add completion popup skeleton. Done.
8. Wire completion responses into popup. Done.
9. Implement real `kotlin/completion` in the daemon. Started: prefix-filtered Kotlin keywords plus heuristic current-buffer/workspace identifiers and declarations.
10. Implement `kotlin/hover` and render hover tooltip. Started: keyword hover plus heuristic declaration line/location lookup from the current buffer and workspace.
11. Implement `kotlin/definition` and Ctrl+B navigation. Started: heuristic simple declaration scan.
10. Implement `kotlin/hover` and render hover tooltip. Started: Kotlin Analysis API symbol resolution with heuristic declaration fallback.
11. Implement `kotlin/definition` and Ctrl+B navigation. Started: Kotlin Analysis API symbol resolution with heuristic declaration fallback.
12. Add back/forward navigation stack. Done.
13. Add find references. Started: heuristic source scan with SDL references popup.
13. Add find references. Started: Kotlin Analysis API symbol matching with heuristic source-scan fallback and SDL references popup.
14. Add rename/refactor support. Started: daemon preview-only rename edits with SDL preview panel.
15. Add Ctrl+Left/Ctrl+Right word movement. Done.
16. Add click-to-move-cursor. Done.

View file

@ -78,6 +78,7 @@ Daemon_Client :: struct {
response_mutex: sync.Mutex,
diagnostic_events: [dynamic]Daemon_Event,
gradle_events: [dynamic]Daemon_Event,
workspace_response_id: int,
pending_response_ids: [dynamic]int,
responses: [dynamic]Daemon_Response,
}
@ -180,7 +181,7 @@ daemon_stop_process :: proc(child: ^Daemon_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)
_, _ = os.process_wait(child.process, 500 * time.Millisecond)
}
child.running = false
}
@ -284,7 +285,8 @@ daemon_close :: proc(client: ^Daemon_Client) {
net.close(client.socket)
client.connected = false
}
if client.reader != nil && thread.is_done(client.reader) {
if client.reader != nil {
thread.join(client.reader)
thread.destroy(client.reader)
client.reader = nil
}
@ -328,6 +330,8 @@ daemon_send_workspace_open :: proc(client: ^Daemon_Client, workspace: string) {
if !client.connected do return
id := client.next_id
client.next_id += 1
client.workspace_response_id = id
daemon_track_response(client, id)
request := fmt.tprintf("{{\"id\":%d,\"method\":\"workspace/open\",\"params\":{{\"root\":%s}}}}\n", id, json_quote(workspace))
daemon_send(client, request)
}
@ -572,6 +576,45 @@ daemon_apply_latest_diagnostics :: proc(client: ^Daemon_Client, editor: ^Editor)
}
}
daemon_apply_workspace_response :: proc(client: ^Daemon_Client, editor: ^Editor) {
if client.workspace_response_id == 0 do return
response := daemon_take_response(client, client.workspace_response_id)
defer delete(response)
if len(response) == 0 do return
message, value, ok := parse_protocol_message(string(response[:]))
if !ok {
client.workspace_response_id = 0
return
}
defer json.destroy_value(value)
if message.kind != .Response || message.id != client.workspace_response_id do return
client.workspace_response_id = 0
if message.ok {
editor_set_status(editor, "Kotlin daemon workspace ready")
return
}
error_message := protocol_error_message(value)
defer delete(error_message)
if len(error_message) > 0 {
editor_set_status(editor, fmt.tprintf("Kotlin daemon workspace failed: %s", error_message))
} else {
editor_set_status(editor, "Kotlin daemon workspace failed")
}
}
protocol_error_message :: proc(value: json.Value) -> string {
error_value, has_error := json_object_get(value, "error")
if !has_error do return strings.clone("")
message, _ := json_get_string(error_value, "message")
if len(message) > 0 do return strings.clone(message)
code, _ := json_get_string(error_value, "code")
return strings.clone(code)
}
diagnostics_destroy :: proc(diagnostics: []Diagnostic) {
for diagnostic in diagnostics {
delete(diagnostic.severity)

View file

@ -0,0 +1,26 @@
package main
import "core:time"
Daemon_Sync_State :: struct {
pending: bool,
last_edit: time.Time,
}
SDL_DAEMON_SYNC_DEBOUNCE_MS :: 250
daemon_sync_schedule :: proc(sync: ^Daemon_Sync_State) {
sync.pending = true
sync.last_edit = time.now()
}
daemon_sync_now :: proc(sync: ^Daemon_Sync_State, daemon: ^Daemon_Client, editor: ^Editor, open: bool) {
daemon_sync_active_buffer(daemon, editor, open)
sync.pending = false
}
daemon_sync_flush_if_due :: proc(sync: ^Daemon_Sync_State, daemon: ^Daemon_Client, editor: ^Editor) {
if !sync.pending || !daemon.connected do return
if time.diff(sync.last_edit, time.now()) < SDL_DAEMON_SYNC_DEBOUNCE_MS * time.Millisecond do return
daemon_sync_now(sync, daemon, editor, false)
}

View file

@ -207,7 +207,14 @@ editor_insert_text :: proc(active: ^Editor_Buffer, text: string) {
editor_update_dirty :: proc(active: ^Editor_Buffer) {
if active == nil do return
active.dirty = active.buffer.version != active.saved_version
if active.buffer.version == active.saved_version {
active.dirty = false
return
}
text := buffer_bytes(&active.buffer)
defer delete(text)
active.dirty = !bytes_equal(text[:], active.original_storage[:])
}
editor_outdent :: proc(active: ^Editor_Buffer) -> bool {
@ -300,6 +307,9 @@ editor_save_active :: proc(editor: ^Editor) -> bool {
return false
}
delete(active.original_storage)
saved_text := clone_bytes(text[:])
active.original_storage = saved_text[:]
active.saved_version = active.buffer.version
active.dirty = false
return true
@ -317,6 +327,9 @@ editor_save_all :: proc(editor: ^Editor) -> bool {
fmt.println("save failed:", buffer.path, err)
ok = false
} else {
delete(buffer.original_storage)
saved_text := clone_bytes(text[:])
buffer.original_storage = saved_text[:]
buffer.saved_version = buffer.buffer.version
buffer.dirty = false
}

View file

@ -617,6 +617,31 @@ gpu_create_font_atlas :: proc(gpu: ^GPU_Renderer) -> bool {
}
gpu_read_font_file :: proc() -> (bytes: []u8, path: string, owned: bool) {
env_font := os.get_env("NATIVE_EDITOR_FONT", context.temp_allocator)
if len(env_font) > 0 {
data, err := os.read_entire_file(env_font, context.allocator)
if err == nil && len(data) > 0 {
return data, env_font, true
}
if err == nil {
delete(data)
}
fmt.println("configured font could not be loaded:", env_font)
}
settings := editor_settings_load()
defer editor_settings_destroy(&settings)
if len(settings.font_path) > 0 {
data, err := os.read_entire_file(settings.font_path, context.allocator)
if err == nil && len(data) > 0 {
return data, settings.font_path, true
}
if err == nil {
delete(data)
}
fmt.println("settings font could not be loaded:", settings.font_path)
}
for override_path in GPU_FONT_OVERRIDE_PATHS {
data, err := os.read_entire_file(override_path, context.allocator)
if err == nil && len(data) > 0 {

View file

@ -4,6 +4,7 @@ import "core:fmt"
import "core:net"
import "core:os"
import "core:strconv"
import "core:strings"
main :: proc() {
args := os.args
@ -15,6 +16,13 @@ main :: proc() {
return
}
if len(args) >= 2 && args[1] == "--editor-selftest" {
if !editor_selftest() {
os.exit(1)
}
return
}
if len(args) >= 3 && args[1] == "--git-status-selftest" {
if !git_status_selftest(args[2]) {
os.exit(1)
@ -168,6 +176,88 @@ run_buffer_smoke :: proc() {
fmt.printf("buffer-cursor: %d:%d\n", line, col)
}
editor_selftest :: proc() -> bool {
ok := true
buffer := buffer_make("one two\nsecond\n")
defer buffer_destroy(&buffer)
cursor := Cursor{}
cursor_move_to_line_col(&buffer, &cursor, 0, 3)
cursor_insert(&buffer, &cursor, "!")
editor_selftest_check(&ok, "buffer insert", buffer_text_equals(&buffer, "one! two\nsecond\n"))
editor_selftest_check(&ok, "buffer undo", buffer_undo(&buffer, &cursor) && buffer_text_equals(&buffer, "one two\nsecond\n"))
editor_selftest_check(&ok, "buffer redo", buffer_redo(&buffer, &cursor) && buffer_text_equals(&buffer, "one! two\nsecond\n"))
version := buffer.version
buffer_replace_range(&buffer, 0, buffer_len(&buffer), "one! two\nsecond\n")
editor_selftest_check(&ok, "replace no-op", buffer.version == version)
editor := Editor{}
defer editor_destroy(&editor)
original := "alpha beta\n child\n"
original_bytes := clone_bytes(transmute([]u8)original)
append(&editor.buffers, Editor_Buffer{
path = strings.clone("selftest.kt"),
daemon_path = strings.clone(""),
original_storage = original_bytes[:],
buffer = buffer_make(string(original_bytes[:])),
saved_version = 0,
})
editor.active = 0
active := editor_active_buffer(&editor)
active.selection_active = true
active.selection_anchor = 0
active.cursor.offset = 5
editor_insert_text(active, "omega")
editor_selftest_check(&ok, "selection replace", buffer_text_equals(&active.buffer, "omega beta\n child\n") && active.dirty)
editor_selftest_check(&ok, "undo returns clean", buffer_undo(&active.buffer, &active.cursor))
editor_update_dirty(active)
editor_selftest_check(&ok, "dirty tracks saved text", buffer_text_equals(&active.buffer, original) && !active.dirty)
cursor_move_to_line_col(&active.buffer, &active.cursor, 1, 9)
editor_insert_newline_auto_indent(active)
editor_selftest_check(&ok, "auto indent newline", buffer_text_equals(&active.buffer, "alpha beta\n child\n \n"))
rename_path := "/tmp/opencode/native-editor-rename-selftest.txt"
_ = os.write_entire_file(rename_path, transmute([]u8)string("alpha beta\n"))
rename := Rename_Panel{open = true}
append(&rename.edits, Rename_Edit{
path = strings.clone(rename_path),
line = 1,
column = 7,
old_text = strings.clone("beta"),
new_text = strings.clone("gamma"),
label = strings.clone("native-editor-rename-selftest.txt:1:7"),
})
sync := Daemon_Sync_State{}
editor_selftest_check(&ok, "rename apply disk", rename_panel_apply_edits(&rename, &editor, &sync))
renamed_data, renamed_err := os.read_entire_file(rename_path, context.allocator)
if renamed_err == nil {
editor_selftest_check(&ok, "rename disk contents", bytes_equal(renamed_data[:], transmute([]u8)string("alpha gamma\n")))
delete(renamed_data)
} else {
editor_selftest_check(&ok, "rename disk contents", false)
}
rename_panel_destroy(&rename)
fmt.println(ok ? "editor selftest: PASS" : "editor selftest: FAIL")
return ok
}
editor_selftest_check :: proc(ok: ^bool, name: string, passed: bool) {
if passed {
fmt.println("ok ", name)
} else {
fmt.println("FAIL", name)
ok^ = false
}
}
buffer_text_equals :: proc(buffer: ^Buffer, expected: string) -> bool {
text := buffer_bytes(buffer)
defer delete(text)
return bytes_equal(text[:], transmute([]u8)expected)
}
send_line :: proc(socket: net.TCP_Socket, line: string) {
bytes := transmute([]byte)line
_, err := net.send_tcp(socket, bytes)

View file

@ -0,0 +1,283 @@
package main
import "core:fmt"
import "core:os"
import "core:strings"
import json "core:encoding/json"
Rename_Edit :: struct {
path: string,
line: int,
column: int,
old_text: string,
new_text: string,
label: string,
}
Rename_File_State :: struct {
path: string,
original_storage: []u8,
buffer: Buffer,
}
Rename_Panel :: struct {
open: bool,
pending_id: int,
input: [dynamic]u8,
summary: string,
edits: [dynamic]Rename_Edit,
}
rename_panel_destroy :: proc(panel: ^Rename_Panel) {
delete(panel.input)
delete(panel.summary)
rename_panel_clear_edits(panel)
delete(panel.edits)
}
rename_panel_clear_edits :: proc(panel: ^Rename_Panel) {
for edit in panel.edits {
delete(edit.path)
delete(edit.old_text)
delete(edit.new_text)
delete(edit.label)
}
clear(&panel.edits)
}
rename_panel_open :: proc(panel: ^Rename_Panel) {
panel.open = true
panel.pending_id = 0
clear(&panel.input)
delete(panel.summary)
panel.summary = strings.clone("Enter new name, then Enter for preview")
rename_panel_clear_edits(panel)
}
rename_panel_insert :: proc(panel: ^Rename_Panel, text: string) {
rename_panel_clear_edits(panel)
for b in transmute([]u8)text {
if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' {
append(&panel.input, b)
}
}
}
rename_panel_backspace :: proc(panel: ^Rename_Panel) {
rename_panel_clear_edits(panel)
if len(panel.input) > 0 {
resize(&panel.input, len(panel.input) - 1)
}
}
rename_panel_request :: proc(panel: ^Rename_Panel, editor: ^Editor, daemon: ^Daemon_Client) {
active := editor_active_buffer(editor)
if active == nil do return
if len(panel.input) == 0 {
rename_panel_set_summary(panel, "New name is empty")
return
}
line, column := buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
panel.pending_id = daemon_send_rename(daemon, active.path, line, column, string(panel.input[:]))
rename_panel_set_summary(panel, "Loading rename preview...")
}
rename_panel_apply_response :: proc(panel: ^Rename_Panel, daemon: ^Daemon_Client) {
if !panel.open || panel.pending_id == 0 do return
response := daemon_take_response(daemon, panel.pending_id)
defer delete(response)
if len(response) == 0 do return
summary, edits, ok := parse_rename_response(string(response[:]), panel.pending_id)
defer {
delete(summary)
rename_edits_destroy(edits[:])
delete(edits)
}
if !ok do return
panel.pending_id = 0
rename_panel_set_summary(panel, summary)
rename_panel_clear_edits(panel)
for edit in edits {
append(&panel.edits, rename_edit_clone(edit))
}
}
rename_panel_apply_edits :: proc(panel: ^Rename_Panel, editor: ^Editor, sync: ^Daemon_Sync_State) -> bool {
if !panel.open || len(panel.edits) == 0 do return false
file_states: [dynamic]Rename_File_State
defer rename_file_states_destroy(file_states[:])
defer delete(file_states)
for edit in panel.edits {
buffer := editor_buffer_for_rename_edit(editor, edit.path)
target_buffer: ^Buffer
if buffer != nil {
if buffer.dirty {
rename_panel_set_summary(panel, "Save affected buffers before applying rename")
return false
}
target_buffer = &buffer.buffer
} else {
file_state := rename_file_state_for_path(&file_states, edit.path)
if file_state == nil {
rename_panel_set_summary(panel, fmt.tprintf("Could not read rename target: %s", edit.path))
return false
}
target_buffer = &file_state.buffer
}
offset := buffer_line_col_to_offset(target_buffer, max_int(edit.line - 1, 0), max_int(edit.column - 1, 0))
existing := buffer_range_bytes(target_buffer, offset, len(edit.old_text))
matches := bytes_equal(existing[:], transmute([]u8)edit.old_text)
delete(existing)
if !matches {
rename_panel_set_summary(panel, "Rename preview is stale; request it again")
return false
}
}
for index := len(panel.edits) - 1; index >= 0; index -= 1 {
edit := panel.edits[index]
buffer := editor_buffer_for_rename_edit(editor, edit.path)
if buffer != nil {
offset := buffer_line_col_to_offset(&buffer.buffer, max_int(edit.line - 1, 0), max_int(edit.column - 1, 0))
buffer_replace_range(&buffer.buffer, offset, len(edit.old_text), edit.new_text)
buffer.cursor.offset = offset + len(edit.new_text)
_, buffer.cursor.wanted_column = buffer_offset_to_line_col(&buffer.buffer, buffer.cursor.offset)
editor_clear_selection(buffer)
editor_update_dirty(buffer)
} else if file_state := rename_file_state_find(file_states[:], edit.path); file_state != nil {
offset := buffer_line_col_to_offset(&file_state.buffer, max_int(edit.line - 1, 0), max_int(edit.column - 1, 0))
buffer_replace_range(&file_state.buffer, offset, len(edit.old_text), edit.new_text)
}
}
for &file_state in file_states {
text := buffer_bytes(&file_state.buffer)
err := os.write_entire_file(file_state.path, text[:])
delete(text)
if err != nil {
rename_panel_set_summary(panel, fmt.tprintf("Could not write rename target: %s", file_state.path))
return false
}
}
applied := len(panel.edits)
rename_panel_clear_edits(panel)
panel.open = false
editor_set_status(editor, fmt.tprintf("Applied rename preview (%d edits)", applied))
daemon_sync_schedule(sync)
return true
}
rename_file_state_for_path :: proc(states: ^[dynamic]Rename_File_State, path: string) -> ^Rename_File_State {
if existing := rename_file_state_find(states^[:], path); existing != nil {
return existing
}
data, err := os.read_entire_file(path, context.allocator)
if err != nil || len(data) == 0 {
if err == nil do delete(data)
return nil
}
append(states, Rename_File_State{
path = strings.clone(path),
original_storage = data,
buffer = buffer_make(string(data)),
})
return &states^[len(states^) - 1]
}
rename_file_state_find :: proc(states: []Rename_File_State, path: string) -> ^Rename_File_State {
for &state in states {
if state.path == path do return &state
}
return nil
}
rename_file_states_destroy :: proc(states: []Rename_File_State) {
for &state in states {
delete(state.path)
buffer_destroy(&state.buffer)
delete(state.original_storage)
}
}
editor_buffer_for_rename_edit :: proc(editor: ^Editor, path: string) -> ^Editor_Buffer {
for &buffer in editor.buffers {
if buffer.path == path || buffer.daemon_path == path {
return &buffer
}
}
return nil
}
rename_panel_set_summary :: proc(panel: ^Rename_Panel, message: string) {
delete(panel.summary)
panel.summary = strings.clone(message)
}
parse_rename_response :: proc(response: string, expected_id: int) -> (string, [dynamic]Rename_Edit, bool) {
edits: [dynamic]Rename_Edit
message, value, ok := parse_protocol_message(response)
if !ok do return "", edits, false
defer json.destroy_value(value)
if message.kind != .Response || message.id != expected_id || !message.ok do return "", edits, false
result, has_result := json_object_get(value, "result")
if !has_result do return "", edits, false
old_name, _ := json_get_string(result, "oldName")
new_name, _ := json_get_string(result, "newName")
edits_value, has_edits := json_object_get(result, "edits")
if !has_edits do return "", edits, false
#partial switch items in edits_value {
case json.Array:
for item in items {
path, has_path := json_get_string(item, "path")
if !has_path do continue
line, _ := json_get_int(item, "line")
column, _ := json_get_int(item, "column")
old_text, _ := json_get_string(item, "oldText")
new_text, _ := json_get_string(item, "newText")
_, file := os.split_path(path)
label := fmt.tprintf("%s:%d:%d", file, line, column)
append(&edits, Rename_Edit{
path = strings.clone(path),
line = line,
column = column,
old_text = strings.clone(old_text),
new_text = strings.clone(new_text),
label = strings.clone(label),
})
}
}
summary := fmt.tprintf("Preview: %s -> %s (%d edits). Press Enter again to apply.", old_name, new_name, len(edits))
return strings.clone(summary), edits, true
}
rename_edit_clone :: proc(edit: Rename_Edit) -> Rename_Edit {
return Rename_Edit{
path = strings.clone(edit.path),
line = edit.line,
column = edit.column,
old_text = strings.clone(edit.old_text),
new_text = strings.clone(edit.new_text),
label = strings.clone(edit.label),
}
}
rename_edits_destroy :: proc(edits: []Rename_Edit) {
for edit in edits {
delete(edit.path)
delete(edit.old_text)
delete(edit.new_text)
delete(edit.label)
}
}

View file

@ -64,6 +64,7 @@ Project_Tree :: struct {
workspace: string,
files: [dynamic]Project_File,
expanded: map[string]bool,
truncated: bool,
}
// State for the system folder-selection dialog. SDL may invoke the dialog
@ -131,6 +132,16 @@ Hover_Tooltip :: struct {
line: int,
column: int,
contents: string,
definition_path: string,
definition_line: int,
definition_column: int,
has_definition: bool,
x, y: f32,
width, height: f32,
selecting: bool,
selection_active: bool,
selection_anchor: int,
selection_cursor: int,
// Mouse-dwell hovers anchor to the hovered identifier and dismiss when
// the mouse leaves its column span; keyboard hovers anchor to the cursor.
from_mouse: bool,
@ -168,14 +179,6 @@ References_Panel :: struct {
items: [dynamic]Reference_Item,
}
Rename_Panel :: struct {
open: bool,
pending_id: int,
input: [dynamic]u8,
summary: string,
edits: [dynamic]string,
}
Search_Panel :: struct {
open: bool,
input: [dynamic]u8,
@ -195,6 +198,7 @@ Command_Palette :: struct {
Command_Id :: enum {
Open_Folder,
New_File,
Reload_Workspace,
Save,
Save_All,
@ -218,6 +222,7 @@ Command_Item :: struct {
COMMAND_ITEMS := [?]Command_Item{
{.Open_Folder, "Open Folder"},
{.New_File, "New File"},
{.Reload_Workspace, "Reload Workspace"},
{.Save, "Save File"},
{.Save_All, "Save All"},
@ -287,11 +292,6 @@ Syntax_State :: struct {
in_triple_string: bool,
}
Daemon_Sync_State :: struct {
pending: bool,
last_edit: time.Time,
}
SDL_WINDOW_WIDTH :: 1100
SDL_WINDOW_HEIGHT :: 760
SDL_TOP_BAR_HEIGHT :: 32
@ -307,7 +307,6 @@ SDL_RIGHT_SIDEBAR_MIN_WIDTH :: 220
SDL_RIGHT_SIDEBAR_MAX_WIDTH :: 620
SDL_RESIZE_HANDLE_WIDTH :: 6
SDL_TOOL_STRIP_WIDTH :: 26
SDL_DAEMON_SYNC_DEBOUNCE_MS :: 250
SDL_HOVER_DWELL_MS :: 450
SDL_GUTTER_WIDTH :: 72
SDL_EDITOR_TOP :: SDL_TOP_BAR_HEIGHT + SDL_TAB_BAR_HEIGHT
@ -484,22 +483,6 @@ editor_text_x :: proc(view: ^SDL_View) -> int {
return left_sidebar_width(view) + SDL_GUTTER_WIDTH + 20
}
daemon_sync_schedule :: proc(sync: ^Daemon_Sync_State) {
sync.pending = true
sync.last_edit = time.now()
}
daemon_sync_now :: proc(sync: ^Daemon_Sync_State, daemon: ^Daemon_Client, editor: ^Editor, open: bool) {
daemon_sync_active_buffer(daemon, editor, open)
sync.pending = false
}
daemon_sync_flush_if_due :: proc(sync: ^Daemon_Sync_State, daemon: ^Daemon_Client, editor: ^Editor) {
if !sync.pending || !daemon.connected do return
if time.diff(sync.last_edit, time.now()) < SDL_DAEMON_SYNC_DEBOUNCE_MS * time.Millisecond do return
daemon_sync_now(sync, daemon, editor, false)
}
daemon_begin_owned_start :: proc(workspace: string, owned_daemon: ^Daemon_Process, port_line: ^[dynamic]u8, editor: ^Editor, start_pending: ^bool) -> bool {
daemon_stop_process(owned_daemon)
clear(port_line)
@ -658,10 +641,10 @@ run_sdl_editor :: proc(initial_workspace: string, daemon_port: int, file_path: s
}
defer gradle_tasks_panel_destroy(&tasks_panel)
owned_daemon := Daemon_Process{}
defer daemon_stop_process(&owned_daemon)
daemon := Daemon_Client{}
defer daemon_close(&daemon)
owned_daemon := Daemon_Process{}
defer daemon_stop_process(&owned_daemon)
daemon_sync_state := Daemon_Sync_State{}
daemon_port_line: [dynamic]u8
defer delete(daemon_port_line)
@ -784,6 +767,8 @@ run_sdl_editor :: proc(initial_workspace: string, daemon_port: int, file_path: s
view.mouse_selecting = false
} else if handle_completion_click(&editor, &view, &gpu, &completion, &hover, &references, &rename, &search, &daemon_sync_state, event.button.x, event.button.y) {
view.mouse_selecting = false
} else if handle_hover_tooltip_click(&hover, &navigation, &editor, &view, &gpu, event.button.x, event.button.y) {
view.mouse_selecting = false
} else if handle_references_click(&references, &navigation, &editor, &view, event.button.x, event.button.y) {
view.mouse_selecting = false
} else if handle_diagnostics_click(&diagnostics_panel, &editor, &view, event.button.x, event.button.y) {
@ -797,6 +782,9 @@ run_sdl_editor :: proc(initial_workspace: string, daemon_port: int, file_path: s
} else if handle_project_tree_click(&editor, &tree, &view, event.button.x, event.button.y) {
daemon_sync_now(&daemon_sync_state, &daemon, &editor, true)
} else {
if hover.open && len(hover.contents) > 0 {
hover.open = false
}
terminal.focused = false
git_status.input_focused = false
view.mouse_selecting = handle_editor_text_mouse(&editor, &view, &gpu, event.button.x, event.button.y, false)
@ -810,17 +798,17 @@ run_sdl_editor :: proc(initial_workspace: string, daemon_port: int, file_path: s
view.tab_dragging = false
view.tab_drag_index = 0
view.mouse_selecting = false
hover.selecting = false
}
case .MOUSE_MOTION:
view.mouse_x = event.motion.x
view.mouse_y = event.motion.y
mouse_moved_at = time.now()
mouse_dwell_handled = false
if hover.open && hover.from_mouse {
line, column, over_text := editor_line_col_from_mouse(&editor, &view, &gpu, event.motion.x, event.motion.y)
if !over_text || line != hover.line || column < hover.word_start || column >= hover.word_end {
hover.open = false
}
if handle_hover_tooltip_motion(&hover, &gpu, event.motion.x, event.motion.y) {
// Keep routing drag selection to the hover text.
} else if hover.open && hover.from_mouse && len(hover.contents) > 0 && !hover_tooltip_motion_safe(&hover, &editor, &view, &gpu, event.motion.x, event.motion.y) {
hover.open = false
}
if view.resizing_panel == .Bottom_Panel && (event.motion.state & SDL.BUTTON_LMASK) != {} {
handle_bottom_panel_resize_motion(&view, event.motion.y)
@ -889,6 +877,7 @@ run_sdl_editor :: proc(initial_workspace: string, daemon_port: int, file_path: s
if daemon.connected {
daemon_start_reader(&daemon)
daemon_send_workspace_open(&daemon, workspace)
editor_set_status(&editor, "Opening Kotlin workspace...")
daemon_sync_now(&daemon_sync_state, &daemon, &editor, true)
if tasks_panel.open && tasks_panel.pending_id == 0 && len(tasks_panel.items) == 0 {
gradle_tasks_panel_request(&tasks_panel, &daemon)
@ -906,6 +895,7 @@ run_sdl_editor :: proc(initial_workspace: string, daemon_port: int, file_path: s
} else if !daemon_initialized {
daemon_start_reader(&daemon)
daemon_send_workspace_open(&daemon, workspace)
editor_set_status(&editor, "Opening Kotlin workspace...")
daemon_sync_now(&daemon_sync_state, &daemon, &editor, true)
if tasks_panel.open && tasks_panel.pending_id == 0 && len(tasks_panel.items) == 0 {
gradle_tasks_panel_request(&tasks_panel, &daemon)
@ -914,6 +904,7 @@ run_sdl_editor :: proc(initial_workspace: string, daemon_port: int, file_path: s
}
daemon_sync_flush_if_due(&daemon_sync_state, &daemon, &editor)
daemon_apply_workspace_response(&daemon, &editor)
daemon_apply_latest_diagnostics(&daemon, &editor)
completion_popup_apply_response(&completion, &daemon)
hover_tooltip_apply_response(&hover, &daemon)
@ -992,6 +983,7 @@ completion_popup_open :: proc(popup: ^Completion_Popup, editor: ^Editor, daemon:
hover_tooltip_destroy :: proc(hover: ^Hover_Tooltip) {
delete(hover.contents)
delete(hover.definition_path)
}
hover_tooltip_open :: proc(hover: ^Hover_Tooltip, editor: ^Editor, daemon: ^Daemon_Client) {
@ -1003,6 +995,7 @@ hover_tooltip_open :: proc(hover: ^Hover_Tooltip, editor: ^Editor, daemon: ^Daem
hover.from_mouse = false
hover.line = line
hover.column = column
hover.has_definition = false
hover.pending_id = daemon_send_hover(daemon, active.path, line, column)
hover_tooltip_set_contents(hover, "Loading hover...")
}
@ -1040,6 +1033,7 @@ hover_tooltip_open_at_mouse :: proc(hover: ^Hover_Tooltip, editor: ^Editor, view
hover.column = column
hover.word_start = word_start
hover.word_end = word_end
hover.has_definition = false
hover.pending_id = daemon_send_hover(daemon, active.path, line, column)
// No "loading" placeholder for dwell hovers: the tooltip stays invisible
// until real contents arrive, so hovering plain code shows nothing.
@ -1291,15 +1285,6 @@ references_panel_accept :: proc(panel: ^References_Panel, navigation: ^Navigatio
panel.open = false
}
rename_panel_destroy :: proc(panel: ^Rename_Panel) {
delete(panel.input)
delete(panel.summary)
for edit in panel.edits {
delete(edit)
}
delete(panel.edits)
}
search_panel_destroy :: proc(panel: ^Search_Panel) {
delete(panel.input)
delete(panel.message)
@ -1383,6 +1368,8 @@ command_palette_accept :: proc(palette: ^Command_Palette, editor: ^Editor, view:
switch item.id {
case .Open_Folder:
folder_dialog_show(dialog, workspace)
case .New_File:
project_tree_create_new_file(editor, view, tree, workspace, daemon, sync)
case .Close_Tab:
if editor_close_active(editor) {
scroll_sdl_view(editor, view, 0)
@ -1425,6 +1412,31 @@ command_palette_accept :: proc(palette: ^Command_Palette, editor: ^Editor, view:
}
}
project_tree_create_new_file :: proc(editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, workspace: string, daemon: ^Daemon_Client, sync: ^Daemon_Sync_State) -> bool {
for index := 1; index < 1000; index += 1 {
name := "untitled.txt" if index == 1 else fmt.tprintf("untitled-%d.txt", index)
path := fmt.tprintf("%s/%s", workspace, name)
if os.is_file(path) do continue
err := os.write_entire_file(path, []u8{})
if err != nil {
editor_set_status(editor, fmt.tprintf("Could not create file: %s", path))
return false
}
if !editor_open_or_focus_file(editor, path) {
editor_set_status(editor, fmt.tprintf("Created file but could not open it: %s", path))
return false
}
project_tree_rebuild(tree)
scroll_project_tree(tree, view, 0)
daemon_sync_now(sync, daemon, editor, true)
editor_set_status(editor, fmt.tprintf("Created file: %s", path))
return true
}
editor_set_status(editor, "Could not choose a new file name")
return false
}
gradle_panel_toggle :: proc(view: ^SDL_View, tasks_panel: ^Gradle_Tasks_Panel, editor: ^Editor, daemon: ^Daemon_Client, sync: ^Daemon_Sync_State) {
if tasks_panel.open {
tasks_panel.open = false
@ -1589,12 +1601,12 @@ parse_gradle_run_response :: proc(response: string, expected_id: int) -> (string
return "", false
}
if !message.ok {
code, _ := json_get_string(value, "code")
error_message, _ := json_get_string(value, "message")
error_message := protocol_error_message(value)
defer delete(error_message)
if len(error_message) > 0 {
return strings.clone(fmt.tprintf("Gradle failed: %s", error_message)), true
}
return strings.clone(fmt.tprintf("Gradle failed: %s", code)), true
return strings.clone("Gradle failed"), true
}
result, has_result := json_object_get(value, "result")
task := "task"
@ -1871,6 +1883,7 @@ attempt_open_folder :: proc(
// just needs the new root; only start a daemon if none is running.
if daemon.connected {
daemon_send_workspace_open(daemon, workspace^)
editor_set_status(editor, "Opening Kotlin workspace...")
daemon_sync_now(sync, daemon, editor, true)
} else if owned_enabled {
_ = daemon_begin_owned_start(workspace^, owned_daemon, port_line, editor, start_pending)
@ -1879,107 +1892,6 @@ attempt_open_folder :: proc(
editor_set_status(editor, fmt.tprintf("Opened folder: %s", workspace^))
}
rename_panel_open :: proc(panel: ^Rename_Panel) {
panel.open = true
panel.pending_id = 0
clear(&panel.input)
delete(panel.summary)
panel.summary = strings.clone("Enter new name, then Enter for preview")
for edit in panel.edits {
delete(edit)
}
clear(&panel.edits)
}
rename_panel_insert :: proc(panel: ^Rename_Panel, text: string) {
for b in transmute([]u8)text {
if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' {
append(&panel.input, b)
}
}
}
rename_panel_backspace :: proc(panel: ^Rename_Panel) {
if len(panel.input) > 0 {
resize(&panel.input, len(panel.input) - 1)
}
}
rename_panel_request :: proc(panel: ^Rename_Panel, editor: ^Editor, daemon: ^Daemon_Client) {
active := editor_active_buffer(editor)
if active == nil do return
if len(panel.input) == 0 {
delete(panel.summary)
panel.summary = strings.clone("New name is empty")
return
}
line, column := buffer_offset_to_line_col(&active.buffer, active.cursor.offset)
panel.pending_id = daemon_send_rename(daemon, active.path, line, column, string(panel.input[:]))
delete(panel.summary)
panel.summary = strings.clone("Loading rename preview...")
}
rename_panel_apply_response :: proc(panel: ^Rename_Panel, daemon: ^Daemon_Client) {
if !panel.open || panel.pending_id == 0 do return
response := daemon_take_response(daemon, panel.pending_id)
defer delete(response)
if len(response) == 0 do return
summary, edits, ok := parse_rename_response(string(response[:]), panel.pending_id)
defer {
delete(summary)
for edit in edits {
delete(edit)
}
delete(edits)
}
if !ok do return
panel.pending_id = 0
delete(panel.summary)
panel.summary = strings.clone(summary)
for edit in panel.edits {
delete(edit)
}
clear(&panel.edits)
for edit in edits {
append(&panel.edits, strings.clone(edit))
}
}
parse_rename_response :: proc(response: string, expected_id: int) -> (string, [dynamic]string, bool) {
edits: [dynamic]string
message, value, ok := parse_protocol_message(response)
if !ok do return "", edits, false
defer json.destroy_value(value)
if message.kind != .Response || message.id != expected_id || !message.ok do return "", edits, false
result, has_result := json_object_get(value, "result")
if !has_result do return "", edits, false
old_name, _ := json_get_string(result, "oldName")
new_name, _ := json_get_string(result, "newName")
edits_value, has_edits := json_object_get(result, "edits")
if !has_edits do return "", edits, false
#partial switch items in edits_value {
case json.Array:
for item in items {
path, has_path := json_get_string(item, "path")
if !has_path do continue
line, _ := json_get_int(item, "line")
column, _ := json_get_int(item, "column")
_, file := os.split_path(path)
label := fmt.tprintf("%s:%d:%d", file, line, column)
append(&edits, strings.clone(label))
}
}
summary := fmt.tprintf("Preview: %s -> %s (%d edits)", old_name, new_name, len(edits))
return strings.clone(summary), edits, true
}
hover_tooltip_apply_response :: proc(hover: ^Hover_Tooltip, daemon: ^Daemon_Client) {
if !hover.open || hover.pending_id == 0 do return
@ -1987,8 +1899,11 @@ hover_tooltip_apply_response :: proc(hover: ^Hover_Tooltip, daemon: ^Daemon_Clie
defer delete(response)
if len(response) == 0 do return
contents, ok := parse_hover_response(string(response[:]), hover.pending_id)
defer delete(contents)
contents, definition_path, definition_line, definition_column, has_definition, ok := parse_hover_response(string(response[:]), hover.pending_id)
defer {
delete(contents)
delete(definition_path)
}
if !ok do return
hover.pending_id = 0
@ -2001,24 +1916,149 @@ hover_tooltip_apply_response :: proc(hover: ^Hover_Tooltip, daemon: ^Daemon_Clie
} else {
hover_tooltip_set_contents(hover, contents)
}
hover_tooltip_set_definition(hover, definition_path, definition_line, definition_column, has_definition)
}
hover_tooltip_set_contents :: proc(hover: ^Hover_Tooltip, contents: string) {
delete(hover.contents)
hover.contents = strings.clone(contents)
hover.selection_active = false
hover.selecting = false
hover.selection_anchor = 0
hover.selection_cursor = 0
}
parse_hover_response :: proc(response: string, expected_id: int) -> (string, bool) {
message, value, ok := parse_protocol_message(response)
if !ok do return "", false
hover_tooltip_set_definition :: proc(hover: ^Hover_Tooltip, path: string, line, column: int, has_definition: bool) {
delete(hover.definition_path)
hover.definition_path = strings.clone(path)
hover.definition_line = line
hover.definition_column = column
hover.has_definition = has_definition && len(path) > 0
}
parse_hover_response :: proc(response: string, expected_id: int) -> (contents: string, definition_path: string, definition_line: int, definition_column: int, has_definition: bool, ok: bool) {
message, value, parsed := parse_protocol_message(response)
if !parsed do return
defer json.destroy_value(value)
if message.kind != .Response || message.id != expected_id || !message.ok do return "", false
if message.kind != .Response || message.id != expected_id || !message.ok do return
result, has_result := json_object_get(value, "result")
if !has_result do return "", false
contents, has_contents := json_get_string(result, "contents")
if !has_contents do return "", true
return strings.clone(contents), true
if !has_result do return
parsed_contents, has_contents := json_get_string(result, "contents")
if has_contents {
contents = strings.clone(parsed_contents)
}
if definition, has_def := json_object_get(result, "definition"); has_def {
if path, has_path := json_get_string(definition, "path"); has_path {
definition_path = strings.clone(path)
definition_line, _ = json_get_int(definition, "line")
definition_column, _ = json_get_int(definition, "column")
has_definition = true
}
}
ok = true
return
}
hover_tooltip_hit :: proc(hover: ^Hover_Tooltip, x, y: f32) -> bool {
return hover.open && len(hover.contents) > 0 && x >= hover.x && x < hover.x + hover.width && y >= hover.y && y < hover.y + hover.height
}
hover_tooltip_motion_safe :: proc(hover: ^Hover_Tooltip, editor: ^Editor, view: ^SDL_View, gpu: ^GPU_Renderer, x, y: f32) -> bool {
if hover_tooltip_hit(hover, x, y) do return true
active := editor_active_buffer(editor)
if active == nil do return false
if hover.line < 0 || hover.line >= buffer_line_count(&active.buffer) do return false
word_x1 := editor_column_pixel_x_gpu(gpu, view, &active.buffer, hover.line, hover.word_start)
word_x2 := editor_column_pixel_x_gpu(gpu, view, &active.buffer, hover.line, hover.word_end)
word_y := f32(SDL_EDITOR_TEXT_Y + (hover.line - view.first_line) * SDL_LINE_HEIGHT)
if x >= word_x1 && x < word_x2 && y >= word_y - 3 && y < word_y + SDL_LINE_HEIGHT {
return true
}
// Let the pointer travel from the source word to the tooltip without the
// dwell hover disappearing in the gap between them.
left := min_f32(word_x1, hover.x) - 10
right := max_f32(word_x2, hover.x + hover.width) + 10
top := min_f32(word_y - 3, hover.y) - 10
bottom := max_f32(word_y + SDL_LINE_HEIGHT, hover.y + hover.height) + 10
return x >= left && x < right && y >= top && y < bottom
}
hover_tooltip_action_hit :: proc(hover: ^Hover_Tooltip, x, y: f32) -> bool {
if !hover.has_definition do return false
action_y := hover.y + hover.height - 25
return x >= hover.x + 8 && x < hover.x + 142 && y >= action_y && y < action_y + 18
}
hover_visible_lines :: proc(hover: ^Hover_Tooltip) -> []string {
all_lines, _ := strings.split_lines(hover.contents, context.temp_allocator)
line_count := min_int(len(all_lines), 12)
lines := all_lines[:line_count]
for len(lines) > 0 && len(strings.trim_space(lines[len(lines) - 1])) == 0 {
lines = lines[:len(lines) - 1]
}
return lines
}
hover_line_start_offset :: proc(hover: ^Hover_Tooltip, line_index: int) -> int {
offset := 0
current := 0
for i := 0; i < len(hover.contents) && current < line_index; i += 1 {
offset += 1
if hover.contents[i] == '\n' {
current += 1
}
}
return offset
}
hover_text_offset_at :: proc(hover: ^Hover_Tooltip, gpu: ^GPU_Renderer, x, y: f32) -> int {
lines := hover_visible_lines(hover)
if len(lines) == 0 do return 0
line_index := clamp_int(int((y - (hover.y + 10)) / 16), 0, len(lines) - 1)
line := lines[line_index]
rel_x := max_f32(x - (hover.x + 10), 0)
column := int((rel_x + max_f32(gpu.font_advance, 1) * 0.5) / max_f32(gpu.font_advance, 1))
column = clamp_int(column, 0, len(line))
return min_int(hover_line_start_offset(hover, line_index) + column, len(hover.contents))
}
hover_tooltip_copy_selection :: proc(hover: ^Hover_Tooltip) -> bool {
if !hover.selection_active do return false
start := min_int(hover.selection_anchor, hover.selection_cursor)
end := max_int(hover.selection_anchor, hover.selection_cursor)
if start == end do return false
c_text, err := strings.clone_to_cstring(hover.contents[start:end], context.temp_allocator)
if err != nil do return false
return SDL.SetClipboardText(c_text)
}
handle_hover_tooltip_click :: proc(hover: ^Hover_Tooltip, navigation: ^Navigation_History, editor: ^Editor, view: ^SDL_View, gpu: ^GPU_Renderer, x, y: f32) -> bool {
if !hover_tooltip_hit(hover, x, y) do return false
if hover_tooltip_action_hit(hover, x, y) {
active := editor_active_buffer(editor)
if active != nil {
navigation_push(&navigation.back, active.path, active.cursor.offset)
navigation_clear_stack(&navigation.forward)
}
_ = editor_jump_current_tab_to_location(editor, view, hover.definition_path, hover.definition_line, hover.definition_column)
hover.open = false
return true
}
offset := hover_text_offset_at(hover, gpu, x, y)
hover.selecting = true
hover.selection_active = true
hover.selection_anchor = offset
hover.selection_cursor = offset
return true
}
handle_hover_tooltip_motion :: proc(hover: ^Hover_Tooltip, gpu: ^GPU_Renderer, x, y: f32) -> bool {
if !hover.selecting do return false
hover.selection_cursor = hover_text_offset_at(hover, gpu, x, y)
return true
}
completion_popup_apply_response :: proc(popup: ^Completion_Popup, daemon: ^Daemon_Client) {
@ -2160,6 +2200,7 @@ project_tree_clear_files :: proc(tree: ^Project_Tree) {
project_tree_rebuild :: proc(tree: ^Project_Tree) {
project_tree_clear_files(tree)
tree.truncated = false
project_tree_append_dir(tree, tree.workspace, 0)
}
@ -2194,12 +2235,16 @@ workspace_reload :: proc(editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree,
scroll_project_tree(tree, view, 0)
editor_clear_all_diagnostics(editor)
daemon_send_workspace_open(daemon, workspace)
editor_set_status(editor, "Opening Kotlin workspace...")
daemon_sync_now(sync, daemon, editor, true)
editor_set_status(editor, "Workspace reloaded")
}
project_tree_append_dir :: proc(tree: ^Project_Tree, dir: string, depth: int) {
if depth > 12 || len(tree.files) >= 5000 do return
if depth > 12 do return
if len(tree.files) >= 5000 {
tree.truncated = true
return
}
entries, err := os.read_all_directory_by_path(dir, context.allocator)
if err != nil do return
@ -2213,7 +2258,10 @@ project_tree_append_dir :: proc(tree: ^Project_Tree, dir: string, depth: int) {
})
for entry in entries {
if len(tree.files) >= 5000 do return
if len(tree.files) >= 5000 {
tree.truncated = true
return
}
if project_tree_skip(entry.name) do continue
path := fmt.tprintf("%s/%s", dir, entry.name)
@ -3011,6 +3059,10 @@ handle_sdl_key :: proc(editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, wo
active := editor_active_buffer(editor)
if active == nil do return false
if key == SDL.K_C && (mod & SDL.KMOD_CTRL) != SDL.KMOD_NONE && hover_tooltip_copy_selection(hover) {
return false
}
if command_palette.open {
switch key {
case SDL.K_ESCAPE:
@ -3052,8 +3104,12 @@ handle_sdl_key :: proc(editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, wo
rename_panel_backspace(rename)
return false
case SDL.K_RETURN:
daemon_sync_now(sync, daemon, editor, false)
rename_panel_request(rename, editor, daemon)
if len(rename.edits) > 0 {
_ = rename_panel_apply_edits(rename, editor, sync)
} else {
daemon_sync_now(sync, daemon, editor, false)
rename_panel_request(rename, editor, daemon)
}
return false
}
}
@ -3352,7 +3408,8 @@ handle_sdl_key :: proc(editor: ^Editor, view: ^SDL_View, tree: ^Project_Tree, wo
switch key {
case SDL.K_ESCAPE:
// Window close is handled by the window manager; escape currently only clears intent.
hover.open = false
return false
case SDL.K_RETURN:
editor_insert_newline_auto_indent(active)
close_stale_edit_overlays(completion, hover, references, rename)
@ -4096,7 +4153,7 @@ render_rename_panel :: proc(renderer: ^SDL.Renderer, panel: ^Rename_Panel) {
item_y := y + 68
for edit, index in panel.edits {
if index >= 8 do break
label := fmt.ctprintf("%s", edit)
label := fmt.ctprintf("%s", edit.label)
_ = SDL.RenderDebugText(renderer, x + 10, item_y, label)
item_y += 16
}
@ -4122,7 +4179,7 @@ render_rename_panel_gpu :: proc(gpu: ^GPU_Renderer, panel: ^Rename_Panel) {
item_y := y + 68
for edit, index in panel.edits {
if index >= 8 do break
gpu_text_limited(gpu, x + 10, item_y, edit, 56, 220, 225, 235, 255)
gpu_text_limited(gpu, x + 10, item_y, edit.label, 56, 220, 225, 235, 255)
item_y += 16
}
}
@ -4597,12 +4654,7 @@ render_hover_tooltip_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL
// Hover contents can span several lines (signatures, docs), so the box
// is sized to the visible lines and each line is drawn separately
// gpu_text would otherwise continue multi-line text below the box.
all_lines, _ := strings.split_lines(hover.contents, context.temp_allocator)
line_count := min_int(len(all_lines), 12)
lines := all_lines[:line_count]
for len(lines) > 0 && len(strings.trim_space(lines[len(lines) - 1])) == 0 {
lines = lines[:len(lines) - 1]
}
lines := hover_visible_lines(hover)
if len(lines) == 0 do return
max_line_width := 0
@ -4610,7 +4662,8 @@ render_hover_tooltip_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL
max_line_width = max_int(max_line_width, gpu_font_text_width(gpu, line))
}
width := f32(max_int(180, min_int(560, max_line_width + 20)))
height := f32(18 + len(lines) * 16)
action_height := 26 if hover.has_definition else 0
height := f32(18 + len(lines) * 16 + action_height)
anchor_line := min_int(hover.line, buffer_line_count(&active.buffer) - 1)
anchor_col := hover.word_start if hover.from_mouse else hover.column
@ -4618,15 +4671,42 @@ render_hover_tooltip_gpu :: proc(gpu: ^GPU_Renderer, editor: ^Editor, view: ^SDL
y := f32(SDL_EDITOR_TEXT_Y + (anchor_line - view.first_line + 2) * SDL_LINE_HEIGHT)
x = max_f32(min_f32(x, f32(content_right_edge(gpu.width)) - width - 4), 0)
y = max_f32(min_f32(y, f32(editor_content_bottom(view)) - height - 4), SDL_EDITOR_TOP)
hover.x = x
hover.y = y
hover.width = width
hover.height = height
gpu_rect(gpu, x, y, width, height, 30, 34, 45, 255)
gpu_rect_outline(gpu, x, y, width, height, 100, 125, 165, 255)
max_columns := max_int(int((width - 20) / max_f32(gpu.font_advance, 1)), 1)
line_y := y + 10
selection_start := min_int(hover.selection_anchor, hover.selection_cursor)
selection_end := max_int(hover.selection_anchor, hover.selection_cursor)
content_offset := 0
syntax_state := Syntax_State{}
for line in lines {
gpu_text_limited(gpu, x + 10, line_y, line, max_columns, 220, 225, 235, 255)
visible_len := min_int(len(line), max_columns)
if hover.selection_active && selection_start != selection_end {
line_start := content_offset
line_end := content_offset + visible_len
if selection_end > line_start && selection_start < line_end {
start_col := clamp_int(selection_start - line_start, 0, visible_len)
end_col := clamp_int(selection_end - line_start, start_col, visible_len)
if end_col > start_col {
gpu_rect(gpu, x + 10 + f32(start_col) * gpu.font_advance, line_y - 2, f32(end_col - start_col) * gpu.font_advance, 16, 62, 83, 125, 190)
}
}
}
syntax_state = render_syntax_line_gpu(gpu, x + 10, line_y, line, max_columns, syntax_state)
content_offset += len(line) + 1
line_y += 16
}
if hover.has_definition {
action_y := y + height - 25
gpu_rect(gpu, x + 8, action_y, 134, 18, 43, 54, 72, 255)
gpu_rect_outline(gpu, x + 8, action_y, 134, 18, 77, 155, 230, 255)
gpu_text(gpu, x + 16, action_y + 4, "Go to definition", 160, 205, 255, 255)
}
}
render_completion_popup :: proc(renderer: ^SDL.Renderer, editor: ^Editor, view: ^SDL_View, popup: ^Completion_Popup) {
@ -4808,7 +4888,8 @@ render_project_tree :: proc(renderer: ^SDL.Renderer, tree: ^Project_Tree, view:
_ = SDL.RenderFillRect(renderer, &sidebar)
_ = SDL.SetRenderDrawColor(renderer, 145, 165, 205, 255)
_ = SDL.RenderDebugText(renderer, 12, 8, "Project")
title := "Project (truncated)" if tree.truncated else "Project"
_ = SDL.RenderDebugText(renderer, 12, 8, fmt.ctprintf("%s", title))
y: f32 = 28
end := min_int(view.tree_first + visible_tree_rows(view), len(tree.files))
@ -4841,7 +4922,8 @@ render_project_tree_gpu :: proc(gpu: ^GPU_Renderer, tree: ^Project_Tree, view: ^
if !view.explorer_visible || view.left_tab != .Files do return
left := f32(content_left_edge())
sidebar_width := left_sidebar_width(view)
gpu_text(gpu, left + 14, SDL_TREE_HEADER_Y, "EXPLORER", 150, 154, 162, 255)
title := "EXPLORER (TRUNCATED)" if tree.truncated else "EXPLORER"
gpu_text(gpu, left + 14, SDL_TREE_HEADER_Y, title, 150, 154, 162, 255)
gpu_rect(gpu, left + 14, SDL_TREE_HEADER_Y + 18, f32(sidebar_width) - left - 28, 1, 52, 54, 60, 255)
hovered_index, has_hover := project_tree_row_at(tree, view, view.mouse_x, view.mouse_y)

43
client/odin/settings.odin Normal file
View file

@ -0,0 +1,43 @@
package main
import "core:fmt"
import "core:os"
import "core:strings"
import json "core:encoding/json"
Editor_Settings :: struct {
font_path: string,
}
editor_settings_load :: proc() -> Editor_Settings {
settings := Editor_Settings{}
path, ok := editor_settings_path(context.temp_allocator)
if !ok do return settings
data, err := os.read_entire_file(path, context.allocator)
if err != nil do return settings
defer delete(data)
value, parse_err := json.parse_string(string(data), .JSON, true)
if parse_err != nil do return settings
defer json.destroy_value(value)
font_path, has_font_path := json_get_string(value, "fontPath")
if has_font_path && len(strings.trim_space(font_path)) > 0 {
settings.font_path = strings.clone(strings.trim_space(font_path))
}
return settings
}
editor_settings_destroy :: proc(settings: ^Editor_Settings) {
delete(settings.font_path)
}
editor_settings_path :: proc(allocator := context.allocator) -> (string, bool) {
if env := os.get_env("NATIVE_EDITOR_SETTINGS", allocator); len(env) > 0 {
return env, true
}
home := os.get_env("HOME", allocator)
if len(home) == 0 do return "", false
return fmt.aprintf("%s/.config/native-kotlin-editor/settings.json", home), true
}

View file

@ -14,7 +14,18 @@ kotlin {
dependencies {
implementation(gradleApi())
implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.21")
// Kotlin Analysis API is published for IDE usage as thin POMs whose internal module
// dependencies are not independently resolvable. Use the combined jars directly.
implementation("org.jetbrains.kotlin:analysis-api-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:analysis-api-impl-base-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:analysis-api-platform-interface-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:analysis-api-k2-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:low-level-api-fir-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:symbol-light-classes-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:analysis-api-standalone-for-ide:2.2.21") { isTransitive = false }
implementation("org.jetbrains.kotlin:kotlin-compiler:2.2.21")
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}

View file

@ -2,6 +2,7 @@ pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies")
}
}
@ -9,6 +10,7 @@ dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies")
}
}

View file

@ -12,6 +12,7 @@ import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import javax.tools.DiagnosticCollector
import javax.tools.JavaFileObject
@ -34,9 +35,25 @@ import org.gradle.tooling.GradleConnector
import org.gradle.tooling.model.GradleProject
import org.gradle.tooling.model.idea.IdeaProject
import org.gradle.tooling.model.idea.IdeaSingleEntryLibraryDependency
import com.intellij.openapi.util.Disposer
import com.intellij.psi.PsiElement
import com.intellij.psi.util.PsiTreeUtil
import org.jetbrains.kotlin.analysis.api.KaImplementationDetail
import org.jetbrains.kotlin.analysis.api.analyze
import org.jetbrains.kotlin.analysis.api.resolution.KaSymbolBasedReference
import org.jetbrains.kotlin.analysis.api.standalone.buildStandaloneAnalysisAPISession
import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISession
import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryModule
import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSourceModule
import org.jetbrains.kotlin.cli.common.ExitCode
import org.jetbrains.kotlin.cli.common.messages.MessageRenderer
import org.jetbrains.kotlin.cli.jvm.K2JVMCompiler
import org.jetbrains.kotlin.platform.jvm.JvmPlatforms
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtDeclaration
import org.jetbrains.kotlin.psi.KtClassOrObject
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtReferenceExpression
fun main(args: Array<String>) {
val requestedPort = args.firstOrNull()?.toIntOrNull() ?: 0
@ -57,15 +74,50 @@ fun main(args: Array<String>) {
private class DaemonState {
@Volatile var workspaceRoot: File? = null
@Volatile var gradleWorkspace: GradleWorkspace? = null
@Volatile var workspaceGeneration: Int = 0
val openTexts = ConcurrentHashMap<String, String>()
val textVersions = ConcurrentHashMap<String, Int>()
val diagnosticsVersions = ConcurrentHashMap<String, Int>()
val diagnosticsCache = ConcurrentHashMap<String, List<KotlinDiagnostic>>()
val semanticCacheLock = Any()
val semanticCache = LinkedHashMap<SemanticCacheKey, CachedSemanticContext>()
fun clearSemanticCache() {
synchronized(semanticCacheLock) {
semanticCache.values.forEach { it.dispose() }
semanticCache.clear()
}
}
fun invalidateSemanticCacheForPath(path: String) {
synchronized(semanticCacheLock) {
val iterator = semanticCache.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.key.path == path) {
entry.value.dispose()
iterator.remove()
}
}
}
}
}
private data class GradleWorkspace(
val root: File,
val modules: List<GradleModule>,
val tasks: List<GradleTaskInfo>,
val sourceTexts: Map<String, String>,
val declarations: List<SourceDeclaration>,
)
private data class SourceDeclaration(
val name: String,
val kind: String,
val location: SourceLocation,
val packageName: String?,
val containerName: String?,
val signature: String,
)
private data class GradleModule(
@ -77,6 +129,8 @@ private data class GradleModule(
val resourceRoots: List<File>,
val testResourceRoots: List<File>,
val classpath: List<File>,
val mainSourceFiles: List<File>,
val testSourceFiles: List<File>,
)
private data class KotlinDiagnostic(
@ -93,6 +147,40 @@ private data class SourceLocation(
val column: Int,
)
private data class HoverInfo(
val contents: String,
val definition: SourceLocation?,
)
private data class SemanticContext(
val session: StandaloneAnalysisAPISession,
val targetFile: KtFile,
val tempPath: String,
val originalPath: String,
)
private data class SemanticCacheKey(
val workspaceGeneration: Int,
val modulePath: String,
val path: String,
val version: Int,
val textHash: Int,
)
private class CachedSemanticContext(
val key: SemanticCacheKey,
val disposable: com.intellij.openapi.Disposable,
val tempDir: File,
val context: SemanticContext,
) {
var lastUsedMillis: Long = System.currentTimeMillis()
fun dispose() {
Disposer.dispose(disposable)
tempDir.deleteRecursively()
}
}
private data class CompletionCandidate(
val label: String,
val kind: String,
@ -173,7 +261,9 @@ private class ClientSession(
return
}
state.clearSemanticCache()
state.gradleWorkspace = gradleWorkspace
state.workspaceGeneration += 1
writeLine(writer, okJson(id, workspaceJson(gradleWorkspace)))
writeLine(writer, eventJson("workspace/indexing", indexingStateJson("idle")))
}
@ -184,6 +274,8 @@ private class ClientSession(
state.openTexts.clear()
state.textVersions.clear()
state.diagnosticsVersions.clear()
state.diagnosticsCache.clear()
state.clearSemanticCache()
writeLine(writer, okJson(id, buildJsonObject { put("closed", true) }))
}
@ -198,6 +290,8 @@ private class ClientSession(
val version = params.intField("version") ?: 0
state.openTexts[normalizedPath] = params.stringField("text") ?: ""
state.textVersions[normalizedPath] = version
state.diagnosticsCache.keys.removeIf { it.startsWith("$normalizedPath\u0000") && it != diagnosticsCacheKey(normalizedPath, version) }
state.invalidateSemanticCacheForPath(normalizedPath)
writeLine(writer, okJson(id, buildJsonObject {
put("path", normalizedPath)
put("version", version)
@ -210,6 +304,8 @@ private class ClientSession(
if (path != null) state.openTexts.remove(path)
if (path != null) state.textVersions.remove(path)
if (path != null) state.diagnosticsVersions.remove(path)
if (path != null) state.diagnosticsCache.keys.removeIf { it.startsWith("$path\u0000") }
if (path != null) state.invalidateSemanticCacheForPath(path)
writeLine(writer, okJson(id, buildJsonObject { put("closed", true) }))
}
@ -309,7 +405,7 @@ private class ClientSession(
return
}
val diagnostics = compileForDiagnostics(file, module, state.openTexts[normalizedPath])
val diagnostics = diagnosticsFor(file, module, normalizedPath)
writeLine(writer, okJson(id, buildJsonObject { put("diagnostics", diagnosticsJson(diagnostics)) }))
}
@ -326,7 +422,7 @@ private class ClientSession(
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)
val items = completionCandidates(prefix, text, normalizedPath, state.gradleWorkspace)
writeLine(writer, okJson(id, buildJsonObject { put("items", completionItemsJson(items)) }))
}
@ -343,8 +439,19 @@ private class ClientSession(
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)
writeLine(writer, okJson(id, buildJsonObject { put("contents", contents) }))
val workspace = state.gradleWorkspace
val file = File(normalizedPath)
val module = workspace?.let { findModule(it, file) }
val hover = if (module != null && file.extension.lowercase() in setOf("kt", "kts")) {
semanticHover(state, file, module, normalizedPath, text, requestLine, requestColumn)
} else {
null
} ?: HoverInfo(
contents = hoverContents(identifier, normalizedPath, text, workspace).orEmpty(),
definition = findSimpleDeclarationInText(normalizedPath, text, identifier)
?: workspace?.let { findSimpleDeclaration(it, identifier, normalizedPath, text) },
)
writeLine(writer, okJson(id, hoverInfoJson(hover)))
}
private fun kotlinDefinition(writer: BufferedWriter, id: Int, params: JsonObject) {
@ -366,8 +473,14 @@ private class ClientSession(
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)
val file = File(normalizedPath)
val module = findModule(workspace, file)
val location = if (module != null && file.extension.lowercase() in setOf("kt", "kts")) {
semanticDefinition(state, file, module, normalizedPath, text, requestLine, requestColumn)
} else {
null
} ?: findSimpleDeclarationInText(normalizedPath, text, identifier)
?: findSimpleDeclaration(workspace, identifier, normalizedPath, text)
writeLine(writer, okJson(id, buildJsonObject { put("locations", definitionLocationsJson(location)) }))
}
@ -390,7 +503,13 @@ private class ClientSession(
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)
val file = File(normalizedPath)
val module = findModule(workspace, file)
val locations = if (module != null && file.extension.lowercase() in setOf("kt", "kts")) {
semanticReferences(state, file, module, normalizedPath, text, requestLine, requestColumn)
} else {
null
} ?: findSimpleReferences(workspace, normalizedPath, text, identifier)
writeLine(writer, okJson(id, buildJsonObject { put("locations", locationsJson(locations)) }))
}
@ -447,23 +566,25 @@ private class ClientSession(
val file = File(path).absoluteFile.normalize()
val module = findModule(workspace, file) ?: return@thread
val diagnostics = try {
compileForDiagnostics(file, module, state.openTexts[path])
diagnosticsFor(file, module, path)
} catch (t: Throwable) {
listOf(KotlinDiagnostic("error", t.message ?: t.javaClass.name, file.path, null, null))
}
if (state.diagnosticsVersions[path] != version || state.textVersions[path] != version) return@thread
writeLine(
writer,
eventJson(
"diagnostics/publish",
buildJsonObject {
put("path", file.path)
put("version", version)
put("diagnostics", diagnosticsJson(diagnostics))
},
),
)
runCatching {
writeLine(
writer,
eventJson(
"diagnostics/publish",
buildJsonObject {
put("path", file.path)
put("version", version)
put("diagnostics", diagnosticsJson(diagnostics))
},
),
)
}
}
}
@ -472,8 +593,19 @@ private class ClientSession(
writeLineLocked(writer, text)
}
}
private fun diagnosticsFor(file: File, module: GradleModule, normalizedPath: String): List<KotlinDiagnostic> {
val version = state.textVersions[normalizedPath] ?: -1
val key = diagnosticsCacheKey(normalizedPath, version)
state.diagnosticsCache[key]?.let { return it }
val diagnostics = compileForDiagnostics(file, module, state.openTexts[normalizedPath])
state.diagnosticsCache[key] = diagnostics
return diagnostics
}
}
private fun diagnosticsCacheKey(path: String, version: Int): String = "$path\u0000$version"
private fun findModule(workspace: GradleWorkspace, file: File): GradleModule? {
val normalizedFile = file.absoluteFile.normalize()
return workspace.modules
@ -526,19 +658,21 @@ private fun importGradleWorkspace(root: File): GradleWorkspace {
val gradleProject = connection.getModel(GradleProject::class.java)
val ideaProject = connection.getModel(IdeaProject::class.java)
return GradleWorkspace(
root = root,
modules = ideaProject.modules.map { module ->
val sourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.sourceDirectories.map { it.directory } } +
generatedSourceRoots(module.gradleProject.projectDirectory, test = false)
val testSourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.testDirectories.map { it.directory } } +
generatedSourceRoots(module.gradleProject.projectDirectory, test = true)
val modules = ideaProject.modules.map { module ->
val sourceRoots = (module.contentRoots.flatMap { contentRoot -> contentRoot.sourceDirectories.map { it.directory } } +
generatedSourceRoots(module.gradleProject.projectDirectory, test = false))
.filter { it.exists() }
.distinctBy { it.absoluteFile.normalize().path }
val testSourceRoots = (module.contentRoots.flatMap { contentRoot -> contentRoot.testDirectories.map { it.directory } } +
generatedSourceRoots(module.gradleProject.projectDirectory, test = true))
.filter { it.exists() }
.distinctBy { it.absoluteFile.normalize().path }
GradleModule(
name = module.name,
gradlePath = module.gradleProject.path,
directory = module.gradleProject.projectDirectory,
sourceRoots = sourceRoots.filter { it.exists() }.distinctBy { it.absoluteFile.normalize().path },
testSourceRoots = testSourceRoots.filter { it.exists() }.distinctBy { it.absoluteFile.normalize().path },
sourceRoots = sourceRoots,
testSourceRoots = testSourceRoots,
resourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.resourceDirectories.map { it.directory } }.distinctBy { it.path },
testResourceRoots = module.contentRoots.flatMap { contentRoot -> contentRoot.testResourceDirectories.map { it.directory } }.distinctBy { it.path },
classpath = module.dependencies
@ -546,13 +680,88 @@ private fun importGradleWorkspace(root: File): GradleWorkspace {
.map { it.file }
.filter { it.exists() }
.distinctBy { it.path },
mainSourceFiles = collectKotlinAndJavaSources(sourceRoots),
testSourceFiles = collectKotlinAndJavaSources(testSourceRoots),
)
},
}
val sourceTexts = sourceTexts(modules)
return GradleWorkspace(
root = root,
modules = modules,
tasks = collectTasks(gradleProject),
sourceTexts = sourceTexts,
declarations = sourceDeclarations(sourceTexts),
)
}
}
private fun sourceDeclarations(sourceTexts: Map<String, String>): List<SourceDeclaration> {
val declarations = mutableListOf<SourceDeclaration>()
val patterns = listOf(
Triple(Regex("\\b(fun|class|object|interface|val|var|typealias)\\s+([A-Za-z_][A-Za-z0-9_]*)"), 2, 1),
Triple(Regex("\\b(class|interface|enum|record)\\s+([A-Za-z_][A-Za-z0-9_]*)"), 2, 1),
Triple(Regex("\\b[A-Za-z_][A-Za-z0-9_<>, ?\\[\\]]+\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(\\(|=|;)"), 1, 2),
)
for ((path, text) in sourceTexts) {
val packageName = sourcePackageName(text, semicolon = path.endsWith(".java"))
val containers = mutableListOf<Pair<String, Int>>()
var braceDepth = 0
text.lineSequence().forEachIndexed { lineIndex, rawLine ->
val line = rawLine.trim()
while (containers.isNotEmpty() && braceDepth <= containers.last().second) {
containers.removeLast()
}
val containerName = containers.lastOrNull()?.first
for ((pattern, nameGroup, markerGroup) in patterns) {
for (match in pattern.findAll(rawLine)) {
val name = match.groupValues[nameGroup]
if (name in allKeywordCompletionItems) continue
val marker = match.groupValues[markerGroup]
val kind = when (marker) {
"fun" -> "function"
"class", "object", "interface", "typealias", "enum", "record" -> "type"
"val", "var" -> "variable"
else -> if (marker == "(") "function" else "variable"
}
val column = match.range.first + match.value.lastIndexOf(name) + 1
declarations += SourceDeclaration(
name = name,
kind = kind,
location = SourceLocation(path, lineIndex + 1, column),
packageName = packageName,
containerName = containerName,
signature = line.take(180),
)
}
}
val typeMatch = Regex("\\b(class|object|interface|enum|record)\\s+([A-Za-z_][A-Za-z0-9_]*)").find(rawLine)
val opens = rawLine.count { it == '{' }
val closes = rawLine.count { it == '}' }
if (typeMatch != null && opens > 0) {
containers += typeMatch.groupValues[2] to braceDepth
}
braceDepth += opens - closes
if (braceDepth < 0) braceDepth = 0
}
}
return declarations.distinctBy { "${it.name}\u0000${it.location.path}\u0000${it.location.line}\u0000${it.location.column}" }
}
private fun sourceTexts(modules: List<GradleModule>): Map<String, String> {
return modules
.flatMap { it.mainSourceFiles + it.testSourceFiles }
.distinctBy { it.absoluteFile.normalize().path }
.mapNotNull { file ->
val path = file.absoluteFile.normalize().path
val text = runCatching { file.readText() }.getOrNull() ?: return@mapNotNull null
path to text
}
.toMap()
}
private fun generatedSourceRoots(projectDir: File, test: Boolean): List<File> {
val sourceSet = if (test) "test" else "main"
val kotlinSourceSets = if (test) listOf("test", "commonTest", "jvmTest") else listOf("main", "commonMain", "jvmMain")
@ -665,7 +874,7 @@ private fun compileKotlinForDiagnostics(file: File, module: GradleModule, openTe
val sourceFilePath = sourceFile.absoluteFile.normalize().path
val diagnosticSourceRoots = sourceRootsForFile(module, file)
val sourceFiles = collectKotlinAndJavaSources(diagnosticSourceRoots)
val sourceFiles = sourceFilesForFile(module, file)
.filter { it.absoluteFile.normalize().path != originalPath }
.map { it.absoluteFile.normalize().path }
.toMutableList()
@ -756,7 +965,14 @@ private fun compileJavaForDiagnostics(file: File, module: GradleModule, openText
}
private fun collectKotlinSources(module: GradleModule): List<File> {
return collectKotlinSources(module.sourceRoots + module.testSourceRoots)
return (module.mainSourceFiles + module.testSourceFiles)
.filter { it.extension == "kt" || it.extension == "kts" }
}
private fun sourceFilesForFile(module: GradleModule, file: File): List<File> {
val normalizedFile = file.absoluteFile.normalize()
val isTestFile = module.testSourceRoots.any { root -> rootContains(root, normalizedFile) }
return if (isTestFile) module.mainSourceFiles + module.testSourceFiles else module.mainSourceFiles
}
private fun sourcePackageName(text: String, semicolon: Boolean): String? {
@ -796,20 +1012,294 @@ private fun collectWorkspaceKotlinSources(workspace: GradleWorkspace): List<File
private fun collectWorkspaceSourceFiles(workspace: GradleWorkspace): List<File> {
return workspace.modules
.flatMap { module -> collectKotlinAndJavaSources(module.sourceRoots + module.testSourceRoots) }
.flatMap { module -> module.mainSourceFiles + module.testSourceFiles }
.distinctBy { it.absoluteFile.normalize().path }
}
private fun findSimpleDeclaration(workspace: GradleWorkspace, identifier: String): SourceLocation? {
private fun findSimpleDeclaration(workspace: GradleWorkspace, identifier: String, currentPath: String? = null, currentText: String? = null): SourceLocation? {
if (identifier.isBlank() || identifier in allKeywordCompletionItems) return null
for (file in collectWorkspaceSourceFiles(workspace)) {
val text = runCatching { file.readText() }.getOrNull() ?: continue
findSimpleDeclarationInText(file.absoluteFile.normalize().path, text, identifier)?.let { return it }
workspaceDeclaration(workspace, identifier, currentPath, currentText)?.let { return it.location }
for ((path, text) in workspace.sourceTexts) {
findSimpleDeclarationInText(path, text, identifier)?.let { return it }
}
return null
}
@OptIn(KaImplementationDetail::class)
private fun semanticDefinition(
state: DaemonState,
file: File,
module: GradleModule,
normalizedPath: String,
text: String,
requestLine: Int,
requestColumn: Int,
): SourceLocation? {
return withSemanticContext(state, file, module, normalizedPath, text) { context ->
val reference = referenceAt(context.targetFile, text, requestLine, requestColumn) ?: return@withSemanticContext null
val resolvedPsi = resolvedSymbolPsi(reference) ?: return@withSemanticContext null
sourceLocationForPsi(resolvedPsi, context.tempPath, context.originalPath)
}
}
@OptIn(KaImplementationDetail::class)
private fun semanticHover(
state: DaemonState,
file: File,
module: GradleModule,
normalizedPath: String,
text: String,
requestLine: Int,
requestColumn: Int,
): HoverInfo? {
return withSemanticContext(state, file, module, normalizedPath, text) { context ->
val reference = referenceAt(context.targetFile, text, requestLine, requestColumn) ?: return@withSemanticContext null
val resolvedPsi = resolvedSymbolPsi(reference) ?: return@withSemanticContext null
val declaration = PsiTreeUtil.getParentOfType(resolvedPsi, KtDeclaration::class.java, false) ?: resolvedPsi as? KtDeclaration
val headline = declaration?.text?.lineSequence()?.firstOrNull()?.trim()?.take(240) ?: return@withSemanticContext null
val classLikeDeclaration = declaration as? KtClassOrObject
?: PsiTreeUtil.getParentOfType(declaration, KtClassOrObject::class.java, false)
?.takeIf { declaration.name == null || it.name == declaration.name }
val members = classLikeDeclaration?.let { hoverMembersForClass(it) }.orEmpty()
val contents = if (members.isEmpty()) headline else buildString {
appendLine(headline)
appendLine()
appendLine("Members:")
members.forEach { appendLine(" $it") }
}.trimEnd()
HoverInfo(contents, sourceLocationForPsi(resolvedPsi, context.tempPath, context.originalPath))
}
}
private fun hoverMembersForClass(classOrObject: KtClassOrObject): List<String> {
val members = mutableListOf<String>()
if (classOrObject is KtClass) {
classOrObject.primaryConstructorParameters
.asSequence()
.filter { it.hasValOrVar() }
.map { it.text.lineSequence().firstOrNull()?.trim()?.take(160).orEmpty() }
.filter { it.isNotBlank() }
.forEach { members += it }
}
classOrObject.declarations
.asSequence()
.filterIsInstance<KtDeclaration>()
.mapNotNull { member -> member.text.lineSequence().firstOrNull()?.trim()?.take(160) }
.filter { it.isNotBlank() }
.forEach { members += it }
return members.distinct().take(8)
}
@OptIn(KaImplementationDetail::class)
private fun semanticReferences(
state: DaemonState,
file: File,
module: GradleModule,
normalizedPath: String,
text: String,
requestLine: Int,
requestColumn: Int,
): List<SourceLocation>? {
return withSemanticContext(state, file, module, normalizedPath, text) { context ->
val reference = referenceAt(context.targetFile, text, requestLine, requestColumn) ?: return@withSemanticContext null
val targetPsi = resolvedSymbolPsi(reference) ?: return@withSemanticContext null
val name = reference.text
if (name.isBlank() || name in allKeywordCompletionItems) return@withSemanticContext null
val locations = mutableListOf<SourceLocation>()
for (ktFile in context.session.modulesWithFiles.values.flatten().filterIsInstance<KtFile>()) {
val references = PsiTreeUtil.collectElementsOfType(ktFile, KtReferenceExpression::class.java)
for (candidate in references) {
if (candidate.text != name) continue
val candidatePsi = resolvedSymbolPsi(candidate) ?: continue
if (candidatePsi == targetPsi || sourceLocationForPsi(candidatePsi, context.tempPath, context.originalPath) == sourceLocationForPsi(targetPsi, context.tempPath, context.originalPath)) {
val path = ktFile.virtualFilePath.let { if (it == context.tempPath) context.originalPath else it }
val lineColumn = lineColumnForOffset(ktFile.text, candidate.textOffset)
locations += SourceLocation(path, lineColumn.first, lineColumn.second)
}
}
}
locations.distinctBy { "${it.path}\u0000${it.line}\u0000${it.column}" }.ifEmpty { null }
}
}
@OptIn(KaImplementationDetail::class)
private fun <T> withSemanticContext(
state: DaemonState,
file: File,
module: GradleModule,
normalizedPath: String,
text: String,
action: (SemanticContext) -> T?,
): T? {
val key = SemanticCacheKey(
workspaceGeneration = state.workspaceGeneration,
modulePath = module.gradlePath,
path = normalizedPath,
version = state.textVersions[normalizedPath] ?: -1,
textHash = text.hashCode(),
)
return synchronized(state.semanticCacheLock) {
val cached = state.semanticCache[key] ?: run {
val created = createSemanticContext(key, file, module, normalizedPath, text) ?: return@synchronized null
state.semanticCache[key] = created
trimSemanticCache(state)
created
}
cached.lastUsedMillis = System.currentTimeMillis()
action(cached.context)
}
}
@OptIn(KaImplementationDetail::class)
private fun createSemanticContext(
key: SemanticCacheKey,
file: File,
module: GradleModule,
normalizedPath: String,
text: String,
): CachedSemanticContext? {
var tempDir: File? = null
val disposable = Disposer.newDisposable("native-editor-semantic")
return try {
tempDir = Files.createTempDirectory("native-editor-semantic-").toFile()
val packageDir = sourcePackageName(text, semicolon = false)
?.replace('.', File.separatorChar)
?.let { File(tempDir, it) }
?: tempDir
packageDir.mkdirs()
val tempFile = File(packageDir, file.name)
tempFile.writeText(text)
val tempPath = tempFile.absoluteFile.normalize().path
val sourceRoots = sourceRootsForFile(module, file, tempDir)
val libraryRoots = classpathForFile(module, file)
.filter { it.exists() }
.map { it.toPath() }
val session = buildStandaloneAnalysisAPISession(disposable) {
buildKtModuleProvider {
platform = JvmPlatforms.defaultJvmPlatform
val libraryModule = if (libraryRoots.isNotEmpty()) {
addModule(buildKtLibraryModule {
libraryName = "${module.name.ifBlank { "module" }}-classpath"
platform = JvmPlatforms.defaultJvmPlatform
addBinaryRoots(libraryRoots)
})
} else {
null
}
addModule(buildKtSourceModule {
moduleName = module.name.ifBlank { "native-editor" }
platform = JvmPlatforms.defaultJvmPlatform
addSourceRoots(sourceRoots)
if (libraryModule != null) addRegularDependency(libraryModule)
})
}
}
val targetFile = session.modulesWithFiles.values
.flatten()
.filterIsInstance<KtFile>()
.firstOrNull { it.virtualFile.path == tempPath }
?: return null
CachedSemanticContext(key, disposable, tempDir, SemanticContext(session, targetFile, tempPath, normalizedPath)).also {
tempDir = null
}
} catch (_: Throwable) {
null
} finally {
if (tempDir != null) {
Disposer.dispose(disposable)
tempDir?.deleteRecursively()
}
}
}
private const val maxSemanticCacheEntries = 6
private fun trimSemanticCache(state: DaemonState) {
while (state.semanticCache.size > maxSemanticCacheEntries) {
val oldest = state.semanticCache.minByOrNull { it.value.lastUsedMillis } ?: return
state.semanticCache.remove(oldest.key)
oldest.value.dispose()
}
}
private fun referenceAt(targetFile: KtFile, text: String, requestLine: Int, requestColumn: Int): KtReferenceExpression? {
val offset = offsetForLineColumn(text, requestLine, requestColumn)
return PsiTreeUtil.findElementOfClassAtOffset(targetFile, offset, KtReferenceExpression::class.java, false)
?: PsiTreeUtil.findElementOfClassAtOffset(targetFile, (offset - 1).coerceAtLeast(0), KtReferenceExpression::class.java, false)
}
@OptIn(KaImplementationDetail::class)
private fun resolvedSymbolPsi(reference: KtReferenceExpression): PsiElement? {
return analyze(reference) {
reference.references
.asSequence()
.filterIsInstance<KaSymbolBasedReference>()
.flatMap { it.resolveToSymbols().asSequence() }
.firstNotNullOfOrNull { it.psi }
}
}
private fun sourceRootsForFile(module: GradleModule, file: File, tempDir: File): List<Path> {
val roots = linkedSetOf<Path>()
roots.add(tempDir.toPath())
for (root in module.sourceRoots + module.testSourceRoots) {
if (root.exists()) roots.add(root.toPath())
}
val parent = file.parentFile
if (roots.size == 1 && parent != null) roots.add(parent.toPath())
return roots.toList()
}
private fun sourceLocationForPsi(source: PsiElement, tempPath: String, originalPath: String): SourceLocation? {
val declaration = PsiTreeUtil.getParentOfType(source, KtDeclaration::class.java, false) ?: source as? KtDeclaration ?: return null
val ktFile = declaration.containingKtFile
val path = ktFile.virtualFilePath.let { if (it == tempPath) originalPath else it }
val lineColumn = lineColumnForOffset(ktFile.text, declaration.textOffset)
return SourceLocation(path, lineColumn.first, lineColumn.second)
}
private fun workspaceDeclaration(workspace: GradleWorkspace, identifier: String, currentPath: String?, currentText: String?): SourceDeclaration? {
val candidates = workspace.declarations.filter { it.name == identifier }
if (candidates.isEmpty()) return null
if (currentPath == null || currentText == null) return candidates.first()
val currentPackage = sourcePackageName(currentText, semicolon = currentPath.endsWith(".java"))
val exactImports = importedSymbols(currentText)
val wildcardImports = wildcardImports(currentText)
return candidates.maxByOrNull { declaration ->
val qualifiedName = listOfNotNull(declaration.packageName, declaration.containerName, declaration.name).joinToString(".")
when {
qualifiedName in exactImports -> 60
declaration.packageName != null && declaration.packageName in wildcardImports -> 50
declaration.packageName == currentPackage -> 40
declaration.location.path == currentPath -> 30
declaration.packageName == null -> 20
else -> 10
}
}
}
private fun importedSymbols(text: String): Set<String> {
return Regex("(?m)^\\s*import\\s+([A-Za-z_][A-Za-z0-9_.]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)?)\\s*;?\\s*$")
.findAll(text)
.map { it.groupValues[1] }
.filter { !it.endsWith(".*") }
.toSet()
}
private fun wildcardImports(text: String): Set<String> {
return Regex("(?m)^\\s*import\\s+([A-Za-z_][A-Za-z0-9_.]*)\\.\\*\\s*;?\\s*$")
.findAll(text)
.map { it.groupValues[1] }
.toSet()
}
private fun findSimpleDeclarationInText(path: String, text: String, identifier: String): SourceLocation? {
if (identifier.isBlank() || identifier in allKeywordCompletionItems) return null
val escaped = Regex.escape(identifier)
@ -832,10 +1322,8 @@ private fun findSimpleReferences(workspace: GradleWorkspace, openPath: String, o
val locations = mutableListOf<SourceLocation>()
locations += findSimpleReferencesInText(openPath, openText, identifier)
for (file in collectWorkspaceSourceFiles(workspace)) {
val path = file.absoluteFile.normalize().path
for ((path, text) in workspace.sourceTexts) {
if (path == openPath) continue
val text = runCatching { file.readText() }.getOrNull() ?: continue
locations += findSimpleReferencesInText(path, text, identifier)
if (locations.size >= 100) break
}
@ -898,7 +1386,7 @@ private val javaKeywordCompletionItems = listOf(
private val allKeywordCompletionItems = (kotlinKeywordCompletionItems + javaKeywordCompletionItems).distinct()
private fun completionCandidates(prefix: String, currentText: String, workspace: GradleWorkspace?): List<CompletionCandidate> {
private fun completionCandidates(prefix: String, currentText: String, currentPath: String, workspace: GradleWorkspace?): List<CompletionCandidate> {
val normalized = prefix.lowercase()
val candidates = linkedMapOf<String, CompletionCandidate>()
@ -912,8 +1400,14 @@ private fun completionCandidates(prefix: String, currentText: String, workspace:
collectCompletionIdentifiers(currentText, 80).forEach { add(it.first, it.second) }
if (workspace != null && candidates.size < 80) {
for (file in collectWorkspaceSourceFiles(workspace)) {
val text = runCatching { file.readText() }.getOrNull() ?: continue
for (declaration in rankedDeclarations(workspace, currentPath, currentText)) {
add(declaration.name, declaration.kind)
if (candidates.size >= 80) break
}
}
if (workspace != null && candidates.size < 80) {
for (text in workspace.sourceTexts.values) {
collectCompletionIdentifiers(text, 60).forEach { add(it.first, it.second) }
if (candidates.size >= 80) break
}
@ -922,6 +1416,23 @@ private fun completionCandidates(prefix: String, currentText: String, workspace:
return candidates.values.take(80)
}
private fun rankedDeclarations(workspace: GradleWorkspace, currentPath: String, currentText: String): List<SourceDeclaration> {
val currentPackage = sourcePackageName(currentText, semicolon = currentPath.endsWith(".java"))
val exactImports = importedSymbols(currentText)
val wildcardImports = wildcardImports(currentText)
return workspace.declarations.sortedByDescending { declaration ->
val qualifiedName = listOfNotNull(declaration.packageName, declaration.containerName, declaration.name).joinToString(".")
when {
declaration.location.path == currentPath -> 70
qualifiedName in exactImports -> 60
declaration.packageName != null && declaration.packageName in wildcardImports -> 50
declaration.packageName == currentPackage -> 40
declaration.packageName == null -> 20
else -> 10
}
}
}
private fun collectCompletionIdentifiers(text: String, limit: Int): List<Pair<String, String>> {
val result = linkedMapOf<String, String>()
val kotlinDeclaration = Regex("\\b(fun|class|object|interface|val|var|typealias)\\s+([A-Za-z_][A-Za-z0-9_]*)")
@ -1017,17 +1528,24 @@ private fun hoverContents(identifier: String, currentPath: String, currentText:
return hoverDeclarationContents(identifier, currentDeclaration, currentText)
}
if (workspace != null) {
for (file in collectWorkspaceSourceFiles(workspace)) {
val path = file.absoluteFile.normalize().path
if (path == currentPath) continue
val text = runCatching { file.readText() }.getOrNull() ?: continue
val declaration = findSimpleDeclarationInText(path, text, identifier) ?: continue
return hoverDeclarationContents(identifier, declaration, text)
val declaration = workspaceDeclaration(workspace, identifier, currentPath, currentText)
if (declaration != null) {
return hoverDeclarationContents(declaration)
}
}
return "Identifier `$identifier`"
}
private fun hoverDeclarationContents(declaration: SourceDeclaration): String {
val qualifiedName = listOfNotNull(declaration.packageName, declaration.containerName, declaration.name).joinToString(".")
val title = when {
qualifiedName.isNotEmpty() -> "${declaration.kind} `$qualifiedName`"
else -> "${declaration.kind} `${declaration.name}`"
}
val signature = declaration.signature.ifBlank { declaration.name }
return "$title\n$signature\n${declaration.location.path}:${declaration.location.line}:${declaration.location.column}"
}
private fun hoverDeclarationContents(identifier: String, location: SourceLocation, text: String): String {
val declarationLine = sourceLine(text, location.line).trim().ifEmpty { identifier }
return "${declarationLine}\n${location.path}:${location.line}:${location.column}"
@ -1067,6 +1585,11 @@ private fun locationJson(location: SourceLocation): JsonObject = buildJsonObject
put("column", location.column)
}
private fun hoverInfoJson(hover: HoverInfo): JsonObject = buildJsonObject {
put("contents", hover.contents)
hover.definition?.let { put("definition", locationJson(it)) }
}
private fun definitionLocationsJson(location: SourceLocation?): JsonElement = buildJsonArray {
if (location != null) add(locationJson(location))
}
@ -1145,4 +1668,3 @@ private fun JsonObject.stringField(key: String): String? =
private fun JsonObject.intField(key: String): Int? =
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull