Files
logos-cpp-sdk/cpp-generator/experimental/lidl_emit_common.cpp
T
Dario Gabriel LipicarandClaude Opus 5 340a2f72c9 feat(codegen): a lossless Qt type mapping — typed containers and optionals
`lidlTypeToQt` answered four different LIDL types with one Qt name. `[uint]`,
`[bstr]`, `[[uint]]` and `[any]` were all QVariantList; `{tstr: uint}` and
`{tstr: any}` were both QVariantMap; every `?T` was a bare QVariant. A Qt
consumer therefore lost, on the SAME contract, types that the std consumer next
door kept — it could not tell `?tstr` from `?uint`, and got no compile-time
check on any element.

The table is now recursive:

    [T]                     QList<qtOf(T)>          ([tstr] stays QStringList)
    {tstr: V}               QMap<QString, qtOf(V)>
    ?T                      std::optional<qtOf(T)>  (through optionalValueType,
                                                     so ??T stays two-state)
    any                     QVariant                 — KEPT, deliberately

`any` is the one row that must not widen: QVariant is the only Qt type that
holds bytes AND an exact uint64 AND arbitrary nesting at once, so every
narrower spelling would lose what it was chosen to carry. The rule is applied
at the LEAF, so anything whose element type bottoms out at `any` keeps the
QVariant-family spelling at every depth — `[any]` is QVariantList, `[[any]]`
still is, `{tstr: [any]}` is QVariantMap, `?any` is QVariant.

THE TRAP, and why this is not just a rename. A widened name must never reach
QVariant::fromValue / qvariant_cast / logos::qt::toWire as a WHOLE value.
logos-protocol's qvariantToNlohmann matches a CLOSED userType() set:
QList<qulonglong> is in none of it, so it serialises to JSON null. The decode
fails just as quietly — qvariant_cast<QList<qulonglong>> of a QVariantList
yields an EMPTY list. Neither direction warns. So every widened slot is encoded
and decoded by a generator-emitted ELEMENT LOOP, the shape the record cases
already used, and `lidlQtNeedsElementLoop` is the single predicate that decides
which slots need one.

The emitted loops take their source as a lambda PARAMETER, not a body-local
binding. They nest (`[[uint]]`), every level wants the same short names, and a
local — or a range-for over a name the loop itself declares — is then
self-referential: it compiles and reads uninitialised memory. Measured: three
round-trip tests died on SIGTRAP before the argument form.

THE STRING-KEYED EMITTER IS FROZEN, ON PURPOSE. generator_lib is keyed on flat
type NAMES (lidl_to_json flattens the contract before it gets there, because
that emitter also serves the metaobject-introspection path), so it cannot
derive the levels an element loop needs without parsing C++ type names back
into a tree. Every widened spelling is folded back to the name it produced
before (legacyQtBase), which keeps BOTH surfaces it feeds byte-for-byte
unchanged: the legacy Qt consumer, and the Qt-free lp one whose table is
DERIVED from it through mapParamTypeStd. Verified by generating a
28-method contract through both before and after: the diff is empty. The
widened types are spent in the TypeExpr-driven emitters instead
(lidl_gen_client.cpp here, lidl_gen_qt_consumer.cpp in logos-qt-sdk).

Also here, because both are consequences of the table becoming recursive:

  * lidlTypeToQt gained a record-name HOOK. A wrapper nests its record structs
    in the wrapper class, so a type written outside that scope must qualify
    them — and the emitters used to do that by matching the three shapes that
    could mention a record on the finished string. `?Point` and
    `QList<QList<Point>>` are now spellable, so the qualification happens
    during the walk, at the one place that knows a name is a record.
  * lidlTypeToLidlText — the LIDL contract spelling of a type. Unused here; the
    commit that follows puts getMethods() on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:17:40 -03:00

232 lines
11 KiB
C++

