feat(records): a struct in the impl header is a real wire type

`any` (LogosMap/LogosList) was the only way to express a heterogeneous shape, so
~104 slots across openmetrics, package_manager, package_downloader, storage and
logoscore-cli describe their payloads with an untyped map. LIDL has carried
TypeDecl/FieldDecl all along — chat_module's hand-written .lidl declares records
and notes they "document the shape and light up if the generators gain struct
support". This is the impl-header path finally producing them.

  parser  - a plain `struct Foo { T a; U b; };` ahead of the impl class becomes a
            TypeDecl. Field types go through the same cppTypeToLidl, so a record
            field is subject to the same 64-bit / bstr / composition rules.
  gate    - a Named type is supported when the module DECLARES it. An undeclared
            name is still a build error naming the type: records are opt-in, not
            a reopening of the opaque fallback.
  emitter - one Codec<Foo> specialisation per record. Fields are addressed
            through decltype, so no C++ type name is spelled (same trick as
            JsonArg), and because the specialisation plugs into
            logos::detail::Codec, a record nested in [T] or {tstr: T} needs
            nothing further emitted — the existing recursion handles it.

A missing field decodes as null and the leaf codec rejects it with the field's
path, so the diagnostics compose too.

Verified end to end, not just as emitted text — a module declaring
`struct Status { uint64_t port; std::string name; std::vector<uint8_t> blob; }`
used as param, return and inside a vector, over the real transport:

  param        {"port":9099,"name":"ok","blob":{"_bytes":"gAE"}} -> "9099|ok|2:128:1:129"
  return       {"blob":{"_bytes":"gAE"},"name":"ok","port":9099}
  [record]     [{...},{"blob":{"_bytes":""},"name":"b","port":1}]
  missing field {"code":"dispatch_failed",
                 "message":"expected string at arg0.name, got null"}

Note the bytes field: tagged inside the record, and inside a record inside an
array, with no record-specific bytes handling anywhere.

Tests: 175/175, including that an undeclared Named type is still rejected.

Next for records: emit the `type Foo {...}` decls into the published .lidl so
consumers generate typed wrappers, then the Rust and Qt sides. Until then a
record is a provider-side contract that consumers still see as an object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-07-27 15:05:58 -03:00
co-authored by Claude Opus 5
parent 77a7009742
commit 22aa17c129
3 changed files with 138 additions and 0 deletions
@@ -365,6 +365,10 @@ ImplParseResult parseImplHeader(const QString& headerPath,
QStringList pendingDoc;
bool inBlockComment = false;
// Record being accumulated (see LookingForClass below).
TypeDecl record;
bool inRecord = false;
QRegularExpression classRe("\\bclass\\s+" + QRegularExpression::escape(className) + "\\b");
QRegularExpression accessRe("^\\s*(public|private|protected)\\s*:");
QRegularExpression eventsRe("^\\s*logos_events\\s*:");
@@ -375,6 +379,41 @@ ImplParseResult parseImplHeader(const QString& headerPath,
switch (state) {
case LookingForClass:
// A plain `struct Foo { T a; U b; };` ahead of the impl class is a
// RECORD: a named wire shape the module's methods can take and
// return. LIDL has carried TypeDecl/FieldDecl all along (chat_module
// declares records in a hand-written .lidl) — this is the
// impl-header path finally producing them, so an author writes a
// struct instead of an untyped LogosMap.
if (inRecord) {
if (line.startsWith("}")) {
if (!record.fields.empty())
result.module.types.push_back(record);
inRecord = false;
} else {
static QRegularExpression fieldRe(
"^([A-Za-z_][A-Za-z0-9_:<>,\\s\\*&]*?)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*;$");
const QRegularExpressionMatch fm = fieldRe.match(line);
if (fm.hasMatch()) {
FieldDecl f;
f.name = fm.captured(2).toStdString();
f.type = cppTypeToLidl(fm.captured(1).trimmed());
record.fields.push_back(f);
}
}
break;
}
{
static QRegularExpression recordRe(
"^struct\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\{\\s*$");
const QRegularExpressionMatch rm = recordRe.match(line);
if (rm.hasMatch()) {
record = TypeDecl();
record.name = rm.captured(1).toStdString();
inRecord = true;
break;
}
}
if (classRe.match(line).hasMatch()) {
state = InClass;
for (QChar c : line) {