Files
logos-cpp-sdk/cpp-generator/legacy/main.cpp
T
Dario LipicarandClaude Opus 5 3d322bd315 fix(codegen): LIDL int/uint are 64-bit in the Qt spelling too (#113)
* fix(codegen): LIDL int/uint are 64-bit in the Qt spelling too

lidlTypeToQt mapped BOTH `int` and `uint` to plain `int`. Everywhere else in the
stack a LIDL int/uint is 64-bit — int64_t/uint64_t in C++ impls, i64/u64 in the
Rust SDK — so the Qt spelling broke the one-type-per-LIDL-type rule and lost
data: a Qt consumer reading a `uint` return got a SIGNED 32-bit value, so
anything above 2^31 came back wrong and anything above 2^63 was never
expressible.

int -> qlonglong, uint -> qulonglong, and returnConversion() gains the matching
accessors (toLongLong / toULongLong instead of toInt).

qlonglong/qulonglong rather than qint64/quint64 so the generated introspection
JSON uses the same names Qt's own metaobject normalisation produces — otherwise
a cdylib module's generated `signature` and a legacy module's
QMetaObject-derived one would disagree for the same LIDL type. Nothing looks
these strings up: the only QMetaType::fromName call in the stack is for
"LogosResult".

This changes two generated surfaces: the Qt consumer wrapper signatures and the
introspection JSON. Passing an int argument still converts implicitly, so
callers keep compiling; code that assigns a wrapper's return into an `int`
narrows and may warn, which is the bug being surfaced rather than a regression.

Tests: 168/168, with the type-mapping and client-emitter expectations updated to
the 64-bit spelling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(records): typed C++ structs for Qt consumers

Completes the Qt half of the type mapping this branch started. A `type Foo { … }`
in a contract now generates a real struct in the client header, so a consumer
writes `Status s = client.makeStatus();` instead of digging fields out of a
QVariantMap. One LIDL type, one type per language.

  lidlTypeToQt   - Named -> the record's struct (was QVariant)
                 - [Record] -> QList<Record>, {tstr: Record} -> QMap<QString,
                   Record>. QVariantList CANNOT hold a record without
                   Q_DECLARE_METATYPE, and a typed list is the point.
  client emitter - struct + inline ToVariant/FromVariant per record, emitted
                   before the class; conversions come after all structs so
                   records may reference each other. Recursive, so a field may
                   itself be [Status] or {tstr: bstr}.
                 - records pass by const&, decode on return, and convert at the
                   call site (sync and async)

bstr fields are QByteArray on purpose: logos-protocol's QVariant<->JSON
conversion already materialises the canonical {"_bytes": base64url} form as a
QByteArray and back, so the record conversions stay pure field mapping and binary
survives at any depth with no record-specific bytes handling.

Verified by COMPILING and RUNNING the generated code, not just asserting on text
— the string tests would not have caught either bug this found: [Record] first
mapped to QVariantList (appending a Status to it does not compile) and the decode
lambdas shadowed their accumulator. Extracted the emitted record block for a
contract with a nested record and a bytes field, compiled it against Qt6Core, and
round-tripped Batch -> QVariant -> Batch asserting items[0].port, the QByteArray
blob and the label all survive. Exit 0.

The LidlTypeToQt.NamedType expectation flips from "QVariant" to the struct name,
which is the behaviour change.

Tests: 169/169.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(codegen): teach the lp/std consumer wrappers the 64-bit spellings

Caught while checking whether the SDKs are ready for the `any` migration, and it
is a regression THIS BRANCH would otherwise have shipped.

legacy/generator_lib.cpp generates the lp/std consumer wrappers a universal
module uses to call its dependencies. It matches type names against an
allow-list and falls back to QVariant for anything else:

    static const QSet<QString> known = {
        "void","bool","int","double","float","QString", … };
    if (known.contains(base)) return base;
    return QString("QVariant");

Once lidlTypeToQt reports `qlonglong`/`qulonglong`, every LIDL int/uint method
misses that list — so a typed `int` parameter would have silently become an
opaque QVariant in those wrappers. Worse than the truncation this branch set out
to fix, and invisible until someone read the generated header.

Adds the two spellings to both allow-lists, plus the conversions they imply:
QVariant->Qt (toLongLong / toULongLong), the std spellings (int64_t / uint64_t),
the QVariant->std return path, the Qt-style return, and the default-value case.
The existing `int` entries stay for legacy Qt plugins, whose QMetaObject still
reports `int` for a 32-bit parameter.

Tests: 171/171, with the allow-list pinned in both mapping test files —
including that an unknown spelling still falls back to QVariant, so the fallback
itself is not what regressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(records): typed structs for C++ dependency wrappers

Closes the second record gap. The wrapper every C++ module actually gets for
its dependencies comes from the LEGACY generator (`--dep <name>=<lidl>`), not
the client-stub backend the previous commit taught about records — and there a
contract's `type Status { ... }` reached the consumer as an untyped bag:
QVariant on the Qt surface, LogosMap on the std/lp one. Worse than
inconvenient for a `bstr` field: the caller received the canonical
`{"_bytes": "..."}` envelope and had to know to unwrap it, while Rust and the
stub backend handed back real bytes.

Now, on all three api styles:

    struct Status { uint64_t port{}; std::vector<uint8_t> blob{}; };
    Status getStatus(logos::CallError* err = nullptr);
    std::string describeStatus(const Status& s, ...);
    std::vector<Status> listStatuses(...);

The struct is NESTED in the wrapper class (`InfoModule::Status`) because a
module consuming two deps that each declare a `Status` includes both wrappers
into one translation unit. Conversions are file-local statics in the generated
.cpp, so a Qt-free module's own TUs still never see QVariant or nlohmann.
Records reach parameters, returns, event callbacks, `[Record]` and
`{tstr: Record}` — at any depth, with bytes tagged throughout.

Same commit, the legacy path's half of the 64-bit fix: `lidlTypeExprToQtTypeName`
mapped BOTH int and uint to `int` ("wire-as-int for now"), so a `uint` method on
a dep reached a Qt consumer as a signed 32-bit value and a std/lp consumer as a
signed int64_t. Now qlonglong/qulonglong, matching the spelling the other half
of this PR gave the stub backend. `lpFromJsonExpr` grew the uint64_t branch it
needed — without it a mistyped payload THREW out of nlohmann's implicit
conversion instead of defaulting like every other scalar.

Verified by generating a contract with a record, a record-of-records, a `uint`
above 2^32 and a high-byte `bstr`, then COMPILING the output for qt/std/lp and
round-tripping the emitted conversions:
  - `{"_bytes":"gAH_"}` at every depth, decoding back to the same bytes
  - 4294967296 intact through both directions
  - garbage/missing fields default rather than throw
That compile is what caught the one real bug here: the container decode lambdas
declared `__m`/`__j`, shadowing the record decoder's own locals, so a
map-of-records field read from its own uninitialized local — it compiled with
nothing but a -Wuninitialized warning. Locals are `__acc`/`__src` now, pinned
by a test.

Also: 8 generator tests (one asserting an empty record set leaves every byte
of the output as it was), 179 total green; logos-test-modules builds and tests
green against this generator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(records): async record returns decoded a default-constructed struct

Two correctness holes in the record work, both silent, found while scoping the
cdylib provider side.

1. The async consumer overload emitted `qvariant_cast<Status>(v)` while the sync
   one emitted `StatusFromVariant(_result)`. The wire delivers a QVariantMap and
   no Q_DECLARE_METATYPE is emitted for the struct, so the cast does not fail —
   it returns a DEFAULT-CONSTRUCTED Status and the caller sees empty fields with
   no diagnostic. The sync path being correct is what makes it bad: the same
   call is right or wrong depending only on which overload the caller reached
   for. Async now decodes field by field through the same conversion.

   (The legacy dependency-wrapper generator already did this correctly — this
   was the experimental client-stub backend only.)

2. A record whose ONLY field is a tstr named `_bytes` is wire-identical to a
   canonical tagged byte string: `isTaggedBytes()` is checked before
   `is_object()` in both logos_codec.h and logos_json_convert.cpp, so such a
   record decodes as bytes and the struct silently disappears. The ambiguity is
   inherent to the tagged form — the codec's own comment says not to name a map
   key `_bytes` — but the generator can refuse to emit the one shape guaranteed
   to misdecode instead of leaving it to be found at runtime. Both front doors
   (the .lidl client-stub path and the --dep/--interface path) now reject it
   with a message naming the type and the fix.

   A second field disambiguates it (isTaggedBytes requires exactly one key), so
   that shape still generates — verified, not assumed.

181 tests, +2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cdylib): records, [bstr], typed maps and nested composites on the C++ provider

