Add a core script engine; wire File ▸ Run script in the wx GUI.

The wx frontend had no way to run essim command scripts (only the tui shell
did). Add a frontend-agnostic engine in core/app/script.{hpp,cpp}:
run_script(unique_ptr<System>&, path, ostream&) -> {ok, error, lines, errors}.
It parses essim scripts (# comments, blank lines, "quoted" args, set + $var /
${var} expansion, nested source with a depth guard) and dispatches the
scriptable, system-building commands — new, load, connect, set-connector-type,
set-signal-type, attach-bsdl, verify, export, save, restore — to the existing
app::* operations, writing their (TUI-identical) output to the stream.
Unsupported/interactive commands are reported and counted, execution continues.
System is taken by reference so new/restore can replace it.

wx gains File ▸ Run script…: pick a .essim, run it, echo the output into the
log and refresh. WxFrontend exposes system_ptr() for the engine.

tests/test_script.cpp: a full script (comment + set/$var + new + load + set-
signal-type + verify) builds the system and produces the expected log; missing
file reported; unsupported command flagged and skipped. 400 core assertions
green; tui + wx build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:49:05 +02:00
parent 184b0d306f
commit fc71cce647
6 changed files with 470 additions and 0 deletions

320
src/core/app/script.cpp Normal file
View File

