Files
logos-cpp-sdk/cpp-generator/experimental/lidl_serializer.cpp
T
Dario LipicarandClaude Opus 4.8 676154070c codegen: Qt-free outbound — ApiStyle::Lp typed wrappers over the lp_* C ABI (#88)
* ci: drop doctest chain pins — the qt-split chain is fully merged

logoscore-cli and module-builder masters now contain the chain; the
temporary --release-for pins (added so stacked-branch CI could resolve
compatible cross-repo revs) default back to latest releases.

* codegen: Qt-free outbound — ApiStyle::Lp typed wrappers over the lp_* C ABI

Adds a third generator flavor (ApiStyle::Lp, --api-style lp) whose typed
dependency wrappers + LogosModules umbrella call the logos-protocol C ABI
directly via a new header-only logos::LpClient, instead of LogosAPIClient.
This lets a module make outbound typed calls and event subscriptions with NO
Qt in its translation units — Qt stays confined to the QRO transport (inside
logos-protocol) and the generated plugin glue.

- cpp/logos_lp_client.h: header-only logos::LpClient (lazy lp_client_create
  on a baked origin; invoke / invokeAsync / subscribe; std<->nlohmann JSON;
  CallError out-param) + RAII logos::LpSubscription (unsubscribes on drop) +
  json<->std helpers. The C++ analog of rust-sdk PluginProxy.
- generator: makeHeaderLp/makeSourceLp emit the Lp wrappers; the Lp umbrella
  drops the LogosAPI ctor and bakes this module name as the lp_client origin
  (LogosModules() default-constructible). Qt/Std emission is byte-unchanged
  (dispatch added at the top of makeHeader/makeSource).

Verified: generator builds; generated wrappers + umbrella compile to .o with
ONLY cpp-sdk + logos-protocol headers + nlohmann (no Qt); cpp-sdk tests pass.

* cdylib: wire the Qt-free typed dependency surface (modules()) into the impl

When a cdylib module declares dependencies, the generated exports now include
the Lp umbrella (logos_sdk.h) and construct LogosModules() + maybeSetLogosModules
on the impl just before onContextReady — so the author can call
modules().<dep>... and subscribe to dep events from a Qt-free cdylib. Guarded
on module.depends so dependency-less cdylib modules are byte-unchanged.

The umbrella + dep wrappers themselves are produced by the --general-only
--api-style lp generation; feeding the dep .lidl files into that during the
module build is the remaining build-system wiring (module-builder + plugin-qt
dep resolution).

* cdylib: wire modules() unconditionally (deps come from metadata, not the .lidl)

The umbrella wiring was guarded on the .lidl module.depends, but a cdylib
module declares its dependencies in metadata.json#dependencies — the .lidl
contract.depends is typically empty — so modules() was left null and a typed
outbound call segfaulted. Always include the generated logos_sdk.h umbrella
and maybeSetLogosModules(impl, new LogosModules()) before onContextReady; the
overload is a no-op for context-less impls and the umbrella codegen emits an
(empty) logos_sdk.h for every cdylib, so this is safe in all cases.

* fix(headers): ship logos_lp_client.h in the include/cpp source-export root

A cdylib module's generated dep wrapper includes "logos_lp_client.h" and,
transitively, "logos_result.h". The wrapper is compiled with the cpp-sdk
source-export include root (include/cpp), so logos_lp_client.h must sit
beside logos_result.h there — a quoted include resolves siblings relative
to the including file's directory. Previously logos_lp_client.h shipped
only at the top-level include/ (the CMake-export layout via cpp/
CMakeLists.txt), so it pulled in include/logos_result.h while the impl's
logos_module_context.h pulled include/cpp/logos_result.h. Those are two
distinct realpaths under the symlinkJoin, so #pragma once could not dedup
them and StdLogosResult was redefined. Install every std header into both
roots so a single TU only ever sees one logos_result.h.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cdylib): route logos_module_accept_token into the protocol TokenManager

