Phase 3d complete: editor infrastructure (Steps 55-58)

Step 55: WelcomeScreen (actions, recent files, tips, visibility) - 7/7 tests
Step 56: ElispCommandBuilder + MockEmacsConnection (escaping, daemon lifecycle) - 6/6 tests
Step 57: BufferManager (multi-file open/close/switch, language/modified tracking) - 7/7 tests
Step 58: EditorMode (per-language indent, comment toggle, bracket pairs, snippets) - 8/8 tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bill
2026-02-08 16:27:22 -07:00
parent 16cc60b36c
commit bb7981d4d7
10 changed files with 1030 additions and 114 deletions

108
editor/src/BufferManager.h Normal file
View File

@@ -0,0 +1,108 @@
#pragma once
// Step 57: Buffer management
//
// BufferManager tracks multiple open file buffers. Each buffer has a path,
// content, language, and modified flag. One buffer is active at a time.
// Provides open, close, switch, and content access operations.
#include <string>
#include <vector>
#include <map>
class BufferManager {
public:
struct BufferInfo {
std::string path;
std::string content;
std::string language;
bool modified = false;
};
BufferManager() = default;
// Open a file into a buffer (makes it active)
bool openBuffer(const std::string& path, const std::string& content,
const std::string& language) {
if (hasBuffer(path)) return false; // already open
BufferInfo info{path, content, language, false};
buffers_[path] = info;
activeBuffer_ = path;
return true;
}
// Close a buffer by path
bool closeBuffer(const std::string& path) {
auto it = buffers_.find(path);
if (it == buffers_.end()) return false;
buffers_.erase(it);
// If we closed the active buffer, switch to another
if (activeBuffer_ == path) {
if (!buffers_.empty())
activeBuffer_ = buffers_.begin()->first;
else
activeBuffer_.clear();
}
return true;
}
// Get current active buffer path
std::string getActiveBufferPath() const { return activeBuffer_; }
// Switch to a different buffer
bool switchToBuffer(const std::string& path) {
if (!hasBuffer(path)) return false;
activeBuffer_ = path;
return true;
}
// Get buffer content
std::string getBufferContent(const std::string& path) const {
auto it = buffers_.find(path);
if (it == buffers_.end()) return "";
return it->second.content;
}
// Set buffer content
void setBufferContent(const std::string& path, const std::string& content) {
auto it = buffers_.find(path);
if (it != buffers_.end()) {
it->second.content = content;
}
}
// Get list of all open buffer paths
std::vector<std::string> getOpenBuffers() const {
std::vector<std::string> result;
for (const auto& [path, _] : buffers_) {
result.push_back(path);
}
return result;
}
// Check if a buffer exists
bool hasBuffer(const std::string& path) const {
return buffers_.find(path) != buffers_.end();
}
// Get buffer info
BufferInfo getBufferInfo(const std::string& path) const {
auto it = buffers_.find(path);
if (it != buffers_.end()) return it->second;
return {};
}
// Mark buffer as modified
void setModified(const std::string& path, bool modified) {
auto it = buffers_.find(path);
if (it != buffers_.end()) {
it->second.modified = modified;
}
}
// Get number of open buffers
size_t bufferCount() const { return buffers_.size(); }
private:
std::map<std::string, BufferInfo> buffers_;
std::string activeBuffer_;
};

240
editor/src/EditorMode.h Normal file
View File

