Step 93: LSP hover and signature help
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<int>();
|
||||
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<CompletionItem> 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<CompletionItem> 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<std::string, std::vector<Diagnostic>> diagnosticsByUri_;
|
||||
int lastCompletionRequestId_ = -1;
|
||||
std::vector<CompletionItem> 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<std::string>();
|
||||
if (contents.is_object()) {
|
||||
if (contents.contains("value")) return contents["value"].get<std::string>();
|
||||
}
|
||||
if (contents.is_array()) {
|
||||
std::string out;
|
||||
for (const auto& item : contents) {
|
||||
if (!out.empty()) out += "\n";
|
||||
out += stringifyMarkup(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
46
editor/tests/step93_test.cpp
Normal file
46
editor/tests/step93_test.cpp
Normal file
@@ -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 <cassert>
|
||||
#include <iostream>
|
||||
#include "LSPClient.h"
|
||||
|
||||
struct MockTransport : public LSPTransport {
|
||||
std::vector<std::string> 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<MockTransport>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user