From 985aa162368a7e98ad4257c39e16897baec640e7 Mon Sep 17 00:00:00 2001 From: pavel Date: Thu, 9 Jul 2026 08:27:13 +0200 Subject: [PATCH] j --- README.md | 8 +- ROADMAP.md | 14 +- client/odin/daemon_client.odin | 47 +- client/odin/daemon_sync.odin | 26 + client/odin/editor.odin | 15 +- client/odin/gpu_renderer.odin | 25 + client/odin/main.odin | 90 +++ client/odin/rename_panel.odin | 283 ++++++++ client/odin/sdl_app.odin | 438 ++++++++----- client/odin/settings.odin | 43 ++ daemon/build.gradle.kts | 13 +- daemon/settings.gradle.kts | 2 + .../kotlin/dev/nativeeditor/daemon/Main.kt | 620 ++++++++++++++++-- 13 files changed, 1382 insertions(+), 242 deletions(-) create mode 100644 client/odin/daemon_sync.odin create mode 100644 client/odin/rename_panel.odin create mode 100644 client/odin/settings.odin diff --git a/README.md b/README.md index 65ba477..6c26440 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 60c25bc..e99d32f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/client/odin/daemon_client.odin b/client/odin/daemon_client.odin index 8e7ae65..31143e9 100644 --- a/client/odin/daemon_client.odin +++ b/client/odin/daemon_client.odin @@ -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) diff --git a/client/odin/daemon_sync.odin b/client/odin/daemon_sync.odin new file mode 100644 index 0000000..f99799f --- /dev/null +++ b/client/odin/daemon_sync.odin @@ -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) +} diff --git a/client/odin/editor.odin b/client/odin/editor.odin index d31c067..2a15cae 100644 --- a/client/odin/editor.odin +++ b/client/odin/editor.odin @@ -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 } diff --git a/client/odin/gpu_renderer.odin b/client/odin/gpu_renderer.odin index 845d863..5b7ebf6 100644 --- a/client/odin/gpu_renderer.odin +++ b/client/odin/gpu_renderer.odin @@ -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 { diff --git a/client/odin/main.odin b/client/odin/main.odin index caac48f..1e9ecdf 100644 --- a/client/odin/main.odin +++ b/client/odin/main.odin @@ -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) diff --git a/client/odin/rename_panel.odin b/client/odin/rename_panel.odin new file mode 100644 index 0000000..044324f --- /dev/null +++ b/client/odin/rename_panel.odin @@ -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) + } +} diff --git a/client/odin/sdl_app.odin b/client/odin/sdl_app.odin index bcc5dac..89a1c43 100644 --- a/client/odin/sdl_app.odin +++ b/client/odin/sdl_app.odin @@ -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) diff --git a/client/odin/settings.odin b/client/odin/settings.odin new file mode 100644 index 0000000..1e7e5dc --- /dev/null +++ b/client/odin/settings.odin @@ -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 +} diff --git a/daemon/build.gradle.kts b/daemon/build.gradle.kts index 93c1d84..888b4e4 100644 --- a/daemon/build.gradle.kts +++ b/daemon/build.gradle.kts @@ -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") } diff --git a/daemon/settings.gradle.kts b/daemon/settings.gradle.kts index 8c10b87..71ab2f6 100644 --- a/daemon/settings.gradle.kts +++ b/daemon/settings.gradle.kts @@ -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") } } diff --git a/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt b/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt index dc238cf..6023695 100644 --- a/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt +++ b/daemon/src/main/kotlin/dev/nativeeditor/daemon/Main.kt @@ -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) { val requestedPort = args.firstOrNull()?.toIntOrNull() ?: 0 @@ -57,15 +74,50 @@ fun main(args: Array) { private class DaemonState { @Volatile var workspaceRoot: File? = null @Volatile var gradleWorkspace: GradleWorkspace? = null + @Volatile var workspaceGeneration: Int = 0 val openTexts = ConcurrentHashMap() val textVersions = ConcurrentHashMap() val diagnosticsVersions = ConcurrentHashMap() + val diagnosticsCache = ConcurrentHashMap>() + val semanticCacheLock = Any() + val semanticCache = LinkedHashMap() + + 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, val tasks: List, + val sourceTexts: Map, + val declarations: List, +) + +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, val testResourceRoots: List, val classpath: List, + val mainSourceFiles: List, + val testSourceFiles: List, ) 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 { + 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): List { + val declarations = mutableListOf() + 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>() + 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): Map { + 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 { 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 { - 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 { + 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 { 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 { + val members = mutableListOf() + 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() + .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? { + 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() + for (ktFile in context.session.modulesWithFiles.values.flatten().filterIsInstance()) { + 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 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() + .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() + .flatMap { it.resolveToSymbols().asSequence() } + .firstNotNullOfOrNull { it.psi } + } +} + +private fun sourceRootsForFile(module: GradleModule, file: File, tempDir: File): List { + val roots = linkedSetOf() + 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 { + 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 { + 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() 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 { +private fun completionCandidates(prefix: String, currentText: String, currentPath: String, workspace: GradleWorkspace?): List { val normalized = prefix.lowercase() val candidates = linkedMapOf() @@ -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 { + 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> { val result = linkedMapOf() 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 -