@@ -0,0 +1,240 @@
#pragma once
// Step 58: Mode-specific editor behavior
//
// EditorMode provides language-aware editing features:
// - Auto-indent rules per language
// - Comment toggling per language
// - Bracket/quote auto-close per language
// - Language-specific snippet templates
//
// The editor queries the active EditorMode to determine behavior
// when the user types or performs editing operations.
#include <string>
#include <vector>
#include <map>
struct IndentRule {
std::string increasePattern; // regex-like pattern that increases indent
std::string decreasePattern; // regex-like pattern that decreases indent
int tabSize = 4;
bool useTabs = false;
};
struct CommentStyle {
std::string lineComment; // e.g. "//", "#", ";;"
std::string blockStart; // e.g. "/*", "\"\"\""
std::string blockEnd; // e.g. "*/", "\"\"\""
};
struct BracketPair {
char open;
char close;
};
struct SnippetTemplate {
std::string trigger; // e.g. "def", "for", "if"
std::string expansion; // template with $1, $2 placeholders
std::string description;
};
enum class EditorModeType {
Python,
Cpp,
Elisp,
PlainText
};
class EditorMode {
public:
explicit EditorMode(const std::string& language = "python") {
setLanguage(language);
}
void setLanguage(const std::string& language) {
language_ = language;
if (language == "python") loadPython();
else if (language == "cpp") loadCpp();
else if (language == "elisp") loadElisp();
else loadPlainText();
}
std::string getLanguage() const { return language_; }
EditorModeType getType() const { return type_; }
// --- Indentation ---
const IndentRule& getIndentRule() const { return indent_; }
// Should we increase indent after this line?
bool shouldIndentAfter(const std::string& line) const {
if (indent_.increasePattern.empty()) return false;
// Simple pattern matching (not full regex)
return lineEndsWith(line, indent_.increasePattern);
}
// Should we decrease indent for this line?
bool shouldDedent(const std::string& line) const {
if (indent_.decreasePattern.empty()) return false;
return trimmedLineStartsWith(line, indent_.decreasePattern);
}
// Get indent string (tabs or spaces)
std::string getIndentString() const {
if (indent_.useTabs) return "\t";
return std::string(indent_.tabSize, ' ');
}
int getTabSize() const { return indent_.tabSize; }
// --- Comments ---
const CommentStyle& getCommentStyle() const { return comment_; }
// Toggle line comment: add or remove line comment prefix
std::string toggleLineComment(const std::string& line) const {
if (comment_.lineComment.empty()) return line;
std::string trimmed = trimLeft(line);
std::string prefix = comment_.lineComment + " ";
if (trimmed.substr(0, prefix.size()) == prefix) {
// Remove comment
size_t commentPos = line.find(prefix);
return line.substr(0, commentPos) + line.substr(commentPos + prefix.size());
} else if (trimmed.substr(0, comment_.lineComment.size()) == comment_.lineComment) {
// Remove comment without space
size_t commentPos = line.find(comment_.lineComment);
return line.substr(0, commentPos) + line.substr(commentPos + comment_.lineComment.size());
} else {
// Add comment at the start of content (preserving indentation)
size_t firstNonSpace = line.find_first_not_of(" \t");
if (firstNonSpace == std::string::npos)
return line; // empty/whitespace line
return line.substr(0, firstNonSpace) + prefix + line.substr(firstNonSpace);
}
}
// --- Auto-close brackets ---
const std::vector<BracketPair>& getBracketPairs() const { return brackets_; }
// Given an opening character, return the closing character (0 if not a bracket)
char getClosingBracket(char open) const {
for (const auto& bp : brackets_) {
if (bp.open == open) return bp.close;
}
return 0;
}
// --- Snippets ---
const std::vector<SnippetTemplate>& getSnippets() const { return snippets_; }
// Find snippet by trigger
const SnippetTemplate* findSnippet(const std::string& trigger) const {
for (const auto& s : snippets_) {
if (s.trigger == trigger) return &s;
}
return nullptr;
}
// --- Static helpers ---
static const char* modeName(EditorModeType t) {
switch (t) {
case EditorModeType::Python: return "Python";
case EditorModeType::Cpp: return "C++";
case EditorModeType::Elisp: return "Elisp";
case EditorModeType::PlainText: return "Plain Text";
}
return "Unknown";
}
static EditorModeType modeFromLanguage(const std::string& lang) {
if (lang == "python") return EditorModeType::Python;
if (lang == "cpp") return EditorModeType::Cpp;
if (lang == "elisp") return EditorModeType::Elisp;
return EditorModeType::PlainText;
}
private:
static std::string trimLeft(const std::string& s) {
size_t start = s.find_first_not_of(" \t");
return (start == std::string::npos) ? "" : s.substr(start);
}
static bool lineEndsWith(const std::string& line, const std::string& suffix) {
std::string trimmed = line;
// Remove trailing whitespace
while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\t'))
trimmed.pop_back();
if (trimmed.size() < suffix.size()) return false;
return trimmed.substr(trimmed.size() - suffix.size()) == suffix;
}
static bool trimmedLineStartsWith(const std::string& line, const std::string& prefix) {
std::string trimmed = trimLeft(line);
return trimmed.substr(0, prefix.size()) == prefix;
}
void loadPython() {
type_ = EditorModeType::Python;
indent_ = {":", "return", 4, false};
comment_ = {"#", "\"\"\"", "\"\"\""};
brackets_ = {{'(', ')'}, {'[', ']'}, {'{', '}'}, {'\'', '\''}, {'"', '"'}};
snippets_ = {
{"def", "def $1($2):\n $0", "Function definition"},
{"class", "class $1:\n def __init__(self):\n $0", "Class definition"},
{"if", "if $1:\n $0", "If statement"},
{"for", "for $1 in $2:\n $0", "For loop"},
{"while", "while $1:\n $0", "While loop"},
{"try", "try:\n $0\nexcept $1:\n pass", "Try/except block"},
};
}
void loadCpp() {
type_ = EditorModeType::Cpp;
indent_ = {"{", "}", 4, false};
comment_ = {"//", "/*", "*/"};
brackets_ = {{'(', ')'}, {'[', ']'}, {'{', '}'}, {'\'', '\''}, {'"', '"'}, {'<', '>'}};
snippets_ = {
{"main", "int main(int argc, char* argv[]) {\n $0\n return 0;\n}", "Main function"},
{"class", "class $1 {\npublic:\n $1();\n ~$1();\nprivate:\n $0\n};", "Class definition"},
{"if", "if ($1) {\n $0\n}", "If statement"},
{"for", "for (int $1 = 0; $1 < $2; ++$1) {\n $0\n}", "For loop"},
{"while", "while ($1) {\n $0\n}", "While loop"},
{"switch", "switch ($1) {\n case $2:\n $0\n break;\n default:\n break;\n}", "Switch statement"},
{"inc", "#include <$1>", "Include directive"},
};
}
void loadElisp() {
type_ = EditorModeType::Elisp;
indent_ = {"(", ")", 2, false};
comment_ = {";;", "", ""};
brackets_ = {{'(', ')'}, {'[', ']'}, {'"', '"'}};
snippets_ = {
{"defun", "(defun $1 ($2)\n \"$3\"\n $0)", "Function definition"},
{"let", "(let (($1 $2))\n $0)", "Let binding"},
{"if", "(if $1\n $2\n $0)", "If expression"},
{"cond", "(cond\n (($1) $2)\n (t $0))", "Cond expression"},
{"lambda", "(lambda ($1) $0)", "Lambda expression"},
};
}
void loadPlainText() {
type_ = EditorModeType::PlainText;
indent_ = {"", "", 4, false};
comment_ = {"", "", ""};
brackets_ = {{'(', ')'}, {'[', ']'}, {'{', '}'}, {'"', '"'}};
snippets_ = {};
}
std::string language_;
EditorModeType type_;
IndentRule indent_;
CommentStyle comment_;
std::vector<BracketPair> brackets_;
std::vector<SnippetTemplate> snippets_;
};

