Step 161: add plugin API and loader
This commit is contained in:
@@ -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 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 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 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. |
|
||||||
|
|||||||
@@ -898,6 +898,9 @@ add_executable(step160_test tests/step160_test.cpp)
|
|||||||
target_include_directories(step160_test PRIVATE src)
|
target_include_directories(step160_test PRIVATE src)
|
||||||
target_link_libraries(step160_test PRIVATE nlohmann_json::nlohmann_json imgui::imgui)
|
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(SDL2 CONFIG REQUIRED)
|
||||||
find_package(OpenGL REQUIRED)
|
find_package(OpenGL REQUIRED)
|
||||||
find_package(glad CONFIG REQUIRED)
|
find_package(glad CONFIG REQUIRED)
|
||||||
|
|||||||
28
editor/src/PluginAPI.h
Normal file
28
editor/src/PluginAPI.h
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
// Step 161: Plugin API definition
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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 (*)();
|
||||||
189
editor/src/PluginLoader.h
Normal file
189
editor/src/PluginLoader.h
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
#pragma once
|
||||||
|
// Step 161: Plugin loader
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <algorithm>
|
||||||
|
#include "PluginAPI.h"
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct PluginRegistry {
|
||||||
|
std::vector<std::string> concepts;
|
||||||
|
std::vector<std::string> generators;
|
||||||
|
std::vector<std::string> grammars;
|
||||||
|
std::vector<std::string> annotations;
|
||||||
|
std::vector<std::string> panels;
|
||||||
|
std::vector<std::string> commands;
|
||||||
|
std::vector<std::pair<std::string, std::string>> 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<PluginInitFn>(
|
||||||
|
GetProcAddress(lib, "WhetstonePluginInit"));
|
||||||
|
auto shutdown = reinterpret_cast<PluginShutdownFn>(
|
||||||
|
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<PluginInitFn>(
|
||||||
|
dlsym(lib, "WhetstonePluginInit"));
|
||||||
|
auto shutdown = reinterpret_cast<PluginShutdownFn>(
|
||||||
|
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<PluginHandle>& 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<PluginLoader*>(ctx)->registry_.concepts.emplace_back(name ? name : "");
|
||||||
|
};
|
||||||
|
host.registerGenerator = [](void* ctx, const char* language) {
|
||||||
|
static_cast<PluginLoader*>(ctx)->registry_.generators.emplace_back(language ? language : "");
|
||||||
|
};
|
||||||
|
host.registerGrammar = [](void* ctx, const char* language) {
|
||||||
|
static_cast<PluginLoader*>(ctx)->registry_.grammars.emplace_back(language ? language : "");
|
||||||
|
};
|
||||||
|
host.registerAnnotation = [](void* ctx, const char* name) {
|
||||||
|
static_cast<PluginLoader*>(ctx)->registry_.annotations.emplace_back(name ? name : "");
|
||||||
|
};
|
||||||
|
host.registerPanel = [](void* ctx, const char* name) {
|
||||||
|
static_cast<PluginLoader*>(ctx)->registry_.panels.emplace_back(name ? name : "");
|
||||||
|
};
|
||||||
|
host.registerCommand = [](void* ctx, const char* commandId) {
|
||||||
|
static_cast<PluginLoader*>(ctx)->registry_.commands.emplace_back(commandId ? commandId : "");
|
||||||
|
};
|
||||||
|
host.registerKeybinding = [](void* ctx, const char* commandId, const char* keybinding) {
|
||||||
|
static_cast<PluginLoader*>(ctx)->registry_.keybindings.emplace_back(
|
||||||
|
commandId ? commandId : "", keybinding ? keybinding : "");
|
||||||
|
};
|
||||||
|
|
||||||
|
return init(&host, &desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void unloadLibrary(void* handle) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
FreeLibrary(reinterpret_cast<HMODULE>(handle));
|
||||||
|
#else
|
||||||
|
dlclose(handle);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginRegistry registry_;
|
||||||
|
std::vector<PluginHandle> plugins_;
|
||||||
|
};
|
||||||
57
editor/tests/step161_test.cpp
Normal file
57
editor/tests/step161_test.cpp
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Step 161 TDD Test: Plugin API + loader
|
||||||
|
#include "PluginLoader.h"
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -349,7 +349,7 @@ Final polish, documentation, and ecosystem features.
|
|||||||
Theme gallery in Settings panel.
|
Theme gallery in Settings panel.
|
||||||
*New:* `ThemeEngine.h`
|
*New:* `ThemeEngine.h`
|
||||||
|
|
||||||
- [ ] **Step 161: Extension/plugin API**
|
- [x] **Step 161: Extension/plugin API**
|
||||||
Define a stable plugin interface. Plugins can:
|
Define a stable plugin interface. Plugins can:
|
||||||
- Register new AST concept types
|
- Register new AST concept types
|
||||||
- Add generators for new languages
|
- Add generators for new languages
|
||||||
|
|||||||
Reference in New Issue
Block a user