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>
This commit is contained in:
Dario Gabriel Lipicar
2026-07-27 16:42:43 -03:00
co-authored by Claude Opus 5
parent 32ab1c6504
commit c598f569fd
5 changed files with 658 additions and 73 deletions
+373 -57
View File
@@ -192,10 +192,171 @@ static QString qVariantToStdReturn(const QString& qtType, const QString& varExpr
return varExpr + ".toString().toStdString()";
}
// ─── Records ─────────────────────────────────────────────────────────────
//
// A contract's `type Status { port: uint }` is a REAL C++ struct on the
// consumer side, not a QVariant / LogosMap the caller picks apart by string
// key. Without this a `bstr` field is the worst case: the caller receives the
// canonical `{"_bytes": "..."}` envelope and has to know to unwrap it, while
// every other language's consumer hands back plain bytes.
//
// main.cpp passes the declarations alongside the methods:
// [ { "name": "Status", "fields": [ { "name": "port", "type": "qulonglong" } ] } ]
// spelled with the same Qt type names methods use, so a field can name another
// record ("Status"), a list of them ("QList<Status>") or a map of them
// ("QMap<QString, Status>"). The struct is nested in the wrapper class —
// `InfoModule::Status` — because one module consuming two deps that each
// declare `Status` includes both wrappers into the same translation unit.
//
// An empty record set leaves every emission path byte-for-byte as it was.
struct RecordField { QString name; QString type; };
struct RecordDef { QString name; QVector<RecordField> fields; };
using RecordSet = QVector<RecordDef>;
// Forward declarations: the Lp (Qt-free) conversion helpers live further down
// with the rest of the Lp backend, but the record helpers below dispatch to
// them for non-record field types.
static QString lpPushExpr(const QString& qtType, const QString& argName);
static QString lpFromJsonExpr(const QString& qtType, const QString& jv);
static RecordSet parseRecords(const QJsonArray& records)
{
RecordSet out;
for (const QJsonValue& rv : records) {
const QJsonObject ro = rv.toObject();
RecordDef def;
def.name = ro.value("name").toString();
if (def.name.isEmpty()) continue;
for (const QJsonValue& fv : ro.value("fields").toArray()) {
const QJsonObject fo = fv.toObject();
RecordField f;
f.name = fo.value("name").toString();
f.type = fo.value("type").toString();
if (f.name.isEmpty()) continue;
def.fields.append(f);
}
out.append(def);
}
return out;
}
static bool isRecordName(const RecordSet& rs, const QString& name)
{
for (const RecordDef& d : rs) if (d.name == name) return true;
return false;
}
// How a type name mentions a record, if at all.
enum class RecordShape { None, Scalar, List, Map };
static RecordShape recordShape(const RecordSet& rs, const QString& t, QString* elem)
{
if (rs.isEmpty()) return RecordShape::None;
if (isRecordName(rs, t)) { if (elem) *elem = t; return RecordShape::Scalar; }
if (t.startsWith("QList<") && t.endsWith(">")) {
const QString e = t.mid(6, t.size() - 7).trimmed();
if (isRecordName(rs, e)) { if (elem) *elem = e; return RecordShape::List; }
}
if (t.startsWith("QMap<QString,") && t.endsWith(">")) {
const QString e = t.mid(13, t.size() - 14).trimmed();
if (isRecordName(rs, e)) { if (elem) *elem = e; return RecordShape::Map; }
}
return RecordShape::None;
}
// The C++ spelling of a record-bearing type, or empty when `t` names none.
// `qual` qualifies the nested struct ("InfoModule::") where class scope does
// not already apply — i.e. a return type written before the `Class::` in a
// definition.
static QString recordCppType(const RecordSet& rs, const QString& t, ApiStyle style, const QString& qual)
{
QString elem;
const RecordShape shape = recordShape(rs, t, &elem);
const QString q = qual + elem;
switch (shape) {
case RecordShape::None: return QString();
case RecordShape::Scalar: return q;
case RecordShape::List:
return style == ApiStyle::Qt ? "QList<" + q + ">" : "std::vector<" + q + ">";
case RecordShape::Map:
return style == ApiStyle::Qt ? "QMap<QString, " + q + ">"
: "std::map<std::string, " + q + ">";
}
return QString();
}
// File-local conversion helpers emitted into the generated .cpp — never into
// the header, so a std/lp consumer's own translation units stay free of the
// wire type (QVariant / nlohmann::json) the conversion is written in.
static QString recToWireFn(const QString& record) { return "recToWire_" + record; }
static QString recFromWireFn(const QString& record) { return "recFromWire_" + record; }
// Record value -> wire value, and back. Empty when `t` names no record.
static QString recordToWireExpr(const RecordSet& rs, const QString& t, ApiStyle style, const QString& expr)
{
QString elem;
const RecordShape shape = recordShape(rs, t, &elem);
if (shape == RecordShape::None) return QString();
const QString conv = recToWireFn(elem);
if (shape == RecordShape::Scalar) return conv + "(" + expr + ")";
// Locals are named apart from the record encoder/decoder's own `__m` / `__j`
// / `__out`: these lambdas are emitted INSIDE those functions when a record
// has a container-of-record field, and a shadowing local silently reads
// itself (caught by -Wuninitialized, not by any assertion on the text).
if (style == ApiStyle::Lp) {
if (shape == RecordShape::List)
return "[&]{ nlohmann::json __acc = nlohmann::json::array(); for (const auto& __e : "
+ expr + ") __acc.push_back(" + conv + "(__e)); return __acc; }()";
return "[&]{ nlohmann::json __acc = nlohmann::json::object(); for (const auto& __kv : "
+ expr + ") __acc[__kv.first] = " + conv + "(__kv.second); return __acc; }()";
}
if (shape == RecordShape::List)
return "[&]{ QVariantList __acc; for (const auto& __e : " + expr
+ ") __acc.append(" + conv + "(__e)); return __acc; }()";
// Qt keys are QString, std keys std::string.
if (style == ApiStyle::Qt)
return "[&]{ QVariantMap __acc; for (auto __i = " + expr + ".cbegin(); __i != " + expr
+ ".cend(); ++__i) __acc.insert(__i.key(), " + conv + "(__i.value())); return __acc; }()";
return "[&]{ QVariantMap __acc; for (const auto& __kv : " + expr
+ ") __acc.insert(QString::fromStdString(__kv.first), " + conv + "(__kv.second)); return __acc; }()";
}
static QString recordFromWireExpr(const RecordSet& rs, const QString& t, ApiStyle style,
const QString& wire, const QString& qual)
{
QString elem;
const RecordShape shape = recordShape(rs, t, &elem);
if (shape == RecordShape::None) return QString();
const QString conv = recFromWireFn(elem);
const QString cpp = recordCppType(rs, t, style, qual);
if (shape == RecordShape::Scalar) return conv + "(" + wire + ")";
if (style == ApiStyle::Lp) {
if (shape == RecordShape::List)
return "[&]{ " + cpp + " __acc; const nlohmann::json& __src = " + wire
+ "; if (__src.is_array()) for (const auto& __e : __src) __acc.push_back(" + conv
+ "(__e)); return __acc; }()";
return "[&]{ " + cpp + " __acc; const nlohmann::json& __src = " + wire
+ "; if (__src.is_object()) for (auto __i = __src.begin(); __i != __src.end(); ++__i) "
"__acc[__i.key()] = " + conv + "(__i.value()); return __acc; }()";
}
if (shape == RecordShape::List)
return "[&]{ " + cpp + " __acc; for (const QVariant& __e : (" + wire
+ ").toList()) __acc.push_back(" + conv + "(__e)); return __acc; }()";
if (style == ApiStyle::Qt)
return "[&]{ " + cpp + " __acc; const QVariantMap __src = (" + wire
+ ").toMap(); for (auto __i = __src.cbegin(); __i != __src.cend(); ++__i) "
"__acc.insert(__i.key(), " + conv + "(__i.value())); return __acc; }()";
return "[&]{ " + cpp + " __acc; const QVariantMap __src = (" + wire
+ ").toMap(); for (auto __i = __src.cbegin(); __i != __src.cend(); ++__i) "
"__acc[__i.key().toStdString()] = " + conv + "(__i.value()); return __acc; }()";
}
// Param-type predicate: passed by const-ref?
static bool isStdRefType(const QString& t)
{
return t == "std::string" || t.startsWith("std::vector")
|| t == "std::map" || t.startsWith("std::map")
|| t == "LogosMap" || t == "LogosList";
}
@@ -214,10 +375,144 @@ static bool isQtRefType(const QString& t)
|| t == "QJsonArray" || t == "QVariantList" || t == "QVariantMap";
}
QString makeHeader(const QString& moduleName, const QString& className, const QJsonArray& methods, ApiStyle apiStyle, const QJsonArray& events, BindMode bindMode)
// ─── Record-aware type / conversion dispatch ─────────────────────────────
//
// The one entry point every emission site goes through. A record-bearing type
// takes the record path; everything else falls through to the pre-existing
// mapping tables unchanged, so an empty record set is a no-op.
static QString paramTypeFor(const QString& qtType, ApiStyle style, const RecordSet& rs,
const QString& qual = QString())
{
const QString rec = recordCppType(rs, qtType, style, qual);
if (!rec.isEmpty()) return rec;
return (style == ApiStyle::Qt) ? mapParamType(qtType) : mapParamTypeStd(qtType);
}
static QString returnTypeFor(const QString& qtType, ApiStyle style, const RecordSet& rs,
const QString& qual = QString())
{
const QString rec = recordCppType(rs, qtType, style, qual);
if (!rec.isEmpty()) return rec;
return (style == ApiStyle::Qt) ? mapReturnType(qtType) : mapReturnTypeStd(qtType);
}
// Records are structs — always by const-ref, never copied into a call.
static bool byRefFor(const QString& qtType, const QString& cppType, ApiStyle style, const RecordSet& rs)
{
if (recordShape(rs, qtType, nullptr) != RecordShape::None) return true;
return (style == ApiStyle::Qt) ? isQtRefType(cppType) : isStdRefType(cppType);
}
// Typed value -> wire value (QVariant for Qt/Std, nlohmann::json for Lp).
static QString toWireFor(const QString& qtType, ApiStyle style, const RecordSet& rs, const QString& expr)
{
const QString rec = recordToWireExpr(rs, qtType, style, expr);
if (!rec.isEmpty()) return rec;
if (style == ApiStyle::Lp) return lpPushExpr(qtType, expr);
if (style == ApiStyle::Std) return stdParamToQVariant(qtType, expr);
return expr; // Qt: the wrapper's own surface already IS the wire type
}
// Wire value -> typed value.
static QString fromWireFor(const QString& qtType, ApiStyle style, const RecordSet& rs,
const QString& wire, const QString& qual = QString())
{
const QString rec = recordFromWireExpr(rs, qtType, style, wire, qual);
if (!rec.isEmpty()) return rec;
if (style == ApiStyle::Lp) return lpFromJsonExpr(qtType, wire);
if (style == ApiStyle::Std) return qVariantToStdReturn(qtType, wire);
return toQVariantConversion(mapParamType(qtType), wire);
}
// The struct declarations, emitted inside the wrapper class.
static void emitRecordStructs(QTextStream& s, const RecordSet& rs, ApiStyle style)
{
if (rs.isEmpty()) return;
s << " // Record types declared by the contract.\n";
for (const RecordDef& d : rs) {
s << " struct " << d.name << " {\n";
for (const RecordField& f : d.fields)
s << " " << paramTypeFor(f.type, style, rs) << " " << f.name << "{};\n";
s << " };\n";
}
s << "\n";
}
// The struct <-> wire conversions, emitted as file-local statics in the
// generated .cpp. Declared up front so records can reference each other (and
// themselves, through a list field) regardless of declaration order.
static void emitRecordConversions(QTextStream& s, const RecordSet& rs, ApiStyle style,
const QString& className)
{
if (rs.isEmpty()) return;
const QString wire = (style == ApiStyle::Lp) ? "nlohmann::json" : "QVariant";
const QString qual = className + "::";
for (const RecordDef& d : rs) {
s << "static " << wire << " " << recToWireFn(d.name)
<< "(const " << qual << d.name << "& v);\n";
s << "static " << qual << d.name << " " << recFromWireFn(d.name)
<< "(const " << wire << "& w);\n";
}
s << "\n";
for (const RecordDef& d : rs) {
// Encode.
s << "static " << wire << " " << recToWireFn(d.name)
<< "(const " << qual << d.name << "& v) {\n";
if (style == ApiStyle::Lp) {
s << " nlohmann::json __j = nlohmann::json::object();\n";
for (const RecordField& f : d.fields)
s << " __j[\"" << f.name << "\"] = "
<< toWireFor(f.type, style, rs, "v." + f.name) << ";\n";
s << " return __j;\n";
} else {
s << " QVariantMap __m;\n";
for (const RecordField& f : d.fields) {
// Qt's surface type IS the wire type for non-record fields, so
// fromValue is what puts it in the map; records/containers
// already produce a QVariant-compatible value.
const QString v = toWireFor(f.type, style, rs, "v." + f.name);
const bool isRec = recordShape(rs, f.type, nullptr) != RecordShape::None;
s << " __m.insert(QStringLiteral(\"" << f.name << "\"), "
<< (isRec || style == ApiStyle::Std ? v : "QVariant::fromValue(" + v + ")")
<< ");\n";
}
s << " return __m;\n";
}
s << "}\n\n";
// Decode. A missing / mistyped field keeps its default rather than
// failing the whole call — same leniency the scalar paths use.
s << "static " << qual << d.name << " " << recFromWireFn(d.name)
<< "(const " << wire << "& w) {\n";
s << " " << qual << d.name << " __out;\n";
if (style == ApiStyle::Lp) {
s << " if (!w.is_object()) return __out;\n";
for (const RecordField& f : d.fields) {
const QString acc = "w.at(\"" + f.name + "\")";
s << " if (w.contains(\"" << f.name << "\")) __out." << f.name << " = "
<< fromWireFor(f.type, style, rs, acc, qual) << ";\n";
}
} else {
s << " const QVariantMap __m = w.toMap();\n";
for (const RecordField& f : d.fields) {
const QString acc = "__m.value(QStringLiteral(\"" + f.name + "\"))";
s << " __out." << f.name << " = "
<< fromWireFor(f.type, style, rs, acc, qual) << ";\n";
}
}
s << " return __out;\n";
s << "}\n\n";
}
}
QString makeHeader(const QString& moduleName, const QString& className, const QJsonArray& methods, ApiStyle apiStyle, const QJsonArray& events, BindMode bindMode, const QJsonArray& records)
{
if (apiStyle == ApiStyle::Lp)
return makeHeaderLp(moduleName, className, methods, events, bindMode);
return makeHeaderLp(moduleName, className, methods, events, bindMode, records);
const RecordSet rs = parseRecords(records);
QString h;
QTextStream s(&h);
s << "#pragma once\n";
@@ -240,6 +535,8 @@ QString makeHeader(const QString& moduleName, const QString& className, const QJ
// any events. Cheap to include unconditionally — keeps the
// header symmetric with the Qt-style branch.
if (!events.isEmpty()) s << "#include \"logos_object.h\"\n";
// Record maps are std::map on the std surface.
if (!rs.isEmpty()) s << "#include <map>\n";
s << "\n";
} else {
s << "#include <QString>\n";
@@ -258,6 +555,7 @@ QString makeHeader(const QString& moduleName, const QString& className, const QJ
}
s << "class " << className << " {\n";
s << "public:\n";
emitRecordStructs(s, rs, apiStyle);
if (bindMode == BindMode::Bound) {
// Interface wrapper: the module to talk to is chosen at runtime.
s << " explicit " << className << "(LogosAPI* api, const QString& moduleName);\n\n";
@@ -308,9 +606,8 @@ QString makeHeader(const QString& moduleName, const QString& className, const QJ
for (int i = 0; i < evParams.size(); ++i) {
const QJsonObject p = evParams.at(i).toObject();
QString qtPt = p.value("type").toString();
QString pt = (apiStyle == ApiStyle::Std)
? mapParamTypeStd(qtPt) : mapParamType(qtPt);
bool byRef = (apiStyle == ApiStyle::Std) ? isStdRefType(pt) : isQtRefType(pt);
QString pt = paramTypeFor(qtPt, apiStyle, rs);
bool byRef = byRefFor(qtPt, pt, apiStyle, rs);
if (byRef) cbParams += "const " + pt + "& ";
else cbParams += pt + " ";
cbParams += p.value("name").toString();
@@ -327,17 +624,15 @@ QString makeHeader(const QString& moduleName, const QString& className, const QJ
if (!invokable) continue;
const QString name = o.value("name").toString();
const QString qtRet = o.value("returnType").toString();
const QString ret = (apiStyle == ApiStyle::Std)
? mapReturnTypeStd(qtRet) : mapReturnType(qtRet);
const QString ret = returnTypeFor(qtRet, apiStyle, rs);
s << " " << ret << " " << name << "(";
QJsonArray params = o.value("parameters").toArray();
for (int i = 0; i < params.size(); ++i) {
QJsonObject p = params.at(i).toObject();
QString qtPt = p.value("type").toString();
QString pt = (apiStyle == ApiStyle::Std)
? mapParamTypeStd(qtPt) : mapParamType(qtPt);
QString pt = paramTypeFor(qtPt, apiStyle, rs);
QString pn = p.value("name").toString();
bool byRef = (apiStyle == ApiStyle::Std) ? isStdRefType(pt) : isQtRefType(pt);
bool byRef = byRefFor(qtPt, pt, apiStyle, rs);
if (byRef) s << "const " << pt << "& " << pn;
else s << pt << " " << pn;
if (i + 1 < params.size()) s << ", ";
@@ -355,10 +650,9 @@ QString makeHeader(const QString& moduleName, const QString& className, const QJ
for (int i = 0; i < params.size(); ++i) {
QJsonObject p = params.at(i).toObject();
QString qtPt = p.value("type").toString();
QString pt = (apiStyle == ApiStyle::Std)
? mapParamTypeStd(qtPt) : mapParamType(qtPt);
QString pt = paramTypeFor(qtPt, apiStyle, rs);
QString pn = p.value("name").toString();
bool byRef = (apiStyle == ApiStyle::Std) ? isStdRefType(pt) : isQtRefType(pt);
bool byRef = byRefFor(qtPt, pt, apiStyle, rs);
if (byRef) s << "const " << pt << "& " << pn;
else s << pt << " " << pn;
if (i + 1 < params.size()) s << ", ";
@@ -399,10 +693,11 @@ QString makeHeader(const QString& moduleName, const QString& className, const QJ
return h;
}
QString makeSource(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, ApiStyle apiStyle, const QJsonArray& events, BindMode bindMode)
QString makeSource(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, ApiStyle apiStyle, const QJsonArray& events, BindMode bindMode, const QJsonArray& records)
{
if (apiStyle == ApiStyle::Lp)
return makeSourceLp(moduleName, className, headerBaseName, methods, events, bindMode);
return makeSourceLp(moduleName, className, headerBaseName, methods, events, bindMode, records);
const RecordSet rs = parseRecords(records);
QString c;
QTextStream s(&c);
s << "#include \"" << headerBaseName << "\"\n\n";
@@ -423,7 +718,12 @@ QString makeSource(const QString& moduleName, const QString& className, const QS
// std mode when events are present.
if (!events.isEmpty()) s << "#include \"logos_object.h\"\n";
}
if (apiStyle == ApiStyle::Qt && !rs.isEmpty()) {
// Record conversions build QVariantMaps regardless of api style.
s << "#include <QVariantMap>\n";
}
s << "\n";
emitRecordConversions(s, rs, apiStyle, className);
// The expression every remote call uses to name its target module.
// Static: the baked string literal "<moduleName>" (unchanged
// behaviour). Bound: the m_moduleName member set from the runtime ctor
@@ -535,9 +835,8 @@ QString makeSource(const QString& moduleName, const QString& className, const QS
for (int i = 0; i < evParams.size(); ++i) {
const QJsonObject p = evParams.at(i).toObject();
QString qtPt = p.value("type").toString();
QString pt = (apiStyle == ApiStyle::Std)
? mapParamTypeStd(qtPt) : mapParamType(qtPt);
bool byRef = (apiStyle == ApiStyle::Std) ? isStdRefType(pt) : isQtRefType(pt);
QString pt = paramTypeFor(qtPt, apiStyle, rs);
bool byRef = byRefFor(qtPt, pt, apiStyle, rs);
if (byRef) cbParams += "const " + pt + "& ";
else cbParams += pt + " ";
cbParams += p.value("name").toString();
@@ -561,13 +860,7 @@ QString makeSource(const QString& moduleName, const QString& className, const QS
QString qtPt = p.value("type").toString();
// Build the QVariant → typed-arg conversion expression.
const QString argExpr = QString("_args.at(%1)").arg(i);
QString conv;
if (apiStyle == ApiStyle::Std) {
conv = qVariantToStdReturn(qtPt, argExpr);
} else {
conv = toQVariantConversion(mapParamType(qtPt), argExpr);
}
s << conv;
s << fromWireFor(qtPt, apiStyle, rs, argExpr);
if (i + 1 < evParams.size()) s << ", ";
}
s << ");\n";
@@ -582,8 +875,11 @@ QString makeSource(const QString& moduleName, const QString& className, const QS
if (!invokable) continue;
const QString name = o.value("name").toString();
const QString qtRet = o.value("returnType").toString();
const QString ret = (apiStyle == ApiStyle::Std)
? mapReturnTypeStd(qtRet) : mapReturnType(qtRet);
// Inside the class's own scope (parameter lists, bodies) a nested
// record needs no qualification; a return type written before the
// `Class::` in a definition does.
const QString ret = returnTypeFor(qtRet, apiStyle, rs);
const QString retQual = returnTypeFor(qtRet, apiStyle, rs, className + "::");
QJsonArray params = o.value("parameters").toArray();
// Helper closures kept inline so the two branches don't get
@@ -591,21 +887,20 @@ QString makeSource(const QString& moduleName, const QString& className, const QS
// the only thing that varies between Qt and Std modes.
auto emitParam = [&](const QJsonObject& p, bool& byRefOut) {
QString qtPt = p.value("type").toString();
QString pt = (apiStyle == ApiStyle::Std)
? mapParamTypeStd(qtPt) : mapParamType(qtPt);
QString pt = paramTypeFor(qtPt, apiStyle, rs);
QString pn = p.value("name").toString();
byRefOut = (apiStyle == ApiStyle::Std) ? isStdRefType(pt) : isQtRefType(pt);
byRefOut = byRefFor(qtPt, pt, apiStyle, rs);
if (byRefOut) s << "const " << pt << "& " << pn;
else s << pt << " " << pn;
};
auto wireArg = [&](const QJsonObject& p) -> QString {
QString qtPt = p.value("type").toString();
QString pn = p.value("name").toString();
return (apiStyle == ApiStyle::Std) ? stdParamToQVariant(qtPt, pn) : pn;
return toWireFor(qtPt, apiStyle, rs, pn);
};
// Signature
s << ret << " " << className << "::" << name << "(";
s << retQual << " " << className << "::" << name << "(";
for (int i = 0; i < params.size(); ++i) {
bool byRef;
emitParam(params.at(i).toObject(), byRef);
@@ -641,8 +936,11 @@ QString makeSource(const QString& moduleName, const QString& className, const QS
<< ": remote call failed:\" << QString::fromStdString(_err.message);\n";
// Return conversion
const bool retIsRecord = recordShape(rs, qtRet, nullptr) != RecordShape::None;
if (ret == "void") {
// nothing
} else if (retIsRecord) {
s << " return " << fromWireFor(qtRet, apiStyle, rs, "_result") << ";\n";
} else if (apiStyle == ApiStyle::Std) {
s << " return " << qVariantToStdReturn(qtRet, "_result") << ";\n";
} else if (ret == "bool") {
@@ -705,6 +1003,10 @@ QString makeSource(const QString& moduleName, const QString& className, const QS
s << ", [callback](QVariant v) {\n";
if (ret == "void") {
s << " (void)v; callback();\n";
} else if (retIsRecord) {
// A record decodes field by field; an invalid QVariant yields a
// default-constructed struct, matching the scalar paths.
s << " callback(" << fromWireFor(qtRet, apiStyle, rs, "v", className + "::") << ");\n";
} else if (apiStyle == ApiStyle::Std) {
// Default-construct on dispatch failure, matching the
// existing Qt code path which falls back to a zero / empty
@@ -887,6 +1189,7 @@ static QString lpFromJsonExpr(const QString& qtType, const QString& jv)
if (t == "void") return QString();
if (t == "std::string") return "(" + jv + ".is_string() ? " + jv + ".get<std::string>() : std::string())";
if (t == "int64_t") return "(" + jv + ".is_number_integer() ? " + jv + ".get<int64_t>() : (" + jv + ".is_number() ? static_cast<int64_t>(" + jv + ".get<double>()) : (int64_t)0))";
if (t == "uint64_t") return "(" + jv + ".is_number_integer() ? " + jv + ".get<uint64_t>() : (" + jv + ".is_number() ? static_cast<uint64_t>(" + jv + ".get<double>()) : (uint64_t)0))";
if (t == "double") return "(" + jv + ".is_number() ? " + jv + ".get<double>() : 0.0)";
if (t == "bool") return "(" + jv + ".is_boolean() ? " + jv + ".get<bool>() : false)";
if (t == "std::vector<std::string>") return "logos::jsonToStringVec(" + jv + ")";
@@ -905,14 +1208,15 @@ static QString lpFromJsonExpr(const QString& qtType, const QString& jv)
// Build the callback parameter list (std types, by-ref where appropriate) for
// a typed event accessor `on<Event>`.
static QString lpEventCbParams(const QJsonArray& evParams)
static QString lpEventCbParams(const QJsonArray& evParams, const RecordSet& rs)
{
QString cbParams;
for (int i = 0; i < evParams.size(); ++i) {
const QJsonObject p = evParams.at(i).toObject();
const QString pt = mapParamTypeStd(p.value("type").toString());
if (isStdRefType(pt)) cbParams += "const " + pt + "& ";
else cbParams += pt + " ";
const QString qtPt = p.value("type").toString();
const QString pt = paramTypeFor(qtPt, ApiStyle::Lp, rs);
if (byRefFor(qtPt, pt, ApiStyle::Lp, rs)) cbParams += "const " + pt + "& ";
else cbParams += pt + " ";
cbParams += p.value("name").toString();
if (i + 1 < evParams.size()) cbParams += ", ";
}
@@ -926,9 +1230,10 @@ static QString lpEventAccessorName(const QString& evName)
return QString("on") + cap;
}
QString makeHeaderLp(const QString& moduleName, const QString& className, const QJsonArray& methods, const QJsonArray& events, BindMode bindMode)
QString makeHeaderLp(const QString& moduleName, const QString& className, const QJsonArray& methods, const QJsonArray& events, BindMode bindMode, const QJsonArray& records)
{
(void)moduleName;
const RecordSet rs = parseRecords(records);
QString h;
QTextStream s(&h);
s << "#pragma once\n";
@@ -940,10 +1245,14 @@ QString makeHeaderLp(const QString& moduleName, const QString& className, const
s << "#include \"logos_json.h\"\n";
s << "#include \"logos_result.h\"\n";
s << "#include \"logos_call_error.h\"\n";
s << "#include \"logos_lp_client.h\"\n\n";
s << "#include \"logos_lp_client.h\"\n";
// Record maps are std::map on the Qt-free surface.
if (!rs.isEmpty()) s << "#include <map>\n";
s << "\n";
s << "class " << className << " {\n";
s << "public:\n";
emitRecordStructs(s, rs, ApiStyle::Lp);
if (bindMode == BindMode::Bound) {
// Bound (interface) wrappers are THIN, copyable handles over
// umbrella-owned persistent State, so a transient
@@ -969,7 +1278,7 @@ QString makeHeaderLp(const QString& moduleName, const QString& className, const
const QString evName = eo.value("name").toString();
if (evName.isEmpty()) continue;
s << " bool " << lpEventAccessorName(evName)
<< "(std::function<void(" << lpEventCbParams(eo.value("params").toArray()) << ")> callback);\n";
<< "(std::function<void(" << lpEventCbParams(eo.value("params").toArray(), rs) << ")> callback);\n";
}
if (!events.isEmpty()) s << "\n";
@@ -978,15 +1287,16 @@ QString makeHeaderLp(const QString& moduleName, const QString& className, const
const QJsonObject o = v.toObject();
if (!o.value("isInvokable").toBool()) continue;
const QString name = o.value("name").toString();
const QString ret = mapReturnTypeStd(o.value("returnType").toString());
const QString ret = returnTypeFor(o.value("returnType").toString(), ApiStyle::Lp, rs);
const QJsonArray params = o.value("parameters").toArray();
s << " " << ret << " " << name << "(";
for (int i = 0; i < params.size(); ++i) {
const QJsonObject p = params.at(i).toObject();
const QString pt = mapParamTypeStd(p.value("type").toString());
if (isStdRefType(pt)) s << "const " << pt << "& " << p.value("name").toString();
else s << pt << " " << p.value("name").toString();
const QString qtPt = p.value("type").toString();
const QString pt = paramTypeFor(qtPt, ApiStyle::Lp, rs);
if (byRefFor(qtPt, pt, ApiStyle::Lp, rs)) s << "const " << pt << "& " << p.value("name").toString();
else s << pt << " " << p.value("name").toString();
if (i + 1 < params.size()) s << ", ";
}
if (!params.isEmpty()) s << ", ";
@@ -998,9 +1308,10 @@ QString makeHeaderLp(const QString& moduleName, const QString& className, const
s << " void " << name << "Async(";
for (int i = 0; i < params.size(); ++i) {
const QJsonObject p = params.at(i).toObject();
const QString pt = mapParamTypeStd(p.value("type").toString());
if (isStdRefType(pt)) s << "const " << pt << "& " << p.value("name").toString();
else s << pt << " " << p.value("name").toString();
const QString qtPt = p.value("type").toString();
const QString pt = paramTypeFor(qtPt, ApiStyle::Lp, rs);
if (byRefFor(qtPt, pt, ApiStyle::Lp, rs)) s << "const " << pt << "& " << p.value("name").toString();
else s << pt << " " << p.value("name").toString();
if (i + 1 < params.size()) s << ", ";
}
if (!params.isEmpty()) s << ", ";
@@ -1018,12 +1329,14 @@ QString makeHeaderLp(const QString& moduleName, const QString& className, const
return h;
}
QString makeSourceLp(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, const QJsonArray& events, BindMode bindMode)
QString makeSourceLp(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, const QJsonArray& events, BindMode bindMode, const QJsonArray& records)
{
const RecordSet rs = parseRecords(records);
QString c;
QTextStream s(&c);
s << "#include \"" << headerBaseName << "\"\n";
s << "#include <nlohmann/json.hpp>\n\n";
emitRecordConversions(s, rs, ApiStyle::Lp, className);
// How the wrapper reaches its persistent LpClient + subscription store.
// Static (concrete dep): owns them by value — the wrapper itself is a
@@ -1047,14 +1360,15 @@ QString makeSourceLp(const QString& moduleName, const QString& className, const
if (evName.isEmpty()) continue;
const QJsonArray evParams = eo.value("params").toArray();
s << "bool " << className << "::" << lpEventAccessorName(evName)
<< "(std::function<void(" << lpEventCbParams(evParams) << ")> callback) {\n";
<< "(std::function<void(" << lpEventCbParams(evParams, rs) << ")> callback) {\n";
s << " if (!callback) return false;\n";
s << " auto _sub = " << clientExpr << ".subscribe(\"" << evName << "\", [callback](nlohmann::json _a) {\n";
s << " if (!_a.is_array() || _a.size() < " << evParams.size() << ") return;\n";
s << " callback(";
for (int i = 0; i < evParams.size(); ++i) {
const QJsonObject p = evParams.at(i).toObject();
s << lpFromJsonExpr(p.value("type").toString(), QString("_a.at(%1)").arg(i));
s << fromWireFor(p.value("type").toString(), ApiStyle::Lp, rs,
QString("_a.at(%1)").arg(i), className + "::");
if (i + 1 < evParams.size()) s << ", ";
}
s << ");\n";
@@ -1071,15 +1385,17 @@ QString makeSourceLp(const QString& moduleName, const QString& className, const
if (!o.value("isInvokable").toBool()) continue;
const QString name = o.value("name").toString();
const QString qtRet = o.value("returnType").toString();
const QString ret = mapReturnTypeStd(qtRet);
const QString ret = returnTypeFor(qtRet, ApiStyle::Lp, rs);
const QString retQual = returnTypeFor(qtRet, ApiStyle::Lp, rs, className + "::");
const QJsonArray params = o.value("parameters").toArray();
auto emitParams = [&]() {
for (int i = 0; i < params.size(); ++i) {
const QJsonObject p = params.at(i).toObject();
const QString pt = mapParamTypeStd(p.value("type").toString());
if (isStdRefType(pt)) s << "const " << pt << "& " << p.value("name").toString();
else s << pt << " " << p.value("name").toString();
const QString qtPt = p.value("type").toString();
const QString pt = paramTypeFor(qtPt, ApiStyle::Lp, rs);
if (byRefFor(qtPt, pt, ApiStyle::Lp, rs)) s << "const " << pt << "& " << p.value("name").toString();
else s << pt << " " << p.value("name").toString();
if (i + 1 < params.size()) s << ", ";
}
};
@@ -1087,12 +1403,12 @@ QString makeSourceLp(const QString& moduleName, const QString& className, const
s << " nlohmann::json _args = nlohmann::json::array();\n";
for (const QJsonValue& pv : params) {
const QJsonObject p = pv.toObject();
s << " _args.push_back(" << lpPushExpr(p.value("type").toString(), p.value("name").toString()) << ");\n";
s << " _args.push_back(" << toWireFor(p.value("type").toString(), ApiStyle::Lp, rs, p.value("name").toString()) << ");\n";
}
};
// Sync
s << ret << " " << className << "::" << name << "(";
s << retQual << " " << className << "::" << name << "(";
emitParams();
if (!params.isEmpty()) s << ", ";
s << "logos::CallError* err) {\n";
@@ -1101,7 +1417,7 @@ QString makeSourceLp(const QString& moduleName, const QString& className, const
s << " " << clientExpr << ".invoke(\"" << name << "\", _args, err);\n";
} else {
s << " nlohmann::json _r = " << clientExpr << ".invoke(\"" << name << "\", _args, err);\n";
s << " return " << lpFromJsonExpr(qtRet, "_r") << ";\n";
s << " return " << fromWireFor(qtRet, ApiStyle::Lp, rs, "_r", className + "::") << ";\n";
}
s << "}\n\n";
@@ -1119,7 +1435,7 @@ QString makeSourceLp(const QString& moduleName, const QString& className, const
if (ret == "void") {
s << " (void)_r; callback();\n";
} else {
s << " callback(" << lpFromJsonExpr(qtRet, "_r") << ");\n";
s << " callback(" << fromWireFor(qtRet, ApiStyle::Lp, rs, "_r", className + "::") << ");\n";
}
s << " });\n";
s << "}\n\n";
+14 -4
View File
@@ -72,15 +72,25 @@ QString toQVariantConversion(const QString& type, const QString& argExpr);
// runtime-bound interface wrapper (Bound) — see BindMode above. In Bound
// mode `moduleName` is used only for the class/file naming the caller
// already decided; the emitted code never bakes it into a call.
QString makeHeader(const QString& moduleName, const QString& className, const QJsonArray& methods, ApiStyle apiStyle = ApiStyle::Qt, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static);
QString makeSource(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, ApiStyle apiStyle = ApiStyle::Qt, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static);
//
// `records` carries the contract's `type Foo { ... }` declarations, as
// [ { "name": "Foo", "fields": [ { "name": "...", "type": "<QtTypeName>" } ] } ]
// Each becomes a struct NESTED in the wrapper class (`<Class>::Foo`, so two
// deps may both declare a `Status`), and every method / event that mentions
// one is typed with it instead of falling back to QVariant / LogosMap. Field
// types use the same Qt type-name spelling as methods, so a field can name
// another record, `QList<Record>`, or `QMap<QString, Record>`. Empty (the
// default, and what the metaobject-introspection path passes) leaves the
// generated output exactly as it was.
QString makeHeader(const QString& moduleName, const QString& className, const QJsonArray& methods, ApiStyle apiStyle = ApiStyle::Qt, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static, const QJsonArray& records = {});
QString makeSource(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, ApiStyle apiStyle = ApiStyle::Qt, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static, const QJsonArray& records = {});
// Qt-free (ApiStyle::Lp) wrapper emission. Same std-typed surface as the Std
// flavor, but the generated body calls the logos-protocol C ABI through
// logos::LpClient instead of LogosAPIClient — no Qt in the wrapper's TU.
// makeHeader/makeSource dispatch here when apiStyle == ApiStyle::Lp.
QString makeHeaderLp(const QString& moduleName, const QString& className, const QJsonArray& methods, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static);
QString makeSourceLp(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static);
QString makeHeaderLp(const QString& moduleName, const QString& className, const QJsonArray& methods, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static, const QJsonArray& records = {});
QString makeSourceLp(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static, const QJsonArray& records = {});
QVector<ParsedMethod> parseProviderHeader(const QString& headerPath, QTextStream& err);
#endif // GENERATOR_LIB_H
+54 -12
View File
@@ -37,8 +37,12 @@ static QString lidlTypeExprToQtTypeName(const TypeExpr& te)
if (te.kind == TypeExpr::Primitive) {
if (te.name == "tstr") return "QString";
if (te.name == "bstr") return "QByteArray";
if (te.name == "int") return "int";
if (te.name == "uint") return "int"; // wire-as-int for now
// 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";
@@ -49,20 +53,33 @@ static QString lidlTypeExprToQtTypeName(const TypeExpr& te)
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) return "QVariantMap";
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";
if (te.kind == TypeExpr::Named) 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)
static QJsonArray loadEventsFromLidl(const QString& lidlPath, QTextStream& err,
QJsonArray* outRecords = nullptr)
{
QJsonArray result;
QFile f(lidlPath);
@@ -80,6 +97,7 @@ static QJsonArray loadEventsFromLidl(const QString& lidlPath, QTextStream& err)
return result;
}
if (outRecords) *outRecords = moduleRecordsToJson(pr.module);
for (const EventDecl& ed : pr.module.events) {
QJsonObject obj;
obj["name"] = qs(ed.name);
@@ -171,6 +189,28 @@ static QJsonArray moduleMethodsToJson(const ModuleDecl& mod)
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)
@@ -276,11 +316,12 @@ static bool generateInterfaceWrappers(const QVector<InterfaceSpec>& ifaces,
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);
const QString source = makeSource(spec.name, className, headerRel, methods, apiStyle, events, bindMode);
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));
@@ -773,7 +814,7 @@ static int generateProviderDispatch(const QString& headerPath, const QString& ou
return 0;
}
static int generateFromPlugin(const QString& pluginInputPath, const QString& outputDir, bool moduleOnly, ApiStyle apiStyle, const QJsonArray& events, QTextStream& out, QTextStream& err)
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()) {
@@ -841,8 +882,8 @@ static int generateFromPlugin(const QString& pluginInputPath, const QString& out
// 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);
QString source = makeSource(moduleName, className, headerRel, methods, apiStyle, events);
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);
@@ -1195,6 +1236,7 @@ int legacy_main(int argc, char* argv[])
// `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;
@@ -1209,10 +1251,10 @@ int legacy_main(int argc, char* argv[])
}
}
if (!evPath.isEmpty() && QFileInfo(evPath).exists()) {
eventsFromSidecar = loadEventsFromLidl(evPath, err);
eventsFromSidecar = loadEventsFromLidl(evPath, err, &recordsFromSidecar);
}
}
QString argPath = args.at(1);
return generateFromPlugin(argPath, outputDir, moduleOnly, apiStyle, eventsFromSidecar, out, err);
return generateFromPlugin(argPath, outputDir, moduleOnly, apiStyle, eventsFromSidecar, out, err, recordsFromSidecar);
}
+1
View File
@@ -8,6 +8,7 @@ add_executable(generator_tests
test_make_header.cpp
test_make_source.cpp
test_parse_provider_header.cpp
test_records.cpp
)
target_include_directories(generator_tests PRIVATE
+216
View File
@@ -0,0 +1,216 @@
// Records on the consumer side of the LEGACY generator — the wrapper every
// C++ module actually gets for its dependencies (`--dep <name>=<lidl>`).
//
// A contract's `type Status { ... }` used to reach every C++ consumer as an
// untyped bag: QVariant on the Qt surface, LogosMap on the std/lp one. The
// caller then had to know the field names AND, for a `bstr` field, that the
// value arrives as the canonical `{"_bytes": "..."}` envelope it must unwrap
// itself — while Rust and the client-stub backend hand back a real struct.
//
// These assert on generated source text. That the emitted conversions compile
// and round-trip (bytes tagged at every depth, uint64 above 2^32 intact) is
// covered by generating a wrapper and building it — see the PR description.
#include <gtest/gtest.h>
#include <QJsonArray>
#include <QJsonObject>
#include "generator_lib.h"
namespace {
QJsonObject field(const char* name, const char* type)
{
QJsonObject f;
f["name"] = name;
f["type"] = type;
return f;
}
QJsonObject param(const char* name, const char* type)
{
QJsonObject p;
p["name"] = name;
p["type"] = type;
return p;
}
QJsonObject method(const char* name, const char* returnType, const QJsonArray& params = {})
{
QJsonObject m;
m["name"] = name;
m["returnType"] = returnType;
m["isInvokable"] = true;
m["parameters"] = params;
return m;
}
// `type Status { port: uint, blob: bstr }` plus a record that nests it in
// both container shapes.
QJsonArray statusRecords()
{
QJsonObject status;
status["name"] = "Status";
status["fields"] = QJsonArray{field("port", "qulonglong"), field("blob", "QByteArray")};
QJsonObject batch;
batch["name"] = "Batch";
batch["fields"] = QJsonArray{field("label", "QString"),
field("items", "QList<Status>"),
field("tags", "QMap<QString, Status>")};
return QJsonArray{status, batch};
}
QJsonArray statusMethods()
{
return QJsonArray{
method("getStatus", "Status"),
method("describeStatus", "QString", QJsonArray{param("s", "Status")}),
method("listStatuses", "QList<Status>"),
method("getBatch", "Batch"),
};
}
} // namespace
// The Qt surface: a struct nested in the wrapper class, typed accessors, and
// no QVariant anywhere a record is named.
TEST(Records, QtWrapperExposesTheStruct)
{
const QString h = makeHeader("info_module", "InfoModule", statusMethods(),
ApiStyle::Qt, {}, BindMode::Static, statusRecords());
// Nested, so two deps may each declare a `Status` in one consumer.
EXPECT_TRUE(h.contains(" struct Status {"));
EXPECT_TRUE(h.contains(" qulonglong port{};"));
EXPECT_TRUE(h.contains(" QByteArray blob{};"));
// Containers of records keep their element type.
EXPECT_TRUE(h.contains(" QList<Status> items{};"));
EXPECT_TRUE(h.contains(" QMap<QString, Status> tags{};"));
EXPECT_TRUE(h.contains("Status getStatus(logos::CallError* err = nullptr);"));
EXPECT_TRUE(h.contains("QString describeStatus(const Status& s,"));
EXPECT_TRUE(h.contains("QList<Status> listStatuses("));
// The old fallback is gone.
EXPECT_FALSE(h.contains("QVariant getStatus("));
EXPECT_FALSE(h.contains("describeStatus(QVariant"));
}
// The std / lp surfaces spell the same records in std types — a universal
// (Qt-free) module never sees a Qt name.
TEST(Records, StdAndLpWrappersUseStdFieldTypes)
{
for (ApiStyle style : {ApiStyle::Std, ApiStyle::Lp}) {
const QString h = makeHeader("info_module", "InfoModule", statusMethods(),
style, {}, BindMode::Static, statusRecords());
EXPECT_TRUE(h.contains(" uint64_t port{};")) << h.toStdString();
EXPECT_TRUE(h.contains(" std::vector<uint8_t> blob{};"));
EXPECT_TRUE(h.contains(" std::vector<Status> items{};"));
EXPECT_TRUE(h.contains(" std::map<std::string, Status> tags{};"));
EXPECT_TRUE(h.contains("std::vector<Status> listStatuses("));
// No LogosMap stand-in for a record.
EXPECT_FALSE(h.contains("LogosMap getStatus("));
}
// std::map needs its header on the std/lp surfaces.
const QString lp = makeHeader("info_module", "InfoModule", statusMethods(),
ApiStyle::Lp, {}, BindMode::Static, statusRecords());
EXPECT_TRUE(lp.contains("#include <map>"));
}
// A `bstr` field must ride the canonical tagged form at any depth — the
// defect class that made a record-as-LogosMap actively wrong rather than
// merely inconvenient.
TEST(Records, BytesFieldsUseTheCanonicalEncoding)
{
const QString lp = makeSource("info_module", "InfoModule", "info_module_api.h",
statusMethods(), ApiStyle::Lp, {}, BindMode::Static,
statusRecords());
EXPECT_TRUE(lp.contains("__j[\"blob\"] = logos::bytesToJson(v.blob);"));
EXPECT_TRUE(lp.contains("__out.blob = logos::jsonToBytes(w.at(\"blob\"));"));
const QString qt = makeSource("info_module", "InfoModule", "info_module_api.h",
statusMethods(), ApiStyle::Qt, {}, BindMode::Static,
statusRecords());
EXPECT_TRUE(qt.contains("__out.blob = __m.value(QStringLiteral(\"blob\")).toByteArray();"));
}
// The conversions are file-local statics in the .cpp: a std/lp consumer's own
// translation units must not need the wire type to include the header.
TEST(Records, ConversionsStayOutOfTheHeader)
{
const QString h = makeHeader("info_module", "InfoModule", statusMethods(),
ApiStyle::Lp, {}, BindMode::Static, statusRecords());
EXPECT_FALSE(h.contains("recToWire_Status"));
const QString c = makeSource("info_module", "InfoModule", "info_module_api.h",
statusMethods(), ApiStyle::Lp, {}, BindMode::Static,
statusRecords());
EXPECT_TRUE(c.contains("static nlohmann::json recToWire_Status(const InfoModule::Status& v);"));
EXPECT_TRUE(c.contains("static InfoModule::Status recFromWire_Status(const nlohmann::json& w);"));
// Declared before defined, so records may reference each other in any order.
EXPECT_LT(c.indexOf("static nlohmann::json recToWire_Batch(const InfoModule::Batch& v);"),
c.indexOf("static nlohmann::json recToWire_Status(const InfoModule::Status& v) {"));
}
// A return type written before the `Class::` of a definition is outside class
// scope and must be qualified; a parameter is inside it and must not be
// (an unqualified return type simply does not compile).
TEST(Records, ReturnTypesAreQualifiedInTheDefinition)
{
const QString c = makeSource("info_module", "InfoModule", "info_module_api.h",
statusMethods(), ApiStyle::Qt, {}, BindMode::Static,
statusRecords());
EXPECT_TRUE(c.contains("InfoModule::Status InfoModule::getStatus("));
EXPECT_TRUE(c.contains("QList<InfoModule::Status> InfoModule::listStatuses("));
EXPECT_TRUE(c.contains("QString InfoModule::describeStatus(const Status& s,"));
}
// Container decode lambdas are emitted INSIDE the record decoder, which has
// its own `__m` / `__j`. Reusing those names made a map-of-records field read
// from its own uninitialized local — it compiled, with only a warning.
TEST(Records, ContainerLambdasDoNotShadowTheDecoderLocals)
{
for (ApiStyle style : {ApiStyle::Qt, ApiStyle::Std, ApiStyle::Lp}) {
const QString c = makeSource("info_module", "InfoModule", "info_module_api.h",
statusMethods(), style, {}, BindMode::Static,
statusRecords());
// The decoder's own map is `__m` (Qt/Std); a nested lambda must not
// declare another one.
EXPECT_FALSE(c.contains("const QVariantMap __m = (__m.value")) << c.toStdString();
EXPECT_FALSE(c.contains("const nlohmann::json& __j = w.at")) << c.toStdString();
}
}
// The record path is additive: with no records declared, every byte of the
// generated output is what it was before.
TEST(Records, EmptyRecordSetChangesNothing)
{
const QJsonArray methods{method("ping", "QString", QJsonArray{param("msg", "QString")})};
for (ApiStyle style : {ApiStyle::Qt, ApiStyle::Std, ApiStyle::Lp}) {
EXPECT_EQ(makeHeader("m", "M", methods, style, {}, BindMode::Static, {}),
makeHeader("m", "M", methods, style, {}, BindMode::Static));
EXPECT_EQ(makeSource("m", "M", "m_api.h", methods, style, {}, BindMode::Static, {}),
makeSource("m", "M", "m_api.h", methods, style, {}, BindMode::Static));
}
}
// Records reach event callbacks too — an event payload is as typed as a
// return value.
TEST(Records, TypedEventCallbacksTakeTheStruct)
{
QJsonObject ev;
ev["name"] = "statusChanged";
ev["params"] = QJsonArray{param("s", "Status"), param("at", "qlonglong")};
const QJsonArray events{ev};
const QString h = makeHeader("info_module", "InfoModule", statusMethods(),
ApiStyle::Qt, events, BindMode::Static, statusRecords());
EXPECT_TRUE(h.contains("bool onStatusChanged(std::function<void(const Status& s, qlonglong at)> callback);"));
const QString c = makeSource("info_module", "InfoModule", "info_module_api.h",
statusMethods(), ApiStyle::Qt, events, BindMode::Static,
statusRecords());
EXPECT_TRUE(c.contains("recFromWire_Status(_args.at(0))"));
}