View File

@@ -0,0 +1,248 @@
#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>
// ---------------------------------------------------------------------------
// 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;
}
};
// ---------------------------------------------------------------------------
// 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 cmd = emacsPath_ + " --daemon 2>&1";
int result = runCommand(cmd);
if (result == 0) {
daemonRunning_ = true;
return true;
}
// Even if command "fails", check if daemon is actually alive
// (Emacs daemon may return non-zero but still be running)
if (isDaemonAlive()) {
daemonRunning_ = true;
return true;
}
lastError_ = "Failed to start Emacs daemon";
return false;
}
// 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
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;
}
// 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_;
};
// ---------------------------------------------------------------------------
// 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 isDaemonAlive() const { return daemonRunning_; }
std::string sendCommand(const std::string& elispCommand) {
lastSentCommand_ = elispCommand;
// Simulate responses for known commands
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";
}
// 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_; }
private:
std::string lastError_;
std::string lastSentCommand_;
};

111
editor/src/WelcomeScreen.h Normal file
View File

@@ -0,0 +1,111 @@
#pragma once
// Step 55: Welcome screen component
//
// WelcomeScreen provides a VSCode-style welcome tab for the editor.
// Shows recent files, quick actions, keybinding profile info, and tips.
// Rendered as an ImGui panel or tab content.
#include <string>
#include <vector>
#include <functional>
struct WelcomeAction {
std::string label;
std::string description;
std::string shortcut; // e.g. "Ctrl+N"
};
struct RecentFile {
std::string path;
std::string language;
std::string displayName; // just the filename
};
class WelcomeScreen {
public:
WelcomeScreen() {
// Default quick actions
actions_ = {
{"New File", "Create a new empty file", "Ctrl+N"},
{"Open File", "Open an existing file", "Ctrl+O"},
{"Change Language", "Switch the active language mode", ""},
{"Keybindings", "Choose VSCode, JetBrains, or Emacs keys", ""},
};
tips_ = {
"Use the Language menu to switch between Python, C++, and Elisp.",
"The AST tab shows your code's abstract syntax tree in real time.",
"The Generated tab shows target-language output from the AST.",
"Switch keybinding profiles from the Keybindings menu.",
"Whetstone annotations (@Reclaim, @Owner, @Lifetime) express memory intent.",
"The Highlighted tab shows syntax coloring via tree-sitter.",
};
}
// --- Data access ---
const std::string& getTitle() const { return title_; }
void setTitle(const std::string& t) { title_ = t; }
const std::string& getSubtitle() const { return subtitle_; }
void setSubtitle(const std::string& s) { subtitle_ = s; }
const std::vector<WelcomeAction>& getActions() const { return actions_; }
void setActions(const std::vector<WelcomeAction>& a) { actions_ = a; }
const std::vector<RecentFile>& getRecentFiles() const { return recentFiles_; }
void addRecentFile(const std::string& path, const std::string& lang) {
// Extract display name from path
std::string name = path;
auto sep = path.find_last_of("/\\");
if (sep != std::string::npos) name = path.substr(sep + 1);
// Don't add duplicates
for (const auto& f : recentFiles_) {
if (f.path == path) return;
}
// Keep max 10 recent files
if (recentFiles_.size() >= 10) recentFiles_.pop_back();
recentFiles_.insert(recentFiles_.begin(), {path, lang, name});
}
void clearRecentFiles() { recentFiles_.clear(); }
const std::vector<std::string>& getTips() const { return tips_; }
// Get the next tip (cycles through)
std::string getNextTip() {
if (tips_.empty()) return "";
std::string tip = tips_[tipIndex_ % tips_.size()];
++tipIndex_;
return tip;
}
// Get a specific tip by index
std::string getTip(size_t index) const {
if (index >= tips_.size()) return "";
return tips_[index];
}
// --- Visibility ---
bool isVisible() const { return visible_; }
void setVisible(bool v) { visible_ = v; }
void show() { visible_ = true; }
void hide() { visible_ = false; }
// --- Version info ---
const std::string& getVersion() const { return version_; }
void setVersion(const std::string& v) { version_ = v; }
private:
std::string title_ = "Whetstone Editor";
std::string subtitle_ = "Semantic Annotation DSL for Cross-Language Code Generation";
std::string version_ = "0.3.0";
bool visible_ = true;
std::vector<WelcomeAction> actions_;
std::vector<RecentFile> recentFiles_;
std::vector<std::string> tips_;
size_t tipIndex_ = 0;
};