The generated module-impl export stored accepted tokens in a process-local
std::map (g_tokens) that nothing ever read, so a cdylib module's OUTBOUND
lp_client (modules().<dep>...) never saw the capability_module bootstrap
token the host delivers at load. The automatic requestModule flow then ran
unauthenticated: capability_module rejected requestModule, no per-target
token was issued, and the cross-module call was rejected (returning a
default-constructed result, e.g. 0).

Forward the token into lp_token_save, which writes the same
TokenManager::instance() singleton the cdylib's lp_client reads. The
capability/token handshake now completes and typed Qt-free outbound calls
return real results. Drop the dead g_tokens map + mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lidl): exclude LogosModuleContext hooks from header-derived contracts

--header-to-lidl parses an impl class's public methods. An impl commonly
overrides onContextReady() (and could redeclare a context accessor) in its
own public section, so the derived LIDL would include onContextReady /
modules / modulePath / instanceId / instancePersistencePath. Those are
framework plumbing, not API methods — and feeding them to the cdylib
backend breaks cdylib-eligibility (e.g. the inherited accessors' Qt-free
return-type check), which is exactly what header-first universal modules
now hit. Skip the reserved LogosModuleContext names in the parser so both
the Qt --from-header path and the cdylib --header-to-lidl path emit clean,
API-only contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cdylib): support the full std type set in header-derived contracts

Routing core universal modules through the cdylib backend surfaced gaps
between the cdylib subset and what the std apiStyle handled — a universal
module that built under std must also build as a header-first cdylib.

- lidl parser: restore the return-shape flags (resultReturn / jsonReturn)
  from the parsed return TypeExpr, so a header -> .lidl -> cdylib round-trip
  (the universal path, needed to feed the Qt glue) preserves the semantics
  the impl-header parser sets from C++ types (StdLogosResult -> result;
  LogosMap/LogosList -> json). Without this the cdylib codegen/eligibility
  mis-handled result / map / list returns.
- cdylib eligibility + dispatch: `void` is not a lidlBuiltinType, so the
  parser yields it as a Named "void" (header path uses empty name) — treat
  both as void in the eligibility check and the dispatch (was relying on
  lidlTypeToQt=="void", which didn't match Named "void" -> generated an
  `auto result = <void call>`).
- typeSupported: accept `any` (both directions), `void`/`result` (returns),
  arrays-of-any, and Map ({k:v}/LogosMap) — the Qt-free-via-nlohmann set.

Verified: a probe with void / LogosMap / LogosList / StdLogosResult /
const returns is cdylib-eligible and dispatches correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lidl): carry method/event descriptions across the .lidl round-trip

Header-first universal modules go header -> .lidl -> cdylib backend. The
impl-header parser captures /// and /** */ doc comments into method/event
descriptions, but the .lidl serializer emitted only the signature, so the
descriptions were dropped — introspection (lm methods / --json, getMethods)
then showed no docs (regressing the wrap-external-lib + tutorial doctests).

Serialize each method/event's description as a trailing `description "..."`
clause (escaped for the string literal; the lexer already decodes \\ \" \n
\t) and parse it back in parseMethodDef/parseEventDef. Module description
now escaped too. Verified: /// docs survive header -> .lidl -> getMethods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lp): bound-interface wrappers are handles over umbrella-owned state

The Lp interface (bind_<iface>) wrapper owned its LpClient + RAII
subscriptions BY VALUE, so the idiomatic transient handle —
  modules().bind_calculator(p).fibonacciAsync(...)
  modules().bind_calculator(p).onVersionReady(...)
— tore the client/subscription down when the temporary died, cancelling
the async callback and the event subscription. (Sync calls completed before
the temporary's destruction, so they worked; the Qt/std flavor works because
its handle is thin over a LogosAPI-owned persistent client.)

Make the Lp Bound wrapper a THIN, copyable handle over `State { LpClient
client; vector<LpSubscription> subs; }` that the LogosModules umbrella OWNS
per provider (std::map<provider, unique_ptr<State>>) for the module's
lifetime. bind_<iface>(p) creates/looks up the State and returns a handle to
it, so a transient handle's async/event registrations outlive it. Concrete
(Static) dep wrappers are unchanged — they're already persistent umbrella
members, so by-value ownership is fine there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cdylib): lenient bytes-param decode (string / array / tagged)

