From c598f569fd2aeff26ec77a2ad0feb7b4021d76ec Mon Sep 17 00:00:00 2001 From: Dario Gabriel Lipicar Date: Mon, 27 Jul 2026 16:42:43 -0300 Subject: [PATCH] feat(records): typed structs for C++ dependency wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second record gap. The wrapper every C++ module actually gets for its dependencies comes from the LEGACY generator (`--dep =`), 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 blob{}; }; Status getStatus(logos::CallError* err = nullptr); std::string describeStatus(const Status& s, ...); std::vector 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 --- cpp-generator/legacy/generator_lib.cpp | 430 +++++++++++++++++++++---- cpp-generator/legacy/generator_lib.h | 18 +- cpp-generator/legacy/main.cpp | 66 +++- tests/generator/CMakeLists.txt | 1 + tests/generator/test_records.cpp | 216 +++++++++++++ 5 files changed, 658 insertions(+), 73 deletions(-) create mode 100644 tests/generator/test_records.cpp diff --git a/cpp-generator/legacy/generator_lib.cpp b/cpp-generator/legacy/generator_lib.cpp index e4c633e..78154b6 100644 --- a/cpp-generator/legacy/generator_lib.cpp +++ b/cpp-generator/legacy/generator_lib.cpp @@ -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") or a map of them +// ("QMap"). 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 fields; }; +using RecordSet = QVector; + +// 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")) { + 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" + : "std::map"; + } + 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 \n"; s << "\n"; } else { s << "#include \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 \n"; + } s << "\n"; + emitRecordConversions(s, rs, apiStyle, className); // The expression every remote call uses to name its target module. // Static: the baked string literal "" (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())"; if (t == "int64_t") return "(" + jv + ".is_number_integer() ? " + jv + ".get() : (" + jv + ".is_number() ? static_cast(" + jv + ".get()) : (int64_t)0))"; + if (t == "uint64_t") return "(" + jv + ".is_number_integer() ? " + jv + ".get() : (" + jv + ".is_number() ? static_cast(" + jv + ".get()) : (uint64_t)0))"; if (t == "double") return "(" + jv + ".is_number() ? " + jv + ".get() : 0.0)"; if (t == "bool") return "(" + jv + ".is_boolean() ? " + jv + ".get() : false)"; if (t == "std::vector") 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`. -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 \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 callback);\n"; + << "(std::function 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 \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 callback) {\n"; + << "(std::function 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"; diff --git a/cpp-generator/legacy/generator_lib.h b/cpp-generator/legacy/generator_lib.h index 6feb656..50166cd 100644 --- a/cpp-generator/legacy/generator_lib.h +++ b/cpp-generator/legacy/generator_lib.h @@ -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": "" } ] } ] +// Each becomes a struct NESTED in the wrapper 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`, or `QMap`. 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 parseProviderHeader(const QString& headerPath, QTextStream& err); #endif // GENERATOR_LIB_H diff --git a/cpp-generator/legacy/main.cpp b/cpp-generator/legacy/main.cpp index 3f84783..6dd8007 100644 --- a/cpp-generator/legacy/main.cpp +++ b/cpp-generator/legacy/main.cpp @@ -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"; + 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& 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(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(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); } diff --git a/tests/generator/CMakeLists.txt b/tests/generator/CMakeLists.txt index 7c15561..0075af2 100644 --- a/tests/generator/CMakeLists.txt +++ b/tests/generator/CMakeLists.txt @@ -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 diff --git a/tests/generator/test_records.cpp b/tests/generator/test_records.cpp new file mode 100644 index 0000000..09c869a --- /dev/null +++ b/tests/generator/test_records.cpp @@ -0,0 +1,216 @@ +// Records on the consumer side of the LEGACY generator — the wrapper every +// C++ module actually gets for its dependencies (`--dep =`). +// +// 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 +#include +#include +#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"), + field("tags", "QMap")}; + return QJsonArray{status, batch}; +} + +QJsonArray statusMethods() +{ + return QJsonArray{ + method("getStatus", "Status"), + method("describeStatus", "QString", QJsonArray{param("s", "Status")}), + method("listStatuses", "QList"), + 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 items{};")); + EXPECT_TRUE(h.contains(" QMap 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 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 blob{};")); + EXPECT_TRUE(h.contains(" std::vector items{};")); + EXPECT_TRUE(h.contains(" std::map tags{};")); + EXPECT_TRUE(h.contains("std::vector 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 ")); +} + +// 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::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 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))")); +}