Step 266: 5 semantic annotation AST types (Intent, Complexity, Risk, Contract, SemanticTag) with JSON roundtrip and compact AST integration. Step 267: Sidecar AST persistence (.whetstone/<file>.ast.json) with save/load/list RPC methods, MCP tools, and permission enforcement. Step 268: Phase 10a integration tests — multi-file sidecar workflow, all 5 types in compact view, idempotent roundtrip, source isolation. Also includes Sprint 10 plan, GUI/installer polish, and Emacs integration fixes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
447 lines
15 KiB
C++
447 lines
15 KiB
C++
#pragma once
|
|
// Step 56: Robust Emacs command integration
|
|
//
|
|
// ElispCommandBuilder: builds valid Elisp expressions with proper escaping.
|
|
// EmacsConnection: manages Emacs daemon lifecycle (start, health check,
|
|
// command execution, auto-restart).
|
|
//
|
|
// The connection talks to emacsclient --eval for command execution.
|
|
// On Windows, Emacs must be on PATH or configured via setEmacsPath().
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <thread>
|
|
#include <chrono>
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ElispCommandBuilder — produces valid Elisp command strings
|
|
// ---------------------------------------------------------------------------
|
|
class ElispCommandBuilder {
|
|
public:
|
|
// Build an Elisp expression to open a file
|
|
static std::string findFile(const std::string& path) {
|
|
return "(find-file \"" + escapeString(path) + "\")";
|
|
}
|
|
|
|
// Build an Elisp expression to save current buffer
|
|
static std::string saveBuffer() {
|
|
return "(save-buffer)";
|
|
}
|
|
|
|
// Build an Elisp expression to evaluate arbitrary Elisp
|
|
static std::string eval(const std::string& elisp) {
|
|
return "(eval (read \"" + escapeString(elisp) + "\"))";
|
|
}
|
|
|
|
// Escape special characters in strings for Elisp embedding
|
|
static std::string escapeString(const std::string& str) {
|
|
std::string result;
|
|
result.reserve(str.size() + 16);
|
|
for (char c : str) {
|
|
switch (c) {
|
|
case '\\': result += "\\\\"; break;
|
|
case '"': result += "\\\""; break;
|
|
case '\n': result += "\\n"; break;
|
|
case '\t': result += "\\t"; break;
|
|
case '\r': result += "\\r"; break;
|
|
default: result += c; break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Build an Elisp expression to set a variable
|
|
static std::string setVariable(const std::string& name, const std::string& value) {
|
|
return "(setq " + name + " \"" + escapeString(value) + "\")";
|
|
}
|
|
|
|
// Build a progn block from multiple expressions
|
|
static std::string progn(const std::vector<std::string>& exprs) {
|
|
std::string result = "(progn";
|
|
for (const auto& e : exprs) {
|
|
result += " " + e;
|
|
}
|
|
result += ")";
|
|
return result;
|
|
}
|
|
|
|
// Build an Elisp expression to list functions matching a prefix/regex
|
|
static std::string aproposFunctions(const std::string& prefixRegex) {
|
|
std::string pattern = prefixRegex;
|
|
return "(mapconcat #'symbol-name (apropos-internal \"" +
|
|
escapeString(pattern) + "\" 'fboundp) \"\\n\")";
|
|
}
|
|
|
|
// Build an Elisp expression to describe a function (signature + doc)
|
|
static std::string describeFunction(const std::string& name) {
|
|
std::string sym = escapeString(name);
|
|
return "(let* ((sym '" + sym + ") "
|
|
"(args (if (fboundp 'help-function-arglist) "
|
|
" (help-function-arglist sym t) nil)) "
|
|
"(doc (or (documentation sym t) \"\"))) "
|
|
"(concat (if args (prin1-to-string args) \"\") \"\\n\" doc))";
|
|
}
|
|
|
|
// Build an Elisp expression to resolve a key binding to a command name
|
|
static std::string keyBinding(const std::string& keySequence) {
|
|
return "(let ((cmd (key-binding (kbd \"" + escapeString(keySequence) + "\")))) "
|
|
"(if cmd (symbol-name cmd) \"\"))";
|
|
}
|
|
|
|
// Build an Elisp expression to call a command interactively
|
|
static std::string callInteractive(const std::string& command) {
|
|
return "(call-interactively '" + escapeString(command) + ")";
|
|
}
|
|
|
|
// Build an Elisp expression to run an extended command by name
|
|
static std::string executeExtendedCommand(const std::string& command) {
|
|
std::string sym = escapeString(command);
|
|
return "(if (commandp '" + sym + ") "
|
|
"(call-interactively '" + sym + ") "
|
|
"(concat \"Unknown command: \" \"" + sym + "\"))";
|
|
}
|
|
|
|
// Build an Elisp expression for the current mode line
|
|
static std::string modeLine() {
|
|
return "(format-mode-line mode-line-format)";
|
|
}
|
|
|
|
// Build an Elisp expression to read a buffer for a given file
|
|
static std::string bufferStringForFile(const std::string& path) {
|
|
return "(with-current-buffer (find-file-noselect \"" +
|
|
escapeString(path) + "\") (buffer-string))";
|
|
}
|
|
|
|
// Build an Elisp expression to replace a buffer's contents and save
|
|
static std::string replaceBufferForFile(const std::string& path, const std::string& text) {
|
|
return "(with-current-buffer (find-file-noselect \"" + escapeString(path) + "\") "
|
|
"(erase-buffer) "
|
|
"(insert \"" + escapeString(text) + "\") "
|
|
"(save-buffer))";
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EmacsConnection — manages Emacs daemon lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
class EmacsConnection {
|
|
public:
|
|
EmacsConnection() = default;
|
|
|
|
// Set custom path to emacs/emacsclient executables
|
|
void setEmacsPath(const std::string& path) { emacsPath_ = path; }
|
|
void setEmacsclientPath(const std::string& path) { emacsclientPath_ = path; }
|
|
|
|
// Start Emacs daemon
|
|
bool startDaemon() {
|
|
std::string output;
|
|
return startDaemonWithConfig("", output);
|
|
}
|
|
|
|
// Start Emacs daemon with an explicit init file path
|
|
virtual bool startDaemonWithConfig(const std::string& initFilePath, std::string& outLog) {
|
|
outLog.clear();
|
|
std::string cmd = emacsPath_ + " --daemon";
|
|
if (!initFilePath.empty()) {
|
|
cmd += " --load \"" + initFilePath + "\"";
|
|
}
|
|
#ifdef _WIN32
|
|
cmd += " 2>&1";
|
|
FILE* pipe = openPipe(cmd.c_str(), "r");
|
|
if (!pipe) {
|
|
lastError_ = "Failed to start Emacs daemon";
|
|
return false;
|
|
}
|
|
char buf[512];
|
|
while (fgets(buf, sizeof(buf), pipe)) {
|
|
outLog += buf;
|
|
}
|
|
int result = closePipe(pipe);
|
|
if (result == 0 || isDaemonAlive()) {
|
|
daemonRunning_ = true;
|
|
return true;
|
|
}
|
|
lastError_ = outLog.empty() ? "Failed to start Emacs daemon" : outLog;
|
|
return false;
|
|
#else
|
|
// Launch daemon in the background to avoid blocking editor startup.
|
|
cmd += " >/tmp/whetstone-emacs-daemon.log 2>&1 &";
|
|
int result = runCommand(cmd);
|
|
if (result != 0) {
|
|
lastError_ = "Failed to start Emacs daemon";
|
|
return false;
|
|
}
|
|
outLog = "Starting Emacs daemon in background.";
|
|
for (int i = 0; i < 15; ++i) {
|
|
if (isDaemonAlive()) {
|
|
daemonRunning_ = true;
|
|
return true;
|
|
}
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
}
|
|
lastError_ = "Emacs daemon did not become ready in time";
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
// Check if daemon is running
|
|
bool isDaemonAlive() const {
|
|
// Try to evaluate a simple expression
|
|
std::string cmd = emacsclientPath_ + " --eval \"(+ 1 1)\" 2>&1";
|
|
FILE* pipe = openPipe(cmd.c_str(), "r");
|
|
if (!pipe) return false;
|
|
|
|
char buf[256];
|
|
std::string output;
|
|
while (fgets(buf, sizeof(buf), pipe)) {
|
|
output += buf;
|
|
}
|
|
int ret = closePipe(pipe);
|
|
|
|
// If emacsclient returns 0 and outputs "2", daemon is alive
|
|
return (ret == 0 && output.find("2") != std::string::npos);
|
|
}
|
|
|
|
// Send command to Emacs and get result
|
|
virtual std::string sendCommand(const std::string& elispCommand) {
|
|
lastError_.clear();
|
|
|
|
if (!daemonRunning_ && !isDaemonAlive()) {
|
|
lastError_ = "Emacs daemon is not running";
|
|
return "";
|
|
}
|
|
|
|
std::string cmd = emacsclientPath_ + " --eval \"" +
|
|
ElispCommandBuilder::escapeString(elispCommand) + "\" 2>&1";
|
|
FILE* pipe = openPipe(cmd.c_str(), "r");
|
|
if (!pipe) {
|
|
lastError_ = "Failed to execute emacsclient";
|
|
return "";
|
|
}
|
|
|
|
char buf[4096];
|
|
std::string output;
|
|
while (fgets(buf, sizeof(buf), pipe)) {
|
|
output += buf;
|
|
}
|
|
int ret = closePipe(pipe);
|
|
|
|
// Trim trailing newline
|
|
while (!output.empty() && (output.back() == '\n' || output.back() == '\r'))
|
|
output.pop_back();
|
|
|
|
if (ret != 0) {
|
|
lastError_ = output.empty() ? "emacsclient returned error" : output;
|
|
// Check if daemon died
|
|
if (!isDaemonAlive()) {
|
|
daemonRunning_ = false;
|
|
lastError_ = "Emacs daemon crashed";
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
std::string getBufferText(const std::string& path) {
|
|
return sendCommand(ElispCommandBuilder::bufferStringForFile(path));
|
|
}
|
|
|
|
bool setBufferText(const std::string& path, const std::string& text) {
|
|
std::string result = sendCommand(ElispCommandBuilder::replaceBufferForFile(path, text));
|
|
if (!lastError_.empty() && result == "error") return false;
|
|
return true;
|
|
}
|
|
|
|
bool openFileInEmacsFrame(const std::string& path) {
|
|
lastError_.clear();
|
|
std::string cmd = emacsclientPath_ + " -c -n \"" + path + "\"";
|
|
int code = runCommand(cmd);
|
|
if (code != 0) {
|
|
lastError_ = "emacsclient failed to open frame";
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Auto-restart daemon if it died
|
|
bool ensureDaemon() {
|
|
if (daemonRunning_ && isDaemonAlive()) return true;
|
|
daemonRunning_ = false;
|
|
return startDaemon();
|
|
}
|
|
|
|
// Get the last error
|
|
std::string getLastError() const { return lastError_; }
|
|
|
|
// Check if we believe the daemon is running
|
|
bool isConnected() const { return daemonRunning_; }
|
|
|
|
protected:
|
|
// Wrappers for popen/pclose to allow testing without real Emacs
|
|
virtual FILE* openPipe(const char* cmd, const char* mode) const {
|
|
#ifdef _WIN32
|
|
return _popen(cmd, mode);
|
|
#else
|
|
return popen(cmd, mode);
|
|
#endif
|
|
}
|
|
|
|
virtual int closePipe(FILE* pipe) const {
|
|
#ifdef _WIN32
|
|
return _pclose(pipe);
|
|
#else
|
|
return pclose(pipe);
|
|
#endif
|
|
}
|
|
|
|
virtual int runCommand(const std::string& cmd) const {
|
|
return std::system(cmd.c_str());
|
|
}
|
|
|
|
bool daemonRunning_ = false;
|
|
|
|
private:
|
|
std::string emacsPath_ = "emacs";
|
|
std::string emacsclientPath_ = "emacsclient";
|
|
std::string lastError_;
|
|
};
|
|
|
|
// Resolve emacs config path to init.el
|
|
static inline std::string resolveEmacsInitPath(const std::string& configPath) {
|
|
if (configPath.empty()) return "";
|
|
std::filesystem::path p(configPath);
|
|
if (std::filesystem::exists(p)) {
|
|
if (std::filesystem::is_directory(p)) {
|
|
return (p / "init.el").string();
|
|
}
|
|
return p.string();
|
|
}
|
|
if (p.extension().string().empty()) {
|
|
return (p / "init.el").string();
|
|
}
|
|
return p.string();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MockEmacsConnection — for testing without a real Emacs installation
|
|
// ---------------------------------------------------------------------------
|
|
class MockEmacsConnection : public EmacsConnection {
|
|
public:
|
|
MockEmacsConnection() {
|
|
// Simulate daemon as running
|
|
daemonRunning_ = true;
|
|
}
|
|
|
|
bool startDaemon() {
|
|
daemonRunning_ = true;
|
|
return true;
|
|
}
|
|
|
|
bool startDaemonWithConfig(const std::string& initFilePath, std::string& outLog) {
|
|
lastInitPath_ = initFilePath;
|
|
outLog = "Loaded " + initFilePath;
|
|
daemonRunning_ = true;
|
|
return true;
|
|
}
|
|
|
|
bool isDaemonAlive() const { return daemonRunning_; }
|
|
|
|
std::string sendCommand(const std::string& elispCommand) override {
|
|
lastSentCommand_ = elispCommand;
|
|
|
|
// Simulate responses for known commands
|
|
if (elispCommand.find("buffer-string") != std::string::npos) {
|
|
return "mock-buffer";
|
|
}
|
|
if (elispCommand.find("erase-buffer") != std::string::npos) {
|
|
return "t";
|
|
}
|
|
if (elispCommand.find("find-file") != std::string::npos) {
|
|
return "t"; // success
|
|
}
|
|
if (elispCommand.find("save-buffer") != std::string::npos) {
|
|
return "t";
|
|
}
|
|
if (elispCommand == "(+ 1 1)") {
|
|
return "2";
|
|
}
|
|
if (elispCommand.find("package-alist") != std::string::npos) {
|
|
return "use-package|2.4.1|Declarative package configuration\n"
|
|
"cl-lib|1.0|Common Lisp extensions for Emacs";
|
|
}
|
|
if (elispCommand.find("package-archive-contents") != std::string::npos) {
|
|
return "magit|3.4.0|Git porcelain inside Emacs\n"
|
|
"projectile|2.7.0|Project interaction library";
|
|
}
|
|
if (elispCommand.find("(require '") != std::string::npos) {
|
|
return "t";
|
|
}
|
|
if (elispCommand.find("apropos-internal") != std::string::npos) {
|
|
if (elispCommand.find("use-package") != std::string::npos) {
|
|
return "use-package\nuse-package-alias";
|
|
}
|
|
if (elispCommand.find("projectile") != std::string::npos) {
|
|
return "projectile-find-file\nprojectile-switch-project";
|
|
}
|
|
if (elispCommand.find("cl-") != std::string::npos) {
|
|
return "cl-loop\ncl-mapcar";
|
|
}
|
|
return "";
|
|
}
|
|
if (elispCommand.find("help-function-arglist") != std::string::npos) {
|
|
if (elispCommand.find("use-package") != std::string::npos) {
|
|
return "(name &rest args)\nConfigure package via use-package.";
|
|
}
|
|
if (elispCommand.find("projectile") != std::string::npos) {
|
|
return "(project)\nProjectile project switching.";
|
|
}
|
|
return "()\nEmacs function.";
|
|
}
|
|
if (elispCommand.find("key-binding") != std::string::npos) {
|
|
if (elispCommand.find("C-x C-s") != std::string::npos) {
|
|
return "save-buffer";
|
|
}
|
|
if (elispCommand.find("C-x C-f") != std::string::npos) {
|
|
return "find-file";
|
|
}
|
|
return "";
|
|
}
|
|
if (elispCommand.find("call-interactively") != std::string::npos) {
|
|
return "t";
|
|
}
|
|
if (elispCommand.find("format-mode-line") != std::string::npos) {
|
|
return "Emacs: Fundamental (Whetstone)";
|
|
}
|
|
// Unknown command — simulate error
|
|
lastError_ = "void-function";
|
|
return "error";
|
|
}
|
|
|
|
bool ensureDaemon() {
|
|
daemonRunning_ = true;
|
|
return true;
|
|
}
|
|
|
|
std::string getLastError() const { return lastError_; }
|
|
|
|
// Test helper: get the last command sent
|
|
std::string getLastSentCommand() const { return lastSentCommand_; }
|
|
std::string getLastInitPath() const { return lastInitPath_; }
|
|
std::string getLastRunCommand() const { return lastRunCommand_; }
|
|
|
|
protected:
|
|
int runCommand(const std::string& cmd) const override {
|
|
lastRunCommand_ = cmd;
|
|
return 0;
|
|
}
|
|
|
|
private:
|
|
std::string lastError_;
|
|
std::string lastSentCommand_;
|
|
std::string lastInitPath_;
|
|
mutable std::string lastRunCommand_;
|
|
};
|