@@ -0,0 +1,320 @@
#include "core/app/script.hpp"
#include "core/app/connect.hpp"
#include "core/app/edit.hpp"
#include "core/app/export.hpp"
#include "core/app/load.hpp"
#include "core/app/verify.hpp"
#include "core/domain/analysis.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/persist.hpp"
#include "core/domain/signal_type.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
#include <cctype>
#include <fstream>
#include <map>
#include <ostream>
#include <string>
#include <vector>
namespace app {
namespace {
// Whitespace split with "double quotes" grouping — same rules as the TUI shell.
std::vector<std::string> tokenize(const std::string &s) {
std::vector<std::string> out;
std::string cur;
bool in_q = false;
bool has = false;
for (char c : s) {
if (c == '"') { in_q = !in_q; has = true; continue; }
if (!in_q && std::isspace((unsigned char)c)) {
if (has) { out.push_back(std::move(cur)); cur.clear(); has = false; }
} else {
cur.push_back(c);
has = true;
}
}
if (has) out.push_back(std::move(cur));
return out;
}
// One script execution: holds the variable table, the System reference (so new/
// restore can replace it) and the output stream.
class Runner {
public:
Runner(std::unique_ptr<System> &sys, std::ostream &out) : sys_(sys), out_(out) {}
// Run a file; `opened` reports whether it could be opened. Returns the count
// of effective lines; accumulates command errors into `r.errors`.
int run_file(const std::string &path, int depth, ScriptResult &r, bool &opened) {
opened = false;
if (depth > 32) {
emit("source: nesting too deep, skipping " + path);
return 0;
}
std::ifstream f(path);
if (!f) return 0;
opened = true;
int count = 0;
std::string line;
while (std::getline(f, line)) {
std::size_t s = line.find_first_not_of(" \t");
if (s == std::string::npos) continue;
if (line[s] == '#') continue;
std::string t = line.substr(s);
while (!t.empty() && std::isspace((unsigned char)t.back())) t.pop_back();
if (t.empty()) continue;
++count;
if (!exec(t, depth, r)) ++r.errors;
}
return count;
}
private:
void emit(const std::string &line) { out_ << line << '\n'; }
// $name / ${name} → variable value; unknown names kept literally.
std::string expand(const std::string &s) const {
std::string out;
std::size_t i = 0;
while (i < s.size()) {
if (s[i] != '$') { out.push_back(s[i++]); continue; }
std::size_t j = i + 1;
bool braces = (j < s.size() && s[j] == '{');
if (braces) ++j;
std::size_t start = j;
while (j < s.size() && (std::isalnum((unsigned char)s[j]) || s[j] == '_')) ++j;
std::string name = s.substr(start, j - start);
if (braces) {
if (j >= s.size() || s[j] != '}') { out.push_back('$'); ++i; continue; }
++j;
}
if (name.empty()) { out.push_back('$'); ++i; continue; }
auto it = vars_.find(name);
if (it != vars_.end()) out += it->second;
else out += s.substr(i, j - i);
i = j;
}
return out;
}
Module *resolve_module(const std::string &name) {
try { return sys_->modules()->get(name); }
catch (const std::exception &) { emit("unknown module: " + name); return nullptr; }
}
Part *resolve_part(Module *m, const std::string &name) {
try { return m->get(name); }
catch (const std::exception &) {
emit("part in " + m->name + " not found: " + name);
return nullptr;
}
}
void render_verify() {
VerifyReport r = verify(sys_.get());
for (const auto &m : r.role_mismatches)
emit(" " + m.module + "/" + m.part + "/" + m.pin + ": expected "
+ signal_type_name(m.expected) + ", got " + signal_type_name(m.actual)
+ " (signal: " + m.signal + ")");
emit("verify: " + std::to_string(r.role_mismatches.size())
+ " local mismatch(es) over " + std::to_string(r.typed_pins)
+ " typed pin(s).");
for (const auto &ni : r.net_inconsistencies) {
std::string line = " net mixes Power and GndShield:";
for (const auto &mem : ni.members)
line += " " + mem.module + "/" + mem.signal
+ "(" + signal_type_name(mem.type) + ")";
emit(line);
}
emit("verify: " + std::to_string(r.net_inconsistencies.size())
+ " inconsistent net(s) over " + std::to_string(r.bridged_nets)
+ " bridged net(s) (" + std::to_string(r.total_nets) + " total).");
emit("verify: " + std::to_string(r.orphan_total())
+ " orphan pin(s) at import (" + std::to_string(r.orphan_imported)
+ " imported NC, " + std::to_string(r.orphan_dropped)
+ " dropped singleton).");
auto grp = [&](const std::vector<Anomaly> &v, const char *tail) {
for (const auto &a : v)
emit(" [" + std::string(anomaly_kind_name(a.kind)) + "] " + a.message);
emit("verify: " + std::to_string(v.size()) + tail);
};
grp(r.pin_anomalies, " model-driven pin anomaly(ies).");
grp(r.jtag_anomalies, " JTAG chain anomaly(ies).");
grp(r.conflict_anomalies, " source-conflict(s).");
grp(r.completeness_anomalies, " BSDL completeness issue(s).");
}
// Execute one already-trimmed line. Returns false on a hard error.
bool exec(const std::string &raw, int depth, ScriptResult &top) {
std::vector<std::string> tok = tokenize(raw);
if (tok.empty()) return true;
const std::string cmd = tok[0];
std::vector<std::string> a;
for (std::size_t i = 1; i < tok.size(); ++i) a.push_back(expand(tok[i]));
auto need = [&](std::size_t n) {
if (a.size() == n) return true;
emit(cmd + ": expected " + std::to_string(n) + " argument(s)");
return false;
};
if (cmd == "set") {
if (a.size() != 2) { emit("set: usage: set <name> <value>"); return false; }
vars_[a[0]] = a[1];
return true;
}
if (cmd == "new") {
sys_ = std::make_unique<System>();
emit("system created.");
return true;
}
if (cmd == "load") {
if (!need(3)) return false;
ImportType t;
if (!import_type_from_name(a[2], t)) { emit("unknown import type: " + a[2]); return false; }
LoadResult r = load_module(sys_.get(), a[0], a[1], t);
if (!r.ok) { emit("load failed: " + r.error); return false; }
emit("loaded '" + a[0] + "' from " + a[1]);
emit(" parts: " + std::to_string(r.parts));
emit(" signals: " + std::to_string(r.signals)
+ (r.dropped ? " (dropped " + std::to_string(r.dropped)
+ " singleton/NC signal(s))" : ""));
emit(" types: " + std::to_string(r.power) + " power, "
+ std::to_string(r.gnd) + " gnd, " + std::to_string(r.kept_other)
+ " suspect Power (name only — kept as Other)");
return true;
}
if (cmd == "connect" || cmd == "plug") {
if (!need(4)) return false;
Module *m1 = resolve_module(a[0]); if (!m1) return false;
Part *p1 = resolve_part(m1, a[1]); if (!p1) return false;
Module *m2 = resolve_module(a[2]); if (!m2) return false;
Part *p2 = resolve_part(m2, a[3]); if (!p2) return false;
ConnectResult r = connect_parts(sys_.get(), m1, p1, m2, p2);
if (r.refused) { emit("connect refused: " + r.error); return false; }
if (!r.identity_info.empty()) {
emit("connect: " + r.identity_info);
if (r.nc_added > 0)
emit("connect: added " + std::to_string(r.nc_added)
+ " NC pin(s) so both sides match");
}
if (!r.ok) { emit("connect failed: " + r.error); return false; }
emit("connected: " + r.connection_name + " via " + r.transform_name
+ " (" + std::to_string(r.wires) + " wires)");
return true;
}
if (cmd == "set-connector-type") {
if (!need(3)) return false;
Module *m = resolve_module(a[0]); if (!m) return false;
Part *p = resolve_part(m, a[1]); if (!p) return false;
SetConnectorTypeResult r = set_connector_type(p, a[2]);
if (!r.ok) { emit("set-connector-type refused: " + r.error); return false; }
emit(m->name + "/" + p->name + ": connector_type = "
+ (a[2].empty() ? "(none)" : a[2]));
if (r.materialised > 0)
emit("set-connector-type: added " + std::to_string(r.materialised)
+ " NC pin(s) from the connector layout");
return true;
}
if (cmd == "set-signal-type") {
if (!need(3)) return false;
Module *m = resolve_module(a[0]); if (!m) return false;
Signal *sig;
try { sig = m->signals->get(a[1]); }
catch (const std::exception &) {
emit("unknown signal: " + m->name + "/" + a[1]); return false;
}
SetSignalTypeResult r = set_signal_type(sig, a[2]);
if (!r.ok) { emit(r.error); return false; }
emit(m->name + "/" + sig->name + ": signal type = " + signal_type_name(r.type));
return true;
}
if (cmd == "attach-bsdl") {
if (!need(3)) return false;
Module *m = resolve_module(a[0]); if (!m) return false;
Part *p = resolve_part(m, a[1]); if (!p) return false;
AttachBsdlResult r = attach_bsdl(p, a[2]);
if (!r.ok) { emit("attach-bsdl: " + r.error); return false; }
emit(m->name + "/" + p->name + ": attached BSDL '" + r.entity + "' — "
+ std::to_string(r.bound) + "/" + std::to_string(r.ports_total)
+ " ports bound" + (r.unbound ? (", " + std::to_string(r.unbound) + " unbound") : ""));
return true;
}
if (cmd == "verify") {
render_verify();
return true;
}
if (cmd == "export") {
if (!need(1)) return false;
ExportFormat fmt;
if (!export_format_from_path(a[0], fmt)) {
emit("export: unknown extension (use .csv or .ods): " + a[0]); return false;
}
ExportResult r = export_connections(sys_.get(), a[0], fmt);
if (!r.ok) { emit("export failed: " + r.error); return false; }
emit("exported " + std::to_string(r.rows) + " row(s) to " + a[0]);
return true;
}
if (cmd == "save") {
if (!need(1)) return false;
std::string err;
if (!save_system(sys_.get(), a[0], err)) { emit("save failed: " + err); return false; }
emit("saved to " + a[0]);
return true;
}
if (cmd == "restore") {
if (!need(1)) return false;
std::string err;
System *fresh = restore_system(a[0], err);
if (!fresh) { emit("restore failed: " + err); return false; }
sys_.reset(fresh);
emit("restored from " + a[0] + " ("
+ std::to_string(sys_->modules()->size()) + " module(s), "
+ std::to_string(sys_->connections()->size()) + " connection(s))");
return true;
}
if (cmd == "source") {
if (!need(1)) return false;
bool opened;
int n = run_file(a[0], depth + 1, top, opened);
if (!opened) { emit("source: cannot open " + a[0]); return false; }
emit("source: " + a[0] + " (" + std::to_string(n) + " line(s))");
return true;
}
emit("script: unsupported command '" + cmd + "'");
return false;
}
std::unique_ptr<System> &sys_;
std::ostream &out_;
std::map<std::string, std::string> vars_;
};
} // namespace
ScriptResult run_script(std::unique_ptr<System> &sys, const std::string &path,
std::ostream &out)
{
ScriptResult r;
Runner runner(sys, out);
bool opened = false;
int n = runner.run_file(path, 0, r, opened);
if (!opened) {
r.error = "cannot open " + path;
return r;
}
r.ok = true;
r.lines = n;
return r;
}
} // namespace app

