Files
logos-cpp-sdk/doctests/cpp-sdk-generator-roundtrip.test.yaml
Dario LipicarandClaude Opus 5 acea0d24e2 feat(codegen): a lossless Qt type mapping, and LIDL types in getMethods (#149)
* 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>
2026-08-22 18:17:44 -03:00

403 lines
21 KiB
YAML

name: "The C++ Generator: LIDL ⇄ C++ (provider & consumer)"
output: cpp-sdk-generator-roundtrip.md
release: ""
intro: |
Every module in Logos has a **contract** — the methods other modules may call,
and the events they may subscribe to — written in **LIDL** (the Logos Interface
Definition Language). The SDK's code generator (`logos-cpp-generator`) sits
between that contract and the C++ on both sides of a call, and this doc-test
exercises the three flows it supports, on the SDK commit under test (the
generator binary itself comes from this commit):
1. **provider header → LIDL** (`--header-to-lidl`) — distil the contract out of
the plain C++ impl class an author writes, with no Qt glue or dispatch, so a
module can publish a cheap, language-neutral contract artifact.
2. **LIDL → provider** (`--lidl --backend cdylib`) — from that contract, emit
the C-ABI **provider glue** that exports the author's impl class to the host
(the Qt-free half of a universal module).
3. **LIDL → consumer header** (`--lidl --module-only`) — from the same
contract, emit the typed **caller** header a *consumer* compiles against,
with synchronous callers, asynchronous callers, and event subscribers.
The example is deliberately a **complex interface**: methods of every arity
(zero to four parameters), the full set of round-trippable types (`int`,
`uint`, `float64`, `bool`, `tstr`, byte strings, typed arrays, and the `result`
error type), and multi-line documentation on both methods *and* events. Because
the contract is distilled from the provider header and then drives both
generated sides, the doc-test shows the `.lidl` is the single source of truth:
the provider header round-trips into it, and the provider glue and consumer
header come back out of it. A final section adds the remaining **composite**
types (records, maps, optionals).
what_you_build: "A rich `sensor_module` provider header, the `.lidl` contract distilled from it, and both generated sides — the C-ABI provider glue and the typed consumer caller header — all from this SDK commit."
what_you_learn:
- How to extract a `.lidl` contract from a plain C++ impl header (`--header-to-lidl`)
- How `///` doc comments (including multi-line) and a `logos_events:` block become contract descriptions and events
- How to generate the C-ABI provider glue from a contract (`--lidl --backend cdylib --impl-class`)
- How to generate a typed consumer caller header from the same contract (`--lidl --module-only`)
- How every LIDL type maps into the generated C++ — scalars, byte strings (`QByteArray`), arrays, and `LogosResult`
- How composite types (records, maps, optionals) appear in generated code
prerequisites:
- |
**Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes:
```bash
mkdir -p ~/.config/nix
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
```
Verify: `nix flake --help >/dev/null 2>&1 && echo "Flakes enabled"`
- "A Linux or macOS machine."
sections:
- title: "Build the generator from this SDK commit"
step: true
text: |
The generator ships in the SDK's `logos-cpp-bin` package as
`logos-cpp-generator`. Build it from the commit under test so every flow
below runs through *this* generator, not a published release.
steps:
- title: "Build logos-cpp-generator"
text: "`{release}` pins to the commit under test (latest master otherwise)."
run: "nix build 'github:logos-co/logos-cpp-sdk{release}#logos-cpp-bin' --out-link ./result"
code_block: |
nix build 'github:logos-co/logos-cpp-sdk#logos-cpp-bin'
check_file: "result/bin/logos-cpp-generator"
post_text: "The binary is now at `./result/bin/logos-cpp-generator`."
- title: "The provider header"
step: true
text: |
A universal C++ module is authored as one plain class — the **provider
header** — where every `public` method is part of the module's API. This one
is deliberately rich:
- **Every arity** — `temperature()` takes no parameters; `enable(on)` takes
one; `record(id, value, note, valid)` takes four.
- **Every round-trippable type** — `int64_t`/`uint64_t`, `double`, `bool`,
`std::string`, `std::vector<uint8_t>` (bytes), typed `std::vector<T>`
arrays, and `StdLogosResult`.
- **Documentation** — every method and event has a `///` doc comment, and
several span multiple lines.
- **Events of varied arity** — declared in the `logos_events:` block;
`ready()` carries no payload, `fault(...)` carries three fields.
steps:
- title: "metadata.json"
text: "Supplies the module name, version, and description the contract carries."
file:
path: metadata.json
language: json
content: |
{
"name": "sensor_module",
"version": "2.0.0",
"type": "core",
"category": "general",
"description": "A sensor hub: typed readings, batch queries, and status events",
"main": "sensor_module_plugin",
"interface": "universal",
"dependencies": []
}
- title: "src/sensor_module_impl.h"
text: |
Plain C++ inheriting `LogosModuleContext`. The generator parses the
header textually, so it needs no SDK headers to resolve.
file:
path: src/sensor_module_impl.h
language: cpp
content: |
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include <logos_module_context.h> // LogosModuleContext base + logos_events
// A sensor hub written in the pure-C++ (universal) style: one plain
// class whose public methods become the module's IPC contract.
class SensorModuleImpl : public LogosModuleContext {
public:
SensorModuleImpl() = default;
~SensorModuleImpl() = default;
/// Returns the latest temperature reading in degrees Celsius.
double temperature() const;
/// Enables or disables the sensor.
/// Returns the new enabled state.
bool enable(bool on);
/// Renames the sensor channel.
std::string rename(uint64_t id, const std::string& name);
/// Calibrates a channel with an offset and a human-readable label.
bool calibrate(uint64_t id, double offset, const std::string& label);
/// Records a reading and returns the new sample count.
int64_t record(uint64_t id, double value, const std::string& note, bool valid);
/// Flashes raw firmware bytes and echoes back the stored image.
std::vector<uint8_t> firmware(const std::vector<uint8_t>& image);
/// Resolves a batch of channel ids to their labels.
std::vector<std::string> labels(const std::vector<uint64_t>& ids);
/// Computes the mean of a batch of samples.
double average(const std::vector<double>& samples);
/// Resets a channel; returns a structured success/error result.
StdLogosResult reset(const std::string& id);
logos_events:
/// Fires once the sensor has finished warming up.
void ready();
/// Fires on each new reading with the channel id and value.
void reading(uint64_t id, double value);
/// Fires when a channel faults.
/// Carries an error code, a message, and whether the fault is fatal.
void fault(int64_t code, const std::string& message, bool fatal);
/// Fires with a raw capture buffer — an event carrying a byte
/// string, which the generator must encode rather than pass
/// through as text.
void capture(uint64_t id, const std::vector<uint8_t>& frame);
};
- title: "Flow 1 — provider header → LIDL"
step: true
text: |
`--header-to-lidl` parses the impl class and writes back the `.lidl`
contract — no Qt glue, no dispatch. The C++ types map straight to LIDL:
`int64_t`→`int`, `uint64_t`→`uint`, `double`→`float64`,
`std::string`→`tstr`, `std::vector<uint8_t>`→`bstr`, `std::vector<T>`→`[T]`,
and `StdLogosResult`→`result`.
steps:
- title: "Extract the .lidl"
text: "`--output-dir` writes `<module-name>.lidl` (here `extracted/sensor_module.lidl`)."
run: "./result/bin/logos-cpp-generator --header-to-lidl src/sensor_module_impl.h --impl-class SensorModuleImpl --metadata metadata.json --output-dir extracted"
code_block: |
logos-cpp-generator --header-to-lidl src/sensor_module_impl.h \
--impl-class SensorModuleImpl \
--metadata metadata.json \
--output-dir extracted
expect_contains:
- "Generated LIDL:"
- "9 methods, 4 events"
check_file: "extracted/sensor_module.lidl"
- title: "Inspect the extracted contract"
text: |
Every type came back intact, the four-parameter `record` kept its shape,
and the doc comments — including the multi-line ones, joined with `\n` —
are carried through as descriptions on methods and events alike. This
`.lidl` is now the single source of truth for both generated sides
below.
run: "cat extracted/sensor_module.lidl"
code_block: "cat extracted/sensor_module.lidl"
expect_contains:
- "method record(id: uint, value: float64, note: tstr, valid: bool) -> int"
- "method firmware(image: bstr) -> bstr"
- "method labels(ids: [uint]) -> [tstr]"
- "method reset(id: tstr) -> result"
- "event fault(code: int, message: tstr, fatal: bool)"
- "event capture(id: uint, frame: bstr)"
- "Returns the new enabled state."
- "Carries an error code, a message, and whether the fault is fatal."
- title: "Flow 2 — LIDL → provider"
step: true
text: |
The provider side. From the contract, `--backend cdylib` emits the C-ABI
**provider glue** that wraps the author's impl class and exports the uniform
`logos_module_*` symbols the host drives — `logos_module_dispatch`,
`logos_module_get_methods`, `logos_module_set_context`, and the event
emitters. It is Qt-free; the uniform Qt-plugin glue is layered on separately
by `logos-qt-host-generator --backend cdylib` (logos-plugin-qt). The author
writes only the impl class above; this glue is generated.
steps:
- title: "Generate the provider glue"
run: "./result/bin/logos-cpp-generator --lidl extracted/sensor_module.lidl --backend cdylib --impl-class SensorModuleImpl --impl-header sensor_module_impl.h --output-dir provider"
code_block: |
logos-cpp-generator --lidl extracted/sensor_module.lidl \
--backend cdylib \
--impl-class SensorModuleImpl --impl-header sensor_module_impl.h \
--output-dir provider
expect_contains:
- "Generated: "
- "sensor_module_module_impl.cpp"
- "sensor_module_events_cdylib.cpp"
check_file: "provider/sensor_module_module_impl.cpp"
- title: "Inspect the provider glue"
text: |
The exports dispatch JSON calls into the author's `SensorModuleImpl`, and
the events file routes each `logos_events:` declaration through the
host's emit callback.
run: "grep -E 'extern \"C\"|logos_module_dispatch|logos_module_get_methods|logos_module_set_context|SensorModuleImpl' provider/sensor_module_module_impl.cpp"
code_block: |
grep -E 'extern \"C\"|logos_module_|SensorModuleImpl' provider/sensor_module_module_impl.cpp
expect_contains:
- "logos_module_dispatch"
- "logos_module_get_methods"
- "SensorModuleImpl"
- title: "How each event marshals its payload"
text: |
The events file defines the body of every `logos_events:` declaration:
each argument is pushed into a JSON array and handed to the host's emit
callback. Scalars and strings go in as they are — but a byte string
cannot, because JSON has no binary type and a NUL would truncate it.
`frame` is therefore encoded into the canonical tagged form
`{"_bytes": "<base64url>"}`, the same representation the transport and
the consumer both understand. A generator that pushed the raw value —
or dropped it — would leave every subscriber with an empty payload
([#99](https://github.com/logos-co/logos-cpp-sdk/issues/99)).
run: "cat provider/sensor_module_events_cdylib.cpp"
code_block: "cat provider/sensor_module_events_cdylib.cpp"
expect_contains:
- "void SensorModuleImpl::reading(uint64_t id, double value)"
- "args.push_back(value);"
- "void SensorModuleImpl::capture(uint64_t id, const std::vector<uint8_t>& frame)"
- "args.push_back(logos::bytesToJson(frame));"
- title: "Flow 3 — LIDL → consumer header"
step: true
text: |
The consumer side. From the same contract, `--module-only` emits the typed
wrapper a *consumer* compiles against to call `sensor_module`. Each LIDL
`method` becomes three entry points — a synchronous caller, an `…Async`
variant that hands the callback a bare value, and an `…AsyncResult`
variant that hands it a `logos::AsyncResult<T>` (`{value, error}`) so a
failed call is distinguishable from a legitimately default-valued one —
and each `event` an `on(...)` subscription. The type mapping is the Qt
caller style, and it is **lossless** — one LIDL type, one C++ spelling:
`float64`→`double`, `tstr`→`QString`, `int`→`qlonglong`, `uint`→
`qulonglong`, `bstr`→`QByteArray`, `result`→`LogosResult`, and each
container carries its element type through — `[tstr]`→`QStringList`,
every other `[T]`→`QList<T>` (so `[uint]` is `QList<qulonglong>`).
Only `any` stays `QVariant`: it is the one Qt type that holds bytes *and*
an exact `uint64` *and* arbitrary nesting, so narrowing it would lose what
it was chosen to carry.
The integer spellings are 64-bit, and unsigned stays unsigned: LIDL
`int`/`uint` are `int64_t`/`uint64_t` in every other binding, so spelling
them `int` here truncated above 2^31 and read a `uint` back as *signed*.
`record` below shows it — its `id` is a `uint64_t` in the impl header.
steps:
- title: "Generate the consumer header"
run: "./result/bin/logos-cpp-generator --lidl extracted/sensor_module.lidl --output-dir consumer --module-only"
code_block: |
logos-cpp-generator --lidl extracted/sensor_module.lidl \
--output-dir consumer --module-only
expect_contains:
- "Generated: "
- "sensor_module_api.h"
check_file: "consumer/sensor_module_api.h"
- title: "Inspect the consumer API"
run: "grep -E 'class SensorModule|double temperature|qlonglong record|QByteArray firmware|QStringList labels|LogosResult reset|bool on\\(' consumer/sensor_module_api.h"
code_block: |
grep -E 'class|temperature|record|firmware|labels|reset|on\(' consumer/sensor_module_api.h
expect_contains:
- "class SensorModule"
- "double temperature("
- "qlonglong record(qulonglong id, double value, const QString& note, bool valid"
- "QByteArray firmware(const QByteArray& image"
- "QStringList labels(const QList<qulonglong>& ids"
- "LogosResult reset(const QString& id"
- title: "The three call surfaces per method"
text: |
One method, three entry points. The sync form takes an optional
`logos::CallError*` **and** an optional `Timeout` — both trailing and
defaulted, so `temperature()` and `temperature(&err)` still compile.
`temperatureAsync` delivers the value alone; `temperatureAsyncResult`
delivers `logos::AsyncResult<double>`, whose `.error` tells a failed
call apart from a provider that legitimately returned `0.0`. The
result-carrying entry point has its own name rather than being an
overload: a `std::function<void(AsyncResult<T>)>` overload alongside
`std::function<void(T)>` is ambiguous for a generic `[](auto v){…}`.
run: "grep -E 'temperature' consumer/sensor_module_api.h"
code_block: |
grep -E 'temperature' consumer/sensor_module_api.h
expect_contains:
- "double temperature(logos::CallError* err = nullptr, Timeout timeout = Timeout());"
- "void temperatureAsync(std::function<void(double)> callback, Timeout timeout = Timeout());"
- "void temperatureAsyncResult(std::function<void(logos::AsyncResult<double>)> callback, Timeout timeout = Timeout());"
- title: "The full type system: composite types"
step: true
text: |
Beyond the round-trippable core above, LIDL also has **composite** types:
named record types (`type`), maps (`{K: V}`), and optionals (`?T`), plus the
untyped escape hatch `any`.
A **record becomes a real C++ struct**: a `type Point { … }` in the contract
generates `struct Point` plus the conversions, so a caller writes
`Point p = client.translate(q, 1, 2)` instead of digging fields out of a
`QVariantMap`. `[Point]` is a `QList<Point>` and `{tstr: Point}` a
`QMap<QString, Point>`. An **optional** `?T` is a
`std::optional<T>` — two-state in the type system, so an absent value is
not spelled the same way as a present default, and `?uint` is
distinguishable from `?tstr`. Only `any` — and any container whose element
type bottoms out at `any`, such as `{tstr: any}` — stays untyped, as
`QVariant` / `QVariantMap`: a record has a declared shape and an `any` does
not. Here is a contract that uses all of them, taken straight to a consumer
header.
steps:
- title: "geometry_module.lidl"
file:
path: geometry_module.lidl
language: text
content: |
module geometry_module {
version "1.0.0"
description "Composite types: records, arrays-of-records, maps, and optionals"
type Point {
x: float64
y: float64
}
method translate(p: Point, dx: float64, dy: float64) -> Point description "Translates a point by an offset."
method bounds(points: [Point]) -> Point description "Returns the bounding corner of a set of points."
method attributes(tags: {tstr: any}) -> {tstr: any} description "Echoes a string-keyed map of arbitrary values."
method nearest(p: Point, limit: ?uint) -> ?Point description "Finds the nearest point within an optional limit; may return nothing."
method describe(p: Point) -> any description "Returns an arbitrary JSON description of a point."
event moved(from: Point, to: Point) description "Fires when a point moves, carrying both record values."
}
- title: "Generate the consumer header"
run: "./result/bin/logos-cpp-generator --lidl geometry_module.lidl --output-dir geometry --module-only"
code_block: |
logos-cpp-generator --lidl geometry_module.lidl \
--output-dir geometry --module-only
expect_contains:
- "Generated: "
- "geometry_module_api.h"
check_file: "geometry/geometry_module_api.h"
- title: "Inspect the composite signatures"
text: |
`Point` is generated as a struct, so a record parameter is taken by
const-ref and a record return comes back typed. An array-of-records is a
`QList<Point>`. `nearest` shows both halves of the optional mapping in
one signature — `?uint` in, `?Point` out — as
`std::optional<qulonglong>` and `std::optional<Point>`: the caller can
ask `limit.has_value()` and the return can be *nothing* without
colliding with a legitimate `Point{0, 0}`. Only `attributes` and
`describe` stay `QVariantMap` / `QVariant`: `{tstr: any}` and `any` are
the untyped carriers for JSON whose shape the contract does not
declare.
run: "grep -E 'class GeometryModule|struct Point|Point translate|QList<Point>|QVariantMap attributes|std::optional<Point> nearest' geometry/geometry_module_api.h"
code_block: |
grep -E 'class|translate|bounds|attributes|nearest' geometry/geometry_module_api.h
expect_contains:
- "class GeometryModule"
- "struct Point"
- "Point translate(const Point& p, double dx, double dy"
- "Point bounds(const QList<Point>& points"
- "QVariantMap attributes(const QVariantMap& tags"
- "std::optional<Point> nearest(const Point& p, const std::optional<qulonglong>& limit"