* 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>
* feat(cdylib): getMethods publishes the LIDL contract vocabulary, not Qt names
A module's published metadata — `returnType`, `parameters[].type`, `signature`
— answered in Qt type names. Two things wrong with that, and the second is the
one that matters:
* a cdylib module is Qt-FREE. It described itself in the types of a language
it does not use, to readers (`lm`, logoscore's method listing, basecamp's
module inspector) that are showing a human what the module offers.
* it was LOSSY. `[uint]`, `[bstr]` and `[any]` are three different LIDL types
and all three published as the single word QVariantList, so the listing
could not be read back as a contract. That is now `[uint]`, `[bstr]`,
`[any]`; `{tstr: uint}`; `? tstr`; and a record publishes its declared
name.
WHY THIS IS SAFE — checked, not assumed. The historical objection is recorded
in the function this replaces: these strings are read as METATYPES, and
emitting a record's struct name here once made the host SIGSEGV. Nothing in the
current runtime does that. logos-plugin-qt's QtProviderObject dispatches on
`method.returnMetaType()` / `parameterMetaType(i)` — the QMetaObject, never
this JSON — and every remaining reader treats these fields as opaque text:
logos-module's `lm` prints them, logoscore's output.cpp prints them, basecamp's
CoreModuleManager forwards the JSON to QML, and the plain wire's json_mapping
only round-trips them. Nothing anywhere builds a QMetaObject from this
metadata.
The spelling comes from lidlTypeToLidlText, which mirrors logos-lidl's
serializeTypeExpr. It is a COPY, because that function is file-local to
logos-lidl's serializer.cpp and the public headers expose no type printer —
so instead of hoping, the pairing is ASSERTED: the test round-trips each shape
through `lidl::serialize` and reads the type text back out of the emitted
`.lidl`. When logos-lidl exports a printer, delete the copy and call it.
Not fixed by this, and not attempted: the Rust SDK's provider generator has its
own `qt_type_name` writing the same JSON, so the two languages now disagree
about how a module describes itself. That is a cross-repo change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(doctests): the generator round-trip pins the lossless Qt spellings
`cpp-sdk-generator-roundtrip.test.yaml` is a CI gate
(.github/workflows/doctests.yml), and two of its `expect_contains` were
pinned to the type names the Qt consumer produced BEFORE the lossless
mapping:
QStringList labels(const QVariantList& ids
QVariant nearest(const Point& p, QVariant limit
The generator now emits `QList<qulonglong>` and
`std::optional<Point>` / `std::optional<qulonglong>` for those slots, so both
assertions failed. The `nearest` step's `run` grep was pinned the same way
(`QVariant nearest`), so the line it was supposed to assert on was not even
in the output being searched.
Verified by running the spec's own steps against the generator built from
this commit: 10 run-steps, 0 failures. The `[uint]` -> QList<qulonglong> and
`?T` -> std::optional<T> lines were read out of the real
`consumer/sensor_module_api.h` and `geometry/geometry_module_api.h`, not
written from the mapping table.
Prose too, in three places that described the old table: the Flow-3 type
mapping ("other arrays -> QVariantList"), the composite-types intro
("optionals ... stay QVariantMap / QVariant"), and the composite-signature
step. They now say what the mapping actually is — one LIDL type, one C++
spelling, with `any` the single deliberate exception — and `nearest` is
called out as the one signature carrying both halves of the optional
mapping.
`doctests/outputs/cpp-sdk-generator-roundtrip.md` carries the same prose
corrections. That tree is hand-pinned and CI never diffs it, which is
exactly why it must be corrected by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(generator): the consumer wrapper comes from the contract, not from getMethods
`logos-cpp-generator <plugin> --module-only` — the invocation
logos-plugin-qt's generate-module-headers.sh makes for every module's lp
wrapper — built that wrapper's whole type surface out of the plugin's
PUBLISHED `getMethods()` metadata. It now builds it out of the module's `.lidl`
contract, the file the same invocation already passes as `--events-from`.
WHY THIS IS A DEFECT AND NOT A PREFERENCE. generator_lib is keyed on flat type
NAMES, and mapParamType / mapReturnType fall back to QVariant for a name they
do not recognise (generator_lib.cpp:142 and :153). So the wrapper's types
depend on the VOCABULARY a module happens to publish its metadata in, and a
vocabulary this emitter has no row for degrades to QVariant — LogosMap on the
lp surface — with no diagnostic at any layer. It is a machine reader of a
listing that every other consumer treats as human-facing text, and it fails
silently.
It was measured, not theorised. 621772a made the cdylib backend publish the
LIDL contract vocabulary (`tstr`, `[uint]`, `result`, `? tstr`) in place of Qt
type names, because that listing is what `lm`, logoscore and basecamp show a
human and Qt names are the wrong answer for a Qt-free module. Every
`interface: "universal"` module's lp wrapper collapsed:
logos-test-modules' `checks.unit-tests-new-api` went PASS -> FAIL, and the
compiler said exactly why —
error: no viable conversion from 'LogosMap' to 'StdLogosResult'
StdLogosResult r = modules().test_basic_module.resultWithMap();
`result` is not a name mapReturnType knows, so it became QVariant, so it became
LogosMap. Bisected to exactly 621772a (5ffd90b passes, dd52d9d fails).
THE FIX IS TO STOP READING THAT VOCABULARY, not to learn a second one.
`int` means a 32-bit Qt int in one table and a 64-bit LIDL integer in the
other, and the reader cannot tell from the string which table it is holding —
a merged table would silently mistype every integer on every module. The
contract has no such ambiguity: it is a TypeExpr tree, and lidl_to_json is the
single place it is flattened. Taking methods from it makes this path emit the
same wrapper as `--general-only --dep <name>=<name>.lidl`, which is what
buildHeaders.nix already runs under cross-compilation and for the entire Qt
surface. Contract-first, on every platform, for every surface.
WHAT CHANGED, exactly:
* loadEventsFromLidl -> loadContractFromLidl. It already parsed the whole
contract and threw the methods away; it now returns them, after the same
lidlCheckRecords + lidlInjectIdentity + noteOptionalPositionalSlots that
main.cpp's --dep path applies. Identity is injected rather than read,
matching the provider side (main.cpp's --backend cdylib), so the two cannot
disagree about name() / version().
* A sidecar that is NAMED BUT MISSING is now refused (exit 2), and an
unreadable or malformed one is fatal (exit 4). Both used to be shrugged off
— which shipped a wrapper with no typed events, and would now ship one with
no typed methods, in the silently-empty shape generate-module-headers.sh
exists to refuse.
* The plugin is STILL LOADED. That load is the dlopen check this path
performs (exit 3 on an SDK/ABI skew) and it is unchanged; what the plugin
says about itself is now compared against the contract instead of believed,
and a divergence — a stale sidecar — is reported by name on stderr. Only
`isInvokable` entries are compared: a cdylib publishes its events into the
same array, tagged `"type": "event"`, and both emitters already skip those.
* A module with NO contract keeps introspection — a handcrafted Qt plugin's
QMetaObject is still the only description of its API that exists, and Qt
type names are the right vocabulary for it — but a listing spelled in the
LIDL vocabulary with no contract to go with it is now REFUSED (exit 7)
instead of silently producing the untyped wrapper. That combination is only
reachable by hand: buildHeaders.nix always passes the flag when the sidecar
exists, and it is the shape the developer guide used to suggest. The two
vocabularies are not distinguishable in general, which is the whole reason
this emitter must read only one — but they do not have to be: the words
they share (`int`, `bool`) are all in the known table and never reach the
fallback, so the check keys on the LIDL half Qt has no word for at all
(`tstr`, `bstr`, `uint`, `float64`, `result`, `any`, and anything starting
`[`, `{` or `?`). No Qt type is spelled that way, so it cannot false-fire;
a false negative is just the old behaviour.
THE ENUMERATION, because two previous ones missed this reader. Searching for
who greps `returnType` is what missed it; the question is what the data FLOWS
INTO. Every consumer of a published getMethods array in the workspace:
MACHINE (one, and it is this one)
logos-cpp-sdk cpp-generator/plugin_introspect.cpp, reached only through
logos-plugin-qt's generate-module-headers.sh / buildHeaders.nix.
HUMAN-READABLE OR OPAQUE PASSTHROUGH (all of them)
logos-module's `lm` (prints; --json re-emits verbatim), logoscore-cli's
client/output.cpp (prints) and core_service_dispatch.cpp (forwards),
logos-logoscore-tui (formats one line per method), logos-module-viewer
(reads the QMetaObject directly, not this JSON), basecamp's
CoreModuleManager / MainUIBackend (hands the JSON string to QML),
logos-protocol's json_mapping.cpp and qvariant_rpc_value.cpp (round-trip
the strings unread).
PRODUCERS, for completeness: lidl_gen_cdylib.cpp (LIDL vocabulary),
logos-plugin-qt's QtProviderObject (Qt names, from the QMetaObject) and
lidl_gen_cdylib_glue.cpp (forwards the cdylib's), logos-rust-sdk's
rustgen_provider.rs (still Qt names — the two languages disagree, as
621772a noted), and logos-protocol's ModuleProxy, which appends derived
name()/version() entries spelled `QString`. None of that reaches a type
decision any more, which is the point of the change.
Build-system paths checked and clear: `<plugin> --module-only` is invoked
from exactly one place in the workspace (generate-module-headers.sh:60);
LogosModule.cmake, buildPlugin.nix and mkLogosModuleTests.nix all use
`--general-only`, which is contract-driven already; the doctests' `--lidl
--module-only` is a different mode entirely.
VERIFIED.
`nix build path:./repos/logos-test-modules#checks.aarch64-darwin.unit-tests-new-api`
with this SDK overridden in (plus the logos-lidl overrides the branch needs at
the qt-sdk and plugin-qt nodes) — 32 passed, 0 failed. The same command against
this branch's HEAD fails to compile, as above. The build log shows the path
taken, per module:
Detected new-API plugin (LogosProviderPlugin), using getMethods() — 43 methods
Using the module's LIDL contract for the method surface — 41 methods
(the plugin's published listing is a description, not a type source)
The refusal, measured by hand against a real LIDL-publishing plugin
(test_basic_module, built from this branch) because no check exercises a
hand-run invocation:
no --events-from -> exit 7, nothing written, the message above naming
8 offending slots
with --events-from -> exit 0, 41 typed methods, 69 `std::string` in the
emitted lp header
a pre-621772a build of the SAME module (Qt-name listing), no --events-from
-> exit 0, still generates, still typed — the refusal does
not fire on the vocabulary this emitter can read
nix/tests-generator-cli.nix gains the two CLI-surface cases this adds: a
`--events-from` naming a file that does not exist is refused with that
sentence, and — the control that makes it mean something — the same command
with a READABLE contract gets past the flag and fails on the plugin instead. No
plugin is needed for either: the contract is loaded before the plugin is
opened.
logos-cpp-sdk's own checks (tests, generator-cli, module-impl-abi): 334 of 334.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
26 KiB
Logos Code Generator — Experimental
Overall Description
The experimental code generator extends logos-cpp-generator with two new capabilities: a lightweight Interface Definition Language (LIDL) for declaring module contracts, and a C++header parser that can infer module interfaces directly from pure C++ implementation classes. Both paths produce the same output: the Qt-free logos_module_* C-ABI provider glue that bridges pure C++ module implementations to the runtime. (It used to emit the Qt plugin glue directly; turning the C ABI into a Qt plugin is now a downstream step, logos-qt-host-generator --backend cdylib in logos-plugin-qt, and that seam is what lets the Rust and JS providers target the same ABI.)
The goal is to decouple module business logic from the Qt framework. Module authors write standard C++ using std::string, int64_t, std::vector<T>, and the build system generates all Qt boilerplate (QObject, Q_PLUGIN_METADATA, QString conversions, method dispatch) automatically.
Definitions & Acronyms
| Term | Definition |
|---|---|
| LIDL | Logos Interface Definition Language — a lightweight DSL for declaring module interfaces |
| Universal Module | A module whose implementation is pure C++ (no Qt types) with all Qt glue generated at build time |
| Provider Glue | Generated code that wraps a pure C++ impl class in a LogosProviderObject with callMethod() dispatch and getMethods() introspection |
| Client Stub | Generated type-safe C++ wrapper class that callers use to invoke a module's methods without string-based dispatch |
| Dispatch | The generated callMethod() function that maps string method names to typed method calls on the provider object |
| Impl Header | The pure C++header file (_impl.h) that declares a module's public methods using standard C++ types |
| TypeExpr | The AST node representing a type in the LIDL type system |
| ModuleDecl | The AST node representing a complete module declaration (name, version, methods, events, types) |
Domain Model
Two Paths to the Same Output
Path 1: LIDL file Path 2: C++ impl header
│ │
▼ ▼
lidlParse() parseImplHeader()
│ (logos-lidl's lidl::parse, │
│ via lidl_compat.h) │
▼ │
lidlValidate() │
│ │
▼ ▼
ModuleDecl ◄────── same AST ──────► ModuleDecl
│ │
├──► lidlMakeTypesHeaderCdylib() │
│ lidlMakeModuleImplExports() │
│ lidlMakeEventsSourceCdylib() │
│ → logos_module_* C ABI │
│ (Qt packaging is a │
│ downstream step: │
│ logos-qt-host-generator) │
│ │
├──► lidlMakeHeader() │
│ → <name>_api.h │
│ │
└──► lidlMakeSource() │
→ <name>_api.cpp │
(There is no lidlTokenize() step here any more: the lexer lives in
logos-lidl with the rest of the frontend, behind lidl::parse.)
Both paths converge at ModuleDecl, the shared AST. From there, the same generation functions produce identical output regardless of the input format.
LIDL Language
LIDL is a minimal interface definition language. A module declaration contains metadata, type definitions, method signatures, and event signatures:
module wallet_module {
version "1.0.0"
description "Wallet operations"
category "finance"
depends [crypto_module]
type Account {
address: tstr
balance: uint
? label: tstr ; optional field
}
method createAccount(passphrase: tstr) -> tstr
method getBalance(address: tstr) -> uint
method listAccounts() -> [tstr]
method transfer(from: tstr, to: tstr, amount: uint) -> result
event onTransfer(from: tstr, to: tstr, amount: uint)
}
Comments start with ; and run to end of line.
Type System
Built-in primitive types:
| LIDL type | Meaning | Qt mapping | C++ std mapping |
|---|---|---|---|
tstr |
Text string | QString |
std::string |
bstr |
Binary data | QByteArray |
std::vector<uint8_t> |
int |
Signed 64-bit integer | qlonglong |
int64_t |
uint |
Unsigned 64-bit integer | qulonglong |
uint64_t |
float64 |
Double precision float | double |
double |
bool |
Boolean | bool |
bool |
result |
Structured result (success/value/error) | LogosResult |
LogosResult |
any |
Untyped value | QVariant |
QVariant |
void |
No return value | void |
void |
Composite types:
[T]— Array of T (e.g.,[tstr]→QStringList/std::vector<std::string>){K: V}— Map from K to V (e.g.,{tstr: int}→QVariantMap)?T— Optional T (→QVarianton the Qt surface, which loses the value type;std::optional<T>on the std surface — see Optionality inproject.md)
Named types reference type definitions within the same module.
C++ Header Parsing
The --from-header mode parses a C++implementation header to extract public method signatures. It maps C++ types to LIDL types:
| C++ type | LIDL type |
|---|---|
std::string / const std::string& |
tstr |
bool |
bool |
int64_t |
int |
uint64_t |
uint |
double |
float64 |
void |
void |
std::vector<std::string> |
[tstr] |
std::vector<uint8_t> |
bstr |
std::vector<int64_t> |
[int] |
std::vector<uint64_t> |
[uint] |
std::vector<double> |
[float64] |
std::vector<bool> |
[bool] |
LogosMap |
{tstr: any} (Map) — nlohmann::json alias; sets jsonReturn flag |
LogosList |
[any] (Array) — nlohmann::json alias; sets jsonReturn flag |
QVariantMap |
{tstr: any} (Map) — legacy Qt type |
QVariantList |
[any] (Array) — legacy Qt type |
QStringList |
[tstr] (Array) — legacy Qt type |
| Anything else | any |
LogosMap and LogosList are using aliases for nlohmann::json defined in logos_json.h (part of the SDK). They allow module implementations to remain completely Qt-free while returning rich structured data. The parser maps them to the same LIDL shapes as QVariantMap/QVariantList, but sets the jsonReturn flag on the method so the generator emits an nlohmannToQVariant() conversion in the glue layer.
The parser uses a state machine to find the target class, track access specifiers (public/private/protected), and extract method declarations. It skips constructors, destructors, typedefs, using declarations, std::function members, and non-method statements. While scanning, it also captures any doc comment immediately above a method declaration as that method's description (see Method documentation).
Module metadata (name, version, description, dependencies) comes from metadata.json, not from the header.
Method documentation
A doc comment written directly above a method's declaration in the impl header
becomes that method's description, stored on MethodDecl.description in the
shared AST and emitted into the description field of each getMethods()
entry. Because getMethods() is what the framework's getPluginMethods()
returns, the description flows — with no extra call — to lm methods,
logoscore module-info, and Basecamp's Methods list.
Only doc comments are captured: /// line comments and /** … */ /
/*! … */ block comments. Plain // and /* … */ comments are ignored, so
section separators and incidental notes don't leak into the API. A multi-line
doc comment is preserved with its line breaks (markers stripped, lines joined
with \n; leading/trailing blank lines dropped, interior blank lines kept), and
only comments immediately adjacent to the declaration (no blank line in
between) attach.
class WalletModuleImpl : public LogosModuleContext {
public:
/// Transfers `amount` from the active account to `toAddress`.
/// Returns the resulting transaction hash.
std::string transfer(const std::string& toAddress, int64_t amount);
};
→ the transfer entry in getMethods() gains
"description": "Transfers amountfrom the active account totoAddress.\nReturns the resulting transaction hash." (the two lines preserved, joined with \n)
A method with no doc comment simply has no description field. Methods
introspected purely via Qt's QMetaObject (legacy Q_INVOKABLE modules with no
generated dispatch) carry no comments at runtime and therefore have no
description.
Event documentation
Events are the subscribe-half of a module's API (methods are the call-half), and
document the same way. A doc comment directly above an event declaration in the
logos_events: section (see Event Emission
below) becomes that event's description, stored on EventDecl.description in
the shared AST and emitted into the description field of the event's entry in
getMethods().
getMethods() returns the module's whole interface — methods and events —
with each entry tagged by a "type" field ("method" or "event"). Events ride
inside getMethods() deliberately: there is no separate getEvents() vtable
method, so LogosProviderObject's vtable layout never shifts and old/new hosts
and modules stay binary-compatible (see Why events live in getMethods()
below). The framework then offers three filtered views over that one call —
getPluginMethods() (entries that aren't events), getPluginEvents()
(type == "event"), and getPluginInterface() (everything) — so the
description flows, with no extra provider call, to lm events, logoscore module-info's Events section, and Basecamp's Interface screen.
The capture rules are identical to methods: only /// line comments and
/** … */ / /*! … */ block comments are captured (plain // and /* … */
are ignored); multi-line comments preserve their line breaks (markers stripped,
joined with \n, leading/trailing blanks dropped); only comments immediately
adjacent to the declaration attach.
logos_events:
/// Emitted once the user has authenticated.
/// Carries the freshly issued session token.
void userLoggedIn(const std::string& userId, const std::string& token);
→ the userLoggedIn entry in getMethods() gains
"type": "event" and
"description": "Emitted once the user has authenticated.\nCarries the freshly issued session token."
An event entry carries type: "event", name, signature, parameters[]
(each with type and name), and — when documented — description. Unlike a
method entry it has no returnType or isInvokable: events are void,
fire-and-forget. Events are a universal (--from-header) concept.
(An entry with no "type" is treated as a method, so a module built
against a pre-events SDK simply reports zero events.)
An event's description may also be supplied out-of-band via an optional
description field on the corresponding metadata.json events[] entry (the
doc comment takes the same role for both sources).
Event Emission via logos_events:
Universal modules declare events in a Qt-signals:-style section parsed by the codegen. The same method name appears on both sides — declared in logos_events:, called directly to emit:
#include <logos_module_context.h>
class MyModuleImpl : public LogosModuleContext {
public:
void doWork() {
userLoggedIn("alice", 12345); // typed emit, same name
}
logos_events: // expands to `public:`; recognised by impl_header_parser
void userLoggedIn(const std::string& userId, int64_t timestamp);
void messageReceived(const std::string& from, const std::string& body);
};
impl_header_parser.cpp recognises the raw logos_events: token (before preprocessing) and populates ModuleDecl.events with one EventDecl per prototype. Three artifacts get emitted from this:
-
<name>_events_cdylib.cpp— Qt-MOC-style definitions of each declared event method on the impl class. Bodies marshal typed args into annlohmann::jsonarray and callthis->emitEventImpl_("<event>", &args), a protected helper onLogosModuleContext:void MyModuleImpl::userLoggedIn(const std::string& userId, int64_t timestamp) { nlohmann::json args = nlohmann::json::array(); args.push_back(userId); args.push_back(timestamp); emitEventImpl_("userLoggedIn", &args); }(This used to be a
<name>_events.cppmarshalling into aQVariantList, back when the emitter it fed was a Qt provider object. A universal module's impl side is Qt-free, so the payload is JSON and the file carries the_cdylibsuffix.) -
Emit-callback wiring —
<name>_module_impl.cpp, the generated C-ABI export TU, installs the callback through_logos_codegen_::maybeSetEmitEventalongsidemaybeSetModuleName/maybeSetContext/maybeSetLogosModules. The lambda casts the void* back tonlohmann::json, dumps it, and hands it to thelogos_module_emit_cbthe host registered vialogos_module_set_emit_callback:_logos_codegen_::maybeSetEmitEvent(lidlImpl(), [](const std::string& name, void* args) { const nlohmann::json* payload = static_cast<const nlohmann::json*>(args); std::lock_guard<std::mutex> lock(g_emitMutex); if (g_emitCb) g_emitCb(name.c_str(), payload ? payload->dump().c_str() : "[]", g_emitUd); });(Was a
<name>_qt_glue.hlambda forwarding toLogosProviderBase::emitEvent(QString, QVariantList); that glue is the retired shape described under Generated Output below.) -
<name>.lidlsidecar — a serialised view of the module's declared events (usinglidlSerialize, which since the frontend extraction islidl::serializein the logos-lidl library, re-exported byexperimental/lidl_compat.h; thelidl_serializer.cppthat used to hold it is gone from this repo):module my_module { event userLoggedIn(userId: tstr, timestamp: int) event messageReceived(from: tstr, body: tstr) }buildPlugin.nixships this at$out/share/logos/<name>.lidl.buildHeaders.nixpasses it to the consumer-side codegen via--events-from, and it is the whole CONTRACT, not only the events: the generated<Module>wrapper takes its typed methods, its record structs and its typedon<EventName>(callback)accessors from this one file (callback-arg and signature types respect--api-style). Only a module that ships no contract is described instead by its compiled plugin'sQMetaObject.
Module metadata (name, version, description, dependencies) still comes from metadata.json, not from the header.
Generated Output
Historical.
<name>_qt_glue.h/<name>_dispatch.cppwere emitted bylidl_gen_provider, which is deleted. A module now emits thelogos_module_*C ABI (--backend cdylib) andlogos-qt-host-generatorturns that into a Qt plugin. The sections below describe the retired shape and are kept because theonInitwiring they document still applies to the cdylib glue.
Provider Glue (<name>_qt_glue.h)
Contains two classes:
- ProviderObject — inherits
LogosProviderBase, holds an instance of the impl class (m_impl). Each public method is wrapped with type conversion:
- Qt parameters → C++ std parameters (e.g.,
QString.toStdString()) - Call
m_impl.method(...) - C++ std return → Qt return (e.g.,
QString::fromStdString(result)) - For
jsonReturnmethods (returningLogosMap/LogosList), the glue calls a generatednlohmannToQVariant()recursive helper to convertnlohmann::json→QVariant/QVariantMap/QVariantList - Always overrides
onInit(LogosAPI*)to (a) copy the three runtime-injected properties (modulePath,instanceId,instancePersistencePath) into the impl when it inherits fromLogosModuleContext, and (b) construct a per-moduleLogosModules(fromgenerated_code/logos_sdk.h) owned by the provider, threading its pointer through the same context base. Both wire-ups go through SFINAE'd helpers inlogos_module_context.h(_logos_codegen_::maybeSetContext/maybeSetLogosModules), so non-inheriting impls compile unchanged and theLogosAPInever escapes the provider.
- Plugin —
QObjectsubclass implementingPluginInterfaceandLogosProviderPlugin. CarriesQ_PLUGIN_METADATAandQ_INTERFACES. ItscreateProviderObject()factory returns a new ProviderObject instance.
Dispatch (<name>_dispatch.cpp)
Implements two methods on the ProviderObject:
-
**callMethod(methodName, args)** — string-based dispatch table. For each method, extracts args fromQVariantList, calls the typed wrapper, returns result asQVariant. Void methods returnQVariant(true). -
**getMethods()**— returns aQJsonArraydescribing the module's whole interface — both methods and events. Each entry carries a"type"of"method"or"event":- method entries have
type: "method",name,signature,returnType,isInvokable,parameters[](withtypeandname), and — when the declaration has a doc comment —description(see Method documentation). - event entries (one per
logos_events:declaration) havetype: "event",name,signature,parameters[], and an optionaldescription(see Event documentation). They omitreturnType/isInvokable— events are void.
The framework slices this single array into
getPluginMethods()(non-event entries),getPluginEvents()(type == "event"), andgetPluginInterface()(everything), which is what surfaces inlm methods/lm events,logoscore module-info, and Basecamp's Interface screen. - method entries have
Why events live in getMethods()
Folding events into getMethods() — rather than adding a sibling getEvents() virtual — is a deliberate ABI choice. LogosProviderObject is the in-process vtable contract between a host/runtime and a loaded module; inserting a new virtual would shift every later vtable slot and break any mix of old/new host and module binaries. Reusing the existing getMethods() slot keeps the vtable byte-for-byte stable: a new host reading an old module just sees no type: "event" entries (so zero events), and an old host reading a new module ignores the "type" field (events show up in its method list — cosmetic, never a crash). Legacy Qt modules declare no events, so their getMethods() is methods-only.
Client Stubs (<name>_api.h + <name>_api.cpp)
Generated from LIDL (not from --from-header). Each module gets one <Module> wrapper class whose signature shape is picked by the consumer's build via --api-style:
--api-style |
Wrapper signatures |
|---|---|
qt (default) |
QString / QStringList / QVariantList / QVariantMap / int / LogosResult |
lp |
std::string / std::vectorstd::string / LogosMap / LogosList / int64_t / StdLogosResult, over the Qt-free logos-protocol C ABI |
(std — the same signatures over a QVariant / LogosAPIClient body — was retired; --api-style=std is now an error.)
Both styles provide:
- Typed sync methods. The Qt style calls
LogosAPIClient::invokeRemoteMethod()and converts theQVariantresult; the lp style callslogos::LpClient::invoke()and converts thenlohmann::jsonresult — no Qt anywhere in the call. - Async overloads with callback + timeout.
- Event subscription. The Qt style exposes the generic
on(eventName, callback)channel plus one typedon<EventName>(callback)adapter per declared event; the std style exposes the typed adapters overlogos::LpClient::subscribe, holding each RAIILpSubscriptionfor the wrapper's lifetime. (Both styles once also emittedsetEventSource()/eventSource()/trigger()— a consumer-side emission surface. It is gone:test_lidl_gen_client.cppasserts notrigger(is emitted. A module emits its own events throughlogos_events:, never through a dependency's wrapper.)
The lp wrappers marshal over the logos-protocol C ABI (lp_*) instead, so the calling translation unit needs zero Qt headers and links no qt-sdk. (The retired std style was the one that shared invokeRemoteMethod with the Qt path and generated a Qt<->std conversion inline in its .cpp.) Both styles emit the same filename (<name>_api.h / <name>_api.cpp) and the same class name (<Module>) — the two are mutually exclusive at build time. No _api_std.{h,cpp} files are ever produced.
Umbrella files (logos_sdk.h / logos_sdk.cpp) aggregate every dep into a flat LogosModules struct — one accessor per metadata.json#dependencies entry, nothing else:
struct LogosModules {
LogosAPI* api;
SomeDep some_dep; // one per declared dependency
// ...
};
Only the modules explicitly listed as dependencies appear. The runtime's core_manager is intentionally NOT exposed here — apps that need to manage the core (basecamp, logoscore) use liblogos' C API directly, not a typed RPC wrapper.
Features & Requirements
LIDL Pipeline
The whole frontend now lives in the standalone logos-lidl repo; this
generator links it and reaches it through experimental/lidl_compat.h, which
re-exports the three stages below under their historical lidl* names. There is
no separately callable lexer entry point here any more — lidlTokenize was part
of the embedded copy that was deleted.
- Lexer — tokenizes source into keywords, identifiers, string literals, symbols (internal to
lidl::parse) - Parser (
lidlParse→lidl::parse) — recursive descent parser producing aModuleDeclAST - Validator (
lidlValidate→lidl::validate) — checks for duplicate names, unknown type references, builtin shadowing, duplicate parameters - Serializer (
lidlSerialize→lidl::serialize) — pretty-prints aModuleDeclback to LIDL text (useful for roundtrip testing)
Impl Header Pipeline
- parseImplHeader — reads
metadata.json+ C++ header, produces aModuleDecl - Same generation functions as LIDL path
Backwards Compatibility
- The remaining generator modes (
--metadata, plugin path) continue to work unchanged viarunPluginIntrospectMode()(plugin_introspect.cpp; waslegacy/main.cpp'slegacy_main()).--provider-header(theLOGOS_METHODdispatch behindinterface: "provider") was REMOVED — every provider now goes through the module-impl C ABI; the flag is refused with a message pointing atinterface: "universal" - The new
--from-headerand--lidlmodes are additive - Generated plugins implement both
PluginInterface(forlmintrospection) andLogosProviderPlugin(for new-API provider creation) - The runtime (
logos-liblogos) already supports both old and new plugin types viaqobject_castdetection
Conversion Helper Generation
Conversion helpers are only emitted when needed:
- String vector helpers (
lidlToQStringList,lidlToStdStringVector) — emitted when the module uses[tstr]parameters or return types - nlohmann→Qt helper (
nlohmannToQVariant) — emitted when any method hasjsonReturn = true(i.e., the impl returnsLogosMaporLogosList). This recursive function convertsnlohmann::jsonobjects, arrays, strings, numbers, and booleans to theirQVariantequivalents.