Step 113: project-wide search
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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
170
editor/src/ProjectSearch.h
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
60
editor/tests/step113_test.cpp
Normal file
60
editor/tests/step113_test.cpp
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user