Step 92: LSP auto-completion

This commit is contained in:
Bill
2026-02-09 09:59:25 -07:00
parent e3439b2a3d
commit 0916026448
5 changed files with 228 additions and 24 deletions

View File

@@ -191,6 +191,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1
### Phase 4c: LSP Client & Diagnostics (Steps 9096) — In Progress
- [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)
---
@@ -261,6 +262,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi
**Step 89:** Compile and pass (1/1)
**Step 90:** Compile and pass (2/2)
**Step 91:** Compile and pass (1/1)
**Step 92:** Compile and pass (1/1)
---
@@ -360,3 +362,4 @@ Sprint 4 in progress. Step 76 (LayoutManager) done. Next: Step 77 (custom code e
| 2026-02-09 | Codex | Step 89: File watcher polling with auto-reload for clean buffers. 1/1 tests pass. |
| 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. |

View File

@@ -510,6 +510,10 @@ add_executable(step91_test tests/step91_test.cpp)
target_include_directories(step91_test PRIVATE src)
target_link_libraries(step91_test PRIVATE nlohmann_json::nlohmann_json)
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)
find_package(SDL2 CONFIG REQUIRED)
find_package(OpenGL REQUIRED)
find_package(glad CONFIG REQUIRED)

View File

@@ -41,6 +41,14 @@ public:
std::string source;
};
struct CompletionItem {
std::string label;
int kind = 0;
std::string detail;
std::string insertText;
std::string filterText;
};
void initialize(const std::string& rootUri,
const std::string& clientName,
int processId) {
@@ -94,6 +102,15 @@ public:
sendNotification("textDocument/didSave", params);
}
int requestCompletion(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/completion", params);
lastCompletionRequestId_ = id;
return id;
}
void handleMessage(const std::string& msg) {
nlohmann::json j;
try {
@@ -101,35 +118,62 @@ public:
} catch (...) {
return;
}
if (!j.contains("method")) return;
if (j["method"].get<std::string>() != "textDocument/publishDiagnostics") return;
if (!j.contains("params")) return;
const auto& params = j["params"];
if (!params.contains("uri") || !params.contains("diagnostics")) return;
if (j.contains("method")) {
if (j["method"].get<std::string>() != "textDocument/publishDiagnostics") return;
if (!j.contains("params")) return;
const auto& params = j["params"];
if (!params.contains("uri") || !params.contains("diagnostics")) return;
std::string uri = params["uri"].get<std::string>();
std::vector<Diagnostic> parsed;
for (const auto& diag : params["diagnostics"]) {
Diagnostic d;
d.uri = uri;
if (diag.contains("range")) {
const auto& range = diag["range"];
if (range.contains("start")) {
d.range.start.line = range["start"].value("line", 0);
d.range.start.character = range["start"].value("character", 0);
}
if (range.contains("end")) {
d.range.end.line = range["end"].value("line", 0);
d.range.end.character = range["end"].value("character", 0);
std::string uri = params["uri"].get<std::string>();
std::vector<Diagnostic> parsed;
for (const auto& diag : params["diagnostics"]) {
Diagnostic d;
d.uri = uri;
if (diag.contains("range")) {
const auto& range = diag["range"];
if (range.contains("start")) {
d.range.start.line = range["start"].value("line", 0);
d.range.start.character = range["start"].value("character", 0);
}
if (range.contains("end")) {
d.range.end.line = range["end"].value("line", 0);
d.range.end.character = range["end"].value("character", 0);
}
}
d.severity = diag.value("severity", 0);
d.message = diag.value("message", "");
d.source = diag.value("source", "");
parsed.push_back(std::move(d));
}
d.severity = diag.value("severity", 0);
d.message = diag.value("message", "");
d.source = diag.value("source", "");
parsed.push_back(std::move(d));
diagnosticsByUri_[uri] = std::move(parsed);
return;
}
diagnosticsByUri_[uri] = std::move(parsed);
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;
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);
}
}
std::vector<Diagnostic> getDiagnostics() const {
@@ -146,6 +190,14 @@ public:
return it->second;
}
std::vector<CompletionItem> getCompletionItems() const {
return completionItems_;
}
void clearCompletionItems() {
completionItems_.clear();
}
void sendNotification(const std::string& method, const nlohmann::json& params) {
nlohmann::json msg;
msg["jsonrpc"] = "2.0";
@@ -175,4 +227,6 @@ private:
std::shared_ptr<LSPTransport> transport_;
int nextId_ = 1;
std::unordered_map<std::string, std::vector<Diagnostic>> diagnosticsByUri_;
int lastCompletionRequestId_ = -1;
std::vector<CompletionItem> completionItems_;
};

View File

@@ -34,6 +34,7 @@
#include <fstream>
#include <sstream>
#include <cstring>
#include <cctype>
#include <filesystem>
#include <map>
#include <memory>
@@ -86,6 +87,10 @@ struct EditorState {
FileWatcher watcher;
std::shared_ptr<LSPTransport> lspTransport;
std::shared_ptr<LSPClient> lsp;
bool completionPending = false;
bool completionVisible = false;
int completionSelected = 0;
double completionLastChange = 0.0;
BufferState* active() { return activeBuffer; }
@@ -119,6 +124,24 @@ struct EditorState {
return pos;
}
static int wordStartAt(const std::string& text, int pos) {
pos = std::max(0, std::min(pos, (int)text.size()));
while (pos > 0) {
char c = text[pos - 1];
if (std::isalnum((unsigned char)c) || c == '_') {
--pos;
} else {
break;
}
}
return pos;
}
static std::string wordPrefixAt(const std::string& text, int pos) {
int start = wordStartAt(text, pos);
return text.substr(start, pos - start);
}
void jumpTo(BufferState* buf, int lineZero, int colZero) {
if (!buf) return;
int pos = byteOffsetForLineCol(buf->editBuf, lineZero, colZero);
@@ -127,6 +150,17 @@ struct EditorState {
buf->cursorCol = colZero + 1;
}
void applyCompletion(BufferState* buf, const std::string& insertText, int replaceStart, int replaceEnd) {
if (!buf) return;
replaceStart = std::max(0, std::min(replaceStart, (int)buf->editBuf.size()));
replaceEnd = std::max(replaceStart, std::min(replaceEnd, (int)buf->editBuf.size()));
buf->editBuf.replace(replaceStart, replaceEnd - replaceStart, insertText);
int newPos = replaceStart + (int)insertText.size();
buf->widget.setCursor(newPos);
activeBuffer = buf;
onTextChanged();
}
std::string makeUntitledName() const {
if (!buffers.hasBuffer("(untitled)")) return "(untitled)";
int i = 1;
@@ -977,6 +1011,76 @@ int main(int, char**) {
state.updateCursorPos(res.cursorByte);
if (res.changed) {
state.onTextChanged();
state.completionPending = true;
state.completionLastChange = ImGui::GetTime();
state.completionVisible = false;
state.completionSelected = 0;
if (state.lsp) state.lsp->clearCompletionItems();
}
double now = ImGui::GetTime();
if (state.completionPending && (now - state.completionLastChange) > 0.2) {
if (state.lsp && state.active() && 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->requestCompletion(EditorState::toFileUri(state.active()->path),
lineZero, colZero);
}
state.completionPending = false;
}
if (state.lsp && state.active()) {
auto items = state.lsp->getCompletionItems();
int cursor = state.active()->widget.getCursor();
std::string prefix = EditorState::wordPrefixAt(state.active()->editBuf, cursor);
std::vector<LSPClient::CompletionItem> filtered;
filtered.reserve(items.size());
for (const auto& item : items) {
const std::string& key = item.filterText.empty() ? item.label : item.filterText;
if (prefix.empty() || key.rfind(prefix, 0) == 0) filtered.push_back(item);
}
state.completionVisible = !filtered.empty();
if (state.completionVisible) {
ImGui::SetCursorScreenPos(ImVec2(ImGui::GetWindowPos().x + 20.0f,
ImGui::GetWindowPos().y + 60.0f));
ImGui::BeginChild("##completionPopup", ImVec2(300, 150), true);
int maxIndex = std::max(0, (int)filtered.size() - 1);
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
state.completionSelected = std::min(state.completionSelected + 1, maxIndex);
}
if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
state.completionSelected = std::max(state.completionSelected - 1, 0);
}
bool accept = ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_Tab);
bool dismiss = ImGui::IsKeyPressed(ImGuiKey_Escape);
if (dismiss) {
state.lsp->clearCompletionItems();
state.completionVisible = false;
}
for (int i = 0; i < (int)filtered.size(); ++i) {
std::string itemLabel = filtered[i].label;
if (filtered[i].kind != 0) {
itemLabel = "[" + std::to_string(filtered[i].kind) + "] " + itemLabel;
}
bool selected = (i == state.completionSelected);
if (ImGui::Selectable(itemLabel.c_str(), selected)) {
state.completionSelected = i;
accept = true;
}
}
if (accept && !filtered.empty()) {
const auto& item = filtered[state.completionSelected];
int start = EditorState::wordStartAt(state.active()->editBuf, cursor);
state.applyCompletion(state.active(), item.insertText, start, cursor);
state.lsp->clearCompletionItems();
state.completionVisible = false;
}
ImGui::EndChild();
}
}
ImGui::EndTabItem();

View File

@@ -0,0 +1,39 @@
// Step 92 TDD Test: LSP auto-completion
//
// Tests:
// 1. completion response parsed into items
#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 reqId = client.requestCompletion("file:///tmp/a.py", 2, 5);
assert(reqId == 1);
std::string resp = R"({"jsonrpc":"2.0","id":1,"result":{"isIncomplete":false,"items":[{"label":"printf","kind":3,"detail":"fn","insertText":"printf"}]}})";
client.handleMessage(resp);
auto items = client.getCompletionItems();
assert(items.size() == 1);
assert(items[0].label == "printf");
assert(items[0].kind == 3);
std::cout << "Test 1 PASS: completion response parsed" << std::endl;
++passed;
std::cout << "\n=== Step 92 Results: " << passed << " passed, " << failed << " failed ===" << std::endl;
return failed > 0 ? 1 : 0;
}