The C++ cdylib backend could express scalars, `[scalar]`, `any` and an untyped
map. Everything else it rejected BY NAME — `[bstr]`, `[[int]]`, `{tstr: int}`,
and records, which the impl-header parser could not even declare because it
skipped `struct` outright. That is why the ext contract had to be Rust-only.

Four changes, in the order they matter:

  typeSupported()  recurses instead of whitelisting element names, which admits
                   [bstr], [[int]], [Record] and [{tstr: T}] in one rule; a
                   declared record is admitted; a map now REQUIRES a tstr key
                   (it used to `return true` for any map and then silently
                   flatten {int: tstr} to an untyped LogosMap, losing the key
                   type).
  lidlTypeToStdCdylib()  became total. Its `lidlTypeToStd` fallback answers
                   QVariantList / QVariantMap — Qt names in a Qt-FREE
                   translation unit — and only failed to appear because the
                   gate rejected everything that reached it. Widening the gate
                   made that fallback a live leak, so composites now recurse and
                   never reach it.
  <name>_types.h   new: the generated codec, recursive, with a FULL
                   specialization for std::vector<uint8_t> that wins over the
                   generic vector rule — which is what keeps a bstr tagged at
                   any depth instead of becoming a plain array of numbers. One
                   Codec specialization per declared record, field by field,
                   with the field path in the error.
  impl_header_parser  learned `struct` (two passes, because a record field may
                   name another record and the type mapper only answers Named()
                   for an already-registered name — one pass silently typed
                   `Blob inner;` as `any`), std::map<std::string, T>, and
                   recursion into vector elements so std::vector<Blob> is
                   [Blob] rather than falling through to `any`.

Records are only names the contract DECLARES: `void` is not a LIDL builtin, so
`-> void` arrives as Named("void"), and treating every Named as a record is the
exact trap that made the Rust generator emit `-> Void`.

Two things the interface JSON got wrong, both found by running it:

  - it spelled a record `Blob` and a `[Record]` `QList<Blob>`. Those are the
    CONSUMER's names, correct in a generated wrapper where the struct exists —
    but this JSON is the module's getMethods(), read by the host to marshal a
    QVariant, and there is no metatype called `Blob`. The host SIGSEGV'd on the
    first call to any record method. A record IS a variant map at that boundary;
    lidlTypeToQtWire() says so.
  - the types header emitted the structs. Header-first, the author owns them and
    the contract was derived from those very declarations, so it was a
    redefinition. It emits forward declarations and the codec.

Also: `jsonReturn` is set by the front end for any map return, which no longer
implies the C++ type IS nlohmann::json now that a typed map is
std::map<std::string, T> — checking the flag before the spelling emitted
`result.dump()` on a std::map. The spelling decides.

Scalars keep their nlohmann accessor verbatim rather than routing through the
codec: `.get<int64_t>()` TRUNCATES a float instead of throwing, and the
conformance matrix pins that leniency (hostile/int/fractional expects 3 from
3.7). Changing it would silently move behaviour something depends on.

The pinned-rejection test for [bstr] is INVERTED rather than deleted — the cell
it pinned still matters, only its answer changed — plus new tests for the
non-tstr map key rejection and for declared-vs-undeclared records. 183 tests.
Every existing module still builds; test_fullapi_cpp is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(records): only structs the API mentions become contract types

Teaching the impl-header parser to read `struct` (previous commit) published
EVERY struct in a header as a contract `type`. Two production modules already
carry private helpers:

  openmetrics-module      struct ModuleSource   (namespace scope, internal)
  logos-package-manager   struct PendingAction  (PRIVATE, inside the class)

Both were being published — a module's interface changing as a side effect of
an internal refactor, which is not something deriving a contract from a header
may do. PendingAction was published WRONG as well: its fields carry trailing
`// comments`, the field regex requires a line ending in ';', and the
unmatched fields were silently dropped. A record with a partial field list is
worse than no record, because it looks like a contract.