A universal module's bstr (std::vector<uint8_t>) PARAM arrived empty when
the caller sent a plain string rather than the tagged {"_bytes": base64url}
form — lidlBytesFromJson only accepted the tagged object, so
byteArraySize("12345") and byteArraySize(b"\x01..") both saw 0 bytes (the
return direction already worked). The std path was lenient (a QString or
QByteArray arg both became bytes). Accept all three forms: a plain JSON
string (raw UTF-8 bytes), an array of byte values, and the tagged
{"_bytes"} form (base64url). Return direction unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cdylib): a number arg to a bytes param decodes as its decimal text

byteArraySize("12345") arrives as a JSON number (the logoscore CLI's type
auto-detection turns the string "12345" into int 12345), and the Qt path
gives QVariant(int)->QByteArray "12345" (5 bytes). The cdylib bstr decode
returned 0 for a number. Treat a JSON number as its decimal text bytes
(j.dump()), matching the Qt behaviour, so a bare-number arg to a bytes
param round-trips identically. Verified: byteArraySize 12345 -> 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:47:09 -03:00

50 lines
2.6 KiB
C++

#include "lidl_serializer.h"
#include <QTextStream>
// Escape a description for a "..."-delimited LIDL string literal (the lexer
// decodes \\ \" \n \t). Keeps method/event docs intact across a .lidl
// round-trip so introspection (lm / getMethods) still shows them.
static QString lidlEscapeStr(QString in) {
in.replace('\\', "\\\\");
in.replace('"', "\\\"");
in.replace('\n', "\\n");
in.replace('\t', "\\t");
return in;
}
static QString serializeTypeExpr(const TypeExpr& te) {
switch (te.kind) {
case TypeExpr::Primitive: case TypeExpr::Named: return te.name;
case TypeExpr::Array: return "[" + serializeTypeExpr(te.elements[0]) + "]";
case TypeExpr::Map: return "{" + serializeTypeExpr(te.elements[0]) + ": " + serializeTypeExpr(te.elements[1]) + "}";
case TypeExpr::Optional: return "? " + serializeTypeExpr(te.elements[0]);
}
return QString();
}
static void serializeParams(QTextStream& s, const QVector<ParamDecl>& params) {
for (int i = 0; i < params.size(); ++i) {
s << params[i].name << ": " << serializeTypeExpr(params[i].type);
if (i + 1 < params.size()) s << ", ";
}
}
QString lidlSerialize(const ModuleDecl& module) {
QString out;
QTextStream s(&out);
s << "module " << module.name << " {\n";
if (!module.version.isEmpty()) s << " version \"" << module.version << "\"\n";
if (!module.description.isEmpty()) s << " description \"" << lidlEscapeStr(module.description) << "\"\n";
if (!module.category.isEmpty()) s << " category \"" << module.category << "\"\n";
s << " depends [";
for (int i = 0; i < module.depends.size(); ++i) { s << module.depends[i]; if (i + 1 < module.depends.size()) s << ", "; }
s << "]\n";
for (const TypeDecl& td : module.types) { s << "\n type " << td.name << " {\n"; for (const FieldDecl& fd : td.fields) { s << " "; if (fd.optional) s << "? "; s << fd.name << ": " << serializeTypeExpr(fd.type) << "\n"; } s << " }\n"; }
if (!module.methods.isEmpty()) s << "\n";
for (const MethodDecl& md : module.methods) { s << " method " << md.name << "("; serializeParams(s, md.params); s << ") -> " << serializeTypeExpr(md.returnType); if (!md.description.isEmpty()) s << " description \"" << lidlEscapeStr(md.description) << "\""; s << "\n"; }
if (!module.events.isEmpty()) s << "\n";
for (const EventDecl& ed : module.events) { s << " event " << ed.name << "("; serializeParams(s, ed.params); s << ")"; if (!ed.description.isEmpty()) s << " description \"" << lidlEscapeStr(ed.description) << "\""; s << "\n"; }
s << "}\n";
return out;
}