35
src/core/app/script.hpp Normal file
View File

@@ -0,0 +1,35 @@
#ifndef _APP_SCRIPT_HPP_
#define _APP_SCRIPT_HPP_
#include <iosfwd>
#include <memory>
#include <string>
class System;
// Application layer: a frontend-agnostic runner for essim command scripts.
// Dispatches the scriptable, system-building commands to the app::* operations
// and writes their output to a stream — no console, no dialogs, no FTXUI. Any
// frontend (and batch mode) can drive a script through it.
namespace app {
// Outcome of running a script file.
struct ScriptResult {
bool ok = false; ///< the top-level file opened and ran
std::string error; ///< set when the top-level file can't be opened
int lines = 0; ///< effective (non-comment, non-blank) lines run
int errors = 0; ///< commands that failed / were unsupported
};
// Run the script at `path` against `sys`, writing per-command output to `out`.
// `sys` is taken by reference because `new` / `restore` replace the System.
// Supported: # comments, blank lines, set + $var/${var} expansion, new, load,
// connect, set-connector-type, set-signal-type, attach-bsdl, verify, export,
// save, restore, source (nested). Unsupported commands are reported and counted
// in `errors`, and execution continues.
ScriptResult run_script(std::unique_ptr<System> &sys, const std::string &path,
std::ostream &out);
} // namespace app
#endif // _APP_SCRIPT_HPP_

