From ea51a2c1352c96e0f614f2cdf9586474793be698 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 9 Feb 2026 12:30:31 -0700 Subject: [PATCH] Step 113: project-wide search --- PROGRESS.md | 5 +- editor/CMakeLists.txt | 4 + editor/src/ProjectSearch.h | 170 ++++++++++++++++++++++++++++++++++ editor/src/main.cpp | 89 ++++++++++++++++-- editor/tests/step113_test.cpp | 60 ++++++++++++ sprint4_plan.md | 2 +- 6 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 editor/src/ProjectSearch.h create mode 100644 editor/tests/step113_test.cpp diff --git a/PROGRESS.md b/PROGRESS.md index b0a0e8b..e37858a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -216,6 +216,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1 - [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) - [x] Step 112: **IMPLEMENTED** — Breadcrumb navigation with scope roles (1/1 tests pass) +- [x] Step 113: **IMPLEMENTED** — Project-wide search panel with regex and glob filters (3/3 tests pass) --- @@ -311,6 +312,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi **Step 110:** Compile and pass (2/2) **Step 111:** Compile and pass (2/2) **Step 112:** Compile and pass (1/1) +**Step 113:** Compile and pass (3/3) --- @@ -362,7 +364,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi ## What's Next -Sprint 4 in progress. Step 112 (breadcrumb navigation) done. Next: Step 113 (project-wide search). +Sprint 4 in progress. Step 113 (project-wide search) done. Next: Step 114 (go-to-line). --- @@ -436,3 +438,4 @@ Sprint 4 in progress. Step 112 (breadcrumb navigation) done. Next: Step 113 (pro | 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. | | 2026-02-09 | Codex | Step 112: Breadcrumb navigation with scope roles. 1/1 tests pass. | +| 2026-02-09 | Codex | Step 113: Project-wide search panel with regex and glob filters. 3/3 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index c563f5c..94275bc 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -610,6 +610,10 @@ add_executable(step112_test tests/step112_test.cpp) target_include_directories(step112_test PRIVATE src) target_link_libraries(step112_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step113_test tests/step113_test.cpp) +target_include_directories(step113_test PRIVATE src) +target_link_libraries(step113_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/ProjectSearch.h b/editor/src/ProjectSearch.h new file mode 100644 index 0000000..f297695 --- /dev/null +++ b/editor/src/ProjectSearch.h @@ -0,0 +1,170 @@ +#pragma once +// Step 113: Project-wide search + +#include +#include +#include +#include +#include +#include +#include +#include +#include "FileTree.h" + +class ProjectSearch { +public: + struct Match { + int line = 0; + int col = 0; + std::string lineText; + }; + + struct FileResult { + std::string path; + std::vector matches; + }; + + void setRoot(const std::string& root) { root_ = root; } + + std::vector search(const std::string& query, + const std::string& includeGlobs, + const std::string& excludeGlobs, + bool useRegex) { + std::vector results; + if (root_.empty() || query.empty()) return results; + + auto include = splitPatterns(includeGlobs); + auto exclude = splitPatterns(excludeGlobs); + + FileTree tree; + FileNode rootNode = tree.build(root_); + std::vector files; + collectFiles(rootNode, files); + + std::regex rx; + bool regexOk = false; + if (useRegex) { + try { + rx = std::regex(query, std::regex_constants::ECMAScript); + regexOk = true; + } catch (...) { + regexOk = false; + } + } + + for (const auto& path : files) { + std::string rel = std::filesystem::relative(path, root_).generic_string(); + if (!matchesInclude(rel, include)) continue; + if (matchesExclude(rel, exclude)) continue; + + std::ifstream in(path, std::ios::binary); + if (!in.is_open()) continue; + + std::string line; + int lineNum = 0; + FileResult fileRes; + fileRes.path = path; + while (std::getline(in, line)) { + if (regexOk) { + for (auto it = std::sregex_iterator(line.begin(), line.end(), rx); + it != std::sregex_iterator(); ++it) { + Match m; + m.line = lineNum; + m.col = (int)it->position(); + m.lineText = line; + fileRes.matches.push_back(std::move(m)); + } + } else { + size_t pos = line.find(query); + while (pos != std::string::npos) { + Match m; + m.line = lineNum; + m.col = (int)pos; + m.lineText = line; + fileRes.matches.push_back(std::move(m)); + pos = line.find(query, pos + 1); + } + } + ++lineNum; + } + if (!fileRes.matches.empty()) results.push_back(std::move(fileRes)); + } + + return results; + } + +private: + std::string root_; + + static void collectFiles(const FileNode& node, std::vector& out) { + if (!node.isDir) { + out.push_back(node.path); + return; + } + for (const auto& child : node.children) { + collectFiles(child, out); + } + } + + static std::vector splitPatterns(const std::string& input) { + std::vector out; + std::string cur; + for (char c : input) { + if (c == ',' || c == ';') { + if (!cur.empty()) out.push_back(trim(cur)); + cur.clear(); + } else { + cur.push_back(c); + } + } + if (!cur.empty()) out.push_back(trim(cur)); + return out; + } + + static std::string trim(const std::string& s) { + size_t start = 0; + while (start < s.size() && std::isspace((unsigned char)s[start])) ++start; + size_t end = s.size(); + while (end > start && std::isspace((unsigned char)s[end - 1])) --end; + return s.substr(start, end - start); + } + + static bool matchGlob(const std::string& text, const std::string& pattern) { + size_t t = 0, p = 0, star = std::string::npos, match = 0; + while (t < text.size()) { + if (p < pattern.size() && (pattern[p] == '?' || pattern[p] == text[t])) { + ++t; ++p; continue; + } + if (p < pattern.size() && pattern[p] == '*') { + star = p++; + match = t; + continue; + } + if (star != std::string::npos) { + p = star + 1; + t = ++match; + continue; + } + return false; + } + while (p < pattern.size() && pattern[p] == '*') ++p; + return p == pattern.size(); + } + + static bool matchesInclude(const std::string& path, const std::vector& patterns) { + if (patterns.empty()) return true; + for (const auto& pat : patterns) { + if (pat.empty()) continue; + if (matchGlob(path, pat)) return true; + } + return false; + } + + static bool matchesExclude(const std::string& path, const std::vector& patterns) { + for (const auto& pat : patterns) { + if (pat.empty()) continue; + if (matchGlob(path, pat)) return true; + } + return false; + } +}; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index a851994..a13ec31 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -43,6 +43,7 @@ #include "CommandPalette.h" #include "ContextAPI.h" #include "Breadcrumbs.h" +#include "ProjectSearch.h" #include "ast/Serialization.h" #include "ast/Generator.h" #include "ast/Annotation.h" @@ -106,6 +107,12 @@ struct EditorState { char findBuf[256] = {}; char replaceBuf[256] = {}; int lastFindPos = 0; + bool showProjectSearch = false; + char searchQuery[256] = {}; + char searchInclude[256] = {}; + char searchExclude[256] = {}; + bool searchUseRegex = true; + std::vector searchResults; // Bottom panel int bottomTab = 0; // 0=Output, 1=AST, 2=Highlighted @@ -124,6 +131,7 @@ struct EditorState { FileNode fileTreeRoot; bool fileTreeDirty = true; FileWatcher watcher; + ProjectSearch projectSearch; std::shared_ptr lspTransport; std::shared_ptr lsp; bool symbolsPending = false; @@ -455,6 +463,7 @@ struct EditorState { void init() { outputLog = "Whetstone Editor ready.\n"; workspaceRoot = std::filesystem::current_path().string(); + projectSearch.setRoot(workspaceRoot); fileTreeDirty = true; loadRecentFiles(); registerCommands(); @@ -498,6 +507,9 @@ struct EditorState { registerCommand("search.find", "Search: Find/Replace", keys.getBinding("search.find").toString(), [this]() { showFind = !showFind; }); + registerCommand("search.findInFiles", "Search: Find in Files", + keys.getBinding("search.findInFiles").toString(), + [this]() { showProjectSearch = !showProjectSearch; }); registerCommand("view.whitespace", "View: Toggle Whitespace", "", [this]() { showWhitespace = !showWhitespace; }); registerCommand("view.minimap", "View: Toggle Minimap", "", @@ -1496,6 +1508,7 @@ static void RenderWelcome(WelcomeScreen& welcome, EditorState& state, std::strin if (!path.empty()) { state.workspaceRoot = path; state.fileTreeDirty = true; + state.projectSearch.setRoot(state.workspaceRoot); lastDialogPath = path; } } @@ -1588,12 +1601,13 @@ int main(int, char**) { if (event.type == SDL_DROPFILE) { char* droppedFile = event.drop.file; if (droppedFile) { - DragDropHandler::handleDrop(droppedFile, - [&](const std::string& p) { state.doOpen(p); }, - [&](const std::string& p) { - state.workspaceRoot = p; - state.fileTreeDirty = true; - }); + DragDropHandler::handleDrop(droppedFile, + [&](const std::string& p) { state.doOpen(p); }, + [&](const std::string& p) { + state.workspaceRoot = p; + state.fileTreeDirty = true; + state.projectSearch.setRoot(state.workspaceRoot); + }); SDL_free(droppedFile); } } @@ -1629,6 +1643,7 @@ int main(int, char**) { if (action == "edit.undo") state.doUndo(); else if (action == "edit.redo") state.doRedo(); else if (action == "search.find") state.showFind = !state.showFind; + else if (action == "search.findInFiles") state.showProjectSearch = !state.showProjectSearch; else if (action == "file.save") state.doSave(); else if (action == "file.new") { std::string lang = state.active() ? state.active()->language : "python"; @@ -1687,6 +1702,7 @@ int main(int, char**) { if (!path.empty()) { state.workspaceRoot = path; state.fileTreeDirty = true; + state.projectSearch.setRoot(state.workspaceRoot); lastDialogPath = path; } } @@ -1719,6 +1735,8 @@ int main(int, char**) { ImGui::Separator(); if (ImGui::MenuItem("Find/Replace", state.keys.getBinding("search.find").toString().c_str())) state.showFind = !state.showFind; + if (ImGui::MenuItem("Find in Files", state.keys.getBinding("search.findInFiles").toString().c_str())) + state.showProjectSearch = !state.showProjectSearch; ImGui::EndMenu(); } if (ImGui::BeginMenu("View")) { @@ -1964,6 +1982,65 @@ int main(int, char**) { ImGui::End(); } + // --------------------------------------------------------------- + // Project Search (Ctrl+Shift+F) + // --------------------------------------------------------------- + if (state.showProjectSearch) { + ImGui::Begin("Search", &state.showProjectSearch); + ImGui::PushFont(uiFont); + bool doSearch = false; + ImGui::SetNextItemWidth(420); + if (ImGui::InputText("Query", state.searchQuery, sizeof(state.searchQuery), + ImGuiInputTextFlags_EnterReturnsTrue)) { + doSearch = true; + } + ImGui::SetNextItemWidth(420); + ImGui::InputText("Include (glob)", state.searchInclude, sizeof(state.searchInclude)); + ImGui::SetNextItemWidth(420); + ImGui::InputText("Exclude (glob)", state.searchExclude, sizeof(state.searchExclude)); + ImGui::Checkbox("Regex", &state.searchUseRegex); + ImGui::SameLine(); + if (ImGui::Button("Search")) doSearch = true; + ImGui::SameLine(); + if (ImGui::Button("Clear")) { + state.searchQuery[0] = '\0'; + state.searchResults.clear(); + } + + if (doSearch) { + state.searchResults = state.projectSearch.search( + state.searchQuery, + state.searchInclude, + state.searchExclude, + state.searchUseRegex); + } + + ImGui::Separator(); + ImGui::BeginChild("##searchResults", ImVec2(0, 0), false); + if (state.searchResults.empty()) { + ImGui::TextDisabled("(no results)"); + } else { + for (const auto& fileRes : state.searchResults) { + std::string label = fileRes.path + " (" + std::to_string(fileRes.matches.size()) + ")"; + if (ImGui::TreeNode(label.c_str())) { + for (const auto& match : fileRes.matches) { + std::string lineLabel = std::to_string(match.line + 1) + ":" + + std::to_string(match.col + 1) + " " + match.lineText; + if (ImGui::Selectable(lineLabel.c_str())) { + if (state.buffers.hasBuffer(fileRes.path)) state.switchToBuffer(fileRes.path); + else state.doOpen(fileRes.path); + state.jumpTo(state.active(), match.line, match.col); + } + } + ImGui::TreePop(); + } + } + } + ImGui::EndChild(); + ImGui::PopFont(); + ImGui::End(); + } + // --------------------------------------------------------------- // LSP Server Settings // --------------------------------------------------------------- diff --git a/editor/tests/step113_test.cpp b/editor/tests/step113_test.cpp new file mode 100644 index 0000000..35c3635 --- /dev/null +++ b/editor/tests/step113_test.cpp @@ -0,0 +1,60 @@ +// Step 113 TDD Test: Project search +#include "ProjectSearch.h" +#include +#include +#include + +static void writeFile(const std::filesystem::path& p, const std::string& content) { + std::filesystem::create_directories(p.parent_path()); + std::ofstream out(p.string(), std::ios::binary); + out << content; +} + +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; + + std::filesystem::path root = std::filesystem::temp_directory_path() / "whetstone_search_test"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + + writeFile(root / "a.txt", "hello world\nsecond line\n"); + writeFile(root / "b.log", "hello log\n"); + writeFile(root / "sub" / "c.txt", "no match here\nhello again\n"); + + ProjectSearch search; + search.setRoot(root.string()); + + { + auto results = search.search("hello", "", "", false); + bool ok = results.size() == 3; + expect(ok, "basic search", passed, failed); + } + + { + auto results = search.search("hello", "*.txt", "", false); + bool ok = results.size() == 2; + expect(ok, "include glob", passed, failed); + } + + { + auto results = search.search("hello", "", "b.log", false); + bool ok = results.size() == 2; + expect(ok, "exclude glob", passed, failed); + } + + std::filesystem::remove_all(root); + + std::cout << "\n=== Step 113 Results: " << passed << " passed, " << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint4_plan.md b/sprint4_plan.md index 6e8aa83..13f193d 100644 --- a/sprint4_plan.md +++ b/sprint4_plan.md @@ -334,7 +334,7 @@ Whetstone-specific AST navigation. LSPs don't provide this annotation-aware scope path). *Modifies:* `CodeEditorWidget.h` -- [ ] **Step 113: Project-wide search** +- [x] **Step 113: Project-wide search** Ctrl+Shift+F opens search panel. Searches all files in the workspace. Results grouped by file with line previews. Click to open file at location. Regex support. Include/exclude glob filters.