diff --git a/PROGRESS.md b/PROGRESS.md index d64fd3c..a8413d3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -213,6 +213,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1 - [x] Step 107c: **IMPLEMENTED** — Per-buffer mode persistence in recent files (2/2 tests pass) - [x] Step 108: **IMPLEMENTED** — Batch mutation UI with refactor actions and diff preview (3/3 tests pass) - [x] Step 109: **IMPLEMENTED** — Command palette with fuzzy search and MRU ranking (2/2 tests pass) +- [x] Step 110: **IMPLEMENTED** — Go-to-definition (LSP + Whetstone) with hover preview (2/2 tests pass) --- @@ -305,6 +306,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi **Step 107c:** Compile and pass (2/2) **Step 108:** Compile and pass (3/3) **Step 109:** Compile and pass (2/2) +**Step 110:** Compile and pass (2/2) --- @@ -356,7 +358,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi ## What's Next -Sprint 4 in progress. Step 76 (LayoutManager) done. Next: Step 77 (custom code editor renderer). +Sprint 4 in progress. Step 110 (Go-to-definition) done. Next: Step 111 (symbol outline panel). --- @@ -427,3 +429,4 @@ Sprint 4 in progress. Step 76 (LayoutManager) done. Next: Step 77 (custom code e | 2026-02-09 | Codex | Step 107c: Per-buffer mode persistence in recent files. 2/2 tests pass. | | 2026-02-09 | Codex | Step 108: Batch mutation UI with refactor actions and diff preview. 3/3 tests pass. | | 2026-02-09 | Codex | Step 109: Command palette with fuzzy search and MRU ranking. 2/2 tests pass. | +| 2026-02-09 | Codex | Step 110: Go-to-definition (LSP + Whetstone) with hover preview. 2/2 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index a7a22e7..49ce922 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -598,6 +598,10 @@ add_executable(step109_test tests/step109_test.cpp) target_include_directories(step109_test PRIVATE src) target_link_libraries(step109_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step110_test tests/step110_test.cpp) +target_include_directories(step110_test PRIVATE src) +target_link_libraries(step110_test PRIVATE nlohmann_json::nlohmann_json) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/CodeEditorWidget.h b/editor/src/CodeEditorWidget.h index f6476de..ec8363b 100644 --- a/editor/src/CodeEditorWidget.h +++ b/editor/src/CodeEditorWidget.h @@ -54,6 +54,14 @@ struct CodeEditorResult { struct SuggestionMarker clickedSuggestion; bool lineClicked = false; int clickedLine = -1; + bool ctrlClick = false; + int ctrlClickLine = -1; + int ctrlClickCol = -1; + int ctrlClickPos = -1; + bool hoverValid = false; + int hoverLine = -1; + int hoverCol = -1; + int hoverPos = -1; }; struct DiagnosticRange { @@ -107,6 +115,7 @@ public: CodeEditorResult result; if (!font) font = ImGui::GetFont(); + ImGuiIO& io = ImGui::GetIO(); // Build line start offsets std::vector lineStarts; lineStarts.reserve(128); @@ -199,6 +208,7 @@ public: selecting_ = false; goto after_mouse; } + bool ctrlClick = io.KeyCtrl || io.KeySuper; int clickCount = ImGui::GetIO().MouseClickedCount[0]; if (mouse.x < origin.x + gutterWidth || clickCount >= 3) { int line = lineFromMouseY(mouse.y, gutterBase.y, lineHeight, lineCount); @@ -217,6 +227,12 @@ public: int pos = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight); result.lineClicked = true; result.clickedLine = lineFromPos(pos, lineStarts); + if (ctrlClick) { + result.ctrlClick = true; + result.ctrlClickPos = pos; + result.ctrlClickLine = result.clickedLine; + result.ctrlClickCol = pos - lineStarts[result.ctrlClickLine]; + } if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { selectWordAt(text, pos); } else if (ImGui::GetIO().KeyShift) { @@ -243,6 +259,19 @@ public: selecting_ = false; } + if (hovered) { + const ImVec2 mouse = ImGui::GetMousePos(); + if (mouse.x >= origin.x + gutterWidth && + (!options.showMinimap || mouse.x < minimapBaseX)) { + int pos = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight); + int line = lineFromPos(pos, lineStarts); + result.hoverValid = true; + result.hoverPos = pos; + result.hoverLine = line; + result.hoverCol = pos - lineStarts[line]; + } + } + // Keyboard input if (focused && !options.readOnly) { handleKeyboard(text, result.changed, lineStarts, options.mode); diff --git a/editor/src/LSPClient.h b/editor/src/LSPClient.h index 27e5f4c..eab0a7b 100644 --- a/editor/src/LSPClient.h +++ b/editor/src/LSPClient.h @@ -55,6 +55,11 @@ public: int activeParameter = 0; }; + struct DefinitionLocation { + std::string uri; + Range range; + }; + void initialize(const std::string& rootUri, const std::string& clientName, int processId) { @@ -135,6 +140,15 @@ public: return id; } + int requestDefinition(const std::string& uri, int line, int character) { + nlohmann::json params; + params["textDocument"] = {{"uri", uri}}; + params["position"] = {{"line", line}, {"character", character}}; + int id = sendRequest("textDocument/definition", params); + lastDefinitionRequestId_ = id; + return id; + } + void handleMessage(const std::string& msg) { nlohmann::json j; try { @@ -217,6 +231,21 @@ public: } return; } + + if (id == lastDefinitionRequestId_) { + std::vector parsed; + if (result.is_array()) { + for (const auto& item : result) { + DefinitionLocation loc; + if (parseDefinitionLocation(item, loc)) parsed.push_back(std::move(loc)); + } + } else if (result.is_object()) { + DefinitionLocation loc; + if (parseDefinitionLocation(result, loc)) parsed.push_back(std::move(loc)); + } + definitionLocations_ = std::move(parsed); + return; + } } } @@ -258,6 +287,14 @@ public: signatureHelp_ = SignatureHelp{}; } + std::vector getDefinitionLocations() const { + return definitionLocations_; + } + + void clearDefinitionLocations() { + definitionLocations_.clear(); + } + void sendNotification(const std::string& method, const nlohmann::json& params) { nlohmann::json msg; msg["jsonrpc"] = "2.0"; @@ -291,8 +328,43 @@ private: std::vector completionItems_; int lastHoverRequestId_ = -1; int lastSignatureRequestId_ = -1; + int lastDefinitionRequestId_ = -1; std::string hoverContents_; SignatureHelp signatureHelp_; + std::vector definitionLocations_; + + static Range parseRange(const nlohmann::json& range) { + Range out; + if (range.contains("start")) { + const auto& start = range["start"]; + out.start.line = start.value("line", 0); + out.start.character = start.value("character", 0); + } + if (range.contains("end")) { + const auto& end = range["end"]; + out.end.line = end.value("line", 0); + out.end.character = end.value("character", 0); + } + return out; + } + + static bool parseDefinitionLocation(const nlohmann::json& item, DefinitionLocation& out) { + if (item.contains("uri") && item.contains("range")) { + out.uri = item.value("uri", ""); + out.range = parseRange(item["range"]); + return !out.uri.empty(); + } + if (item.contains("targetUri")) { + out.uri = item.value("targetUri", ""); + if (item.contains("targetRange")) { + out.range = parseRange(item["targetRange"]); + } else if (item.contains("targetSelectionRange")) { + out.range = parseRange(item["targetSelectionRange"]); + } + return !out.uri.empty(); + } + return false; + } static std::string stringifyMarkup(const nlohmann::json& contents) { if (contents.is_string()) return contents.get(); diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 8e4adc1..fa8c6c4 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -41,6 +41,7 @@ #include "EditorModePolicy.h" #include "RefactorActions.h" #include "CommandPalette.h" +#include "ContextAPI.h" #include "ast/Serialization.h" #include "ast/Generator.h" #include "ast/Annotation.h" @@ -129,6 +130,13 @@ struct EditorState { bool hoverPending = false; double hoverLastMove = 0.0; ImVec2 hoverLastMouse = ImVec2(-1.0f, -1.0f); + bool definitionPending = false; + double definitionLastRequest = 0.0; + int definitionRequestLine = 0; + int definitionRequestCol = 0; + std::string definitionRequestPath; + LSPClient::DefinitionLocation definitionPreview; + bool definitionPreviewValid = false; Pipeline pipeline; bool analysisPending = false; double analysisLastChange = 0.0; @@ -223,6 +231,31 @@ struct EditorState { return pos; } + static bool isWordChar(char c) { + return std::isalnum((unsigned char)c) || c == '_'; + } + + static std::string wordAt(const std::string& text, int pos) { + if (text.empty()) return ""; + pos = std::max(0, std::min(pos, (int)text.size())); + if (pos == (int)text.size()) pos = (int)text.size() - 1; + if (pos < 0) return ""; + if (!isWordChar(text[pos]) && pos > 0 && isWordChar(text[pos - 1])) { + --pos; + } + if (!isWordChar(text[pos])) return ""; + int start = pos; + while (start > 0 && isWordChar(text[start - 1])) --start; + int end = pos; + while (end < (int)text.size() && isWordChar(text[end])) ++end; + return text.substr(start, end - start); + } + + static std::string wordAtLineCol(const std::string& text, int lineZero, int colZero) { + int pos = byteOffsetForLineCol(text, lineZero, colZero); + return wordAt(text, pos); + } + static std::string wordPrefixAt(const std::string& text, int pos) { int start = wordStartAt(text, pos); return text.substr(start, pos - start); @@ -290,6 +323,79 @@ struct EditorState { } } + bool jumpToDefinitionLocation(const LSPClient::DefinitionLocation& loc) { + if (loc.uri.empty()) return false; + std::string path = fromFileUri(loc.uri); + if (path.empty()) return false; + if (buffers.hasBuffer(path)) switchToBuffer(path); + else doOpen(path); + jumpTo(active(), loc.range.start.line, loc.range.start.character); + return true; + } + + bool goToDefinitionFallback(int lineZero, int colZero) { + if (!isStructured()) return false; + Module* ast = activeAST(); + if (!ast || !active()) return false; + std::string word = wordAtLineCol(active()->editBuf, lineZero, colZero); + if (word.empty()) return false; + + ASTNode* scopeNode = findNodeAtPosition(ast, lineZero, colZero); + if (!scopeNode) scopeNode = ast; + + ContextAPI ctx; + ctx.setRoot(ast); + auto symbols = ctx.getInScopeSymbols(scopeNode->id); + for (const auto& sym : symbols) { + if (sym.name != word) continue; + ASTNode* target = findNodeById(ast, sym.nodeId); + if (target && target->hasSpan()) { + jumpTo(active(), target->spanStartLine, target->spanStartCol); + return true; + } + } + return false; + } + + bool goToDefinitionAt(int lineZero, int colZero) { + if (!active()) return false; + if (lsp && lspTransport && lspTransport->isOpen() && + active()->path.rfind("(untitled", 0) != 0) { + definitionPreviewValid = false; + definitionPending = true; + definitionLastRequest = ImGui::GetTime(); + definitionRequestLine = lineZero; + definitionRequestCol = colZero; + definitionRequestPath = active()->path; + lsp->clearDefinitionLocations(); + lsp->requestDefinition(toFileUri(active()->path), lineZero, colZero); + return true; + } + return goToDefinitionFallback(lineZero, colZero); + } + + bool previewDefinitionAt(int lineZero, int colZero, std::string& outLabel) { + if (!isStructured() || !active()) return false; + Module* ast = activeAST(); + if (!ast) return false; + std::string word = wordAtLineCol(active()->editBuf, lineZero, colZero); + if (word.empty()) return false; + ASTNode* scopeNode = findNodeAtPosition(ast, lineZero, colZero); + if (!scopeNode) scopeNode = ast; + ContextAPI ctx; + ctx.setRoot(ast); + auto symbols = ctx.getInScopeSymbols(scopeNode->id); + for (const auto& sym : symbols) { + if (sym.name != word) continue; + ASTNode* target = findNodeById(ast, sym.nodeId); + if (target && target->hasSpan()) { + outLabel = word + " -> line " + std::to_string(target->spanStartLine + 1); + return true; + } + } + return false; + } + void switchToBuffer(const std::string& path) { if (!buffers.hasBuffer(path)) return; buffers.switchToBuffer(path); @@ -1006,6 +1112,27 @@ static int spanScore(const ASTNode* node) { return lines * 10000 + cols; } +static bool spanContains(const ASTNode* node, int line, int col) { + if (!node || !node->hasSpan()) return false; + if (line < node->spanStartLine || line > node->spanEndLine) return false; + if (line == node->spanStartLine && col < node->spanStartCol) return false; + if (line == node->spanEndLine && col > node->spanEndCol) return false; + return true; +} + +static ASTNode* findNodeAtPosition(ASTNode* node, int line, int col) { + if (!node) return nullptr; + ASTNode* best = nullptr; + if (spanContains(node, line, col)) best = node; + for (auto* child : node->allChildren()) { + ASTNode* cand = findNodeAtPosition(child, line, col); + if (cand) { + if (!best || spanScore(cand) < spanScore(best)) best = cand; + } + } + return best; +} + static ASTNode* findAnnotationTarget(ASTNode* node, int line) { if (!node) return nullptr; ASTNode* best = nullptr; @@ -1994,6 +2121,9 @@ int main(int, char**) { } state.updateCursorPos(res.cursorByte); + if (res.ctrlClick && state.active()) { + state.goToDefinitionAt(res.ctrlClickLine, res.ctrlClickCol); + } if (res.lineClicked && buf->bufferMode == BufferManager::BufferMode::Structured) { Module* ast = state.activeAST(); if (ast) { @@ -2193,6 +2323,21 @@ int main(int, char**) { } } + if (state.definitionPending) { + auto defs = state.lsp->getDefinitionLocations(); + if (!defs.empty()) { + state.definitionPending = false; + state.definitionPreview = defs.front(); + state.definitionPreviewValid = true; + state.lsp->clearDefinitionLocations(); + state.jumpToDefinitionLocation(state.definitionPreview); + } else if ((ImGui::GetTime() - state.definitionLastRequest) > 0.5) { + state.definitionPending = false; + state.goToDefinitionFallback(state.definitionRequestLine, + state.definitionRequestCol); + } + } + auto sig = state.lsp->getSignatureHelp(); if (!sig.label.empty()) { ImGui::SetCursorScreenPos(ImVec2(ImGui::GetWindowPos().x + 20.0f, @@ -2203,6 +2348,22 @@ int main(int, char**) { } } + if (res.hoverValid && (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper)) { + std::string preview; + if (state.previewDefinitionAt(res.hoverLine, res.hoverCol, preview)) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted(preview.c_str()); + ImGui::EndTooltip(); + } else if (state.definitionPreviewValid) { + std::string path = EditorState::fromFileUri(state.definitionPreview.uri); + std::string label = path + ":" + + std::to_string(state.definitionPreview.range.start.line + 1); + ImGui::BeginTooltip(); + ImGui::TextUnformatted(label.c_str()); + ImGui::EndTooltip(); + } + } + if (ImGui::BeginPopupContextWindow("AnnotationContext", ImGuiPopupFlags_MouseButtonRight)) { Module* ast = state.active() ? state.active()->sync.getAST() : nullptr; int lineZero = state.active() ? std::max(0, state.active()->cursorLine - 1) : 0; diff --git a/editor/tests/step110_test.cpp b/editor/tests/step110_test.cpp new file mode 100644 index 0000000..8c5f1a3 --- /dev/null +++ b/editor/tests/step110_test.cpp @@ -0,0 +1,59 @@ +// Step 110 TDD Test: Go-to-definition parsing +#include "LSPClient.h" +#include +#include + +struct DummyTransport : public LSPTransport { + void send(const std::string& msg) override { (void)msg; } + bool receive(std::string& out) override { (void)out; return false; } + bool isOpen() const override { return true; } + void close() override {} +}; + +static void expect(bool cond, const std::string& name, int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n"; + ++failed; + } +} + +int main() { + int passed = 0; + int failed = 0; + + { + auto transport = std::make_shared(); + LSPClient client(transport); + int id = client.requestDefinition("file:///test.py", 1, 2); + std::string msg = std::string("{\"jsonrpc\":\"2.0\",\"id\":") + + std::to_string(id) + + ",\"result\":{\"uri\":\"file:///test.py\",\"range\":{\"start\":{\"line\":5,\"character\":1},\"end\":{\"line\":5,\"character\":3}}}}"; + client.handleMessage(msg); + auto defs = client.getDefinitionLocations(); + bool ok = defs.size() == 1 && defs[0].uri == "file:///test.py" && + defs[0].range.start.line == 5 && defs[0].range.start.character == 1 && + defs[0].range.end.line == 5 && defs[0].range.end.character == 3; + expect(ok, "location object", passed, failed); + } + + { + auto transport = std::make_shared(); + LSPClient client(transport); + int id = client.requestDefinition("file:///main.py", 0, 0); + std::string msg = std::string("{\"jsonrpc\":\"2.0\",\"id\":") + + std::to_string(id) + + ",\"result\":[{\"targetUri\":\"file:///lib.py\",\"targetRange\":{\"start\":{\"line\":2,\"character\":0},\"end\":{\"line\":2,\"character\":4}}}]}"; + client.handleMessage(msg); + auto defs = client.getDefinitionLocations(); + bool ok = defs.size() == 1 && defs[0].uri == "file:///lib.py" && + defs[0].range.start.line == 2 && defs[0].range.start.character == 0 && + defs[0].range.end.line == 2 && defs[0].range.end.character == 4; + expect(ok, "location link array", passed, failed); + } + + std::cout << "\n=== Step 110 Results: " << passed << " passed, " << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint4_plan.md b/sprint4_plan.md index 0ac6f61..c0ab40a 100644 --- a/sprint4_plan.md +++ b/sprint4_plan.md @@ -314,7 +314,7 @@ Whetstone-specific AST navigation. Most-recently-used commands float to top. Extensible for plugins. *New:* `CommandPalette.h` -- [ ] **Step 110: Go-to-definition (LSP + Whetstone)** +- [x] **Step 110: Go-to-definition (LSP + Whetstone)** Ctrl+click on a symbol → jump to its definition. Primary: send `textDocument/definition` to LSP server. Fallback: use `ContextAPI.getInScopeSymbols()` for Whetstone AST-level resolution. Hover shows definition preview popup.