View File

@@ -6,6 +6,7 @@
#include "core/app/edit.hpp" #include "core/app/edit.hpp"
#include "core/app/export.hpp" #include "core/app/export.hpp"
#include "core/app/load.hpp" #include "core/app/load.hpp"
#include "core/app/script.hpp"
#include "core/app/verify.hpp" #include "core/app/verify.hpp"
#include "core/domain/analysis.hpp" #include "core/domain/analysis.hpp"
#include "core/domain/connect.hpp" #include "core/domain/connect.hpp"
@@ -26,6 +27,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <sstream>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -33,6 +35,7 @@ namespace {
enum { enum {
ID_LOAD = wxID_HIGHEST + 1, ID_LOAD = wxID_HIGHEST + 1,
ID_RESTORE, ID_RESTORE,
ID_RUN_SCRIPT,
ID_SAVE, ID_SAVE,
ID_EXPORT, ID_EXPORT,
ID_SET_CONNECTOR_TYPE, ID_SET_CONNECTOR_TYPE,
@@ -116,6 +119,7 @@ EssimFrame::EssimFrame(WxFrontend &fe)
auto *file = new wxMenu; auto *file = new wxMenu;
file->Append(ID_LOAD, "&Load module…\tCtrl-L"); file->Append(ID_LOAD, "&Load module…\tCtrl-L");
file->Append(ID_RESTORE, "&Restore snapshot…\tCtrl-R"); file->Append(ID_RESTORE, "&Restore snapshot…\tCtrl-R");
file->Append(ID_RUN_SCRIPT, "&Run script…\tCtrl-U");
file->Append(ID_SAVE, "&Save snapshot…\tCtrl-S"); file->Append(ID_SAVE, "&Save snapshot…\tCtrl-S");
file->AppendSeparator(); file->AppendSeparator();
file->Append(ID_EXPORT, "&Export connections…\tCtrl-E"); file->Append(ID_EXPORT, "&Export connections…\tCtrl-E");
@@ -169,6 +173,7 @@ EssimFrame::EssimFrame(WxFrontend &fe)
Bind(wxEVT_MENU, &EssimFrame::OnLoad, this, ID_LOAD); Bind(wxEVT_MENU, &EssimFrame::OnLoad, this, ID_LOAD);
Bind(wxEVT_MENU, &EssimFrame::OnRestore, this, ID_RESTORE); Bind(wxEVT_MENU, &EssimFrame::OnRestore, this, ID_RESTORE);
Bind(wxEVT_MENU, &EssimFrame::OnSave, this, ID_SAVE); Bind(wxEVT_MENU, &EssimFrame::OnSave, this, ID_SAVE);
Bind(wxEVT_MENU, &EssimFrame::OnRunScript, this, ID_RUN_SCRIPT);
Bind(wxEVT_MENU, &EssimFrame::OnExport, this, ID_EXPORT); Bind(wxEVT_MENU, &EssimFrame::OnExport, this, ID_EXPORT);
Bind(wxEVT_MENU, &EssimFrame::OnSetConnectorType, this, ID_SET_CONNECTOR_TYPE); Bind(wxEVT_MENU, &EssimFrame::OnSetConnectorType, this, ID_SET_CONNECTOR_TYPE);
Bind(wxEVT_MENU, &EssimFrame::OnAttachBsdl, this, ID_ATTACH_BSDL); Bind(wxEVT_MENU, &EssimFrame::OnAttachBsdl, this, ID_ATTACH_BSDL);
@@ -350,6 +355,32 @@ void EssimFrame::OnRestore(wxCommandEvent &) {
RebuildModelView(); RebuildModelView();
} }
void EssimFrame::OnRunScript(wxCommandEvent &) {
wxFileDialog dlg(this, "Run an essim script", "", "",
"essim scripts (*.essim)|*.essim|All files (*.*)|*.*",
wxFD_OPEN | wxFD_FILE_MUST_EXIST);
if (dlg.ShowModal() != wxID_OK) return;
fe_.ensure_system();
std::ostringstream out;
app::ScriptResult r =
app::run_script(fe_.system_ptr(), dlg.GetPath().utf8_string(), out);
if (!r.ok) {
Log("run script: " + wx(r.error));
wxMessageBox(wx(r.error), "Run script", wxOK | wxICON_ERROR, this);
return;
}
// Echo each line of the script's output into the log pane.
std::istringstream lines(out.str());
std::string line;
while (std::getline(lines, line)) Log(wx(line));
Log(wxString::Format("source: %s (%d line(s), %d error(s))",
dlg.GetPath(), r.lines, r.errors));
RebuildModelView();
}
void EssimFrame::OnSave(wxCommandEvent &) { void EssimFrame::OnSave(wxCommandEvent &) {
wxFileDialog dlg(this, "Save system snapshot", "", "system.essim", wxFileDialog dlg(this, "Save system snapshot", "", "system.essim",
"essim snapshots (*.essim)|*.essim|All files (*.*)|*.*", "essim snapshots (*.essim)|*.essim|All files (*.*)|*.*",

View File

@@ -21,6 +21,7 @@ private:
// Menu handlers — each is a thin wrapper over a core/app operation. // Menu handlers — each is a thin wrapper over a core/app operation.
void OnLoad(wxCommandEvent &); void OnLoad(wxCommandEvent &);
void OnRestore(wxCommandEvent &); void OnRestore(wxCommandEvent &);
void OnRunScript(wxCommandEvent &);
void OnSave(wxCommandEvent &); void OnSave(wxCommandEvent &);
void OnExport(wxCommandEvent &); void OnExport(wxCommandEvent &);
void OnSetConnectorType(wxCommandEvent &); void OnSetConnectorType(wxCommandEvent &);

View File

@@ -26,6 +26,7 @@ public:
// --- used by the window (EssimFrame) --- // --- used by the window (EssimFrame) ---
System *system() const { return sys_.get(); } System *system() const { return sys_.get(); }
std::unique_ptr<System> &system_ptr() { return sys_; } ///< for run_script (new/restore replace it)
void set_system(System *fresh); ///< take ownership (used by Restore) void set_system(System *fresh); ///< take ownership (used by Restore)
void ensure_system(); ///< create an empty System if none yet void ensure_system(); ///< create an empty System if none yet

82
tests/test_script.cpp Normal file
View File

@@ -0,0 +1,82 @@
#include <doctest/doctest.h>
#include "core/app/script.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/system.hpp"
#include <cstdio>
#include <fstream>
#include <memory>
#include <sstream>
#include <string>
// app::run_script is pure core: it drives the app::* operations from a command
// script and writes their output to a stream. No UI.
TEST_CASE("run_script builds a system from a command script") {
const char *net = "test_script_in.net";
{
std::ofstream f(net);
f << "COMP: 'C1' 'J1'\n"
" Explicit Pin: '1' 'x' 'NETA'\n"
" Explicit Pin: '2' 'x' 'NETB'\n"
"COMP: 'C2' 'J2'\n"
" Explicit Pin: '1' 'x' 'NETA'\n"
" Explicit Pin: '2' 'x' 'NETB'\n";
}
const char *scr = "test_script_in.essim";
{
std::ofstream f(scr);
f << "# a comment\n"
"\n"
"set NET " << net << "\n"
"new\n"
"load M $NET mentor\n"
"set-signal-type M NETA power\n"
"verify\n";
}
std::unique_ptr<System> sys;
std::ostringstream out;
app::ScriptResult r = app::run_script(sys, scr, out);
CHECK(r.ok);
CHECK(r.errors == 0);
REQUIRE(sys != nullptr);
CHECK(sys->modules()->size() == 1);
const std::string log = out.str();
CHECK(log.find("loaded 'M'") != std::string::npos); // load ran
CHECK(log.find("signal type = power") != std::string::npos); // $NET expanded + set
CHECK(log.find("verify:") != std::string::npos); // verify ran
std::remove(net);
std::remove(scr);
}
TEST_CASE("run_script reports a missing top-level file") {
std::unique_ptr<System> sys;
std::ostringstream out;
app::ScriptResult r =
app::run_script(sys, "/nonexistent-xyz/none.essim", out);
CHECK_FALSE(r.ok);
CHECK(r.error.find("cannot open") != std::string::npos);
}
TEST_CASE("run_script flags an unsupported command and keeps going") {
const char *scr = "test_script_unsup.essim";
{
std::ofstream f(scr);
f << "new\nfrobnicate widgets\nnew\n";
}
std::unique_ptr<System> sys;
std::ostringstream out;
app::ScriptResult r = app::run_script(sys, scr, out);
CHECK(r.ok);
CHECK(r.errors == 1);
CHECK(out.str().find("unsupported command 'frobnicate'") != std::string::npos);
REQUIRE(sys != nullptr); // the two `new` lines still ran
std::remove(scr);
}