diff --git a/PROGRESS.md b/PROGRESS.md index 9eae7fe..c5b6449 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -192,6 +192,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1 - [x] Step 90: **IMPLEMENTED** — LSPClient core with JSON-RPC initialize/shutdown, injectable transport (2/2 tests pass) - [x] Step 91: **IMPLEMENTED** — LSP diagnostics: didOpen/didChange/didSave notifications, publishDiagnostics parsing/storage, Problems panel UI (1/1 tests pass) - [x] Step 92: **IMPLEMENTED** — LSP completion requests, response parsing, and completion popup with filtering/acceptance (1/1 tests pass) +- [x] Step 93: **IMPLEMENTED** — LSP hover and signature help requests, response parsing, and inline tooltip/popup (2/2 tests pass) --- @@ -263,6 +264,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi **Step 90:** Compile and pass (2/2) **Step 91:** Compile and pass (1/1) **Step 92:** Compile and pass (1/1) +**Step 93:** Compile and pass (2/2) --- @@ -363,3 +365,4 @@ Sprint 4 in progress. Step 76 (LayoutManager) done. Next: Step 77 (custom code e | 2026-02-09 | Codex | Step 90: LSPClient core with JSON-RPC initialize/shutdown and injectable transport. 2/2 tests pass. | | 2026-02-09 | Codex | Step 91: LSP diagnostics integration: didOpen/didChange/didSave notifications, publishDiagnostics parsing, Problems panel with jump-to-location. 1/1 tests pass. | | 2026-02-09 | Codex | Step 92: LSP completion requests/response parsing; completion popup with filtering and acceptance. 1/1 tests pass. | +| 2026-02-09 | Codex | Step 93: LSP hover and signature help requests/response parsing; tooltip and signature popup. 2/2 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index a7c3664..27e624c 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -514,6 +514,10 @@ add_executable(step92_test tests/step92_test.cpp) target_include_directories(step92_test PRIVATE src) target_link_libraries(step92_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step93_test tests/step93_test.cpp) +target_include_directories(step93_test PRIVATE src) +target_link_libraries(step93_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/LSPClient.h b/editor/src/LSPClient.h index 5587f7d..27e5f4c 100644 --- a/editor/src/LSPClient.h +++ b/editor/src/LSPClient.h @@ -49,6 +49,12 @@ public: std::string filterText; }; + struct SignatureHelp { + std::string label; + int activeSignature = 0; + int activeParameter = 0; + }; + void initialize(const std::string& rootUri, const std::string& clientName, int processId) { @@ -111,6 +117,24 @@ public: return id; } + int requestHover(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/hover", params); + lastHoverRequestId_ = id; + return id; + } + + int requestSignatureHelp(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/signatureHelp", params); + lastSignatureRequestId_ = id; + return id; + } + void handleMessage(const std::string& msg) { nlohmann::json j; try { @@ -152,27 +176,47 @@ public: if (j.contains("id") && j.contains("result")) { int id = j["id"].get(); - if (lastCompletionRequestId_ > 0 && id != lastCompletionRequestId_) return; const auto& result = j["result"]; - const nlohmann::json* items = nullptr; - if (result.is_array()) { - items = &result; - } else if (result.is_object() && result.contains("items")) { - items = &result["items"]; - } - if (!items || !items->is_array()) return; + if (id == lastCompletionRequestId_) { + const nlohmann::json* items = nullptr; + if (result.is_array()) { + items = &result; + } else if (result.is_object() && result.contains("items")) { + items = &result["items"]; + } + if (!items || !items->is_array()) return; - std::vector parsed; - for (const auto& item : *items) { - CompletionItem ci; - ci.label = item.value("label", ""); - ci.kind = item.value("kind", 0); - ci.detail = item.value("detail", ""); - ci.insertText = item.value("insertText", ci.label); - ci.filterText = item.value("filterText", ""); - parsed.push_back(std::move(ci)); + std::vector parsed; + for (const auto& item : *items) { + CompletionItem ci; + ci.label = item.value("label", ""); + ci.kind = item.value("kind", 0); + ci.detail = item.value("detail", ""); + ci.insertText = item.value("insertText", ci.label); + ci.filterText = item.value("filterText", ""); + parsed.push_back(std::move(ci)); + } + completionItems_ = std::move(parsed); + return; + } + + if (id == lastHoverRequestId_) { + if (result.contains("contents")) { + hoverContents_ = stringifyMarkup(result["contents"]); + } + return; + } + + if (id == lastSignatureRequestId_) { + if (result.contains("signatures") && result["signatures"].is_array() && + !result["signatures"].empty()) { + const auto& sig = result["signatures"][0]; + signatureHelp_.label = sig.value("label", ""); + signatureHelp_.activeSignature = result.value("activeSignature", 0); + signatureHelp_.activeParameter = result.value("activeParameter", 0); + } + return; } - completionItems_ = std::move(parsed); } } @@ -198,6 +242,22 @@ public: completionItems_.clear(); } + std::string getHoverContents() const { + return hoverContents_; + } + + void clearHover() { + hoverContents_.clear(); + } + + SignatureHelp getSignatureHelp() const { + return signatureHelp_; + } + + void clearSignatureHelp() { + signatureHelp_ = SignatureHelp{}; + } + void sendNotification(const std::string& method, const nlohmann::json& params) { nlohmann::json msg; msg["jsonrpc"] = "2.0"; @@ -229,4 +289,24 @@ private: std::unordered_map> diagnosticsByUri_; int lastCompletionRequestId_ = -1; std::vector completionItems_; + int lastHoverRequestId_ = -1; + int lastSignatureRequestId_ = -1; + std::string hoverContents_; + SignatureHelp signatureHelp_; + + static std::string stringifyMarkup(const nlohmann::json& contents) { + if (contents.is_string()) return contents.get(); + if (contents.is_object()) { + if (contents.contains("value")) return contents["value"].get(); + } + if (contents.is_array()) { + std::string out; + for (const auto& item : contents) { + if (!out.empty()) out += "\n"; + out += stringifyMarkup(item); + } + return out; + } + return ""; + } }; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index cb227fe..4043b2c 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -91,6 +91,9 @@ struct EditorState { bool completionVisible = false; int completionSelected = 0; double completionLastChange = 0.0; + bool hoverPending = false; + double hoverLastMove = 0.0; + ImVec2 hoverLastMouse = ImVec2(-1.0f, -1.0f); BufferState* active() { return activeBuffer; } @@ -1016,6 +1019,17 @@ int main(int, char**) { state.completionVisible = false; state.completionSelected = 0; if (state.lsp) state.lsp->clearCompletionItems(); + if (state.lsp && state.active() && state.active()->path.rfind("(untitled", 0) != 0) { + int cursor = state.active()->widget.getCursor(); + if (cursor > 0 && state.active()->editBuf[cursor - 1] == '(') { + int lineZero = std::max(0, state.active()->cursorLine - 1); + int colZero = std::max(0, state.active()->cursorCol - 1); + state.lsp->requestSignatureHelp(EditorState::toFileUri(state.active()->path), + lineZero, colZero); + } else if (cursor > 0 && state.active()->editBuf[cursor - 1] == ')') { + state.lsp->clearSignatureHelp(); + } + } } double now = ImGui::GetTime(); @@ -1083,6 +1097,42 @@ int main(int, char**) { } } + if (state.lsp && state.active()) { + if (ImGui::IsWindowHovered()) { + ImVec2 mouse = ImGui::GetMousePos(); + if (mouse.x != state.hoverLastMouse.x || mouse.y != state.hoverLastMouse.y) { + state.hoverLastMouse = mouse; + state.hoverLastMove = ImGui::GetTime(); + state.hoverPending = true; + state.lsp->clearHover(); + } + if (state.hoverPending && (ImGui::GetTime() - state.hoverLastMove) > 0.5) { + if (state.active()->path.rfind("(untitled", 0) != 0) { + int lineZero = std::max(0, state.active()->cursorLine - 1); + int colZero = std::max(0, state.active()->cursorCol - 1); + state.lsp->requestHover(EditorState::toFileUri(state.active()->path), + lineZero, colZero); + } + state.hoverPending = false; + } + std::string hover = state.lsp->getHoverContents(); + if (!hover.empty()) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted(hover.c_str()); + ImGui::EndTooltip(); + } + } + + auto sig = state.lsp->getSignatureHelp(); + if (!sig.label.empty()) { + ImGui::SetCursorScreenPos(ImVec2(ImGui::GetWindowPos().x + 20.0f, + ImGui::GetWindowPos().y + ImGui::GetWindowHeight() - 80.0f)); + ImGui::BeginChild("##signatureHelp", ImVec2(420, 60), true); + ImGui::TextUnformatted(sig.label.c_str()); + ImGui::EndChild(); + } + } + ImGui::EndTabItem(); } if (!open) { diff --git a/editor/tests/step93_test.cpp b/editor/tests/step93_test.cpp new file mode 100644 index 0000000..4e2b965 --- /dev/null +++ b/editor/tests/step93_test.cpp @@ -0,0 +1,46 @@ +// Step 93 TDD Test: LSP hover and signature help +// +// Tests: +// 1. hover response parsed into text +// 2. signature help response parsed + +#include +#include +#include "LSPClient.h" + +struct MockTransport : public LSPTransport { + std::vector sent; + void send(const std::string& msg) override { sent.push_back(msg); } + bool receive(std::string& out) override { (void)out; return false; } + bool isOpen() const override { return true; } + void close() override {} +}; + +int main() { + int passed = 0; + int failed = 0; + + auto transport = std::make_shared(); + LSPClient client(transport); + + int hoverId = client.requestHover("file:///tmp/a.py", 0, 1); + assert(hoverId == 1); + std::string hoverResp = R"json({"jsonrpc":"2.0","id":1,"result":{"contents":"int"}})json"; + client.handleMessage(hoverResp); + assert(client.getHoverContents() == "int"); + std::cout << "Test 1 PASS: hover response parsed" << std::endl; + ++passed; + + int sigId = client.requestSignatureHelp("file:///tmp/a.py", 0, 5); + assert(sigId == 2); + std::string sigResp = R"json({"jsonrpc":"2.0","id":2,"result":{"signatures":[{"label":"foo(a: int, b: int)"}],"activeSignature":0,"activeParameter":1}})json"; + client.handleMessage(sigResp); + auto sig = client.getSignatureHelp(); + assert(sig.label == "foo(a: int, b: int)"); + assert(sig.activeParameter == 1); + std::cout << "Test 2 PASS: signature help response parsed" << std::endl; + ++passed; + + std::cout << "\n=== Step 93 Results: " << passed << " passed, " << failed << " failed ===" << std::endl; + return failed > 0 ? 1 : 0; +}