From b474ad2752fd3f669c4c5c2a47f826d19ce2652d Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 9 Feb 2026 12:23:45 -0700 Subject: [PATCH] Step 111: symbol outline panel --- PROGRESS.md | 5 +- editor/CMakeLists.txt | 4 + editor/src/LSPClient.h | 78 +++++++++++++ editor/src/main.cpp | 199 ++++++++++++++++++++++++++++++++++ editor/tests/step111_test.cpp | 59 ++++++++++ sprint4_plan.md | 2 +- 6 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 editor/tests/step111_test.cpp diff --git a/PROGRESS.md b/PROGRESS.md index a8413d3..74a8f46 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -214,6 +214,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1 - [x] Step 108: **IMPLEMENTED** — Batch mutation UI with refactor actions and diff preview (3/3 tests pass) - [x] Step 109: **IMPLEMENTED** — Command palette with fuzzy search and MRU ranking (2/2 tests pass) - [x] Step 110: **IMPLEMENTED** — Go-to-definition (LSP + Whetstone) with hover preview (2/2 tests pass) +- [x] Step 111: **IMPLEMENTED** — Symbol outline panel with LSP + AST fallback (2/2 tests pass) --- @@ -307,6 +308,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi **Step 108:** Compile and pass (3/3) **Step 109:** Compile and pass (2/2) **Step 110:** Compile and pass (2/2) +**Step 111:** Compile and pass (2/2) --- @@ -358,7 +360,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi ## What's Next -Sprint 4 in progress. Step 110 (Go-to-definition) done. Next: Step 111 (symbol outline panel). +Sprint 4 in progress. Step 111 (symbol outline panel) done. Next: Step 112 (breadcrumb navigation). --- @@ -430,3 +432,4 @@ Sprint 4 in progress. Step 110 (Go-to-definition) done. Next: Step 111 (symbol o | 2026-02-09 | Codex | Step 108: Batch mutation UI with refactor actions and diff preview. 3/3 tests pass. | | 2026-02-09 | Codex | Step 109: Command palette with fuzzy search and MRU ranking. 2/2 tests pass. | | 2026-02-09 | Codex | Step 110: Go-to-definition (LSP + Whetstone) with hover preview. 2/2 tests pass. | +| 2026-02-09 | Codex | Step 111: Symbol outline panel with LSP + AST fallback. 2/2 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 49ce922..cb2e536 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -602,6 +602,10 @@ add_executable(step110_test tests/step110_test.cpp) target_include_directories(step110_test PRIVATE src) target_link_libraries(step110_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step111_test tests/step111_test.cpp) +target_include_directories(step111_test PRIVATE src) +target_link_libraries(step111_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 eab0a7b..8606c5f 100644 --- a/editor/src/LSPClient.h +++ b/editor/src/LSPClient.h @@ -60,6 +60,15 @@ public: Range range; }; + struct DocumentSymbol { + std::string name; + std::string detail; + int kind = 0; + Range range; + Range selectionRange; + std::vector children; + }; + void initialize(const std::string& rootUri, const std::string& clientName, int processId) { @@ -149,6 +158,14 @@ public: return id; } + int requestDocumentSymbols(const std::string& uri) { + nlohmann::json params; + params["textDocument"] = {{"uri", uri}}; + int id = sendRequest("textDocument/documentSymbol", params); + lastDocumentSymbolRequestId_ = id; + return id; + } + void handleMessage(const std::string& msg) { nlohmann::json j; try { @@ -246,6 +263,24 @@ public: definitionLocations_ = std::move(parsed); return; } + + if (id == lastDocumentSymbolRequestId_) { + std::vector parsed; + if (result.is_array()) { + bool isSymbolInfo = !result.empty() && result[0].contains("location"); + for (const auto& item : result) { + DocumentSymbol sym; + bool ok = isSymbolInfo ? parseSymbolInformation(item, sym) + : parseDocumentSymbol(item, sym); + if (ok) parsed.push_back(std::move(sym)); + } + } else if (result.is_object()) { + DocumentSymbol sym; + if (parseDocumentSymbol(result, sym)) parsed.push_back(std::move(sym)); + } + documentSymbols_ = std::move(parsed); + return; + } } } @@ -295,6 +330,14 @@ public: definitionLocations_.clear(); } + std::vector getDocumentSymbols() const { + return documentSymbols_; + } + + void clearDocumentSymbols() { + documentSymbols_.clear(); + } + void sendNotification(const std::string& method, const nlohmann::json& params) { nlohmann::json msg; msg["jsonrpc"] = "2.0"; @@ -329,9 +372,11 @@ private: int lastHoverRequestId_ = -1; int lastSignatureRequestId_ = -1; int lastDefinitionRequestId_ = -1; + int lastDocumentSymbolRequestId_ = -1; std::string hoverContents_; SignatureHelp signatureHelp_; std::vector definitionLocations_; + std::vector documentSymbols_; static Range parseRange(const nlohmann::json& range) { Range out; @@ -366,6 +411,39 @@ private: return false; } + static bool parseDocumentSymbol(const nlohmann::json& item, DocumentSymbol& out) { + if (!item.contains("name")) return false; + out.name = item.value("name", ""); + out.detail = item.value("detail", ""); + out.kind = item.value("kind", 0); + if (item.contains("range")) out.range = parseRange(item["range"]); + if (item.contains("selectionRange")) { + out.selectionRange = parseRange(item["selectionRange"]); + } else { + out.selectionRange = out.range; + } + if (item.contains("children") && item["children"].is_array()) { + for (const auto& child : item["children"]) { + DocumentSymbol c; + if (parseDocumentSymbol(child, c)) out.children.push_back(std::move(c)); + } + } + return !out.name.empty(); + } + + static bool parseSymbolInformation(const nlohmann::json& item, DocumentSymbol& out) { + if (!item.contains("name") || !item.contains("location")) return false; + out.name = item.value("name", ""); + out.detail = item.value("detail", ""); + out.kind = item.value("kind", 0); + const auto& loc = item["location"]; + if (loc.contains("range")) { + out.range = parseRange(loc["range"]); + out.selectionRange = out.range; + } + return !out.name.empty(); + } + static std::string stringifyMarkup(const nlohmann::json& contents) { if (contents.is_string()) return contents.get(); if (contents.is_object()) { diff --git a/editor/src/main.cpp b/editor/src/main.cpp index fa8c6c4..1322505 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -114,6 +114,8 @@ struct EditorState { bool showWhitespace = false; bool showMinimap = false; bool showAnnotations = false; + bool showOutline = true; + char outlineFilter[128] = {}; LayoutPreset layoutPreset = LayoutPreset::VSCode; WelcomeScreen welcome; std::string workspaceRoot; @@ -123,6 +125,8 @@ struct EditorState { FileWatcher watcher; std::shared_ptr lspTransport; std::shared_ptr lsp; + bool symbolsPending = false; + double symbolsLastChange = 0.0; bool completionPending = false; bool completionVisible = false; int completionSelected = 0; @@ -321,6 +325,9 @@ struct EditorState { if (lsp && path.rfind("(untitled", 0) != 0) { lsp->didOpen(toFileUri(path), language, content, activeBuffer->lspVersion); } + symbolsPending = true; + symbolsLastChange = ImGui::GetTime(); + if (lsp) lsp->clearDocumentSymbols(); } bool jumpToDefinitionLocation(const LSPClient::DefinitionLocation& loc) { @@ -400,6 +407,9 @@ struct EditorState { if (!buffers.hasBuffer(path)) return; buffers.switchToBuffer(path); activeBuffer = bufferStates[path].get(); + symbolsPending = true; + symbolsLastChange = ImGui::GetTime(); + if (lsp) lsp->clearDocumentSymbols(); } std::filesystem::path configDir() const { @@ -493,6 +503,8 @@ struct EditorState { [this]() { showMinimap = !showMinimap; }); registerCommand("view.annotations", "View: Toggle Annotations", "", [this]() { showAnnotations = !showAnnotations; }); + registerCommand("view.outline", "View: Toggle Outline", "", + [this]() { showOutline = !showOutline; }); registerCommand("view.lsp", "View: LSP Servers...", "", [this]() { showLspSettings = !showLspSettings; }); registerCommand("mode.toggle", "Mode: Toggle Text/Structured", "", @@ -570,6 +582,9 @@ struct EditorState { active()->highlightsDirty = true; active()->generatedHighlightsDirty = true; active()->modified = true; + symbolsPending = true; + symbolsLastChange = ImGui::GetTime(); + if (lsp) lsp->clearDocumentSymbols(); if (lsp && active()->path.rfind("(untitled", 0) != 0) { active()->lspVersion += 1; lsp->didChange(toFileUri(active()->path), active()->editBuf, active()->lspVersion); @@ -1179,6 +1194,106 @@ static void collectAnnotationMarkers(const ASTNode* node, std::vector children; +}; + +static OutlineItem makeOutlineItem(const std::string& name, + const std::string& kind, + const ASTNode* node) { + OutlineItem item; + item.name = name; + item.kind = kind; + if (node && node->hasSpan()) { + item.line = node->spanStartLine; + item.col = node->spanStartCol; + } + return item; +} + +static void collectOutlineFromFunction(const Function* fn, std::vector& out) { + OutlineItem item = makeOutlineItem(fn->name, "Function", fn); + for (auto* p : fn->getChildren("parameters")) { + if (p->conceptType == "Parameter") { + auto* param = static_cast(p); + item.children.push_back(makeOutlineItem(param->name, "Parameter", param)); + } + } + for (auto* child : fn->getChildren("body")) { + if (child->conceptType == "Variable") { + auto* var = static_cast(child); + item.children.push_back(makeOutlineItem(var->name, "Variable", var)); + } + } + out.push_back(std::move(item)); +} + +static void collectOutlineFromAST(const Module* mod, std::vector& out) { + if (!mod) return; + for (auto* child : mod->getChildren("variables")) { + if (child->conceptType == "Variable") { + auto* var = static_cast(child); + out.push_back(makeOutlineItem(var->name, "Variable", var)); + } + } + for (auto* child : mod->getChildren("functions")) { + if (child->conceptType == "Function") { + auto* fn = static_cast(child); + collectOutlineFromFunction(fn, out); + } + } +} + +static std::string toLowerCopy(const std::string& input) { + std::string out = input; + std::transform(out.begin(), out.end(), out.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + return out; +} + +static bool containsCaseInsensitive(const std::string& text, const std::string& filter) { + if (filter.empty()) return true; + std::string hay = toLowerCopy(text); + return hay.find(filter) != std::string::npos; +} + +static bool outlineMatchesFilter(const OutlineItem& item, const std::string& filter) { + if (filter.empty()) return true; + if (containsCaseInsensitive(item.name, filter)) return true; + for (const auto& child : item.children) { + if (outlineMatchesFilter(child, filter)) return true; + } + return false; +} + +static const char* lspSymbolKindLabel(int kind) { + switch (kind) { + case 1: return "File"; + case 2: return "Module"; + case 5: return "Class"; + case 6: return "Method"; + case 12: return "Function"; + case 13: return "Variable"; + case 14: return "Constant"; + case 19: return "String"; + case 23: return "Struct"; + default: return "Symbol"; + } +} + +static bool lspSymbolMatchesFilter(const LSPClient::DocumentSymbol& sym, const std::string& filter) { + if (filter.empty()) return true; + if (containsCaseInsensitive(sym.name, filter)) return true; + for (const auto& child : sym.children) { + if (lspSymbolMatchesFilter(child, filter)) return true; + } + return false; +} + // --------------------------------------------------------------------------- // Theme setup — VSCode Dark-inspired // --------------------------------------------------------------------------- @@ -1609,6 +1724,7 @@ int main(int, char**) { ImGui::MenuItem("Show Whitespace", nullptr, &state.showWhitespace); ImGui::MenuItem("Show Minimap", nullptr, &state.showMinimap); ImGui::MenuItem("Show Annotations", nullptr, &state.showAnnotations); + ImGui::MenuItem("Show Outline", nullptr, &state.showOutline); ImGui::MenuItem("LSP Servers...", nullptr, &state.showLspSettings); if (state.active()) { bool textMode = state.active()->bufferMode == BufferManager::BufferMode::Text; @@ -1749,6 +1865,82 @@ int main(int, char**) { ImGui::PopFont(); ImGui::End(); + // --------------------------------------------------------------- + // Symbol Outline + // --------------------------------------------------------------- + if (state.showOutline) { + ImGui::Begin("Outline", &state.showOutline); + ImGui::PushFont(uiFont); + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputText("Filter", state.outlineFilter, sizeof(state.outlineFilter)); + ImGui::Separator(); + + std::string filter = toLowerCopy(state.outlineFilter); + if (!state.active()) { + ImGui::TextDisabled("(no file)"); + } else { + std::vector lspSymbols = + state.lsp ? state.lsp->getDocumentSymbols() : std::vector{}; + + if (!lspSymbols.empty()) { + int outlineId = 0; + std::function renderLsp; + renderLsp = [&](const LSPClient::DocumentSymbol& sym) { + if (!lspSymbolMatchesFilter(sym, filter)) return; + ImGui::PushID(outlineId++); + std::string label = std::string(lspSymbolKindLabel(sym.kind)) + ": " + sym.name; + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanFullWidth; + if (sym.children.empty()) flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen; + bool open = ImGui::TreeNodeEx(label.c_str(), flags); + if (ImGui::IsItemClicked()) { + state.jumpTo(state.active(), sym.selectionRange.start.line, + sym.selectionRange.start.character); + } + if (!sym.children.empty() && open) { + for (const auto& child : sym.children) { + renderLsp(child); + } + ImGui::TreePop(); + } + ImGui::PopID(); + }; + for (const auto& sym : lspSymbols) renderLsp(sym); + } else if (state.isStructured()) { + std::vector outline; + collectOutlineFromAST(state.activeAST(), outline); + if (outline.empty()) { + ImGui::TextDisabled("(no symbols)"); + } else { + int outlineId = 0; + std::function renderItem; + renderItem = [&](const OutlineItem& item) { + if (!outlineMatchesFilter(item, filter)) return; + ImGui::PushID(outlineId++); + std::string label = item.kind + ": " + item.name; + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanFullWidth; + if (item.children.empty()) flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen; + bool open = ImGui::TreeNodeEx(label.c_str(), flags); + if (ImGui::IsItemClicked() && item.line >= 0) { + state.jumpTo(state.active(), item.line, item.col); + } + if (!item.children.empty() && open) { + for (const auto& child : item.children) { + renderItem(child); + } + ImGui::TreePop(); + } + ImGui::PopID(); + }; + for (const auto& item : outline) renderItem(item); + } + } else { + ImGui::TextDisabled("(no symbols)"); + } + } + ImGui::PopFont(); + ImGui::End(); + } + // --------------------------------------------------------------- // Find / Replace bar (floating at top of editor) // --------------------------------------------------------------- @@ -2179,6 +2371,13 @@ int main(int, char**) { state.completionPending = false; } + if (state.symbolsPending && (now - state.symbolsLastChange) > 0.3) { + if (state.lsp && state.active() && state.active()->path.rfind("(untitled", 0) != 0) { + state.lsp->requestDocumentSymbols(EditorState::toFileUri(state.active()->path)); + } + state.symbolsPending = false; + } + if (state.analysisPending && (now - state.analysisLastChange) > 0.5) { if (state.isStructured()) { auto result = state.pipeline.run(state.active()->editBuf, diff --git a/editor/tests/step111_test.cpp b/editor/tests/step111_test.cpp new file mode 100644 index 0000000..54114d4 --- /dev/null +++ b/editor/tests/step111_test.cpp @@ -0,0 +1,59 @@ +// Step 111 TDD Test: Document symbol parsing +#include "LSPClient.h" +#include +#include + +struct DummyTransport : 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 true; } + void close() override {} +}; + +static void expect(bool cond, const std::string& name, int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n"; + ++failed; + } +} + +int main() { + int passed = 0; + int failed = 0; + + { + auto transport = std::make_shared(); + LSPClient client(transport); + int id = client.requestDocumentSymbols("file:///test.py"); + std::string msg = std::string("{\"jsonrpc\":\"2.0\",\"id\":") + + std::to_string(id) + + ",\"result\":[{\"name\":\"foo\",\"kind\":12,\"range\":{\"start\":{\"line\":1,\"character\":0},\"end\":{\"line\":3,\"character\":0}},\"selectionRange\":{\"start\":{\"line\":1,\"character\":4},\"end\":{\"line\":1,\"character\":7}},\"children\":[{\"name\":\"x\",\"kind\":13,\"range\":{\"start\":{\"line\":2,\"character\":2},\"end\":{\"line\":2,\"character\":3}},\"selectionRange\":{\"start\":{\"line\":2,\"character\":2},\"end\":{\"line\":2,\"character\":3}}}]}]}"; + client.handleMessage(msg); + auto symbols = client.getDocumentSymbols(); + bool ok = symbols.size() == 1 && symbols[0].name == "foo" && symbols[0].children.size() == 1 && + symbols[0].selectionRange.start.line == 1 && symbols[0].selectionRange.start.character == 4 && + symbols[0].children[0].name == "x" && symbols[0].children[0].range.start.line == 2; + expect(ok, "document symbol tree", passed, failed); + } + + { + auto transport = std::make_shared(); + LSPClient client(transport); + int id = client.requestDocumentSymbols("file:///main.py"); + std::string msg = std::string("{\"jsonrpc\":\"2.0\",\"id\":") + + std::to_string(id) + + ",\"result\":[{\"name\":\"bar\",\"kind\":12,\"location\":{\"uri\":\"file:///main.py\",\"range\":{\"start\":{\"line\":4,\"character\":0},\"end\":{\"line\":4,\"character\":3}}}}]}"; + client.handleMessage(msg); + auto symbols = client.getDocumentSymbols(); + bool ok = symbols.size() == 1 && symbols[0].name == "bar" && + symbols[0].range.start.line == 4 && symbols[0].range.start.character == 0 && + symbols[0].selectionRange.start.line == 4; + expect(ok, "symbol information list", passed, failed); + } + + std::cout << "\n=== Step 111 Results: " << passed << " passed, " << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint4_plan.md b/sprint4_plan.md index c0ab40a..85f5d4a 100644 --- a/sprint4_plan.md +++ b/sprint4_plan.md @@ -321,7 +321,7 @@ Whetstone-specific AST navigation. Works across files when LSP supports it. *Wires:* `ContextAPI.h`, `LSPClient.h` -- [ ] **Step 111: Symbol outline panel** +- [x] **Step 111: Symbol outline panel** New "Outline" panel. Primary: use LSP `textDocument/documentSymbol` for the symbol tree. Fallback: walk Whetstone AST for functions/variables/parameters. Click to navigate. Icons by symbol kind. Search/filter box.