From fc71cce647069b2a646d5f8b2cacebb2918e0c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Wed, 3 Jun 2026 21:49:05 +0200 Subject: [PATCH] =?UTF-8?q?Add=20a=20core=20script=20engine;=20wire=20File?= =?UTF-8?q?=20=E2=96=B8=20Run=20script=20in=20the=20wx=20GUI.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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&, 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 --- src/core/app/script.cpp | 320 +++++++++++++++++++++++++++++++ src/core/app/script.hpp | 35 ++++ src/frontends/wx/wx_frame.cpp | 31 +++ src/frontends/wx/wx_frame.hpp | 1 + src/frontends/wx/wx_frontend.hpp | 1 + tests/test_script.cpp | 82 ++++++++ 6 files changed, 470 insertions(+) create mode 100644 src/core/app/script.cpp create mode 100644 src/core/app/script.hpp create mode 100644 tests/test_script.cpp diff --git a/src/core/app/script.cpp b/src/core/app/script.cpp new file mode 100644 index 0000000..81a0002 --- /dev/null +++ b/src/core/app/script.cpp @@ -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 +#include +#include +#include +#include +#include + +namespace app { + +namespace { + +// Whitespace split with "double quotes" grouping — same rules as the TUI shell. +std::vector tokenize(const std::string &s) { + std::vector 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 &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 &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 tok = tokenize(raw); + if (tok.empty()) return true; + const std::string cmd = tok[0]; + std::vector 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 "); return false; } + vars_[a[0]] = a[1]; + return true; + } + if (cmd == "new") { + sys_ = std::make_unique(); + 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 &sys_; + std::ostream &out_; + std::map vars_; +}; + +} // namespace + +ScriptResult run_script(std::unique_ptr &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 diff --git a/src/core/app/script.hpp b/src/core/app/script.hpp new file mode 100644 index 0000000..67da61d --- /dev/null +++ b/src/core/app/script.hpp @@ -0,0 +1,35 @@ +#ifndef _APP_SCRIPT_HPP_ +#define _APP_SCRIPT_HPP_ + +#include +#include +#include + +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 &sys, const std::string &path, + std::ostream &out); + +} // namespace app + +#endif // _APP_SCRIPT_HPP_ diff --git a/src/frontends/wx/wx_frame.cpp b/src/frontends/wx/wx_frame.cpp index c82040f..88689e7 100644 --- a/src/frontends/wx/wx_frame.cpp +++ b/src/frontends/wx/wx_frame.cpp @@ -6,6 +6,7 @@ #include "core/app/edit.hpp" #include "core/app/export.hpp" #include "core/app/load.hpp" +#include "core/app/script.hpp" #include "core/app/verify.hpp" #include "core/domain/analysis.hpp" #include "core/domain/connect.hpp" @@ -26,6 +27,7 @@ #include #include +#include #include #include @@ -33,6 +35,7 @@ namespace { enum { ID_LOAD = wxID_HIGHEST + 1, ID_RESTORE, + ID_RUN_SCRIPT, ID_SAVE, ID_EXPORT, ID_SET_CONNECTOR_TYPE, @@ -116,6 +119,7 @@ EssimFrame::EssimFrame(WxFrontend &fe) auto *file = new wxMenu; file->Append(ID_LOAD, "&Load module…\tCtrl-L"); 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->AppendSeparator(); 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::OnRestore, this, ID_RESTORE); 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::OnSetConnectorType, this, ID_SET_CONNECTOR_TYPE); Bind(wxEVT_MENU, &EssimFrame::OnAttachBsdl, this, ID_ATTACH_BSDL); @@ -350,6 +355,32 @@ void EssimFrame::OnRestore(wxCommandEvent &) { 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 &) { wxFileDialog dlg(this, "Save system snapshot", "", "system.essim", "essim snapshots (*.essim)|*.essim|All files (*.*)|*.*", diff --git a/src/frontends/wx/wx_frame.hpp b/src/frontends/wx/wx_frame.hpp index 255078f..b378f66 100644 --- a/src/frontends/wx/wx_frame.hpp +++ b/src/frontends/wx/wx_frame.hpp @@ -21,6 +21,7 @@ private: // Menu handlers — each is a thin wrapper over a core/app operation. void OnLoad(wxCommandEvent &); void OnRestore(wxCommandEvent &); + void OnRunScript(wxCommandEvent &); void OnSave(wxCommandEvent &); void OnExport(wxCommandEvent &); void OnSetConnectorType(wxCommandEvent &); diff --git a/src/frontends/wx/wx_frontend.hpp b/src/frontends/wx/wx_frontend.hpp index 1545d22..bc88357 100644 --- a/src/frontends/wx/wx_frontend.hpp +++ b/src/frontends/wx/wx_frontend.hpp @@ -26,6 +26,7 @@ public: // --- used by the window (EssimFrame) --- System *system() const { return sys_.get(); } + std::unique_ptr &system_ptr() { return sys_; } ///< for run_script (new/restore replace it) void set_system(System *fresh); ///< take ownership (used by Restore) void ensure_system(); ///< create an empty System if none yet diff --git a/tests/test_script.cpp b/tests/test_script.cpp new file mode 100644 index 0000000..0284a99 --- /dev/null +++ b/tests/test_script.cpp @@ -0,0 +1,82 @@ +#include + +#include "core/app/script.hpp" +#include "core/domain/modules.hpp" +#include "core/domain/system.hpp" + +#include +#include +#include +#include +#include + +// 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 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 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 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); +}