diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index eaee187..fb9c475 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -2126,4 +2126,8 @@ add_executable(step357_test tests/step357_test.cpp) target_include_directories(step357_test PRIVATE src) target_link_libraries(step357_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step358_test tests/step358_test.cpp) +target_include_directories(step358_test PRIVATE src) +target_link_libraries(step358_test PRIVATE nlohmann_json::nlohmann_json) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/panels/NavigationTools.h b/editor/src/panels/NavigationTools.h new file mode 100644 index 0000000..ebfc8dd --- /dev/null +++ b/editor/src/panels/NavigationTools.h @@ -0,0 +1,127 @@ +#pragma once +// Step 358: Go-to-Definition + Go-to-Symbol headless navigation tools. + +#include +#include +#include +#include +#include +#include +#include "../CompactAST.h" +#include "../Icons.h" +#include "../ast/Expression.h" +#include "../ast/Module.h" + +struct SymbolLocation { + std::string name; + std::string type; // function/class/variable/method/annotation/... + std::string icon; + std::string filePath; + int line = -1; + int column = -1; + const ASTNode* node = nullptr; +}; + +inline std::string navLower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return s; +} + +inline void collectSymbolsRecursive(const ASTNode* node, + const std::string& filePath, + std::vector& out) { + if (!node) return; + const std::string ct = node->conceptType; + const bool isSymbol = + ct == "Function" || ct == "MethodDeclaration" || ct == "ClassDeclaration" || + ct == "Variable" || ct == "InterfaceDeclaration" || ct == "EnumDeclaration"; + if (isSymbol) { + SymbolLocation s; + s.name = getNodeName(node); + s.type = navLower(ct); + s.icon = WhetstoneIcons::iconForNodeType(ct); + s.filePath = filePath; + s.line = node->spanStartLine; + s.column = node->spanStartCol; + s.node = node; + out.push_back(s); + } + for (auto* child : node->allChildren()) { + collectSymbolsRecursive(child, filePath, out); + } +} + +inline std::vector collectFileSymbols(const Module* module, + const std::string& filePath) { + std::vector out; + collectSymbolsRecursive(module, filePath, out); + std::sort(out.begin(), out.end(), [](const SymbolLocation& a, const SymbolLocation& b) { + int la = a.line < 0 ? 1'000'000 : a.line; + int lb = b.line < 0 ? 1'000'000 : b.line; + if (la != lb) return la < lb; + return a.name < b.name; + }); + return out; +} + +inline std::vector collectProjectSymbols( + const std::vector>& modules) { + std::vector out; + for (const auto& [filePath, module] : modules) { + auto fileSymbols = collectFileSymbols(module, filePath); + out.insert(out.end(), fileSymbols.begin(), fileSymbols.end()); + } + std::sort(out.begin(), out.end(), [](const SymbolLocation& a, const SymbolLocation& b) { + if (a.filePath != b.filePath) return a.filePath < b.filePath; + int la = a.line < 0 ? 1'000'000 : a.line; + int lb = b.line < 0 ? 1'000'000 : b.line; + if (la != lb) return la < lb; + return a.name < b.name; + }); + return out; +} + +inline std::vector filterSymbols(const std::vector& symbols, + const std::string& query) { + if (query.empty()) return symbols; + std::string q = navLower(query); + std::vector out; + for (const auto& s : symbols) { + std::string hay = navLower(s.name + " " + s.type + " " + s.filePath); + if (hay.find(q) != std::string::npos) out.push_back(s); + } + return out; +} + +inline std::optional goToDefinition( + const FunctionCall* call, + const std::string& currentFile, + const std::vector>& modules) { + if (!call || call->functionName.empty()) return std::nullopt; + const std::string wanted = call->functionName; + + // Prefer current file definitions first. + for (const auto& [filePath, module] : modules) { + if (filePath != currentFile) continue; + for (const auto& s : collectFileSymbols(module, filePath)) { + if ((s.type == "function" || s.type == "methoddeclaration") && s.name == wanted) { + return s; + } + } + } + // Then project-wide. + for (const auto& [filePath, module] : modules) { + for (const auto& s : collectFileSymbols(module, filePath)) { + if ((s.type == "function" || s.type == "methoddeclaration") && s.name == wanted) { + return s; + } + } + } + return std::nullopt; +} + +inline std::string definitionStatusMessage(const std::optional& loc) { + if (loc.has_value()) return "Definition found"; + return "No definition found"; +} diff --git a/editor/tests/step358_test.cpp b/editor/tests/step358_test.cpp new file mode 100644 index 0000000..30bae6b --- /dev/null +++ b/editor/tests/step358_test.cpp @@ -0,0 +1,179 @@ +// Step 358: Go-to-Definition + Go-to-Symbol (12 tests) + +#include +#include +#include +#include +#include "panels/NavigationTools.h" +#include "ast/ClassDeclaration.h" +#include "ast/Function.h" +#include "ast/Variable.h" + +int main() { + int passed = 0; + + // Build module A + Module modA("mA", "modA", "python"); + auto* fnHelperA = new Function("f1", "helper"); + fnHelperA->setSpan(9, 0, 12, 0); // line 10 human-readable + auto* fnCaller = new Function("f2", "caller"); + fnCaller->setSpan(29, 0, 33, 0); + auto* cls = new ClassDeclaration("c1", "Engine"); + cls->setSpan(19, 0, 24, 0); + auto* var = new Variable("v1", "count"); + var->setSpan(14, 0, 14, 5); + modA.addChild("functions", fnHelperA); + modA.addChild("functions", fnCaller); + modA.addChild("classes", cls); + modA.addChild("variables", var); + + // Build module B with cross-file helper + Module modB("mB", "modB", "python"); + auto* fnHelperB = new Function("f3", "external_helper"); + fnHelperB->setSpan(4, 0, 8, 0); + modB.addChild("functions", fnHelperB); + + std::vector> modules = { + {"a.py", &modA}, + {"b.py", &modB} + }; + + // Test 1: F12 on function call jumps to definition + { + FunctionCall call; + call.functionName = "helper"; + auto def = goToDefinition(&call, "a.py", modules); + assert(def.has_value()); + assert(def->name == "helper"); + assert(def->filePath == "a.py"); + std::cout << "Test 1 PASSED: F12 go-to-definition\n"; + passed++; + } + + // Test 2: Ctrl+Shift+O lists file symbols + { + auto symbols = collectFileSymbols(&modA, "a.py"); + assert(symbols.size() >= 4); + std::cout << "Test 2 PASSED: file symbol list\n"; + passed++; + } + + // Test 3: Ctrl+T lists project symbols + { + auto symbols = collectProjectSymbols(modules); + assert(symbols.size() >= 5); + bool hasFromB = false; + for (const auto& s : symbols) { + if (s.filePath == "b.py" && s.name == "external_helper") hasFromB = true; + } + assert(hasFromB); + std::cout << "Test 3 PASSED: project symbol list\n"; + passed++; + } + + // Test 4: symbol list shows icons by type + { + auto symbols = collectFileSymbols(&modA, "a.py"); + bool classIcon = false; + bool fnIcon = false; + for (const auto& s : symbols) { + if (s.name == "Engine" && !s.icon.empty()) classIcon = true; + if (s.name == "helper" && !s.icon.empty()) fnIcon = true; + } + assert(classIcon && fnIcon); + std::cout << "Test 4 PASSED: symbols include icons\n"; + passed++; + } + + // Test 5: filter works on symbol list + { + auto symbols = collectFileSymbols(&modA, "a.py"); + auto filtered = filterSymbols(symbols, "eng"); + assert(filtered.size() == 1); + assert(filtered[0].name == "Engine"); + std::cout << "Test 5 PASSED: symbol filter\n"; + passed++; + } + + // Test 6: cross-file jump opens target buffer path + { + FunctionCall call; + call.functionName = "external_helper"; + auto def = goToDefinition(&call, "a.py", modules); + assert(def.has_value()); + assert(def->filePath == "b.py"); + std::cout << "Test 6 PASSED: cross-file jump\n"; + passed++; + } + + // Test 7: no match shows message + { + FunctionCall call; + call.functionName = "missing_fn"; + auto def = goToDefinition(&call, "a.py", modules); + assert(!def.has_value()); + assert(definitionStatusMessage(def) == "No definition found"); + std::cout << "Test 7 PASSED: no-match message\n"; + passed++; + } + + // Test 8: symbols sorted by line number + { + auto symbols = collectFileSymbols(&modA, "a.py"); + int prev = -1; + for (const auto& s : symbols) { + if (s.line < 0) continue; + assert(s.line >= prev); + prev = s.line; + } + std::cout << "Test 8 PASSED: sorted by line\n"; + passed++; + } + + // Test 9: empty query returns all + { + auto symbols = collectProjectSymbols(modules); + auto filtered = filterSymbols(symbols, ""); + assert(filtered.size() == symbols.size()); + std::cout << "Test 9 PASSED: empty query returns all\n"; + passed++; + } + + // Test 10: case-insensitive filter + { + auto symbols = collectProjectSymbols(modules); + auto filtered = filterSymbols(symbols, "EXTERNAL"); + assert(filtered.size() == 1); + assert(filtered[0].name == "external_helper"); + std::cout << "Test 10 PASSED: case-insensitive filter\n"; + passed++; + } + + // Test 11: definition prefers current file over project + { + // Add same-name function in modB to validate preference. + auto* shadow = new Function("f4", "helper"); + shadow->setSpan(1, 0, 2, 0); + modB.addChild("functions", shadow); + + FunctionCall call; + call.functionName = "helper"; + auto def = goToDefinition(&call, "a.py", modules); + assert(def.has_value()); + assert(def->filePath == "a.py"); + std::cout << "Test 11 PASSED: current-file preference\n"; + passed++; + } + + // Test 12: null call safely handled + { + auto def = goToDefinition(nullptr, "a.py", modules); + assert(!def.has_value()); + std::cout << "Test 12 PASSED: null call safe\n"; + passed++; + } + + std::cout << "\nResults: " << passed << "/12\n"; + assert(passed == 12); + return 0; +} diff --git a/progress.md b/progress.md index b3309df..d5d4d3d 100644 --- a/progress.md +++ b/progress.md @@ -1935,6 +1935,42 @@ focus target for click handling. - `step356_test` — PASS (12/12) regression coverage - `step357_test` — PASS (12/12) new step coverage +### Step 358: Go-to-Definition + Go-to-Symbol +**Status:** PASS (12/12 tests) + +Implemented headless navigation tooling for symbol discovery and jump resolution: +file-level and project-level symbol lists, symbol filtering, icon/type metadata, +and go-to-definition lookup with current-file preference and cross-file fallback. + +**Files created:** +- `editor/src/panels/NavigationTools.h` — navigation model: + - symbol collection (`collectFileSymbols`, `collectProjectSymbols`) + - symbol filtering (`filterSymbols`) + - F12-style definition lookup (`goToDefinition`) + - no-match messaging (`definitionStatusMessage`) + - line-order sorting and icon assignment +- `editor/tests/step358_test.cpp` — 12 tests covering: + 1. definition jump from function call + 2. file symbol listing + 3. project symbol listing + 4. icon presence by symbol type + 5. symbol filtering + 6. cross-file definition jump + 7. no-match message + 8. line-number sort order + 9. empty-query behavior + 10. case-insensitive filtering + 11. current-file definition preference + 12. null-call safety + +**Files modified:** +- `editor/CMakeLists.txt` — `step358_test` target + +**Verification run:** +- `step356_test` — PASS (12/12) regression coverage +- `step357_test` — PASS (12/12) regression coverage +- `step358_test` — PASS (12/12) new step coverage + # Roadmap Planning — Sprints 12-25+ ## Status: Planning Complete (Sprints 12-19 detailed, 20-25 in roadmap.md)