diff --git a/PROGRESS.md b/PROGRESS.md index 00045c7..b638a0c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -508,3 +508,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Step 158: Agent workflow recorder with JSON export, replay support, and RPC wiring. 5/5 tests pass. | | 2026-02-10 | Codex | Step 159: Agent marketplace registry with UI panel, install toggles, and registry serialization. 6/6 tests pass. | | 2026-02-10 | Codex | Step 160: Theme engine with JSON themes, ImGui styling, and syntax/editor color mapping. 4/4 tests pass. | +| 2026-02-10 | Codex | Step 161: Plugin API + loader with registry hooks for concepts/generators/grammars/annotations/panels/commands. 10/10 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index ccb6bca..ba884f1 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -898,6 +898,9 @@ add_executable(step160_test tests/step160_test.cpp) target_include_directories(step160_test PRIVATE src) target_link_libraries(step160_test PRIVATE nlohmann_json::nlohmann_json imgui::imgui) +add_executable(step161_test tests/step161_test.cpp) +target_include_directories(step161_test PRIVATE src) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/PluginAPI.h b/editor/src/PluginAPI.h new file mode 100644 index 0000000..22d8b47 --- /dev/null +++ b/editor/src/PluginAPI.h @@ -0,0 +1,28 @@ +#pragma once +// Step 161: Plugin API definition + +#include + +struct PluginDescriptor { + std::string name; + std::string version; + std::string apiVersion; + std::string author; + std::string entrySymbol; +}; + +struct PluginHost { + void* ctx = nullptr; + void (*registerConcept)(void* ctx, const char* name) = nullptr; + void (*registerGenerator)(void* ctx, const char* language) = nullptr; + void (*registerGrammar)(void* ctx, const char* language) = nullptr; + void (*registerAnnotation)(void* ctx, const char* name) = nullptr; + void (*registerPanel)(void* ctx, const char* name) = nullptr; + void (*registerCommand)(void* ctx, const char* commandId) = nullptr; + void (*registerKeybinding)(void* ctx, const char* commandId, + const char* keybinding) = nullptr; +}; + +using PluginInitFn = bool (*)(const PluginHost* host, + PluginDescriptor* outDescriptor); +using PluginShutdownFn = void (*)(); diff --git a/editor/src/PluginLoader.h b/editor/src/PluginLoader.h new file mode 100644 index 0000000..ee89139 --- /dev/null +++ b/editor/src/PluginLoader.h @@ -0,0 +1,189 @@ +#pragma once +// Step 161: Plugin loader + +#include +#include +#include +#include +#include +#include "PluginAPI.h" + +#ifdef _WIN32 +#include +#else +#include +#endif + +struct PluginRegistry { + std::vector concepts; + std::vector generators; + std::vector grammars; + std::vector annotations; + std::vector panels; + std::vector commands; + std::vector> keybindings; + + void clear() { + concepts.clear(); + generators.clear(); + grammars.clear(); + annotations.clear(); + panels.clear(); + commands.clear(); + keybindings.clear(); + } +}; + +struct PluginHandle { + std::string path; + PluginDescriptor descriptor; + void* handle = nullptr; + PluginShutdownFn shutdown = nullptr; +}; + +class PluginLoader { +public: + bool loadFromDirectory(const std::string& dir, std::string& error) { + namespace fs = std::filesystem; + std::error_code ec; + if (!fs::exists(dir, ec)) { + error = "Directory not found: " + dir; + return false; + } + bool any = false; + for (const auto& entry : fs::directory_iterator(dir)) { + if (!entry.is_regular_file()) continue; + auto path = entry.path(); +#ifdef _WIN32 + if (path.extension() != ".dll") continue; +#elif __APPLE__ + if (path.extension() != ".dylib") continue; +#else + if (path.extension() != ".so") continue; +#endif + std::string err; + if (!loadPlugin(path.string(), err)) { + error = err; + return false; + } + any = true; + } + if (!any) { + error = "No plugin libraries found in: " + dir; + } + return any; + } + + bool loadPlugin(const std::string& path, std::string& error) { +#ifdef _WIN32 + HMODULE lib = LoadLibraryA(path.c_str()); + if (!lib) { + error = "LoadLibrary failed: " + path; + return false; + } + auto init = reinterpret_cast( + GetProcAddress(lib, "WhetstonePluginInit")); + auto shutdown = reinterpret_cast( + GetProcAddress(lib, "WhetstonePluginShutdown")); +#else + void* lib = dlopen(path.c_str(), RTLD_LAZY); + if (!lib) { + error = std::string("dlopen failed: ") + dlerror(); + return false; + } + auto init = reinterpret_cast( + dlsym(lib, "WhetstonePluginInit")); + auto shutdown = reinterpret_cast( + dlsym(lib, "WhetstonePluginShutdown")); +#endif + if (!init) { + error = "Missing WhetstonePluginInit in: " + path; + unloadLibrary(lib); + return false; + } + + PluginDescriptor desc; + if (!invokeInit(init, desc)) { + error = "Plugin init failed: " + path; + unloadLibrary(lib); + return false; + } + PluginHandle handle; + handle.path = path; + handle.descriptor = desc; + handle.handle = lib; + handle.shutdown = shutdown; + plugins_.push_back(std::move(handle)); + return true; + } + + bool loadFromEntryPoint(PluginInitFn init, + const std::string& pathLabel, + std::string& error) { + PluginDescriptor desc; + if (!invokeInit(init, desc)) { + error = "Plugin init failed: " + pathLabel; + return false; + } + PluginHandle handle; + handle.path = pathLabel; + handle.descriptor = desc; + plugins_.push_back(std::move(handle)); + return true; + } + + void unloadAll() { + for (auto& plugin : plugins_) { + if (plugin.shutdown) plugin.shutdown(); + if (plugin.handle) { + unloadLibrary(plugin.handle); + } + } + plugins_.clear(); + registry_.clear(); + } + + const std::vector& plugins() const { return plugins_; } + const PluginRegistry& registry() const { return registry_; } + +private: + bool invokeInit(PluginInitFn init, PluginDescriptor& desc) { + PluginHost host; + host.ctx = this; + host.registerConcept = [](void* ctx, const char* name) { + static_cast(ctx)->registry_.concepts.emplace_back(name ? name : ""); + }; + host.registerGenerator = [](void* ctx, const char* language) { + static_cast(ctx)->registry_.generators.emplace_back(language ? language : ""); + }; + host.registerGrammar = [](void* ctx, const char* language) { + static_cast(ctx)->registry_.grammars.emplace_back(language ? language : ""); + }; + host.registerAnnotation = [](void* ctx, const char* name) { + static_cast(ctx)->registry_.annotations.emplace_back(name ? name : ""); + }; + host.registerPanel = [](void* ctx, const char* name) { + static_cast(ctx)->registry_.panels.emplace_back(name ? name : ""); + }; + host.registerCommand = [](void* ctx, const char* commandId) { + static_cast(ctx)->registry_.commands.emplace_back(commandId ? commandId : ""); + }; + host.registerKeybinding = [](void* ctx, const char* commandId, const char* keybinding) { + static_cast(ctx)->registry_.keybindings.emplace_back( + commandId ? commandId : "", keybinding ? keybinding : ""); + }; + + return init(&host, &desc); + } + + static void unloadLibrary(void* handle) { +#ifdef _WIN32 + FreeLibrary(reinterpret_cast(handle)); +#else + dlclose(handle); +#endif + } + + PluginRegistry registry_; + std::vector plugins_; +}; diff --git a/editor/tests/step161_test.cpp b/editor/tests/step161_test.cpp new file mode 100644 index 0000000..e9417fa --- /dev/null +++ b/editor/tests/step161_test.cpp @@ -0,0 +1,57 @@ +// Step 161 TDD Test: Plugin API + loader +#include "PluginLoader.h" +#include + +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; + } +} + +static bool FakeInit(const PluginHost* host, PluginDescriptor* outDesc) { + outDesc->name = "FakePlugin"; + outDesc->version = "0.1"; + outDesc->apiVersion = "1"; + outDesc->author = "Test"; + outDesc->entrySymbol = "WhetstonePluginInit"; + + host->registerConcept(host->ctx, "ExternalThing"); + host->registerGenerator(host->ctx, "zig"); + host->registerGrammar(host->ctx, "zig"); + host->registerAnnotation(host->ctx, "TraceSafe"); + host->registerPanel(host->ctx, "CustomPanel"); + host->registerCommand(host->ctx, "plugin.command"); + host->registerKeybinding(host->ctx, "plugin.command", "Ctrl+Alt+P"); + return true; +} + +int main() { + int passed = 0; + int failed = 0; + + PluginLoader loader; + std::string error; + expect(loader.loadFromEntryPoint(&FakeInit, "fake", error), + "load from entry point", passed, failed); + + const auto& registry = loader.registry(); + expect(registry.concepts.size() == 1, "concept registered", passed, failed); + expect(registry.generators.size() == 1, "generator registered", passed, failed); + expect(registry.grammars.size() == 1, "grammar registered", passed, failed); + expect(registry.annotations.size() == 1, "annotation registered", passed, failed); + expect(registry.panels.size() == 1, "panel registered", passed, failed); + expect(registry.commands.size() == 1, "command registered", passed, failed); + expect(registry.keybindings.size() == 1, "keybinding registered", passed, failed); + + expect(loader.plugins().size() == 1, "plugin tracked", passed, failed); + expect(loader.plugins()[0].descriptor.name == "FakePlugin", + "descriptor stored", passed, failed); + + std::cout << "\n=== Step 161 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint5_plan.md b/sprint5_plan.md index f46d439..d6dc38e 100644 --- a/sprint5_plan.md +++ b/sprint5_plan.md @@ -349,7 +349,7 @@ Final polish, documentation, and ecosystem features. Theme gallery in Settings panel. *New:* `ThemeEngine.h` -- [ ] **Step 161: Extension/plugin API** +- [x] **Step 161: Extension/plugin API** Define a stable plugin interface. Plugins can: - Register new AST concept types - Add generators for new languages