refactor(generator): the umbrella emitter leaves legacy/, and gets its own mode

`cpp-generator/legacy/` held four things and only one was legacy. The shared
emitter library was misfiled there: `generator_lib.{h,cpp}` is already consumed
by the MODERN `experimental/lidl_gen_client.h` and by all 11 tests under
tests/generator/. `lidl_to_json.{h,cpp}` likewise. Both are now
`cpp-generator/`; `legacy/` is down to `main.cpp` + `legacy_main.h`.

The logos_sdk umbrella (`struct LogosModules`) is not legacy either — it is the
CURRENT typed-dependency surface. `LogosModuleContext::modules()` returns it,
so every universal module that calls a declared dependency goes through it, and
LogosModule.cmake runs `--general-only` for every module build. Yet the only
code that could emit it lived inside the directory the plan wants deleted.

So `cpp-generator/main.cpp` gains `--umbrella`, with `--general-only` routed to
the same implementation and dispatched before the fall-through to legacy_main.
The deps-driven emission needed no rewriting: `makeUmbrella{Header,Source}
FromDeps` were already in generator_lib, and legacy/main.cpp merely wrapped
them in file I/O. -352 lines from legacy/main.cpp (827 -> 475), including the
interface-wrapper helpers that only that branch used.

`--general-only` keeps working identically, because LogosModule.cmake and
logos-basecamp both call it. The alias is guarded on `--metadata`, since
`--general-only` was never a standalone mode — without metadata it fell through
and reported the flag as a missing plugin path, and it still does.

The scraping `writeUmbrellaHeader`/`writeUmbrellaSource` are untouched: they
belong to `generateFromPlugin`, the QPluginLoader introspection path, and die
with it.

