#include "lidl_gen_cdylib.h" #include "lidl_emit_common.h" #include #include #include #include QString lidlToPascalCase(const QString& name); QString lidlTypeToQt(const TypeExpr& te); bool lidlIsStdConvertible(const TypeExpr& te); namespace { // The cdylib-supported subset: std-convertible LIDL types only — the same // Qt-free set the std apiStyle handled, so any universal module that built // under std also builds as a header-first cdylib. // The records a contract DECLARES. A `Named` type is a record only if it is in // here: `void` is not a LIDL builtin, so `-> void` arrives as Named("void") and // treating every Named as a record is how the Rust generator once emitted // `-> Void`. Same trap, same guard. std::set recordNames(const ModuleDecl& module) { std::set out; for (const TypeDecl& t : module.types) out.insert(t.name); return out; } bool isRecord(const TypeExpr& te, const std::set& recs) { return te.kind == TypeExpr::Named && recs.count(te.name) > 0; } bool typeSupported(const TypeExpr& te, bool isReturn, const std::set& recs) { if (te.kind == TypeExpr::Primitive) { if (te.name == "tstr" || te.name == "bstr" || te.name == "int" || te.name == "uint" || te.name == "float64" || te.name == "bool") return true; // any (LogosMap/LogosList/json) routes through nlohmann in either // direction; result (StdLogosResult) and void only make sense as a // return. All Qt-free. if (te.name == "any") return true; if (isReturn && (te.name == "result" || te.name == "void")) return true; return false; } // A declared record is a generated struct with a generated codec. if (isRecord(te, recs)) return true; // `?T` — supported exactly when its VALUE type is. // // The value type is checked as a NON-return position on purpose: `result` // and `void` are the two spellings that only make sense as a return, and // neither can be optional. `void` is the absence of a value, so `?void` is // meaningless; `result` already carries its own success/error discriminant, // so `?result` would be a second one. `-> ?Point` and `-> ?tstr` are the // real optional returns and stay eligible. if (te.kind == TypeExpr::Optional) { if (te.elements.empty()) return false; return typeSupported(optionalValueType(te), /*isReturn=*/false, recs); } // Recurse rather than whitelisting element names: that admits [bstr], // [[int]], [Record] and [{tstr: T}] in one rule, and keeps the gate and // the spelling function agreeing about what is expressible. if (te.kind == TypeExpr::Array && te.elements.size() == 1) return typeSupported(te.elements[0], false, recs); // Only tstr keys: the generated codec spells a map as // std::map, so a non-tstr key has no C++ spelling. This // used to `return true` for ANY map, which admitted `{int: tstr}` and then // silently produced a LogosMap that lost the key type. if (te.kind == TypeExpr::Map) { if (te.elements.size() != 2) return false; const TypeExpr& k = te.elements[0]; if (!(k.kind == TypeExpr::Primitive && k.name == "tstr")) return false; return typeSupported(te.elements[1], false, recs); } return false; } // Qt-free spelling of a LIDL type (defined below). Forward-declared so the // method-param decoder can spell composite `any` containers as their nlohmann // aliases instead of Qt containers in this Qt-free TU. QString lidlTypeToStdCdylib(const TypeExpr& te, const std::set& recs); // json arg expression -> std-typed C++ expression // A method argument, decoded into the author's C++ type. // // EVERY typed value goes through the generated codec, which recurses — so a bstr // keeps its canonical tag at ANY depth, a record decodes field by field with a // path in the error, and a scalar is checked against its declared type. // // The scalars used to keep their nlohmann accessor verbatim, and that was the // last hole in the type contract on this backend: `.get()` on -1 wraps // to 18446744073709551615 with no exception, so `echoUint(-1)` answered // 18446744073709551615 here and `dispatch_failed` on the Rust provider — a // silent sign flip on a nominal type, in a contract both providers share. // `.get()` on 3.7 likewise truncated to 3 instead of rejecting. // // The comment that used to sit here justified the leniency by pointing at the // conformance matrix cells that pinned it. That was circular: those cells exist // to DOCUMENT the divergence, and their own `why` text says the strict behaviour // is the correct one. The expectations moved with this change. // // `any` still passes through untouched — it is the one LIDL type that declares // nothing, so there is nothing to check it against. QString jsonArgToStd(const TypeExpr& te, const QString& expr, const QString& path, const std::set& recs) { // `?T` — decode is LIBERAL, and only by exactly one inhabitant. // // null decodes to empty; anything else is decoded as T by the SAME decoder a // required T would get, so a present-but-wrong value fails with the same // message at the same path. Optional widens the domain, it does not switch // type checking off. if (te.kind == TypeExpr::Optional && !te.elements.empty()) { const QString cpp = lidlTypeToStdCdylib(te, recs); const TypeExpr& vt = optionalValueType(te); // `?any` collapses onto `any` (see lidlTypeToStdCdylib): untyped JSON // already carries null, so there is no wrapper to build. if (!cpp.startsWith("std::optional<")) return jsonArgToStd(vt, expr, path, recs); // A scalar `bstr` argument does NOT go through the codec — it gets the // lenient bytes decode, so a caller may send the tagged form, a plain // string, a number or a byte array. `?bstr` has to keep that, or the // identical value would be accepted in a required slot and rejected in // an optional one. Test for the empty inhabitant here and wrap. if (vt.kind == TypeExpr::Primitive && vt.name == "bstr") return "(" + expr + ".is_null() ? " + cpp + "() : " + cpp + "(" + jsonArgToStd(vt, expr, path, recs) + "))"; // Everything else names std::optional and lets // Codec> map null -> nullopt in one expression. return "logos::fromJson<" + cpp + ">(" + expr + ", \"" + path + "\")"; } if (te.kind == TypeExpr::Primitive) { if (te.name == "bstr") return "logos::bytesFromJsonLenient(" + expr + ", \"" + path + "\")"; if (te.name == "any") return expr; } const QString cpp = lidlTypeToStdCdylib(te, recs); if (cpp == "LogosMap" || cpp == "LogosList") return expr; // untyped JSON passes through, as it always has // A TYPED map does not NAME its C++ type — it hands the compiler a proxy and // lets the author's own declaration pick it. // // `{tstr: T}` has two C++ spellings, std::map and std::unordered_map, and // logos_codec.h specializes Codec for both. Naming one of them here would // silently make the other a compile error in generated code the author never // wrote: `logos::fromJson>` returns a std::map, and a std::map // does not convert to an unordered_map parameter. logos::JsonArg instantiates // the conversion with the EXACT parameter type instead, so both spellings // decode — through the same Codec, with the same path in the same error. // // Only maps: every other LIDL type has exactly one C++ spelling here, and // JsonArg documents one type it cannot serve (std::optional, whose own // converting constructor out-ranks the proxy's conversion operator) — the // Optional branch above returns before reaching this line. if (te.kind == TypeExpr::Map) return "logos::JsonArg(" + expr + ", \"" + path + "\")"; return "logos::fromJson<" + cpp + ">(" + expr + ", \"" + path + "\")"; } // std-typed return variable -> json expression QString stdReturnToJson(const MethodDecl& md, const QString& var, const std::set& recs) { const TypeExpr& te = md.returnType; if (md.resultReturn) { // StdLogosResult -> the canonical {success, value, error} object // (same shape logos_json_convert emits for Qt LogosResult). return "lidlResultToJson(" + var + ")"; } // `jsonReturn` is set by the front end for any map/list return, but that no // longer implies the C++ type IS nlohmann::json: a TYPED map now spells // std::map. Checking the flag before the spelling emitted // `result.dump()` on a std::map. The spelling decides. const QString cppRet = lidlTypeToStdCdylib(te, recs); if (md.jsonReturn && (cppRet == "LogosMap" || cppRet == "LogosList")) { return var; // LogosMap / LogosList are nlohmann::json already } if (te.kind == TypeExpr::Primitive) { if (te.name == "bstr") return "logos::bytesToJson(" + var + ")"; if (te.name == "any") return var; return "nlohmann::json(" + var + ")"; } if (cppRet == "LogosMap" || cppRet == "LogosList") return var; // Same reason the map ARGUMENT does not name its type: `{tstr: T}` is both // std::map and std::unordered_map, so let the return variable's own type be // deduced rather than asserting one of them. if (te.kind == TypeExpr::Map) return "logos::toJson(" + var + ")"; // `nlohmann::json(v)` would serialize a vector as a plain number // array and a record not at all; the codec keeps bytes tagged at depth. return "logos::toJson<" + cppRet + ">(" + var + ")"; } // Qt-free spelling of a LIDL type. lidlTypeToStd() falls back to Qt containers // (QVariant / QVariantMap / QVariantList) for the composite types, but a cdylib // TU is Qt-free by definition and typeSupported() admits `any` and maps — so // spell those as their nlohmann aliases (LogosMap / LogosList) instead. Without // this the events sidecar emits a bare `QVariant` parameter and does not // compile. QString lidlTypeToStdCdylib(const TypeExpr& te, const std::set& recs) { // `?T` -> std::optional, EXCEPT over the untyped-JSON aliases. // // LogosMap / LogosList are nlohmann::json, and json already has `null` among // its inhabitants — so std::optional would give `?any` TWO empty // spellings (nullopt and json(null)) and make it three-state, which is // exactly what R1 forbids. `?any` therefore collapses onto `any`: same two // states, one C++ type. (logos-lidl's validator warns on `?any` for the same // reason, and the warning is about the spelling, not about this mapping.) if (te.kind == TypeExpr::Optional && !te.elements.empty()) { const QString inner = lidlTypeToStdCdylib(optionalValueType(te), recs); if (inner == "LogosMap" || inner == "LogosList") return inner; return "std::optional<" + inner + ">"; } if (te.kind == TypeExpr::Primitive && te.name == "any") return "LogosMap"; // `{tstr: any}` and `[any]` keep their nlohmann aliases: every existing // universal module spells them that way, and narrowing them would be a // source break for no gain (they ARE untyped JSON). if (te.kind == TypeExpr::Map && te.elements.size() == 2 && te.elements[1].kind == TypeExpr::Primitive && te.elements[1].name == "any") return "LogosMap"; if (te.kind == TypeExpr::Array && te.elements.size() == 1 && te.elements[0].kind == TypeExpr::Primitive && te.elements[0].name == "any") return "LogosList"; // A declared record is its generated struct. if (isRecord(te, recs)) return qs(te.name); // Recurse, so [bstr] is std::vector> and {tstr: Blob} // is std::map. lidlTypeToStd() would answer QVariantList // / QVariantMap here — a Qt name in a Qt-FREE translation unit, which only // failed to appear because the gate used to reject these types. Widening // the gate makes that fallback a live leak, so composites must never reach // it. if (te.kind == TypeExpr::Array && te.elements.size() == 1) return "std::vector<" + lidlTypeToStdCdylib(te.elements[0], recs) + ">"; if (te.kind == TypeExpr::Map && te.elements.size() == 2) return "std::map"; return lidlTypeToStd(te); } // The C++ spelling of a RECORD FIELD, honouring both optionality spellings. // // `? name: T` and `name: ?T` are the same declaration and must produce // byte-identical code (logos-lidl docs/spec.md, "Optionality"). That only holds // because fieldIsOptional()/fieldValueType() reconcile them in the frontend — // spelling one of the two out here would reintroduce the drift they exist to // prevent. Never write `f.optional` or `f.type.kind == Optional` in a backend. QString lidlFieldTypeCdylib(const FieldDecl& f, const std::set& recs) { if (!fieldIsOptional(f)) return lidlTypeToStdCdylib(f.type, recs); const QString inner = lidlTypeToStdCdylib(fieldValueType(f), recs); // Same collapse as lidlTypeToStdCdylib: untyped JSON already has null. if (inner == "LogosMap" || inner == "LogosList") return inner; return "std::optional<" + inner + ">"; } // True when anything in the contract is optional — a record field by either // spelling, a method parameter or return, or an event parameter. Gates the // `#include ` in the generated TUs, so a contract that declares no // optional keeps its output byte-for-byte unchanged. bool moduleUsesOptional(const ModuleDecl& module) { std::function mentions = [&](const TypeExpr& t) -> bool { if (t.kind == TypeExpr::Optional) return true; for (const TypeExpr& e : t.elements) if (mentions(e)) return true; return false; }; for (const TypeDecl& t : module.types) for (const FieldDecl& f : t.fields) if (fieldIsOptional(f) || mentions(f.type)) return true; for (const MethodDecl& md : module.methods) { if (mentions(md.returnType)) return true; for (const ParamDecl& pd : md.params) if (mentions(pd.type)) return true; } for (const EventDecl& ed : module.events) for (const ParamDecl& pd : ed.params) if (mentions(pd.type)) return true; return false; } // True when the module declares at least one `bstr` event parameter — the only // reason the events sidecar needs the bytes encoder. Emitting it unconditionally // leaves an unused static function (a -Wunused-function warning) in every module // whose events carry no binary data. // ── The generated codec ───────────────────────────────────────────────────── // // Emitted into the module's types header so the author's impl class and the // generated dispatch share one definition of how a value crosses the wire. // // This is deliberately the same SHAPE as logos-protocol's logos_codec.h — and // it exists as generated code only because that header cannot currently be // included here: logos_json.h (which every universal module pulls in for // LogosMap) and logos_codec.h both define logos::b64UrlEncode / // b64UrlDecode / bytesToJson as inline, so including both in one translation // unit is a redefinition error. Unify when that is resolved; the emitted // specializations would then be the only generated part. // // The primary template is intentionally left UNDEFINED: an unsupported T is a // compile error naming the type, never a silent default-constructed value. // Emits ONE specialization per record the module declares — and nothing else. // // The generic half (scalars, bstr, the vector/map composition, the error paths) // used to be emitted here too, ~186 lines of C++-emitting-C++ that mirrored // logos-protocol's logos_codec.h by hand. It no longer is: logos_json.h stopped // defining byte helpers that collided with that header, so a module TU can now // include the canonical codec directly. // // That duplication was not free. The two copies had drifted (the emitted integer // decode gated on is_number() where the canonical one checked // is_number_integer() || is_number_unsigned()), they disagreed on padded base64, // and every codec fix had to be written twice or it silently only half-applied. // // What remains is irreducible: a LIDL `type` is a per-contract struct whose field // names and member types exist only in this module's header, and C++17 has no // field reflection. Nesting composes for free — Codec> and // deeper come from the shared generic half once Codec<::Blob> exists. void emitRecordCodecs(QTextStream& s, const ModuleDecl& module, const std::set& recs) { if (module.types.empty()) return; // Reopened so the specializations land beside the primary template they // specialize. `::Name` because the author's record types are at global // scope, while this is namespace logos::detail — without the qualifier the // name would resolve inside logos::. s << "namespace logos { namespace detail {\n\n"; // One specialization per declared record. Field order follows the contract. for (const TypeDecl& t : module.types) { const QString name = qs(t.name); s << "template <> struct Codec<::" << name << ", void> {\n"; s << " static nlohmann::json to(const " << name << "& v) {\n"; s << " nlohmann::json out = nlohmann::json::object();\n"; for (const FieldDecl& f : t.fields) { const QString ft = lidlFieldTypeCdylib(f, recs); const QString fn = qs(f.name); if (ft.startsWith("std::optional<")) { // ENCODE: a record field is a NAMED slot, so empty is spelled by // OMITTING the key — never by writing null. This is the half of // the rule Codec> deliberately cannot do: a // codec only ever sees a VALUE, so it emits the positional // spelling (null) and leaves key omission to the one place that // knows there IS a key. That place is here. // // The round trip is therefore CANONICALISING, not identity: a // peer that sent `"f": null` gets the key back omitted, and both // spellings mean the same state. const QString vt = lidlTypeToStdCdylib(fieldValueType(f), recs); s << " if (v." << fn << ".has_value())\n"; s << " out[\"" << fn << "\"] = Codec<" << vt << ">::to(*v." << fn << ");\n"; } else { s << " out[\"" << fn << "\"] = Codec<" << ft << ">::to(v." << fn << ");\n"; } } s << " return out;\n }\n"; s << " static " << name << " from(const nlohmann::json& j, const std::string& path) {\n"; s << " if (!j.is_object()) detail::typeError(path, \"object\", j);\n"; s << " " << name << " out;\n"; for (const FieldDecl& f : t.fields) { const QString ft = lidlFieldTypeCdylib(f, recs); const QString fn = qs(f.name); // A missing field is reported at its own path rather than // default-constructed: a record that silently loses a field is the // failure mode this whole layer exists to prevent. // // DECODE needs no optional branch, and that is the point: an absent // key is already materialised as null right here, so absent and // explicit null arrive at the codec indistinguishable. In an // optional field Codec> answers nullopt for both; // in a required one Codec still rejects both. One expression, // both halves of the rule. s << " out." << fn << " = Codec<" << ft << ">::from(\n"; s << " j.contains(\"" << fn << "\") ? j.at(\"" << fn << "\") : nlohmann::json(),\n"; s << " path + \"." << fn << "\");\n"; } s << " return out;\n }\n};\n\n"; } s << "}} // namespace logos::detail\n\n"; } // The Qt spelling of what actually crosses the Qt boundary. // // NOT lidlTypeToQt: that answers the CONSUMER's question ("what type does the // caller hold?") and since records became real structs it answers `Blob` / // `QList`. Those names are correct in a generated consumer wrapper, where // the struct exists — but this JSON is the module's getMethods(), read by the // host to marshal a QVariant across the plugin boundary, and there is no // metatype called `Blob`. Emitting it made the host SIGSEGV on the first call // to any record method. // // A record IS a variant map at that boundary; the struct only exists inside the // cdylib. QString lidlTypeToQtWire(const TypeExpr& te, const std::set& recs) { if (isRecord(te, recs)) return "QVariantMap"; if (te.kind == TypeExpr::Array && te.elements.size() == 1 && isRecord(te.elements[0], recs)) return "QVariantList"; if (te.kind == TypeExpr::Map && te.elements.size() == 2 && isRecord(te.elements[1], recs)) return "QVariantMap"; return lidlTypeToQt(te); } // True when any event parameter is spelled LogosMap / LogosList, so the sidecar // needs for those aliases. bool hasJsonEventParam(const ModuleDecl& module) { const std::set recs = recordNames(module); for (const EventDecl& ed : module.events) for (const ParamDecl& pd : ed.params) { const QString t = lidlTypeToStdCdylib(pd.type, recs); if (t == "LogosMap" || t == "LogosList") return true; } return false; } // The generated base64 codec is GONE — all of it. // // #117 replaced the emitted generic codec with logos-protocol's logos_codec.h, // but left behind the base64 pair it had grown around: an encoder // (lidlB64UrlEncode / lidlBytesToJson) and a decoder (lidlB64Idx / // lidlBytesFromJson), ~89 emitted lines in every module's export TU. The decoder // had no call site at all — every byte parameter had already moved to // logos::bytesFromJsonLenient — and the encoder was a byte-for-byte reimplementation // of logos::bytesToJson, which is included via in the very same // translation unit. // // A second copy of an encoder is not free: this is the arrangement that let the // emitted and canonical halves drift over padded base64 once already, and it is // exactly the duplication #117's own comment set out to end. Scalar `bstr` slots // now call logos::bytesToJson directly, which is what every composite slot // (`[bstr]`, `{tstr: bstr}`, records) has been doing through logos::Codec since // #117. void emitInterfaceJson(QTextStream& s, const ModuleDecl& module) { const std::set recs = recordNames(module); s << "static nlohmann::json lidlInterfaceJson()\n{\n"; s << " nlohmann::json methods = nlohmann::json::array();\n"; for (const MethodDecl& md : module.methods) { s << " {\n nlohmann::json obj;\n"; s << " obj[\"name\"] = \"" << md.name << "\";\n"; if (!md.description.empty()) { QString esc = qs(md.description); esc.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n"); s << " obj[\"description\"] = \"" << esc << "\";\n"; } QString sig = qs(md.name) + "("; for (int i = 0; i < md.params.size(); ++i) { sig += lidlTypeToQtWire(md.params[i].type, recs); if (i + 1 < md.params.size()) sig += ","; } sig += ")"; s << " obj[\"signature\"] = \"" << sig << "\";\n"; s << " obj[\"returnType\"] = \"" << lidlTypeToQtWire(md.returnType, recs) << "\";\n"; s << " obj[\"isInvokable\"] = true;\n"; if (!md.params.empty()) { s << " nlohmann::json params = nlohmann::json::array();\n"; for (const ParamDecl& pd : md.params) { s << " params.push_back({{\"type\", \"" << lidlTypeToQtWire(pd.type, recs) << "\"}, {\"name\", \"" << pd.name << "\"}});\n"; } s << " obj[\"parameters\"] = params;\n"; } s << " methods.push_back(obj);\n }\n"; } for (const EventDecl& ed : module.events) { s << " {\n nlohmann::json obj;\n"; s << " obj[\"type\"] = \"event\";\n"; s << " obj[\"name\"] = \"" << ed.name << "\";\n"; if (!ed.description.empty()) { QString esc = qs(ed.description); esc.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n"); s << " obj[\"description\"] = \"" << esc << "\";\n"; } QString sig = qs(ed.name) + "("; for (int i = 0; i < ed.params.size(); ++i) { sig += lidlTypeToQtWire(ed.params[i].type, recs); if (i + 1 < ed.params.size()) sig += ","; } sig += ")"; s << " obj[\"signature\"] = \"" << sig << "\";\n"; if (!ed.params.empty()) { s << " nlohmann::json params = nlohmann::json::array();\n"; for (const ParamDecl& pd : ed.params) { s << " params.push_back({{\"type\", \"" << lidlTypeToQtWire(pd.type, recs) << "\"}, {\"name\", \"" << pd.name << "\"}});\n"; } s << " obj[\"parameters\"] = params;\n"; } s << " methods.push_back(obj);\n }\n"; } s << " return methods;\n}\n\n"; } } // namespace bool lidlCdylibSupported(const ModuleDecl& module, QString* error) { const std::set recs = recordNames(module); for (const MethodDecl& md : module.methods) { for (const ParamDecl& pd : md.params) { if (!typeSupported(pd.type, /*isReturn=*/false, recs)) { if (error) *error = QString("method '%1': parameter '%2' has a type outside the " "cdylib-supported (Qt-free) subset") .arg(qs(md.name), qs(pd.name)); return false; } } // `void` is not a lidlBuiltinType, so the .lidl parser yields it as a // Named type "void" (the impl-header parser writes "-> void"); an empty // name is the in-memory void from the header path. Treat both as void. const bool voidReturn = md.returnType.name == "void" || (md.returnType.kind == TypeExpr::Primitive && md.returnType.name.empty()); if (!voidReturn && !md.jsonReturn && !md.resultReturn && !typeSupported(md.returnType, /*isReturn=*/true, recs)) { if (error) *error = QString("method '%1': return type outside the cdylib-supported " "(Qt-free) subset").arg(qs(md.name)); return false; } } for (const EventDecl& ed : module.events) { for (const ParamDecl& pd : ed.params) { if (!typeSupported(pd.type, /*isReturn=*/false, recs)) { if (error) *error = QString("event '%1': parameter '%2' has a type outside the " "cdylib-supported (Qt-free) subset") .arg(qs(ed.name), qs(pd.name)); return false; } } } return true; } QString lidlMakeTypesHeaderCdylib(const ModuleDecl& module) { const std::set recs = recordNames(module); QString c; QTextStream s(&c); s << "// AUTO-GENERATED by logos-cpp-generator --backend cdylib -- do not edit\n"; s << "//\n"; s << "// The record types `" << module.name << "` declares, plus the codec that moves\n"; s << "// them across the wire. Qt-FREE. The author's impl header includes this and\n"; s << "// writes the structs directly:\n"; s << "//\n"; s << "// Blob echoBlob(const Blob& v);\n"; s << "//\n"; s << "// rather than picking fields out of a LogosMap.\n"; s << "#pragma once\n"; s << "#include \n"; // LogosMap / LogosList aliases s << "#include \n"; // logos::Codec — the ONE definition s << "#include \n"; s << "#include \n"; // Only when the contract actually declares an optional: logos_codec.h // already pulls in, so this is documentation of what the emitted // codec names — and emitting it unconditionally would rewrite the types // header of every contract that has no optional at all. if (moduleUsesOptional(module)) s << "#include \n"; s << "#include \n"; s << "#include \n\n"; // The structs themselves are the AUTHOR's: this file is included after the // impl header, and the contract was derived from those very declarations, // so emitting them again is a redefinition error. Only forward // declarations, so the codec below can name them in any order. if (!module.types.empty()) { for (const TypeDecl& t : module.types) s << "struct " << qs(t.name) << ";\n"; s << "\n"; } emitRecordCodecs(s, module, recs); return c; } QString lidlMakeModuleImplExports(const ModuleDecl& module, const QString& implClass, const QString& implHeader) { const std::set recs = recordNames(module); QString c; QTextStream s(&c); s << "// AUTO-GENERATED by logos-cpp-generator --cdylib -- do not edit\n"; s << "//\n"; s << "// The common module-impl C ABI exports (logos_module_impl.h) around the\n"; s << "// universal impl class `" << implClass << "`. Qt-FREE: compiled into the\n"; s << "// module's cdylib; the uniform Qt-plugin glue (or a future no-Qt host)\n"; s << "// drives it exclusively through these symbols.\n"; s << "#include \"" << implHeader << "\"\n"; s << "#include \"" << module.name << "_types.h\"\n"; s << "#include \"logos_module_impl.h\"\n"; s << "#include \"logos_protocol.h\"\n"; s << "#include \"logos_module_context.h\"\n"; s << "#include \"logos_result.h\"\n"; s << "#include \n"; s << "#include \n"; s << "#include \n"; s << "#include \n"; s << "#include \n"; s << "#include \n"; if (moduleUsesOptional(module)) s << "#include \n"; s << "#include \n"; s << "#include \n"; // The Qt-free typed dependency surface: LogosModules (behind modules()) // built from this module's dependencies (metadata.json#dependencies), // calling the lp_* C ABI — no Qt in the cdylib. The umbrella codegen // emits logos_sdk.h for every cdylib module (empty when there are no // dependencies), so this include is always available. s << "#include \"logos_sdk.h\"\n"; s << "\n"; // -- shared statics ------------------------------------------------------ s << "namespace {\n\n"; s << implClass << "& lidlImpl()\n{\n static " << implClass << " impl;\n return impl;\n}\n\n"; s << "logos_module_emit_cb g_emitCb = nullptr;\n"; s << "void* g_emitUd = nullptr;\n"; s << "std::mutex g_emitMutex;\n"; s << "std::mutex g_ctxMutex;\n"; s << "bool g_ctxStored = false;\n"; s << "std::string g_ctxPath, g_ctxId, g_ctxPersist;\n"; s << "std::atomic g_hookFired{false};\n\n"; s << "char* lidlStrdup(const std::string& str)\n{\n"; s << " char* out = static_cast(std::malloc(str.size() + 1));\n"; s << " if (out) std::memcpy(out, str.data(), str.size() + 1);\n"; s << " return out;\n}\n\n"; s << "nlohmann::json lidlResultToJson(const StdLogosResult& r)\n{\n"; s << " nlohmann::json obj;\n"; s << " obj[\"success\"] = r.success;\n"; s << " obj[\"value\"] = r.value;\n"; s << " obj[\"error\"] = r.error.empty() ? nlohmann::json() : nlohmann::json(r.error);\n"; s << " return obj;\n}\n\n"; emitInterfaceJson(s, module); s << "} // namespace\n\n"; // -- event wiring (install once, lazily) --------------------------------- s << "static void lidlEnsureEmitWiring()\n{\n"; s << " static std::once_flag once;\n"; s << " std::call_once(once, []() {\n"; s << " _logos_codegen_::maybeSetEmitEvent(lidlImpl(),\n"; s << " [](const std::string& name, void* args) {\n"; s << " // cdylib events sidecar marshals into nlohmann::json\n"; s << " const nlohmann::json* payload = static_cast(args);\n"; s << " std::lock_guard lock(g_emitMutex);\n"; s << " if (g_emitCb) {\n"; s << " const std::string dumped = payload ? payload->dump() : \"[]\";\n"; s << " g_emitCb(name.c_str(), dumped.c_str(), g_emitUd);\n"; s << " }\n"; s << " });\n"; s << " });\n}\n\n"; // -- typed dependency surface (modules()....) ----------------------- // Wire modules() INDEPENDENTLY of the persistence context. Each dependency // client bakes its target+origin at codegen time and creates its lp client // lazily on first call, so modules() needs nothing from the context. A // module with deps but no STORED context still must have it wired — gating // it on the context latch (as it used to be) left m_logosModulesPtr null and // segfaulted the first cross-module call when the daemon never delivered a // context. No-op for impls that don't derive LogosModuleContext. Fired once // from the FIRST lidlTryFireContext (i.e. the first dispatch / set_context / // set_emit_callback), before the context-gated early return below. s << "static void lidlEnsureModulesWired()\n{\n"; s << " static std::once_flag once;\n"; s << " std::call_once(once, []() {\n"; s << " _logos_codegen_::maybeSetLogosModules(lidlImpl(), new LogosModules());\n"; s << " });\n}\n\n"; // The context ready-latch: stamp the context + fire onContextReady ONCE, // as soon as the module is fully wired (context stored AND the emit // callback delivered) — at module load, before publication. Hosts that // never wire an emit callback still get the hook before first dispatch // (requireEmit = false fallback). s << "static void lidlTryFireContext(bool requireEmit)\n{\n"; s << " lidlEnsureEmitWiring();\n"; s << " lidlEnsureModulesWired();\n"; s << " if (g_hookFired.load(std::memory_order_acquire)) return;\n"; s << " std::string path, id, persist;\n"; s << " {\n"; s << " std::lock_guard lock(g_ctxMutex);\n"; s << " if (!g_ctxStored) return;\n"; s << " path = g_ctxPath; id = g_ctxId; persist = g_ctxPersist;\n"; s << " }\n"; s << " if (requireEmit) {\n"; s << " std::lock_guard lock(g_emitMutex);\n"; s << " if (!g_emitCb) return;\n"; s << " }\n"; s << " g_hookFired.store(true, std::memory_order_release);\n"; // modules() was already wired by lidlEnsureModulesWired() above (before this // context-gated early return), so onContextReady can safely call // modules().... / subscribe to dependency events from the hook. // The module's own registry name, which the generator knows statically. // Set BEFORE the context so moduleName() is live inside onContextReady(). s << " _logos_codegen_::maybeSetModuleName(lidlImpl(), \"" << module.name << "\");\n"; s << " _logos_codegen_::maybeSetContext(lidlImpl(), path, id, persist);\n"; s << "}\n\n"; // -- exports ------------------------------------------------------------- s << "extern \"C\" {\n\n"; s << "char* logos_module_dispatch(const char* method, const char* args_json)\n{\n"; s << " if (!method) return nullptr;\n"; s << " lidlTryFireContext(false);\n"; s << " nlohmann::json args = nlohmann::json::array();\n"; s << " if (args_json && *args_json) {\n"; s << " args = nlohmann::json::parse(args_json, nullptr, false);\n"; s << " if (args.is_discarded() || !args.is_array()) return nullptr;\n"; s << " }\n"; s << " const std::string m(method);\n"; s << " try {\n"; for (const MethodDecl& md : module.methods) { // The arity gate, and the one place the LIBERAL half of the decode rule // reaches a POSITIONAL slot. // // A canonical encoder never changes arity: an empty positional slot is // spelled null and still occupies its position. But absent and null are // the same state on decode, so an optional trailing argument may also // simply not be there. The gate therefore admits anything from the last // REQUIRED parameter onwards, and each optional beyond it materialises // as null exactly the way an absent record field already does. Below // that point nothing changes: a missing required argument is still a // hard reject, and a contract with no optional parameters emits the // byte-identical `args.size() < ` it always did. size_t minArgs = 0; for (size_t i = 0; i < md.params.size(); ++i) if (!paramIsOptional(md.params[i])) minArgs = i + 1; s << " if (m == \"" << md.name << "\") {\n"; // A wrong argument COUNT is reported, not swallowed. // // This used to be `return nullptr`, and the Qt glue turns a NULL reply // into an empty QVariant — indistinguishable from a method that // legitimately returned nothing. "You passed 2 of 4 arguments" looked // like a successful empty answer. // // The shape is the one logos-rust-sdk's args::invalid_args() already // emits (src/args.rs), so a C++ and a Rust provider answer a malformed // call identically — which is what that module's // invalid_args_shape_matches_cpp test claims, and what was not true // until now. Same three keys, same message text, same `origin`. // // Emitted only when the method has at least one REQUIRED parameter: // `args.size() < 0` is unsigned-compared and always false, so a zero-arg // method carried a dead branch (the Rust generator skips it for the same // reason). if (minArgs > 0) { s << " if (args.size() < " << minArgs << ") {\n"; s << " nlohmann::json err{{\"code\", \"invalid_args\"},\n"; s << " {\"message\", \"expected " << minArgs << " arguments, got \" + std::to_string(args.size())},\n"; s << " {\"origin\", \"" << module.name << "\"}};\n"; s << " return lidlStrdup(err.dump());\n"; s << " }\n"; } QString call = "lidlImpl()." + qs(md.name) + "("; for (size_t i = 0; i < md.params.size(); ++i) { const QString expr = (i < minArgs) ? QString("args.at(%1)").arg(i) : QString("(args.size() > %1 ? args.at(%1) : nlohmann::json())").arg(i); call += jsonArgToStd(md.params[i].type, expr, QString("arg%1").arg(i), recs); if (i + 1 < md.params.size()) call += ", "; } call += ")"; // `void` parses as a Named type "void" from a .lidl (it isn't a // lidlBuiltinType); empty name is the header path's in-memory void. const bool voidReturn = md.returnType.name == "void" || (md.returnType.kind == TypeExpr::Primitive && md.returnType.name.empty()) || lidlTypeToQt(md.returnType) == "void"; if (voidReturn) { s << " " << call << ";\n"; s << " return lidlStrdup(\"true\");\n"; } else { s << " auto result = " << call << ";\n"; s << " return lidlStrdup(" << stdReturnToJson(md, "result", recs) << ".dump());\n"; } s << " }\n"; } s << " } catch (const std::exception& e) {\n"; s << " nlohmann::json err{{\"code\", \"dispatch_failed\"}, {\"message\", e.what()},\n"; s << " {\"origin\", \"" << module.name << "\"}};\n"; s << " return lidlStrdup(err.dump());\n"; s << " }\n"; s << " return nullptr; // unknown method\n"; s << "}\n\n"; s << "char* logos_module_get_methods(void)\n{\n"; s << " return lidlStrdup(lidlInterfaceJson().dump());\n}\n\n"; s << "void logos_module_set_context(const char* module_path,\n"; s << " const char* instance_id,\n"; s << " const char* instance_persistence_path)\n{\n"; s << " {\n"; s << " std::lock_guard lock(g_ctxMutex);\n"; s << " g_ctxPath = module_path ? module_path : \"\";\n"; s << " g_ctxId = instance_id ? instance_id : \"\";\n"; s << " g_ctxPersist = instance_persistence_path ? instance_persistence_path : \"\";\n"; s << " g_ctxStored = true;\n"; s << " }\n"; s << " lidlTryFireContext(true);\n"; s << "}\n\n"; s << "void logos_module_set_emit_callback(logos_module_emit_cb cb, void* user_data)\n{\n"; s << " {\n"; s << " std::lock_guard lock(g_emitMutex);\n"; s << " g_emitCb = cb;\n"; s << " g_emitUd = user_data;\n"; s << " }\n"; s << " lidlTryFireContext(true);\n"; s << "}\n\n"; s << "int logos_module_accept_token(const char* module_name, const char* token)\n{\n"; s << " if (!module_name || !token) return -1;\n"; s << " // Seed the protocol's shared TokenManager so this module's OUTBOUND\n"; s << " // lp_client (modules()....) can authenticate calls. In\n"; s << " // particular the capability_module bootstrap token the host\n"; s << " // delivers at load lets the automatic requestModule flow fetch a\n"; s << " // per-target token on the first cross-module call. lp_token_save\n"; s << " // writes the same TokenManager::instance() the lp_client reads.\n"; s << " return lp_token_save(module_name, token);\n}\n\n"; // Guarded on the protocol MINOR that introduced the trust-root surface // (0.3). The emitted module must still COMPILE against an older // logos-protocol, which has neither lp_grant_host_services nor the // logos_module_impl.h declaration — a module built against 0.2 simply has // no grant entry point, which is the same fail-closed state as never being // granted. Without this an older protocol is a hard compile error in // generated code the author never sees. s << "#if defined(LOGOS_PROTOCOL_VERSION_MINOR) && LOGOS_PROTOCOL_VERSION_MINOR >= 3\n"; s << "int logos_module_grant_host_services(const char* services_json)\n{\n"; s << " // Route the host's grant into THIS image's gate state.\n"; s << " //\n"; s << " // The grant has to travel over the C ABI rather than being\n"; s << " // recorded once by the host, and that is the whole reason this\n"; s << " // export exists: the host binary and this cdylib each link their\n"; s << " // own copy of logos-protocol, so each has its own process-global\n"; s << " // grant state, exactly as each has its own TokenManager. A grant\n"; s << " // the host records for itself is invisible to the gate a\n"; s << " // lp_token_keys() call checks HERE, so a gate 'simplified' into\n"; s << " // the host would silently never fire.\n"; s << " //\n"; s << " // Emitted unconditionally, for every module, rather than behind a\n"; s << " // codegen flag: which modules are privileged is the HOST's\n"; s << " // decision (it chooses what to push, and pushes nothing to an\n"; s << " // ordinary module), and lp_grant_host_services itself validates\n"; s << " // the names and fails closed. A per-module flag would only add a\n"; s << " // second place for the two to disagree.\n"; s << " //\n"; s << " // NOTE this is a declaration-and-audit boundary, NOT a defence\n"; s << " // against a hostile module: this cdylib links logos-protocol, so\n"; s << " // its own code can call lp_grant_host_services() directly and\n"; s << " // self-grant. What the gate buys is that the privilege is\n"; s << " // explicit, greppable and off by default, so no module acquires\n"; s << " // it by accident. Isolation between modules rests on process\n"; s << " // separation, the auth token and the target's allowedCallers.\n"; s << " return lp_grant_host_services(services_json);\n}\n"; s << "#endif\n\n"; s << "const char* logos_module_get_protocol_version(void)\n{\n"; s << " return LOGOS_PROTOCOL_VERSION_STRING;\n}\n\n"; s << "void logos_module_string_free(char* str)\n{\n"; s << " std::free(str);\n}\n\n"; s << "} // extern \"C\"\n"; return c; } QString lidlMakeEventsSourceCdylib(const ModuleDecl& module, const QString& implClass, const QString& implHeader) { QString c; QTextStream s(&c); s << "// AUTO-GENERATED by logos-cpp-generator --cdylib -- do not edit\n"; s << "// Typed `logos_events:` bodies, cdylib flavor: marshal into\n"; s << "// nlohmann::json and route through LogosModuleContext::emitEventImpl_\n"; s << "// (the export wrapper forwards to the host's emit callback).\n"; const std::set recsEv = recordNames(module); s << "#include \"" << implHeader << "\"\n"; s << "#include \"" << module.name << "_types.h\"\n"; s << "#include \n\n"; s << "#include \n"; s << "#include \n"; if (moduleUsesOptional(module)) s << "#include \n"; s << "#include \n"; s << "#include \n"; // LogosMap / LogosList (nlohmann aliases) appear in the emitted signatures // whenever an event carries a map or an `any` payload. if (hasJsonEventParam(module)) s << "#include \n"; s << "\n"; // No local bytes encoder any more, and so no hasBytesEventParam() gate for // it either: a `bstr` event parameter calls logos::bytesToJson, which the // pulled in by "_types.h" above already provides. // The gate existed only to keep the emitted copy from sitting unused in // modules whose events carry no binary data. for (const EventDecl& ed : module.events) { s << "void " << implClass << "::" << ed.name << "("; for (int i = 0; i < ed.params.size(); ++i) { const QString stdType = lidlTypeToStdCdylib(ed.params[i].type, recsEv); // Must match the author's declaration in the `logos_events:` block: // the non-scalar types are conventionally taken by const-ref there. // Records and std::map belong in that set too — they are structs and // containers, and emitting them BY VALUE makes the generated // definition not match the author's declaration, which is a compile // error naming a parameter type mismatch rather than anything // helpful. if (stdType == "std::string" || stdType.startsWith("std::vector") || stdType.startsWith("std::map") || stdType.startsWith("std::optional") || isRecord(ed.params[i].type, recsEv) || stdType == "LogosMap" || stdType == "LogosList") s << "const " << stdType << "& " << ed.params[i].name; else s << stdType << " " << ed.params[i].name; if (i + 1 < ed.params.size()) s << ", "; } s << ")\n{\n"; s << " nlohmann::json args = nlohmann::json::array();\n"; for (const ParamDecl& pd : ed.params) { const QString evStd = lidlTypeToStdCdylib(pd.type, recsEv); // A record or a composite carrying bytes rides the generated codec, // exactly like a method return — otherwise an event payload would be // the one place a bstr silently loses its tag. // // An optional joins them: an event parameter is a POSITIONAL slot, // so empty is spelled null and the argument list keeps its length. // Codec>::to answers exactly that. (`?any` collapsed // to LogosMap above and is excluded by the same guard the untyped // aliases always were.) if (evStd != "LogosMap" && evStd != "LogosList" && (isRecord(pd.type, recsEv) || pd.type.kind == TypeExpr::Array || pd.type.kind == TypeExpr::Map || pd.type.kind == TypeExpr::Optional)) { s << " args.push_back(logos::toJson<" << evStd << ">(" << pd.name << "));\n"; continue; } if (pd.type.kind == TypeExpr::Primitive && pd.type.name == "bstr") s << " args.push_back(logos::bytesToJson(" << pd.name << "));\n"; else s << " args.push_back(" << pd.name << ");\n"; } s << " emitEventImpl_(\"" << ed.name << "\", &args);\n"; s << "}\n\n"; } return c; }