Step 111: symbol outline panel

This commit is contained in:
Bill
2026-02-09 12:23:45 -07:00
parent e541aaab29
commit b474ad2752
6 changed files with 345 additions and 2 deletions

View File

@@ -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. |

View File

@@ -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)

View File

@@ -60,6 +60,15 @@ public:
Range range;
};
struct DocumentSymbol {
std::string name;
std::string detail;
int kind = 0;
Range range;
Range selectionRange;
std::vector<DocumentSymbol> 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<DocumentSymbol> 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<DocumentSymbol> 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<DefinitionLocation> definitionLocations_;
std::vector<DocumentSymbol> 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<std::string>();
if (contents.is_object()) {

View File

@@ -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> lspTransport;
std::shared_ptr<LSPClient> 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<Annotation
}
}
struct OutlineItem {
std::string name;
std::string kind;
int line = -1;
int col = -1;
std::vector<OutlineItem> 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<OutlineItem>& out) {
OutlineItem item = makeOutlineItem(fn->name, "Function", fn);
for (auto* p : fn->getChildren("parameters")) {
if (p->conceptType == "Parameter") {
auto* param = static_cast<const Parameter*>(p);
item.children.push_back(makeOutlineItem(param->name, "Parameter", param));
}
}
for (auto* child : fn->getChildren("body")) {
if (child->conceptType == "Variable") {
auto* var = static_cast<const Variable*>(child);
item.children.push_back(makeOutlineItem(var->name, "Variable", var));
}
}
out.push_back(std::move(item));
}
static void collectOutlineFromAST(const Module* mod, std::vector<OutlineItem>& out) {
if (!mod) return;
for (auto* child : mod->getChildren("variables")) {
if (child->conceptType == "Variable") {
auto* var = static_cast<const Variable*>(child);
out.push_back(makeOutlineItem(var->name, "Variable", var));
}
}
for (auto* child : mod->getChildren("functions")) {
if (child->conceptType == "Function") {
auto* fn = static_cast<const Function*>(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<LSPClient::DocumentSymbol> lspSymbols =
state.lsp ? state.lsp->getDocumentSymbols() : std::vector<LSPClient::DocumentSymbol>{};
if (!lspSymbols.empty()) {
int outlineId = 0;
std::function<void(const LSPClient::DocumentSymbol&)> 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<OutlineItem> outline;
collectOutlineFromAST(state.activeAST(), outline);
if (outline.empty()) {
ImGui::TextDisabled("(no symbols)");
} else {
int outlineId = 0;
std::function<void(const OutlineItem&)> 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,

View File

@@ -0,0 +1,59 @@
// Step 111 TDD Test: Document symbol parsing
#include "LSPClient.h"
#include <iostream>
#include <memory>
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<DummyTransport>();
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<DummyTransport>();
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;
}

View File

@@ -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.