Step 258: project model and workspace indexing (Phase 9d start)

Adds multi-buffer management via openFile/closeFile/listBuffers/setActiveBuffer
RPC methods, plus indexWorkspace for scanning workspace files without opening
them. Language auto-detected from extension. 27 MCP tools total (was 22).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bill
2026-02-11 19:43:53 +00:00
parent 5830361463
commit ac5b7e21c8
8 changed files with 774 additions and 2 deletions

View File

@@ -50,7 +50,10 @@ struct AgentPermissionPolicy {
method == "getDiagnostics" ||
method == "getDiagnosticsDelta" ||
method == "getQuickFixes" ||
method == "batchQuery") {
method == "batchQuery" ||
method == "listBuffers" ||
method == "setActiveBuffer" ||
method == "indexWorkspace") {
return true;
}
@@ -61,7 +64,9 @@ struct AgentPermissionPolicy {
method == "applyBatch" ||
method == "fileWrite" ||
method == "fileCreate" ||
method == "applyQuickFix") {
method == "applyQuickFix" ||
method == "openFile" ||
method == "closeFile") {
return role == AgentRole::Refactor || role == AgentRole::Generator;
}

View File

@@ -932,6 +932,114 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
return headlessRpcResult(id, {{"files", files}});
}
// --- openFile ---
if (method == "openFile") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto params = request.contains("params") ? request["params"]
: json::object();
std::string path = params.value("path", "");
if (path.empty())
return headlessRpcError(id, -32602, "Missing path parameter");
std::string content = params.value("content", "");
std::string language = params.value("language", "");
// Auto-detect language from extension if not specified
if (language.empty())
language = detectLanguage(path);
if (language.empty())
language = state.defaultLanguage;
// Read from disk if no content provided and workspace is set
if (content.empty() && !state.workspaceRoot.empty()) {
auto [ok, resolved] =
fileOpsResolvePath(state.workspaceRoot, path);
if (ok) {
auto [rok, fileContent, lc] = fileOpsRead(resolved);
if (rok) content = fileContent;
path = resolved;
}
}
auto* buf = state.openBuffer(path, content, language);
if (!buf)
return headlessRpcError(id, -32041, "Failed to open buffer");
return headlessRpcResult(id, {
{"path", path}, {"language", language},
{"bufferCount", (int)state.bufferStates.size()}
});
}
// --- closeFile ---
if (method == "closeFile") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto params = request.contains("params") ? request["params"]
: json::object();
std::string path = params.value("path", "");
if (path.empty())
return headlessRpcError(id, -32602, "Missing path parameter");
auto it = state.bufferStates.find(path);
if (it == state.bufferStates.end())
return headlessRpcError(id, -32002, "Buffer not found: " + path);
state.closeBuffer(path);
return headlessRpcResult(id, {
{"closed", path},
{"bufferCount", (int)state.bufferStates.size()}
});
}
// --- listBuffers ---
if (method == "listBuffers") {
json buffers = json::array();
for (const auto& [path, buf] : state.bufferStates) {
bool isActive = (buf.get() == state.activeBuffer);
buffers.push_back({
{"path", buf->path},
{"language", buf->language},
{"modified", buf->modified},
{"active", isActive}
});
}
return headlessRpcResult(id, {
{"buffers", buffers},
{"count", (int)buffers.size()},
{"activeBuffer", state.activeBuffer
? state.activeBuffer->path : ""}
});
}
// --- setActiveBuffer ---
if (method == "setActiveBuffer") {
auto params = request.contains("params") ? request["params"]
: json::object();
std::string path = params.value("path", "");
if (path.empty())
return headlessRpcError(id, -32602, "Missing path parameter");
if (!state.setActiveBuffer(path))
return headlessRpcError(id, -32002, "Buffer not found: " + path);
return headlessRpcResult(id, {
{"activeBuffer", path},
{"language", state.activeBuffer->language}
});
}
// --- indexWorkspace ---
if (method == "indexWorkspace") {
auto params = request.contains("params") ? request["params"]
: json::object();
std::string root = params.value("root", state.workspaceRoot);
if (root.empty())
return headlessRpcError(id, -32602,
"No workspace root (set via --workspace or root param)");
state.project.scanWorkspace(root);
state.workspaceRoot = root;
const auto& idx = state.project.index;
return headlessRpcResult(id, {
{"root", root},
{"fileCount", idx.fileCount()},
{"dirCount", idx.dirCount()},
{"totalEntries", (int)idx.files().size()}
});
}
// --- batchQuery ---
if (method == "batchQuery") {
auto params = request.contains("params") ? request["params"]

View File

@@ -33,6 +33,7 @@
#include "EditorModePolicy.h"
#include "PrimitivesRegistry.h"
#include "SemanticTags.h"
#include "ProjectState.h"
#include <nlohmann/json.hpp>
#include <string>
@@ -87,6 +88,7 @@ struct HeadlessEditorState {
HeadlessBufferState* activeBuffer = nullptr;
HeadlessAgentState agent;
HeadlessLibraryState library;
ProjectState project;
std::string workspaceRoot;
std::string defaultLanguage = "python";
bool verbose = false;

View File

@@ -749,11 +749,82 @@ private:
};
}
// ---------------------------------------------------------------
// Project management tools
// ---------------------------------------------------------------
void registerProjectTools() {
// whetstone_open_file
tools_.push_back({"whetstone_open_file",
"Open a file as a buffer for AST analysis. Reads from disk "
"if content not provided. Language auto-detected from extension.",
{{"type", "object"}, {"properties", {
{"path", {{"type", "string"},
{"description", "File path (relative to workspace or absolute)"}}},
{"content", {{"type", "string"},
{"description", "File content (optional, reads from disk if omitted)"}}},
{"language", {{"type", "string"},
{"description", "Language (optional, auto-detected from extension)"}}}
}}, {"required", {"path"}}}
});
toolHandlers_["whetstone_open_file"] = [this](const json& args) {
return callWhetstone("openFile", args);
};
// whetstone_close_file
tools_.push_back({"whetstone_close_file",
"Close an open buffer, removing it from the project.",
{{"type", "object"}, {"properties", {
{"path", {{"type", "string"},
{"description", "Path of the buffer to close"}}}
}}, {"required", {"path"}}}
});
toolHandlers_["whetstone_close_file"] = [this](const json& args) {
return callWhetstone("closeFile", args);
};
// whetstone_list_buffers
tools_.push_back({"whetstone_list_buffers",
"List all open buffers with language, modified status, and "
"which buffer is currently active.",
{{"type", "object"}, {"properties", json::object()}}
});
toolHandlers_["whetstone_list_buffers"] = [this](const json& args) {
return callWhetstone("listBuffers", args);
};
// whetstone_set_active_buffer
tools_.push_back({"whetstone_set_active_buffer",
"Switch the active buffer. Subsequent getAST, getDiagnostics, "
"etc. will operate on this buffer.",
{{"type", "object"}, {"properties", {
{"path", {{"type", "string"},
{"description", "Path of the buffer to activate"}}}
}}, {"required", {"path"}}}
});
toolHandlers_["whetstone_set_active_buffer"] = [this](const json& args) {
return callWhetstone("setActiveBuffer", args);
};
// whetstone_index_workspace
tools_.push_back({"whetstone_index_workspace",
"Scan the workspace directory and index all file paths. "
"Returns file and directory counts. Does not open files.",
{{"type", "object"}, {"properties", {
{"root", {{"type", "string"},
{"description", "Workspace root (optional, uses --workspace default)"}}}
}}}
});
toolHandlers_["whetstone_index_workspace"] = [this](const json& args) {
return callWhetstone("indexWorkspace", args);
};
}
void registerWhetstoneTools() {
registerASTTools();
registerAnnotationTools();
registerFileTools();
registerDiagnosticTools();
registerBatchTools();
registerProjectTools();
}
};