Verified byte-identical, which is the whole claim of a relocation. An
adversarial pass built its own pre- and post-change binaries and diffed the
emitted `logos_sdk.{h,cpp}` across 12 real metadata.json files x {qt,lp} x
{--general-only,--umbrella}: 48/48 identical, stdout/stderr/exit included, with
a positive control (qt vs lp) confirming the harness can see a difference. Real
modules then built through logos-module-builder against both binaries with
`diff -r` empty, including the compiled plugin. 266/266 tests pass, and the
pre-change tree also reports 266, so no test was silently dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-17 15:39:19 -03:00
co-authored by Claude Opus 5
parent c0cba910ae
commit 5a0c3ed93a
12 changed files with 488 additions and 394 deletions
+398
View File
@@ -1,4 +1,6 @@
#include "legacy/legacy_main.h"
#include "generator_lib.h"
#include "lidl_to_json.h"
#include "experimental/lidl_gen_client.h"
#include "experimental/lidl_gen_cdylib.h"
#include "experimental/lidl_compat.h"
@@ -8,7 +10,382 @@
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QSet>
#include <QStringList>
#include <QTextStream>
#include <QVector>
// ─── Umbrella mode (`--umbrella`, alias `--general-only`) ────────────────────
//
// Emits the umbrella — `logos_sdk.h` / `logos_sdk.cpp`, i.e. `struct
// LogosModules` — over a module's declared `metadata.json#dependencies` plus
// its interface dependencies, and the per-dependency / per-interface wrappers
// those aggregate.
//
// This is NOT a legacy mode, despite having lived in `legacy/main.cpp` until
// now: `LogosModuleContext::modules()` returns `LogosModules&`, so every
// `interface: "universal"` module that calls a declared dependency goes
// through it, and LogosModule.cmake runs it for every module build. Only the
// QPluginLoader-introspection path in `legacy/main.cpp` is legacy.
//
// `--general-only` is kept as an exact alias — it is what LogosModule.cmake,
// buildPlugin.nix and buildHeaders.nix all pass today — so there is ONE
// implementation of the mode and no second copy to drift.
// A single interface to generate a bound wrapper for. `path` is already
// resolved (nix resolves local `${src}/file` and remote `${input}/file`
// store paths and passes them via --interface; the generator never touches
// flake inputs). `implClass` is required for `.h` files, empty for `.lidl`.
struct InterfaceSpec {
QString name; // interface identifier → class/file name + bind_<name>
QString path; // resolved path to the .lidl / .h definition
QString implClass; // class inside a .h whose API defines the interface
};
// Parse all `<flag> <name>=<path>[=<impl_class>]` (or `<flag>=<name>=...`)
// occurrences. Names and store paths contain no '=', so splitting on the
// first two '=' is unambiguous. Used for both `--interface` (runtime-bound
// wrappers) and `--dep` (name-baked wrappers generated from a dep's LIDL).
static QVector<InterfaceSpec> parseSpecFlags(const QStringList& args, const QString& flag)
{
const QString flagEq = flag + "=";
QVector<InterfaceSpec> specs;
for (int i = 0; i < args.size(); ++i) {
QString value;
if (args.at(i) == flag && i + 1 < args.size()) {
value = args.at(i + 1);
} else if (args.at(i).startsWith(flagEq)) {
value = args.at(i).section('=', 1);
} else {
continue;
}
const int firstEq = value.indexOf('=');
if (firstEq <= 0) continue; // need at least name=path
InterfaceSpec spec;
spec.name = value.left(firstEq);
const int secondEq = value.indexOf('=', firstEq + 1);
if (secondEq < 0) {
spec.path = value.mid(firstEq + 1);
} else {
spec.path = value.mid(firstEq + 1, secondEq - firstEq - 1);
spec.implClass = value.mid(secondEq + 1);
}
specs.append(spec);
}
return specs;
}
// Parse an interface definition file into a ModuleDecl. `.lidl` parses
// directly; `.h`/`.hpp` go through the impl-header parser, which needs a
// metadata.json — we feed it a synthetic one carrying only the interface
// name so the consumer's identity and events are NOT pulled in (the
// interface's events come solely from the file's own `logos_events:` block).
static bool parseInterfaceFile(const InterfaceSpec& spec, const QString& genDirPath,
ModuleDecl& outMod, QTextStream& err)
{
QFileInfo fi(spec.path);
if (!fi.exists()) {
err << "Interface file not found for '" << spec.name << "': " << spec.path << "\n";
return false;
}
const QString ext = fi.suffix().toLower();
if (ext == "lidl") {
QFile f(spec.path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
err << "Failed to open interface file: " << spec.path << "\n";
return false;
}
const QString src = QString::fromUtf8(f.readAll());
f.close();
LidlParseResult pr = lidlParse(src);
if (pr.hasError()) {
err << spec.path << ":" << pr.errorLine << ":" << pr.errorColumn
<< ": " << pr.error << "\n";
return false;
}
outMod = pr.module;
return true;
}
if (ext == "h" || ext == "hpp") {
if (spec.implClass.isEmpty()) {
err << "Interface '" << spec.name << "' is a C++ header but no impl_class was given "
<< "(metadata.json interface_dependencies entry needs \"impl_class\")\n";
return false;
}
// Synthetic minimal metadata: name only, no events — keeps the
// consumer's identity/events out of the interface.
const QString synthMeta = QDir(genDirPath).filePath("." + spec.name + "_iface_meta.json");
{
QFile mf(synthMeta);
if (!mf.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
err << "Failed to write temporary interface metadata: " << synthMeta << "\n";
return false;
}
mf.write(QString("{\"name\":\"%1\"}").arg(spec.name).toUtf8());
mf.close();
}
ImplParseResult pr = parseImplHeader(spec.path, spec.implClass, synthMeta, err);
QFile::remove(synthMeta);
if (pr.hasError()) {
err << "Error parsing interface header " << spec.path << ": " << pr.error << "\n";
return false;
}
outMod = pr.module;
return true;
}
err << "Unsupported interface file type for '" << spec.name << "': " << spec.path
<< " (expected .lidl or .h)\n";
return false;
}
// Generate a wrapper (`<name>_api.{h,cpp}`) per spec from its definition file.
// The wrapper class is named from the spec `name` (PascalCase), NOT the
// definition file's internal module name, so it matches the `#include` the
// umbrella header emits. `bindMode` picks the wrapper flavour:
// Bound — interface dependency: ctor takes a runtime module name; exposed
// via a `bind_<name>(...)` factory on the umbrella.
// Static — concrete dependency: the module name is baked in; exposed as a
// `<name>` member on the umbrella (byte-identical to the wrapper the
// dep's prebuilt headers used to ship).
static bool generateInterfaceWrappers(const QVector<InterfaceSpec>& ifaces,
const QString& genDirPath, ApiStyle apiStyle,
QTextStream& out, QTextStream& err,
BindMode bindMode = BindMode::Bound)
{
for (const InterfaceSpec& spec : ifaces) {
ModuleDecl mod;
if (!parseInterfaceFile(spec, genDirPath, mod, err)) return false;
{
QString recErr;
if (!lidlCheckRecords(mod, &recErr)) {
err << spec.path << ": " << recErr << "\n";
return false;
}
}
noteOptionalPositionalSlots(mod, spec.path, err);
const QString className = toPascalCase(spec.name);
const QJsonArray methods = moduleMethodsToJson(mod);
const QJsonArray events = moduleEventsToJson(mod);
const QJsonArray records = moduleRecordsToJson(mod);
const QString headerRel = spec.name + "_api.h";
const QString sourceRel = spec.name + "_api.cpp";
const QString header = makeHeader(spec.name, className, methods, apiStyle, events, bindMode, records);
const QString source = makeSource(spec.name, className, headerRel, methods, apiStyle, events, bindMode, records);
{
QFile f(QDir(genDirPath).filePath(headerRel));
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
err << "Failed to write wrapper header: " << headerRel << "\n";
return false;
}
f.write(header.toUtf8());
}
{
QFile f(QDir(genDirPath).filePath(sourceRel));
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
err << "Failed to write wrapper source: " << sourceRel << "\n";
return false;
}
f.write(source.toUtf8());
}
out << "Generated " << (bindMode == BindMode::Bound ? "bound interface" : "dependency")
<< " wrapper: " << headerRel << " (class " << className << ", "
<< methods.size() << " methods, " << events.size() << " events)\n";
}
out.flush();
return true;
}
// The mode proper. `progName` is only used in the usage diagnostic.
static int runUmbrellaMode(const QStringList& args, const QString& progName,
QTextStream& out, QTextStream& err)
{
// Some build drivers pass paths as `@/abs/path`.
auto stripAt = [](QString p) { if (p.startsWith('@')) p.remove(0, 1); return p; };
QString outputDir;
const int outDirIdx = args.indexOf("--output-dir");
if (outDirIdx != -1 && outDirIdx + 1 < args.size()) {
outputDir = stripAt(args.at(outDirIdx + 1));
}
// `--api-style qt|lp` — the one parser, shared with legacy_main's plugin
// path (generator_lib.h, next to the ApiStyle enum).
ApiStyle apiStyle = ApiStyle::Qt;
if (!parseApiStyleFlag(args, apiStyle, err)) return 1;
const int metaIdx = args.indexOf("--metadata");
if (metaIdx == -1 || metaIdx + 1 >= args.size()) {
err << "Usage: " << progName
<< " --metadata /absolute/path/to/metadata.json --umbrella (or --general-only)"
" [--output-dir /path/to/output] [--api-style qt|lp]"
" [--interface <name>=<file.lidl|file.h>[=<ImplClass>]]"
" [--dep <name>=<file.lidl>]\n";
return 1;
}
const QString metaPathArg = stripAt(args.at(metaIdx + 1));
QFileInfo mfi(metaPathArg);
if (!mfi.exists()) {
err << "Metadata file does not exist: " << metaPathArg << "\n";
return 2;
}
QString metaResolvedPath = mfi.canonicalFilePath();
if (metaResolvedPath.isEmpty()) {
metaResolvedPath = mfi.absoluteFilePath();
}
QFile mf(metaResolvedPath);
if (!mf.open(QIODevice::ReadOnly | QIODevice::Text)) {
err << "Failed to open metadata file: " << metaResolvedPath << "\n";
return 3;
}
const QByteArray jsonData = mf.readAll();
mf.close();
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(jsonData, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
err << "Invalid metadata JSON in " << metaResolvedPath << ": " << parseError.errorString() << "\n";
return 4;
}
const QJsonObject obj = doc.object();
const QJsonArray deps = obj.value("dependencies").toArray();
// `LogosModules` exposes ONLY the modules listed in
// `metadata.json#dependencies` — apps that need to manage the core use
// liblogos' C API directly.
const QString genDirPath = outputDir.isEmpty()
? QDir::current().filePath("logos-cpp-sdk/cpp/generated")
: outputDir;
QDir().mkpath(genDirPath);
// Collect interface dependencies. Primary source: --interface flags (nix
// resolves both local `${src}/file` and remote `${input}/file` store paths
// and passes them here, so the generator never touches flake inputs).
// Fallback: self-resolve LOCAL interface_dependencies entries (those
// without an `input`) from metadata.json, relative to the metadata dir —
// covers non-nix / source-tree builds. Flags win on collision.
// Dedup --interface flags by name and drop malformed specs: a repeated
// interface name would emit duplicate #include "<name>_api.h" /
// bind_<name>(...) into logos_sdk.h and fail to compile, and an empty
// name/path can only fail later in a less actionable way.
QVector<InterfaceSpec> ifaceSpecs;
QSet<QString> haveIface;
for (const InterfaceSpec& sp : parseSpecFlags(args, "--interface")) {
if (sp.name.isEmpty() || sp.path.isEmpty()) {
err << "Ignoring malformed --interface spec (empty name or path)\n";
continue;
}
if (haveIface.contains(sp.name)) {
err << "Ignoring duplicate --interface '" << sp.name << "'\n";
continue;
}
haveIface.insert(sp.name);
ifaceSpecs.append(sp);
}
const QString metaDir = QFileInfo(metaResolvedPath).absolutePath();
const QJsonArray ifaceDeps = obj.value("interface_dependencies").toArray();
for (const QJsonValue& v : ifaceDeps) {
if (!v.isObject()) continue;
const QJsonObject eo = v.toObject();
const QString name = eo.value("name").toString();
if (name.isEmpty() || haveIface.contains(name)) continue;
// Entries with an `input` reference another repo (flake input); only
// nix can resolve those, via a --interface flag. If we reach here
// without a matching flag, skip.
if (eo.contains("input")) {
err << "Note: interface '" << name << "' has an 'input' (cross-repo) "
<< "but no --interface flag was passed; skipping (nix supplies the path).\n";
continue;
}
const QString file = eo.value("file").toString();
if (file.isEmpty()) continue;
InterfaceSpec spec;
spec.name = name;
spec.path = QDir(metaDir).filePath(file);
spec.implClass = eo.value("impl_class").toString();
ifaceSpecs.append(spec);
haveIface.insert(name);
}
// Generate one bound wrapper (<name>_api.{h,cpp}) per interface.
if (!ifaceSpecs.isEmpty()) {
if (!generateInterfaceWrappers(ifaceSpecs, genDirPath, apiStyle, out, err)) {
return 9;
}
}
// Concrete dependencies generated from their published LIDL
// (`--dep <name>=<lidl>`). Same backend as interfaces but BindMode::Static
// — the module name is baked in and the dep is exposed as a `<dep>` MEMBER
// (the umbrella already emits it from `dependencies`, so no umbrella
// change). nix passes `--dep` only for deps that publish a `lidl` output;
// deps without one fall back to the header-copy path and are NOT passed
// here. Dedup vs each other and vs interface names.
QVector<InterfaceSpec> depSpecs;
QSet<QString> haveDep;
for (const InterfaceSpec& sp : parseSpecFlags(args, "--dep")) {
if (sp.name.isEmpty() || sp.path.isEmpty()) {
err << "Ignoring malformed --dep spec (empty name or path)\n";
continue;
}
if (haveIface.contains(sp.name)) {
err << "Ignoring --dep '" << sp.name << "' (name already used by an interface)\n";
continue;
}
if (haveDep.contains(sp.name)) {
err << "Ignoring duplicate --dep '" << sp.name << "'\n";
continue;
}
haveDep.insert(sp.name);
depSpecs.append(sp);
}
if (!depSpecs.isEmpty()) {
if (!generateInterfaceWrappers(depSpecs, genDirPath, apiStyle, out, err, BindMode::Static)) {
return 9;
}
}
QStringList interfaceNames;
for (const InterfaceSpec& sp : ifaceSpecs) interfaceNames.append(sp.name);
// The umbrella itself. Emission lives in generator_lib next to the
// per-module wrapper emitters, so the aggregate can be asserted on without
// a filesystem (tests/generator/test_make_umbrella.cpp); this only writes
// what those return. For the Lp (Qt-free) flavor the umbrella bakes this
// module's name as the lp_client origin.
const QString originName = obj.value("name").toString();
const QDir genDir(genDirPath);
{
QFile outFile(genDir.filePath("logos_sdk.h"));
if (!outFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
err << "Failed to write umbrella header: " << outFile.fileName() << "\n";
return 7;
}
outFile.write(makeUmbrellaHeaderFromDeps(deps, interfaceNames, apiStyle, originName).toUtf8());
outFile.close();
}
{
QFile outFile(genDir.filePath("logos_sdk.cpp"));
if (!outFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
err << "Failed to write umbrella source: " << outFile.fileName() << "\n";
return 8;
}
outFile.write(makeUmbrellaSourceFromDeps(deps, interfaceNames).toUtf8());
outFile.close();
}
out << "Generated logos_sdk.h and logos_sdk.cpp\n";
out.flush();
return 0;
}
int main(int argc, char* argv[])
{
@@ -17,11 +394,32 @@ int main(int argc, char* argv[])
bool hasLidl = false;
bool hasFromHeader = false;
bool hasHeaderToLidl = false;
bool hasUmbrella = false;
bool hasGeneralOnly = false;
bool hasMetadata = false;
for (int i = 1; i < argc; ++i) {
QString arg = QString::fromUtf8(argv[i]);
if (arg == "--lidl") hasLidl = true;
if (arg == "--from-header") hasFromHeader = true;
if (arg == "--header-to-lidl") hasHeaderToLidl = true;
if (arg == "--umbrella") hasUmbrella = true;
if (arg == "--general-only") hasGeneralOnly = true;
if (arg == "--metadata") hasMetadata = true;
}
// Umbrella mode. `--general-only` routes here too — ONE implementation,
// no second copy in legacy/main.cpp to drift — but only in the shape
// legacy_main ever honoured it: inside the `--metadata` branch. Without
// `--metadata` the flag was never a mode at all (it fell through to the
// plugin path and reported the flag itself as a missing plugin file), so
// that case still falls through, unchanged.
if (hasUmbrella || (hasGeneralOnly && hasMetadata)) {
QCoreApplication app(argc, argv);
QTextStream err(stderr);
QTextStream out(stdout);
return runUmbrellaMode(app.arguments(),
QFileInfo(app.applicationFilePath()).fileName(),
out, err);
}
// --header-to-lidl: the C++ frontend of the source -> LIDL -> bindings