Step 91: LSP diagnostics integration
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
class LSPTransport {
|
||||
@@ -22,6 +23,24 @@ public:
|
||||
explicit LSPClient(std::shared_ptr<LSPTransport> 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<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);
|
||||
}
|
||||
}
|
||||
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<Diagnostic> getDiagnostics() const {
|
||||
std::vector<Diagnostic> out;
|
||||
for (const auto& kv : diagnosticsByUri_) {
|
||||
out.insert(out.end(), kv.second.begin(), kv.second.end());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<Diagnostic> 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<LSPTransport> transport_;
|
||||
int nextId_ = 1;
|
||||
std::unordered_map<std::string, std::vector<Diagnostic>> diagnosticsByUri_;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "WelcomeScreen.h"
|
||||
#include "DragDropHandler.h"
|
||||
#include "FileWatcher.h"
|
||||
#include "LSPClient.h"
|
||||
#include "ast/Generator.h"
|
||||
|
||||
#include <cstdio>
|
||||
@@ -52,6 +53,7 @@ struct BufferState {
|
||||
bool modified = false;
|
||||
int cursorLine = 1;
|
||||
int cursorCol = 1;
|
||||
int lspVersion = 1;
|
||||
std::string editBuf;
|
||||
std::vector<HighlightSpan> highlights;
|
||||
bool highlightsDirty = true;
|
||||
@@ -82,9 +84,49 @@ struct EditorState {
|
||||
FileNode fileTreeRoot;
|
||||
bool fileTreeDirty = true;
|
||||
FileWatcher watcher;
|
||||
std::shared_ptr<LSPTransport> lspTransport;
|
||||
std::shared_ptr<LSPClient> 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<NullLSPTransport>();
|
||||
state.lsp = std::make_shared<LSPClient>(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<LSPClient::Diagnostic>{};
|
||||
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);
|
||||
|
||||
37
editor/tests/step91_test.cpp
Normal file
37
editor/tests/step91_test.cpp
Normal file
@@ -0,0 +1,37 @@
|
||||
// Step 91 TDD Test: LSP diagnostics integration
|
||||
//
|
||||
// Tests:
|
||||
// 1. handleMessage parses publishDiagnostics
|
||||
|
||||
#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);
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user