Step 113: project-wide search

This commit is contained in:
Bill
2026-02-09 12:30:31 -07:00
parent 8b33cd05d5
commit ea51a2c135
6 changed files with 322 additions and 8 deletions

View File

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

View File

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

170
editor/src/ProjectSearch.h Normal file
View File

@@ -0,0 +1,170 @@
#pragma once
// Step 113: Project-wide search
#include <string>
#include <vector>
#include <filesystem>
#include <fstream>
#include <regex>
#include <sstream>
#include <algorithm>
#include <cctype>
#include "FileTree.h"
class ProjectSearch {
public:
struct Match {
int line = 0;
int col = 0;
std::string lineText;
};
struct FileResult {
std::string path;
std::vector<Match> matches;
};
void setRoot(const std::string& root) { root_ = root; }
std::vector<FileResult> search(const std::string& query,
const std::string& includeGlobs,
const std::string& excludeGlobs,
bool useRegex) {
std::vector<FileResult> 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<std::string> 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<std::string>& out) {
if (!node.isDir) {
out.push_back(node.path);
return;
}
for (const auto& child : node.children) {
collectFiles(child, out);
}
}
static std::vector<std::string> splitPatterns(const std::string& input) {
std::vector<std::string> 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<std::string>& 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<std::string>& patterns) {
for (const auto& pat : patterns) {
if (pat.empty()) continue;
if (matchGlob(path, pat)) return true;
}
return false;
}
};

View File

@@ -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<ProjectSearch::FileResult> 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> lspTransport;
std::shared_ptr<LSPClient> 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
// ---------------------------------------------------------------

View File

@@ -0,0 +1,60 @@
// Step 113 TDD Test: Project search
#include "ProjectSearch.h"
#include <iostream>
#include <filesystem>
#include <fstream>
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;
}

View File

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