From e3439b2a3d403c4509801333ab675e6eeb9e7d91 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 9 Feb 2026 09:55:48 -0700 Subject: [PATCH] Step 91: LSP diagnostics integration --- PROGRESS.md | 3 + editor/CMakeLists.txt | 4 ++ editor/src/LSPClient.h | 107 +++++++++++++++++++++++++++++++++++ editor/src/main.cpp | 93 ++++++++++++++++++++++++++++++ editor/tests/step91_test.cpp | 37 ++++++++++++ 5 files changed, 244 insertions(+) create mode 100644 editor/tests/step91_test.cpp diff --git a/PROGRESS.md b/PROGRESS.md index a40504b..e401aa9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -190,6 +190,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1 ### Phase 4c: LSP Client & Diagnostics (Steps 90–96) — 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) --- @@ -259,6 +260,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi **Step 88:** Compile and pass (2/2) **Step 89:** Compile and pass (1/1) **Step 90:** Compile and pass (2/2) +**Step 91:** Compile and pass (1/1) --- @@ -357,3 +359,4 @@ Sprint 4 in progress. Step 76 (LayoutManager) done. Next: Step 77 (custom code e | 2026-02-09 | Codex | Step 88: Drag-and-drop open for files and folders. 2/2 tests pass. | | 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. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 22ffdb7..3396b89 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -506,6 +506,10 @@ add_executable(step90_test tests/step90_test.cpp) target_include_directories(step90_test PRIVATE src) target_link_libraries(step90_test PRIVATE nlohmann_json::nlohmann_json) +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) + 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 1b6b833..f47ac01 100644 --- a/editor/src/LSPClient.h +++ b/editor/src/LSPClient.h @@ -6,6 +6,7 @@ #include #include #include +#include #include class LSPTransport { @@ -22,6 +23,24 @@ public: explicit LSPClient(std::shared_ptr transport) : transport_(std::move(transport)) {} + struct Position { + int line = 0; + int character = 0; + }; + + struct Range { + Position start; + Position end; + }; + + struct Diagnostic { + std::string uri; + Range range; + int severity = 0; + std::string message; + std::string source; + }; + void initialize(const std::string& rootUri, const std::string& clientName, int processId) { @@ -40,6 +59,93 @@ public: if (transport_) transport_->close(); } + void didOpen(const std::string& uri, + const std::string& languageId, + const std::string& text, + int version) { + nlohmann::json params; + params["textDocument"] = { + {"uri", uri}, + {"languageId", languageId}, + {"version", version}, + {"text", text} + }; + sendNotification("textDocument/didOpen", params); + } + + void didChange(const std::string& uri, + const std::string& text, + int version) { + nlohmann::json params; + params["textDocument"] = { + {"uri", uri}, + {"version", version} + }; + params["contentChanges"] = nlohmann::json::array({{ + {"text", text} + }}); + sendNotification("textDocument/didChange", params); + } + + void didSave(const std::string& uri, const std::string& text = std::string()) { + nlohmann::json params; + params["textDocument"] = {{"uri", uri}}; + if (!text.empty()) params["text"] = text; + sendNotification("textDocument/didSave", params); + } + + void handleMessage(const std::string& msg) { + nlohmann::json j; + try { + j = nlohmann::json::parse(msg); + } catch (...) { + return; + } + if (!j.contains("method")) return; + if (j["method"].get() != "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::vector 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)); + } + + diagnosticsByUri_[uri] = std::move(parsed); + } + + std::vector getDiagnostics() const { + std::vector out; + for (const auto& kv : diagnosticsByUri_) { + out.insert(out.end(), kv.second.begin(), kv.second.end()); + } + return out; + } + + std::vector getDiagnosticsForUri(const std::string& uri) const { + auto it = diagnosticsByUri_.find(uri); + if (it == diagnosticsByUri_.end()) return {}; + return it->second; + } + void sendNotification(const std::string& method, const nlohmann::json& params) { nlohmann::json msg; msg["jsonrpc"] = "2.0"; @@ -68,4 +174,5 @@ private: std::shared_ptr transport_; int nextId_ = 1; + std::unordered_map> diagnosticsByUri_; }; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 74b0152..ca3cc65 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -24,6 +24,7 @@ #include "WelcomeScreen.h" #include "DragDropHandler.h" #include "FileWatcher.h" +#include "LSPClient.h" #include "ast/Generator.h" #include @@ -52,6 +53,7 @@ struct BufferState { bool modified = false; int cursorLine = 1; int cursorCol = 1; + int lspVersion = 1; std::string editBuf; std::vector highlights; bool highlightsDirty = true; @@ -82,9 +84,49 @@ struct EditorState { FileNode fileTreeRoot; bool fileTreeDirty = true; FileWatcher watcher; + std::shared_ptr lspTransport; + std::shared_ptr lsp; BufferState* active() { return activeBuffer; } + static std::string toFileUri(const std::string& path) { + std::filesystem::path p(path); + return "file:///" + p.generic_string(); + } + + static std::string fromFileUri(const std::string& uri) { + const std::string prefix = "file:///"; + if (uri.rfind(prefix, 0) != 0) return uri; + std::string path = uri.substr(prefix.size()); + std::replace(path.begin(), path.end(), '/', '\\'); + return path; + } + + static int byteOffsetForLineCol(const std::string& text, int lineZero, int colZero) { + if (lineZero < 0) lineZero = 0; + if (colZero < 0) colZero = 0; + int line = 0; + int pos = 0; + while (pos < (int)text.size() && line < lineZero) { + if (text[pos] == '\n') ++line; + ++pos; + } + int col = 0; + while (pos < (int)text.size() && text[pos] != '\n' && col < colZero) { + ++pos; + ++col; + } + return pos; + } + + void jumpTo(BufferState* buf, int lineZero, int colZero) { + if (!buf) return; + int pos = byteOffsetForLineCol(buf->editBuf, lineZero, colZero); + buf->widget.setCursor(pos); + buf->cursorLine = lineZero + 1; + buf->cursorCol = colZero + 1; + } + std::string makeUntitledName() const { if (!buffers.hasBuffer("(untitled)")) return "(untitled)"; int i = 1; @@ -111,10 +153,14 @@ struct EditorState { state->editBuf = content; state->highlightsDirty = true; state->modified = false; + state->lspVersion = 1; buffers.openBuffer(path, content, language); activeBuffer = state.get(); bufferStates[path] = std::move(state); if (path.rfind("(untitled", 0) != 0) watcher.watch(path); + if (lsp && path.rfind("(untitled", 0) != 0) { + lsp->didOpen(toFileUri(path), language, content, activeBuffer->lspVersion); + } } void switchToBuffer(const std::string& path) { @@ -186,6 +232,10 @@ struct EditorState { active()->sync.syncNow(); active()->highlightsDirty = true; active()->modified = true; + if (lsp && active()->path.rfind("(untitled", 0) != 0) { + active()->lspVersion += 1; + lsp->didChange(toFileUri(active()->path), active()->editBuf, active()->lspVersion); + } } void doUndo() { @@ -249,6 +299,7 @@ struct EditorState { out.close(); active()->modified = false; outputLog += "Saved: " + active()->path + "\n"; + if (lsp) lsp->didSave(toFileUri(active()->path), active()->editBuf); } else { outputLog += "Error saving: " + active()->path + "\n"; } @@ -342,6 +393,13 @@ struct EditorState { } }; +struct NullLSPTransport : 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 false; } + void close() override {} +}; + // --------------------------------------------------------------------------- // ImGui InputTextMultiline with std::string resize callback @@ -643,6 +701,8 @@ int main(int, char**) { // Editor state EditorState state; state.init(); + state.lspTransport = std::make_shared(); + state.lsp = std::make_shared(state.lspTransport); // File dialog defaults std::string lastDialogPath; @@ -958,6 +1018,39 @@ int main(int, char**) { ImGui::EndTabItem(); } + // Problems (LSP diagnostics) + if (ImGui::BeginTabItem("Problems")) { + ImGui::PushFont(monoFont); + ImGui::BeginChild("##problemsScroll", ImVec2(0, 0), false); + auto diags = state.lsp ? state.lsp->getDiagnostics() : std::vector{}; + if (diags.empty()) { + ImGui::TextDisabled("(no diagnostics)"); + } else { + for (const auto& d : diags) { + const char* sev = "Unknown"; + if (d.severity == 1) sev = "Error"; + else if (d.severity == 2) sev = "Warning"; + else if (d.severity == 3) sev = "Info"; + else if (d.severity == 4) sev = "Hint"; + + std::string path = EditorState::fromFileUri(d.uri); + std::string label = "[" + std::string(sev) + "] " + d.message + + " (" + path + ":" + std::to_string(d.range.start.line + 1) + + ":" + std::to_string(d.range.start.character + 1) + ")"; + if (ImGui::Selectable(label.c_str())) { + if (!path.empty()) { + if (state.buffers.hasBuffer(path)) state.switchToBuffer(path); + else state.doOpen(path); + state.jumpTo(state.active(), d.range.start.line, d.range.start.character); + } + } + } + } + ImGui::EndChild(); + ImGui::PopFont(); + ImGui::EndTabItem(); + } + // AST view if (ImGui::BeginTabItem("AST")) { ImGui::PushFont(monoFont); diff --git a/editor/tests/step91_test.cpp b/editor/tests/step91_test.cpp new file mode 100644 index 0000000..049d3ae --- /dev/null +++ b/editor/tests/step91_test.cpp @@ -0,0 +1,37 @@ +// Step 91 TDD Test: LSP diagnostics integration +// +// Tests: +// 1. handleMessage parses publishDiagnostics + +#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); + + std::string msg = R"({"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///tmp/a.py","diagnostics":[{"range":{"start":{"line":1,"character":2},"end":{"line":1,"character":3}},"severity":1,"message":"err"}]}})"; + client.handleMessage(msg); + + auto diags = client.getDiagnostics(); + assert(diags.size() == 1); + assert(diags[0].uri == "file:///tmp/a.py"); + assert(diags[0].message == "err"); + std::cout << "Test 1 PASS: publishDiagnostics parsed" << std::endl; + ++passed; + + std::cout << "\n=== Step 91 Results: " << passed << " passed, " << failed << " failed ===" << std::endl; + return failed > 0 ? 1 : 0; +}