148
editor/src/ProjectState.h Normal file
View File

@@ -0,0 +1,148 @@
#pragma once
// Step 258: Project Model and Workspace Indexing
//
// Tracks workspace files, detects languages, and provides a project-level
// index of file paths (not content) for fast lookup. Works with
// HeadlessEditorState to manage multiple open buffers.
#include <string>
#include <vector>
#include <map>
#include <set>
#include <filesystem>
#include "FileTree.h"
namespace fs = std::filesystem;
// -----------------------------------------------------------------------
// Language detection from file extension
// -----------------------------------------------------------------------
inline std::string detectLanguage(const std::string& filePath) {
std::string ext = fs::path(filePath).extension().string();
if (ext == ".py") return "python";
if (ext == ".cpp" || ext == ".cc" ||
ext == ".cxx" || ext == ".h" ||
ext == ".hpp" || ext == ".hxx") return "cpp";
if (ext == ".rs") return "rust";
if (ext == ".go") return "go";
if (ext == ".java") return "java";
if (ext == ".js" || ext == ".mjs") return "javascript";
if (ext == ".ts" || ext == ".tsx") return "typescript";
if (ext == ".el" || ext == ".elisp") return "elisp";
if (ext == ".org") return "org";
return "";
}
// -----------------------------------------------------------------------
// WorkspaceIndex — file path index (not content) for fast lookup
// -----------------------------------------------------------------------
struct IndexedFile {
std::string path; // absolute path
std::string relativePath; // relative to workspace root
std::string language; // detected from extension
bool isDir = false;
};
class WorkspaceIndex {
public:
void scan(const std::string& workspaceRoot) {
files_.clear();
root_ = workspaceRoot;
if (root_.empty() || !fs::exists(root_)) return;
FileTree tree;
FileNode rootNode = tree.build(root_);
collectFiles(rootNode);
}
const std::vector<IndexedFile>& files() const { return files_; }
// Find files matching a glob-like pattern (extension filter)
std::vector<IndexedFile> findByExtension(
const std::string& ext) const {
std::vector<IndexedFile> result;
for (const auto& f : files_) {
if (!f.isDir &&
fs::path(f.path).extension().string() == ext)
result.push_back(f);
}
return result;
}
// Find files by language
std::vector<IndexedFile> findByLanguage(
const std::string& lang) const {
std::vector<IndexedFile> result;
for (const auto& f : files_) {
if (!f.isDir && f.language == lang)
result.push_back(f);
}
return result;
}
// Lookup by relative path
const IndexedFile* findByRelativePath(
const std::string& relPath) const {
auto it = byRelPath_.find(relPath);
if (it != byRelPath_.end()) return &files_[it->second];
return nullptr;
}
int fileCount() const {
int count = 0;
for (const auto& f : files_)
if (!f.isDir) ++count;
return count;
}
int dirCount() const {
int count = 0;
for (const auto& f : files_)
if (f.isDir) ++count;
return count;
}
const std::string& root() const { return root_; }
private:
std::string root_;
std::vector<IndexedFile> files_;
std::map<std::string, size_t> byRelPath_;
void collectFiles(const FileNode& node) {
if (node.path == root_) {
for (const auto& child : node.children)
collectFiles(child);
return;
}
IndexedFile entry;
entry.path = node.path;
entry.isDir = node.isDir;
std::error_code ec;
entry.relativePath = fs::relative(
fs::path(node.path), fs::path(root_), ec).string();
if (!node.isDir)
entry.language = detectLanguage(node.path);
size_t idx = files_.size();
files_.push_back(entry);
byRelPath_[entry.relativePath] = idx;
if (node.isDir) {
for (const auto& child : node.children)
collectFiles(child);
}
}
};
// -----------------------------------------------------------------------
// ProjectState — aggregates workspace index and open buffer tracking
// -----------------------------------------------------------------------
struct ProjectState {
WorkspaceIndex index;
void scanWorkspace(const std::string& workspaceRoot) {
index.scan(workspaceRoot);
}
};