A struct now earns its place by appearing in a method or event signature —
transitively, since a published record's own fields may name others. Verified
on the real headers: package-manager and openmetrics publish zero types again,
while the ext provider keeps both Blob and Wrapper (Wrapper is reachable only
through Blob's use in a signature). Trailing comments are stripped before the
field match, so no field is dropped.

Two tests over a fixture carrying both an internal namespace-scope struct and a
private in-class one; 185 tests. test-modules and openmetrics both rebuild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(doctests): the generator round-trip now shows 64-bit ints and typed records

The two failing assertions in cpp-sdk-generator-roundtrip were documentation
asserting the OLD behaviour, and both changes are the point of this branch:

  int record(int id, …)          ->  qlonglong record(qulonglong id, …)
  QVariant translate(QVariant p) ->  Point translate(const Point& p, …)

`record`'s `id` is a `uint64_t` in the impl header, so the old signature was
handing a caller a SIGNED 32-bit value for a `uint` — the doc showed the bug.

The surrounding prose was wrong too, not just the expectations, so both blocks
are rewritten rather than patched:

  * Flow 3 now states the mapping as int->qlonglong / uint->qulonglong and says
    why (LIDL int/uint are int64_t/uint64_t in every other binding), pointing at
    `record` as the worked example.
  * The composite section claimed "records and optionals surface as QVariant".
    Records now generate a struct, `[Point]` a QList<Point> and `{tstr: Point}`
    a QMap<QString, Point>; maps of `any`, optionals and bare `any` still cross
    untyped and stay QVariant/QVariantMap — a record has a declared shape, those
    do not. The new text draws that line explicitly.

Expectations added for `struct Point` and `Point bounds(const QList<Point>&…)`
so the record path is pinned in the doc, not just described.

Verified the way CI runs it — `--release-for logos-cpp-sdk=feat/qt-64bit-numerics`,
which is what makes `{release}` resolve to this branch instead of master:
10 passed, 0 failed. (A plain local run builds master and is not
representative — that is why it still showed the old signatures.)

outputs/ regenerated; the diff also picks up unrelated pre-existing drift where
the committed Markdown had fallen behind the spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 17:24:49 -03:00

1269 lines
53 KiB
C++

#include <QCoreApplication>
#include <QPluginLoader>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonDocument>
#include <QMetaObject>
#include <QMetaMethod>
#include <QByteArray>
#include <QTextStream>
#include <QDir>
#include <QByteArrayList>
#include <QFile>
#include <QSet>
#include <QRegularExpression>
#include <QtGlobal>
#include "logos_provider_interface.h"
#include "generator_lib.h"
#include "../experimental/lidl_compat.h"
#include "../experimental/impl_header_parser.h"
// Escape a string for safe embedding inside a generated C++ string literal.
static QString cppStringEscape(const QString& s)
{
QString out = s;
out.replace('\\', "\\\\");
out.replace('"', "\\\"");
out.replace('\n', "\\n");
return out;
}
// Convert a TypeExpr → Qt-typed string name (same surface the
// metaobject-introspection path produces for methods, so generator_lib
// can consume both via one code path).
static QString lidlTypeExprToQtTypeName(const TypeExpr& te)
{
if (te.kind == TypeExpr::Primitive) {
if (te.name == "tstr") return "QString";
if (te.name == "bstr") return "QByteArray";
// 64-bit, and unsigned stays unsigned. LIDL int/uint are int64_t /
// uint64_t in every other binding; spelling them `int` here handed a
// Qt-style consumer a signed 32-bit value (silent truncation above
// 2^31) and a std/lp consumer a SIGNED int64_t for a `uint`.
if (te.name == "int") return "qlonglong";
if (te.name == "uint") return "qulonglong";
if (te.name == "float64") return "double";
if (te.name == "bool") return "bool";
if (te.name == "result") return "LogosResult";
if (te.name == "any") return "QVariant";
return "QVariant";
}
if (te.kind == TypeExpr::Array && te.elements.size() == 1) {
const TypeExpr& elem = te.elements[0];
if (elem.kind == TypeExpr::Primitive && elem.name == "tstr")
return "QStringList";
// A list of records keeps its element type — the wrapper emits a
// typed container, not a bag of QVariants.
if (elem.kind == TypeExpr::Named)
return "QList<" + qs(elem.name) + ">";
return "QVariantList";
}
if (te.kind == TypeExpr::Map) {
if (te.elements.size() == 2 && te.elements[1].kind == TypeExpr::Named)
return "QMap<QString, " + qs(te.elements[1].name) + ">";
return "QVariantMap";
}
if (te.kind == TypeExpr::Optional) return "QVariant";
// A record declared by the contract: generator_lib emits the struct and
// types every mention of it with the struct.
if (te.kind == TypeExpr::Named) return qs(te.name);
return "QVariant";
}
static QJsonArray moduleRecordsToJson(const ModuleDecl& mod);
// Load events from a `.lidl` sidecar shipped alongside a module's
// pre-built headers. Returns a JSON array of
// { name, params: [ { name, type } ] }
// using Qt-typed type names — same shape generator_lib's makeHeader /
// makeSource already consume for methods.
static QJsonArray loadEventsFromLidl(const QString& lidlPath, QTextStream& err,
QJsonArray* outRecords = nullptr)
{
QJsonArray result;
QFile f(lidlPath);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
err << "Failed to open events sidecar: " << lidlPath << "\n";
return result;
}
QString source = QString::fromUtf8(f.readAll());
f.close();
LidlParseResult pr = lidlParse(source);
if (pr.hasError()) {
err << lidlPath << ":" << pr.errorLine << ":" << pr.errorColumn
<< ": " << pr.error << "\n";
return result;
}
if (outRecords) *outRecords = moduleRecordsToJson(pr.module);
for (const EventDecl& ed : pr.module.events) {
QJsonObject obj;
obj["name"] = qs(ed.name);
QJsonArray params;
for (const ParamDecl& pd : ed.params) {
QJsonObject p;
p["name"] = qs(pd.name);
p["type"] = lidlTypeExprToQtTypeName(pd.type);
params.append(p);
}
obj["params"] = params;
result.append(obj);
}
return result;
}
// ── Dependency interfaces ───────────────────────────────────────────────────
//
// An "interface dependency" is a method/event contract a consumer declares
// (in `metadata.json#interface_dependencies`) decoupled from any concrete
// module. The definition file is either a `.lidl` or a pure-C++ `.h` (the
// module's own language). The generator emits a BOUND wrapper class — the
// target module name is a runtime ctor argument, not baked in — so one
// interface can be bound to any module that satisfies it.
// 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;
}
// Build a getMethods()-shaped QJsonArray (the surface makeHeader/makeSource
// consume) from a parsed ModuleDecl. Every interface method is invokable.
static QJsonArray moduleMethodsToJson(const ModuleDecl& mod)
{
QJsonArray arr;
for (const MethodDecl& m : mod.methods) {
QJsonObject o;
o["name"] = qs(m.name);
o["returnType"] = lidlTypeExprToQtTypeName(m.returnType);
o["isInvokable"] = true;
QJsonArray params;
for (const ParamDecl& p : m.params) {
QJsonObject po;
po["type"] = lidlTypeExprToQtTypeName(p.type);
po["name"] = qs(p.name);
params.append(po);
}
o["parameters"] = params;
arr.append(o);
}
return arr;
}
// Build the records QJsonArray ({ name, fields:[{name,type}] }) from a parsed
// ModuleDecl — the contract's `type Foo { ... }` declarations, which
// generator_lib turns into structs nested in the wrapper class.
static QJsonArray moduleRecordsToJson(const ModuleDecl& mod)
{
QJsonArray arr;
for (const TypeDecl& td : mod.types) {
QJsonObject o;
o["name"] = qs(td.name);
QJsonArray fields;
for (const FieldDecl& fd : td.fields) {
QJsonObject f;
f["name"] = qs(fd.name);
f["type"] = lidlTypeExprToQtTypeName(fd.type);
fields.append(f);
}
o["fields"] = fields;
arr.append(o);
}
return arr;
}
// Build the events QJsonArray ({ name, params:[{name,type}] }) — same shape
// loadEventsFromLidl produces — from a parsed ModuleDecl.
static QJsonArray moduleEventsToJson(const ModuleDecl& mod)
{
QJsonArray arr;
for (const EventDecl& ed : mod.events) {
QJsonObject o;
o["name"] = qs(ed.name);
QJsonArray params;
for (const ParamDecl& pd : ed.params) {
QJsonObject p;
p["name"] = qs(pd.name);
p["type"] = lidlTypeExprToQtTypeName(pd.type);
params.append(p);
}
o["params"] = params;
arr.append(o);
}
return arr;
}
// 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;
}
}
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;
}
static QJsonArray enumerateMethods(QObject* moduleInstance)
{
QJsonArray methodsArray;
if (!moduleInstance) {
return methodsArray;
}
const QMetaObject* metaObject = moduleInstance->metaObject();
for (int i = 0; i < metaObject->methodCount(); ++i) {
QMetaMethod method = metaObject->method(i);
if (method.enclosingMetaObject() != metaObject) {
continue;
}
QJsonObject methodObj;
methodObj["signature"] = QString::fromUtf8(method.methodSignature());
methodObj["name"] = QString::fromUtf8(method.name());
methodObj["returnType"] = QString::fromUtf8(method.typeName());
bool isInvokable = method.isValid() && (method.methodType() == QMetaMethod::Method || method.methodType() == QMetaMethod::Slot);
methodObj["isInvokable"] = isInvokable;
if (method.parameterCount() > 0) {
QJsonArray params;
for (int p = 0; p < method.parameterCount(); ++p) {
QJsonObject paramObj;
paramObj["type"] = QString::fromUtf8(method.parameterTypeName(p));
QByteArrayList paramNames = method.parameterNames();
if (p < paramNames.size() && !paramNames.at(p).isEmpty()) {
paramObj["name"] = QString::fromUtf8(paramNames.at(p));
} else {
paramObj["name"] = QString("param%1").arg(p);
}
params.append(paramObj);
}
methodObj["parameters"] = params;
}
methodsArray.append(methodObj);
}
return methodsArray;
}
// toPascalCase, normalizeType, mapParamType, mapReturnType -> generator_lib.h/cpp
// makeHeader -> generator_lib.h/cpp
// makeSource -> generator_lib.h/cpp
static bool writeUmbrellaHeader(const QString& genDirPath, QTextStream& err)
{
// Generate logos_sdk.h: include every per-module wrapper header in
// the gen dir and aggregate them into a flat `LogosModules` struct.
// The wrappers may be Qt-typed or Std-typed depending on the
// --api-style picked for this build; the umbrella shape doesn't
// change because either flavor produces the same accessor name
// (`<dep>`) on the same class name (`<Dep>`).
//
// `core_manager_api.h` (if present in the gen dir from an older
// run) is intentionally filtered out — universal modules access
// only the deps they explicitly declared in `metadata.json#
// dependencies`. Apps that need to manage the core use the C API
// in liblogos directly, not the typed `LogosModules` aggregate.
QDir genDir(genDirPath);
QStringList headers = genDir.entryList(QStringList() << "*_api.h", QDir::Files | QDir::Readable);
headers.removeAll(QStringLiteral("core_manager_api.h"));
QString content;
QTextStream s(&content);
s << "#pragma once\n";
s << "#include \"logos_api.h\"\n";
s << "#include \"logos_api_client.h\"\n\n";
for (const QString& h : headers) s << "#include \"" << h << "\"\n";
s << "\n";
s << "struct LogosModules {\n";
s << " explicit LogosModules(LogosAPI* api) : api(api)";
for (const QString& h : headers) {
QString base = h;
base.chop(QString("_api.h").size());
s << ", \n " << base << "(api)";
}
s << " {}\n";
s << " LogosAPI* api;\n";
for (const QString& h : headers) {
QString base = h;
base.chop(QString("_api.h").size());
QString className = toPascalCase(base);
s << " " << className << " " << base << ";\n";
}
s << "};\n";
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 false;
}
outFile.write(content.toUtf8());
outFile.close();
return true;
}
static bool writeUmbrellaHeaderFromDeps(const QString& genDirPath, const QJsonArray& deps, const QStringList& interfaceNames, QTextStream& err, ApiStyle apiStyle = ApiStyle::Qt, const QString& originName = QString())
{
// Lp (Qt-free) umbrella: no LogosAPI. Each dep wrapper self-creates its
// lp_client on behalf of `originName` (this module), so the struct is
// default-constructible and the glue just does `new LogosModules()`.
if (apiStyle == ApiStyle::Lp) {
QDir genDir(genDirPath);
QString content;
QTextStream s(&content);
s << "#pragma once\n";
s << "#include <string>\n";
if (!interfaceNames.isEmpty()) {
s << "#include <map>\n";
s << "#include <memory>\n";
}
for (const QJsonValue& v : deps) {
if (!v.isString()) continue;
s << "#include \"" << v.toString() << "_api.h\"\n";
}
for (const QString& ifaceName : interfaceNames)
s << "#include \"" << ifaceName << "_api.h\"\n";
s << "\n";
s << "struct LogosModules {\n";
s << " LogosModules()";
bool first = true;
for (const QJsonValue& v : deps) {
if (!v.isString()) continue;
s << (first ? " : " : ",\n ");
first = false;
s << v.toString() << "(\"" << originName << "\")";
}
s << " {}\n";
for (const QJsonValue& v : deps) {
if (!v.isString()) continue;
const QString depName = v.toString();
s << " " << toPascalCase(depName) << " " << depName << ";\n";
}
// Interface dependencies: bound at runtime. The bound wrapper is a
// THIN handle over per-provider State the umbrella OWNS for the
// module's lifetime — so a transient `modules().bind_x(p)` temporary
// can register an async callback / event subscription that outlives
// it (the LpClient + RAII subscriptions persist in the map). Keyed by
// provider so repeated binds to the same provider share one client.
for (const QString& ifaceName : interfaceNames) {
const QString className = toPascalCase(ifaceName);
s << " " << className << " bind_" << ifaceName << "(const std::string& moduleName) {\n";
s << " auto& _st = m_" << ifaceName << "_bound[moduleName];\n";
s << " if (!_st) _st = std::make_unique<" << className << "::State>(moduleName, \"" << originName << "\");\n";
s << " return " << className << "(_st.get());\n";
s << " }\n";
}
for (const QString& ifaceName : interfaceNames) {
const QString className = toPascalCase(ifaceName);
s << " std::map<std::string, std::unique_ptr<" << className << "::State>> m_"
<< ifaceName << "_bound;\n";
}
s << "};\n";
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 false;
}
outFile.write(content.toUtf8());
outFile.close();
return true;
}
// Generate logos_sdk.h from metadata.json's dependencies list. The
// shape doesn't depend on apiStyle — each dep emits a single
// `<name>_api.h` whose class signature shape was already decided
// at codegen time. The umbrella just `#include`s and aggregates
// each wrapper into the flat `LogosModules` struct.
//
// Only the modules explicitly listed in `metadata.json#
// dependencies` are exposed. Apps that need to manage the core
// (basecamp, logoscore) use liblogos' C API directly rather than
// the typed `LogosModules` aggregate.
//
// Interface dependencies (`metadata.json#interface_dependencies`) are
// NOT fixed members — they bind to a runtime-chosen module — so each
// gets a `bind_<name>(moduleName)` factory instead, returning a bound
// wrapper by value.
QDir genDir(genDirPath);
QString content;
QTextStream s(&content);
s << "#pragma once\n";
// <string> is only needed for the std::string bind_<iface> overloads;
// omit it when there are no interfaces so the umbrella stays identical
// to its historical form for dependency-only modules.
if (!interfaceNames.isEmpty()) s << "#include <string>\n";
s << "#include \"logos_api.h\"\n";
s << "#include \"logos_api_client.h\"\n\n";
for (const QJsonValue& v : deps) {
if (!v.isString()) continue;
QString depName = v.toString();
s << "#include \"" << depName << "_api.h\"\n";
}
for (const QString& ifaceName : interfaceNames) {
s << "#include \"" << ifaceName << "_api.h\"\n";
}
s << "\n";
s << "struct LogosModules {\n";
s << " explicit LogosModules(LogosAPI* api) : api(api)";
for (const QJsonValue& v : deps) {
if (!v.isString()) continue;
QString depName = v.toString();
s << ", \n " << depName << "(api)";
}
s << " {}\n";
s << " LogosAPI* api;\n";
for (const QJsonValue& v : deps) {
if (!v.isString()) continue;
QString depName = v.toString();
QString className = toPascalCase(depName);
s << " " << className << " " << depName << ";\n";
}
// Bind factories — one per interface dependency. Two overloads so
// both Qt-typed (QString) and std-typed (std::string) call sites can
// pass the runtime module name without converting at the call site.
for (const QString& ifaceName : interfaceNames) {
const QString className = toPascalCase(ifaceName);
s << " " << className << " bind_" << ifaceName << "(const QString& moduleName) {\n";
s << " return " << className << "(api, moduleName);\n";
s << " }\n";
s << " " << className << " bind_" << ifaceName << "(const std::string& moduleName) {\n";
s << " return " << className << "(api, QString::fromStdString(moduleName));\n";
s << " }\n";
}
s << "};\n";
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 false;
}
outFile.write(content.toUtf8());
outFile.close();
return true;
}
static bool writeUmbrellaSource(const QString& genDirPath, QTextStream& err)
{
// Generate logos_sdk.cpp: one #include per per-module wrapper
// `.cpp` in the gen dir. There's now exactly one wrapper file per
// module (Qt or std, picked at generation time), so no de-dup or
// twin-file filtering is needed.
//
// `core_manager_api.cpp` (if present from an older run) is
// filtered out — the umbrella header no longer declares
// `CoreManager core_manager;` so including its definitions would
// produce dead code.
QDir genDir(genDirPath);
QStringList sources = genDir.entryList(QStringList() << "*_api.cpp", QDir::Files | QDir::Readable);
sources.removeAll(QStringLiteral("core_manager_api.cpp"));
QString content;
QTextStream s(&content);
s << "#include \"logos_sdk.h\"\n\n";
for (const QString& c : sources) s << "#include \"" << c << "\"\n";
s << "\n";
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 false;
}
outFile.write(content.toUtf8());
outFile.close();
return true;
}
static bool writeUmbrellaSourceFromDeps(const QString& genDirPath, const QJsonArray& deps, const QStringList& interfaceNames, QTextStream& err)
{
// Generate logos_sdk.cpp from metadata.json's dependencies list.
// Each dep emits one wrapper `.cpp` (Qt or std — decided at codegen
// time, file name is the same either way), `#include`'d here.
// Interface wrappers (`<name>_api.cpp`) are #include'd the same way.
QDir genDir(genDirPath);
QString content;
QTextStream s(&content);
s << "#include \"logos_sdk.h\"\n\n";
for (const QJsonValue& v : deps) {
if (!v.isString()) continue;
QString depName = v.toString();
s << "#include \"" << depName << "_api.cpp\"\n";
}
for (const QString& ifaceName : interfaceNames) {
s << "#include \"" << ifaceName << "_api.cpp\"\n";
}
s << "\n";
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 false;
}
outFile.write(content.toUtf8());
outFile.close();
return true;
}
// ── Provider-header mode: scan LOGOS_METHOD markers and generate dispatch ────
// ParsedMethod, parseProviderHeader, toQVariantConversion -> generator_lib.h/cpp
static int generateProviderDispatch(const QString& headerPath, const QString& outputDir, QTextStream& out, QTextStream& err)
{
QFileInfo fi(headerPath);
if (!fi.exists()) {
err << "Header file does not exist: " << headerPath << "\n";
return 2;
}
QVector<ParsedMethod> methods = parseProviderHeader(headerPath, err);
if (methods.isEmpty()) {
err << "No LOGOS_METHOD markers found in: " << headerPath << "\n";
return 3;
}
// Derive the class name from the header: parse for ": public LogosProviderBase"
QString className;
{
QFile f(headerPath);
f.open(QIODevice::ReadOnly | QIODevice::Text);
QTextStream ts(&f);
QRegularExpression classRe(R"(class\s+(\w+)\s*:\s*public\s+LogosProviderBase)");
while (!ts.atEnd()) {
QString line = ts.readLine();
auto m = classRe.match(line);
if (m.hasMatch()) {
className = m.captured(1);
break;
}
}
f.close();
}
if (className.isEmpty()) {
err << "Could not find class inheriting LogosProviderBase in: " << headerPath << "\n";
return 4;
}
QString headerBaseName = fi.fileName();
QString genDirPath = outputDir.isEmpty() ? fi.absolutePath() : outputDir;
QDir().mkpath(genDirPath);
// Generate logos_provider_dispatch.cpp
QString content;
QTextStream s(&content);
s << "// AUTO-GENERATED by logos-cpp-generator -- do not edit\n";
s << "#include \"" << headerBaseName << "\"\n";
s << "#include <QJsonArray>\n";
s << "#include <QJsonObject>\n";
s << "#include <QVariant>\n";
s << "#include <QString>\n";
s << "#include \"logos_types.h\"\n";
s << "#include <exception>\n\n";
// callMethod() — group by name to support overloaded methods
QMap<QString, QVector<const ParsedMethod*>> methodsByName;
for (const ParsedMethod& m : methods) {
methodsByName[m.name].append(&m);
}
// The dispatch body is wrapped in a catch-all: any exception the author's
// code lets escape becomes an ordinary method failure (invalid QVariant)
// instead of unwinding through Qt event dispatch and killing the module
// process.
s << "QVariant " << className << "::callMethod(const QString& methodName, const QVariantList& args)\n";
s << "{\n";
s << " try {\n";
for (auto it = methodsByName.constBegin(); it != methodsByName.constEnd(); ++it) {
const QString& name = it.key();
const QVector<const ParsedMethod*>& overloads = it.value();
s << " if (methodName == \"" << name << "\") {\n";
bool needArgsSizeCheck = overloads.size() > 1;
for (const ParsedMethod* m : overloads) {
if (needArgsSizeCheck) {
s << " if (args.size() == " << m->params.size() << ") {\n";
s << " ";
}
if (m->returnType == "void" || m->returnType.isEmpty()) {
s << " " << m->name << "(";
for (int i = 0; i < m->params.size(); ++i) {
s << toQVariantConversion(m->params[i].first, QString("args.at(%1)").arg(i));
if (i + 1 < m->params.size()) s << ", ";
}
s << ");\n";
if (needArgsSizeCheck) s << " ";
s << " return QVariant(true);\n";
} else {
s << " return QVariant::fromValue(" << m->name << "(";
for (int i = 0; i < m->params.size(); ++i) {
s << toQVariantConversion(m->params[i].first, QString("args.at(%1)").arg(i));
if (i + 1 < m->params.size()) s << ", ";
}
s << "));\n";
}
if (needArgsSizeCheck) {
s << " }\n";
}
}
s << " }\n";
}
s << " } catch (const std::exception& e) {\n";
s << " qWarning() << \"" << className
<< "::callMethod:\" << methodName << \"failed:\" << e.what();\n";
s << " return QVariant();\n";
s << " }\n";
s << " qWarning() << \"" << className << "::callMethod: unknown method:\" << methodName;\n";
s << " return QVariant();\n";
s << "}\n\n";
// getMethods()
s << "QJsonArray " << className << "::getMethods()\n";
s << "{\n";
s << " QJsonArray methods;\n";
for (const ParsedMethod& m : methods) {
s << " {\n";
s << " QJsonObject obj;\n";
s << " obj[\"name\"] = QStringLiteral(\"" << m.name << "\");\n";
s << " obj[\"returnType\"] = QStringLiteral(\"" << m.returnType << "\");\n";
s << " obj[\"isInvokable\"] = true;\n";
if (!m.description.isEmpty()) {
s << " obj[\"description\"] = QStringLiteral(\"" << cppStringEscape(m.description) << "\");\n";
}
QString sig = m.name + "(";
for (int i = 0; i < m.params.size(); ++i) {
sig += m.params[i].first;
if (i + 1 < m.params.size()) sig += ",";
}
sig += ")";
s << " obj[\"signature\"] = QStringLiteral(\"" << sig << "\");\n";
if (!m.params.isEmpty()) {
s << " QJsonArray params;\n";
for (int i = 0; i < m.params.size(); ++i) {
s << " params.append(QJsonObject{{\"type\", QStringLiteral(\"" << m.params[i].first << "\")}, {\"name\", QStringLiteral(\"" << m.params[i].second << "\")}});\n";
}
s << " obj[\"parameters\"] = params;\n";
}
s << " methods.append(obj);\n";
s << " }\n";
}
s << " return methods;\n";
s << "}\n";
QString outputPath = QDir(genDirPath).filePath("logos_provider_dispatch.cpp");
QFile outFile(outputPath);
if (!outFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
err << "Failed to write dispatch file: " << outputPath << "\n";
return 5;
}
outFile.write(content.toUtf8());
outFile.close();
out << "Generated provider dispatch: " << outputPath << " (" << methods.size() << " methods from " << className << ")\n";
out.flush();
return 0;
}
static int generateFromPlugin(const QString& pluginInputPath, const QString& outputDir, bool moduleOnly, ApiStyle apiStyle, const QJsonArray& events, QTextStream& out, QTextStream& err, const QJsonArray& records = {})
{
QFileInfo fi(pluginInputPath);
if (!fi.exists()) {
err << "Plugin file does not exist: " << pluginInputPath << "\n";
return 2;
}
QString resolvedPath = fi.canonicalFilePath();
if (resolvedPath.isEmpty()) {
resolvedPath = fi.absoluteFilePath();
}
QString genDirPath = outputDir.isEmpty() ? QDir::current().filePath("logos-cpp-sdk/cpp/generated") : outputDir;
QDir().mkpath(genDirPath);
QPluginLoader loader(resolvedPath);
if (!loader.load()) {
err << "Failed to load plugin at " << resolvedPath << ": " << loader.errorString() << "\n";
return 3;
}
QObject* instance = loader.instance();
if (!instance) {
err << "Plugin loaded but no instance could be created for " << resolvedPath << "\n";
loader.unload();
return 4;
}
QString moduleName;
{
QJsonObject md = loader.metaData();
QJsonObject meta = md.value("MetaData").toObject();
moduleName = meta.value("name").toString();
if (moduleName.isEmpty()) {
moduleName = QFileInfo(resolvedPath).baseName();
}
}
QJsonArray methods;
LogosProviderPlugin* providerPlugin = qobject_cast<LogosProviderPlugin*>(instance);
if (providerPlugin) {
LogosProviderObject* provider = providerPlugin->createProviderObject();
if (provider) {
methods = provider->getMethods();
out << "Detected new-API plugin (LogosProviderPlugin), using getMethods() — "
<< methods.size() << " methods\n";
delete provider;
} else {
err << "LogosProviderPlugin::createProviderObject() returned null\n";
}
} else {
methods = enumerateMethods(instance);
}
QString className = toPascalCase(moduleName);
QString headerRel = QString("%1_api.h").arg(moduleName);
QString sourceRel = QString("%1_api.cpp").arg(moduleName);
QString headerAbs = QDir(genDirPath).filePath(headerRel);
QString sourceAbs = QDir(genDirPath).filePath(sourceRel);
// Single per-module wrapper file pair. apiStyle decides the
// signature shape: Qt-typed for legacy / handcrafted callers
// (default), std-typed when the consuming module's build passed
// --api-style=std (typically because it's `interface: "universal"`).
// Both produce the same filename and class name, so the umbrella
// doesn't need to know which style was picked. `events` (loaded
// from a sibling `.lidl` sidecar via --events-from) adds typed
// `on<EventName>(callback)` accessors next to the existing methods.
QString header = makeHeader(moduleName, className, methods, apiStyle, events, BindMode::Static, records);
QString source = makeSource(moduleName, className, headerRel, methods, apiStyle, events, BindMode::Static, records);
{
QFile f(headerAbs);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
err << "Failed to write header: " << headerAbs << "\n";
loader.unload();
return 5;
}
f.write(header.toUtf8());
f.close();
}
{
QFile f(sourceAbs);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
err << "Failed to write source: " << sourceAbs << "\n";
loader.unload();
return 6;
}
f.write(source.toUtf8());
f.close();
}
if (!moduleOnly) {
if (!writeUmbrellaHeader(genDirPath, err)) {
loader.unload();
return 7;
}
if (!writeUmbrellaSource(genDirPath, err)) {
loader.unload();
return 8;
}
}
QJsonDocument doc(methods);
// out << doc.toJson(QJsonDocument::Indented) << "\n";
out << "Generated: " << QDir(genDirPath).filePath(headerRel) << " and " << QDir(genDirPath).filePath(sourceRel) << "\n";
out.flush();
loader.unload();
return 0;
}
int legacy_main(int argc, char* argv[])
{
QCoreApplication app(argc, argv);
QTextStream err(stderr);
QTextStream out(stdout);
const QStringList args = app.arguments();
// Parse --output-dir option
QString outputDir;
const int outDirIdx = args.indexOf("--output-dir");
if (outDirIdx != -1 && outDirIdx + 1 < args.size()) {
outputDir = args.at(outDirIdx + 1);
if (outputDir.startsWith('@')) {
outputDir.remove(0, 1);
}
}
// Parse --module-only option
bool moduleOnly = args.contains("--module-only");
// Parse --general-only option
bool generalOnly = args.contains("--general-only");
// Parse --api-style option (qt | std). Picks which type surface
// the generated `<Module>` wrapper exposes. Default is qt for
// backward compatibility — every existing module that doesn't
// declare `interface: "universal"` in its metadata.json keeps
// its Qt-typed LogosModules surface. Universal modules get
// -DLOGOS_API_STYLE=std threaded through by mkLogosModule.nix /
// LogosModule.cmake, which becomes `--api-style=std` here.
// Both forms accepted: `--api-style std` and `--api-style=std`.
ApiStyle apiStyle = ApiStyle::Qt;
{
QString apiVal;
for (int i = 0; i < args.size(); ++i) {
const QString& a = args.at(i);
if (a == "--api-style") {
if (i + 1 < args.size()) apiVal = args.at(i + 1);
break;
}
if (a.startsWith("--api-style=")) {
apiVal = a.section('=', 1);
break;
}
}
if (apiVal == "std") apiStyle = ApiStyle::Std;
else if (apiVal == "lp") apiStyle = ApiStyle::Lp;
else if (!apiVal.isEmpty() && apiVal != "qt") {
err << "Unknown --api-style value: " << apiVal
<< " (expected 'qt', 'std', or 'lp')\n";
return 1;
}
}
// Support: extract dependencies from a metadata.json file
{
const int metaIdx = args.indexOf("--metadata");
if (metaIdx != -1) {
if (metaIdx + 1 >= args.size()) {
err << "Usage: " << QFileInfo(app.applicationFilePath()).fileName() << " --metadata /absolute/path/to/metadata.json [--output-dir /path/to/output] [--module-only] [--general-only]\n";
return 1;
}
QString metaPathArg = args.at(metaIdx + 1);
if (metaPathArg.startsWith('@')) {
metaPathArg.remove(0, 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();
// If --general-only provided, generate only the umbrella files.
// `LogosModules` exposes ONLY the modules listed in
// `metadata.json#dependencies` — apps that need to manage the
// core use liblogos' C API directly.
if (generalOnly) {
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);
// Generate umbrella headers based on dependencies + interfaces.
// 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();
if (!writeUmbrellaHeaderFromDeps(genDirPath, deps, interfaceNames, err, apiStyle, originName)) {
return 7;
}
if (!writeUmbrellaSourceFromDeps(genDirPath, deps, interfaceNames, err)) {
return 8;
}
out << "Generated logos_sdk.h and logos_sdk.cpp\n";
out.flush();
return 0;
}
// If --module-dir provided, generate for each dependency; else print deps
const int modDirIdx = args.indexOf("--module-dir");
if (modDirIdx != -1) {
if (modDirIdx + 1 >= args.size()) {
err << "Usage: " << QFileInfo(app.applicationFilePath()).fileName() << " --metadata /path/to/metadata.json --module-dir /path/to/modules_dir [--output-dir /path/to/output] [--module-only] [--general-only]\n";
return 1;
}
QString moduleDirArg = args.at(modDirIdx + 1);
if (moduleDirArg.startsWith('@')) {
moduleDirArg.remove(0, 1);
}
QDir moduleDir(moduleDirArg);
if (!moduleDir.exists()) {
err << "Module directory does not exist: " << moduleDirArg << "\n";
return 2;
}
QString genDirPath = outputDir.isEmpty() ? QDir::current().filePath("logos-cpp-sdk/cpp/generated") : outputDir;
QDir().mkpath(genDirPath);
QString suffix;
#if defined(Q_OS_MACOS)
suffix = ".dylib";
#elif defined(Q_OS_LINUX)
suffix = ".so";
#elif defined(Q_OS_WIN)
suffix = ".dll";
#else
suffix = "";
#endif
int overallStatus = 0;
for (const QJsonValue& v : deps) {
if (!v.isString()) continue;
const QString depName = v.toString();
const QString pluginFileName = depName + "_plugin" + suffix;
const QString pluginPath = moduleDir.filePath(pluginFileName);
if (!QFileInfo::exists(pluginPath)) {
err << "Skipping: plugin not found for dependency '" << depName << "' at " << pluginPath << "\n";
continue;
}
out << "Running generator for dependency plugin: " << pluginPath << "\n";
// No --events-from sidecar in the multi-dep iteration
// path (each dep would need its own sidecar — out of
// scope here; --events-from is consumed by the
// per-plugin path below, invoked from buildHeaders.nix).
const int st = generateFromPlugin(pluginPath, outputDir, moduleOnly, apiStyle, QJsonArray(), out, err);
if (st != 0) {
overallStatus = st; // remember last non-zero
}
}
if (overallStatus == 0 && !moduleOnly) {
if (!writeUmbrellaHeader(genDirPath, err)) {
overallStatus = 7;
} else if (!writeUmbrellaSource(genDirPath, err)) {
overallStatus = 8;
}
}
return overallStatus;
} else {
for (const QJsonValue& v : deps) {
if (v.isString()) {
out << v.toString() << "\n";
}
}
out.flush();
return 0;
}
}
}
// --provider-header mode: scan LOGOS_METHOD markers and generate dispatch code
{
const int phIdx = args.indexOf("--provider-header");
if (phIdx != -1) {
if (phIdx + 1 >= args.size()) {
err << "Usage: " << QFileInfo(app.applicationFilePath()).fileName() << " --provider-header /path/to/impl.h [--output-dir /path/to/output]\n";
return 1;
}
QString headerArg = args.at(phIdx + 1);
if (headerArg.startsWith('@')) headerArg.remove(0, 1);
return generateProviderDispatch(headerArg, outputDir, out, err);
}
}
if (args.size() < 2) {
err << "Usage: " << QFileInfo(app.applicationFilePath()).fileName() << " /absolute/path/to/plugin [--output-dir /path/to/output] [--module-only] [--events-from /path/to/<name>.lidl]\n";
err << " or: " << QFileInfo(app.applicationFilePath()).fileName() << " --metadata /absolute/path/to/metadata.json [--output-dir /path/to/output] [--module-only] [--general-only]\n";
err << " or: " << QFileInfo(app.applicationFilePath()).fileName() << " --metadata /absolute/path/to/metadata.json --general-only [--output-dir /path/to/output]\n";
err << " or: " << QFileInfo(app.applicationFilePath()).fileName() << " --provider-header /path/to/impl.h [--output-dir /path/to/output]\n";
return 1;
}
// --events-from <path>: load typed event prototypes from a LIDL
// sidecar shipped alongside a dep's pre-built headers. When set,
// the consumer wrapper (<name>_api.{h,cpp}) gains typed
// `on<EventName>(callback)` accessors next to the existing
// generic `onEvent(name, callback)` channel.
QJsonArray eventsFromSidecar;
QJsonArray recordsFromSidecar;
{
const int evIdx = args.indexOf("--events-from");
QString evPath;
if (evIdx != -1 && evIdx + 1 < args.size()) {
evPath = args.at(evIdx + 1);
} else {
for (const QString& a : args) {
if (a.startsWith("--events-from=")) {
evPath = a.section('=', 1);
break;
}
}
}
if (!evPath.isEmpty() && QFileInfo(evPath).exists()) {
eventsFromSidecar = loadEventsFromLidl(evPath, err, &recordsFromSidecar);
}
}
QString argPath = args.at(1);
return generateFromPlugin(argPath, outputDir, moduleOnly, apiStyle, eventsFromSidecar, out, err, recordsFromSidecar);
}