Step 110: go-to-definition

This commit is contained in:
Bill
2026-02-09 12:18:54 -07:00
parent e67358740b
commit e541aaab29
7 changed files with 330 additions and 2 deletions

View File

@@ -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. |

View File

@@ -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)

View File

@@ -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<int> 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);

View File

@@ -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<DefinitionLocation> 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<DefinitionLocation> 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<CompletionItem> completionItems_;
int lastHoverRequestId_ = -1;
int lastSignatureRequestId_ = -1;
int lastDefinitionRequestId_ = -1;
std::string hoverContents_;
SignatureHelp signatureHelp_;
std::vector<DefinitionLocation> 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<std::string>();

View File

@@ -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;

View File

@@ -0,0 +1,59 @@
// Step 110 TDD Test: Go-to-definition parsing
#include "LSPClient.h"
#include <iostream>
#include <memory>
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<DummyTransport>();
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<DummyTransport>();
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;
}

View File

@@ -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.