Files
Dario LipicarandClaude Opus 5 1f69bca54a feat(generator): emit the derived module identity methods (#141)
* feat(generator): emit the derived module identity methods

Run logos-lidl's injectIdentityMethods() on every ModuleDecl this generator
emits code from, and give the cdylib dispatch a body for the two methods it
adds.

Injection happens at EMISSION points, never at artifact points:

  * generateInterfaceWrappers -- one load point covering both --dep and
    --interface, so a consumer sees name()/version() on every dependency and
    bound interface;
  * --from-header --backend cdylib and --lidl --backend cdylib, so the provider
    answers them;
  * NOT --header-to-lidl, which writes the published contract.

The distinction is belt-and-braces rather than load-bearing: the injected
methods are `derived` and lidlSerialize omits those, so the .lidl a
--from-header build writes stays byte-identical to what --header-to-lidl writes
for the same header.

The dispatch emits a literal for a derived identity method instead of the usual
lidlImpl().<name>(...) -- the author's impl class has no such member, so
delegating would not compile. The literal is the module's own name and version,
so it cannot drift from the metadata the module was built with. A module that
declares name() itself is not derived and still reaches its impl.

290/290 tests pass, 4 new: that the emitted literal is the module's OWN version
(a test at 1.0.0 could not tell a correct generator from one that fell back),
that identity is listed for introspection as well as dispatched, that an
author's own name() still reaches the impl, and that a versionless declaration
falls back rather than emitting "" -- which would read as a failed call.

Requires logos-lidl#10; flake.lock pins that branch until it merges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): track logos-lidl master now that the identity pass has landed

logos-co/logos-lidl#10 merged as ae3ffe0. The lock pointed at the PR branch
while it was open; this re-points it at master.

The narHash is unchanged across the move (sha256-WHmisUvYA8DQ2ZxSF2mM2...),
so the merged tree is byte-identical to the branch this was built and tested
against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:31:03 -03:00

136 lines
5.8 KiB
C++

#ifndef LIDL_COMPAT_H
#define LIDL_COMPAT_H
// Bridge the cpp-generator's Qt-flavored codegen backends onto the canonical
// logos-lidl frontend. The lexer / parser / AST / serializer / validator now
// live in logos-lidl (std-typed, language-neutral); this header brings those
// types into the global scope the backends use unqualified and adds thin
// Qt-friendly shims so the existing emission code (QTextStream) keeps
// compiling. The backends themselves (impl-header parsing, gen_client,
// gen_cdylib) stay here — they are the C++/Qt-specific parts.
#include "lidl/ast.hpp"
#include "lidl/identity.hpp"
#include "lidl/parser.hpp"
#include "lidl/serializer.hpp"
#include "lidl/validator.hpp"
#include <QString>
#include <QTextStream>
#include <string>
// The canonical AST, in the global scope the generator backends reference it
// from (they predate the logos-lidl extraction and use the unqualified names).
using lidl::TypeExpr;
using lidl::FieldDecl;
using lidl::ParamDecl;
using lidl::MethodDecl;
using lidl::EventDecl;
using lidl::TypeDecl;
using lidl::ModuleDecl;
// Optionality accessors, from the SAME header — never re-derived here.
//
// `?T` has two equivalent spellings for a record field: the flag (`? name: T`,
// which leaves `FieldDecl::type` as T and sets `FieldDecl::optional`) and the
// type kind (`name: ?T`, which leaves the flag false and makes the type an
// Optional). logos-lidl's docs/spec.md binds them to the same meaning, so they
// MUST emit identical code — and the only way that holds is if no backend
// answers the question itself. Reading `f.optional` alone is a bug; reading
// `f.type.kind == Optional` alone is the same bug from the other side.
// fieldIsOptional() / fieldValueType() are the answer.
using lidl::typeIsOptional;
using lidl::optionalValueType;
using lidl::fieldIsOptional;
using lidl::fieldValueType;
using lidl::paramIsOptional;
using lidl::paramValueType;
// std::string -> QString, and let QTextStream accept std::string directly so
// emission of AST string fields (`s << md.name`) keeps compiling unchanged.
inline QString qs(const std::string& s) { return QString::fromStdString(s); }
inline QTextStream& operator<<(QTextStream& s, const std::string& v)
{
return s << QString::fromStdString(v);
}
// Name-compatible shims over the canonical frontend so the call sites that used
// the deleted experimental lexer/parser/serializer/validator keep their shape.
using LidlParseResult = lidl::ParseResult;
using LidlValidationResult = lidl::ValidationResult;
inline lidl::ParseResult lidlParse(const QString& source)
{
return lidl::parse(source.toStdString());
}
inline QString lidlSerialize(const ModuleDecl& module)
{
return QString::fromStdString(lidl::serialize(module));
}
inline lidl::ValidationResult lidlValidate(const ModuleDecl& module)
{
return lidl::validate(module);
}
// Add the derived module identity methods — name() and version() — to a
// ModuleDecl that is about to have CODE emitted from it. Returns false and
// fills `error` when the module declares one of those reserved names with an
// incompatible signature.
//
// Emission only. Never call this before serializing a .lidl: the published
// contract stays exactly what the author wrote, and the provider and every
// consumer each add the identity methods from this one function, so the two
// sides cannot disagree about them. Injecting into the artifact instead would
// make `--header-to-lidl` and `--from-header` disagree about the same module,
// and would make the two methods indistinguishable from author-declared ones.
inline bool lidlInjectIdentity(ModuleDecl& module, QString* error)
{
const lidl::IdentityInjection r = lidl::injectIdentityMethods(module);
if (r.hasError()) {
if (error) *error = qs(r.error);
return false;
}
return true;
}
// A record whose ONLY field is a `tstr` named `_bytes` is indistinguishable on
// the wire from a canonical tagged byte string: `isTaggedBytes()` is checked
// BEFORE `is_object()` in both logos_codec.h and logos_json_convert.cpp, so
// such a record silently decodes as a byte string and the struct is gone. The
// ambiguity is inherent to the tagged form — the codec's own comment says not
// to name a map key `_bytes` — but a generator can at least refuse to emit the
// one shape that is guaranteed to misdecode, instead of leaving it to be
// discovered at runtime.
// Optionality does not rescue it: a PRESENT `? _bytes: tstr` still encodes to
// {"_bytes": "..."}, which is the ambiguous shape. So the check reads through
// the optional — via fieldValueType, not f.type — and refuses both spellings.
// Reading f.type here refused `? _bytes: tstr` (whose type stays Primitive
// tstr) while letting `_bytes: ?tstr` straight through: one declaration, two
// answers, which is the exact drift the accessors exist to prevent.
inline bool lidlRecordCollidesWithBytesTag(const TypeDecl& t)
{
if (t.fields.size() != 1 || t.fields[0].name != "_bytes")
return false;
const TypeExpr& vt = fieldValueType(t.fields[0]);
return vt.kind == TypeExpr::Primitive && vt.name == "tstr";
}
// Returns false and fills `error` when any declared record cannot round-trip.
inline bool lidlCheckRecords(const ModuleDecl& m, QString* error)
{
for (const TypeDecl& t : m.types) {
if (lidlRecordCollidesWithBytesTag(t)) {
if (error)
*error = QString("type '%1': a record whose only field is a tstr named "
"'_bytes' is wire-identical to a tagged byte string and "
"would decode as bytes, not as the record. Rename the "
"field or give the record another field.")
.arg(qs(t.name));
return false;
}
}
return true;
}
#endif // LIDL_COMPAT_H