Files
logos-cpp-sdk/cpp-generator/experimental/lidl_gen_cdylib.cpp
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

861 lines
42 KiB
C++

#include "lidl_gen_cdylib.h"
#include "lidl_emit_common.h"
#include <QTextStream>
#include <set>
#include <string>
QString lidlToPascalCase(const QString& name);
QString lidlTypeToQt(const TypeExpr& te);
bool lidlIsStdConvertible(const TypeExpr& te);
namespace {
// The cdylib-supported subset: std-convertible LIDL types only — the same
// Qt-free set the std apiStyle handled, so any universal module that built
// under std also builds as a header-first cdylib.
// The records a contract DECLARES. A `Named` type is a record only if it is in
// here: `void` is not a LIDL builtin, so `-> void` arrives as Named("void") and
// treating every Named as a record is how the Rust generator once emitted
// `-> Void`. Same trap, same guard.
std::set<std::string> recordNames(const ModuleDecl& module)
{
std::set<std::string> out;
for (const TypeDecl& t : module.types) out.insert(t.name);
return out;
}
bool isRecord(const TypeExpr& te, const std::set<std::string>& recs)
{
return te.kind == TypeExpr::Named && recs.count(te.name) > 0;
}
bool typeSupported(const TypeExpr& te, bool isReturn, const std::set<std::string>& recs)
{
if (te.kind == TypeExpr::Primitive) {
if (te.name == "tstr" || te.name == "bstr" || te.name == "int"
|| te.name == "uint" || te.name == "float64" || te.name == "bool")
return true;
// any (LogosMap/LogosList/json) routes through nlohmann in either
// direction; result (StdLogosResult) and void only make sense as a
// return. All Qt-free.
if (te.name == "any")
return true;
if (isReturn && (te.name == "result" || te.name == "void"))
return true;
return false;
}
// A declared record is a generated struct with a generated codec.
if (isRecord(te, recs))
return true;
// Recurse rather than whitelisting element names: that admits [bstr],
// [[int]], [Record] and [{tstr: T}] in one rule, and keeps the gate and
// the spelling function agreeing about what is expressible.
if (te.kind == TypeExpr::Array && te.elements.size() == 1)
return typeSupported(te.elements[0], false, recs);
// Only tstr keys: the generated codec spells a map as
// std::map<std::string, T>, so a non-tstr key has no C++ spelling. This
// used to `return true` for ANY map, which admitted `{int: tstr}` and then
// silently produced a LogosMap that lost the key type.
if (te.kind == TypeExpr::Map) {
if (te.elements.size() != 2) return false;
const TypeExpr& k = te.elements[0];
if (!(k.kind == TypeExpr::Primitive && k.name == "tstr")) return false;
return typeSupported(te.elements[1], false, recs);
}
return false;
}
// Qt-free spelling of a LIDL type (defined below). Forward-declared so the
// method-param decoder can spell composite `any` containers as their nlohmann
// aliases instead of Qt containers in this Qt-free TU.
QString lidlTypeToStdCdylib(const TypeExpr& te, const std::set<std::string>& recs);
// json arg expression -> std-typed C++ expression
// A method argument, decoded into the author's C++ type.
//
// Everything that is not a plain scalar goes through the generated codec, which
// recurses — so a bstr keeps its canonical tag at ANY depth and a record
// decodes field by field with a path in the error. The scalars keep their
// nlohmann accessor verbatim: `.get<int64_t>()` TRUNCATES a float rather than
// throwing, and that leniency is pinned by the conformance matrix
// (`hostile/int/fractional` expects 3 from 3.7 on this provider). Routing them
// through the codec would silently change behaviour that something depends on.
QString jsonArgToStd(const TypeExpr& te, const QString& expr, const QString& path,
const std::set<std::string>& recs)
{
if (te.kind == TypeExpr::Primitive) {
if (te.name == "tstr") return expr + ".get<std::string>()";
if (te.name == "bstr") return "lidlBytesFromJson(" + expr + ")";
if (te.name == "int") return expr + ".get<int64_t>()";
if (te.name == "uint") return expr + ".get<uint64_t>()";
if (te.name == "float64") return expr + ".get<double>()";
if (te.name == "bool") return expr + ".get<bool>()";
if (te.name == "any") return expr;
}
const QString cpp = lidlTypeToStdCdylib(te, recs);
if (cpp == "LogosMap" || cpp == "LogosList")
return expr; // untyped JSON passes through, as it always has
return "logos_gen::Codec<" + cpp + ">::from(" + expr + ", \"" + path + "\")";
}
// std-typed return variable -> json expression
QString stdReturnToJson(const MethodDecl& md, const QString& var,
const std::set<std::string>& recs)
{
const TypeExpr& te = md.returnType;
if (md.resultReturn) {
// StdLogosResult -> the canonical {success, value, error} object
// (same shape logos_json_convert emits for Qt LogosResult).
return "lidlResultToJson(" + var + ")";
}
// `jsonReturn` is set by the front end for any map/list return, but that no
// longer implies the C++ type IS nlohmann::json: a TYPED map now spells
// std::map<std::string, T>. Checking the flag before the spelling emitted
// `result.dump()` on a std::map. The spelling decides.
const QString cppRet = lidlTypeToStdCdylib(te, recs);
if (md.jsonReturn && (cppRet == "LogosMap" || cppRet == "LogosList")) {
return var; // LogosMap / LogosList are nlohmann::json already
}
if (te.kind == TypeExpr::Primitive) {
if (te.name == "bstr") return "lidlBytesToJson(" + var + ")";
if (te.name == "any") return var;
return "nlohmann::json(" + var + ")";
}
if (cppRet == "LogosMap" || cppRet == "LogosList")
return var;
// `nlohmann::json(v)` would serialize a vector<uint8_t> as a plain number
// array and a record not at all; the codec keeps bytes tagged at depth.
return "logos_gen::Codec<" + cppRet + ">::to(" + var + ")";
}
// Qt-free spelling of a LIDL type. lidlTypeToStd() falls back to Qt containers
// (QVariant / QVariantMap / QVariantList) for the composite types, but a cdylib
// TU is Qt-free by definition and typeSupported() admits `any` and maps — so
// spell those as their nlohmann aliases (LogosMap / LogosList) instead. Without
// this the events sidecar emits a bare `QVariant` parameter and does not
// compile.
QString lidlTypeToStdCdylib(const TypeExpr& te, const std::set<std::string>& recs)
{
if (te.kind == TypeExpr::Primitive && te.name == "any")
return "LogosMap";
// `{tstr: any}` and `[any]` keep their nlohmann aliases: every existing
// universal module spells them that way, and narrowing them would be a
// source break for no gain (they ARE untyped JSON).
if (te.kind == TypeExpr::Map && te.elements.size() == 2
&& te.elements[1].kind == TypeExpr::Primitive && te.elements[1].name == "any")
return "LogosMap";
if (te.kind == TypeExpr::Array && te.elements.size() == 1
&& te.elements[0].kind == TypeExpr::Primitive
&& te.elements[0].name == "any")
return "LogosList";
// A declared record is its generated struct.
if (isRecord(te, recs))
return qs(te.name);
// Recurse, so [bstr] is std::vector<std::vector<uint8_t>> and {tstr: Blob}
// is std::map<std::string, Blob>. lidlTypeToStd() would answer QVariantList
// / QVariantMap here — a Qt name in a Qt-FREE translation unit, which only
// failed to appear because the gate used to reject these types. Widening
// the gate makes that fallback a live leak, so composites must never reach
// it.
if (te.kind == TypeExpr::Array && te.elements.size() == 1)
return "std::vector<" + lidlTypeToStdCdylib(te.elements[0], recs) + ">";
if (te.kind == TypeExpr::Map && te.elements.size() == 2)
return "std::map<std::string, " + lidlTypeToStdCdylib(te.elements[1], recs) + ">";
return lidlTypeToStd(te);
}
// True when the module declares at least one `bstr` event parameter — the only
// reason the events sidecar needs the bytes encoder. Emitting it unconditionally
// leaves an unused static function (a -Wunused-function warning) in every module
// whose events carry no binary data.
// ── The generated codec ─────────────────────────────────────────────────────
//
// Emitted into the module's types header so the author's impl class and the
// generated dispatch share one definition of how a value crosses the wire.
//
// This is deliberately the same SHAPE as logos-protocol's logos_codec.h — and
// it exists as generated code only because that header cannot currently be
// included here: logos_json.h (which every universal module pulls in for
// LogosMap) and logos_codec.h both define logos::b64UrlEncode /
// b64UrlDecode / bytesToJson as inline, so including both in one translation
// unit is a redefinition error. Unify when that is resolved; the emitted
// specializations would then be the only generated part.
//
// The primary template is intentionally left UNDEFINED: an unsupported T is a
// compile error naming the type, never a silent default-constructed value.
void emitGeneratedCodec(QTextStream& s, const ModuleDecl& module,
const std::set<std::string>& recs)
{
s << "namespace logos_gen {\n\n";
s << "// Codec<T>::to / ::from — the one place a value's wire form is decided.\n";
s << "// The primary template is undefined on purpose: an unsupported T is a\n";
s << "// compile error naming the type, not a silent default.\n";
s << "template <class T> struct Codec;\n\n";
s << "[[noreturn]] inline void lidlTypeError(const char* want, const std::string& path,\n";
s << " const nlohmann::json& got)\n{\n";
s << " throw std::runtime_error(std::string(\"expected \") + want + \" at \" + path\n";
s << " + \", got \" + std::string(got.type_name()));\n}\n\n";
// Scalars. Their leniency matches what the dispatch did before the codec
// existed, so behaviour for already-working modules is unchanged.
struct Scalar { const char* cpp; const char* want; const char* check; const char* get; };
const Scalar scalars[] = {
{"std::string", "string", "is_string()", "get<std::string>()"},
{"int64_t", "integer", "is_number()", "get<int64_t>()"},
{"uint64_t", "integer", "is_number()", "get<uint64_t>()"},
{"double", "number", "is_number()", "get<double>()"},
{"bool", "boolean", "is_boolean()", "get<bool>()"},
};
for (const Scalar& sc : scalars) {
s << "template <> struct Codec<" << sc.cpp << "> {\n";
s << " static nlohmann::json to(const " << sc.cpp << "& v) { return nlohmann::json(v); }\n";
s << " static " << sc.cpp << " from(const nlohmann::json& j, const std::string& path) {\n";
s << " if (!j." << sc.check << ") lidlTypeError(\"" << sc.want << "\", path, j);\n";
s << " return j." << sc.get << ";\n }\n};\n\n";
}
// bstr. The FULL specialization wins over the generic vector rule below,
// which is what keeps bytes tagged at every depth instead of being
// serialized as a plain array of numbers.
s << "template <> struct Codec<std::vector<uint8_t>> {\n";
s << " static nlohmann::json to(const std::vector<uint8_t>& v) { return logos::bytesToJson(v); }\n";
s << " static std::vector<uint8_t> from(const nlohmann::json& j, const std::string& path) {\n";
s << " if (j.is_object() && j.size() == 1 && j.contains(\"_bytes\")\n";
s << " && j.at(\"_bytes\").is_string())\n";
s << " return logos::jsonToBytes(j);\n";
s << " lidlTypeError(\"bytes\", path, j);\n }\n};\n\n";
// Untyped JSON passes through unchanged — `any`, and the LogosMap/LogosList
// aliases, are all nlohmann::json.
s << "template <> struct Codec<nlohmann::json> {\n";
s << " static nlohmann::json to(const nlohmann::json& v) { return v; }\n";
s << " static nlohmann::json from(const nlohmann::json& j, const std::string&) { return j; }\n";
s << "};\n\n";
s << "template <class T> struct Codec<std::vector<T>> {\n";
s << " static nlohmann::json to(const std::vector<T>& v) {\n";
s << " nlohmann::json out = nlohmann::json::array();\n";
s << " for (const T& e : v) out.push_back(Codec<T>::to(e));\n";
s << " return out;\n }\n";
s << " static std::vector<T> from(const nlohmann::json& j, const std::string& path) {\n";
s << " if (!j.is_array()) lidlTypeError(\"array\", path, j);\n";
s << " std::vector<T> out;\n out.reserve(j.size());\n";
s << " for (size_t i = 0; i < j.size(); ++i)\n";
s << " out.push_back(Codec<T>::from(j.at(i), path + \"[\" + std::to_string(i) + \"]\"));\n";
s << " return out;\n }\n};\n\n";
s << "template <class T> struct Codec<std::map<std::string, T>> {\n";
s << " static nlohmann::json to(const std::map<std::string, T>& v) {\n";
s << " nlohmann::json out = nlohmann::json::object();\n";
s << " for (const auto& kv : v) out[kv.first] = Codec<T>::to(kv.second);\n";
s << " return out;\n }\n";
s << " static std::map<std::string, T> from(const nlohmann::json& j, const std::string& path) {\n";
s << " if (!j.is_object()) lidlTypeError(\"object\", path, j);\n";
s << " std::map<std::string, T> out;\n";
s << " for (auto it = j.begin(); it != j.end(); ++it)\n";
s << " out.emplace(it.key(), Codec<T>::from(it.value(), path + \".\" + it.key()));\n";
s << " return out;\n }\n};\n\n";
// One specialization per declared record. Field order follows the contract.
for (const TypeDecl& t : module.types) {
const QString name = qs(t.name);
s << "template <> struct Codec<" << name << "> {\n";
s << " static nlohmann::json to(const " << name << "& v) {\n";
s << " nlohmann::json out = nlohmann::json::object();\n";
for (const FieldDecl& f : t.fields) {
const QString ft = lidlTypeToStdCdylib(f.type, recs);
s << " out[\"" << qs(f.name) << "\"] = Codec<" << ft << ">::to(v."
<< qs(f.name) << ");\n";
}
s << " return out;\n }\n";
s << " static " << name << " from(const nlohmann::json& j, const std::string& path) {\n";
s << " if (!j.is_object()) lidlTypeError(\"object\", path, j);\n";
s << " " << name << " out;\n";
for (const FieldDecl& f : t.fields) {
const QString ft = lidlTypeToStdCdylib(f.type, recs);
const QString fn = qs(f.name);
// A missing field is reported at its own path rather than
// default-constructed: a record that silently loses a field is the
// failure mode this whole layer exists to prevent.
s << " out." << fn << " = Codec<" << ft << ">::from(\n";
s << " j.contains(\"" << fn << "\") ? j.at(\"" << fn
<< "\") : nlohmann::json(),\n";
s << " path + \"." << fn << "\");\n";
}
s << " return out;\n }\n};\n\n";
}
s << "} // namespace logos_gen\n\n";
}
bool hasBytesEventParam(const ModuleDecl& module)
{
for (const EventDecl& ed : module.events)
for (const ParamDecl& pd : ed.params)
if (pd.type.kind == TypeExpr::Primitive && pd.type.name == "bstr")
return true;
return false;
}
// The Qt spelling of what actually crosses the Qt boundary.
//
// NOT lidlTypeToQt: that answers the CONSUMER's question ("what type does the
// caller hold?") and since records became real structs it answers `Blob` /
// `QList<Blob>`. Those names are correct in a generated consumer wrapper, where
// the struct exists — but this JSON is the module's getMethods(), read by the
// host to marshal a QVariant across the plugin boundary, and there is no
// metatype called `Blob`. Emitting it made the host SIGSEGV on the first call
// to any record method.
//
// A record IS a variant map at that boundary; the struct only exists inside the
// cdylib.
QString lidlTypeToQtWire(const TypeExpr& te, const std::set<std::string>& recs)
{
if (isRecord(te, recs))
return "QVariantMap";
if (te.kind == TypeExpr::Array && te.elements.size() == 1
&& isRecord(te.elements[0], recs))
return "QVariantList";
if (te.kind == TypeExpr::Map && te.elements.size() == 2
&& isRecord(te.elements[1], recs))
return "QVariantMap";
return lidlTypeToQt(te);
}
// True when any event parameter is spelled LogosMap / LogosList, so the sidecar
// needs <logos_json.h> for those aliases.
bool hasJsonEventParam(const ModuleDecl& module)
{
const std::set<std::string> recs = recordNames(module);
for (const EventDecl& ed : module.events)
for (const ParamDecl& pd : ed.params) {
const QString t = lidlTypeToStdCdylib(pd.type, recs);
if (t == "LogosMap" || t == "LogosList")
return true;
}
return false;
}
// The SCALAR tagged-bytes helpers. A `[bstr]` (and bytes at any deeper
// nesting) rides logos_gen::Codec instead: its full specialization for
// std::vector<uint8_t> beats the generic vector rule, so one mechanism covers
// [bstr], [[bstr]] and {tstr: [bstr]} alike. #111 emitted a dedicated depth-1
// list codec here; the generic one subsumes it, and keeping both left an
// unused static in every module that mentioned [bstr].
void emitBytesEncodeHelpers(QTextStream& s)
{
s << "// Canonical tagged bytes form {\"_bytes\": base64url} (see logos_protocol.h)\n";
s << "std::string lidlB64UrlEncode(const std::vector<uint8_t>& bytes)\n{\n";
s << " static const char* alpha = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_\";\n";
s << " std::string out;\n";
s << " size_t i = 0;\n";
s << " while (i + 3 <= bytes.size()) {\n";
s << " uint32_t n = (uint32_t(bytes[i]) << 16) | (uint32_t(bytes[i+1]) << 8) | uint32_t(bytes[i+2]);\n";
s << " out += alpha[(n >> 18) & 0x3f]; out += alpha[(n >> 12) & 0x3f];\n";
s << " out += alpha[(n >> 6) & 0x3f]; out += alpha[n & 0x3f];\n";
s << " i += 3;\n }\n";
s << " if (i < bytes.size()) {\n";
s << " uint32_t n = uint32_t(bytes[i]) << 16;\n";
s << " if (i + 1 < bytes.size()) n |= uint32_t(bytes[i+1]) << 8;\n";
s << " out += alpha[(n >> 18) & 0x3f]; out += alpha[(n >> 12) & 0x3f];\n";
s << " if (i + 1 < bytes.size()) out += alpha[(n >> 6) & 0x3f];\n";
s << " }\n return out;\n}\n\n";
s << "nlohmann::json lidlBytesToJson(const std::vector<uint8_t>& bytes)\n{\n";
s << " return nlohmann::json{{\"_bytes\", lidlB64UrlEncode(bytes)}};\n}\n\n";
}
void emitInterfaceJson(QTextStream& s, const ModuleDecl& module)
{
const std::set<std::string> recs = recordNames(module);
s << "static nlohmann::json lidlInterfaceJson()\n{\n";
s << " nlohmann::json methods = nlohmann::json::array();\n";
for (const MethodDecl& md : module.methods) {
s << " {\n nlohmann::json obj;\n";
s << " obj[\"name\"] = \"" << md.name << "\";\n";
if (!md.description.empty()) {
QString esc = qs(md.description);
esc.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
s << " obj[\"description\"] = \"" << esc << "\";\n";
}
QString sig = qs(md.name) + "(";
for (int i = 0; i < md.params.size(); ++i) {
sig += lidlTypeToQtWire(md.params[i].type, recs);
if (i + 1 < md.params.size()) sig += ",";
}
sig += ")";
s << " obj[\"signature\"] = \"" << sig << "\";\n";
s << " obj[\"returnType\"] = \"" << lidlTypeToQtWire(md.returnType, recs) << "\";\n";
s << " obj[\"isInvokable\"] = true;\n";
if (!md.params.empty()) {
s << " nlohmann::json params = nlohmann::json::array();\n";
for (const ParamDecl& pd : md.params) {
s << " params.push_back({{\"type\", \"" << lidlTypeToQtWire(pd.type, recs)
<< "\"}, {\"name\", \"" << pd.name << "\"}});\n";
}
s << " obj[\"parameters\"] = params;\n";
}
s << " methods.push_back(obj);\n }\n";
}
for (const EventDecl& ed : module.events) {
s << " {\n nlohmann::json obj;\n";
s << " obj[\"type\"] = \"event\";\n";
s << " obj[\"name\"] = \"" << ed.name << "\";\n";
if (!ed.description.empty()) {
QString esc = qs(ed.description);
esc.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
s << " obj[\"description\"] = \"" << esc << "\";\n";
}
QString sig = qs(ed.name) + "(";
for (int i = 0; i < ed.params.size(); ++i) {
sig += lidlTypeToQtWire(ed.params[i].type, recs);
if (i + 1 < ed.params.size()) sig += ",";
}
sig += ")";
s << " obj[\"signature\"] = \"" << sig << "\";\n";
if (!ed.params.empty()) {
s << " nlohmann::json params = nlohmann::json::array();\n";
for (const ParamDecl& pd : ed.params) {
s << " params.push_back({{\"type\", \"" << lidlTypeToQtWire(pd.type, recs)
<< "\"}, {\"name\", \"" << pd.name << "\"}});\n";
}
s << " obj[\"parameters\"] = params;\n";
}
s << " methods.push_back(obj);\n }\n";
}
s << " return methods;\n}\n\n";
}
} // namespace
bool lidlCdylibSupported(const ModuleDecl& module, QString* error)
{
const std::set<std::string> recs = recordNames(module);
for (const MethodDecl& md : module.methods) {
for (const ParamDecl& pd : md.params) {
if (!typeSupported(pd.type, /*isReturn=*/false, recs)) {
if (error)
*error = QString("method '%1': parameter '%2' has a type outside the "
"cdylib-supported (Qt-free) subset")
.arg(qs(md.name), qs(pd.name));
return false;
}
}
// `void` is not a lidlBuiltinType, so the .lidl parser yields it as a
// Named type "void" (the impl-header parser writes "-> void"); an empty
// name is the in-memory void from the header path. Treat both as void.
const bool voidReturn =
md.returnType.name == "void"
|| (md.returnType.kind == TypeExpr::Primitive && md.returnType.name.empty());
if (!voidReturn && !md.jsonReturn && !md.resultReturn
&& !typeSupported(md.returnType, /*isReturn=*/true, recs)) {
if (error)
*error = QString("method '%1': return type outside the cdylib-supported "
"(Qt-free) subset").arg(qs(md.name));
return false;
}
}
for (const EventDecl& ed : module.events) {
for (const ParamDecl& pd : ed.params) {
if (!typeSupported(pd.type, /*isReturn=*/false, recs)) {
if (error)
*error = QString("event '%1': parameter '%2' has a type outside the "
"cdylib-supported (Qt-free) subset")
.arg(qs(ed.name), qs(pd.name));
return false;
}
}
}
return true;
}
QString lidlMakeTypesHeaderCdylib(const ModuleDecl& module)
{
const std::set<std::string> recs = recordNames(module);
QString c;
QTextStream s(&c);
s << "// AUTO-GENERATED by logos-cpp-generator --backend cdylib -- do not edit\n";
s << "//\n";
s << "// The record types `" << module.name << "` declares, plus the codec that moves\n";
s << "// them across the wire. Qt-FREE. The author's impl header includes this and\n";
s << "// writes the structs directly:\n";
s << "//\n";
s << "// Blob echoBlob(const Blob& v);\n";
s << "//\n";
s << "// rather than picking fields out of a LogosMap.\n";
s << "#pragma once\n";
s << "#include <logos_json.h>\n";
s << "#include <cstdint>\n";
s << "#include <map>\n";
s << "#include <stdexcept>\n";
s << "#include <string>\n";
s << "#include <vector>\n\n";
// The structs themselves are the AUTHOR's: this file is included after the
// impl header, and the contract was derived from those very declarations,
// so emitting them again is a redefinition error. Only forward
// declarations, so the codec below can name them in any order.
if (!module.types.empty()) {
for (const TypeDecl& t : module.types)
s << "struct " << qs(t.name) << ";\n";
s << "\n";
}
emitGeneratedCodec(s, module, recs);
return c;
}
QString lidlMakeModuleImplExports(const ModuleDecl& module,
const QString& implClass,
const QString& implHeader)
{
const std::set<std::string> recs = recordNames(module);
QString c;
QTextStream s(&c);
s << "// AUTO-GENERATED by logos-cpp-generator --cdylib -- do not edit\n";
s << "//\n";
s << "// The common module-impl C ABI exports (logos_module_impl.h) around the\n";
s << "// universal impl class `" << implClass << "`. Qt-FREE: compiled into the\n";
s << "// module's cdylib; the uniform Qt-plugin glue (or a future no-Qt host)\n";
s << "// drives it exclusively through these symbols.\n";
s << "#include \"" << implHeader << "\"\n";
s << "#include \"" << module.name << "_types.h\"\n";
s << "#include \"logos_module_impl.h\"\n";
s << "#include \"logos_protocol.h\"\n";
s << "#include \"logos_module_context.h\"\n";
s << "#include \"logos_result.h\"\n";
s << "#include <nlohmann/json.hpp>\n";
s << "#include <cstdlib>\n";
s << "#include <cstring>\n";
s << "#include <atomic>\n";
s << "#include <map>\n";
s << "#include <mutex>\n";
s << "#include <string>\n";
s << "#include <vector>\n";
// The Qt-free typed dependency surface: LogosModules (behind modules())
// built from this module's dependencies (metadata.json#dependencies),
// calling the lp_* C ABI — no Qt in the cdylib. The umbrella codegen
// emits logos_sdk.h for every cdylib module (empty when there are no
// dependencies), so this include is always available.
s << "#include \"logos_sdk.h\"\n";
s << "\n";
// -- shared statics ------------------------------------------------------
s << "namespace {\n\n";
s << implClass << "& lidlImpl()\n{\n static " << implClass << " impl;\n return impl;\n}\n\n";
s << "logos_module_emit_cb g_emitCb = nullptr;\n";
s << "void* g_emitUd = nullptr;\n";
s << "std::mutex g_emitMutex;\n";
s << "std::mutex g_ctxMutex;\n";
s << "bool g_ctxStored = false;\n";
s << "std::string g_ctxPath, g_ctxId, g_ctxPersist;\n";
s << "std::atomic<bool> g_hookFired{false};\n\n";
s << "char* lidlStrdup(const std::string& str)\n{\n";
s << " char* out = static_cast<char*>(std::malloc(str.size() + 1));\n";
s << " if (out) std::memcpy(out, str.data(), str.size() + 1);\n";
s << " return out;\n}\n\n";
emitBytesEncodeHelpers(s);
s << "int lidlB64Idx(char ch)\n{\n";
s << " if (ch >= 'A' && ch <= 'Z') return ch - 'A';\n";
s << " if (ch >= 'a' && ch <= 'z') return ch - 'a' + 26;\n";
s << " if (ch >= '0' && ch <= '9') return ch - '0' + 52;\n";
s << " if (ch == '-') return 62;\n if (ch == '_') return 63;\n return -1;\n}\n\n";
s << "std::vector<uint8_t> lidlBytesFromJson(const nlohmann::json& j)\n{\n";
s << " std::vector<uint8_t> out;\n";
s << " // Lenient bytes decode (matches the std path, where a QString or\n";
s << " // QByteArray arg both became bytes): a caller may send the tagged\n";
s << " // {\"_bytes\": base64url} form, a plain string (raw UTF-8 bytes), or\n";
s << " // an array of byte values. Only the tagged form needs base64.\n";
s << " if (j.is_string()) {\n";
s << " const std::string s = j.get<std::string>();\n";
s << " out.assign(s.begin(), s.end());\n";
s << " return out;\n";
s << " }\n";
s << " if (j.is_number()) {\n";
s << " // A number arg becomes its decimal text as bytes — matches\n";
s << " // Qt's QVariant(int)->QByteArray, so a caller (or the\n";
s << " // logoscore CLI's type auto-detection) passing a bare number\n";
s << " // to a bytes param behaves the same as the Qt path.\n";
s << " const std::string s = j.dump();\n";
s << " out.assign(s.begin(), s.end());\n";
s << " return out;\n";
s << " }\n";
s << " if (j.is_array()) {\n";
s << " for (const auto& e : j)\n";
s << " if (e.is_number_integer() || e.is_number_unsigned())\n";
s << " out.push_back(static_cast<uint8_t>(e.get<int64_t>() & 0xff));\n";
s << " return out;\n";
s << " }\n";
s << " if (!j.is_object() || j.size() != 1 || !j.contains(\"_bytes\") || !j[\"_bytes\"].is_string())\n";
s << " return out;\n";
s << " const std::string s64 = j[\"_bytes\"].get<std::string>();\n";
s << " size_t i = 0;\n";
s << " while (i + 4 <= s64.size()) {\n";
s << " int a = lidlB64Idx(s64[i]), b = lidlB64Idx(s64[i+1]), c2 = lidlB64Idx(s64[i+2]), d = lidlB64Idx(s64[i+3]);\n";
s << " if (a < 0 || b < 0 || c2 < 0 || d < 0) return {};\n";
s << " uint32_t n = (uint32_t(a) << 18) | (uint32_t(b) << 12) | (uint32_t(c2) << 6) | uint32_t(d);\n";
s << " out.push_back((n >> 16) & 0xff); out.push_back((n >> 8) & 0xff); out.push_back(n & 0xff);\n";
s << " i += 4;\n }\n";
s << " size_t rem = s64.size() - i;\n";
s << " if (rem == 2 || rem == 3) {\n";
s << " int a = lidlB64Idx(s64[i]), b = lidlB64Idx(s64[i+1]);\n";
s << " if (a < 0 || b < 0) return {};\n";
s << " uint32_t n = (uint32_t(a) << 18) | (uint32_t(b) << 12);\n";
s << " out.push_back((n >> 16) & 0xff);\n";
s << " if (rem == 3) {\n";
s << " int c2 = lidlB64Idx(s64[i+2]);\n";
s << " if (c2 < 0) return {};\n";
s << " n |= uint32_t(c2) << 6;\n";
s << " out.push_back((n >> 8) & 0xff);\n";
s << " }\n }\n return out;\n}\n\n";
s << "nlohmann::json lidlResultToJson(const StdLogosResult& r)\n{\n";
s << " nlohmann::json obj;\n";
s << " obj[\"success\"] = r.success;\n";
s << " obj[\"value\"] = r.value;\n";
s << " obj[\"error\"] = r.error.empty() ? nlohmann::json() : nlohmann::json(r.error);\n";
s << " return obj;\n}\n\n";
emitInterfaceJson(s, module);
s << "} // namespace\n\n";
// -- event wiring (install once, lazily) ---------------------------------
s << "static void lidlEnsureEmitWiring()\n{\n";
s << " static std::once_flag once;\n";
s << " std::call_once(once, []() {\n";
s << " _logos_codegen_::maybeSetEmitEvent(lidlImpl(),\n";
s << " [](const std::string& name, void* args) {\n";
s << " // cdylib events sidecar marshals into nlohmann::json\n";
s << " const nlohmann::json* payload = static_cast<const nlohmann::json*>(args);\n";
s << " std::lock_guard<std::mutex> lock(g_emitMutex);\n";
s << " if (g_emitCb) {\n";
s << " const std::string dumped = payload ? payload->dump() : \"[]\";\n";
s << " g_emitCb(name.c_str(), dumped.c_str(), g_emitUd);\n";
s << " }\n";
s << " });\n";
s << " });\n}\n\n";
// -- typed dependency surface (modules().<dep>...) -----------------------
// Wire modules() INDEPENDENTLY of the persistence context. Each dependency
// client bakes its target+origin at codegen time and creates its lp client
// lazily on first call, so modules() needs nothing from the context. A
// module with deps but no STORED context still must have it wired — gating
// it on the context latch (as it used to be) left m_logosModulesPtr null and
// segfaulted the first cross-module call when the daemon never delivered a
// context. No-op for impls that don't derive LogosModuleContext. Fired once
// from the FIRST lidlTryFireContext (i.e. the first dispatch / set_context /
// set_emit_callback), before the context-gated early return below.
s << "static void lidlEnsureModulesWired()\n{\n";
s << " static std::once_flag once;\n";
s << " std::call_once(once, []() {\n";
s << " _logos_codegen_::maybeSetLogosModules(lidlImpl(), new LogosModules());\n";
s << " });\n}\n\n";
// The context ready-latch: stamp the context + fire onContextReady ONCE,
// as soon as the module is fully wired (context stored AND the emit
// callback delivered) — at module load, before publication. Hosts that
// never wire an emit callback still get the hook before first dispatch
// (requireEmit = false fallback).
s << "static void lidlTryFireContext(bool requireEmit)\n{\n";
s << " lidlEnsureEmitWiring();\n";
s << " lidlEnsureModulesWired();\n";
s << " if (g_hookFired.load(std::memory_order_acquire)) return;\n";
s << " std::string path, id, persist;\n";
s << " {\n";
s << " std::lock_guard<std::mutex> lock(g_ctxMutex);\n";
s << " if (!g_ctxStored) return;\n";
s << " path = g_ctxPath; id = g_ctxId; persist = g_ctxPersist;\n";
s << " }\n";
s << " if (requireEmit) {\n";
s << " std::lock_guard<std::mutex> lock(g_emitMutex);\n";
s << " if (!g_emitCb) return;\n";
s << " }\n";
s << " g_hookFired.store(true, std::memory_order_release);\n";
// modules() was already wired by lidlEnsureModulesWired() above (before this
// context-gated early return), so onContextReady can safely call
// modules().<dep>... / subscribe to dependency events from the hook.
s << " _logos_codegen_::maybeSetContext(lidlImpl(), path, id, persist);\n";
s << "}\n\n";
// -- exports -------------------------------------------------------------
s << "extern \"C\" {\n\n";
s << "char* logos_module_dispatch(const char* method, const char* args_json)\n{\n";
s << " if (!method) return nullptr;\n";
s << " lidlTryFireContext(false);\n";
s << " nlohmann::json args = nlohmann::json::array();\n";
s << " if (args_json && *args_json) {\n";
s << " args = nlohmann::json::parse(args_json, nullptr, false);\n";
s << " if (args.is_discarded() || !args.is_array()) return nullptr;\n";
s << " }\n";
s << " const std::string m(method);\n";
s << " try {\n";
for (const MethodDecl& md : module.methods) {
s << " if (m == \"" << md.name << "\") {\n";
s << " if (args.size() < " << md.params.size() << ") return nullptr;\n";
QString call = "lidlImpl()." + qs(md.name) + "(";
for (int i = 0; i < md.params.size(); ++i) {
call += jsonArgToStd(md.params[i].type,
QString("args.at(%1)").arg(i),
QString("arg%1").arg(i), recs);
if (i + 1 < md.params.size()) call += ", ";
}
call += ")";
// `void` parses as a Named type "void" from a .lidl (it isn't a
// lidlBuiltinType); empty name is the header path's in-memory void.
const bool voidReturn =
md.returnType.name == "void"
|| (md.returnType.kind == TypeExpr::Primitive && md.returnType.name.empty())
|| lidlTypeToQt(md.returnType) == "void";
if (voidReturn) {
s << " " << call << ";\n";
s << " return lidlStrdup(\"true\");\n";
} else {
s << " auto result = " << call << ";\n";
s << " return lidlStrdup(" << stdReturnToJson(md, "result", recs) << ".dump());\n";
}
s << " }\n";
}
s << " } catch (const std::exception& e) {\n";
s << " nlohmann::json err{{\"code\", \"dispatch_failed\"}, {\"message\", e.what()},\n";
s << " {\"origin\", \"" << module.name << "\"}};\n";
s << " return lidlStrdup(err.dump());\n";
s << " }\n";
s << " return nullptr; // unknown method\n";
s << "}\n\n";
s << "char* logos_module_get_methods(void)\n{\n";
s << " return lidlStrdup(lidlInterfaceJson().dump());\n}\n\n";
s << "void logos_module_set_context(const char* module_path,\n";
s << " const char* instance_id,\n";
s << " const char* instance_persistence_path)\n{\n";
s << " {\n";
s << " std::lock_guard<std::mutex> lock(g_ctxMutex);\n";
s << " g_ctxPath = module_path ? module_path : \"\";\n";
s << " g_ctxId = instance_id ? instance_id : \"\";\n";
s << " g_ctxPersist = instance_persistence_path ? instance_persistence_path : \"\";\n";
s << " g_ctxStored = true;\n";
s << " }\n";
s << " lidlTryFireContext(true);\n";
s << "}\n\n";
s << "void logos_module_set_emit_callback(logos_module_emit_cb cb, void* user_data)\n{\n";
s << " {\n";
s << " std::lock_guard<std::mutex> lock(g_emitMutex);\n";
s << " g_emitCb = cb;\n";
s << " g_emitUd = user_data;\n";
s << " }\n";
s << " lidlTryFireContext(true);\n";
s << "}\n\n";
s << "int logos_module_accept_token(const char* module_name, const char* token)\n{\n";
s << " if (!module_name || !token) return -1;\n";
s << " // Seed the protocol's shared TokenManager so this module's OUTBOUND\n";
s << " // lp_client (modules().<dep>...) can authenticate calls. In\n";
s << " // particular the capability_module bootstrap token the host\n";
s << " // delivers at load lets the automatic requestModule flow fetch a\n";
s << " // per-target token on the first cross-module call. lp_token_save\n";
s << " // writes the same TokenManager::instance() the lp_client reads.\n";
s << " return lp_token_save(module_name, token);\n}\n\n";
s << "const char* logos_module_get_protocol_version(void)\n{\n";
s << " return LOGOS_PROTOCOL_VERSION_STRING;\n}\n\n";
s << "void logos_module_string_free(char* str)\n{\n";
s << " std::free(str);\n}\n\n";
s << "} // extern \"C\"\n";
return c;
}
QString lidlMakeEventsSourceCdylib(const ModuleDecl& module,
const QString& implClass,
const QString& implHeader)
{
QString c;
QTextStream s(&c);
s << "// AUTO-GENERATED by logos-cpp-generator --cdylib -- do not edit\n";
s << "// Typed `logos_events:` bodies, cdylib flavor: marshal into\n";
s << "// nlohmann::json and route through LogosModuleContext::emitEventImpl_\n";
s << "// (the export wrapper forwards to the host's emit callback).\n";
const std::set<std::string> recsEv = recordNames(module);
s << "#include \"" << implHeader << "\"\n";
s << "#include \"" << module.name << "_types.h\"\n";
s << "#include <nlohmann/json.hpp>\n\n";
s << "#include <cstdint>\n";
s << "#include <map>\n";
s << "#include <string>\n";
s << "#include <vector>\n";
// LogosMap / LogosList (nlohmann aliases) appear in the emitted signatures
// whenever an event carries a map or an `any` payload.
if (hasJsonEventParam(module))
s << "#include <logos_json.h>\n";
s << "\n";
// Only the modules that actually emit binary event payloads need the bytes
// encoder; emitting it everywhere would leave it unused (and warned about).
if (hasBytesEventParam(module)) {
s << "namespace {\n\n";
emitBytesEncodeHelpers(s);
s << "} // namespace\n\n";
}
for (const EventDecl& ed : module.events) {
s << "void " << implClass << "::" << ed.name << "(";
for (int i = 0; i < ed.params.size(); ++i) {
const QString stdType = lidlTypeToStdCdylib(ed.params[i].type, recsEv);
// Must match the author's declaration in the `logos_events:` block:
// the non-scalar types are conventionally taken by const-ref there.
// Records and std::map belong in that set too — they are structs and
// containers, and emitting them BY VALUE makes the generated
// definition not match the author's declaration, which is a compile
// error naming a parameter type mismatch rather than anything
// helpful.
if (stdType == "std::string" || stdType.startsWith("std::vector")
|| stdType.startsWith("std::map")
|| isRecord(ed.params[i].type, recsEv)
|| stdType == "LogosMap" || stdType == "LogosList")
s << "const " << stdType << "& " << ed.params[i].name;
else
s << stdType << " " << ed.params[i].name;
if (i + 1 < ed.params.size()) s << ", ";
}
s << ")\n{\n";
s << " nlohmann::json args = nlohmann::json::array();\n";
for (const ParamDecl& pd : ed.params) {
const QString evStd = lidlTypeToStdCdylib(pd.type, recsEv);
// A record or a composite carrying bytes rides the generated codec,
// exactly like a method return — otherwise an event payload would be
// the one place a bstr silently loses its tag.
if (evStd != "LogosMap" && evStd != "LogosList"
&& (isRecord(pd.type, recsEv)
|| pd.type.kind == TypeExpr::Array || pd.type.kind == TypeExpr::Map)) {
s << " args.push_back(logos_gen::Codec<" << evStd << ">::to("
<< pd.name << "));\n";
continue;
}
if (pd.type.kind == TypeExpr::Primitive && pd.type.name == "bstr")
s << " args.push_back(lidlBytesToJson(" << pd.name << "));\n";
else
s << " args.push_back(" << pd.name << ");\n";
}
s << " emitEventImpl_(\"" << ed.name << "\", &args);\n";
s << "}\n\n";
}
return c;
}