#include "lidl_emit_common.h"
QString lidlToPascalCase(const QString& name)
{
QString out;
bool cap = true;
for (QChar c : name) {
if (!c.isLetterOrNumber()) { cap = true; continue; }
if (cap) { out.append(c.toUpper()); cap = false; }
else { out.append(c.toLower()); }
}
if (out.isEmpty()) return QString("Module");
return out;
}
// A type "bottoms out at `any`" when its scalar LEAF is `any` — or an
// unrecognised primitive, which this table has always spelled QVariant too.
// Optionality and container nesting are transparent to the question:
// `[[any]]`, `{tstr: [any]}` and `?any` all bottom out at `any`.
bool lidlQtBottomsOutAtAny(const TypeExpr& te)
{
switch (te.kind) {
case TypeExpr::Primitive:
return !(te.name == "void" || te.name == "tstr" || te.name == "bstr"
|| te.name == "int" || te.name == "uint" || te.name == "float64"
|| te.name == "bool" || te.name == "result");
case TypeExpr::Named:
// A record declared by the contract: a real struct, never a blob.
return false;
case TypeExpr::Array:
// A degenerate Array carrying no element (unreachable from the parser,
// constructible by hand or over the JSON bridge) keeps the opaque
// spelling rather than being described as typed.
return te.elements.size() != 1 || lidlQtBottomsOutAtAny(te.elements[0]);
case TypeExpr::Map:
return te.elements.size() != 2 || lidlQtBottomsOutAtAny(te.elements[1]);
case TypeExpr::Optional:
// Through optionalValueType(), so `??T` answers for T — optionality is
// idempotent under the two-state rule.
return te.elements.empty() || lidlQtBottomsOutAtAny(optionalValueType(te));
}
return true;
}
bool lidlQtNeedsElementLoop(const TypeExpr& te)
{
if (lidlQtBottomsOutAtAny(te)) return false; // QVariant / List / Map
switch (te.kind) {
case TypeExpr::Array:
// `[tstr]` is QStringList, which crosses whole (QMetaType::QStringList
// is in qvariantToNlohmann's closed set). Every other typed array is
// QList<T>, which is not.
return !(te.elements[0].kind == TypeExpr::Primitive
&& te.elements[0].name == "tstr");
case TypeExpr::Map:
case TypeExpr::Optional:
return true;
case TypeExpr::Primitive:
case TypeExpr::Named:
return false;
}
return false;
}
// The LIDL contract spelling. Mirrors logos-lidl's serializeTypeExpr; see the
// header for why it is a copy and what pins it.
QString lidlTypeToLidlText(const TypeExpr& te)
{
switch (te.kind) {
case TypeExpr::Primitive:
case TypeExpr::Named:
return QString::fromStdString(te.name);
case TypeExpr::Array:
if (te.elements.size() != 1) return QStringLiteral("any");
return "[" + lidlTypeToLidlText(te.elements[0]) + "]";
case TypeExpr::Map:
if (te.elements.size() != 2) return QStringLiteral("any");
return "{" + lidlTypeToLidlText(te.elements[0]) + ": "
+ lidlTypeToLidlText(te.elements[1]) + "}";
case TypeExpr::Optional:
if (te.elements.empty()) return QStringLiteral("any");
return "? " + lidlTypeToLidlText(te.elements[0]);
}
return QStringLiteral("any");
}
QString lidlTypeToQt(const TypeExpr& te)
{
return lidlTypeToQt(te, [](const QString& n) { return n; });
}
QString lidlTypeToQt(const TypeExpr& te,
const std::function<QString(const QString&)>& recordName)
{
switch (te.kind) {
case TypeExpr::Primitive:
if (te.name == "void") return "void";
if (te.name == "tstr") return "QString";
if (te.name == "bstr") return "QByteArray";
// 64-bit, and unsigned stays unsigned. LIDL int/uint are int64_t/uint64_t
// everywhere else (C++ impls, Rust's i64/u64), so spelling them `int`
// here broke the 1-1 mapping and truncated: a Qt consumer reading a
// `uint` return got a SIGNED 32-bit value. qlonglong/qulonglong rather
// than qint64/quint64 so the generated introspection matches the names
// Qt's own metaobject normalisation produces.
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";
// `any` — KEPT untyped, and it is the only row here that is. QVariant is
// the sole Qt type that carries bytes AND an exact uint64 AND arbitrary
// nesting, so narrowing it would lose what it was chosen to hold.
if (te.name == "any") return "QVariant";
return "QVariant";
case TypeExpr::Named:
// A record declared by the contract: its generated struct. One LIDL
// type, one type per language — a record is not a QVariant blob.
return recordName(QString::fromStdString(te.name));
case TypeExpr::Array:
// `[any]` (and anything else whose leaf is `any`) keeps QVariantList:
// there is no narrower Qt list that can hold those elements.
if (lidlQtBottomsOutAtAny(te)) return "QVariantList";
// `[tstr]` is QStringList — the one typed array Qt has a native
// spelling for, and the one this table already produced.
if (te.elements[0].kind == TypeExpr::Primitive
&& te.elements[0].name == "tstr") {
return "QStringList";
}
// Every other `[T]` — including a list of records, which could not ride
// a QVariantList without Q_DECLARE_METATYPE — is the typed list. The
// element spelling is this same table applied recursively, so
// `[[uint]]` is QList<QList<qulonglong>> and `[?tstr]` is
// QList<std::optional<QString>>.
return "QList<" + lidlTypeToQt(te.elements[0], recordName) + ">";
case TypeExpr::Map:
if (lidlQtBottomsOutAtAny(te)) return "QVariantMap";
// The key is spelled QString unconditionally, as it always has been: a
// JSON object key IS a string, so a contract that writes a non-tstr key
// does not change what crosses the wire.
return "QMap<QString, " + lidlTypeToQt(te.elements[1], recordName) + ">";
case TypeExpr::Optional:
// `?T` -> std::optional<T>. This row used to be a bare QVariant and was
// the ONE mapping in this table that lost the value type: a Qt consumer
// could not tell `?tstr` from `?uint`, while the std surface next door
// kept both through std::optional.
//
// The objection that kept it QVariant was that the name is read as a
// METATYPE — the legacy consumer path and getMethods() introspection
// both handed it to the host to marshal, and there is no metatype called
// `std::optional<QString>`. Both halves of that are now false:
// getMethods() publishes the LIDL spelling (lidlTypeToQtWire), and the
// string-keyed legacy emitter folds every widened spelling back to the
// name it used before (legacyQtBase in generator_lib.cpp). What is left
// reading this row is the TypeExpr-driven Qt emitters, which emit
// element loops rather than a metatype lookup.
//
// Recursed through optionalValueType() rather than elements[0], because
// optionality is idempotent under the two-state rule: `??T` denotes the
// same two states as `?T` and must not become
// std::optional<std::optional<T>>. A degenerate Optional carrying no
// element keeps the opaque fallback instead of recursing forever
// (lidlQtBottomsOutAtAny answers true for it).
if (lidlQtBottomsOutAtAny(te)) return "QVariant";
return "std::optional<" + lidlTypeToQt(optionalValueType(te), recordName) + ">";
}
return "QVariant";
}
bool lidlIsStdConvertible(const TypeExpr& te)
{
if (te.kind == TypeExpr::Primitive) {
return te.name == "tstr" || te.name == "bstr"
|| te.name == "int" || te.name == "uint"
|| te.name == "float64" || te.name == "bool";
}
if (te.kind == TypeExpr::Array && te.elements.size() == 1) {
const TypeExpr& elem = te.elements[0];
if (elem.kind == TypeExpr::Primitive) {
return elem.name == "tstr" || elem.name == "bstr"
|| elem.name == "int" || elem.name == "uint"
|| elem.name == "float64" || elem.name == "bool";
}
}
return false;
}
QString lidlTypeToStd(const TypeExpr& te)
{
if (te.kind == TypeExpr::Primitive) {
if (te.name == "tstr") return "std::string";
if (te.name == "bstr") return "std::vector<uint8_t>";
if (te.name == "int") return "int64_t";
if (te.name == "uint") return "uint64_t";
if (te.name == "float64") return "double";
if (te.name == "bool") return "bool";
if (te.name == "result") return "LogosResult";
if (te.name == "any") return "QVariant";
return "QVariant";
}
if (te.kind == TypeExpr::Array && te.elements.size() == 1) {
const TypeExpr& elem = te.elements[0];
if (elem.kind == TypeExpr::Primitive) {
if (elem.name == "tstr") return "std::vector<std::string>";
if (elem.name == "bstr") return "std::vector<std::vector<uint8_t>>";
if (elem.name == "int") return "std::vector<int64_t>";
if (elem.name == "uint") return "std::vector<uint64_t>";
if (elem.name == "float64") return "std::vector<double>";
if (elem.name == "bool") return "std::vector<bool>";
}
return "QVariantList";
}
if (te.kind == TypeExpr::Map) return "QVariantMap";
// `?T` -> std::optional<T>. The std surface HAS an optional, so unlike the
// Qt table above this one keeps the value type. std::nullopt is C++'s single
// empty inhabitant, which is what makes the mapping two-state; the encoder
// that pairs with it is logos-protocol's Codec<std::optional<T>>.
//
// Recurse through optionalValueType() rather than elements[0]: optionality
// is idempotent under the two-state rule, so `??T` denotes the same two
// states as `?T` and must not become std::optional<std::optional<T>>.
// A degenerate Optional carrying no element (unreachable from the parser,
// constructible by hand or over the JSON bridge) keeps the opaque fallback
// instead of recursing forever.
if (te.kind == TypeExpr::Optional) {
if (te.elements.empty()) return "QVariant";
return "std::optional<" + lidlTypeToStd(optionalValueType(te)) + ">";
}
if (te.kind == TypeExpr::Named) return "QVariant";
return "QVariant";
}